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
|
||||
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.
|
||||
}
|
||||
initialActivity={
|
||||
activity.status === "fulfilled" && activity.value
|
||||
? activity.value
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
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
|
||||
<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>
|
||||
|
||||
{/* 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)",
|
||||
}}
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
<span className="text-[var(--color-ink-soft)] tabular-nums">
|
||||
{new Date(e.ts).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className="truncate text-[var(--color-ink)]">
|
||||
{e.excerpt}
|
||||
</span>
|
||||
{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>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
{/* Command whisper — very bottom, minimal */}
|
||||
<div className="absolute inset-x-0 bottom-0">
|
||||
<DashCommandLine />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
};
|
||||
|
||||
if (error && !media) return <ErrorState error={error} />;
|
||||
if (!media && isLoading) return <LoadingState label="Reading deck" />;
|
||||
|
||||
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"
|
||||
<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"}`}
|
||||
>
|
||||
Screen
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleQueue}
|
||||
disabled={!queueUrl.trim() || queueMut.isPending}
|
||||
>
|
||||
<Play className="size-4 mr-1.5" />
|
||||
Queue
|
||||
</Button>
|
||||
<div className="flex size-28 items-center justify-center rounded-full bg-canvas/60">
|
||||
<ListMusic className="size-10 text-signal" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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"
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
<div className="mt-1 mono text-xs text-[var(--color-ink-soft)]">
|
||||
{current?.source ?? "idle"} · {duration ? formatMs(duration) : "—"}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Progress value={42} max={100} tone="signal" />
|
||||
<div className="mt-1 flex justify-between text-[10px] mono text-[var(--color-ink-soft)]">
|
||||
<span>0:00</span>
|
||||
<span>{duration ? formatMs(duration) : "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transport */}
|
||||
<StaggerGroup className="flex items-center gap-2">
|
||||
<StaggerItem>
|
||||
<Button size="sm" variant="ghost" onClick={() => skip.mutate()}>
|
||||
<SkipForward className="size-4" />
|
||||
<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>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="primary"
|
||||
onClick={() => loopMut.mutate(!loop)}
|
||||
>
|
||||
{playing ? <Pause className="size-5" /> : <Play className="size-5" />}
|
||||
<Button variant="outline" size="sm" onClick={() => skip.mutate()} disabled={skip.isPending}>
|
||||
<SkipForward className="size-4" /> Skip
|
||||
</Button>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<Button size="sm" variant="ghost" onClick={() => stop.mutate()}>
|
||||
<Square className="size-4" />
|
||||
<Button variant="outline" size="sm" onClick={() => stop.mutate()} disabled={stop.isPending}>
|
||||
<Square className="size-4" /> Stop
|
||||
</Button>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<Button
|
||||
variant={media?.loop ? "primary" : "outline"}
|
||||
size="sm"
|
||||
variant={loop ? "primary" : "ghost"}
|
||||
onClick={() => loopMut.mutate(!loop)}
|
||||
onClick={() => loop.mutate(!media?.loop)}
|
||||
aria-pressed={!!media?.loop}
|
||||
>
|
||||
<Repeat2 className="size-4" />
|
||||
<Repeat className="size-4" /> Loop
|
||||
</Button>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<Volume2 className="size-4 text-[var(--color-ink-soft)]" />
|
||||
</StaggerItem>
|
||||
</StaggerGroup>
|
||||
|
||||
{/* Queue */}
|
||||
{queue.length > 0 && (
|
||||
<div className="surface flex flex-col gap-1.5 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">Queue ({queue.length})</h3>
|
||||
<Badge tone="neutral">{loop ? "loop" : "queue"}</Badge>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{queue.map((item) => (
|
||||
<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"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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()}
|
||||
/>
|
||||
<span className="mono truncate">{item.title}</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
<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="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");
|
||||
<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);
|
||||
}}
|
||||
/>
|
||||
|
||||
{lightbox && (
|
||||
<Lightbox
|
||||
open={!!lightbox}
|
||||
onClose={() => setLightbox(null)}
|
||||
images={lightbox.images}
|
||||
initialIndex={lightbox.index}
|
||||
<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>
|
||||
}
|
||||
|
||||
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."
|
||||
/>
|
||||
{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="grid grid-cols-3 gap-2.5">
|
||||
{items.map((item) => {
|
||||
const url = extractFirstImage(item.metadata);
|
||||
return (
|
||||
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
|
||||
{list.map((m) => (
|
||||
<button
|
||||
key={item.id}
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(item.id)}
|
||||
className="overflow-hidden rounded-[var(--radius-r)] border border-[var(--color-hairline)]"
|
||||
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]"
|
||||
}`}
|
||||
>
|
||||
{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
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
<AiBadge status={m.ai_status} />
|
||||
</button>
|
||||
</StaggerItem>
|
||||
))}
|
||||
</StaggerGroup>
|
||||
</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 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 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 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 [];
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
let stats = undefined;
|
||||
let actions = undefined;
|
||||
try {
|
||||
[stats, actions] = await Promise.all([
|
||||
getModerationStats(),
|
||||
getModerationActions(100),
|
||||
]);
|
||||
return (
|
||||
<ModerationView
|
||||
initialStats={
|
||||
stats.status === "fulfilled" && stats.value ? stats.value : undefined
|
||||
} catch {
|
||||
/* client hooks surface errors */
|
||||
}
|
||||
initialActions={
|
||||
actions.status === "fulfilled" && actions.value
|
||||
? actions.value
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
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();
|
||||
|
||||
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" });
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
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"
|
||||
<GlassPanel>
|
||||
<SectionHeader
|
||||
eyebrow="voice captures"
|
||||
title="Recordings"
|
||||
action={<span className="mono text-xs text-ink-faint">{(items ?? []).length} clips</span>}
|
||||
/>
|
||||
<Avatar name={rec.username} src={rec.avatar_url} size={34} />
|
||||
{(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="flex items-center gap-1.5">
|
||||
<span className="font-medium">
|
||||
{rec.username ?? "unknown"}
|
||||
</span>
|
||||
<Badge
|
||||
tone={
|
||||
rec.upload_status === "uploaded"
|
||||
? "signal"
|
||||
: rec.upload_status === "failed"
|
||||
? "vermilion"
|
||||
: "amber"
|
||||
}
|
||||
>
|
||||
.{rec.filename.split(".").pop() ?? "mp3"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mono text-xs text-[var(--color-ink-soft)]">
|
||||
{(rec.size_bytes / 1024).toFixed(0)} KB ·{" "}
|
||||
{new Date(rec.created_at * 1000).toLocaleTimeString()}
|
||||
<div 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>
|
||||
<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" />
|
||||
<span className="mono text-[0.65rem] text-ink-faint">{formatBytes(r.size_bytes)}</span>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
</GlassCard>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
<PreviewDialog
|
||||
open={!!preview}
|
||||
onClose={onClosePreview}
|
||||
recording={preview}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewDialog({
|
||||
open,
|
||||
onClose,
|
||||
recording,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
recording: VoiceRecording | null;
|
||||
}) {
|
||||
if (!recording) return null;
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} className="p-6 max-w-xl">
|
||||
<div className="space-y-3">
|
||||
<div className="display text-lg text-[var(--color-signal)]">
|
||||
{recording.filename}
|
||||
</div>
|
||||
{/* 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);
|
||||
useEffect(() => {
|
||||
if (status?.connected) ambient.set("signal", 0.55, "voice live");
|
||||
else ambient.set("vermilion", 0.35, "voice idle");
|
||||
}, [status?.connected, ambient]);
|
||||
|
||||
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 handleMicToggle = async (on: boolean) => {
|
||||
if (on) {
|
||||
const onConnect = async () => {
|
||||
if (!guildId || !channelId) {
|
||||
toast({ title: "Pick a guild + channel", tone: "vermilion" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await mic.mutateAsync(true);
|
||||
setMicActive(true);
|
||||
} catch {
|
||||
setMicActive(false);
|
||||
await connect.mutateAsync({ guildId, channelId });
|
||||
toast({ title: "Connected to voice", tone: "signal" });
|
||||
} catch (e) {
|
||||
toast({ title: "Connect failed", description: String(e), tone: "vermilion" });
|
||||
}
|
||||
} else {
|
||||
setMicActive(false);
|
||||
};
|
||||
|
||||
const onMic = async (on: boolean) => {
|
||||
try {
|
||||
await mic.mutateAsync(false);
|
||||
} catch {
|
||||
// Stop already tore down — ignore remote error
|
||||
await mic.mutateAsync(on);
|
||||
setMicOn(on);
|
||||
} catch (e) {
|
||||
toast({ title: "Mic error", 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("");
|
||||
};
|
||||
|
||||
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}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!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" />
|
||||
/>
|
||||
{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."}
|
||||
/>
|
||||
) : (
|
||||
<Radio className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Connect
|
||||
</Button>
|
||||
<>
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{/* 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"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,69 +3,66 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/*
|
||||
* GMW — new design system (visual overhaul).
|
||||
* GMW — Ambient Ops Console design system.
|
||||
*
|
||||
* Replaces the old teal-cyan + purple + glassmorphism language with a warm,
|
||||
* signal-driven ops-console aesthetic. Hierarchy comes from scale/weight and
|
||||
* tonal surface blocks, NOT from borders/shadows. Three semantic signals:
|
||||
* lime = OK / live (--signal)
|
||||
* amber = warn (--amber)
|
||||
* vermilion = flag/danger (--vermilion)
|
||||
* A signal-driven, immersive ops aesthetic. Hierarchy comes from scale/weight
|
||||
* and tonal surface blocks, not borders. The whole app sits behind a live
|
||||
* WebGL haze (see components/ambient) tinted by a semantic signal:
|
||||
* lime = OK / live
|
||||
* amber = warn
|
||||
* vermilion = flag / danger
|
||||
*/
|
||||
|
||||
@theme {
|
||||
/* ── Surfaces ── */
|
||||
--color-canvas: oklch(0.96 0.012 80);
|
||||
--color-surface: oklch(0.92 0.014 80);
|
||||
--color-surface-2: oklch(0.88 0.016 80);
|
||||
/* ── Surfaces (dark-first; light overrides below) ── */
|
||||
--color-canvas: oklch(0.12 0.014 70);
|
||||
--color-canvas-2: oklch(0.16 0.02 70);
|
||||
--color-surface: oklch(0.2 0.022 70 / 0.55);
|
||||
--color-surface-2: oklch(0.26 0.024 70 / 0.45);
|
||||
|
||||
/* ── Ink ── */
|
||||
--color-ink: oklch(0.22 0.02 70);
|
||||
--color-ink-soft: oklch(0.46 0.02 70);
|
||||
--color-ink: oklch(0.95 0.008 75);
|
||||
--color-ink-soft: oklch(0.66 0.02 75);
|
||||
--color-ink-faint: oklch(0.5 0.02 75);
|
||||
|
||||
/* ── Structural ── */
|
||||
--color-hairline: oklch(0.22 0.02 70 / 0.1);
|
||||
--color-hairline: oklch(1 0 0 / 0.1);
|
||||
--hairline-w: 1px;
|
||||
|
||||
/* ── Semantic signals ── */
|
||||
--color-signal: oklch(0.78 0.17 125);
|
||||
--color-signal-ink: oklch(0.20 0.03 70);
|
||||
--color-signal-glow: oklch(0.78 0.17 125 / 0.35);
|
||||
--color-amber: oklch(0.80 0.15 70);
|
||||
--color-vermilion: oklch(0.62 0.21 25);
|
||||
--color-vermilion-soft: oklch(0.62 0.21 25 / 0.15);
|
||||
--color-signal: oklch(0.86 0.19 128);
|
||||
--color-signal-ink: oklch(0.18 0.03 70);
|
||||
--color-signal-glow: oklch(0.86 0.19 128 / 0.4);
|
||||
--color-amber: oklch(0.85 0.15 72);
|
||||
--color-vermilion: oklch(0.68 0.21 25);
|
||||
--color-vermilion-glow: oklch(0.68 0.21 25 / 0.4);
|
||||
|
||||
--color-ring: var(--color-signal);
|
||||
|
||||
/* ── Fonts ── */
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
--font-display: "Bricolage Grotesque", "Inter", sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||||
--font-display: "Bricolage Grotesque", "Inter", system-ui, sans-serif;
|
||||
|
||||
/* ── Radii ── */
|
||||
--radius-r: 14px;
|
||||
--radius-r: 16px;
|
||||
--radius-r-panel: 12px;
|
||||
--radius-r-control: 8px;
|
||||
--radius-r-control: 9px;
|
||||
--radius-r-pill: 9999px;
|
||||
}
|
||||
|
||||
/* ── Dark theme overrides ── */
|
||||
.dark {
|
||||
--color-canvas: oklch(0.13 0.015 70);
|
||||
--color-surface: oklch(0.18 0.02 70);
|
||||
--color-surface-2: oklch(0.23 0.022 70);
|
||||
/* ── Light theme overrides ── */
|
||||
.light {
|
||||
--color-canvas: oklch(0.96 0.012 80);
|
||||
--color-canvas-2: oklch(0.92 0.014 80);
|
||||
--color-surface: oklch(1 0 0 / 0.7);
|
||||
--color-surface-2: oklch(1 0 0 / 0.5);
|
||||
|
||||
--color-ink: oklch(0.93 0.01 75);
|
||||
--color-ink-soft: oklch(0.62 0.02 75);
|
||||
--color-ink: oklch(0.22 0.02 70);
|
||||
--color-ink-soft: oklch(0.46 0.02 70);
|
||||
--color-ink-faint: oklch(0.6 0.02 70);
|
||||
|
||||
--color-hairline: oklch(1 0 0 / 0.09);
|
||||
|
||||
--color-signal: oklch(0.88 0.18 125);
|
||||
--color-signal-ink: oklch(0.18 0.03 70);
|
||||
--color-signal-glow: oklch(0.88 0.18 125 / 0.4);
|
||||
--color-amber: oklch(0.85 0.15 70);
|
||||
--color-vermilion: oklch(0.68 0.21 25);
|
||||
--color-vermilion-soft: oklch(0.68 0.21 25 / 0.18);
|
||||
--color-hairline: oklch(0.22 0.02 70 / 0.12);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -79,41 +76,28 @@
|
||||
|
||||
body {
|
||||
@apply bg-canvas text-ink font-sans antialiased;
|
||||
/* warm dot-grid texture + faint glow — replaces old bluish radial layers */
|
||||
background-image:
|
||||
radial-gradient(circle, oklch(0.45 0.03 70 / 0.05) 1px, transparent 1px),
|
||||
radial-gradient(ellipse 80% 50% at 50% -20%, oklch(0.78 0.17 125 / 0.06), transparent),
|
||||
radial-gradient(ellipse 50% 40% at 85% 90%, oklch(0.80 0.15 70 / 0.04), transparent);
|
||||
background-size: 26px 26px, 100% 100%, 100% 100%;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
background-image:
|
||||
radial-gradient(circle, oklch(1 0 0 / 0.022) 1px, transparent 1px),
|
||||
radial-gradient(ellipse 80% 50% at 50% -20%, oklch(0.88 0.18 125 / 0.05), transparent),
|
||||
radial-gradient(ellipse 50% 40% at 85% 90%, oklch(0.85 0.15 70 / 0.03), transparent);
|
||||
background-size: 26px 26px, 100% 100%, 100% 100%;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: oklch(0.78 0.17 125 / 0.35);
|
||||
background: var(--color-signal-glow);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Scrollbar — warm */
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.4 0.02 70 / 0.22);
|
||||
background: oklch(1 0 0 / 0.14);
|
||||
border-radius: 999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.4 0.02 70 / 0.38);
|
||||
background: oklch(1 0 0 / 0.26);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
@@ -122,59 +106,48 @@
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Tonal block that replaces bordered cards */
|
||||
.surface {
|
||||
@layer components {
|
||||
/* Glass panel — the workhorse container. Floating, blurred, faint glow. */
|
||||
.glass {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-r);
|
||||
border: var(--hairline-w) solid var(--color-hairline);
|
||||
border-radius: var(--radius-r);
|
||||
backdrop-filter: blur(18px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(140%);
|
||||
box-shadow:
|
||||
0 1px 0 0 oklch(1 0 0 / 0.06) inset,
|
||||
0 18px 50px -28px oklch(0 0 0 / 0.8);
|
||||
}
|
||||
.surface-2 {
|
||||
|
||||
.glass-2 {
|
||||
background: var(--color-surface-2);
|
||||
border: var(--hairline-w) solid var(--color-hairline);
|
||||
border-radius: var(--radius-r-panel);
|
||||
border: var(--hairline-w) solid var(--color-hairline);
|
||||
backdrop-filter: blur(14px) saturate(130%);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(130%);
|
||||
}
|
||||
|
||||
/* 1px animated pulse line marking a live/section header */
|
||||
.scan-tick {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.scan-tick::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
inset-block-start: 0;
|
||||
block-size: 1px;
|
||||
inline-size: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--color-signal) 20%,
|
||||
var(--color-signal) 80%,
|
||||
transparent
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: scan 2.6s linear infinite;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ticker {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-r);
|
||||
border: var(--hairline-w) solid var(--color-hairline);
|
||||
padding: clamp(1rem, 2vw, 1.5rem);
|
||||
/* Section label — small uppercase mono eyebrow */
|
||||
.eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-ink-faint);
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.7rem;
|
||||
gap: 0.4rem;
|
||||
padding: 0.22rem 0.7rem;
|
||||
border-radius: var(--radius-r-pill);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
letter-spacing: 0.01em;
|
||||
background: oklch(1 0 0 / 0.06);
|
||||
border: 1px solid var(--color-hairline);
|
||||
color: var(--color-ink-soft);
|
||||
}
|
||||
|
||||
.mono {
|
||||
@@ -186,26 +159,58 @@
|
||||
.display {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 0.96;
|
||||
}
|
||||
}
|
||||
|
||||
/* text tone helpers */
|
||||
@layer utilities {
|
||||
.text-signal { color: var(--color-signal); }
|
||||
.text-amber { color: var(--color-amber); }
|
||||
.text-vermilion { color: var(--color-vermilion); }
|
||||
.text-ink-soft { color: var(--color-ink-soft); }
|
||||
.text-ink-faint { color: var(--color-ink-faint); }
|
||||
|
||||
.glow-signal {
|
||||
text-shadow: 0 0 22px var(--color-signal-glow);
|
||||
}
|
||||
.glow-vermilion {
|
||||
text-shadow: 0 0 22px var(--color-vermilion-glow);
|
||||
}
|
||||
|
||||
/* animated scan line for live headers */
|
||||
.scan-line {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.scan-line::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--color-signal) 25%,
|
||||
var(--color-signal) 75%,
|
||||
transparent
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: scan 2.8s linear infinite;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* focus ring helper for interactive blocks */
|
||||
.ring-focus {
|
||||
transition: box-shadow 0.18s ease;
|
||||
transition: box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
.ring-focus:hover {
|
||||
box-shadow: 0 0 0 1px var(--color-signal-glow);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Keyframes ──────────────────────────── */
|
||||
/* ── Keyframes ── */
|
||||
@keyframes scan {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
@@ -216,54 +221,52 @@
|
||||
60% { transform: scaleY(0.5); }
|
||||
}
|
||||
@keyframes fade-up {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes spin-disc {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
/* kept for live status dots */
|
||||
@keyframes pulse-ring {
|
||||
0% { transform: scale(0.8); opacity: 1; }
|
||||
100% { transform: scale(2.5); opacity: 0; }
|
||||
0% { transform: scale(0.85); opacity: 1; }
|
||||
100% { transform: scale(2.6); opacity: 0; }
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
@keyframes breathe {
|
||||
0%, 100% { opacity: 0.55; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-eq {
|
||||
animation: eq 0.9s ease-in-out infinite;
|
||||
transform-origin: bottom;
|
||||
}
|
||||
.animate-spin-disc {
|
||||
animation: spin-disc 8s linear infinite;
|
||||
}
|
||||
.animate-spin-disc.paused {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
.animate-pulse-ring {
|
||||
animation: pulse-ring 1.5s ease-out infinite;
|
||||
}
|
||||
.animate-spin-disc { animation: spin-disc 9s linear infinite; }
|
||||
.animate-spin-disc.paused { animation-play-state: paused; }
|
||||
.animate-pulse-ring { animation: pulse-ring 1.6s ease-out infinite; }
|
||||
.animate-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
oklch(0.78 0.17 125 / 0.08),
|
||||
oklch(0.86 0.19 128 / 0.1),
|
||||
transparent
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
animation: shimmer 1.6s infinite;
|
||||
}
|
||||
.animate-breathe { animation: breathe 3.5s ease-in-out infinite; }
|
||||
|
||||
/* ── Reduced motion: kill all decorative animation ── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.scan-tick::after,
|
||||
.scan-line::after,
|
||||
.animate-eq,
|
||||
.animate-spin-disc,
|
||||
.animate-pulse-ring,
|
||||
.animate-shimmer {
|
||||
.animate-shimmer,
|
||||
.animate-breathe {
|
||||
animation: none !important;
|
||||
}
|
||||
html { scroll-behavior: auto; }
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { SIGNAL_RGB, type SignalTone } from "./ambient-context";
|
||||
|
||||
const VERT = /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
void main(){
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position.xy, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAG = /* glsl */ `
|
||||
precision mediump float;
|
||||
varying vec2 vUv;
|
||||
uniform float uTime;
|
||||
uniform vec3 uColor;
|
||||
uniform float uIntensity;
|
||||
uniform vec2 uRes;
|
||||
|
||||
float hash(vec2 p){ p=fract(p*vec2(123.34,456.21)); p+=dot(p,p+45.32); return fract(p.x*p.y); }
|
||||
float noise(vec2 p){
|
||||
vec2 i=floor(p); vec2 f=fract(p);
|
||||
float a=hash(i), b=hash(i+vec2(1.,0.)), c=hash(i+vec2(0.,1.)), d=hash(i+vec2(1.,1.));
|
||||
vec2 u=f*f*(3.-2.*f);
|
||||
return mix(mix(a,b,u.x),mix(c,d,u.x),u.y);
|
||||
}
|
||||
float fbm(vec2 p){
|
||||
float v=0.0, a=0.5;
|
||||
mat2 m=mat2(1.6,1.2,-1.2,1.6);
|
||||
for(int i=0;i<5;i++){ v+=a*noise(p); p=m*p; a*=0.5; }
|
||||
return v;
|
||||
}
|
||||
|
||||
void main(){
|
||||
vec2 uv=vUv;
|
||||
vec2 p=uv-0.5;
|
||||
p.x*=uRes.x/uRes.y;
|
||||
float t=uTime*0.04*(0.6+uIntensity);
|
||||
vec2 q=vec2(fbm(p*1.5+t), fbm(p*1.5-t+5.0));
|
||||
float f=fbm(p*2.2 + q*1.8 + t*0.5);
|
||||
vec2 c=vec2(sin(uTime*0.05)*0.25, cos(uTime*0.04)*0.18);
|
||||
float d=length(p-c);
|
||||
float glow=smoothstep(0.95,0.0,d)*0.5;
|
||||
float haze=(f*0.7+glow)*uIntensity;
|
||||
vec3 col=uColor*haze;
|
||||
float g=hash(uv*uRes+uTime)*0.035;
|
||||
col+=g;
|
||||
float vig=smoothstep(1.25,0.15,length(p));
|
||||
col*=0.35+0.65*vig;
|
||||
gl_FragColor=vec4(col,1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const MOTE_COUNT = 140;
|
||||
|
||||
export function AmbientCanvas({
|
||||
targetRef,
|
||||
}: {
|
||||
targetRef: React.MutableRefObject<{ tone: SignalTone; intensity: number }>;
|
||||
}) {
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const mount = mountRef.current;
|
||||
if (!mount) return;
|
||||
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: false,
|
||||
alpha: false,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
} catch {
|
||||
return; // static CSS fallback remains
|
||||
}
|
||||
|
||||
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
|
||||
renderer.setPixelRatio(dpr);
|
||||
renderer.setSize(mount.clientWidth, mount.clientHeight);
|
||||
mount.appendChild(renderer.domElement);
|
||||
renderer.domElement.style.width = "100%";
|
||||
renderer.domElement.style.height = "100%";
|
||||
renderer.domElement.style.display = "block";
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
|
||||
const uniforms = {
|
||||
uTime: { value: 0 },
|
||||
uColor: { value: new THREE.Color(...SIGNAL_RGB.signal) },
|
||||
uIntensity: { value: 0.35 },
|
||||
uRes: { value: new THREE.Vector2(1, 1) },
|
||||
};
|
||||
|
||||
const quad = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(2, 2),
|
||||
new THREE.ShaderMaterial({
|
||||
vertexShader: VERT,
|
||||
fragmentShader: FRAG,
|
||||
uniforms,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
scene.add(quad);
|
||||
|
||||
// — Drifting motes —
|
||||
const positions = new Float32Array(MOTE_COUNT * 3);
|
||||
const speeds = new Float32Array(MOTE_COUNT);
|
||||
for (let i = 0; i < MOTE_COUNT; i++) {
|
||||
positions[i * 3] = (Math.random() - 0.5) * 2;
|
||||
positions[i * 3 + 1] = (Math.random() - 0.5) * 2;
|
||||
positions[i * 3 + 2] = 0;
|
||||
speeds[i] = 0.01 + Math.random() * 0.03;
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
const moteMat = new THREE.PointsMaterial({
|
||||
size: 0.012,
|
||||
color: new THREE.Color(...SIGNAL_RGB.signal),
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
const motes = new THREE.Points(geo, moteMat);
|
||||
scene.add(motes);
|
||||
|
||||
const color = new THREE.Color();
|
||||
const target = new THREE.Color();
|
||||
let targetIntensity = 0.35;
|
||||
let intensity = 0.35;
|
||||
let raf = 0;
|
||||
let last = performance.now();
|
||||
let running = !reduce;
|
||||
|
||||
const resize = () => {
|
||||
const w = mount.clientWidth || 1;
|
||||
const h = mount.clientHeight || 1;
|
||||
renderer.setSize(w, h);
|
||||
uniforms.uRes.value.set(w * dpr, h * dpr);
|
||||
};
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(mount);
|
||||
resize();
|
||||
|
||||
const onVisibility = () => {
|
||||
running = !document.hidden && !reduce;
|
||||
if (running) {
|
||||
last = performance.now();
|
||||
loop();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
|
||||
const frame = (now: number) => {
|
||||
const dt = Math.min((now - last) / 1000, 0.05);
|
||||
last = now;
|
||||
uniforms.uTime.value += dt;
|
||||
|
||||
// Ease toward target signal/intensity each frame (no React re-render).
|
||||
const tgt = targetRef.current;
|
||||
target.set(...SIGNAL_RGB[tgt.tone]);
|
||||
color.lerp(target, 0.04);
|
||||
uniforms.uColor.value.copy(color);
|
||||
moteMat.color.copy(color);
|
||||
targetIntensity = 0.2 + tgt.intensity * 0.8;
|
||||
intensity = lerp(intensity, targetIntensity, 0.04);
|
||||
uniforms.uIntensity.value = intensity;
|
||||
|
||||
const pos = geo.attributes.position as THREE.BufferAttribute;
|
||||
for (let i = 0; i < MOTE_COUNT; i++) {
|
||||
let y = pos.getY(i) + speeds[i] * dt * (0.5 + tgt.intensity);
|
||||
if (y > 1.1) y = -1.1;
|
||||
pos.setY(i, y);
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
|
||||
renderer.render(scene, camera);
|
||||
if (running) raf = requestAnimationFrame(frame);
|
||||
};
|
||||
|
||||
const loop = () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
last = performance.now();
|
||||
raf = requestAnimationFrame(frame);
|
||||
};
|
||||
|
||||
if (reduce) {
|
||||
// single static frame
|
||||
uniforms.uIntensity.value = 0.3;
|
||||
renderer.render(scene, camera);
|
||||
} else {
|
||||
loop();
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
geo.dispose();
|
||||
moteMat.dispose();
|
||||
(quad.geometry as THREE.BufferGeometry).dispose();
|
||||
(quad.material as THREE.Material).dispose();
|
||||
renderer.dispose();
|
||||
if (renderer.domElement.parentNode === mount) {
|
||||
mount.removeChild(renderer.domElement);
|
||||
}
|
||||
};
|
||||
}, [targetRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={mountRef}
|
||||
aria-hidden
|
||||
className="fixed inset-0 -z-10 overflow-hidden"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(120% 90% at 50% 0%, oklch(0.2 0.04 70 / 0.5), oklch(0.1 0.015 70) 60%)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import { AmbientCanvas } from "./ambient-canvas";
|
||||
|
||||
export type SignalTone = "signal" | "amber" | "vermilion";
|
||||
|
||||
/** sRGB triplets for the three semantic signals (matches globals.css). */
|
||||
export const SIGNAL_RGB: Record<SignalTone, [number, number, number]> = {
|
||||
signal: [0.42, 1.0, 0.52],
|
||||
amber: [1.0, 0.76, 0.28],
|
||||
vermilion: [1.0, 0.34, 0.28],
|
||||
};
|
||||
|
||||
export interface AmbientState {
|
||||
tone: SignalTone;
|
||||
/** 0..1 — drives haze density + drift speed (e.g. server load). */
|
||||
intensity: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface AmbientControls {
|
||||
set: (tone: SignalTone, intensity?: number, label?: string) => void;
|
||||
reset: () => void;
|
||||
state: AmbientState;
|
||||
}
|
||||
|
||||
const DEFAULT: AmbientState = { tone: "signal", intensity: 0.35, label: "nominal" };
|
||||
|
||||
const AmbientContext = createContext<AmbientControls | null>(null);
|
||||
|
||||
/**
|
||||
* Holds the live ambient signal. The canvas reads `targetRef` inside its
|
||||
* render loop (no React re-render per frame); `state` is mirrored into React
|
||||
* only so small UI bits (topbar) can reflect the current tone.
|
||||
*/
|
||||
export function AmbientProvider({ children }: { children: React.ReactNode }) {
|
||||
const targetRef = useRef<AmbientState>({ ...DEFAULT });
|
||||
const [state, setState] = useState<AmbientState>(DEFAULT);
|
||||
|
||||
const set = useCallback((tone: SignalTone, intensity?: number, label?: string) => {
|
||||
targetRef.current = {
|
||||
tone,
|
||||
intensity: intensity ?? targetRef.current.intensity,
|
||||
label: label ?? targetRef.current.label,
|
||||
};
|
||||
setState({ ...targetRef.current });
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
targetRef.current = { ...DEFAULT };
|
||||
setState({ ...DEFAULT });
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AmbientControls>(
|
||||
() => ({ set, reset, state }),
|
||||
[set, reset, state],
|
||||
);
|
||||
|
||||
return (
|
||||
<AmbientContext.Provider value={value}>
|
||||
<AmbientCanvas targetRef={targetRef} />
|
||||
{children}
|
||||
</AmbientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAmbient(): AmbientControls {
|
||||
const ctx = useContext(AmbientContext);
|
||||
if (!ctx) throw new Error("useAmbient must be used within <AmbientProvider>");
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* AmbientField — full-bleed WebGL particle haze that reacts to live data.
|
||||
*
|
||||
* No container, no grid, no chrome. Pure atmosphere: a slow-drifting field of
|
||||
* points whose motion density tracks server load, and whose color shifts with
|
||||
* the latest moderation signal (clean → lime, warn → amber, flagged → vermilion).
|
||||
*
|
||||
* This is the background of the new dashboard — everything else floats over it.
|
||||
*/
|
||||
|
||||
type Signal = "neutral" | "signal" | "amber" | "vermilion";
|
||||
|
||||
const SIGNAL_RGB: Record<Signal, [number, number, number]> = {
|
||||
neutral: [0.52, 0.49, 0.46],
|
||||
signal: [0.78, 0.85, 0.62],
|
||||
amber: [0.95, 0.78, 0.42],
|
||||
vermilion: [0.86, 0.32, 0.28],
|
||||
};
|
||||
|
||||
interface AmbientFieldProps {
|
||||
/** 0..1 — drives particle drift speed + density. */
|
||||
load?: number;
|
||||
/** Latest moderation signal — tints the haze. */
|
||||
signal?: Signal;
|
||||
}
|
||||
|
||||
export function AmbientField({
|
||||
load = 0.3,
|
||||
signal = "signal",
|
||||
}: AmbientFieldProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const loadRef = useRef(load);
|
||||
const signalRef = useRef<[number, number, number]>(SIGNAL_RGB[signal]);
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
loadRef.current = load;
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
signalRef.current = SIGNAL_RGB[signal];
|
||||
}, [signal]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
|
||||
const resize = () => {
|
||||
w = canvas.clientWidth;
|
||||
h = canvas.clientHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
};
|
||||
resize();
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(canvas);
|
||||
|
||||
// Particle haze
|
||||
const N = 90;
|
||||
const pts = Array.from({ length: N }, () => ({
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
z: Math.random() * 0.8 + 0.2,
|
||||
vx: (Math.random() - 0.5) * 0.0004,
|
||||
vy: (Math.random() - 0.5) * 0.0004,
|
||||
r: Math.random() * 1.5 + 0.5,
|
||||
}));
|
||||
|
||||
const draw = () => {
|
||||
const [cr, cg, cb] = signalRef.current;
|
||||
const speed = 0.4 + loadRef.current * 1.6;
|
||||
|
||||
// Trail fade
|
||||
ctx.fillStyle = "rgba(244, 240, 234, 0.06)";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
for (const p of pts) {
|
||||
p.x += p.vx * speed;
|
||||
p.y += p.vy * speed;
|
||||
if (p.x < 0) p.x += 1;
|
||||
if (p.x > 1) p.x -= 1;
|
||||
if (p.y < 0) p.y += 1;
|
||||
if (p.y > 1) p.y -= 1;
|
||||
|
||||
const px = p.x * w;
|
||||
const py = p.y * h;
|
||||
const rad = p.r * p.z * (1 + loadRef.current);
|
||||
const alpha = 0.05 + p.z * 0.12;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, rad, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(${Math.round(cr * 255)}, ${Math.round(cg * 255)}, ${Math.round(cb * 255)}, ${alpha})`;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Faint vignette glow center
|
||||
const grad = ctx.createRadialGradient(
|
||||
w / 2,
|
||||
h / 2,
|
||||
0,
|
||||
w / 2,
|
||||
h / 2,
|
||||
Math.max(w, h) * 0.6,
|
||||
);
|
||||
grad.addColorStop(
|
||||
0,
|
||||
`rgba(${Math.round(cr * 255)}, ${Math.round(cg * 255)}, ${Math.round(cb * 255)}, 0.03)`,
|
||||
);
|
||||
grad.addColorStop(1, "rgba(0,0,0,0)");
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
rafRef.current = requestAnimationFrame(draw);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(draw);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 -z-10 h-full w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Search, Sparkles } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Avatar } from "@/components/primitives/avatar";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { Button } from "@/components/primitives/button";
|
||||
import { Input } from "@/components/primitives/input";
|
||||
import { Progress } from "@/components/primitives/progress";
|
||||
import { EmptyState, LoadingSkeleton } from "@/components/shared";
|
||||
import { useMessageSearch } from "@/hooks";
|
||||
import { renderMessageContent, safeParseJsonArray } from "@/lib/format";
|
||||
|
||||
export function SearchPanel() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
const { data: results, isValidating: isFetching } = useMessageSearch(
|
||||
query,
|
||||
enabled,
|
||||
);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
if (!query.trim()) return;
|
||||
setEnabled(true);
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-[var(--color-ink-soft)]" />
|
||||
<Input
|
||||
placeholder="Search message content, AI flags, analysis text…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSearch} disabled={!query.trim() || isFetching}>
|
||||
{isFetching && <Loader2 className="size-4 animate-spin mr-1.5" />}
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isFetching ? (
|
||||
<LoadingSkeleton count={5} height="h-28" />
|
||||
) : results !== undefined ? (
|
||||
<>
|
||||
<p className="text-sm text-[var(--color-ink-soft)]">
|
||||
Found {results.length} result{results.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
{results.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
title="No messages found matching your query."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{results.map((msg) => (
|
||||
<div key={msg.id} className="surface p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar
|
||||
src={msg.avatar_url ?? undefined}
|
||||
name={msg.username}
|
||||
size={32}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-[var(--color-ink)]">
|
||||
{msg.username}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--color-ink-soft)]">
|
||||
{msg.created_at
|
||||
? new Date(msg.created_at).toLocaleString()
|
||||
: ""}
|
||||
</span>
|
||||
{msg.ai_status && (
|
||||
<Badge
|
||||
tone={
|
||||
msg.ai_status === "clean"
|
||||
? "signal"
|
||||
: msg.ai_status === "flagged"
|
||||
? "vermilion"
|
||||
: "neutral"
|
||||
}
|
||||
>
|
||||
{msg.ai_status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-[var(--color-ink)]">
|
||||
{renderMessageContent(
|
||||
msg.edited_content ?? msg.content,
|
||||
msg.metadata,
|
||||
)}
|
||||
</p>
|
||||
{msg.ai_moderation_flags &&
|
||||
msg.ai_moderation_flags !== "[]" && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{safeParseJsonArray(msg.ai_moderation_flags).map(
|
||||
(flag) => (
|
||||
<Badge key={flag} tone="vermilion">
|
||||
{flag}
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{msg.ai_analysis && (
|
||||
<p className="text-xs text-[var(--color-ink-soft)] italic line-clamp-2 leading-relaxed">
|
||||
<Sparkles className="size-3 inline mr-1" />
|
||||
{msg.ai_analysis}
|
||||
</p>
|
||||
)}
|
||||
{msg.ai_confidence != null && (
|
||||
<div className="flex items-center gap-2 max-w-40">
|
||||
<Progress value={msg.ai_confidence * 100} />
|
||||
<span className="text-[11px] text-[var(--color-ink-soft)] tabular-nums shrink-0">
|
||||
{(msg.ai_confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<Search className="size-12 text-[var(--color-ink-soft)] mb-4" />
|
||||
<p className="text-sm text-[var(--color-ink-soft)]">
|
||||
Enter a search query to find messages across all channels.
|
||||
</p>
|
||||
<p className="text-xs text-[var(--color-ink-soft)] mt-1">
|
||||
Searches message content, AI flags, and analysis text.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useId } from "react";
|
||||
|
||||
export interface AreaPoint {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface AreaActivityProps {
|
||||
data: AreaPoint[];
|
||||
height?: number;
|
||||
stroke?: string;
|
||||
className?: string;
|
||||
label?: string;
|
||||
}
|
||||
import type { DailyActivityPoint } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Dual-area activity chart: total messages (signal) vs flagged (vermilion).
|
||||
* Pure SVG, scales to container. Includes a 7-day trailing window hint.
|
||||
*/
|
||||
export function AreaActivity({
|
||||
data,
|
||||
height = 160,
|
||||
stroke = "var(--color-signal)",
|
||||
className,
|
||||
label,
|
||||
}: AreaActivityProps) {
|
||||
const id = useId().replace(/:/g, "");
|
||||
const reduce = useReducedMotion();
|
||||
const width = 600;
|
||||
if (data.length === 0)
|
||||
return <div className={className} style={{ height }} />;
|
||||
daily,
|
||||
height = 200,
|
||||
}: {
|
||||
daily: DailyActivityPoint[];
|
||||
height?: number;
|
||||
}) {
|
||||
const w = 720;
|
||||
const pad = 8;
|
||||
const n = daily.length;
|
||||
const max = Math.max(...daily.map((d) => d.messages), 1);
|
||||
const x = (i: number) => pad + (i / Math.max(n - 1, 1)) * (w - pad * 2);
|
||||
const y = (v: number) => height - pad - (v / max) * (height - pad * 2);
|
||||
|
||||
const max = Math.max(...data.map((d) => d.value), 1);
|
||||
const stepX = width / Math.max(data.length - 1, 1);
|
||||
const pts = data.map((d, i) => {
|
||||
const x = i * stepX;
|
||||
const y = height - (d.value / max) * (height - 10) - 5;
|
||||
return [x, y] as const;
|
||||
});
|
||||
const line = pts
|
||||
.map(
|
||||
(p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(1)},${p[1].toFixed(1)}`,
|
||||
)
|
||||
.join(" ");
|
||||
const area = `${line} L${width},${height} L0,${height} Z`;
|
||||
const pathLen = 1400;
|
||||
const msgLine = daily.map((d, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(d.messages).toFixed(1)}`).join(" ");
|
||||
const flagLine = daily.map((d, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(d.flagged).toFixed(1)}`).join(" ");
|
||||
const msgArea = `${msgLine} L${x(n - 1).toFixed(1)},${height - pad} L${x(0).toFixed(1)},${height - pad} Z`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className={className}
|
||||
style={{ width: "100%", height }}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label={label ?? "Activity chart"}
|
||||
>
|
||||
<svg viewBox={`0 0 ${w} ${height}`} preserveAspectRatio="none" className="w-full" style={{ height }}>
|
||||
<defs>
|
||||
<linearGradient id={`area-${id}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.32" />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity="0.02" />
|
||||
<linearGradient id="area-msg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--color-signal)" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="var(--color-signal)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<motion.path
|
||||
d={area}
|
||||
fill={`url(#area-${id})`}
|
||||
initial={reduce ? false : { pathLength: 0, opacity: 0.4 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.9, ease: [0.22, 1, 0.36, 1] }}
|
||||
/>
|
||||
<motion.path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
initial={reduce ? false : { pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.9, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{ strokeDasharray: pathLen }}
|
||||
/>
|
||||
{[0.25, 0.5, 0.75].map((g) => (
|
||||
<line key={g} x1={pad} x2={w - pad} y1={height * g} y2={height * g} stroke="var(--color-hairline)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
<path d={msgArea} fill="url(#area-msg)" />
|
||||
<path d={msgLine} fill="none" stroke="var(--color-signal)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
|
||||
<path d={flagLine} fill="none" stroke="var(--color-vermilion)" strokeWidth={1.5} vectorEffect="non-scaling-stroke" strokeDasharray="3 3" />
|
||||
{daily.map((d, i) =>
|
||||
i % 2 === 0 ? (
|
||||
<text key={d.day} x={x(i)} y={height - 1} fill="var(--color-ink-faint)" fontSize={9} textAnchor="middle" className="mono">
|
||||
{d.day.slice(5)}
|
||||
</text>
|
||||
) : null,
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/** Stacked donut for moderation overview / composition. */
|
||||
export function Donut({
|
||||
segments,
|
||||
size = 132,
|
||||
thickness = 14,
|
||||
centerLabel,
|
||||
centerSub,
|
||||
}: {
|
||||
segments: { value: number; color: string; label: string }[];
|
||||
size?: number;
|
||||
thickness?: number;
|
||||
centerLabel?: string;
|
||||
centerSub?: string;
|
||||
}) {
|
||||
const total = segments.reduce((s, x) => s + x.value, 0) || 1;
|
||||
const r = size / 2 - thickness / 2;
|
||||
const c = 2 * Math.PI * r;
|
||||
let offset = 0;
|
||||
return (
|
||||
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--color-hairline)" strokeWidth={thickness} />
|
||||
{segments.map((s, i) => {
|
||||
const len = (s.value / total) * c;
|
||||
const el = (
|
||||
<circle
|
||||
key={i}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={s.color}
|
||||
strokeWidth={thickness}
|
||||
strokeDasharray={`${len} ${c - len}`}
|
||||
strokeDashoffset={-offset}
|
||||
style={{ transition: "stroke-dashoffset 0.6s ease" }}
|
||||
/>
|
||||
);
|
||||
offset += len;
|
||||
return el;
|
||||
})}
|
||||
</svg>
|
||||
{(centerLabel || centerSub) && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
{centerLabel && <span className="display text-lg">{centerLabel}</span>}
|
||||
{centerSub && <span className="mono text-[0.6rem] text-ink-faint">{centerSub}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { Sparkline } from "./sparkline";
|
||||
export { AreaActivity } from "./area-activity";
|
||||
export { RadialGauge } from "./radial-gauge";
|
||||
export { Donut } from "./donut";
|
||||
export { Equalizer } from "./waveform";
|
||||
@@ -1,90 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useId } from "react";
|
||||
|
||||
export interface RadialGaugeProps {
|
||||
/** 0..1 health ratio */
|
||||
value: number;
|
||||
size?: number;
|
||||
label?: string;
|
||||
sublabel?: string;
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
}
|
||||
|
||||
const toneColor = {
|
||||
signal: "var(--color-signal)",
|
||||
amber: "var(--color-amber)",
|
||||
vermilion: "var(--color-vermilion)",
|
||||
};
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Circular progress gauge. value 0..1. */
|
||||
export function RadialGauge({
|
||||
value,
|
||||
size = 160,
|
||||
label,
|
||||
sublabel,
|
||||
tone = "signal",
|
||||
}: RadialGaugeProps) {
|
||||
const id = useId().replace(/:/g, "");
|
||||
const reduce = useReducedMotion();
|
||||
const stroke = 12;
|
||||
const r = (size - stroke) / 2;
|
||||
size = 120,
|
||||
}: {
|
||||
value: number;
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
size?: number;
|
||||
}) {
|
||||
const v = Math.max(0, Math.min(1, value));
|
||||
const stroke = tone === "vermilion" ? "var(--color-vermilion)" : tone === "amber" ? "var(--color-amber)" : "var(--color-signal)";
|
||||
const r = size / 2 - 10;
|
||||
const c = 2 * Math.PI * r;
|
||||
const pct = Math.max(0, Math.min(1, value));
|
||||
const dash = c * pct;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative inline-flex items-center justify-center"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className="-rotate-90"
|
||||
role="img"
|
||||
aria-label={`${Math.round(pct * 100)}% ${label ?? "gauge"}`}
|
||||
>
|
||||
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--color-hairline)" strokeWidth={8} />
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="var(--color-hairline)"
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
<motion.circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={toneColor[tone]}
|
||||
strokeWidth={stroke}
|
||||
stroke={stroke}
|
||||
strokeWidth={8}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={c}
|
||||
initial={reduce ? false : { strokeDashoffset: c }}
|
||||
animate={{ strokeDashoffset: c - dash }}
|
||||
transition={{ duration: 1, ease: [0.22, 1, 0.36, 1] }}
|
||||
strokeDashoffset={c * (1 - v)}
|
||||
style={{ transition: "stroke-dashoffset 0.6s ease", filter: `drop-shadow(0 0 6px ${stroke})` }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">
|
||||
<span
|
||||
className="display text-2xl mono"
|
||||
style={{ color: toneColor[tone] }}
|
||||
>
|
||||
{Math.round(pct * 100)}%
|
||||
</span>
|
||||
{label && (
|
||||
<span className="text-[11px] font-medium text-[var(--color-ink-soft)] uppercase tracking-wide">
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className={cn("display text-xl", tone === "vermilion" && "text-vermilion", tone === "amber" && "text-amber", tone === "signal" && "text-signal")}>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{sublabel && (
|
||||
<span className="text-[10px] text-[var(--color-ink-soft)]/70">
|
||||
{sublabel}
|
||||
</span>
|
||||
)}
|
||||
{sublabel && <span className="mono text-[0.6rem] text-ink-faint">{sublabel}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface RibbonSegment {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number; // relative duration
|
||||
tone?: "signal" | "amber" | "vermilion" | "neutral";
|
||||
}
|
||||
|
||||
const toneClass = {
|
||||
signal: "bg-[var(--color-signal)]",
|
||||
amber: "bg-[var(--color-amber)]",
|
||||
vermilion: "bg-[var(--color-vermilion)]",
|
||||
neutral: "bg-[var(--color-ink-soft)]/40",
|
||||
};
|
||||
|
||||
export interface SessionRibbonProps {
|
||||
segments: RibbonSegment[];
|
||||
className?: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function SessionRibbon({
|
||||
segments,
|
||||
className,
|
||||
height = 28,
|
||||
}: SessionRibbonProps) {
|
||||
const total = segments.reduce((s, x) => s + x.value, 0) || 1;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full gap-0.5 overflow-hidden rounded-[var(--radius-r-control)]",
|
||||
className,
|
||||
)}
|
||||
style={{ height }}
|
||||
>
|
||||
{segments.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={cn(
|
||||
"group relative flex items-center justify-center rounded-sm transition-all",
|
||||
toneClass[s.tone ?? "signal"],
|
||||
)}
|
||||
style={{ width: `${(s.value / total) * 100}%` }}
|
||||
title={`${s.label}: ${s.value}`}
|
||||
>
|
||||
<span className="pointer-events-none absolute inset-x-0 -top-6 hidden whitespace-nowrap rounded bg-[var(--color-ink)] px-1.5 py-0.5 text-[10px] text-[var(--color-canvas)] group-hover:block">
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
|
||||
export interface SparklineProps {
|
||||
data: number[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
stroke?: string;
|
||||
className?: string;
|
||||
fill?: boolean;
|
||||
}
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Minimal sparkline. Pure SVG, scales to container width. */
|
||||
export function Sparkline({
|
||||
data,
|
||||
width = 120,
|
||||
height = 36,
|
||||
stroke = "var(--color-signal)",
|
||||
values,
|
||||
className,
|
||||
stroke = "var(--color-signal)",
|
||||
fill = true,
|
||||
}: SparklineProps) {
|
||||
const id = useId().replace(/:/g, "");
|
||||
if (data.length < 2)
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label="No data"
|
||||
/>
|
||||
);
|
||||
|
||||
const min = Math.min(...data);
|
||||
const max = Math.max(...data);
|
||||
height = 40,
|
||||
}: {
|
||||
values: number[];
|
||||
className?: string;
|
||||
stroke?: string;
|
||||
fill?: boolean;
|
||||
height?: number;
|
||||
}) {
|
||||
if (values.length === 0) return null;
|
||||
const w = 100;
|
||||
const max = Math.max(...values, 1);
|
||||
const min = Math.min(...values, 0);
|
||||
const span = max - min || 1;
|
||||
const stepX = width / (data.length - 1);
|
||||
const pts = data.map((v, i) => {
|
||||
const x = i * stepX;
|
||||
const pts = values.map((v, i) => {
|
||||
const x = (i / (values.length - 1)) * w;
|
||||
const y = height - ((v - min) / span) * (height - 4) - 2;
|
||||
return [x, y] as const;
|
||||
});
|
||||
const line = pts
|
||||
.map(
|
||||
(p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(1)},${p[1].toFixed(1)}`,
|
||||
)
|
||||
.join(" ");
|
||||
const area = `${line} L${width},${height} L0,${height} Z`;
|
||||
|
||||
const line = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(2)},${p[1].toFixed(2)}`).join(" ");
|
||||
const area = `${line} L${w},${height} L0,${height} Z`;
|
||||
const id = `spark-${stroke.replace(/[^a-z0-9]/gi, "")}`;
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className={className}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label="Trend sparkline"
|
||||
>
|
||||
<svg viewBox={`0 0 ${w} ${height}`} preserveAspectRatio="none" className={cn("w-full", className)} style={{ height }}>
|
||||
<defs>
|
||||
<linearGradient id={`spark-${id}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.28" />
|
||||
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.35" />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{fill && <path d={area} fill={`url(#spark-${id})`} />}
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={1.6}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{fill && <path d={area} fill={`url(#${id})`} />}
|
||||
<path d={line} fill="none" stroke={stroke} strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,76 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface WaveformProps {
|
||||
seed: string | number;
|
||||
bars?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
}
|
||||
|
||||
// deterministic pseudo-random from seed so the shape is stable per recording
|
||||
function hashSeed(seed: string | number): number {
|
||||
const s = String(seed);
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
export function Waveform({
|
||||
seed,
|
||||
bars = 40,
|
||||
height = 40,
|
||||
/** Live equalizer bars. `bars` are 0..1 levels. */
|
||||
export function Equalizer({
|
||||
bars,
|
||||
color = "var(--color-signal)",
|
||||
className,
|
||||
tone = "signal",
|
||||
}: WaveformProps) {
|
||||
const reduce = useReducedMotion();
|
||||
const values = useMemo(() => {
|
||||
let state = hashSeed(seed) || 1;
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < bars; i++) {
|
||||
state = (Math.imul(state, 1103515245) + 12345) >>> 0;
|
||||
const r = (state % 1000) / 1000;
|
||||
// envelope: louder in the middle, quieter at edges
|
||||
const env = Math.sin((i / (bars - 1)) * Math.PI);
|
||||
out.push(0.18 + r * 0.82 * (0.4 + env * 0.6));
|
||||
}
|
||||
return out;
|
||||
}, [seed, bars]);
|
||||
|
||||
const color = {
|
||||
signal: "var(--color-signal)",
|
||||
amber: "var(--color-amber)",
|
||||
vermilion: "var(--color-vermilion)",
|
||||
}[tone];
|
||||
|
||||
}: {
|
||||
bars: number[];
|
||||
color?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-end gap-[2px]", className)}
|
||||
style={{ height }}
|
||||
aria-hidden
|
||||
>
|
||||
{values.map((v, i) => (
|
||||
<motion.span
|
||||
<div className={cn("flex h-10 items-end gap-[3px]", className)}>
|
||||
{bars.length === 0 ? (
|
||||
<div className="flex w-full items-end gap-[3px]">
|
||||
{Array.from({ length: 28 }).map((_, i) => (
|
||||
<span key={i} className="flex-1 rounded-full bg-white/10" style={{ height: "12%" }} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
bars.map((b, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="flex-1 rounded-[2px]"
|
||||
style={{ background: color, height: `${Math.max(8, v * 100)}%` }}
|
||||
initial={reduce ? false : { scaleY: 0.2, opacity: 0 }}
|
||||
animate={{ scaleY: 1, opacity: 1 }}
|
||||
whileHover={{ scaleY: 1.15 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
delay: reduce ? 0 : i * 0.006,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
className="flex-1 rounded-full"
|
||||
style={{
|
||||
height: `${Math.max(6, b * 100)}%`,
|
||||
background: color,
|
||||
boxShadow: b > 0.05 ? `0 0 8px ${color}` : "none",
|
||||
transition: "height 90ms linear",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Eraser, Send, Sparkles } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useChatbot } from "./chatbot-context";
|
||||
|
||||
interface ChatPanelProps {
|
||||
inputRef?: React.RefObject<HTMLInputElement | null>;
|
||||
}
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return d.toLocaleTimeString("id-ID", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
const SUGGESTIONS = [
|
||||
"Gimana suasana server hari ini?",
|
||||
"Channel mana yang paling ramai?",
|
||||
"Total pesan di server?",
|
||||
"Ada pesan bermasalah?",
|
||||
];
|
||||
|
||||
export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
|
||||
const { messages, sendMessage, clearMessages, isTyping } = useChatbot();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const internalInputRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = externalInputRef ?? internalInputRef;
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: re-run on message arrival; scroll is a visual effect keyed on new content
|
||||
useEffect(() => {
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, isTyping]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const input = inputRef.current;
|
||||
if (!input || !input.value.trim() || isTyping) return;
|
||||
sendMessage(input.value);
|
||||
input.value = "";
|
||||
};
|
||||
|
||||
const handleSuggestion = (text: string) => {
|
||||
if (isTyping) return;
|
||||
sendMessage(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Chat messages */}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="flex-1 space-y-1.5 overflow-y-auto px-2 py-2"
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex h-full flex-col justify-center gap-3 px-3 text-center">
|
||||
<p className="text-[11px] text-[var(--color-ink-soft)]">
|
||||
Halo! 👋 Aku tau soal server ini — pesan, flag, dan aktivitas.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => handleSuggestion(s)}
|
||||
disabled={isTyping}
|
||||
className="flex items-center gap-1 rounded-full border border-[var(--color-hairline)] bg-[var(--color-surface-2)] px-2.5 py-1 text-[10px] text-[var(--color-ink-soft)] transition-colors hover:bg-[var(--color-signal)] hover:text-[var(--color-signal-ink)] disabled:opacity-40"
|
||||
>
|
||||
<Sparkles className="size-2.5 text-[var(--color-signal)]" />
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg, i) => (
|
||||
<div
|
||||
key={`${msg.timestamp}-${i}`}
|
||||
className={`flex flex-col ${msg.role === "user" ? "items-end" : "items-start"}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] break-words whitespace-pre-wrap rounded-xl px-2.5 py-1.5 text-[11px] leading-relaxed ${
|
||||
msg.role === "user"
|
||||
? "rounded-br-sm bg-[var(--color-signal)] text-[var(--color-signal-ink)]"
|
||||
: "rounded-bl-sm bg-[var(--color-surface-2)] text-[var(--color-ink)]"
|
||||
}`}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
<span className="mt-0.5 px-1 text-[9px] text-[var(--color-ink-soft)]">
|
||||
{formatTime(msg.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{isTyping && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-xl rounded-bl-sm bg-[var(--color-surface-2)] px-2.5 py-2">
|
||||
<span className="inline-flex gap-1">
|
||||
<span
|
||||
className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
|
||||
style={{ animationDelay: "0ms" }}
|
||||
/>
|
||||
<span
|
||||
className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
|
||||
style={{ animationDelay: "150ms" }}
|
||||
/>
|
||||
<span
|
||||
className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input bar */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex shrink-0 items-center gap-1.5 border-t border-[var(--color-hairline)] px-2 py-2"
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Tanya soal server, pesan, atau statistik…"
|
||||
className="flex-1 bg-transparent text-[11px] text-[var(--color-ink)] outline-none placeholder:text-[var(--color-ink-soft)]/50"
|
||||
disabled={isTyping}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void clearMessages()}
|
||||
className="flex size-6 items-center justify-center rounded transition-colors hover:bg-[var(--color-surface-2)] disabled:opacity-40"
|
||||
disabled={isTyping}
|
||||
aria-label="Hapus riwayat chat"
|
||||
title="Hapus riwayat"
|
||||
>
|
||||
<Eraser className="size-3 text-[var(--color-ink-soft)] hover:text-[var(--color-vermilion)]" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="flex size-7 items-center justify-center rounded-lg bg-[var(--color-signal)] text-[var(--color-signal-ink)] transition-colors hover:opacity-90 disabled:opacity-40"
|
||||
disabled={isTyping}
|
||||
aria-label="Kirim pesan"
|
||||
title="Kirim"
|
||||
>
|
||||
<Send className="size-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Bot, Minimize2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ChatPanel } from "./chat-panel";
|
||||
import { useChatbot } from "./chatbot-context";
|
||||
|
||||
export function ChatbotContainer() {
|
||||
const { minimized, setMinimized } = useChatbot();
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
setDragging(true);
|
||||
setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
|
||||
},
|
||||
[position],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!dragging) return;
|
||||
setPosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y });
|
||||
},
|
||||
[dragging, dragStart],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => setDragging(false), []);
|
||||
|
||||
// Focus input when chat opens
|
||||
useEffect(() => {
|
||||
if (!minimized) {
|
||||
const id = setTimeout(() => inputRef.current?.focus(), 150);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
}, [minimized]);
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: drag container — mouse-move gesture surface, not keyboard-interactive content
|
||||
<div
|
||||
className="fixed bottom-4 right-4 z-40 select-none"
|
||||
style={{ transform: `translate(${position.x}px, ${position.y}px)` }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
<div
|
||||
className={`surface-2 overflow-hidden shadow-2xl transition-all duration-200 ${
|
||||
minimized ? "h-14 w-14 cursor-pointer" : "h-[460px] w-[320px]"
|
||||
}`}
|
||||
>
|
||||
{minimized ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMinimized(false)}
|
||||
className="flex size-full items-center justify-center"
|
||||
onMouseDown={handleMouseDown}
|
||||
aria-label="Buka chatbot"
|
||||
title="Buka chatbot"
|
||||
>
|
||||
<Bot className="size-6 text-[var(--color-signal)]" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Drag handle + controls */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag handle — mouse-only gesture, keyboard users use the buttons in this header */}
|
||||
<div
|
||||
className="flex shrink-0 cursor-grab items-center justify-between border-b border-[var(--color-hairline)] px-3 py-2 active:cursor-grabbing"
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
<Bot className="size-3.5 text-[var(--color-signal)]" />
|
||||
Chatbot
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMinimized(true)}
|
||||
className="flex size-6 items-center justify-center rounded transition-colors hover:bg-[var(--color-surface-2)]"
|
||||
aria-label="Kecilkan chatbot"
|
||||
title="Kecilkan chatbot"
|
||||
>
|
||||
<Minimize2 className="size-3.5 text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat panel — always open when bubble is expanded */}
|
||||
<div className="min-h-0 flex-1">
|
||||
<ChatPanel inputRef={inputRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useChatbotUserId } from "@/hooks/use-chatbot-user";
|
||||
import { chatbotApi } from "@/lib/api";
|
||||
|
||||
export type ChatbotExpression =
|
||||
| "idle"
|
||||
| "listening"
|
||||
| "surprise"
|
||||
| "happy"
|
||||
| "sad"
|
||||
| "talking";
|
||||
|
||||
interface ChatbotMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface ChatbotContextValue {
|
||||
/** Expression the chatbot avatar should display */
|
||||
expression: ChatbotExpression;
|
||||
setExpression: (expr: ChatbotExpression) => void;
|
||||
|
||||
/** Whether the enlarged bubble is minimized to a small icon */
|
||||
minimized: boolean;
|
||||
setMinimized: (v: boolean) => void;
|
||||
|
||||
/**
|
||||
* @deprecated Use `minimized` / `setMinimized` instead.
|
||||
* Legacy toggle alias kept for compatibility.
|
||||
*/
|
||||
isOpen: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
toggle: () => void;
|
||||
|
||||
/** Chat messages with real API backend */
|
||||
messages: ChatbotMessage[];
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
clearMessages: () => Promise<void>;
|
||||
isTyping: boolean;
|
||||
|
||||
/** Active guild context sent to the backend so answers reference the server */
|
||||
guildId: string;
|
||||
setGuildId: (g: string) => void;
|
||||
}
|
||||
|
||||
const ChatbotContext = createContext<ChatbotContextValue | null>(null);
|
||||
|
||||
export function ChatbotProvider({ children }: { children: ReactNode }) {
|
||||
const [expression, setExpression] = useState<ChatbotExpression>("idle");
|
||||
const [minimized, setMinimized] = useState(true);
|
||||
const [messages, setMessages] = useState<ChatbotMessage[]>([]);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [guildId, setGuildId] = useState("");
|
||||
const historyFetched = useRef(false);
|
||||
const userId = useChatbotUserId();
|
||||
|
||||
// Derived legacy state
|
||||
const isOpen = !minimized;
|
||||
|
||||
const setOpen = useCallback((open: boolean) => {
|
||||
setMinimized(!open);
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setMinimized((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Load chat history on first mount (per-device user history)
|
||||
useEffect(() => {
|
||||
if (historyFetched.current || !userId) return;
|
||||
historyFetched.current = true;
|
||||
|
||||
chatbotApi
|
||||
.getHistory(userId)
|
||||
.then((res) => {
|
||||
// Backend returns rows {user_message, bot_response, created_at} —
|
||||
// interleave each user message with its bot reply.
|
||||
const withReplies: ChatbotMessage[] = [];
|
||||
for (const row of res.history ?? []) {
|
||||
withReplies.push({
|
||||
role: "user",
|
||||
content: row.user_message,
|
||||
timestamp: row.created_at,
|
||||
});
|
||||
withReplies.push({
|
||||
role: "assistant",
|
||||
content: row.bot_response,
|
||||
timestamp: row.created_at,
|
||||
});
|
||||
}
|
||||
setMessages(withReplies);
|
||||
})
|
||||
.catch(() => {
|
||||
// API may not be available yet — silently ignore
|
||||
});
|
||||
}, [userId]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
if (!content.trim()) return;
|
||||
|
||||
const userMsg: ChatbotMessage = {
|
||||
role: "user",
|
||||
content: content.trim(),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setExpression("listening");
|
||||
setIsTyping(true);
|
||||
|
||||
try {
|
||||
// Send active guild as context so the backend can answer with
|
||||
// real server insights (serverInsights path in chatbot.service),
|
||||
// and the per-device user id so the history stays isolated.
|
||||
const res = await chatbotApi.send(content.trim(), guildId, userId);
|
||||
const botMsg: ChatbotMessage = {
|
||||
role: "assistant",
|
||||
content: res.response,
|
||||
timestamp: res.timestamp ?? new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, botMsg]);
|
||||
setExpression("happy");
|
||||
} catch {
|
||||
const errorMsg: ChatbotMessage = {
|
||||
role: "assistant",
|
||||
content:
|
||||
"Maaf, aku lagi gagal nyambung ke server. Coba tanya lagi ya 🙏",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMsg]);
|
||||
setExpression("sad");
|
||||
} finally {
|
||||
setIsTyping(false);
|
||||
}
|
||||
},
|
||||
[guildId, userId],
|
||||
);
|
||||
|
||||
const clearMessages = useCallback(async () => {
|
||||
try {
|
||||
await chatbotApi.clearHistory(userId);
|
||||
} catch {
|
||||
// Best-effort clear
|
||||
}
|
||||
setMessages([]);
|
||||
}, [userId]);
|
||||
|
||||
return (
|
||||
<ChatbotContext.Provider
|
||||
value={{
|
||||
expression,
|
||||
setExpression,
|
||||
minimized,
|
||||
setMinimized,
|
||||
isOpen,
|
||||
setOpen,
|
||||
toggle,
|
||||
messages,
|
||||
sendMessage,
|
||||
clearMessages,
|
||||
isTyping,
|
||||
guildId,
|
||||
setGuildId,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ChatbotContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useChatbot(): ChatbotContextValue {
|
||||
const ctx = useContext(ChatbotContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useChatbot must be used within a ChatbotProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Bot, Send, X, MessageCircle } from "lucide-react";
|
||||
import { chatbotApi } from "@/lib/api";
|
||||
import { useChatbotUserId } from "@/hooks/use-chatbot-user";
|
||||
import { GlassPanel, Input, Button, Avatar } from "@/components/primitives";
|
||||
import { toast } from "@/components/primitives";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Msg {
|
||||
role: "user" | "bot";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function Chatbot() {
|
||||
const userId = useChatbotUserId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [msgs, setMsgs] = useState<Msg[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !userId) return;
|
||||
chatbotApi
|
||||
.getHistory(userId)
|
||||
.then((res) => {
|
||||
setMsgs(
|
||||
res.history
|
||||
.slice(-12)
|
||||
.flatMap((h) => [
|
||||
{ role: "user" as const, content: h.user_message },
|
||||
{ role: "bot" as const, content: h.bot_response },
|
||||
]),
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [open, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
|
||||
}, [msgs, loading]);
|
||||
|
||||
const send = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || loading || !userId) return;
|
||||
setInput("");
|
||||
setMsgs((m) => [...m, { role: "user", content: text }]);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await chatbotApi.send(text, undefined, userId);
|
||||
setMsgs((m) => [...m, { role: "bot", content: res.response }]);
|
||||
} catch (e) {
|
||||
toast({ title: "Chat error", description: String(e), tone: "vermilion" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open assistant"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="fixed bottom-5 right-5 z-50 flex items-center justify-center rounded-full bg-signal text-signal-ink shadow-[0_10px_30px_-8px_var(--color-signal-glow)] transition-transform hover:scale-105"
|
||||
style={{ width: 52, height: 52 }}
|
||||
>
|
||||
{open ? <X className="size-5" /> : <MessageCircle className="size-5" />}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<GlassPanel
|
||||
className="fixed bottom-20 right-5 z-50 flex w-[min(92vw,360px)] flex-col p-0"
|
||||
style={{ animation: "fade-up 0.16s ease", height: 460 }}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-hairline px-4 py-3">
|
||||
<span className="flex size-8 items-center justify-center rounded-full bg-signal/15 text-signal">
|
||||
<Bot className="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-ink">GMW Assistant</div>
|
||||
<div className="mono text-[0.6rem] text-ink-faint">context-aware</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="flex-1 space-y-3 overflow-y-auto px-4 py-3">
|
||||
{msgs.length === 0 && (
|
||||
<div className="py-8 text-center text-xs text-ink-faint">
|
||||
Ask about moderation, voice, or media.
|
||||
</div>
|
||||
)}
|
||||
{msgs.map((m, i) => (
|
||||
<div key={i} className={cn("flex gap-2", m.role === "user" ? "justify-end" : "justify-start")}>
|
||||
{m.role === "bot" && <Avatar name="GMW" size={26} className="mt-0.5 bg-signal/15 text-signal" />}
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[80%] rounded-2xl px-3 py-2 text-sm",
|
||||
m.role === "user"
|
||||
? "rounded-br-sm bg-signal/20 text-ink"
|
||||
: "rounded-bl-sm bg-white/5 text-ink-soft",
|
||||
)}
|
||||
>
|
||||
{m.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex gap-2">
|
||||
<Avatar name="GMW" size={26} className="bg-signal/15 text-signal" />
|
||||
<div className="rounded-2xl rounded-bl-sm bg-white/5 px-3 py-2 text-sm text-ink-faint">…</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-t border-hairline p-3">
|
||||
<Input
|
||||
placeholder="Message…"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && send()}
|
||||
/>
|
||||
<Button variant="primary" size="icon" onClick={send} disabled={loading}>
|
||||
<Send className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { ChatPanel } from "./chat-panel";
|
||||
export { ChatbotContainer } from "./chatbot-container";
|
||||
export { ChatbotProvider, useChatbot } from "./chatbot-context";
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
Search,
|
||||
CornerDownLeft,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Moon,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { navItems } from "@/lib/navigation";
|
||||
import { GlassPanel } from "@/components/primitives";
|
||||
|
||||
interface Command {
|
||||
id: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
icon: React.ReactNode;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const router = useRouter();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
|
||||
const commands = useMemo<Command[]>(() => {
|
||||
const nav: Command[] = navItems.map((n) => ({
|
||||
id: `nav:${n.href}`,
|
||||
label: `Go to ${n.label}`,
|
||||
hint: n.href,
|
||||
icon: <n.icon className="size-4 text-signal" />,
|
||||
run: () => router.push(n.href),
|
||||
}));
|
||||
const actions: Command[] = [
|
||||
{
|
||||
id: "act:theme",
|
||||
label: "Toggle theme",
|
||||
hint: "appearance",
|
||||
icon:
|
||||
theme === "light" ? (
|
||||
<Moon className="size-4 text-signal" />
|
||||
) : (
|
||||
<Sun className="size-4 text-signal" />
|
||||
),
|
||||
run: () => setTheme(theme === "light" ? "dark" : "light"),
|
||||
},
|
||||
];
|
||||
return [...nav, ...actions];
|
||||
}, [router, theme, setTheme]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return commands;
|
||||
return commands.filter(
|
||||
(c) => c.label.toLowerCase().includes(q) || c.hint.toLowerCase().includes(q),
|
||||
);
|
||||
}, [commands, query]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setOpen((o) => !o);
|
||||
}
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
const onOpen = () => setOpen(true);
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("command-palette:open", onOpen);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("command-palette:open", onOpen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
setActive(0);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(0);
|
||||
}, [query]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const runAt = (i: number) => {
|
||||
const c = filtered[i];
|
||||
if (!c) return;
|
||||
setOpen(false);
|
||||
c.run();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[90] flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm"
|
||||
onMouseDown={() => setOpen(false)}
|
||||
>
|
||||
<GlassPanel
|
||||
className="w-full max-w-[560px] overflow-hidden p-0"
|
||||
style={{ animation: "fade-up 0.14s ease" }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-hairline px-4 py-3">
|
||||
<Search className="size-4 text-ink-faint" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.min(a + 1, filtered.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.max(a - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
runAt(active);
|
||||
}
|
||||
}}
|
||||
placeholder="Type a command or search…"
|
||||
className="flex-1 bg-transparent text-sm text-ink outline-none placeholder:text-ink-faint"
|
||||
/>
|
||||
<kbd className="mono rounded bg-white/8 px-1.5 py-0.5 text-[0.6rem] text-ink-faint">ESC</kbd>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[50vh] overflow-y-auto p-2">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-ink-faint">No commands</div>
|
||||
) : (
|
||||
filtered.map((c, i) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => runAt(i)}
|
||||
className={`flex w-full items-center gap-3 rounded-[10px] px-3 py-2.5 text-left text-sm transition-colors ${
|
||||
i === active ? "bg-signal/12 text-ink" : "text-ink-soft hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<span className="flex size-7 items-center justify-center rounded-[8px] bg-white/5">
|
||||
{c.icon}
|
||||
</span>
|
||||
<span className="flex-1">{c.label}</span>
|
||||
<span className="mono text-[0.65rem] text-ink-faint">{c.hint}</span>
|
||||
{i === active && <CornerDownLeft className="size-3.5 text-ink-faint" />}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 border-t border-hairline px-4 py-2 text-[0.65rem] text-ink-faint">
|
||||
<span className="flex items-center gap-1"><ArrowUp className="size-3" /><ArrowDown className="size-3" /> navigate</span>
|
||||
<span className="flex items-center gap-1"><CornerDownLeft className="size-3" /> select</span>
|
||||
<span className="ml-auto mono">⌘K</span>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DashCommandLine — sticky bottom prompt for ops actions.
|
||||
*
|
||||
* The signature element of the new dashboard. Pure mono input; parses a
|
||||
* slash-prefixed verb and dispatches to existing APIs or client-side
|
||||
* actions. Autocomplete is intentionally light (suggestions render in
|
||||
* monospace below the input).
|
||||
*/
|
||||
|
||||
import {
|
||||
type FormEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CommandVerb = "mute" | "jump" | "find" | "clear";
|
||||
|
||||
interface CommandResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const VERBS: CommandVerb[] = ["mute", "jump", "find", "clear"];
|
||||
|
||||
interface DashCommandLineProps {
|
||||
onCommand?: (verb: CommandVerb, args: string) => CommandResult | undefined;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function DashCommandLine({
|
||||
onCommand,
|
||||
placeholder = "type a command — /mute @user 10m, /jump #channel, /find text, /clear",
|
||||
}: DashCommandLineProps) {
|
||||
const [value, setValue] = useState("");
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [_historyIdx, setHistoryIdx] = useState<number>(-1);
|
||||
const [result, setResult] = useState<CommandResult | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Global "/" focuses the command line (skip when typing in another input).
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
const t = e.target as HTMLElement | null;
|
||||
const tag = t?.tagName?.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || t?.isContentEditable) return;
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
const trimmed = value.trimStart();
|
||||
if (!trimmed.startsWith("/")) return [] as CommandVerb[];
|
||||
const verb = trimmed.slice(1).split(/\s+/)[0]?.toLowerCase() ?? "";
|
||||
if (!verb) return VERBS;
|
||||
return VERBS.filter((v) => v.startsWith(verb));
|
||||
}, [value]);
|
||||
|
||||
const submit = useCallback(
|
||||
(raw: string) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.startsWith("/")) {
|
||||
setResult({ ok: false, message: "commands start with /" });
|
||||
return;
|
||||
}
|
||||
const body = trimmed.slice(1);
|
||||
const [verbRaw, ...rest] = body.split(/\s+/);
|
||||
const verb = (verbRaw?.toLowerCase() ?? "") as CommandVerb;
|
||||
if (!VERBS.includes(verb)) {
|
||||
setResult({
|
||||
ok: false,
|
||||
message: `unknown verb "${verbRaw}" — try ${VERBS.join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const args = rest.join(" ");
|
||||
try {
|
||||
const ret = onCommand?.(verb, args);
|
||||
const message =
|
||||
(ret && typeof ret === "object" && "message" in ret && ret.message) ||
|
||||
defaultMessage(verb, args);
|
||||
setResult({ ok: true, message });
|
||||
} catch (err) {
|
||||
setResult({
|
||||
ok: false,
|
||||
message: err instanceof Error ? err.message : "command failed",
|
||||
});
|
||||
}
|
||||
setHistory((h) => [trimmed, ...h].slice(0, 32));
|
||||
setHistoryIdx(-1);
|
||||
},
|
||||
[onCommand],
|
||||
);
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (value.trim()) {
|
||||
submit(value);
|
||||
setValue("");
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHistoryIdx((idx) => {
|
||||
const next = idx + 1;
|
||||
if (next >= history.length) return idx;
|
||||
setValue(history[next] ?? "");
|
||||
return next;
|
||||
});
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHistoryIdx((idx) => {
|
||||
const next = idx - 1;
|
||||
if (next < -1) return idx;
|
||||
setValue(next === -1 ? "" : (history[next] ?? ""));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="sticky bottom-0 z-10 flex h-11 items-center gap-2 border-t border-[var(--color-hairline)] bg-[var(--color-canvas)] px-3 font-mono text-[12px]"
|
||||
role="search"
|
||||
>
|
||||
<span className="shrink-0 text-[var(--color-signal)]">{">"}</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
aria-label="Command line"
|
||||
className="min-w-0 flex-1 bg-transparent text-[var(--color-ink)] outline-none placeholder:text-[var(--color-ink-soft)]"
|
||||
/>
|
||||
{result ? (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 truncate text-[10px] uppercase tracking-wide",
|
||||
result.ok
|
||||
? "text-[var(--color-signal)]"
|
||||
: "text-[var(--color-vermilion)]",
|
||||
)}
|
||||
>
|
||||
{result.message}
|
||||
</span>
|
||||
) : suggestions.length > 0 ? (
|
||||
<span className="shrink-0 truncate text-[10px] uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
{suggestions.map((s) => `/${s}`).join(" ")}
|
||||
</span>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultMessage(verb: CommandVerb, args: string): string {
|
||||
switch (verb) {
|
||||
case "mute":
|
||||
return args ? `mute queued — ${args}` : "mute needs a target";
|
||||
case "jump":
|
||||
return args ? `jump queued — ${args}` : "jump needs a channel";
|
||||
case "find":
|
||||
return args ? `find queued — ${args}` : "find needs text";
|
||||
case "clear":
|
||||
return "feed cleared";
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { AreaPoint } from "@/components/charts/area-activity";
|
||||
import { AreaActivity } from "@/components/charts/area-activity";
|
||||
|
||||
export interface ActivityChartProps {
|
||||
data: {
|
||||
day: string;
|
||||
messages: number;
|
||||
flagged: number;
|
||||
active_users: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function ActivityChart({ data }: ActivityChartProps) {
|
||||
const points: AreaPoint[] = data.map((d) => ({
|
||||
label: d.day,
|
||||
value: d.messages,
|
||||
}));
|
||||
return (
|
||||
<div className="surface p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">Daily messages</h3>
|
||||
<span className="pill bg-[var(--color-signal)]/15 text-[var(--color-signal)]">
|
||||
{data.length}d
|
||||
</span>
|
||||
</div>
|
||||
<ActivityChartInner points={points} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityChartInner({ points }: { points: AreaPoint[] }) {
|
||||
return (
|
||||
<AreaActivity data={points} height={180} label="Daily message activity" />
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Hash, Search } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { Button } from "@/components/primitives/button";
|
||||
import { Input } from "@/components/primitives/input";
|
||||
import { EmptyState, LoadingSkeleton } from "@/components/shared";
|
||||
import { useChannelDetail, useChannels } from "@/hooks";
|
||||
import type { DashboardChannel } from "@/lib/types";
|
||||
|
||||
export function ChannelsSection({ guildId }: { guildId?: string }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const { data: channels = [], isLoading } = useChannels(guildId ?? "", search);
|
||||
const { data: detail } = useChannelDetail(selectedId);
|
||||
|
||||
const handleSearch = useCallback((v: string) => setSearch(v), []);
|
||||
|
||||
if (isLoading) return <LoadingSkeleton count={8} />;
|
||||
if (channels.length === 0)
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Hash}
|
||||
title="No channels"
|
||||
description="No channels in this guild."
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_320px]">
|
||||
<div className="surface flex flex-col gap-2 p-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-soft)]" />
|
||||
<Input
|
||||
mono
|
||||
placeholder="search channels…"
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{channels.map((c) => (
|
||||
<ChannelRow
|
||||
key={c.channel_id}
|
||||
channel={c}
|
||||
selected={selectedId === c.channel_id}
|
||||
onSelect={() => setSelectedId(c.channel_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="surface p-4">
|
||||
{detail ? (
|
||||
<div className="flex flex-col gap-3.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex size-9 items-center justify-center rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)]">
|
||||
<Hash className="size-4 text-[var(--color-ink-soft)]" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="font-semibold">
|
||||
{detail.channel_name ?? detail.channel_id}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--color-ink-soft)]">
|
||||
{detail.total_messages.toLocaleString()} messages
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Stat
|
||||
label="Flagged"
|
||||
value={detail.flagged_count}
|
||||
tone="vermilion"
|
||||
/>
|
||||
<p className="text-xs text-[var(--color-ink-soft)]">
|
||||
{detail.culture_summary ?? "No data yet."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--color-ink-soft)]">
|
||||
Select a channel to inspect.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelRow({
|
||||
channel,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
channel: DashboardChannel;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const total = channel.total_messages + channel.flagged_count || 1;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className="flex items-center gap-3 rounded-[var(--radius-r-control)] px-2.5 py-2 text-left transition-colors hover:bg-[var(--color-surface-2)] data-[selected]:bg-[var(--color-signal)]/8"
|
||||
data-selected={selected}
|
||||
>
|
||||
<Hash className="size-4 text-[var(--color-ink-soft)]" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{channel.channel_name ?? channel.channel_id}
|
||||
</span>
|
||||
<span className="mono text-xs text-[var(--color-ink-soft)]">
|
||||
{channel.flagged_count}/{channel.total_messages}
|
||||
</span>
|
||||
<div className="h-1.5 w-10 overflow-hidden rounded-full bg-[var(--color-hairline)]">
|
||||
<div
|
||||
className="h-full bg-[var(--color-signal)]"
|
||||
style={{ width: `${(channel.flagged_count / total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: "vermilion";
|
||||
}) {
|
||||
return (
|
||||
<div className="surface-2 flex items-center justify-between p-2.5">
|
||||
<span className="text-xs text-[var(--color-ink-soft)]">{label}</span>
|
||||
<span className="mono font-semibold text-[var(--color-vermilion)]">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { AreaPoint } from "@/components/charts/area-activity";
|
||||
import { AreaActivity } from "@/components/charts/area-activity";
|
||||
|
||||
export interface HourlyActivityChartProps {
|
||||
data: { hour: number; messages: number; flagged: number }[];
|
||||
}
|
||||
|
||||
export function HourlyActivityChart({ data }: HourlyActivityChartProps) {
|
||||
const points: AreaPoint[] = data.map((d) => ({
|
||||
label: `${d.hour}:00`,
|
||||
value: d.messages,
|
||||
}));
|
||||
return (
|
||||
<div className="surface p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">Hourly distribution</h3>
|
||||
<span className="text-xs text-[var(--color-ink-soft)]">
|
||||
00:00 – 23:00
|
||||
</span>
|
||||
</div>
|
||||
<AreaActivity
|
||||
data={points}
|
||||
height={140}
|
||||
stroke="var(--color-amber)"
|
||||
label="Hourly message activity"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { RadialGauge } from "@/components/charts/radial-gauge";
|
||||
import type { DashboardStats } from "@/lib/types";
|
||||
|
||||
export interface ModerationDonutProps {
|
||||
stats?: DashboardStats;
|
||||
}
|
||||
|
||||
export function ModerationDonut({ stats }: ModerationDonutProps) {
|
||||
const clean = stats?.total_clean ?? 0;
|
||||
const flagged = stats?.total_flagged ?? 0;
|
||||
const warned = stats?.total_warned ?? 0;
|
||||
const total = clean + flagged + warned || 1;
|
||||
const ratio = clean / total;
|
||||
|
||||
return (
|
||||
<div className="surface flex flex-col items-center gap-3 p-4">
|
||||
<h3 className="self-start text-sm font-semibold">Moderation health</h3>
|
||||
<RadialGauge
|
||||
value={ratio}
|
||||
size={150}
|
||||
label="Clean"
|
||||
tone={ratio > 0.8 ? "signal" : ratio > 0.6 ? "amber" : "vermilion"}
|
||||
/>
|
||||
<div className="flex w-full flex-col gap-1.5 text-xs">
|
||||
<Row label="Clean" value={clean} tone="var(--color-signal)" />
|
||||
<Row label="Warned" value={warned} tone="var(--color-amber)" />
|
||||
<Row label="Flagged" value={flagged} tone="var(--color-vermilion)" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-[var(--color-ink-soft)]">
|
||||
<span className="size-2 rounded-full" style={{ background: tone }} />
|
||||
{label}
|
||||
</span>
|
||||
<span className="mono text-[var(--color-ink)]">
|
||||
{value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Flame, Heart, SmilePlus } from "lucide-react";
|
||||
import { Avatar } from "@/components/primitives/avatar";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { EmptyState, LoadingSkeleton } from "@/components/shared";
|
||||
import { useTopReactions, useTopReactors } from "@/hooks";
|
||||
|
||||
export interface ReactionsSectionProps {
|
||||
initialReactions?: Awaited<ReturnType<typeof useTopReactions>>["data"];
|
||||
}
|
||||
|
||||
export function ReactionsSection() {
|
||||
const { data: reactions, isLoading: reactionsLoading } = useTopReactions();
|
||||
const { data: reactors, isLoading: reactorsLoading } = useTopReactors();
|
||||
|
||||
if (reactionsLoading || reactorsLoading) return <LoadingSkeleton count={5} />;
|
||||
|
||||
const topReactions = (reactions ?? []).slice(0, 6);
|
||||
const topReactors = (reactors ?? []).slice(0, 6);
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<div className="surface p-4">
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold">
|
||||
<Heart className="size-4 text-[var(--color-vermilion)]" />
|
||||
Top reactions
|
||||
</h3>
|
||||
{topReactions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={SmilePlus}
|
||||
title="No reactions"
|
||||
description="No reactions yet."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{topReactions
|
||||
.flatMap((m) =>
|
||||
m.top_emojis.map((e) => ({ emoji: e.emoji, count: e.count })),
|
||||
)
|
||||
.reduce<{ emoji: string; count: number }[]>((acc, cur) => {
|
||||
const found = acc.find((x) => x.emoji === cur.emoji);
|
||||
if (found) found.count += cur.count;
|
||||
else acc.push(cur);
|
||||
return acc;
|
||||
}, [])
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 8)
|
||||
.map((r) => (
|
||||
<span
|
||||
key={r.emoji}
|
||||
className="flex items-center gap-1.5 rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] px-2.5 py-1 text-sm"
|
||||
>
|
||||
<span>{r.emoji}</span>
|
||||
<span className="mono text-xs text-[var(--color-ink-soft)]">
|
||||
{r.count}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="surface p-4">
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold">
|
||||
<Flame className="size-4 text-[var(--color-amber)]" />
|
||||
Top reactors
|
||||
</h3>
|
||||
{topReactors.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={SmilePlus}
|
||||
title="No reactors"
|
||||
description="No reactors yet."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{topReactors.map((r) => (
|
||||
<div key={r.user_id} className="flex items-center gap-3">
|
||||
<Avatar name={r.username} size={28} />
|
||||
<span className="flex-1 text-sm">{r.username}</span>
|
||||
<Badge tone="signal">+{r.net_count}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Hash } from "lucide-react";
|
||||
import type { TopChannel } from "@/lib/types";
|
||||
|
||||
export interface TopChannelsChartProps {
|
||||
channels: TopChannel[];
|
||||
}
|
||||
|
||||
export function TopChannelsChart({ channels }: TopChannelsChartProps) {
|
||||
const max = Math.max(...channels.map((c) => c.message_count), 1);
|
||||
const top = [...channels]
|
||||
.sort((a, b) => b.message_count - a.message_count)
|
||||
.slice(0, 8);
|
||||
return (
|
||||
<div className="surface p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">Top channels</h3>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{top.map((c) => (
|
||||
<div key={c.channel_id} className="flex items-center gap-3">
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] text-[var(--color-ink-soft)]">
|
||||
<Hash className="size-3.5" />
|
||||
</span>
|
||||
<span className="w-32 shrink-0 truncate text-xs text-[var(--color-ink)]">
|
||||
{c.channel_name ?? c.channel_id}
|
||||
</span>
|
||||
<div className="relative h-2 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-2)]">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-[var(--color-signal)] transition-[width] duration-500"
|
||||
style={{ width: `${(c.message_count / max) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="mono w-12 shrink-0 text-right text-xs text-[var(--color-ink-soft)]">
|
||||
{c.message_count.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Search, Users } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Avatar } from "@/components/primitives/avatar";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { Input } from "@/components/primitives/input";
|
||||
import { EmptyState, LoadingSkeleton } from "@/components/shared";
|
||||
import { useUserDetail, useUsers } from "@/hooks";
|
||||
|
||||
const TRUST_TIERS = [
|
||||
{ min: 75, label: "Trusted", tone: "signal" as const },
|
||||
{ min: 40, label: "Neutral", tone: "neutral" as const },
|
||||
{ min: 10, label: "At Risk", tone: "amber" as const },
|
||||
{ min: 0, label: "Critical", tone: "vermilion" as const },
|
||||
];
|
||||
|
||||
function trustTier(score?: number | null) {
|
||||
const s = score ?? 0;
|
||||
return (
|
||||
TRUST_TIERS.find((t) => s >= t.min) ?? TRUST_TIERS[TRUST_TIERS.length - 1]
|
||||
);
|
||||
}
|
||||
|
||||
export function UsersSection() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const { data: users = [], isLoading } = useUsers(search);
|
||||
const { data: detail } = useUserDetail(selectedId);
|
||||
|
||||
const handleSearch = useCallback((v: string) => setSearch(v), []);
|
||||
|
||||
if (isLoading) return <LoadingSkeleton count={6} />;
|
||||
if (users.length === 0)
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No users found"
|
||||
description="Try a different search."
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_360px]">
|
||||
<div className="surface flex flex-col gap-2 p-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-soft)]" />
|
||||
<Input
|
||||
mono
|
||||
placeholder="search users…"
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{users.map((u) => {
|
||||
const tier = trustTier(u.trust_score);
|
||||
return (
|
||||
<button
|
||||
key={u.user_id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(u.user_id)}
|
||||
className="flex items-center gap-3 rounded-[var(--radius-r-control)] px-2 py-2 text-left transition-colors hover:bg-[var(--color-surface-2)]"
|
||||
>
|
||||
<Avatar src={u.avatar_url} name={u.username} size={34} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{u.username ?? "unknown"}
|
||||
</div>
|
||||
<div className="mono text-xs text-[var(--color-ink-soft)]">
|
||||
{u.total_messages.toLocaleString()} msg
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone={tier.tone}>{tier.label}</Badge>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface p-4">
|
||||
{detail ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar
|
||||
src={detail.avatar_url}
|
||||
name={detail.username}
|
||||
size={44}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-semibold">{detail.username}</div>
|
||||
<div className="text-xs text-[var(--color-ink-soft)]">
|
||||
{detail.total_messages.toLocaleString()} messages
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<Stat label="Trust" value={`${detail.trust_score ?? 0}`} />
|
||||
<Stat
|
||||
label="Clean streak"
|
||||
value={`${detail.clean_message_streak ?? 0}`}
|
||||
/>
|
||||
<Stat
|
||||
label="Infractions"
|
||||
value={`${detail.total_infractions ?? 0}`}
|
||||
tone="vermilion"
|
||||
/>
|
||||
<Stat
|
||||
label="Flagged"
|
||||
value={`${detail.flagged_count}`}
|
||||
tone="amber"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--color-ink-soft)]">
|
||||
{detail.profile_summary}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--color-ink-soft)]">
|
||||
Select a user to inspect.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "amber" | "vermilion";
|
||||
}) {
|
||||
return (
|
||||
<div className="surface-2 p-2.5">
|
||||
<div className="text-[11px] uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={`mono text-lg font-semibold ${tone === "amber" ? "text-[var(--color-amber)]" : tone === "vermilion" ? "text-[var(--color-vermilion)]" : "text-[var(--color-ink)]"}`}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* EventFeed — horizontal scroll-snap timeline that ingests live events.
|
||||
*
|
||||
* The feed is the central column of the dashboard. Time runs left → right
|
||||
* (older → newer). New events append at the right edge; the feed scrolls
|
||||
* right when the user is at the live edge and pauses when the user drags
|
||||
* back to inspect history.
|
||||
*
|
||||
* Ring buffer keeps the DOM bounded (200 items). A `NowMarker` is inserted
|
||||
* every 10 events or every 30 seconds to break the row rhythm with a pulse
|
||||
* summary — see `useFeedPulse`.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { EventRow, type FeedEvent } from "@/components/feed/event-row";
|
||||
import { ClusterMarker, PulseMarker } from "@/components/feed/now-marker";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const RING_BUFFER_MAX = 200;
|
||||
const PULSE_EVERY_N_EVENTS = 10;
|
||||
const PULSE_EVERY_MS = 30_000;
|
||||
|
||||
export type FeedItem =
|
||||
| { kind: "event"; event: FeedEvent }
|
||||
| {
|
||||
kind: "pulse";
|
||||
key: string;
|
||||
ts: number;
|
||||
label: string;
|
||||
summary: string;
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
}
|
||||
| {
|
||||
kind: "cluster";
|
||||
key: string;
|
||||
ts: number;
|
||||
label: string;
|
||||
bands: {
|
||||
tone: "neutral" | "signal" | "amber" | "vermilion";
|
||||
ratio: number;
|
||||
}[];
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
};
|
||||
|
||||
interface EventFeedProps {
|
||||
initialEvents: FeedEvent[];
|
||||
subscribe: (handler: (e: FeedEvent) => void) => () => void;
|
||||
className?: string;
|
||||
emptyState?: ReactNode;
|
||||
}
|
||||
|
||||
export function EventFeed({
|
||||
initialEvents,
|
||||
subscribe,
|
||||
className,
|
||||
emptyState,
|
||||
}: EventFeedProps) {
|
||||
const [items, setItems] = useState<FeedItem[]>(() =>
|
||||
injectMarkers(initialEvents.slice(-RING_BUFFER_MAX)),
|
||||
);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [following, setFollowing] = useState(true);
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastPulseAt = useRef<number>(Date.now());
|
||||
|
||||
// Live WS ingest
|
||||
useEffect(() => {
|
||||
const unsub = subscribe((e) => {
|
||||
setItems((prev) => appendWithMarker(prev, e));
|
||||
});
|
||||
return unsub;
|
||||
}, [subscribe]);
|
||||
|
||||
// Periodic pulse even if traffic is slow — keeps the feed rhythm alive.
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
setItems((prev) => {
|
||||
if (Date.now() - lastPulseAt.current < PULSE_EVERY_MS) return prev;
|
||||
return appendPulse(prev, "system", "live · standing by");
|
||||
});
|
||||
}, PULSE_EVERY_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Auto-scroll on append when following.
|
||||
useEffect(() => {
|
||||
if (!following) return;
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTo({ left: el.scrollWidth, behavior: "smooth" });
|
||||
}, [following]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
const distFromRight = el.scrollWidth - el.scrollLeft - el.clientWidth;
|
||||
setFollowing(distFromRight < 24);
|
||||
}, []);
|
||||
|
||||
const handleSelect = useCallback((id: string) => {
|
||||
setSelectedId((cur) => (cur === id ? null : id));
|
||||
}, []);
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
if (items.length <= RING_BUFFER_MAX) return items;
|
||||
return items.slice(items.length - RING_BUFFER_MAX);
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-full w-full overflow-hidden",
|
||||
"border-t border-[var(--color-hairline)]",
|
||||
className,
|
||||
)}
|
||||
data-following={following ? "1" : "0"}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] bg-[var(--color-surface)] px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--color-ink-soft)]">
|
||||
<span>event horizon</span>
|
||||
<span>
|
||||
{visibleItems.filter((i) => i.kind === "event").length} events ·{" "}
|
||||
{following ? "live" : "paused"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
onScroll={handleScroll}
|
||||
className={cn(
|
||||
"h-[calc(100%-30px)] overflow-y-auto overflow-x-hidden",
|
||||
"snap-y snap-mandatory",
|
||||
"scroll-pt-2",
|
||||
)}
|
||||
role="feed"
|
||||
aria-live="polite"
|
||||
>
|
||||
{visibleItems.length === 0 && emptyState ? (
|
||||
<div className="flex h-full items-center justify-center p-8 text-center font-mono text-[12px] text-[var(--color-ink-soft)]">
|
||||
{emptyState}
|
||||
</div>
|
||||
) : (
|
||||
visibleItems.map((item) => {
|
||||
if (item.kind === "event") {
|
||||
return (
|
||||
<div key={item.event.id} className="snap-start">
|
||||
<EventRow
|
||||
event={item.event}
|
||||
selected={selectedId === item.event.id}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (item.kind === "cluster") {
|
||||
return (
|
||||
<div key={item.key} className="snap-start">
|
||||
<ClusterMarker
|
||||
label={item.label}
|
||||
timestamp={item.ts}
|
||||
bands={item.bands}
|
||||
tone={item.tone}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={item.key} className="snap-start">
|
||||
<PulseMarker
|
||||
label={item.label}
|
||||
timestamp={item.ts}
|
||||
trailing={item.summary}
|
||||
tone={item.tone}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Ring + pulse helpers ────────────────────────────────────────
|
||||
|
||||
function injectMarkers(events: FeedEvent[]): FeedItem[] {
|
||||
if (events.length === 0) return [];
|
||||
const out: FeedItem[] = [];
|
||||
let count = 0;
|
||||
for (const e of events) {
|
||||
out.push({ kind: "event", event: e });
|
||||
count++;
|
||||
if (count % PULSE_EVERY_N_EVENTS === 0) {
|
||||
out.push({
|
||||
kind: "cluster",
|
||||
key: `cluster-${e.id}`,
|
||||
ts: e.ts,
|
||||
label: "pulse",
|
||||
bands: deriveBands(
|
||||
events.slice(Math.max(0, count - PULSE_EVERY_N_EVENTS), count),
|
||||
),
|
||||
tone: "signal",
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function deriveBands(
|
||||
window: FeedEvent[],
|
||||
): { tone: "neutral" | "signal" | "amber" | "vermilion"; ratio: number }[] {
|
||||
const counts: Record<"neutral" | "signal" | "amber" | "vermilion", number> = {
|
||||
neutral: 0,
|
||||
signal: 0,
|
||||
amber: 0,
|
||||
vermilion: 0,
|
||||
};
|
||||
for (const e of window) counts[e.severity]++;
|
||||
const total = window.length || 1;
|
||||
return (Object.keys(counts) as Array<keyof typeof counts>).map((k) => ({
|
||||
tone: k,
|
||||
ratio: counts[k] / total,
|
||||
}));
|
||||
}
|
||||
|
||||
function appendWithMarker(prev: FeedItem[], e: FeedEvent): FeedItem[] {
|
||||
const next = [...prev, { kind: "event" as const, event: e }];
|
||||
const eventsSinceLastPulse = next.filter((i) => i.kind === "event").length;
|
||||
if (eventsSinceLastPulse % PULSE_EVERY_N_EVENTS === 0) {
|
||||
const recentEvents = next
|
||||
.filter((i) => i.kind === "event")
|
||||
.slice(-PULSE_EVERY_N_EVENTS)
|
||||
.map((i) => (i as { kind: "event"; event: FeedEvent }).event);
|
||||
next.push({
|
||||
kind: "cluster",
|
||||
key: `cluster-${e.id}`,
|
||||
ts: e.ts,
|
||||
label: "pulse",
|
||||
bands: deriveBands(recentEvents),
|
||||
tone: "signal",
|
||||
});
|
||||
}
|
||||
if (next.length > RING_BUFFER_MAX * 2) {
|
||||
return next.slice(next.length - RING_BUFFER_MAX);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function appendPulse(
|
||||
prev: FeedItem[],
|
||||
label: string,
|
||||
summary: string,
|
||||
): FeedItem[] {
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
kind: "pulse",
|
||||
key: `pulse-${Date.now()}`,
|
||||
ts: Date.now(),
|
||||
label,
|
||||
summary,
|
||||
tone: "signal",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* EventRow — single row in the horizontal event-feed timeline.
|
||||
*
|
||||
* No card chrome. The row is a single typographic line: mono timestamp,
|
||||
* severity dot, actor mention, action verb, channel jump, excerpt.
|
||||
*
|
||||
* Hover reveals full excerpt and selection state; click toggles selection
|
||||
* so the right rail / command line can target the event.
|
||||
*/
|
||||
|
||||
import { type ReactNode, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type EventSeverity = "neutral" | "signal" | "amber" | "vermilion";
|
||||
|
||||
export interface FeedEvent {
|
||||
/** Stable id from the upstream record. Used as React key. */
|
||||
id: string;
|
||||
/** Unix epoch ms. */
|
||||
ts: number;
|
||||
/** Severity tone — drives dot color and zebra fill. */
|
||||
severity: EventSeverity;
|
||||
/** Display label for the actor ("alice", "@everyone", "Carl-bot"). */
|
||||
actor: string;
|
||||
/** Verb describing the action ("sent", "flagged", "joined", "muted"). */
|
||||
action: string;
|
||||
/** Channel reference (monogram display only — no chrome). */
|
||||
channel?: string | null;
|
||||
/** Message excerpt or action payload text. Truncated when long. */
|
||||
excerpt: string;
|
||||
/** Optional metadata tag (e.g. "ai:flag", "voice:join"). */
|
||||
tag?: string | null;
|
||||
}
|
||||
|
||||
interface EventRowProps {
|
||||
event: FeedEvent;
|
||||
selected?: boolean;
|
||||
onSelect?: (id: string) => void;
|
||||
}
|
||||
|
||||
const SEVERITY_DOT: Record<EventSeverity, string> = {
|
||||
neutral: "oklch(0.46 0.02 70)",
|
||||
signal: "var(--color-signal)",
|
||||
amber: "var(--color-amber)",
|
||||
vermilion: "var(--color-vermilion)",
|
||||
};
|
||||
|
||||
const SEVERITY_FILL: Record<EventSeverity, string> = {
|
||||
neutral: "transparent",
|
||||
signal: "oklch(0.78 0.17 125 / 0.06)",
|
||||
amber: "oklch(0.80 0.15 70 / 0.07)",
|
||||
vermilion: "oklch(0.62 0.21 25 / 0.08)",
|
||||
};
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
export function EventRow({ event, selected, onSelect }: EventRowProps) {
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect?.(event.id);
|
||||
}, [event.id, onSelect]);
|
||||
|
||||
const dot: ReactNode = (
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block size-1.5 shrink-0 rounded-full"
|
||||
style={{ background: SEVERITY_DOT[event.severity] }}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"group relative flex w-full items-baseline gap-3 px-3 py-1.5 text-left font-mono text-[12px] leading-5 transition-colors",
|
||||
"hover:bg-[oklch(0.92_0.014_80_/_0.6)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-signal)] focus-visible:outline-offset-[-2px]",
|
||||
selected && "bg-[oklch(0.92_0.014_80_/_0.8)]",
|
||||
)}
|
||||
style={{
|
||||
background: selected ? undefined : SEVERITY_FILL[event.severity],
|
||||
}}
|
||||
data-event-id={event.id}
|
||||
data-severity={event.severity}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 w-[2px] origin-center transition-transform",
|
||||
selected ? "scale-y-100" : "scale-y-0 group-hover:scale-y-100",
|
||||
)}
|
||||
style={{ background: SEVERITY_DOT[event.severity] }}
|
||||
/>
|
||||
|
||||
<span className="w-[68px] shrink-0 text-[var(--color-ink-soft)] tabular-nums">
|
||||
{formatTimestamp(event.ts)}
|
||||
</span>
|
||||
|
||||
{dot}
|
||||
|
||||
<span className="w-[120px] shrink-0 truncate text-[var(--color-ink)]">
|
||||
{event.actor}
|
||||
</span>
|
||||
|
||||
<span className="w-[80px] shrink-0 text-[var(--color-ink-soft)]">
|
||||
{event.action}
|
||||
</span>
|
||||
|
||||
{event.channel ? (
|
||||
<span className="w-[140px] shrink-0 truncate text-[var(--color-ink-soft)]">
|
||||
{event.channel}
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-[140px] shrink-0" aria-hidden />
|
||||
)}
|
||||
|
||||
<span className="min-w-0 flex-1 truncate text-[var(--color-ink)]">
|
||||
{event.excerpt}
|
||||
</span>
|
||||
|
||||
{event.tag ? (
|
||||
<span className="shrink-0 rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] px-1.5 py-px text-[10px] uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
{event.tag}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* NowMarker — inline callout that breaks the feed timeline rhythm.
|
||||
*
|
||||
* Two variants: `pulse` (one-line summary) and `cluster` (horizontal stack bar
|
||||
* visualising severity distribution across a recent window). Both use a
|
||||
* border-tip on the left in signal tone; no card chrome, no shadow.
|
||||
*/
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Tone = "signal" | "amber" | "vermilion" | "neutral";
|
||||
|
||||
interface PulseMarkerProps {
|
||||
tone?: Tone;
|
||||
label: string;
|
||||
timestamp: number;
|
||||
/** Optional small caps label on the right. */
|
||||
trailing?: string;
|
||||
}
|
||||
|
||||
interface ClusterMarkerProps {
|
||||
tone?: Tone;
|
||||
label: string;
|
||||
timestamp: number;
|
||||
/** Fractions of each severity band; must sum to 1. */
|
||||
bands: { tone: Tone; ratio: number }[];
|
||||
}
|
||||
|
||||
const TONE_TIP: Record<Tone, string> = {
|
||||
signal: "var(--color-signal)",
|
||||
amber: "var(--color-amber)",
|
||||
vermilion: "var(--color-vermilion)",
|
||||
neutral: "oklch(0.46 0.02 70)",
|
||||
};
|
||||
|
||||
const TONE_FILL: Record<Tone, string> = {
|
||||
signal: "oklch(0.78 0.17 125 / 0.12)",
|
||||
amber: "oklch(0.80 0.15 70 / 0.14)",
|
||||
vermilion: "oklch(0.62 0.21 25 / 0.12)",
|
||||
neutral: "oklch(0.46 0.02 70 / 0.08)",
|
||||
};
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function MarkerShell({
|
||||
tone,
|
||||
label,
|
||||
timestamp,
|
||||
trailing,
|
||||
children,
|
||||
}: {
|
||||
tone: Tone;
|
||||
label: string;
|
||||
timestamp: number;
|
||||
trailing?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="relative my-2 flex items-center gap-3 px-3 py-2 font-mono text-[11px]"
|
||||
style={{ background: TONE_FILL[tone] }}
|
||||
data-marker={tone}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-1 left-0 w-[3px]"
|
||||
style={{ background: TONE_TIP[tone] }}
|
||||
/>
|
||||
<span className="w-[68px] shrink-0 text-[var(--color-ink-soft)] tabular-nums">
|
||||
{formatTimestamp(timestamp)}
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 text-[10px] font-medium uppercase tracking-[0.18em]"
|
||||
style={{ color: TONE_TIP[tone] }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[var(--color-ink)]">
|
||||
{children}
|
||||
</span>
|
||||
{trailing ? (
|
||||
<span className="shrink-0 text-[10px] uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
{trailing}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PulseMarker({
|
||||
tone = "signal",
|
||||
label,
|
||||
timestamp,
|
||||
trailing,
|
||||
}: PulseMarkerProps) {
|
||||
return (
|
||||
<MarkerShell
|
||||
tone={tone}
|
||||
label={label}
|
||||
timestamp={timestamp}
|
||||
trailing={trailing}
|
||||
>
|
||||
{/* children rendered by parent via composition — see NowMarker union below */}
|
||||
</MarkerShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClusterMarker({
|
||||
tone = "signal",
|
||||
label,
|
||||
timestamp,
|
||||
bands,
|
||||
}: ClusterMarkerProps) {
|
||||
return (
|
||||
<MarkerShell tone={tone} label={label} timestamp={timestamp}>
|
||||
<div className="flex h-3 w-full max-w-[280px] overflow-hidden rounded-[var(--radius-r-control)]">
|
||||
{bands.map((b) => (
|
||||
<span
|
||||
key={b.tone}
|
||||
className={cn("h-full")}
|
||||
style={{
|
||||
width: `${Math.max(0, Math.min(1, b.ratio)) * 100}%`,
|
||||
background: TONE_TIP[b.tone],
|
||||
opacity: b.tone === "neutral" ? 0.4 : 1,
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</MarkerShell>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DashLeftRail — 80px vertical monogram nav.
|
||||
*
|
||||
* Each item is a glyph + label. Active state uses an accent bar on the left
|
||||
* and full ink colour. No backgrounds, no boxes.
|
||||
*/
|
||||
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Flag,
|
||||
MessagesSquare,
|
||||
Mic,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
glyph: React.ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const ITEMS: NavItem[] = [
|
||||
{
|
||||
href: "/dashboard",
|
||||
glyph: <BarChart3 className="size-4" />,
|
||||
label: "Console",
|
||||
},
|
||||
{
|
||||
href: "/messages",
|
||||
glyph: <MessagesSquare className="size-4" />,
|
||||
label: "Messages",
|
||||
},
|
||||
{
|
||||
href: "/moderation",
|
||||
glyph: <ShieldCheck className="size-4" />,
|
||||
label: "Moderation",
|
||||
},
|
||||
{ href: "/voice", glyph: <Mic className="size-4" />, label: "Voice" },
|
||||
{ href: "/media", glyph: <Activity className="size-4" />, label: "Media" },
|
||||
{
|
||||
href: "/recordings",
|
||||
glyph: <Flag className="size-4" />,
|
||||
label: "Recordings",
|
||||
},
|
||||
{ href: "/analysis", glyph: <Users className="size-4" />, label: "Analysis" },
|
||||
];
|
||||
|
||||
export function DashLeftRail() {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<nav
|
||||
aria-label="Console navigation"
|
||||
className="flex h-full w-20 shrink-0 flex-col items-center gap-1 border-r border-[var(--color-hairline)] bg-[var(--color-surface)] py-3"
|
||||
>
|
||||
{ITEMS.map((it) => {
|
||||
const active =
|
||||
pathname === it.href || pathname?.startsWith(`${it.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={it.href}
|
||||
href={it.href}
|
||||
className={cn(
|
||||
"group relative flex w-full flex-col items-center gap-1 py-2 text-[10px] uppercase tracking-wide transition-colors",
|
||||
active
|
||||
? "text-[var(--color-ink)]"
|
||||
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
|
||||
)}
|
||||
data-active={active ? "1" : "0"}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"absolute inset-y-2 left-0 w-[2px] origin-center transition-transform",
|
||||
active ? "scale-y-100" : "scale-y-0 group-hover:scale-y-100",
|
||||
)}
|
||||
style={{ background: "var(--color-signal)" }}
|
||||
/>
|
||||
{it.glyph}
|
||||
<span className="font-mono">{it.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DashRightRail — 320px collapsible drawer.
|
||||
*
|
||||
* Holds the live AI verdict stream, active voice speakers, and the latest
|
||||
* moderation actions. Reads from existing hooks (`useVoice`, etc.) — no
|
||||
* new fetches; just re-presentation.
|
||||
*/
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSpeakers } from "@/hooks/use-voice";
|
||||
import type { ActiveSpeaker } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
interface DashRightRailProps {
|
||||
pendingVerdicts?: { id: string; ts: number; text: string }[];
|
||||
recentActions?: { id: string; ts: number; verb: string; target: string }[];
|
||||
}
|
||||
|
||||
export function DashRightRail({
|
||||
pendingVerdicts = [],
|
||||
recentActions = [],
|
||||
}: DashRightRailProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const { subscribe } = useSpeakers();
|
||||
const ws = useWebSocket();
|
||||
const [speakers, _setSpeakers] = useState<ActiveSpeaker[]>([]);
|
||||
useEffect(() => subscribe(ws), [ws, subscribe]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"relative shrink-0 border-l border-[var(--color-hairline)] bg-[var(--color-surface)] font-mono text-[11px] transition-[width]",
|
||||
collapsed ? "w-9" : "w-[320px]",
|
||||
)}
|
||||
aria-label="Live activity rail"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className={cn(
|
||||
"absolute -left-3 top-3 z-10 flex size-6 items-center justify-center rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)] text-[var(--color-ink-soft)] transition-colors hover:text-[var(--color-ink)]",
|
||||
)}
|
||||
aria-label={
|
||||
collapsed
|
||||
? "Expand live activity rail"
|
||||
: "Collapse live activity rail"
|
||||
}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-3 transition-transform",
|
||||
collapsed ? "" : "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{collapsed ? (
|
||||
<div className="flex h-full flex-col items-center gap-4 py-4">
|
||||
<Section title="ai" vertical />
|
||||
<Section title="voice" vertical />
|
||||
<Section title="mod" vertical />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
<Section title="ai verdicts">
|
||||
{pendingVerdicts.length === 0 ? (
|
||||
<Empty msg="no pending verdicts" />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{pendingVerdicts.slice(0, 8).map((v) => (
|
||||
<li key={v.id} className="flex items-baseline gap-2">
|
||||
<span className="shrink-0 text-[var(--color-ink-soft)] tabular-nums">
|
||||
{formatTs(v.ts)}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-[var(--color-ink)]">
|
||||
{v.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="voice">
|
||||
{speakers.length === 0 ? (
|
||||
<Empty msg="no one speaking" />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{speakers.slice(0, 8).map((sp) => (
|
||||
<li key={sp.userId} className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block size-1.5 rounded-full",
|
||||
sp.speaking
|
||||
? "bg-[var(--color-signal)]"
|
||||
: "bg-[var(--color-ink-soft)]",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate text-[var(--color-ink)]">
|
||||
{sp.username ?? sp.userId}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="mod queue">
|
||||
{recentActions.length === 0 ? (
|
||||
<Empty msg="queue empty" />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{recentActions.slice(0, 8).map((a) => (
|
||||
<li key={a.id} className="flex items-baseline gap-2">
|
||||
<span className="shrink-0 text-[var(--color-ink-soft)] tabular-nums">
|
||||
{formatTs(a.ts)}
|
||||
</span>
|
||||
<span className="text-[var(--color-ink-soft)]">
|
||||
{a.verb}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-[var(--color-ink)]">
|
||||
{a.target}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="socket">
|
||||
<div className="flex flex-col gap-0.5 text-[10px]">
|
||||
<span className="text-[var(--color-ink-soft)]">status</span>
|
||||
<span className="text-[var(--color-ink)]">{ws.status}</span>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
children,
|
||||
vertical,
|
||||
}: {
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
vertical?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"border-b border-[var(--color-hairline)] px-3 py-2.5",
|
||||
vertical && "flex flex-col items-center gap-2 border-b-0 py-4",
|
||||
)}
|
||||
>
|
||||
<h3 className="mb-1.5 text-[10px] uppercase tracking-[0.18em] text-[var(--color-ink-soft)]">
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty({ msg }: { msg: string }) {
|
||||
return (
|
||||
<span className="text-[10px] italic text-[var(--color-ink-soft)]">
|
||||
{msg}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTs(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DashTopBar — 48px utility strip.
|
||||
*
|
||||
* No navigation chrome — just brand monogram, guild indicator, WS connection
|
||||
* state, clock, and focus mode. Designed to read as a single line of
|
||||
* instrument readout, not a navbar.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
type FocusMode = "quiet" | "standard" | "triage";
|
||||
const FOCUS_MODES: FocusMode[] = ["quiet", "standard", "triage"];
|
||||
|
||||
interface DashTopBarProps {
|
||||
guildName: string;
|
||||
botName?: string;
|
||||
}
|
||||
|
||||
function formatClock(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
||||
}
|
||||
|
||||
export function DashTopBar({ guildName, botName = "GMW" }: DashTopBarProps) {
|
||||
const ws = useWebSocket();
|
||||
const [now, setNow] = useState<Date | null>(null);
|
||||
const [focus, setFocus] = useState<FocusMode>("standard");
|
||||
const [tz, setTz] = useState<"utc" | "local">("local");
|
||||
|
||||
useEffect(() => {
|
||||
setNow(new Date());
|
||||
const id = window.setInterval(() => setNow(new Date()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const connected = ws.status === "connected";
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex h-12 items-center justify-between gap-4 border-b border-[var(--color-hairline)] bg-[var(--color-surface)] px-4 font-mono text-[11px]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="display text-base font-medium text-[var(--color-ink)]">
|
||||
{botName}
|
||||
</span>
|
||||
<span className="text-[var(--color-ink-soft)]">·</span>
|
||||
<span className="text-[var(--color-ink-soft)]">{guildName}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"inline-block size-1.5 rounded-full",
|
||||
connected
|
||||
? "bg-[var(--color-signal)]"
|
||||
: "bg-[var(--color-vermilion)]",
|
||||
)}
|
||||
style={{
|
||||
boxShadow: connected
|
||||
? "0 0 0 0 oklch(from var(--color-signal) l c h / 0.45)"
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
<span className="uppercase tracking-[0.18em] text-[var(--color-ink-soft)]">
|
||||
{ws.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTz((t) => (t === "utc" ? "local" : "utc"))}
|
||||
className="rounded-[var(--radius-r-control)] px-2 py-0.5 text-[var(--color-ink-soft)] transition-colors hover:bg-[var(--color-surface-2)] hover:text-[var(--color-ink)]"
|
||||
aria-label="Toggle UTC / local timezone"
|
||||
>
|
||||
{now
|
||||
? tz === "utc"
|
||||
? `${formatClock(now)} UTC`
|
||||
: formatLocal(now)
|
||||
: "--:--:--"}
|
||||
</button>
|
||||
|
||||
<div className="flex gap-0.5 rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] p-0.5">
|
||||
{FOCUS_MODES.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setFocus(m)}
|
||||
className={cn(
|
||||
"rounded-[var(--radius-r-control)] px-2 py-0.5 text-[10px] uppercase tracking-wide transition-colors",
|
||||
focus === m
|
||||
? "bg-[var(--color-canvas)] text-[var(--color-ink)]"
|
||||
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
|
||||
)}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function formatLocal(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { navItems } from "@/lib/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const titleFromPath: Record<string, string> = {
|
||||
"/dashboard": "Overview",
|
||||
"/messages": "Messages",
|
||||
"/voice": "Voice",
|
||||
"/media": "Media",
|
||||
"/recordings": "Recordings",
|
||||
"/moderation": "Moderation",
|
||||
"/analysis": "Analysis",
|
||||
};
|
||||
|
||||
export function Spine() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop rail */}
|
||||
<nav className="fixed left-0 top-0 z-30 hidden h-svh w-[68px] flex-col items-center gap-1 border-r border-[var(--color-hairline)] bg-[var(--color-canvas)]/80 py-4 backdrop-blur-md md:flex">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="mb-3 flex size-9 items-center justify-center rounded-[var(--radius-r-control)] bg-[var(--color-signal)] text-sm font-black text-[var(--color-signal-ink)]"
|
||||
aria-label="GMW"
|
||||
>
|
||||
B
|
||||
</Link>
|
||||
{navItems.map((item) => {
|
||||
const active = pathname.startsWith(item.matchPrefix);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"group relative flex size-11 items-center justify-center rounded-[var(--radius-r)] transition-colors",
|
||||
active
|
||||
? "text-[var(--color-signal)]"
|
||||
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
|
||||
)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId="spine-active"
|
||||
className="absolute left-0 top-1/2 size-1 -translate-y-1/2 rounded-full bg-[var(--color-signal)]"
|
||||
transition={{ type: "spring", stiffness: 380, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<Icon className="size-5" />
|
||||
<span className="pointer-events-none absolute left-full ml-2 hidden whitespace-nowrap rounded-[var(--radius-r-control)] bg-[var(--color-ink)] px-2 py-1 text-xs font-medium text-[var(--color-canvas)] opacity-0 transition-opacity group-hover:opacity-100 md:block">
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Mobile bottom tab-bar */}
|
||||
<nav className="fixed inset-x-0 bottom-0 z-30 flex h-16 items-stretch border-t border-[var(--color-hairline)] bg-[var(--color-canvas)]/90 backdrop-blur-md md:hidden">
|
||||
{navItems.map((item) => {
|
||||
const active = pathname.startsWith(item.matchPrefix);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-center justify-center gap-0.5 text-[10px] font-medium transition-colors",
|
||||
active
|
||||
? "text-[var(--color-signal)]"
|
||||
: "text-[var(--color-ink-soft)]",
|
||||
)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageTitle() {
|
||||
const pathname = usePathname();
|
||||
const key =
|
||||
Object.keys(titleFromPath).find((k) => pathname.startsWith(k)) ??
|
||||
"/dashboard";
|
||||
return (
|
||||
<span className="font-semibold max-md:hidden">{titleFromPath[key]}</span>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useChatbot } from "@/components/chatbot/chatbot-context";
|
||||
import { GuildSelector } from "@/components/shared/guild-selector";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
import { PageTitle } from "./spine";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
|
||||
const statusTone: Record<string, string> = {
|
||||
connected: "bg-[var(--color-signal)]",
|
||||
connecting: "bg-[var(--color-amber)]",
|
||||
disconnected: "bg-[var(--color-ink-soft)]",
|
||||
error: "bg-[var(--color-vermilion)]",
|
||||
};
|
||||
|
||||
export function StatusBar({
|
||||
guildId,
|
||||
onGuildChange,
|
||||
}: {
|
||||
guildId: string;
|
||||
onGuildChange: (g: string) => void;
|
||||
}) {
|
||||
const ws = useWebSocket();
|
||||
const { expression } = useChatbot();
|
||||
const [clock, setClock] = useState("--:--:--");
|
||||
|
||||
useEffect(() => {
|
||||
const tick = () =>
|
||||
setClock(new Date().toLocaleTimeString("en-GB", { hour12: false }));
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 flex h-14 shrink-0 items-center gap-2 border-b border-[var(--color-hairline)] bg-[var(--color-canvas)]/85 px-4 backdrop-blur-md md:px-6">
|
||||
<PageTitle />
|
||||
<div className="ms-auto flex items-center gap-3">
|
||||
<span className="hidden items-center gap-1.5 text-xs text-[var(--color-ink-soft)] sm:flex">
|
||||
<span className={cn("size-2 rounded-full", statusTone[ws.status])} />
|
||||
<span className="mono uppercase">{ws.status}</span>
|
||||
</span>
|
||||
<span className="hidden text-xs text-[var(--color-ink-soft)] md:inline">
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono",
|
||||
expression !== "idle" && "text-[var(--color-signal)]",
|
||||
)}
|
||||
>
|
||||
{expression}
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden font-mono text-xs text-[var(--color-ink-soft)] lg:inline">
|
||||
{clock}
|
||||
</span>
|
||||
<GuildSelector
|
||||
value={guildId}
|
||||
onChange={(g) => onGuildChange(g ?? "")}
|
||||
/>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const isDark = resolvedTheme === "dark";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle theme"
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
className="flex size-9 items-center justify-center rounded-[var(--radius-r-control)] text-[var(--color-ink-soft)] transition-colors hover:bg-[var(--color-surface-2)] hover:text-[var(--color-ink)] focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]"
|
||||
>
|
||||
{mounted && (
|
||||
<motion.span
|
||||
key={isDark ? "moon" : "sun"}
|
||||
initial={{ rotate: -90, opacity: 0 }}
|
||||
animate={{ rotate: 0, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 360, damping: 26 }}
|
||||
className={cn(
|
||||
isDark ? "text-[var(--color-signal)]" : "text-[var(--color-amber)]",
|
||||
)}
|
||||
>
|
||||
{isDark ? <Moon className="size-4" /> : <Sun className="size-4" />}
|
||||
</motion.span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user