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