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 -1
View File
@@ -4,8 +4,8 @@ export {
useChannelDetail,
useChannels,
useStats,
useTopReactors,
useTopReactions,
useTopReactors,
useUserDetail,
useUsers,
} from "./use-dashboard";
+17 -6
View File
@@ -10,15 +10,26 @@ import type {
TopReactor,
} from "@/lib/types";
export function useStats() {
return useSWR<DashboardStats>(["dashboard-stats"], () =>
dashboardApi.getStats(),
/**
* Server-seeded SWR hooks.
*
* SSR pages fetch the initial payload on the server and hand it here as
* `initialData` — the first render is server data, and SWR takes over for
* revalidation from then on (no blank-spinner-first-load).
*/
export function useStats(initialData?: DashboardStats) {
return useSWR<DashboardStats>(
["dashboard-stats"],
() => dashboardApi.getStats(),
{ fallbackData: initialData },
);
}
export function useActivity(days = 14) {
return useSWR<DashboardActivity>(["dashboard-activity", days], () =>
dashboardApi.getActivity(days),
export function useActivity(days = 14, initialData?: DashboardActivity) {
return useSWR<DashboardActivity>(
["dashboard-activity", days],
() => dashboardApi.getActivity(days),
{ fallbackData: initialData },
);
}
+2 -1
View File
@@ -6,8 +6,9 @@ import type { Guild } from "@/lib/types";
/**
* Fetch the list of available Discord guilds.
*/
export function useGuilds() {
export function useGuilds(initialData?: Guild[]) {
return useSWR<Guild[]>(["guilds"], () => voiceApi.getGuilds(), {
dedupingInterval: 60_000,
fallbackData: initialData,
});
}
+2 -1
View File
@@ -7,10 +7,11 @@ import type { WsHook } from "@/lib/ws-hook";
const MEDIA_KEY = ["media-state"] as const;
export function useMediaState() {
export function useMediaState(initialData?: MediaState) {
return useSWR<MediaState>(MEDIA_KEY, () => mediaApi.getStatus(), {
refreshInterval: 10_000,
shouldRetryOnError: false,
fallbackData: initialData,
});
}
+15 -5
View File
@@ -24,17 +24,27 @@ type MessagePage = { data: MessageRecord[]; nextCursor: string | null };
* useMessagesHasMore derive from this one SWR key, so the cursor probe no
* longer triggers a duplicate API call.
*/
function useMessagesPage(guildId: string, channelId?: string) {
function useMessagesPage(
guildId: string,
channelId?: string,
initialPage?: MessagePage,
) {
const key = guildId ? msgKeys.list(guildId, channelId) : null;
return useSWR<MessagePage>(key, () =>
messagesApi.list(guildId, 50, channelId || undefined),
return useSWR<MessagePage>(
key,
() => messagesApi.list(guildId, 50, channelId || undefined),
{ fallbackData: initialPage },
);
}
// ── Messages list (paginated, cursor-based) ──────
export function useMessages(guildId: string, channelId?: string) {
const page = useMessagesPage(guildId, channelId);
export function useMessages(
guildId: string,
channelId?: string,
initialPage?: MessagePage,
) {
const page = useMessagesPage(guildId, channelId, initialPage);
return {
...page,
data: page.data?.data,
+22 -7
View File
@@ -1,20 +1,35 @@
import useSWR from "swr";
import { moderationApi } from "@/lib/api";
import type { ModerationStats } from "@/lib/types";
import type { ModerationAction, ModerationStats } from "@/lib/types";
export function useModerationStats() {
return useSWR<ModerationStats>(["moderation-stats"], () =>
moderationApi.getStats(),
export function useModerationStats(initialData?: ModerationStats) {
return useSWR<ModerationStats>(
["moderation-stats"],
() => moderationApi.getStats(),
{ fallbackData: initialData },
);
}
export function useModerationActions(status?: string, actionType?: string) {
export function useModerationActions(
status?: string,
actionType?: string,
initialData?: ModerationAction[],
) {
const key = [
"moderation-actions",
status ?? "__all__",
actionType ?? "__all__",
];
return useSWR(
["moderation-actions", status ?? "__all__", actionType ?? "__all__"],
key,
async () => {
const res = await moderationApi.listActions(100, status, actionType);
return res.data;
},
{ keepPreviousData: true },
{
keepPreviousData: true,
fallbackData:
!status && !actionType && initialData ? initialData : undefined,
},
);
}
@@ -7,11 +7,15 @@ import type { WsHook } from "@/lib/ws-hook";
const RECORDINGS_KEY = ["recordings"] as const;
export function useRecordings() {
return useSWR<VoiceRecording[]>(RECORDINGS_KEY, async () => {
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() {
+26 -3
View File
@@ -20,9 +20,10 @@ export function hashUserId(userId: string): number {
const STATUS_KEY = ["voice-status"] as const;
export function useVoiceStatus() {
export function useVoiceStatus(initialData?: VoiceStatus) {
return useSWR<VoiceStatus>(STATUS_KEY, () => voiceApi.getStatus(), {
shouldRetryOnError: false,
fallbackData: initialData,
});
}
@@ -32,10 +33,31 @@ export function useVoiceChannels(guildId: string) {
);
}
export function useSpeakers() {
const [speakers, setSpeakers] = useState<ActiveSpeaker[]>([]);
/**
* Live shared speaker state.
*
* Seeded from the server-authored snapshot (`initial` — the voice status the
* server rendered, which includes the authoritative active speakers). From
* there the WS keeps it converged across ALL users:
* - `voice_state` → authoritative FULL replacement (e.g. a late join seeds
* every client with the same list);
* - `voice_active_user` → incremental upsert of a single speaker delta.
*
* This replaces the old per-browser model where each tab accumulated speakers
* only from events it happened to receive while mounted.
*/
export function useSpeakers(initialStatusActive?: ActiveSpeaker[]) {
const [speakers, setSpeakers] = useState<ActiveSpeaker[]>(
initialStatusActive ?? [],
);
const subscribe = useCallback((ws: WsHook) => {
const unsubSnapshot = ws.on("voice_state", (data) => {
const state = data as { activeSpeakers?: ActiveSpeaker[] };
if (Array.isArray(state?.activeSpeakers)) {
setSpeakers(state.activeSpeakers);
}
});
const unsub = ws.on("voice_active_user", (data) => {
const speaker = data as ActiveSpeaker;
setSpeakers((prev) => {
@@ -49,6 +71,7 @@ export function useSpeakers() {
});
});
return () => {
unsubSnapshot();
unsub();
setSpeakers([]);
};