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