feat(gmw): public features #2-#6 — live moderation feed, toxic topic trends, channel timeline, CSV export, activity heatmap
- Live Moderation Feed: gateway publishes discord:moderation:action (Redis) → backend WS emits moderation_action → public web shows realtime stream. - Toxic Topic Trends: backend moderation.trends aggregates categories/severity/action_type (read-only) → SVG bar + donut. - Channel Timeline: messages view gets Feed/Timeline toggle with date-grouped separators. - CSV Export: client-side downloadCsv for moderation actions (no backend write scope). - Activity Heatmap: backend messages.activity (per-hour volume by channel) → pure-SVG grid. User reputation deliberately excluded — no such feature exists in the codebase. All read-only / public-facing / fully automatic per project rules.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { GlassPanel } from "@/components/primitives";
|
||||
import { SectionHeader } from "@/components/shared";
|
||||
import type { MessageActivityBucket } from "@/lib/types";
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
function heatColor(t: number): string {
|
||||
// t in [0,1] → signal gradient (dark → bright).
|
||||
if (t <= 0) return "var(--color-hairline)";
|
||||
return `rgba(45, 212, 191, ${0.15 + 0.85 * t})`;
|
||||
}
|
||||
|
||||
export function ActivityHeatmap({
|
||||
buckets,
|
||||
}: {
|
||||
buckets: MessageActivityBucket[];
|
||||
}) {
|
||||
// Group by channel, find max count for normalization.
|
||||
const channels = Array.from(new Set(buckets.map((b) => b.channelId)));
|
||||
const byKey = new Map<string, number>();
|
||||
let max = 0;
|
||||
for (const b of buckets) {
|
||||
const k = `${b.channelId}:${b.hour}`;
|
||||
byKey.set(k, (byKey.get(k) ?? 0) + b.count);
|
||||
if (byKey.get(k)! > max) max = byKey.get(k)!;
|
||||
}
|
||||
|
||||
if (buckets.length === 0) {
|
||||
return (
|
||||
<GlassPanel className="lg:col-span-2">
|
||||
<SectionHeader eyebrow="insight" title="Activity Heatmap" />
|
||||
<p className="py-6 text-center text-xs text-ink-faint">
|
||||
No message activity recorded yet.
|
||||
</p>
|
||||
</GlassPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GlassPanel className="lg:col-span-5">
|
||||
<SectionHeader
|
||||
eyebrow="insight"
|
||||
title="Activity Heatmap"
|
||||
action={
|
||||
<span className="mono text-[0.65rem] text-ink-faint">
|
||||
{channels.length} channels · messages/hour
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[640px] space-y-1">
|
||||
{channels.map((ch) => (
|
||||
<div key={ch} className="flex items-center gap-2">
|
||||
<span className="mono w-24 shrink-0 truncate text-[0.6rem] text-ink-faint">
|
||||
{ch.slice(-6)}
|
||||
</span>
|
||||
<div className="flex flex-1 gap-0.5">
|
||||
{HOURS.map((h) => {
|
||||
const c = byKey.get(`${ch}:${h}`) ?? 0;
|
||||
const t = max > 0 ? c / max : 0;
|
||||
return (
|
||||
<div
|
||||
key={h}
|
||||
title={`${ch} · ${String(h).padStart(2, "0")}:00 — ${c} msgs`}
|
||||
className="h-4 flex-1 rounded-[2px]"
|
||||
style={{ background: heatColor(t) }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<span className="w-24 shrink-0" />
|
||||
<div className="flex flex-1 justify-between">
|
||||
{[0, 6, 12, 18, 23].map((h) => (
|
||||
<span key={h} className="mono text-[0.55rem] text-ink-faint">
|
||||
{String(h).padStart(2, "0")}h
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, GlassPanel } from "@/components/primitives";
|
||||
import { formatRelativeTime } from "@/lib/format";
|
||||
import type { ModerationAction } from "@/lib/types";
|
||||
|
||||
const ACTION_LABEL: Record<string, string> = {
|
||||
delete_message: "Deleted",
|
||||
timeout_user: "Timeout",
|
||||
warn_user: "Warned",
|
||||
reset_nickname: "Nickname reset",
|
||||
ban_user: "Banned",
|
||||
kick_user: "Kicked",
|
||||
notify_user: "Notified",
|
||||
none: "None",
|
||||
};
|
||||
|
||||
function severityTone(
|
||||
sev?: string | null,
|
||||
): "signal" | "amber" | "vermilion" | null {
|
||||
switch (sev) {
|
||||
case "critical":
|
||||
case "high":
|
||||
return "vermilion";
|
||||
case "medium":
|
||||
return "amber";
|
||||
case "low":
|
||||
return "signal";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function LiveModerationFeed({
|
||||
actions,
|
||||
}: {
|
||||
actions: ModerationAction[];
|
||||
}) {
|
||||
return (
|
||||
<GlassPanel className="flex max-h-[420px] flex-col">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex size-2.5">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex size-2.5 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
<h3 className="text-sm font-medium text-ink">Live Feed</h3>
|
||||
</div>
|
||||
<span className="text-xs text-ink-faint">{actions.length} recent</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{actions.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-xs text-ink-faint">
|
||||
Waiting for new moderation actions…
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-white/5">
|
||||
{actions.map((a, i) => {
|
||||
const tone = severityTone(a.severity);
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
className={`flex items-start gap-3 px-4 py-3 ${
|
||||
i === 0 ? "animate-[fadeIn_0.4s_ease-out]" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge tone={tone ?? "signal"} className="capitalize">
|
||||
{ACTION_LABEL[a.action_type] ?? a.action_type}
|
||||
</Badge>
|
||||
{a.severity && (
|
||||
<span className="text-xs text-ink-faint">
|
||||
{a.severity}
|
||||
</span>
|
||||
)}
|
||||
{a.categories?.length ? (
|
||||
<span className="truncate text-xs text-ink-soft">
|
||||
{a.categories.slice(0, 3).join(", ")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{a.reason && (
|
||||
<p className="mt-1 truncate text-xs text-ink-soft">
|
||||
“{a.reason}”
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-0.5 text-[11px] text-ink-faint">
|
||||
{a.username ?? a.user_id ?? "unknown"} ·{" "}
|
||||
{formatRelativeTime(a.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { Donut } from "@/components/charts/donut";
|
||||
import { GlassPanel } from "@/components/primitives";
|
||||
import { SectionHeader } from "@/components/shared";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import type { ModerationTrends } from "@/lib/types";
|
||||
|
||||
const SEVERITY_COLOR: Record<string, string> = {
|
||||
critical: "var(--color-vermilion)",
|
||||
high: "var(--color-vermilion)",
|
||||
medium: "var(--color-amber)",
|
||||
low: "var(--color-signal)",
|
||||
none: "var(--color-ink-faint)",
|
||||
};
|
||||
|
||||
function BarRow({
|
||||
label,
|
||||
count,
|
||||
max,
|
||||
color = "var(--color-signal)",
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
max: number;
|
||||
color?: string;
|
||||
}) {
|
||||
const pct = max > 0 ? Math.max(2, (count / max) * 100) : 0;
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="w-32 shrink-0 truncate text-ink-soft">{label}</span>
|
||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/5">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${pct}%`, background: color }}
|
||||
/>
|
||||
</div>
|
||||
<span className="mono w-10 shrink-0 text-right text-ink">
|
||||
{formatNumber(count)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopicTrends({ trends }: { trends: ModerationTrends }) {
|
||||
const maxCat = trends.categories.reduce((m, c) => Math.max(m, c.count), 0);
|
||||
const maxAct = trends.actions.reduce((m, a) => Math.max(m, a.count), 0);
|
||||
const totalSev = trends.severities.reduce((s, x) => s + x.count, 0);
|
||||
|
||||
const severitySegments = trends.severities.map((s) => ({
|
||||
value: s.count,
|
||||
color: SEVERITY_COLOR[s.level] ?? "var(--color-ink-faint)",
|
||||
label: s.level,
|
||||
}));
|
||||
|
||||
return (
|
||||
<GlassPanel className="lg:col-span-2">
|
||||
<SectionHeader eyebrow="insight" title="Toxic Topic Trends" />
|
||||
{trends.categories.length === 0 && trends.severities.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-ink-faint">
|
||||
No categorized actions in the last 30 days.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<p className="mb-2 text-xs uppercase tracking-wide text-ink-faint">
|
||||
Top flagged categories
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{trends.categories.slice(0, 10).map((c) => (
|
||||
<BarRow
|
||||
key={c.name}
|
||||
label={c.name}
|
||||
count={c.count}
|
||||
max={maxCat}
|
||||
/>
|
||||
))}
|
||||
{trends.categories.length === 0 && (
|
||||
<p className="text-xs text-ink-faint">
|
||||
No categories recorded.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="mb-2 text-xs uppercase tracking-wide text-ink-faint">
|
||||
Severity
|
||||
</p>
|
||||
{totalSev > 0 ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<Donut
|
||||
segments={severitySegments}
|
||||
centerLabel={formatNumber(totalSev)}
|
||||
centerSub="total"
|
||||
size={88}
|
||||
/>
|
||||
<div className="space-y-1 text-xs">
|
||||
{trends.severities.map((s) => (
|
||||
<div key={s.level} className="flex items-center gap-2">
|
||||
<span
|
||||
className="size-2.5 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
SEVERITY_COLOR[s.level] ??
|
||||
"var(--color-ink-faint)",
|
||||
}}
|
||||
/>
|
||||
<span className="capitalize text-ink-soft">
|
||||
{s.level}
|
||||
</span>
|
||||
<span className="mono ml-auto text-ink">
|
||||
{formatNumber(s.count)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-ink-faint">No severity data.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs uppercase tracking-wide text-ink-faint">
|
||||
Action types
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{trends.actions.slice(0, 6).map((a) => (
|
||||
<BarRow
|
||||
key={a.type}
|
||||
label={a.type.replace("_", " ")}
|
||||
count={a.count}
|
||||
max={maxAct}
|
||||
color="#8b5cf6"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user