feat(dashboard): add moderation log page + message edit history

- Backend moderation module: GET /api/moderation/stats (per-status + failed rate)
  + GET /api/moderation/actions (filter by status/actionType, cursor paging),
  joins messages for target username + content
- Message GET /api/messages/detail/:id now returns edit_count + edit_history
  (old_content snapshots from message_edits, newest first)
- FE: new /moderation page — summary cards (total/executed/failed/pending +
  failed-rate), status+type filter chips, timeline rows with action icon,
  target user, reason, status badge, timestamps, error text
- FE: message detail shows 'Riwayat edit' panel with previous versions
This commit is contained in:
asepharyana
2026-08-05 11:11:10 +07:00
parent a309570d29
commit f999be4fa0
18 changed files with 664 additions and 2 deletions
@@ -0,0 +1,11 @@
"use client";
import { ModerationSection } from "@/components/moderation/moderation-section";
export default function ModerationPage() {
return (
<div className="space-y-4 animate-fade-in-up">
<ModerationSection />
</div>
);
}
@@ -1,6 +1,6 @@
"use client";
import { ArrowLeft, MessageSquare, MessagesSquare } from "lucide-react";
import { ArrowLeft, MessageSquare, MessagesSquare, Pencil } from "lucide-react";
import { GlassCard } from "@/components/glass/card";
import { getMessageChannelLabel, renderMessageContent } from "@/lib/format";
import type { AttachmentRecord, MessageRecord } from "@/lib/types";
@@ -52,6 +52,27 @@ export function MessageDetailView({
) || "(no text content)"}
</div>
{/* Edit history */}
{message.edit_history && message.edit_history.length > 0 && (
<div className="mb-4 space-y-2 rounded-lg border border-border/40 bg-card/30 p-3">
<p className="text-[10px] font-semibold uppercase tracking-wide text-text-secondary/50 flex items-center gap-1">
<Pencil className="size-3" />
Riwayat edit · {message.edit_history.length} versi sebelumnya
</p>
{message.edit_history.map((edit, i) => (
<div key={`${edit.edited_at}-${i}`} className="space-y-0.5">
<p className="text-[10px] font-mono text-text-secondary/40">
{new Date(edit.edited_at).toLocaleString("id-ID")}
</p>
<p className="text-xs leading-relaxed text-text-secondary/80 line-clamp-4 whitespace-pre-wrap">
{renderMessageContent(edit.old_content, message.metadata) ||
"(kosong)"}
</p>
</div>
))}
</div>
)}
{/* Attachments */}
{attachments && attachments.length > 0 && (
<div className="mb-4">
@@ -0,0 +1,297 @@
"use client";
import {
AlertTriangle,
Ban,
CheckCircle2,
Loader2,
MicOff,
ShieldAlert,
Trash2,
UserX,
XCircle,
} from "lucide-react";
import { useState } from "react";
import { GlassCard } from "@/components/glass/card";
import { EmptyState, LoadingSkeleton } from "@/components/shared";
import { Badge } from "@/components/ui/badge";
import { useModerationActions, useModerationStats } from "@/hooks";
import { renderMessageContent } from "@/lib/format";
import type { ModerationAction, ModerationActionType } from "@/lib/types";
import { cn } from "@/lib/utils";
const ACTION_META: Record<
ModerationActionType,
{ label: string; Icon: typeof Trash2; className: string }
> = {
delete_message: {
label: "Delete message",
Icon: Trash2,
className: "text-red-500",
},
mute_user: { label: "Mute user", Icon: MicOff, className: "text-orange-500" },
warn_user: {
label: "Warn user",
Icon: AlertTriangle,
className: "text-amber-500",
},
kick_user: { label: "Kick user", Icon: UserX, className: "text-orange-500" },
ban_user: { label: "Ban user", Icon: Ban, className: "text-red-500" },
};
const STATUS_META: Record<
ModerationAction["status"],
{ label: string; className: string; dot: string }
> = {
executed: {
label: "Executed",
className: "border-green-500/40 text-green-500",
dot: "bg-green-500",
},
failed: {
label: "Failed",
className: "border-red-500/40 text-red-500",
dot: "bg-red-500",
},
pending: {
label: "Pending",
className: "border-amber-500/40 text-amber-500",
dot: "bg-amber-500",
},
};
function fmtTime(ts: number | null): string {
if (!ts) return "—";
const d = new Date(ts);
const diff = Date.now() - ts;
const hours = Math.floor(diff / 3600000);
const rel =
hours < 1
? "baru saja"
: hours < 24
? `${hours} jam lalu`
: `${Math.floor(hours / 24)} hari lalu`;
return `${d.toLocaleString("id-ID")} (${rel})`;
}
const EMPTY_ACTION_RATE = {
total: 0,
executed: 0,
failed: 0,
pending: 0,
failed_rate: 0,
};
export function ModerationSection() {
const [status, setStatus] = useState<string>("");
const [actionType, setActionType] = useState<string>("");
const { data: stats } = useModerationStats();
const { data: actions, isLoading: actionsLoading } = useModerationActions(
status,
actionType,
);
const s = stats ?? EMPTY_ACTION_RATE;
const statusFilters = ["", "executed", "failed", "pending"];
const typeFilters = [
"",
"delete_message",
"warn_user",
"kick_user",
"ban_user",
"mute_user",
];
return (
<div className="space-y-4 animate-fade-in-up">
{/* Summary cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<SummaryCard
label="Total aksi"
value={s.total}
color="text-text-primary"
/>
<SummaryCard
label="Executed"
value={s.executed}
color="text-green-500"
/>
<SummaryCard
label="Failed"
value={s.failed}
color="text-red-500"
hint={s.total > 0 ? `${s.failed_rate}%` : undefined}
/>
<SummaryCard label="Pending" value={s.pending} color="text-amber-500" />
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wide text-text-secondary/50">
Status
</span>
{statusFilters.map((f) => (
<FilterChip
key={f || "all"}
active={status === f}
label={
f === ""
? "Semua"
: STATUS_META[f as keyof typeof STATUS_META].label
}
onClick={() => setStatus(f)}
/>
))}
<span className="ml-3 text-[10px] font-semibold uppercase tracking-wide text-text-secondary/50">
Tipe
</span>
{typeFilters.map((f) => (
<FilterChip
key={f || "all"}
active={actionType === f}
label={
f === "" ? "Semua" : ACTION_META[f as ModerationActionType].label
}
onClick={() => setActionType(f)}
/>
))}
</div>
{/* Timeline */}
{actionsLoading ? (
<LoadingSkeleton count={6} height="h-16" />
) : !actions || actions.length === 0 ? (
<GlassCard className="p-6">
<EmptyState
icon={ShieldAlert}
title="Belum ada aksi moderasi"
description="Aksi auto- moderasi (delete, warn, kick, ban) akan muncul di sini."
/>
</GlassCard>
) : (
<div className="space-y-2">
{actions.map((a) => (
<ActionRow key={a.id} action={a} />
))}
</div>
)}
<p className="text-[10px] text-text-secondary/40">
{actions?.length ?? 0} aksi ditampilkan · log moderasi gateway Discord
</p>
</div>
);
}
function SummaryCard({
label,
value,
color,
hint,
}: {
label: string;
value: number;
color: string;
hint?: string;
}) {
return (
<GlassCard className="p-4">
<p className="text-[10px] uppercase tracking-wide text-text-secondary/50">
{label}
</p>
<p className={cn("mt-1 text-2xl font-bold", color)}>
{value}
{hint && (
<span className="ml-1 text-xs font-medium opacity-80">({hint})</span>
)}
</p>
</GlassCard>
);
}
function FilterChip({
active,
label,
onClick,
}: {
active: boolean;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"rounded-full px-3 py-1 text-[11px] transition-colors",
active
? "bg-primary/20 text-primary"
: "text-text-secondary/60 hover:text-text-primary glass",
)}
>
{label}
</button>
);
}
function ActionRow({ action }: { action: ModerationAction }) {
const meta = ACTION_META[action.action_type] ?? ACTION_META.delete_message;
const st = STATUS_META[action.status];
const Icon = meta.Icon;
return (
<GlassCard className="flex items-start gap-3 p-3">
<span className={cn("mt-0.5 shrink-0", meta.className)}>
<Icon className="size-4" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold text-text-primary">
{meta.label}
</span>
{action.username && (
<span className="text-xs text-text-secondary">
@{action.username}
</span>
)}
<Badge variant="outline" className={cn("text-[10px]", st.className)}>
<span
className={cn("mr-1 inline-block size-1.5 rounded-full", st.dot)}
/>
{st.label}
</Badge>
</div>
{action.content && (
<p className="mt-1 line-clamp-2 text-xs text-text-secondary/80">
{renderMessageContent(action.content, null)}
</p>
)}
{action.reason && (
<p className="mt-1 text-[11px] text-text-secondary/60">
Alasan: {action.reason}
</p>
)}
{action.error && (
<p className="mt-1 text-[11px] text-red-500/80 line-clamp-2">
Error: {action.error}
</p>
)}
<p className="mt-1.5 text-[10px] font-mono text-text-secondary/40">
dibuat {fmtTime(action.created_at)}
{action.executed_at
? ` · dieksekusi ${fmtTime(action.executed_at)}`
: ""}
</p>
</div>
{action.status === "executed" ? (
<CheckCircle2 className="mt-0.5 size-3.5 shrink-0 text-green-500" />
) : action.status === "failed" ? (
<XCircle className="mt-0.5 size-3.5 shrink-0 text-red-500" />
) : (
<Loader2 className="mt-0.5 size-3.5 shrink-0 animate-spin text-amber-500" />
)}
</GlassCard>
);
}
export default ModerationSection;
+4
View File
@@ -28,6 +28,10 @@ export {
useReview,
useTextChannels,
} from "./use-messages";
export {
useModerationActions,
useModerationStats,
} from "./use-moderation";
export {
useDeleteRecording,
useRecordings,
@@ -0,0 +1,20 @@
import useSWR from "swr";
import { moderationApi } from "@/lib/api";
import type { ModerationStats } from "@/lib/types";
export function useModerationStats() {
return useSWR<ModerationStats>(["moderation-stats"], () =>
moderationApi.getStats(),
);
}
export function useModerationActions(status?: string, actionType?: string) {
return useSWR(
["moderation-actions", status ?? "__all__", actionType ?? "__all__"],
async () => {
const res = await moderationApi.listActions(100, status, actionType);
return res.data;
},
{ keepPreviousData: true },
);
}
+1
View File
@@ -4,6 +4,7 @@ export { configApi } from "./config";
export { dashboardApi } from "./dashboard";
export { mediaApi } from "./media";
export { messagesApi } from "./messages";
export { moderationApi } from "./moderation";
export { recordingsApi } from "./recordings";
export { uiStateApi } from "./ui-state";
export { voiceApi } from "./voice";
@@ -0,0 +1,23 @@
import type { ModerationStats, PaginatedModerationActions } from "@/lib/types";
import { api } from "./client";
export const moderationApi = {
getStats: () => api.get<ModerationStats>("/api/moderation/stats"),
listActions: (
limit?: number,
status?: string,
actionType?: string,
cursor?: string,
) => {
const params = new URLSearchParams();
if (limit) params.set("limit", String(limit));
if (status) params.set("status", status);
if (actionType) params.set("actionType", actionType);
if (cursor) params.set("cursor", cursor);
const qs = params.toString();
return api.get<PaginatedModerationActions>(
`/api/moderation/actions${qs ? `?${qs}` : ""}`,
);
},
};
+7
View File
@@ -6,6 +6,7 @@ import {
Mic,
Music,
Search,
Shield,
} from "lucide-react";
export interface NavItem {
@@ -50,6 +51,12 @@ export const navItems: NavItem[] = [
icon: Headphones,
matchPrefix: "/recordings",
},
{
href: "/moderation",
label: "Moderation",
icon: Shield,
matchPrefix: "/moderation",
},
{
href: "/analysis",
label: "Search",
+1
View File
@@ -2,6 +2,7 @@ export * from "./dashboard";
export * from "./guild";
export * from "./media";
export * from "./message";
export * from "./moderation";
export * from "./recording";
export * from "./ui";
export * from "./voice";
@@ -127,6 +127,10 @@ export interface MessageRecord {
ai_recommended_action?: AiRecommendedAction | null;
ai_error?: string | null;
ai_analyzed_at?: number | null;
/** Detail-only: number of past edits (message_edits snapshots) */
edit_count?: number;
/** Detail-only: previous content snapshots, newest first */
edit_history?: Array<{ old_content: string; edited_at: number }>;
}
// ── Pagination ──────────────────────────────────────────────
@@ -0,0 +1,38 @@
export type ModerationActionType =
| "delete_message"
| "mute_user"
| "warn_user"
| "kick_user"
| "ban_user";
export type ModerationStatus = "pending" | "executed" | "failed";
export interface ModerationAction {
id: string;
message_id: string | null;
user_id: string | null;
guild_id: string;
action_type: ModerationActionType;
reason: string | null;
executed_by: string | null;
status: ModerationStatus;
error: string | null;
created_at: number | null;
executed_at: number | null;
username: string | null;
content: string | null;
}
export interface ModerationStats {
total: number;
executed: number;
failed: number;
pending: number;
failed_rate: number;
by_action: Record<string, ModerationActionType>;
}
export interface PaginatedModerationActions {
data: ModerationAction[];
nextCursor: string | null;
}