feat(frontend): messages orbital-belt & moderation verdict-hub scenes
This commit is contained in:
@@ -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)]">
|
||||||
)}
|
“{a.reason}”
|
||||||
{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)]">
|
||||||
“{a.evidence[0]}”
|
“{a.evidence[0]}”
|
||||||
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user