feat(frontend): rebuild as Ambient/WebGL console with all pages + command palette

Ground-up rombak UI: hapus semua component/page lama, bangun ulang dengan
desain sistem Ambient (WebGL haze + drifting motes, signal-driven color)
di atas kontrak API/WS/type yang sudah ada.

- Design system: globals.css tokens + primitives (glass, button, badge,
  select, avatar, toast, chart SVG murni).
- Shell: nav rail, topbar (status WS + pill signal + theme), AppFrame.
- 8 halaman: dashboard, voice (orbital stage), media, messages (live feed +
  detail AI), moderation, analysis (search), recordings, + chatbot floating.
- Command palette (Cmd/Ctrl+K) untuk navigasi cepat.
- Server fetch di-page di-try/catch agar render graceful saat backend mati.

Verified: tsc clean, next build 8/8 halaman, semua route 200.
This commit is contained in:
asepharyana
2026-08-15 17:53:48 +07:00
parent b98101c576
commit 1b56212d1a
104 changed files with 2905 additions and 7048 deletions
@@ -1,11 +1,7 @@
"use client";
import { AnalysisView } from "./view";
import { SearchPanel } from "@/components/analysis/search-panel";
export const dynamic = "force-dynamic";
export default function AnalysisPage() {
return (
<div className="space-y-5" style={{ animation: "fade-up 0.4s ease both" }}>
<SearchPanel />
</div>
);
return <AnalysisView />;
}
@@ -0,0 +1,112 @@
"use client";
import { useEffect, useState } from "react";
import { Search, Sparkles, TrendingUp, Hash } from "lucide-react";
import { useMessageSearch, useTopReactors, useChannels } from "@/hooks";
import { useAmbient } from "@/components/ambient/ambient-context";
import { GlassPanel, GlassCard, Avatar, Input, Badge } from "@/components/primitives";
import { SectionHeader, EmptyState, LoadingState } from "@/components/shared";
import { renderMessageContent, getMessageChannelLabel } from "@/lib/format";
import type { AiStatus } from "@/lib/types";
function aiTone(s?: AiStatus | null): "signal" | "amber" | "vermilion" | "neutral" {
if (s === "clean") return "signal";
if (s === "warn") return "amber";
if (s === "flagged" || s === "error") return "vermilion";
return "neutral";
}
export function AnalysisView() {
const [query, setQuery] = useState("");
const search = useMessageSearch(query, query.trim().length >= 2);
const { data: reactors } = useTopReactors();
const { data: channels } = useChannels();
const ambient = useAmbient();
useEffect(() => {
ambient.set(query ? "amber" : "signal", 0.3, query ? "analyzing" : "search");
}, [query, ambient]);
return (
<div className="space-y-5">
<GlassPanel glow className="relative overflow-hidden">
<div className="scan-line absolute inset-x-0 top-0" />
<div className="flex items-center gap-3">
<Sparkles className="size-5 text-signal" />
<div>
<div className="eyebrow">Semantic search</div>
<h2 className="display text-2xl text-ink">Search the archive</h2>
</div>
</div>
<div className="relative mt-4">
<Search className="absolute left-4 top-1/2 size-5 -translate-y-1/2 text-ink-faint" />
<Input
className="h-12 pl-12 text-base"
placeholder="Find messages, patterns, flags…"
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
</div>
{query.trim().length > 0 && query.trim().length < 2 && (
<div className="mono mt-2 text-xs text-ink-faint">Type at least 2 characters</div>
)}
</GlassPanel>
<div className="grid gap-5 lg:grid-cols-5">
<GlassPanel className="lg:col-span-3">
<SectionHeader eyebrow="results" title="Matches" action={<span className="mono text-xs text-ink-faint">{(search.data ?? []).length}</span>} />
{query.trim().length >= 2 && search.isLoading && <LoadingState label="Scanning" />}
{(search.data ?? []).length === 0 ? (
<EmptyState icon={<Search className="size-7" />} title="No matches yet" description="Run a search to surface messages across the guild." />
) : (
<div className="space-y-1.5">
{(search.data ?? []).map((m) => (
<div key={m.id} className="flex items-start gap-3 rounded-[12px] border border-hairline bg-white/[0.03] p-3">
<Avatar src={m.avatar_url} name={m.username} size={32} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-ink">{m.username}</span>
<span className="mono text-[0.65rem] text-ink-faint">{getMessageChannelLabel(m)}</span>
{m.ai_status && <Badge tone={aiTone(m.ai_status)} className="ml-auto">{m.ai_status}</Badge>}
</div>
<div className="mt-0.5 text-sm text-ink-soft">{renderMessageContent(m.content, m.metadata) || "(embed)"}</div>
</div>
</div>
))}
</div>
)}
</GlassPanel>
<div className="space-y-5 lg:col-span-2">
<GlassPanel>
<SectionHeader eyebrow="culture" title={<span className="flex items-center gap-2"><TrendingUp className="size-4 text-signal" /> Top reactors</span>} />
<div className="space-y-2">
{(reactors ?? []).slice(0, 6).map((r, i) => (
<div key={r.user_id} className="flex items-center gap-3 text-sm">
<span className="mono w-5 text-ink-faint">{i + 1}</span>
<span className="flex-1 truncate text-ink">{r.username}</span>
<span className="mono text-xs text-signal">+{r.net_count}</span>
</div>
))}
{(reactors ?? []).length === 0 && <div className="py-4 text-center text-xs text-ink-faint">No data</div>}
</div>
</GlassPanel>
<GlassPanel>
<SectionHeader eyebrow="channels" title={<span className="flex items-center gap-2"><Hash className="size-4 text-signal" /> Top channels</span>} />
<div className="space-y-2">
{(channels ?? []).slice(0, 6).map((c) => (
<div key={c.channel_id} className="flex items-center gap-3 text-sm">
<span className="flex-1 truncate text-ink-soft">{c.channel_name ?? c.channel_id.slice(0, 8)}</span>
<span className="mono text-xs text-ink-faint">{c.total_messages}</span>
</div>
))}
{(channels ?? []).length === 0 && <div className="py-4 text-center text-xs text-ink-faint">No data</div>}
</div>
</GlassPanel>
</div>
</div>
</div>
);
}
@@ -1,27 +1,15 @@
/**
* 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";
import { DashboardView } from "./view";
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
const [stats, activity] = await Promise.allSettled([
getDashboardStats().catch(() => undefined),
getActivity(14).catch(() => undefined),
]);
return (
<DashboardView
initialStats={
stats.status === "fulfilled" && stats.value ? stats.value : undefined
}
initialActivity={
activity.status === "fulfilled" && activity.value
? activity.value
: undefined
}
/>
);
let stats = undefined;
let activity = undefined;
try {
[stats, activity] = await Promise.all([getDashboardStats(), getActivity(14)]);
} catch {
// Backend unavailable — client hooks will surface the error state.
}
return <DashboardView initialStats={stats} initialActivity={activity} />;
}
@@ -1,144 +1,228 @@
"use client";
/**
* Dashboard — Ambient Field layout.
*
* No top bar. No side rail. No grid. No panels.
*
* A full-bleed WebGL haze (AmbientField) is the page. Content floats over it:
* a giant headline bottom-left, a live metric cluster top-right, a drifting
* event ribbon mid-screen, a command whispher at the very bottom. Whitespace
* is the layout — density comes from data, not chrome.
*/
import { useEffect } from "react";
import {
Activity,
Flag,
MessageSquare,
Mic,
Radio,
ShieldAlert,
Users,
} from "lucide-react";
import {
useActivity,
useStats,
useTopReactors,
useTopReactions,
} from "@/hooks";
import { useAmbient } from "@/components/ambient/ambient-context";
import { GlassPanel, GlassCard } from "@/components/primitives";
import {
AreaActivity,
Donut,
RadialGauge,
Sparkline,
} from "@/components/charts";
import { MetricTile, SectionHeader } from "@/components/shared/section";
import { ErrorState, LoadingState } from "@/components/shared";
import { formatNumber } from "@/lib/format";
import type { DashboardStats } from "@/lib/types";
import { useCallback, useMemo, useState } from "react";
import { AmbientField } from "@/components/ambient/ambient-field";
import { DashCommandLine } from "@/components/command/dash-command-line";
import type { DashboardActivity, DashboardStats } from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context";
function deriveSignal(stats?: DashboardStats) {
if (!stats) return { tone: "signal" as const, label: "nominal" };
const total = stats.total_flagged + stats.total_clean || 1;
const ratio = stats.total_flagged / total;
if (stats.moderation_overview.error > 0) return { tone: "vermilion" as const, label: "moderation fault" };
if (ratio > 0.25) return { tone: "vermilion" as const, label: "elevated flags" };
if (ratio > 0.1) return { tone: "amber" as const, label: "watch" };
return { tone: "signal" as const, label: "nominal" };
}
export default function DashboardView({
export function DashboardView({
initialStats,
initialActivity,
}: {
initialStats?: DashboardStats;
initialActivity?: DashboardActivity;
initialActivity?: Awaited<ReturnType<typeof useActivity>>["data"];
}) {
const ws = useWebSocket();
const [signal, setSignal] = useState<
"signal" | "amber" | "vermilion" | "neutral"
>("signal");
const [load, setLoad] = useState(0.3);
const { data: stats, isLoading, error } = useStats(initialStats);
const { data: activity } = useActivity(14, initialActivity as never);
const { data: reactors } = useTopReactors();
const { data: reactions } = useTopReactions();
const ambient = useAmbient();
const total = initialStats?.total_messages ?? 0;
const clean = initialStats?.total_clean ?? 0;
const flagged = initialStats?.total_flagged ?? 0;
const warned = initialStats?.total_warned ?? 0;
const ratio = ((clean / (clean + flagged + warned || 1)) * 100).toFixed(1);
useEffect(() => {
const s = deriveSignal(stats);
ambient.set(s.tone, 0.3 + Math.min(0.5, (stats?.today_flagged ?? 0) / 50), s.label);
}, [stats, ambient]);
const _subscribe = useCallback(
(handler: (e: { severity: string; ts: number }) => void) => {
const unsub = ws.on("message_created", (data: any) => {
const s = data.ai_status;
setSignal(
s === "flagged" ? "vermilion" : s === "warn" ? "amber" : "signal",
);
setLoad((l) => Math.min(1, l + 0.02));
handler({
severity: s ?? "neutral",
ts: data.created_at ?? Date.now(),
});
});
return unsub;
},
[ws],
);
if (error && !stats) return <ErrorState error={error} />;
if (!stats && isLoading) return <LoadingState label="Reading grid" />;
const seedEvents = useMemo(() => {
if (!initialActivity) return [];
return initialActivity.daily.slice(-10).flatMap((d) =>
Array.from({ length: Math.min(3, d.messages) }, (_, i) => ({
id: `seed-${d.day}-${i}`,
ts: Date.now() - i * 120_000,
severity: i < d.flagged ? "vermilion" : "signal",
actor: i < d.flagged ? "ai" : "user",
action: i < d.flagged ? "flagged" : "sent",
channel: "#general",
excerpt: `seed ${d.day}`,
})),
);
}, [initialActivity]);
const s = stats!;
const total = s.total_flagged + s.total_clean || 1;
const cleanRatio = s.total_clean / total;
return (
<div className="relative h-[calc(100svh-3rem)] w-full overflow-hidden bg-[var(--color-canvas)]">
<AmbientField load={load} signal={signal} />
{/* Metric cluster — top right, floating, no container */}
<div className="absolute right-6 top-6 flex flex-col items-end gap-1 font-mono text-right">
<span className="text-[10px] uppercase tracking-[0.3em] text-[var(--color-ink-soft)]">
watched
</span>
<span className="display text-5xl font-medium tabular-nums leading-none text-[var(--color-ink)]">
{total.toLocaleString()}
</span>
<div className="mt-2 flex gap-4 text-[12px]">
<span className="text-[var(--color-signal)]">
{clean.toLocaleString()} clean
</span>
<span className="text-[var(--color-amber)]">{warned} warn</span>
<span className="text-[var(--color-vermilion)]">{flagged} flag</span>
</div>
<span className="text-[10px] text-[var(--color-ink-soft)]">
{ratio}% ratio
</span>
</div>
{/* Headline — bottom left, massive */}
<div className="absolute bottom-20 left-6 max-w-[60vw]">
<h1 className="display text-[clamp(3rem,9vw,7rem)] font-medium leading-[0.95] tracking-tight text-[var(--color-ink)]">
GMW
<br />
Console
</h1>
<p className="mt-3 font-mono text-[12px] text-[var(--color-ink-soft)]">
{(initialStats?.total_users ?? 0).toLocaleString()} users ·{" "}
{initialStats?.active_users_24h ?? 0} active 24h
</p>
</div>
{/* Event ribbon — mid screen, drifting row */}
<div className="absolute left-1/2 top-1/2 w-[min(90vw,900px)] -translate-x-1/2 -translate-y-1/2">
<div className="flex flex-col gap-1 font-mono text-[11px]">
{seedEvents.slice(0, 6).map((e) => (
<div
key={e.id}
className="flex items-center gap-2 opacity-70"
data-severity={e.severity}
>
<span
className="inline-block size-1.5 rounded-full"
style={{
background:
e.severity === "vermilion"
? "var(--color-vermilion)"
: "var(--color-signal)",
}}
/>
<span className="text-[var(--color-ink-soft)] tabular-nums">
{new Date(e.ts).toLocaleTimeString()}
</span>
<span className="truncate text-[var(--color-ink)]">
{e.excerpt}
<div className="space-y-5">
{/* Hero */}
<GlassPanel glow className="relative overflow-hidden">
<div className="scan-line absolute inset-x-0 top-0" />
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<div className="eyebrow mb-2">GMW · Operations Grid</div>
<h2 className="display text-[2.6rem] leading-none text-ink glow-signal">
Ambient Field
</h2>
<p className="mt-2 max-w-md text-sm text-ink-soft">
Real-time moderation, voice & media presence across the monitored
guild. {formatNumber(s.total_messages)} messages captured.
</p>
</div>
<div className="flex items-center gap-2 text-ink-soft">
<Radio className="size-4 text-signal animate-breathe" />
<span className="mono text-xs uppercase tracking-wider">
{deriveSignal(s).label}
</span>
</div>
))}
</div>
<div className="mt-5 grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile label="Messages" value={formatNumber(s.total_messages)} tone="signal" icon={<MessageSquare className="size-3.5" />} />
<MetricTile label="Flagged" value={formatNumber(s.total_flagged)} tone={s.total_flagged > 0 ? "vermilion" : "neutral"} hint={`${s.today_flagged} today`} />
<MetricTile label="Active 24h" value={formatNumber(s.active_users_24h)} tone="signal" icon={<Users className="size-3.5" />} />
<MetricTile label="Voice clips" value={formatNumber(s.total_voice_recordings)} icon={<Mic className="size-3.5" />} />
</div>
</GlassPanel>
{/* Activity */}
<GlassPanel>
<SectionHeader
eyebrow="14-day signal"
title={
<span className="flex items-center gap-2">
<Activity className="size-4 text-signal" /> Activity & moderation
</span>
}
action={
<div className="flex items-center gap-3 text-xs text-ink-soft">
<span className="flex items-center gap-1.5"><span className="size-2 rounded-full bg-signal" /> messages</span>
<span className="flex items-center gap-1.5"><span className="size-2 rounded-full bg-vermilion" /> flagged</span>
</div>
}
/>
{activity ? (
<AreaActivity daily={activity.daily} />
) : (
<LoadingState label="streaming" />
)}
</GlassPanel>
{/* Two-column: channels + moderation */}
<div className="grid gap-5 lg:grid-cols-5">
<GlassPanel className="lg:col-span-3">
<SectionHeader eyebrow="throughput" title="Top channels" />
<div className="space-y-2.5">
{s.top_channels.slice(0, 7).map((c) => {
const pct = (c.message_count / (s.top_channels[0]?.message_count || 1)) * 100;
return (
<div key={c.channel_id} className="flex items-center gap-3">
<span className="w-40 truncate text-sm text-ink-soft">{c.channel_name ?? c.channel_id.slice(0, 8)}</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/8">
<div className="h-full rounded-full bg-signal/70" style={{ width: `${pct}%` }} />
</div>
<span className="mono w-14 text-right text-xs text-ink-faint">{formatNumber(c.message_count)}</span>
</div>
);
})}
</div>
</GlassPanel>
<GlassPanel className="lg:col-span-2">
<SectionHeader eyebrow="trust" title="Moderation" />
<div className="flex items-center gap-5">
<RadialGauge
value={cleanRatio}
tone={cleanRatio > 0.8 ? "signal" : cleanRatio > 0.6 ? "amber" : "vermilion"}
label={`${Math.round(cleanRatio * 100)}%`}
sublabel="clean"
/>
<div className="flex-1 space-y-2 text-sm">
<Row icon={<ShieldAlert className="size-4 text-signal" />} label="Clean" value={formatNumber(s.total_clean)} />
<Row icon={<Flag className="size-4 text-vermilion" />} label="Flagged" value={formatNumber(s.total_flagged)} />
<Row icon={<Activity className="size-4 text-amber" />} label="Warned" value={formatNumber(s.total_warned)} />
</div>
</div>
<div className="mt-4 flex items-center justify-around border-t border-hairline pt-3 text-center">
<Mini label="pending" value={s.moderation_overview.pending} tone="amber" />
<Mini label="processing" value={s.moderation_overview.processing} tone="signal" />
<Mini label="errors" value={s.moderation_overview.error} tone="vermilion" />
</div>
</GlassPanel>
</div>
{/* Reactors + reactions */}
<div className="grid gap-5 lg:grid-cols-2">
<GlassPanel>
<SectionHeader eyebrow="engagement" title="Top reactors" />
<div className="space-y-2">
{(reactors ?? []).slice(0, 6).map((r, i) => (
<div key={r.user_id} className="flex items-center gap-3">
<span className="mono w-5 text-ink-faint">{i + 1}</span>
<span className="flex-1 truncate text-sm text-ink">{r.username}</span>
<span className="mono text-xs text-signal">+{formatNumber(r.net_count)}</span>
</div>
))}
{(reactors ?? []).length === 0 && <EmptyHint />}
</div>
</GlassPanel>
<GlassPanel>
<SectionHeader eyebrow="culture" title="Top reactions" />
<div className="space-y-3">
{(reactions ?? []).slice(0, 5).map((m) => (
<div key={m.message_id} className="flex items-start gap-3">
<div className="flex flex-wrap gap-1 pt-0.5">
{m.top_emojis.slice(0, 3).map((e, i) => (
<span key={i} className="text-lg leading-none">{e.emoji}</span>
))}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-ink">{m.content || "(no text)"}</div>
<div className="mono text-[0.65rem] text-ink-faint">{m.username} · {m.channel_name ?? m.channel_id.slice(0, 8)}</div>
</div>
<span className="mono text-xs text-ink-soft">{m.reaction_count}</span>
</div>
))}
{(reactions ?? []).length === 0 && <EmptyHint />}
</div>
</GlassPanel>
</div>
</div>
);
}
{/* Command whisper — very bottom, minimal */}
<div className="absolute inset-x-0 bottom-0">
<DashCommandLine />
</div>
function Row({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<div className="flex items-center gap-2.5">
{icon}
<span className="flex-1 text-ink-soft">{label}</span>
<span className="mono text-ink">{value}</span>
</div>
);
}
function Mini({ label, value, tone }: { label: string; value: number; tone: "signal" | "amber" | "vermilion" }) {
const color = tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal";
return (
<div>
<div className={`display text-xl ${color}`}>{value}</div>
<div className="eyebrow mt-0.5">{label}</div>
</div>
);
}
function EmptyHint() {
return <div className="py-6 text-center text-xs text-ink-faint">Awaiting data</div>;
}
+11 -125
View File
@@ -1,133 +1,19 @@
"use client";
import { usePathname } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
import { SWRConfig } from "swr";
import { ChatbotContainer } from "@/components/chatbot/chatbot-container";
import {
ChatbotProvider,
useChatbot,
} from "@/components/chatbot/chatbot-context";
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 { 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;
}
function ChatbotExpressionSync() {
const ws = useWebSocket();
const { setExpression } = useChatbot();
useEffect(() => {
const unsub1 = ws.on("message_created", (data: any) => {
if (data.ai_status === "flagged" || data.ai_status === "warn") {
setExpression("surprise");
setTimeout(() => setExpression("idle"), 2000);
}
});
const unsub2 = ws.on("voice_active_user", () => setExpression("listening"));
return () => {
unsub1();
unsub2();
};
}, [ws, setExpression]);
return null;
}
/**
* Ambient shell — used only on /dashboard.
*
* No TopBar, no LeftRail, no main padding. The view itself is full-bleed
* (AmbientField + floating overlays). This is the ground-up rombak — not a
* re-skin of the classic dashboard template.
*/
function AmbientShell({ children }: { children: React.ReactNode }) {
return (
<div className="h-[calc(100svh-3rem)] w-full overflow-hidden">
{children}
</div>
);
}
/**
* Classic shell — used on every other route under /(dashboard).
*/
function ClassicShell({
children,
guildId,
setGuildId,
}: {
children: React.ReactNode;
guildId: string;
setGuildId: (g: string) => void;
}) {
return (
<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>
</div>
);
}
import { AmbientProvider } from "@/components/ambient/ambient-context";
import { WsProvider } from "@/lib/ws/context";
import { AppFrame } from "@/components/shell";
import { Chatbot } from "@/components/chatbot/chatbot";
import { CommandPalette } from "@/components/command/command-palette";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const [guildId, setGuildId] = useState("");
const pathname = usePathname();
// Match exact /dashboard or /dashboard/ but not /dashboard/<subroute>
const isConsole = pathname === "/dashboard" || pathname === "/dashboard/";
}: Readonly<{ children: React.ReactNode }>) {
return (
<SWRConfig
value={{
revalidateOnFocus: false,
dedupingInterval: 10_000,
shouldRetryOnError: (err) =>
(err as { statusCode?: number })?.statusCode !== 404,
}}
>
<AmbientProvider>
<WsProvider>
<MediaPlayerProvider>
<ChatbotProvider>
<ChatbotGuildSync guildId={guildId} />
<ChatbotExpressionSync />
{isConsole ? (
<AmbientShell>{children}</AmbientShell>
) : (
<ClassicShell guildId={guildId} setGuildId={setGuildId}>
{children}
</ClassicShell>
)}
<MiniPlayer />
<ChatbotContainer />
</ChatbotProvider>
</MediaPlayerProvider>
<AppFrame>{children}</AppFrame>
<Chatbot />
<CommandPalette />
</WsProvider>
</SWRConfig>
</AmbientProvider>
);
}
@@ -1,13 +1,14 @@
/**
* Media page — Server Component. Seeds the music player with the shared media
* state fetched on the server (same state every user sees), then live-updates
* over WS.
*/
import { getMediaStatus } from "@/lib/api/server";
import MediaView from "./view";
import { MediaView } from "./view";
export const dynamic = "force-dynamic";
export default async function MediaPage() {
const status = await getMediaStatus().catch(() => undefined);
let status = undefined;
try {
status = await getMediaStatus();
} catch {
/* client hooks surface errors */
}
return <MediaView initialStatus={status} />;
}
@@ -1,185 +1,149 @@
"use client";
import { Pause, Play, Repeat2, SkipForward, Square, Volume2 } from "lucide-react";
import { motion } from "motion/react";
import { useState } from "react";
import { Waveform } from "@/components/charts/waveform";
import { StaggerGroup, StaggerItem } from "@/components/motion/stagger";
import { Badge } from "@/components/primitives/badge";
import { Button } from "@/components/primitives/button";
import { Input } from "@/components/primitives/input";
import { Progress } from "@/components/primitives/progress";
import { useEffect, useState } from "react";
import {
useMediaLoop,
ListMusic,
Pause,
Play,
Repeat,
SkipForward,
Square,
Radio,
} from "lucide-react";
import { useWebSocket } from "@/lib/ws/context";
import {
useMediaState,
useMediaQueue,
useMediaSkip,
useMediaState,
useMediaStop,
useMediaLoop,
useMediaWsSync,
} from "@/hooks";
import { useAmbient } from "@/components/ambient/ambient-context";
import { GlassPanel, GlassCard, Button, Input } from "@/components/primitives";
import { SectionHeader, ErrorState, LoadingState } from "@/components/shared";
import { toast } from "@/components/primitives";
import type { MediaState } from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context";
export default function MediaView({
initialStatus,
}: {
initialStatus?: MediaState;
}) {
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
const ws = useWebSocket();
const { data: state } = useMediaState(initialStatus);
const queueMut = useMediaQueue();
const { data: media, isLoading, error } = useMediaState(initialStatus);
const queue = useMediaQueue();
const skip = useMediaSkip();
const stop = useMediaStop();
const loopMut = useMediaLoop();
const loop = useMediaLoop();
useMediaWsSync(ws);
const ambient = useAmbient();
const current = state?.current;
const playing = state?.playing ?? false;
const queue = state?.queue ?? [];
const loop = state?.loop ?? false;
const [url, setUrl] = useState("");
const duration = current?.durationMs ?? 0;
const [queueUrl, setQueueUrl] = useState("");
const [screenMode, setScreenMode] = useState(false);
const playing = media?.playing ?? false;
const current = media?.current ?? null;
const queueList = media?.queue ?? [];
const handleQueue = () => {
if (!queueUrl.trim()) return;
queueMut.mutate({ url: queueUrl.trim(), mode: screenMode ? "screen" : "music" });
setQueueUrl("");
const tone = playing ? "signal" : queueList.length ? "amber" : "signal";
useEffect(() => {
ambient.set(tone, playing ? 0.5 : 0.25, playing ? "now playing" : "media idle");
}, [tone, playing, ambient]);
const onPlay = async () => {
const u = url.trim();
if (!u) {
toast({ title: "Enter a media URL", tone: "vermilion" });
return;
}
try {
await queue.mutateAsync({ url: u, mode: "music" });
setUrl("");
toast({ title: "Queued", tone: "signal" });
} catch (e) {
toast({ title: "Queue failed", description: String(e), tone: "vermilion" });
}
};
return (
<div className="flex flex-col gap-5">
{/* URL queue input */}
<div className="flex gap-2">
<Input
placeholder="Queue a URL (YouTube, audio file…)"
value={queueUrl}
onChange={(e) => setQueueUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleQueue()}
className="flex-1 h-9"
/>
<Button
size="sm"
variant={screenMode ? "primary" : "ghost"}
onClick={() => setScreenMode((v) => !v)}
title="Queue as Discord GoLive screenshare instead of audio playback"
>
Screen
</Button>
<Button
size="sm"
onClick={handleQueue}
disabled={!queueUrl.trim() || queueMut.isPending}
>
<Play className="size-4 mr-1.5" />
Queue
</Button>
</div>
if (error && !media) return <ErrorState error={error} />;
if (!media && isLoading) return <LoadingState label="Reading deck" />;
{/* Turntable hero */}
<div className="flex items-center gap-6 surface scan-tick flex-wrap p-5">
{current && (
<motion.div
className={`relative mx-auto size-[160px] rounded-full ${
playing ? "animate-spin-disc" : "animate-spin-disc paused"
}`}
return (
<div className="space-y-5">
<GlassPanel glow className="relative overflow-hidden">
<div className="scan-line absolute inset-x-0 top-0" />
<div className="flex flex-col gap-5 sm:flex-row sm:items-center">
<div
className={`flex size-32 shrink-0 items-center justify-center rounded-full border border-hairline bg-gradient-to-br from-white/10 to-white/[0.02] ${playing ? "animate-spin-disc" : "animate-spin-disc paused"}`}
>
<img
src={current.thumbnailUrl ?? "/favicon.ico"}
alt={current.title ?? "cover"}
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 className="flex size-28 items-center justify-center rounded-full bg-canvas/60">
<ListMusic className="size-10 text-signal" />
</div>
</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 className="min-w-0 flex-1">
<div className="eyebrow mb-1">Now playing</div>
<h2 className="display truncate text-2xl text-ink">
{current?.title ?? "Nothing queued"}
</h2>
{current?.source && (
<div className="mono mt-1 truncate text-xs text-ink-faint">{current.source}</div>
)}
<div className="mt-4 flex flex-wrap items-center gap-2">
<Button variant="primary" size="sm" onClick={onPlay} disabled={queue.isPending}>
<Play className="size-4" /> Queue & play
</Button>
<Button variant="outline" size="sm" onClick={() => skip.mutate()} disabled={skip.isPending}>
<SkipForward className="size-4" /> Skip
</Button>
<Button variant="outline" size="sm" onClick={() => stop.mutate()} disabled={stop.isPending}>
<Square className="size-4" /> Stop
</Button>
<Button
variant={media?.loop ? "primary" : "outline"}
size="sm"
onClick={() => loop.mutate(!media?.loop)}
aria-pressed={!!media?.loop}
>
<Repeat className="size-4" /> Loop
</Button>
</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>
<div className="mt-5 flex items-center gap-2">
<Input
placeholder="Paste a YouTube / music URL…"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onPlay()}
/>
</div>
</GlassPanel>
{/* 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>
<GlassPanel>
<SectionHeader
eyebrow="up next"
title="Queue"
action={<span className="mono text-xs text-ink-faint">{queueList.length} tracks</span>}
/>
{queueList.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-10 text-center">
<Radio className="size-6 text-ink-faint" />
<div className="text-sm text-ink-soft">Queue is empty</div>
<div className="text-xs text-ink-faint">Paste a URL above to start playback.</div>
</div>
<div className="flex flex-col gap-1">
{queue.map((item) => (
<motion.div
key={item.id ?? item.source}
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 ?? item.source}
bars={12}
height={20}
className="w-16"
/>
<span className="mono truncate">{item.title}</span>
</motion.div>
) : (
<div className="space-y-2">
{queueList.map((item, i) => (
<div key={`${item.source}-${i}`} className="flex items-center gap-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5">
<span className="mono w-5 text-ink-faint">{i + 1}</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-ink">{item.title}</div>
<div className="mono truncate text-[0.65rem] text-ink-faint">{item.source}</div>
</div>
<span className="pill">{item.mode ?? "music"}</span>
</div>
))}
</div>
</div>
)}
)}
</GlassPanel>
</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")}`;
}
@@ -1,38 +1,15 @@
/**
* 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 MessagesView from "./view";
import { getConfig, getGuilds } from "@/lib/api/server";
import { MessagesView } from "./view";
export default async function MessagesPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const sp = await searchParams;
const guild = typeof sp.guild === "string" ? sp.guild : "";
const channel = typeof sp.channel === "string" ? sp.channel : "";
const selected = typeof sp.selected === "string" ? sp.selected : null;
const tab =
typeof sp.tab === "string" && ["all", "images", "review"].includes(sp.tab)
? (sp.tab as "all" | "images" | "review")
: "all";
export const dynamic = "force-dynamic";
let initialPage: MessagePageResult | undefined;
if (guild) {
initialPage = await getMessages(guild, channel || undefined).catch(
() => undefined,
);
export default async function MessagesPage() {
let config = undefined;
let guilds = undefined;
try {
[config, guilds] = await Promise.all([getConfig(), getGuilds()]);
} catch {
/* client hooks surface errors */
}
return (
<MessagesView
initialGuild={guild}
initialChannel={channel}
initialDetailId={selected}
initialTab={tab}
initialMessagePage={initialPage}
/>
);
return <MessagesView initialGuilds={guilds} initialGuildId={config?.monitorGuildId ?? null} />;
}
@@ -1,377 +1,228 @@
"use client";
import { Flag, Image, Loader2, Search, Send, X } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { Lightbox } from "@/components/messages/lightbox";
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 { useEffect, useState } from "react";
import {
useImages,
useLoadMore,
useMessageDetail,
useMessages,
useMessagesHasMore,
useMessagesWsSync,
useReview,
useTextChannels,
} from "@/hooks";
import { renderMessageContent } from "@/lib/format";
import type { MessageRecord } from "@/lib/types";
import { cn } from "@/lib/utils";
MessageSquare,
Search,
Paperclip,
Image as ImageIcon,
ShieldAlert,
AlertTriangle,
CheckCircle2,
Loader2,
} from "lucide-react";
import { useWebSocket } from "@/lib/ws/context";
import {
useGuilds,
useMessages,
useMessagesWsSync,
useMessageSearch,
useMessageDetail,
} from "@/hooks";
import { useAmbient } from "@/components/ambient/ambient-context";
import { GlassPanel, GlassCard, Avatar, Badge, Input, Skeleton } from "@/components/primitives";
import { SectionHeader, EmptyState, ErrorState, LoadingState } from "@/components/shared";
import { GuildChannelPicker } from "@/components/shared/guild-picker";
import { renderMessageContent, getMessageChannelLabel, safeParseJsonArray, formatBytes } from "@/lib/format";
import type { AiStatus, Guild, MessageRecord } from "@/lib/types";
type MessagesTab = "all" | "images" | "review";
interface MessagesViewProps {
initialGuild?: string;
initialChannel?: string;
initialDetailId?: string | null;
initialTab?: MessagesTab;
initialMessagePage?: { data: MessageRecord[]; nextCursor: string | null };
function relTime(ts?: number | null) {
if (!ts) return "";
const d = Date.now() - ts;
const m = Math.floor(d / 60000);
if (m < 1) return "just now";
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
return `${Math.floor(h / 24)}d`;
}
export default function MessagesView({
initialGuild = "",
initialChannel = "",
initialDetailId = null,
initialTab = "all",
initialMessagePage,
}: MessagesViewProps) {
const router = useRouter();
const [guildId, setGuildId] = useState(initialGuild);
const [selectedChannel, setSelectedChannel] = useState(initialChannel);
const [detailId, setDetailId] = useState<string | null>(initialDetailId);
const [tab, setTab] = useState<MessagesTab>(initialTab);
const [searchOpen, setSearchOpen] = useState(false);
const [lightbox, setLightbox] = useState<{
images: Array<{ src: string; alt?: string }>;
index: number;
} | null>(null);
function aiTone(s?: AiStatus | null): "signal" | "amber" | "vermilion" | "neutral" {
if (s === "clean") return "signal";
if (s === "warn") return "amber";
if (s === "flagged" || s === "error") return "vermilion";
if (s === "processing" || s === "pending") return "neutral";
return "neutral";
}
export function MessagesView({
initialGuilds,
initialGuildId,
}: {
initialGuilds?: Guild[];
initialGuildId?: string | null;
}) {
const ws = useWebSocket();
const { data: channels = [] } = useTextChannels(guildId);
const {
data: messages,
error,
refetch,
} = useMessages(
guildId,
selectedChannel || undefined,
guildId === initialGuild && selectedChannel === initialChannel
? initialMessagePage
: undefined,
const { data: guilds } = useGuilds(initialGuilds);
const [guildId, setGuildId] = useState<string | null>(
initialGuildId ?? initialGuilds?.[0]?.id ?? null,
);
const { data: cursorData } = useMessagesHasMore(
guildId,
selectedChannel || undefined,
);
const loadMoreMut = useLoadMore();
const { data: images } = useImages(guildId);
const { data: reviews } = useReview(selectedChannel || undefined);
const { message: detailMessage, loading: detailLoading } =
useMessageDetail(detailId);
const [channelId, setChannelId] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [query, setQuery] = useState("");
useMessagesWsSync(ws, guildId);
const { data: messages, isLoading, error } = useMessages(guildId ?? "", channelId ?? undefined);
useMessagesWsSync(ws, guildId ?? "");
const search = useMessageSearch(query, query.trim().length >= 2);
const detail = useMessageDetail(selected);
const ambient = useAmbient();
useEffect(() => {
const params = new URLSearchParams();
if (guildId) params.set("guild", guildId);
if (selectedChannel) params.set("channel", selectedChannel);
if (detailId) params.set("selected", detailId);
if (tab !== "all") params.set("tab", tab);
router.replace(`/messages?${params.toString()}`, { scroll: false });
}, [guildId, selectedChannel, detailId, tab, router]);
ambient.set(query ? "amber" : "signal", 0.3, query ? "search" : "messages");
}, [query, ambient]);
// global Cmd+K
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setSearchOpen(true);
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
const handleLoadMore = useCallback(() => {
if (!cursorData?.cursor || loadMoreMut.isPending) return;
loadMoreMut.mutate({
guildId,
channelId: selectedChannel || undefined,
cursor: cursorData.cursor,
});
}, [cursorData, loadMoreMut, guildId, selectedChannel]);
const handleGuildChange = useCallback((g: string) => {
setGuildId(g);
setSelectedChannel("");
setDetailId(null);
}, []);
const tabs: { id: MessagesTab; label: string; icon: React.ReactNode }[] = [
{ id: "all", label: "All", icon: null },
{ id: "images", label: "Images", icon: <Image className="size-3.5" /> },
{ id: "review", label: "Review", icon: <Flag className="size-3.5" /> },
];
const currentMessages = messages ?? [];
const searching = query.trim().length >= 2;
const list = searching ? search.data ?? [] : (messages ?? []);
return (
<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}
onChange={(e) => setSelectedChannel(e.target.value || "")}
className="w-48"
>
<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="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] sm:inline">(K)</span>
</button>
</div>
{/* 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)]",
)}
>
{t.icon}
{t.label}
</button>
))}
</div>
<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 ? (
<div className="flex h-40 items-center justify-center">
<Loader2 className="size-5 animate-spin text-[var(--color-ink-soft)]" />
</div>
) : detailMessage ? (
<div className="flex flex-col gap-3">
<button
type="button"
onClick={() => setDetailId(null)}
className="text-xs text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
>
Back to list
</button>
<MessageDetailView message={detailMessage} />
{detailMessage && (
<Lightbox
open={!!lightbox}
onClose={() => setLightbox(null)}
images={extractImages(detailMessage.metadata)}
/>
)}
</div>
) : null}
</div>
</div>
)}
</div>
<SearchOverlay
open={searchOpen}
onClose={() => setSearchOpen(false)}
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
open={!!lightbox}
onClose={() => setLightbox(null)}
images={lightbox.images}
initialIndex={lightbox.index}
<div className="space-y-4">
<GlassPanel className="flex flex-wrap items-center gap-3">
<GuildChannelPicker
mode="text"
guildsInitial={initialGuilds}
guildId={guildId}
channelId={channelId}
onChange={(g, c) => {
setGuildId(g);
setChannelId(c);
setSelected(null);
}}
/>
<div className="relative ml-auto w-64">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-ink-faint" />
<Input
className="pl-9"
placeholder="Search messages…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
</GlassPanel>
<div className="grid gap-4 lg:grid-cols-5">
<GlassPanel className="lg:col-span-3">
<SectionHeader
eyebrow={searching ? "results" : "live feed"}
title={searching ? `${query}` : "Messages"}
action={
<span className="mono text-xs text-ink-faint">
{list.length} shown
</span>
}
/>
{error && !messages ? (
<ErrorState error={error} />
) : isLoading && !messages ? (
<LoadingState label="Capturing" />
) : list.length === 0 ? (
<EmptyState icon={<MessageSquare className="size-7" />} title="No messages" description="Pick a guild to begin, or run a search." />
) : (
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
{list.map((m) => (
<button
key={m.id}
type="button"
onClick={() => setSelected(m.id)}
className={`flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${
selected === m.id ? "border-signal/40 bg-signal/8" : "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
}`}
>
<Avatar src={m.avatar_url} name={m.username} size={34} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold text-ink">{m.username}</span>
<span className="mono text-[0.65rem] text-ink-faint">{getMessageChannelLabel(m)}</span>
<span className="mono ml-auto text-[0.6rem] text-ink-faint">{relTime(m.created_at)}</span>
</div>
<div className="mt-0.5 line-clamp-2 text-sm text-ink-soft">
{renderMessageContent(m.content, m.metadata) || <span className="italic text-ink-faint">(empty / embed)</span>}
</div>
</div>
<AiBadge status={m.ai_status} />
</button>
))}
</div>
)}
</GlassPanel>
<GlassPanel className="lg:col-span-2">
<SectionHeader eyebrow="inspect" title="Detail" />
{!selected ? (
<EmptyState title="Select a message" description="Click any message to inspect AI analysis, attachments and edit history." />
) : detail.loading ? (
<div className="space-y-2">
<Skeleton className="h-20" />
<Skeleton className="h-12" />
</div>
) : detail.message ? (
<MessageDetail m={detail.message} attachments={detail.attachments} />
) : (
<EmptyState title="Not found" />
)}
</GlassPanel>
</div>
</div>
);
}
function AiBadge({ status }: { status?: AiStatus | null }) {
if (!status) return null;
const tone = aiTone(status);
const icon =
status === "clean" ? <CheckCircle2 className="size-3" /> :
status === "flagged" ? <ShieldAlert className="size-3" /> :
status === "warn" ? <AlertTriangle className="size-3" /> :
status === "processing" || status === "pending" ? <Loader2 className="size-3 animate-spin" /> :
<AlertTriangle className="size-3" />;
return <Badge tone={tone} dot={status === "processing" || status === "pending"}>{icon}{status}</Badge>;
}
function MessageDetail({ m, attachments }: { m: MessageRecord; attachments: import("@/lib/types").AttachmentRecord[] }) {
const flags = safeParseJsonArray(m.ai_moderation_flags);
const cats = safeParseJsonArray(m.ai_categories);
return (
<div className="space-y-3 text-sm">
<div className="flex items-center gap-3">
<Avatar src={m.avatar_url} name={m.username} size={40} />
<div>
<div className="font-semibold text-ink">{m.username}</div>
<div className="mono text-[0.65rem] text-ink-faint">{getMessageChannelLabel(m)} · {relTime(m.created_at)}</div>
</div>
<div className="ml-auto"><AiBadge status={m.ai_status} /></div>
</div>
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">
{renderMessageContent(m.edited_content ?? m.content, m.metadata) || "(no text)"}
</div>
{m.ai_analysis && (
<div>
<div className="eyebrow mb-1">AI analysis</div>
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">{m.ai_analysis}</div>
</div>
)}
{(flags.length > 0 || cats.length > 0) && (
<div className="flex flex-wrap gap-1.5">
{flags.map((f) => <Badge key={f} tone="vermilion">{f}</Badge>)}
{cats.map((c) => <Badge key={c} tone="amber">{c}</Badge>)}
</div>
)}
{attachments.length > 0 && (
<div>
<div className="eyebrow mb-1 flex items-center gap-1.5"><Paperclip className="size-3" /> Attachments ({attachments.length})</div>
<div className="space-y-1.5">
{attachments.map((a) => (
<a key={a.id} href={a.discord_url ?? a.uploaded_url ?? "#"} target="_blank" rel="noreferrer" className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft hover:text-ink">
<ImageIcon className="size-3.5 text-signal" />
<span className="flex-1 truncate">{a.filename}</span>
<span className="mono text-ink-faint">{formatBytes(a.size)}</span>
</a>
))}
</div>
</div>
)}
</div>
);
}
function ImageGrid({
items,
onSelect,
}: {
items: MessageRecord[];
onSelect: (id: string) => void;
}) {
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 url = extractFirstImage(item.metadata);
return (
<button
key={item.id}
type="button"
onClick={() => onSelect(item.id)}
className="overflow-hidden rounded-[var(--radius-r)] border border-[var(--color-hairline)]"
>
{url ? (
<img
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-[var(--color-ink-soft)]/40">
No image
</div>
)}
</button>
);
})}
</div>
);
}
function ReviewList({
items,
onSelect,
}: {
items: MessageRecord[];
onSelect: (id: string) => void;
}) {
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) => (
<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>
))}
</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,25 +1,18 @@
/**
* Moderation Server Component.
* Seeds moderation stats + action log for SSR first paint; live via WS.
*/
import { getModerationActions, getModerationStats } from "@/lib/api/server";
import ModerationView from "./view";
import { ModerationView } from "./view";
export const dynamic = "force-dynamic";
export default async function ModerationPage() {
const [stats, actions] = await Promise.allSettled([
getModerationStats().catch(() => undefined),
getModerationActions(100).catch(() => undefined),
]);
return (
<ModerationView
initialStats={
stats.status === "fulfilled" && stats.value ? stats.value : undefined
}
initialActions={
actions.status === "fulfilled" && actions.value
? actions.value
: undefined
}
/>
);
let stats = undefined;
let actions = undefined;
try {
[stats, actions] = await Promise.all([
getModerationStats(),
getModerationActions(100),
]);
} catch {
/* client hooks surface errors */
}
return <ModerationView initialStats={stats} initialActions={actions} />;
}
@@ -1,19 +1,188 @@
"use client";
import { ModerationSection } from "@/components/moderation/moderation-section";
import type { ModerationAction, ModerationStats } from "@/lib/types";
import { useEffect, useState } from "react";
import {
ShieldAlert,
CheckCircle2,
XCircle,
Clock,
Ban,
Trash2,
MicOff,
AlertTriangle,
UserX,
MessageSquareWarning,
Filter,
} from "lucide-react";
import {
useModerationStats,
useModerationActions,
} from "@/hooks";
import { useAmbient } from "@/components/ambient/ambient-context";
import { GlassPanel, GlassCard, Badge, Select, type SelectOption } from "@/components/primitives";
import { SectionHeader, MetricTile, ErrorState, LoadingState } from "@/components/shared";
import { Donut } from "@/components/charts";
import { formatNumber } from "@/lib/format";
import type {
ModerationAction,
ModerationActionType,
ModerationStats,
} from "@/lib/types";
export default function ModerationView({
const ACTION_ICON: Record<ModerationActionType, React.ReactNode> = {
delete_message: <Trash2 className="size-3.5" />,
mute_user: <MicOff className="size-3.5" />,
warn_user: <MessageSquareWarning className="size-3.5" />,
kick_user: <UserX className="size-3.5" />,
ban_user: <Ban className="size-3.5" />,
};
const ACTION_LABEL: Record<ModerationActionType, string> = {
delete_message: "Delete",
mute_user: "Mute",
warn_user: "Warn",
kick_user: "Kick",
ban_user: "Ban",
};
export function ModerationView({
initialStats,
initialActions,
}: {
initialStats?: ModerationStats;
initialActions?: ModerationAction[];
}) {
const { data: stats, isLoading, error } = useModerationStats(initialStats);
const [statusFilter, setStatusFilter] = useState<string>("");
const [typeFilter, setTypeFilter] = useState<string>("");
const { data: actions } = useModerationActions(
statusFilter || undefined,
typeFilter || undefined,
!statusFilter && !typeFilter ? initialActions : undefined,
);
const failedRate = stats ? stats.failed_rate * 100 : 0;
const byAction = stats?.by_action ?? {};
const segments = Object.entries(byAction).map(([k, v]) => ({
value: 1,
color:
k === "ban_user" || k === "kick_user"
? "var(--color-vermilion)"
: k === "warn_user"
? "var(--color-amber)"
: "var(--color-signal)",
label: k,
}));
const ambient = useAmbient();
useEffect(() => {
ambient.set(
failedRate > 20 ? "vermilion" : failedRate > 5 ? "amber" : "signal",
0.3 + Math.min(0.4, failedRate / 50),
"moderation",
);
}, [failedRate, ambient]);
if (error && !stats) return <ErrorState error={error} />;
if (!stats && isLoading) return <LoadingState label="Reading log" />;
const statusOpts: SelectOption[] = [
{ value: "", label: "All statuses" },
{ value: "pending", label: "Pending" },
{ value: "executed", label: "Executed" },
{ value: "failed", label: "Failed" },
];
const typeOpts: SelectOption[] = [
{ value: "", label: "All actions" },
...Object.keys(byAction).map((k) => ({ value: k, label: ACTION_LABEL[k as ModerationActionType] ?? k })),
];
return (
<ModerationSection
initialStats={initialStats}
initialActions={initialActions}
/>
<div className="space-y-5">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile label="Total actions" value={formatNumber(stats!.total)} tone="signal" icon={<ShieldAlert className="size-3.5" />} />
<MetricTile label="Executed" value={formatNumber(stats!.executed)} tone="signal" icon={<CheckCircle2 className="size-3.5" />} />
<MetricTile label="Failed" value={formatNumber(stats!.failed)} tone={stats!.failed > 0 ? "vermilion" : "neutral"} icon={<XCircle className="size-3.5" />} />
<MetricTile label="Pending" value={formatNumber(stats!.pending)} tone={stats!.pending > 0 ? "amber" : "neutral"} icon={<Clock className="size-3.5" />} />
</div>
<div className="grid gap-5 lg:grid-cols-5">
<GlassPanel className="lg:col-span-2">
<SectionHeader eyebrow="health" title="Breakdown" />
<div className="flex items-center gap-5">
<Donut
segments={segments.length ? segments : [{ value: 1, color: "var(--color-ink-faint)", label: "none" }]}
centerLabel={`${Math.round(failedRate)}%`}
centerSub="fail rate"
/>
<div className="flex-1 space-y-2 text-sm">
{Object.entries(byAction).map(([k, v]) => {
const count = typeof v === "number" ? v : null;
return (
<div key={k} className="flex items-center gap-2.5">
<span className="text-ink-soft">{ACTION_ICON[k as ModerationActionType]}</span>
<span className="flex-1 text-ink-soft">{ACTION_LABEL[k as ModerationActionType] ?? k}</span>
{count !== null && <span className="mono text-ink">{count}</span>}
</div>
);
})}
{Object.keys(byAction).length === 0 && (
<div className="text-xs text-ink-faint">No actions recorded yet.</div>
)}
</div>
</div>
</GlassPanel>
<GlassPanel className="lg:col-span-3">
<SectionHeader
eyebrow="filter"
title="Action log"
action={
<div className="flex items-center gap-2">
<Filter className="size-3.5 text-ink-faint" />
<Select value={typeFilter} onChange={setTypeFilter} options={typeOpts} size="sm" className="w-36" />
<Select value={statusFilter} onChange={setStatusFilter} options={statusOpts} size="sm" className="w-32" />
</div>
}
/>
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
{(actions ?? []).map((a) => (
<ActionRow key={a.id} a={a} />
))}
{(actions ?? []).length === 0 && (
<div className="py-10 text-center text-xs text-ink-faint">No matching actions.</div>
)}
</div>
</GlassPanel>
</div>
</div>
);
}
function ActionRow({ a }: { a: ModerationAction }) {
const tone =
a.status === "executed" ? "signal" : a.status === "failed" ? "vermilion" : "amber";
const icon = ACTION_ICON[a.action_type] ?? <AlertTriangle className="size-3.5" />;
return (
<div className="flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3">
<span className={`mt-0.5 ${tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal"}`}>{icon}</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-ink">{a.username ?? "unknown"}</span>
<Badge tone={tone}>{a.status}</Badge>
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
{a.created_at ? new Date(a.created_at).toLocaleString() : "—"}
</span>
</div>
{a.reason && <div className="mt-0.5 text-xs text-ink-soft">{a.reason}</div>}
{a.content && (
<div className="mt-1 line-clamp-2 rounded-[8px] bg-white/[0.03] px-2 py-1 text-xs text-ink-faint">
{a.content}
</div>
)}
{a.error && <div className="mt-1 text-xs text-vermilion">{a.error}</div>}
</div>
</div>
);
}
@@ -1,12 +1,14 @@
/**
* 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";
import { RecordingsView } from "./view";
export const dynamic = "force-dynamic";
export default async function RecordingsPage() {
const data = await getRecordings(50).catch(() => undefined);
return <RecordingsView initialRecordings={data?.items} />;
let recordings = undefined;
try {
recordings = await getRecordings(50);
} catch {
/* client hooks surface errors */
}
return <RecordingsView initialItems={recordings?.items} />;
}
@@ -1,206 +1,92 @@
"use client";
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 { useEffect } from "react";
import { Headphones, Trash2, Download } from "lucide-react";
import { useWebSocket } from "@/lib/ws/context";
import { useRecordings, useDeleteRecording, useRecordingsWsSync } from "@/hooks";
import { useAmbient } from "@/components/ambient/ambient-context";
import { GlassPanel, GlassCard, Avatar, Button } from "@/components/primitives";
import { SectionHeader, EmptyState, ErrorState, LoadingState } from "@/components/shared";
import { formatBytes } from "@/lib/format";
import { toast } from "@/components/primitives";
import type { VoiceRecording } from "@/lib/types";
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;
}
export function RecordingsView({ initialItems }: { initialItems?: VoiceRecording[] }) {
const ws = useWebSocket();
const { data: items, isLoading, error } = useRecordings(initialItems);
const del = useDeleteRecording();
useRecordingsWsSync(ws);
const ambient = useAmbient();
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>
);
useEffect(() => {
ambient.set("signal", 0.3, "recordings");
}, [ambient]);
const onDelete = async (id: string) => {
try {
await del.mutateAsync(id);
toast({ title: "Recording deleted", tone: "signal" });
} catch (e) {
toast({ title: "Delete failed", description: String(e), tone: "vermilion" });
}
};
if (error && !items) return <ErrorState error={error} />;
if (!items && isLoading) return <LoadingState label="Loading clips" />;
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()}
<GlassPanel>
<SectionHeader
eyebrow="voice captures"
title="Recordings"
action={<span className="mono text-xs text-ink-faint">{(items ?? []).length} clips</span>}
/>
{(items ?? []).length === 0 ? (
<EmptyState icon={<Headphones className="size-7" />} title="No recordings" description="Voice clips captured by the bot appear here." />
) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{(items ?? []).map((r) => (
<GlassCard key={r.id} className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<Avatar src={r.avatar_url} name={r.username} size={38} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-ink">{r.username}</div>
<div className="mono text-[0.65rem] text-ink-faint">
{r.channel_name ?? "voice"} · {new Date(r.created_at).toLocaleString()}
</div>
</div>
<span className="mono text-[0.65rem] text-ink-faint">{formatBytes(r.size_bytes)}</span>
</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>
</>
{r.download_url ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<audio controls src={r.download_url} className="h-9 w-full" preload="none" />
) : (
<div className="rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-faint">
Upload pending
</div>
)}
<div className="flex items-center gap-2">
{r.download_url && (
<a href={r.download_url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1.5 rounded-[9px] border border-hairline px-2.5 py-1.5 text-xs text-ink-soft hover:text-ink hover:border-signal/40">
<Download className="size-3.5" /> Download
</a>
)}
<Button
variant="outline"
size="sm"
variant="danger"
disabled={deleting === rec.id}
onClick={() => onDelete(rec)}
aria-label="Delete"
className="ml-auto"
onClick={() => onDelete(r.id)}
disabled={del.isPending}
>
<Delete className="size-4" />
<Trash2 className="size-3.5" /> Delete
</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}
</GlassCard>
))}
</div>
{/* biome-ignore lint/a11y/useMediaCaption: voice recordings are uncaptioned audio previews — no transcript available */}
<audio
controls
src={recording.download_url ?? ""}
aria-label={`Audio recording: ${recording.filename}`}
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 ws = useWebSocket();
const {
data: recordings = [],
error,
isLoading,
} = useRecordings(initialRecordings);
const del = useDeleteRecording();
const [deleting, setDeleting] = useState<string | null>(null);
useRecordingsWsSync(ws);
const handleDelete = useCallback(
(rec: VoiceRecording) => {
setDeleting(rec.id);
del.mutate(rec.id);
setTimeout(() => setDeleting(null), 800);
},
[del],
);
const [preview, setPreview] = useState<VoiceRecording | null>(null);
return (
<RecordingsList
recordings={recordings}
error={error}
isLoading={isLoading}
deleting={deleting}
onSelect={setPreview}
onDelete={handleDelete}
preview={preview}
onClosePreview={() => setPreview(null)}
/>
)}
</GlassPanel>
);
}
@@ -1,13 +1,15 @@
/**
* 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 { getVoiceStatus } from "@/lib/api/server";
import type { VoiceStatus } from "@/lib/types";
import VoiceView from "./view";
import { getGuilds, getVoiceStatus } from "@/lib/api/server";
import { VoiceView } from "./view";
export const dynamic = "force-dynamic";
export default async function VoicePage() {
const status = await getVoiceStatus().catch(() => undefined);
return <VoiceView initialStatus={status} />;
let status = undefined;
let guilds = undefined;
try {
[status, guilds] = await Promise.all([getVoiceStatus(), getGuilds()]);
} catch {
/* client hooks surface errors */
}
return <VoiceView initialStatus={status} initialGuilds={guilds} />;
}
@@ -1,232 +1,203 @@
"use client";
import { Headphones, Loader2, Radio, RadioOff } from "lucide-react";
import { useEffect, useState } from "react";
import { SignalField } from "@/components/three";
import { WebGLGuard } from "@/components/three/webgl-guard";
import { StaticFallback } from "@/components/three/static-fallback";
import { StaggerGroup, StaggerItem } from "@/components/motion/stagger";
import { Button } from "@/components/primitives/button";
import { Badge } from "@/components/primitives/badge";
import { Select } from "@/components/primitives/select";
import { SpeakerWaveform } from "@/components/voice/speaker-waveform";
import { SessionRibbon } from "@/components/charts/session-ribbon";
import { ActiveSpeakersPanel } from "@/components/voice/active-speakers-panel";
import { MicControl } from "@/components/voice/mic-control";
import { ListenControl } from "@/components/voice/listen-control";
import { Mic, MicOff, Headphones, PhoneOff, Radio, Volume2, Waves } from "lucide-react";
import { useWebSocket } from "@/lib/ws/context";
import {
useGuilds,
useMicTransmit,
useSpeakers,
useVoiceChannels,
useVoiceStatus,
useVoiceConnect,
useVoiceDisconnect,
useSpeakers,
useMicTransmit,
useVoiceListen,
useVoiceStatus,
} from "@/hooks";
import type { VoiceStatus } from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context";
import { useAmbient } from "@/components/ambient/ambient-context";
import { GlassPanel, Button } from "@/components/primitives";
import { VoiceStage } from "@/components/voice/voice-stage";
import { Equalizer } from "@/components/charts";
import { SectionHeader, EmptyState, ErrorState, LoadingState } from "@/components/shared";
import { GuildChannelPicker } from "@/components/shared/guild-picker";
import { toast } from "@/components/primitives";
import type { Guild, VoiceStatus } from "@/lib/types";
export default function VoiceView({ initialStatus }: { initialStatus?: VoiceStatus }) {
export function VoiceView({
initialStatus,
initialGuilds,
}: {
initialStatus?: VoiceStatus;
initialGuilds?: Guild[];
}) {
const ws = useWebSocket();
const [selectedGuild, setSelectedGuild] = useState("");
const [selectedChannel, setSelectedChannel] = useState("");
// Live connection status — SWR revalidates on connect/disconnect (the
// useVoiceConnect/Disconnect actions invalidate the "voice-status" key),
// so this reflects real-time state instead of the static SSR snapshot.
const { data: status } = useVoiceStatus(initialStatus);
const { speakers, subscribe } = useSpeakers(status?.activeSpeakers ?? []);
const { data: guilds = [] } = useGuilds();
const { data: voiceChannels = [] } = useVoiceChannels(selectedGuild);
const { data: status, isLoading, error } = useVoiceStatus(initialStatus);
const { data: guilds } = useGuilds(initialGuilds);
const connect = useVoiceConnect();
const disconnect = useVoiceDisconnect();
const listen = useVoiceListen(ws);
const mic = useMicTransmit(ws);
const [micActive, setMicActive] = useState(false);
const [micVolume, setMicVolume] = useState(75);
const listen = useVoiceListen(ws);
const { speakers, subscribe } = useSpeakers(initialStatus?.activeSpeakers);
const ambient = useAmbient();
const [guildId, setGuildId] = useState<string | null>(
initialStatus?.activeGuildId ?? initialGuilds?.[0]?.id ?? null,
);
const [channelId, setChannelId] = useState<string | null>(
initialStatus?.activeChannelId ?? null,
);
const [micOn, setMicOn] = useState(false);
useEffect(() => {
const unsub = subscribe(ws);
return unsub;
}, [subscribe, ws]);
const active = speakers.filter((s) => s.speaking);
const connected = status?.connected ?? false;
useEffect(() => {
if (status?.connected) ambient.set("signal", 0.55, "voice live");
else ambient.set("vermilion", 0.35, "voice idle");
}, [status?.connected, ambient]);
const handleMicToggle = async (on: boolean) => {
if (on) {
try {
await mic.mutateAsync(true);
setMicActive(true);
} catch {
setMicActive(false);
}
} else {
setMicActive(false);
try {
await mic.mutateAsync(false);
} catch {
// Stop already tore down — ignore remote error
}
if (error && !status) return <ErrorState error={error} />;
if (!status && isLoading) return <LoadingState label="Linking voice" />;
const connected = status?.connected ?? false;
const listenBars = Array.from(listen.levels.values()).slice(0, 32);
const onConnect = async () => {
if (!guildId || !channelId) {
toast({ title: "Pick a guild + channel", tone: "vermilion" });
return;
}
try {
await connect.mutateAsync({ guildId, channelId });
toast({ title: "Connected to voice", tone: "signal" });
} catch (e) {
toast({ title: "Connect failed", description: String(e), tone: "vermilion" });
}
};
const handleMicVolume = (v: number) => {
setMicVolume(v);
mic.setVolume(v);
};
const handleListenVolume = (v: number) => {
listen.setVolume(v);
};
const handleGuildChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const g = e.target.value;
setSelectedGuild(g);
setSelectedChannel("");
const onMic = async (on: boolean) => {
try {
await mic.mutateAsync(on);
setMicOn(on);
} catch (e) {
toast({ title: "Mic error", description: String(e), tone: "vermilion" });
}
};
return (
<div className="flex flex-col gap-5">
{/* Connection bar with guild + voice channel pickers */}
<div className="surface flex flex-col gap-3 p-4">
<div className="flex items-center gap-3">
<Badge tone={connected ? "signal" : "neutral"} dot>
{connected ? "Connected" : "Disconnected"}
</Badge>
{connected && status?.activeChannelName && (
<span className="hidden items-center gap-1.5 text-xs text-[var(--color-ink-soft)] sm:flex">
<Headphones className="size-3.5" />
{status.activeChannelName}
</span>
<div className="space-y-5">
<GlassPanel>
<div className="flex flex-wrap items-center justify-between gap-3">
<GuildChannelPicker
mode="voice"
guildsInitial={initialGuilds}
guildId={guildId}
channelId={channelId}
onChange={(g, c) => {
setGuildId(g);
setChannelId(c);
}}
/>
<div className="flex items-center gap-2">
{connected ? (
<Button variant="danger" size="sm" onClick={() => disconnect.mutate()} disabled={disconnect.isPending}>
<PhoneOff className="size-4" /> Disconnect
</Button>
) : (
<Button variant="primary" size="sm" onClick={onConnect} disabled={connect.isPending}>
<Radio className="size-4" /> Connect
</Button>
)}
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
variant={micOn ? "primary" : "outline"}
size="sm"
onClick={() => onMic(!micOn)}
disabled={mic.isPending}
aria-pressed={micOn}
>
{micOn ? <Mic className="size-4" /> : <MicOff className="size-4" />}
{micOn ? "Mic live" : "Push-to-talk"}
</Button>
<Button
variant={listen.active ? "primary" : "outline"}
size="sm"
onClick={() => listen.toggle(!listen.active)}
>
{listen.active ? <Headphones className="size-4" /> : <Volume2 className="size-4" />}
{listen.active ? "Listening" : "Listen in"}
</Button>
{listen.active && (
<div className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-1.5">
<Waves className="size-4 text-signal" />
<Equalizer bars={listenBars} className="w-40" />
</div>
)}
</div>
</GlassPanel>
{!connected ? (
<div className="flex flex-wrap gap-2">
<Select
value={selectedGuild}
onChange={handleGuildChange}
className="flex-1 min-w-[140px] h-9"
>
<option value="" disabled>
Select guild
</option>
{guilds.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</Select>
<Select
value={selectedChannel}
onChange={(e) => setSelectedChannel(e.target.value)}
disabled={!selectedGuild}
className="flex-1 min-w-[140px] h-9"
>
<option value="" disabled>
Select channel
</option>
{(voiceChannels ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
<Button
size="sm"
variant="primary"
onClick={() =>
void connect.mutate({
guildId: selectedGuild,
channelId: selectedChannel,
})
}
disabled={connect.isPending || !selectedGuild || !selectedChannel}
>
{connect.isPending ? (
<Loader2 className="size-3.5 animate-spin mr-1.5" />
) : (
<Radio className="size-3.5 mr-1.5" />
)}
Connect
</Button>
</div>
) : (
<Button
size="sm"
variant="danger"
onClick={() => void disconnect.mutate(undefined)}
disabled={disconnect.isPending}
>
{disconnect.isPending ? (
<Loader2 className="size-3.5 animate-spin mr-1.5" />
) : (
<RadioOff className="size-3.5 mr-1.5" />
)}
Disconnect
</Button>
)}
</div>
{/* Stage hero */}
<div className="relative surface 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"
<div className="grid gap-5 lg:grid-cols-3">
<GlassPanel className="lg:col-span-2">
<SectionHeader
eyebrow="stage"
title="Live speakers"
action={
<span className="mono text-xs text-ink-faint">
{speakers.length} present
</span>
}
/>
{speakers.length === 0 ? (
<EmptyState
icon={<MicOff className="size-7" />}
title={connected ? "Silent right now" : "Not connected"}
description={connected ? "Speakers appear as they talk." : "Connect to a voice channel to see presence."}
/>
}
>
<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>
) : (
<>
<VoiceStage speakers={speakers} />
<div className="mt-2 flex flex-wrap justify-center gap-2">
{speakers.map((sp) => (
<span
key={sp.userId}
className={`mono flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs ${
sp.speaking
? "border-signal/40 bg-signal/10 text-signal"
: "border-hairline bg-white/5 text-ink-soft"
}`}
>
<span className={`size-1.5 rounded-full ${sp.speaking ? "bg-signal animate-breathe" : "bg-ink-faint"}`} />
{sp.username}
</span>
))}
</div>
</>
)}
</GlassPanel>
<GlassPanel>
<SectionHeader eyebrow="links" title="Connections" />
<div className="space-y-2">
{(status?.connections ?? []).map((c) => (
<div key={`${c.guildId}-${c.channelId}`} className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-sm">
<span className="size-2 rounded-full bg-signal" />
<span className="flex-1 truncate text-ink-soft">{c.channelName}</span>
<span className="mono text-[0.6rem] text-ink-faint">{new Date(c.connectedAt).toLocaleTimeString()}</span>
</div>
))}
{(status?.connections ?? []).length === 0 && (
<div className="py-6 text-center text-xs text-ink-faint">No active links</div>
)}
</div>
<div className="mt-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft">
<span className="mono text-ink-faint">channel</span>{" "}
{status?.activeChannelName ?? "—"}
</div>
</GlassPanel>
</div>
<StaggerGroup className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
<StaggerItem>
<MicControl
micOn={micActive}
onToggle={handleMicToggle}
levels={listen.levels}
/>
</StaggerItem>
<StaggerItem>
<ListenControl
listening={listen.active}
onToggle={(on) => listen.toggle(on)}
volume={75}
onVolume={handleListenVolume}
/>
</StaggerItem>
</StaggerGroup>
{/* Activity timeline */}
<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>
<ActiveSpeakersPanel speakers={speakers} />
</div>
);
}