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