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