feat(frontend): messages orbital-belt & moderation verdict-hub scenes

This commit is contained in:
asepharyana
2026-08-24 15:35:51 +07:00
parent 3d6c07bd91
commit 0581dc3485
2 changed files with 536 additions and 418 deletions
@@ -1,5 +1,11 @@
"use client"; "use client";
/**
* Messages scene — the live feed becomes an orbital belt of nodes on the
* stage; the functional console (picker/search/modes) floats top-left,
* the stream itself is a translucent dossier column, and a selected
* message opens its inspection dossier bottom-center.
*/
import { import {
AlertTriangle, AlertTriangle,
Calendar, Calendar,
@@ -15,20 +21,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityHeatmap } from "@/components/ActivityHeatmap"; import { ActivityHeatmap } from "@/components/ActivityHeatmap";
import { useAmbient } from "@/components/ambient/ambient-context"; import { useAmbient } from "@/components/ambient/ambient-context";
import { EditHistory } from "@/components/EditHistory"; import { EditHistory } from "@/components/EditHistory";
import { import { Avatar, Badge, Skeleton } from "@/components/primitives";
Avatar, import { EmptyState, ErrorState, SkeletonRows } from "@/components/shared";
Badge,
GlassPanel,
Input,
Skeleton,
} from "@/components/primitives";
import {
EmptyState,
ErrorState,
SectionHeader,
SkeletonRows,
} from "@/components/shared";
import { GuildChannelPicker } from "@/components/shared/guild-picker"; import { GuildChannelPicker } from "@/components/shared/guild-picker";
import {
useSceneFocusSetter,
useScenePublish,
} from "@/components/shell/scene-graph-context";
import { import {
useLoadMore, useLoadMore,
useMessageActivity, useMessageActivity,
@@ -42,6 +41,7 @@ import {
useSemanticSearch, useSemanticSearch,
} from "@/hooks"; } from "@/hooks";
import { aiTone } from "@/lib/ai-status"; import { aiTone } from "@/lib/ai-status";
import type { ConstellationGraph } from "@/lib/constellation/graph";
import { import {
formatBytes, formatBytes,
formatDuration, formatDuration,
@@ -59,6 +59,24 @@ import type {
import { staggerDelay } from "@/lib/utils"; import { staggerDelay } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context"; import { useWebSocket } from "@/lib/ws/context";
/** Recent messages → orbital belt (chained edges give the belt shape). */
function beltGraph(list: MessageRecord[]): ConstellationGraph {
const recent = list.slice(0, 36);
const nodes = recent.map((m, i) => ({
id: `msg:${m.id}`,
label: m.username,
kind:
m.ai_status === "flagged" ? ("flagged" as const) : ("message" as const),
value: Math.max(0.15, 1 - i / Math.max(1, recent.length)),
href: undefined,
}));
const edges = nodes.slice(0, -1).map((n, i) => ({
source: n.id,
target: nodes[i + 1]?.id ?? n.id,
}));
return { nodes, edges };
}
export function MessagesView({ export function MessagesView({
initialGuilds, initialGuilds,
initialGuildId, initialGuildId,
@@ -80,15 +98,11 @@ export function MessagesView({
const [channelId, setChannelId] = useState<string | null>(null); const [channelId, setChannelId] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null); const [selected, setSelected] = useState<string | null>(null);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
// Search mode: "exact" (substring match over captured messages) or
// "semantic" (vector similarity over the persistent Qdrant archive).
const [semanticMode, setSemanticMode] = useState(false); const [semanticMode, setSemanticMode] = useState(false);
// feed | timeline: "timeline" groups messages into date-grouped cards.
const [viewMode, setViewMode] = useState<"feed" | "timeline">("feed"); const [viewMode, setViewMode] = useState<"feed" | "timeline">("feed");
// Guard against loading the entire history on a long scroll: cap how many
// older pages we append. Each page is 50 messages (backend limit default).
const MAX_OLDER_PAGES = 10; const MAX_OLDER_PAGES = 10;
const [loadedPages, setLoadedPages] = useState(0); const [loadedPages, setLoadedPages] = useState(0);
const [showIntel, setShowIntel] = useState(false);
const { const {
data: messages, data: messages,
@@ -99,11 +113,7 @@ export function MessagesView({
channelId ?? undefined, channelId ?? undefined,
initialMessages ?? undefined, initialMessages ?? undefined,
); );
// Stream history one message per WS frame (replaces the 50-row batched fetch).
// Drives snapshots into the SWR list above as they arrive; falls back to the
// SSR `initialMessages` seed if WS is unavailable.
useMessagesStream(ws, guildId ?? "", channelId ?? undefined); useMessagesStream(ws, guildId ?? "", channelId ?? undefined);
// Cursor to the next (older) page + whether more history exists.
const { data: pageInfo } = useMessagesHasMore( const { data: pageInfo } = useMessagesHasMore(
guildId ?? "", guildId ?? "",
channelId ?? undefined, channelId ?? undefined,
@@ -124,10 +134,12 @@ export function MessagesView({
const edits = useRecentEdits(50, undefined, initialEdits); const edits = useRecentEdits(50, undefined, initialEdits);
const detail = useMessageDetail(selected); const detail = useMessageDetail(selected);
const ambient = useAmbient(); const ambient = useAmbient();
const publish = useScenePublish();
const setFocus = useSceneFocusSetter();
const scrollRef = useRef<HTMLDivElement>(null);
const nearBottomRef = useRef(true);
// Fetch the next (older) page via cursor and bump the loaded-page counter.
// Older messages prepend at the top, so preserve the viewport by offsetting
// scrollTop by the height added above (Discord keeps your place while loading).
const loadOlder = useCallback(async () => { const loadOlder = useCallback(async () => {
if (!guildId || !hasMore || loadedPages >= MAX_OLDER_PAGES) return; if (!guildId || !hasMore || loadedPages >= MAX_OLDER_PAGES) return;
const el = scrollRef.current; const el = scrollRef.current;
@@ -152,21 +164,32 @@ export function MessagesView({
const searching = query.trim().length >= 2 && !semanticMode; const searching = query.trim().length >= 2 && !semanticMode;
const semanticSearching = query.trim().length >= 2 && semanticMode; const semanticSearching = query.trim().length >= 2 && semanticMode;
const list = searching ? (search.data ?? []) : (messages ?? []); const list = searching ? (search.data ?? []) : (messages ?? []);
// Discord-style order: oldest at the top, newest at the bottom. The backend
// returns DESC (newest first); reverse so the feed reads top→bottom like DC.
const display = useMemo(() => [...list].reverse(), [list]); const display = useMemo(() => [...list].reverse(), [list]);
// Timeline mode: inject date-separator headers above the first message of const graph = useMemo(
// each day. Messages are sorted oldest→newest (display is reversed), so a () => (searching ? { nodes: [], edges: [] } : beltGraph(list)),
// date change means a new group. Produces an array of either "date" or "msg" [list, searching],
// nodes so the render loop can switch easily. );
useEffect(() => {
publish({ graph, focus: selected ? `msg:${selected}` : null });
}, [graph, selected, publish]);
useEffect(
() => () => {
publish({ graph: { nodes: [], edges: [] }, focus: null });
setFocus(null);
},
[publish, setFocus],
);
const timelineNodes = useMemo(() => { const timelineNodes = useMemo(() => {
if (viewMode !== "timeline") return null; if (viewMode !== "timeline") return null;
const out: Array< const out: Array<
| { type: "date"; label: string; iso: string } | { type: "date"; label: string; iso: string }
| { type: "msg"; m: (typeof display)[number] } | { type: "msg"; m: (typeof display)[number] }
> = []; > = [];
let prev = ""; let prevDate = "";
for (const m of display) { for (const m of display) {
const d = new Date(m.created_at).toLocaleDateString(undefined, { const d = new Date(m.created_at).toLocaleDateString(undefined, {
weekday: "short", weekday: "short",
@@ -174,24 +197,15 @@ export function MessagesView({
day: "numeric", day: "numeric",
}); });
const iso = new Date(m.created_at).toISOString().slice(0, 10); const iso = new Date(m.created_at).toISOString().slice(0, 10);
if (d !== prev) { if (d !== prevDate) {
out.push({ type: "date", label: d, iso }); out.push({ type: "date", label: d, iso });
prev = d; prevDate = d;
} }
out.push({ type: "msg", m }); out.push({ type: "msg", m });
} }
return out; return out;
}, [display, viewMode]); }, [display, viewMode]);
// Ref to the scroll container so we can manage scroll position like Discord:
// open at the bottom (newest), keep the viewport stable when prepending older
// messages at the top, and follow new live messages only when already near
// the bottom.
const scrollRef = useRef<HTMLDivElement>(null);
const nearBottomRef = useRef(true);
// Scroll to the bottom on the first load / when switching guild-channel, so
// the newest messages are visible (Discord behaviour).
const firstLoadRef = useRef(true); const firstLoadRef = useRef(true);
useEffect(() => { useEffect(() => {
if (firstLoadRef.current && display.length > 0) { if (firstLoadRef.current && display.length > 0) {
@@ -201,8 +215,6 @@ export function MessagesView({
} }
}, [display.length]); }, [display.length]);
// When a new live message lands (list grows, still searching off), follow it
// to the bottom only if the user was already near the bottom.
const prevLen = useRef(list.length); const prevLen = useRef(list.length);
useEffect(() => { useEffect(() => {
if (searching) return; if (searching) return;
@@ -215,8 +227,12 @@ export function MessagesView({
}, [list.length, searching]); }, [list.length, searching]);
return ( return (
<div className="space-y-4"> <div className="min-h-full">
<GlassPanel className="flex flex-wrap items-center gap-3"> {/* Console whisper — top-left */}
<section
className="pointer-events-auto absolute left-5 top-16 z-20 w-[min(22rem,90vw)] space-y-2"
aria-label="Stream controls"
>
<GuildChannelPicker <GuildChannelPicker
mode="text" mode="text"
guildsInitial={initialGuilds} guildsInitial={initialGuilds}
@@ -230,22 +246,24 @@ export function MessagesView({
firstLoadRef.current = true; firstLoadRef.current = true;
}} }}
/> />
<div className="relative ml-auto w-full sm:w-64"> <div className="flex items-center gap-2">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-ink-faint" /> <div className="relative flex-1">
<Input <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-faint)]" />
className="pl-9" <input
placeholder="Search messages…" type="search"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Search messages…"
className="h-9 w-full rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/70 pl-9 pr-3 font-mono text-xs text-[var(--color-ink)] backdrop-blur-md outline-none placeholder:text-[var(--color-ink-faint)] focus:border-[var(--color-signal)]"
/> />
</div> </div>
<button <button
type="button" type="button"
onClick={() => setSemanticMode((v) => !v)} onClick={() => setSemanticMode((v) => !v)}
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${ className={`rounded-full border px-3 py-1.5 font-mono text-xs transition-colors ${
semanticMode semanticMode
? "border-signal/40 bg-signal/10 text-signal" ? "border-signal/40 bg-signal/10 text-signal"
: "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]" : "border-[var(--color-hairline)] text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
}`} }`}
title="Toggle semantic (vector) search over the message archive" title="Toggle semantic (vector) search over the message archive"
> >
@@ -256,91 +274,51 @@ export function MessagesView({
onClick={() => onClick={() =>
setViewMode((v) => (v === "feed" ? "timeline" : "feed")) setViewMode((v) => (v === "feed" ? "timeline" : "feed"))
} }
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${ className={`rounded-full border px-3 py-1.5 font-mono text-xs transition-colors ${
viewMode === "timeline" viewMode === "timeline"
? "border-signal/40 bg-signal/10 text-signal" ? "border-signal/40 bg-signal/10 text-signal"
: "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]" : "border-[var(--color-hairline)] text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
}`} }`}
title="Toggle timeline (date-grouped) view" title="Toggle timeline (date-grouped) view"
> >
{viewMode === "timeline" ? "Timeline" : "Feed"} {viewMode === "timeline" ? "Timeline" : "Feed"}
</button> </button>
</GlassPanel> </div>
</section>
<div className="grid gap-4 lg:grid-cols-5"> {/* Stream dossier — right column */}
{semanticSearching && ( <section
<GlassPanel className="lg:col-span-5"> className="pointer-events-auto absolute bottom-20 right-5 top-28 hidden w-[min(30rem,92vw)] flex-col overflow-hidden rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/70 backdrop-blur-xl md:flex"
<SectionHeader aria-label="Message stream"
eyebrow="semantic"
title={`${query}`}
action={
<span className="mono text-xs text-ink-faint">
{semantic.data?.length ?? 0} matches
</span>
}
/>
{semantic.isLoading ? (
<SkeletonRows rows={4} />
) : semantic.data && semantic.data.length > 0 ? (
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
{semantic.data.map((r, i) => (
<div
key={r.message_id ?? i}
className="animate-stagger flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3"
style={staggerDelay(i)}
> >
<div className="min-w-0 flex-1"> <div className="flex items-center justify-between border-b border-[var(--color-hairline)] px-4 py-2.5">
<div className="flex items-center gap-2"> <span className="eyebrow">
<span className="mono text-[0.6rem] text-signal"> {searching ? `${query}` : "live stream"}
{(r.score * 100).toFixed(0)}%
</span> </span>
<span className="mono ml-auto text-[0.6rem] text-ink-faint"> <span className="font-mono text-xs text-[var(--color-ink-faint)]">
{formatRelativeTime(r.created_at)}
</span>
</div>
<div className="mt-0.5 line-clamp-3 text-sm text-ink-soft">
{r.content}
</div>
</div>
</div>
))}
</div>
) : (
<EmptyState
icon={<Search className="size-7" />}
title="No semantic matches"
description="Try different wording — semantic search finds meaning, not exact text."
/>
)}
</GlassPanel>
)}
<GlassPanel className="lg:col-span-3">
<SectionHeader
eyebrow={searching ? "results" : "live feed"}
title={searching ? `${query}` : "Messages"}
action={
<span className="mono text-xs text-ink-faint">
{list.length} shown {list.length} shown
</span> </span>
} </div>
/>
{error && !messages ? ( <div className="min-h-0 flex-1 overflow-hidden p-2">
{semanticSearching ? (
<SemanticResults semantic={semantic} />
) : error && !messages ? (
<ErrorState error={error} /> <ErrorState error={error} />
) : isLoading && !messages ? ( ) : isLoading && !messages ? (
<SkeletonRows rows={8} /> <SkeletonRows rows={8} />
) : list.length === 0 ? ( ) : list.length === 0 ? (
<EmptyState <EmptyState
icon={<MessageSquare className="size-7" />} icon={<MessageSquare className="size-6" />}
title="No messages" title="No messages"
description="Pick a guild to begin, or run a search." description="Pick a guild to begin, or run a search."
/> />
) : ( ) : (
<div> <div className="flex h-full flex-col">
{!searching && ( {!searching && (
<div className="mb-2 flex items-center justify-center gap-2"> <div className="mb-1.5 flex items-center justify-center gap-2">
{loadMore.isPending ? ( {loadMore.isPending ? (
<span className="flex items-center gap-1.5 text-xs text-ink-soft"> <span className="flex items-center gap-1.5 font-mono text-xs text-[var(--color-ink-soft)]">
<Loader2 className="size-3.5 animate-spin" /> <Loader2 className="size-3.5 animate-spin" />
Loading older Loading older
</span> </span>
@@ -348,19 +326,19 @@ export function MessagesView({
<button <button
type="button" type="button"
onClick={loadOlder} onClick={loadOlder}
className="rounded-full border border-hairline bg-white/[0.03] px-3 py-1 text-xs text-ink-soft transition-colors hover:bg-white/[0.06]" 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)]"
> >
Load older messages Load older messages
</button> </button>
) : loadedPages >= MAX_OLDER_PAGES ? ( ) : loadedPages >= MAX_OLDER_PAGES ? (
<span className="mono text-[0.65rem] text-ink-faint"> <span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
capped at {MAX_OLDER_PAGES} older pages · use search for capped at {MAX_OLDER_PAGES} older pages · use search for
more more
</span> </span>
) : ( ) : (
messages && messages &&
messages.length > 0 && ( messages.length > 0 && (
<span className="mono text-[0.65rem] text-ink-faint"> <span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
beginning of history beginning of history
</span> </span>
) )
@@ -369,11 +347,9 @@ export function MessagesView({
)} )}
<div <div
ref={scrollRef} ref={scrollRef}
className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1" className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
onScroll={(e) => { onScroll={(e) => {
const el = e.currentTarget; const el = e.currentTarget;
// Track whether the user is near the bottom (to follow live
// messages) and auto-load older messages when scrolled to top.
nearBottomRef.current = nearBottomRef.current =
el.scrollHeight - el.scrollTop - el.clientHeight < 120; el.scrollHeight - el.scrollTop - el.clientHeight < 120;
if (searching || !hasMore || loadMore.isPending) return; if (searching || !hasMore || loadMore.isPending) return;
@@ -384,11 +360,11 @@ export function MessagesView({
}} }}
> >
{viewMode === "timeline" && timelineNodes {viewMode === "timeline" && timelineNodes
? timelineNodes.map((node, _i) => ? timelineNodes.map((node) =>
node.type === "date" ? ( node.type === "date" ? (
<div <div
key={`date-${node.iso}`} key={`date-${node.iso}`}
className="flex items-center gap-2 px-1 text-[0.65rem] text-ink-faint" className="flex items-center gap-2 px-1 font-mono text-[0.65rem] text-[var(--color-ink-faint)]"
> >
<Calendar className="size-3" /> <Calendar className="size-3" />
{node.label} {node.label}
@@ -402,7 +378,7 @@ export function MessagesView({
/> />
), ),
) )
: display.map((m, _i) => ( : display.map((m) => (
<MessageRow <MessageRow
key={m.id} key={m.id}
m={m} m={m}
@@ -413,19 +389,49 @@ export function MessagesView({
</div> </div>
</div> </div>
)} )}
</GlassPanel> </div>
</section>
<GlassPanel className="lg:col-span-2"> {/* Mobile stream — full-width sheet under the console */}
<SectionHeader eyebrow="inspect" title="Detail" /> <section
{!selected ? ( className="pointer-events-auto absolute inset-x-4 bottom-24 top-44 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/80 p-2 backdrop-blur-xl md:hidden"
<EmptyState aria-label="Message stream mobile"
title="Select a message" >
description="Click any message to inspect AI analysis, attachments and edit history." {error && !messages ? (
<ErrorState error={error} />
) : isLoading && !messages ? (
<SkeletonRows rows={6} />
) : display.length === 0 ? (
<EmptyState title="No messages" description="Pick a guild first." />
) : (
display.map((m) => (
<MessageRow
key={m.id}
m={m}
selected={selected}
onSelect={setSelected}
/> />
) : detail.loading ? ( ))
)}
</section>
{/* Inspection dossier — bottom-center */}
{selected ? (
<aside
className="pointer-events-auto absolute bottom-20 left-1/2 z-30 max-h-[52vh] w-[min(34rem,92vw)] -translate-x-1/2 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/85 p-4 backdrop-blur-xl"
aria-label="Message detail"
>
<button
type="button"
className="absolute right-3 top-3 font-mono text-xs text-[var(--color-ink-faint)] hover:text-[var(--color-ink)]"
onClick={() => setSelected(null)}
>
esc
</button>
{detail.loading ? (
<div className="space-y-2"> <div className="space-y-2">
<Skeleton className="h-20" /> <Skeleton className="h-16" />
<Skeleton className="h-12" /> <Skeleton className="h-10" />
</div> </div>
) : detail.message ? ( ) : detail.message ? (
<MessageDetail <MessageDetail
@@ -435,14 +441,71 @@ export function MessagesView({
) : ( ) : (
<EmptyState title="Not found" /> <EmptyState title="Not found" />
)} )}
</GlassPanel> </aside>
</div> ) : null}
{activity.data && activity.data.length > 0 && ( {/* Intel strip — bottom-left */}
<div className="pointer-events-auto absolute bottom-20 left-5 hidden w-72 lg:block">
<button
type="button"
onClick={() => setShowIntel((v) => !v)}
className={`rounded-full border px-3 py-1 font-mono text-xs transition-colors ${
showIntel
? "border-signal/40 bg-signal/10 text-signal"
: "border-[var(--color-hairline)] text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
}`}
>
intel {showIntel ? "▲" : "▼"}
</button>
{showIntel ? (
<div className="mt-2 max-h-[38vh] space-y-3 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/75 p-3 backdrop-blur-xl">
{activity.data && activity.data.length > 0 ? (
<ActivityHeatmap buckets={activity.data} /> <ActivityHeatmap buckets={activity.data} />
)} ) : null}
{edits.data ? <EditHistory edits={edits.data} /> : null}
</div>
) : null}
</div>
</div>
);
}
{edits.data && <EditHistory edits={edits.data} />} function SemanticResults({
semantic,
}: {
semantic: ReturnType<typeof useSemanticSearch>;
}) {
if (semantic.isLoading) return <SkeletonRows rows={4} />;
if (!semantic.data || semantic.data.length === 0) {
return (
<EmptyState
icon={<Search className="size-6" />}
title="No semantic matches"
description="Try different wording — semantic search finds meaning, not exact text."
/>
);
}
return (
<div className="h-full space-y-1.5 overflow-y-auto pr-1">
{semantic.data.map((r, i) => (
<div
key={r.message_id ?? i}
className="animate-stagger rounded-xl border border-[var(--color-hairline)] p-2.5"
style={staggerDelay(i)}
>
<div className="flex items-center gap-2">
<span className="font-mono text-[0.6rem] text-signal">
{(r.score * 100).toFixed(0)}%
</span>
<span className="ml-auto font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
{formatRelativeTime(r.created_at)}
</span>
</div>
<p className="mt-0.5 line-clamp-3 text-sm text-[var(--color-ink-soft)]">
{r.content}
</p>
</div>
))}
</div> </div>
); );
} }
@@ -493,10 +556,12 @@ function MessageDetail({
return ( return (
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar src={m.avatar_url} name={m.username} size={40} /> <Avatar src={m.avatar_url} name={m.username} size={36} />
<div> <div>
<div className="font-semibold text-ink">{m.username}</div> <div className="font-semibold text-[var(--color-ink)]">
<div className="mono text-[0.65rem] text-ink-faint"> {m.username}
</div>
<div className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
{getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)} {getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)}
</div> </div>
</div> </div>
@@ -508,21 +573,21 @@ function MessageDetail({
</div> </div>
</div> </div>
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft"> <div className="rounded-xl border border-[var(--color-hairline)] p-3 text-[var(--color-ink-soft)]">
{renderMessageContent(m.edited_content ?? m.content, m.metadata) || {renderMessageContent(m.edited_content ?? m.content, m.metadata) ||
"(no text)"} "(no text)"}
</div> </div>
{m.ai_analysis && ( {m.ai_analysis ? (
<div> <div>
<div className="eyebrow mb-1">AI analysis</div> <div className="eyebrow mb-1">AI analysis</div>
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft"> <div className="rounded-xl border border-[var(--color-hairline)] p-3 text-[var(--color-ink-soft)]">
{m.ai_analysis} {m.ai_analysis}
</div> </div>
</div> </div>
)} ) : null}
{(flags.length > 0 || cats.length > 0) && ( {flags.length > 0 || cats.length > 0 ? (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{flags.map((f) => ( {flags.map((f) => (
<Badge key={f} tone="vermilion"> <Badge key={f} tone="vermilion">
@@ -535,9 +600,9 @@ function MessageDetail({
</Badge> </Badge>
))} ))}
</div> </div>
)} ) : null}
{attachments.length > 0 && ( {attachments.length > 0 ? (
<div> <div>
<div className="eyebrow mb-1 flex items-center gap-1.5"> <div className="eyebrow mb-1 flex items-center gap-1.5">
<Paperclip className="size-3" /> Attachments ({attachments.length}) <Paperclip className="size-3" /> Attachments ({attachments.length})
@@ -549,23 +614,22 @@ function MessageDetail({
href={a.discord_url ?? a.uploaded_url ?? "#"} href={a.discord_url ?? a.uploaded_url ?? "#"}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft hover:text-ink" className="flex items-center gap-2 rounded-xl border border-[var(--color-hairline)] px-3 py-2 font-mono text-xs text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
> >
<ImageIcon className="size-3.5 text-signal" /> <ImageIcon className="size-3.5 text-signal" />
<span className="flex-1 truncate">{a.filename}</span> <span className="flex-1 truncate">{a.filename}</span>
<span className="mono text-ink-faint"> <span className="text-[var(--color-ink-faint)]">
{formatBytes(a.size)} {formatBytes(a.size)}
</span> </span>
</a> </a>
))} ))}
</div> </div>
</div> </div>
)} ) : null}
</div> </div>
); );
} }
/** Single message card used by both the live feed and the date-grouped timeline. */
function MessageRow({ function MessageRow({
m, m,
selected, selected,
@@ -577,31 +641,32 @@ function MessageRow({
}) { }) {
return ( return (
<button <button
key={m.id}
type="button" type="button"
onClick={() => onSelect(m.id)} onClick={() => onSelect(m.id)}
className={`animate-stagger flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${ className={`animate-stagger flex w-full items-start gap-3 rounded-xl border p-2.5 text-left transition-colors ${
selected === m.id selected === m.id
? "border-signal/40 bg-signal/8" ? "border-signal/40 bg-signal/8"
: "border-hairline bg-white/[0.03] hover:bg-white/[0.06]" : "border-[var(--color-hairline)] hover:bg-white/[0.04]"
}`} }`}
> >
<Avatar src={m.avatar_url} name={m.username} size={34} /> <Avatar src={m.avatar_url} name={m.username} size={32} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold text-ink"> <span className="truncate text-sm font-semibold text-[var(--color-ink)]">
{m.username} {m.username}
</span> </span>
<span className="mono text-[0.65rem] text-ink-faint"> <span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
{getMessageChannelLabel(m)} {getMessageChannelLabel(m)}
</span> </span>
<span className="mono ml-auto text-[0.6rem] text-ink-faint"> <span className="ml-auto font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
{formatRelativeTime(m.created_at)} {formatRelativeTime(m.created_at)}
</span> </span>
</div> </div>
<div className="mt-0.5 line-clamp-2 text-sm text-ink-soft"> <div className="mt-0.5 line-clamp-2 text-sm text-[var(--color-ink-soft)]">
{renderMessageContent(m.content, m.metadata) || ( {renderMessageContent(m.content, m.metadata) || (
<span className="italic text-ink-faint">(empty / embed)</span> <span className="italic text-[var(--color-ink-faint)]">
(empty / embed)
</span>
)} )}
</div> </div>
</div> </div>
@@ -1,40 +1,34 @@
"use client"; "use client";
/**
* 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.
*/
import { import {
AlertTriangle, AlertTriangle,
Ban, Ban,
CheckCircle2,
Clock,
Filter, Filter,
MessageSquareWarning, MessageSquareWarning,
MicOff, MicOff,
ShieldAlert, ShieldAlert,
Trash2, Trash2,
UserX, UserX,
XCircle,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context"; import { useAmbient } from "@/components/ambient/ambient-context";
import { CategoryDrilldown } from "@/components/CategoryDrilldown"; import { CategoryDrilldown } from "@/components/CategoryDrilldown";
import { CoverageTiles } from "@/components/CoverageTiles"; import { CoverageTiles } from "@/components/CoverageTiles";
import { Donut } from "@/components/charts"; import { Donut } from "@/components/charts";
import { LiveModerationFeed } from "@/components/LiveModerationFeed";
import { ModerationHeatmap } from "@/components/ModerationHeatmap"; import { ModerationHeatmap } from "@/components/ModerationHeatmap";
import { import { Badge, Select, type SelectOption } from "@/components/primitives";
Badge,
GlassPanel,
Select,
type SelectOption,
} from "@/components/primitives";
import { ScamDomains } from "@/components/ScamDomains"; import { ScamDomains } from "@/components/ScamDomains";
import { ErrorState } from "@/components/shared";
import { import {
ErrorState, useSceneFocusSetter,
MetricTile, useScenePublish,
SectionHeader, } from "@/components/shell/scene-graph-context";
SkeletonMetricRow,
SkeletonPanel,
SkeletonRows,
} from "@/components/shared";
import { TopChannels } from "@/components/TopChannels"; import { TopChannels } from "@/components/TopChannels";
import { TopicTrends } from "@/components/TopicTrends"; import { TopicTrends } from "@/components/TopicTrends";
import { import {
@@ -49,6 +43,7 @@ import {
useTopFlaggedDomains, useTopFlaggedDomains,
} from "@/hooks"; } from "@/hooks";
import { aiTone } from "@/lib/ai-status"; import { aiTone } from "@/lib/ai-status";
import type { ConstellationGraph } from "@/lib/constellation/graph";
import { downloadCsv } from "@/lib/csv"; import { downloadCsv } from "@/lib/csv";
import { formatNumber, formatRelativeTime } from "@/lib/format"; import { formatNumber, formatRelativeTime } from "@/lib/format";
import type { import type {
@@ -56,7 +51,6 @@ import type {
ModerationActionType, ModerationActionType,
ModerationStats, ModerationStats,
} from "@/lib/types"; } from "@/lib/types";
import { staggerDelay } from "@/lib/utils";
const ACTION_ICON: Record<ModerationActionType, React.ReactNode> = { const ACTION_ICON: Record<ModerationActionType, React.ReactNode> = {
delete_message: <Trash2 className="size-3.5" />, delete_message: <Trash2 className="size-3.5" />,
@@ -74,6 +68,35 @@ const ACTION_LABEL: Record<ModerationActionType, string> = {
ban_user: "Ban", ban_user: "Ban",
}; };
/** 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}` })),
};
}
export function ModerationView({ export function ModerationView({
initialStats, initialStats,
initialActions, initialActions,
@@ -96,13 +119,17 @@ export function ModerationView({
const { data: hourly } = useHourlyModeration(30); const { data: hourly } = useHourlyModeration(30);
const { data: coverage } = useModerationCoverage(30); const { data: coverage } = useModerationCoverage(30);
const [drilldown, setDrilldown] = useState<string | null>(null); const [drilldown, setDrilldown] = useState<string | null>(null);
const [intelOpen, setIntelOpen] = useState(false);
const { data: categoryActions, isValidating: categoryLoading } = const { data: categoryActions, isValidating: categoryLoading } =
useModerationByCategory(drilldown ? 30 : 0, drilldown); useModerationByCategory(drilldown ? 30 : 0, drilldown);
const publish = useScenePublish();
const setFocus = useSceneFocusSetter();
const failedRate = stats ? stats.failed_rate * 100 : 0; const failedRate = stats ? stats.failed_rate * 100 : 0;
const byAction = stats?.by_action ?? {}; const byAction = stats?.by_action ?? {};
const segments = Object.entries(byAction).map(([k, _v]) => ({ const segments = Object.entries(byAction).map(([k]) => ({
value: 1, value: 1,
color: color:
k === "ban_user" || k === "kick_user" k === "ban_user" || k === "kick_user"
@@ -113,6 +140,23 @@ export function ModerationView({
label: k, label: k,
})); }));
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],
);
const ambient = useAmbient(); const ambient = useAmbient();
useEffect(() => { useEffect(() => {
ambient.set( ambient.set(
@@ -125,11 +169,9 @@ export function ModerationView({
if (error && !stats) return <ErrorState error={error} />; if (error && !stats) return <ErrorState error={error} />;
if (!stats && isLoading) if (!stats && isLoading)
return ( return (
<div className="space-y-5"> <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)]">
<SkeletonMetricRow cols={4} /> menghubungkan verdict hub
<SkeletonPanel rows={5} /> </p>
<SkeletonRows rows={6} />
</div>
); );
if (!stats) return <ErrorState error={error ?? new Error("No data")} />; if (!stats) return <ErrorState error={error ?? new Error("No data")} />;
@@ -148,76 +190,84 @@ export function ModerationView({
]; ];
return ( return (
<div className="space-y-5"> <div className="min-h-full">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4"> {/* Metric whisper — left */}
<MetricTile <section
label="Total actions" 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)} value={formatNumber(stats.total)}
tone="signal" tone="ink"
icon={<ShieldAlert className="size-3.5" />}
/> />
<MetricTile <Whisper
label="Executed" label="executed"
value={formatNumber(stats.executed)} value={formatNumber(stats.executed)}
tone="signal" tone="signal"
icon={<CheckCircle2 className="size-3.5" />}
/> />
<MetricTile <Whisper
label="Failed" label="failed"
value={formatNumber(stats.failed)} value={formatNumber(stats.failed)}
tone={stats.failed > 0 ? "vermilion" : "neutral"} tone={stats.failed > 0 ? "vermilion" : "ink"}
icon={<XCircle className="size-3.5" />}
/> />
<MetricTile <Whisper
label="Pending" label="pending"
value={formatNumber(stats?.pending)} value={formatNumber(stats.pending)}
tone={stats?.pending > 0 ? "amber" : "neutral"} tone={stats.pending > 0 ? "amber" : "ink"}
icon={<Clock className="size-3.5" />}
/> />
</div> </div>
<div className="mt-4">
<div className="grid gap-5 lg:grid-cols-5"> <Donut
<div className="lg:col-span-2"> segments={
{trends ? ( segments.length
<TopicTrends trends={trends} /> ? segments
) : ( : [{ value: 1, color: "var(--color-ink-faint)", label: "none" }]
<SkeletonPanel rows={6} /> }
)} centerLabel={`${Math.round(failedRate)}%`}
centerSub="fail rate"
/>
<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>
);
})}
</div> </div>
<div className="lg:col-span-5">
<LiveModerationFeed actions={liveActions} />
</div> </div>
</section>
{coverage ? ( {/* Intel dossier — right */}
<CoverageTiles coverage={coverage} /> <div className="pointer-events-auto absolute right-5 top-16 hidden w-[min(24rem,90vw)] md:block">
) : ( <button
<SkeletonPanel rows={3} className="lg:col-span-5" /> type="button"
)} onClick={() => setIntelOpen((v) => !v)}
className={`rounded-full border px-3 py-1 font-mono text-xs transition-colors ${
<div className="lg:col-span-2"> intelOpen
{domains ? ( ? "border-signal/40 bg-signal/10 text-signal"
<ScamDomains domains={domains} /> : "border-[var(--color-hairline)] bg-[var(--color-canvas)]/60 text-[var(--color-ink-soft)] backdrop-blur-md hover:text-[var(--color-ink)]"
) : ( }`}
<SkeletonPanel rows={6} /> >
)} intel {intelOpen ? "▲" : "▼"}
</div> </button>
<div className="lg:col-span-2"> {intelOpen ? (
{hourly ? ( <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">
<ModerationHeatmap hours={hourly} /> {trends ? <TopicTrends trends={trends} /> : null}
) : ( {coverage ? <CoverageTiles coverage={coverage} /> : null}
<SkeletonPanel rows={6} /> {domains ? <ScamDomains domains={domains} /> : null}
)} {hourly ? <ModerationHeatmap hours={hourly} /> : null}
</div> {channels ? <TopChannels channels={channels} /> : null}
<div className="lg:col-span-1">
{channels ? (
<TopChannels channels={channels} />
) : (
<SkeletonPanel rows={6} />
)}
</div>
<div className="lg:col-span-3">
<CategoryDrilldown <CategoryDrilldown
trends={trends ?? { categories: [], severities: [], actions: [] }} trends={trends ?? { categories: [], severities: [], actions: [] }}
selected={drilldown} selected={drilldown}
@@ -226,71 +276,33 @@ export function ModerationView({
onSelect={setDrilldown} onSelect={setDrilldown}
/> />
</div> </div>
) : null}
</div>
<GlassPanel className="lg:col-span-2"> {/* Live feed ribbon — bottom */}
<SectionHeader eyebrow="health" title="Breakdown" /> <section
<div className="flex items-center gap-5"> 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]"
<Donut aria-label="Live moderation feed"
segments={ >
segments.length <div className="flex items-center justify-between border-b border-[var(--color-hairline)] px-4 py-2">
? segments <span className="eyebrow flex items-center gap-2">
: [ <Filter className="size-3 text-[var(--color-ink-faint)]" />
{ action log
value: 1,
color: "var(--color-ink-faint)",
label: "none",
},
]
}
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">
<span className="text-ink-soft">
{ACTION_ICON[k as ModerationActionType]}
</span> </span>
<span className="flex-1 text-ink-soft">
{ACTION_LABEL[k as ModerationActionType] ?? k}
</span>
{count !== null && (
<span className="mono text-ink">{count}</span>
)}
</div>
);
})}
{Object.keys(byAction).length === 0 && (
<div className="text-xs text-ink-faint">
No actions recorded yet.
</div>
)}
</div>
</div>
</GlassPanel>
<GlassPanel className="lg:col-span-3">
<SectionHeader
eyebrow="filter"
title="Action log"
action={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Filter className="size-3.5 text-ink-faint" />
<Select <Select
value={typeFilter} value={typeFilter}
onChange={setTypeFilter} onChange={setTypeFilter}
options={typeOpts} options={typeOpts}
size="sm" size="sm"
className="w-36" className="w-32"
/> />
<Select <Select
value={statusFilter} value={statusFilter}
onChange={setStatusFilter} onChange={setStatusFilter}
options={statusOpts} options={statusOpts}
size="sm" size="sm"
className="w-32" className="w-28"
/> />
<button <button
type="button" type="button"
@@ -311,30 +323,71 @@ export function ModerationView({
})), })),
) )
} }
className="rounded-full border border-hairline bg-white/[0.03] px-3 py-1 text-xs text-ink-soft transition-colors hover:bg-white/[0.06]" 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" title="Download moderation actions as CSV"
> >
CSV CSV
</button> </button>
</div> </div>
</div>
<div className="max-h-[40vh] overflow-y-auto p-2">
<ActionRows actions={actions ?? []} liveCount={liveActions.length} />
</div>
</section>
</div>
);
} }
/>
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1"> function ActionRows({
{(actions ?? []).map((a, i) => ( actions,
<ActionRow key={a.id} a={a} index={i} /> liveCount,
))} }: {
{(actions ?? []).length === 0 && ( actions: ModerationAction[];
<div className="py-10 text-center text-xs text-ink-faint"> 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. No matching actions.
</div> </div>
)} );
</div> }
</GlassPanel> return (
</div> <div className="space-y-1.5">
{actions.map((a, i) => (
<ActionRow key={a.id} a={a} index={i} />
))}
</div> </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>
);
}
function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) { function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
const tone = const tone =
a.status === "executed" a.status === "executed"
@@ -345,8 +398,6 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
const icon = ACTION_ICON[a.action_type] ?? ( const icon = ACTION_ICON[a.action_type] ?? (
<AlertTriangle className="size-3.5" /> <AlertTriangle className="size-3.5" />
); );
// Map moderation severity → design-system tone (reuse aiTone with a
// severity→status projection so "none" reads as clean/signal).
const severityTone = const severityTone =
a.severity == null a.severity == null
? null ? null
@@ -359,8 +410,8 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
); );
return ( return (
<div <div
className="animate-stagger flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3" className="animate-stagger flex items-start gap-3 rounded-xl border border-[var(--color-hairline)] p-3"
style={staggerDelay(index)} style={{ animationDelay: `${index * 30}ms` }}
> >
<span <span
className={`mt-0.5 ${tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal"}`} className={`mt-0.5 ${tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal"}`}
@@ -369,27 +420,29 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-semibold text-ink"> <span className="text-sm font-semibold text-[var(--color-ink)]">
{a.username ?? "unknown"} {a.username ?? "unknown"}
</span> </span>
<Badge tone={tone}>{a.status}</Badge> <Badge tone={tone}>{a.status}</Badge>
<span className="mono ml-auto text-[0.6rem] text-ink-faint"> <span className="ml-auto font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
{formatRelativeTime(a.created_at)} {formatRelativeTime(a.created_at)}
</span> </span>
</div> </div>
{a.reason && ( {a.reason ? (
<div className="mt-0.5 text-xs text-ink-soft">{a.reason}</div> <div className="mt-0.5 text-xs text-[var(--color-ink-soft)]">
)} &ldquo;{a.reason}&rdquo;
{severityTone && a.severity && ( </div>
) : null}
{severityTone && a.severity ? (
<div className="mt-1 flex flex-wrap items-center gap-1"> <div className="mt-1 flex flex-wrap items-center gap-1">
<Badge tone={severityTone}>{a.severity}</Badge> <Badge tone={severityTone}>{a.severity}</Badge>
{a.confidence != null && ( {a.confidence != null ? (
<span className="mono text-[0.6rem] text-ink-faint"> <span className="font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
conf {(a.confidence * 100).toFixed(0)}% conf {(a.confidence * 100).toFixed(0)}%
</span> </span>
)} ) : null}
</div> </div>
)} ) : null}
{a.flags?.length ? ( {a.flags?.length ? (
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1 flex flex-wrap gap-1">
{a.flags.slice(0, 6).map((f) => ( {a.flags.slice(0, 6).map((f) => (
@@ -400,24 +453,24 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
</div> </div>
) : null} ) : null}
{a.evidence?.length ? ( {a.evidence?.length ? (
<div className="mt-1 border-l-2 border-hairline pl-2 text-xs text-ink-faint"> <div className="mt-1 border-l-2 border-[var(--color-hairline)] pl-2 text-xs text-[var(--color-ink-faint)]">
&ldquo;{a.evidence[0]}&rdquo; &ldquo;{a.evidence[0]}&rdquo;
</div> </div>
) : null} ) : null}
{a.executed_by && ( {a.executed_by ? (
<div className="mono mt-0.5 text-[0.6rem] text-ink-faint"> <div className="mt-0.5 font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
by {a.executed_by} by {a.executed_by}
{a.executed_at ? ` · ${formatRelativeTime(a.executed_at)}` : ""} {a.executed_at ? ` · ${formatRelativeTime(a.executed_at)}` : ""}
</div> </div>
)} ) : null}
{a.content && ( {a.content ? (
<div className="mt-1 line-clamp-2 rounded-[8px] bg-white/[0.03] px-2 py-1 text-xs text-ink-faint"> <div className="mt-1 line-clamp-2 rounded-lg bg-white/[0.03] px-2 py-1 text-xs text-[var(--color-ink-faint)]">
{a.content} {a.content}
</div> </div>
)} ) : null}
{a.error && ( {a.error ? (
<div className="mt-1 text-xs text-vermilion">{a.error}</div> <div className="mt-1 text-xs text-vermilion">{a.error}</div>
)} ) : null}
</div> </div>
</div> </div>
); );