feat(fe): enrich dashboard, moderation, messages inspector, channels roster with full backend data

- Dashboard: 6-tile metric deck (flagged today, active users 24h, mod queue),
  pipeline status bar, top channels ranking, enriched reactions with emojis,
  warned counter in gauge, 4 quick-nav links
- Moderation: coverage tile + header rate, top flagged domains, top flagged
  channels, category/severity/action breakdown trends, coverage progress bar
- Messages inspector: severity meter, confidence %, risk score, recommended
  action badge, reply-context chain, mention + role chips
- Channels: roster/culture tabs, rich table (messages, clean, flagged, risk
  bar, last activity) wired to dashboard.channels endpoint
This commit is contained in:
asepharyana
2026-09-01 23:51:39 +07:00
parent 7bfdf9c85c
commit 38b74cc1db
4 changed files with 784 additions and 85 deletions
@@ -1,39 +1,234 @@
"use client";
import { Hash } from "lucide-react";
import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import { ChannelCultureGlossary } from "@/components/ChannelCultureGlossary";
import { SkeletonPanel } from "@/components/shared";
import { useChannelCultures } from "@/hooks";
import type { ChannelCultureRow } from "@/lib/types";
import { GlassPanel } from "@/components/primitives";
import {
EmptyState,
ErrorState,
PageTransition,
SectionHeader,
SkeletonPanel,
} from "@/components/shared";
import { useChannelCultures, useChannels } from "@/hooks";
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
import { formatNumber } from "@/lib/format";
import type { DashboardChannel } from "@/lib/types";
type ViewMode = "roster" | "culture";
export function ChannelsView({
initialCultures,
}: {
initialCultures?: ChannelCultureRow[];
initialCultures?: import("@/lib/types").ChannelCultureRow[];
}) {
const { data: cultures } = useChannelCultures(100, initialCultures);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
<div className="flex items-center gap-3">
<div className="relative flex size-3 items-center justify-center">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-signal opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-signal" />
</div>
<h1 className="font-mono text-xs font-semibold tracking-widest text-ink uppercase">
CHANNEL ROSTER · SIGNAL_MAP
</h1>
</div>
<div className="inline-flex items-center gap-1.5 rounded-md bg-surface px-2 py-0.5 font-mono text-[11px] text-ink-soft">
<span className="text-ink-faint">NODES:</span>
<span className="font-bold text-signal">{cultures?.length ?? 0}</span>
</div>
</div>
const [viewMode, setViewMode] = useState<ViewMode>("roster");
const { data: channels, isLoading, error } = useChannels();
const ambient = useAmbient();
{cultures ? (
<ChannelCultureGlossary cultures={cultures} />
) : (
<SkeletonPanel rows={6} />
)}
</div>
const containerRef = useStaggerReveal<HTMLDivElement>(
".channel-roster-card",
{
stagger: 0.03,
y: 8,
dependencies: [channels?.length],
},
);
useEffect(() => {
ambient.set("signal", 0.25, "channels");
}, [ambient]);
if (error && !channels) return <ErrorState error={error} />;
return (
<PageTransition>
<div ref={containerRef} className="space-y-4">
{/* Tactical HUD Header Bar */}
<div className="channel-roster-card flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
<div className="flex items-center gap-3">
<div className="relative flex size-3 items-center justify-center">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-signal opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-signal" />
</div>
<h1 className="font-mono text-xs font-semibold tracking-widest text-ink uppercase">
Channel Roster · Signal Map
</h1>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 rounded-[6px] border border-hairline bg-surface-2 p-0.5">
<button
type="button"
onClick={() => setViewMode("roster")}
className={`rounded-[4px] px-2.5 py-1 font-mono text-[10px] font-medium transition-all ${
viewMode === "roster"
? "bg-surface text-ink border border-hairline-focus shadow-xs"
: "text-ink-muted hover:text-ink"
}`}
>
ROSTER
</button>
<button
type="button"
onClick={() => setViewMode("culture")}
className={`rounded-[4px] px-2.5 py-1 font-mono text-[10px] font-medium transition-all ${
viewMode === "culture"
? "bg-surface text-ink border border-hairline-focus shadow-xs"
: "text-ink-muted hover:text-ink"
}`}
>
CULTURE
</button>
</div>
<div className="inline-flex items-center gap-1.5 rounded-md bg-surface px-2 py-0.5 font-mono text-[11px] text-ink-soft">
<span className="text-ink-faint">NODES:</span>
<span className="font-bold text-signal">
{channels?.length ?? 0}
</span>
</div>
</div>
</div>
{viewMode === "culture" ? (
<ChannelsCultureView initialCultures={initialCultures} />
) : (
<>
{isLoading && !channels ? (
<SkeletonPanel rows={8} />
) : !channels || channels.length === 0 ? (
<EmptyState
icon={<Hash className="size-7" />}
title="No channels discovered"
description="Channels appear here once the Discord gateway captures messages."
/>
) : (
<GlassPanel className="channel-roster-card">
<SectionHeader
eyebrow="registry"
title="Channel Roster"
action={
<span className="mono text-xs text-[#8a8f98]">
{channels.length} channels
</span>
}
/>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-left text-xs">
<thead>
<tr className="border-b border-hairline font-mono text-[10px] text-ink-muted uppercase">
<th className="px-3 py-2">Channel</th>
<th className="px-3 py-2 text-right">Messages</th>
<th className="px-3 py-2 text-right">Clean</th>
<th className="px-3 py-2 text-right">Flagged</th>
<th className="px-3 py-2">Risk</th>
<th className="px-3 py-2 text-right">Last Activity</th>
</tr>
</thead>
<tbody>
{channels.map((ch) => (
<ChannelRow key={ch.channel_id} channel={ch} />
))}
</tbody>
</table>
</div>
</GlassPanel>
)}
</>
)}
</div>
</PageTransition>
);
}
function ChannelRow({ channel }: { channel: DashboardChannel }) {
const total = channel.total_messages || 1;
const flaggedPct = channel.flagged_count
? (channel.flagged_count / total) * 100
: 0;
const riskTone =
flaggedPct > 10 ? "vermilion" : flaggedPct > 3 ? "amber" : "signal";
return (
<tr className="border-b border-hairline/50 transition-colors hover:bg-surface-2/50">
<td className="px-3 py-2.5">
<div className="flex items-center gap-2">
<Hash className="size-3.5 text-signal" />
<div>
<div className="font-medium text-ink">
{channel.channel_name ?? channel.channel_id.slice(0, 20)}
</div>
{channel.guild_id && (
<div className="font-mono text-[9px] text-ink-faint">
guild: {channel.guild_id.slice(0, 12)}…
</div>
)}
</div>
</div>
</td>
<td className="px-3 py-2.5 text-right font-mono text-[11px] font-semibold text-ink">
{formatNumber(channel.total_messages)}
</td>
<td className="px-3 py-2.5 text-right">
<span className="font-mono text-[11px] text-success">
{formatNumber(channel.total_messages - channel.flagged_count)}
</span>
</td>
<td className="px-3 py-2.5 text-right">
<span
className={`font-mono text-[11px] ${
channel.flagged_count > 0 ? "text-vermilion" : "text-ink-muted"
}`}
>
{formatNumber(channel.flagged_count)}
</span>
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-2">
<div className="h-1 w-16 overflow-hidden rounded-full bg-surface-2">
<div
className={`h-full rounded-full ${
riskTone === "vermilion"
? "bg-vermilion"
: riskTone === "amber"
? "bg-amber"
: "bg-signal"
}`}
style={{ width: `${Math.min(100, flaggedPct * 3)}%` }}
/>
</div>
<span className="font-mono text-[9px] text-ink-faint">
{flaggedPct.toFixed(1)}%
</span>
</div>
</td>
<td className="px-3 py-2.5 text-right">
{channel.last_message_at ? (
<span
className="font-mono text-[10px] text-ink-muted"
suppressHydrationWarning
>
{new Date(channel.last_message_at).toLocaleDateString()}
</span>
) : (
<span className="text-ink-faint">—</span>
)}
</td>
</tr>
);
}
function ChannelsCultureView({
initialCultures,
}: {
initialCultures?: import("@/lib/types").ChannelCultureRow[];
}) {
const { data: cultures } = useChannelCultures(100, initialCultures);
return cultures ? (
<ChannelCultureGlossary cultures={cultures} />
) : (
<SkeletonPanel rows={6} />
);
}
@@ -1,12 +1,18 @@
"use client";
import {
Activity,
AlertTriangle,
AudioWaveform,
ChevronRight,
Hash,
MessageSquare,
Mic,
Shield,
ShieldAlert,
ShieldCheck,
Users,
Zap,
} from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
@@ -98,6 +104,8 @@ export function DashboardView({
const s = stats;
const total = s.total_flagged + s.total_clean || 1;
const cleanRatio = s.total_clean / total;
const mod = s.moderation_overview;
const hasModQueue = mod.pending > 0 || mod.processing > 0 || mod.error > 0;
return (
<PageTransition>
@@ -113,13 +121,19 @@ export function DashboardView({
<div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted">
<span>SIGNAL:</span>
<span className="rounded bg-signal/15 px-2 py-0.5 font-medium text-signal border border-signal/30">
STABLE_STREAM
{deriveSignal(stats).label}
</span>
{hasModQueue && (
<span className="flex items-center gap-1 rounded bg-amber/15 px-2 py-0.5 font-medium text-amber border border-amber/30">
<Zap className="size-3" />
{mod.pending + mod.processing + mod.error} IN QUEUE
</span>
)}
</div>
</div>
{/* Primary Metric Deck */}
<div className="linear-tile grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{/* Primary Metric Deck — 6 tiles */}
<div className="linear-tile grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<MetricTile
label="Total Messages"
value={formatNumber(s.total_messages)}
@@ -133,6 +147,19 @@ export function DashboardView({
tone="signal"
icon={<Shield className="size-4" />}
/>
<MetricTile
label="Flagged Today"
value={formatNumber(s.today_flagged)}
hint={`${formatNumber(s.total_flagged)} all-time flagged`}
tone={s.today_flagged > 0 ? "vermilion" : "neutral"}
icon={<ShieldAlert className="size-4" />}
/>
<MetricTile
label="Active Users 24h"
value={formatNumber(s.active_users_24h)}
hint={`${formatNumber(s.total_users)} total profiles`}
icon={<Users className="size-4" />}
/>
<MetricTile
label="Voice Recordings"
value={formatNumber(s.total_voice_recordings)}
@@ -140,13 +167,47 @@ export function DashboardView({
icon={<Mic className="size-4" />}
/>
<MetricTile
label="Total Profiles"
value={formatNumber(s.total_profiles)}
hint={`${formatNumber(s.active_users_24h)} active 24h`}
icon={<Users className="size-4" />}
label="Mod Queue"
value={formatNumber(mod.pending + mod.processing)}
hint={mod.error > 0 ? `${mod.error} errors` : "Processing pipeline"}
tone={
mod.error > 0
? "vermilion"
: mod.pending > 0
? "amber"
: "neutral"
}
icon={<Activity className="size-4" />}
/>
</div>
{/* Moderation Queue Status Bar */}
{hasModQueue && (
<div className="linear-tile flex flex-wrap items-center gap-4 rounded-[8px] border border-amber/20 bg-amber/5 px-4 py-2.5 font-mono text-[10px] text-ink-soft">
<span className="text-amber font-semibold uppercase">
Pipeline Status
</span>
{mod.pending > 0 && (
<span className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-amber" />
{mod.pending} PENDING
</span>
)}
{mod.processing > 0 && (
<span className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-signal animate-pulse" />
{mod.processing} PROCESSING
</span>
)}
{mod.error > 0 && (
<span className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-vermilion" />
{mod.error} ERROR
</span>
)}
</div>
)}
{/* Dynamic Activity Area & Telemetry Gauges */}
<div className="grid gap-3 lg:grid-cols-3">
<GlassPanel className="linear-tile lg:col-span-2">
@@ -174,7 +235,7 @@ export function DashboardView({
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2 border-t border-hairline pt-3 text-center">
<div className="grid grid-cols-3 gap-2 border-t border-hairline pt-3 text-center">
<div>
<div className="eyebrow">CLEAN</div>
<div className="font-sans text-sm font-semibold text-success">
@@ -187,12 +248,70 @@ export function DashboardView({
{formatNumber(s.total_flagged)}
</div>
</div>
<div>
<div className="eyebrow">WARNED</div>
<div className="font-sans text-sm font-semibold text-amber">
{formatNumber(s.total_warned)}
</div>
</div>
</div>
</GlassPanel>
</div>
{/* Secondary Insights Deck */}
{/* Top Channels + Engagement Row */}
<div className="grid gap-3 lg:grid-cols-2">
{/* Top Channels Ranking */}
{s.top_channels && s.top_channels.length > 0 && (
<GlassPanel className="linear-tile">
<SectionHeader
eyebrow="volume"
title="Top Active Channels"
action={
<Link
href="/analysis"
className="flex items-center gap-1 text-xs text-ink-soft hover:text-ink"
>
View All
<ChevronRight className="size-3" />
</Link>
}
/>
<div className="mt-3 space-y-2">
{s.top_channels.slice(0, 6).map((ch, i) => {
const maxCount = s.top_channels[0]?.message_count || 1;
const pct = Math.max(
4,
Math.round((ch.message_count / maxCount) * 100),
);
return (
<div
key={ch.channel_id}
className="flex items-center gap-3 text-xs"
>
<span className="font-mono w-4 text-[10px] text-ink-faint">
{String(i + 1).padStart(2, "0")}
</span>
<span className="w-28 shrink-0 truncate font-mono text-xs text-ink sm:w-36">
<Hash className="mr-0.5 inline size-3 text-signal" />
{ch.channel_name ?? ch.channel_id.slice(0, 14)}
</span>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
<div
className="h-full rounded-full bg-signal"
style={{ width: `${pct}%` }}
/>
</div>
<span className="font-mono w-16 text-right text-[11px] font-semibold text-signal">
{formatNumber(ch.message_count)}
</span>
</div>
);
})}
</div>
</GlassPanel>
)}
{/* Top Reacted Messages */}
{reactions && reactions.length > 0 && (
<GlassPanel className="linear-tile">
<SectionHeader
@@ -203,47 +322,86 @@ export function DashboardView({
{reactions.slice(0, 4).map((r) => (
<div
key={r.message_id}
className="hud-card flex items-center justify-between p-2.5"
className="hud-card flex items-start justify-between gap-2 p-2.5"
>
<span className="truncate pr-2 text-xs text-ink-soft">
{r.content || "[Media/Attachment]"}
</span>
<span className="font-mono text-xs font-semibold text-signal shrink-0">
{formatNumber(r.reaction_count)} reactions
</span>
<div className="min-w-0 flex-1">
<span className="line-clamp-1 block text-xs text-ink-soft">
{r.content || "[Media/Attachment]"}
</span>
{r.username && (
<span className="mt-0.5 block font-mono text-[10px] text-ink-muted">
@{r.username}
{r.channel_name && ` in #${r.channel_name}`}
</span>
)}
</div>
<div className="shrink-0 text-right">
<span className="font-mono text-xs font-semibold text-signal">
{formatNumber(r.reaction_count)}
</span>
{r.top_emojis && r.top_emojis.length > 0 && (
<div className="mt-0.5 flex gap-0.5">
{r.top_emojis.map((e) => (
<span
key={e.emoji}
className="text-[10px]"
title={`${e.emoji} ×${e.count}`}
>
{e.emoji}
</span>
))}
</div>
)}
</div>
</div>
))}
</div>
</GlassPanel>
)}
</div>
<GlassPanel className="linear-tile flex flex-col justify-between">
<div>
<SectionHeader eyebrow="Navigation" title="Quick Diagnostics" />
<div className="mt-2 space-y-1.5">
<Link
href="/moderation"
className="hud-card flex items-center justify-between p-2.5 text-xs text-ink-soft transition-colors hover:text-ink"
>
<span className="flex items-center gap-2">
<Shield className="size-3.5 text-signal" />
Review Live Moderation Queue
</span>
<ChevronRight className="size-3.5 text-ink-muted" />
</Link>
<Link
href="/voice"
className="hud-card flex items-center justify-between p-2.5 text-xs text-ink-soft transition-colors hover:text-ink"
>
<span className="flex items-center gap-2">
<AudioWaveform className="size-3.5 text-success" />
Inspect Active Voice Stages
</span>
<ChevronRight className="size-3.5 text-ink-muted" />
</Link>
</div>
</div>
</GlassPanel>
{/* Quick Navigation */}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Link
href="/moderation"
className="linear-tile hud-card flex items-center justify-between p-3 text-xs text-ink-soft transition-colors hover:text-ink"
>
<span className="flex items-center gap-2">
<ShieldCheck className="size-3.5 text-signal" />
Live Moderation Queue
</span>
<ChevronRight className="size-3.5 text-ink-muted" />
</Link>
<Link
href="/voice"
className="linear-tile hud-card flex items-center justify-between p-3 text-xs text-ink-soft transition-colors hover:text-ink"
>
<span className="flex items-center gap-2">
<AudioWaveform className="size-3.5 text-success" />
Active Voice Stages
</span>
<ChevronRight className="size-3.5 text-ink-muted" />
</Link>
<Link
href="/analysis"
className="linear-tile hud-card flex items-center justify-between p-3 text-xs text-ink-soft transition-colors hover:text-ink"
>
<span className="flex items-center gap-2">
<AlertTriangle className="size-3.5 text-amber" />
Flagged Content Analysis
</span>
<ChevronRight className="size-3.5 text-ink-muted" />
</Link>
<Link
href="/recordings"
className="linear-tile hud-card flex items-center justify-between p-3 text-xs text-ink-soft transition-colors hover:text-ink"
>
<span className="flex items-center gap-2">
<Mic className="size-3.5 text-vermilion" />
Voice Recording Archive
</span>
<ChevronRight className="size-3.5 text-ink-muted" />
</Link>
</div>
</div>
</PageTransition>
@@ -572,6 +572,34 @@ function AiBadge({
);
}
function severityTone(
sev: import("@/lib/types").AiSeverity | null | undefined,
): "signal" | "amber" | "vermilion" | "neutral" {
if (sev === "critical" || sev === "high") return "vermilion";
if (sev === "medium") return "amber";
if (sev === "low") return "neutral";
return "neutral";
}
function severityWidth(
sev: import("@/lib/types").AiSeverity | null | undefined,
): number {
if (sev === "critical") return 100;
if (sev === "high") return 75;
if (sev === "medium") return 50;
if (sev === "low") return 25;
return 0;
}
const RECOMMEND_LABELS: Record<string, string> = {
none: "No Action",
monitor: "Monitor",
warn: "Warn User",
review: "Manual Review",
delete: "Delete Message",
escalate: "Escalate",
};
function MessageDetail({
m,
attachments,
@@ -585,6 +613,14 @@ function MessageDetail({
(m as { edit_history?: Array<{ old_content: string; edited_at: number }> })
.edit_history ?? [];
const editCount = (m as { edit_count?: number }).edit_count ?? 0;
const ref = parseMeta(m.metadata)?.reference;
const mentions = parseMeta(m.metadata)?.mentionedUsers ?? [];
const mentionRoles = parseMeta(m.metadata)?.mentionedRoles ?? [];
const sev = m.ai_severity;
const conf = m.ai_confidence;
const recAction = m.ai_recommended_action;
const score = m.ai_moderation_score;
return (
<div className="space-y-3 text-sm">
<div className="flex items-center gap-3">
@@ -615,11 +651,89 @@ function MessageDetail({
</div>
</div>
{/* Reply context chain */}
{ref && (ref.content || ref.repliedUsername) && (
<div className="rounded-[6px] border-l-2 border-signal/40 bg-signal/5 px-3 py-2 text-[11px]">
<span className="font-semibold text-ink-muted">
Replying to {ref.repliedUsername ?? "message"}
</span>
{ref.content && (
<div className="mt-0.5 line-clamp-2 text-ink-faint">
{ref.content}
</div>
)}
</div>
)}
<div className="hud-card p-3 text-xs text-ink-soft leading-relaxed">
{renderMessageContent(m.edited_content ?? m.content, m.metadata) ||
"(no text content)"}
</div>
{/* AI Analysis Metrics — severity, confidence, score, recommended action */}
{(sev || conf != null || score != null || recAction) && (
<div className="grid grid-cols-2 gap-2">
{sev && sev !== "none" && (
<div className="hud-card px-2.5 py-2">
<div className="eyebrow mb-1">Severity</div>
<div className="flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
<div
className={`h-full rounded-full transition-all ${
severityTone(sev) === "vermilion"
? "bg-vermilion"
: severityTone(sev) === "amber"
? "bg-amber"
: "bg-signal"
}`}
style={{ width: `${severityWidth(sev)}%` }}
/>
</div>
<Badge
tone={severityTone(sev)}
className="font-mono text-[9px]"
>
{sev.toUpperCase()}
</Badge>
</div>
</div>
)}
{conf != null && (
<div className="hud-card px-2.5 py-2">
<div className="eyebrow mb-1">Confidence</div>
<div className="font-mono text-xs font-semibold text-ink">
{Math.round(conf * 100)}%
</div>
</div>
)}
{score != null && (
<div className="hud-card px-2.5 py-2">
<div className="eyebrow mb-1">Risk Score</div>
<div className="font-mono text-xs font-semibold text-ink">
{score.toFixed(2)}
</div>
</div>
)}
{recAction && recAction !== "none" && (
<div className="hud-card px-2.5 py-2">
<div className="eyebrow mb-1">Recommended Action</div>
<Badge
tone={
recAction === "delete" || recAction === "escalate"
? "vermilion"
: recAction === "warn" || recAction === "review"
? "amber"
: "neutral"
}
className="font-mono text-[9px]"
>
{RECOMMEND_LABELS[recAction] ?? recAction}
</Badge>
</div>
)}
</div>
)}
{m.ai_analysis && (
<div>
<div className="eyebrow mb-1">AI heuristic reasoning</div>
@@ -629,18 +743,52 @@ function MessageDetail({
</div>
)}
{(flags.length > 0 || cats.length > 0) && (
<div className="flex flex-wrap gap-1.5">
{flags.map((f) => (
<Badge key={f} tone="vermilion">
{f}
</Badge>
))}
{cats.map((c) => (
<Badge key={c} tone="amber">
{c}
</Badge>
))}
{/* Flags / Categories / Mentions */}
{(flags.length > 0 ||
cats.length > 0 ||
mentions.length > 0 ||
mentionRoles.length > 0) && (
<div className="space-y-1.5">
{(flags.length > 0 || cats.length > 0) && (
<div className="flex flex-wrap gap-1.5">
{flags.map((f) => (
<Badge key={f} tone="vermilion">
{f}
</Badge>
))}
{cats.map((c) => (
<Badge key={c} tone="amber">
{c}
</Badge>
))}
</div>
)}
{mentions.length > 0 && (
<div className="flex flex-wrap gap-1">
<span className="text-[10px] text-ink-muted">Mentions:</span>
{mentions.map((u) => (
<span
key={u.id}
className="rounded bg-signal/10 px-1.5 py-0.5 text-[10px] text-signal"
>
@{u.username}
</span>
))}
</div>
)}
{mentionRoles.length > 0 && (
<div className="flex flex-wrap gap-1">
<span className="text-[10px] text-ink-muted">Roles:</span>
{mentionRoles.map((r) => (
<span
key={r.id}
className="rounded bg-amber/10 px-1.5 py-0.5 text-[10px] text-amber"
>
@{r.name}
</span>
))}
</div>
)}
</div>
)}
@@ -1,11 +1,20 @@
"use client";
import { AlertTriangle, CheckCircle2, Shield } from "lucide-react";
import {
AlertTriangle,
BarChart3,
CheckCircle2,
Globe,
Hash,
Shield,
TrendingUp,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import { LiveModerationFeed } from "@/components/LiveModerationFeed";
import { ModerationHeatmap } from "@/components/ModerationHeatmap";
import { GlassPanel } from "@/components/primitives";
import { ScamDomains } from "@/components/ScamDomains";
import {
ErrorState,
MetricTile,
@@ -14,10 +23,15 @@ import {
SkeletonMetricRow,
SkeletonPanel,
} from "@/components/shared";
import { TopChannels } from "@/components/TopChannels";
import {
useHourlyModeration,
useModerationActions,
useModerationCoverage,
useModerationStats,
useModerationTrends,
useTopFlaggedChannels,
useTopFlaggedDomains,
} from "@/hooks";
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
import { formatNumber } from "@/lib/format";
@@ -48,6 +62,10 @@ export function ModerationView({
initialActions,
);
const { data: hourly } = useHourlyModeration();
const { data: domains } = useTopFlaggedDomains(30);
const { data: channels } = useTopFlaggedChannels(30);
const { data: trends } = useModerationTrends(30);
const { data: coverage } = useModerationCoverage(30);
const ambient = useAmbient();
const [filterMode, setFilterMode] = useState<"all" | "flagged" | "clean">(
@@ -99,6 +117,26 @@ export function ModerationView({
Moderation Intelligence Console
</h1>
</div>
<div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted">
<span>EXECUTION:</span>
<span className="rounded bg-signal/15 px-2 py-0.5 font-medium text-signal border border-signal/30">
{Math.round((executed / total) * 100)}% RATE
</span>
{coverage && (
<>
<span>COVERAGE:</span>
<span
className={`rounded px-2 py-0.5 font-medium border ${
coverage.coverage_rate >= 90
? "bg-signal/15 text-signal border-signal/30"
: "bg-amber/15 text-amber border-amber/30"
}`}
>
{Math.round(coverage.coverage_rate)}%
</span>
</>
)}
</div>
<div className="flex items-center gap-1.5 rounded-[6px] border border-hairline bg-surface-2 p-0.5">
{(["all", "flagged", "clean"] as const).map((mode) => (
<button
@@ -118,7 +156,7 @@ export function ModerationView({
</div>
{/* Primary Metric Grid */}
<div className="mod-tile grid gap-3 sm:grid-cols-3">
<div className="mod-tile grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<MetricTile
label="Total Audited"
value={formatNumber(stats.total)}
@@ -139,6 +177,15 @@ export function ModerationView({
tone={failedRatio > 0.05 ? "vermilion" : "neutral"}
icon={<AlertTriangle className="size-4" />}
/>
{coverage && (
<MetricTile
label="Analysis Coverage"
value={`${Math.round(coverage.coverage_rate)}%`}
hint={`${formatNumber(coverage.completed)}/${formatNumber(coverage.total)} analyzed`}
tone={coverage.coverage_rate >= 90 ? "signal" : "amber"}
icon={<BarChart3 className="size-4" />}
/>
)}
</div>
{/* Live Moderation Stream */}
@@ -176,6 +223,157 @@ export function ModerationView({
</GlassPanel>
</div>
)}
{/* Domains, Channels, and Trends — 3-column row */}
<div className="grid gap-3 lg:grid-cols-3">
{/* Top Flagged Domains */}
{domains && domains.length > 0 && (
<div className="mod-tile">
<ScamDomains domains={domains} />
</div>
)}
{/* Top Flagged Channels */}
{channels && channels.length > 0 && (
<div className="mod-tile">
<TopChannels channels={channels} />
</div>
)}
{/* Moderation Trends */}
{trends && (
<div className="mod-tile">
<GlassPanel>
<SectionHeader eyebrow="trends" title="Category Breakdown" />
<div className="mt-3 space-y-3">
{/* Categories */}
{trends.categories.length > 0 && (
<div>
<div className="eyebrow mb-1.5">By Category</div>
<div className="flex flex-wrap gap-1.5">
{trends.categories.slice(0, 8).map((cat) => (
<span
key={cat.name}
className="inline-flex items-center gap-1 rounded-md border border-hairline bg-surface-2 px-2 py-1 text-[10px] font-medium text-ink-soft"
>
{cat.name}
<span className="font-mono text-ink-muted">
{cat.count}
</span>
</span>
))}
</div>
</div>
)}
{/* Severities */}
{trends.severities.length > 0 && (
<div>
<div className="eyebrow mb-1.5">By Severity</div>
<div className="space-y-1.5">
{trends.severities.map((sev) => {
const maxSev = Math.max(
...trends.severities.map((s) => s.count),
);
const pct = maxSev
? Math.round((sev.count / maxSev) * 100)
: 0;
const tone =
sev.level === "critical" || sev.level === "high"
? "bg-vermilion"
: sev.level === "medium"
? "bg-amber"
: "bg-signal";
return (
<div
key={sev.level}
className="flex items-center gap-2 text-[10px]"
>
<span className="w-16 shrink-0 font-mono text-ink-muted">
{sev.level}
</span>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
<div
className={`h-full rounded-full ${tone}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="w-8 text-right font-mono text-ink-faint">
{sev.count}
</span>
</div>
);
})}
</div>
</div>
)}
{/* Action Types */}
{trends.actions.length > 0 && (
<div>
<div className="eyebrow mb-1.5">By Action Type</div>
<div className="flex flex-wrap gap-1.5">
{trends.actions.map((act) => (
<span
key={act.type}
className="inline-flex items-center gap-1 rounded-md border border-vermilion/20 bg-vermilion/5 px-2 py-1 text-[10px] font-medium text-vermilion/80"
>
{act.type.replace(/_/g, " ")}
<span className="font-mono text-ink-muted">
{act.count}
</span>
</span>
))}
</div>
</div>
)}
</div>
</GlassPanel>
</div>
)}
</div>
{/* Coverage Progress Bar */}
{coverage && (
<div className="mod-tile">
<GlassPanel>
<SectionHeader
eyebrow="pipeline"
title="Analysis Coverage"
action={
<span className="font-mono text-[11px] text-ink-muted">
{formatNumber(coverage.completed)}/
{formatNumber(coverage.total)} (
{Math.round(coverage.coverage_rate)}%)
</span>
}
/>
<div className="mt-3">
<div className="h-2.5 w-full overflow-hidden rounded-full bg-surface-2">
<div
className={`h-full rounded-full transition-all ${
coverage.coverage_rate >= 90
? "bg-signal"
: coverage.coverage_rate >= 70
? "bg-amber"
: "bg-vermilion"
}`}
style={{
width: `${Math.min(100, coverage.coverage_rate)}%`,
}}
/>
</div>
<div className="mt-2 flex justify-between font-mono text-[10px] text-ink-muted">
<span>{formatNumber(coverage.completed)} analyzed</span>
<span>
{formatNumber(coverage.pending)} pending ·{" "}
{formatNumber(coverage.failed)} failed
</span>
</div>
</div>
</GlassPanel>
</div>
)}
</div>
</PageTransition>
);