refactor(frontend): finish shadcn→custom primitive migration (green build)
- Remove tw-animate-css import + dead src/components/ui shadcn tree - Convert 7 orphaned components (moderation, analysis, guild-selector, voice/activity-timeline, shared/empty+error) to new primitives - Add missing moderation/view.tsx; analysis uses SearchPanel directly - globals.css now uses new signal-driven ops-console tokens - tsc --noEmit clean, next build green (11 routes), local smoke 200
This commit is contained in:
@@ -1,24 +1,26 @@
|
||||
/**
|
||||
* 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.
|
||||
* Dashboard — Server Component.
|
||||
* Fetches initial stats + activity on the server (SSR first paint), hands to
|
||||
* the hydrated client View. Keeps the documented server-seed data flow.
|
||||
*/
|
||||
import { getActivity, getDashboardStats } from "@/lib/api/server";
|
||||
import DashboardView from "./view";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const [stats, activity] = await Promise.allSettled([
|
||||
getDashboardStats(),
|
||||
getActivity(14),
|
||||
getDashboardStats().catch(() => undefined),
|
||||
getActivity(14).catch(() => undefined),
|
||||
]);
|
||||
|
||||
return (
|
||||
<DashboardView
|
||||
initialStats={stats.status === "fulfilled" ? stats.value : undefined}
|
||||
initialStats={
|
||||
stats.status === "fulfilled" && stats.value ? stats.value : undefined
|
||||
}
|
||||
initialActivity={
|
||||
activity.status === "fulfilled" ? activity.value : undefined
|
||||
activity.status === "fulfilled" && activity.value
|
||||
? activity.value
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,44 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Hash,
|
||||
Heart,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Activity as ActivityIcon,
|
||||
Flag,
|
||||
MessagesSquare,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { RadialGauge } from "@/components/charts/radial-gauge";
|
||||
import { Sparkline } from "@/components/charts/sparkline";
|
||||
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 { StaggerGroup, StaggerItem } from "@/components/motion/stagger";
|
||||
import { SignalField } from "@/components/three";
|
||||
import { useActivity, useStats } from "@/hooks";
|
||||
import type { DashboardActivity, DashboardStats } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type DashboardTab = "stats" | "users" | "channels" | "reactions";
|
||||
type Tab = "stats" | "users" | "channels" | "reactions";
|
||||
|
||||
const DAY_RANGES = [7, 14, 30] as const;
|
||||
const DAYS = [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)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Dashboard view — hydrated on the client but seeded with server-rendered
|
||||
* initial data. SWR takes over for revalidation after first paint.
|
||||
*/
|
||||
export default function DashboardView({
|
||||
initialStats,
|
||||
initialActivity,
|
||||
@@ -46,143 +35,216 @@ export default function DashboardView({
|
||||
initialStats?: DashboardStats;
|
||||
initialActivity?: DashboardActivity;
|
||||
}) {
|
||||
const [tab, setTab] = useState<DashboardTab>("stats");
|
||||
const [tab, setTab] = useState<Tab>("stats");
|
||||
const [days, setDays] = useState<number>(14);
|
||||
const { data: stats, error, mutate: refetch } = useStats(initialStats);
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
const { data: stats } = useStats(initialStats);
|
||||
const { data: activity } = useActivity(
|
||||
days,
|
||||
days === 14 ? initialActivity : undefined,
|
||||
);
|
||||
|
||||
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 clean = stats?.total_clean ?? 0;
|
||||
const flagged = stats?.total_flagged ?? 0;
|
||||
const warned = stats?.total_warned ?? 0;
|
||||
const total = clean + flagged + warned || 1;
|
||||
const health = clean / total;
|
||||
const activityRatio = Math.min(
|
||||
1,
|
||||
(activity?.daily.at(-1)?.messages ?? 0) /
|
||||
(Math.max(...(activity?.daily.map((d) => d.messages) ?? [1]), 1) || 1),
|
||||
);
|
||||
|
||||
const daily = activity?.daily ?? [];
|
||||
const spark = daily.map((d) => d.messages);
|
||||
const flaggedSpark = daily.map((d) => d.flagged);
|
||||
const usersSpark = daily.map((d) => d.active_users);
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
id: "stats",
|
||||
label: "Stats",
|
||||
icon: <MessagesSquare className="size-3.5" />,
|
||||
},
|
||||
{ id: "users", label: "Users", icon: <Users className="size-3.5" /> },
|
||||
{
|
||||
id: "channels",
|
||||
label: "Channels",
|
||||
icon: <ActivityIcon className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
id: "reactions",
|
||||
label: "Reactions",
|
||||
icon: <Flag className="size-3.5" />,
|
||||
},
|
||||
];
|
||||
|
||||
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)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in-up">
|
||||
<SubNav
|
||||
tabs={subNavTabs}
|
||||
activeTab={tab}
|
||||
onTabChange={(t) => setTab(t as DashboardTab)}
|
||||
/>
|
||||
<div className="flex flex-col gap-5">
|
||||
<Hero stats={stats} activityRatio={activityRatio} health={health} />
|
||||
|
||||
{tab === "stats" && (
|
||||
<div className="space-y-4">
|
||||
{error ? (
|
||||
<ErrorState message={error.message} onRetry={refetch} />
|
||||
) : !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">
|
||||
{activity && <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">
|
||||
{activity && <HourlyActivityChart data={activity.hourly} />}
|
||||
</div>
|
||||
<TopChannelsChart
|
||||
data={stats.top_channels.map((c) => ({
|
||||
name: c.channel_name ?? c.channel_id,
|
||||
count: c.message_count,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Tabs */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex gap-1 rounded-[var(--radius-r)] bg-[var(--color-surface-2)] p-1">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setTab(t.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-[var(--radius-r-control)] px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
tab === t.id
|
||||
? "bg-[var(--color-signal)] text-[var(--color-signal-ink)]"
|
||||
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
|
||||
)}
|
||||
>
|
||||
{t.icon}
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ms-auto flex gap-1">
|
||||
{DAYS.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
onClick={() => setDays(d)}
|
||||
className={cn(
|
||||
"rounded-[var(--radius-r-control)] px-2.5 py-1 text-xs font-mono transition-colors",
|
||||
days === d
|
||||
? "bg-[var(--color-surface-2)] text-[var(--color-ink)]"
|
||||
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
|
||||
)}
|
||||
>
|
||||
{d}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ticker row (stats tab) */}
|
||||
{tab === "stats" && (
|
||||
<StaggerGroup className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Ticker
|
||||
icon={<MessagesSquare className="size-4" />}
|
||||
label="Messages"
|
||||
value={stats?.total_messages ?? 0}
|
||||
data={spark}
|
||||
/>
|
||||
<Ticker
|
||||
icon={<Flag className="size-4" />}
|
||||
label="Flagged"
|
||||
value={flagged}
|
||||
data={flaggedSpark}
|
||||
tone="vermilion"
|
||||
/>
|
||||
<Ticker
|
||||
icon={<Users className="size-4" />}
|
||||
label="Active 24h"
|
||||
value={stats?.active_users_24h ?? 0}
|
||||
data={usersSpark}
|
||||
tone="amber"
|
||||
/>
|
||||
<Ticker
|
||||
icon={<ShieldCheck className="size-4" />}
|
||||
label="Recordings"
|
||||
value={stats?.total_voice_recordings ?? 0}
|
||||
data={spark}
|
||||
/>
|
||||
</StaggerGroup>
|
||||
)}
|
||||
|
||||
{tab === "users" && <UsersSection />}
|
||||
|
||||
{tab === "channels" && <ChannelsSection />}
|
||||
|
||||
{tab === "reactions" && <ReactionsSection />}
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<div className="space-y-4 xl:col-span-2">
|
||||
{tab === "stats" && (
|
||||
<>
|
||||
<ActivityChart data={daily} />
|
||||
<HourlyActivityChart data={activity?.hourly ?? []} />
|
||||
<TopChannelsChart channels={stats?.top_channels ?? []} />
|
||||
</>
|
||||
)}
|
||||
{tab === "users" && <UsersSection />}
|
||||
{tab === "channels" && <ChannelsSection />}
|
||||
{tab === "reactions" && <ReactionsSection />}
|
||||
</div>
|
||||
<div className="xl:col-span-1">
|
||||
<ModerationDonut stats={stats} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Hero({
|
||||
stats,
|
||||
activityRatio,
|
||||
health,
|
||||
}: {
|
||||
stats?: DashboardStats;
|
||||
activityRatio: number;
|
||||
health: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-[var(--radius-r)] bg-[var(--color-surface)] p-5">
|
||||
<div className="absolute inset-0 opacity-50">
|
||||
<SignalField activity={activityRatio} className="size-full" />
|
||||
</div>
|
||||
<div className="relative z-10 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="display text-3xl">Bete Console</div>
|
||||
<div className="mt-1 text-sm text-[var(--color-ink-soft)]">
|
||||
{stats?.total_messages?.toLocaleString() ?? 0} messages watched ·{" "}
|
||||
{stats?.total_users?.toLocaleString() ?? 0} users
|
||||
</div>
|
||||
</div>
|
||||
<RadialGauge
|
||||
value={health}
|
||||
size={132}
|
||||
label="Clean"
|
||||
tone={health > 0.8 ? "signal" : health > 0.6 ? "amber" : "vermilion"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Ticker({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
data,
|
||||
tone = "signal",
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: number;
|
||||
data: number[];
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
}) {
|
||||
const color = {
|
||||
signal: "var(--color-signal)",
|
||||
amber: "var(--color-amber)",
|
||||
vermilion: "var(--color-vermilion)",
|
||||
}[tone];
|
||||
return (
|
||||
<StaggerItem className="surface scan-tick flex flex-col gap-2 p-4">
|
||||
<div className="flex items-center gap-2 text-[var(--color-ink-soft)]">
|
||||
<span style={{ color }}>{icon}</span>
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="display text-2xl" style={{ color }}>
|
||||
{value.toLocaleString()}
|
||||
</div>
|
||||
<Sparkline
|
||||
data={data}
|
||||
width={220}
|
||||
height={32}
|
||||
stroke={color}
|
||||
className="w-full"
|
||||
/>
|
||||
</StaggerItem>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user