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:
@@ -223,7 +223,7 @@ WRAPPER
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# ---- Frontend (Next.js static export) ----
|
# ---- Frontend (Next.js SSR standalone) ----
|
||||||
frontend = pkgs.stdenv.mkDerivation {
|
frontend = pkgs.stdenv.mkDerivation {
|
||||||
pname = "gmw-frontend";
|
pname = "gmw-frontend";
|
||||||
version = "1.0.0";
|
version = "1.0.0";
|
||||||
@@ -233,29 +233,40 @@ WRAPPER
|
|||||||
nativeBuildInputs = [ nodejs pnpm pkgs.gnumake pkgs.gcc pkgs.cacert ];
|
nativeBuildInputs = [ nodejs pnpm pkgs.gnumake pkgs.gcc pkgs.cacert ];
|
||||||
|
|
||||||
buildPhase = pnpmInstall + ''
|
buildPhase = pnpmInstall + ''
|
||||||
echo "=== Building Next.js static export ==="
|
echo "=== Building Next.js SSR (standalone) ==="
|
||||||
# Build args are provided as env vars
|
|
||||||
export NEXT_TELEMETRY_DISABLED=1
|
export NEXT_TELEMETRY_DISABLED=1
|
||||||
|
export GMW_BACKEND_URL=http://127.0.0.1:4001
|
||||||
npx next build 2>&1
|
npx next build 2>&1
|
||||||
'';
|
'';
|
||||||
|
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
mkdir -p $out/share/gmw-frontend
|
echo "=== Packaging standalone server ==="
|
||||||
cp -r out $out/share/gmw-frontend/out 2>/dev/null || \
|
mkdir -p $out/lib/gmw-frontend/standalone
|
||||||
cp -r dist $out/share/gmw-frontend/dist 2>/dev/null || \
|
# The standalone server bundles its own minimal node_modules but
|
||||||
cp -r .next $out/share/gmw-frontend/.next 2>/dev/null || true
|
# 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
|
mkdir -p $out/bin
|
||||||
cp -r node_modules $out/share/gmw-frontend/ 2>/dev/null || true
|
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 = {
|
meta = {
|
||||||
description = "GMW Frontend — Next.js static dashboard";
|
description = "GMW Frontend — Next.js SSR dashboard";
|
||||||
platforms = pkgs.lib.platforms.linux;
|
platforms = pkgs.lib.platforms.linux;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# ---- Proxy (nginx serving frontend) ----
|
# ---- Proxy (nginx: / -> Next SSR, /api + /ws -> backend) ----
|
||||||
proxy = pkgs.stdenv.mkDerivation {
|
proxy = pkgs.stdenv.mkDerivation {
|
||||||
pname = "gmw-proxy";
|
pname = "gmw-proxy";
|
||||||
version = "1.0.0";
|
version = "1.0.0";
|
||||||
@@ -270,11 +281,10 @@ WRAPPER
|
|||||||
mkdir -p $out/bin $out/etc $out/share
|
mkdir -p $out/bin $out/etc $out/share
|
||||||
|
|
||||||
# Substitute placeholders in nginx template
|
# Substitute placeholders in nginx template
|
||||||
sed \
|
sed -e "s|@NGINX_MIME@|${pkgs.nginx}/conf/mime.types|g" \
|
||||||
-e "s|@NGINX_MIME@|${pkgs.nginx}/conf/mime.types|g" \
|
-e "s|@NEXT_PORT@|4017|g" \
|
||||||
-e "s|@FRONTEND_ROOT@|${frontend}/share/gmw-frontend/out|g" \
|
${./infra/nix/nginx.conf.template} \
|
||||||
${./infra/nix/nginx.conf.template} \
|
> $out/etc/nginx.conf
|
||||||
> $out/etc/nginx.conf
|
|
||||||
|
|
||||||
cat > $out/bin/gmw-proxy << WRAPPER
|
cat > $out/bin/gmw-proxy << WRAPPER
|
||||||
#!${pkgs.runtimeShell}
|
#!${pkgs.runtimeShell}
|
||||||
@@ -284,7 +294,7 @@ WRAPPER
|
|||||||
'';
|
'';
|
||||||
|
|
||||||
meta = {
|
meta = {
|
||||||
description = "GMW Proxy — nginx serving frontend";
|
description = "GMW Proxy — nginx -> Next.js + backend";
|
||||||
platforms = pkgs.lib.platforms.linux;
|
platforms = pkgs.lib.platforms.linux;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,28 +11,44 @@ http {
|
|||||||
'' close;
|
'' 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 {
|
server {
|
||||||
listen 4009;
|
listen 4009;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
# Use relative redirects (Location: /dashboard/) instead of absolute
|
# 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;
|
absolute_redirect off;
|
||||||
|
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_types text/plain text/css application/json application/javascript application/wasm image/svg+xml;
|
gzip_types text/plain text/css application/json application/javascript application/wasm image/svg+xml;
|
||||||
gzip_min_length 256;
|
gzip_min_length 256;
|
||||||
|
|
||||||
|
# ── Backend REST ───────────────────────────────────────────────
|
||||||
location ^~ /api {
|
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 Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Backend WebSocket (realtime shared state + voice PCM) ──────
|
||||||
location ^~ /ws {
|
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_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection $connection_upgrade;
|
proxy_set_header Connection $connection_upgrade;
|
||||||
@@ -45,16 +61,30 @@ http {
|
|||||||
proxy_send_timeout 86400s;
|
proxy_send_timeout 86400s;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /assets/ {
|
# ── Next.js build assets — immutable, edge/shareable ───────────
|
||||||
root @FRONTEND_ROOT@;
|
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;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Everything else → Next.js server (SSR) ──
|
||||||
location / {
|
location / {
|
||||||
root @FRONTEND_ROOT@;
|
proxy_pass http://gmw_next$uri$is_args$args;
|
||||||
index index.html;
|
proxy_http_version 1.1;
|
||||||
try_files $uri $uri/ /index.html;
|
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,
|
VOICE_STATUS_KEY,
|
||||||
} from "../../shared/index.js";
|
} from "../../shared/index.js";
|
||||||
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
|
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
|
||||||
|
import {
|
||||||
|
getActiveSpeakers,
|
||||||
|
type LiveSpeaker,
|
||||||
|
} from "./live-speaker.js";
|
||||||
|
|
||||||
const logger = createChildLogger("voice.service");
|
const logger = createChildLogger("voice.service");
|
||||||
|
|
||||||
@@ -45,6 +49,12 @@ export interface VoiceStatus {
|
|||||||
activeChannelId: string | null;
|
activeChannelId: string | null;
|
||||||
activeChannelName: string | null;
|
activeChannelName: string | null;
|
||||||
connections: GuildVoiceEntry[];
|
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 = {
|
export const DEFAULT_VOICE_STATUS: VoiceStatus = {
|
||||||
@@ -53,8 +63,16 @@ export const DEFAULT_VOICE_STATUS: VoiceStatus = {
|
|||||||
activeChannelId: null,
|
activeChannelId: null,
|
||||||
activeChannelName: null,
|
activeChannelName: null,
|
||||||
connections: [],
|
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.
|
* Wraps tryCommandThenFallback with a cleaner signature for use within this module.
|
||||||
* Attempts a Redis command first; on failure, falls back to the provided function.
|
* 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> {
|
function readVoiceStatusFallback(): Promise<VoiceStatus> {
|
||||||
return readRedisStatus(VOICE_STATUS_KEY).then(
|
return readRedisStatus(VOICE_STATUS_KEY).then((cached) =>
|
||||||
(cached) => (cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS,
|
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> {
|
export async function getVoiceStatus(): Promise<VoiceStatus> {
|
||||||
logger.debug("getVoiceStatus called");
|
logger.debug("getVoiceStatus called");
|
||||||
const cached = await readRedisStatus(VOICE_STATUS_KEY);
|
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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import Redis from "ioredis";
|
|||||||
import { config } from "../shared/config/index.js";
|
import { config } from "../shared/config/index.js";
|
||||||
import {
|
import {
|
||||||
DISCORD_CHANNEL_TO_WS_EVENT,
|
DISCORD_CHANNEL_TO_WS_EVENT,
|
||||||
|
DISCORD_VOICE_ACTIVE_USER,
|
||||||
DISCORD_VOICE_PCM,
|
DISCORD_VOICE_PCM,
|
||||||
} from "../shared/index.js";
|
} from "../shared/index.js";
|
||||||
import { createChildLogger } from "../shared/logger/index.js";
|
import { createChildLogger } from "../shared/logger/index.js";
|
||||||
|
import { recordSpeaker } from "../modules/voice/live-speaker.js";
|
||||||
import { broadcastBinary, broadcastEvent } from "./broadcast.js";
|
import { broadcastBinary, broadcastEvent } from "./broadcast.js";
|
||||||
|
|
||||||
const logger = createChildLogger("ws.redis-bridge");
|
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");
|
logger.debug({ channel, eventType }, "Broadcasting Redis event");
|
||||||
broadcastEvent(eventType, data);
|
broadcastEvent(eventType, data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,22 @@ async function sendInitialStates(ws: WebSocket): Promise<void> {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn({ err }, "Failed to send initial media_state");
|
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 {
|
export function closeWebSocketServer(): void {
|
||||||
|
|||||||
+30
-13
@@ -3,26 +3,41 @@
|
|||||||
Next.js 16 (App Router), React 19, TypeScript strict, Tailwind v4, shadcn/ui, base-ui.
|
Next.js 16 (App Router), React 19, TypeScript strict, Tailwind v4, shadcn/ui, base-ui.
|
||||||
|
|
||||||
Key points:
|
Key points:
|
||||||
- **All pages** are `"use client"` — the dashboard is fully client-rendered
|
- **Server-side rendered (SSR)** — `output: "standalone"` in next.config.ts; pages
|
||||||
- **API client** at `src/lib/api/` — fetch-based, covers all 30+ backend endpoints
|
are React Server Components that fetch initial data from the backend at
|
||||||
- **WebSocket** at `src/lib/ws/` — auto-reconnecting client with typed event subscriptions
|
render-time, then hydrate interactive client components (no blank-spinner-first-load).
|
||||||
- **Static export**: `output: "export"` in next.config.ts, served via nginx
|
- **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
|
- **No authentication**: all endpoints are public
|
||||||
|
|
||||||
## Data flow (match these — do not invent endpoints)
|
## Data flow (match these — do not invent endpoints)
|
||||||
|
|
||||||
```
|
```
|
||||||
Discord → discord-gateway → Redis pub/sub → backend (Express :4001) ←→ frontend
|
Discord → discord-gateway → Redis pub/sub → backend (Express :4001) ←→ Next.js SSR
|
||||||
↑ REST /api/* (same-origin)
|
↑ REST /api/* (server: GMW_BACKEND_URL
|
||||||
└ WS /ws (events + PCM binary)
|
└ 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)
|
- **Rendering**: `gmw-proxy` nginx (:4009) proxies `/` → Next standalone server
|
||||||
proxies `/api` and `/ws` to the backend on :4001. Public host:
|
(:4017, `node .next/standalone/server.js`), and `/api` + `/ws` → backend :4001.
|
||||||
`imphnen.asepharyana.my.id` (Caddy reverse proxy → :4009).
|
Public host: `imphnen.asepharyana.my.id` (Caddy reverse proxy → :4009).
|
||||||
- Local dev overrides: `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_WS_URL`
|
- **SSR seed pattern**: each `page.tsx` is a server component that fetches via
|
||||||
(e.g. https://imphnen.asepharyana.my.id).
|
`src/lib/api/server.ts` and passes typed data to a `view.tsx` client
|
||||||
- **Never hardcode a host** in api/ws clients — same-origin or env override only.
|
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
|
## 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).
|
- Dashboard endpoints: `/api/dashboard/stats|users|channels` (+ `/:id` details).
|
||||||
- Channel/guild names live inside `message.metadata` JSON (`channel.channelName`),
|
- Channel/guild names live inside `message.metadata` JSON (`channel.channelName`),
|
||||||
not top-level.
|
not top-level.
|
||||||
|
- `GET /api/voice/status` now includes `activeSpeakers` (authoritative shared
|
||||||
|
snapshot from `src/modules/voice/live-speaker.ts` on the backend).
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
reactCompiler: true,
|
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,
|
trailingSlash: true,
|
||||||
images: { unoptimized: 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 {
|
export default async function DashboardPage() {
|
||||||
AlertCircle,
|
const [stats, activity] = await Promise.allSettled([
|
||||||
Clock,
|
getDashboardStats(),
|
||||||
Hash,
|
getActivity(14),
|
||||||
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)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 animate-fade-in-up">
|
<DashboardView
|
||||||
<SubNav
|
initialStats={stats.status === "fulfilled" ? stats.value : undefined}
|
||||||
tabs={subNavTabs}
|
initialActivity={
|
||||||
activeTab={tab}
|
activity.status === "fulfilled" ? activity.value : undefined
|
||||||
onTabChange={(t) => setTab(t as DashboardTab)}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{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";
|
export default async function MediaPage() {
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
const status = await getMediaStatus().catch(() => undefined);
|
||||||
|
|
||||||
export default function MediaPage() {
|
return <MediaView initialStatus={status} />;
|
||||||
const ws = useWebSocket();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5 animate-fade-in-up">
|
|
||||||
<MusicPlayer ws={ws} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
export default async function MessagesPage({
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
searchParams,
|
||||||
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">
|
|
||||||
⌘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"
|
|
||||||
>
|
|
||||||
← 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[];
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
onSelect: (id: string) => void;
|
|
||||||
}) {
|
}) {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<MessagesView
|
||||||
{items.map((item) => {
|
initialGuild={guild}
|
||||||
const imgUrl = extractFirstImage(item.metadata);
|
initialChannel={channel}
|
||||||
return (
|
initialDetailId={selected}
|
||||||
<button
|
initialTab={tab}
|
||||||
key={item.id}
|
initialMessagePage={initialPage}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
⌘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"
|
||||||
|
>
|
||||||
|
← 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 { 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 (
|
return (
|
||||||
<div className="space-y-4 animate-fade-in-up">
|
<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>
|
</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";
|
export default async function RecordingsPage() {
|
||||||
import { useMemo, useRef, useState } from "react";
|
const data = await getRecordings(50).catch(() => undefined);
|
||||||
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";
|
return <RecordingsView initialRecordings={data?.items} />;
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
export default async function VoicePage() {
|
||||||
import { SubNav } from "@/components/layout/sub-nav";
|
const [status, guilds] = await Promise.allSettled([
|
||||||
import { VoiceActivityTimeline } from "@/components/voice/activity-timeline";
|
getVoiceStatus(),
|
||||||
import { VoiceConnectionCard } from "@/components/voice/connection-card";
|
getGuilds(),
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 animate-fade-in-up">
|
<VoiceView
|
||||||
<SubNav
|
initialStatus={status.status === "fulfilled" ? status.value : undefined}
|
||||||
tabs={[
|
initialGuilds={guilds.status === "fulfilled" ? guilds.value : undefined}
|
||||||
{ 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
} from "@/hooks";
|
||||||
import type { WsHook } from "@/lib/ws-hook";
|
import type { WsHook } from "@/lib/ws-hook";
|
||||||
|
|
||||||
|
import type { MediaState } from "@/lib/types";
|
||||||
|
|
||||||
interface MusicPlayerProps {
|
interface MusicPlayerProps {
|
||||||
ws: WsHook;
|
ws: WsHook;
|
||||||
|
/** Server-fetched media snapshot used to seed the first render. */
|
||||||
|
initialData?: MediaState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MusicPlayer({ ws }: MusicPlayerProps) {
|
export function MusicPlayer({ ws, initialData }: MusicPlayerProps) {
|
||||||
const { data: mediaState } = useMediaState();
|
const { data: mediaState } = useMediaState(initialData);
|
||||||
const queueMut = useMediaQueue();
|
const queueMut = useMediaQueue();
|
||||||
const skipMut = useMediaSkip();
|
const skipMut = useMediaSkip();
|
||||||
const stopMut = useMediaStop();
|
const stopMut = useMediaStop();
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import { EmptyState, LoadingSkeleton } from "@/components/shared";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { useModerationActions, useModerationStats } from "@/hooks";
|
import { useModerationActions, useModerationStats } from "@/hooks";
|
||||||
import { renderMessageContent } from "@/lib/format";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const ACTION_META: Record<
|
const ACTION_META: Record<
|
||||||
@@ -82,13 +86,20 @@ const EMPTY_ACTION_RATE = {
|
|||||||
failed_rate: 0,
|
failed_rate: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ModerationSection() {
|
export function ModerationSection({
|
||||||
|
initialStats,
|
||||||
|
initialActions,
|
||||||
|
}: {
|
||||||
|
initialStats?: ModerationStats;
|
||||||
|
initialActions?: ModerationAction[];
|
||||||
|
} = {}) {
|
||||||
const [status, setStatus] = useState<string>("");
|
const [status, setStatus] = useState<string>("");
|
||||||
const [actionType, setActionType] = useState<string>("");
|
const [actionType, setActionType] = useState<string>("");
|
||||||
const { data: stats } = useModerationStats();
|
const { data: stats } = useModerationStats(initialStats);
|
||||||
const { data: actions, isLoading: actionsLoading } = useModerationActions(
|
const { data: actions, isLoading: actionsLoading } = useModerationActions(
|
||||||
status,
|
status,
|
||||||
actionType,
|
actionType,
|
||||||
|
initialActions,
|
||||||
);
|
);
|
||||||
|
|
||||||
const s = stats ?? EMPTY_ACTION_RATE;
|
const s = stats ?? EMPTY_ACTION_RATE;
|
||||||
@@ -159,7 +170,7 @@ export function ModerationSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeline */}
|
{/* Timeline */}
|
||||||
{actionsLoading ? (
|
{actionsLoading && !actions ? (
|
||||||
<LoadingSkeleton count={6} height="h-16" />
|
<LoadingSkeleton count={6} height="h-16" />
|
||||||
) : !actions || actions.length === 0 ? (
|
) : !actions || actions.length === 0 ? (
|
||||||
<GlassCard className="p-6">
|
<GlassCard className="p-6">
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ export {
|
|||||||
useChannelDetail,
|
useChannelDetail,
|
||||||
useChannels,
|
useChannels,
|
||||||
useStats,
|
useStats,
|
||||||
useTopReactors,
|
|
||||||
useTopReactions,
|
useTopReactions,
|
||||||
|
useTopReactors,
|
||||||
useUserDetail,
|
useUserDetail,
|
||||||
useUsers,
|
useUsers,
|
||||||
} from "./use-dashboard";
|
} from "./use-dashboard";
|
||||||
|
|||||||
@@ -10,15 +10,26 @@ import type {
|
|||||||
TopReactor,
|
TopReactor,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
export function useStats() {
|
/**
|
||||||
return useSWR<DashboardStats>(["dashboard-stats"], () =>
|
* Server-seeded SWR hooks.
|
||||||
dashboardApi.getStats(),
|
*
|
||||||
|
* 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) {
|
export function useActivity(days = 14, initialData?: DashboardActivity) {
|
||||||
return useSWR<DashboardActivity>(["dashboard-activity", days], () =>
|
return useSWR<DashboardActivity>(
|
||||||
dashboardApi.getActivity(days),
|
["dashboard-activity", days],
|
||||||
|
() => dashboardApi.getActivity(days),
|
||||||
|
{ fallbackData: initialData },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import type { Guild } from "@/lib/types";
|
|||||||
/**
|
/**
|
||||||
* Fetch the list of available Discord guilds.
|
* Fetch the list of available Discord guilds.
|
||||||
*/
|
*/
|
||||||
export function useGuilds() {
|
export function useGuilds(initialData?: Guild[]) {
|
||||||
return useSWR<Guild[]>(["guilds"], () => voiceApi.getGuilds(), {
|
return useSWR<Guild[]>(["guilds"], () => voiceApi.getGuilds(), {
|
||||||
dedupingInterval: 60_000,
|
dedupingInterval: 60_000,
|
||||||
|
fallbackData: initialData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import type { WsHook } from "@/lib/ws-hook";
|
|||||||
|
|
||||||
const MEDIA_KEY = ["media-state"] as const;
|
const MEDIA_KEY = ["media-state"] as const;
|
||||||
|
|
||||||
export function useMediaState() {
|
export function useMediaState(initialData?: MediaState) {
|
||||||
return useSWR<MediaState>(MEDIA_KEY, () => mediaApi.getStatus(), {
|
return useSWR<MediaState>(MEDIA_KEY, () => mediaApi.getStatus(), {
|
||||||
refreshInterval: 10_000,
|
refreshInterval: 10_000,
|
||||||
shouldRetryOnError: false,
|
shouldRetryOnError: false,
|
||||||
|
fallbackData: initialData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,17 +24,27 @@ type MessagePage = { data: MessageRecord[]; nextCursor: string | null };
|
|||||||
* useMessagesHasMore derive from this one SWR key, so the cursor probe no
|
* useMessagesHasMore derive from this one SWR key, so the cursor probe no
|
||||||
* longer triggers a duplicate API call.
|
* 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;
|
const key = guildId ? msgKeys.list(guildId, channelId) : null;
|
||||||
return useSWR<MessagePage>(key, () =>
|
return useSWR<MessagePage>(
|
||||||
messagesApi.list(guildId, 50, channelId || undefined),
|
key,
|
||||||
|
() => messagesApi.list(guildId, 50, channelId || undefined),
|
||||||
|
{ fallbackData: initialPage },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Messages list (paginated, cursor-based) ──────
|
// ── Messages list (paginated, cursor-based) ──────
|
||||||
|
|
||||||
export function useMessages(guildId: string, channelId?: string) {
|
export function useMessages(
|
||||||
const page = useMessagesPage(guildId, channelId);
|
guildId: string,
|
||||||
|
channelId?: string,
|
||||||
|
initialPage?: MessagePage,
|
||||||
|
) {
|
||||||
|
const page = useMessagesPage(guildId, channelId, initialPage);
|
||||||
return {
|
return {
|
||||||
...page,
|
...page,
|
||||||
data: page.data?.data,
|
data: page.data?.data,
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { moderationApi } from "@/lib/api";
|
import { moderationApi } from "@/lib/api";
|
||||||
import type { ModerationStats } from "@/lib/types";
|
import type { ModerationAction, ModerationStats } from "@/lib/types";
|
||||||
|
|
||||||
export function useModerationStats() {
|
export function useModerationStats(initialData?: ModerationStats) {
|
||||||
return useSWR<ModerationStats>(["moderation-stats"], () =>
|
return useSWR<ModerationStats>(
|
||||||
moderationApi.getStats(),
|
["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(
|
return useSWR(
|
||||||
["moderation-actions", status ?? "__all__", actionType ?? "__all__"],
|
key,
|
||||||
async () => {
|
async () => {
|
||||||
const res = await moderationApi.listActions(100, status, actionType);
|
const res = await moderationApi.listActions(100, status, actionType);
|
||||||
return res.data;
|
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;
|
const RECORDINGS_KEY = ["recordings"] as const;
|
||||||
|
|
||||||
export function useRecordings() {
|
export function useRecordings(initialData?: VoiceRecording[]) {
|
||||||
return useSWR<VoiceRecording[]>(RECORDINGS_KEY, async () => {
|
return useSWR<VoiceRecording[]>(
|
||||||
const res = await recordingsApi.list(50);
|
RECORDINGS_KEY,
|
||||||
return res.items;
|
async () => {
|
||||||
});
|
const res = await recordingsApi.list(50);
|
||||||
|
return res.items;
|
||||||
|
},
|
||||||
|
{ fallbackData: initialData },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDeleteRecording() {
|
export function useDeleteRecording() {
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ export function hashUserId(userId: string): number {
|
|||||||
|
|
||||||
const STATUS_KEY = ["voice-status"] as const;
|
const STATUS_KEY = ["voice-status"] as const;
|
||||||
|
|
||||||
export function useVoiceStatus() {
|
export function useVoiceStatus(initialData?: VoiceStatus) {
|
||||||
return useSWR<VoiceStatus>(STATUS_KEY, () => voiceApi.getStatus(), {
|
return useSWR<VoiceStatus>(STATUS_KEY, () => voiceApi.getStatus(), {
|
||||||
shouldRetryOnError: false,
|
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 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 unsub = ws.on("voice_active_user", (data) => {
|
||||||
const speaker = data as ActiveSpeaker;
|
const speaker = data as ActiveSpeaker;
|
||||||
setSpeakers((prev) => {
|
setSpeakers((prev) => {
|
||||||
@@ -49,6 +71,7 @@ export function useSpeakers() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
|
unsubSnapshot();
|
||||||
unsub();
|
unsub();
|
||||||
setSpeakers([]);
|
setSpeakers([]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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()}`);
|
||||||
|
}
|
||||||
@@ -11,6 +11,12 @@ export interface VoiceStatus {
|
|||||||
activeChannelId?: string | null;
|
activeChannelId?: string | null;
|
||||||
activeChannelName?: string | null;
|
activeChannelName?: string | null;
|
||||||
connections: GuildVoiceEntry[];
|
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 {
|
export interface ActiveSpeaker {
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ export interface WsEventMap {
|
|||||||
voice_recording_stopped: unknown;
|
voice_recording_stopped: unknown;
|
||||||
voice_recording_uploaded: VoiceRecording;
|
voice_recording_uploaded: VoiceRecording;
|
||||||
voice_active_user: ActiveSpeaker;
|
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 */
|
/** NOT delivered as JSON — arrives only via onPcm() binary handler as PcmChunk */
|
||||||
voice_pcm_data: never;
|
voice_pcm_data: never;
|
||||||
voice_analyzed: unknown;
|
voice_analyzed: unknown;
|
||||||
|
|||||||
Reference in New Issue
Block a user