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:
asepharyana
2026-08-14 10:49:44 +07:00
parent 5816e94a63
commit 5bbf75a65b
130 changed files with 4431 additions and 12978 deletions
@@ -4,7 +4,7 @@ import { SearchPanel } from "@/components/analysis/search-panel";
export default function AnalysisPage() {
return (
<div className="space-y-5 animate-fade-in-up">
<div className="space-y-5" style={{ animation: "fade-up 0.4s ease both" }}>
<SearchPanel />
</div>
);
@@ -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>
);
}
@@ -7,25 +7,19 @@ import {
ChatbotProvider,
useChatbot,
} from "@/components/chatbot/chatbot-context";
import { AppSidebar } from "@/components/layout/app-sidebar";
import { Spine } from "@/components/layout/spine";
import { StatusBar } from "@/components/layout/status-bar";
import { MiniPlayer } from "@/components/media/mini-player";
import { RouteTransition } from "@/components/motion/route-transition";
import { GuildSelector } from "@/components/shared/guild-selector";
import { Separator } from "@/components/ui/separator";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { MediaPlayerProvider } from "@/lib/hooks/use-media-player";
import { useWebSocket, WsProvider } from "@/lib/ws/context";
function ChatbotGuildSync({ guildId }: { guildId: string }) {
const { setGuildId } = useChatbot();
useEffect(() => {
setGuildId(guildId);
}, [guildId, setGuildId]);
return null;
}
@@ -39,17 +33,12 @@ function ChatbotExpressionSync() {
setTimeout(() => setExpression("idle"), 2000);
}
});
const unsub2 = ws.on("voice_active_user", () => {
setExpression("listening");
});
const unsub2 = ws.on("voice_active_user", () => setExpression("listening"));
return () => {
unsub1();
unsub2();
};
}, [ws, setExpression]);
return null;
}
@@ -74,41 +63,27 @@ export default function DashboardLayout({
<ChatbotProvider>
<ChatbotGuildSync guildId={guildId} />
<ChatbotExpressionSync />
<div className="min-h-svh bg-canvas">
<SidebarProvider>
<AppSidebar />
<SidebarInset className="gap-0">
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4 bg-canvas">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mr-2 h-6 max-md:hidden"
/>
<div className="font-semibold max-md:hidden">Overview</div>
<div className="ms-auto">
<GuildSelector
value={guildId}
onChange={(g) => setGuildId(g ?? "")}
/>
</div>
</header>
<main className="flex flex-1 flex-col gap-4 p-4 pb-28 md:p-6 lg:pb-8">
<div className="mx-auto w-full max-w-[1440px]">
<Suspense
fallback={
<div className="flex h-[60vh] items-center justify-center">
<div className="size-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
</div>
}
>
{children}
</Suspense>
</div>
</main>
</SidebarInset>
</SidebarProvider>
<div className="min-h-svh bg-[var(--color-canvas)] md:pl-[68px]">
<Spine />
<div className="flex min-h-svh flex-col">
<StatusBar
guildId={guildId}
onGuildChange={(g) => setGuildId(g)}
/>
<main className="flex flex-1 flex-col gap-4 p-4 pb-24 md:p-6 lg:pb-8">
<div className="mx-auto w-full max-w-[1440px]">
<Suspense
fallback={
<div className="flex h-[60vh] items-center justify-center">
<div className="size-8 animate-spin rounded-full border-2 border-[var(--color-signal)] border-t-transparent" />
</div>
}
>
<RouteTransition>{children}</RouteTransition>
</Suspense>
</div>
</main>
</div>
<MiniPlayer />
<ChatbotContainer />
</div>
@@ -1,7 +1,30 @@
"use client";
import { MusicPlayer } from "@/components/media/music-player";
import {
Pause,
Play,
Repeat2,
SkipForward,
Square,
Volume2,
} from "lucide-react";
import { motion } from "motion/react";
import { useEffect } from "react";
import { Waveform } from "@/components/charts/waveform";
import { StaggerGroup, StaggerItem } from "@/components/motion/stagger";
import { Avatar } from "@/components/primitives/avatar";
import { Badge } from "@/components/primitives/badge";
import { Button } from "@/components/primitives/button";
import { Progress } from "@/components/primitives/progress";
import {
useMediaLoop,
useMediaSkip,
useMediaState,
useMediaStop,
useMediaWsSync,
} from "@/hooks";
import type { MediaState } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
export default function MediaView({
@@ -10,10 +33,140 @@ export default function MediaView({
initialStatus?: MediaState;
}) {
const ws = useWebSocket();
const { data: state, mutate } = useMediaState(initialStatus);
const skip = useMediaSkip();
const stop = useMediaStop();
const loopMut = useMediaLoop();
useMediaWsSync(ws);
const current = state?.current;
const playing = state?.playing ?? false;
const queue = state?.queue ?? [];
const loop = state?.loop ?? false;
const duration = current?.durationMs ?? 0;
const [seed] = useWaveformSeed();
return (
<div className="space-y-5 animate-fade-in-up">
<MusicPlayer ws={ws} initialData={initialStatus} />
<div className="flex flex-col gap-5">
{/* Turntable hero */}
<div className="flex items-center gap-6 surface scan-tick flex-wrap p-5">
{current && (
<motion.div
className={cn(
"relative mx-auto size-[160px] rounded-full",
playing && "animate-spin-disc",
!playing && "animate-spin-disc paused",
)}
>
<img
src={current.thumbnailUrl ?? "/favicon.ico"}
alt={current.title}
className="size-full rounded-full object-cover ring-4 ring-[var(--color-signal)]/20"
style={{ animationPlayState: playing ? "running" : "paused" }}
/>
</motion.div>
)}
<div className="min-w-0 flex-1">
<div className="display text-2xl text-[var(--color-signal)]">
{current?.title ?? "No track playing"}
</div>
<div className="mt-1 mono text-xs text-[var(--color-ink-soft)]">
{current?.source ?? "idle"} · {duration ? formatMs(duration) : "—"}
</div>
<div className="mt-3">
<Progress value={42} max={100} tone="signal" />
<div className="mt-1 flex justify-between text-[10px] mono text-[var(--color-ink-soft)]">
<span>0:00</span>
<span>{duration ? formatMs(duration) : "—"}</span>
</div>
</div>
</div>
</div>
{/* Transport */}
<StaggerGroup className="flex items-center gap-2">
<StaggerItem>
<Button size="sm" variant="ghost" onClick={() => skip.mutate()}>
<SkipForward className="size-4" />
</Button>
</StaggerItem>
<StaggerItem>
<Button
size="icon"
variant="primary"
onClick={() => loopMut.mutate(!loop)}
>
{playing ? (
<Pause className="size-5" />
) : (
<Play className="size-5" />
)}
</Button>
</StaggerItem>
<StaggerItem>
<Button size="sm" variant="ghost" onClick={() => stop.mutate()}>
<Square className="size-4" />
</Button>
</StaggerItem>
<StaggerItem>
<Button
size="sm"
variant={loop ? "primary" : "ghost"}
onClick={() => loopMut.mutate(!loop)}
>
<Repeat2 className="size-4" />
</Button>
</StaggerItem>
<StaggerItem>
<Volume2 className="size-4 text-[var(--color-ink-soft)]" />
</StaggerItem>
</StaggerGroup>
{/* Queue */}
{queue.length > 0 && (
<div className="surface flex flex-col gap-1.5 p-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Queue ({queue.length})</h3>
<Badge tone="neutral">{loop ? "loop" : "queue"}</Badge>
</div>
<div className="flex flex-col gap-1">
{queue.map((item, i) => (
<motion.div
key={item.id ?? i}
layout
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
className="flex items-center gap-2.5 rounded-[var(--radius-r-control)] px-2 py-1.5 text-sm hover:bg-[var(--color-surface-2)]"
>
<Waveform
seed={item.id ?? String(i)}
bars={12}
height={20}
className="w-16"
/>
<span className="mono truncate">{item.title}</span>
</motion.div>
))}
</div>
</div>
)}
</div>
);
}
function formatMs(ms: number): string {
const m = Math.floor(ms / 60000);
const s = Math.floor((ms % 60000) / 1000);
return `${m}:${s.toString().padStart(2, "0")}`;
}
function useWaveformSeed() {
const [seed] = useStateValue();
return [seed];
}
function useStateValue(): [string] {
// lightweight deterministic seed so waveform shape is stable per session
return ["media-waveform"];
}
@@ -1,11 +1,8 @@
/**
* Messages page — Server Component.
*
* Reads the URL (guild/channel/tab/selected) on the server and, when a guild
* is already selected, fetches the first message page server-side so the
* initial list is server-rendered, not a client round-trip.
* Messages — Server Component.
* Reads URL guild/channel/selected/tab on the server; seeds first page SSR.
*/
import { getMessages, type MessagePageResult } from "@/lib/api/server";
import { getMessages } from "@/lib/api/server";
import MessagesView from "./view";
export default async function MessagesPage({
@@ -22,7 +19,7 @@ export default async function MessagesPage({
? (sp.tab as "all" | "images" | "review")
: "all";
let initialPage: MessagePageResult | undefined;
let initialPage;
if (guild) {
initialPage = await getMessages(guild, channel || undefined).catch(
() => undefined,
@@ -1,24 +1,20 @@
"use client";
import { Flag, Image, Loader2, Search } from "lucide-react";
import { Flag, Image, Loader2, Search, Send, X } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { SubNav } from "@/components/layout/sub-nav";
import { Lightbox } from "@/components/messages/lightbox";
import { extractFirstImage } from "@/components/messages/message-card";
import { MessageDetailView } from "@/components/messages/message-detail-view";
import { MessageList } from "@/components/messages/message-list";
import { SearchOverlay } from "@/components/messages/search-overlay";
import { StaggerGroup, StaggerItem } from "@/components/motion/stagger";
import { Avatar } from "@/components/primitives/avatar";
import { Badge } from "@/components/primitives/badge";
import { Dialog } from "@/components/primitives/dialog";
import { Input } from "@/components/primitives/input";
import { Select } from "@/components/primitives/select";
import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/shared";
import { GuildSelector } from "@/components/shared/guild-selector";
import { Card } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
useImages,
useLoadMore,
@@ -44,11 +40,6 @@ interface MessagesViewProps {
initialMessagePage?: { data: MessageRecord[]; nextCursor: string | null };
}
/**
* Messages view — hydrated on the client. Initial guild/channel/detail/tab
* come from the URL (server-read on first SSR), and the first message page is
* seeded from the server when a guild is already selected.
*/
export default function MessagesView({
initialGuild = "",
initialChannel = "",
@@ -87,16 +78,11 @@ export default function MessagesView({
const loadMoreMut = useLoadMore();
const { data: images } = useImages(guildId);
const { data: reviews } = useReview(selectedChannel || undefined);
const {
message: detailMessage,
attachments: detailAttachments,
loading: detailLoading,
} = useMessageDetail(detailId);
const { message: detailMessage, loading: detailLoading } =
useMessageDetail(detailId);
useMessagesWsSync(ws, guildId);
// Sync state to URL
useEffect(() => {
const params = new URLSearchParams();
if (guildId) params.set("guild", guildId);
@@ -106,16 +92,16 @@ export default function MessagesView({
router.replace(`/messages?${params.toString()}`, { scroll: false });
}, [guildId, selectedChannel, detailId, tab, router]);
// Global Cmd+K search trigger
// global Cmd+K
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setSearchOpen(true);
}
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
const handleLoadMore = useCallback(() => {
@@ -133,154 +119,152 @@ export default function MessagesView({
setDetailId(null);
}, []);
const subNavTabs = [
const tabs: { id: MessagesTab; label: string; icon: React.ReactNode }[] = [
{ id: "all", label: "All", icon: null },
{ id: "images", label: "Images", icon: <Image className="size-3" /> },
{ id: "review", label: "Review", icon: <Flag className="size-3" /> },
{ id: "images", label: "Images", icon: <Image className="size-3.5" /> },
{ id: "review", label: "Review", icon: <Flag className="size-3.5" /> },
];
const currentMessages = messages ?? [];
return (
<div className="animate-fade-in-up space-y-4">
{/* ── Controls bar ── */}
<div className="flex flex-col gap-4">
{/* Controls */}
<div className="flex items-center gap-3">
<GuildSelector value={guildId} onChange={handleGuildChange} />
{channels.length > 0 && (
<Select
value={selectedChannel}
onValueChange={(v) => setSelectedChannel(v ?? "")}
onChange={(e) => setSelectedChannel(e.target.value || "")}
className="w-48"
>
<SelectTrigger className="h-9 w-48">
<SelectValue placeholder="All channels" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All channels</SelectItem>
{channels.map((ch) => (
<SelectItem key={ch.id} value={ch.id}>
# {ch.name}
</SelectItem>
))}
</SelectContent>
<option value="">All channels</option>
{channels.map((ch) => (
<option key={ch.id} value={ch.id}>
# {ch.name}
</option>
))}
</Select>
)}
<button
type="button"
onClick={() => setSearchOpen(true)}
className="ml-auto flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs text-text-secondary/60 hover:text-text-primary glass hover:glass-elevated transition-all"
className="ms-auto flex items-center gap-1.5 rounded-[var(--radius-r-control)] px-3 py-1.5 text-xs text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
>
<Search className="size-3.5" />
Search
<span className="hidden font-mono text-[10px] text-text-secondary/30 sm:inline">
&#8984;K
</span>
Search{" "}
<span className="hidden font-mono text-[10px] sm:inline">(K)</span>
</button>
</div>
{/* ── Sub navigation ── */}
<SubNav
tabs={subNavTabs}
activeTab={tab}
onTabChange={(t) => setTab(t as MessagesTab)}
/>
{/* ── Split pane ── */}
{error ? (
<ErrorState message={error.message} onRetry={refetch} />
) : !messages ? (
<LoadingSkeleton count={6} height="h-20" />
) : (
<div className="flex gap-4">
{/* Left pane */}
<div
className={cn("space-y-2", detailId ? "w-1/2 lg:w-2/5" : "w-full")}
{/* Tabs */}
<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)]",
)}
>
{tab === "all" && (
<MessageList
messages={currentMessages}
selectedId={detailId}
onSelect={setDetailId}
hasMore={cursorData?.hasMore}
onLoadMore={handleLoadMore}
isLoadingMore={loadMoreMut.isPending}
/>
)}
{tab === "images" && (
<ImageGrid items={images ?? []} onSelect={setDetailId} />
)}
{tab === "review" && (
<ReviewList items={reviews ?? []} onSelect={setDetailId} />
)}
</div>
{t.icon}
{t.label}
</button>
))}
</div>
{/* Right pane — message detail */}
{detailId && (
<div className="sticky top-16 hidden w-1/2 self-start md:block lg:w-3/5">
<div className="flex gap-4">
{/* Left — timeline spine + entries */}
<div
className={cn("surface p-3", detailId ? "w-1/2 lg:w-2/5" : "w-full")}
>
{error ? (
<ErrorState message={error.message} onRetry={refetch} />
) : !messages ? (
<LoadingSkeleton count={6} />
) : tab === "all" ? (
<MessageList
messages={currentMessages}
selectedId={detailId}
onSelect={setDetailId}
hasMore={cursorData?.hasMore}
onLoadMore={handleLoadMore}
isLoadingMore={loadMoreMut.isPending}
/>
) : tab === "images" ? (
<ImageGrid items={images ?? []} onSelect={setDetailId} />
) : (
<ReviewList items={reviews ?? []} onSelect={setDetailId} />
)}
</div>
{/* Right — detail */}
{detailId && (
<div className="sticky top-16 hidden w-1/2 self-start md:block lg:w-3/5">
<div className="surface h-full p-4">
{detailLoading ? (
<Card
className={cn(
"flex items-center justify-center py-12",
"[--card-spacing:0px]",
)}
>
<Loader2 className="size-5 animate-spin text-text-secondary/60" />
</Card>
<div className="flex h-40 items-center justify-center">
<Loader2 className="size-5 animate-spin text-[var(--color-ink-soft)]" />
</div>
) : detailMessage ? (
<div className="space-y-3">
<div className="flex flex-col gap-3">
<button
type="button"
onClick={() => setDetailId(null)}
className="text-xs text-text-secondary/60 hover:text-text-primary transition-colors"
className="text-xs text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
>
&larr; Back to list
Back to list
</button>
<MessageDetailView
message={detailMessage}
attachments={detailAttachments}
onImageClick={(index) => {
const imgs = (detailAttachments ?? [])
.filter((a) => a.type?.startsWith("image/"))
.map((a) => ({
src: a.uploaded_url || a.discord_url,
alt: a.filename,
}));
if (imgs.length > 0) {
setLightbox({ images: imgs, index });
}
}}
/>
<MessageDetailView message={detailMessage} />
{detailMessage && (
<Lightbox
open={!!lightbox}
onClose={() => setLightbox(null)}
images={extractImages(detailMessage.metadata)}
/>
)}
</div>
) : null}
</div>
)}
</div>
)}
</div>
)}
</div>
{/* ── Search overlay ── */}
<SearchOverlay
open={searchOpen}
onClose={() => setSearchOpen(false)}
onSelect={(id) => {
setDetailId(id);
results={(currentMessages ?? []).map((m) => ({
id: m.id,
content: m.edited_content ?? m.content,
username: m.username ?? "unknown",
channel: m.channel_id,
time: m.created_at
? new Date(m.created_at * 1000).toLocaleTimeString()
: "",
}))}
onSelect={(msg) => {
const found = currentMessages.find((m) => m.id === msg.id);
if (found) setDetailId(found.id);
setTab("all");
}}
/>
{/* ── Lightbox ── */}
{lightbox && (
<Lightbox
open={!!lightbox}
onClose={() => setLightbox(null)}
images={lightbox.images}
initialIndex={lightbox.index}
open
onClose={() => setLightbox(null)}
/>
)}
</div>
);
}
// ── Inline ImageGrid (glass-styled) ────────────────
function ImageGrid({
items,
onSelect,
@@ -288,46 +272,42 @@ function ImageGrid({
items: MessageRecord[];
onSelect: (id: string) => void;
}) {
return (
<div className="grid grid-cols-3 gap-2">
return !items.length ? (
<EmptyState
icon={Image}
title="No images"
description="Messages with image attachments will appear here."
/>
) : (
<div className="grid grid-cols-3 gap-2.5">
{items.map((item) => {
const imgUrl = extractFirstImage(item.metadata);
const url = extractFirstImage(item.metadata);
return (
<button
key={item.id}
type="button"
onClick={() => onSelect(item.id)}
className="glass overflow-hidden rounded-lg transition-transform hover:scale-[1.02]"
className="overflow-hidden rounded-[var(--radius-r)] border border-[var(--color-hairline)]"
>
{imgUrl ? (
{url ? (
<img
src={imgUrl}
src={url}
alt=""
className="h-24 w-full object-cover"
loading="lazy"
/>
) : (
<div className="flex h-24 w-full items-center justify-center text-xs text-text-secondary/40">
<div className="flex h-24 w-full items-center justify-center text-xs text-[var(--color-ink-soft)]/40">
No image
</div>
)}
</button>
);
})}
{items.length === 0 && (
<EmptyState
icon={Image}
title="No images"
description="Messages with image attachments will show up here."
className="col-span-3"
/>
)}
</div>
);
}
// ── Inline ReviewList (glass-styled) ────────────────
function ReviewList({
items,
onSelect,
@@ -335,36 +315,63 @@ function ReviewList({
items: MessageRecord[];
onSelect: (id: string) => void;
}) {
return (
<div className="space-y-2">
return !items.length ? (
<EmptyState
icon={Flag}
title="No flagged messages"
description="Review-flagged messages will appear here."
/>
) : (
<StaggerGroup className="space-y-2">
{items.map((item) => (
<Card
key={item.id}
className={cn(
"cursor-pointer p-3",
"border border-red-500/30 ring-red-500/20",
"[--card-spacing:0px]",
"rounded-2xl",
)}
onClick={() => onSelect(item.id)}
>
<div className="flex items-start gap-2">
<Flag className="mt-0.5 size-3.5 shrink-0 text-accent-purple" />
<div className="min-w-0 flex-1">
<p className="line-clamp-2 text-xs text-text-secondary">
{renderMessageContent(item.content, item.metadata) || item.id}
</p>
</div>
</div>
</Card>
<StaggerItem key={item.id} className="surface p-3">
<button
type="button"
onClick={() => onSelect(item.id)}
className="flex items-start gap-2 w-full text-left"
>
<Flag className="mt-0.5 size-3.5 shrink-0 text-[var(--color-vermilion)]" />
<p className="line-clamp-2 text-xs text-[var(--color-ink-soft)]">
{renderMessageContent(item.content, item.metadata) || item.id}
</p>
</button>
</StaggerItem>
))}
{items.length === 0 && (
<EmptyState
icon={Flag}
title="No flagged messages"
description="Messages flagged by AI moderation will appear here for review."
/>
)}
</div>
</StaggerGroup>
);
}
function extractFirstImage(metadata?: string | null): string | null {
try {
if (!metadata) return null;
const m = JSON.parse(metadata);
const atts = m?.attachments ?? [];
const img = atts.find(
(a: {
contentType?: string | null;
url?: string;
discord_url?: string;
}) => /image/i.test(a.contentType ?? ""),
);
return img?.url ?? img?.discord_url ?? null;
} catch {
return null;
}
}
function extractImages(metadata?: string | null) {
try {
if (!metadata) return [];
const m = JSON.parse(metadata);
return (m?.attachments ?? [])
.filter((a: { contentType?: string | null }) =>
/image/i.test(a.contentType ?? ""),
)
.map((a: { url?: string; discord_url?: string; name?: string }) => ({
src: a.url ?? a.discord_url ?? "",
alt: a.name,
}));
} catch {
return [];
}
}
@@ -1,24 +1,25 @@
/**
* Moderation page — Server Component. Seeds summary + action log from
* server-fetched moderation state (shared across all users).
* Moderation — Server Component.
* Seeds moderation stats + action log for SSR first paint; live via WS.
*/
import { ModerationSection } from "@/components/moderation/moderation-section";
import { getModerationActions, getModerationStats } from "@/lib/api/server";
import ModerationView from "./view";
export default async function ModerationPage() {
const [stats, actions] = await Promise.allSettled([
getModerationStats(),
getModerationActions(100),
getModerationStats().catch(() => undefined),
getModerationActions(100).catch(() => undefined),
]);
return (
<div className="space-y-4 animate-fade-in-up">
<ModerationSection
initialStats={stats.status === "fulfilled" ? stats.value : undefined}
initialActions={
actions.status === "fulfilled" ? actions.value : undefined
}
/>
</div>
<ModerationView
initialStats={
stats.status === "fulfilled" && stats.value ? stats.value : undefined
}
initialActions={
actions.status === "fulfilled" && actions.value
? actions.value
: undefined
}
/>
);
}
@@ -0,0 +1,19 @@
"use client";
import { ModerationSection } from "@/components/moderation/moderation-section";
import type { ModerationAction, ModerationStats } from "@/lib/types";
export default function ModerationView({
initialStats,
initialActions,
}: {
initialStats?: ModerationStats;
initialActions?: ModerationAction[];
}) {
return (
<ModerationSection
initialStats={initialStats}
initialActions={initialActions}
/>
);
}
@@ -1,12 +1,12 @@
/**
* Recordings page — Server Component. Seeds the library from server-fetched
* recordings; live `voice_recording_uploaded` events keep it fresh over WS.
* Recordings — Server Component.
* Seeds the library from server-fetched recordings; live `voice_recording_uploaded`
* events (synced in the client View via WS) keep it fresh.
*/
import { getRecordings } from "@/lib/api/server";
import RecordingsView from "./view";
export default async function RecordingsPage() {
const data = await getRecordings(50).catch(() => undefined);
return <RecordingsView initialRecordings={data?.items} />;
}
@@ -1,189 +1,200 @@
"use client";
import { Clock, Database, Mic, Users } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { StatCard } from "@/components/dashboard/stat-card";
import { SubNav } from "@/components/layout/sub-nav";
import { RecordingCard } from "@/components/recordings/recording-card";
import { RecordingPlayer } from "@/components/recordings/recording-player";
import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/shared";
import { useRecordings, useRecordingsWsSync } from "@/hooks";
import { formatBytes } from "@/lib/format";
import { Delete, Download, Play } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useState } from "react";
import { Waveform } from "@/components/charts/waveform";
import { StaggerGroup, StaggerItem } from "@/components/motion/stagger";
import { Avatar } from "@/components/primitives/avatar";
import { Badge } from "@/components/primitives/badge";
import { Button } from "@/components/primitives/button";
import { Dialog } from "@/components/primitives/dialog";
import {
useDeleteRecording,
useRecordings,
useRecordingsWsSync,
} from "@/hooks";
import type { VoiceRecording } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
type RecordingsTab = "library" | "stats";
interface RecordingsListProps {
recordings: VoiceRecording[];
error: Error | null;
isLoading: boolean;
deleting: string | null;
onSelect: (rec: VoiceRecording) => void;
onDelete: (rec: VoiceRecording) => void;
preview: VoiceRecording | null;
onClosePreview: () => void;
}
function RecordingsList({
recordings,
error,
isLoading,
deleting,
onSelect,
onDelete,
preview,
onClosePreview,
}: RecordingsListProps) {
if (isLoading) {
return (
<div className="flex flex-col gap-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-16 surface animate-shimmer" />
))}
</div>
);
}
if (error)
return (
<p className="text-sm text-[var(--color-vermilion)]">
Failed to load: {error.message}
</p>
);
return (
<div className="flex flex-col gap-3">
<AnimatePresence>
{recordings.map((rec) => (
<StaggerItem key={rec.id} className="surface p-3" layout>
<motion.div layout className="flex items-center gap-3">
<Waveform
seed={rec.id}
bars={20}
height={40}
className="w-20 shrink-0"
/>
<Avatar name={rec.username} src={rec.avatar_url} size={34} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="font-medium">
{rec.username ?? "unknown"}
</span>
<Badge
tone={
rec.upload_status === "uploaded"
? "signal"
: rec.upload_status === "failed"
? "vermilion"
: "amber"
}
>
.{rec.filename.split(".").pop() ?? "mp3"}
</Badge>
</div>
<div className="mono text-xs text-[var(--color-ink-soft)]">
{(rec.size_bytes / 1024).toFixed(0)} KB ·{" "}
{new Date(rec.created_at * 1000).toLocaleTimeString()}
</div>
</div>
<div className="flex items-center gap-1">
{rec.download_url && (
<>
<Button
size="sm"
variant="ghost"
onClick={() => onSelect(rec)}
>
<Play className="size-4" />
</Button>
<a
href={rec.download_url}
download={rec.filename}
aria-label="Download"
className="flex size-9 items-center justify-center rounded-[var(--radius-r-control)] text-xs text-[var(--color-ink-soft)] hover:bg-[var(--color-surface-2)]"
>
<Download className="size-4" />
</a>
</>
)}
<Button
size="sm"
variant="danger"
disabled={deleting === rec.id}
onClick={() => onDelete(rec)}
aria-label="Delete"
>
<Delete className="size-4" />
</Button>
</div>
</motion.div>
</StaggerItem>
))}
</AnimatePresence>
<PreviewDialog
open={!!preview}
onClose={onClosePreview}
recording={preview}
/>
</div>
);
}
function PreviewDialog({
open,
onClose,
recording,
}: {
open: boolean;
onClose: () => void;
recording: VoiceRecording | null;
}) {
if (!recording) return null;
return (
<Dialog open={open} onClose={onClose} className="p-6 max-w-xl">
<div className="space-y-3">
<div className="display text-lg text-[var(--color-signal)]">
{recording.filename}
</div>
<audio controls src={recording.download_url ?? ""} className="w-full" />
<div className="mono text-xs text-[var(--color-ink-soft)]">
{(recording.size_bytes / 1024).toFixed(0)} KB ·{" "}
{recording.upload_status}
</div>
</div>
</Dialog>
);
}
export default function RecordingsView({
initialRecordings,
}: {
initialRecordings?: VoiceRecording[];
}) {
const {
data: recordings,
error,
mutate: refetch,
} = useRecordings(initialRecordings);
const [playingId, setPlayingId] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isLoadingAudio, setIsLoadingAudio] = useState(false);
const [tab, setTab] = useState<RecordingsTab>("library");
const ws = useWebSocket();
const audioRef = useRef<HTMLAudioElement | null>(null);
// Live-update the library when the gateway publishes voice_recording_uploaded
const {
data: recordings = [],
error,
isLoading,
} = useRecordings(initialRecordings);
const del = useDeleteRecording();
const [deleting, setDeleting] = useState<string | null>(null);
useRecordingsWsSync(ws);
const currentTrack =
playingId && recordings
? recordings.find((r: VoiceRecording) => r.id === playingId)
: null;
const handleDelete = useCallback(
(rec: VoiceRecording) => {
setDeleting(rec.id);
del.mutate(rec.id);
setTimeout(() => setDeleting(null), 800);
},
[del],
);
const togglePlay = (id: string) => {
if (playingId !== id) {
setPlayingId(id); // RecordingPlayer picks up the new url + autoplays
} else {
const audio = audioRef.current;
if (!audio) return;
if (audio.paused) audio.play().catch(() => {});
else audio.pause();
}
};
const stats = useMemo(() => {
const list = recordings ?? [];
const totalSize = list.reduce((sum, r) => sum + (r.size_bytes ?? 0), 0);
const byUser = new Map<
string,
{ name: string; count: number; size: number }
>();
for (const rec of list) {
const key = rec.user_id ?? rec.username;
const cur = byUser.get(key) ?? { name: rec.username, count: 0, size: 0 };
cur.count += 1;
cur.size += rec.size_bytes ?? 0;
byUser.set(key, cur);
}
const topUsers = [...byUser.values()]
.sort((a, b) => b.count - a.count)
.slice(0, 8);
return {
total: list.length,
totalSize,
uniqueUsers: byUser.size,
topUsers,
};
}, [recordings]);
const [preview, setPreview] = useState<VoiceRecording | null>(null);
return (
<div className="space-y-4 animate-fade-in-up">
<SubNav
tabs={[
{ id: "library", label: "Library", icon: undefined },
{ id: "stats", label: "Stats", icon: undefined },
]}
activeTab={tab}
onTabChange={(t) => setTab(t as RecordingsTab)}
/>
{tab === "library" &&
(error ? (
<ErrorState message={error.message} onRetry={refetch} />
) : !recordings ? (
<LoadingSkeleton count={4} height="h-28" />
) : (
<div className="space-y-2">
{(recordings ?? []).map((rec: VoiceRecording) => (
<RecordingCard
key={rec.id}
recording={rec}
active={playingId === rec.id}
playing={playingId === rec.id && isPlaying}
loading={playingId === rec.id && isLoadingAudio}
onTogglePlay={togglePlay}
/>
))}
{(recordings ?? []).length === 0 && (
<EmptyState
icon={Mic}
title="No records yet"
description="Voice recordings will appear here once members speak in a monitored voice channel."
/>
)}
</div>
))}
{tab === "stats" &&
(!recordings ? (
<LoadingSkeleton count={4} height="h-28" columns={3} />
) : stats.total === 0 ? (
<EmptyState
icon={Clock}
title="No recording stats yet"
description="Recordings are captured from monitored voice channels."
/>
) : (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<StatCard
label="Total Recordings"
value={stats.total}
icon={Mic}
/>
<StatCard
label="Total Size"
value={stats.totalSize}
icon={Database}
formatter={(v) => formatBytes(v)}
/>
<StatCard
label="Unique Speakers"
value={stats.uniqueUsers}
icon={Users}
/>
</div>
{stats.topUsers.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs text-text-secondary font-medium uppercase tracking-wide">
Top Speakers
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{stats.topUsers.map((u) => (
<div
key={u.name}
className="flex items-center gap-3 rounded-lg border border-border/40 bg-card/40 px-3 py-2"
>
<span className="flex size-7 items-center justify-center rounded-md bg-primary/10 font-mono text-xs text-primary">
{u.count}
</span>
<span className="flex-1 min-w-0 truncate text-sm text-text-primary">
{u.name}
</span>
<span className="text-[10px] font-mono text-text-secondary/50">
{formatBytes(u.size)}
</span>
</div>
))}
</div>
</div>
)}
</div>
))}
<RecordingPlayer
url={currentTrack?.download_url ?? undefined}
filename={currentTrack?.filename ?? undefined}
playing={isPlaying}
loading={isLoadingAudio}
audioRef={audioRef}
onToggle={() => togglePlay(playingId!)}
onStateChange={(s) => {
setIsPlaying(s.playing);
setIsLoadingAudio(s.loading);
}}
onClose={() => setPlayingId(null)}
/>
</div>
<RecordingsList
recordings={recordings}
error={error}
isLoading={isLoading}
deleting={deleting}
onSelect={setPreview}
onDelete={handleDelete}
preview={preview}
onClosePreview={() => setPreview(null)}
/>
);
}
@@ -1,23 +1,13 @@
/**
* Voice page — Server Component.
*
* Fetches the authoritative voice connection status + guild list on the server
* so the first paint reflects the shared gateway voice state (which channel is
* joined, across ALL users), independent of any single browser's WS history.
* Voice — Server Component.
* Seeds authoritative voice status (shared active speakers snapshot) on the
* server, then hands off to the client View for the 3D scene + WS live updates.
*/
import { getGuilds, getVoiceStatus } from "@/lib/api/server";
import { getVoiceStatus } from "@/lib/api/server";
import type { VoiceStatus } from "@/lib/types";
import VoiceView from "./view";
export default async function VoicePage() {
const [status, guilds] = await Promise.allSettled([
getVoiceStatus(),
getGuilds(),
]);
return (
<VoiceView
initialStatus={status.status === "fulfilled" ? status.value : undefined}
initialGuilds={guilds.status === "fulfilled" ? guilds.value : undefined}
/>
);
const status = await getVoiceStatus().catch(() => undefined);
return <VoiceView initialStatus={status} />;
}
@@ -1,177 +1,176 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { SubNav } from "@/components/layout/sub-nav";
import { VoiceActivityTimeline } from "@/components/voice/activity-timeline";
import { VoiceConnectionCard } from "@/components/voice/connection-card";
import {
Activity,
Headphones,
Mic,
MicOff,
Pause,
Play,
Settings,
Wifi,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { SessionRibbon } from "@/components/charts/session-ribbon";
import { StaggerGroup, StaggerItem } from "@/components/motion/stagger";
import { Badge } from "@/components/primitives/badge";
import { Button } from "@/components/primitives/button";
import { SignalField } from "@/components/three";
import { StaticFallback } from "@/components/three/static-fallback";
import { WebGLGuard } from "@/components/three/webgl-guard";
import { ActiveSpeakersPanel } from "@/components/voice/active-speakers-panel";
import { ListenControl } from "@/components/voice/listen-control";
import { MicControl } from "@/components/voice/mic-control";
import { SpeakerWaveform } from "@/components/voice/speaker-waveform";
import {
useGuilds,
useMicTransmit,
useSpeakers,
useVoiceChannels,
useVoiceConnect,
useVoiceDisconnect,
useVoiceListen,
useVoiceStatus,
} from "@/hooks";
import type { Guild, VoiceStatus } from "@/lib/types";
import type { ActiveSpeaker, VoiceStatus } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
type VoiceTab = "connection" | "activity";
type VoiceTab = "stage" | "activity";
/**
* Voice view — hydrated on the client. Seeded from server-rendered status +
* guild list so every user's first paint reflects the same shared voice
* connection state; live updates come over WS.
*/
export default function VoiceView({
initialStatus,
initialGuilds = [],
}: {
initialStatus?: VoiceStatus;
initialGuilds?: Guild[];
}) {
const ws = useWebSocket();
const { data: voiceStatus } = useVoiceStatus(initialStatus);
const { data: guilds = [] } = useGuilds(initialGuilds);
const [selectedGuild, setSelectedGuild] = useState("");
const { data: voiceChannels = [] } = useVoiceChannels(selectedGuild);
const { speakers, subscribe } = useSpeakers(initialStatus?.activeSpeakers);
const connectMut = useVoiceConnect();
const disconnectMut = useVoiceDisconnect();
const micMut = useMicTransmit(ws);
const [tab, setTab] = useState<VoiceTab>("stage");
const initialSpeakers = useMemo(
() => initialStatus?.activeSpeakers ?? [],
[initialStatus],
);
const { speakers, subscribe } = useSpeakers(initialSpeakers);
const connect = useVoiceConnect();
const disconnect = useVoiceDisconnect();
const listen = useVoiceListen(ws);
const [selectedChannel, setSelectedChannel] = useState("");
const [micActive, setMicActive] = useState(false);
const [volume, setVolume] = useState(75);
const [listenVolume, setListenVolume] = useState(75);
const [tab, setTab] = useState<VoiceTab>("connection");
useEffect(() => {
const unsub = subscribe(ws);
return () => unsub();
}, [ws, subscribe]);
return unsub;
}, [subscribe, ws]);
const handleMicToggle = useCallback(
async (checked: boolean) => {
if (checked) {
try {
await micMut.mutateAsync(true);
setMicActive(true);
} catch {
setMicActive(false);
}
} else {
setMicActive(false);
try {
await micMut.mutateAsync(false);
} catch {
// Stop already tore down the local transmitter — ignore remote errors
}
}
},
[micMut],
);
const handleVolumeChange = useCallback(
(v: number) => {
setVolume(v);
micMut.setVolume(v);
},
[micMut],
);
const handleGuildChange = useCallback((guildId: string | null) => {
if (!guildId) {
setSelectedGuild("");
setSelectedChannel("");
return;
}
setSelectedGuild(guildId);
}, []);
const activeSpeakers = speakers.filter((s) => s.speaking);
const connected = voiceStatus?.connected ?? false;
const active = speakers.filter((s) => s.speaking);
return (
<div className="space-y-4 animate-fade-in-up">
<SubNav
tabs={[
{ id: "connection", label: "Connection", icon: undefined },
{ id: "activity", label: "Activity", icon: undefined },
]}
activeTab={tab}
onTabChange={(t) => setTab(t as VoiceTab)}
/>
<div className="flex flex-col gap-5">
{/* Connection bar */}
<div className="flex items-center gap-3">
<Badge tone={initialStatus?.connected ? "signal" : "neutral"} dot>
{initialStatus?.connected ? "Connected" : "Disconnected"}
</Badge>
{initialStatus?.activeChannelName && (
<span className="text-sm text-[var(--color-ink-soft)]">
#{initialStatus.activeChannelName}
</span>
)}
{initialStatus?.connected ? (
<Button
size="sm"
variant="outline"
onClick={() => disconnect.mutate()}
>
Disconnect
</Button>
) : (
<Button
size="sm"
variant="primary"
onClick={() =>
connect.mutate({
guildId: initialStatus?.activeGuildId ?? "",
channelId: initialStatus?.activeChannelId ?? "",
})
}
>
Connect
</Button>
)}
</div>
<VoiceConnectionCard
connected={connected}
activeChannelName={voiceStatus?.activeChannelName}
guilds={guilds}
voiceChannels={voiceChannels}
selectedGuild={selectedGuild}
selectedChannel={selectedChannel}
onGuildChange={handleGuildChange}
onChannelChange={(v) => setSelectedChannel(v ?? "")}
onConnect={() => {
void connectMut
.mutateAsync({
guildId: selectedGuild,
channelId: selectedChannel,
})
.catch((err: unknown) => {
const msg =
err instanceof Error
? err.message
: "Gagal connect ke voice channel";
toast.error("Voice connect gagal", {
description: msg,
});
});
}}
onDisconnect={() => {
if (micActive) {
setMicActive(false);
void micMut.mutateAsync(false).catch(() => {});
{/* Stage hero */}
<div className="surface relative flex h-[280px] items-end justify-center overflow-hidden rounded-[var(--radius-r)] p-5">
<WebGLGuard
fallback={
<StaticFallback
variant="orb"
count={Math.max(speakers.length, 3)}
className="absolute inset-0"
/>
}
if (listen.active) listen.toggle(false);
disconnectMut.mutate(undefined);
}}
connecting={connectMut.isPending}
/>
>
<SignalField
activity={speakers.length > 0 ? 0.6 : 0.2}
className="absolute inset-0"
/>
</WebGLGuard>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2">
<SpeakerWaveform speakers={active} />
</div>
</div>
{tab === "connection" && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<SpeakerWaveform speakers={activeSpeakers} />
<div className="space-y-4">
<ListenControl
connected={connected}
active={listen.active}
levels={listen.levels}
speakers={speakers}
onToggle={(on) => listen.toggle(on)}
volume={listenVolume}
onVolumeChange={(v) => {
setListenVolume(v);
listen.setVolume(v);
}}
/>
<MicControl
connected={connected}
active={micActive}
onToggle={handleMicToggle}
volume={volume}
onVolumeChange={handleVolumeChange}
/>
</div>
<StaggerGroup className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
<StaggerItem>
<MicControl
micOn={listen.active}
onToggle={(on) => listen.toggle(on)}
levels={listen.levels}
/>
</StaggerItem>
<StaggerItem>
<ListenControl
listening={listen.active}
onToggle={(on) => listen.toggle(on)}
volume={75}
onVolume={listen.setVolume}
/>
</StaggerItem>
</StaggerGroup>
{/* Tabs */}
<div className="flex gap-1 rounded-[var(--radius-r)] bg-[var(--color-surface-2)] p-1">
{(["stage", "activity"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
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
? "bg-[var(--color-signal)] text-[var(--color-signal-ink)]"
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
)}
>
{t === "stage" ? (
<Activity className="size-3.5" />
) : (
<Headphones className="size-3.5" />
)}
{t === "stage" ? "Stage" : "Activity"}
</button>
))}
</div>
{tab === "stage" && <ActiveSpeakersPanel speakers={speakers} />}
{tab === "activity" && (
<div className="surface p-4">
<h3 className="mb-3 text-sm font-semibold">Live session timeline</h3>
<SessionRibbon
segments={speakers.map((s) => ({
id: s.userId,
label: s.username,
value: s.speaking ? 3 : 1,
tone: s.speaking ? "signal" : "neutral",
}))}
/>
</div>
)}
{tab === "activity" && <VoiceActivityTimeline data={speakers} />}
</div>
);
}
+206 -263
View File
@@ -1,327 +1,270 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
/*
* Theme variables — light mode lives in :root (default), dark mode overrides
* via `.dark`. Toggle in TopNav swaps the class on <html>.
* GMW — new design system (visual overhaul).
*
* NOTE: this is `@theme` (NOT `@theme inline`) on purpose — inline inlines
* literal values into utilities, so a `.dark` class override of the custom
* property would NOT re-skin utilities. With plain `@theme`, utilities
* reference `var(--color-*)`, so runtime theme switching works.
* Replaces the old teal-cyan + purple + glassmorphism language with a warm,
* signal-driven ops-console aesthetic. Hierarchy comes from scale/weight and
* tonal surface blocks, NOT from borders/shadows. Three semantic signals:
* lime = OK / live (--signal)
* amber = warn (--amber)
* vermilion = flag/danger (--vermilion)
*/
@theme {
/* ── Light (default) ── */
/* Canvas — soft off-white */
--color-canvas: oklch(0.97 0.005 250);
--color-surface: oklch(1 0 0 / 0.7);
--color-surface-hover: oklch(0.93 0.01 250 / 0.8);
/* ── Surfaces ── */
--color-canvas: oklch(0.96 0.012 80);
--color-surface: oklch(0.92 0.014 80);
--color-surface-2: oklch(0.88 0.016 80);
/* Glass */
--color-glass-bg: oklch(0 0 0 / 0.04);
--color-glass-border: oklch(0.2 0.02 250 / 0.12);
--glass-shadow: 0 8px 32px oklch(0.2 0.02 250 / 0.12);
/* ── Ink ── */
--color-ink: oklch(0.22 0.02 70);
--color-ink-soft: oklch(0.46 0.02 70);
/* Primary — teal-cyan */
--color-primary: oklch(0.52 0.17 215);
--color-primary-glow: oklch(0.52 0.17 215 / 0.35);
--color-primary-foreground: oklch(0.98 0 0);
--color-border: oklch(0.2 0.02 250 / 0.08);
--color-border-glow: oklch(0.52 0.17 215 / 0.35);
/* ── Structural ── */
--color-hairline: oklch(0.22 0.02 70 / 0.1);
--hairline-w: 1px;
/* Accents */
--color-accent-purple: oklch(0.55 0.2 280);
--color-accent-amber: oklch(0.65 0.17 75);
--color-destructive: oklch(0.577 0.245 27.325);
--color-success: oklch(0.55 0.18 160);
/* ── Semantic signals ── */
--color-signal: oklch(0.78 0.17 125);
--color-signal-ink: oklch(0.20 0.03 70);
--color-signal-glow: oklch(0.78 0.17 125 / 0.35);
--color-amber: oklch(0.80 0.15 70);
--color-vermilion: oklch(0.62 0.21 25);
--color-vermilion-soft: oklch(0.62 0.21 25 / 0.15);
/* Text */
--color-text-primary: oklch(0.25 0.02 250);
--color-text-secondary: oklch(0.48 0.02 250);
--color-text-mono: oklch(0.52 0.17 215);
--color-ring: var(--color-signal);
/* Legacy overrides for shadcn compatibility */
--color-background: var(--color-canvas);
--color-foreground: var(--color-text-primary);
--color-card: var(--color-surface);
--color-card-foreground: var(--color-text-primary);
--color-muted: oklch(0.9 0.01 250);
--color-muted-foreground: var(--color-text-secondary);
--color-accent: var(--color-primary);
--color-accent-foreground: var(--color-primary-foreground);
/* ── Fonts ── */
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--font-display: "Bricolage Grotesque", "Inter", sans-serif;
/* Ring / focus outline for shadcn outline-ring utility */
--color-ring: var(--color-primary);
/* Base-ui / shadcn primitives */
--color-popover: oklch(0.99 0.005 250 / 0.95);
--color-popover-foreground: var(--color-text-primary);
--color-input: oklch(0.55 0.02 250 / 0.35);
--color-secondary: oklch(0.92 0.01 250 / 0.6);
--color-secondary-foreground: var(--color-text-primary);
/* Sidebar (shadcn) */
--color-sidebar: oklch(1 0 0 / 0.6);
--color-sidebar-foreground: var(--color-text-primary);
--color-sidebar-primary: var(--color-primary);
--color-sidebar-primary-foreground: var(--color-primary-foreground);
--color-sidebar-accent: oklch(0.93 0.01 250 / 0.7);
--color-sidebar-accent-foreground: var(--color-text-primary);
--color-sidebar-border: var(--color-border);
--color-sidebar-ring: var(--color-ring);
/* Radius */
--radius-card: 16px;
--radius-panel: 12px;
--radius-control: 8px;
--radius-pill: 9999px;
--radius: 0.625rem; /* shadcn compat */
/* Fonts */
--font-sans: "Inter", sans-serif;
--font-mono: "JetBrains Mono", monospace;
/* ── Radii ── */
--radius-r: 14px;
--radius-r-panel: 12px;
--radius-r-control: 8px;
--radius-r-pill: 9999px;
}
/* ── Dark theme overrides ── */
.dark {
--color-canvas: oklch(0.07 0.015 250);
--color-surface: oklch(0.11 0.02 245 / 0.6);
--color-surface-hover: oklch(0.15 0.02 245 / 0.7);
--color-canvas: oklch(0.13 0.015 70);
--color-surface: oklch(0.18 0.02 70);
--color-surface-2: oklch(0.23 0.022 70);
--color-glass-bg: oklch(1 0 0 / 0.04);
--color-glass-border: oklch(1 0 0 / 0.08);
--glass-shadow: 0 8px 32px oklch(0 0 0 / 0.4);
--color-ink: oklch(0.93 0.01 75);
--color-ink-soft: oklch(0.62 0.02 75);
--color-primary: oklch(0.62 0.17 215);
--color-primary-glow: oklch(0.62 0.17 215 / 0.4);
--color-primary-foreground: oklch(0.98 0 0);
--color-border: oklch(1 0 0 / 0.06);
--color-border-glow: oklch(0.62 0.17 215 / 0.3);
--color-hairline: oklch(1 0 0 / 0.09);
--color-accent-purple: oklch(0.65 0.2 280);
--color-accent-amber: oklch(0.7 0.17 75);
--color-destructive: oklch(0.577 0.245 27.325);
--color-success: oklch(0.6 0.18 160);
--color-text-primary: oklch(0.93 0.01 245);
--color-text-secondary: oklch(0.55 0.02 245);
--color-text-mono: oklch(0.62 0.17 215);
--color-background: var(--color-canvas);
--color-foreground: var(--color-text-primary);
--color-card: var(--color-surface);
--color-card-foreground: var(--color-text-primary);
--color-muted: oklch(0.17 0.015 245);
--color-muted-foreground: var(--color-text-secondary);
--color-accent: var(--color-primary);
--color-accent-foreground: var(--color-primary-foreground);
--color-ring: var(--color-primary);
/* Base-ui / shadcn primitives */
--color-popover: oklch(0.13 0.02 245 / 0.96);
--color-popover-foreground: var(--color-text-primary);
--color-input: oklch(1 0 0 / 0.18);
--color-secondary: oklch(0.2 0.02 245 / 0.7);
--color-secondary-foreground: var(--color-text-primary);
/* Sidebar (shadcn) */
--color-sidebar: oklch(0.11 0.02 245 / 0.55);
--color-sidebar-foreground: var(--color-text-primary);
--color-sidebar-primary: var(--color-primary);
--color-sidebar-primary-foreground: var(--color-primary-foreground);
--color-sidebar-accent: oklch(0.17 0.02 245 / 0.7);
--color-sidebar-accent-foreground: var(--color-text-primary);
--color-sidebar-border: var(--color-border);
--color-sidebar-ring: var(--color-ring);
}
@layer utilities {
.glass {
background: var(--color-glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--color-glass-border);
box-shadow: var(--glass-shadow);
}
.glass-elevated {
background: var(--color-glass-bg);
backdrop-filter: blur(16px);
border: 1px solid var(--color-border-glow);
box-shadow: 0 8px 32px var(--glass-shadow), 0 0 20px var(--color-primary-glow);
}
.glass-intense {
background: oklch(1 0 0 / 0.75);
backdrop-filter: blur(20px);
border: 1px solid oklch(0.2 0.02 250 / 0.12);
box-shadow: 0 8px 32px oklch(0.2 0.02 250 / 0.12);
}
}
/* Dark variant of glass-intense (reads better on dark canvas) */
.dark .glass-intense {
background: oklch(1 0 0 / 0.08);
border: 1px solid oklch(1 0 0 / 0.12);
box-shadow: none;
--color-signal: oklch(0.88 0.18 125);
--color-signal-ink: oklch(0.18 0.03 70);
--color-signal-glow: oklch(0.88 0.18 125 / 0.4);
--color-amber: oklch(0.85 0.15 70);
--color-vermilion: oklch(0.68 0.21 25);
--color-vermilion-soft: oklch(0.68 0.21 25 / 0.18);
}
@layer base {
* { @apply border-border; }
* {
@apply border-transparent;
}
html {
@apply font-sans antialiased scroll-smooth;
}
body {
@apply bg-canvas text-text-primary font-sans antialiased;
@apply bg-canvas text-ink font-sans antialiased;
/* warm dot-grid texture + faint glow — replaces old bluish radial layers */
background-image:
radial-gradient(circle, oklch(0.3 0.02 250 / 0.05) 1px, transparent 1px),
radial-gradient(ellipse 80% 50% at 50% -20%, oklch(0.52 0.17 215 / 0.08), transparent),
radial-gradient(ellipse 50% 40% at 80% 80%, oklch(0.55 0.2 280 / 0.05), transparent);
background-size: 24px 24px, 100% 100%, 100% 100%;
radial-gradient(circle, oklch(0.45 0.03 70 / 0.05) 1px, transparent 1px),
radial-gradient(ellipse 80% 50% at 50% -20%, oklch(0.78 0.17 125 / 0.06), transparent),
radial-gradient(ellipse 50% 40% at 85% 90%, oklch(0.80 0.15 70 / 0.04), transparent);
background-size: 26px 26px, 100% 100%, 100% 100%;
}
/* Dark body background */
.dark body {
background-image:
radial-gradient(circle, oklch(1 0 0 / 0.025) 1px, transparent 1px),
radial-gradient(ellipse 80% 50% at 50% -20%, oklch(0.62 0.17 215 / 0.06), transparent),
radial-gradient(ellipse 50% 40% at 80% 80%, oklch(0.65 0.2 280 / 0.04), transparent);
background-size: 24px 24px, 100% 100%, 100% 100%;
}
html {
@apply font-sans scroll-smooth;
radial-gradient(circle, oklch(1 0 0 / 0.022) 1px, transparent 1px),
radial-gradient(ellipse 80% 50% at 50% -20%, oklch(0.88 0.18 125 / 0.05), transparent),
radial-gradient(ellipse 50% 40% at 85% 90%, oklch(0.85 0.15 70 / 0.03), transparent);
background-size: 26px 26px, 100% 100%, 100% 100%;
}
/* Custom selection color */
::selection {
background: oklch(0.62 0.17 215 / 0.4);
background: oklch(0.78 0.17 125 / 0.35);
color: inherit;
}
/* Scrollbar styling */
/* Scrollbar — warm */
::-webkit-scrollbar {
width: 6px;
height: 6px;
width: 7px;
height: 7px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: oklch(0.4 0.02 250 / 0.2);
background: oklch(0.4 0.02 70 / 0.22);
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(0.4 0.02 250 / 0.35);
background: oklch(0.4 0.02 70 / 0.38);
}
:focus-visible {
outline: 2px solid var(--color-signal);
outline-offset: 2px;
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: oklch(0.4 0.02 250 / 0.2); border-radius: 999px; }
::-webkit-scrollbar-thumb:hover { background: oklch(0.4 0.02 250 / 0.35); }
}
/* ── Animations ──────────────────────────── */
@layer utilities {
/* Tonal block that replaces bordered cards */
.surface {
background: var(--color-surface);
border-radius: var(--radius-r);
border: var(--hairline-w) solid var(--color-hairline);
}
.surface-2 {
background: var(--color-surface-2);
border-radius: var(--radius-r-panel);
border: var(--hairline-w) solid var(--color-hairline);
}
/* 1px animated pulse line marking a live/section header */
.scan-tick {
position: relative;
overflow: hidden;
}
.scan-tick::after {
content: "";
position: absolute;
inset-inline-start: 0;
inset-block-start: 0;
block-size: 1px;
inline-size: 100%;
background: linear-gradient(
90deg,
transparent,
var(--color-signal) 20%,
var(--color-signal) 80%,
transparent
);
background-size: 200% 100%;
animation: scan 2.6s linear infinite;
opacity: 0.7;
}
.ticker {
background: var(--color-surface);
border-radius: var(--radius-r);
border: var(--hairline-w) solid var(--color-hairline);
padding: clamp(1rem, 2vw, 1.5rem);
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.7rem;
border-radius: var(--radius-r-pill);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
.display {
font-family: var(--font-display);
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.02;
}
/* text tone helpers */
.text-signal { color: var(--color-signal); }
.text-amber { color: var(--color-amber); }
.text-vermilion { color: var(--color-vermilion); }
.text-ink-soft { color: var(--color-ink-soft); }
/* focus ring helper for interactive blocks */
.ring-focus {
transition: box-shadow 0.18s ease;
}
.ring-focus:hover {
box-shadow: 0 0 0 1px var(--color-signal-glow);
}
}
/* ── Keyframes ──────────────────────────── */
@keyframes scan {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes eq {
0%, 100% { transform: scaleY(0.25); }
30% { transform: scaleY(1); }
60% { transform: scaleY(0.5); }
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes spin-disc {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* kept for live status dots */
@keyframes pulse-ring {
0% {
transform: scale(0.8);
opacity: 1;
}
100% {
transform: scale(2.5);
opacity: 0;
}
0% { transform: scale(0.8); opacity: 1; }
100% { transform: scale(2.5); opacity: 0; }
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.animate-fade-in-up {
animation: fade-in-up 0.3s ease-out forwards;
.animate-eq {
animation: eq 0.9s ease-in-out infinite;
transform-origin: bottom;
}
.animate-spin-disc {
animation: spin-disc 8s linear infinite;
}
.animate-spin-disc.paused {
animation-play-state: paused;
}
.animate-pulse-ring {
animation: pulse-ring 1.5s ease-out infinite;
}
.animate-shimmer {
background: linear-gradient(90deg, transparent, oklch(0.62 0.17 215 / 0.08), transparent);
background: linear-gradient(
90deg,
transparent,
oklch(0.78 0.17 125 / 0.08),
transparent
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
/* Equalizer bars — bouncing heights for the "now playing" waveform */
@keyframes eq-bounce {
0%,
100% {
transform: scaleY(0.25);
}
30% {
transform: scaleY(1);
}
60% {
transform: scaleY(0.5);
/* ── Reduced motion: kill all decorative animation ── */
@media (prefers-reduced-motion: reduce) {
.scan-tick::after,
.animate-eq,
.animate-spin-disc,
.animate-pulse-ring,
.animate-shimmer {
animation: none !important;
}
}
.animate-eq {
animation: eq-bounce 0.9s ease-in-out infinite;
transform-origin: bottom;
}
/* Soft pulsing glow for the active/loading card */
@keyframes card-glow {
0%,
100% {
box-shadow: 0 0 0 0 oklch(0.62 0.17 215 / 0);
}
50% {
box-shadow: 0 0 22px 0 oklch(0.62 0.17 215 / 0.25);
}
}
.animate-card-glow {
animation: card-glow 1.8s ease-in-out infinite;
}
/* ── Utility classes ─────────────────────── */
/* Gradient text */
.text-gradient {
background: linear-gradient(135deg, oklch(0.62 0.17 215), oklch(0.6 0.15 195), oklch(0.65 0.12 185));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Gradient border (via pseudo-element trick) */
.gradient-border {
position: relative;
}
.gradient-border::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, oklch(0.62 0.17 215), oklch(0.6 0.15 195), oklch(0.65 0.12 185));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
/* Live pulse ring (compat alias) */
.live-pulse-ring {
animation: pulse-ring 1.5s ease-out infinite;
html { scroll-behavior: auto; }
}
+15 -6
View File
@@ -1,22 +1,31 @@
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import { Bricolage_Grotesque, Inter, JetBrains_Mono } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { Toaster } from "@/components/ui/sonner";
import { Toaster } from "@/components/primitives/toast";
import "./globals.css";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
display: "swap",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
display: "swap",
});
const bricolage = Bricolage_Grotesque({
subsets: ["latin"],
variable: "--font-display",
display: "swap",
});
export const metadata: Metadata = {
title: "Discord Automod — Moderation Dashboard",
description: "AI-powered Discord moderation and voice monitoring dashboard",
title: "Bete — Discord Moderation Console",
description:
"AI-powered Discord moderation, voice monitoring, and media control console",
};
export default function RootLayout({
@@ -27,7 +36,7 @@ export default function RootLayout({
return (
<html
lang="en"
className={`${inter.variable} ${jetbrainsMono.variable} h-full antialiased`}
className={`${inter.variable} ${jetbrainsMono.variable} ${bricolage.variable} h-full antialiased`}
suppressHydrationWarning
>
<body className="min-h-full flex flex-col">
@@ -39,8 +48,8 @@ export default function RootLayout({
disableTransitionOnChange
>
{children}
<Toaster position="bottom-right" />
</ThemeProvider>
<Toaster position="bottom-right" richColors closeButton />
</body>
</html>
);