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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user