2026-08-14 10:49:44 +07:00
|
|
|
"use client";
|
|
|
|
|
|
2026-08-24 15:35:51 +07:00
|
|
|
/**
|
|
|
|
|
* Moderation scene — flagged actions orbit a verdict hub on the stage
|
|
|
|
|
* (vermilion stars); the overlay carries the metric whisper (left),
|
|
|
|
|
* the live feed as a bottom ribbon, and an intel dossier (right) with
|
|
|
|
|
* trends / coverage / domains / heatmap / drilldown.
|
|
|
|
|
*/
|
2026-08-15 17:53:48 +07:00
|
|
|
import {
|
|
|
|
|
AlertTriangle,
|
2026-08-15 20:03:55 +07:00
|
|
|
Ban,
|
2026-08-15 17:53:48 +07:00
|
|
|
Filter,
|
2026-08-15 20:03:55 +07:00
|
|
|
MessageSquareWarning,
|
|
|
|
|
MicOff,
|
|
|
|
|
ShieldAlert,
|
|
|
|
|
Trash2,
|
|
|
|
|
UserX,
|
2026-08-15 17:53:48 +07:00
|
|
|
} from "lucide-react";
|
2026-08-24 15:35:51 +07:00
|
|
|
import { useEffect, useMemo, useState } from "react";
|
2026-08-15 17:53:48 +07:00
|
|
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
2026-08-18 20:45:12 +07:00
|
|
|
import { CategoryDrilldown } from "@/components/CategoryDrilldown";
|
|
|
|
|
import { CoverageTiles } from "@/components/CoverageTiles";
|
2026-08-15 17:53:48 +07:00
|
|
|
import { Donut } from "@/components/charts";
|
2026-08-18 20:45:12 +07:00
|
|
|
import { ModerationHeatmap } from "@/components/ModerationHeatmap";
|
2026-08-24 15:35:51 +07:00
|
|
|
import { Badge, Select, type SelectOption } from "@/components/primitives";
|
2026-08-18 20:45:12 +07:00
|
|
|
import { ScamDomains } from "@/components/ScamDomains";
|
2026-08-24 15:35:51 +07:00
|
|
|
import { ErrorState } from "@/components/shared";
|
2026-08-15 20:03:55 +07:00
|
|
|
import {
|
2026-08-24 15:35:51 +07:00
|
|
|
useSceneFocusSetter,
|
|
|
|
|
useScenePublish,
|
|
|
|
|
} from "@/components/shell/scene-graph-context";
|
2026-08-18 20:45:12 +07:00
|
|
|
import { TopChannels } from "@/components/TopChannels";
|
2026-08-18 17:43:02 +07:00
|
|
|
import { TopicTrends } from "@/components/TopicTrends";
|
|
|
|
|
import {
|
2026-08-18 20:45:12 +07:00
|
|
|
useHourlyModeration,
|
2026-08-18 17:43:02 +07:00
|
|
|
useLiveModeration,
|
|
|
|
|
useModerationActions,
|
2026-08-18 20:45:12 +07:00
|
|
|
useModerationByCategory,
|
|
|
|
|
useModerationCoverage,
|
2026-08-18 17:43:02 +07:00
|
|
|
useModerationStats,
|
|
|
|
|
useModerationTrends,
|
2026-08-18 20:45:12 +07:00
|
|
|
useTopFlaggedChannels,
|
|
|
|
|
useTopFlaggedDomains,
|
2026-08-18 17:43:02 +07:00
|
|
|
} from "@/hooks";
|
2026-08-18 15:11:01 +07:00
|
|
|
import { aiTone } from "@/lib/ai-status";
|
2026-08-24 15:35:51 +07:00
|
|
|
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
2026-08-18 17:43:02 +07:00
|
|
|
import { downloadCsv } from "@/lib/csv";
|
2026-08-18 09:05:12 +07:00
|
|
|
import { formatNumber, formatRelativeTime } from "@/lib/format";
|
2026-08-15 17:53:48 +07:00
|
|
|
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",
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-24 15:35:51 +07:00
|
|
|
/** Flagged actions → vermilion satellites around a verdict hub. */
|
|
|
|
|
function moderationGraph(
|
|
|
|
|
liveActions: ModerationAction[],
|
|
|
|
|
stats?: ModerationStats,
|
|
|
|
|
): ConstellationGraph {
|
|
|
|
|
const recent = liveActions.slice(0, 24);
|
|
|
|
|
const nodes = [
|
|
|
|
|
{ id: "verdict", label: "verdict hub", kind: "guild" as const, value: 1 },
|
|
|
|
|
...recent.map((a) => ({
|
|
|
|
|
id: `mod:${a.id}`,
|
|
|
|
|
label: a.username ?? (a.user_id ?? "unknown").slice(0, 8),
|
|
|
|
|
kind:
|
|
|
|
|
a.status === "failed"
|
|
|
|
|
? ("flagged" as const)
|
|
|
|
|
: a.status === "pending"
|
|
|
|
|
? ("message" as const)
|
|
|
|
|
: ("channel" as const),
|
|
|
|
|
value: a.status === "executed" ? 0.55 : 0.35,
|
|
|
|
|
href: undefined,
|
|
|
|
|
meta: { mod_status: a.status },
|
|
|
|
|
})),
|
|
|
|
|
];
|
|
|
|
|
void stats;
|
|
|
|
|
return {
|
|
|
|
|
nodes,
|
|
|
|
|
edges: recent.map((a) => ({ source: "verdict", target: `mod:${a.id}` })),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 17:53:48 +07:00
|
|
|
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,
|
|
|
|
|
);
|
2026-08-18 17:43:02 +07:00
|
|
|
const liveActions = useLiveModeration(initialActions ?? [], 50);
|
|
|
|
|
const { data: trends } = useModerationTrends(30);
|
2026-08-18 20:45:12 +07:00
|
|
|
const { data: domains } = useTopFlaggedDomains(30);
|
|
|
|
|
const { data: channels } = useTopFlaggedChannels(30);
|
|
|
|
|
const { data: hourly } = useHourlyModeration(30);
|
|
|
|
|
const { data: coverage } = useModerationCoverage(30);
|
|
|
|
|
const [drilldown, setDrilldown] = useState<string | null>(null);
|
2026-08-24 15:35:51 +07:00
|
|
|
const [intelOpen, setIntelOpen] = useState(false);
|
2026-08-18 20:45:12 +07:00
|
|
|
const { data: categoryActions, isValidating: categoryLoading } =
|
|
|
|
|
useModerationByCategory(drilldown ? 30 : 0, drilldown);
|
2026-08-15 17:53:48 +07:00
|
|
|
|
2026-08-24 15:35:51 +07:00
|
|
|
const publish = useScenePublish();
|
|
|
|
|
const setFocus = useSceneFocusSetter();
|
|
|
|
|
|
2026-08-15 17:53:48 +07:00
|
|
|
const failedRate = stats ? stats.failed_rate * 100 : 0;
|
|
|
|
|
|
|
|
|
|
const byAction = stats?.by_action ?? {};
|
2026-08-24 15:35:51 +07:00
|
|
|
const segments = Object.entries(byAction).map(([k]) => ({
|
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,
|
|
|
|
|
}));
|
|
|
|
|
|
2026-08-24 15:35:51 +07:00
|
|
|
const graph = useMemo(
|
|
|
|
|
() => moderationGraph(liveActions, stats),
|
|
|
|
|
[liveActions, stats],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
publish({ graph, focus: null });
|
|
|
|
|
}, [graph, publish]);
|
|
|
|
|
|
|
|
|
|
useEffect(
|
|
|
|
|
() => () => {
|
|
|
|
|
publish({ graph: { nodes: [], edges: [] }, focus: null });
|
|
|
|
|
setFocus(null);
|
|
|
|
|
},
|
|
|
|
|
[publish, setFocus],
|
|
|
|
|
);
|
|
|
|
|
|
2026-08-15 17:53:48 +07:00
|
|
|
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} />;
|
2026-08-18 09:21:12 +07:00
|
|
|
if (!stats && isLoading)
|
|
|
|
|
return (
|
2026-08-24 15:35:51 +07:00
|
|
|
<p className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 text-center font-mono text-sm text-[var(--color-ink-faint)]">
|
|
|
|
|
menghubungkan verdict hub…
|
|
|
|
|
</p>
|
2026-08-18 09:21:12 +07:00
|
|
|
);
|
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-24 15:35:51 +07:00
|
|
|
<div className="min-h-full">
|
|
|
|
|
{/* Metric whisper — left */}
|
|
|
|
|
<section
|
|
|
|
|
className="pointer-events-auto absolute left-5 top-16 w-56"
|
|
|
|
|
aria-label="Moderation metrics"
|
|
|
|
|
>
|
|
|
|
|
<p className="eyebrow mb-2 flex items-center gap-1.5">
|
|
|
|
|
<ShieldAlert className="size-3.5 text-signal" /> Enforcement
|
|
|
|
|
</p>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Whisper
|
|
|
|
|
label="total actions"
|
|
|
|
|
value={formatNumber(stats.total)}
|
|
|
|
|
tone="ink"
|
|
|
|
|
/>
|
|
|
|
|
<Whisper
|
|
|
|
|
label="executed"
|
|
|
|
|
value={formatNumber(stats.executed)}
|
|
|
|
|
tone="signal"
|
|
|
|
|
/>
|
|
|
|
|
<Whisper
|
|
|
|
|
label="failed"
|
|
|
|
|
value={formatNumber(stats.failed)}
|
|
|
|
|
tone={stats.failed > 0 ? "vermilion" : "ink"}
|
|
|
|
|
/>
|
|
|
|
|
<Whisper
|
|
|
|
|
label="pending"
|
|
|
|
|
value={formatNumber(stats.pending)}
|
|
|
|
|
tone={stats.pending > 0 ? "amber" : "ink"}
|
2026-08-18 20:45:12 +07:00
|
|
|
/>
|
|
|
|
|
</div>
|
2026-08-24 15:35:51 +07:00
|
|
|
<div className="mt-4">
|
|
|
|
|
<Donut
|
|
|
|
|
segments={
|
|
|
|
|
segments.length
|
|
|
|
|
? segments
|
|
|
|
|
: [{ value: 1, color: "var(--color-ink-faint)", label: "none" }]
|
2026-08-15 17:53:48 +07:00
|
|
|
}
|
2026-08-24 15:35:51 +07:00
|
|
|
centerLabel={`${Math.round(failedRate)}%`}
|
|
|
|
|
centerSub="fail rate"
|
2026-08-15 17:53:48 +07:00
|
|
|
/>
|
2026-08-24 15:35:51 +07:00
|
|
|
<div className="mt-2 space-y-1 font-mono text-xs text-[var(--color-ink-soft)]">
|
|
|
|
|
{Object.entries(byAction).map(([k, v]) => {
|
|
|
|
|
const count = typeof v === "number" ? v : null;
|
|
|
|
|
return (
|
|
|
|
|
<p key={k} className="flex items-center gap-2">
|
|
|
|
|
<span>{ACTION_ICON[k as ModerationActionType]}</span>
|
|
|
|
|
<span className="flex-1">
|
|
|
|
|
{ACTION_LABEL[k as ModerationActionType] ?? k}
|
|
|
|
|
</span>
|
|
|
|
|
{count !== null ? <span>{count}</span> : null}
|
|
|
|
|
</p>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2026-08-15 17:53:48 +07:00
|
|
|
</div>
|
2026-08-24 15:35:51 +07:00
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{/* Intel dossier — right */}
|
|
|
|
|
<div className="pointer-events-auto absolute right-5 top-16 hidden w-[min(24rem,90vw)] md:block">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setIntelOpen((v) => !v)}
|
|
|
|
|
className={`rounded-full border px-3 py-1 font-mono text-xs transition-colors ${
|
|
|
|
|
intelOpen
|
|
|
|
|
? "border-signal/40 bg-signal/10 text-signal"
|
|
|
|
|
: "border-[var(--color-hairline)] bg-[var(--color-canvas)]/60 text-[var(--color-ink-soft)] backdrop-blur-md hover:text-[var(--color-ink)]"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
intel {intelOpen ? "▲" : "▼"}
|
|
|
|
|
</button>
|
|
|
|
|
{intelOpen ? (
|
|
|
|
|
<div className="mt-2 max-h-[64vh] space-y-4 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/78 p-3 backdrop-blur-xl">
|
|
|
|
|
{trends ? <TopicTrends trends={trends} /> : null}
|
|
|
|
|
{coverage ? <CoverageTiles coverage={coverage} /> : null}
|
|
|
|
|
{domains ? <ScamDomains domains={domains} /> : null}
|
|
|
|
|
{hourly ? <ModerationHeatmap hours={hourly} /> : null}
|
|
|
|
|
{channels ? <TopChannels channels={channels} /> : null}
|
|
|
|
|
<CategoryDrilldown
|
|
|
|
|
trends={trends ?? { categories: [], severities: [], actions: [] }}
|
|
|
|
|
selected={drilldown}
|
|
|
|
|
actions={categoryActions ?? []}
|
|
|
|
|
loading={categoryLoading}
|
|
|
|
|
onSelect={setDrilldown}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-08-15 17:53:48 +07:00
|
|
|
</div>
|
2026-08-24 15:35:51 +07:00
|
|
|
|
|
|
|
|
{/* Live feed ribbon — bottom */}
|
|
|
|
|
<section
|
|
|
|
|
className="pointer-events-auto absolute inset-x-4 bottom-20 max-h-[46vh] overflow-hidden rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/75 backdrop-blur-xl lg:left-80 lg:right-[26rem]"
|
|
|
|
|
aria-label="Live moderation feed"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] px-4 py-2">
|
|
|
|
|
<span className="eyebrow flex items-center gap-2">
|
|
|
|
|
<Filter className="size-3 text-[var(--color-ink-faint)]" />
|
|
|
|
|
action log
|
|
|
|
|
</span>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Select
|
|
|
|
|
value={typeFilter}
|
|
|
|
|
onChange={setTypeFilter}
|
|
|
|
|
options={typeOpts}
|
|
|
|
|
size="sm"
|
|
|
|
|
className="w-32"
|
|
|
|
|
/>
|
|
|
|
|
<Select
|
|
|
|
|
value={statusFilter}
|
|
|
|
|
onChange={setStatusFilter}
|
|
|
|
|
options={statusOpts}
|
|
|
|
|
size="sm"
|
|
|
|
|
className="w-28"
|
|
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() =>
|
|
|
|
|
downloadCsv(
|
|
|
|
|
"moderation-actions.csv",
|
|
|
|
|
(actions ?? []).map((a) => ({
|
|
|
|
|
id: a.id,
|
|
|
|
|
user: a.username ?? a.user_id,
|
|
|
|
|
action_type: a.action_type,
|
|
|
|
|
status: a.status,
|
|
|
|
|
severity: a.severity ?? "",
|
|
|
|
|
categories: (a.categories ?? []).join("|"),
|
|
|
|
|
reason: a.reason ?? "",
|
|
|
|
|
created_at: a.created_at
|
|
|
|
|
? new Date(a.created_at).toISOString()
|
|
|
|
|
: "",
|
|
|
|
|
})),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
className="rounded-full border border-[var(--color-hairline)] px-3 py-1 font-mono text-xs text-[var(--color-ink-soft)] transition-colors hover:text-[var(--color-ink)]"
|
|
|
|
|
title="Download moderation actions as CSV"
|
|
|
|
|
>
|
|
|
|
|
CSV
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="max-h-[40vh] overflow-y-auto p-2">
|
|
|
|
|
<ActionRows actions={actions ?? []} liveCount={liveActions.length} />
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
2026-08-15 17:53:48 +07:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 15:35:51 +07:00
|
|
|
function ActionRows({
|
|
|
|
|
actions,
|
|
|
|
|
liveCount,
|
|
|
|
|
}: {
|
|
|
|
|
actions: ModerationAction[];
|
|
|
|
|
liveCount: number;
|
|
|
|
|
}) {
|
|
|
|
|
if (actions.length === 0 && liveCount === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="py-6 text-center font-mono text-xs text-[var(--color-ink-faint)]">
|
|
|
|
|
No matching actions.
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
{actions.map((a, i) => (
|
|
|
|
|
<ActionRow key={a.id} a={a} index={i} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Whisper({
|
|
|
|
|
label,
|
|
|
|
|
value,
|
|
|
|
|
tone,
|
|
|
|
|
}: {
|
|
|
|
|
label: string;
|
|
|
|
|
value: string;
|
|
|
|
|
tone: "ink" | "signal" | "vermilion" | "amber";
|
|
|
|
|
}) {
|
|
|
|
|
const color =
|
|
|
|
|
tone === "vermilion"
|
|
|
|
|
? "text-vermilion"
|
|
|
|
|
: tone === "amber"
|
|
|
|
|
? "text-amber"
|
|
|
|
|
: tone === "signal"
|
|
|
|
|
? "text-signal"
|
|
|
|
|
: "text-ink";
|
|
|
|
|
return (
|
|
|
|
|
<p className="flex items-baseline gap-2">
|
|
|
|
|
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-ink-faint)]">
|
|
|
|
|
{label}
|
|
|
|
|
</span>
|
|
|
|
|
<span className={`display text-lg leading-none ${color}`}>{value}</span>
|
|
|
|
|
</p>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 11:01:00 +07:00
|
|
|
function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
|
2026-08-15 17:53:48 +07:00
|
|
|
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-18 15:11:01 +07:00
|
|
|
const severityTone =
|
|
|
|
|
a.severity == null
|
|
|
|
|
? null
|
|
|
|
|
: aiTone(
|
|
|
|
|
a.severity === "none"
|
|
|
|
|
? "clean"
|
|
|
|
|
: a.severity === "low" || a.severity === "medium"
|
|
|
|
|
? "warn"
|
|
|
|
|
: "flagged",
|
|
|
|
|
);
|
2026-08-15 17:53:48 +07:00
|
|
|
return (
|
2026-08-18 11:01:00 +07:00
|
|
|
<div
|
2026-08-24 15:35:51 +07:00
|
|
|
className="animate-stagger flex items-start gap-3 rounded-xl border border-[var(--color-hairline)] p-3"
|
|
|
|
|
style={{ animationDelay: `${index * 30}ms` }}
|
2026-08-18 11:01:00 +07:00
|
|
|
>
|
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-24 15:35:51 +07:00
|
|
|
<span className="text-sm font-semibold text-[var(--color-ink)]">
|
2026-08-15 20:03:55 +07:00
|
|
|
{a.username ?? "unknown"}
|
|
|
|
|
</span>
|
2026-08-15 17:53:48 +07:00
|
|
|
<Badge tone={tone}>{a.status}</Badge>
|
2026-08-24 15:35:51 +07:00
|
|
|
<span className="ml-auto font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
|
2026-08-18 09:05:12 +07:00
|
|
|
{formatRelativeTime(a.created_at)}
|
2026-08-15 17:53:48 +07:00
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-08-24 15:35:51 +07:00
|
|
|
{a.reason ? (
|
|
|
|
|
<div className="mt-0.5 text-xs text-[var(--color-ink-soft)]">
|
|
|
|
|
“{a.reason}”
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
{severityTone && a.severity ? (
|
2026-08-18 15:11:01 +07:00
|
|
|
<div className="mt-1 flex flex-wrap items-center gap-1">
|
|
|
|
|
<Badge tone={severityTone}>{a.severity}</Badge>
|
2026-08-24 15:35:51 +07:00
|
|
|
{a.confidence != null ? (
|
|
|
|
|
<span className="font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
|
2026-08-18 15:11:01 +07:00
|
|
|
conf {(a.confidence * 100).toFixed(0)}%
|
|
|
|
|
</span>
|
2026-08-24 15:35:51 +07:00
|
|
|
) : null}
|
2026-08-18 15:11:01 +07:00
|
|
|
</div>
|
2026-08-24 15:35:51 +07:00
|
|
|
) : null}
|
2026-08-18 15:11:01 +07:00
|
|
|
{a.flags?.length ? (
|
|
|
|
|
<div className="mt-1 flex flex-wrap gap-1">
|
|
|
|
|
{a.flags.slice(0, 6).map((f) => (
|
|
|
|
|
<Badge key={f} tone="amber">
|
|
|
|
|
{f}
|
|
|
|
|
</Badge>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
{a.evidence?.length ? (
|
2026-08-24 15:35:51 +07:00
|
|
|
<div className="mt-1 border-l-2 border-[var(--color-hairline)] pl-2 text-xs text-[var(--color-ink-faint)]">
|
2026-08-18 15:11:01 +07:00
|
|
|
“{a.evidence[0]}”
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-08-24 15:35:51 +07:00
|
|
|
{a.executed_by ? (
|
|
|
|
|
<div className="mt-0.5 font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
|
2026-08-18 09:05:12 +07:00
|
|
|
by {a.executed_by}
|
|
|
|
|
{a.executed_at ? ` · ${formatRelativeTime(a.executed_at)}` : ""}
|
|
|
|
|
</div>
|
2026-08-24 15:35:51 +07:00
|
|
|
) : null}
|
|
|
|
|
{a.content ? (
|
|
|
|
|
<div className="mt-1 line-clamp-2 rounded-lg bg-white/[0.03] px-2 py-1 text-xs text-[var(--color-ink-faint)]">
|
2026-08-15 17:53:48 +07:00
|
|
|
{a.content}
|
|
|
|
|
</div>
|
2026-08-24 15:35:51 +07:00
|
|
|
) : null}
|
|
|
|
|
{a.error ? (
|
2026-08-15 20:03:55 +07:00
|
|
|
<div className="mt-1 text-xs text-vermilion">{a.error}</div>
|
2026-08-24 15:35:51 +07:00
|
|
|
) : null}
|
2026-08-15 17:53:48 +07:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-08-14 10:49:44 +07:00
|
|
|
);
|
|
|
|
|
}
|