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,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
}
/>
);
}