feat(frontend): rebuild as SSR with server-authoritative shared state

Rombak total alur data frontend: dari static-export CSR (tiap browser
fetch sendiri + akumulasi state voice per-tab) jadi server-side rendering.

Frontend (Next.js):
- next.config: output export -> standalone; halaman jadi server components
- server data layer baru src/lib/api/server.ts (GMW_BACKEND_URL, no window)
- dashboard/media/messages/moderation/recordings/voice page -> RSC yang
  fetch backend di render-time, seed ke client view (SWR fallbackData)
- hook-hook utama terima initialData -> first paint data server, revalidate
  SWR setelahnya, tanpa spinner-blank-load
- messages: guild/channel/tab/selected dibaca dari URL di server, page awal
  di-fetch server-side

Shared realtime state (voice) server-authoritative:
- backend src/modules/voice/live-speaker.ts: agregat voice_active_user dari
  gateway jadi snapshot authoritatif (single source of truth semua browser)
- GET /api/voice/status kini include activeSpeakers
- WS initial states kirim voice_state snapshot saat connect (late join
  langsung dapat state yang sama, bukan daftar kosong)
- useSpeakers seed dari server snapshot + voice_state full-replace +
  voice_active_user delta upsert

Deploy:
- flake.nix: frontend package build SSR standalone (server.js wrapper,
  GMW_FRONTEND_PORT=4017); proxy nginx template proxy / -> Next server,
  /api + /ws tetap ke backend :4001
This commit is contained in:
asepharyana
2026-08-07 10:44:03 +07:00
parent aa440eda69
commit f20889868d
32 changed files with 1556 additions and 956 deletions
+25 -15
View File
@@ -223,7 +223,7 @@ WRAPPER
};
};
# ---- Frontend (Next.js static export) ----
# ---- Frontend (Next.js SSR standalone) ----
frontend = pkgs.stdenv.mkDerivation {
pname = "gmw-frontend";
version = "1.0.0";
@@ -233,29 +233,40 @@ WRAPPER
nativeBuildInputs = [ nodejs pnpm pkgs.gnumake pkgs.gcc pkgs.cacert ];
buildPhase = pnpmInstall + ''
echo "=== Building Next.js static export ==="
# Build args are provided as env vars
echo "=== Building Next.js SSR (standalone) ==="
export NEXT_TELEMETRY_DISABLED=1
export GMW_BACKEND_URL=http://127.0.0.1:4001
npx next build 2>&1
'';
installPhase = ''
mkdir -p $out/share/gmw-frontend
cp -r out $out/share/gmw-frontend/out 2>/dev/null || \
cp -r dist $out/share/gmw-frontend/dist 2>/dev/null || \
cp -r .next $out/share/gmw-frontend/.next 2>/dev/null || true
echo "=== Packaging standalone server ==="
mkdir -p $out/lib/gmw-frontend/standalone
# The standalone server bundles its own minimal node_modules but
# needs the build assets + public copied INSIDE its tree.
cp -r .next/standalone/. $out/lib/gmw-frontend/standalone/
mkdir -p $out/lib/gmw-frontend/standalone/.next
cp -r .next/static $out/lib/gmw-frontend/standalone/.next/static
cp -r public $out/lib/gmw-frontend/standalone/public 2>/dev/null || true
# Copy node_modules for standalone mode if it exists
cp -r node_modules $out/share/gmw-frontend/ 2>/dev/null || true
mkdir -p $out/bin
cat > $out/bin/gmw-frontend << WRAPPER
#!${pkgs.runtimeShell}
cd $out/lib/gmw-frontend/standalone
export PORT=''${GMW_FRONTEND_PORT:-4017}
export HOSTNAME=127.0.0.1
exec ${nodejs}/bin/node server.js
WRAPPER
chmod +x $out/bin/gmw-frontend
'';
meta = {
description = "GMW Frontend Next.js static dashboard";
description = "GMW Frontend Next.js SSR dashboard";
platforms = pkgs.lib.platforms.linux;
};
};
# ---- Proxy (nginx serving frontend) ----
# ---- Proxy (nginx: / -> Next SSR, /api + /ws -> backend) ----
proxy = pkgs.stdenv.mkDerivation {
pname = "gmw-proxy";
version = "1.0.0";
@@ -270,9 +281,8 @@ WRAPPER
mkdir -p $out/bin $out/etc $out/share
# Substitute placeholders in nginx template
sed \
-e "s|@NGINX_MIME@|${pkgs.nginx}/conf/mime.types|g" \
-e "s|@FRONTEND_ROOT@|${frontend}/share/gmw-frontend/out|g" \
sed -e "s|@NGINX_MIME@|${pkgs.nginx}/conf/mime.types|g" \
-e "s|@NEXT_PORT@|4017|g" \
${./infra/nix/nginx.conf.template} \
> $out/etc/nginx.conf
@@ -284,7 +294,7 @@ WRAPPER
'';
meta = {
description = "GMW Proxy nginx serving frontend";
description = "GMW Proxy nginx -> Next.js + backend";
platforms = pkgs.lib.platforms.linux;
};
};
+38 -8
View File
@@ -11,28 +11,44 @@ http {
'' close;
}
# Next.js standalone SSR server (backend-fetching on every render).
# Not for hand-editing: @NEXT_PORT@ is substituted at build time.
upstream gmw_next {
server 127.0.0.1:@NEXT_PORT@;
keepalive 16;
}
upstream gmw_backend {
server 127.0.0.1:4001;
keepalive 16;
}
server {
listen 4009;
server_name _;
# Use relative redirects (Location: /dashboard/) instead of absolute
# URLs that leak the internal listen port (4009) through Traefik.
# URLs that leak the internal listen port (4009) through the reverse proxy.
absolute_redirect off;
gzip on;
gzip_types text/plain text/css application/json application/javascript application/wasm image/svg+xml;
gzip_min_length 256;
# ── Backend REST ───────────────────────────────────────────────
location ^~ /api {
proxy_pass http://127.0.0.1:4001$uri$is_args$args;
proxy_pass http://gmw_backend$uri$is_args$args;
proxy_http_version 1.1;
proxy_set_header Connection ""; # keepalive to backend
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ── Backend WebSocket (realtime shared state + voice PCM) ──────
location ^~ /ws {
proxy_pass http://127.0.0.1:4001$uri$is_args$args;
proxy_pass http://gmw_backend$uri$is_args$args;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
@@ -45,16 +61,30 @@ http {
proxy_send_timeout 86400s;
}
location /assets/ {
root @FRONTEND_ROOT@;
# ── Next.js build assets — immutable, edge/shareable ───────────
location ^~ /_next/static/ {
proxy_pass http://gmw_next$uri$is_args$args;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
expires 1y;
add_header Cache-Control "public, immutable";
}
# ── Everything else → Next.js server (SSR) ──
location / {
root @FRONTEND_ROOT@;
index index.html;
try_files $uri $uri/ /index.html;
proxy_pass http://gmw_next$uri$is_args$args;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Next-Prefetch $http_x_next_prefetch;
proxy_buffering off;
proxy_read_timeout 30s;
}
}
}
@@ -0,0 +1,83 @@
/**
* Authoritative live-voice store.
*
* Single source of truth for who is present / speaking in voice. The backend
* WebSocket server is the one relay every frontend client connects to, so it
* is the correct place to aggregate the gateway's `voice_active_user` deltas
* into a shared snapshot. A late-joining browser must be able to see the same
* state as everyone else — this store makes that possible (seeded into the WS
* initial states and served via GET /api/voice/status).
*/
export interface LiveSpeaker {
userId: string;
username: string;
avatar?: string | null;
speaking: boolean;
/** Epoch ms of the most recent activity (start OR end of speech). */
lastActiveAt: number;
}
const speakers = new Map<string, LiveSpeaker>();
const MAX_SPEAKERS = 200;
/**
* Record a voice_active_user event. `speaking: true` upserts the speaker as
* active; `speaking: false` marks them inactive while keeping them for the
* activity timeline.
*/
/**
* recordSpeaker(data) — apply a `voice_active_user` event. `speaking: true`
* upserts the speaker as ACTIVE; `speaking: false` marks them inactive while
* keeping them for the activity timeline.
*/
export function recordSpeaker(data: {
userId: string;
username?: string;
avatar?: string | null;
speaking: boolean;
}): void {
const { userId, speaking } = data;
const existing = speakers.get(userId);
const speaker: LiveSpeaker = {
userId,
username: data.username ?? existing?.username ?? "Unknown",
avatar: data.avatar ?? existing?.avatar ?? null,
speaking,
lastActiveAt: Date.now(),
};
if (speakers.size >= MAX_SPEAKERS && !existing) {
// Drop the least-recently-active non-speaking speaker to stay bounded.
let oldestId: string | null = null;
let oldestTs = Infinity;
for (const [id, s] of speakers) {
if (!s.speaking && s.lastActiveAt < oldestTs) {
oldestTs = s.lastActiveAt;
oldestId = id;
}
}
if (oldestId) speakers.delete(oldestId);
else return;
}
speakers.set(userId, speaker);
}
/** All known speakers, most recently active first. */
export function getActiveSpeakers(): LiveSpeaker[] {
return [...speakers.values()].sort((a, b) => b.lastActiveAt - a.lastActiveAt);
}
/** Only speakers currently flagged as speaking. */
export function getSpeakingSpeakers(): LiveSpeaker[] {
return [...speakers.values()]
.filter((s) => s.speaking)
.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
}
/** Drop all tracked speakers (used on backend restart). */
export function resetLiveSpeakers(): void {
speakers.clear();
}
@@ -15,6 +15,10 @@ import {
VOICE_STATUS_KEY,
} from "../../shared/index.js";
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
import {
getActiveSpeakers,
type LiveSpeaker,
} from "./live-speaker.js";
const logger = createChildLogger("voice.service");
@@ -45,6 +49,12 @@ export interface VoiceStatus {
activeChannelId: string | null;
activeChannelName: string | null;
connections: GuildVoiceEntry[];
/**
* Authoritative shared voice snapshot — who is present / speaking right
* now, aggregated server-side from the gateway's `voice_active_user`
* deltas. All browsers converge on this same list.
*/
activeSpeakers: LiveSpeaker[];
}
export const DEFAULT_VOICE_STATUS: VoiceStatus = {
@@ -53,8 +63,16 @@ export const DEFAULT_VOICE_STATUS: VoiceStatus = {
activeChannelId: null,
activeChannelName: null,
connections: [],
activeSpeakers: [],
};
/** Attach the live speaker snapshot to any voice status payload. */
function withActiveSpeakers<T extends Partial<VoiceStatus>>(
status: T,
): T & { activeSpeakers: LiveSpeaker[] } {
return { ...status, activeSpeakers: getActiveSpeakers() };
}
/**
* Wraps tryCommandThenFallback with a cleaner signature for use within this module.
* Attempts a Redis command first; on failure, falls back to the provided function.
@@ -68,8 +86,10 @@ async function withFallback<T>(
}
function readVoiceStatusFallback(): Promise<VoiceStatus> {
return readRedisStatus(VOICE_STATUS_KEY).then(
(cached) => (cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS,
return readRedisStatus(VOICE_STATUS_KEY).then((cached) =>
withActiveSpeakers(
(cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS,
),
);
}
@@ -139,7 +159,9 @@ export async function getVoiceChannels(guildId: string): Promise<Channel[]> {
export async function getVoiceStatus(): Promise<VoiceStatus> {
logger.debug("getVoiceStatus called");
const cached = await readRedisStatus(VOICE_STATUS_KEY);
return (cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS;
return withActiveSpeakers(
(cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS,
);
}
/**
+22
View File
@@ -2,9 +2,11 @@ import Redis from "ioredis";
import { config } from "../shared/config/index.js";
import {
DISCORD_CHANNEL_TO_WS_EVENT,
DISCORD_VOICE_ACTIVE_USER,
DISCORD_VOICE_PCM,
} from "../shared/index.js";
import { createChildLogger } from "../shared/logger/index.js";
import { recordSpeaker } from "../modules/voice/live-speaker.js";
import { broadcastBinary, broadcastEvent } from "./broadcast.js";
const logger = createChildLogger("ws.redis-bridge");
@@ -62,6 +64,26 @@ function handleSubscriptionMessage(channel: string, message: string): void {
}
}
// Aggregate live-voice state authoritatively BEFORE broadcasting.
// Every browser hears the same `voice_active_user` deltas, so the backend
// can maintain the single shared snapshot for late-joining clients.
if (channel === DISCORD_VOICE_ACTIVE_USER) {
const speaker = data as {
userId?: string;
username?: string;
avatar?: string | null;
speaking?: boolean;
};
if (speaker?.userId) {
recordSpeaker({
userId: speaker.userId,
username: speaker.username,
avatar: speaker.avatar,
speaking: Boolean(speaker.speaking),
});
}
}
logger.debug({ channel, eventType }, "Broadcasting Redis event");
broadcastEvent(eventType, data);
}
+16
View File
@@ -66,6 +66,22 @@ async function sendInitialStates(ws: WebSocket): Promise<void> {
} catch (err) {
logger.warn({ err }, "Failed to send initial media_state");
}
// Send initial live-voice snapshot (shared authoritative state — a browser
// joining mid-call sees the same speakers as everyone else, not an empty DB).
try {
const { getActiveSpeakers } = await import(
"../modules/voice/live-speaker.js"
);
ws.send(
JSON.stringify({
type: "voice_state",
state: { activeSpeakers: getActiveSpeakers() },
}),
);
} catch (err) {
logger.warn({ err }, "Failed to send initial voice_state");
}
}
export function closeWebSocketServer(): void {
+30 -13
View File
@@ -3,26 +3,41 @@
Next.js 16 (App Router), React 19, TypeScript strict, Tailwind v4, shadcn/ui, base-ui.
Key points:
- **All pages** are `"use client"` — the dashboard is fully client-rendered
- **API client** at `src/lib/api/` — fetch-based, covers all 30+ backend endpoints
- **WebSocket** at `src/lib/ws/` — auto-reconnecting client with typed event subscriptions
- **Static export**: `output: "export"` in next.config.ts, served via nginx
- **Server-side rendered (SSR)** — `output: "standalone"` in next.config.ts; pages
are React Server Components that fetch initial data from the backend at
render-time, then hydrate interactive client components (no blank-spinner-first-load).
- **Server data layer** at `src/lib/api/server.ts` — server-only fetchers that
call the backend directly via `GMW_BACKEND_URL` (default `http://127.0.0.1:4001`).
Never import from a client component.
- **API client** at `src/lib/api/client.ts` — browser-side fetch for live ops,
same-origin through the reverse proxy.
- **WebSocket** at `src/lib/ws/` — auto-reconnecting client with typed event
subscriptions. Realtime state (voice, media, messages) stays client-side.
- **Shared realtime state is server-authoritative**: the backend aggregates the
gateway's `voice_active_user` deltas into a live speaker snapshot
(`GET /api/voice/status``activeSpeakers`, plus WS `voice_state` sent on
connect). Every browser converges on the same voice state; `useSpeakers`
seeds from the server snapshot instead of accumulating per-tab.
- **No authentication**: all endpoints are public
## Data flow (match these — do not invent endpoints)
```
Discord → discord-gateway → Redis pub/sub → backend (Express :4001) ←→ frontend
↑ REST /api/* (same-origin)
└ WS /ws (events + PCM binary)
Discord → discord-gateway → Redis pub/sub → backend (Express :4001) ←→ Next.js SSR
↑ REST /api/* (server: GMW_BACKEND_URL
└ WS /ws (events + PCM binary) 127.0.0.1:4001)
↑ browser WS (same-origin /ws)
```
- **Base URL**: API + WS default to same-origin. `gmw-proxy` nginx (:4009)
proxies `/api` and `/ws` to the backend on :4001. Public host:
`imphnen.asepharyana.my.id` (Caddy reverse proxy → :4009).
- Local dev overrides: `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_WS_URL`
(e.g. https://imphnen.asepharyana.my.id).
- **Never hardcode a host** in api/ws clients — same-origin or env override only.
- **Rendering**: `gmw-proxy` nginx (:4009) proxies `/` → Next standalone server
(:4017, `node .next/standalone/server.js`), and `/api` + `/ws` backend :4001.
Public host: `imphnen.asepharyana.my.id` (Caddy reverse proxy → :4009).
- **SSR seed pattern**: each `page.tsx` is a server component that fetches via
`src/lib/api/server.ts` and passes typed data to a `view.tsx` client
component; the hooks take `initialData` as SWR `fallbackData`.
- Local dev overrides: `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_WS_URL` for the
browser; `GMW_BACKEND_URL` for the server.
- **Never hardcode a host** in api/ws clients — same-origin/env or GMW_BACKEND_URL only.
## Backend response shapes that bite
@@ -34,3 +49,5 @@ Discord → discord-gateway → Redis pub/sub → backend (Express :4001) ←→
- Dashboard endpoints: `/api/dashboard/stats|users|channels` (+ `/:id` details).
- Channel/guild names live inside `message.metadata` JSON (`channel.channelName`),
not top-level.
- `GET /api/voice/status` now includes `activeSpeakers` (authoritative shared
snapshot from `src/modules/voice/live-speaker.ts` on the backend).
+4 -1
View File
@@ -2,7 +2,10 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactCompiler: true,
output: "export",
// SSR — pages render on the server; realtime stays client-side via WS.
// No static export: shared state (voice, media, moderation) is served
// from the backend at render-time on the server.
output: "standalone",
trailingSlash: true,
images: { unoptimized: true },
};
@@ -1,182 +1,25 @@
"use client";
/**
* Dashboard page — Server Component.
*
* Fetches y the initial stats + activity on the server (no client round-trip
* for first paint) and hands them to the hydrated client view. This is the
* "data on the server" leg of the reworked data flow.
*/
import { getActivity, getDashboardStats } from "@/lib/api/server";
import DashboardView from "./view";
import {
AlertCircle,
Clock,
Hash,
Heart,
Shield,
Sparkles,
Users,
} from "lucide-react";
import { useState } from "react";
import { ActivityChart } from "@/components/dashboard/activity-chart";
import { ChannelsSection } from "@/components/dashboard/channels-section";
import { HourlyActivityChart } from "@/components/dashboard/hourly-activity-chart";
import { ModerationDonut } from "@/components/dashboard/moderation-donut";
import { ReactionsSection } from "@/components/dashboard/reactions-section";
import { StatCard } from "@/components/dashboard/stat-card";
import { TopChannelsChart } from "@/components/dashboard/top-channels-chart";
import { UsersSection } from "@/components/dashboard/users-section";
import { SubNav } from "@/components/layout/sub-nav";
import { ErrorState, LoadingSkeleton } from "@/components/shared";
import { useActivity, useStats } from "@/hooks";
import { cn } from "@/lib/utils";
type DashboardTab = "stats" | "users" | "channels" | "reactions";
const DAY_RANGES = [7, 14, 30] as const;
const MODERATION_COLORS: Record<string, string> = {
Clean: "oklch(0.72 0.16 155)",
Flagged: "oklch(0.62 0.19 25)",
Warned: "oklch(0.78 0.15 80)",
Error: "oklch(0.55 0.02 245)",
};
export default function DashboardPage() {
const [tab, setTab] = useState<DashboardTab>("stats");
const [days, setDays] = useState<number>(14);
const { data: stats, isLoading, error, mutate: refetch } = useStats();
const { data: activity, isLoading: activityLoading } = useActivity(days);
const subNavTabs = [
{ id: "stats", label: "Stats", icon: <Hash className="size-3" /> },
{ id: "users", label: "Users", icon: <Users className="size-3" /> },
{ id: "channels", label: "Channels", icon: <Hash className="size-3" /> },
{ id: "reactions", label: "Reactions", icon: <Heart className="size-3" /> },
];
const moderationData = stats
? [
{
name: "Clean",
value: stats.total_clean,
color: MODERATION_COLORS.Clean,
},
{
name: "Flagged",
value: stats.total_flagged,
color: MODERATION_COLORS.Flagged,
},
{
name: "Warned",
value: stats.total_warned,
color: MODERATION_COLORS.Warned,
},
{
name: "Error",
value: stats.total_error,
color: MODERATION_COLORS.Error,
},
].filter((d) => d.value > 0)
: [];
export default async function DashboardPage() {
const [stats, activity] = await Promise.allSettled([
getDashboardStats(),
getActivity(14),
]);
return (
<div className="space-y-4 animate-fade-in-up">
<SubNav
tabs={subNavTabs}
activeTab={tab}
onTabChange={(t) => setTab(t as DashboardTab)}
<DashboardView
initialStats={stats.status === "fulfilled" ? stats.value : undefined}
initialActivity={
activity.status === "fulfilled" ? activity.value : undefined
}
/>
{tab === "stats" && (
<div className="space-y-4">
{error ? (
<ErrorState message={error.message} onRetry={refetch} />
) : isLoading || !stats ? (
<LoadingSkeleton count={6} height="h-28" columns={3} />
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard
label="Total Messages"
value={stats.total_messages}
icon={Hash}
/>
<StatCard
label="Today"
value={stats.today_messages}
icon={Clock}
/>
<StatCard
label="Users"
value={stats.total_users}
icon={Users}
/>
<StatCard
label="Active 24h"
value={stats.active_users_24h}
icon={Sparkles}
/>
<StatCard
label="Flagged"
value={stats.total_flagged}
icon={AlertCircle}
variant="danger"
/>
<StatCard
label="Clean"
value={stats.total_clean}
icon={Shield}
variant="success"
/>
</div>
<div className="flex items-center justify-end gap-1">
{DAY_RANGES.map((range) => (
<button
key={range}
type="button"
onClick={() => setDays(range)}
className={cn(
"px-2.5 py-1 text-[10px] font-medium uppercase tracking-wide rounded-md transition-colors",
days === range
? "bg-primary/20 text-primary"
: "text-text-secondary/60 hover:text-text-primary",
)}
>
{range}d
</button>
))}
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<div className="xl:col-span-2">
{activityLoading ? (
<LoadingSkeleton count={1} height="h-56" />
) : (
<ActivityChart data={activity?.daily} />
)}
</div>
<ModerationDonut data={moderationData} />
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<div className="xl:col-span-2">
{activityLoading ? (
<LoadingSkeleton count={1} height="h-40" />
) : (
<HourlyActivityChart data={activity?.hourly} />
)}
</div>
<TopChannelsChart
data={stats.top_channels.map((c) => ({
name: c.channel_name ?? c.channel_id,
count: c.message_count,
}))}
/>
</div>
</>
)}
</div>
)}
{tab === "users" && <UsersSection />}
{tab === "channels" && <ChannelsSection />}
{tab === "reactions" && <ReactionsSection />}
</div>
);
}
@@ -0,0 +1,188 @@
"use client";
import {
AlertCircle,
Clock,
Hash,
Heart,
Shield,
Sparkles,
Users,
} from "lucide-react";
import { useState } from "react";
import { ActivityChart } from "@/components/dashboard/activity-chart";
import { ChannelsSection } from "@/components/dashboard/channels-section";
import { HourlyActivityChart } from "@/components/dashboard/hourly-activity-chart";
import { ModerationDonut } from "@/components/dashboard/moderation-donut";
import { ReactionsSection } from "@/components/dashboard/reactions-section";
import { StatCard } from "@/components/dashboard/stat-card";
import { TopChannelsChart } from "@/components/dashboard/top-channels-chart";
import { UsersSection } from "@/components/dashboard/users-section";
import { SubNav } from "@/components/layout/sub-nav";
import { ErrorState, LoadingSkeleton } from "@/components/shared";
import { useActivity, useStats } from "@/hooks";
import type { DashboardActivity, DashboardStats } from "@/lib/types";
import { cn } from "@/lib/utils";
type DashboardTab = "stats" | "users" | "channels" | "reactions";
const DAY_RANGES = [7, 14, 30] as const;
const MODERATION_COLORS: Record<string, string> = {
Clean: "oklch(0.72 0.16 155)",
Flagged: "oklch(0.62 0.19 25)",
Warned: "oklch(0.78 0.15 80)",
Error: "oklch(0.55 0.02 245)",
};
/**
* Dashboard view — hydrated on the client but seeded with server-rendered
* initial data. SWR takes over for revalidation after first paint.
*/
export default function DashboardView({
initialStats,
initialActivity,
}: {
initialStats?: DashboardStats;
initialActivity?: DashboardActivity;
}) {
const [tab, setTab] = useState<DashboardTab>("stats");
const [days, setDays] = useState<number>(14);
const { data: stats, error, mutate: refetch } = useStats(initialStats);
const { data: activity } = useActivity(
days,
days === 14 ? initialActivity : undefined,
);
const subNavTabs = [
{ id: "stats", label: "Stats", icon: <Hash className="size-3" /> },
{ id: "users", label: "Users", icon: <Users className="size-3" /> },
{ id: "channels", label: "Channels", icon: <Hash className="size-3" /> },
{ id: "reactions", label: "Reactions", icon: <Heart className="size-3" /> },
];
const moderationData = stats
? [
{
name: "Clean",
value: stats.total_clean,
color: MODERATION_COLORS.Clean,
},
{
name: "Flagged",
value: stats.total_flagged,
color: MODERATION_COLORS.Flagged,
},
{
name: "Warned",
value: stats.total_warned,
color: MODERATION_COLORS.Warned,
},
{
name: "Error",
value: stats.total_error,
color: MODERATION_COLORS.Error,
},
].filter((d) => d.value > 0)
: [];
return (
<div className="space-y-4 animate-fade-in-up">
<SubNav
tabs={subNavTabs}
activeTab={tab}
onTabChange={(t) => setTab(t as DashboardTab)}
/>
{tab === "stats" && (
<div className="space-y-4">
{error ? (
<ErrorState message={error.message} onRetry={refetch} />
) : !stats ? (
<LoadingSkeleton count={6} height="h-28" columns={3} />
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard
label="Total Messages"
value={stats.total_messages}
icon={Hash}
/>
<StatCard
label="Today"
value={stats.today_messages}
icon={Clock}
/>
<StatCard
label="Users"
value={stats.total_users}
icon={Users}
/>
<StatCard
label="Active 24h"
value={stats.active_users_24h}
icon={Sparkles}
/>
<StatCard
label="Flagged"
value={stats.total_flagged}
icon={AlertCircle}
variant="danger"
/>
<StatCard
label="Clean"
value={stats.total_clean}
icon={Shield}
variant="success"
/>
</div>
<div className="flex items-center justify-end gap-1">
{DAY_RANGES.map((range) => (
<button
key={range}
type="button"
onClick={() => setDays(range)}
className={cn(
"px-2.5 py-1 text-[10px] font-medium uppercase tracking-wide rounded-md transition-colors",
days === range
? "bg-primary/20 text-primary"
: "text-text-secondary/60 hover:text-text-primary",
)}
>
{range}d
</button>
))}
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<div className="xl:col-span-2">
{activity && <ActivityChart data={activity.daily} />}
</div>
<ModerationDonut data={moderationData} />
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<div className="xl:col-span-2">
{activity && <HourlyActivityChart data={activity.hourly} />}
</div>
<TopChannelsChart
data={stats.top_channels.map((c) => ({
name: c.channel_name ?? c.channel_id,
count: c.message_count,
}))}
/>
</div>
</>
)}
</div>
)}
{tab === "users" && <UsersSection />}
{tab === "channels" && <ChannelsSection />}
{tab === "reactions" && <ReactionsSection />}
</div>
);
}
@@ -1,14 +1,13 @@
"use client";
/**
* Media page — Server Component. Seeds the music player with the shared media
* state fetched on the server (same state every user sees), then live-updates
* over WS.
*/
import { getMediaStatus } from "@/lib/api/server";
import MediaView from "./view";
import { MusicPlayer } from "@/components/media/music-player";
import { useWebSocket } from "@/lib/ws/context";
export default async function MediaPage() {
const status = await getMediaStatus().catch(() => undefined);
export default function MediaPage() {
const ws = useWebSocket();
return (
<div className="space-y-5 animate-fade-in-up">
<MusicPlayer ws={ws} />
</div>
);
return <MediaView initialStatus={status} />;
}
@@ -0,0 +1,19 @@
"use client";
import { MusicPlayer } from "@/components/media/music-player";
import type { MediaState } from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context";
export default function MediaView({
initialStatus,
}: {
initialStatus?: MediaState;
}) {
const ws = useWebSocket();
return (
<div className="space-y-5 animate-fade-in-up">
<MusicPlayer ws={ws} initialData={initialStatus} />
</div>
);
}
@@ -1,348 +1,41 @@
"use client";
/**
* Messages page — Server Component.
*
* Reads the URL (guild/channel/tab/selected) on the server and, when a guild
* is already selected, fetches the first message page server-side so the
* initial list is server-rendered, not a client round-trip.
*/
import { getMessages, type MessagePageResult } from "@/lib/api/server";
import MessagesView from "./view";
import { Flag, Image, Loader2, Search } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { GlassCard } from "@/components/glass/card";
import { GlassPanel } from "@/components/glass/panel";
import { SubNav } from "@/components/layout/sub-nav";
import { Lightbox } from "@/components/messages/lightbox";
import { extractFirstImage } from "@/components/messages/message-card";
import { MessageDetailView } from "@/components/messages/message-detail-view";
import { MessageList } from "@/components/messages/message-list";
import { SearchOverlay } from "@/components/messages/search-overlay";
import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/shared";
import { GuildSelector } from "@/components/shared/guild-selector";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
useImages,
useLoadMore,
useMessageDetail,
useMessages,
useMessagesHasMore,
useMessagesWsSync,
useReview,
useTextChannels,
} from "@/hooks";
import { renderMessageContent } from "@/lib/format";
import type { MessageRecord } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
type MessagesTab = "all" | "images" | "review";
export default function MessagesPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [guildId, setGuildId] = useState(searchParams.get("guild") || "");
const [selectedChannel, setSelectedChannel] = useState(
searchParams.get("channel") || "",
);
const [detailId, setDetailId] = useState<string | null>(
searchParams.get("selected"),
);
const [tab, setTab] = useState<MessagesTab>(
(searchParams.get("tab") as MessagesTab) || "all",
);
const [searchOpen, setSearchOpen] = useState(false);
const [lightbox, setLightbox] = useState<{
images: Array<{ src: string; alt?: string }>;
index: number;
} | null>(null);
const ws = useWebSocket();
const { data: channels = [] } = useTextChannels(guildId);
const {
data: messages,
isLoading,
error,
refetch,
} = useMessages(guildId, selectedChannel || undefined);
const { data: cursorData } = useMessagesHasMore(
guildId,
selectedChannel || undefined,
);
const loadMoreMut = useLoadMore();
const { data: images } = useImages(guildId);
const { data: reviews } = useReview(selectedChannel || undefined);
const {
message: detailMessage,
attachments: detailAttachments,
loading: detailLoading,
} = useMessageDetail(detailId);
useMessagesWsSync(ws, guildId);
// Sync state to URL
useEffect(() => {
const params = new URLSearchParams();
if (guildId) params.set("guild", guildId);
if (selectedChannel) params.set("channel", selectedChannel);
if (detailId) params.set("selected", detailId);
if (tab !== "all") params.set("tab", tab);
router.replace(`/messages?${params.toString()}`, { scroll: false });
}, [guildId, selectedChannel, detailId, tab, router]);
// Global Cmd+K search trigger
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setSearchOpen(true);
}
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, []);
const handleLoadMore = useCallback(() => {
if (!cursorData?.cursor || loadMoreMut.isPending) return;
loadMoreMut.mutate({
guildId,
channelId: selectedChannel || undefined,
cursor: cursorData.cursor,
});
}, [cursorData, loadMoreMut, guildId, selectedChannel]);
const handleGuildChange = useCallback((g: string) => {
setGuildId(g);
setSelectedChannel("");
setDetailId(null);
}, []);
const subNavTabs = [
{ id: "all", label: "All", icon: null },
{ id: "images", label: "Images", icon: <Image className="size-3" /> },
{ id: "review", label: "Review", icon: <Flag className="size-3" /> },
];
const currentMessages = messages ?? [];
return (
<div className="animate-fade-in-up space-y-4">
{/* ── Controls bar ── */}
<div className="flex items-center gap-3">
<GuildSelector value={guildId} onChange={handleGuildChange} />
{channels.length > 0 && (
<Select
value={selectedChannel}
onValueChange={(v) => setSelectedChannel(v ?? "")}
>
<SelectTrigger className="h-9 w-48">
<SelectValue placeholder="All channels" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All channels</SelectItem>
{channels.map((ch) => (
<SelectItem key={ch.id} value={ch.id}>
# {ch.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<button
type="button"
onClick={() => setSearchOpen(true)}
className="ml-auto flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs text-text-secondary/60 hover:text-text-primary glass hover:glass-elevated transition-all"
>
<Search className="size-3.5" />
Search
<span className="hidden font-mono text-[10px] text-text-secondary/30 sm:inline">
&#8984;K
</span>
</button>
</div>
{/* ── Sub navigation ── */}
<SubNav
tabs={subNavTabs}
activeTab={tab}
onTabChange={(t) => setTab(t as MessagesTab)}
/>
{/* ── Split pane ── */}
{error ? (
<ErrorState message={error.message} onRetry={refetch} />
) : isLoading ? (
<LoadingSkeleton count={6} height="h-20" />
) : (
<div className="flex gap-4">
{/* Left pane */}
<div
className={cn("space-y-2", detailId ? "w-1/2 lg:w-2/5" : "w-full")}
>
{tab === "all" && (
<MessageList
messages={currentMessages}
selectedId={detailId}
onSelect={setDetailId}
hasMore={cursorData?.hasMore}
onLoadMore={handleLoadMore}
isLoadingMore={loadMoreMut.isPending}
/>
)}
{tab === "images" && (
<ImageGrid items={images ?? []} onSelect={setDetailId} />
)}
{tab === "review" && (
<ReviewList items={reviews ?? []} onSelect={setDetailId} />
)}
</div>
{/* Right pane — message detail */}
{detailId && (
<div className="sticky top-16 hidden w-1/2 self-start md:block lg:w-3/5">
{detailLoading ? (
<GlassPanel
dense
className="flex items-center justify-center py-12"
>
<Loader2 className="size-5 animate-spin text-text-secondary/60" />
</GlassPanel>
) : detailMessage ? (
<div className="space-y-3">
<button
type="button"
onClick={() => setDetailId(null)}
className="text-xs text-text-secondary/60 hover:text-text-primary transition-colors"
>
&larr; Back to list
</button>
<MessageDetailView
message={detailMessage}
attachments={detailAttachments}
onImageClick={(index) => {
const imgs = (detailAttachments ?? [])
.filter((a) => a.type?.startsWith("image/"))
.map((a) => ({
src: a.uploaded_url || a.discord_url,
alt: a.filename,
}));
if (imgs.length > 0) {
setLightbox({ images: imgs, index });
}
}}
/>
</div>
) : null}
</div>
)}
</div>
)}
{/* ── Search overlay ── */}
<SearchOverlay
open={searchOpen}
onClose={() => setSearchOpen(false)}
onSelect={(id) => {
setDetailId(id);
setTab("all");
}}
/>
{/* ── Lightbox ── */}
{lightbox && (
<Lightbox
images={lightbox.images}
initialIndex={lightbox.index}
open
onClose={() => setLightbox(null)}
/>
)}
</div>
);
}
// ── Inline ImageGrid (glass-styled) ────────────────
function ImageGrid({
items,
onSelect,
export default async function MessagesPage({
searchParams,
}: {
items: MessageRecord[];
onSelect: (id: string) => void;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
return (
<div className="grid grid-cols-3 gap-2">
{items.map((item) => {
const imgUrl = extractFirstImage(item.metadata);
return (
<button
key={item.id}
type="button"
onClick={() => onSelect(item.id)}
className="glass overflow-hidden rounded-lg transition-transform hover:scale-[1.02]"
>
{imgUrl ? (
<img
src={imgUrl}
alt=""
className="h-24 w-full object-cover"
loading="lazy"
/>
) : (
<div className="flex h-24 w-full items-center justify-center text-xs text-text-secondary/40">
No image
</div>
)}
</button>
);
})}
{items.length === 0 && (
<EmptyState
icon={Image}
title="No images"
description="Messages with image attachments will show up here."
className="col-span-3"
/>
)}
</div>
const sp = await searchParams;
const guild = typeof sp.guild === "string" ? sp.guild : "";
const channel = typeof sp.channel === "string" ? sp.channel : "";
const selected = typeof sp.selected === "string" ? sp.selected : null;
const tab =
typeof sp.tab === "string" && ["all", "images", "review"].includes(sp.tab)
? (sp.tab as "all" | "images" | "review")
: "all";
let initialPage: MessagePageResult | undefined;
if (guild) {
initialPage = await getMessages(guild, channel || undefined).catch(
() => undefined,
);
}
// ── Inline ReviewList (glass-styled) ────────────────
function ReviewList({
items,
onSelect,
}: {
items: MessageRecord[];
onSelect: (id: string) => void;
}) {
return (
<div className="space-y-2">
{items.map((item) => (
<GlassCard
key={item.id}
variant="danger"
className="cursor-pointer p-3"
onClick={() => onSelect(item.id)}
>
<div className="flex items-start gap-2">
<Flag className="mt-0.5 size-3.5 shrink-0 text-accent-purple" />
<div className="min-w-0 flex-1">
<p className="line-clamp-2 text-xs text-text-secondary">
{renderMessageContent(item.content, item.metadata) || item.id}
</p>
</div>
</div>
</GlassCard>
))}
{items.length === 0 && (
<EmptyState
icon={Flag}
title="No flagged messages"
description="Messages flagged by AI moderation will appear here for review."
<MessagesView
initialGuild={guild}
initialChannel={channel}
initialDetailId={selected}
initialTab={tab}
initialMessagePage={initialPage}
/>
)}
</div>
);
}
@@ -0,0 +1,365 @@
"use client";
import { Flag, Image, Loader2, Search } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { GlassCard } from "@/components/glass/card";
import { GlassPanel } from "@/components/glass/panel";
import { SubNav } from "@/components/layout/sub-nav";
import { Lightbox } from "@/components/messages/lightbox";
import { extractFirstImage } from "@/components/messages/message-card";
import { MessageDetailView } from "@/components/messages/message-detail-view";
import { MessageList } from "@/components/messages/message-list";
import { SearchOverlay } from "@/components/messages/search-overlay";
import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/shared";
import { GuildSelector } from "@/components/shared/guild-selector";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
useImages,
useLoadMore,
useMessageDetail,
useMessages,
useMessagesHasMore,
useMessagesWsSync,
useReview,
useTextChannels,
} from "@/hooks";
import { renderMessageContent } from "@/lib/format";
import type { MessageRecord } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
type MessagesTab = "all" | "images" | "review";
interface MessagesViewProps {
initialGuild?: string;
initialChannel?: string;
initialDetailId?: string | null;
initialTab?: MessagesTab;
initialMessagePage?: { data: MessageRecord[]; nextCursor: string | null };
}
/**
* Messages view — hydrated on the client. Initial guild/channel/detail/tab
* come from the URL (server-read on first SSR), and the first message page is
* seeded from the server when a guild is already selected.
*/
export default function MessagesView({
initialGuild = "",
initialChannel = "",
initialDetailId = null,
initialTab = "all",
initialMessagePage,
}: MessagesViewProps) {
const router = useRouter();
const [guildId, setGuildId] = useState(initialGuild);
const [selectedChannel, setSelectedChannel] = useState(initialChannel);
const [detailId, setDetailId] = useState<string | null>(initialDetailId);
const [tab, setTab] = useState<MessagesTab>(initialTab);
const [searchOpen, setSearchOpen] = useState(false);
const [lightbox, setLightbox] = useState<{
images: Array<{ src: string; alt?: string }>;
index: number;
} | null>(null);
const ws = useWebSocket();
const { data: channels = [] } = useTextChannels(guildId);
const {
data: messages,
error,
refetch,
} = useMessages(
guildId,
selectedChannel || undefined,
guildId === initialGuild && selectedChannel === initialChannel
? initialMessagePage
: undefined,
);
const { data: cursorData } = useMessagesHasMore(
guildId,
selectedChannel || undefined,
);
const loadMoreMut = useLoadMore();
const { data: images } = useImages(guildId);
const { data: reviews } = useReview(selectedChannel || undefined);
const {
message: detailMessage,
attachments: detailAttachments,
loading: detailLoading,
} = useMessageDetail(detailId);
useMessagesWsSync(ws, guildId);
// Sync state to URL
useEffect(() => {
const params = new URLSearchParams();
if (guildId) params.set("guild", guildId);
if (selectedChannel) params.set("channel", selectedChannel);
if (detailId) params.set("selected", detailId);
if (tab !== "all") params.set("tab", tab);
router.replace(`/messages?${params.toString()}`, { scroll: false });
}, [guildId, selectedChannel, detailId, tab, router]);
// Global Cmd+K search trigger
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setSearchOpen(true);
}
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, []);
const handleLoadMore = useCallback(() => {
if (!cursorData?.cursor || loadMoreMut.isPending) return;
loadMoreMut.mutate({
guildId,
channelId: selectedChannel || undefined,
cursor: cursorData.cursor,
});
}, [cursorData, loadMoreMut, guildId, selectedChannel]);
const handleGuildChange = useCallback((g: string) => {
setGuildId(g);
setSelectedChannel("");
setDetailId(null);
}, []);
const subNavTabs = [
{ id: "all", label: "All", icon: null },
{ id: "images", label: "Images", icon: <Image className="size-3" /> },
{ id: "review", label: "Review", icon: <Flag className="size-3" /> },
];
const currentMessages = messages ?? [];
return (
<div className="animate-fade-in-up space-y-4">
{/* ── Controls bar ── */}
<div className="flex items-center gap-3">
<GuildSelector value={guildId} onChange={handleGuildChange} />
{channels.length > 0 && (
<Select
value={selectedChannel}
onValueChange={(v) => setSelectedChannel(v ?? "")}
>
<SelectTrigger className="h-9 w-48">
<SelectValue placeholder="All channels" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All channels</SelectItem>
{channels.map((ch) => (
<SelectItem key={ch.id} value={ch.id}>
# {ch.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<button
type="button"
onClick={() => setSearchOpen(true)}
className="ml-auto flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs text-text-secondary/60 hover:text-text-primary glass hover:glass-elevated transition-all"
>
<Search className="size-3.5" />
Search
<span className="hidden font-mono text-[10px] text-text-secondary/30 sm:inline">
&#8984;K
</span>
</button>
</div>
{/* ── Sub navigation ── */}
<SubNav
tabs={subNavTabs}
activeTab={tab}
onTabChange={(t) => setTab(t as MessagesTab)}
/>
{/* ── Split pane ── */}
{error ? (
<ErrorState message={error.message} onRetry={refetch} />
) : !messages ? (
<LoadingSkeleton count={6} height="h-20" />
) : (
<div className="flex gap-4">
{/* Left pane */}
<div
className={cn("space-y-2", detailId ? "w-1/2 lg:w-2/5" : "w-full")}
>
{tab === "all" && (
<MessageList
messages={currentMessages}
selectedId={detailId}
onSelect={setDetailId}
hasMore={cursorData?.hasMore}
onLoadMore={handleLoadMore}
isLoadingMore={loadMoreMut.isPending}
/>
)}
{tab === "images" && (
<ImageGrid items={images ?? []} onSelect={setDetailId} />
)}
{tab === "review" && (
<ReviewList items={reviews ?? []} onSelect={setDetailId} />
)}
</div>
{/* Right pane — message detail */}
{detailId && (
<div className="sticky top-16 hidden w-1/2 self-start md:block lg:w-3/5">
{detailLoading ? (
<GlassPanel
dense
className="flex items-center justify-center py-12"
>
<Loader2 className="size-5 animate-spin text-text-secondary/60" />
</GlassPanel>
) : detailMessage ? (
<div className="space-y-3">
<button
type="button"
onClick={() => setDetailId(null)}
className="text-xs text-text-secondary/60 hover:text-text-primary transition-colors"
>
&larr; Back to list
</button>
<MessageDetailView
message={detailMessage}
attachments={detailAttachments}
onImageClick={(index) => {
const imgs = (detailAttachments ?? [])
.filter((a) => a.type?.startsWith("image/"))
.map((a) => ({
src: a.uploaded_url || a.discord_url,
alt: a.filename,
}));
if (imgs.length > 0) {
setLightbox({ images: imgs, index });
}
}}
/>
</div>
) : null}
</div>
)}
</div>
)}
{/* ── Search overlay ── */}
<SearchOverlay
open={searchOpen}
onClose={() => setSearchOpen(false)}
onSelect={(id) => {
setDetailId(id);
setTab("all");
}}
/>
{/* ── Lightbox ── */}
{lightbox && (
<Lightbox
images={lightbox.images}
initialIndex={lightbox.index}
open
onClose={() => setLightbox(null)}
/>
)}
</div>
);
}
// ── Inline ImageGrid (glass-styled) ────────────────
function ImageGrid({
items,
onSelect,
}: {
items: MessageRecord[];
onSelect: (id: string) => void;
}) {
return (
<div className="grid grid-cols-3 gap-2">
{items.map((item) => {
const imgUrl = extractFirstImage(item.metadata);
return (
<button
key={item.id}
type="button"
onClick={() => onSelect(item.id)}
className="glass overflow-hidden rounded-lg transition-transform hover:scale-[1.02]"
>
{imgUrl ? (
<img
src={imgUrl}
alt=""
className="h-24 w-full object-cover"
loading="lazy"
/>
) : (
<div className="flex h-24 w-full items-center justify-center text-xs text-text-secondary/40">
No image
</div>
)}
</button>
);
})}
{items.length === 0 && (
<EmptyState
icon={Image}
title="No images"
description="Messages with image attachments will show up here."
className="col-span-3"
/>
)}
</div>
);
}
// ── Inline ReviewList (glass-styled) ────────────────
function ReviewList({
items,
onSelect,
}: {
items: MessageRecord[];
onSelect: (id: string) => void;
}) {
return (
<div className="space-y-2">
{items.map((item) => (
<GlassCard
key={item.id}
variant="danger"
className="cursor-pointer p-3"
onClick={() => onSelect(item.id)}
>
<div className="flex items-start gap-2">
<Flag className="mt-0.5 size-3.5 shrink-0 text-accent-purple" />
<div className="min-w-0 flex-1">
<p className="line-clamp-2 text-xs text-text-secondary">
{renderMessageContent(item.content, item.metadata) || item.id}
</p>
</div>
</div>
</GlassCard>
))}
{items.length === 0 && (
<EmptyState
icon={Flag}
title="No flagged messages"
description="Messages flagged by AI moderation will appear here for review."
/>
)}
</div>
);
}
@@ -1,11 +1,24 @@
"use client";
/**
* Moderation page — Server Component. Seeds summary + action log from
* server-fetched moderation state (shared across all users).
*/
import { ModerationSection } from "@/components/moderation/moderation-section";
import { getModerationActions, getModerationStats } from "@/lib/api/server";
export default async function ModerationPage() {
const [stats, actions] = await Promise.allSettled([
getModerationStats(),
getModerationActions(100),
]);
export default function ModerationPage() {
return (
<div className="space-y-4 animate-fade-in-up">
<ModerationSection />
<ModerationSection
initialStats={stats.status === "fulfilled" ? stats.value : undefined}
initialActions={
actions.status === "fulfilled" ? actions.value : undefined
}
/>
</div>
);
}
@@ -1,186 +1,12 @@
"use client";
/**
* Recordings page — Server Component. Seeds the library from server-fetched
* recordings; live `voice_recording_uploaded` events keep it fresh over WS.
*/
import { getRecordings } from "@/lib/api/server";
import RecordingsView from "./view";
import { Clock, Database, Mic, Users } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { StatCard } from "@/components/dashboard/stat-card";
import { SubNav } from "@/components/layout/sub-nav";
import { RecordingCard } from "@/components/recordings/recording-card";
import { RecordingPlayer } from "@/components/recordings/recording-player";
import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/shared";
import { useRecordings, useRecordingsWsSync } from "@/hooks";
import { formatBytes } from "@/lib/format";
import type { VoiceRecording } from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context";
export default async function RecordingsPage() {
const data = await getRecordings(50).catch(() => undefined);
type RecordingsTab = "library" | "stats";
export default function RecordingsPage() {
const {
data: recordings,
isLoading,
error,
mutate: refetch,
} = useRecordings();
const [playingId, setPlayingId] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isLoadingAudio, setIsLoadingAudio] = useState(false);
const [tab, setTab] = useState<RecordingsTab>("library");
const ws = useWebSocket();
const audioRef = useRef<HTMLAudioElement | null>(null);
// Live-update the library when the gateway publishes voice_recording_uploaded
useRecordingsWsSync(ws);
const currentTrack =
playingId && recordings
? recordings.find((r: VoiceRecording) => r.id === playingId)
: null;
const togglePlay = (id: string) => {
if (playingId !== id) {
setPlayingId(id); // RecordingPlayer picks up the new url + autoplays
} else {
const audio = audioRef.current;
if (!audio) return;
if (audio.paused) audio.play().catch(() => {});
else audio.pause();
}
};
const stats = useMemo(() => {
const list = recordings ?? [];
const totalSize = list.reduce((sum, r) => sum + (r.size_bytes ?? 0), 0);
const byUser = new Map<
string,
{ name: string; count: number; size: number }
>();
for (const rec of list) {
const key = rec.user_id ?? rec.username;
const cur = byUser.get(key) ?? { name: rec.username, count: 0, size: 0 };
cur.count += 1;
cur.size += rec.size_bytes ?? 0;
byUser.set(key, cur);
}
const topUsers = [...byUser.values()]
.sort((a, b) => b.count - a.count)
.slice(0, 8);
return {
total: list.length,
totalSize,
uniqueUsers: byUser.size,
topUsers,
};
}, [recordings]);
return (
<div className="space-y-4 animate-fade-in-up">
<SubNav
tabs={[
{ id: "library", label: "Library", icon: undefined },
{ id: "stats", label: "Stats", icon: undefined },
]}
activeTab={tab}
onTabChange={(t) => setTab(t as RecordingsTab)}
/>
{tab === "library" &&
(error ? (
<ErrorState message={error.message} onRetry={refetch} />
) : isLoading ? (
<LoadingSkeleton count={4} height="h-28" />
) : (
<div className="space-y-2">
{(recordings ?? []).map((rec: VoiceRecording) => (
<RecordingCard
key={rec.id}
recording={rec}
active={playingId === rec.id}
playing={playingId === rec.id && isPlaying}
loading={playingId === rec.id && isLoadingAudio}
onTogglePlay={togglePlay}
/>
))}
{(recordings ?? []).length === 0 && (
<EmptyState
icon={Mic}
title="No recordings yet"
description="Voice recordings will appear here once members speak in a monitored voice channel."
/>
)}
</div>
))}
{tab === "stats" &&
(isLoading ? (
<LoadingSkeleton count={4} height="h-28" columns={3} />
) : stats.total === 0 ? (
<EmptyState
icon={Clock}
title="No recording stats yet"
description="Recordings are captured from monitored voice channels."
/>
) : (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<StatCard
label="Total Recordings"
value={stats.total}
icon={Mic}
/>
<StatCard
label="Total Size"
value={stats.totalSize}
icon={Database}
formatter={(v) => formatBytes(v)}
/>
<StatCard
label="Unique Speakers"
value={stats.uniqueUsers}
icon={Users}
/>
</div>
{stats.topUsers.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs text-text-secondary font-medium uppercase tracking-wide">
Top Speakers
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{stats.topUsers.map((u) => (
<div
key={u.name}
className="flex items-center gap-3 rounded-lg border border-border/40 bg-card/40 px-3 py-2"
>
<span className="flex size-7 items-center justify-center rounded-md bg-primary/10 font-mono text-xs text-primary">
{u.count}
</span>
<span className="flex-1 min-w-0 truncate text-sm text-text-primary">
{u.name}
</span>
<span className="text-[10px] font-mono text-text-secondary/50">
{formatBytes(u.size)}
</span>
</div>
))}
</div>
</div>
)}
</div>
))}
<RecordingPlayer
url={currentTrack?.download_url ?? undefined}
filename={currentTrack?.filename ?? undefined}
playing={isPlaying}
loading={isLoadingAudio}
audioRef={audioRef}
onToggle={() => togglePlay(playingId!)}
onStateChange={(s) => {
setIsPlaying(s.playing);
setIsLoadingAudio(s.loading);
}}
onClose={() => setPlayingId(null)}
/>
</div>
);
return <RecordingsView initialRecordings={data?.items} />;
}
@@ -0,0 +1,189 @@
"use client";
import { Clock, Database, Mic, Users } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { StatCard } from "@/components/dashboard/stat-card";
import { SubNav } from "@/components/layout/sub-nav";
import { RecordingCard } from "@/components/recordings/recording-card";
import { RecordingPlayer } from "@/components/recordings/recording-player";
import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/shared";
import { useRecordings, useRecordingsWsSync } from "@/hooks";
import { formatBytes } from "@/lib/format";
import type { VoiceRecording } from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context";
type RecordingsTab = "library" | "stats";
export default function RecordingsView({
initialRecordings,
}: {
initialRecordings?: VoiceRecording[];
}) {
const {
data: recordings,
error,
mutate: refetch,
} = useRecordings(initialRecordings);
const [playingId, setPlayingId] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isLoadingAudio, setIsLoadingAudio] = useState(false);
const [tab, setTab] = useState<RecordingsTab>("library");
const ws = useWebSocket();
const audioRef = useRef<HTMLAudioElement | null>(null);
// Live-update the library when the gateway publishes voice_recording_uploaded
useRecordingsWsSync(ws);
const currentTrack =
playingId && recordings
? recordings.find((r: VoiceRecording) => r.id === playingId)
: null;
const togglePlay = (id: string) => {
if (playingId !== id) {
setPlayingId(id); // RecordingPlayer picks up the new url + autoplays
} else {
const audio = audioRef.current;
if (!audio) return;
if (audio.paused) audio.play().catch(() => {});
else audio.pause();
}
};
const stats = useMemo(() => {
const list = recordings ?? [];
const totalSize = list.reduce((sum, r) => sum + (r.size_bytes ?? 0), 0);
const byUser = new Map<
string,
{ name: string; count: number; size: number }
>();
for (const rec of list) {
const key = rec.user_id ?? rec.username;
const cur = byUser.get(key) ?? { name: rec.username, count: 0, size: 0 };
cur.count += 1;
cur.size += rec.size_bytes ?? 0;
byUser.set(key, cur);
}
const topUsers = [...byUser.values()]
.sort((a, b) => b.count - a.count)
.slice(0, 8);
return {
total: list.length,
totalSize,
uniqueUsers: byUser.size,
topUsers,
};
}, [recordings]);
return (
<div className="space-y-4 animate-fade-in-up">
<SubNav
tabs={[
{ id: "library", label: "Library", icon: undefined },
{ id: "stats", label: "Stats", icon: undefined },
]}
activeTab={tab}
onTabChange={(t) => setTab(t as RecordingsTab)}
/>
{tab === "library" &&
(error ? (
<ErrorState message={error.message} onRetry={refetch} />
) : !recordings ? (
<LoadingSkeleton count={4} height="h-28" />
) : (
<div className="space-y-2">
{(recordings ?? []).map((rec: VoiceRecording) => (
<RecordingCard
key={rec.id}
recording={rec}
active={playingId === rec.id}
playing={playingId === rec.id && isPlaying}
loading={playingId === rec.id && isLoadingAudio}
onTogglePlay={togglePlay}
/>
))}
{(recordings ?? []).length === 0 && (
<EmptyState
icon={Mic}
title="No records yet"
description="Voice recordings will appear here once members speak in a monitored voice channel."
/>
)}
</div>
))}
{tab === "stats" &&
(!recordings ? (
<LoadingSkeleton count={4} height="h-28" columns={3} />
) : stats.total === 0 ? (
<EmptyState
icon={Clock}
title="No recording stats yet"
description="Recordings are captured from monitored voice channels."
/>
) : (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<StatCard
label="Total Recordings"
value={stats.total}
icon={Mic}
/>
<StatCard
label="Total Size"
value={stats.totalSize}
icon={Database}
formatter={(v) => formatBytes(v)}
/>
<StatCard
label="Unique Speakers"
value={stats.uniqueUsers}
icon={Users}
/>
</div>
{stats.topUsers.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs text-text-secondary font-medium uppercase tracking-wide">
Top Speakers
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{stats.topUsers.map((u) => (
<div
key={u.name}
className="flex items-center gap-3 rounded-lg border border-border/40 bg-card/40 px-3 py-2"
>
<span className="flex size-7 items-center justify-center rounded-md bg-primary/10 font-mono text-xs text-primary">
{u.count}
</span>
<span className="flex-1 min-w-0 truncate text-sm text-text-primary">
{u.name}
</span>
<span className="text-[10px] font-mono text-text-secondary/50">
{formatBytes(u.size)}
</span>
</div>
))}
</div>
</div>
)}
</div>
))}
<RecordingPlayer
url={currentTrack?.download_url ?? undefined}
filename={currentTrack?.filename ?? undefined}
playing={isPlaying}
loading={isLoadingAudio}
audioRef={audioRef}
onToggle={() => togglePlay(playingId!)}
onStateChange={(s) => {
setIsPlaying(s.playing);
setIsLoadingAudio(s.loading);
}}
onClose={() => setPlayingId(null)}
/>
</div>
);
}
@@ -1,165 +1,23 @@
"use client";
/**
* Voice page — Server Component.
*
* Fetches the authoritative voice connection status + guild list on the server
* so the first paint reflects the shared gateway voice state (which channel is
* joined, across ALL users), independent of any single browser's WS history.
*/
import { getGuilds, getVoiceStatus } from "@/lib/api/server";
import VoiceView from "./view";
import { useCallback, useEffect, useState } from "react";
import { SubNav } from "@/components/layout/sub-nav";
import { VoiceActivityTimeline } from "@/components/voice/activity-timeline";
import { VoiceConnectionCard } from "@/components/voice/connection-card";
import { ListenControl } from "@/components/voice/listen-control";
import { MicControl } from "@/components/voice/mic-control";
import { SpeakerWaveform } from "@/components/voice/speaker-waveform";
import {
useGuilds,
useMicTransmit,
useSpeakers,
useVoiceChannels,
useVoiceConnect,
useVoiceDisconnect,
useVoiceListen,
useVoiceStatus,
} from "@/hooks";
import { useWebSocket } from "@/lib/ws/context";
import { toast } from "sonner";
type VoiceTab = "connection" | "activity";
export default function VoicePage() {
const ws = useWebSocket();
const { data: voiceStatus } = useVoiceStatus();
const { data: guilds = [] } = useGuilds();
const [selectedGuild, setSelectedGuild] = useState("");
const { data: voiceChannels = [] } = useVoiceChannels(selectedGuild);
const { speakers, subscribe } = useSpeakers();
const connectMut = useVoiceConnect();
const disconnectMut = useVoiceDisconnect();
const micMut = useMicTransmit(ws);
const listen = useVoiceListen(ws);
const [selectedChannel, setSelectedChannel] = useState("");
const [micActive, setMicActive] = useState(false);
const [volume, setVolume] = useState(75);
const [listenVolume, setListenVolume] = useState(75);
const [tab, setTab] = useState<VoiceTab>("connection");
useEffect(() => {
const unsub = subscribe(ws);
return () => unsub();
}, [ws, subscribe]);
const handleMicToggle = useCallback(
async (checked: boolean) => {
if (checked) {
try {
await micMut.mutateAsync(true);
setMicActive(true);
} catch {
setMicActive(false);
}
} else {
setMicActive(false);
try {
await micMut.mutateAsync(false);
} catch {
// Stop already tore down the local transmitter — ignore remote errors
}
}
},
[micMut],
);
const handleVolumeChange = useCallback(
(v: number) => {
setVolume(v);
micMut.setVolume(v);
},
[micMut],
);
const handleGuildChange = useCallback((guildId: string | null) => {
if (!guildId) {
setSelectedGuild("");
setSelectedChannel("");
return;
}
setSelectedGuild(guildId);
}, []);
const activeSpeakers = speakers.filter((s) => s.speaking);
const connected = voiceStatus?.connected ?? false;
export default async function VoicePage() {
const [status, guilds] = await Promise.allSettled([
getVoiceStatus(),
getGuilds(),
]);
return (
<div className="space-y-4 animate-fade-in-up">
<SubNav
tabs={[
{ id: "connection", label: "Connection", icon: undefined },
{ id: "activity", label: "Activity", icon: undefined },
]}
activeTab={tab}
onTabChange={(t) => setTab(t as VoiceTab)}
<VoiceView
initialStatus={status.status === "fulfilled" ? status.value : undefined}
initialGuilds={guilds.status === "fulfilled" ? guilds.value : undefined}
/>
<VoiceConnectionCard
connected={connected}
activeChannelName={voiceStatus?.activeChannelName}
guilds={guilds}
voiceChannels={voiceChannels}
selectedGuild={selectedGuild}
selectedChannel={selectedChannel}
onGuildChange={handleGuildChange}
onChannelChange={(v) => setSelectedChannel(v ?? "")}
onConnect={() => {
void connectMut
.mutateAsync({
guildId: selectedGuild,
channelId: selectedChannel,
})
.catch((err: unknown) => {
const msg =
err instanceof Error
? err.message
: "Gagal connect ke voice channel";
toast.error("Voice connect gagal", {
description: msg,
});
});
}}
onDisconnect={() => {
if (micActive) {
setMicActive(false);
void micMut.mutateAsync(false).catch(() => {});
}
if (listen.active) listen.toggle(false);
disconnectMut.mutate(undefined);
}}
connecting={connectMut.isPending}
/>
{tab === "connection" && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<SpeakerWaveform speakers={activeSpeakers} />
<div className="space-y-4">
<ListenControl
connected={connected}
active={listen.active}
levels={listen.levels}
speakers={speakers}
onToggle={(on) => listen.toggle(on)}
volume={listenVolume}
onVolumeChange={(v) => {
setListenVolume(v);
listen.setVolume(v);
}}
/>
<MicControl
connected={connected}
active={micActive}
onToggle={handleMicToggle}
volume={volume}
onVolumeChange={handleVolumeChange}
/>
</div>
</div>
)}
{tab === "activity" && <VoiceActivityTimeline data={speakers} />}
</div>
);
}
@@ -0,0 +1,177 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { SubNav } from "@/components/layout/sub-nav";
import { VoiceActivityTimeline } from "@/components/voice/activity-timeline";
import { VoiceConnectionCard } from "@/components/voice/connection-card";
import { ListenControl } from "@/components/voice/listen-control";
import { MicControl } from "@/components/voice/mic-control";
import { SpeakerWaveform } from "@/components/voice/speaker-waveform";
import {
useGuilds,
useMicTransmit,
useSpeakers,
useVoiceChannels,
useVoiceConnect,
useVoiceDisconnect,
useVoiceListen,
useVoiceStatus,
} from "@/hooks";
import type { Guild, VoiceStatus } from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context";
type VoiceTab = "connection" | "activity";
/**
* Voice view — hydrated on the client. Seeded from server-rendered status +
* guild list so every user's first paint reflects the same shared voice
* connection state; live updates come over WS.
*/
export default function VoiceView({
initialStatus,
initialGuilds = [],
}: {
initialStatus?: VoiceStatus;
initialGuilds?: Guild[];
}) {
const ws = useWebSocket();
const { data: voiceStatus } = useVoiceStatus(initialStatus);
const { data: guilds = [] } = useGuilds(initialGuilds);
const [selectedGuild, setSelectedGuild] = useState("");
const { data: voiceChannels = [] } = useVoiceChannels(selectedGuild);
const { speakers, subscribe } = useSpeakers(initialStatus?.activeSpeakers);
const connectMut = useVoiceConnect();
const disconnectMut = useVoiceDisconnect();
const micMut = useMicTransmit(ws);
const listen = useVoiceListen(ws);
const [selectedChannel, setSelectedChannel] = useState("");
const [micActive, setMicActive] = useState(false);
const [volume, setVolume] = useState(75);
const [listenVolume, setListenVolume] = useState(75);
const [tab, setTab] = useState<VoiceTab>("connection");
useEffect(() => {
const unsub = subscribe(ws);
return () => unsub();
}, [ws, subscribe]);
const handleMicToggle = useCallback(
async (checked: boolean) => {
if (checked) {
try {
await micMut.mutateAsync(true);
setMicActive(true);
} catch {
setMicActive(false);
}
} else {
setMicActive(false);
try {
await micMut.mutateAsync(false);
} catch {
// Stop already tore down the local transmitter — ignore remote errors
}
}
},
[micMut],
);
const handleVolumeChange = useCallback(
(v: number) => {
setVolume(v);
micMut.setVolume(v);
},
[micMut],
);
const handleGuildChange = useCallback((guildId: string | null) => {
if (!guildId) {
setSelectedGuild("");
setSelectedChannel("");
return;
}
setSelectedGuild(guildId);
}, []);
const activeSpeakers = speakers.filter((s) => s.speaking);
const connected = voiceStatus?.connected ?? false;
return (
<div className="space-y-4 animate-fade-in-up">
<SubNav
tabs={[
{ id: "connection", label: "Connection", icon: undefined },
{ id: "activity", label: "Activity", icon: undefined },
]}
activeTab={tab}
onTabChange={(t) => setTab(t as VoiceTab)}
/>
<VoiceConnectionCard
connected={connected}
activeChannelName={voiceStatus?.activeChannelName}
guilds={guilds}
voiceChannels={voiceChannels}
selectedGuild={selectedGuild}
selectedChannel={selectedChannel}
onGuildChange={handleGuildChange}
onChannelChange={(v) => setSelectedChannel(v ?? "")}
onConnect={() => {
void connectMut
.mutateAsync({
guildId: selectedGuild,
channelId: selectedChannel,
})
.catch((err: unknown) => {
const msg =
err instanceof Error
? err.message
: "Gagal connect ke voice channel";
toast.error("Voice connect gagal", {
description: msg,
});
});
}}
onDisconnect={() => {
if (micActive) {
setMicActive(false);
void micMut.mutateAsync(false).catch(() => {});
}
if (listen.active) listen.toggle(false);
disconnectMut.mutate(undefined);
}}
connecting={connectMut.isPending}
/>
{tab === "connection" && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<SpeakerWaveform speakers={activeSpeakers} />
<div className="space-y-4">
<ListenControl
connected={connected}
active={listen.active}
levels={listen.levels}
speakers={speakers}
onToggle={(on) => listen.toggle(on)}
volume={listenVolume}
onVolumeChange={(v) => {
setListenVolume(v);
listen.setVolume(v);
}}
/>
<MicControl
connected={connected}
active={micActive}
onToggle={handleMicToggle}
volume={volume}
onVolumeChange={handleVolumeChange}
/>
</div>
</div>
)}
{tab === "activity" && <VoiceActivityTimeline data={speakers} />}
</div>
);
}
@@ -18,12 +18,16 @@ import {
} from "@/hooks";
import type { WsHook } from "@/lib/ws-hook";
import type { MediaState } from "@/lib/types";
interface MusicPlayerProps {
ws: WsHook;
/** Server-fetched media snapshot used to seed the first render. */
initialData?: MediaState;
}
export function MusicPlayer({ ws }: MusicPlayerProps) {
const { data: mediaState } = useMediaState();
export function MusicPlayer({ ws, initialData }: MusicPlayerProps) {
const { data: mediaState } = useMediaState(initialData);
const queueMut = useMediaQueue();
const skipMut = useMediaSkip();
const stopMut = useMediaStop();
@@ -17,7 +17,11 @@ import { EmptyState, LoadingSkeleton } from "@/components/shared";
import { Badge } from "@/components/ui/badge";
import { useModerationActions, useModerationStats } from "@/hooks";
import { renderMessageContent } from "@/lib/format";
import type { ModerationAction, ModerationActionType } from "@/lib/types";
import type {
ModerationAction,
ModerationActionType,
ModerationStats,
} from "@/lib/types";
import { cn } from "@/lib/utils";
const ACTION_META: Record<
@@ -82,13 +86,20 @@ const EMPTY_ACTION_RATE = {
failed_rate: 0,
};
export function ModerationSection() {
export function ModerationSection({
initialStats,
initialActions,
}: {
initialStats?: ModerationStats;
initialActions?: ModerationAction[];
} = {}) {
const [status, setStatus] = useState<string>("");
const [actionType, setActionType] = useState<string>("");
const { data: stats } = useModerationStats();
const { data: stats } = useModerationStats(initialStats);
const { data: actions, isLoading: actionsLoading } = useModerationActions(
status,
actionType,
initialActions,
);
const s = stats ?? EMPTY_ACTION_RATE;
@@ -159,7 +170,7 @@ export function ModerationSection() {
</div>
{/* Timeline */}
{actionsLoading ? (
{actionsLoading && !actions ? (
<LoadingSkeleton count={6} height="h-16" />
) : !actions || actions.length === 0 ? (
<GlassCard className="p-6">
+1 -1
View File
@@ -4,8 +4,8 @@ export {
useChannelDetail,
useChannels,
useStats,
useTopReactors,
useTopReactions,
useTopReactors,
useUserDetail,
useUsers,
} from "./use-dashboard";
+17 -6
View File
@@ -10,15 +10,26 @@ import type {
TopReactor,
} from "@/lib/types";
export function useStats() {
return useSWR<DashboardStats>(["dashboard-stats"], () =>
dashboardApi.getStats(),
/**
* Server-seeded SWR hooks.
*
* SSR pages fetch the initial payload on the server and hand it here as
* `initialData` — the first render is server data, and SWR takes over for
* revalidation from then on (no blank-spinner-first-load).
*/
export function useStats(initialData?: DashboardStats) {
return useSWR<DashboardStats>(
["dashboard-stats"],
() => dashboardApi.getStats(),
{ fallbackData: initialData },
);
}
export function useActivity(days = 14) {
return useSWR<DashboardActivity>(["dashboard-activity", days], () =>
dashboardApi.getActivity(days),
export function useActivity(days = 14, initialData?: DashboardActivity) {
return useSWR<DashboardActivity>(
["dashboard-activity", days],
() => dashboardApi.getActivity(days),
{ fallbackData: initialData },
);
}
+2 -1
View File
@@ -6,8 +6,9 @@ import type { Guild } from "@/lib/types";
/**
* Fetch the list of available Discord guilds.
*/
export function useGuilds() {
export function useGuilds(initialData?: Guild[]) {
return useSWR<Guild[]>(["guilds"], () => voiceApi.getGuilds(), {
dedupingInterval: 60_000,
fallbackData: initialData,
});
}
+2 -1
View File
@@ -7,10 +7,11 @@ import type { WsHook } from "@/lib/ws-hook";
const MEDIA_KEY = ["media-state"] as const;
export function useMediaState() {
export function useMediaState(initialData?: MediaState) {
return useSWR<MediaState>(MEDIA_KEY, () => mediaApi.getStatus(), {
refreshInterval: 10_000,
shouldRetryOnError: false,
fallbackData: initialData,
});
}
+15 -5
View File
@@ -24,17 +24,27 @@ type MessagePage = { data: MessageRecord[]; nextCursor: string | null };
* useMessagesHasMore derive from this one SWR key, so the cursor probe no
* longer triggers a duplicate API call.
*/
function useMessagesPage(guildId: string, channelId?: string) {
function useMessagesPage(
guildId: string,
channelId?: string,
initialPage?: MessagePage,
) {
const key = guildId ? msgKeys.list(guildId, channelId) : null;
return useSWR<MessagePage>(key, () =>
messagesApi.list(guildId, 50, channelId || undefined),
return useSWR<MessagePage>(
key,
() => messagesApi.list(guildId, 50, channelId || undefined),
{ fallbackData: initialPage },
);
}
// ── Messages list (paginated, cursor-based) ──────
export function useMessages(guildId: string, channelId?: string) {
const page = useMessagesPage(guildId, channelId);
export function useMessages(
guildId: string,
channelId?: string,
initialPage?: MessagePage,
) {
const page = useMessagesPage(guildId, channelId, initialPage);
return {
...page,
data: page.data?.data,
+22 -7
View File
@@ -1,20 +1,35 @@
import useSWR from "swr";
import { moderationApi } from "@/lib/api";
import type { ModerationStats } from "@/lib/types";
import type { ModerationAction, ModerationStats } from "@/lib/types";
export function useModerationStats() {
return useSWR<ModerationStats>(["moderation-stats"], () =>
moderationApi.getStats(),
export function useModerationStats(initialData?: ModerationStats) {
return useSWR<ModerationStats>(
["moderation-stats"],
() => moderationApi.getStats(),
{ fallbackData: initialData },
);
}
export function useModerationActions(status?: string, actionType?: string) {
export function useModerationActions(
status?: string,
actionType?: string,
initialData?: ModerationAction[],
) {
const key = [
"moderation-actions",
status ?? "__all__",
actionType ?? "__all__",
];
return useSWR(
["moderation-actions", status ?? "__all__", actionType ?? "__all__"],
key,
async () => {
const res = await moderationApi.listActions(100, status, actionType);
return res.data;
},
{ keepPreviousData: true },
{
keepPreviousData: true,
fallbackData:
!status && !actionType && initialData ? initialData : undefined,
},
);
}
@@ -7,11 +7,15 @@ import type { WsHook } from "@/lib/ws-hook";
const RECORDINGS_KEY = ["recordings"] as const;
export function useRecordings() {
return useSWR<VoiceRecording[]>(RECORDINGS_KEY, async () => {
export function useRecordings(initialData?: VoiceRecording[]) {
return useSWR<VoiceRecording[]>(
RECORDINGS_KEY,
async () => {
const res = await recordingsApi.list(50);
return res.items;
});
},
{ fallbackData: initialData },
);
}
export function useDeleteRecording() {
+26 -3
View File
@@ -20,9 +20,10 @@ export function hashUserId(userId: string): number {
const STATUS_KEY = ["voice-status"] as const;
export function useVoiceStatus() {
export function useVoiceStatus(initialData?: VoiceStatus) {
return useSWR<VoiceStatus>(STATUS_KEY, () => voiceApi.getStatus(), {
shouldRetryOnError: false,
fallbackData: initialData,
});
}
@@ -32,10 +33,31 @@ export function useVoiceChannels(guildId: string) {
);
}
export function useSpeakers() {
const [speakers, setSpeakers] = useState<ActiveSpeaker[]>([]);
/**
* Live shared speaker state.
*
* Seeded from the server-authored snapshot (`initial` — the voice status the
* server rendered, which includes the authoritative active speakers). From
* there the WS keeps it converged across ALL users:
* - `voice_state` → authoritative FULL replacement (e.g. a late join seeds
* every client with the same list);
* - `voice_active_user` → incremental upsert of a single speaker delta.
*
* This replaces the old per-browser model where each tab accumulated speakers
* only from events it happened to receive while mounted.
*/
export function useSpeakers(initialStatusActive?: ActiveSpeaker[]) {
const [speakers, setSpeakers] = useState<ActiveSpeaker[]>(
initialStatusActive ?? [],
);
const subscribe = useCallback((ws: WsHook) => {
const unsubSnapshot = ws.on("voice_state", (data) => {
const state = data as { activeSpeakers?: ActiveSpeaker[] };
if (Array.isArray(state?.activeSpeakers)) {
setSpeakers(state.activeSpeakers);
}
});
const unsub = ws.on("voice_active_user", (data) => {
const speaker = data as ActiveSpeaker;
setSpeakers((prev) => {
@@ -49,6 +71,7 @@ export function useSpeakers() {
});
});
return () => {
unsubSnapshot();
unsub();
setSpeakers([]);
};
+135
View File
@@ -0,0 +1,135 @@
/**
* Server-only data layer.
*
* These fetchers run exclusively on the Next.js server (React Server
* Components / route handlers). They call the backend over HTTP directly
* (`GMW_BACKEND_URL`), so the browser never needs a client round-trip for the
* initial page data — the first paint is server-rendered.
*
* Never import this module from a client component. Browser code should keep
* using `@/lib/api/client` (same-origin via the reverse proxy) for live ops.
*/
import type {
AppConfig,
DashboardActivity,
DashboardStats,
Guild,
MediaState,
ModerationAction,
ModerationStats,
PaginatedRecordings,
VoiceStatus,
} from "@/lib/types";
const BACKEND_URL =
process.env.GMW_BACKEND_URL?.replace(/\/+$/, "") || "http://127.0.0.1:4001";
export class ApiServerError extends Error {
statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.name = "ApiServerError";
this.statusCode = statusCode;
}
}
async function serverFetch<T>(
path: string,
init?: { timeoutMs?: number },
): Promise<T> {
const url = `${BACKEND_URL}${path}`;
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
init?.timeoutMs ?? 8_000,
);
let res: Response;
try {
res = await fetch(url, {
headers: { Accept: "application/json" },
cache: "no-store",
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new ApiServerError(text || `HTTP ${res.status}`, res.status);
}
return res.json() as Promise<T>;
}
// ---- Dashboard ----
export async function getDashboardStats(): Promise<DashboardStats> {
return serverFetch<DashboardStats>("/api/dashboard/stats");
}
export async function getActivity(days = 14): Promise<DashboardActivity> {
return serverFetch<DashboardActivity>(`/api/dashboard/activity?days=${days}`);
}
// ---- Media ----
export async function getMediaStatus(): Promise<MediaState> {
return serverFetch<MediaState>("/api/media/status");
}
// ---- Config ----
export async function getConfig(): Promise<AppConfig> {
return serverFetch<AppConfig>("/api/config");
}
// ---- Moderation ----
export async function getModerationStats(): Promise<ModerationStats> {
return serverFetch<ModerationStats>("/api/moderation/stats");
}
export async function getModerationActions(
limit = 100,
): Promise<ModerationAction[]> {
const res = await serverFetch<{ data: ModerationAction[] }>(
`/api/moderation/actions?limit=${limit}`,
);
return res.data;
}
// ---- Voice ----
export async function getGuilds(): Promise<Guild[]> {
return serverFetch<Guild[]>("/api/guilds");
}
export async function getVoiceStatus(): Promise<VoiceStatus> {
return serverFetch<VoiceStatus>("/api/voice/status");
}
// ---- Recordings ----
export async function getRecordings(limit = 50): Promise<PaginatedRecordings> {
return serverFetch<PaginatedRecordings>(`/api/recordings?limit=${limit}`);
}
// ---- Messages ----
export interface MessagePageResult {
data: import("@/lib/types").MessageRecord[];
nextCursor: string | null;
}
export async function getMessages(
guildId: string,
channelId?: string,
cursor?: string,
): Promise<MessagePageResult> {
const params = new URLSearchParams({ guildId });
if (channelId) params.set("channelId", channelId);
if (cursor) params.set("cursor", cursor);
return serverFetch<MessagePageResult>(`/api/messages?${params.toString()}`);
}
+6
View File
@@ -11,6 +11,12 @@ export interface VoiceStatus {
activeChannelId?: string | null;
activeChannelName?: string | null;
connections: GuildVoiceEntry[];
/**
* Authoritative shared voice snapshot — who is present / speaking right
* now, aggregated server-side from the gateway's `voice_active_user`
* deltas. All browsers converge on this same list.
*/
activeSpeakers?: ActiveSpeaker[];
}
export interface ActiveSpeaker {
+6
View File
@@ -42,6 +42,12 @@ export interface WsEventMap {
voice_recording_stopped: unknown;
voice_recording_uploaded: VoiceRecording;
voice_active_user: ActiveSpeaker;
/**
* Authoritative shared live-voice snapshot — `{ activeSpeakers: [...] }`.
* The backend sends this on WS connect (initial state) and clients replace
* their local list wholesale so every user converges on the same state.
*/
voice_state: { activeSpeakers: ActiveSpeaker[] };
/** NOT delivered as JSON — arrives only via onPcm() binary handler as PcmChunk */
voice_pcm_data: never;
voice_analyzed: unknown;