2026-08-14 10:49:44 +07:00
|
|
|
"use client";
|
|
|
|
|
|
2026-08-15 17:53:48 +07:00
|
|
|
import {
|
|
|
|
|
AlertTriangle,
|
2026-08-15 20:03:55 +07:00
|
|
|
Ban,
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
Clock,
|
2026-08-15 17:53:48 +07:00
|
|
|
Filter,
|
2026-08-15 20:03:55 +07:00
|
|
|
MessageSquareWarning,
|
|
|
|
|
MicOff,
|
|
|
|
|
ShieldAlert,
|
|
|
|
|
Trash2,
|
|
|
|
|
UserX,
|
|
|
|
|
XCircle,
|
2026-08-15 17:53:48 +07:00
|
|
|
} from "lucide-react";
|
2026-08-15 20:03:55 +07:00
|
|
|
import { useEffect, useState } from "react";
|
2026-08-15 17:53:48 +07:00
|
|
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
|
|
|
|
import { Donut } from "@/components/charts";
|
2026-08-15 20:03:55 +07:00
|
|
|
import {
|
|
|
|
|
Badge,
|
|
|
|
|
GlassPanel,
|
|
|
|
|
Select,
|
|
|
|
|
type SelectOption,
|
|
|
|
|
} from "@/components/primitives";
|
|
|
|
|
import {
|
|
|
|
|
ErrorState,
|
|
|
|
|
LoadingState,
|
|
|
|
|
MetricTile,
|
|
|
|
|
SectionHeader,
|
|
|
|
|
} from "@/components/shared";
|
|
|
|
|
import { useModerationActions, useModerationStats } from "@/hooks";
|
2026-08-15 17:53:48 +07:00
|
|
|
import { formatNumber } from "@/lib/format";
|
|
|
|
|
import type {
|
|
|
|
|
ModerationAction,
|
|
|
|
|
ModerationActionType,
|
|
|
|
|
ModerationStats,
|
|
|
|
|
} from "@/lib/types";
|
2026-08-14 10:49:44 +07:00
|
|
|
|
2026-08-15 17:53:48 +07:00
|
|
|
const ACTION_ICON: Record<ModerationActionType, React.ReactNode> = {
|
|
|
|
|
delete_message: <Trash2 className="size-3.5" />,
|
|
|
|
|
mute_user: <MicOff className="size-3.5" />,
|
|
|
|
|
warn_user: <MessageSquareWarning className="size-3.5" />,
|
|
|
|
|
kick_user: <UserX className="size-3.5" />,
|
|
|
|
|
ban_user: <Ban className="size-3.5" />,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const ACTION_LABEL: Record<ModerationActionType, string> = {
|
|
|
|
|
delete_message: "Delete",
|
|
|
|
|
mute_user: "Mute",
|
|
|
|
|
warn_user: "Warn",
|
|
|
|
|
kick_user: "Kick",
|
|
|
|
|
ban_user: "Ban",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function ModerationView({
|
2026-08-14 10:49:44 +07:00
|
|
|
initialStats,
|
|
|
|
|
initialActions,
|
|
|
|
|
}: {
|
|
|
|
|
initialStats?: ModerationStats;
|
|
|
|
|
initialActions?: ModerationAction[];
|
|
|
|
|
}) {
|
2026-08-15 17:53:48 +07:00
|
|
|
const { data: stats, isLoading, error } = useModerationStats(initialStats);
|
|
|
|
|
const [statusFilter, setStatusFilter] = useState<string>("");
|
|
|
|
|
const [typeFilter, setTypeFilter] = useState<string>("");
|
|
|
|
|
const { data: actions } = useModerationActions(
|
|
|
|
|
statusFilter || undefined,
|
|
|
|
|
typeFilter || undefined,
|
|
|
|
|
!statusFilter && !typeFilter ? initialActions : undefined,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const failedRate = stats ? stats.failed_rate * 100 : 0;
|
|
|
|
|
|
|
|
|
|
const byAction = stats?.by_action ?? {};
|
2026-08-15 20:03:55 +07:00
|
|
|
const segments = Object.entries(byAction).map(([k, _v]) => ({
|
2026-08-15 17:53:48 +07:00
|
|
|
value: 1,
|
|
|
|
|
color:
|
|
|
|
|
k === "ban_user" || k === "kick_user"
|
|
|
|
|
? "var(--color-vermilion)"
|
|
|
|
|
: k === "warn_user"
|
|
|
|
|
? "var(--color-amber)"
|
|
|
|
|
: "var(--color-signal)",
|
|
|
|
|
label: k,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const ambient = useAmbient();
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
ambient.set(
|
|
|
|
|
failedRate > 20 ? "vermilion" : failedRate > 5 ? "amber" : "signal",
|
|
|
|
|
0.3 + Math.min(0.4, failedRate / 50),
|
|
|
|
|
"moderation",
|
|
|
|
|
);
|
|
|
|
|
}, [failedRate, ambient]);
|
|
|
|
|
|
|
|
|
|
if (error && !stats) return <ErrorState error={error} />;
|
|
|
|
|
if (!stats && isLoading) return <LoadingState label="Reading log" />;
|
2026-08-15 20:03:55 +07:00
|
|
|
if (!stats) return <ErrorState error={error ?? new Error("No data")} />;
|
2026-08-15 17:53:48 +07:00
|
|
|
|
|
|
|
|
const statusOpts: SelectOption[] = [
|
|
|
|
|
{ value: "", label: "All statuses" },
|
|
|
|
|
{ value: "pending", label: "Pending" },
|
|
|
|
|
{ value: "executed", label: "Executed" },
|
|
|
|
|
{ value: "failed", label: "Failed" },
|
|
|
|
|
];
|
|
|
|
|
const typeOpts: SelectOption[] = [
|
|
|
|
|
{ value: "", label: "All actions" },
|
2026-08-15 20:03:55 +07:00
|
|
|
...Object.keys(byAction).map((k) => ({
|
|
|
|
|
value: k,
|
|
|
|
|
label: ACTION_LABEL[k as ModerationActionType] ?? k,
|
|
|
|
|
})),
|
2026-08-15 17:53:48 +07:00
|
|
|
];
|
|
|
|
|
|
2026-08-14 10:49:44 +07:00
|
|
|
return (
|
2026-08-15 17:53:48 +07:00
|
|
|
<div className="space-y-5">
|
|
|
|
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
2026-08-15 20:03:55 +07:00
|
|
|
<MetricTile
|
|
|
|
|
label="Total actions"
|
|
|
|
|
value={formatNumber(stats.total)}
|
|
|
|
|
tone="signal"
|
|
|
|
|
icon={<ShieldAlert className="size-3.5" />}
|
|
|
|
|
/>
|
|
|
|
|
<MetricTile
|
|
|
|
|
label="Executed"
|
|
|
|
|
value={formatNumber(stats.executed)}
|
|
|
|
|
tone="signal"
|
|
|
|
|
icon={<CheckCircle2 className="size-3.5" />}
|
|
|
|
|
/>
|
|
|
|
|
<MetricTile
|
|
|
|
|
label="Failed"
|
|
|
|
|
value={formatNumber(stats.failed)}
|
|
|
|
|
tone={stats.failed > 0 ? "vermilion" : "neutral"}
|
|
|
|
|
icon={<XCircle className="size-3.5" />}
|
|
|
|
|
/>
|
|
|
|
|
<MetricTile
|
|
|
|
|
label="Pending"
|
|
|
|
|
value={formatNumber(stats?.pending)}
|
|
|
|
|
tone={stats?.pending > 0 ? "amber" : "neutral"}
|
|
|
|
|
icon={<Clock className="size-3.5" />}
|
|
|
|
|
/>
|
2026-08-15 17:53:48 +07:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-5 lg:grid-cols-5">
|
|
|
|
|
<GlassPanel className="lg:col-span-2">
|
|
|
|
|
<SectionHeader eyebrow="health" title="Breakdown" />
|
|
|
|
|
<div className="flex items-center gap-5">
|
|
|
|
|
<Donut
|
2026-08-15 20:03:55 +07:00
|
|
|
segments={
|
|
|
|
|
segments.length
|
|
|
|
|
? segments
|
|
|
|
|
: [
|
|
|
|
|
{
|
|
|
|
|
value: 1,
|
|
|
|
|
color: "var(--color-ink-faint)",
|
|
|
|
|
label: "none",
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
}
|
2026-08-15 17:53:48 +07:00
|
|
|
centerLabel={`${Math.round(failedRate)}%`}
|
|
|
|
|
centerSub="fail rate"
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex-1 space-y-2 text-sm">
|
|
|
|
|
{Object.entries(byAction).map(([k, v]) => {
|
|
|
|
|
const count = typeof v === "number" ? v : null;
|
|
|
|
|
return (
|
|
|
|
|
<div key={k} className="flex items-center gap-2.5">
|
2026-08-15 20:03:55 +07:00
|
|
|
<span className="text-ink-soft">
|
|
|
|
|
{ACTION_ICON[k as ModerationActionType]}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="flex-1 text-ink-soft">
|
|
|
|
|
{ACTION_LABEL[k as ModerationActionType] ?? k}
|
|
|
|
|
</span>
|
|
|
|
|
{count !== null && (
|
|
|
|
|
<span className="mono text-ink">{count}</span>
|
|
|
|
|
)}
|
2026-08-15 17:53:48 +07:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
{Object.keys(byAction).length === 0 && (
|
2026-08-15 20:03:55 +07:00
|
|
|
<div className="text-xs text-ink-faint">
|
|
|
|
|
No actions recorded yet.
|
|
|
|
|
</div>
|
2026-08-15 17:53:48 +07:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</GlassPanel>
|
|
|
|
|
|
|
|
|
|
<GlassPanel className="lg:col-span-3">
|
|
|
|
|
<SectionHeader
|
|
|
|
|
eyebrow="filter"
|
|
|
|
|
title="Action log"
|
|
|
|
|
action={
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Filter className="size-3.5 text-ink-faint" />
|
2026-08-15 20:03:55 +07:00
|
|
|
<Select
|
|
|
|
|
value={typeFilter}
|
|
|
|
|
onChange={setTypeFilter}
|
|
|
|
|
options={typeOpts}
|
|
|
|
|
size="sm"
|
|
|
|
|
className="w-36"
|
|
|
|
|
/>
|
|
|
|
|
<Select
|
|
|
|
|
value={statusFilter}
|
|
|
|
|
onChange={setStatusFilter}
|
|
|
|
|
options={statusOpts}
|
|
|
|
|
size="sm"
|
|
|
|
|
className="w-32"
|
|
|
|
|
/>
|
2026-08-15 17:53:48 +07:00
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
|
|
|
|
|
{(actions ?? []).map((a) => (
|
|
|
|
|
<ActionRow key={a.id} a={a} />
|
|
|
|
|
))}
|
|
|
|
|
{(actions ?? []).length === 0 && (
|
2026-08-15 20:03:55 +07:00
|
|
|
<div className="py-10 text-center text-xs text-ink-faint">
|
|
|
|
|
No matching actions.
|
|
|
|
|
</div>
|
2026-08-15 17:53:48 +07:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</GlassPanel>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ActionRow({ a }: { a: ModerationAction }) {
|
|
|
|
|
const tone =
|
2026-08-15 20:03:55 +07:00
|
|
|
a.status === "executed"
|
|
|
|
|
? "signal"
|
|
|
|
|
: a.status === "failed"
|
|
|
|
|
? "vermilion"
|
|
|
|
|
: "amber";
|
|
|
|
|
const icon = ACTION_ICON[a.action_type] ?? (
|
|
|
|
|
<AlertTriangle className="size-3.5" />
|
|
|
|
|
);
|
2026-08-15 17:53:48 +07:00
|
|
|
return (
|
|
|
|
|
<div className="flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3">
|
2026-08-15 20:03:55 +07:00
|
|
|
<span
|
|
|
|
|
className={`mt-0.5 ${tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal"}`}
|
|
|
|
|
>
|
|
|
|
|
{icon}
|
|
|
|
|
</span>
|
2026-08-15 17:53:48 +07:00
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<div className="flex items-center gap-2">
|
2026-08-15 20:03:55 +07:00
|
|
|
<span className="text-sm font-semibold text-ink">
|
|
|
|
|
{a.username ?? "unknown"}
|
|
|
|
|
</span>
|
2026-08-15 17:53:48 +07:00
|
|
|
<Badge tone={tone}>{a.status}</Badge>
|
|
|
|
|
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
|
|
|
|
|
{a.created_at ? new Date(a.created_at).toLocaleString() : "—"}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-08-15 20:03:55 +07:00
|
|
|
{a.reason && (
|
|
|
|
|
<div className="mt-0.5 text-xs text-ink-soft">“{a.reason}”</div>
|
|
|
|
|
)}
|
2026-08-15 17:53:48 +07:00
|
|
|
{a.content && (
|
|
|
|
|
<div className="mt-1 line-clamp-2 rounded-[8px] bg-white/[0.03] px-2 py-1 text-xs text-ink-faint">
|
|
|
|
|
{a.content}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-08-15 20:03:55 +07:00
|
|
|
{a.error && (
|
|
|
|
|
<div className="mt-1 text-xs text-vermilion">{a.error}</div>
|
|
|
|
|
)}
|
2026-08-15 17:53:48 +07:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-08-14 10:49:44 +07:00
|
|
|
);
|
|
|
|
|
}
|