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:
@@ -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([]);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user