feat(gmw): public features #7-14 — scam domains, top channels, hourly heatmap, category drill-down, coverage stats, channel culture glossary, term KB, edit history

ALSO fixes: dashboard.repository still JOINed dropped user_reputations table (listUsers/getUserDetail crash).
This commit is contained in:
asepharyana
2026-08-18 20:45:12 +07:00
parent 2a8f6d9062
commit 00e8d68ce5
38 changed files with 1542 additions and 25 deletions
@@ -0,0 +1,20 @@
import { PageTransition } from "@/components/shared";
import { getChannelCultures } from "@/lib/api/server";
import type { ChannelCultureRow } from "@/lib/types";
import { ChannelsView } from "./view";
export const dynamic = "force-dynamic";
export default async function ChannelsPage() {
let cultures: ChannelCultureRow[] | undefined;
try {
cultures = await getChannelCultures(100);
} catch {
cultures = undefined;
}
return (
<PageTransition>
<ChannelsView initialCultures={cultures} />
</PageTransition>
);
}
@@ -0,0 +1,23 @@
"use client";
import { ChannelCultureGlossary } from "@/components/ChannelCultureGlossary";
import { SkeletonPanel } from "@/components/shared";
import { useChannelCultures } from "@/hooks";
import type { ChannelCultureRow } from "@/lib/types";
export function ChannelsView({
initialCultures,
}: {
initialCultures?: ChannelCultureRow[];
}) {
const { data: cultures } = useChannelCultures(100, initialCultures);
return (
<div className="space-y-5">
{cultures ? (
<ChannelCultureGlossary cultures={cultures} />
) : (
<SkeletonPanel rows={6} />
)}
</div>
);
}
@@ -0,0 +1,20 @@
import { PageTransition } from "@/components/shared";
import { getGlossary } from "@/lib/api/server";
import type { GlossaryRow } from "@/lib/types";
import { GlossaryView } from "./view";
export const dynamic = "force-dynamic";
export default async function GlossaryPage() {
let terms: GlossaryRow[] | undefined;
try {
terms = await getGlossary(100);
} catch {
terms = undefined;
}
return (
<PageTransition>
<GlossaryView initialTerms={terms} />
</PageTransition>
);
}
@@ -0,0 +1,15 @@
"use client";
import { SkeletonPanel } from "@/components/shared";
import { TermGlossary } from "@/components/TermGlossary";
import { useGlossary } from "@/hooks";
import type { GlossaryRow } from "@/lib/types";
export function GlossaryView({
initialTerms,
}: {
initialTerms?: GlossaryRow[];
}) {
const { data: terms } = useGlossary(100, initialTerms);
return terms ? <TermGlossary terms={terms} /> : <SkeletonPanel rows={6} />;
}
@@ -1,5 +1,10 @@
import { PageTransition } from "@/components/shared";
import { getConfig, getGuilds, getMessages } from "@/lib/api/server";
import {
getConfig,
getGuilds,
getMessages,
getRecentEdits,
} from "@/lib/api/server";
import { MessagesView } from "./view";
export const dynamic = "force-dynamic";
@@ -11,12 +16,14 @@ export default async function MessagesPage() {
data: import("@/lib/types").MessageRecord[];
nextCursor: string | null;
} | null = null;
let initialEdits: import("@/lib/types").EditHistoryRow[] | undefined;
try {
[config, guilds] = await Promise.all([getConfig(), getGuilds()]);
const gid = config?.monitorGuildId;
if (gid) {
initialMessages = await getMessages(gid, undefined, 50);
}
initialEdits = await getRecentEdits(50);
} catch {
/* client hooks surface errors */
}
@@ -26,6 +33,7 @@ export default async function MessagesPage() {
initialGuilds={guilds}
initialGuildId={config?.monitorGuildId ?? null}
initialMessages={initialMessages}
initialEdits={initialEdits}
/>
</PageTransition>
);
@@ -14,6 +14,7 @@ import {
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityHeatmap } from "@/components/ActivityHeatmap";
import { useAmbient } from "@/components/ambient/ambient-context";
import { EditHistory } from "@/components/EditHistory";
import {
Avatar,
Badge,
@@ -37,6 +38,7 @@ import {
useMessagesHasMore,
useMessagesStream,
useMessagesWsSync,
useRecentEdits,
useSemanticSearch,
} from "@/hooks";
import { aiTone } from "@/lib/ai-status";
@@ -48,7 +50,12 @@ import {
renderMessageContent,
safeParseJsonArray,
} from "@/lib/format";
import type { AiStatus, Guild, MessageRecord } from "@/lib/types";
import type {
AiStatus,
EditHistoryRow,
Guild,
MessageRecord,
} from "@/lib/types";
import { staggerDelay } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
@@ -56,6 +63,7 @@ export function MessagesView({
initialGuilds,
initialGuildId,
initialMessages,
initialEdits,
}: {
initialGuilds?: Guild[];
initialGuildId?: string | null;
@@ -63,6 +71,7 @@ export function MessagesView({
data: MessageRecord[];
nextCursor: string | null;
} | null;
initialEdits?: EditHistoryRow[];
}) {
const ws = useWebSocket();
const [guildId, setGuildId] = useState<string | null>(
@@ -112,6 +121,7 @@ export function MessagesView({
query.trim().length >= 2 && semanticMode,
);
const activity = useMessageActivity(30);
const edits = useRecentEdits(50, undefined, initialEdits);
const detail = useMessageDetail(selected);
const ambient = useAmbient();
@@ -431,6 +441,8 @@ export function MessagesView({
{activity.data && activity.data.length > 0 && (
<ActivityHeatmap buckets={activity.data} />
)}
{edits.data && <EditHistory edits={edits.data} />}
</div>
);
}
@@ -15,14 +15,18 @@ import {
} from "lucide-react";
import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import { CategoryDrilldown } from "@/components/CategoryDrilldown";
import { CoverageTiles } from "@/components/CoverageTiles";
import { Donut } from "@/components/charts";
import { LiveModerationFeed } from "@/components/LiveModerationFeed";
import { ModerationHeatmap } from "@/components/ModerationHeatmap";
import {
Badge,
GlassPanel,
Select,
type SelectOption,
} from "@/components/primitives";
import { ScamDomains } from "@/components/ScamDomains";
import {
ErrorState,
MetricTile,
@@ -31,12 +35,18 @@ import {
SkeletonPanel,
SkeletonRows,
} from "@/components/shared";
import { TopChannels } from "@/components/TopChannels";
import { TopicTrends } from "@/components/TopicTrends";
import {
useHourlyModeration,
useLiveModeration,
useModerationActions,
useModerationByCategory,
useModerationCoverage,
useModerationStats,
useModerationTrends,
useTopFlaggedChannels,
useTopFlaggedDomains,
} from "@/hooks";
import { aiTone } from "@/lib/ai-status";
import { downloadCsv } from "@/lib/csv";
@@ -81,6 +91,13 @@ export function ModerationView({
);
const liveActions = useLiveModeration(initialActions ?? [], 50);
const { data: trends } = useModerationTrends(30);
const { data: domains } = useTopFlaggedDomains(30);
const { data: channels } = useTopFlaggedChannels(30);
const { data: hourly } = useHourlyModeration(30);
const { data: coverage } = useModerationCoverage(30);
const [drilldown, setDrilldown] = useState<string | null>(null);
const { data: categoryActions, isValidating: categoryLoading } =
useModerationByCategory(drilldown ? 30 : 0, drilldown);
const failedRate = stats ? stats.failed_rate * 100 : 0;
@@ -172,6 +189,44 @@ export function ModerationView({
<LiveModerationFeed actions={liveActions} />
</div>
{coverage ? (
<CoverageTiles coverage={coverage} />
) : (
<SkeletonPanel rows={3} className="lg:col-span-5" />
)}
<div className="lg:col-span-2">
{domains ? (
<ScamDomains domains={domains} />
) : (
<SkeletonPanel rows={6} />
)}
</div>
<div className="lg:col-span-2">
{hourly ? (
<ModerationHeatmap hours={hourly} />
) : (
<SkeletonPanel rows={6} />
)}
</div>
<div className="lg:col-span-1">
{channels ? (
<TopChannels channels={channels} />
) : (
<SkeletonPanel rows={6} />
)}
</div>
<div className="lg:col-span-3">
<CategoryDrilldown
trends={trends ?? { categories: [], severities: [], actions: [] }}
selected={drilldown}
actions={categoryActions ?? []}
loading={categoryLoading}
onSelect={setDrilldown}
/>
</div>
<GlassPanel className="lg:col-span-2">
<SectionHeader eyebrow="health" title="Breakdown" />
<div className="flex items-center gap-5">