Author SHA1 Message Date
asepharyana 8583bcdf17 fe(fe): enrich dashboard Top Reacted Messages
Show relative time, always-on username with channel context, and
collapse top emojis to two; add VIEW ALL toggle to reveal all 20
fetched reactions instead of the top 4.
2026-09-23 23:56:02 +07:00
asepharyana b12eb0a038 fe(fe): surface moderation explainability in live feed
Show confidence bar, flag chips, status dot, and error text per action
in the Live Stream Audit Log; drop stale media comment on SkeletonHero.
2026-09-23 23:46:22 +07:00
asepharyana b72423c64d fix(fe): remove remaining voice/recording/media leftovers from navigation, dashboard, and ws layer 2026-09-23 23:21:05 +07:00
asepharyana 6b34fc97ec Merge branch 'fix/remove-voice-recording' 2026-09-23 22:13:00 +07:00
dependabot[bot] 8ce8978755 build(deps-dev): bump tsx (#84)
Bumps the development group in /services/discord-gateway with 1 update: [tsx](https://github.com/privatenumber/tsx).


Updates `tsx` from 4.23.13 to 4.23.15
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.13...v4.23.15)

---
updated-dependencies:
- dependency-name: tsx
  dependency-version: 4.23.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-23 22:10:04 +07:00
mytheclipsebotreview[bot] 869cad88e4 Auto-merge PR #83
build(deps-dev): bump tsx from 4.23.13 to 4.23.15 in /services/backend in the development group
2026-09-23 14:41:14 +00:00
mytheclipsebotreview[bot] f136cf3f6b Auto-merge PR #82
build(deps): bump openai from 7.19.0 to 7.20.0 in /services/discord-gateway in the production group
2026-09-23 14:31:27 +00:00
dependabot[bot] cd3ee5b5d8 build(deps-dev): bump tsx in /services/backend in the development group
Bumps the development group in /services/backend with 1 update: [tsx](https://github.com/privatenumber/tsx).


Updates `tsx` from 4.23.13 to 4.23.15
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.13...v4.23.15)

---
updated-dependencies:
- dependency-name: tsx
  dependency-version: 4.23.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-23 14:24:58 +00:00
dependabot[bot] 93288afa0b build(deps): bump openai
Bumps the production group in /services/discord-gateway with 1 update: [openai](https://github.com/openai/openai-node).


Updates `openai` from 7.19.0 to 7.20.0
- [Release notes](https://github.com/openai/openai-node/releases)
- [Changelog](https://github.com/openai/openai-node/blob/main/CHANGELOG.md)
- [Commits](https://github.com/openai/openai-node/compare/v7.19.0...v7.20.0)

---
updated-dependencies:
- dependency-name: openai
  dependency-version: 7.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-23 14:24:44 +00:00
14 changed files with 155 additions and 186 deletions

No files matched your search

+1 -1
View File
@@ -48,7 +48,7 @@
"@types/pg": "^8.23.1",
"@types/ws": "^8.18.1",
"drizzle-kit": "^0.31.10",
"tsx": "^4.23.13",
"tsx": "^4.23.15",
"typescript": "^7.0.2",
"vitest": "latest"
}
+14 -14
View File
@@ -79,14 +79,14 @@ importers:
specifier: ^0.31.10
version: 0.31.10
tsx:
specifier: ^4.23.13
version: 4.23.13
specifier: ^4.23.15
version: 4.23.15
typescript:
specifier: ^7.0.2
version: 7.0.2
vitest:
specifier: latest
version: 5.0.1(@types/node@26.4.0)(vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13))
version: 5.0.1(@types/node@26.4.0)(vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.15))
packages:
@@ -2172,8 +2172,8 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tsx@4.23.13:
resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==}
tsx@4.23.15:
resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==}
engines: {node: '>=18.0.0'}
hasBin: true
@@ -3055,14 +3055,14 @@ snapshots:
'@typescript/typescript-win32-x64@7.0.2':
optional: true
'@vitest/mocker@5.0.1(vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13))':
'@vitest/mocker@5.0.1(vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.15))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.1
estree-walker: 3.0.3
magic-string: 1.2.3
optionalDependencies:
vite: 8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)
vite: 8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.15)
'@vitest/spy@5.0.1': {}
@@ -3230,7 +3230,7 @@ snapshots:
'@drizzle-team/brocli': 0.10.2
'@esbuild-kit/esm-loader': 2.6.5
esbuild: 0.25.12
tsx: 4.23.13
tsx: 4.23.15
drizzle-orm@0.45.2(@types/pg@8.23.1)(pg@8.23.0):
optionalDependencies:
@@ -3960,7 +3960,7 @@ snapshots:
tslib@2.8.1: {}
tsx@4.23.13:
tsx@4.23.15:
dependencies:
esbuild: 0.28.2
optionalDependencies:
@@ -3996,7 +3996,7 @@ snapshots:
util-deprecate@1.0.2:
optional: true
vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13):
vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.15):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
@@ -4007,12 +4007,12 @@ snapshots:
'@types/node': 26.4.0
esbuild: 0.28.2
fsevents: 2.3.3
tsx: 4.23.13
tsx: 4.23.15
vitest@5.0.1(@types/node@26.4.0)(vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)):
vitest@5.0.1(@types/node@26.4.0)(vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.15)):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.1(vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13))
'@vitest/mocker': 5.0.1(vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.15))
chai: 6.2.2
es-module-lexer: 2.3.2
expect-type: 1.4.0
@@ -4023,7 +4023,7 @@ snapshots:
tinybench: 6.1.4
tinyexec: 1.3.0
tinyglobby: 0.2.17
vite: 8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)
vite: 8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.15)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.4.0
@@ -6,7 +6,6 @@ import {
ChevronRight,
Hash,
MessageSquare,
Mic,
Shield,
ShieldAlert,
ShieldCheck,
@@ -14,7 +13,7 @@ import {
Zap,
} from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import { AreaActivity, RadialGauge } from "@/components/charts";
import { GlassPanel } from "@/components/primitives";
@@ -29,7 +28,7 @@ import {
} from "@/components/shared";
import { useActivity, useStats, useTopReactions } from "@/hooks";
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
import { formatNumber } from "@/lib/format";
import { formatNumber, formatRelativeTime } from "@/lib/format";
import type { DashboardStats } from "@/lib/types";
import { staggerDelay } from "@/lib/utils";
@@ -66,6 +65,7 @@ export function DashboardView({
} = useStats(initialStats);
const { data: activity } = useActivity(14, initialActivity);
const { data: reactions } = useTopReactions();
const [showAllReactions, setShowAllReactions] = useState(false);
const ambient = useAmbient();
const hudRef = useStaggerReveal<HTMLDivElement>(".linear-tile", {
@@ -160,12 +160,6 @@ export function DashboardView({
hint={`${formatNumber(s.total_users)} total profiles`}
icon={<Users className="size-4" />}
/>
<MetricTile
label="Voice Recordings"
value={formatNumber(s.total_voice_recordings)}
hint="Archived audio sessions"
icon={<Mic className="size-4" />}
/>
<MetricTile
label="Mod Queue"
value={formatNumber(mod.pending + mod.processing)}
@@ -366,44 +360,64 @@ export function DashboardView({
<SectionHeader
eyebrow="Engagement"
title="Top Reacted Messages"
action={
<button
type="button"
onClick={() => setShowAllReactions((v) => !v)}
className="font-mono text-[10px] text-ink-muted transition-colors hover:text-ink"
>
{showAllReactions
? "SHOW TOP 4"
: `VIEW ALL ${reactions.length}`}
</button>
}
/>
<div className="mt-3 space-y-2">
{reactions.slice(0, 4).map((r) => (
<div
key={r.message_id}
className="hud-card flex items-start justify-between gap-2 p-2.5"
>
<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}`}
{(showAllReactions ? reactions : reactions.slice(0, 4)).map(
(r) => (
<div
key={r.message_id}
className="hud-card flex items-start justify-between gap-2 p-2.5"
>
<div className="min-w-0 flex-1">
<span className="line-clamp-1 block text-xs text-ink-soft">
{r.content || "[Media/Attachment]"}
</span>
)}
<span className="mt-0.5 block font-mono text-[10px] text-ink-muted">
@{r.username || "unknown"}
{r.channel_name && ` in #${r.channel_name}`}
{r.created_at != null && (
<>
{" "}
·{" "}
<span suppressHydrationWarning>
{formatRelativeTime(r.created_at)}
</span>
</>
)}
</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.slice(0, 2).map((e) => (
<span
key={e.emoji}
className="text-[10px]"
title={`${e.emoji} ×${e.count}`}
>
{e.emoji}
</span>
))}
</div>
)}
</div>
</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>
)}
-12
View File
@@ -567,11 +567,6 @@
100% { background-position: -200% 0; }
}
@keyframes eq {
0%, 100% { transform: scaleY(0.35); }
50% { transform: scaleY(1); }
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
@@ -634,12 +629,6 @@
animation: shimmer 1.6s ease-in-out infinite;
}
/* Live equalizer bars (mini-player / recording player). */
.animate-eq {
transform-origin: bottom;
animation: eq 0.9s ease-in-out infinite;
}
/* Entrance for popovers, toasts, mini-player. */
.animate-fade-up { animation: fade-up 0.18s cubic-bezier(0.16, 1, 0.3, 1); }
.animate-toast-in { animation: toast-in 0.2s cubic-bezier(0.16, 1, 0.3, 1); }
@@ -694,7 +683,6 @@
.animate-spin-disc,
.animate-pulse-ring,
.animate-shimmer,
.animate-eq,
.animate-fade-up,
.animate-toast-in,
.animate-stagger,
+1 -2
View File
@@ -25,8 +25,7 @@ const bricolage = Bricolage_Grotesque({
export const metadata: Metadata = {
title: "GMW — Discord Moderation Console",
description:
"AI-powered Discord moderation, voice monitoring, and media control console",
description: "AI-powered Discord moderation console",
};
export const viewport: Viewport = {
@@ -49,6 +49,15 @@ function severityTone(
}
}
function confidenceTone(confidence?: number | null) {
if (confidence == null) return null;
return confidence >= 0.75
? "bg-signal"
: confidence >= 0.5
? "bg-amber"
: "bg-vermilion";
}
export function LiveModerationFeed({
actions,
}: {
@@ -115,8 +124,27 @@ export function LiveModerationFeed({
className="mod-feed-item hud-card animate-stagger flex items-start gap-3 p-3 transition-all"
style={staggerDelay(i)}
>
<span className="flex size-7 shrink-0 items-center justify-center rounded-[6px] border border-hairline bg-surface-2">
<span
className={`relative flex size-7 shrink-0 items-center justify-center rounded-[6px] border ${
a.status === "failed"
? "border-vermilion/40 bg-vermilion/10"
: a.status === "pending"
? "border-amber/40 bg-amber/10"
: "border-hairline bg-surface-2"
}`}
>
{actionIcon(a.action_type)}
{a.status && (
<span
className={`absolute -right-0.5 -top-0.5 size-2 rounded-full ring-2 ring-surface ${
a.status === "failed"
? "bg-vermilion"
: a.status === "pending"
? "bg-amber"
: "bg-signal"
}`}
/>
)}
</span>
<div className="min-w-0 flex-1">
@@ -151,6 +179,56 @@ export function LiveModerationFeed({
</p>
)}
{(a.confidence != null || a.flags?.length) && (
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
{a.confidence != null && (
<span
className="flex items-center gap-1.5"
title={`Model confidence ${Math.round(a.confidence * 100)}%`}
>
<span className="font-mono text-[9px] tracking-wider text-ink-faint uppercase">
conf
</span>
<span className="inline-flex h-1 w-14 overflow-hidden rounded-full bg-surface-2">
<span
className={`h-full rounded-full ${confidenceTone(a.confidence) ?? "bg-ink-faint"}`}
style={{
width: `${Math.min(100, Math.round(a.confidence * 100))}%`,
}}
/>
</span>
<span className="font-mono text-[9px] text-ink-faint">
{Math.round(a.confidence * 100)}%
</span>
</span>
)}
{a.flags && a.flags.length > 0 && (
<span className="flex flex-wrap items-center gap-1">
{a.flags.slice(0, 2).map((flag) => (
<Badge
key={flag}
tone={tone ?? "neutral"}
size="sm"
className="uppercase"
>
{flag}
</Badge>
))}
{a.flags.length > 2 && (
<span className="font-mono text-[9px] text-ink-faint">
+{a.flags.length - 2}
</span>
)}
</span>
)}
{a.error && (
<span className="font-mono text-[9px] text-vermilion/80">
{a.error}
</span>
)}
</div>
)}
<div className="mt-1.5 flex items-center gap-2 font-mono text-[10px] text-ink-faint">
<span>TARGET:</span>
<span className="text-ink-soft">
@@ -2,4 +2,3 @@ export { AreaActivity } from "./area-activity";
export { Donut } from "./donut";
export { RadialGauge } from "./radial-gauge";
export { Sparkline } from "./sparkline";
export { Equalizer } from "./waveform";
@@ -1,41 +0,0 @@
import { cn } from "@/lib/utils";
/** Live equalizer bars. `bars` are 0..1 levels. */
export function Equalizer({
bars,
color = "var(--color-signal)",
className,
}: {
bars: number[];
color?: string;
className?: string;
}) {
return (
<div className={cn("flex h-10 items-end gap-[3px]", className)}>
{bars.length === 0 ? (
<div className="flex w-full items-end gap-[3px]">
{Array.from({ length: 28 }).map((_, i) => (
<span
key={`eq-${i}`}
className="flex-1 rounded-full bg-hairline"
style={{ height: "12%" }}
/>
))}
</div>
) : (
bars.map((b, i) => (
<span
key={`bar-${i}`}
className="flex-1 rounded-full"
style={{
height: `${Math.max(6, b * 100)}%`,
background: color,
boxShadow: b > 0.05 ? `0 0 8px ${color}` : "none",
transition: "height 90ms linear",
}}
/>
))
)}
</div>
);
}
@@ -75,7 +75,7 @@ export function SkeletonRows({
);
}
/** Hero block placeholder (dashboard / media "now playing"). */
/** Hero block placeholder (dashboard headline). */
export function SkeletonHero({ className }: { className?: string }) {
return (
<GlassPanel glow className={cn("relative overflow-hidden", className)}>
-21
View File
@@ -1,10 +1,7 @@
import {
Headphones,
LayoutDashboard,
type LucideIcon,
MessageSquare,
Mic,
Music,
Search,
Shield,
Users,
@@ -40,24 +37,6 @@ export const navItems: NavItem[] = [
icon: Users,
matchPrefix: "/users",
},
{
href: "/voice",
label: "Voice",
icon: Mic,
matchPrefix: "/voice",
},
{
href: "/media",
label: "Media",
icon: Music,
matchPrefix: "/media",
},
{
href: "/recordings",
label: "Recordings",
icon: Headphones,
matchPrefix: "/recordings",
},
{
href: "/moderation",
label: "Moderation",
@@ -7,7 +7,6 @@ export interface DashboardStats {
total_clean: number;
total_warned: number;
total_error: number;
total_voice_recordings: number;
total_profiles: number;
today_messages: number;
today_flagged: number;
-4
View File
@@ -2,13 +2,9 @@ export type DashboardTab = "messages" | "live" | "dashboard";
export interface UiState {
selected_guild?: string | null;
selected_voice_guild?: string | null;
selected_voice_channel?: string | null;
selected_text_guild?: string | null;
selected_text_channel?: string | null;
active_tab?: DashboardTab | null;
is_listening?: boolean | null;
is_streaming?: boolean | null;
}
export interface ChatbotResponse {
+2 -37
View File
@@ -9,24 +9,20 @@ import {
useRef,
useState,
} from "react";
import { useSWRConfig } from "swr";
import { toast } from "@/components/primitives";
import { WsConnection } from "./connection";
import type { PcmChunk, WsEventHandler, WsEventType, WsStatus } from "./types";
import type { WsEventHandler, WsEventType, WsStatus } from "./types";
interface WsContextValue {
status: WsStatus;
connect: () => void;
disconnect: () => void;
sendText: (text: string) => void;
sendBinary: (data: ArrayBufferLike) => void;
/** Subscribe to a typed WS event. Returns unsubscribe function. */
on: <E extends WsEventType>(
eventType: E,
handler: WsEventHandler<E>,
) => () => void;
/** Subscribe to binary PCM events. Returns unsubscribe function. */
onPcm: (handler: (chunk: PcmChunk) => void) => () => void;
}
const WsContext = createContext<WsContextValue | null>(null);
@@ -40,7 +36,6 @@ export function WsProvider({
}) {
const connRef = useRef<WsConnection | null>(null);
const [status, setStatus] = useState<WsStatus>("disconnected");
const { mutate } = useSWRConfig();
// Tracks whether we've ever been connected — used to suppress the
// "reconnecting" toast on initial page load.
const wasConnected = useRef(false);
@@ -48,7 +43,6 @@ export function WsProvider({
// Event handler registry — Ref so listeners survive re-renders without reconnect
// Using unknown as internal store; typed at the subscribe interface
const handlersRef = useRef<Record<string, Set<(data: unknown) => void>>>({});
const pcmHandlersRef = useRef<Set<(chunk: PcmChunk) => void>>(new Set());
const handleJsonEvent = useCallback((json: string) => {
try {
@@ -66,17 +60,6 @@ export function WsProvider({
}
}, []);
const handleBinaryEvent = useCallback((buffer: ArrayBuffer) => {
if (buffer.byteLength < 4 || pcmHandlersRef.current.size === 0) return;
const view = new DataView(buffer);
const userIdHash = view.getUint32(0, true);
const samples = new Int16Array(buffer, 4);
const chunk: PcmChunk = { userIdHash, samples };
pcmHandlersRef.current.forEach((h) => h(chunk));
}, []);
useEffect(() => {
const conn = new WsConnection(url);
connRef.current = conn;
@@ -97,9 +80,6 @@ export function WsProvider({
wasConnected.current = false;
} else if (s === "connected") {
wasConnected.current = true;
// After reconnect, force-refetch voice status immediately so
// the UI converges faster instead of waiting up to 4s for SWR poll.
void mutate(["voice-status"]);
} else if (s === "error" && !wasConnected.current) {
toast({
title: "Connection error",
@@ -112,8 +92,6 @@ export function WsProvider({
const unsubEvent = conn.onEvent((event) => {
if (event.type === "text") {
handleJsonEvent(event.data);
} else {
handleBinaryEvent(event.data);
}
});
@@ -126,7 +104,7 @@ export function WsProvider({
unsubEvent();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url, handleBinaryEvent, handleJsonEvent, mutate]);
}, [url, handleJsonEvent]);
const subscribe = useCallback(
<E extends WsEventType>(_eventType: E, handler: WsEventHandler<E>) => {
@@ -144,23 +122,12 @@ export function WsProvider({
[],
);
const subscribePcm = useCallback((handler: (chunk: PcmChunk) => void) => {
pcmHandlersRef.current.add(handler);
return () => {
pcmHandlersRef.current.delete(handler);
};
}, []);
const connect = useCallback(() => connRef.current?.connect(), []);
const disconnect = useCallback(() => connRef.current?.disconnect(), []);
const sendText = useCallback(
(text: string) => connRef.current?.sendText(text),
[],
);
const sendBinary = useCallback(
(data: ArrayBufferLike) => connRef.current?.sendBinary(data),
[],
);
return (
<WsContext.Provider
@@ -169,9 +136,7 @@ export function WsProvider({
connect,
disconnect,
sendText,
sendBinary,
on: subscribe,
onPcm: subscribePcm,
}}
>
{children}
-7
View File
@@ -62,10 +62,3 @@ export type WsEventType = keyof WsEventMap;
export type WsEventHandler<E extends WsEventType = WsEventType> = (
data: WsEventMap[E],
) => void;
// ── Binary PCM ─────────────────────────────────────────────
export interface PcmChunk {
userIdHash: number;
samples: Int16Array;
}