refactor: split monolith into 3 microservices (frontend, backend, discord-gateway)
- Extract services into services/{frontend,backend,discord-gateway}
- Create packages/shared/ for shared logger, errors, utils, types
- Setup Modular MVC pattern in backend (controller→service→repository)
- Setup event-driven architecture in discord-gateway with Redis pub/sub
- Move Docker files to infra/docker/ with per-service Dockerfiles
- Update docker-compose.yml to use Traefik-only routing (no port exposes)
- Update GitHub Actions deploy workflow for multi-service matrix build
- Fix all import paths and resolve type errors across all services
- All 3 services pass tsc --noEmit clean
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
bda8304bb9
commit
c48a0c5e3b
@@ -0,0 +1,116 @@
|
||||
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,119 @@
|
||||
import { Activity, BarChart3 } from "lucide-react";
|
||||
import type { Channel, Guild } from "../../../shared/api/client";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Select,
|
||||
} from "../../../shared/ui";
|
||||
|
||||
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,131 @@
|
||||
import { useMemo } from "react";
|
||||
import type { HeatmapCell } from "../../../shared/api/client";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../shared/ui";
|
||||
|
||||
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,103 @@
|
||||
import type { ModerationBreakdown } from "../../../shared/api/client";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
import { Card, CardContent, Skeleton } from "../../../shared/ui";
|
||||
|
||||
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,88 @@
|
||||
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,244 @@
|
||||
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,142 @@
|
||||
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,155 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import type {
|
||||
AnalyticsOverview,
|
||||
HeatmapCell,
|
||||
HourlyBucket,
|
||||
TopicTrend,
|
||||
TrendBucket,
|
||||
UserStat,
|
||||
ViolatorStat,
|
||||
} from "../../../shared/api/client";
|
||||
import {
|
||||
fetchAnalyticsOverview,
|
||||
fetchHeatmap,
|
||||
fetchTrend,
|
||||
fetchViolators,
|
||||
} from "../../../shared/api/client";
|
||||
|
||||
function analyticsKeys(
|
||||
guildId: string,
|
||||
channelId: string | undefined,
|
||||
hours: number,
|
||||
) {
|
||||
return {
|
||||
overview: [
|
||||
"analytics",
|
||||
"overview",
|
||||
guildId,
|
||||
channelId ?? "",
|
||||
hours,
|
||||
] as const,
|
||||
violators: [
|
||||
"analytics",
|
||||
"violators",
|
||||
guildId,
|
||||
channelId ?? "",
|
||||
hours,
|
||||
] as const,
|
||||
trend: ["analytics", "trend", guildId, channelId ?? "", hours] as const,
|
||||
heatmap: ["analytics", "heatmap", guildId, channelId ?? "", hours] as const,
|
||||
};
|
||||
}
|
||||
|
||||
interface UseAnalyticsOptions {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}
|
||||
|
||||
export function useAnalytics({
|
||||
guildId,
|
||||
channelId,
|
||||
hours = 24,
|
||||
}: UseAnalyticsOptions) {
|
||||
const keys = analyticsKeys(guildId, channelId, hours);
|
||||
|
||||
const overviewQuery = useQuery({
|
||||
queryKey: keys.overview,
|
||||
queryFn: () => fetchAnalyticsOverview({ guildId, channelId, hours }),
|
||||
enabled: !!guildId,
|
||||
staleTime: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const violatorsQuery = useQuery({
|
||||
queryKey: keys.violators,
|
||||
queryFn: () => fetchViolators({ guildId, channelId, hours, limit: 20 }),
|
||||
enabled: !!guildId,
|
||||
staleTime: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const trendQuery = useQuery({
|
||||
queryKey: keys.trend,
|
||||
queryFn: () => fetchTrend({ guildId, channelId, hours }),
|
||||
enabled: !!guildId,
|
||||
staleTime: 60_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const heatmapQuery = useQuery({
|
||||
queryKey: keys.heatmap,
|
||||
queryFn: () => fetchHeatmap({ guildId, channelId, hours }),
|
||||
enabled: !!guildId,
|
||||
staleTime: 60_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!guildId) return;
|
||||
window.dispatchEvent(new CustomEvent("analytics_refresh"));
|
||||
}, [guildId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
if (!guildId) return;
|
||||
// Use queryClient.invalidateQueries from the React Query internals
|
||||
window.dispatchEvent(new CustomEvent("analytics_force_refresh"));
|
||||
};
|
||||
window.addEventListener("analytics_refresh", handler);
|
||||
return () => window.removeEventListener("analytics_refresh", handler);
|
||||
}, [refresh]);
|
||||
|
||||
const overview = overviewQuery.data ?? null;
|
||||
const isFetching = overviewQuery.isFetching && !overviewQuery.isLoading;
|
||||
const isLoading = overviewQuery.isLoading && !overviewQuery.data;
|
||||
|
||||
return {
|
||||
overview,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error:
|
||||
overviewQuery.error instanceof Error ? overviewQuery.error.message : null,
|
||||
refresh,
|
||||
|
||||
violators: violatorsQuery.data ?? [],
|
||||
violatorsLoading: violatorsQuery.isLoading && !violatorsQuery.data,
|
||||
violatorsFetching: violatorsQuery.isFetching && !violatorsQuery.isLoading,
|
||||
refreshViolators: () => {
|
||||
if (guildId) window.dispatchEvent(new CustomEvent("analytics_refresh"));
|
||||
},
|
||||
|
||||
trend: trendQuery.data ?? [],
|
||||
trendLoading: trendQuery.isLoading && !trendQuery.data,
|
||||
trendFetching: trendQuery.isFetching && !trendQuery.isLoading,
|
||||
|
||||
heatmap: heatmapQuery.data ?? [],
|
||||
heatmapLoading: heatmapQuery.isLoading && !heatmapQuery.data,
|
||||
heatmapFetching: heatmapQuery.isFetching && !heatmapQuery.isLoading,
|
||||
|
||||
hourly: overview?.hourly ?? ([] as HourlyBucket[]),
|
||||
topics: overview?.topics ?? ([] as TopicTrend[]),
|
||||
topUsers: overview?.top_users ?? ([] as UserStat[]),
|
||||
messages: overview?.messages ?? null,
|
||||
period: overview?.period ?? null,
|
||||
activeUsersCount: overview?.active_users_count ?? 0,
|
||||
totalChannels: overview?.total_channels ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export type {
|
||||
AnalyticsOverview,
|
||||
HeatmapCell,
|
||||
HourlyBucket,
|
||||
TopicTrend,
|
||||
TrendBucket,
|
||||
UserStat,
|
||||
ViolatorStat,
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from "react";
|
||||
import type { Channel, Guild } from "../../shared/api/client";
|
||||
import { ActivityChart } from "./components/ActivityChart";
|
||||
import { ControlBar } from "./components/ControlBar";
|
||||
import { Heatmap } from "./components/Heatmap";
|
||||
import { SummaryCards } from "./components/SummaryCards";
|
||||
import { TopicList } from "./components/TopicList";
|
||||
import { TrendChart } from "./components/TrendChart";
|
||||
import { UserTable } from "./components/UserTable";
|
||||
import { ViolatorTable } from "./components/ViolatorTable";
|
||||
import { useAnalytics } from "./hooks/useAnalytics";
|
||||
|
||||
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 analytics = useAnalytics({
|
||||
guildId: selectedGuild,
|
||||
channelId: selectedChannel || undefined,
|
||||
hours,
|
||||
});
|
||||
|
||||
const {
|
||||
hourly,
|
||||
topics,
|
||||
topUsers,
|
||||
activeUsersCount,
|
||||
totalChannels,
|
||||
violators,
|
||||
trend,
|
||||
heatmap,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refresh,
|
||||
refreshViolators,
|
||||
messages: analyticsMessages,
|
||||
} = analytics;
|
||||
const loading = isLoading && !isFetching;
|
||||
|
||||
if (error && !analyticsMessages) {
|
||||
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">
|
||||
<ControlBar
|
||||
guilds={guilds}
|
||||
channels={channels}
|
||||
selectedGuild={selectedGuild}
|
||||
selectedChannel={selectedChannel}
|
||||
hours={hours}
|
||||
isFetching={isFetching}
|
||||
onGuildChange={onGuildChange}
|
||||
onChannelChange={onChannelChange}
|
||||
onHoursChange={setHours}
|
||||
onRefresh={() => {
|
||||
refresh();
|
||||
refreshViolators();
|
||||
}}
|
||||
/>
|
||||
<SummaryCards
|
||||
messages={analyticsMessages}
|
||||
activeUsersCount={activeUsersCount}
|
||||
totalChannels={totalChannels}
|
||||
loading={loading}
|
||||
/>
|
||||
<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>
|
||||
{hours >= 48 && <TrendChart trend={trend} loading={loading} />}
|
||||
<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>
|
||||
<ViolatorTable users={violators} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user