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
@@ -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">