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

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

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

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

Deploy:
- flake.nix: frontend package build SSR standalone (server.js wrapper,
  GMW_FRONTEND_PORT=4017); proxy nginx template proxy / -> Next server,
  /api + /ws tetap ke backend :4001
This commit is contained in:
asepharyana
2026-08-07 10:44:03 +07:00
parent aa440eda69
commit f20889868d
32 changed files with 1556 additions and 956 deletions
@@ -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}
/>
);
}