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>
);
}
+124 -121
View File
@@ -3,69 +3,66 @@
@custom-variant dark (&:is(.dark *));
/*
* GMW — new design system (visual overhaul).
* GMW — Ambient Ops Console design system.
*
* Replaces the old teal-cyan + purple + glassmorphism language with a warm,
* signal-driven ops-console aesthetic. Hierarchy comes from scale/weight and
* tonal surface blocks, NOT from borders/shadows. Three semantic signals:
* lime = OK / live (--signal)
* amber = warn (--amber)
* vermilion = flag/danger (--vermilion)
* A signal-driven, immersive ops aesthetic. Hierarchy comes from scale/weight
* and tonal surface blocks, not borders. The whole app sits behind a live
* WebGL haze (see components/ambient) tinted by a semantic signal:
* lime = OK / live
* amber = warn
* vermilion = flag / danger
*/
@theme {
/* ── Surfaces ── */
--color-canvas: oklch(0.96 0.012 80);
--color-surface: oklch(0.92 0.014 80);
--color-surface-2: oklch(0.88 0.016 80);
/* ── Surfaces (dark-first; light overrides below) ── */
--color-canvas: oklch(0.12 0.014 70);
--color-canvas-2: oklch(0.16 0.02 70);
--color-surface: oklch(0.2 0.022 70 / 0.55);
--color-surface-2: oklch(0.26 0.024 70 / 0.45);
/* ── Ink ── */
--color-ink: oklch(0.22 0.02 70);
--color-ink-soft: oklch(0.46 0.02 70);
--color-ink: oklch(0.95 0.008 75);
--color-ink-soft: oklch(0.66 0.02 75);
--color-ink-faint: oklch(0.5 0.02 75);
/* ── Structural ── */
--color-hairline: oklch(0.22 0.02 70 / 0.1);
--color-hairline: oklch(1 0 0 / 0.1);
--hairline-w: 1px;
/* ── Semantic signals ── */
--color-signal: oklch(0.78 0.17 125);
--color-signal-ink: oklch(0.20 0.03 70);
--color-signal-glow: oklch(0.78 0.17 125 / 0.35);
--color-amber: oklch(0.80 0.15 70);
--color-vermilion: oklch(0.62 0.21 25);
--color-vermilion-soft: oklch(0.62 0.21 25 / 0.15);
--color-signal: oklch(0.86 0.19 128);
--color-signal-ink: oklch(0.18 0.03 70);
--color-signal-glow: oklch(0.86 0.19 128 / 0.4);
--color-amber: oklch(0.85 0.15 72);
--color-vermilion: oklch(0.68 0.21 25);
--color-vermilion-glow: oklch(0.68 0.21 25 / 0.4);
--color-ring: var(--color-signal);
/* ── Fonts ── */
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--font-display: "Bricolage Grotesque", "Inter", sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
--font-display: "Bricolage Grotesque", "Inter", system-ui, sans-serif;
/* ── Radii ── */
--radius-r: 14px;
--radius-r: 16px;
--radius-r-panel: 12px;
--radius-r-control: 8px;
--radius-r-control: 9px;
--radius-r-pill: 9999px;
}
/* ── Dark theme overrides ── */
.dark {
--color-canvas: oklch(0.13 0.015 70);
--color-surface: oklch(0.18 0.02 70);
--color-surface-2: oklch(0.23 0.022 70);
/* ── Light theme overrides ── */
.light {
--color-canvas: oklch(0.96 0.012 80);
--color-canvas-2: oklch(0.92 0.014 80);
--color-surface: oklch(1 0 0 / 0.7);
--color-surface-2: oklch(1 0 0 / 0.5);
--color-ink: oklch(0.93 0.01 75);
--color-ink-soft: oklch(0.62 0.02 75);
--color-ink: oklch(0.22 0.02 70);
--color-ink-soft: oklch(0.46 0.02 70);
--color-ink-faint: oklch(0.6 0.02 70);
--color-hairline: oklch(1 0 0 / 0.09);
--color-signal: oklch(0.88 0.18 125);
--color-signal-ink: oklch(0.18 0.03 70);
--color-signal-glow: oklch(0.88 0.18 125 / 0.4);
--color-amber: oklch(0.85 0.15 70);
--color-vermilion: oklch(0.68 0.21 25);
--color-vermilion-soft: oklch(0.68 0.21 25 / 0.18);
--color-hairline: oklch(0.22 0.02 70 / 0.12);
}
@layer base {
@@ -79,41 +76,28 @@
body {
@apply bg-canvas text-ink font-sans antialiased;
/* warm dot-grid texture + faint glow — replaces old bluish radial layers */
background-image:
radial-gradient(circle, oklch(0.45 0.03 70 / 0.05) 1px, transparent 1px),
radial-gradient(ellipse 80% 50% at 50% -20%, oklch(0.78 0.17 125 / 0.06), transparent),
radial-gradient(ellipse 50% 40% at 85% 90%, oklch(0.80 0.15 70 / 0.04), transparent);
background-size: 26px 26px, 100% 100%, 100% 100%;
}
.dark body {
background-image:
radial-gradient(circle, oklch(1 0 0 / 0.022) 1px, transparent 1px),
radial-gradient(ellipse 80% 50% at 50% -20%, oklch(0.88 0.18 125 / 0.05), transparent),
radial-gradient(ellipse 50% 40% at 85% 90%, oklch(0.85 0.15 70 / 0.03), transparent);
background-size: 26px 26px, 100% 100%, 100% 100%;
min-height: 100dvh;
}
::selection {
background: oklch(0.78 0.17 125 / 0.35);
background: var(--color-signal-glow);
color: inherit;
}
/* Scrollbar — warm */
/* Scrollbar */
::-webkit-scrollbar {
width: 7px;
height: 7px;
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: oklch(0.4 0.02 70 / 0.22);
background: oklch(1 0 0 / 0.14);
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(0.4 0.02 70 / 0.38);
background: oklch(1 0 0 / 0.26);
}
:focus-visible {
@@ -122,59 +106,48 @@
}
}
@layer utilities {
/* Tonal block that replaces bordered cards */
.surface {
@layer components {
/* Glass panel — the workhorse container. Floating, blurred, faint glow. */
.glass {
background: var(--color-surface);
border-radius: var(--radius-r);
border: var(--hairline-w) solid var(--color-hairline);
border-radius: var(--radius-r);
backdrop-filter: blur(18px) saturate(140%);
-webkit-backdrop-filter: blur(18px) saturate(140%);
box-shadow:
0 1px 0 0 oklch(1 0 0 / 0.06) inset,
0 18px 50px -28px oklch(0 0 0 / 0.8);
}
.surface-2 {
.glass-2 {
background: var(--color-surface-2);
border: var(--hairline-w) solid var(--color-hairline);
border-radius: var(--radius-r-panel);
border: var(--hairline-w) solid var(--color-hairline);
backdrop-filter: blur(14px) saturate(130%);
-webkit-backdrop-filter: blur(14px) saturate(130%);
}
/* 1px animated pulse line marking a live/section header */
.scan-tick {
position: relative;
overflow: hidden;
}
.scan-tick::after {
content: "";
position: absolute;
inset-inline-start: 0;
inset-block-start: 0;
block-size: 1px;
inline-size: 100%;
background: linear-gradient(
90deg,
transparent,
var(--color-signal) 20%,
var(--color-signal) 80%,
transparent
);
background-size: 200% 100%;
animation: scan 2.6s linear infinite;
opacity: 0.7;
}
.ticker {
background: var(--color-surface);
border-radius: var(--radius-r);
border: var(--hairline-w) solid var(--color-hairline);
padding: clamp(1rem, 2vw, 1.5rem);
/* Section label — small uppercase mono eyebrow */
.eyebrow {
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-ink-faint);
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.7rem;
gap: 0.4rem;
padding: 0.22rem 0.7rem;
border-radius: var(--radius-r-pill);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.02em;
letter-spacing: 0.01em;
background: oklch(1 0 0 / 0.06);
border: 1px solid var(--color-hairline);
color: var(--color-ink-soft);
}
.mono {
@@ -186,26 +159,58 @@
.display {
font-family: var(--font-display);
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.02;
letter-spacing: -0.03em;
line-height: 0.96;
}
}
/* text tone helpers */
@layer utilities {
.text-signal { color: var(--color-signal); }
.text-amber { color: var(--color-amber); }
.text-vermilion { color: var(--color-vermilion); }
.text-ink-soft { color: var(--color-ink-soft); }
.text-ink-faint { color: var(--color-ink-faint); }
.glow-signal {
text-shadow: 0 0 22px var(--color-signal-glow);
}
.glow-vermilion {
text-shadow: 0 0 22px var(--color-vermilion-glow);
}
/* animated scan line for live headers */
.scan-line {
position: relative;
overflow: hidden;
}
.scan-line::after {
content: "";
position: absolute;
left: 0;
top: 0;
height: 1px;
width: 100%;
background: linear-gradient(
90deg,
transparent,
var(--color-signal) 25%,
var(--color-signal) 75%,
transparent
);
background-size: 200% 100%;
animation: scan 2.8s linear infinite;
opacity: 0.7;
}
/* focus ring helper for interactive blocks */
.ring-focus {
transition: box-shadow 0.18s ease;
transition: box-shadow 0.18s ease, border-color 0.18s ease;
}
.ring-focus:hover {
box-shadow: 0 0 0 1px var(--color-signal-glow);
}
}
/* ── Keyframes ──────────────────────────── */
/* ── Keyframes ── */
@keyframes scan {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
@@ -216,54 +221,52 @@
60% { transform: scaleY(0.5); }
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(8px); }
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes spin-disc {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* kept for live status dots */
@keyframes pulse-ring {
0% { transform: scale(0.8); opacity: 1; }
100% { transform: scale(2.5); opacity: 0; }
0% { transform: scale(0.85); opacity: 1; }
100% { transform: scale(2.6); opacity: 0; }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes breathe {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
.animate-eq {
animation: eq 0.9s ease-in-out infinite;
transform-origin: bottom;
}
.animate-spin-disc {
animation: spin-disc 8s linear infinite;
}
.animate-spin-disc.paused {
animation-play-state: paused;
}
.animate-pulse-ring {
animation: pulse-ring 1.5s ease-out infinite;
}
.animate-spin-disc { animation: spin-disc 9s linear infinite; }
.animate-spin-disc.paused { animation-play-state: paused; }
.animate-pulse-ring { animation: pulse-ring 1.6s ease-out infinite; }
.animate-shimmer {
background: linear-gradient(
90deg,
transparent,
oklch(0.78 0.17 125 / 0.08),
oklch(0.86 0.19 128 / 0.1),
transparent
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
animation: shimmer 1.6s infinite;
}
.animate-breathe { animation: breathe 3.5s ease-in-out infinite; }
/* ── Reduced motion: kill all decorative animation ── */
@media (prefers-reduced-motion: reduce) {
.scan-tick::after,
.scan-line::after,
.animate-eq,
.animate-spin-disc,
.animate-pulse-ring,
.animate-shimmer {
.animate-shimmer,
.animate-breathe {
animation: none !important;
}
html { scroll-behavior: auto; }
@@ -0,0 +1,231 @@
"use client";
import { useEffect, useRef } from "react";
import * as THREE from "three";
import { SIGNAL_RGB, type SignalTone } from "./ambient-context";
const VERT = /* glsl */ `
varying vec2 vUv;
void main(){
vUv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;
const FRAG = /* glsl */ `
precision mediump float;
varying vec2 vUv;
uniform float uTime;
uniform vec3 uColor;
uniform float uIntensity;
uniform vec2 uRes;
float hash(vec2 p){ p=fract(p*vec2(123.34,456.21)); p+=dot(p,p+45.32); return fract(p.x*p.y); }
float noise(vec2 p){
vec2 i=floor(p); vec2 f=fract(p);
float a=hash(i), b=hash(i+vec2(1.,0.)), c=hash(i+vec2(0.,1.)), d=hash(i+vec2(1.,1.));
vec2 u=f*f*(3.-2.*f);
return mix(mix(a,b,u.x),mix(c,d,u.x),u.y);
}
float fbm(vec2 p){
float v=0.0, a=0.5;
mat2 m=mat2(1.6,1.2,-1.2,1.6);
for(int i=0;i<5;i++){ v+=a*noise(p); p=m*p; a*=0.5; }
return v;
}
void main(){
vec2 uv=vUv;
vec2 p=uv-0.5;
p.x*=uRes.x/uRes.y;
float t=uTime*0.04*(0.6+uIntensity);
vec2 q=vec2(fbm(p*1.5+t), fbm(p*1.5-t+5.0));
float f=fbm(p*2.2 + q*1.8 + t*0.5);
vec2 c=vec2(sin(uTime*0.05)*0.25, cos(uTime*0.04)*0.18);
float d=length(p-c);
float glow=smoothstep(0.95,0.0,d)*0.5;
float haze=(f*0.7+glow)*uIntensity;
vec3 col=uColor*haze;
float g=hash(uv*uRes+uTime)*0.035;
col+=g;
float vig=smoothstep(1.25,0.15,length(p));
col*=0.35+0.65*vig;
gl_FragColor=vec4(col,1.0);
}
`;
const MOTE_COUNT = 140;
export function AmbientCanvas({
targetRef,
}: {
targetRef: React.MutableRefObject<{ tone: SignalTone; intensity: number }>;
}) {
const mountRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const mount = mountRef.current;
if (!mount) return;
let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({
antialias: false,
alpha: false,
powerPreference: "high-performance",
});
} catch {
return; // static CSS fallback remains
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
renderer.setPixelRatio(dpr);
renderer.setSize(mount.clientWidth, mount.clientHeight);
mount.appendChild(renderer.domElement);
renderer.domElement.style.width = "100%";
renderer.domElement.style.height = "100%";
renderer.domElement.style.display = "block";
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const uniforms = {
uTime: { value: 0 },
uColor: { value: new THREE.Color(...SIGNAL_RGB.signal) },
uIntensity: { value: 0.35 },
uRes: { value: new THREE.Vector2(1, 1) },
};
const quad = new THREE.Mesh(
new THREE.PlaneGeometry(2, 2),
new THREE.ShaderMaterial({
vertexShader: VERT,
fragmentShader: FRAG,
uniforms,
depthTest: false,
depthWrite: false,
}),
);
scene.add(quad);
// — Drifting motes —
const positions = new Float32Array(MOTE_COUNT * 3);
const speeds = new Float32Array(MOTE_COUNT);
for (let i = 0; i < MOTE_COUNT; i++) {
positions[i * 3] = (Math.random() - 0.5) * 2;
positions[i * 3 + 1] = (Math.random() - 0.5) * 2;
positions[i * 3 + 2] = 0;
speeds[i] = 0.01 + Math.random() * 0.03;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const moteMat = new THREE.PointsMaterial({
size: 0.012,
color: new THREE.Color(...SIGNAL_RGB.signal),
transparent: true,
opacity: 0.5,
blending: THREE.AdditiveBlending,
depthTest: false,
depthWrite: false,
});
const motes = new THREE.Points(geo, moteMat);
scene.add(motes);
const color = new THREE.Color();
const target = new THREE.Color();
let targetIntensity = 0.35;
let intensity = 0.35;
let raf = 0;
let last = performance.now();
let running = !reduce;
const resize = () => {
const w = mount.clientWidth || 1;
const h = mount.clientHeight || 1;
renderer.setSize(w, h);
uniforms.uRes.value.set(w * dpr, h * dpr);
};
const ro = new ResizeObserver(resize);
ro.observe(mount);
resize();
const onVisibility = () => {
running = !document.hidden && !reduce;
if (running) {
last = performance.now();
loop();
}
};
document.addEventListener("visibilitychange", onVisibility);
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const frame = (now: number) => {
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
uniforms.uTime.value += dt;
// Ease toward target signal/intensity each frame (no React re-render).
const tgt = targetRef.current;
target.set(...SIGNAL_RGB[tgt.tone]);
color.lerp(target, 0.04);
uniforms.uColor.value.copy(color);
moteMat.color.copy(color);
targetIntensity = 0.2 + tgt.intensity * 0.8;
intensity = lerp(intensity, targetIntensity, 0.04);
uniforms.uIntensity.value = intensity;
const pos = geo.attributes.position as THREE.BufferAttribute;
for (let i = 0; i < MOTE_COUNT; i++) {
let y = pos.getY(i) + speeds[i] * dt * (0.5 + tgt.intensity);
if (y > 1.1) y = -1.1;
pos.setY(i, y);
}
pos.needsUpdate = true;
renderer.render(scene, camera);
if (running) raf = requestAnimationFrame(frame);
};
const loop = () => {
if (raf) cancelAnimationFrame(raf);
last = performance.now();
raf = requestAnimationFrame(frame);
};
if (reduce) {
// single static frame
uniforms.uIntensity.value = 0.3;
renderer.render(scene, camera);
} else {
loop();
}
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
geo.dispose();
moteMat.dispose();
(quad.geometry as THREE.BufferGeometry).dispose();
(quad.material as THREE.Material).dispose();
renderer.dispose();
if (renderer.domElement.parentNode === mount) {
mount.removeChild(renderer.domElement);
}
};
}, [targetRef]);
return (
<div
ref={mountRef}
aria-hidden
className="fixed inset-0 -z-10 overflow-hidden"
style={{
background:
"radial-gradient(120% 90% at 50% 0%, oklch(0.2 0.04 70 / 0.5), oklch(0.1 0.015 70) 60%)",
}}
/>
);
}
@@ -0,0 +1,72 @@
"use client";
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import { AmbientCanvas } from "./ambient-canvas";
export type SignalTone = "signal" | "amber" | "vermilion";
/** sRGB triplets for the three semantic signals (matches globals.css). */
export const SIGNAL_RGB: Record<SignalTone, [number, number, number]> = {
signal: [0.42, 1.0, 0.52],
amber: [1.0, 0.76, 0.28],
vermilion: [1.0, 0.34, 0.28],
};
export interface AmbientState {
tone: SignalTone;
/** 0..1 — drives haze density + drift speed (e.g. server load). */
intensity: number;
label?: string;
}
export interface AmbientControls {
set: (tone: SignalTone, intensity?: number, label?: string) => void;
reset: () => void;
state: AmbientState;
}
const DEFAULT: AmbientState = { tone: "signal", intensity: 0.35, label: "nominal" };
const AmbientContext = createContext<AmbientControls | null>(null);
/**
* Holds the live ambient signal. The canvas reads `targetRef` inside its
* render loop (no React re-render per frame); `state` is mirrored into React
* only so small UI bits (topbar) can reflect the current tone.
*/
export function AmbientProvider({ children }: { children: React.ReactNode }) {
const targetRef = useRef<AmbientState>({ ...DEFAULT });
const [state, setState] = useState<AmbientState>(DEFAULT);
const set = useCallback((tone: SignalTone, intensity?: number, label?: string) => {
targetRef.current = {
tone,
intensity: intensity ?? targetRef.current.intensity,
label: label ?? targetRef.current.label,
};
setState({ ...targetRef.current });
}, []);
const reset = useCallback(() => {
targetRef.current = { ...DEFAULT };
setState({ ...DEFAULT });
}, []);
const value = useMemo<AmbientControls>(
() => ({ set, reset, state }),
[set, reset, state],
);
return (
<AmbientContext.Provider value={value}>
<AmbientCanvas targetRef={targetRef} />
{children}
</AmbientContext.Provider>
);
}
export function useAmbient(): AmbientControls {
const ctx = useContext(AmbientContext);
if (!ctx) throw new Error("useAmbient must be used within <AmbientProvider>");
return ctx;
}
@@ -1,140 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
/**
* AmbientField — full-bleed WebGL particle haze that reacts to live data.
*
* No container, no grid, no chrome. Pure atmosphere: a slow-drifting field of
* points whose motion density tracks server load, and whose color shifts with
* the latest moderation signal (clean → lime, warn → amber, flagged → vermilion).
*
* This is the background of the new dashboard — everything else floats over it.
*/
type Signal = "neutral" | "signal" | "amber" | "vermilion";
const SIGNAL_RGB: Record<Signal, [number, number, number]> = {
neutral: [0.52, 0.49, 0.46],
signal: [0.78, 0.85, 0.62],
amber: [0.95, 0.78, 0.42],
vermilion: [0.86, 0.32, 0.28],
};
interface AmbientFieldProps {
/** 0..1 — drives particle drift speed + density. */
load?: number;
/** Latest moderation signal — tints the haze. */
signal?: Signal;
}
export function AmbientField({
load = 0.3,
signal = "signal",
}: AmbientFieldProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const loadRef = useRef(load);
const signalRef = useRef<[number, number, number]>(SIGNAL_RGB[signal]);
const rafRef = useRef<number>(0);
useEffect(() => {
loadRef.current = load;
}, [load]);
useEffect(() => {
signalRef.current = SIGNAL_RGB[signal];
}, [signal]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let w = 0;
let h = 0;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const resize = () => {
w = canvas.clientWidth;
h = canvas.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas);
// Particle haze
const N = 90;
const pts = Array.from({ length: N }, () => ({
x: Math.random(),
y: Math.random(),
z: Math.random() * 0.8 + 0.2,
vx: (Math.random() - 0.5) * 0.0004,
vy: (Math.random() - 0.5) * 0.0004,
r: Math.random() * 1.5 + 0.5,
}));
const draw = () => {
const [cr, cg, cb] = signalRef.current;
const speed = 0.4 + loadRef.current * 1.6;
// Trail fade
ctx.fillStyle = "rgba(244, 240, 234, 0.06)";
ctx.fillRect(0, 0, w, h);
for (const p of pts) {
p.x += p.vx * speed;
p.y += p.vy * speed;
if (p.x < 0) p.x += 1;
if (p.x > 1) p.x -= 1;
if (p.y < 0) p.y += 1;
if (p.y > 1) p.y -= 1;
const px = p.x * w;
const py = p.y * h;
const rad = p.r * p.z * (1 + loadRef.current);
const alpha = 0.05 + p.z * 0.12;
ctx.beginPath();
ctx.arc(px, py, rad, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${Math.round(cr * 255)}, ${Math.round(cg * 255)}, ${Math.round(cb * 255)}, ${alpha})`;
ctx.fill();
}
// Faint vignette glow center
const grad = ctx.createRadialGradient(
w / 2,
h / 2,
0,
w / 2,
h / 2,
Math.max(w, h) * 0.6,
);
grad.addColorStop(
0,
`rgba(${Math.round(cr * 255)}, ${Math.round(cg * 255)}, ${Math.round(cb * 255)}, 0.03)`,
);
grad.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
rafRef.current = requestAnimationFrame(draw);
};
rafRef.current = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(rafRef.current);
ro.disconnect();
};
}, []);
return (
<canvas
ref={canvasRef}
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 h-full w-full"
/>
);
}
@@ -1,146 +0,0 @@
"use client";
import { Loader2, Search, Sparkles } from "lucide-react";
import { useCallback, useState } from "react";
import { Avatar } from "@/components/primitives/avatar";
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 { EmptyState, LoadingSkeleton } from "@/components/shared";
import { useMessageSearch } from "@/hooks";
import { renderMessageContent, safeParseJsonArray } from "@/lib/format";
export function SearchPanel() {
const [query, setQuery] = useState("");
const [enabled, setEnabled] = useState(false);
const { data: results, isValidating: isFetching } = useMessageSearch(
query,
enabled,
);
const handleSearch = useCallback(() => {
if (!query.trim()) return;
setEnabled(true);
}, [query]);
return (
<div className="space-y-5">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-[var(--color-ink-soft)]" />
<Input
placeholder="Search message content, AI flags, analysis text…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
className="pl-9 h-9"
/>
</div>
<Button onClick={handleSearch} disabled={!query.trim() || isFetching}>
{isFetching && <Loader2 className="size-4 animate-spin mr-1.5" />}
Search
</Button>
</div>
{isFetching ? (
<LoadingSkeleton count={5} height="h-28" />
) : results !== undefined ? (
<>
<p className="text-sm text-[var(--color-ink-soft)]">
Found {results.length} result{results.length !== 1 ? "s" : ""}
</p>
{results.length === 0 ? (
<EmptyState
icon={Search}
title="No messages found matching your query."
/>
) : (
<div className="space-y-2">
{results.map((msg) => (
<div key={msg.id} className="surface p-4">
<div className="flex items-start gap-3">
<Avatar
src={msg.avatar_url ?? undefined}
name={msg.username}
size={32}
className="mt-0.5 shrink-0"
/>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-[var(--color-ink)]">
{msg.username}
</span>
<span className="text-xs text-[var(--color-ink-soft)]">
{msg.created_at
? new Date(msg.created_at).toLocaleString()
: ""}
</span>
{msg.ai_status && (
<Badge
tone={
msg.ai_status === "clean"
? "signal"
: msg.ai_status === "flagged"
? "vermilion"
: "neutral"
}
>
{msg.ai_status}
</Badge>
)}
</div>
<p className="text-sm leading-relaxed text-[var(--color-ink)]">
{renderMessageContent(
msg.edited_content ?? msg.content,
msg.metadata,
)}
</p>
{msg.ai_moderation_flags &&
msg.ai_moderation_flags !== "[]" && (
<div className="flex flex-wrap gap-1">
{safeParseJsonArray(msg.ai_moderation_flags).map(
(flag) => (
<Badge key={flag} tone="vermilion">
{flag}
</Badge>
),
)}
</div>
)}
{msg.ai_analysis && (
<p className="text-xs text-[var(--color-ink-soft)] italic line-clamp-2 leading-relaxed">
<Sparkles className="size-3 inline mr-1" />
{msg.ai_analysis}
</p>
)}
{msg.ai_confidence != null && (
<div className="flex items-center gap-2 max-w-40">
<Progress value={msg.ai_confidence * 100} />
<span className="text-[11px] text-[var(--color-ink-soft)] tabular-nums shrink-0">
{(msg.ai_confidence * 100).toFixed(0)}%
</span>
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
</>
) : (
<div className="flex flex-col items-center justify-center py-24 text-center">
<Search className="size-12 text-[var(--color-ink-soft)] mb-4" />
<p className="text-sm text-[var(--color-ink-soft)]">
Enter a search query to find messages across all channels.
</p>
<p className="text-xs text-[var(--color-ink-soft)] mt-1">
Searches message content, AI flags, and analysis text.
</p>
</div>
)}
</div>
);
}
@@ -1,83 +1,48 @@
"use client";
import { motion, useReducedMotion } from "motion/react";
import { useId } from "react";
export interface AreaPoint {
label: string;
value: number;
}
export interface AreaActivityProps {
data: AreaPoint[];
height?: number;
stroke?: string;
className?: string;
label?: string;
}
import type { DailyActivityPoint } from "@/lib/types";
/**
* Dual-area activity chart: total messages (signal) vs flagged (vermilion).
* Pure SVG, scales to container. Includes a 7-day trailing window hint.
*/
export function AreaActivity({
data,
height = 160,
stroke = "var(--color-signal)",
className,
label,
}: AreaActivityProps) {
const id = useId().replace(/:/g, "");
const reduce = useReducedMotion();
const width = 600;
if (data.length === 0)
return <div className={className} style={{ height }} />;
daily,
height = 200,
}: {
daily: DailyActivityPoint[];
height?: number;
}) {
const w = 720;
const pad = 8;
const n = daily.length;
const max = Math.max(...daily.map((d) => d.messages), 1);
const x = (i: number) => pad + (i / Math.max(n - 1, 1)) * (w - pad * 2);
const y = (v: number) => height - pad - (v / max) * (height - pad * 2);
const max = Math.max(...data.map((d) => d.value), 1);
const stepX = width / Math.max(data.length - 1, 1);
const pts = data.map((d, i) => {
const x = i * stepX;
const y = height - (d.value / max) * (height - 10) - 5;
return [x, y] as const;
});
const line = pts
.map(
(p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(1)},${p[1].toFixed(1)}`,
)
.join(" ");
const area = `${line} L${width},${height} L0,${height} Z`;
const pathLen = 1400;
const msgLine = daily.map((d, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(d.messages).toFixed(1)}`).join(" ");
const flagLine = daily.map((d, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(d.flagged).toFixed(1)}`).join(" ");
const msgArea = `${msgLine} L${x(n - 1).toFixed(1)},${height - pad} L${x(0).toFixed(1)},${height - pad} Z`;
return (
<svg
viewBox={`0 0 ${width} ${height}`}
className={className}
style={{ width: "100%", height }}
preserveAspectRatio="none"
role="img"
aria-label={label ?? "Activity chart"}
>
<svg viewBox={`0 0 ${w} ${height}`} preserveAspectRatio="none" className="w-full" style={{ height }}>
<defs>
<linearGradient id={`area-${id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={stroke} stopOpacity="0.32" />
<stop offset="100%" stopColor={stroke} stopOpacity="0.02" />
<linearGradient id="area-msg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--color-signal)" stopOpacity="0.3" />
<stop offset="100%" stopColor="var(--color-signal)" stopOpacity="0" />
</linearGradient>
</defs>
<motion.path
d={area}
fill={`url(#area-${id})`}
initial={reduce ? false : { pathLength: 0, opacity: 0.4 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: 0.9, ease: [0.22, 1, 0.36, 1] }}
/>
<motion.path
d={line}
fill="none"
stroke={stroke}
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
initial={reduce ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.9, ease: [0.22, 1, 0.36, 1] }}
style={{ strokeDasharray: pathLen }}
/>
{[0.25, 0.5, 0.75].map((g) => (
<line key={g} x1={pad} x2={w - pad} y1={height * g} y2={height * g} stroke="var(--color-hairline)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
))}
<path d={msgArea} fill="url(#area-msg)" />
<path d={msgLine} fill="none" stroke="var(--color-signal)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
<path d={flagLine} fill="none" stroke="var(--color-vermilion)" strokeWidth={1.5} vectorEffect="non-scaling-stroke" strokeDasharray="3 3" />
{daily.map((d, i) =>
i % 2 === 0 ? (
<text key={d.day} x={x(i)} y={height - 1} fill="var(--color-ink-faint)" fontSize={9} textAnchor="middle" className="mono">
{d.day.slice(5)}
</text>
) : null,
)}
</svg>
);
}
@@ -0,0 +1,51 @@
/** Stacked donut for moderation overview / composition. */
export function Donut({
segments,
size = 132,
thickness = 14,
centerLabel,
centerSub,
}: {
segments: { value: number; color: string; label: string }[];
size?: number;
thickness?: number;
centerLabel?: string;
centerSub?: string;
}) {
const total = segments.reduce((s, x) => s + x.value, 0) || 1;
const r = size / 2 - thickness / 2;
const c = 2 * Math.PI * r;
let offset = 0;
return (
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--color-hairline)" strokeWidth={thickness} />
{segments.map((s, i) => {
const len = (s.value / total) * c;
const el = (
<circle
key={i}
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={s.color}
strokeWidth={thickness}
strokeDasharray={`${len} ${c - len}`}
strokeDashoffset={-offset}
style={{ transition: "stroke-dashoffset 0.6s ease" }}
/>
);
offset += len;
return el;
})}
</svg>
{(centerLabel || centerSub) && (
<div className="absolute inset-0 flex flex-col items-center justify-center">
{centerLabel && <span className="display text-lg">{centerLabel}</span>}
{centerSub && <span className="mono text-[0.6rem] text-ink-faint">{centerSub}</span>}
</div>
)}
</div>
);
}
@@ -0,0 +1,5 @@
export { Sparkline } from "./sparkline";
export { AreaActivity } from "./area-activity";
export { RadialGauge } from "./radial-gauge";
export { Donut } from "./donut";
export { Equalizer } from "./waveform";
@@ -1,90 +1,45 @@
"use client";
import { motion, useReducedMotion } from "motion/react";
import { useId } from "react";
export interface RadialGaugeProps {
/** 0..1 health ratio */
value: number;
size?: number;
label?: string;
sublabel?: string;
tone?: "signal" | "amber" | "vermilion";
}
const toneColor = {
signal: "var(--color-signal)",
amber: "var(--color-amber)",
vermilion: "var(--color-vermilion)",
};
import { cn } from "@/lib/utils";
/** Circular progress gauge. value 0..1. */
export function RadialGauge({
value,
size = 160,
label,
sublabel,
tone = "signal",
}: RadialGaugeProps) {
const id = useId().replace(/:/g, "");
const reduce = useReducedMotion();
const stroke = 12;
const r = (size - stroke) / 2;
size = 120,
}: {
value: number;
label: string;
sublabel?: string;
tone?: "signal" | "amber" | "vermilion";
size?: number;
}) {
const v = Math.max(0, Math.min(1, value));
const stroke = tone === "vermilion" ? "var(--color-vermilion)" : tone === "amber" ? "var(--color-amber)" : "var(--color-signal)";
const r = size / 2 - 10;
const c = 2 * Math.PI * r;
const pct = Math.max(0, Math.min(1, value));
const dash = c * pct;
return (
<div
className="relative inline-flex items-center justify-center"
style={{ width: size, height: size }}
>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className="-rotate-90"
role="img"
aria-label={`${Math.round(pct * 100)}% ${label ?? "gauge"}`}
>
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--color-hairline)" strokeWidth={8} />
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="var(--color-hairline)"
strokeWidth={stroke}
/>
<motion.circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={toneColor[tone]}
strokeWidth={stroke}
stroke={stroke}
strokeWidth={8}
strokeLinecap="round"
strokeDasharray={c}
initial={reduce ? false : { strokeDashoffset: c }}
animate={{ strokeDashoffset: c - dash }}
transition={{ duration: 1, ease: [0.22, 1, 0.36, 1] }}
strokeDashoffset={c * (1 - v)}
style={{ transition: "stroke-dashoffset 0.6s ease", filter: `drop-shadow(0 0 6px ${stroke})` }}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">
<span
className="display text-2xl mono"
style={{ color: toneColor[tone] }}
>
{Math.round(pct * 100)}%
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={cn("display text-xl", tone === "vermilion" && "text-vermilion", tone === "amber" && "text-amber", tone === "signal" && "text-signal")}>
{label}
</span>
{label && (
<span className="text-[11px] font-medium text-[var(--color-ink-soft)] uppercase tracking-wide">
{label}
</span>
)}
{sublabel && (
<span className="text-[10px] text-[var(--color-ink-soft)]/70">
{sublabel}
</span>
)}
{sublabel && <span className="mono text-[0.6rem] text-ink-faint">{sublabel}</span>}
</div>
</div>
);
@@ -1,56 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
export interface RibbonSegment {
id: string;
label: string;
value: number; // relative duration
tone?: "signal" | "amber" | "vermilion" | "neutral";
}
const toneClass = {
signal: "bg-[var(--color-signal)]",
amber: "bg-[var(--color-amber)]",
vermilion: "bg-[var(--color-vermilion)]",
neutral: "bg-[var(--color-ink-soft)]/40",
};
export interface SessionRibbonProps {
segments: RibbonSegment[];
className?: string;
height?: number;
}
export function SessionRibbon({
segments,
className,
height = 28,
}: SessionRibbonProps) {
const total = segments.reduce((s, x) => s + x.value, 0) || 1;
return (
<div
className={cn(
"flex w-full gap-0.5 overflow-hidden rounded-[var(--radius-r-control)]",
className,
)}
style={{ height }}
>
{segments.map((s) => (
<div
key={s.id}
className={cn(
"group relative flex items-center justify-center rounded-sm transition-all",
toneClass[s.tone ?? "signal"],
)}
style={{ width: `${(s.value / total) * 100}%` }}
title={`${s.label}: ${s.value}`}
>
<span className="pointer-events-none absolute inset-x-0 -top-6 hidden whitespace-nowrap rounded bg-[var(--color-ink)] px-1.5 py-0.5 text-[10px] text-[var(--color-canvas)] group-hover:block">
{s.label}
</span>
</div>
))}
</div>
);
}
@@ -1,77 +1,42 @@
"use client";
import { useId } from "react";
export interface SparklineProps {
data: number[];
width?: number;
height?: number;
stroke?: string;
className?: string;
fill?: boolean;
}
import { cn } from "@/lib/utils";
/** Minimal sparkline. Pure SVG, scales to container width. */
export function Sparkline({
data,
width = 120,
height = 36,
stroke = "var(--color-signal)",
values,
className,
stroke = "var(--color-signal)",
fill = true,
}: SparklineProps) {
const id = useId().replace(/:/g, "");
if (data.length < 2)
return (
<svg
width={width}
height={height}
className={className}
role="img"
aria-label="No data"
/>
);
const min = Math.min(...data);
const max = Math.max(...data);
height = 40,
}: {
values: number[];
className?: string;
stroke?: string;
fill?: boolean;
height?: number;
}) {
if (values.length === 0) return null;
const w = 100;
const max = Math.max(...values, 1);
const min = Math.min(...values, 0);
const span = max - min || 1;
const stepX = width / (data.length - 1);
const pts = data.map((v, i) => {
const x = i * stepX;
const pts = values.map((v, i) => {
const x = (i / (values.length - 1)) * w;
const y = height - ((v - min) / span) * (height - 4) - 2;
return [x, y] as const;
});
const line = pts
.map(
(p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(1)},${p[1].toFixed(1)}`,
)
.join(" ");
const area = `${line} L${width},${height} L0,${height} Z`;
const line = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(2)},${p[1].toFixed(2)}`).join(" ");
const area = `${line} L${w},${height} L0,${height} Z`;
const id = `spark-${stroke.replace(/[^a-z0-9]/gi, "")}`;
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className={className}
preserveAspectRatio="none"
role="img"
aria-label="Trend sparkline"
>
<svg viewBox={`0 0 ${w} ${height}`} preserveAspectRatio="none" className={cn("w-full", className)} style={{ height }}>
<defs>
<linearGradient id={`spark-${id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={stroke} stopOpacity="0.28" />
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={stroke} stopOpacity="0.35" />
<stop offset="100%" stopColor={stroke} stopOpacity="0" />
</linearGradient>
</defs>
{fill && <path d={area} fill={`url(#spark-${id})`} />}
<path
d={line}
fill="none"
stroke={stroke}
strokeWidth={1.6}
strokeLinejoin="round"
strokeLinecap="round"
/>
{fill && <path d={area} fill={`url(#${id})`} />}
<path d={line} fill="none" stroke={stroke} strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
</svg>
);
}
@@ -1,76 +1,37 @@
"use client";
import { motion, useReducedMotion } from "motion/react";
import { useMemo } from "react";
import { cn } from "@/lib/utils";
export interface WaveformProps {
seed: string | number;
bars?: number;
height?: number;
className?: string;
tone?: "signal" | "amber" | "vermilion";
}
// deterministic pseudo-random from seed so the shape is stable per recording
function hashSeed(seed: string | number): number {
const s = String(seed);
let h = 2166136261;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
export function Waveform({
seed,
bars = 40,
height = 40,
/** Live equalizer bars. `bars` are 0..1 levels. */
export function Equalizer({
bars,
color = "var(--color-signal)",
className,
tone = "signal",
}: WaveformProps) {
const reduce = useReducedMotion();
const values = useMemo(() => {
let state = hashSeed(seed) || 1;
const out: number[] = [];
for (let i = 0; i < bars; i++) {
state = (Math.imul(state, 1103515245) + 12345) >>> 0;
const r = (state % 1000) / 1000;
// envelope: louder in the middle, quieter at edges
const env = Math.sin((i / (bars - 1)) * Math.PI);
out.push(0.18 + r * 0.82 * (0.4 + env * 0.6));
}
return out;
}, [seed, bars]);
const color = {
signal: "var(--color-signal)",
amber: "var(--color-amber)",
vermilion: "var(--color-vermilion)",
}[tone];
}: {
bars: number[];
color?: string;
className?: string;
}) {
return (
<div
className={cn("flex items-end gap-[2px]", className)}
style={{ height }}
aria-hidden
>
{values.map((v, i) => (
<motion.span
key={i}
className="flex-1 rounded-[2px]"
style={{ background: color, height: `${Math.max(8, v * 100)}%` }}
initial={reduce ? false : { scaleY: 0.2, opacity: 0 }}
animate={{ scaleY: 1, opacity: 1 }}
whileHover={{ scaleY: 1.15 }}
transition={{
duration: 0.3,
delay: reduce ? 0 : i * 0.006,
ease: [0.22, 1, 0.36, 1],
}}
/>
))}
<div className={cn("flex h-10 items-end gap-[3px]", className)}>
{bars.length === 0 ? (
<div className="flex w-full items-end gap-[3px]">
{Array.from({ length: 28 }).map((_, i) => (
<span key={i} className="flex-1 rounded-full bg-white/10" style={{ height: "12%" }} />
))}
</div>
) : (
bars.map((b, i) => (
<span
key={i}
className="flex-1 rounded-full"
style={{
height: `${Math.max(6, b * 100)}%`,
background: color,
boxShadow: b > 0.05 ? `0 0 8px ${color}` : "none",
transition: "height 90ms linear",
}}
/>
))
)}
</div>
);
}
@@ -1,161 +0,0 @@
"use client";
import { Eraser, Send, Sparkles } from "lucide-react";
import { useEffect, useRef } from "react";
import { useChatbot } from "./chatbot-context";
interface ChatPanelProps {
inputRef?: React.RefObject<HTMLInputElement | null>;
}
function formatTime(ts: string): string {
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleTimeString("id-ID", {
hour: "2-digit",
minute: "2-digit",
});
}
const SUGGESTIONS = [
"Gimana suasana server hari ini?",
"Channel mana yang paling ramai?",
"Total pesan di server?",
"Ada pesan bermasalah?",
];
export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
const { messages, sendMessage, clearMessages, isTyping } = useChatbot();
const listRef = useRef<HTMLDivElement>(null);
const internalInputRef = useRef<HTMLInputElement>(null);
const inputRef = externalInputRef ?? internalInputRef;
// Auto-scroll to bottom on new messages
// biome-ignore lint/correctness/useExhaustiveDependencies: re-run on message arrival; scroll is a visual effect keyed on new content
useEffect(() => {
if (listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, [messages, isTyping]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const input = inputRef.current;
if (!input || !input.value.trim() || isTyping) return;
sendMessage(input.value);
input.value = "";
};
const handleSuggestion = (text: string) => {
if (isTyping) return;
sendMessage(text);
};
return (
<div className="flex h-full flex-col">
{/* Chat messages */}
<div
ref={listRef}
className="flex-1 space-y-1.5 overflow-y-auto px-2 py-2"
>
{messages.length === 0 ? (
<div className="flex h-full flex-col justify-center gap-3 px-3 text-center">
<p className="text-[11px] text-[var(--color-ink-soft)]">
Halo! 👋 Aku tau soal server ini pesan, flag, dan aktivitas.
</p>
<div className="flex flex-wrap justify-center gap-1.5">
{SUGGESTIONS.map((s) => (
<button
key={s}
type="button"
onClick={() => handleSuggestion(s)}
disabled={isTyping}
className="flex items-center gap-1 rounded-full border border-[var(--color-hairline)] bg-[var(--color-surface-2)] px-2.5 py-1 text-[10px] text-[var(--color-ink-soft)] transition-colors hover:bg-[var(--color-signal)] hover:text-[var(--color-signal-ink)] disabled:opacity-40"
>
<Sparkles className="size-2.5 text-[var(--color-signal)]" />
{s}
</button>
))}
</div>
</div>
) : (
messages.map((msg, i) => (
<div
key={`${msg.timestamp}-${i}`}
className={`flex flex-col ${msg.role === "user" ? "items-end" : "items-start"}`}
>
<div
className={`max-w-[85%] break-words whitespace-pre-wrap rounded-xl px-2.5 py-1.5 text-[11px] leading-relaxed ${
msg.role === "user"
? "rounded-br-sm bg-[var(--color-signal)] text-[var(--color-signal-ink)]"
: "rounded-bl-sm bg-[var(--color-surface-2)] text-[var(--color-ink)]"
}`}
>
{msg.content}
</div>
<span className="mt-0.5 px-1 text-[9px] text-[var(--color-ink-soft)]">
{formatTime(msg.timestamp)}
</span>
</div>
))
)}
{isTyping && (
<div className="flex justify-start">
<div className="rounded-xl rounded-bl-sm bg-[var(--color-surface-2)] px-2.5 py-2">
<span className="inline-flex gap-1">
<span
className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
style={{ animationDelay: "0ms" }}
/>
<span
className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
style={{ animationDelay: "150ms" }}
/>
<span
className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
style={{ animationDelay: "300ms" }}
/>
</span>
</div>
</div>
)}
</div>
{/* Input bar */}
<form
onSubmit={handleSubmit}
className="flex shrink-0 items-center gap-1.5 border-t border-[var(--color-hairline)] px-2 py-2"
>
<input
ref={inputRef}
type="text"
placeholder="Tanya soal server, pesan, atau statistik…"
className="flex-1 bg-transparent text-[11px] text-[var(--color-ink)] outline-none placeholder:text-[var(--color-ink-soft)]/50"
disabled={isTyping}
autoComplete="off"
/>
{messages.length > 0 && (
<button
type="button"
onClick={() => void clearMessages()}
className="flex size-6 items-center justify-center rounded transition-colors hover:bg-[var(--color-surface-2)] disabled:opacity-40"
disabled={isTyping}
aria-label="Hapus riwayat chat"
title="Hapus riwayat"
>
<Eraser className="size-3 text-[var(--color-ink-soft)] hover:text-[var(--color-vermilion)]" />
</button>
)}
<button
type="submit"
className="flex size-7 items-center justify-center rounded-lg bg-[var(--color-signal)] text-[var(--color-signal-ink)] transition-colors hover:opacity-90 disabled:opacity-40"
disabled={isTyping}
aria-label="Kirim pesan"
title="Kirim"
>
<Send className="size-3.5" />
</button>
</form>
</div>
);
}
@@ -1,100 +0,0 @@
"use client";
import { Bot, Minimize2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ChatPanel } from "./chat-panel";
import { useChatbot } from "./chatbot-context";
export function ChatbotContainer() {
const { minimized, setMinimized } = useChatbot();
const [position, setPosition] = useState({ x: 0, y: 0 });
const [dragging, setDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const inputRef = useRef<HTMLInputElement>(null);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
setDragging(true);
setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
},
[position],
);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!dragging) return;
setPosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y });
},
[dragging, dragStart],
);
const handleMouseUp = useCallback(() => setDragging(false), []);
// Focus input when chat opens
useEffect(() => {
if (!minimized) {
const id = setTimeout(() => inputRef.current?.focus(), 150);
return () => clearTimeout(id);
}
}, [minimized]);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: drag container — mouse-move gesture surface, not keyboard-interactive content
<div
className="fixed bottom-4 right-4 z-40 select-none"
style={{ transform: `translate(${position.x}px, ${position.y}px)` }}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div
className={`surface-2 overflow-hidden shadow-2xl transition-all duration-200 ${
minimized ? "h-14 w-14 cursor-pointer" : "h-[460px] w-[320px]"
}`}
>
{minimized ? (
<button
type="button"
onClick={() => setMinimized(false)}
className="flex size-full items-center justify-center"
onMouseDown={handleMouseDown}
aria-label="Buka chatbot"
title="Buka chatbot"
>
<Bot className="size-6 text-[var(--color-signal)]" />
</button>
) : (
<div className="flex h-full flex-col">
{/* Drag handle + controls */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag handle — mouse-only gesture, keyboard users use the buttons in this header */}
<div
className="flex shrink-0 cursor-grab items-center justify-between border-b border-[var(--color-hairline)] px-3 py-2 active:cursor-grabbing"
onMouseDown={handleMouseDown}
>
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-[var(--color-ink-soft)]">
<Bot className="size-3.5 text-[var(--color-signal)]" />
Chatbot
</span>
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={() => setMinimized(true)}
className="flex size-6 items-center justify-center rounded transition-colors hover:bg-[var(--color-surface-2)]"
aria-label="Kecilkan chatbot"
title="Kecilkan chatbot"
>
<Minimize2 className="size-3.5 text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]" />
</button>
</div>
</div>
{/* Chat panel — always open when bubble is expanded */}
<div className="min-h-0 flex-1">
<ChatPanel inputRef={inputRef} />
</div>
</div>
)}
</div>
</div>
);
}
@@ -1,188 +0,0 @@
"use client";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { useChatbotUserId } from "@/hooks/use-chatbot-user";
import { chatbotApi } from "@/lib/api";
export type ChatbotExpression =
| "idle"
| "listening"
| "surprise"
| "happy"
| "sad"
| "talking";
interface ChatbotMessage {
role: "user" | "assistant";
content: string;
timestamp: string;
}
interface ChatbotContextValue {
/** Expression the chatbot avatar should display */
expression: ChatbotExpression;
setExpression: (expr: ChatbotExpression) => void;
/** Whether the enlarged bubble is minimized to a small icon */
minimized: boolean;
setMinimized: (v: boolean) => void;
/**
* @deprecated Use `minimized` / `setMinimized` instead.
* Legacy toggle alias kept for compatibility.
*/
isOpen: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
/** Chat messages with real API backend */
messages: ChatbotMessage[];
sendMessage: (content: string) => Promise<void>;
clearMessages: () => Promise<void>;
isTyping: boolean;
/** Active guild context sent to the backend so answers reference the server */
guildId: string;
setGuildId: (g: string) => void;
}
const ChatbotContext = createContext<ChatbotContextValue | null>(null);
export function ChatbotProvider({ children }: { children: ReactNode }) {
const [expression, setExpression] = useState<ChatbotExpression>("idle");
const [minimized, setMinimized] = useState(true);
const [messages, setMessages] = useState<ChatbotMessage[]>([]);
const [isTyping, setIsTyping] = useState(false);
const [guildId, setGuildId] = useState("");
const historyFetched = useRef(false);
const userId = useChatbotUserId();
// Derived legacy state
const isOpen = !minimized;
const setOpen = useCallback((open: boolean) => {
setMinimized(!open);
}, []);
const toggle = useCallback(() => {
setMinimized((prev) => !prev);
}, []);
// Load chat history on first mount (per-device user history)
useEffect(() => {
if (historyFetched.current || !userId) return;
historyFetched.current = true;
chatbotApi
.getHistory(userId)
.then((res) => {
// Backend returns rows {user_message, bot_response, created_at} —
// interleave each user message with its bot reply.
const withReplies: ChatbotMessage[] = [];
for (const row of res.history ?? []) {
withReplies.push({
role: "user",
content: row.user_message,
timestamp: row.created_at,
});
withReplies.push({
role: "assistant",
content: row.bot_response,
timestamp: row.created_at,
});
}
setMessages(withReplies);
})
.catch(() => {
// API may not be available yet — silently ignore
});
}, [userId]);
const sendMessage = useCallback(
async (content: string) => {
if (!content.trim()) return;
const userMsg: ChatbotMessage = {
role: "user",
content: content.trim(),
timestamp: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMsg]);
setExpression("listening");
setIsTyping(true);
try {
// Send active guild as context so the backend can answer with
// real server insights (serverInsights path in chatbot.service),
// and the per-device user id so the history stays isolated.
const res = await chatbotApi.send(content.trim(), guildId, userId);
const botMsg: ChatbotMessage = {
role: "assistant",
content: res.response,
timestamp: res.timestamp ?? new Date().toISOString(),
};
setMessages((prev) => [...prev, botMsg]);
setExpression("happy");
} catch {
const errorMsg: ChatbotMessage = {
role: "assistant",
content:
"Maaf, aku lagi gagal nyambung ke server. Coba tanya lagi ya 🙏",
timestamp: new Date().toISOString(),
};
setMessages((prev) => [...prev, errorMsg]);
setExpression("sad");
} finally {
setIsTyping(false);
}
},
[guildId, userId],
);
const clearMessages = useCallback(async () => {
try {
await chatbotApi.clearHistory(userId);
} catch {
// Best-effort clear
}
setMessages([]);
}, [userId]);
return (
<ChatbotContext.Provider
value={{
expression,
setExpression,
minimized,
setMinimized,
isOpen,
setOpen,
toggle,
messages,
sendMessage,
clearMessages,
isTyping,
guildId,
setGuildId,
}}
>
{children}
</ChatbotContext.Provider>
);
}
export function useChatbot(): ChatbotContextValue {
const ctx = useContext(ChatbotContext);
if (!ctx) {
throw new Error("useChatbot must be used within a ChatbotProvider");
}
return ctx;
}
@@ -0,0 +1,132 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Bot, Send, X, MessageCircle } from "lucide-react";
import { chatbotApi } from "@/lib/api";
import { useChatbotUserId } from "@/hooks/use-chatbot-user";
import { GlassPanel, Input, Button, Avatar } from "@/components/primitives";
import { toast } from "@/components/primitives";
import { cn } from "@/lib/utils";
interface Msg {
role: "user" | "bot";
content: string;
}
export function Chatbot() {
const userId = useChatbotUserId();
const [open, setOpen] = useState(false);
const [msgs, setMsgs] = useState<Msg[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open || !userId) return;
chatbotApi
.getHistory(userId)
.then((res) => {
setMsgs(
res.history
.slice(-12)
.flatMap((h) => [
{ role: "user" as const, content: h.user_message },
{ role: "bot" as const, content: h.bot_response },
]),
);
})
.catch(() => {});
}, [open, userId]);
useEffect(() => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
}, [msgs, loading]);
const send = async () => {
const text = input.trim();
if (!text || loading || !userId) return;
setInput("");
setMsgs((m) => [...m, { role: "user", content: text }]);
setLoading(true);
try {
const res = await chatbotApi.send(text, undefined, userId);
setMsgs((m) => [...m, { role: "bot", content: res.response }]);
} catch (e) {
toast({ title: "Chat error", description: String(e), tone: "vermilion" });
} finally {
setLoading(false);
}
};
return (
<>
<button
type="button"
aria-label="Open assistant"
onClick={() => setOpen((o) => !o)}
className="fixed bottom-5 right-5 z-50 flex items-center justify-center rounded-full bg-signal text-signal-ink shadow-[0_10px_30px_-8px_var(--color-signal-glow)] transition-transform hover:scale-105"
style={{ width: 52, height: 52 }}
>
{open ? <X className="size-5" /> : <MessageCircle className="size-5" />}
</button>
{open && (
<GlassPanel
className="fixed bottom-20 right-5 z-50 flex w-[min(92vw,360px)] flex-col p-0"
style={{ animation: "fade-up 0.16s ease", height: 460 }}
>
<div className="flex items-center gap-2 border-b border-hairline px-4 py-3">
<span className="flex size-8 items-center justify-center rounded-full bg-signal/15 text-signal">
<Bot className="size-4" />
</span>
<div>
<div className="text-sm font-semibold text-ink">GMW Assistant</div>
<div className="mono text-[0.6rem] text-ink-faint">context-aware</div>
</div>
</div>
<div ref={listRef} className="flex-1 space-y-3 overflow-y-auto px-4 py-3">
{msgs.length === 0 && (
<div className="py-8 text-center text-xs text-ink-faint">
Ask about moderation, voice, or media.
</div>
)}
{msgs.map((m, i) => (
<div key={i} className={cn("flex gap-2", m.role === "user" ? "justify-end" : "justify-start")}>
{m.role === "bot" && <Avatar name="GMW" size={26} className="mt-0.5 bg-signal/15 text-signal" />}
<div
className={cn(
"max-w-[80%] rounded-2xl px-3 py-2 text-sm",
m.role === "user"
? "rounded-br-sm bg-signal/20 text-ink"
: "rounded-bl-sm bg-white/5 text-ink-soft",
)}
>
{m.content}
</div>
</div>
))}
{loading && (
<div className="flex gap-2">
<Avatar name="GMW" size={26} className="bg-signal/15 text-signal" />
<div className="rounded-2xl rounded-bl-sm bg-white/5 px-3 py-2 text-sm text-ink-faint"></div>
</div>
)}
</div>
<div className="flex items-center gap-2 border-t border-hairline p-3">
<Input
placeholder="Message…"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send()}
/>
<Button variant="primary" size="icon" onClick={send} disabled={loading}>
<Send className="size-4" />
</Button>
</div>
</GlassPanel>
)}
</>
);
}
@@ -1,3 +0,0 @@
export { ChatPanel } from "./chat-panel";
export { ChatbotContainer } from "./chatbot-container";
export { ChatbotProvider, useChatbot } from "./chatbot-context";
@@ -0,0 +1,169 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import {
Search,
CornerDownLeft,
ArrowUp,
ArrowDown,
Moon,
Sun,
} from "lucide-react";
import { navItems } from "@/lib/navigation";
import { GlassPanel } from "@/components/primitives";
interface Command {
id: string;
label: string;
hint: string;
icon: React.ReactNode;
run: () => void;
}
export function CommandPalette() {
const router = useRouter();
const { theme, setTheme } = useTheme();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const commands = useMemo<Command[]>(() => {
const nav: Command[] = navItems.map((n) => ({
id: `nav:${n.href}`,
label: `Go to ${n.label}`,
hint: n.href,
icon: <n.icon className="size-4 text-signal" />,
run: () => router.push(n.href),
}));
const actions: Command[] = [
{
id: "act:theme",
label: "Toggle theme",
hint: "appearance",
icon:
theme === "light" ? (
<Moon className="size-4 text-signal" />
) : (
<Sun className="size-4 text-signal" />
),
run: () => setTheme(theme === "light" ? "dark" : "light"),
},
];
return [...nav, ...actions];
}, [router, theme, setTheme]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return commands;
return commands.filter(
(c) => c.label.toLowerCase().includes(q) || c.hint.toLowerCase().includes(q),
);
}, [commands, query]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((o) => !o);
}
if (e.key === "Escape") setOpen(false);
};
const onOpen = () => setOpen(true);
window.addEventListener("keydown", onKey);
window.addEventListener("command-palette:open", onOpen);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("command-palette:open", onOpen);
};
}, []);
useEffect(() => {
if (open) {
setQuery("");
setActive(0);
}
}, [open]);
useEffect(() => {
setActive(0);
}, [query]);
if (!open) return null;
const runAt = (i: number) => {
const c = filtered[i];
if (!c) return;
setOpen(false);
c.run();
};
return (
<div
className="fixed inset-0 z-[90] flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm"
onMouseDown={() => setOpen(false)}
>
<GlassPanel
className="w-full max-w-[560px] overflow-hidden p-0"
style={{ animation: "fade-up 0.14s ease" }}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3 border-b border-hairline px-4 py-3">
<Search className="size-4 text-ink-faint" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((a) => Math.min(a + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => Math.max(a - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
runAt(active);
}
}}
placeholder="Type a command or search…"
className="flex-1 bg-transparent text-sm text-ink outline-none placeholder:text-ink-faint"
/>
<kbd className="mono rounded bg-white/8 px-1.5 py-0.5 text-[0.6rem] text-ink-faint">ESC</kbd>
</div>
<div className="max-h-[50vh] overflow-y-auto p-2">
{filtered.length === 0 ? (
<div className="py-8 text-center text-xs text-ink-faint">No commands</div>
) : (
filtered.map((c, i) => (
<button
key={c.id}
type="button"
onMouseEnter={() => setActive(i)}
onClick={() => runAt(i)}
className={`flex w-full items-center gap-3 rounded-[10px] px-3 py-2.5 text-left text-sm transition-colors ${
i === active ? "bg-signal/12 text-ink" : "text-ink-soft hover:bg-white/5"
}`}
>
<span className="flex size-7 items-center justify-center rounded-[8px] bg-white/5">
{c.icon}
</span>
<span className="flex-1">{c.label}</span>
<span className="mono text-[0.65rem] text-ink-faint">{c.hint}</span>
{i === active && <CornerDownLeft className="size-3.5 text-ink-faint" />}
</button>
))
)}
</div>
<div className="flex items-center gap-4 border-t border-hairline px-4 py-2 text-[0.65rem] text-ink-faint">
<span className="flex items-center gap-1"><ArrowUp className="size-3" /><ArrowDown className="size-3" /> navigate</span>
<span className="flex items-center gap-1"><CornerDownLeft className="size-3" /> select</span>
<span className="ml-auto mono">K</span>
</div>
</GlassPanel>
</div>
);
}
@@ -1,182 +0,0 @@
"use client";
/**
* DashCommandLine — sticky bottom prompt for ops actions.
*
* The signature element of the new dashboard. Pure mono input; parses a
* slash-prefixed verb and dispatches to existing APIs or client-side
* actions. Autocomplete is intentionally light (suggestions render in
* monospace below the input).
*/
import {
type FormEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
type CommandVerb = "mute" | "jump" | "find" | "clear";
interface CommandResult {
ok: boolean;
message: string;
}
const VERBS: CommandVerb[] = ["mute", "jump", "find", "clear"];
interface DashCommandLineProps {
onCommand?: (verb: CommandVerb, args: string) => CommandResult | undefined;
placeholder?: string;
}
export function DashCommandLine({
onCommand,
placeholder = "type a command — /mute @user 10m, /jump #channel, /find text, /clear",
}: DashCommandLineProps) {
const [value, setValue] = useState("");
const [history, setHistory] = useState<string[]>([]);
const [_historyIdx, setHistoryIdx] = useState<number>(-1);
const [result, setResult] = useState<CommandResult | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// Global "/" focuses the command line (skip when typing in another input).
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
const t = e.target as HTMLElement | null;
const tag = t?.tagName?.toLowerCase();
if (tag === "input" || tag === "textarea" || t?.isContentEditable) return;
e.preventDefault();
inputRef.current?.focus();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
const suggestions = useMemo(() => {
const trimmed = value.trimStart();
if (!trimmed.startsWith("/")) return [] as CommandVerb[];
const verb = trimmed.slice(1).split(/\s+/)[0]?.toLowerCase() ?? "";
if (!verb) return VERBS;
return VERBS.filter((v) => v.startsWith(verb));
}, [value]);
const submit = useCallback(
(raw: string) => {
const trimmed = raw.trim();
if (!trimmed.startsWith("/")) {
setResult({ ok: false, message: "commands start with /" });
return;
}
const body = trimmed.slice(1);
const [verbRaw, ...rest] = body.split(/\s+/);
const verb = (verbRaw?.toLowerCase() ?? "") as CommandVerb;
if (!VERBS.includes(verb)) {
setResult({
ok: false,
message: `unknown verb "${verbRaw}" — try ${VERBS.join(", ")}`,
});
return;
}
const args = rest.join(" ");
try {
const ret = onCommand?.(verb, args);
const message =
(ret && typeof ret === "object" && "message" in ret && ret.message) ||
defaultMessage(verb, args);
setResult({ ok: true, message });
} catch (err) {
setResult({
ok: false,
message: err instanceof Error ? err.message : "command failed",
});
}
setHistory((h) => [trimmed, ...h].slice(0, 32));
setHistoryIdx(-1);
},
[onCommand],
);
const onSubmit = (e: FormEvent) => {
e.preventDefault();
if (value.trim()) {
submit(value);
setValue("");
}
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowUp") {
e.preventDefault();
setHistoryIdx((idx) => {
const next = idx + 1;
if (next >= history.length) return idx;
setValue(history[next] ?? "");
return next;
});
} else if (e.key === "ArrowDown") {
e.preventDefault();
setHistoryIdx((idx) => {
const next = idx - 1;
if (next < -1) return idx;
setValue(next === -1 ? "" : (history[next] ?? ""));
return next;
});
}
};
return (
<form
onSubmit={onSubmit}
className="sticky bottom-0 z-10 flex h-11 items-center gap-2 border-t border-[var(--color-hairline)] bg-[var(--color-canvas)] px-3 font-mono text-[12px]"
role="search"
>
<span className="shrink-0 text-[var(--color-signal)]">{">"}</span>
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
spellCheck={false}
autoComplete="off"
aria-label="Command line"
className="min-w-0 flex-1 bg-transparent text-[var(--color-ink)] outline-none placeholder:text-[var(--color-ink-soft)]"
/>
{result ? (
<span
className={cn(
"shrink-0 truncate text-[10px] uppercase tracking-wide",
result.ok
? "text-[var(--color-signal)]"
: "text-[var(--color-vermilion)]",
)}
>
{result.message}
</span>
) : suggestions.length > 0 ? (
<span className="shrink-0 truncate text-[10px] uppercase tracking-wide text-[var(--color-ink-soft)]">
{suggestions.map((s) => `/${s}`).join(" ")}
</span>
) : null}
</form>
);
}
function defaultMessage(verb: CommandVerb, args: string): string {
switch (verb) {
case "mute":
return args ? `mute queued — ${args}` : "mute needs a target";
case "jump":
return args ? `jump queued — ${args}` : "jump needs a channel";
case "find":
return args ? `find queued — ${args}` : "find needs text";
case "clear":
return "feed cleared";
}
}
@@ -1,35 +0,0 @@
import type { AreaPoint } from "@/components/charts/area-activity";
import { AreaActivity } from "@/components/charts/area-activity";
export interface ActivityChartProps {
data: {
day: string;
messages: number;
flagged: number;
active_users: number;
}[];
}
export function ActivityChart({ data }: ActivityChartProps) {
const points: AreaPoint[] = data.map((d) => ({
label: d.day,
value: d.messages,
}));
return (
<div className="surface p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold">Daily messages</h3>
<span className="pill bg-[var(--color-signal)]/15 text-[var(--color-signal)]">
{data.length}d
</span>
</div>
<ActivityChartInner points={points} />
</div>
);
}
function ActivityChartInner({ points }: { points: AreaPoint[] }) {
return (
<AreaActivity data={points} height={180} label="Daily message activity" />
);
}
@@ -1,140 +0,0 @@
"use client";
import { Hash, Search } from "lucide-react";
import { useCallback, useState } from "react";
import { Badge } from "@/components/primitives/badge";
import { Button } from "@/components/primitives/button";
import { Input } from "@/components/primitives/input";
import { EmptyState, LoadingSkeleton } from "@/components/shared";
import { useChannelDetail, useChannels } from "@/hooks";
import type { DashboardChannel } from "@/lib/types";
export function ChannelsSection({ guildId }: { guildId?: string }) {
const [search, setSearch] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const { data: channels = [], isLoading } = useChannels(guildId ?? "", search);
const { data: detail } = useChannelDetail(selectedId);
const handleSearch = useCallback((v: string) => setSearch(v), []);
if (isLoading) return <LoadingSkeleton count={8} />;
if (channels.length === 0)
return (
<EmptyState
icon={Hash}
title="No channels"
description="No channels in this guild."
/>
);
return (
<div className="grid gap-3 lg:grid-cols-[1fr_320px]">
<div className="surface flex flex-col gap-2 p-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-soft)]" />
<Input
mono
placeholder="search channels…"
value={search}
onChange={(e) => handleSearch(e.target.value)}
className="pl-9"
/>
</div>
{channels.map((c) => (
<ChannelRow
key={c.channel_id}
channel={c}
selected={selectedId === c.channel_id}
onSelect={() => setSelectedId(c.channel_id)}
/>
))}
</div>
<div className="surface p-4">
{detail ? (
<div className="flex flex-col gap-3.5">
<div className="flex items-center gap-2.5">
<span className="flex size-9 items-center justify-center rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)]">
<Hash className="size-4 text-[var(--color-ink-soft)]" />
</span>
<div>
<div className="font-semibold">
{detail.channel_name ?? detail.channel_id}
</div>
<div className="text-xs text-[var(--color-ink-soft)]">
{detail.total_messages.toLocaleString()} messages
</div>
</div>
</div>
<Stat
label="Flagged"
value={detail.flagged_count}
tone="vermilion"
/>
<p className="text-xs text-[var(--color-ink-soft)]">
{detail.culture_summary ?? "No data yet."}
</p>
</div>
) : (
<p className="text-sm text-[var(--color-ink-soft)]">
Select a channel to inspect.
</p>
)}
</div>
</div>
);
}
function ChannelRow({
channel,
selected,
onSelect,
}: {
channel: DashboardChannel;
selected: boolean;
onSelect: () => void;
}) {
const total = channel.total_messages + channel.flagged_count || 1;
return (
<button
type="button"
onClick={onSelect}
className="flex items-center gap-3 rounded-[var(--radius-r-control)] px-2.5 py-2 text-left transition-colors hover:bg-[var(--color-surface-2)] data-[selected]:bg-[var(--color-signal)]/8"
data-selected={selected}
>
<Hash className="size-4 text-[var(--color-ink-soft)]" />
<span className="min-w-0 flex-1 truncate text-sm">
{channel.channel_name ?? channel.channel_id}
</span>
<span className="mono text-xs text-[var(--color-ink-soft)]">
{channel.flagged_count}/{channel.total_messages}
</span>
<div className="h-1.5 w-10 overflow-hidden rounded-full bg-[var(--color-hairline)]">
<div
className="h-full bg-[var(--color-signal)]"
style={{ width: `${(channel.flagged_count / total) * 100}%` }}
/>
</div>
</button>
);
}
function Stat({
label,
value,
tone,
}: {
label: string;
value: number;
tone: "vermilion";
}) {
return (
<div className="surface-2 flex items-center justify-between p-2.5">
<span className="text-xs text-[var(--color-ink-soft)]">{label}</span>
<span className="mono font-semibold text-[var(--color-vermilion)]">
{value}
</span>
</div>
);
}
@@ -1,29 +0,0 @@
import type { AreaPoint } from "@/components/charts/area-activity";
import { AreaActivity } from "@/components/charts/area-activity";
export interface HourlyActivityChartProps {
data: { hour: number; messages: number; flagged: number }[];
}
export function HourlyActivityChart({ data }: HourlyActivityChartProps) {
const points: AreaPoint[] = data.map((d) => ({
label: `${d.hour}:00`,
value: d.messages,
}));
return (
<div className="surface p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold">Hourly distribution</h3>
<span className="text-xs text-[var(--color-ink-soft)]">
00:00 23:00
</span>
</div>
<AreaActivity
data={points}
height={140}
stroke="var(--color-amber)"
label="Hourly message activity"
/>
</div>
);
}
@@ -1,53 +0,0 @@
import { RadialGauge } from "@/components/charts/radial-gauge";
import type { DashboardStats } from "@/lib/types";
export interface ModerationDonutProps {
stats?: DashboardStats;
}
export function ModerationDonut({ stats }: ModerationDonutProps) {
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 ratio = clean / total;
return (
<div className="surface flex flex-col items-center gap-3 p-4">
<h3 className="self-start text-sm font-semibold">Moderation health</h3>
<RadialGauge
value={ratio}
size={150}
label="Clean"
tone={ratio > 0.8 ? "signal" : ratio > 0.6 ? "amber" : "vermilion"}
/>
<div className="flex w-full flex-col gap-1.5 text-xs">
<Row label="Clean" value={clean} tone="var(--color-signal)" />
<Row label="Warned" value={warned} tone="var(--color-amber)" />
<Row label="Flagged" value={flagged} tone="var(--color-vermilion)" />
</div>
</div>
);
}
function Row({
label,
value,
tone,
}: {
label: string;
value: number;
tone: string;
}) {
return (
<div className="flex items-center justify-between">
<span className="flex items-center gap-2 text-[var(--color-ink-soft)]">
<span className="size-2 rounded-full" style={{ background: tone }} />
{label}
</span>
<span className="mono text-[var(--color-ink)]">
{value.toLocaleString()}
</span>
</div>
);
}
@@ -1,89 +0,0 @@
"use client";
import { Flame, Heart, SmilePlus } from "lucide-react";
import { Avatar } from "@/components/primitives/avatar";
import { Badge } from "@/components/primitives/badge";
import { EmptyState, LoadingSkeleton } from "@/components/shared";
import { useTopReactions, useTopReactors } from "@/hooks";
export interface ReactionsSectionProps {
initialReactions?: Awaited<ReturnType<typeof useTopReactions>>["data"];
}
export function ReactionsSection() {
const { data: reactions, isLoading: reactionsLoading } = useTopReactions();
const { data: reactors, isLoading: reactorsLoading } = useTopReactors();
if (reactionsLoading || reactorsLoading) return <LoadingSkeleton count={5} />;
const topReactions = (reactions ?? []).slice(0, 6);
const topReactors = (reactors ?? []).slice(0, 6);
return (
<div className="grid gap-3 lg:grid-cols-2">
<div className="surface p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold">
<Heart className="size-4 text-[var(--color-vermilion)]" />
Top reactions
</h3>
{topReactions.length === 0 ? (
<EmptyState
icon={SmilePlus}
title="No reactions"
description="No reactions yet."
/>
) : (
<div className="flex flex-wrap gap-2">
{topReactions
.flatMap((m) =>
m.top_emojis.map((e) => ({ emoji: e.emoji, count: e.count })),
)
.reduce<{ emoji: string; count: number }[]>((acc, cur) => {
const found = acc.find((x) => x.emoji === cur.emoji);
if (found) found.count += cur.count;
else acc.push(cur);
return acc;
}, [])
.sort((a, b) => b.count - a.count)
.slice(0, 8)
.map((r) => (
<span
key={r.emoji}
className="flex items-center gap-1.5 rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] px-2.5 py-1 text-sm"
>
<span>{r.emoji}</span>
<span className="mono text-xs text-[var(--color-ink-soft)]">
{r.count}
</span>
</span>
))}
</div>
)}
</div>
<div className="surface p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold">
<Flame className="size-4 text-[var(--color-amber)]" />
Top reactors
</h3>
{topReactors.length === 0 ? (
<EmptyState
icon={SmilePlus}
title="No reactors"
description="No reactors yet."
/>
) : (
<div className="flex flex-col gap-2">
{topReactors.map((r) => (
<div key={r.user_id} className="flex items-center gap-3">
<Avatar name={r.username} size={28} />
<span className="flex-1 text-sm">{r.username}</span>
<Badge tone="signal">+{r.net_count}</Badge>
</div>
))}
</div>
)}
</div>
</div>
);
}
@@ -1,39 +0,0 @@
import { Hash } from "lucide-react";
import type { TopChannel } from "@/lib/types";
export interface TopChannelsChartProps {
channels: TopChannel[];
}
export function TopChannelsChart({ channels }: TopChannelsChartProps) {
const max = Math.max(...channels.map((c) => c.message_count), 1);
const top = [...channels]
.sort((a, b) => b.message_count - a.message_count)
.slice(0, 8);
return (
<div className="surface p-4">
<h3 className="mb-3 text-sm font-semibold">Top channels</h3>
<div className="flex flex-col gap-2.5">
{top.map((c) => (
<div key={c.channel_id} className="flex items-center gap-3">
<span className="flex size-6 shrink-0 items-center justify-center rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] text-[var(--color-ink-soft)]">
<Hash className="size-3.5" />
</span>
<span className="w-32 shrink-0 truncate text-xs text-[var(--color-ink)]">
{c.channel_name ?? c.channel_id}
</span>
<div className="relative h-2 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-2)]">
<div
className="absolute inset-y-0 left-0 rounded-full bg-[var(--color-signal)] transition-[width] duration-500"
style={{ width: `${(c.message_count / max) * 100}%` }}
/>
</div>
<span className="mono w-12 shrink-0 text-right text-xs text-[var(--color-ink-soft)]">
{c.message_count.toLocaleString()}
</span>
</div>
))}
</div>
</div>
);
}
@@ -1,151 +0,0 @@
"use client";
import { Search, Users } from "lucide-react";
import { useCallback, useState } from "react";
import { Avatar } from "@/components/primitives/avatar";
import { Badge } from "@/components/primitives/badge";
import { Input } from "@/components/primitives/input";
import { EmptyState, LoadingSkeleton } from "@/components/shared";
import { useUserDetail, useUsers } from "@/hooks";
const TRUST_TIERS = [
{ min: 75, label: "Trusted", tone: "signal" as const },
{ min: 40, label: "Neutral", tone: "neutral" as const },
{ min: 10, label: "At Risk", tone: "amber" as const },
{ min: 0, label: "Critical", tone: "vermilion" as const },
];
function trustTier(score?: number | null) {
const s = score ?? 0;
return (
TRUST_TIERS.find((t) => s >= t.min) ?? TRUST_TIERS[TRUST_TIERS.length - 1]
);
}
export function UsersSection() {
const [search, setSearch] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const { data: users = [], isLoading } = useUsers(search);
const { data: detail } = useUserDetail(selectedId);
const handleSearch = useCallback((v: string) => setSearch(v), []);
if (isLoading) return <LoadingSkeleton count={6} />;
if (users.length === 0)
return (
<EmptyState
icon={Users}
title="No users found"
description="Try a different search."
/>
);
return (
<div className="grid gap-3 lg:grid-cols-[1fr_360px]">
<div className="surface flex flex-col gap-2 p-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-soft)]" />
<Input
mono
placeholder="search users…"
value={search}
onChange={(e) => handleSearch(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex flex-col">
{users.map((u) => {
const tier = trustTier(u.trust_score);
return (
<button
key={u.user_id}
type="button"
onClick={() => setSelectedId(u.user_id)}
className="flex items-center gap-3 rounded-[var(--radius-r-control)] px-2 py-2 text-left transition-colors hover:bg-[var(--color-surface-2)]"
>
<Avatar src={u.avatar_url} name={u.username} size={34} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">
{u.username ?? "unknown"}
</div>
<div className="mono text-xs text-[var(--color-ink-soft)]">
{u.total_messages.toLocaleString()} msg
</div>
</div>
<Badge tone={tier.tone}>{tier.label}</Badge>
</button>
);
})}
</div>
</div>
<div className="surface p-4">
{detail ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<Avatar
src={detail.avatar_url}
name={detail.username}
size={44}
/>
<div>
<div className="font-semibold">{detail.username}</div>
<div className="text-xs text-[var(--color-ink-soft)]">
{detail.total_messages.toLocaleString()} messages
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<Stat label="Trust" value={`${detail.trust_score ?? 0}`} />
<Stat
label="Clean streak"
value={`${detail.clean_message_streak ?? 0}`}
/>
<Stat
label="Infractions"
value={`${detail.total_infractions ?? 0}`}
tone="vermilion"
/>
<Stat
label="Flagged"
value={`${detail.flagged_count}`}
tone="amber"
/>
</div>
<p className="text-xs text-[var(--color-ink-soft)]">
{detail.profile_summary}
</p>
</div>
) : (
<p className="text-sm text-[var(--color-ink-soft)]">
Select a user to inspect.
</p>
)}
</div>
</div>
);
}
function Stat({
label,
value,
tone,
}: {
label: string;
value: string;
tone?: "amber" | "vermilion";
}) {
return (
<div className="surface-2 p-2.5">
<div className="text-[11px] uppercase tracking-wide text-[var(--color-ink-soft)]">
{label}
</div>
<div
className={`mono text-lg font-semibold ${tone === "amber" ? "text-[var(--color-amber)]" : tone === "vermilion" ? "text-[var(--color-vermilion)]" : "text-[var(--color-ink)]"}`}
>
{value}
</div>
</div>
);
}
@@ -1,273 +0,0 @@
"use client";
/**
* EventFeed — horizontal scroll-snap timeline that ingests live events.
*
* The feed is the central column of the dashboard. Time runs left → right
* (older → newer). New events append at the right edge; the feed scrolls
* right when the user is at the live edge and pauses when the user drags
* back to inspect history.
*
* Ring buffer keeps the DOM bounded (200 items). A `NowMarker` is inserted
* every 10 events or every 30 seconds to break the row rhythm with a pulse
* summary — see `useFeedPulse`.
*/
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { EventRow, type FeedEvent } from "@/components/feed/event-row";
import { ClusterMarker, PulseMarker } from "@/components/feed/now-marker";
import { cn } from "@/lib/utils";
const RING_BUFFER_MAX = 200;
const PULSE_EVERY_N_EVENTS = 10;
const PULSE_EVERY_MS = 30_000;
export type FeedItem =
| { kind: "event"; event: FeedEvent }
| {
kind: "pulse";
key: string;
ts: number;
label: string;
summary: string;
tone?: "signal" | "amber" | "vermilion";
}
| {
kind: "cluster";
key: string;
ts: number;
label: string;
bands: {
tone: "neutral" | "signal" | "amber" | "vermilion";
ratio: number;
}[];
tone?: "signal" | "amber" | "vermilion";
};
interface EventFeedProps {
initialEvents: FeedEvent[];
subscribe: (handler: (e: FeedEvent) => void) => () => void;
className?: string;
emptyState?: ReactNode;
}
export function EventFeed({
initialEvents,
subscribe,
className,
emptyState,
}: EventFeedProps) {
const [items, setItems] = useState<FeedItem[]>(() =>
injectMarkers(initialEvents.slice(-RING_BUFFER_MAX)),
);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [following, setFollowing] = useState(true);
const scrollerRef = useRef<HTMLDivElement | null>(null);
const lastPulseAt = useRef<number>(Date.now());
// Live WS ingest
useEffect(() => {
const unsub = subscribe((e) => {
setItems((prev) => appendWithMarker(prev, e));
});
return unsub;
}, [subscribe]);
// Periodic pulse even if traffic is slow — keeps the feed rhythm alive.
useEffect(() => {
const id = window.setInterval(() => {
setItems((prev) => {
if (Date.now() - lastPulseAt.current < PULSE_EVERY_MS) return prev;
return appendPulse(prev, "system", "live · standing by");
});
}, PULSE_EVERY_MS);
return () => window.clearInterval(id);
}, []);
// Auto-scroll on append when following.
useEffect(() => {
if (!following) return;
const el = scrollerRef.current;
if (!el) return;
el.scrollTo({ left: el.scrollWidth, behavior: "smooth" });
}, [following]);
const handleScroll = useCallback(() => {
const el = scrollerRef.current;
if (!el) return;
const distFromRight = el.scrollWidth - el.scrollLeft - el.clientWidth;
setFollowing(distFromRight < 24);
}, []);
const handleSelect = useCallback((id: string) => {
setSelectedId((cur) => (cur === id ? null : id));
}, []);
const visibleItems = useMemo(() => {
if (items.length <= RING_BUFFER_MAX) return items;
return items.slice(items.length - RING_BUFFER_MAX);
}, [items]);
return (
<div
className={cn(
"relative h-full w-full overflow-hidden",
"border-t border-[var(--color-hairline)]",
className,
)}
data-following={following ? "1" : "0"}
>
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] bg-[var(--color-surface)] px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--color-ink-soft)]">
<span>event horizon</span>
<span>
{visibleItems.filter((i) => i.kind === "event").length} events ·{" "}
{following ? "live" : "paused"}
</span>
</div>
<div
ref={scrollerRef}
onScroll={handleScroll}
className={cn(
"h-[calc(100%-30px)] overflow-y-auto overflow-x-hidden",
"snap-y snap-mandatory",
"scroll-pt-2",
)}
role="feed"
aria-live="polite"
>
{visibleItems.length === 0 && emptyState ? (
<div className="flex h-full items-center justify-center p-8 text-center font-mono text-[12px] text-[var(--color-ink-soft)]">
{emptyState}
</div>
) : (
visibleItems.map((item) => {
if (item.kind === "event") {
return (
<div key={item.event.id} className="snap-start">
<EventRow
event={item.event}
selected={selectedId === item.event.id}
onSelect={handleSelect}
/>
</div>
);
}
if (item.kind === "cluster") {
return (
<div key={item.key} className="snap-start">
<ClusterMarker
label={item.label}
timestamp={item.ts}
bands={item.bands}
tone={item.tone}
/>
</div>
);
}
return (
<div key={item.key} className="snap-start">
<PulseMarker
label={item.label}
timestamp={item.ts}
trailing={item.summary}
tone={item.tone}
/>
</div>
);
})
)}
</div>
</div>
);
}
// ── Ring + pulse helpers ────────────────────────────────────────
function injectMarkers(events: FeedEvent[]): FeedItem[] {
if (events.length === 0) return [];
const out: FeedItem[] = [];
let count = 0;
for (const e of events) {
out.push({ kind: "event", event: e });
count++;
if (count % PULSE_EVERY_N_EVENTS === 0) {
out.push({
kind: "cluster",
key: `cluster-${e.id}`,
ts: e.ts,
label: "pulse",
bands: deriveBands(
events.slice(Math.max(0, count - PULSE_EVERY_N_EVENTS), count),
),
tone: "signal",
});
}
}
return out;
}
function deriveBands(
window: FeedEvent[],
): { tone: "neutral" | "signal" | "amber" | "vermilion"; ratio: number }[] {
const counts: Record<"neutral" | "signal" | "amber" | "vermilion", number> = {
neutral: 0,
signal: 0,
amber: 0,
vermilion: 0,
};
for (const e of window) counts[e.severity]++;
const total = window.length || 1;
return (Object.keys(counts) as Array<keyof typeof counts>).map((k) => ({
tone: k,
ratio: counts[k] / total,
}));
}
function appendWithMarker(prev: FeedItem[], e: FeedEvent): FeedItem[] {
const next = [...prev, { kind: "event" as const, event: e }];
const eventsSinceLastPulse = next.filter((i) => i.kind === "event").length;
if (eventsSinceLastPulse % PULSE_EVERY_N_EVENTS === 0) {
const recentEvents = next
.filter((i) => i.kind === "event")
.slice(-PULSE_EVERY_N_EVENTS)
.map((i) => (i as { kind: "event"; event: FeedEvent }).event);
next.push({
kind: "cluster",
key: `cluster-${e.id}`,
ts: e.ts,
label: "pulse",
bands: deriveBands(recentEvents),
tone: "signal",
});
}
if (next.length > RING_BUFFER_MAX * 2) {
return next.slice(next.length - RING_BUFFER_MAX);
}
return next;
}
function appendPulse(
prev: FeedItem[],
label: string,
summary: string,
): FeedItem[] {
return [
...prev,
{
kind: "pulse",
key: `pulse-${Date.now()}`,
ts: Date.now(),
label,
summary,
tone: "signal",
},
];
}
@@ -1,133 +0,0 @@
"use client";
/**
* EventRow — single row in the horizontal event-feed timeline.
*
* No card chrome. The row is a single typographic line: mono timestamp,
* severity dot, actor mention, action verb, channel jump, excerpt.
*
* Hover reveals full excerpt and selection state; click toggles selection
* so the right rail / command line can target the event.
*/
import { type ReactNode, useCallback } from "react";
import { cn } from "@/lib/utils";
export type EventSeverity = "neutral" | "signal" | "amber" | "vermilion";
export interface FeedEvent {
/** Stable id from the upstream record. Used as React key. */
id: string;
/** Unix epoch ms. */
ts: number;
/** Severity tone — drives dot color and zebra fill. */
severity: EventSeverity;
/** Display label for the actor ("alice", "@everyone", "Carl-bot"). */
actor: string;
/** Verb describing the action ("sent", "flagged", "joined", "muted"). */
action: string;
/** Channel reference (monogram display only — no chrome). */
channel?: string | null;
/** Message excerpt or action payload text. Truncated when long. */
excerpt: string;
/** Optional metadata tag (e.g. "ai:flag", "voice:join"). */
tag?: string | null;
}
interface EventRowProps {
event: FeedEvent;
selected?: boolean;
onSelect?: (id: string) => void;
}
const SEVERITY_DOT: Record<EventSeverity, string> = {
neutral: "oklch(0.46 0.02 70)",
signal: "var(--color-signal)",
amber: "var(--color-amber)",
vermilion: "var(--color-vermilion)",
};
const SEVERITY_FILL: Record<EventSeverity, string> = {
neutral: "transparent",
signal: "oklch(0.78 0.17 125 / 0.06)",
amber: "oklch(0.80 0.15 70 / 0.07)",
vermilion: "oklch(0.62 0.21 25 / 0.08)",
};
function formatTimestamp(ts: number): string {
const d = new Date(ts);
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
export function EventRow({ event, selected, onSelect }: EventRowProps) {
const handleClick = useCallback(() => {
onSelect?.(event.id);
}, [event.id, onSelect]);
const dot: ReactNode = (
<span
aria-hidden
className="inline-block size-1.5 shrink-0 rounded-full"
style={{ background: SEVERITY_DOT[event.severity] }}
/>
);
return (
<button
type="button"
onClick={handleClick}
className={cn(
"group relative flex w-full items-baseline gap-3 px-3 py-1.5 text-left font-mono text-[12px] leading-5 transition-colors",
"hover:bg-[oklch(0.92_0.014_80_/_0.6)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-signal)] focus-visible:outline-offset-[-2px]",
selected && "bg-[oklch(0.92_0.014_80_/_0.8)]",
)}
style={{
background: selected ? undefined : SEVERITY_FILL[event.severity],
}}
data-event-id={event.id}
data-severity={event.severity}
>
<span
aria-hidden
className={cn(
"absolute inset-y-0 left-0 w-[2px] origin-center transition-transform",
selected ? "scale-y-100" : "scale-y-0 group-hover:scale-y-100",
)}
style={{ background: SEVERITY_DOT[event.severity] }}
/>
<span className="w-[68px] shrink-0 text-[var(--color-ink-soft)] tabular-nums">
{formatTimestamp(event.ts)}
</span>
{dot}
<span className="w-[120px] shrink-0 truncate text-[var(--color-ink)]">
{event.actor}
</span>
<span className="w-[80px] shrink-0 text-[var(--color-ink-soft)]">
{event.action}
</span>
{event.channel ? (
<span className="w-[140px] shrink-0 truncate text-[var(--color-ink-soft)]">
{event.channel}
</span>
) : (
<span className="w-[140px] shrink-0" aria-hidden />
)}
<span className="min-w-0 flex-1 truncate text-[var(--color-ink)]">
{event.excerpt}
</span>
{event.tag ? (
<span className="shrink-0 rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] px-1.5 py-px text-[10px] uppercase tracking-wide text-[var(--color-ink-soft)]">
{event.tag}
</span>
) : null}
</button>
);
}
@@ -1,138 +0,0 @@
"use client";
/**
* NowMarker — inline callout that breaks the feed timeline rhythm.
*
* Two variants: `pulse` (one-line summary) and `cluster` (horizontal stack bar
* visualising severity distribution across a recent window). Both use a
* border-tip on the left in signal tone; no card chrome, no shadow.
*/
import { cn } from "@/lib/utils";
type Tone = "signal" | "amber" | "vermilion" | "neutral";
interface PulseMarkerProps {
tone?: Tone;
label: string;
timestamp: number;
/** Optional small caps label on the right. */
trailing?: string;
}
interface ClusterMarkerProps {
tone?: Tone;
label: string;
timestamp: number;
/** Fractions of each severity band; must sum to 1. */
bands: { tone: Tone; ratio: number }[];
}
const TONE_TIP: Record<Tone, string> = {
signal: "var(--color-signal)",
amber: "var(--color-amber)",
vermilion: "var(--color-vermilion)",
neutral: "oklch(0.46 0.02 70)",
};
const TONE_FILL: Record<Tone, string> = {
signal: "oklch(0.78 0.17 125 / 0.12)",
amber: "oklch(0.80 0.15 70 / 0.14)",
vermilion: "oklch(0.62 0.21 25 / 0.12)",
neutral: "oklch(0.46 0.02 70 / 0.08)",
};
function formatTimestamp(ts: number): string {
const d = new Date(ts);
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function MarkerShell({
tone,
label,
timestamp,
trailing,
children,
}: {
tone: Tone;
label: string;
timestamp: number;
trailing?: string;
children?: React.ReactNode;
}) {
return (
<div
className="relative my-2 flex items-center gap-3 px-3 py-2 font-mono text-[11px]"
style={{ background: TONE_FILL[tone] }}
data-marker={tone}
>
<span
aria-hidden
className="absolute inset-y-1 left-0 w-[3px]"
style={{ background: TONE_TIP[tone] }}
/>
<span className="w-[68px] shrink-0 text-[var(--color-ink-soft)] tabular-nums">
{formatTimestamp(timestamp)}
</span>
<span
className="shrink-0 text-[10px] font-medium uppercase tracking-[0.18em]"
style={{ color: TONE_TIP[tone] }}
>
{label}
</span>
<span className="min-w-0 flex-1 truncate text-[var(--color-ink)]">
{children}
</span>
{trailing ? (
<span className="shrink-0 text-[10px] uppercase tracking-wide text-[var(--color-ink-soft)]">
{trailing}
</span>
) : null}
</div>
);
}
export function PulseMarker({
tone = "signal",
label,
timestamp,
trailing,
}: PulseMarkerProps) {
return (
<MarkerShell
tone={tone}
label={label}
timestamp={timestamp}
trailing={trailing}
>
{/* children rendered by parent via composition — see NowMarker union below */}
</MarkerShell>
);
}
export function ClusterMarker({
tone = "signal",
label,
timestamp,
bands,
}: ClusterMarkerProps) {
return (
<MarkerShell tone={tone} label={label} timestamp={timestamp}>
<div className="flex h-3 w-full max-w-[280px] overflow-hidden rounded-[var(--radius-r-control)]">
{bands.map((b) => (
<span
key={b.tone}
className={cn("h-full")}
style={{
width: `${Math.max(0, Math.min(1, b.ratio)) * 100}%`,
background: TONE_TIP[b.tone],
opacity: b.tone === "neutral" ? 0.4 : 1,
}}
aria-hidden
/>
))}
</div>
</MarkerShell>
);
}
@@ -1,92 +0,0 @@
"use client";
/**
* DashLeftRail — 80px vertical monogram nav.
*
* Each item is a glyph + label. Active state uses an accent bar on the left
* and full ink colour. No backgrounds, no boxes.
*/
import {
Activity,
BarChart3,
Flag,
MessagesSquare,
Mic,
ShieldCheck,
Users,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
interface NavItem {
href: string;
glyph: React.ReactNode;
label: string;
}
const ITEMS: NavItem[] = [
{
href: "/dashboard",
glyph: <BarChart3 className="size-4" />,
label: "Console",
},
{
href: "/messages",
glyph: <MessagesSquare className="size-4" />,
label: "Messages",
},
{
href: "/moderation",
glyph: <ShieldCheck className="size-4" />,
label: "Moderation",
},
{ href: "/voice", glyph: <Mic className="size-4" />, label: "Voice" },
{ href: "/media", glyph: <Activity className="size-4" />, label: "Media" },
{
href: "/recordings",
glyph: <Flag className="size-4" />,
label: "Recordings",
},
{ href: "/analysis", glyph: <Users className="size-4" />, label: "Analysis" },
];
export function DashLeftRail() {
const pathname = usePathname();
return (
<nav
aria-label="Console navigation"
className="flex h-full w-20 shrink-0 flex-col items-center gap-1 border-r border-[var(--color-hairline)] bg-[var(--color-surface)] py-3"
>
{ITEMS.map((it) => {
const active =
pathname === it.href || pathname?.startsWith(`${it.href}/`);
return (
<Link
key={it.href}
href={it.href}
className={cn(
"group relative flex w-full flex-col items-center gap-1 py-2 text-[10px] uppercase tracking-wide transition-colors",
active
? "text-[var(--color-ink)]"
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
)}
data-active={active ? "1" : "0"}
>
<span
aria-hidden
className={cn(
"absolute inset-y-2 left-0 w-[2px] origin-center transition-transform",
active ? "scale-y-100" : "scale-y-0 group-hover:scale-y-100",
)}
style={{ background: "var(--color-signal)" }}
/>
{it.glyph}
<span className="font-mono">{it.label}</span>
</Link>
);
})}
</nav>
);
}
@@ -1,183 +0,0 @@
"use client";
/**
* DashRightRail — 320px collapsible drawer.
*
* Holds the live AI verdict stream, active voice speakers, and the latest
* moderation actions. Reads from existing hooks (`useVoice`, etc.) — no
* new fetches; just re-presentation.
*/
import { ChevronRight } from "lucide-react";
import { useEffect, useState } from "react";
import { useSpeakers } from "@/hooks/use-voice";
import type { ActiveSpeaker } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
interface DashRightRailProps {
pendingVerdicts?: { id: string; ts: number; text: string }[];
recentActions?: { id: string; ts: number; verb: string; target: string }[];
}
export function DashRightRail({
pendingVerdicts = [],
recentActions = [],
}: DashRightRailProps) {
const [collapsed, setCollapsed] = useState(false);
const { subscribe } = useSpeakers();
const ws = useWebSocket();
const [speakers, _setSpeakers] = useState<ActiveSpeaker[]>([]);
useEffect(() => subscribe(ws), [ws, subscribe]);
return (
<aside
className={cn(
"relative shrink-0 border-l border-[var(--color-hairline)] bg-[var(--color-surface)] font-mono text-[11px] transition-[width]",
collapsed ? "w-9" : "w-[320px]",
)}
aria-label="Live activity rail"
>
<button
type="button"
onClick={() => setCollapsed((c) => !c)}
className={cn(
"absolute -left-3 top-3 z-10 flex size-6 items-center justify-center rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)] text-[var(--color-ink-soft)] transition-colors hover:text-[var(--color-ink)]",
)}
aria-label={
collapsed
? "Expand live activity rail"
: "Collapse live activity rail"
}
>
<ChevronRight
className={cn(
"size-3 transition-transform",
collapsed ? "" : "rotate-180",
)}
/>
</button>
{collapsed ? (
<div className="flex h-full flex-col items-center gap-4 py-4">
<Section title="ai" vertical />
<Section title="voice" vertical />
<Section title="mod" vertical />
</div>
) : (
<div className="flex h-full flex-col overflow-y-auto">
<Section title="ai verdicts">
{pendingVerdicts.length === 0 ? (
<Empty msg="no pending verdicts" />
) : (
<ul className="flex flex-col gap-1.5">
{pendingVerdicts.slice(0, 8).map((v) => (
<li key={v.id} className="flex items-baseline gap-2">
<span className="shrink-0 text-[var(--color-ink-soft)] tabular-nums">
{formatTs(v.ts)}
</span>
<span className="min-w-0 truncate text-[var(--color-ink)]">
{v.text}
</span>
</li>
))}
</ul>
)}
</Section>
<Section title="voice">
{speakers.length === 0 ? (
<Empty msg="no one speaking" />
) : (
<ul className="flex flex-col gap-1.5">
{speakers.slice(0, 8).map((sp) => (
<li key={sp.userId} className="flex items-center gap-2">
<span
className={cn(
"inline-block size-1.5 rounded-full",
sp.speaking
? "bg-[var(--color-signal)]"
: "bg-[var(--color-ink-soft)]",
)}
aria-hidden
/>
<span className="truncate text-[var(--color-ink)]">
{sp.username ?? sp.userId}
</span>
</li>
))}
</ul>
)}
</Section>
<Section title="mod queue">
{recentActions.length === 0 ? (
<Empty msg="queue empty" />
) : (
<ul className="flex flex-col gap-1.5">
{recentActions.slice(0, 8).map((a) => (
<li key={a.id} className="flex items-baseline gap-2">
<span className="shrink-0 text-[var(--color-ink-soft)] tabular-nums">
{formatTs(a.ts)}
</span>
<span className="text-[var(--color-ink-soft)]">
{a.verb}
</span>
<span className="min-w-0 truncate text-[var(--color-ink)]">
{a.target}
</span>
</li>
))}
</ul>
)}
</Section>
<Section title="socket">
<div className="flex flex-col gap-0.5 text-[10px]">
<span className="text-[var(--color-ink-soft)]">status</span>
<span className="text-[var(--color-ink)]">{ws.status}</span>
</div>
</Section>
</div>
)}
</aside>
);
}
function Section({
title,
children,
vertical,
}: {
title: string;
children?: React.ReactNode;
vertical?: boolean;
}) {
return (
<section
className={cn(
"border-b border-[var(--color-hairline)] px-3 py-2.5",
vertical && "flex flex-col items-center gap-2 border-b-0 py-4",
)}
>
<h3 className="mb-1.5 text-[10px] uppercase tracking-[0.18em] text-[var(--color-ink-soft)]">
{title}
</h3>
{children}
</section>
);
}
function Empty({ msg }: { msg: string }) {
return (
<span className="text-[10px] italic text-[var(--color-ink-soft)]">
{msg}
</span>
);
}
function formatTs(ts: number): string {
const d = new Date(ts);
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
@@ -1,115 +0,0 @@
"use client";
/**
* DashTopBar — 48px utility strip.
*
* No navigation chrome — just brand monogram, guild indicator, WS connection
* state, clock, and focus mode. Designed to read as a single line of
* instrument readout, not a navbar.
*/
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
type FocusMode = "quiet" | "standard" | "triage";
const FOCUS_MODES: FocusMode[] = ["quiet", "standard", "triage"];
interface DashTopBarProps {
guildName: string;
botName?: string;
}
function formatClock(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
}
export function DashTopBar({ guildName, botName = "GMW" }: DashTopBarProps) {
const ws = useWebSocket();
const [now, setNow] = useState<Date | null>(null);
const [focus, setFocus] = useState<FocusMode>("standard");
const [tz, setTz] = useState<"utc" | "local">("local");
useEffect(() => {
setNow(new Date());
const id = window.setInterval(() => setNow(new Date()), 1000);
return () => window.clearInterval(id);
}, []);
const connected = ws.status === "connected";
return (
<header
className={cn(
"flex h-12 items-center justify-between gap-4 border-b border-[var(--color-hairline)] bg-[var(--color-surface)] px-4 font-mono text-[11px]",
)}
>
<div className="flex items-center gap-3">
<span className="display text-base font-medium text-[var(--color-ink)]">
{botName}
</span>
<span className="text-[var(--color-ink-soft)]">·</span>
<span className="text-[var(--color-ink-soft)]">{guildName}</span>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<span
aria-hidden
className={cn(
"inline-block size-1.5 rounded-full",
connected
? "bg-[var(--color-signal)]"
: "bg-[var(--color-vermilion)]",
)}
style={{
boxShadow: connected
? "0 0 0 0 oklch(from var(--color-signal) l c h / 0.45)"
: "none",
}}
/>
<span className="uppercase tracking-[0.18em] text-[var(--color-ink-soft)]">
{ws.status}
</span>
</div>
<button
type="button"
onClick={() => setTz((t) => (t === "utc" ? "local" : "utc"))}
className="rounded-[var(--radius-r-control)] px-2 py-0.5 text-[var(--color-ink-soft)] transition-colors hover:bg-[var(--color-surface-2)] hover:text-[var(--color-ink)]"
aria-label="Toggle UTC / local timezone"
>
{now
? tz === "utc"
? `${formatClock(now)} UTC`
: formatLocal(now)
: "--:--:--"}
</button>
<div className="flex gap-0.5 rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] p-0.5">
{FOCUS_MODES.map((m) => (
<button
key={m}
type="button"
onClick={() => setFocus(m)}
className={cn(
"rounded-[var(--radius-r-control)] px-2 py-0.5 text-[10px] uppercase tracking-wide transition-colors",
focus === m
? "bg-[var(--color-canvas)] text-[var(--color-ink)]"
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
)}
>
{m}
</button>
))}
</div>
</div>
</header>
);
}
function formatLocal(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
@@ -1,99 +0,0 @@
"use client";
import { motion } from "motion/react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { navItems } from "@/lib/navigation";
import { cn } from "@/lib/utils";
const titleFromPath: Record<string, string> = {
"/dashboard": "Overview",
"/messages": "Messages",
"/voice": "Voice",
"/media": "Media",
"/recordings": "Recordings",
"/moderation": "Moderation",
"/analysis": "Analysis",
};
export function Spine() {
const pathname = usePathname();
return (
<>
{/* Desktop rail */}
<nav className="fixed left-0 top-0 z-30 hidden h-svh w-[68px] flex-col items-center gap-1 border-r border-[var(--color-hairline)] bg-[var(--color-canvas)]/80 py-4 backdrop-blur-md md:flex">
<Link
href="/dashboard"
className="mb-3 flex size-9 items-center justify-center rounded-[var(--radius-r-control)] bg-[var(--color-signal)] text-sm font-black text-[var(--color-signal-ink)]"
aria-label="GMW"
>
B
</Link>
{navItems.map((item) => {
const active = pathname.startsWith(item.matchPrefix);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className={cn(
"group relative flex size-11 items-center justify-center rounded-[var(--radius-r)] transition-colors",
active
? "text-[var(--color-signal)]"
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
)}
aria-current={active ? "page" : undefined}
>
{active && (
<motion.span
layoutId="spine-active"
className="absolute left-0 top-1/2 size-1 -translate-y-1/2 rounded-full bg-[var(--color-signal)]"
transition={{ type: "spring", stiffness: 380, damping: 30 }}
/>
)}
<Icon className="size-5" />
<span className="pointer-events-none absolute left-full ml-2 hidden whitespace-nowrap rounded-[var(--radius-r-control)] bg-[var(--color-ink)] px-2 py-1 text-xs font-medium text-[var(--color-canvas)] opacity-0 transition-opacity group-hover:opacity-100 md:block">
{item.label}
</span>
</Link>
);
})}
</nav>
{/* Mobile bottom tab-bar */}
<nav className="fixed inset-x-0 bottom-0 z-30 flex h-16 items-stretch border-t border-[var(--color-hairline)] bg-[var(--color-canvas)]/90 backdrop-blur-md md:hidden">
{navItems.map((item) => {
const active = pathname.startsWith(item.matchPrefix);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex flex-1 flex-col items-center justify-center gap-0.5 text-[10px] font-medium transition-colors",
active
? "text-[var(--color-signal)]"
: "text-[var(--color-ink-soft)]",
)}
aria-current={active ? "page" : undefined}
>
<Icon className="size-5" />
{item.label}
</Link>
);
})}
</nav>
</>
);
}
export function PageTitle() {
const pathname = usePathname();
const key =
Object.keys(titleFromPath).find((k) => pathname.startsWith(k)) ??
"/dashboard";
return (
<span className="font-semibold max-md:hidden">{titleFromPath[key]}</span>
);
}
@@ -1,66 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useChatbot } from "@/components/chatbot/chatbot-context";
import { GuildSelector } from "@/components/shared/guild-selector";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
import { PageTitle } from "./spine";
import { ThemeToggle } from "./theme-toggle";
const statusTone: Record<string, string> = {
connected: "bg-[var(--color-signal)]",
connecting: "bg-[var(--color-amber)]",
disconnected: "bg-[var(--color-ink-soft)]",
error: "bg-[var(--color-vermilion)]",
};
export function StatusBar({
guildId,
onGuildChange,
}: {
guildId: string;
onGuildChange: (g: string) => void;
}) {
const ws = useWebSocket();
const { expression } = useChatbot();
const [clock, setClock] = useState("--:--:--");
useEffect(() => {
const tick = () =>
setClock(new Date().toLocaleTimeString("en-GB", { hour12: false }));
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
return (
<header className="sticky top-0 z-20 flex h-14 shrink-0 items-center gap-2 border-b border-[var(--color-hairline)] bg-[var(--color-canvas)]/85 px-4 backdrop-blur-md md:px-6">
<PageTitle />
<div className="ms-auto flex items-center gap-3">
<span className="hidden items-center gap-1.5 text-xs text-[var(--color-ink-soft)] sm:flex">
<span className={cn("size-2 rounded-full", statusTone[ws.status])} />
<span className="mono uppercase">{ws.status}</span>
</span>
<span className="hidden text-xs text-[var(--color-ink-soft)] md:inline">
<span
className={cn(
"font-mono",
expression !== "idle" && "text-[var(--color-signal)]",
)}
>
{expression}
</span>
</span>
<span className="hidden font-mono text-xs text-[var(--color-ink-soft)] lg:inline">
{clock}
</span>
<GuildSelector
value={guildId}
onChange={(g) => onGuildChange(g ?? "")}
/>
<ThemeToggle />
</div>
</header>
);
}
@@ -1,38 +0,0 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { motion } from "motion/react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const isDark = resolvedTheme === "dark";
return (
<button
type="button"
aria-label="Toggle theme"
onClick={() => setTheme(isDark ? "light" : "dark")}
className="flex size-9 items-center justify-center rounded-[var(--radius-r-control)] text-[var(--color-ink-soft)] transition-colors hover:bg-[var(--color-surface-2)] hover:text-[var(--color-ink)] focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]"
>
{mounted && (
<motion.span
key={isDark ? "moon" : "sun"}
initial={{ rotate: -90, opacity: 0 }}
animate={{ rotate: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 360, damping: 26 }}
className={cn(
isDark ? "text-[var(--color-signal)]" : "text-[var(--color-amber)]",
)}
>
{isDark ? <Moon className="size-4" /> : <Sun className="size-4" />}
</motion.span>
)}
</button>
);
}
@@ -1,59 +0,0 @@
"use client";
import { Pause, Play, SkipForward, Volume2 } from "lucide-react";
import { motion } from "motion/react";
import { Button } from "@/components/primitives/button";
import { useMediaSkip, useMediaState, useMediaWsSync } from "@/hooks";
import { useMediaPlayer } from "@/lib/hooks/use-media-player";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
export function MiniPlayer() {
const ws = useWebSocket();
const { data: state } = useMediaState();
const { playing, current } = useMediaPlayer();
useMediaWsSync(ws);
const skip = useMediaSkip();
if (!current) return null;
return (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ type: "spring", stiffness: 320, damping: 30 }}
className={cn(
"pointer-events-auto fixed inset-x-0 bottom-20 z-30 mx-auto w-[calc(100%-2rem)] max-w-[480px]",
"surface flex items-center gap-3 px-3 py-2 text-sm",
)}
>
<img
src={current.thumbnailUrl ?? "/favicon.ico"}
alt={current.title}
className="size-9 rounded-[var(--radius-r-control)] object-cover"
/>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{current.title}</div>
<div className="text-xs text-[var(--color-ink-soft)] mono">
{current.source}
</div>
</div>
<div className="flex items-center gap-1">
<Button size="sm" variant="ghost" onClick={() => skip.mutate()}>
<SkipForward className="size-3.5" />
</Button>
<Button
size="sm"
variant={playing ? "primary" : "ghost"}
onClick={() =>
state?.playing ? void skip.mutate() : void skip.mutate()
}
>
{playing ? <Pause className="size-4" /> : <Play className="size-4" />}
</Button>
<Volume2 className="size-4 text-[var(--color-ink-soft)]" />
</div>
</motion.div>
);
}
@@ -1,159 +0,0 @@
"use client";
import { useState } from "react";
import { Badge } from "@/components/primitives/badge";
import { Progress } from "@/components/primitives/progress";
import { cn } from "@/lib/utils";
interface AiAnalysisPanelProps {
status?: string | null;
severity?: string | null;
confidence?: number | null;
flags?: string[] | string | null;
categories?: string[] | string | null;
action?: string | null;
score?: number | null;
analysis?: string | null;
}
const severityColor: Record<string, string> = {
none: "text-[var(--color-ink-soft)]",
low: "text-[var(--color-ink-soft)]",
medium: "text-[var(--color-amber)]",
high: "text-orange-500",
critical: "text-[var(--color-vermilion)]",
};
export function AiAnalysisPanel({
status,
severity,
confidence,
flags,
categories,
action,
score,
analysis,
}: AiAnalysisPanelProps) {
const [expanded, setExpanded] = useState(false);
if (!status || status === "pending") {
return (
<div className="surface-2 p-3">
<span className="text-xs text-[var(--color-ink-soft)]/60">
AI analysis pending
</span>
</div>
);
}
const flagsArray =
typeof flags === "string" ? (flags ? JSON.parse(flags) : []) : flags || [];
const categoriesArray =
typeof categories === "string"
? categories
? JSON.parse(categories)
: []
: categories || [];
const statusTone =
status === "clean"
? "signal"
: status === "flagged"
? "vermilion"
: status === "warn"
? "amber"
: "neutral";
return (
<div className="surface-2 flex flex-col gap-2.5 p-3">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-[var(--color-ink-soft)]">
AI Analysis
</span>
<Badge tone={statusTone}>{status}</Badge>
</div>
{severity && (
<div className="flex items-center gap-2 text-xs">
<span className="text-[var(--color-ink-soft)]/60">Severity:</span>
<span
className={cn(
"font-mono font-medium",
severityColor[severity] || "",
)}
>
{severity}
</span>
</div>
)}
{confidence !== null && confidence !== undefined && (
<div className="flex items-center gap-2 text-xs">
<span className="text-[var(--color-ink-soft)]/60">Confidence</span>
<Progress
value={confidence * 100}
max={100}
tone="signal"
showLabel
/>
</div>
)}
{score !== null && score !== undefined && (
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--color-ink-soft)]/60">Score</span>
<span className="font-mono">{score.toFixed(2)}</span>
</div>
)}
{flagsArray.length > 0 && (
<div className="flex flex-wrap gap-1">
{flagsArray.map((f: string) => (
<Badge key={f} tone="vermilion">
{f}
</Badge>
))}
</div>
)}
{categoriesArray.length > 0 && (
<div className="flex flex-wrap gap-1">
{categoriesArray.map((c: string) => (
<Badge key={c} tone="neutral">
{c}
</Badge>
))}
</div>
)}
{analysis && (
<div className="border-l-2 border-[var(--color-hairline)] pl-2">
<p
className={cn(
"text-xs leading-relaxed text-[var(--color-ink-soft)]",
!expanded && "line-clamp-3",
)}
>
{analysis}
</p>
{analysis.length > 120 && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="mt-1 text-[10px] font-medium uppercase tracking-wide text-[var(--color-ink-soft)]/50 transition-colors hover:text-[var(--color-ink)]"
>
{expanded ? "Show less" : "Show more"}
</button>
)}
</div>
)}
{action && action !== "none" && (
<div className="text-xs">
<span className="text-[var(--color-ink-soft)]/60">Recommended: </span>
<span className="font-mono text-[var(--color-amber)]">{action}</span>
</div>
)}
</div>
);
}
@@ -1,44 +0,0 @@
"use client";
import type { AiSeverity, AiStatus } from "@/lib/types";
import { cn } from "@/lib/utils";
const severityTick: Record<NonNullable<AiSeverity>, string> = {
none: "border-[var(--color-signal)]/30",
low: "border-[var(--color-amber)]/50",
medium: "border-[var(--color-amber)]",
high: "border-orange-500/80",
critical: "border-[var(--color-vermilion)]",
};
const statusBadge: Record<NonNullable<AiStatus>, string> = {
pending: "bg-[var(--color-ink-soft)]/20 text-[var(--color-ink-soft)]",
processing: "bg-[var(--color-amber)]/15 text-[var(--color-amber)]",
clean: "bg-[var(--color-signal)]/15 text-[var(--color-signal)]",
warn: "bg-[var(--color-amber)]/15 text-[var(--color-amber)]",
flagged: "bg-[var(--color-vermilion)]/15 text-[var(--color-vermilion)]",
error: "bg-[var(--color-vermilion)]/15 text-[var(--color-vermilion)]",
};
export function SeverityTick({ severity }: { severity?: AiSeverity | null }) {
const cls = severity ? severityTick[severity] : "border-transparent";
return (
<span
className={cn("absolute left-0 top-0 h-full w-0.5 border-l-2", cls)}
aria-hidden="true"
/>
);
}
export function AiStatusBadge({ status }: { status?: AiStatus | null }) {
if (!status) return null;
return (
<span className={cn("pill", statusBadge[status])}>
<span
className="size-1.5 rounded-full"
style={{ background: "currentColor" }}
/>
<span className="ml-1 text-[10px] font-medium uppercase">{status}</span>
</span>
);
}
@@ -1,35 +0,0 @@
"use client";
import type { AttachmentRef } from "@/lib/types";
import { cn } from "@/lib/utils";
export function AttachmentsGrid({
attachments,
onOpen,
}: {
attachments: AttachmentRef[];
onOpen: (url: string) => void;
}) {
if (attachments.length === 0) return null;
const images = attachments.filter((a) => /image/i.test(a.contentType ?? ""));
if (images.length === 0) return null;
return (
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-3">
{images.map((a, i) => (
<button
key={`${a.url}-${i}`}
type="button"
onClick={() => onOpen(a.url)}
className="group relative aspect-video overflow-hidden rounded-[var(--radius-r-control)] border border-[var(--color-hairline)]"
>
<img
src={a.url}
alt={a.name}
loading="lazy"
className="size-full object-cover transition-transform duration-200 group-hover:scale-105"
/>
</button>
))}
</div>
);
}
@@ -1,74 +0,0 @@
"use client";
import { X } from "lucide-react";
import { useEffect, useState } from "react";
import { Dialog } from "@/components/primitives/dialog";
import { cn } from "@/lib/utils";
export function Lightbox({
open,
onClose,
src,
alt,
images = [],
initialIndex = 0,
}: {
open: boolean;
onClose: () => void;
src?: string;
alt?: string;
images?: Array<{ src: string; alt?: string }>;
initialIndex?: number;
}) {
const gallery = images.length > 0 ? images : src ? [{ src, alt }] : [];
const [idx, setIdx] = useState(initialIndex);
useEffect(() => setIdx(initialIndex), [initialIndex]);
if (!gallery.length) return null;
const current = gallery[idx];
const hasNav = gallery.length > 1;
return (
<Dialog
open={open}
onClose={onClose}
className="p-0 border-0 bg-transparent shadow-none"
>
<div className="relative flex items-center justify-center p-4">
{hasNav && (
<button
type="button"
onClick={() =>
setIdx((i) => (i - 1 + gallery.length) % gallery.length)
}
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/40 p-2 text-white hover:bg-black/60"
aria-label="Previous"
>
</button>
)}
<img
src={current.src}
alt={current.alt ?? alt ?? "attachment"}
className="max-h-[80vh] max-w-full rounded-[var(--radius-r)] object-contain"
/>
{hasNav && (
<button
type="button"
onClick={() => setIdx((i) => (i + 1) % gallery.length)}
className="absolute right-16 top-1/2 -translate-y-1/2 rounded-full bg-black/40 p-2 text-white hover:bg-black/60"
aria-label="Next"
>
</button>
)}
<button
type="button"
onClick={onClose}
className="absolute top-2 right-2 rounded-full bg-black/40 p-1.5 text-white hover:bg-black/60"
aria-label="Close"
>
<X className="size-4" />
</button>
</div>
</Dialog>
);
}
@@ -1,124 +0,0 @@
"use client";
import { useState } from "react";
import { Avatar } from "@/components/primitives/avatar";
import { Badge } from "@/components/primitives/badge";
import {
getMessageChannelLabel,
renderMessageContent,
safeParseJsonArray,
} from "@/lib/format";
import type { AttachmentRef, MessageRecord } from "@/lib/types";
import { cn } from "@/lib/utils";
import { AiStatusBadge, SeverityTick } from "./ai-status-badge";
import { AttachmentsGrid } from "./attachments-grid";
import { Lightbox } from "./lightbox";
function extractAttachments(metadata?: string | null): AttachmentRef[] {
if (!metadata) return [];
try {
const m = JSON.parse(metadata);
return (m?.attachments ?? []) as AttachmentRef[];
} catch {
return [];
}
}
function fmtFull(ts?: number): string {
if (!ts) return "";
return new Date(ts * 1000).toLocaleString();
}
export interface MessageDetailProps {
message: MessageRecord;
channelLabel?: string;
}
export function MessageDetailView({
message: msg,
channelLabel,
}: MessageDetailProps) {
const [img, setImg] = useState<string | null>(null);
const severity = msg.ai_severity ?? "none";
const flags = safeParseJsonArray(msg.ai_moderation_flags || "[]");
return (
<>
<div className="mb-4 flex items-start gap-3">
<Avatar src={msg.avatar_url} name={msg.username} size={40} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2.5 flex-wrap">
<span className="font-semibold">{msg.username}</span>
<span className="mono text-xs text-[var(--color-ink-soft)]">
{fmtFull(msg.created_at)}
</span>
<AiStatusBadge status={msg.ai_status ?? null} />
</div>
<div className="mt-2 text-xs text-[var(--color-ink-soft)]">
#{channelLabel ?? getMessageChannelLabel(msg)}
{msg.thread_id && <span className="mx-1 opacity-40">·</span>}
{msg.thread_id && <span>Thread {msg.thread_id.slice(0, 8)}</span>}
</div>
</div>
</div>
<div
className={cn(
"relative rounded-[var(--radius-r)] p-4",
severity === "critical"
? "border-l-2 border-[var(--color-vermilion)]"
: severity === "high"
? "border-l-2 border-[var(--color-amber)]"
: "border border-[var(--color-hairline)]",
)}
>
<SeverityTick severity={severity} />
<div className="text-sm leading-relaxed">
{msg.deleted_at ? (
<span className="italic text-[var(--color-ink-soft)]">
message deleted
</span>
) : (
renderMessageContent(
msg.edited_content ?? msg.content,
msg.metadata,
)
)}
</div>
</div>
{flags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{flags.map((f) => (
<Badge key={f} tone="vermilion">
{f}
</Badge>
))}
</div>
)}
{msg.ai_analysis && (
<div className="mt-3 rounded-[var(--radius-r)] bg-[var(--color-surface-2)] p-3 text-xs">
<span className="font-medium text-[var(--color-amber)]">
AI analysis:
</span>{" "}
<span className="text-[var(--color-ink-soft)]">
{msg.ai_analysis}
</span>
</div>
)}
{extractAttachments(msg.metadata).length > 0 && (
<AttachmentsGrid
attachments={extractAttachments(msg.metadata)}
onOpen={(u) => setImg(u)}
/>
)}
<Lightbox
open={!!img}
onClose={() => setImg(null)}
src={img ?? undefined}
/>
</>
);
}
@@ -1,87 +0,0 @@
"use client";
import { Avatar } from "@/components/primitives/avatar";
import { renderMessageContent } from "@/lib/format";
import type { AiSeverity, MessageRecord } from "@/lib/types";
import { cn } from "@/lib/utils";
import { AiStatusBadge, SeverityTick } from "./ai-status-badge";
function fmtTime(ts?: number): string {
if (!ts) return "";
return new Date(ts * 1000).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
const severityColor: Record<NonNullable<AiSeverity>, string> = {
none: "text-[var(--color-ink-soft)]",
low: "text-[var(--color-amber)]",
medium: "text-[var(--color-amber)]",
high: "text-orange-500",
critical: "text-[var(--color-vermilion)]",
};
export interface MessageEntryProps {
message: MessageRecord;
selected: boolean;
onSelect: () => void;
onAvatarClick?: () => void;
}
export function MessageEntry({
message: msg,
selected,
onSelect,
}: MessageEntryProps) {
const severity = (msg.ai_severity ?? "none") as AiSeverity;
const status = msg.ai_status ?? null;
return (
<button
type="button"
data-selected={selected}
onClick={onSelect}
className={cn(
"group relative mb-1.5 flex w-full items-start gap-2.5 rounded-[var(--radius-r)] p-2.5 text-left cursor-pointer",
"transition-all hover:bg-[var(--color-surface-2)]",
selected && "bg-[var(--color-signal)]/6",
)}
>
<SeverityTick severity={severity} />
<Avatar src={msg.avatar_url} name={msg.username} size={32} />
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{msg.username}</span>
<span className="text-xs text-[var(--color-ink-soft)] mono">
{fmtTime(msg.created_at)}
</span>
{status && <AiStatusBadge status={status} />}
{severity !== "none" && (
<span
className={cn(
"text-[10px] font-bold uppercase",
severityColor[severity],
)}
>
{severity}
</span>
)}
</div>
<div className="text-sm leading-relaxed">
{msg.deleted_at ? (
<span className="italic text-[var(--color-ink-soft)]">
message deleted
</span>
) : (
renderMessageContent(
msg.edited_content ?? msg.content,
msg.metadata,
)
)}
</div>
</div>
</button>
);
}
@@ -1,56 +0,0 @@
"use client";
import type { MessageRecord } from "@/lib/types";
import { MessageEntry } from "./message-entry";
export { MessageEntry };
export function MessageList({
messages,
selectedId,
onSelect,
hasMore,
onLoadMore,
isLoadingMore,
}: {
messages: MessageRecord[];
selectedId: string | null;
onSelect: (id: string) => void;
hasMore?: boolean;
onLoadMore?: () => void;
isLoadingMore?: boolean;
}) {
if (messages.length === 0) {
return (
<div className="py-10 text-center">
<p className="text-sm text-[var(--color-ink-soft)]">
No messages found.
</p>
</div>
);
}
return (
<>
<div className="space-y-0.5">
{messages.map((m) => (
<MessageEntry
key={m.id}
message={m}
selected={selectedId === m.id}
onSelect={() => onSelect(m.id)}
/>
))}
</div>
{hasMore && (
<button
type="button"
onClick={onLoadMore}
disabled={isLoadingMore}
className="mt-3 w-full text-center text-xs text-[var(--color-amber)] hover:underline disabled:opacity-50"
>
{isLoadingMore ? "Loading…" : "Load more"}
</button>
)}
</>
);
}
@@ -1,82 +0,0 @@
"use client";
import { Search } from "lucide-react";
import { useState } from "react";
import { Dialog } from "@/components/primitives/dialog";
import { Input } from "@/components/primitives/input";
import { cn } from "@/lib/utils";
export interface Message {
id: string;
content: string;
username: string;
channel: string;
time: string;
}
export interface SearchOverlayProps {
open: boolean;
onClose: () => void;
results: Message[];
onSelect: (msg: Message) => void;
}
export function SearchOverlay({
open,
onClose,
results,
onSelect,
}: SearchOverlayProps) {
const [query, setQuery] = useState("");
const filtered = query
? results.filter(
(m) =>
m.content.toLowerCase().includes(query.toLowerCase()) ||
m.username.toLowerCase().includes(query.toLowerCase()),
)
: results;
return (
<Dialog open={open} onClose={onClose} className="p-0 max-w-xl">
<div className="p-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-soft)]" />
<Input
autoFocus
placeholder="Search messages… (Esc to close)"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 font-mono"
/>
</div>
<div className="mt-3 max-h-[420px] overflow-y-auto">
{filtered.length === 0 ? (
<p className="py-6 text-center text-sm text-[var(--color-ink-soft)]">
No results.
</p>
) : (
<div className="flex flex-col gap-1">
{filtered.slice(0, 32).map((m) => (
<button
key={m.id}
type="button"
onClick={() => onSelect(m)}
className="group flex flex-col items-start gap-1 rounded-[var(--radius-r-control)] px-2.5 py-2 text-left transition-colors hover:bg-[var(--color-surface-2)]"
>
<span className="text-xs text-[var(--color-ink-soft)] group-hover:text-[var(--color-ink)]">
#{m.channel} · {m.username}
</span>
<span className="text-sm">{m.content}</span>
<span className="text-[10px] text-[var(--color-ink-soft)]/60">
{m.time}
</span>
</button>
))}
</div>
)}
</div>
</div>
</Dialog>
);
}
@@ -1,294 +0,0 @@
"use client";
import {
AlertTriangle,
Ban,
CheckCircle2,
Loader2,
MicOff,
ShieldAlert,
Trash2,
UserX,
XCircle,
} from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/primitives/badge";
import { EmptyState, LoadingSkeleton } from "@/components/shared";
import { useModerationActions, useModerationStats } from "@/hooks";
import { renderMessageContent } from "@/lib/format";
import type {
ModerationAction,
ModerationActionType,
ModerationStats,
} from "@/lib/types";
import { cn } from "@/lib/utils";
const ACTION_META: Record<
ModerationActionType,
{ label: string; Icon: typeof Trash2; tone: "vermilion" | "amber" }
> = {
delete_message: { label: "Delete message", Icon: Trash2, tone: "vermilion" },
mute_user: { label: "Mute user", Icon: MicOff, tone: "amber" },
warn_user: { label: "Warn user", Icon: AlertTriangle, tone: "amber" },
kick_user: { label: "Kick user", Icon: UserX, tone: "amber" },
ban_user: { label: "Ban user", Icon: Ban, tone: "vermilion" },
};
const STATUS_META: Record<
ModerationAction["status"],
{ label: string; tone: "signal" | "vermilion" | "amber" }
> = {
executed: { label: "Executed", tone: "signal" },
failed: { label: "Failed", tone: "vermilion" },
pending: { label: "Pending", tone: "amber" },
};
function fmtTime(ts: number | null): string {
if (!ts) return "—";
const d = new Date(ts);
const diff = Date.now() - ts;
const hours = Math.floor(diff / 3600000);
const rel =
hours < 1
? "baru saja"
: hours < 24
? `${hours} jam lalu`
: `${Math.floor(hours / 24)} hari lalu`;
return `${d.toLocaleString("id-ID")} (${rel})`;
}
const EMPTY_ACTION_RATE = {
total: 0,
executed: 0,
failed: 0,
pending: 0,
failed_rate: 0,
};
export function ModerationSection({
initialStats,
initialActions,
}: {
initialStats?: ModerationStats;
initialActions?: ModerationAction[];
} = {}) {
const [status, setStatus] = useState<string>("");
const [actionType, setActionType] = useState<string>("");
const { data: stats } = useModerationStats(initialStats);
const { data: actions, isLoading: actionsLoading } = useModerationActions(
status,
actionType,
initialActions,
);
const s = stats ?? EMPTY_ACTION_RATE;
const statusFilters = ["", "executed", "failed", "pending"];
const typeFilters = [
"",
"delete_message",
"warn_user",
"kick_user",
"ban_user",
"mute_user",
];
return (
<div className="space-y-4">
{/* Summary cards */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<SummaryCard label="Total aksi" value={s.total} />
<SummaryCard
label="Executed"
value={s.executed}
tone="signal"
hint={undefined}
/>
<SummaryCard
label="Failed"
value={s.failed}
tone="vermilion"
hint={s.total > 0 ? `${s.failed_rate}%` : undefined}
/>
<SummaryCard label="Pending" value={s.pending} tone="amber" />
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wide text-[var(--color-ink-soft)]">
Status
</span>
{statusFilters.map((f) => (
<FilterChip
key={f || "all"}
active={status === f}
label={
f === ""
? "Semua"
: STATUS_META[f as keyof typeof STATUS_META].label
}
onClick={() => setStatus(f)}
/>
))}
<span className="ml-3 text-[10px] font-semibold uppercase tracking-wide text-[var(--color-ink-soft)]">
Tipe
</span>
{typeFilters.map((f) => (
<FilterChip
key={f || "all"}
active={actionType === f}
label={
f === "" ? "Semua" : ACTION_META[f as ModerationActionType].label
}
onClick={() => setActionType(f)}
/>
))}
</div>
{/* Timeline */}
{actionsLoading && !actions ? (
<LoadingSkeleton count={6} height="h-16" />
) : !actions || actions.length === 0 ? (
<div className="surface p-6">
<EmptyState
icon={ShieldAlert}
title="Belum ada aksi moderasi"
description="Aksi auto-moderasi (delete, warn, kick, ban) akan muncul di sini."
/>
</div>
) : (
<div className="space-y-2">
{actions.map((a) => (
<ActionRow key={a.id} action={a} />
))}
</div>
)}
<p className="text-[10px] text-[var(--color-ink-soft)]">
{actions?.length ?? 0} aksi ditampilkan · log moderasi gateway Discord
</p>
</div>
);
}
function SummaryCard({
label,
value,
tone,
hint,
}: {
label: string;
value: number;
tone?: "signal" | "vermilion" | "amber";
hint?: string;
}) {
return (
<div className="surface p-4">
<p className="text-[10px] uppercase tracking-wide text-[var(--color-ink-soft)]">
{label}
</p>
<p
className={cn(
"mt-1 text-2xl font-bold",
tone === "signal" && "text-[var(--color-signal)]",
tone === "vermilion" && "text-[var(--color-vermilion)]",
tone === "amber" && "text-[var(--color-amber)]",
!tone && "text-[var(--color-ink)]",
)}
>
{value}
{hint && (
<span className="ml-1 text-xs font-medium opacity-80">({hint})</span>
)}
</p>
</div>
);
}
function FilterChip({
active,
label,
onClick,
}: {
active: boolean;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"rounded-full px-3 py-1 text-[11px] transition-colors",
active
? "bg-[var(--color-signal)] text-[var(--color-signal-ink)]"
: "text-[var(--color-ink-soft)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-ink)]",
)}
>
{label}
</button>
);
}
function ActionRow({ action }: { action: ModerationAction }) {
const meta = ACTION_META[action.action_type] ?? ACTION_META.delete_message;
const st = STATUS_META[action.status];
const Icon = meta.Icon;
return (
<div className="surface flex items-start gap-3 p-3">
<span
className={cn(
"mt-0.5 shrink-0",
meta.tone === "vermilion"
? "text-[var(--color-vermilion)]"
: "text-[var(--color-amber)]",
)}
>
<Icon className="size-4" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold text-[var(--color-ink)]">
{meta.label}
</span>
{action.username && (
<span className="text-xs text-[var(--color-ink-soft)]">
@{action.username}
</span>
)}
<Badge tone={st.tone}>{st.label}</Badge>
</div>
{action.content && (
<p className="mt-1 line-clamp-2 text-xs text-[var(--color-ink-soft)]">
{renderMessageContent(action.content, null)}
</p>
)}
{action.reason && (
<p className="mt-1 text-[11px] text-[var(--color-ink-soft)]">
Alasan: {action.reason}
</p>
)}
{action.error && (
<p className="mt-1 text-[11px] text-[var(--color-vermilion)] line-clamp-2">
Error: {action.error}
</p>
)}
<p className="mt-1.5 text-[10px] font-mono text-[var(--color-ink-soft)]">
dibuat {fmtTime(action.created_at)}
{action.executed_at
? ` · dieksekusi ${fmtTime(action.executed_at)}`
: ""}
</p>
</div>
{action.status === "executed" ? (
<CheckCircle2 className="mt-0.5 size-3.5 shrink-0 text-[var(--color-signal)]" />
) : action.status === "failed" ? (
<XCircle className="mt-0.5 size-3.5 shrink-0 text-[var(--color-vermilion)]" />
) : (
<Loader2 className="mt-0.5 size-3.5 shrink-0 animate-spin text-[var(--color-amber)]" />
)}
</div>
);
}
export default ModerationSection;
@@ -1,30 +0,0 @@
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { ease } from "./variants";
export function RouteTransition({ children }: { children: ReactNode }) {
const pathname = usePathname();
const reduce = useReducedMotion();
if (reduce) {
return <div key={pathname}>{children}</div>;
}
return (
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={pathname}
initial={{ opacity: 0, y: 8, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: "blur(4px)" }}
transition={{ duration: 0.22, ease }}
className="contents"
>
{children}
</motion.div>
</AnimatePresence>
);
}
@@ -1,53 +0,0 @@
"use client";
import { motion, type Variants } from "motion/react";
import type { ReactNode } from "react";
import { fadeUp, stagger } from "./variants";
type V = Variants;
interface StaggerGroupProps {
children: ReactNode;
className?: string;
variants?: V;
as?: "div" | "ul" | "section";
}
export function StaggerGroup({
children,
className,
variants = stagger,
as = "div",
}: StaggerGroupProps) {
const Tag = motion[as];
return (
<Tag
className={className}
variants={variants}
initial="hidden"
animate="visible"
>
{children}
</Tag>
);
}
interface StaggerItemProps {
children: ReactNode;
className?: string;
variants?: V;
layout?: boolean;
}
export function StaggerItem({
children,
className,
variants = fadeUp,
layout,
}: StaggerItemProps) {
return (
<motion.div className={className} variants={variants} layout={layout}>
{children}
</motion.div>
);
}
@@ -1,31 +0,0 @@
import type { Transition, Variants } from "motion/react";
/** Spring tuned for UI micro-interactions. */
export const spring: Transition = {
type: "spring",
stiffness: 260,
damping: 24,
};
/** Expressive ease-out for page/section transitions. */
export const ease = [0.22, 1, 0.36, 1] as const;
/** Single-element fade + rise. */
export const fadeUp: Variants = {
hidden: { opacity: 0, y: 8 },
visible: { opacity: 1, y: 0, transition: { duration: 0.32, ease } },
};
/** Parent that staggers its children. */
export const stagger: Variants = {
hidden: {},
visible: {
transition: { staggerChildren: 0.06, delayChildren: 0.08 },
},
};
/** Scale-in for emphasis blocks. */
export const popIn: Variants = {
hidden: { opacity: 0, scale: 0.94 },
visible: { opacity: 1, scale: 1, transition: spring },
};
@@ -1,38 +1,51 @@
import { cn } from "@/lib/utils";
export interface AvatarProps {
src?: string | null;
name?: string | null;
size?: number;
className?: string;
}
function initials(name?: string | null): string {
if (!name) return "?";
const parts = name.trim().split(/\s+/);
const parts = name.replace(/[^\p{L}\p{N} _]/gu, "").trim().split(/\s+/);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
export function Avatar({ src, name, size = 36, className }: AvatarProps) {
export function Avatar({
src,
name,
size = 36,
className,
ring,
}: {
src?: string | null;
name?: string | null;
size?: number;
className?: string;
ring?: boolean;
}) {
const dim = { width: size, height: size };
return (
<span
className={cn(
"relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full",
"bg-[var(--color-signal)]/20 text-[var(--color-signal)] font-semibold",
"bg-gradient-to-br from-white/10 to-white/[0.02] text-ink-soft",
ring && "ring-2 ring-signal/50",
className,
)}
style={{ width: size, height: size, fontSize: size * 0.38 }}
style={dim}
>
{src ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={name ?? ""}
className="size-full object-cover"
alt={name ?? "avatar"}
className="h-full w-full object-cover"
loading="lazy"
/>
) : (
initials(name)
<span
className="font-semibold"
style={{ fontSize: Math.max(10, size * 0.36) }}
>
{initials(name)}
</span>
)}
</span>
);
@@ -1,40 +1,38 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export type BadgeTone = "signal" | "amber" | "vermilion" | "neutral";
type Tone = "signal" | "amber" | "vermilion" | "neutral";
const toneClass: Record<BadgeTone, string> = {
signal: "bg-[var(--color-signal)]/15 text-[var(--color-signal)]",
amber: "bg-[var(--color-amber)]/15 text-[var(--color-amber)]",
vermilion: "bg-[var(--color-vermilion)]/15 text-[var(--color-vermilion)]",
neutral: "bg-[var(--color-hairline)] text-[var(--color-ink-soft)]",
const tones: Record<Tone, string> = {
signal: "bg-signal/12 text-signal border-signal/30",
amber: "bg-amber/12 text-amber border-amber/30",
vermilion: "bg-vermilion/12 text-vermilion border-vermilion/30",
neutral: "bg-white/6 text-ink-soft border-white/10",
};
export interface BadgeProps {
tone?: BadgeTone;
children: ReactNode;
className?: string;
dot?: boolean;
}
export function Badge({
tone = "neutral",
children,
className,
dot,
}: BadgeProps) {
className,
children,
}: {
tone?: Tone;
dot?: boolean;
className?: string;
children: React.ReactNode;
}) {
return (
<span className={cn("pill", toneClass[tone], className)}>
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[0.7rem] font-semibold tracking-wide",
tones[tone],
className,
)}
>
{dot && (
<span
className={cn(
"size-1.5 rounded-full",
tone === "signal" && "bg-[var(--color-signal)]",
tone === "amber" && "bg-[var(--color-amber)]",
tone === "vermilion" && "bg-[var(--color-vermilion)]",
tone === "neutral" && "bg-[var(--color-ink-soft)]",
)}
/>
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full rounded-full bg-current opacity-60 animate-pulse-ring" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
</span>
)}
{children}
</span>
@@ -1,55 +1,52 @@
"use client";
import { type HTMLMotionProps, motion, useReducedMotion } from "motion/react";
import { forwardRef } from "react";
import { Slot } from "./slot";
import { cn } from "@/lib/utils";
type Variant = "primary" | "ghost" | "danger" | "outline";
type Variant = "primary" | "ghost" | "outline" | "danger" | "subtle";
type Size = "sm" | "md" | "lg" | "icon";
const variantClass: Record<Variant, string> = {
const variants: Record<Variant, string> = {
primary:
"bg-[var(--color-signal)] text-[var(--color-signal-ink)] hover:opacity-90",
ghost:
"bg-transparent text-[var(--color-ink)] hover:bg-[var(--color-surface-2)]",
danger: "bg-[var(--color-vermilion)] text-white hover:opacity-90",
"bg-signal text-signal-ink hover:brightness-110 shadow-[0_8px_24px_-10px_var(--color-signal-glow)] font-semibold",
ghost: "text-ink-soft hover:text-ink hover:bg-white/5",
outline:
"bg-transparent text-[var(--color-ink)] border border-[var(--color-hairline)] hover:bg-[var(--color-surface-2)]",
"border border-hairline bg-white/0 text-ink hover:bg-white/5 hover:border-signal/40",
danger:
"bg-vermilion text-white hover:brightness-110 shadow-[0_8px_24px_-10px_var(--color-vermilion-glow)] font-semibold",
subtle: "bg-white/5 text-ink hover:bg-white/10",
};
const sizeClass: Record<Size, string> = {
sm: "h-8 px-3 text-xs rounded-[var(--radius-r-control)]",
md: "h-10 px-4 text-sm rounded-[var(--radius-r-control)]",
lg: "h-12 px-6 text-base rounded-[var(--radius-r)]",
icon: "size-9 rounded-[var(--radius-r-control)]",
const sizes: Record<Size, string> = {
sm: "h-8 px-3 text-xs rounded-[9px] gap-1.5",
md: "h-10 px-4 text-sm rounded-[11px] gap-2",
lg: "h-12 px-6 text-base rounded-[13px] gap-2",
icon: "h-10 w-10 rounded-[11px]",
};
export interface ButtonProps extends Omit<HTMLMotionProps<"button">, "ref"> {
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
asChild?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{ className, variant = "primary", size = "md", children, ...props },
ref,
) => {
const reduce = useReducedMotion();
return (
<motion.button
ref={ref}
className={cn(
"inline-flex items-center justify-center gap-2 select-none cursor-pointer font-medium outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] disabled:opacity-50 disabled:pointer-events-none transition-colors duration-150",
variantClass[variant],
sizeClass[size],
className,
)}
whileTap={reduce ? undefined : { scale: 0.97 }}
{...props}
>
{children}
</motion.button>
);
},
);
Button.displayName = "Button";
export const Button = ({
className,
variant = "subtle",
size = "md",
asChild,
...props
}: ButtonProps) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(
"inline-flex items-center justify-center whitespace-nowrap transition-all duration-150 select-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal/60 disabled:opacity-40 disabled:pointer-events-none",
variants[variant],
sizes[size],
className,
)}
{...props}
/>
);
};
@@ -0,0 +1,35 @@
import { cn } from "@/lib/utils";
/** Floating glass panel — primary container. */
export function GlassPanel({
className,
children,
glow,
...props
}: React.HTMLAttributes<HTMLDivElement> & { glow?: boolean }) {
return (
<div
className={cn(
"glass p-5",
glow && "shadow-[0_0_40px_-18px_var(--color-signal-glow)]",
className,
)}
{...props}
>
{children}
</div>
);
}
/** Smaller glass sub-panel. */
export function GlassCard({
className,
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("glass-2 p-4", className)} {...props}>
{children}
</div>
);
}
@@ -1,75 +0,0 @@
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { type ReactNode, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
export interface DialogProps {
open: boolean;
onClose: () => void;
children: ReactNode;
className?: string;
labelledBy?: string;
}
export function Dialog({
open,
onClose,
children,
className,
labelledBy,
}: DialogProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open, onClose]);
return (
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
>
<div
className="absolute inset-0 bg-black/55 backdrop-blur-sm"
onClick={onClose}
aria-hidden
/>
<motion.div
ref={ref}
role="dialog"
aria-modal="true"
aria-labelledby={labelledBy}
className={cn(
"relative z-10 w-full max-w-lg surface-2 shadow-2xl",
className,
)}
initial={
reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 12 }
}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 12 }}
transition={{ type: "spring", stiffness: 320, damping: 28 }}
>
{children}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -1,11 +1,11 @@
export { Avatar, type AvatarProps } from "./avatar";
export { Badge, type BadgeProps, type BadgeTone } from "./badge";
export { Button, type ButtonProps } from "./button";
export { Dialog, type DialogProps } from "./dialog";
export { Input, type InputProps } from "./input";
export { Progress, type ProgressProps } from "./progress";
export { Select, type SelectProps } from "./select";
export { Sheet, type SheetProps } from "./sheet";
export { Skeleton, type SkeletonProps } from "./skeleton";
export { Toaster, type ToasterProps, useToast } from "./toast";
export { Tooltip, type TooltipProps } from "./tooltip";
export { Button } from "./button";
export { Badge } from "./badge";
export { GlassPanel, GlassCard } from "./card";
export { Input, Textarea } from "./input";
export { Skeleton } from "./skeleton";
export { Avatar } from "./avatar";
export { Select } from "./select";
export type { SelectOption } from "./select";
export { Toaster, toast, useToast } from "./toast";
export { Progress, Spinner } from "./progress";
export { Tooltip } from "./tooltip";
@@ -1,23 +1,35 @@
import { forwardRef, type InputHTMLAttributes } from "react";
import { cn } from "@/lib/utils";
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
mono?: boolean;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, mono, ...props }, ref) => (
export function Input({
className,
...props
}: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
ref={ref}
className={cn(
"w-full bg-[var(--color-surface-2)] text-[var(--color-ink)] placeholder:text-[var(--color-ink-soft)]/60",
"rounded-[var(--radius-r-control)] border border-[var(--color-hairline)] px-3 py-2 text-sm",
"outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] transition-colors",
mono && "font-mono tracking-tight",
"h-10 w-full rounded-[11px] bg-white/5 border border-hairline px-3.5 text-sm text-ink",
"placeholder:text-ink-faint transition-colors",
"focus:outline-none focus:border-signal/50 focus:bg-white/8",
className,
)}
{...props}
/>
),
);
Input.displayName = "Input";
);
}
export function Textarea({
className,
...props
}: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
return (
<textarea
className={cn(
"w-full rounded-[11px] bg-white/5 border border-hairline px-3.5 py-2.5 text-sm text-ink",
"placeholder:text-ink-faint transition-colors resize-none",
"focus:outline-none focus:border-signal/50 focus:bg-white/8",
className,
)}
{...props}
/>
);
}
@@ -1,39 +1,38 @@
import { cn } from "@/lib/utils";
export interface ProgressProps {
value: number;
max?: number;
className?: string;
tone?: "signal" | "amber" | "vermilion";
showLabel?: boolean;
}
export function Progress({
value,
max = 100,
className,
tone = "signal",
showLabel,
}: ProgressProps) {
const pct = Math.max(0, Math.min(100, (value / max) * 100));
const stroke = {
signal: "var(--color-signal)",
amber: "var(--color-amber)",
vermilion: "var(--color-vermilion)",
}[tone];
className,
}: {
value: number;
tone?: "signal" | "amber" | "vermilion";
className?: string;
}) {
const pct = Math.max(0, Math.min(100, value));
const color =
tone === "vermilion"
? "var(--color-vermilion)"
: tone === "amber"
? "var(--color-amber)"
: "var(--color-signal)";
return (
<div className={cn("flex items-center gap-2", className)}>
<div className="relative h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--color-hairline)]">
<div
className="absolute inset-y-0 left-0 rounded-full transition-[width] duration-300"
style={{ width: `${pct}%`, background: stroke }}
/>
</div>
{showLabel && (
<span className="mono text-xs text-[var(--color-ink-soft)]">
{Math.round(pct)}%
</span>
)}
<div className={cn("h-1.5 w-full overflow-hidden rounded-full bg-white/8", className)}>
<div
className="h-full rounded-full transition-[width] duration-500"
style={{ width: `${pct}%`, background: color, boxShadow: `0 0 12px -2px ${color}` }}
/>
</div>
);
}
export function Spinner({ className }: { className?: string }) {
return (
<span
className={cn(
"inline-block size-4 animate-spin rounded-full border-2 border-white/20 border-t-signal",
className,
)}
/>
);
}
@@ -1,26 +1,91 @@
import { forwardRef, type SelectHTMLAttributes } from "react";
"use client";
import { useEffect, useRef, useState } from "react";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
mono?: boolean;
export interface SelectOption {
value: string;
label: string;
hint?: string;
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ className, mono, children, ...props }, ref) => (
<select
ref={ref}
className={cn(
"w-full bg-[var(--color-surface-2)] text-[var(--color-ink)] appearance-none cursor-pointer",
"rounded-[var(--radius-r-control)] border border-[var(--color-hairline)] px-3 py-2 text-sm",
"outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] transition-colors",
"bg-[url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 fill=%22none%22 stroke=%22%23aaa%22 stroke-width=%222%22><path d=%22M2 4l4 4 4-4%22/></svg>')] bg-[length:12px] bg-[right_0.75rem_center] bg-no-repeat pr-9",
mono && "font-mono tracking-tight",
className,
export function Select({
value,
onChange,
options,
placeholder = "Select…",
className,
size = "md",
}: {
value: string | null;
onChange: (value: string) => void;
options: SelectOption[];
placeholder?: string;
className?: string;
size?: "sm" | "md";
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
const selected = options.find((o) => o.value === value);
return (
<div ref={ref} className={cn("relative", className)}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-[11px] border border-hairline bg-white/5 text-left text-ink transition-colors hover:border-signal/40",
"focus:outline-none focus:border-signal/60",
size === "sm" ? "h-9 px-3 text-xs" : "h-10 px-3.5 text-sm",
)}
>
<span className={cn("truncate", !selected && "text-ink-faint")}>
{selected?.label ?? placeholder}
</span>
<ChevronDown
className={cn("size-4 shrink-0 text-ink-faint transition-transform", open && "rotate-180")}
/>
</button>
{open && (
<div
className="glass absolute z-50 mt-1.5 max-h-72 w-full overflow-auto p-1.5"
style={{ animation: "fade-up 0.14s ease" }}
>
{options.length === 0 && (
<div className="px-3 py-2 text-xs text-ink-faint">No options</div>
)}
{options.map((o) => (
<button
key={o.value}
type="button"
onClick={() => {
onChange(o.value);
setOpen(false);
}}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-[9px] px-3 py-2 text-left text-sm transition-colors",
o.value === value ? "bg-signal/15 text-signal" : "text-ink hover:bg-white/6",
)}
>
<span className="truncate">{o.label}</span>
{o.hint && <span className="mono text-[0.65rem] text-ink-faint">{o.hint}</span>}
{o.value === value && <Check className="size-3.5 shrink-0" />}
</button>
))}
</div>
)}
{...props}
>
{children}
</select>
),
);
Select.displayName = "Select";
</div>
);
}
@@ -1,74 +0,0 @@
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { type ReactNode, useEffect } from "react";
import { cn } from "@/lib/utils";
export interface SheetProps {
open: boolean;
onClose: () => void;
children: ReactNode;
side?: "left" | "right" | "bottom";
className?: string;
}
export function Sheet({
open,
onClose,
children,
side = "left",
className,
}: SheetProps) {
const reduce = useReducedMotion();
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
const dir =
side === "left"
? { initial: { x: "-100%" }, animate: { x: 0 } }
: side === "right"
? { initial: { x: "100%" }, animate: { x: 0 } }
: { initial: { y: "100%" }, animate: { y: 0 } };
return (
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<div
className="absolute inset-0 bg-black/55 backdrop-blur-sm"
onClick={onClose}
aria-hidden
/>
<motion.aside
className={cn(
"absolute bg-[var(--color-canvas)] shadow-2xl",
side === "left" &&
"left-0 top-0 h-full w-72 border-r border-[var(--color-hairline)]",
side === "right" &&
"right-0 top-0 h-full w-72 border-l border-[var(--color-hairline)]",
side === "bottom" &&
"bottom-0 left-0 w-full rounded-t-2xl border-t border-[var(--color-hairline)]",
className,
)}
initial={reduce ? { opacity: 0 } : dir.initial}
animate={reduce ? { opacity: 1 } : dir.animate}
exit={reduce ? { opacity: 0 } : dir.initial}
transition={{ type: "spring", stiffness: 320, damping: 32 }}
>
{children}
</motion.aside>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -1,20 +1,16 @@
import { cn } from "@/lib/utils";
export interface SkeletonProps {
className?: string;
rounded?: boolean;
}
export function Skeleton({ className, rounded }: SkeletonProps) {
export function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
aria-hidden
className={cn(
"animate-shimmer rounded-[var(--radius-r-control)]",
"bg-[var(--color-surface-2)]",
rounded && "rounded-full",
"rounded-[10px] bg-white/[0.06] animate-shimmer relative overflow-hidden",
className,
)}
{...props}
/>
);
}
@@ -0,0 +1,27 @@
"use client";
import * as React from "react";
/**
* Minimal Slot — merges its props onto its single child element (Radix-style
* `asChild`). Enough for wrapping <Link>/<a> in a Button.
*/
export const Slot = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode }>(
({ children, ...slotProps }, ref) => {
if (!React.isValidElement(children)) return null;
const childProps = children.props as Record<string, unknown>;
const merged: Record<string, unknown> = { ...childProps, ...slotProps, ref };
// Merge className
if (slotProps.className || childProps.className) {
merged.className = [childProps.className, slotProps.className]
.filter(Boolean)
.join(" ");
}
// Merge style
if (slotProps.style || childProps.style) {
merged.style = { ...(childProps.style as object), ...(slotProps.style as object) };
}
return React.cloneElement(children, merged);
},
);
Slot.displayName = "Slot";
@@ -1,147 +1,104 @@
"use client";
import { X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { useEffect, useState } from "react";
import { CheckCircle2, AlertTriangle, Info, X } from "lucide-react";
import { cn } from "@/lib/utils";
type ToastTone = "signal" | "amber" | "vermilion" | "neutral";
interface ToastItem {
type ToastTone = "signal" | "vermilion" | "neutral";
interface Toast {
id: number;
title?: string;
title: string;
description?: string;
tone: ToastTone;
}
interface ToastContextValue {
toast: (t: {
title?: string;
description?: string;
tone?: ToastTone;
}) => void;
let nextId = 1;
const listeners = new Set<(t: Toast[]) => void>();
let store: Toast[] = [];
function emit() {
store = [...store];
listeners.forEach((l) => l(store));
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function toast(t: {
title: string;
description?: string;
tone?: ToastTone;
}) {
const item: Toast = { id: nextId++, tone: t.tone ?? "neutral", ...t };
store = [...store, item];
listeners.forEach((l) => l(store));
setTimeout(() => {
store = store.filter((x) => x.id !== item.id);
emit();
}, 4200);
}
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) {
return {
toast: (_: {
title?: string;
description?: string;
tone?: ToastTone;
}) => {},
};
}
return ctx;
return { toast };
}
const toneBar: Record<ToastTone, string> = {
signal: "bg-[var(--color-signal)]",
amber: "bg-[var(--color-amber)]",
vermilion: "bg-[var(--color-vermilion)]",
neutral: "bg-[var(--color-ink-soft)]",
const icons = {
signal: CheckCircle2,
vermilion: AlertTriangle,
neutral: Info,
};
export interface ToasterProps {
position?: "bottom-right" | "bottom-left" | "top-right" | "top-left";
}
export function Toaster({ position = "bottom-right" }: ToasterProps) {
const [items, setItems] = useState<ToastItem[]>([]);
const reduce = useReducedMotion();
const toast = useCallback(
(t: { title?: string; description?: string; tone?: ToastTone }) => {
const id = Date.now() + Math.random();
const item: ToastItem = {
id,
tone: t.tone ?? "neutral",
title: t.title,
description: t.description,
};
setItems((prev) => [...prev, item]);
setTimeout(() => {
setItems((prev) => prev.filter((i) => i.id !== id));
}, 4500);
},
[],
);
export function Toaster({ position = "bottom-right" }: { position?: string }) {
const [items, setItems] = useState<Toast[]>([]);
useEffect(() => {
// expose a no-op provider only; actual provider wraps below
listeners.add(setItems);
setItems(store);
return () => {
listeners.delete(setItems);
};
}, []);
const posClass =
const pos =
position === "bottom-right"
? "bottom-4 right-4"
: position === "bottom-left"
? "bottom-4 left-4"
: position === "top-right"
? "top-4 right-4"
: "top-4 left-4";
: position === "top-right"
? "top-4 right-4"
: "bottom-4 left-1/2 -translate-x-1/2";
return (
<ToastContext.Provider value={{ toast }}>
<div
className={cn(
"fixed z-[60] flex w-[min(92vw,360px)] flex-col gap-2",
posClass,
)}
>
<AnimatePresence>
{items.map((it) => (
<motion.div
key={it.id}
layout
initial={
reduce ? { opacity: 0 } : { opacity: 0, x: 40, scale: 0.96 }
}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={
reduce ? { opacity: 0 } : { opacity: 0, x: 40, scale: 0.96 }
}
transition={{ type: "spring", stiffness: 360, damping: 30 }}
className="surface-2 relative flex gap-3 overflow-hidden p-3 pr-9 shadow-xl"
<div className={cn("pointer-events-none fixed z-[100] flex w-[min(92vw,360px)] flex-col gap-2", pos)}>
{items.map((t) => {
const Icon = icons[t.tone];
return (
<div
key={t.id}
className="glass pointer-events-auto flex items-start gap-3 p-3.5"
style={{ animation: "fade-up 0.18s ease" }}
>
<Icon
className={cn(
"mt-0.5 size-4 shrink-0",
t.tone === "signal" && "text-signal",
t.tone === "vermilion" && "text-vermilion",
t.tone === "neutral" && "text-ink-soft",
)}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-ink">{t.title}</div>
{t.description && (
<div className="mt-0.5 text-xs text-ink-soft">{t.description}</div>
)}
</div>
<button
type="button"
onClick={() => {
store = store.filter((x) => x.id !== t.id);
emit();
}}
className="text-ink-faint hover:text-ink"
>
<span
className={cn("w-1 shrink-0 rounded-full", toneBar[it.tone])}
/>
<div className="min-w-0 flex-1">
{it.title && (
<div className="text-sm font-semibold text-[var(--color-ink)]">
{it.title}
</div>
)}
{it.description && (
<div className="text-xs text-[var(--color-ink-soft)]">
{it.description}
</div>
)}
</div>
<button
type="button"
onClick={() =>
setItems((prev) => prev.filter((i) => i.id !== it.id))
}
className="absolute right-2 top-2 text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
aria-label="Dismiss"
>
<X className="size-3.5" />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
</ToastContext.Provider>
<X className="size-3.5" />
</button>
</div>
);
})}
</div>
);
}
@@ -1,31 +1,20 @@
"use client";
import { type ReactNode, useState } from "react";
import { useState } from "react";
import { cn } from "@/lib/utils";
export interface TooltipProps {
content: ReactNode;
children: ReactNode;
side?: "top" | "bottom" | "left" | "right";
className?: string;
}
const sidePos: Record<NonNullable<TooltipProps["side"]>, string> = {
top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
left: "right-full top-1/2 -translate-y-1/2 mr-2",
right: "left-full top-1/2 -translate-y-1/2 ml-2",
};
/** Lightweight hover/focus tooltip. */
export function Tooltip({
content,
label,
children,
side = "top",
className,
}: TooltipProps) {
}: {
label: string;
children: React.ReactNode;
side?: "top" | "bottom";
}) {
const [show, setShow] = useState(false);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: tooltip wrapper — reveals on hover AND focus (keyboard-accessible via focus handlers above)
<span
className="relative inline-flex"
onMouseEnter={() => setShow(true)}
@@ -34,18 +23,18 @@ export function Tooltip({
onBlur={() => setShow(false)}
>
{children}
<span
role="tooltip"
className={cn(
"pointer-events-none absolute z-50 whitespace-nowrap rounded-[var(--radius-r-control)] px-2.5 py-1 text-xs font-medium",
"bg-[var(--color-ink)] text-[var(--color-canvas)] opacity-0 transition-opacity duration-150",
sidePos[side],
show && "opacity-100",
className,
)}
>
{content}
</span>
{show && (
<span
role="tooltip"
className={cn(
"glass pointer-events-none absolute left-1/2 z-50 -translate-x-1/2 whitespace-nowrap px-2.5 py-1 text-xs text-ink",
side === "top" ? "bottom-[calc(100%+6px)]" : "top-[calc(100%+6px)]",
)}
style={{ animation: "fade-up 0.12s ease" }}
>
{label}
</span>
)}
</span>
);
}
@@ -1,78 +0,0 @@
"use client";
/**
* RecordingPlayer — canonical single-recording row component.
*/
import { Delete, Download, Play } from "lucide-react";
import { Waveform } from "@/components/charts/waveform";
import { Avatar } from "@/components/primitives/avatar";
import { Badge } from "@/components/primitives/badge";
import { Button } from "@/components/primitives/button";
import type { VoiceRecording } from "@/lib/types";
export interface RecordingPlayerProps {
recording: VoiceRecording;
onSelect?: (rec: VoiceRecording) => void;
onDelete?: (rec: VoiceRecording) => void;
deleting?: boolean;
}
export function RecordingPlayer({
recording,
onSelect,
onDelete,
deleting,
}: RecordingPlayerProps) {
return (
<div className="flex items-center gap-3 rounded-[var(--radius-r)] bg-[var(--color-surface)] p-3">
<Waveform
seed={recording.id}
bars={16}
height={36}
className="w-20 shrink-0"
/>
<Avatar name={recording.username} src={recording.avatar_url} size={32} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium">
{recording.username ?? "unknown"}
</span>
<Badge tone="neutral">
.{recording.filename.split(".").pop() ?? "mp3"}
</Badge>
</div>
<div className="mono text-xs text-[var(--color-ink-soft)]">
{(recording.size_bytes / 1024).toFixed(1)} KB
</div>
</div>
<div className="flex items-center gap-1">
{recording.download_url && onSelect && (
<Button size="sm" variant="ghost" onClick={() => onSelect(recording)}>
<Play className="size-4" />
</Button>
)}
{recording.download_url && (
<a
href={recording.download_url}
download={recording.filename}
className="flex size-8 items-center justify-center rounded-[var(--radius-r-control)] text-[var(--color-ink-soft)] hover:bg-[var(--color-surface-2)]"
aria-label="Download"
>
<Download className="size-4" />
</a>
)}
{onDelete && (
<Button
size="sm"
variant="danger"
disabled={deleting}
onClick={() => onDelete(recording)}
aria-label="Delete"
>
<Delete className="size-4" />
</Button>
)}
</div>
</div>
);
}
@@ -1,30 +0,0 @@
import type { LucideIcon } from "lucide-react";
import { Inbox } from "lucide-react";
import { cn } from "@/lib/utils";
interface EmptyStateProps {
icon?: LucideIcon;
title?: string;
description?: string;
className?: string;
}
export function EmptyState({
icon: Icon = Inbox,
title = "No data yet",
description = "Nothing to display here yet.",
className,
}: EmptyStateProps) {
return (
<div
className={cn(
"surface flex flex-col items-center gap-2 py-12 text-center",
className,
)}
>
<Icon className="size-8 text-[var(--color-ink-soft)]" />
<p className="text-sm font-medium text-[var(--color-ink)]">{title}</p>
<p className="text-xs text-[var(--color-ink-soft)]">{description}</p>
</div>
);
}
@@ -1,43 +0,0 @@
import { AlertCircle, RefreshCw } from "lucide-react";
import { Component, type ReactNode } from "react";
import { cn } from "@/lib/utils";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return (
this.props.fallback || (
<div className={cn("surface flex flex-col items-center gap-2 py-8")}>
<AlertCircle className="size-6 text-[var(--color-vermilion)]" />
<p className="text-sm text-[var(--color-ink)]">
{this.state.error?.message || "Something went wrong"}
</p>
<button
type="button"
onClick={() => this.setState({ hasError: false })}
className="flex items-center gap-1 text-xs text-[var(--color-signal)] hover:opacity-80 transition-colors"
>
<RefreshCw className="size-3" /> Try again
</button>
</div>
)
);
}
return this.props.children;
}
}
@@ -1,24 +0,0 @@
import { AlertCircle, RefreshCw } from "lucide-react";
import { Button } from "@/components/primitives/button";
interface ErrorStateProps {
message: string;
onRetry?: () => void;
}
export function ErrorState({ message, onRetry }: ErrorStateProps) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<AlertCircle className="size-10 text-[var(--color-vermilion)] mb-3" />
<p className="text-sm text-[var(--color-ink-soft)] mb-4 max-w-sm">
{message}
</p>
{onRetry && (
<Button variant="outline" onClick={onRetry}>
<RefreshCw className="size-4 mr-2" />
Retry
</Button>
)}
</div>
);
}
@@ -0,0 +1,70 @@
"use client";
import { useEffect, useState } from "react";
import { useGuilds, useTextChannels, useVoiceChannels } from "@/hooks";
import { Select, type SelectOption } from "@/components/primitives";
import type { Guild } from "@/lib/types";
export function GuildChannelPicker({
mode,
guildsInitial,
guildId,
channelId,
onChange,
}: {
mode: "voice" | "text";
guildsInitial?: Guild[];
guildId: string | null;
channelId: string | null;
onChange: (guildId: string, channelId: string | null) => void;
}) {
const { data: guilds } = useGuilds(guildsInitial);
// Call both hooks unconditionally (rules of hooks); select by mode.
const voiceChannels = useVoiceChannels(guildId ?? "");
const textChannels = useTextChannels(guildId ?? "");
const channels = mode === "voice" ? voiceChannels.data : textChannels.data;
const [g, setG] = useState(guildId);
const [c, setC] = useState(channelId);
useEffect(() => setG(guildId), [guildId]);
useEffect(() => setC(channelId), [channelId]);
const guildOpts: SelectOption[] = (guilds ?? []).map((x) => ({
value: x.id,
label: x.name,
}));
const channelOpts: SelectOption[] = (channels ?? []).map((x) => ({
value: x.id,
label: x.name,
hint: x.type,
}));
return (
<div className="flex flex-wrap items-center gap-2">
<Select
value={g}
onChange={(v) => {
setG(v);
setC(null);
onChange(v, null);
}}
options={guildOpts}
placeholder="Guild"
size="sm"
className="w-44"
/>
<Select
value={c}
onChange={(v) => {
setC(v);
if (g) onChange(g, v);
}}
options={channelOpts}
placeholder={mode === "voice" ? "Voice channel" : "Text channel"}
size="sm"
className="w-52"
/>
</div>
);
}
@@ -1,97 +0,0 @@
"use client";
import { AlertCircle, RefreshCw } from "lucide-react";
import { useEffect, useRef } from "react";
import { Badge } from "@/components/primitives/badge";
import { Button } from "@/components/primitives/button";
import { Select } from "@/components/primitives/select";
import { Skeleton } from "@/components/primitives/skeleton";
import { useConfig, useGuilds } from "@/hooks";
export interface GuildSelectorProps {
/** Currently selected guild ID */
value: string;
/** Called when user selects a different guild */
onChange: (guildId: string) => void;
/** If true, the bar is hidden when there's only one guild */
autoHide?: boolean;
}
export function GuildSelector({
value,
onChange,
autoHide = true,
}: GuildSelectorProps) {
const { data: guilds = [], isLoading, error, mutate: refetch } = useGuilds();
const { data: config } = useConfig();
const initDone = useRef(false);
useEffect(() => {
if (value || guilds.length === 0 || initDone.current) return;
initDone.current = true;
const preferred = config?.monitorGuildId ?? guilds[0].id;
if (preferred) onChange(preferred);
}, [value, guilds, config, onChange]);
if (autoHide && guilds.length <= 1 && !isLoading && !error) return null;
if (isLoading) {
return (
<div className="flex items-center gap-3 rounded-[var(--radius-r)] bg-[var(--color-surface)] p-3">
<Skeleton className="h-8 w-36" />
<Skeleton rounded className="h-8 w-8" />
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-between rounded-[var(--radius-r)] bg-[var(--color-vermilion)]/10 p-3">
<div className="flex items-center gap-2">
<AlertCircle className="size-4 text-[var(--color-vermilion)] shrink-0" />
<p className="text-sm text-[var(--color-ink-soft)]">
Could not load guilds: {error?.message ?? "Failed to load"}
</p>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="size-3 mr-1" />
Retry
</Button>
</div>
);
}
if (guilds.length === 0) {
return (
<div className="rounded-[var(--radius-r)] bg-[var(--color-amber)]/10 p-3">
<div className="flex items-center gap-2">
<AlertCircle className="size-4 text-[var(--color-amber)] shrink-0" />
<p className="text-sm text-[var(--color-ink-soft)]">
No guilds available. Make sure the Discord gateway is connected.
</p>
</div>
</div>
);
}
return (
<div className="flex items-center gap-3 rounded-[var(--radius-r)] bg-[var(--color-surface)] p-3">
<Badge tone="neutral" className="shrink-0 text-xs font-normal">
Guild
</Badge>
<Select
value={value}
onChange={(e) => e.target.value && onChange(e.target.value)}
className="h-10 w-full max-w-sm"
>
{guilds.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</Select>
</div>
);
}
@@ -1,4 +1,3 @@
export { EmptyState } from "./empty-state";
export { ErrorBoundary } from "./error-boundary";
export { ErrorState } from "./error-state";
export { LoadingSkeleton } from "./loading-skeleton";
export { SectionHeader, MetricTile } from "./section";
export { EmptyState, ErrorState, LoadingState } from "./states";
export { GuildChannelPicker } from "./guild-picker";
@@ -1,43 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
interface LoadingSkeletonProps {
count?: number;
height?: string;
width?: string;
columns?: number;
className?: string;
}
export function LoadingSkeleton({
count = 4,
height = "h-24",
width,
columns,
className,
}: LoadingSkeletonProps) {
const items = Array.from({ length: count }, (_, i) => (
<div
key={i}
className={cn(
"surface-2 overflow-hidden",
height,
width,
className,
)}
>
<div className="w-full h-full animate-shimmer" />
</div>
));
if (columns) {
return (
<div className={`grid grid-cols-1 md:grid-cols-${columns} gap-3`}>
{items}
</div>
);
}
return <div className="space-y-2">{items}</div>;
}
@@ -0,0 +1,78 @@
import { cn } from "@/lib/utils";
export function SectionHeader({
eyebrow,
title,
action,
className,
}: {
eyebrow?: string;
title: React.ReactNode;
action?: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("mb-3 flex items-end justify-between gap-3", className)}>
<div className="min-w-0">
{eyebrow && <div className="eyebrow mb-1">{eyebrow}</div>}
<h2 className="display text-xl text-ink">{title}</h2>
</div>
{action}
</div>
);
}
export function MetricTile({
label,
value,
hint,
tone = "neutral",
spark,
icon,
className,
}: {
label: string;
value: React.ReactNode;
hint?: React.ReactNode;
tone?: "neutral" | "signal" | "amber" | "vermilion";
spark?: number[];
icon?: React.ReactNode;
className?: string;
}) {
const toneColor =
tone === "vermilion"
? "var(--color-vermilion)"
: tone === "amber"
? "var(--color-amber)"
: tone === "signal"
? "var(--color-signal)"
: "var(--color-ink)";
return (
<div className={cn("glass p-4", className)}>
<div className="flex items-center justify-between">
<div className="eyebrow">{label}</div>
{icon && <span className="text-ink-faint">{icon}</span>}
</div>
<div
className="display mt-1 text-[1.9rem] leading-none"
style={{ color: tone === "neutral" ? undefined : toneColor }}
>
{value}
</div>
{hint && <div className="mono mt-1 text-[0.68rem] text-ink-faint">{hint}</div>}
{spark && spark.length > 1 && (
<div className="mt-2">
<div
className="h-1 w-full overflow-hidden rounded-full"
style={{ background: "oklch(1 0 0 / 0.08)" }}
>
<div
className="h-full rounded-full"
style={{ width: `${Math.min(100, (spark[spark.length - 1] / (Math.max(...spark) || 1)) * 100)}%`, background: toneColor, opacity: 0.7 }}
/>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,62 @@
import { AlertTriangle, Inbox, Loader2 } from "lucide-react";
import { Spinner } from "@/components/primitives";
import { cn } from "@/lib/utils";
export function EmptyState({
title = "Nothing here yet",
description,
icon,
className,
}: {
title?: string;
description?: string;
icon?: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("flex flex-col items-center justify-center gap-2 py-12 text-center", className)}>
<div className="text-ink-faint">{icon ?? <Inbox className="size-7" />}</div>
<div className="text-sm font-medium text-ink-soft">{title}</div>
{description && <div className="max-w-xs text-xs text-ink-faint">{description}</div>}
</div>
);
}
export function ErrorState({
title = "Couldn't load",
error,
onRetry,
}: {
title?: string;
error?: unknown;
onRetry?: () => void;
}) {
const msg = error instanceof Error ? error.message : String(error ?? "");
return (
<div className="glass flex flex-col items-center gap-3 p-8 text-center">
<AlertTriangle className="size-7 text-vermilion" />
<div className="text-sm font-medium text-ink">{title}</div>
{msg && <div className="mono max-w-md break-words text-xs text-ink-faint">{msg}</div>}
{onRetry && (
<button
type="button"
onClick={onRetry}
className="mt-1 rounded-[10px] border border-hairline px-3 py-1.5 text-xs text-ink-soft hover:text-ink hover:border-signal/40"
>
Retry
</button>
)}
</div>
);
}
export function LoadingState({ label = "Syncing" }: { label?: string }) {
return (
<div className="flex items-center justify-center gap-2 py-12 text-ink-faint">
<Spinner />
<span className="mono text-xs uppercase tracking-wider">{label}</span>
</div>
);
}
export { Loader2 };
@@ -0,0 +1,21 @@
import { NavRail } from "./nav-rail";
import { TopBar } from "./topbar";
/**
* App chrome: slim nav rail + sticky top bar + scrollable content region.
* Sits above the fixed AmbientCanvas. Providers (Ambient + WS) are mounted in
* the route layout so every page shares one live link and signal context.
*/
export function AppFrame({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-dvh w-full overflow-hidden">
<NavRail />
<div className="flex min-w-0 flex-1 flex-col">
<TopBar />
<main className="min-h-0 flex-1 overflow-y-auto px-5 pb-8">
{children}
</main>
</div>
</div>
);
}
@@ -0,0 +1,4 @@
export { AppFrame } from "./ambient-app";
export { NavRail } from "./nav-rail";
export { TopBar } from "./topbar";
export { ConnectionStatus } from "./status-dot";
@@ -0,0 +1,50 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { navItems, isActivePath } from "@/lib/navigation";
import { cn } from "@/lib/utils";
import {
Tooltip,
} from "@/components/primitives/tooltip";
export function NavRail() {
const pathname = usePathname();
return (
<nav className="glass m-3 mr-0 flex w-[68px] flex-col items-center gap-1 rounded-[18px] py-4">
<Link
href="/dashboard"
className="mb-3 flex size-11 items-center justify-center rounded-[14px] bg-signal/15 text-signal glow-signal"
aria-label="GMW home"
>
<span className="display text-xl">G</span>
</Link>
<div className="flex flex-1 flex-col gap-1">
{navItems.map((item) => {
const active = isActivePath(pathname, item.matchPrefix);
const Icon = item.icon;
return (
<Tooltip key={item.href} label={item.label} side="bottom">
<Link
href={item.href}
aria-label={item.label}
aria-current={active ? "page" : undefined}
className={cn(
"group relative flex size-11 items-center justify-center rounded-[13px] transition-all",
active
? "bg-signal/15 text-signal"
: "text-ink-faint hover:bg-white/5 hover:text-ink-soft",
)}
>
{active && (
<span className="absolute -left-3 h-6 w-1 rounded-full bg-signal shadow-[0_0_12px_var(--color-signal-glow)]" />
)}
<Icon className="size-[18px]" strokeWidth={active ? 2.4 : 2} />
</Link>
</Tooltip>
);
})}
</div>
</nav>
);
}
@@ -0,0 +1,32 @@
"use client";
import { useWebSocket } from "@/lib/ws/context";
import { Tooltip } from "@/components/primitives/tooltip";
import { cn } from "@/lib/utils";
const MAP = {
connected: { color: "bg-signal", label: "Live link" },
connecting: { color: "bg-amber animate-breathe", label: "Connecting…" },
disconnected: { color: "bg-ink-faint", label: "Offline" },
error: { color: "bg-vermilion", label: "Link error" },
} as const;
export function ConnectionStatus({ compact = false }: { compact?: boolean }) {
const { status } = useWebSocket();
const s = MAP[status];
return (
<Tooltip label={s.label}>
<span className="inline-flex items-center gap-2">
<span className="relative flex size-2.5">
<span className={cn("absolute inline-flex h-full w-full rounded-full opacity-60 animate-pulse-ring", s.color)} />
<span className={cn("relative inline-flex size-2.5 rounded-full", s.color)} />
</span>
{!compact && (
<span className="mono text-[0.7rem] uppercase tracking-wider text-ink-soft">
{s.label}
</span>
)}
</span>
</Tooltip>
);
}
@@ -0,0 +1,74 @@
"use client";
import { usePathname } from "next/navigation";
import { useTheme } from "next-themes";
import { Moon, Sun } from "lucide-react";
import { navItems } from "@/lib/navigation";
import { useAmbient } from "@/components/ambient/ambient-context";
import { ConnectionStatus } from "./status-dot";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
function useActiveLabel() {
const pathname = usePathname();
const item = [...navItems]
.sort((a, b) => b.matchPrefix.length - a.matchPrefix.length)
.find((i) => pathname.startsWith(i.matchPrefix));
return item?.label ?? "Console";
}
export function TopBar() {
const label = useActiveLabel();
const { state } = useAmbient();
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const signalTone =
state.tone === "vermilion"
? "text-vermilion"
: state.tone === "amber"
? "text-amber"
: "text-signal";
return (
<header className="sticky top-0 z-40 flex items-center gap-4 px-5 py-3.5">
<div className="flex min-w-0 items-baseline gap-3">
<span className="eyebrow">GMW</span>
<h1 className="display truncate text-[1.5rem] text-ink">{label}</h1>
</div>
<div className="ml-auto flex items-center gap-3">
<span className={cn("pill", signalTone)}>
<span
className={cn(
"size-1.5 rounded-full bg-current animate-breathe",
)}
/>
{state.label ?? "nominal"}
</span>
<ConnectionStatus />
<button
type="button"
aria-label="Open command palette"
onClick={() => window.dispatchEvent(new Event("command-palette:open"))}
className="hidden items-center gap-1.5 rounded-[11px] border border-hairline bg-white/5 px-2.5 py-1.5 text-xs text-ink-soft transition-colors hover:text-ink hover:border-signal/40 sm:flex"
>
<span className="mono text-[0.65rem]">K</span>
</button>
<button
type="button"
aria-label="Toggle theme"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
className="flex size-9 items-center justify-center rounded-[11px] border border-hairline bg-white/5 text-ink-soft transition-colors hover:text-ink hover:border-signal/40"
>
{mounted && theme === "light" ? (
<Moon className="size-4" />
) : (
<Sun className="size-4" />
)}
</button>
</div>
</header>
);
}
@@ -1,49 +0,0 @@
"use client";
import dynamic from "next/dynamic";
import type { OrbFieldProps } from "./orb-field";
import type { SignalFieldProps } from "./signal-field";
import { StaticFallback } from "./static-fallback";
import { WebGLGuard } from "./webgl-guard";
const SignalFieldImpl = dynamic(
() => import("./signal-field").then((m) => m.SignalField),
{
ssr: false,
loading: () => null,
},
);
const OrbFieldImpl = dynamic(
() => import("./orb-field").then((m) => m.OrbField),
{
ssr: false,
loading: () => null,
},
);
export function SignalField(props: SignalFieldProps) {
return (
<WebGLGuard
fallback={<StaticFallback variant="signal" className={props.className} />}
>
<SignalFieldImpl {...props} />
</WebGLGuard>
);
}
export function OrbField(props: OrbFieldProps) {
return (
<WebGLGuard
fallback={
<StaticFallback
variant="orb"
count={props.speakers.length}
className={props.className}
/>
}
>
<OrbFieldImpl {...props} />
</WebGLGuard>
);
}
@@ -1,136 +0,0 @@
"use client";
import { useRef } from "react";
import * as THREE from "three";
import { useThreeScene } from "./use-three-scene";
export interface OrbSpeaker {
id: string;
name: string;
speaking: boolean;
severity?: "none" | "low" | "medium" | "high" | "critical";
}
export interface OrbFieldProps {
speakers: OrbSpeaker[];
className?: string;
}
const severityColor: Record<string, string> = {
none: "oklch(0.82 0.18 125)",
low: "oklch(0.80 0.15 70)",
medium: "oklch(0.78 0.16 70)",
high: "oklch(0.70 0.2 35)",
critical: "oklch(0.66 0.22 25)",
};
/**
* Voice page hero. Each speaker is a glowing orb; when speaking it rises and
* its ring radius expands. Warm palette only.
*/
export function OrbField({ speakers, className }: OrbFieldProps) {
const ref = useRef<HTMLDivElement>(null);
const speakersRef = useRef(speakers);
speakersRef.current = speakers;
useThreeScene(ref, {
setup: (ctx) => {
const group = new THREE.Group();
ctx.scene.add(group);
const orbMeshes: Record<string, THREE.Mesh> = {};
const ringMeshes: Record<string, THREE.Mesh> = {};
const layout = () => {
const list = speakersRef.current;
const n = Math.max(list.length, 1);
list.forEach((sp, i) => {
const angle = (i / n) * Math.PI * 2;
const radius = n === 1 ? 0 : 2.6;
const x = Math.cos(angle) * radius;
const z = Math.sin(angle) * radius;
if (!orbMeshes[sp.id]) {
const geo = new THREE.SphereGeometry(0.5, 32, 32);
const mat = new THREE.MeshStandardMaterial({
color: new THREE.Color(severityColor[sp.severity ?? "none"]),
emissive: new THREE.Color(severityColor[sp.severity ?? "none"]),
emissiveIntensity: 0.6,
roughness: 0.4,
metalness: 0,
});
const orb = new THREE.Mesh(geo, mat);
orb.position.set(x, 0, z);
group.add(orb);
orbMeshes[sp.id] = orb;
const ringGeo = new THREE.TorusGeometry(0.75, 0.03, 16, 64);
const ringMat = new THREE.MeshBasicMaterial({
color: new THREE.Color(severityColor[sp.severity ?? "none"]),
transparent: true,
opacity: 0.5,
});
const ring = new THREE.Mesh(ringGeo, ringMat);
ring.rotation.x = Math.PI / 2;
ring.position.set(x, 0, z);
group.add(ring);
ringMeshes[sp.id] = ring;
} else {
orbMeshes[sp.id].position.x = x;
orbMeshes[sp.id].position.z = z;
ringMeshes[sp.id].position.x = x;
ringMeshes[sp.id].position.z = z;
}
});
// remove orbs no longer present
for (const id of Object.keys(orbMeshes)) {
if (!list.find((s) => s.id === id)) {
group.remove(orbMeshes[id]);
(orbMeshes[id].geometry as THREE.BufferGeometry).dispose();
group.remove(ringMeshes[id]);
(ringMeshes[id].geometry as THREE.BufferGeometry).dispose();
delete orbMeshes[id];
delete ringMeshes[id];
}
}
};
layout();
const light = new THREE.PointLight(0xffffff, 1.2, 50);
light.position.set(0, 4, 6);
ctx.scene.add(light);
const amb = new THREE.AmbientLight(0xffffff, 0.4);
ctx.scene.add(amb);
(ctx as any)._layout = layout;
(ctx as any)._orbs = orbMeshes;
(ctx as any)._rings = ringMeshes;
return () => {};
},
onFrame: (ctx, t) => {
const layout = (ctx as any)._layout as () => void;
const orbs = (ctx as any)._orbs as Record<string, THREE.Mesh>;
const rings = (ctx as any)._rings as Record<string, THREE.Mesh>;
// relayout in case speaker set changed
layout();
for (const sp of speakersRef.current) {
const orb = orbs[sp.id];
const ring = rings[sp.id];
if (!orb || !ring) continue;
const targetY = sp.speaking
? 0.6 + Math.sin(t * 4 + (sp.id.charCodeAt(0) || 1)) * 0.15
: 0;
orb.position.y += (targetY - orb.position.y) * 0.1;
const ringScale = sp.speaking ? 1.25 + Math.sin(t * 5) * 0.1 : 1;
ring.scale.setScalar(ringScale);
(ring.material as THREE.MeshBasicMaterial).opacity = sp.speaking
? 0.7
: 0.3;
}
ctx.scene.rotation.y = t * 0.08;
},
});
return <div ref={ref} className={className} aria-hidden />;
}
@@ -1,91 +0,0 @@
"use client";
import { useRef } from "react";
import * as THREE from "three";
import { useThreeScene } from "./use-three-scene";
export interface SignalFieldProps {
/** 0..1 — scales particle pulse speed + opacity */
activity?: number;
className?: string;
}
/**
* Dashboard hero. A particle field whose idle rotation + breathing pulse
* reflects live activity. Warm signal-lime palette, additive glow, no harsh
* white. Pointer parallax via camera lerp.
*/
export function SignalField({ activity = 0.4, className }: SignalFieldProps) {
const ref = useRef<HTMLDivElement>(null);
const activityRef = useRef(activity);
activityRef.current = activity;
useThreeScene(ref, {
setup: (ctx) => {
const w = ctx.width;
const h = ctx.height;
const area = w * h;
const count = Math.min(900, Math.max(220, Math.floor(area / 2000)));
const positions = new Float32Array(count * 3);
const phases = new Float32Array(count);
const radius = 4.2;
for (let i = 0; i < count; i++) {
const r = radius * (0.25 + Math.random() * 0.75);
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
positions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta) * 0.6;
positions[i * 3 + 2] = r * Math.cos(phi);
phases[i] = Math.random() * Math.PI * 2;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({
color: new THREE.Color("oklch(0.82 0.18 125)"),
size: 0.045,
transparent: true,
opacity: 0.85,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const points = new THREE.Points(geo, mat);
ctx.scene.add(points);
const key = { x: 0, y: 0 };
const onMove = (e: PointerEvent) => {
const rect = ctx.container.getBoundingClientRect();
key.x = ((e.clientX - rect.left) / rect.width - 0.5) * 2;
key.y = ((e.clientY - rect.top) / rect.height - 0.5) * 2;
};
ctx.container.addEventListener("pointermove", onMove);
(ctx as any)._key = key;
(ctx as any)._points = points;
(ctx as any)._phases = phases;
return () => {
ctx.container.removeEventListener("pointermove", onMove);
};
},
onFrame: (ctx, t) => {
const points = (ctx as any)._points as THREE.Points;
const key = (ctx as any)._key as { x: number; y: number };
const phases = (ctx as any)._phases as Float32Array;
const act = activityRef.current;
const pulse = 1 + Math.sin(t * (1.2 + act * 2.2)) * 0.08 * (0.5 + act);
points.scale.setScalar(pulse);
points.rotation.y = t * (0.05 + act * 0.12);
points.rotation.x = Math.sin(t * 0.2) * 0.1;
// parallax
ctx.camera.position.x += (key.x * 1.4 - ctx.camera.position.x) * 0.04;
ctx.camera.position.y += (-key.y * 1.0 - ctx.camera.position.y) * 0.04;
ctx.camera.lookAt(0, 0, 0);
},
});
return <div ref={ref} className={className} aria-hidden />;
}
@@ -1,96 +0,0 @@
"use client";
export interface StaticFallbackProps {
variant?: "signal" | "orb";
count?: number;
className?: string;
}
/**
* 2D SVG silhouette used when WebGL is unavailable — so the hero still reads
* as a living visual, never blank. `variant="signal"` = drifting dot grid;
* `variant="orb"` = speaker orbs.
*/
export function StaticFallback({
variant = "signal",
count = 60,
className,
}: StaticFallbackProps) {
if (variant === "orb") {
const orbs = Array.from({ length: count > 12 ? 12 : count }, (_, i) => {
const angle = (i / 12) * Math.PI * 2;
const r = 60;
return {
x: 100 + Math.cos(angle) * r,
y: 100 + Math.sin(angle) * r,
d: i * 0.3,
};
});
return (
<svg
viewBox="0 0 200 200"
className={className}
aria-hidden
preserveAspectRatio="xMidYMid slice"
>
<title>Speaker orbs</title>
<rect width="200" height="200" fill="oklch(0.18 0.02 70)" />
{orbs.map((o, i) => (
<g
key={i}
style={{ animation: `fade-up 1.2s ${o.d}s infinite alternate` }}
>
<circle
cx={o.x}
cy={o.y}
r={12}
fill="oklch(0.82 0.18 125 / 0.5)"
/>
<circle
cx={o.x}
cy={o.y}
r={20}
fill="none"
stroke="oklch(0.82 0.18 125 / 0.3)"
strokeWidth={1}
/>
</g>
))}
</svg>
);
}
const dots = Array.from({ length: count }, (_, i) => ({
x: (i * 53) % 200,
y: (i * 89) % 200,
r: 1.5 + ((i * 7) % 3),
}));
return (
<svg
viewBox="0 0 200 200"
className={className}
aria-hidden
preserveAspectRatio="xMidYMid slice"
>
<title>Signal dots</title>
<rect width="200" height="200" fill="oklch(0.18 0.02 70)" />
{dots.map((d, i) => (
<circle
key={i}
cx={d.x}
cy={d.y}
r={d.r}
fill="oklch(0.82 0.18 125 / 0.4)"
>
<animate
attributeName="opacity"
values="0.2;0.8;0.2"
dur="3s"
begin={`${i * 0.05}s`}
repeatCount="indefinite"
/>
</circle>
))}
</svg>
);
}
@@ -1,135 +0,0 @@
import { useEffect, useRef } from "react";
import * as THREE from "three";
export interface ThreeSceneOptions {
/** Called once after renderer/scene/camera are created. */
setup: (ctx: ThreeSceneCtx) => (() => void) | void;
/** Optional per-frame callback. */
onFrame?: (ctx: ThreeSceneCtx, t: number) => void;
background?: string;
}
export interface ThreeSceneCtx {
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
container: HTMLDivElement;
width: number;
height: number;
}
/**
* Shared Three.js lifecycle hook:
* - capped DPR [1, 1.75], high-performance hint
* - RAF loop paused when tab hidden
* - resize observer
* - full geometry/material/renderer dispose on unmount
*
* `setup` may return a cleanup fn (e.g. to remove its own listeners).
*/
export function useThreeScene(
containerRef: React.RefObject<HTMLDivElement | null>,
opts: ThreeSceneOptions,
) {
const optsRef = useRef(opts);
optsRef.current = opts;
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let disposed = false;
let cleanup: (() => void) | void;
let raf = 0;
let ctx: ThreeSceneCtx;
const init = () => {
const width = container.clientWidth || 1;
const height = container.clientHeight || 1;
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: "high-performance",
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75));
renderer.setSize(width, height);
container.appendChild(renderer.domElement);
renderer.domElement.style.display = "block";
renderer.domElement.style.width = "100%";
renderer.domElement.style.height = "100%";
const scene = new THREE.Scene();
if (optsRef.current.background) {
scene.background = new THREE.Color(optsRef.current.background);
}
scene.fog = new THREE.FogExp2(0x000000, 0.06);
const camera = new THREE.PerspectiveCamera(55, width / height, 0.1, 100);
camera.position.set(0, 0, 6);
ctx = { renderer, scene, camera, container, width, height };
const c = optsRef.current.setup(ctx);
if (typeof c === "function") cleanup = c;
const start = performance.now();
const loop = () => {
if (disposed || document.hidden) {
raf = requestAnimationFrame(loop);
return;
}
const t = (performance.now() - start) / 1000;
optsRef.current.onFrame?.(ctx, t);
renderer.render(scene, camera);
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
const ro = new ResizeObserver(() => {
const w = container.clientWidth || 1;
const h = container.clientHeight || 1;
ctx.width = w;
ctx.height = h;
renderer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
});
ro.observe(container);
const onVis = () => {
/* loop checks document.hidden */
};
document.addEventListener("visibilitychange", onVis);
(ctx as any)._ro = ro;
(ctx as any)._onVis = onVis;
};
init();
return () => {
disposed = true;
cancelAnimationFrame(raf);
if (typeof cleanup === "function") cleanup();
const c = ctx as any;
if (c?._ro) c._ro.disconnect();
if (c?._onVis) document.removeEventListener("visibilitychange", c._onVis);
if (ctx) {
ctx.scene.traverse((obj) => {
const mesh = obj as THREE.Mesh;
if (mesh.geometry) mesh.geometry.dispose?.();
const mat = mesh.material as
| THREE.Material
| THREE.Material[]
| undefined;
if (Array.isArray(mat)) mat.forEach((m) => m.dispose());
else mat?.dispose?.();
});
ctx.renderer.dispose();
if (ctx.renderer.domElement.parentNode === container) {
container.removeChild(ctx.renderer.domElement);
}
}
};
}, [containerRef]);
}
@@ -1,35 +0,0 @@
"use client";
import { type ReactNode, useEffect, useRef } from "react";
export interface WebGLGuardProps {
children: ReactNode;
fallback: ReactNode;
}
/**
* Detects WebGL support. If unavailable (old device / privacy browser /
* headless without GPU), renders `fallback` instead of the 3D scene so the
* page is never blank.
*/
export function WebGLGuard({ children, fallback }: WebGLGuardProps) {
const supported = useRef<boolean | null>(null);
if (supported.current === null) {
if (typeof window === "undefined") {
supported.current = false;
} else {
try {
const canvas = document.createElement("canvas");
supported.current = !!(
window.WebGLRenderingContext &&
(canvas.getContext("webgl2") || canvas.getContext("webgl"))
);
} catch {
supported.current = false;
}
}
}
return <>{supported.current ? children : fallback}</>;
}
@@ -1,62 +0,0 @@
"use client";
import { useMemo } from "react";
import { Avatar } from "@/components/primitives/avatar";
import { Badge } from "@/components/primitives/badge";
import type { ActiveSpeaker } from "@/lib/types";
import { cn } from "@/lib/utils";
export interface ActiveSpeakersPanelProps {
speakers: ActiveSpeaker[];
}
export function ActiveSpeakersPanel({ speakers }: ActiveSpeakersPanelProps) {
const sorted = useMemo(
() => [...speakers].sort((a, b) => Number(b.speaking) - Number(a.speaking)),
[speakers],
);
if (sorted.length === 0) {
return (
<div className="surface p-5 text-center text-sm text-[var(--color-ink-soft)]">
No speakers in range.
</div>
);
}
return (
<div className="surface divide-y divide-[var(--color-hairline)] overflow-hidden">
<div className="px-4 py-2.5 text-xs font-medium uppercase tracking-wide text-[var(--color-ink-soft)]">
Active speakers ({sorted.length})
</div>
<div className="flex flex-col">
{sorted.map((s) => (
<div
key={s.userId}
className={cn(
"flex items-center gap-3 px-4 py-2.5 transition-colors",
s.speaking && "bg-[var(--color-signal)]/5",
)}
>
<div className="relative">
<Avatar src={s.avatar} name={s.username} size={34} />
{s.speaking && (
<span className="absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full bg-[var(--color-signal)] ring-2 ring-[var(--color-canvas)]" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium">{s.username}</span>
{s.speaking ? (
<Badge tone="signal">speaking</Badge>
) : (
<Badge tone="neutral">idle</Badge>
)}
</div>
</div>
</div>
))}
</div>
</div>
);
}
@@ -1,79 +0,0 @@
"use client";
import { Mic, MicOff } from "lucide-react";
import type { ActiveSpeaker } from "@/lib/types";
import { cn } from "@/lib/utils";
interface ActivityTimelineProps {
data?: ActiveSpeaker[];
}
export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) {
const sorted = [...data].sort((a, b) =>
a.speaking === b.speaking
? String(a.username).localeCompare(b.username)
: a.speaking
? -1
: 1,
);
return (
<div className="surface p-5">
<div className="flex items-center gap-2 mb-3">
<span className="text-xs font-semibold tracking-wide uppercase text-[var(--color-ink-soft)]">
Voice Activity
</span>
<span className="text-[10px] text-[var(--color-ink-soft)] ml-auto">
{sorted.length} speaker{sorted.length !== 1 ? "s" : ""} · live
</span>
</div>
{sorted.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<MicOff className="size-8 text-[var(--color-ink-soft)] mb-2" />
<p className="text-xs text-[var(--color-ink-soft)]">
No speakers in the monitored voice channel.
</p>
<p className="mt-1 text-[10px] text-[var(--color-ink-soft)]">
Connect to a voice channel to see live activity here.
</p>
</div>
) : (
<div className="space-y-1.5">
{sorted.map((s) => (
<div
key={s.userId}
className="flex items-center gap-2 rounded-[var(--radius-r-panel)] bg-[var(--color-surface-2)] px-3 py-2"
>
{s.speaking ? (
<Mic className="size-3.5 text-[var(--color-signal)] shrink-0" />
) : (
<MicOff className="size-3.5 text-[var(--color-ink-soft)] shrink-0" />
)}
<span
className={cn(
"truncate text-sm",
s.speaking
? "text-[var(--color-ink)] font-medium"
: "text-[var(--color-ink-soft)]",
)}
>
{s.username}
</span>
<span
className={cn(
"ml-auto shrink-0 text-[9px] font-semibold uppercase tracking-widest",
s.speaking
? "text-[var(--color-signal)]"
: "text-[var(--color-ink-soft)]",
)}
>
{s.speaking ? "Speaking" : "Listening"}
</span>
</div>
))}
</div>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More