feat(frontend): rebuild as Ambient/WebGL console with all pages + command palette
Ground-up rombak UI: hapus semua component/page lama, bangun ulang dengan desain sistem Ambient (WebGL haze + drifting motes, signal-driven color) di atas kontrak API/WS/type yang sudah ada. - Design system: globals.css tokens + primitives (glass, button, badge, select, avatar, toast, chart SVG murni). - Shell: nav rail, topbar (status WS + pill signal + theme), AppFrame. - 8 halaman: dashboard, voice (orbital stage), media, messages (live feed + detail AI), moderation, analysis (search), recordings, + chatbot floating. - Command palette (Cmd/Ctrl+K) untuk navigasi cepat. - Server fetch di-page di-try/catch agar render graceful saat backend mati. Verified: tsc clean, next build 8/8 halaman, semua route 200.
This commit is contained in:
@@ -1,11 +1,7 @@
|
||||
"use client";
|
||||
import { AnalysisView } from "./view";
|
||||
|
||||
import { SearchPanel } from "@/components/analysis/search-panel";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function AnalysisPage() {
|
||||
return (
|
||||
<div className="space-y-5" style={{ animation: "fade-up 0.4s ease both" }}>
|
||||
<SearchPanel />
|
||||
</div>
|
||||
);
|
||||
return <AnalysisView />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Search, Sparkles, TrendingUp, Hash } from "lucide-react";
|
||||
import { useMessageSearch, useTopReactors, useChannels } from "@/hooks";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { GlassPanel, GlassCard, Avatar, Input, Badge } from "@/components/primitives";
|
||||
import { SectionHeader, EmptyState, LoadingState } from "@/components/shared";
|
||||
import { renderMessageContent, getMessageChannelLabel } from "@/lib/format";
|
||||
import type { AiStatus } from "@/lib/types";
|
||||
|
||||
function aiTone(s?: AiStatus | null): "signal" | "amber" | "vermilion" | "neutral" {
|
||||
if (s === "clean") return "signal";
|
||||
if (s === "warn") return "amber";
|
||||
if (s === "flagged" || s === "error") return "vermilion";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
export function AnalysisView() {
|
||||
const [query, setQuery] = useState("");
|
||||
const search = useMessageSearch(query, query.trim().length >= 2);
|
||||
const { data: reactors } = useTopReactors();
|
||||
const { data: channels } = useChannels();
|
||||
const ambient = useAmbient();
|
||||
|
||||
useEffect(() => {
|
||||
ambient.set(query ? "amber" : "signal", 0.3, query ? "analyzing" : "search");
|
||||
}, [query, ambient]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<GlassPanel glow className="relative overflow-hidden">
|
||||
<div className="scan-line absolute inset-x-0 top-0" />
|
||||
<div className="flex items-center gap-3">
|
||||
<Sparkles className="size-5 text-signal" />
|
||||
<div>
|
||||
<div className="eyebrow">Semantic search</div>
|
||||
<h2 className="display text-2xl text-ink">Search the archive</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-4">
|
||||
<Search className="absolute left-4 top-1/2 size-5 -translate-y-1/2 text-ink-faint" />
|
||||
<Input
|
||||
className="h-12 pl-12 text-base"
|
||||
placeholder="Find messages, patterns, flags…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{query.trim().length > 0 && query.trim().length < 2 && (
|
||||
<div className="mono mt-2 text-xs text-ink-faint">Type at least 2 characters…</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-5">
|
||||
<GlassPanel className="lg:col-span-3">
|
||||
<SectionHeader eyebrow="results" title="Matches" action={<span className="mono text-xs text-ink-faint">{(search.data ?? []).length}</span>} />
|
||||
{query.trim().length >= 2 && search.isLoading && <LoadingState label="Scanning" />}
|
||||
{(search.data ?? []).length === 0 ? (
|
||||
<EmptyState icon={<Search className="size-7" />} title="No matches yet" description="Run a search to surface messages across the guild." />
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{(search.data ?? []).map((m) => (
|
||||
<div key={m.id} className="flex items-start gap-3 rounded-[12px] border border-hairline bg-white/[0.03] p-3">
|
||||
<Avatar src={m.avatar_url} name={m.username} size={32} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-ink">{m.username}</span>
|
||||
<span className="mono text-[0.65rem] text-ink-faint">{getMessageChannelLabel(m)}</span>
|
||||
{m.ai_status && <Badge tone={aiTone(m.ai_status)} className="ml-auto">{m.ai_status}</Badge>}
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm text-ink-soft">{renderMessageContent(m.content, m.metadata) || "(embed)"}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
|
||||
<div className="space-y-5 lg:col-span-2">
|
||||
<GlassPanel>
|
||||
<SectionHeader eyebrow="culture" title={<span className="flex items-center gap-2"><TrendingUp className="size-4 text-signal" /> Top reactors</span>} />
|
||||
<div className="space-y-2">
|
||||
{(reactors ?? []).slice(0, 6).map((r, i) => (
|
||||
<div key={r.user_id} className="flex items-center gap-3 text-sm">
|
||||
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||
<span className="flex-1 truncate text-ink">{r.username}</span>
|
||||
<span className="mono text-xs text-signal">+{r.net_count}</span>
|
||||
</div>
|
||||
))}
|
||||
{(reactors ?? []).length === 0 && <div className="py-4 text-center text-xs text-ink-faint">No data</div>}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel>
|
||||
<SectionHeader eyebrow="channels" title={<span className="flex items-center gap-2"><Hash className="size-4 text-signal" /> Top channels</span>} />
|
||||
<div className="space-y-2">
|
||||
{(channels ?? []).slice(0, 6).map((c) => (
|
||||
<div key={c.channel_id} className="flex items-center gap-3 text-sm">
|
||||
<span className="flex-1 truncate text-ink-soft">{c.channel_name ?? c.channel_id.slice(0, 8)}</span>
|
||||
<span className="mono text-xs text-ink-faint">{c.total_messages}</span>
|
||||
</div>
|
||||
))}
|
||||
{(channels ?? []).length === 0 && <div className="py-4 text-center text-xs text-ink-faint">No data</div>}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,15 @@
|
||||
/**
|
||||
* Dashboard — Server Component.
|
||||
* Fetches initial stats + activity on the server (SSR first paint), hands to
|
||||
* the hydrated client View. Keeps the documented server-seed data flow.
|
||||
*/
|
||||
import { getActivity, getDashboardStats } from "@/lib/api/server";
|
||||
import DashboardView from "./view";
|
||||
import { DashboardView } from "./view";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const [stats, activity] = await Promise.allSettled([
|
||||
getDashboardStats().catch(() => undefined),
|
||||
getActivity(14).catch(() => undefined),
|
||||
]);
|
||||
|
||||
return (
|
||||
<DashboardView
|
||||
initialStats={
|
||||
stats.status === "fulfilled" && stats.value ? stats.value : undefined
|
||||
}
|
||||
initialActivity={
|
||||
activity.status === "fulfilled" && activity.value
|
||||
? activity.value
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
let stats = undefined;
|
||||
let activity = undefined;
|
||||
try {
|
||||
[stats, activity] = await Promise.all([getDashboardStats(), getActivity(14)]);
|
||||
} catch {
|
||||
// Backend unavailable — client hooks will surface the error state.
|
||||
}
|
||||
return <DashboardView initialStats={stats} initialActivity={activity} />;
|
||||
}
|
||||
|
||||
@@ -1,144 +1,228 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Dashboard — Ambient Field layout.
|
||||
*
|
||||
* No top bar. No side rail. No grid. No panels.
|
||||
*
|
||||
* A full-bleed WebGL haze (AmbientField) is the page. Content floats over it:
|
||||
* a giant headline bottom-left, a live metric cluster top-right, a drifting
|
||||
* event ribbon mid-screen, a command whispher at the very bottom. Whitespace
|
||||
* is the layout — density comes from data, not chrome.
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Flag,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
Radio,
|
||||
ShieldAlert,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useActivity,
|
||||
useStats,
|
||||
useTopReactors,
|
||||
useTopReactions,
|
||||
} from "@/hooks";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { GlassPanel, GlassCard } from "@/components/primitives";
|
||||
import {
|
||||
AreaActivity,
|
||||
Donut,
|
||||
RadialGauge,
|
||||
Sparkline,
|
||||
} from "@/components/charts";
|
||||
import { MetricTile, SectionHeader } from "@/components/shared/section";
|
||||
import { ErrorState, LoadingState } from "@/components/shared";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import type { DashboardStats } from "@/lib/types";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { AmbientField } from "@/components/ambient/ambient-field";
|
||||
import { DashCommandLine } from "@/components/command/dash-command-line";
|
||||
import type { DashboardActivity, DashboardStats } from "@/lib/types";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
function deriveSignal(stats?: DashboardStats) {
|
||||
if (!stats) return { tone: "signal" as const, label: "nominal" };
|
||||
const total = stats.total_flagged + stats.total_clean || 1;
|
||||
const ratio = stats.total_flagged / total;
|
||||
if (stats.moderation_overview.error > 0) return { tone: "vermilion" as const, label: "moderation fault" };
|
||||
if (ratio > 0.25) return { tone: "vermilion" as const, label: "elevated flags" };
|
||||
if (ratio > 0.1) return { tone: "amber" as const, label: "watch" };
|
||||
return { tone: "signal" as const, label: "nominal" };
|
||||
}
|
||||
|
||||
export default function DashboardView({
|
||||
export function DashboardView({
|
||||
initialStats,
|
||||
initialActivity,
|
||||
}: {
|
||||
initialStats?: DashboardStats;
|
||||
initialActivity?: DashboardActivity;
|
||||
initialActivity?: Awaited<ReturnType<typeof useActivity>>["data"];
|
||||
}) {
|
||||
const ws = useWebSocket();
|
||||
const [signal, setSignal] = useState<
|
||||
"signal" | "amber" | "vermilion" | "neutral"
|
||||
>("signal");
|
||||
const [load, setLoad] = useState(0.3);
|
||||
const { data: stats, isLoading, error } = useStats(initialStats);
|
||||
const { data: activity } = useActivity(14, initialActivity as never);
|
||||
const { data: reactors } = useTopReactors();
|
||||
const { data: reactions } = useTopReactions();
|
||||
const ambient = useAmbient();
|
||||
|
||||
const total = initialStats?.total_messages ?? 0;
|
||||
const clean = initialStats?.total_clean ?? 0;
|
||||
const flagged = initialStats?.total_flagged ?? 0;
|
||||
const warned = initialStats?.total_warned ?? 0;
|
||||
const ratio = ((clean / (clean + flagged + warned || 1)) * 100).toFixed(1);
|
||||
useEffect(() => {
|
||||
const s = deriveSignal(stats);
|
||||
ambient.set(s.tone, 0.3 + Math.min(0.5, (stats?.today_flagged ?? 0) / 50), s.label);
|
||||
}, [stats, ambient]);
|
||||
|
||||
const _subscribe = useCallback(
|
||||
(handler: (e: { severity: string; ts: number }) => void) => {
|
||||
const unsub = ws.on("message_created", (data: any) => {
|
||||
const s = data.ai_status;
|
||||
setSignal(
|
||||
s === "flagged" ? "vermilion" : s === "warn" ? "amber" : "signal",
|
||||
);
|
||||
setLoad((l) => Math.min(1, l + 0.02));
|
||||
handler({
|
||||
severity: s ?? "neutral",
|
||||
ts: data.created_at ?? Date.now(),
|
||||
});
|
||||
});
|
||||
return unsub;
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
if (error && !stats) return <ErrorState error={error} />;
|
||||
if (!stats && isLoading) return <LoadingState label="Reading grid" />;
|
||||
|
||||
const seedEvents = useMemo(() => {
|
||||
if (!initialActivity) return [];
|
||||
return initialActivity.daily.slice(-10).flatMap((d) =>
|
||||
Array.from({ length: Math.min(3, d.messages) }, (_, i) => ({
|
||||
id: `seed-${d.day}-${i}`,
|
||||
ts: Date.now() - i * 120_000,
|
||||
severity: i < d.flagged ? "vermilion" : "signal",
|
||||
actor: i < d.flagged ? "ai" : "user",
|
||||
action: i < d.flagged ? "flagged" : "sent",
|
||||
channel: "#general",
|
||||
excerpt: `seed ${d.day}`,
|
||||
})),
|
||||
);
|
||||
}, [initialActivity]);
|
||||
const s = stats!;
|
||||
const total = s.total_flagged + s.total_clean || 1;
|
||||
const cleanRatio = s.total_clean / total;
|
||||
|
||||
return (
|
||||
<div className="relative h-[calc(100svh-3rem)] w-full overflow-hidden bg-[var(--color-canvas)]">
|
||||
<AmbientField load={load} signal={signal} />
|
||||
|
||||
{/* Metric cluster — top right, floating, no container */}
|
||||
<div className="absolute right-6 top-6 flex flex-col items-end gap-1 font-mono text-right">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[var(--color-ink-soft)]">
|
||||
watched
|
||||
</span>
|
||||
<span className="display text-5xl font-medium tabular-nums leading-none text-[var(--color-ink)]">
|
||||
{total.toLocaleString()}
|
||||
</span>
|
||||
<div className="mt-2 flex gap-4 text-[12px]">
|
||||
<span className="text-[var(--color-signal)]">
|
||||
{clean.toLocaleString()} clean
|
||||
</span>
|
||||
<span className="text-[var(--color-amber)]">{warned} warn</span>
|
||||
<span className="text-[var(--color-vermilion)]">{flagged} flag</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--color-ink-soft)]">
|
||||
{ratio}% ratio
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Headline — bottom left, massive */}
|
||||
<div className="absolute bottom-20 left-6 max-w-[60vw]">
|
||||
<h1 className="display text-[clamp(3rem,9vw,7rem)] font-medium leading-[0.95] tracking-tight text-[var(--color-ink)]">
|
||||
GMW
|
||||
<br />
|
||||
Console
|
||||
</h1>
|
||||
<p className="mt-3 font-mono text-[12px] text-[var(--color-ink-soft)]">
|
||||
{(initialStats?.total_users ?? 0).toLocaleString()} users ·{" "}
|
||||
{initialStats?.active_users_24h ?? 0} active 24h
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Event ribbon — mid screen, drifting row */}
|
||||
<div className="absolute left-1/2 top-1/2 w-[min(90vw,900px)] -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="flex flex-col gap-1 font-mono text-[11px]">
|
||||
{seedEvents.slice(0, 6).map((e) => (
|
||||
<div
|
||||
key={e.id}
|
||||
className="flex items-center gap-2 opacity-70"
|
||||
data-severity={e.severity}
|
||||
>
|
||||
<span
|
||||
className="inline-block size-1.5 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
e.severity === "vermilion"
|
||||
? "var(--color-vermilion)"
|
||||
: "var(--color-signal)",
|
||||
}}
|
||||
/>
|
||||
<span className="text-[var(--color-ink-soft)] tabular-nums">
|
||||
{new Date(e.ts).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className="truncate text-[var(--color-ink)]">
|
||||
{e.excerpt}
|
||||
<div className="space-y-5">
|
||||
{/* Hero */}
|
||||
<GlassPanel glow className="relative overflow-hidden">
|
||||
<div className="scan-line absolute inset-x-0 top-0" />
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="eyebrow mb-2">GMW · Operations Grid</div>
|
||||
<h2 className="display text-[2.6rem] leading-none text-ink glow-signal">
|
||||
Ambient Field
|
||||
</h2>
|
||||
<p className="mt-2 max-w-md text-sm text-ink-soft">
|
||||
Real-time moderation, voice & media presence across the monitored
|
||||
guild. {formatNumber(s.total_messages)} messages captured.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-ink-soft">
|
||||
<Radio className="size-4 text-signal animate-breathe" />
|
||||
<span className="mono text-xs uppercase tracking-wider">
|
||||
{deriveSignal(s).label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile label="Messages" value={formatNumber(s.total_messages)} tone="signal" icon={<MessageSquare className="size-3.5" />} />
|
||||
<MetricTile label="Flagged" value={formatNumber(s.total_flagged)} tone={s.total_flagged > 0 ? "vermilion" : "neutral"} hint={`${s.today_flagged} today`} />
|
||||
<MetricTile label="Active 24h" value={formatNumber(s.active_users_24h)} tone="signal" icon={<Users className="size-3.5" />} />
|
||||
<MetricTile label="Voice clips" value={formatNumber(s.total_voice_recordings)} icon={<Mic className="size-3.5" />} />
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
{/* Activity */}
|
||||
<GlassPanel>
|
||||
<SectionHeader
|
||||
eyebrow="14-day signal"
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Activity className="size-4 text-signal" /> Activity & moderation
|
||||
</span>
|
||||
}
|
||||
action={
|
||||
<div className="flex items-center gap-3 text-xs text-ink-soft">
|
||||
<span className="flex items-center gap-1.5"><span className="size-2 rounded-full bg-signal" /> messages</span>
|
||||
<span className="flex items-center gap-1.5"><span className="size-2 rounded-full bg-vermilion" /> flagged</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{activity ? (
|
||||
<AreaActivity daily={activity.daily} />
|
||||
) : (
|
||||
<LoadingState label="streaming" />
|
||||
)}
|
||||
</GlassPanel>
|
||||
|
||||
{/* Two-column: channels + moderation */}
|
||||
<div className="grid gap-5 lg:grid-cols-5">
|
||||
<GlassPanel className="lg:col-span-3">
|
||||
<SectionHeader eyebrow="throughput" title="Top channels" />
|
||||
<div className="space-y-2.5">
|
||||
{s.top_channels.slice(0, 7).map((c) => {
|
||||
const pct = (c.message_count / (s.top_channels[0]?.message_count || 1)) * 100;
|
||||
return (
|
||||
<div key={c.channel_id} className="flex items-center gap-3">
|
||||
<span className="w-40 truncate text-sm text-ink-soft">{c.channel_name ?? c.channel_id.slice(0, 8)}</span>
|
||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/8">
|
||||
<div className="h-full rounded-full bg-signal/70" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="mono w-14 text-right text-xs text-ink-faint">{formatNumber(c.message_count)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel className="lg:col-span-2">
|
||||
<SectionHeader eyebrow="trust" title="Moderation" />
|
||||
<div className="flex items-center gap-5">
|
||||
<RadialGauge
|
||||
value={cleanRatio}
|
||||
tone={cleanRatio > 0.8 ? "signal" : cleanRatio > 0.6 ? "amber" : "vermilion"}
|
||||
label={`${Math.round(cleanRatio * 100)}%`}
|
||||
sublabel="clean"
|
||||
/>
|
||||
<div className="flex-1 space-y-2 text-sm">
|
||||
<Row icon={<ShieldAlert className="size-4 text-signal" />} label="Clean" value={formatNumber(s.total_clean)} />
|
||||
<Row icon={<Flag className="size-4 text-vermilion" />} label="Flagged" value={formatNumber(s.total_flagged)} />
|
||||
<Row icon={<Activity className="size-4 text-amber" />} label="Warned" value={formatNumber(s.total_warned)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-around border-t border-hairline pt-3 text-center">
|
||||
<Mini label="pending" value={s.moderation_overview.pending} tone="amber" />
|
||||
<Mini label="processing" value={s.moderation_overview.processing} tone="signal" />
|
||||
<Mini label="errors" value={s.moderation_overview.error} tone="vermilion" />
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
|
||||
{/* Reactors + reactions */}
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<GlassPanel>
|
||||
<SectionHeader eyebrow="engagement" title="Top reactors" />
|
||||
<div className="space-y-2">
|
||||
{(reactors ?? []).slice(0, 6).map((r, i) => (
|
||||
<div key={r.user_id} className="flex items-center gap-3">
|
||||
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||
<span className="flex-1 truncate text-sm text-ink">{r.username}</span>
|
||||
<span className="mono text-xs text-signal">+{formatNumber(r.net_count)}</span>
|
||||
</div>
|
||||
))}
|
||||
{(reactors ?? []).length === 0 && <EmptyHint />}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel>
|
||||
<SectionHeader eyebrow="culture" title="Top reactions" />
|
||||
<div className="space-y-3">
|
||||
{(reactions ?? []).slice(0, 5).map((m) => (
|
||||
<div key={m.message_id} className="flex items-start gap-3">
|
||||
<div className="flex flex-wrap gap-1 pt-0.5">
|
||||
{m.top_emojis.slice(0, 3).map((e, i) => (
|
||||
<span key={i} className="text-lg leading-none">{e.emoji}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-ink">{m.content || "(no text)"}</div>
|
||||
<div className="mono text-[0.65rem] text-ink-faint">{m.username} · {m.channel_name ?? m.channel_id.slice(0, 8)}</div>
|
||||
</div>
|
||||
<span className="mono text-xs text-ink-soft">{m.reaction_count}</span>
|
||||
</div>
|
||||
))}
|
||||
{(reactions ?? []).length === 0 && <EmptyHint />}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Command whisper — very bottom, minimal */}
|
||||
<div className="absolute inset-x-0 bottom-0">
|
||||
<DashCommandLine />
|
||||
</div>
|
||||
function Row({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
{icon}
|
||||
<span className="flex-1 text-ink-soft">{label}</span>
|
||||
<span className="mono text-ink">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Mini({ label, value, tone }: { label: string; value: number; tone: "signal" | "amber" | "vermilion" }) {
|
||||
const color = tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal";
|
||||
return (
|
||||
<div>
|
||||
<div className={`display text-xl ${color}`}>{value}</div>
|
||||
<div className="eyebrow mt-0.5">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyHint() {
|
||||
return <div className="py-6 text-center text-xs text-ink-faint">Awaiting data…</div>;
|
||||
}
|
||||
|
||||
@@ -1,133 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { SWRConfig } from "swr";
|
||||
import { ChatbotContainer } from "@/components/chatbot/chatbot-container";
|
||||
import {
|
||||
ChatbotProvider,
|
||||
useChatbot,
|
||||
} from "@/components/chatbot/chatbot-context";
|
||||
import { Spine } from "@/components/layout/spine";
|
||||
import { StatusBar } from "@/components/layout/status-bar";
|
||||
import { MiniPlayer } from "@/components/media/mini-player";
|
||||
import { RouteTransition } from "@/components/motion/route-transition";
|
||||
import { MediaPlayerProvider } from "@/lib/hooks/use-media-player";
|
||||
import { useWebSocket, WsProvider } from "@/lib/ws/context";
|
||||
|
||||
function ChatbotGuildSync({ guildId }: { guildId: string }) {
|
||||
const { setGuildId } = useChatbot();
|
||||
useEffect(() => {
|
||||
setGuildId(guildId);
|
||||
}, [guildId, setGuildId]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function ChatbotExpressionSync() {
|
||||
const ws = useWebSocket();
|
||||
const { setExpression } = useChatbot();
|
||||
useEffect(() => {
|
||||
const unsub1 = ws.on("message_created", (data: any) => {
|
||||
if (data.ai_status === "flagged" || data.ai_status === "warn") {
|
||||
setExpression("surprise");
|
||||
setTimeout(() => setExpression("idle"), 2000);
|
||||
}
|
||||
});
|
||||
const unsub2 = ws.on("voice_active_user", () => setExpression("listening"));
|
||||
return () => {
|
||||
unsub1();
|
||||
unsub2();
|
||||
};
|
||||
}, [ws, setExpression]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ambient shell — used only on /dashboard.
|
||||
*
|
||||
* No TopBar, no LeftRail, no main padding. The view itself is full-bleed
|
||||
* (AmbientField + floating overlays). This is the ground-up rombak — not a
|
||||
* re-skin of the classic dashboard template.
|
||||
*/
|
||||
function AmbientShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="h-[calc(100svh-3rem)] w-full overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Classic shell — used on every other route under /(dashboard).
|
||||
*/
|
||||
function ClassicShell({
|
||||
children,
|
||||
guildId,
|
||||
setGuildId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
guildId: string;
|
||||
setGuildId: (g: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-svh bg-[var(--color-canvas)] md:pl-[68px]">
|
||||
<Spine />
|
||||
<div className="flex min-h-svh flex-col">
|
||||
<StatusBar guildId={guildId} onGuildChange={(g) => setGuildId(g)} />
|
||||
<main className="flex flex-1 flex-col gap-4 p-4 pb-24 md:p-6 lg:pb-8">
|
||||
<div className="mx-auto w-full max-w-[1440px]">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-[60vh] items-center justify-center">
|
||||
<div className="size-8 animate-spin rounded-full border-2 border-[var(--color-signal)] border-t-transparent" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<RouteTransition>{children}</RouteTransition>
|
||||
</Suspense>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { AmbientProvider } from "@/components/ambient/ambient-context";
|
||||
import { WsProvider } from "@/lib/ws/context";
|
||||
import { AppFrame } from "@/components/shell";
|
||||
import { Chatbot } from "@/components/chatbot/chatbot";
|
||||
import { CommandPalette } from "@/components/command/command-palette";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [guildId, setGuildId] = useState("");
|
||||
const pathname = usePathname();
|
||||
// Match exact /dashboard or /dashboard/ but not /dashboard/<subroute>
|
||||
const isConsole = pathname === "/dashboard" || pathname === "/dashboard/";
|
||||
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<SWRConfig
|
||||
value={{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10_000,
|
||||
shouldRetryOnError: (err) =>
|
||||
(err as { statusCode?: number })?.statusCode !== 404,
|
||||
}}
|
||||
>
|
||||
<AmbientProvider>
|
||||
<WsProvider>
|
||||
<MediaPlayerProvider>
|
||||
<ChatbotProvider>
|
||||
<ChatbotGuildSync guildId={guildId} />
|
||||
<ChatbotExpressionSync />
|
||||
{isConsole ? (
|
||||
<AmbientShell>{children}</AmbientShell>
|
||||
) : (
|
||||
<ClassicShell guildId={guildId} setGuildId={setGuildId}>
|
||||
{children}
|
||||
</ClassicShell>
|
||||
)}
|
||||
<MiniPlayer />
|
||||
<ChatbotContainer />
|
||||
</ChatbotProvider>
|
||||
</MediaPlayerProvider>
|
||||
<AppFrame>{children}</AppFrame>
|
||||
<Chatbot />
|
||||
<CommandPalette />
|
||||
</WsProvider>
|
||||
</SWRConfig>
|
||||
</AmbientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* Media page — Server Component. Seeds the music player with the shared media
|
||||
* state fetched on the server (same state every user sees), then live-updates
|
||||
* over WS.
|
||||
*/
|
||||
import { getMediaStatus } from "@/lib/api/server";
|
||||
import MediaView from "./view";
|
||||
import { MediaView } from "./view";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MediaPage() {
|
||||
const status = await getMediaStatus().catch(() => undefined);
|
||||
|
||||
let status = undefined;
|
||||
try {
|
||||
status = await getMediaStatus();
|
||||
} catch {
|
||||
/* client hooks surface errors */
|
||||
}
|
||||
return <MediaView initialStatus={status} />;
|
||||
}
|
||||
|
||||
@@ -1,185 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { Pause, Play, Repeat2, SkipForward, Square, Volume2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { Waveform } from "@/components/charts/waveform";
|
||||
import { StaggerGroup, StaggerItem } from "@/components/motion/stagger";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { Button } from "@/components/primitives/button";
|
||||
import { Input } from "@/components/primitives/input";
|
||||
import { Progress } from "@/components/primitives/progress";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
useMediaLoop,
|
||||
ListMusic,
|
||||
Pause,
|
||||
Play,
|
||||
Repeat,
|
||||
SkipForward,
|
||||
Square,
|
||||
Radio,
|
||||
} from "lucide-react";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
import {
|
||||
useMediaState,
|
||||
useMediaQueue,
|
||||
useMediaSkip,
|
||||
useMediaState,
|
||||
useMediaStop,
|
||||
useMediaLoop,
|
||||
useMediaWsSync,
|
||||
} from "@/hooks";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { GlassPanel, GlassCard, Button, Input } from "@/components/primitives";
|
||||
import { SectionHeader, ErrorState, LoadingState } from "@/components/shared";
|
||||
import { toast } from "@/components/primitives";
|
||||
import type { MediaState } from "@/lib/types";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
export default function MediaView({
|
||||
initialStatus,
|
||||
}: {
|
||||
initialStatus?: MediaState;
|
||||
}) {
|
||||
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||
const ws = useWebSocket();
|
||||
const { data: state } = useMediaState(initialStatus);
|
||||
const queueMut = useMediaQueue();
|
||||
const { data: media, isLoading, error } = useMediaState(initialStatus);
|
||||
const queue = useMediaQueue();
|
||||
const skip = useMediaSkip();
|
||||
const stop = useMediaStop();
|
||||
const loopMut = useMediaLoop();
|
||||
const loop = useMediaLoop();
|
||||
useMediaWsSync(ws);
|
||||
const ambient = useAmbient();
|
||||
|
||||
const current = state?.current;
|
||||
const playing = state?.playing ?? false;
|
||||
const queue = state?.queue ?? [];
|
||||
const loop = state?.loop ?? false;
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
const duration = current?.durationMs ?? 0;
|
||||
const [queueUrl, setQueueUrl] = useState("");
|
||||
const [screenMode, setScreenMode] = useState(false);
|
||||
const playing = media?.playing ?? false;
|
||||
const current = media?.current ?? null;
|
||||
const queueList = media?.queue ?? [];
|
||||
|
||||
const handleQueue = () => {
|
||||
if (!queueUrl.trim()) return;
|
||||
queueMut.mutate({ url: queueUrl.trim(), mode: screenMode ? "screen" : "music" });
|
||||
setQueueUrl("");
|
||||
const tone = playing ? "signal" : queueList.length ? "amber" : "signal";
|
||||
useEffect(() => {
|
||||
ambient.set(tone, playing ? 0.5 : 0.25, playing ? "now playing" : "media idle");
|
||||
}, [tone, playing, ambient]);
|
||||
|
||||
const onPlay = async () => {
|
||||
const u = url.trim();
|
||||
if (!u) {
|
||||
toast({ title: "Enter a media URL", tone: "vermilion" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await queue.mutateAsync({ url: u, mode: "music" });
|
||||
setUrl("");
|
||||
toast({ title: "Queued", tone: "signal" });
|
||||
} catch (e) {
|
||||
toast({ title: "Queue failed", description: String(e), tone: "vermilion" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* URL queue input */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Queue a URL (YouTube, audio file…)"
|
||||
value={queueUrl}
|
||||
onChange={(e) => setQueueUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleQueue()}
|
||||
className="flex-1 h-9"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={screenMode ? "primary" : "ghost"}
|
||||
onClick={() => setScreenMode((v) => !v)}
|
||||
title="Queue as Discord GoLive screenshare instead of audio playback"
|
||||
>
|
||||
Screen
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleQueue}
|
||||
disabled={!queueUrl.trim() || queueMut.isPending}
|
||||
>
|
||||
<Play className="size-4 mr-1.5" />
|
||||
Queue
|
||||
</Button>
|
||||
</div>
|
||||
if (error && !media) return <ErrorState error={error} />;
|
||||
if (!media && isLoading) return <LoadingState label="Reading deck" />;
|
||||
|
||||
{/* Turntable hero */}
|
||||
<div className="flex items-center gap-6 surface scan-tick flex-wrap p-5">
|
||||
{current && (
|
||||
<motion.div
|
||||
className={`relative mx-auto size-[160px] rounded-full ${
|
||||
playing ? "animate-spin-disc" : "animate-spin-disc paused"
|
||||
}`}
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<GlassPanel glow className="relative overflow-hidden">
|
||||
<div className="scan-line absolute inset-x-0 top-0" />
|
||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-center">
|
||||
<div
|
||||
className={`flex size-32 shrink-0 items-center justify-center rounded-full border border-hairline bg-gradient-to-br from-white/10 to-white/[0.02] ${playing ? "animate-spin-disc" : "animate-spin-disc paused"}`}
|
||||
>
|
||||
<img
|
||||
src={current.thumbnailUrl ?? "/favicon.ico"}
|
||||
alt={current.title ?? "cover"}
|
||||
className="size-full rounded-full object-cover ring-4 ring-[var(--color-signal)]/20"
|
||||
style={{ animationPlayState: playing ? "running" : "paused" }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="display text-2xl text-[var(--color-signal)]">
|
||||
{current?.title ?? "No track playing"}
|
||||
<div className="flex size-28 items-center justify-center rounded-full bg-canvas/60">
|
||||
<ListMusic className="size-10 text-signal" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 mono text-xs text-[var(--color-ink-soft)]">
|
||||
{current?.source ?? "idle"} · {duration ? formatMs(duration) : "—"}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Progress value={42} max={100} tone="signal" />
|
||||
<div className="mt-1 flex justify-between text-[10px] mono text-[var(--color-ink-soft)]">
|
||||
<span>0:00</span>
|
||||
<span>{duration ? formatMs(duration) : "—"}</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="eyebrow mb-1">Now playing</div>
|
||||
<h2 className="display truncate text-2xl text-ink">
|
||||
{current?.title ?? "Nothing queued"}
|
||||
</h2>
|
||||
{current?.source && (
|
||||
<div className="mono mt-1 truncate text-xs text-ink-faint">{current.source}</div>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Button variant="primary" size="sm" onClick={onPlay} disabled={queue.isPending}>
|
||||
<Play className="size-4" /> Queue & play
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => skip.mutate()} disabled={skip.isPending}>
|
||||
<SkipForward className="size-4" /> Skip
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => stop.mutate()} disabled={stop.isPending}>
|
||||
<Square className="size-4" /> Stop
|
||||
</Button>
|
||||
<Button
|
||||
variant={media?.loop ? "primary" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => loop.mutate(!media?.loop)}
|
||||
aria-pressed={!!media?.loop}
|
||||
>
|
||||
<Repeat className="size-4" /> Loop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transport */}
|
||||
<StaggerGroup className="flex items-center gap-2">
|
||||
<StaggerItem>
|
||||
<Button size="sm" variant="ghost" onClick={() => skip.mutate()}>
|
||||
<SkipForward className="size-4" />
|
||||
</Button>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="primary"
|
||||
onClick={() => loopMut.mutate(!loop)}
|
||||
>
|
||||
{playing ? <Pause className="size-5" /> : <Play className="size-5" />}
|
||||
</Button>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<Button size="sm" variant="ghost" onClick={() => stop.mutate()}>
|
||||
<Square className="size-4" />
|
||||
</Button>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={loop ? "primary" : "ghost"}
|
||||
onClick={() => loopMut.mutate(!loop)}
|
||||
>
|
||||
<Repeat2 className="size-4" />
|
||||
</Button>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<Volume2 className="size-4 text-[var(--color-ink-soft)]" />
|
||||
</StaggerItem>
|
||||
</StaggerGroup>
|
||||
<div className="mt-5 flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Paste a YouTube / music URL…"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && onPlay()}
|
||||
/>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
{/* Queue */}
|
||||
{queue.length > 0 && (
|
||||
<div className="surface flex flex-col gap-1.5 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">Queue ({queue.length})</h3>
|
||||
<Badge tone="neutral">{loop ? "loop" : "queue"}</Badge>
|
||||
<GlassPanel>
|
||||
<SectionHeader
|
||||
eyebrow="up next"
|
||||
title="Queue"
|
||||
action={<span className="mono text-xs text-ink-faint">{queueList.length} tracks</span>}
|
||||
/>
|
||||
{queueList.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
||||
<Radio className="size-6 text-ink-faint" />
|
||||
<div className="text-sm text-ink-soft">Queue is empty</div>
|
||||
<div className="text-xs text-ink-faint">Paste a URL above to start playback.</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{queue.map((item) => (
|
||||
<motion.div
|
||||
key={item.id ?? item.source}
|
||||
layout
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
className="flex items-center gap-2.5 rounded-[var(--radius-r-control)] px-2 py-1.5 text-sm hover:bg-[var(--color-surface-2)]"
|
||||
>
|
||||
<Waveform
|
||||
seed={item.id ?? item.source}
|
||||
bars={12}
|
||||
height={20}
|
||||
className="w-16"
|
||||
/>
|
||||
<span className="mono truncate">{item.title}</span>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{queueList.map((item, i) => (
|
||||
<div key={`${item.source}-${i}`} className="flex items-center gap-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5">
|
||||
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-ink">{item.title}</div>
|
||||
<div className="mono truncate text-[0.65rem] text-ink-faint">{item.source}</div>
|
||||
</div>
|
||||
<span className="pill">{item.mode ?? "music"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</GlassPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatMs(ms: number): string {
|
||||
const m = Math.floor(ms / 60000);
|
||||
const s = Math.floor((ms % 60000) / 1000);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
@@ -1,38 +1,15 @@
|
||||
/**
|
||||
* Messages — Server Component.
|
||||
* Reads URL guild/channel/selected/tab on the server; seeds first page SSR.
|
||||
*/
|
||||
import { getMessages, type MessagePageResult } from "@/lib/api/server";
|
||||
import MessagesView from "./view";
|
||||
import { getConfig, getGuilds } from "@/lib/api/server";
|
||||
import { MessagesView } from "./view";
|
||||
|
||||
export default async function MessagesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const guild = typeof sp.guild === "string" ? sp.guild : "";
|
||||
const channel = typeof sp.channel === "string" ? sp.channel : "";
|
||||
const selected = typeof sp.selected === "string" ? sp.selected : null;
|
||||
const tab =
|
||||
typeof sp.tab === "string" && ["all", "images", "review"].includes(sp.tab)
|
||||
? (sp.tab as "all" | "images" | "review")
|
||||
: "all";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
let initialPage: MessagePageResult | undefined;
|
||||
if (guild) {
|
||||
initialPage = await getMessages(guild, channel || undefined).catch(
|
||||
() => undefined,
|
||||
);
|
||||
export default async function MessagesPage() {
|
||||
let config = undefined;
|
||||
let guilds = undefined;
|
||||
try {
|
||||
[config, guilds] = await Promise.all([getConfig(), getGuilds()]);
|
||||
} catch {
|
||||
/* client hooks surface errors */
|
||||
}
|
||||
|
||||
return (
|
||||
<MessagesView
|
||||
initialGuild={guild}
|
||||
initialChannel={channel}
|
||||
initialDetailId={selected}
|
||||
initialTab={tab}
|
||||
initialMessagePage={initialPage}
|
||||
/>
|
||||
);
|
||||
return <MessagesView initialGuilds={guilds} initialGuildId={config?.monitorGuildId ?? null} />;
|
||||
}
|
||||
|
||||
@@ -1,377 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { Flag, Image, Loader2, Search, Send, X } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Lightbox } from "@/components/messages/lightbox";
|
||||
import { MessageDetailView } from "@/components/messages/message-detail-view";
|
||||
import { MessageList } from "@/components/messages/message-list";
|
||||
import { SearchOverlay } from "@/components/messages/search-overlay";
|
||||
import { StaggerGroup, StaggerItem } from "@/components/motion/stagger";
|
||||
import { Avatar } from "@/components/primitives/avatar";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { Dialog } from "@/components/primitives/dialog";
|
||||
import { Input } from "@/components/primitives/input";
|
||||
import { Select } from "@/components/primitives/select";
|
||||
import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/shared";
|
||||
import { GuildSelector } from "@/components/shared/guild-selector";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
useImages,
|
||||
useLoadMore,
|
||||
useMessageDetail,
|
||||
useMessages,
|
||||
useMessagesHasMore,
|
||||
useMessagesWsSync,
|
||||
useReview,
|
||||
useTextChannels,
|
||||
} from "@/hooks";
|
||||
import { renderMessageContent } from "@/lib/format";
|
||||
import type { MessageRecord } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
MessageSquare,
|
||||
Search,
|
||||
Paperclip,
|
||||
Image as ImageIcon,
|
||||
ShieldAlert,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
import {
|
||||
useGuilds,
|
||||
useMessages,
|
||||
useMessagesWsSync,
|
||||
useMessageSearch,
|
||||
useMessageDetail,
|
||||
} from "@/hooks";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { GlassPanel, GlassCard, Avatar, Badge, Input, Skeleton } from "@/components/primitives";
|
||||
import { SectionHeader, EmptyState, ErrorState, LoadingState } from "@/components/shared";
|
||||
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
||||
import { renderMessageContent, getMessageChannelLabel, safeParseJsonArray, formatBytes } from "@/lib/format";
|
||||
import type { AiStatus, Guild, MessageRecord } from "@/lib/types";
|
||||
|
||||
type MessagesTab = "all" | "images" | "review";
|
||||
|
||||
interface MessagesViewProps {
|
||||
initialGuild?: string;
|
||||
initialChannel?: string;
|
||||
initialDetailId?: string | null;
|
||||
initialTab?: MessagesTab;
|
||||
initialMessagePage?: { data: MessageRecord[]; nextCursor: string | null };
|
||||
function relTime(ts?: number | null) {
|
||||
if (!ts) return "";
|
||||
const d = Date.now() - ts;
|
||||
const m = Math.floor(d / 60000);
|
||||
if (m < 1) return "just now";
|
||||
if (m < 60) return `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h`;
|
||||
return `${Math.floor(h / 24)}d`;
|
||||
}
|
||||
|
||||
export default function MessagesView({
|
||||
initialGuild = "",
|
||||
initialChannel = "",
|
||||
initialDetailId = null,
|
||||
initialTab = "all",
|
||||
initialMessagePage,
|
||||
}: MessagesViewProps) {
|
||||
const router = useRouter();
|
||||
const [guildId, setGuildId] = useState(initialGuild);
|
||||
const [selectedChannel, setSelectedChannel] = useState(initialChannel);
|
||||
const [detailId, setDetailId] = useState<string | null>(initialDetailId);
|
||||
const [tab, setTab] = useState<MessagesTab>(initialTab);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [lightbox, setLightbox] = useState<{
|
||||
images: Array<{ src: string; alt?: string }>;
|
||||
index: number;
|
||||
} | null>(null);
|
||||
function aiTone(s?: AiStatus | null): "signal" | "amber" | "vermilion" | "neutral" {
|
||||
if (s === "clean") return "signal";
|
||||
if (s === "warn") return "amber";
|
||||
if (s === "flagged" || s === "error") return "vermilion";
|
||||
if (s === "processing" || s === "pending") return "neutral";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
export function MessagesView({
|
||||
initialGuilds,
|
||||
initialGuildId,
|
||||
}: {
|
||||
initialGuilds?: Guild[];
|
||||
initialGuildId?: string | null;
|
||||
}) {
|
||||
const ws = useWebSocket();
|
||||
const { data: channels = [] } = useTextChannels(guildId);
|
||||
const {
|
||||
data: messages,
|
||||
error,
|
||||
refetch,
|
||||
} = useMessages(
|
||||
guildId,
|
||||
selectedChannel || undefined,
|
||||
guildId === initialGuild && selectedChannel === initialChannel
|
||||
? initialMessagePage
|
||||
: undefined,
|
||||
const { data: guilds } = useGuilds(initialGuilds);
|
||||
const [guildId, setGuildId] = useState<string | null>(
|
||||
initialGuildId ?? initialGuilds?.[0]?.id ?? null,
|
||||
);
|
||||
const { data: cursorData } = useMessagesHasMore(
|
||||
guildId,
|
||||
selectedChannel || undefined,
|
||||
);
|
||||
const loadMoreMut = useLoadMore();
|
||||
const { data: images } = useImages(guildId);
|
||||
const { data: reviews } = useReview(selectedChannel || undefined);
|
||||
const { message: detailMessage, loading: detailLoading } =
|
||||
useMessageDetail(detailId);
|
||||
const [channelId, setChannelId] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
useMessagesWsSync(ws, guildId);
|
||||
const { data: messages, isLoading, error } = useMessages(guildId ?? "", channelId ?? undefined);
|
||||
useMessagesWsSync(ws, guildId ?? "");
|
||||
const search = useMessageSearch(query, query.trim().length >= 2);
|
||||
const detail = useMessageDetail(selected);
|
||||
const ambient = useAmbient();
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (guildId) params.set("guild", guildId);
|
||||
if (selectedChannel) params.set("channel", selectedChannel);
|
||||
if (detailId) params.set("selected", detailId);
|
||||
if (tab !== "all") params.set("tab", tab);
|
||||
router.replace(`/messages?${params.toString()}`, { scroll: false });
|
||||
}, [guildId, selectedChannel, detailId, tab, router]);
|
||||
ambient.set(query ? "amber" : "signal", 0.3, query ? "search" : "messages");
|
||||
}, [query, ambient]);
|
||||
|
||||
// global Cmd+K
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
setSearchOpen(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (!cursorData?.cursor || loadMoreMut.isPending) return;
|
||||
loadMoreMut.mutate({
|
||||
guildId,
|
||||
channelId: selectedChannel || undefined,
|
||||
cursor: cursorData.cursor,
|
||||
});
|
||||
}, [cursorData, loadMoreMut, guildId, selectedChannel]);
|
||||
|
||||
const handleGuildChange = useCallback((g: string) => {
|
||||
setGuildId(g);
|
||||
setSelectedChannel("");
|
||||
setDetailId(null);
|
||||
}, []);
|
||||
|
||||
const tabs: { id: MessagesTab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "all", label: "All", icon: null },
|
||||
{ id: "images", label: "Images", icon: <Image className="size-3.5" /> },
|
||||
{ id: "review", label: "Review", icon: <Flag className="size-3.5" /> },
|
||||
];
|
||||
|
||||
const currentMessages = messages ?? [];
|
||||
const searching = query.trim().length >= 2;
|
||||
const list = searching ? search.data ?? [] : (messages ?? []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
<GuildSelector value={guildId} onChange={handleGuildChange} />
|
||||
{channels.length > 0 && (
|
||||
<Select
|
||||
value={selectedChannel}
|
||||
onChange={(e) => setSelectedChannel(e.target.value || "")}
|
||||
className="w-48"
|
||||
>
|
||||
<option value="">All channels</option>
|
||||
{channels.map((ch) => (
|
||||
<option key={ch.id} value={ch.id}>
|
||||
# {ch.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="ms-auto flex items-center gap-1.5 rounded-[var(--radius-r-control)] px-3 py-1.5 text-xs text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
||||
>
|
||||
<Search className="size-3.5" />
|
||||
Search{" "}
|
||||
<span className="hidden font-mono text-[10px] sm:inline">(⌘K)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 rounded-[var(--radius-r)] bg-[var(--color-surface-2)] p-1">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setTab(t.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-[var(--radius-r-control)] px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
tab === t.id
|
||||
? "bg-[var(--color-signal)] text-[var(--color-signal-ink)]"
|
||||
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
|
||||
)}
|
||||
>
|
||||
{t.icon}
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
{/* Left — timeline spine + entries */}
|
||||
<div
|
||||
className={cn("surface p-3", detailId ? "w-1/2 lg:w-2/5" : "w-full")}
|
||||
>
|
||||
{error ? (
|
||||
<ErrorState message={error.message} onRetry={refetch} />
|
||||
) : !messages ? (
|
||||
<LoadingSkeleton count={6} />
|
||||
) : tab === "all" ? (
|
||||
<MessageList
|
||||
messages={currentMessages}
|
||||
selectedId={detailId}
|
||||
onSelect={setDetailId}
|
||||
hasMore={cursorData?.hasMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
isLoadingMore={loadMoreMut.isPending}
|
||||
/>
|
||||
) : tab === "images" ? (
|
||||
<ImageGrid items={images ?? []} onSelect={setDetailId} />
|
||||
) : (
|
||||
<ReviewList items={reviews ?? []} onSelect={setDetailId} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right — detail */}
|
||||
{detailId && (
|
||||
<div className="sticky top-16 hidden w-1/2 self-start md:block lg:w-3/5">
|
||||
<div className="surface h-full p-4">
|
||||
{detailLoading ? (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-[var(--color-ink-soft)]" />
|
||||
</div>
|
||||
) : detailMessage ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailId(null)}
|
||||
className="text-xs text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
||||
>
|
||||
← Back to list
|
||||
</button>
|
||||
<MessageDetailView message={detailMessage} />
|
||||
{detailMessage && (
|
||||
<Lightbox
|
||||
open={!!lightbox}
|
||||
onClose={() => setLightbox(null)}
|
||||
images={extractImages(detailMessage.metadata)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SearchOverlay
|
||||
open={searchOpen}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
results={(currentMessages ?? []).map((m) => ({
|
||||
id: m.id,
|
||||
content: m.edited_content ?? m.content,
|
||||
username: m.username ?? "unknown",
|
||||
channel: m.channel_id,
|
||||
time: m.created_at
|
||||
? new Date(m.created_at * 1000).toLocaleTimeString()
|
||||
: "",
|
||||
}))}
|
||||
onSelect={(msg) => {
|
||||
const found = currentMessages.find((m) => m.id === msg.id);
|
||||
if (found) setDetailId(found.id);
|
||||
setTab("all");
|
||||
}}
|
||||
/>
|
||||
|
||||
{lightbox && (
|
||||
<Lightbox
|
||||
open={!!lightbox}
|
||||
onClose={() => setLightbox(null)}
|
||||
images={lightbox.images}
|
||||
initialIndex={lightbox.index}
|
||||
<div className="space-y-4">
|
||||
<GlassPanel className="flex flex-wrap items-center gap-3">
|
||||
<GuildChannelPicker
|
||||
mode="text"
|
||||
guildsInitial={initialGuilds}
|
||||
guildId={guildId}
|
||||
channelId={channelId}
|
||||
onChange={(g, c) => {
|
||||
setGuildId(g);
|
||||
setChannelId(c);
|
||||
setSelected(null);
|
||||
}}
|
||||
/>
|
||||
<div className="relative ml-auto w-64">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-ink-faint" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Search messages…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-5">
|
||||
<GlassPanel className="lg:col-span-3">
|
||||
<SectionHeader
|
||||
eyebrow={searching ? "results" : "live feed"}
|
||||
title={searching ? `“${query}”` : "Messages"}
|
||||
action={
|
||||
<span className="mono text-xs text-ink-faint">
|
||||
{list.length} shown
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{error && !messages ? (
|
||||
<ErrorState error={error} />
|
||||
) : isLoading && !messages ? (
|
||||
<LoadingState label="Capturing" />
|
||||
) : list.length === 0 ? (
|
||||
<EmptyState icon={<MessageSquare className="size-7" />} title="No messages" description="Pick a guild to begin, or run a search." />
|
||||
) : (
|
||||
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
|
||||
{list.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => setSelected(m.id)}
|
||||
className={`flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${
|
||||
selected === m.id ? "border-signal/40 bg-signal/8" : "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
|
||||
}`}
|
||||
>
|
||||
<Avatar src={m.avatar_url} name={m.username} size={34} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold text-ink">{m.username}</span>
|
||||
<span className="mono text-[0.65rem] text-ink-faint">{getMessageChannelLabel(m)}</span>
|
||||
<span className="mono ml-auto text-[0.6rem] text-ink-faint">{relTime(m.created_at)}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-sm text-ink-soft">
|
||||
{renderMessageContent(m.content, m.metadata) || <span className="italic text-ink-faint">(empty / embed)</span>}
|
||||
</div>
|
||||
</div>
|
||||
<AiBadge status={m.ai_status} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel className="lg:col-span-2">
|
||||
<SectionHeader eyebrow="inspect" title="Detail" />
|
||||
{!selected ? (
|
||||
<EmptyState title="Select a message" description="Click any message to inspect AI analysis, attachments and edit history." />
|
||||
) : detail.loading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-20" />
|
||||
<Skeleton className="h-12" />
|
||||
</div>
|
||||
) : detail.message ? (
|
||||
<MessageDetail m={detail.message} attachments={detail.attachments} />
|
||||
) : (
|
||||
<EmptyState title="Not found" />
|
||||
)}
|
||||
</GlassPanel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AiBadge({ status }: { status?: AiStatus | null }) {
|
||||
if (!status) return null;
|
||||
const tone = aiTone(status);
|
||||
const icon =
|
||||
status === "clean" ? <CheckCircle2 className="size-3" /> :
|
||||
status === "flagged" ? <ShieldAlert className="size-3" /> :
|
||||
status === "warn" ? <AlertTriangle className="size-3" /> :
|
||||
status === "processing" || status === "pending" ? <Loader2 className="size-3 animate-spin" /> :
|
||||
<AlertTriangle className="size-3" />;
|
||||
return <Badge tone={tone} dot={status === "processing" || status === "pending"}>{icon}{status}</Badge>;
|
||||
}
|
||||
|
||||
function MessageDetail({ m, attachments }: { m: MessageRecord; attachments: import("@/lib/types").AttachmentRecord[] }) {
|
||||
const flags = safeParseJsonArray(m.ai_moderation_flags);
|
||||
const cats = safeParseJsonArray(m.ai_categories);
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar src={m.avatar_url} name={m.username} size={40} />
|
||||
<div>
|
||||
<div className="font-semibold text-ink">{m.username}</div>
|
||||
<div className="mono text-[0.65rem] text-ink-faint">{getMessageChannelLabel(m)} · {relTime(m.created_at)}</div>
|
||||
</div>
|
||||
<div className="ml-auto"><AiBadge status={m.ai_status} /></div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">
|
||||
{renderMessageContent(m.edited_content ?? m.content, m.metadata) || "(no text)"}
|
||||
</div>
|
||||
|
||||
{m.ai_analysis && (
|
||||
<div>
|
||||
<div className="eyebrow mb-1">AI analysis</div>
|
||||
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">{m.ai_analysis}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(flags.length > 0 || cats.length > 0) && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{flags.map((f) => <Badge key={f} tone="vermilion">{f}</Badge>)}
|
||||
{cats.map((c) => <Badge key={c} tone="amber">{c}</Badge>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div>
|
||||
<div className="eyebrow mb-1 flex items-center gap-1.5"><Paperclip className="size-3" /> Attachments ({attachments.length})</div>
|
||||
<div className="space-y-1.5">
|
||||
{attachments.map((a) => (
|
||||
<a key={a.id} href={a.discord_url ?? a.uploaded_url ?? "#"} target="_blank" rel="noreferrer" className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft hover:text-ink">
|
||||
<ImageIcon className="size-3.5 text-signal" />
|
||||
<span className="flex-1 truncate">{a.filename}</span>
|
||||
<span className="mono text-ink-faint">{formatBytes(a.size)}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageGrid({
|
||||
items,
|
||||
onSelect,
|
||||
}: {
|
||||
items: MessageRecord[];
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return !items.length ? (
|
||||
<EmptyState
|
||||
icon={Image}
|
||||
title="No images"
|
||||
description="Messages with image attachments will appear here."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
{items.map((item) => {
|
||||
const url = extractFirstImage(item.metadata);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(item.id)}
|
||||
className="overflow-hidden rounded-[var(--radius-r)] border border-[var(--color-hairline)]"
|
||||
>
|
||||
{url ? (
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className="h-24 w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-24 w-full items-center justify-center text-xs text-[var(--color-ink-soft)]/40">
|
||||
No image
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewList({
|
||||
items,
|
||||
onSelect,
|
||||
}: {
|
||||
items: MessageRecord[];
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return !items.length ? (
|
||||
<EmptyState
|
||||
icon={Flag}
|
||||
title="No flagged messages"
|
||||
description="Review-flagged messages will appear here."
|
||||
/>
|
||||
) : (
|
||||
<StaggerGroup className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<StaggerItem key={item.id} className="surface p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(item.id)}
|
||||
className="flex items-start gap-2 w-full text-left"
|
||||
>
|
||||
<Flag className="mt-0.5 size-3.5 shrink-0 text-[var(--color-vermilion)]" />
|
||||
<p className="line-clamp-2 text-xs text-[var(--color-ink-soft)]">
|
||||
{renderMessageContent(item.content, item.metadata) || item.id}
|
||||
</p>
|
||||
</button>
|
||||
</StaggerItem>
|
||||
))}
|
||||
</StaggerGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function extractFirstImage(metadata?: string | null): string | null {
|
||||
try {
|
||||
if (!metadata) return null;
|
||||
const m = JSON.parse(metadata);
|
||||
const atts = m?.attachments ?? [];
|
||||
const img = atts.find(
|
||||
(a: {
|
||||
contentType?: string | null;
|
||||
url?: string;
|
||||
discord_url?: string;
|
||||
}) => /image/i.test(a.contentType ?? ""),
|
||||
);
|
||||
return img?.url ?? img?.discord_url ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractImages(metadata?: string | null) {
|
||||
try {
|
||||
if (!metadata) return [];
|
||||
const m = JSON.parse(metadata);
|
||||
return (m?.attachments ?? [])
|
||||
.filter((a: { contentType?: string | null }) =>
|
||||
/image/i.test(a.contentType ?? ""),
|
||||
)
|
||||
.map((a: { url?: string; discord_url?: string; name?: string }) => ({
|
||||
src: a.url ?? a.discord_url ?? "",
|
||||
alt: a.name,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
/**
|
||||
* Moderation — Server Component.
|
||||
* Seeds moderation stats + action log for SSR first paint; live via WS.
|
||||
*/
|
||||
import { getModerationActions, getModerationStats } from "@/lib/api/server";
|
||||
import ModerationView from "./view";
|
||||
import { ModerationView } from "./view";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ModerationPage() {
|
||||
const [stats, actions] = await Promise.allSettled([
|
||||
getModerationStats().catch(() => undefined),
|
||||
getModerationActions(100).catch(() => undefined),
|
||||
]);
|
||||
return (
|
||||
<ModerationView
|
||||
initialStats={
|
||||
stats.status === "fulfilled" && stats.value ? stats.value : undefined
|
||||
}
|
||||
initialActions={
|
||||
actions.status === "fulfilled" && actions.value
|
||||
? actions.value
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
let stats = undefined;
|
||||
let actions = undefined;
|
||||
try {
|
||||
[stats, actions] = await Promise.all([
|
||||
getModerationStats(),
|
||||
getModerationActions(100),
|
||||
]);
|
||||
} catch {
|
||||
/* client hooks surface errors */
|
||||
}
|
||||
return <ModerationView initialStats={stats} initialActions={actions} />;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { ModerationSection } from "@/components/moderation/moderation-section";
|
||||
import type { ModerationAction, ModerationStats } from "@/lib/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ShieldAlert,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
Ban,
|
||||
Trash2,
|
||||
MicOff,
|
||||
AlertTriangle,
|
||||
UserX,
|
||||
MessageSquareWarning,
|
||||
Filter,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useModerationStats,
|
||||
useModerationActions,
|
||||
} from "@/hooks";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { GlassPanel, GlassCard, Badge, Select, type SelectOption } from "@/components/primitives";
|
||||
import { SectionHeader, MetricTile, ErrorState, LoadingState } from "@/components/shared";
|
||||
import { Donut } from "@/components/charts";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import type {
|
||||
ModerationAction,
|
||||
ModerationActionType,
|
||||
ModerationStats,
|
||||
} from "@/lib/types";
|
||||
|
||||
export default function ModerationView({
|
||||
const ACTION_ICON: Record<ModerationActionType, React.ReactNode> = {
|
||||
delete_message: <Trash2 className="size-3.5" />,
|
||||
mute_user: <MicOff className="size-3.5" />,
|
||||
warn_user: <MessageSquareWarning className="size-3.5" />,
|
||||
kick_user: <UserX className="size-3.5" />,
|
||||
ban_user: <Ban className="size-3.5" />,
|
||||
};
|
||||
|
||||
const ACTION_LABEL: Record<ModerationActionType, string> = {
|
||||
delete_message: "Delete",
|
||||
mute_user: "Mute",
|
||||
warn_user: "Warn",
|
||||
kick_user: "Kick",
|
||||
ban_user: "Ban",
|
||||
};
|
||||
|
||||
export function ModerationView({
|
||||
initialStats,
|
||||
initialActions,
|
||||
}: {
|
||||
initialStats?: ModerationStats;
|
||||
initialActions?: ModerationAction[];
|
||||
}) {
|
||||
const { data: stats, isLoading, error } = useModerationStats(initialStats);
|
||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("");
|
||||
const { data: actions } = useModerationActions(
|
||||
statusFilter || undefined,
|
||||
typeFilter || undefined,
|
||||
!statusFilter && !typeFilter ? initialActions : undefined,
|
||||
);
|
||||
|
||||
const failedRate = stats ? stats.failed_rate * 100 : 0;
|
||||
|
||||
const byAction = stats?.by_action ?? {};
|
||||
const segments = Object.entries(byAction).map(([k, v]) => ({
|
||||
value: 1,
|
||||
color:
|
||||
k === "ban_user" || k === "kick_user"
|
||||
? "var(--color-vermilion)"
|
||||
: k === "warn_user"
|
||||
? "var(--color-amber)"
|
||||
: "var(--color-signal)",
|
||||
label: k,
|
||||
}));
|
||||
|
||||
const ambient = useAmbient();
|
||||
useEffect(() => {
|
||||
ambient.set(
|
||||
failedRate > 20 ? "vermilion" : failedRate > 5 ? "amber" : "signal",
|
||||
0.3 + Math.min(0.4, failedRate / 50),
|
||||
"moderation",
|
||||
);
|
||||
}, [failedRate, ambient]);
|
||||
|
||||
if (error && !stats) return <ErrorState error={error} />;
|
||||
if (!stats && isLoading) return <LoadingState label="Reading log" />;
|
||||
|
||||
const statusOpts: SelectOption[] = [
|
||||
{ value: "", label: "All statuses" },
|
||||
{ value: "pending", label: "Pending" },
|
||||
{ value: "executed", label: "Executed" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
];
|
||||
const typeOpts: SelectOption[] = [
|
||||
{ value: "", label: "All actions" },
|
||||
...Object.keys(byAction).map((k) => ({ value: k, label: ACTION_LABEL[k as ModerationActionType] ?? k })),
|
||||
];
|
||||
|
||||
return (
|
||||
<ModerationSection
|
||||
initialStats={initialStats}
|
||||
initialActions={initialActions}
|
||||
/>
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile label="Total actions" value={formatNumber(stats!.total)} tone="signal" icon={<ShieldAlert className="size-3.5" />} />
|
||||
<MetricTile label="Executed" value={formatNumber(stats!.executed)} tone="signal" icon={<CheckCircle2 className="size-3.5" />} />
|
||||
<MetricTile label="Failed" value={formatNumber(stats!.failed)} tone={stats!.failed > 0 ? "vermilion" : "neutral"} icon={<XCircle className="size-3.5" />} />
|
||||
<MetricTile label="Pending" value={formatNumber(stats!.pending)} tone={stats!.pending > 0 ? "amber" : "neutral"} icon={<Clock className="size-3.5" />} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-5">
|
||||
<GlassPanel className="lg:col-span-2">
|
||||
<SectionHeader eyebrow="health" title="Breakdown" />
|
||||
<div className="flex items-center gap-5">
|
||||
<Donut
|
||||
segments={segments.length ? segments : [{ value: 1, color: "var(--color-ink-faint)", label: "none" }]}
|
||||
centerLabel={`${Math.round(failedRate)}%`}
|
||||
centerSub="fail rate"
|
||||
/>
|
||||
<div className="flex-1 space-y-2 text-sm">
|
||||
{Object.entries(byAction).map(([k, v]) => {
|
||||
const count = typeof v === "number" ? v : null;
|
||||
return (
|
||||
<div key={k} className="flex items-center gap-2.5">
|
||||
<span className="text-ink-soft">{ACTION_ICON[k as ModerationActionType]}</span>
|
||||
<span className="flex-1 text-ink-soft">{ACTION_LABEL[k as ModerationActionType] ?? k}</span>
|
||||
{count !== null && <span className="mono text-ink">{count}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{Object.keys(byAction).length === 0 && (
|
||||
<div className="text-xs text-ink-faint">No actions recorded yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel className="lg:col-span-3">
|
||||
<SectionHeader
|
||||
eyebrow="filter"
|
||||
title="Action log"
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="size-3.5 text-ink-faint" />
|
||||
<Select value={typeFilter} onChange={setTypeFilter} options={typeOpts} size="sm" className="w-36" />
|
||||
<Select value={statusFilter} onChange={setStatusFilter} options={statusOpts} size="sm" className="w-32" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
|
||||
{(actions ?? []).map((a) => (
|
||||
<ActionRow key={a.id} a={a} />
|
||||
))}
|
||||
{(actions ?? []).length === 0 && (
|
||||
<div className="py-10 text-center text-xs text-ink-faint">No matching actions.</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionRow({ a }: { a: ModerationAction }) {
|
||||
const tone =
|
||||
a.status === "executed" ? "signal" : a.status === "failed" ? "vermilion" : "amber";
|
||||
const icon = ACTION_ICON[a.action_type] ?? <AlertTriangle className="size-3.5" />;
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3">
|
||||
<span className={`mt-0.5 ${tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal"}`}>{icon}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-ink">{a.username ?? "unknown"}</span>
|
||||
<Badge tone={tone}>{a.status}</Badge>
|
||||
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
|
||||
{a.created_at ? new Date(a.created_at).toLocaleString() : "—"}
|
||||
</span>
|
||||
</div>
|
||||
{a.reason && <div className="mt-0.5 text-xs text-ink-soft">“{a.reason}”</div>}
|
||||
{a.content && (
|
||||
<div className="mt-1 line-clamp-2 rounded-[8px] bg-white/[0.03] px-2 py-1 text-xs text-ink-faint">
|
||||
{a.content}
|
||||
</div>
|
||||
)}
|
||||
{a.error && <div className="mt-1 text-xs text-vermilion">{a.error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* Recordings — Server Component.
|
||||
* Seeds the library from server-fetched recordings; live `voice_recording_uploaded`
|
||||
* events (synced in the client View via WS) keep it fresh.
|
||||
*/
|
||||
import { getRecordings } from "@/lib/api/server";
|
||||
import RecordingsView from "./view";
|
||||
import { RecordingsView } from "./view";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function RecordingsPage() {
|
||||
const data = await getRecordings(50).catch(() => undefined);
|
||||
return <RecordingsView initialRecordings={data?.items} />;
|
||||
let recordings = undefined;
|
||||
try {
|
||||
recordings = await getRecordings(50);
|
||||
} catch {
|
||||
/* client hooks surface errors */
|
||||
}
|
||||
return <RecordingsView initialItems={recordings?.items} />;
|
||||
}
|
||||
|
||||
@@ -1,206 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { Delete, Download, Play } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Waveform } from "@/components/charts/waveform";
|
||||
import { StaggerGroup, StaggerItem } from "@/components/motion/stagger";
|
||||
import { Avatar } from "@/components/primitives/avatar";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { Button } from "@/components/primitives/button";
|
||||
import { Dialog } from "@/components/primitives/dialog";
|
||||
import {
|
||||
useDeleteRecording,
|
||||
useRecordings,
|
||||
useRecordingsWsSync,
|
||||
} from "@/hooks";
|
||||
import type { VoiceRecording } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect } from "react";
|
||||
import { Headphones, Trash2, Download } from "lucide-react";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
import { useRecordings, useDeleteRecording, useRecordingsWsSync } from "@/hooks";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { GlassPanel, GlassCard, Avatar, Button } from "@/components/primitives";
|
||||
import { SectionHeader, EmptyState, ErrorState, LoadingState } from "@/components/shared";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
import { toast } from "@/components/primitives";
|
||||
import type { VoiceRecording } from "@/lib/types";
|
||||
|
||||
interface RecordingsListProps {
|
||||
recordings: VoiceRecording[];
|
||||
error: Error | null;
|
||||
isLoading: boolean;
|
||||
deleting: string | null;
|
||||
onSelect: (rec: VoiceRecording) => void;
|
||||
onDelete: (rec: VoiceRecording) => void;
|
||||
preview: VoiceRecording | null;
|
||||
onClosePreview: () => void;
|
||||
}
|
||||
export function RecordingsView({ initialItems }: { initialItems?: VoiceRecording[] }) {
|
||||
const ws = useWebSocket();
|
||||
const { data: items, isLoading, error } = useRecordings(initialItems);
|
||||
const del = useDeleteRecording();
|
||||
useRecordingsWsSync(ws);
|
||||
const ambient = useAmbient();
|
||||
|
||||
function RecordingsList({
|
||||
recordings,
|
||||
error,
|
||||
isLoading,
|
||||
deleting,
|
||||
onSelect,
|
||||
onDelete,
|
||||
preview,
|
||||
onClosePreview,
|
||||
}: RecordingsListProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-16 surface animate-shimmer" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error)
|
||||
return (
|
||||
<p className="text-sm text-[var(--color-vermilion)]">
|
||||
Failed to load: {error.message}
|
||||
</p>
|
||||
);
|
||||
useEffect(() => {
|
||||
ambient.set("signal", 0.3, "recordings");
|
||||
}, [ambient]);
|
||||
|
||||
const onDelete = async (id: string) => {
|
||||
try {
|
||||
await del.mutateAsync(id);
|
||||
toast({ title: "Recording deleted", tone: "signal" });
|
||||
} catch (e) {
|
||||
toast({ title: "Delete failed", description: String(e), tone: "vermilion" });
|
||||
}
|
||||
};
|
||||
|
||||
if (error && !items) return <ErrorState error={error} />;
|
||||
if (!items && isLoading) return <LoadingState label="Loading clips" />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<AnimatePresence>
|
||||
{recordings.map((rec) => (
|
||||
<StaggerItem key={rec.id} className="surface p-3" layout>
|
||||
<motion.div layout className="flex items-center gap-3">
|
||||
<Waveform
|
||||
seed={rec.id}
|
||||
bars={20}
|
||||
height={40}
|
||||
className="w-20 shrink-0"
|
||||
/>
|
||||
<Avatar name={rec.username} src={rec.avatar_url} size={34} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium">
|
||||
{rec.username ?? "unknown"}
|
||||
</span>
|
||||
<Badge
|
||||
tone={
|
||||
rec.upload_status === "uploaded"
|
||||
? "signal"
|
||||
: rec.upload_status === "failed"
|
||||
? "vermilion"
|
||||
: "amber"
|
||||
}
|
||||
>
|
||||
.{rec.filename.split(".").pop() ?? "mp3"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mono text-xs text-[var(--color-ink-soft)]">
|
||||
{(rec.size_bytes / 1024).toFixed(0)} KB ·{" "}
|
||||
{new Date(rec.created_at * 1000).toLocaleTimeString()}
|
||||
<GlassPanel>
|
||||
<SectionHeader
|
||||
eyebrow="voice captures"
|
||||
title="Recordings"
|
||||
action={<span className="mono text-xs text-ink-faint">{(items ?? []).length} clips</span>}
|
||||
/>
|
||||
{(items ?? []).length === 0 ? (
|
||||
<EmptyState icon={<Headphones className="size-7" />} title="No recordings" description="Voice clips captured by the bot appear here." />
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{(items ?? []).map((r) => (
|
||||
<GlassCard key={r.id} className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar src={r.avatar_url} name={r.username} size={38} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-ink">{r.username}</div>
|
||||
<div className="mono text-[0.65rem] text-ink-faint">
|
||||
{r.channel_name ?? "voice"} · {new Date(r.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<span className="mono text-[0.65rem] text-ink-faint">{formatBytes(r.size_bytes)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{rec.download_url && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onSelect(rec)}
|
||||
>
|
||||
<Play className="size-4" />
|
||||
</Button>
|
||||
<a
|
||||
href={rec.download_url}
|
||||
download={rec.filename}
|
||||
aria-label="Download"
|
||||
className="flex size-9 items-center justify-center rounded-[var(--radius-r-control)] text-xs text-[var(--color-ink-soft)] hover:bg-[var(--color-surface-2)]"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</a>
|
||||
</>
|
||||
|
||||
{r.download_url ? (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<audio controls src={r.download_url} className="h-9 w-full" preload="none" />
|
||||
) : (
|
||||
<div className="rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-faint">
|
||||
Upload pending…
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{r.download_url && (
|
||||
<a href={r.download_url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1.5 rounded-[9px] border border-hairline px-2.5 py-1.5 text-xs text-ink-soft hover:text-ink hover:border-signal/40">
|
||||
<Download className="size-3.5" /> Download
|
||||
</a>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={deleting === rec.id}
|
||||
onClick={() => onDelete(rec)}
|
||||
aria-label="Delete"
|
||||
className="ml-auto"
|
||||
onClick={() => onDelete(r.id)}
|
||||
disabled={del.isPending}
|
||||
>
|
||||
<Delete className="size-4" />
|
||||
<Trash2 className="size-3.5" /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</StaggerItem>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
<PreviewDialog
|
||||
open={!!preview}
|
||||
onClose={onClosePreview}
|
||||
recording={preview}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewDialog({
|
||||
open,
|
||||
onClose,
|
||||
recording,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
recording: VoiceRecording | null;
|
||||
}) {
|
||||
if (!recording) return null;
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} className="p-6 max-w-xl">
|
||||
<div className="space-y-3">
|
||||
<div className="display text-lg text-[var(--color-signal)]">
|
||||
{recording.filename}
|
||||
</GlassCard>
|
||||
))}
|
||||
</div>
|
||||
{/* biome-ignore lint/a11y/useMediaCaption: voice recordings are uncaptioned audio previews — no transcript available */}
|
||||
<audio
|
||||
controls
|
||||
src={recording.download_url ?? ""}
|
||||
aria-label={`Audio recording: ${recording.filename}`}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="mono text-xs text-[var(--color-ink-soft)]">
|
||||
{(recording.size_bytes / 1024).toFixed(0)} KB ·{" "}
|
||||
{recording.upload_status}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RecordingsView({
|
||||
initialRecordings,
|
||||
}: {
|
||||
initialRecordings?: VoiceRecording[];
|
||||
}) {
|
||||
const ws = useWebSocket();
|
||||
const {
|
||||
data: recordings = [],
|
||||
error,
|
||||
isLoading,
|
||||
} = useRecordings(initialRecordings);
|
||||
const del = useDeleteRecording();
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
useRecordingsWsSync(ws);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(rec: VoiceRecording) => {
|
||||
setDeleting(rec.id);
|
||||
del.mutate(rec.id);
|
||||
setTimeout(() => setDeleting(null), 800);
|
||||
},
|
||||
[del],
|
||||
);
|
||||
|
||||
const [preview, setPreview] = useState<VoiceRecording | null>(null);
|
||||
|
||||
return (
|
||||
<RecordingsList
|
||||
recordings={recordings}
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
deleting={deleting}
|
||||
onSelect={setPreview}
|
||||
onDelete={handleDelete}
|
||||
preview={preview}
|
||||
onClosePreview={() => setPreview(null)}
|
||||
/>
|
||||
)}
|
||||
</GlassPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* Voice — Server Component.
|
||||
* Seeds authoritative voice status (shared active speakers snapshot) on the
|
||||
* server, then hands off to the client View for the 3D scene + WS live updates.
|
||||
*/
|
||||
import { getVoiceStatus } from "@/lib/api/server";
|
||||
import type { VoiceStatus } from "@/lib/types";
|
||||
import VoiceView from "./view";
|
||||
import { getGuilds, getVoiceStatus } from "@/lib/api/server";
|
||||
import { VoiceView } from "./view";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function VoicePage() {
|
||||
const status = await getVoiceStatus().catch(() => undefined);
|
||||
return <VoiceView initialStatus={status} />;
|
||||
let status = undefined;
|
||||
let guilds = undefined;
|
||||
try {
|
||||
[status, guilds] = await Promise.all([getVoiceStatus(), getGuilds()]);
|
||||
} catch {
|
||||
/* client hooks surface errors */
|
||||
}
|
||||
return <VoiceView initialStatus={status} initialGuilds={guilds} />;
|
||||
}
|
||||
|
||||
@@ -1,232 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { Headphones, Loader2, Radio, RadioOff } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { SignalField } from "@/components/three";
|
||||
import { WebGLGuard } from "@/components/three/webgl-guard";
|
||||
import { StaticFallback } from "@/components/three/static-fallback";
|
||||
import { StaggerGroup, StaggerItem } from "@/components/motion/stagger";
|
||||
import { Button } from "@/components/primitives/button";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { Select } from "@/components/primitives/select";
|
||||
import { SpeakerWaveform } from "@/components/voice/speaker-waveform";
|
||||
import { SessionRibbon } from "@/components/charts/session-ribbon";
|
||||
import { ActiveSpeakersPanel } from "@/components/voice/active-speakers-panel";
|
||||
import { MicControl } from "@/components/voice/mic-control";
|
||||
import { ListenControl } from "@/components/voice/listen-control";
|
||||
import { Mic, MicOff, Headphones, PhoneOff, Radio, Volume2, Waves } from "lucide-react";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
import {
|
||||
useGuilds,
|
||||
useMicTransmit,
|
||||
useSpeakers,
|
||||
useVoiceChannels,
|
||||
useVoiceStatus,
|
||||
useVoiceConnect,
|
||||
useVoiceDisconnect,
|
||||
useSpeakers,
|
||||
useMicTransmit,
|
||||
useVoiceListen,
|
||||
useVoiceStatus,
|
||||
} from "@/hooks";
|
||||
import type { VoiceStatus } from "@/lib/types";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { GlassPanel, Button } from "@/components/primitives";
|
||||
import { VoiceStage } from "@/components/voice/voice-stage";
|
||||
import { Equalizer } from "@/components/charts";
|
||||
import { SectionHeader, EmptyState, ErrorState, LoadingState } from "@/components/shared";
|
||||
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
||||
import { toast } from "@/components/primitives";
|
||||
import type { Guild, VoiceStatus } from "@/lib/types";
|
||||
|
||||
export default function VoiceView({ initialStatus }: { initialStatus?: VoiceStatus }) {
|
||||
export function VoiceView({
|
||||
initialStatus,
|
||||
initialGuilds,
|
||||
}: {
|
||||
initialStatus?: VoiceStatus;
|
||||
initialGuilds?: Guild[];
|
||||
}) {
|
||||
const ws = useWebSocket();
|
||||
const [selectedGuild, setSelectedGuild] = useState("");
|
||||
const [selectedChannel, setSelectedChannel] = useState("");
|
||||
|
||||
// Live connection status — SWR revalidates on connect/disconnect (the
|
||||
// useVoiceConnect/Disconnect actions invalidate the "voice-status" key),
|
||||
// so this reflects real-time state instead of the static SSR snapshot.
|
||||
const { data: status } = useVoiceStatus(initialStatus);
|
||||
const { speakers, subscribe } = useSpeakers(status?.activeSpeakers ?? []);
|
||||
const { data: guilds = [] } = useGuilds();
|
||||
const { data: voiceChannels = [] } = useVoiceChannels(selectedGuild);
|
||||
const { data: status, isLoading, error } = useVoiceStatus(initialStatus);
|
||||
const { data: guilds } = useGuilds(initialGuilds);
|
||||
const connect = useVoiceConnect();
|
||||
const disconnect = useVoiceDisconnect();
|
||||
const listen = useVoiceListen(ws);
|
||||
const mic = useMicTransmit(ws);
|
||||
const [micActive, setMicActive] = useState(false);
|
||||
const [micVolume, setMicVolume] = useState(75);
|
||||
const listen = useVoiceListen(ws);
|
||||
const { speakers, subscribe } = useSpeakers(initialStatus?.activeSpeakers);
|
||||
const ambient = useAmbient();
|
||||
|
||||
const [guildId, setGuildId] = useState<string | null>(
|
||||
initialStatus?.activeGuildId ?? initialGuilds?.[0]?.id ?? null,
|
||||
);
|
||||
const [channelId, setChannelId] = useState<string | null>(
|
||||
initialStatus?.activeChannelId ?? null,
|
||||
);
|
||||
const [micOn, setMicOn] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = subscribe(ws);
|
||||
return unsub;
|
||||
}, [subscribe, ws]);
|
||||
|
||||
const active = speakers.filter((s) => s.speaking);
|
||||
const connected = status?.connected ?? false;
|
||||
useEffect(() => {
|
||||
if (status?.connected) ambient.set("signal", 0.55, "voice live");
|
||||
else ambient.set("vermilion", 0.35, "voice idle");
|
||||
}, [status?.connected, ambient]);
|
||||
|
||||
const handleMicToggle = async (on: boolean) => {
|
||||
if (on) {
|
||||
try {
|
||||
await mic.mutateAsync(true);
|
||||
setMicActive(true);
|
||||
} catch {
|
||||
setMicActive(false);
|
||||
}
|
||||
} else {
|
||||
setMicActive(false);
|
||||
try {
|
||||
await mic.mutateAsync(false);
|
||||
} catch {
|
||||
// Stop already tore down — ignore remote error
|
||||
}
|
||||
if (error && !status) return <ErrorState error={error} />;
|
||||
if (!status && isLoading) return <LoadingState label="Linking voice" />;
|
||||
|
||||
const connected = status?.connected ?? false;
|
||||
const listenBars = Array.from(listen.levels.values()).slice(0, 32);
|
||||
|
||||
const onConnect = async () => {
|
||||
if (!guildId || !channelId) {
|
||||
toast({ title: "Pick a guild + channel", tone: "vermilion" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await connect.mutateAsync({ guildId, channelId });
|
||||
toast({ title: "Connected to voice", tone: "signal" });
|
||||
} catch (e) {
|
||||
toast({ title: "Connect failed", description: String(e), tone: "vermilion" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleMicVolume = (v: number) => {
|
||||
setMicVolume(v);
|
||||
mic.setVolume(v);
|
||||
};
|
||||
|
||||
const handleListenVolume = (v: number) => {
|
||||
listen.setVolume(v);
|
||||
};
|
||||
|
||||
const handleGuildChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const g = e.target.value;
|
||||
setSelectedGuild(g);
|
||||
setSelectedChannel("");
|
||||
const onMic = async (on: boolean) => {
|
||||
try {
|
||||
await mic.mutateAsync(on);
|
||||
setMicOn(on);
|
||||
} catch (e) {
|
||||
toast({ title: "Mic error", description: String(e), tone: "vermilion" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Connection bar with guild + voice channel pickers */}
|
||||
<div className="surface flex flex-col gap-3 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge tone={connected ? "signal" : "neutral"} dot>
|
||||
{connected ? "Connected" : "Disconnected"}
|
||||
</Badge>
|
||||
{connected && status?.activeChannelName && (
|
||||
<span className="hidden items-center gap-1.5 text-xs text-[var(--color-ink-soft)] sm:flex">
|
||||
<Headphones className="size-3.5" />
|
||||
{status.activeChannelName}
|
||||
</span>
|
||||
<div className="space-y-5">
|
||||
<GlassPanel>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<GuildChannelPicker
|
||||
mode="voice"
|
||||
guildsInitial={initialGuilds}
|
||||
guildId={guildId}
|
||||
channelId={channelId}
|
||||
onChange={(g, c) => {
|
||||
setGuildId(g);
|
||||
setChannelId(c);
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{connected ? (
|
||||
<Button variant="danger" size="sm" onClick={() => disconnect.mutate()} disabled={disconnect.isPending}>
|
||||
<PhoneOff className="size-4" /> Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" size="sm" onClick={onConnect} disabled={connect.isPending}>
|
||||
<Radio className="size-4" /> Connect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant={micOn ? "primary" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onMic(!micOn)}
|
||||
disabled={mic.isPending}
|
||||
aria-pressed={micOn}
|
||||
>
|
||||
{micOn ? <Mic className="size-4" /> : <MicOff className="size-4" />}
|
||||
{micOn ? "Mic live" : "Push-to-talk"}
|
||||
</Button>
|
||||
<Button
|
||||
variant={listen.active ? "primary" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => listen.toggle(!listen.active)}
|
||||
>
|
||||
{listen.active ? <Headphones className="size-4" /> : <Volume2 className="size-4" />}
|
||||
{listen.active ? "Listening" : "Listen in"}
|
||||
</Button>
|
||||
{listen.active && (
|
||||
<div className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-1.5">
|
||||
<Waves className="size-4 text-signal" />
|
||||
<Equalizer bars={listenBars} className="w-40" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
{!connected ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select
|
||||
value={selectedGuild}
|
||||
onChange={handleGuildChange}
|
||||
className="flex-1 min-w-[140px] h-9"
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select guild…
|
||||
</option>
|
||||
{guilds.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={selectedChannel}
|
||||
onChange={(e) => setSelectedChannel(e.target.value)}
|
||||
disabled={!selectedGuild}
|
||||
className="flex-1 min-w-[140px] h-9"
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select channel…
|
||||
</option>
|
||||
{(voiceChannels ?? []).map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
void connect.mutate({
|
||||
guildId: selectedGuild,
|
||||
channelId: selectedChannel,
|
||||
})
|
||||
}
|
||||
disabled={connect.isPending || !selectedGuild || !selectedChannel}
|
||||
>
|
||||
{connect.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin mr-1.5" />
|
||||
) : (
|
||||
<Radio className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => void disconnect.mutate(undefined)}
|
||||
disabled={disconnect.isPending}
|
||||
>
|
||||
{disconnect.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin mr-1.5" />
|
||||
) : (
|
||||
<RadioOff className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stage hero */}
|
||||
<div className="relative surface h-[280px] items-end justify-center overflow-hidden rounded-[var(--radius-r)] p-5">
|
||||
<WebGLGuard
|
||||
fallback={
|
||||
<StaticFallback
|
||||
variant="orb"
|
||||
count={Math.max(speakers.length, 3)}
|
||||
className="absolute inset-0"
|
||||
<div className="grid gap-5 lg:grid-cols-3">
|
||||
<GlassPanel className="lg:col-span-2">
|
||||
<SectionHeader
|
||||
eyebrow="stage"
|
||||
title="Live speakers"
|
||||
action={
|
||||
<span className="mono text-xs text-ink-faint">
|
||||
{speakers.length} present
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{speakers.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<MicOff className="size-7" />}
|
||||
title={connected ? "Silent right now" : "Not connected"}
|
||||
description={connected ? "Speakers appear as they talk." : "Connect to a voice channel to see presence."}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SignalField
|
||||
activity={speakers.length > 0 ? 0.6 : 0.2}
|
||||
className="absolute inset-0"
|
||||
/>
|
||||
</WebGLGuard>
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2">
|
||||
<SpeakerWaveform speakers={active} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<VoiceStage speakers={speakers} />
|
||||
<div className="mt-2 flex flex-wrap justify-center gap-2">
|
||||
{speakers.map((sp) => (
|
||||
<span
|
||||
key={sp.userId}
|
||||
className={`mono flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs ${
|
||||
sp.speaking
|
||||
? "border-signal/40 bg-signal/10 text-signal"
|
||||
: "border-hairline bg-white/5 text-ink-soft"
|
||||
}`}
|
||||
>
|
||||
<span className={`size-1.5 rounded-full ${sp.speaking ? "bg-signal animate-breathe" : "bg-ink-faint"}`} />
|
||||
{sp.username}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel>
|
||||
<SectionHeader eyebrow="links" title="Connections" />
|
||||
<div className="space-y-2">
|
||||
{(status?.connections ?? []).map((c) => (
|
||||
<div key={`${c.guildId}-${c.channelId}`} className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-sm">
|
||||
<span className="size-2 rounded-full bg-signal" />
|
||||
<span className="flex-1 truncate text-ink-soft">{c.channelName}</span>
|
||||
<span className="mono text-[0.6rem] text-ink-faint">{new Date(c.connectedAt).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
))}
|
||||
{(status?.connections ?? []).length === 0 && (
|
||||
<div className="py-6 text-center text-xs text-ink-faint">No active links</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft">
|
||||
<span className="mono text-ink-faint">channel</span>{" "}
|
||||
{status?.activeChannelName ?? "—"}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
|
||||
<StaggerGroup className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
|
||||
<StaggerItem>
|
||||
<MicControl
|
||||
micOn={micActive}
|
||||
onToggle={handleMicToggle}
|
||||
levels={listen.levels}
|
||||
/>
|
||||
</StaggerItem>
|
||||
<StaggerItem>
|
||||
<ListenControl
|
||||
listening={listen.active}
|
||||
onToggle={(on) => listen.toggle(on)}
|
||||
volume={75}
|
||||
onVolume={handleListenVolume}
|
||||
/>
|
||||
</StaggerItem>
|
||||
</StaggerGroup>
|
||||
|
||||
{/* Activity timeline */}
|
||||
<div className="surface p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">
|
||||
Live session timeline
|
||||
</h3>
|
||||
<SessionRibbon
|
||||
segments={speakers.map((s) => ({
|
||||
id: s.userId,
|
||||
label: s.username,
|
||||
value: s.speaking ? 3 : 1,
|
||||
tone: s.speaking ? "signal" : "neutral",
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ActiveSpeakersPanel speakers={speakers} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,69 +3,66 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/*
|
||||
* GMW — new design system (visual overhaul).
|
||||
* GMW — Ambient Ops Console design system.
|
||||
*
|
||||
* Replaces the old teal-cyan + purple + glassmorphism language with a warm,
|
||||
* signal-driven ops-console aesthetic. Hierarchy comes from scale/weight and
|
||||
* tonal surface blocks, NOT from borders/shadows. Three semantic signals:
|
||||
* lime = OK / live (--signal)
|
||||
* amber = warn (--amber)
|
||||
* vermilion = flag/danger (--vermilion)
|
||||
* A signal-driven, immersive ops aesthetic. Hierarchy comes from scale/weight
|
||||
* and tonal surface blocks, not borders. The whole app sits behind a live
|
||||
* WebGL haze (see components/ambient) tinted by a semantic signal:
|
||||
* lime = OK / live
|
||||
* amber = warn
|
||||
* vermilion = flag / danger
|
||||
*/
|
||||
|
||||
@theme {
|
||||
/* ── Surfaces ── */
|
||||
--color-canvas: oklch(0.96 0.012 80);
|
||||
--color-surface: oklch(0.92 0.014 80);
|
||||
--color-surface-2: oklch(0.88 0.016 80);
|
||||
/* ── Surfaces (dark-first; light overrides below) ── */
|
||||
--color-canvas: oklch(0.12 0.014 70);
|
||||
--color-canvas-2: oklch(0.16 0.02 70);
|
||||
--color-surface: oklch(0.2 0.022 70 / 0.55);
|
||||
--color-surface-2: oklch(0.26 0.024 70 / 0.45);
|
||||
|
||||
/* ── Ink ── */
|
||||
--color-ink: oklch(0.22 0.02 70);
|
||||
--color-ink-soft: oklch(0.46 0.02 70);
|
||||
--color-ink: oklch(0.95 0.008 75);
|
||||
--color-ink-soft: oklch(0.66 0.02 75);
|
||||
--color-ink-faint: oklch(0.5 0.02 75);
|
||||
|
||||
/* ── Structural ── */
|
||||
--color-hairline: oklch(0.22 0.02 70 / 0.1);
|
||||
--color-hairline: oklch(1 0 0 / 0.1);
|
||||
--hairline-w: 1px;
|
||||
|
||||
/* ── Semantic signals ── */
|
||||
--color-signal: oklch(0.78 0.17 125);
|
||||
--color-signal-ink: oklch(0.20 0.03 70);
|
||||
--color-signal-glow: oklch(0.78 0.17 125 / 0.35);
|
||||
--color-amber: oklch(0.80 0.15 70);
|
||||
--color-vermilion: oklch(0.62 0.21 25);
|
||||
--color-vermilion-soft: oklch(0.62 0.21 25 / 0.15);
|
||||
--color-signal: oklch(0.86 0.19 128);
|
||||
--color-signal-ink: oklch(0.18 0.03 70);
|
||||
--color-signal-glow: oklch(0.86 0.19 128 / 0.4);
|
||||
--color-amber: oklch(0.85 0.15 72);
|
||||
--color-vermilion: oklch(0.68 0.21 25);
|
||||
--color-vermilion-glow: oklch(0.68 0.21 25 / 0.4);
|
||||
|
||||
--color-ring: var(--color-signal);
|
||||
|
||||
/* ── Fonts ── */
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
--font-display: "Bricolage Grotesque", "Inter", sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||||
--font-display: "Bricolage Grotesque", "Inter", system-ui, sans-serif;
|
||||
|
||||
/* ── Radii ── */
|
||||
--radius-r: 14px;
|
||||
--radius-r: 16px;
|
||||
--radius-r-panel: 12px;
|
||||
--radius-r-control: 8px;
|
||||
--radius-r-control: 9px;
|
||||
--radius-r-pill: 9999px;
|
||||
}
|
||||
|
||||
/* ── Dark theme overrides ── */
|
||||
.dark {
|
||||
--color-canvas: oklch(0.13 0.015 70);
|
||||
--color-surface: oklch(0.18 0.02 70);
|
||||
--color-surface-2: oklch(0.23 0.022 70);
|
||||
/* ── Light theme overrides ── */
|
||||
.light {
|
||||
--color-canvas: oklch(0.96 0.012 80);
|
||||
--color-canvas-2: oklch(0.92 0.014 80);
|
||||
--color-surface: oklch(1 0 0 / 0.7);
|
||||
--color-surface-2: oklch(1 0 0 / 0.5);
|
||||
|
||||
--color-ink: oklch(0.93 0.01 75);
|
||||
--color-ink-soft: oklch(0.62 0.02 75);
|
||||
--color-ink: oklch(0.22 0.02 70);
|
||||
--color-ink-soft: oklch(0.46 0.02 70);
|
||||
--color-ink-faint: oklch(0.6 0.02 70);
|
||||
|
||||
--color-hairline: oklch(1 0 0 / 0.09);
|
||||
|
||||
--color-signal: oklch(0.88 0.18 125);
|
||||
--color-signal-ink: oklch(0.18 0.03 70);
|
||||
--color-signal-glow: oklch(0.88 0.18 125 / 0.4);
|
||||
--color-amber: oklch(0.85 0.15 70);
|
||||
--color-vermilion: oklch(0.68 0.21 25);
|
||||
--color-vermilion-soft: oklch(0.68 0.21 25 / 0.18);
|
||||
--color-hairline: oklch(0.22 0.02 70 / 0.12);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -79,41 +76,28 @@
|
||||
|
||||
body {
|
||||
@apply bg-canvas text-ink font-sans antialiased;
|
||||
/* warm dot-grid texture + faint glow — replaces old bluish radial layers */
|
||||
background-image:
|
||||
radial-gradient(circle, oklch(0.45 0.03 70 / 0.05) 1px, transparent 1px),
|
||||
radial-gradient(ellipse 80% 50% at 50% -20%, oklch(0.78 0.17 125 / 0.06), transparent),
|
||||
radial-gradient(ellipse 50% 40% at 85% 90%, oklch(0.80 0.15 70 / 0.04), transparent);
|
||||
background-size: 26px 26px, 100% 100%, 100% 100%;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
background-image:
|
||||
radial-gradient(circle, oklch(1 0 0 / 0.022) 1px, transparent 1px),
|
||||
radial-gradient(ellipse 80% 50% at 50% -20%, oklch(0.88 0.18 125 / 0.05), transparent),
|
||||
radial-gradient(ellipse 50% 40% at 85% 90%, oklch(0.85 0.15 70 / 0.03), transparent);
|
||||
background-size: 26px 26px, 100% 100%, 100% 100%;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: oklch(0.78 0.17 125 / 0.35);
|
||||
background: var(--color-signal-glow);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Scrollbar — warm */
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.4 0.02 70 / 0.22);
|
||||
background: oklch(1 0 0 / 0.14);
|
||||
border-radius: 999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.4 0.02 70 / 0.38);
|
||||
background: oklch(1 0 0 / 0.26);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
@@ -122,59 +106,48 @@
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Tonal block that replaces bordered cards */
|
||||
.surface {
|
||||
@layer components {
|
||||
/* Glass panel — the workhorse container. Floating, blurred, faint glow. */
|
||||
.glass {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-r);
|
||||
border: var(--hairline-w) solid var(--color-hairline);
|
||||
border-radius: var(--radius-r);
|
||||
backdrop-filter: blur(18px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(140%);
|
||||
box-shadow:
|
||||
0 1px 0 0 oklch(1 0 0 / 0.06) inset,
|
||||
0 18px 50px -28px oklch(0 0 0 / 0.8);
|
||||
}
|
||||
.surface-2 {
|
||||
|
||||
.glass-2 {
|
||||
background: var(--color-surface-2);
|
||||
border: var(--hairline-w) solid var(--color-hairline);
|
||||
border-radius: var(--radius-r-panel);
|
||||
border: var(--hairline-w) solid var(--color-hairline);
|
||||
backdrop-filter: blur(14px) saturate(130%);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(130%);
|
||||
}
|
||||
|
||||
/* 1px animated pulse line marking a live/section header */
|
||||
.scan-tick {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.scan-tick::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
inset-block-start: 0;
|
||||
block-size: 1px;
|
||||
inline-size: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--color-signal) 20%,
|
||||
var(--color-signal) 80%,
|
||||
transparent
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: scan 2.6s linear infinite;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ticker {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-r);
|
||||
border: var(--hairline-w) solid var(--color-hairline);
|
||||
padding: clamp(1rem, 2vw, 1.5rem);
|
||||
/* Section label — small uppercase mono eyebrow */
|
||||
.eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-ink-faint);
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.7rem;
|
||||
gap: 0.4rem;
|
||||
padding: 0.22rem 0.7rem;
|
||||
border-radius: var(--radius-r-pill);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
letter-spacing: 0.01em;
|
||||
background: oklch(1 0 0 / 0.06);
|
||||
border: 1px solid var(--color-hairline);
|
||||
color: var(--color-ink-soft);
|
||||
}
|
||||
|
||||
.mono {
|
||||
@@ -186,26 +159,58 @@
|
||||
.display {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 0.96;
|
||||
}
|
||||
}
|
||||
|
||||
/* text tone helpers */
|
||||
@layer utilities {
|
||||
.text-signal { color: var(--color-signal); }
|
||||
.text-amber { color: var(--color-amber); }
|
||||
.text-vermilion { color: var(--color-vermilion); }
|
||||
.text-ink-soft { color: var(--color-ink-soft); }
|
||||
.text-ink-faint { color: var(--color-ink-faint); }
|
||||
|
||||
.glow-signal {
|
||||
text-shadow: 0 0 22px var(--color-signal-glow);
|
||||
}
|
||||
.glow-vermilion {
|
||||
text-shadow: 0 0 22px var(--color-vermilion-glow);
|
||||
}
|
||||
|
||||
/* animated scan line for live headers */
|
||||
.scan-line {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.scan-line::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--color-signal) 25%,
|
||||
var(--color-signal) 75%,
|
||||
transparent
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: scan 2.8s linear infinite;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* focus ring helper for interactive blocks */
|
||||
.ring-focus {
|
||||
transition: box-shadow 0.18s ease;
|
||||
transition: box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
.ring-focus:hover {
|
||||
box-shadow: 0 0 0 1px var(--color-signal-glow);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Keyframes ──────────────────────────── */
|
||||
/* ── Keyframes ── */
|
||||
@keyframes scan {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
@@ -216,54 +221,52 @@
|
||||
60% { transform: scaleY(0.5); }
|
||||
}
|
||||
@keyframes fade-up {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes spin-disc {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
/* kept for live status dots */
|
||||
@keyframes pulse-ring {
|
||||
0% { transform: scale(0.8); opacity: 1; }
|
||||
100% { transform: scale(2.5); opacity: 0; }
|
||||
0% { transform: scale(0.85); opacity: 1; }
|
||||
100% { transform: scale(2.6); opacity: 0; }
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
@keyframes breathe {
|
||||
0%, 100% { opacity: 0.55; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-eq {
|
||||
animation: eq 0.9s ease-in-out infinite;
|
||||
transform-origin: bottom;
|
||||
}
|
||||
.animate-spin-disc {
|
||||
animation: spin-disc 8s linear infinite;
|
||||
}
|
||||
.animate-spin-disc.paused {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
.animate-pulse-ring {
|
||||
animation: pulse-ring 1.5s ease-out infinite;
|
||||
}
|
||||
.animate-spin-disc { animation: spin-disc 9s linear infinite; }
|
||||
.animate-spin-disc.paused { animation-play-state: paused; }
|
||||
.animate-pulse-ring { animation: pulse-ring 1.6s ease-out infinite; }
|
||||
.animate-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
oklch(0.78 0.17 125 / 0.08),
|
||||
oklch(0.86 0.19 128 / 0.1),
|
||||
transparent
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
animation: shimmer 1.6s infinite;
|
||||
}
|
||||
.animate-breathe { animation: breathe 3.5s ease-in-out infinite; }
|
||||
|
||||
/* ── Reduced motion: kill all decorative animation ── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.scan-tick::after,
|
||||
.scan-line::after,
|
||||
.animate-eq,
|
||||
.animate-spin-disc,
|
||||
.animate-pulse-ring,
|
||||
.animate-shimmer {
|
||||
.animate-shimmer,
|
||||
.animate-breathe {
|
||||
animation: none !important;
|
||||
}
|
||||
html { scroll-behavior: auto; }
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { SIGNAL_RGB, type SignalTone } from "./ambient-context";
|
||||
|
||||
const VERT = /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
void main(){
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position.xy, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAG = /* glsl */ `
|
||||
precision mediump float;
|
||||
varying vec2 vUv;
|
||||
uniform float uTime;
|
||||
uniform vec3 uColor;
|
||||
uniform float uIntensity;
|
||||
uniform vec2 uRes;
|
||||
|
||||
float hash(vec2 p){ p=fract(p*vec2(123.34,456.21)); p+=dot(p,p+45.32); return fract(p.x*p.y); }
|
||||
float noise(vec2 p){
|
||||
vec2 i=floor(p); vec2 f=fract(p);
|
||||
float a=hash(i), b=hash(i+vec2(1.,0.)), c=hash(i+vec2(0.,1.)), d=hash(i+vec2(1.,1.));
|
||||
vec2 u=f*f*(3.-2.*f);
|
||||
return mix(mix(a,b,u.x),mix(c,d,u.x),u.y);
|
||||
}
|
||||
float fbm(vec2 p){
|
||||
float v=0.0, a=0.5;
|
||||
mat2 m=mat2(1.6,1.2,-1.2,1.6);
|
||||
for(int i=0;i<5;i++){ v+=a*noise(p); p=m*p; a*=0.5; }
|
||||
return v;
|
||||
}
|
||||
|
||||
void main(){
|
||||
vec2 uv=vUv;
|
||||
vec2 p=uv-0.5;
|
||||
p.x*=uRes.x/uRes.y;
|
||||
float t=uTime*0.04*(0.6+uIntensity);
|
||||
vec2 q=vec2(fbm(p*1.5+t), fbm(p*1.5-t+5.0));
|
||||
float f=fbm(p*2.2 + q*1.8 + t*0.5);
|
||||
vec2 c=vec2(sin(uTime*0.05)*0.25, cos(uTime*0.04)*0.18);
|
||||
float d=length(p-c);
|
||||
float glow=smoothstep(0.95,0.0,d)*0.5;
|
||||
float haze=(f*0.7+glow)*uIntensity;
|
||||
vec3 col=uColor*haze;
|
||||
float g=hash(uv*uRes+uTime)*0.035;
|
||||
col+=g;
|
||||
float vig=smoothstep(1.25,0.15,length(p));
|
||||
col*=0.35+0.65*vig;
|
||||
gl_FragColor=vec4(col,1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const MOTE_COUNT = 140;
|
||||
|
||||
export function AmbientCanvas({
|
||||
targetRef,
|
||||
}: {
|
||||
targetRef: React.MutableRefObject<{ tone: SignalTone; intensity: number }>;
|
||||
}) {
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const mount = mountRef.current;
|
||||
if (!mount) return;
|
||||
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: false,
|
||||
alpha: false,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
} catch {
|
||||
return; // static CSS fallback remains
|
||||
}
|
||||
|
||||
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
|
||||
renderer.setPixelRatio(dpr);
|
||||
renderer.setSize(mount.clientWidth, mount.clientHeight);
|
||||
mount.appendChild(renderer.domElement);
|
||||
renderer.domElement.style.width = "100%";
|
||||
renderer.domElement.style.height = "100%";
|
||||
renderer.domElement.style.display = "block";
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
|
||||
const uniforms = {
|
||||
uTime: { value: 0 },
|
||||
uColor: { value: new THREE.Color(...SIGNAL_RGB.signal) },
|
||||
uIntensity: { value: 0.35 },
|
||||
uRes: { value: new THREE.Vector2(1, 1) },
|
||||
};
|
||||
|
||||
const quad = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(2, 2),
|
||||
new THREE.ShaderMaterial({
|
||||
vertexShader: VERT,
|
||||
fragmentShader: FRAG,
|
||||
uniforms,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
scene.add(quad);
|
||||
|
||||
// — Drifting motes —
|
||||
const positions = new Float32Array(MOTE_COUNT * 3);
|
||||
const speeds = new Float32Array(MOTE_COUNT);
|
||||
for (let i = 0; i < MOTE_COUNT; i++) {
|
||||
positions[i * 3] = (Math.random() - 0.5) * 2;
|
||||
positions[i * 3 + 1] = (Math.random() - 0.5) * 2;
|
||||
positions[i * 3 + 2] = 0;
|
||||
speeds[i] = 0.01 + Math.random() * 0.03;
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
const moteMat = new THREE.PointsMaterial({
|
||||
size: 0.012,
|
||||
color: new THREE.Color(...SIGNAL_RGB.signal),
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
const motes = new THREE.Points(geo, moteMat);
|
||||
scene.add(motes);
|
||||
|
||||
const color = new THREE.Color();
|
||||
const target = new THREE.Color();
|
||||
let targetIntensity = 0.35;
|
||||
let intensity = 0.35;
|
||||
let raf = 0;
|
||||
let last = performance.now();
|
||||
let running = !reduce;
|
||||
|
||||
const resize = () => {
|
||||
const w = mount.clientWidth || 1;
|
||||
const h = mount.clientHeight || 1;
|
||||
renderer.setSize(w, h);
|
||||
uniforms.uRes.value.set(w * dpr, h * dpr);
|
||||
};
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(mount);
|
||||
resize();
|
||||
|
||||
const onVisibility = () => {
|
||||
running = !document.hidden && !reduce;
|
||||
if (running) {
|
||||
last = performance.now();
|
||||
loop();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
|
||||
const frame = (now: number) => {
|
||||
const dt = Math.min((now - last) / 1000, 0.05);
|
||||
last = now;
|
||||
uniforms.uTime.value += dt;
|
||||
|
||||
// Ease toward target signal/intensity each frame (no React re-render).
|
||||
const tgt = targetRef.current;
|
||||
target.set(...SIGNAL_RGB[tgt.tone]);
|
||||
color.lerp(target, 0.04);
|
||||
uniforms.uColor.value.copy(color);
|
||||
moteMat.color.copy(color);
|
||||
targetIntensity = 0.2 + tgt.intensity * 0.8;
|
||||
intensity = lerp(intensity, targetIntensity, 0.04);
|
||||
uniforms.uIntensity.value = intensity;
|
||||
|
||||
const pos = geo.attributes.position as THREE.BufferAttribute;
|
||||
for (let i = 0; i < MOTE_COUNT; i++) {
|
||||
let y = pos.getY(i) + speeds[i] * dt * (0.5 + tgt.intensity);
|
||||
if (y > 1.1) y = -1.1;
|
||||
pos.setY(i, y);
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
|
||||
renderer.render(scene, camera);
|
||||
if (running) raf = requestAnimationFrame(frame);
|
||||
};
|
||||
|
||||
const loop = () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
last = performance.now();
|
||||
raf = requestAnimationFrame(frame);
|
||||
};
|
||||
|
||||
if (reduce) {
|
||||
// single static frame
|
||||
uniforms.uIntensity.value = 0.3;
|
||||
renderer.render(scene, camera);
|
||||
} else {
|
||||
loop();
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
geo.dispose();
|
||||
moteMat.dispose();
|
||||
(quad.geometry as THREE.BufferGeometry).dispose();
|
||||
(quad.material as THREE.Material).dispose();
|
||||
renderer.dispose();
|
||||
if (renderer.domElement.parentNode === mount) {
|
||||
mount.removeChild(renderer.domElement);
|
||||
}
|
||||
};
|
||||
}, [targetRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={mountRef}
|
||||
aria-hidden
|
||||
className="fixed inset-0 -z-10 overflow-hidden"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(120% 90% at 50% 0%, oklch(0.2 0.04 70 / 0.5), oklch(0.1 0.015 70) 60%)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import { AmbientCanvas } from "./ambient-canvas";
|
||||
|
||||
export type SignalTone = "signal" | "amber" | "vermilion";
|
||||
|
||||
/** sRGB triplets for the three semantic signals (matches globals.css). */
|
||||
export const SIGNAL_RGB: Record<SignalTone, [number, number, number]> = {
|
||||
signal: [0.42, 1.0, 0.52],
|
||||
amber: [1.0, 0.76, 0.28],
|
||||
vermilion: [1.0, 0.34, 0.28],
|
||||
};
|
||||
|
||||
export interface AmbientState {
|
||||
tone: SignalTone;
|
||||
/** 0..1 — drives haze density + drift speed (e.g. server load). */
|
||||
intensity: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface AmbientControls {
|
||||
set: (tone: SignalTone, intensity?: number, label?: string) => void;
|
||||
reset: () => void;
|
||||
state: AmbientState;
|
||||
}
|
||||
|
||||
const DEFAULT: AmbientState = { tone: "signal", intensity: 0.35, label: "nominal" };
|
||||
|
||||
const AmbientContext = createContext<AmbientControls | null>(null);
|
||||
|
||||
/**
|
||||
* Holds the live ambient signal. The canvas reads `targetRef` inside its
|
||||
* render loop (no React re-render per frame); `state` is mirrored into React
|
||||
* only so small UI bits (topbar) can reflect the current tone.
|
||||
*/
|
||||
export function AmbientProvider({ children }: { children: React.ReactNode }) {
|
||||
const targetRef = useRef<AmbientState>({ ...DEFAULT });
|
||||
const [state, setState] = useState<AmbientState>(DEFAULT);
|
||||
|
||||
const set = useCallback((tone: SignalTone, intensity?: number, label?: string) => {
|
||||
targetRef.current = {
|
||||
tone,
|
||||
intensity: intensity ?? targetRef.current.intensity,
|
||||
label: label ?? targetRef.current.label,
|
||||
};
|
||||
setState({ ...targetRef.current });
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
targetRef.current = { ...DEFAULT };
|
||||
setState({ ...DEFAULT });
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AmbientControls>(
|
||||
() => ({ set, reset, state }),
|
||||
[set, reset, state],
|
||||
);
|
||||
|
||||
return (
|
||||
<AmbientContext.Provider value={value}>
|
||||
<AmbientCanvas targetRef={targetRef} />
|
||||
{children}
|
||||
</AmbientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAmbient(): AmbientControls {
|
||||
const ctx = useContext(AmbientContext);
|
||||
if (!ctx) throw new Error("useAmbient must be used within <AmbientProvider>");
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* AmbientField — full-bleed WebGL particle haze that reacts to live data.
|
||||
*
|
||||
* No container, no grid, no chrome. Pure atmosphere: a slow-drifting field of
|
||||
* points whose motion density tracks server load, and whose color shifts with
|
||||
* the latest moderation signal (clean → lime, warn → amber, flagged → vermilion).
|
||||
*
|
||||
* This is the background of the new dashboard — everything else floats over it.
|
||||
*/
|
||||
|
||||
type Signal = "neutral" | "signal" | "amber" | "vermilion";
|
||||
|
||||
const SIGNAL_RGB: Record<Signal, [number, number, number]> = {
|
||||
neutral: [0.52, 0.49, 0.46],
|
||||
signal: [0.78, 0.85, 0.62],
|
||||
amber: [0.95, 0.78, 0.42],
|
||||
vermilion: [0.86, 0.32, 0.28],
|
||||
};
|
||||
|
||||
interface AmbientFieldProps {
|
||||
/** 0..1 — drives particle drift speed + density. */
|
||||
load?: number;
|
||||
/** Latest moderation signal — tints the haze. */
|
||||
signal?: Signal;
|
||||
}
|
||||
|
||||
export function AmbientField({
|
||||
load = 0.3,
|
||||
signal = "signal",
|
||||
}: AmbientFieldProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const loadRef = useRef(load);
|
||||
const signalRef = useRef<[number, number, number]>(SIGNAL_RGB[signal]);
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
loadRef.current = load;
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
signalRef.current = SIGNAL_RGB[signal];
|
||||
}, [signal]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
|
||||
const resize = () => {
|
||||
w = canvas.clientWidth;
|
||||
h = canvas.clientHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
};
|
||||
resize();
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(canvas);
|
||||
|
||||
// Particle haze
|
||||
const N = 90;
|
||||
const pts = Array.from({ length: N }, () => ({
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
z: Math.random() * 0.8 + 0.2,
|
||||
vx: (Math.random() - 0.5) * 0.0004,
|
||||
vy: (Math.random() - 0.5) * 0.0004,
|
||||
r: Math.random() * 1.5 + 0.5,
|
||||
}));
|
||||
|
||||
const draw = () => {
|
||||
const [cr, cg, cb] = signalRef.current;
|
||||
const speed = 0.4 + loadRef.current * 1.6;
|
||||
|
||||
// Trail fade
|
||||
ctx.fillStyle = "rgba(244, 240, 234, 0.06)";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
for (const p of pts) {
|
||||
p.x += p.vx * speed;
|
||||
p.y += p.vy * speed;
|
||||
if (p.x < 0) p.x += 1;
|
||||
if (p.x > 1) p.x -= 1;
|
||||
if (p.y < 0) p.y += 1;
|
||||
if (p.y > 1) p.y -= 1;
|
||||
|
||||
const px = p.x * w;
|
||||
const py = p.y * h;
|
||||
const rad = p.r * p.z * (1 + loadRef.current);
|
||||
const alpha = 0.05 + p.z * 0.12;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, rad, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(${Math.round(cr * 255)}, ${Math.round(cg * 255)}, ${Math.round(cb * 255)}, ${alpha})`;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Faint vignette glow center
|
||||
const grad = ctx.createRadialGradient(
|
||||
w / 2,
|
||||
h / 2,
|
||||
0,
|
||||
w / 2,
|
||||
h / 2,
|
||||
Math.max(w, h) * 0.6,
|
||||
);
|
||||
grad.addColorStop(
|
||||
0,
|
||||
`rgba(${Math.round(cr * 255)}, ${Math.round(cg * 255)}, ${Math.round(cb * 255)}, 0.03)`,
|
||||
);
|
||||
grad.addColorStop(1, "rgba(0,0,0,0)");
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
rafRef.current = requestAnimationFrame(draw);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(draw);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 -z-10 h-full w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Search, Sparkles } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Avatar } from "@/components/primitives/avatar";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { Button } from "@/components/primitives/button";
|
||||
import { Input } from "@/components/primitives/input";
|
||||
import { Progress } from "@/components/primitives/progress";
|
||||
import { EmptyState, LoadingSkeleton } from "@/components/shared";
|
||||
import { useMessageSearch } from "@/hooks";
|
||||
import { renderMessageContent, safeParseJsonArray } from "@/lib/format";
|
||||
|
||||
export function SearchPanel() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
const { data: results, isValidating: isFetching } = useMessageSearch(
|
||||
query,
|
||||
enabled,
|
||||
);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
if (!query.trim()) return;
|
||||
setEnabled(true);
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-[var(--color-ink-soft)]" />
|
||||
<Input
|
||||
placeholder="Search message content, AI flags, analysis text…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSearch} disabled={!query.trim() || isFetching}>
|
||||
{isFetching && <Loader2 className="size-4 animate-spin mr-1.5" />}
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isFetching ? (
|
||||
<LoadingSkeleton count={5} height="h-28" />
|
||||
) : results !== undefined ? (
|
||||
<>
|
||||
<p className="text-sm text-[var(--color-ink-soft)]">
|
||||
Found {results.length} result{results.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
{results.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
title="No messages found matching your query."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{results.map((msg) => (
|
||||
<div key={msg.id} className="surface p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar
|
||||
src={msg.avatar_url ?? undefined}
|
||||
name={msg.username}
|
||||
size={32}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-[var(--color-ink)]">
|
||||
{msg.username}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--color-ink-soft)]">
|
||||
{msg.created_at
|
||||
? new Date(msg.created_at).toLocaleString()
|
||||
: ""}
|
||||
</span>
|
||||
{msg.ai_status && (
|
||||
<Badge
|
||||
tone={
|
||||
msg.ai_status === "clean"
|
||||
? "signal"
|
||||
: msg.ai_status === "flagged"
|
||||
? "vermilion"
|
||||
: "neutral"
|
||||
}
|
||||
>
|
||||
{msg.ai_status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-[var(--color-ink)]">
|
||||
{renderMessageContent(
|
||||
msg.edited_content ?? msg.content,
|
||||
msg.metadata,
|
||||
)}
|
||||
</p>
|
||||
{msg.ai_moderation_flags &&
|
||||
msg.ai_moderation_flags !== "[]" && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{safeParseJsonArray(msg.ai_moderation_flags).map(
|
||||
(flag) => (
|
||||
<Badge key={flag} tone="vermilion">
|
||||
{flag}
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{msg.ai_analysis && (
|
||||
<p className="text-xs text-[var(--color-ink-soft)] italic line-clamp-2 leading-relaxed">
|
||||
<Sparkles className="size-3 inline mr-1" />
|
||||
{msg.ai_analysis}
|
||||
</p>
|
||||
)}
|
||||
{msg.ai_confidence != null && (
|
||||
<div className="flex items-center gap-2 max-w-40">
|
||||
<Progress value={msg.ai_confidence * 100} />
|
||||
<span className="text-[11px] text-[var(--color-ink-soft)] tabular-nums shrink-0">
|
||||
{(msg.ai_confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<Search className="size-12 text-[var(--color-ink-soft)] mb-4" />
|
||||
<p className="text-sm text-[var(--color-ink-soft)]">
|
||||
Enter a search query to find messages across all channels.
|
||||
</p>
|
||||
<p className="text-xs text-[var(--color-ink-soft)] mt-1">
|
||||
Searches message content, AI flags, and analysis text.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useId } from "react";
|
||||
|
||||
export interface AreaPoint {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface AreaActivityProps {
|
||||
data: AreaPoint[];
|
||||
height?: number;
|
||||
stroke?: string;
|
||||
className?: string;
|
||||
label?: string;
|
||||
}
|
||||
import type { DailyActivityPoint } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Dual-area activity chart: total messages (signal) vs flagged (vermilion).
|
||||
* Pure SVG, scales to container. Includes a 7-day trailing window hint.
|
||||
*/
|
||||
export function AreaActivity({
|
||||
data,
|
||||
height = 160,
|
||||
stroke = "var(--color-signal)",
|
||||
className,
|
||||
label,
|
||||
}: AreaActivityProps) {
|
||||
const id = useId().replace(/:/g, "");
|
||||
const reduce = useReducedMotion();
|
||||
const width = 600;
|
||||
if (data.length === 0)
|
||||
return <div className={className} style={{ height }} />;
|
||||
daily,
|
||||
height = 200,
|
||||
}: {
|
||||
daily: DailyActivityPoint[];
|
||||
height?: number;
|
||||
}) {
|
||||
const w = 720;
|
||||
const pad = 8;
|
||||
const n = daily.length;
|
||||
const max = Math.max(...daily.map((d) => d.messages), 1);
|
||||
const x = (i: number) => pad + (i / Math.max(n - 1, 1)) * (w - pad * 2);
|
||||
const y = (v: number) => height - pad - (v / max) * (height - pad * 2);
|
||||
|
||||
const max = Math.max(...data.map((d) => d.value), 1);
|
||||
const stepX = width / Math.max(data.length - 1, 1);
|
||||
const pts = data.map((d, i) => {
|
||||
const x = i * stepX;
|
||||
const y = height - (d.value / max) * (height - 10) - 5;
|
||||
return [x, y] as const;
|
||||
});
|
||||
const line = pts
|
||||
.map(
|
||||
(p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(1)},${p[1].toFixed(1)}`,
|
||||
)
|
||||
.join(" ");
|
||||
const area = `${line} L${width},${height} L0,${height} Z`;
|
||||
const pathLen = 1400;
|
||||
const msgLine = daily.map((d, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(d.messages).toFixed(1)}`).join(" ");
|
||||
const flagLine = daily.map((d, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(d.flagged).toFixed(1)}`).join(" ");
|
||||
const msgArea = `${msgLine} L${x(n - 1).toFixed(1)},${height - pad} L${x(0).toFixed(1)},${height - pad} Z`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className={className}
|
||||
style={{ width: "100%", height }}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label={label ?? "Activity chart"}
|
||||
>
|
||||
<svg viewBox={`0 0 ${w} ${height}`} preserveAspectRatio="none" className="w-full" style={{ height }}>
|
||||
<defs>
|
||||
<linearGradient id={`area-${id}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.32" />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity="0.02" />
|
||||
<linearGradient id="area-msg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--color-signal)" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="var(--color-signal)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<motion.path
|
||||
d={area}
|
||||
fill={`url(#area-${id})`}
|
||||
initial={reduce ? false : { pathLength: 0, opacity: 0.4 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.9, ease: [0.22, 1, 0.36, 1] }}
|
||||
/>
|
||||
<motion.path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
initial={reduce ? false : { pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.9, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{ strokeDasharray: pathLen }}
|
||||
/>
|
||||
{[0.25, 0.5, 0.75].map((g) => (
|
||||
<line key={g} x1={pad} x2={w - pad} y1={height * g} y2={height * g} stroke="var(--color-hairline)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
<path d={msgArea} fill="url(#area-msg)" />
|
||||
<path d={msgLine} fill="none" stroke="var(--color-signal)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
|
||||
<path d={flagLine} fill="none" stroke="var(--color-vermilion)" strokeWidth={1.5} vectorEffect="non-scaling-stroke" strokeDasharray="3 3" />
|
||||
{daily.map((d, i) =>
|
||||
i % 2 === 0 ? (
|
||||
<text key={d.day} x={x(i)} y={height - 1} fill="var(--color-ink-faint)" fontSize={9} textAnchor="middle" className="mono">
|
||||
{d.day.slice(5)}
|
||||
</text>
|
||||
) : null,
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/** Stacked donut for moderation overview / composition. */
|
||||
export function Donut({
|
||||
segments,
|
||||
size = 132,
|
||||
thickness = 14,
|
||||
centerLabel,
|
||||
centerSub,
|
||||
}: {
|
||||
segments: { value: number; color: string; label: string }[];
|
||||
size?: number;
|
||||
thickness?: number;
|
||||
centerLabel?: string;
|
||||
centerSub?: string;
|
||||
}) {
|
||||
const total = segments.reduce((s, x) => s + x.value, 0) || 1;
|
||||
const r = size / 2 - thickness / 2;
|
||||
const c = 2 * Math.PI * r;
|
||||
let offset = 0;
|
||||
return (
|
||||
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--color-hairline)" strokeWidth={thickness} />
|
||||
{segments.map((s, i) => {
|
||||
const len = (s.value / total) * c;
|
||||
const el = (
|
||||
<circle
|
||||
key={i}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={s.color}
|
||||
strokeWidth={thickness}
|
||||
strokeDasharray={`${len} ${c - len}`}
|
||||
strokeDashoffset={-offset}
|
||||
style={{ transition: "stroke-dashoffset 0.6s ease" }}
|
||||
/>
|
||||
);
|
||||
offset += len;
|
||||
return el;
|
||||
})}
|
||||
</svg>
|
||||
{(centerLabel || centerSub) && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
{centerLabel && <span className="display text-lg">{centerLabel}</span>}
|
||||
{centerSub && <span className="mono text-[0.6rem] text-ink-faint">{centerSub}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { Sparkline } from "./sparkline";
|
||||
export { AreaActivity } from "./area-activity";
|
||||
export { RadialGauge } from "./radial-gauge";
|
||||
export { Donut } from "./donut";
|
||||
export { Equalizer } from "./waveform";
|
||||
@@ -1,90 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useId } from "react";
|
||||
|
||||
export interface RadialGaugeProps {
|
||||
/** 0..1 health ratio */
|
||||
value: number;
|
||||
size?: number;
|
||||
label?: string;
|
||||
sublabel?: string;
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
}
|
||||
|
||||
const toneColor = {
|
||||
signal: "var(--color-signal)",
|
||||
amber: "var(--color-amber)",
|
||||
vermilion: "var(--color-vermilion)",
|
||||
};
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Circular progress gauge. value 0..1. */
|
||||
export function RadialGauge({
|
||||
value,
|
||||
size = 160,
|
||||
label,
|
||||
sublabel,
|
||||
tone = "signal",
|
||||
}: RadialGaugeProps) {
|
||||
const id = useId().replace(/:/g, "");
|
||||
const reduce = useReducedMotion();
|
||||
const stroke = 12;
|
||||
const r = (size - stroke) / 2;
|
||||
size = 120,
|
||||
}: {
|
||||
value: number;
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
size?: number;
|
||||
}) {
|
||||
const v = Math.max(0, Math.min(1, value));
|
||||
const stroke = tone === "vermilion" ? "var(--color-vermilion)" : tone === "amber" ? "var(--color-amber)" : "var(--color-signal)";
|
||||
const r = size / 2 - 10;
|
||||
const c = 2 * Math.PI * r;
|
||||
const pct = Math.max(0, Math.min(1, value));
|
||||
const dash = c * pct;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative inline-flex items-center justify-center"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className="-rotate-90"
|
||||
role="img"
|
||||
aria-label={`${Math.round(pct * 100)}% ${label ?? "gauge"}`}
|
||||
>
|
||||
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--color-hairline)" strokeWidth={8} />
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="var(--color-hairline)"
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
<motion.circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={toneColor[tone]}
|
||||
strokeWidth={stroke}
|
||||
stroke={stroke}
|
||||
strokeWidth={8}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={c}
|
||||
initial={reduce ? false : { strokeDashoffset: c }}
|
||||
animate={{ strokeDashoffset: c - dash }}
|
||||
transition={{ duration: 1, ease: [0.22, 1, 0.36, 1] }}
|
||||
strokeDashoffset={c * (1 - v)}
|
||||
style={{ transition: "stroke-dashoffset 0.6s ease", filter: `drop-shadow(0 0 6px ${stroke})` }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">
|
||||
<span
|
||||
className="display text-2xl mono"
|
||||
style={{ color: toneColor[tone] }}
|
||||
>
|
||||
{Math.round(pct * 100)}%
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className={cn("display text-xl", tone === "vermilion" && "text-vermilion", tone === "amber" && "text-amber", tone === "signal" && "text-signal")}>
|
||||
{label}
|
||||
</span>
|
||||
{label && (
|
||||
<span className="text-[11px] font-medium text-[var(--color-ink-soft)] uppercase tracking-wide">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{sublabel && (
|
||||
<span className="text-[10px] text-[var(--color-ink-soft)]/70">
|
||||
{sublabel}
|
||||
</span>
|
||||
)}
|
||||
{sublabel && <span className="mono text-[0.6rem] text-ink-faint">{sublabel}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface RibbonSegment {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number; // relative duration
|
||||
tone?: "signal" | "amber" | "vermilion" | "neutral";
|
||||
}
|
||||
|
||||
const toneClass = {
|
||||
signal: "bg-[var(--color-signal)]",
|
||||
amber: "bg-[var(--color-amber)]",
|
||||
vermilion: "bg-[var(--color-vermilion)]",
|
||||
neutral: "bg-[var(--color-ink-soft)]/40",
|
||||
};
|
||||
|
||||
export interface SessionRibbonProps {
|
||||
segments: RibbonSegment[];
|
||||
className?: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function SessionRibbon({
|
||||
segments,
|
||||
className,
|
||||
height = 28,
|
||||
}: SessionRibbonProps) {
|
||||
const total = segments.reduce((s, x) => s + x.value, 0) || 1;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full gap-0.5 overflow-hidden rounded-[var(--radius-r-control)]",
|
||||
className,
|
||||
)}
|
||||
style={{ height }}
|
||||
>
|
||||
{segments.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={cn(
|
||||
"group relative flex items-center justify-center rounded-sm transition-all",
|
||||
toneClass[s.tone ?? "signal"],
|
||||
)}
|
||||
style={{ width: `${(s.value / total) * 100}%` }}
|
||||
title={`${s.label}: ${s.value}`}
|
||||
>
|
||||
<span className="pointer-events-none absolute inset-x-0 -top-6 hidden whitespace-nowrap rounded bg-[var(--color-ink)] px-1.5 py-0.5 text-[10px] text-[var(--color-canvas)] group-hover:block">
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
|
||||
export interface SparklineProps {
|
||||
data: number[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
stroke?: string;
|
||||
className?: string;
|
||||
fill?: boolean;
|
||||
}
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Minimal sparkline. Pure SVG, scales to container width. */
|
||||
export function Sparkline({
|
||||
data,
|
||||
width = 120,
|
||||
height = 36,
|
||||
stroke = "var(--color-signal)",
|
||||
values,
|
||||
className,
|
||||
stroke = "var(--color-signal)",
|
||||
fill = true,
|
||||
}: SparklineProps) {
|
||||
const id = useId().replace(/:/g, "");
|
||||
if (data.length < 2)
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label="No data"
|
||||
/>
|
||||
);
|
||||
|
||||
const min = Math.min(...data);
|
||||
const max = Math.max(...data);
|
||||
height = 40,
|
||||
}: {
|
||||
values: number[];
|
||||
className?: string;
|
||||
stroke?: string;
|
||||
fill?: boolean;
|
||||
height?: number;
|
||||
}) {
|
||||
if (values.length === 0) return null;
|
||||
const w = 100;
|
||||
const max = Math.max(...values, 1);
|
||||
const min = Math.min(...values, 0);
|
||||
const span = max - min || 1;
|
||||
const stepX = width / (data.length - 1);
|
||||
const pts = data.map((v, i) => {
|
||||
const x = i * stepX;
|
||||
const pts = values.map((v, i) => {
|
||||
const x = (i / (values.length - 1)) * w;
|
||||
const y = height - ((v - min) / span) * (height - 4) - 2;
|
||||
return [x, y] as const;
|
||||
});
|
||||
const line = pts
|
||||
.map(
|
||||
(p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(1)},${p[1].toFixed(1)}`,
|
||||
)
|
||||
.join(" ");
|
||||
const area = `${line} L${width},${height} L0,${height} Z`;
|
||||
|
||||
const line = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(2)},${p[1].toFixed(2)}`).join(" ");
|
||||
const area = `${line} L${w},${height} L0,${height} Z`;
|
||||
const id = `spark-${stroke.replace(/[^a-z0-9]/gi, "")}`;
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className={className}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label="Trend sparkline"
|
||||
>
|
||||
<svg viewBox={`0 0 ${w} ${height}`} preserveAspectRatio="none" className={cn("w-full", className)} style={{ height }}>
|
||||
<defs>
|
||||
<linearGradient id={`spark-${id}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.28" />
|
||||
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.35" />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{fill && <path d={area} fill={`url(#spark-${id})`} />}
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={1.6}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{fill && <path d={area} fill={`url(#${id})`} />}
|
||||
<path d={line} fill="none" stroke={stroke} strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,76 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface WaveformProps {
|
||||
seed: string | number;
|
||||
bars?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
}
|
||||
|
||||
// deterministic pseudo-random from seed so the shape is stable per recording
|
||||
function hashSeed(seed: string | number): number {
|
||||
const s = String(seed);
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
export function Waveform({
|
||||
seed,
|
||||
bars = 40,
|
||||
height = 40,
|
||||
/** Live equalizer bars. `bars` are 0..1 levels. */
|
||||
export function Equalizer({
|
||||
bars,
|
||||
color = "var(--color-signal)",
|
||||
className,
|
||||
tone = "signal",
|
||||
}: WaveformProps) {
|
||||
const reduce = useReducedMotion();
|
||||
const values = useMemo(() => {
|
||||
let state = hashSeed(seed) || 1;
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < bars; i++) {
|
||||
state = (Math.imul(state, 1103515245) + 12345) >>> 0;
|
||||
const r = (state % 1000) / 1000;
|
||||
// envelope: louder in the middle, quieter at edges
|
||||
const env = Math.sin((i / (bars - 1)) * Math.PI);
|
||||
out.push(0.18 + r * 0.82 * (0.4 + env * 0.6));
|
||||
}
|
||||
return out;
|
||||
}, [seed, bars]);
|
||||
|
||||
const color = {
|
||||
signal: "var(--color-signal)",
|
||||
amber: "var(--color-amber)",
|
||||
vermilion: "var(--color-vermilion)",
|
||||
}[tone];
|
||||
|
||||
}: {
|
||||
bars: number[];
|
||||
color?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-end gap-[2px]", className)}
|
||||
style={{ height }}
|
||||
aria-hidden
|
||||
>
|
||||
{values.map((v, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
className="flex-1 rounded-[2px]"
|
||||
style={{ background: color, height: `${Math.max(8, v * 100)}%` }}
|
||||
initial={reduce ? false : { scaleY: 0.2, opacity: 0 }}
|
||||
animate={{ scaleY: 1, opacity: 1 }}
|
||||
whileHover={{ scaleY: 1.15 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
delay: reduce ? 0 : i * 0.006,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div className={cn("flex h-10 items-end gap-[3px]", className)}>
|
||||
{bars.length === 0 ? (
|
||||
<div className="flex w-full items-end gap-[3px]">
|
||||
{Array.from({ length: 28 }).map((_, i) => (
|
||||
<span key={i} className="flex-1 rounded-full bg-white/10" style={{ height: "12%" }} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
bars.map((b, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="flex-1 rounded-full"
|
||||
style={{
|
||||
height: `${Math.max(6, b * 100)}%`,
|
||||
background: color,
|
||||
boxShadow: b > 0.05 ? `0 0 8px ${color}` : "none",
|
||||
transition: "height 90ms linear",
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Eraser, Send, Sparkles } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useChatbot } from "./chatbot-context";
|
||||
|
||||
interface ChatPanelProps {
|
||||
inputRef?: React.RefObject<HTMLInputElement | null>;
|
||||
}
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return d.toLocaleTimeString("id-ID", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
const SUGGESTIONS = [
|
||||
"Gimana suasana server hari ini?",
|
||||
"Channel mana yang paling ramai?",
|
||||
"Total pesan di server?",
|
||||
"Ada pesan bermasalah?",
|
||||
];
|
||||
|
||||
export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
|
||||
const { messages, sendMessage, clearMessages, isTyping } = useChatbot();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const internalInputRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = externalInputRef ?? internalInputRef;
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: re-run on message arrival; scroll is a visual effect keyed on new content
|
||||
useEffect(() => {
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, isTyping]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const input = inputRef.current;
|
||||
if (!input || !input.value.trim() || isTyping) return;
|
||||
sendMessage(input.value);
|
||||
input.value = "";
|
||||
};
|
||||
|
||||
const handleSuggestion = (text: string) => {
|
||||
if (isTyping) return;
|
||||
sendMessage(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Chat messages */}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="flex-1 space-y-1.5 overflow-y-auto px-2 py-2"
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex h-full flex-col justify-center gap-3 px-3 text-center">
|
||||
<p className="text-[11px] text-[var(--color-ink-soft)]">
|
||||
Halo! 👋 Aku tau soal server ini — pesan, flag, dan aktivitas.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => handleSuggestion(s)}
|
||||
disabled={isTyping}
|
||||
className="flex items-center gap-1 rounded-full border border-[var(--color-hairline)] bg-[var(--color-surface-2)] px-2.5 py-1 text-[10px] text-[var(--color-ink-soft)] transition-colors hover:bg-[var(--color-signal)] hover:text-[var(--color-signal-ink)] disabled:opacity-40"
|
||||
>
|
||||
<Sparkles className="size-2.5 text-[var(--color-signal)]" />
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg, i) => (
|
||||
<div
|
||||
key={`${msg.timestamp}-${i}`}
|
||||
className={`flex flex-col ${msg.role === "user" ? "items-end" : "items-start"}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] break-words whitespace-pre-wrap rounded-xl px-2.5 py-1.5 text-[11px] leading-relaxed ${
|
||||
msg.role === "user"
|
||||
? "rounded-br-sm bg-[var(--color-signal)] text-[var(--color-signal-ink)]"
|
||||
: "rounded-bl-sm bg-[var(--color-surface-2)] text-[var(--color-ink)]"
|
||||
}`}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
<span className="mt-0.5 px-1 text-[9px] text-[var(--color-ink-soft)]">
|
||||
{formatTime(msg.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{isTyping && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-xl rounded-bl-sm bg-[var(--color-surface-2)] px-2.5 py-2">
|
||||
<span className="inline-flex gap-1">
|
||||
<span
|
||||
className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
|
||||
style={{ animationDelay: "0ms" }}
|
||||
/>
|
||||
<span
|
||||
className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
|
||||
style={{ animationDelay: "150ms" }}
|
||||
/>
|
||||
<span
|
||||
className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input bar */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex shrink-0 items-center gap-1.5 border-t border-[var(--color-hairline)] px-2 py-2"
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Tanya soal server, pesan, atau statistik…"
|
||||
className="flex-1 bg-transparent text-[11px] text-[var(--color-ink)] outline-none placeholder:text-[var(--color-ink-soft)]/50"
|
||||
disabled={isTyping}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void clearMessages()}
|
||||
className="flex size-6 items-center justify-center rounded transition-colors hover:bg-[var(--color-surface-2)] disabled:opacity-40"
|
||||
disabled={isTyping}
|
||||
aria-label="Hapus riwayat chat"
|
||||
title="Hapus riwayat"
|
||||
>
|
||||
<Eraser className="size-3 text-[var(--color-ink-soft)] hover:text-[var(--color-vermilion)]" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="flex size-7 items-center justify-center rounded-lg bg-[var(--color-signal)] text-[var(--color-signal-ink)] transition-colors hover:opacity-90 disabled:opacity-40"
|
||||
disabled={isTyping}
|
||||
aria-label="Kirim pesan"
|
||||
title="Kirim"
|
||||
>
|
||||
<Send className="size-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Bot, Minimize2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ChatPanel } from "./chat-panel";
|
||||
import { useChatbot } from "./chatbot-context";
|
||||
|
||||
export function ChatbotContainer() {
|
||||
const { minimized, setMinimized } = useChatbot();
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
setDragging(true);
|
||||
setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
|
||||
},
|
||||
[position],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!dragging) return;
|
||||
setPosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y });
|
||||
},
|
||||
[dragging, dragStart],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => setDragging(false), []);
|
||||
|
||||
// Focus input when chat opens
|
||||
useEffect(() => {
|
||||
if (!minimized) {
|
||||
const id = setTimeout(() => inputRef.current?.focus(), 150);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
}, [minimized]);
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: drag container — mouse-move gesture surface, not keyboard-interactive content
|
||||
<div
|
||||
className="fixed bottom-4 right-4 z-40 select-none"
|
||||
style={{ transform: `translate(${position.x}px, ${position.y}px)` }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
<div
|
||||
className={`surface-2 overflow-hidden shadow-2xl transition-all duration-200 ${
|
||||
minimized ? "h-14 w-14 cursor-pointer" : "h-[460px] w-[320px]"
|
||||
}`}
|
||||
>
|
||||
{minimized ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMinimized(false)}
|
||||
className="flex size-full items-center justify-center"
|
||||
onMouseDown={handleMouseDown}
|
||||
aria-label="Buka chatbot"
|
||||
title="Buka chatbot"
|
||||
>
|
||||
<Bot className="size-6 text-[var(--color-signal)]" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Drag handle + controls */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag handle — mouse-only gesture, keyboard users use the buttons in this header */}
|
||||
<div
|
||||
className="flex shrink-0 cursor-grab items-center justify-between border-b border-[var(--color-hairline)] px-3 py-2 active:cursor-grabbing"
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
<Bot className="size-3.5 text-[var(--color-signal)]" />
|
||||
Chatbot
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMinimized(true)}
|
||||
className="flex size-6 items-center justify-center rounded transition-colors hover:bg-[var(--color-surface-2)]"
|
||||
aria-label="Kecilkan chatbot"
|
||||
title="Kecilkan chatbot"
|
||||
>
|
||||
<Minimize2 className="size-3.5 text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat panel — always open when bubble is expanded */}
|
||||
<div className="min-h-0 flex-1">
|
||||
<ChatPanel inputRef={inputRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useChatbotUserId } from "@/hooks/use-chatbot-user";
|
||||
import { chatbotApi } from "@/lib/api";
|
||||
|
||||
export type ChatbotExpression =
|
||||
| "idle"
|
||||
| "listening"
|
||||
| "surprise"
|
||||
| "happy"
|
||||
| "sad"
|
||||
| "talking";
|
||||
|
||||
interface ChatbotMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface ChatbotContextValue {
|
||||
/** Expression the chatbot avatar should display */
|
||||
expression: ChatbotExpression;
|
||||
setExpression: (expr: ChatbotExpression) => void;
|
||||
|
||||
/** Whether the enlarged bubble is minimized to a small icon */
|
||||
minimized: boolean;
|
||||
setMinimized: (v: boolean) => void;
|
||||
|
||||
/**
|
||||
* @deprecated Use `minimized` / `setMinimized` instead.
|
||||
* Legacy toggle alias kept for compatibility.
|
||||
*/
|
||||
isOpen: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
toggle: () => void;
|
||||
|
||||
/** Chat messages with real API backend */
|
||||
messages: ChatbotMessage[];
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
clearMessages: () => Promise<void>;
|
||||
isTyping: boolean;
|
||||
|
||||
/** Active guild context sent to the backend so answers reference the server */
|
||||
guildId: string;
|
||||
setGuildId: (g: string) => void;
|
||||
}
|
||||
|
||||
const ChatbotContext = createContext<ChatbotContextValue | null>(null);
|
||||
|
||||
export function ChatbotProvider({ children }: { children: ReactNode }) {
|
||||
const [expression, setExpression] = useState<ChatbotExpression>("idle");
|
||||
const [minimized, setMinimized] = useState(true);
|
||||
const [messages, setMessages] = useState<ChatbotMessage[]>([]);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [guildId, setGuildId] = useState("");
|
||||
const historyFetched = useRef(false);
|
||||
const userId = useChatbotUserId();
|
||||
|
||||
// Derived legacy state
|
||||
const isOpen = !minimized;
|
||||
|
||||
const setOpen = useCallback((open: boolean) => {
|
||||
setMinimized(!open);
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setMinimized((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Load chat history on first mount (per-device user history)
|
||||
useEffect(() => {
|
||||
if (historyFetched.current || !userId) return;
|
||||
historyFetched.current = true;
|
||||
|
||||
chatbotApi
|
||||
.getHistory(userId)
|
||||
.then((res) => {
|
||||
// Backend returns rows {user_message, bot_response, created_at} —
|
||||
// interleave each user message with its bot reply.
|
||||
const withReplies: ChatbotMessage[] = [];
|
||||
for (const row of res.history ?? []) {
|
||||
withReplies.push({
|
||||
role: "user",
|
||||
content: row.user_message,
|
||||
timestamp: row.created_at,
|
||||
});
|
||||
withReplies.push({
|
||||
role: "assistant",
|
||||
content: row.bot_response,
|
||||
timestamp: row.created_at,
|
||||
});
|
||||
}
|
||||
setMessages(withReplies);
|
||||
})
|
||||
.catch(() => {
|
||||
// API may not be available yet — silently ignore
|
||||
});
|
||||
}, [userId]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
if (!content.trim()) return;
|
||||
|
||||
const userMsg: ChatbotMessage = {
|
||||
role: "user",
|
||||
content: content.trim(),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setExpression("listening");
|
||||
setIsTyping(true);
|
||||
|
||||
try {
|
||||
// Send active guild as context so the backend can answer with
|
||||
// real server insights (serverInsights path in chatbot.service),
|
||||
// and the per-device user id so the history stays isolated.
|
||||
const res = await chatbotApi.send(content.trim(), guildId, userId);
|
||||
const botMsg: ChatbotMessage = {
|
||||
role: "assistant",
|
||||
content: res.response,
|
||||
timestamp: res.timestamp ?? new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, botMsg]);
|
||||
setExpression("happy");
|
||||
} catch {
|
||||
const errorMsg: ChatbotMessage = {
|
||||
role: "assistant",
|
||||
content:
|
||||
"Maaf, aku lagi gagal nyambung ke server. Coba tanya lagi ya 🙏",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMsg]);
|
||||
setExpression("sad");
|
||||
} finally {
|
||||
setIsTyping(false);
|
||||
}
|
||||
},
|
||||
[guildId, userId],
|
||||
);
|
||||
|
||||
const clearMessages = useCallback(async () => {
|
||||
try {
|
||||
await chatbotApi.clearHistory(userId);
|
||||
} catch {
|
||||
// Best-effort clear
|
||||
}
|
||||
setMessages([]);
|
||||
}, [userId]);
|
||||
|
||||
return (
|
||||
<ChatbotContext.Provider
|
||||
value={{
|
||||
expression,
|
||||
setExpression,
|
||||
minimized,
|
||||
setMinimized,
|
||||
isOpen,
|
||||
setOpen,
|
||||
toggle,
|
||||
messages,
|
||||
sendMessage,
|
||||
clearMessages,
|
||||
isTyping,
|
||||
guildId,
|
||||
setGuildId,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ChatbotContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useChatbot(): ChatbotContextValue {
|
||||
const ctx = useContext(ChatbotContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useChatbot must be used within a ChatbotProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Bot, Send, X, MessageCircle } from "lucide-react";
|
||||
import { chatbotApi } from "@/lib/api";
|
||||
import { useChatbotUserId } from "@/hooks/use-chatbot-user";
|
||||
import { GlassPanel, Input, Button, Avatar } from "@/components/primitives";
|
||||
import { toast } from "@/components/primitives";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Msg {
|
||||
role: "user" | "bot";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function Chatbot() {
|
||||
const userId = useChatbotUserId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [msgs, setMsgs] = useState<Msg[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !userId) return;
|
||||
chatbotApi
|
||||
.getHistory(userId)
|
||||
.then((res) => {
|
||||
setMsgs(
|
||||
res.history
|
||||
.slice(-12)
|
||||
.flatMap((h) => [
|
||||
{ role: "user" as const, content: h.user_message },
|
||||
{ role: "bot" as const, content: h.bot_response },
|
||||
]),
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [open, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
|
||||
}, [msgs, loading]);
|
||||
|
||||
const send = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || loading || !userId) return;
|
||||
setInput("");
|
||||
setMsgs((m) => [...m, { role: "user", content: text }]);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await chatbotApi.send(text, undefined, userId);
|
||||
setMsgs((m) => [...m, { role: "bot", content: res.response }]);
|
||||
} catch (e) {
|
||||
toast({ title: "Chat error", description: String(e), tone: "vermilion" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open assistant"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="fixed bottom-5 right-5 z-50 flex items-center justify-center rounded-full bg-signal text-signal-ink shadow-[0_10px_30px_-8px_var(--color-signal-glow)] transition-transform hover:scale-105"
|
||||
style={{ width: 52, height: 52 }}
|
||||
>
|
||||
{open ? <X className="size-5" /> : <MessageCircle className="size-5" />}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<GlassPanel
|
||||
className="fixed bottom-20 right-5 z-50 flex w-[min(92vw,360px)] flex-col p-0"
|
||||
style={{ animation: "fade-up 0.16s ease", height: 460 }}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-hairline px-4 py-3">
|
||||
<span className="flex size-8 items-center justify-center rounded-full bg-signal/15 text-signal">
|
||||
<Bot className="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-ink">GMW Assistant</div>
|
||||
<div className="mono text-[0.6rem] text-ink-faint">context-aware</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="flex-1 space-y-3 overflow-y-auto px-4 py-3">
|
||||
{msgs.length === 0 && (
|
||||
<div className="py-8 text-center text-xs text-ink-faint">
|
||||
Ask about moderation, voice, or media.
|
||||
</div>
|
||||
)}
|
||||
{msgs.map((m, i) => (
|
||||
<div key={i} className={cn("flex gap-2", m.role === "user" ? "justify-end" : "justify-start")}>
|
||||
{m.role === "bot" && <Avatar name="GMW" size={26} className="mt-0.5 bg-signal/15 text-signal" />}
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[80%] rounded-2xl px-3 py-2 text-sm",
|
||||
m.role === "user"
|
||||
? "rounded-br-sm bg-signal/20 text-ink"
|
||||
: "rounded-bl-sm bg-white/5 text-ink-soft",
|
||||
)}
|
||||
>
|
||||
{m.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex gap-2">
|
||||
<Avatar name="GMW" size={26} className="bg-signal/15 text-signal" />
|
||||
<div className="rounded-2xl rounded-bl-sm bg-white/5 px-3 py-2 text-sm text-ink-faint">…</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-t border-hairline p-3">
|
||||
<Input
|
||||
placeholder="Message…"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && send()}
|
||||
/>
|
||||
<Button variant="primary" size="icon" onClick={send} disabled={loading}>
|
||||
<Send className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { ChatPanel } from "./chat-panel";
|
||||
export { ChatbotContainer } from "./chatbot-container";
|
||||
export { ChatbotProvider, useChatbot } from "./chatbot-context";
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
Search,
|
||||
CornerDownLeft,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Moon,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { navItems } from "@/lib/navigation";
|
||||
import { GlassPanel } from "@/components/primitives";
|
||||
|
||||
interface Command {
|
||||
id: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
icon: React.ReactNode;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const router = useRouter();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
|
||||
const commands = useMemo<Command[]>(() => {
|
||||
const nav: Command[] = navItems.map((n) => ({
|
||||
id: `nav:${n.href}`,
|
||||
label: `Go to ${n.label}`,
|
||||
hint: n.href,
|
||||
icon: <n.icon className="size-4 text-signal" />,
|
||||
run: () => router.push(n.href),
|
||||
}));
|
||||
const actions: Command[] = [
|
||||
{
|
||||
id: "act:theme",
|
||||
label: "Toggle theme",
|
||||
hint: "appearance",
|
||||
icon:
|
||||
theme === "light" ? (
|
||||
<Moon className="size-4 text-signal" />
|
||||
) : (
|
||||
<Sun className="size-4 text-signal" />
|
||||
),
|
||||
run: () => setTheme(theme === "light" ? "dark" : "light"),
|
||||
},
|
||||
];
|
||||
return [...nav, ...actions];
|
||||
}, [router, theme, setTheme]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return commands;
|
||||
return commands.filter(
|
||||
(c) => c.label.toLowerCase().includes(q) || c.hint.toLowerCase().includes(q),
|
||||
);
|
||||
}, [commands, query]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setOpen((o) => !o);
|
||||
}
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
const onOpen = () => setOpen(true);
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("command-palette:open", onOpen);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("command-palette:open", onOpen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
setActive(0);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(0);
|
||||
}, [query]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const runAt = (i: number) => {
|
||||
const c = filtered[i];
|
||||
if (!c) return;
|
||||
setOpen(false);
|
||||
c.run();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[90] flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm"
|
||||
onMouseDown={() => setOpen(false)}
|
||||
>
|
||||
<GlassPanel
|
||||
className="w-full max-w-[560px] overflow-hidden p-0"
|
||||
style={{ animation: "fade-up 0.14s ease" }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-hairline px-4 py-3">
|
||||
<Search className="size-4 text-ink-faint" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.min(a + 1, filtered.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.max(a - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
runAt(active);
|
||||
}
|
||||
}}
|
||||
placeholder="Type a command or search…"
|
||||
className="flex-1 bg-transparent text-sm text-ink outline-none placeholder:text-ink-faint"
|
||||
/>
|
||||
<kbd className="mono rounded bg-white/8 px-1.5 py-0.5 text-[0.6rem] text-ink-faint">ESC</kbd>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[50vh] overflow-y-auto p-2">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-ink-faint">No commands</div>
|
||||
) : (
|
||||
filtered.map((c, i) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => runAt(i)}
|
||||
className={`flex w-full items-center gap-3 rounded-[10px] px-3 py-2.5 text-left text-sm transition-colors ${
|
||||
i === active ? "bg-signal/12 text-ink" : "text-ink-soft hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<span className="flex size-7 items-center justify-center rounded-[8px] bg-white/5">
|
||||
{c.icon}
|
||||
</span>
|
||||
<span className="flex-1">{c.label}</span>
|
||||
<span className="mono text-[0.65rem] text-ink-faint">{c.hint}</span>
|
||||
{i === active && <CornerDownLeft className="size-3.5 text-ink-faint" />}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 border-t border-hairline px-4 py-2 text-[0.65rem] text-ink-faint">
|
||||
<span className="flex items-center gap-1"><ArrowUp className="size-3" /><ArrowDown className="size-3" /> navigate</span>
|
||||
<span className="flex items-center gap-1"><CornerDownLeft className="size-3" /> select</span>
|
||||
<span className="ml-auto mono">⌘K</span>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DashCommandLine — sticky bottom prompt for ops actions.
|
||||
*
|
||||
* The signature element of the new dashboard. Pure mono input; parses a
|
||||
* slash-prefixed verb and dispatches to existing APIs or client-side
|
||||
* actions. Autocomplete is intentionally light (suggestions render in
|
||||
* monospace below the input).
|
||||
*/
|
||||
|
||||
import {
|
||||
type FormEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CommandVerb = "mute" | "jump" | "find" | "clear";
|
||||
|
||||
interface CommandResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const VERBS: CommandVerb[] = ["mute", "jump", "find", "clear"];
|
||||
|
||||
interface DashCommandLineProps {
|
||||
onCommand?: (verb: CommandVerb, args: string) => CommandResult | undefined;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function DashCommandLine({
|
||||
onCommand,
|
||||
placeholder = "type a command — /mute @user 10m, /jump #channel, /find text, /clear",
|
||||
}: DashCommandLineProps) {
|
||||
const [value, setValue] = useState("");
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [_historyIdx, setHistoryIdx] = useState<number>(-1);
|
||||
const [result, setResult] = useState<CommandResult | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Global "/" focuses the command line (skip when typing in another input).
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
const t = e.target as HTMLElement | null;
|
||||
const tag = t?.tagName?.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || t?.isContentEditable) return;
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
const trimmed = value.trimStart();
|
||||
if (!trimmed.startsWith("/")) return [] as CommandVerb[];
|
||||
const verb = trimmed.slice(1).split(/\s+/)[0]?.toLowerCase() ?? "";
|
||||
if (!verb) return VERBS;
|
||||
return VERBS.filter((v) => v.startsWith(verb));
|
||||
}, [value]);
|
||||
|
||||
const submit = useCallback(
|
||||
(raw: string) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.startsWith("/")) {
|
||||
setResult({ ok: false, message: "commands start with /" });
|
||||
return;
|
||||
}
|
||||
const body = trimmed.slice(1);
|
||||
const [verbRaw, ...rest] = body.split(/\s+/);
|
||||
const verb = (verbRaw?.toLowerCase() ?? "") as CommandVerb;
|
||||
if (!VERBS.includes(verb)) {
|
||||
setResult({
|
||||
ok: false,
|
||||
message: `unknown verb "${verbRaw}" — try ${VERBS.join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const args = rest.join(" ");
|
||||
try {
|
||||
const ret = onCommand?.(verb, args);
|
||||
const message =
|
||||
(ret && typeof ret === "object" && "message" in ret && ret.message) ||
|
||||
defaultMessage(verb, args);
|
||||
setResult({ ok: true, message });
|
||||
} catch (err) {
|
||||
setResult({
|
||||
ok: false,
|
||||
message: err instanceof Error ? err.message : "command failed",
|
||||
});
|
||||
}
|
||||
setHistory((h) => [trimmed, ...h].slice(0, 32));
|
||||
setHistoryIdx(-1);
|
||||
},
|
||||
[onCommand],
|
||||
);
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (value.trim()) {
|
||||
submit(value);
|
||||
setValue("");
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHistoryIdx((idx) => {
|
||||
const next = idx + 1;
|
||||
if (next >= history.length) return idx;
|
||||
setValue(history[next] ?? "");
|
||||
return next;
|
||||
});
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHistoryIdx((idx) => {
|
||||
const next = idx - 1;
|
||||
if (next < -1) return idx;
|
||||
setValue(next === -1 ? "" : (history[next] ?? ""));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="sticky bottom-0 z-10 flex h-11 items-center gap-2 border-t border-[var(--color-hairline)] bg-[var(--color-canvas)] px-3 font-mono text-[12px]"
|
||||
role="search"
|
||||
>
|
||||
<span className="shrink-0 text-[var(--color-signal)]">{">"}</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
aria-label="Command line"
|
||||
className="min-w-0 flex-1 bg-transparent text-[var(--color-ink)] outline-none placeholder:text-[var(--color-ink-soft)]"
|
||||
/>
|
||||
{result ? (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 truncate text-[10px] uppercase tracking-wide",
|
||||
result.ok
|
||||
? "text-[var(--color-signal)]"
|
||||
: "text-[var(--color-vermilion)]",
|
||||
)}
|
||||
>
|
||||
{result.message}
|
||||
</span>
|
||||
) : suggestions.length > 0 ? (
|
||||
<span className="shrink-0 truncate text-[10px] uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
{suggestions.map((s) => `/${s}`).join(" ")}
|
||||
</span>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultMessage(verb: CommandVerb, args: string): string {
|
||||
switch (verb) {
|
||||
case "mute":
|
||||
return args ? `mute queued — ${args}` : "mute needs a target";
|
||||
case "jump":
|
||||
return args ? `jump queued — ${args}` : "jump needs a channel";
|
||||
case "find":
|
||||
return args ? `find queued — ${args}` : "find needs text";
|
||||
case "clear":
|
||||
return "feed cleared";
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { AreaPoint } from "@/components/charts/area-activity";
|
||||
import { AreaActivity } from "@/components/charts/area-activity";
|
||||
|
||||
export interface ActivityChartProps {
|
||||
data: {
|
||||
day: string;
|
||||
messages: number;
|
||||
flagged: number;
|
||||
active_users: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function ActivityChart({ data }: ActivityChartProps) {
|
||||
const points: AreaPoint[] = data.map((d) => ({
|
||||
label: d.day,
|
||||
value: d.messages,
|
||||
}));
|
||||
return (
|
||||
<div className="surface p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">Daily messages</h3>
|
||||
<span className="pill bg-[var(--color-signal)]/15 text-[var(--color-signal)]">
|
||||
{data.length}d
|
||||
</span>
|
||||
</div>
|
||||
<ActivityChartInner points={points} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityChartInner({ points }: { points: AreaPoint[] }) {
|
||||
return (
|
||||
<AreaActivity data={points} height={180} label="Daily message activity" />
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Hash, Search } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { Button } from "@/components/primitives/button";
|
||||
import { Input } from "@/components/primitives/input";
|
||||
import { EmptyState, LoadingSkeleton } from "@/components/shared";
|
||||
import { useChannelDetail, useChannels } from "@/hooks";
|
||||
import type { DashboardChannel } from "@/lib/types";
|
||||
|
||||
export function ChannelsSection({ guildId }: { guildId?: string }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const { data: channels = [], isLoading } = useChannels(guildId ?? "", search);
|
||||
const { data: detail } = useChannelDetail(selectedId);
|
||||
|
||||
const handleSearch = useCallback((v: string) => setSearch(v), []);
|
||||
|
||||
if (isLoading) return <LoadingSkeleton count={8} />;
|
||||
if (channels.length === 0)
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Hash}
|
||||
title="No channels"
|
||||
description="No channels in this guild."
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_320px]">
|
||||
<div className="surface flex flex-col gap-2 p-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-soft)]" />
|
||||
<Input
|
||||
mono
|
||||
placeholder="search channels…"
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{channels.map((c) => (
|
||||
<ChannelRow
|
||||
key={c.channel_id}
|
||||
channel={c}
|
||||
selected={selectedId === c.channel_id}
|
||||
onSelect={() => setSelectedId(c.channel_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="surface p-4">
|
||||
{detail ? (
|
||||
<div className="flex flex-col gap-3.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex size-9 items-center justify-center rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)]">
|
||||
<Hash className="size-4 text-[var(--color-ink-soft)]" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="font-semibold">
|
||||
{detail.channel_name ?? detail.channel_id}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--color-ink-soft)]">
|
||||
{detail.total_messages.toLocaleString()} messages
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Stat
|
||||
label="Flagged"
|
||||
value={detail.flagged_count}
|
||||
tone="vermilion"
|
||||
/>
|
||||
<p className="text-xs text-[var(--color-ink-soft)]">
|
||||
{detail.culture_summary ?? "No data yet."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--color-ink-soft)]">
|
||||
Select a channel to inspect.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelRow({
|
||||
channel,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
channel: DashboardChannel;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const total = channel.total_messages + channel.flagged_count || 1;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className="flex items-center gap-3 rounded-[var(--radius-r-control)] px-2.5 py-2 text-left transition-colors hover:bg-[var(--color-surface-2)] data-[selected]:bg-[var(--color-signal)]/8"
|
||||
data-selected={selected}
|
||||
>
|
||||
<Hash className="size-4 text-[var(--color-ink-soft)]" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{channel.channel_name ?? channel.channel_id}
|
||||
</span>
|
||||
<span className="mono text-xs text-[var(--color-ink-soft)]">
|
||||
{channel.flagged_count}/{channel.total_messages}
|
||||
</span>
|
||||
<div className="h-1.5 w-10 overflow-hidden rounded-full bg-[var(--color-hairline)]">
|
||||
<div
|
||||
className="h-full bg-[var(--color-signal)]"
|
||||
style={{ width: `${(channel.flagged_count / total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: "vermilion";
|
||||
}) {
|
||||
return (
|
||||
<div className="surface-2 flex items-center justify-between p-2.5">
|
||||
<span className="text-xs text-[var(--color-ink-soft)]">{label}</span>
|
||||
<span className="mono font-semibold text-[var(--color-vermilion)]">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { AreaPoint } from "@/components/charts/area-activity";
|
||||
import { AreaActivity } from "@/components/charts/area-activity";
|
||||
|
||||
export interface HourlyActivityChartProps {
|
||||
data: { hour: number; messages: number; flagged: number }[];
|
||||
}
|
||||
|
||||
export function HourlyActivityChart({ data }: HourlyActivityChartProps) {
|
||||
const points: AreaPoint[] = data.map((d) => ({
|
||||
label: `${d.hour}:00`,
|
||||
value: d.messages,
|
||||
}));
|
||||
return (
|
||||
<div className="surface p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">Hourly distribution</h3>
|
||||
<span className="text-xs text-[var(--color-ink-soft)]">
|
||||
00:00 – 23:00
|
||||
</span>
|
||||
</div>
|
||||
<AreaActivity
|
||||
data={points}
|
||||
height={140}
|
||||
stroke="var(--color-amber)"
|
||||
label="Hourly message activity"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { RadialGauge } from "@/components/charts/radial-gauge";
|
||||
import type { DashboardStats } from "@/lib/types";
|
||||
|
||||
export interface ModerationDonutProps {
|
||||
stats?: DashboardStats;
|
||||
}
|
||||
|
||||
export function ModerationDonut({ stats }: ModerationDonutProps) {
|
||||
const clean = stats?.total_clean ?? 0;
|
||||
const flagged = stats?.total_flagged ?? 0;
|
||||
const warned = stats?.total_warned ?? 0;
|
||||
const total = clean + flagged + warned || 1;
|
||||
const ratio = clean / total;
|
||||
|
||||
return (
|
||||
<div className="surface flex flex-col items-center gap-3 p-4">
|
||||
<h3 className="self-start text-sm font-semibold">Moderation health</h3>
|
||||
<RadialGauge
|
||||
value={ratio}
|
||||
size={150}
|
||||
label="Clean"
|
||||
tone={ratio > 0.8 ? "signal" : ratio > 0.6 ? "amber" : "vermilion"}
|
||||
/>
|
||||
<div className="flex w-full flex-col gap-1.5 text-xs">
|
||||
<Row label="Clean" value={clean} tone="var(--color-signal)" />
|
||||
<Row label="Warned" value={warned} tone="var(--color-amber)" />
|
||||
<Row label="Flagged" value={flagged} tone="var(--color-vermilion)" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-[var(--color-ink-soft)]">
|
||||
<span className="size-2 rounded-full" style={{ background: tone }} />
|
||||
{label}
|
||||
</span>
|
||||
<span className="mono text-[var(--color-ink)]">
|
||||
{value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Flame, Heart, SmilePlus } from "lucide-react";
|
||||
import { Avatar } from "@/components/primitives/avatar";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { EmptyState, LoadingSkeleton } from "@/components/shared";
|
||||
import { useTopReactions, useTopReactors } from "@/hooks";
|
||||
|
||||
export interface ReactionsSectionProps {
|
||||
initialReactions?: Awaited<ReturnType<typeof useTopReactions>>["data"];
|
||||
}
|
||||
|
||||
export function ReactionsSection() {
|
||||
const { data: reactions, isLoading: reactionsLoading } = useTopReactions();
|
||||
const { data: reactors, isLoading: reactorsLoading } = useTopReactors();
|
||||
|
||||
if (reactionsLoading || reactorsLoading) return <LoadingSkeleton count={5} />;
|
||||
|
||||
const topReactions = (reactions ?? []).slice(0, 6);
|
||||
const topReactors = (reactors ?? []).slice(0, 6);
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<div className="surface p-4">
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold">
|
||||
<Heart className="size-4 text-[var(--color-vermilion)]" />
|
||||
Top reactions
|
||||
</h3>
|
||||
{topReactions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={SmilePlus}
|
||||
title="No reactions"
|
||||
description="No reactions yet."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{topReactions
|
||||
.flatMap((m) =>
|
||||
m.top_emojis.map((e) => ({ emoji: e.emoji, count: e.count })),
|
||||
)
|
||||
.reduce<{ emoji: string; count: number }[]>((acc, cur) => {
|
||||
const found = acc.find((x) => x.emoji === cur.emoji);
|
||||
if (found) found.count += cur.count;
|
||||
else acc.push(cur);
|
||||
return acc;
|
||||
}, [])
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 8)
|
||||
.map((r) => (
|
||||
<span
|
||||
key={r.emoji}
|
||||
className="flex items-center gap-1.5 rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] px-2.5 py-1 text-sm"
|
||||
>
|
||||
<span>{r.emoji}</span>
|
||||
<span className="mono text-xs text-[var(--color-ink-soft)]">
|
||||
{r.count}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="surface p-4">
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold">
|
||||
<Flame className="size-4 text-[var(--color-amber)]" />
|
||||
Top reactors
|
||||
</h3>
|
||||
{topReactors.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={SmilePlus}
|
||||
title="No reactors"
|
||||
description="No reactors yet."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{topReactors.map((r) => (
|
||||
<div key={r.user_id} className="flex items-center gap-3">
|
||||
<Avatar name={r.username} size={28} />
|
||||
<span className="flex-1 text-sm">{r.username}</span>
|
||||
<Badge tone="signal">+{r.net_count}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Hash } from "lucide-react";
|
||||
import type { TopChannel } from "@/lib/types";
|
||||
|
||||
export interface TopChannelsChartProps {
|
||||
channels: TopChannel[];
|
||||
}
|
||||
|
||||
export function TopChannelsChart({ channels }: TopChannelsChartProps) {
|
||||
const max = Math.max(...channels.map((c) => c.message_count), 1);
|
||||
const top = [...channels]
|
||||
.sort((a, b) => b.message_count - a.message_count)
|
||||
.slice(0, 8);
|
||||
return (
|
||||
<div className="surface p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">Top channels</h3>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{top.map((c) => (
|
||||
<div key={c.channel_id} className="flex items-center gap-3">
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] text-[var(--color-ink-soft)]">
|
||||
<Hash className="size-3.5" />
|
||||
</span>
|
||||
<span className="w-32 shrink-0 truncate text-xs text-[var(--color-ink)]">
|
||||
{c.channel_name ?? c.channel_id}
|
||||
</span>
|
||||
<div className="relative h-2 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-2)]">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-[var(--color-signal)] transition-[width] duration-500"
|
||||
style={{ width: `${(c.message_count / max) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="mono w-12 shrink-0 text-right text-xs text-[var(--color-ink-soft)]">
|
||||
{c.message_count.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Search, Users } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Avatar } from "@/components/primitives/avatar";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { Input } from "@/components/primitives/input";
|
||||
import { EmptyState, LoadingSkeleton } from "@/components/shared";
|
||||
import { useUserDetail, useUsers } from "@/hooks";
|
||||
|
||||
const TRUST_TIERS = [
|
||||
{ min: 75, label: "Trusted", tone: "signal" as const },
|
||||
{ min: 40, label: "Neutral", tone: "neutral" as const },
|
||||
{ min: 10, label: "At Risk", tone: "amber" as const },
|
||||
{ min: 0, label: "Critical", tone: "vermilion" as const },
|
||||
];
|
||||
|
||||
function trustTier(score?: number | null) {
|
||||
const s = score ?? 0;
|
||||
return (
|
||||
TRUST_TIERS.find((t) => s >= t.min) ?? TRUST_TIERS[TRUST_TIERS.length - 1]
|
||||
);
|
||||
}
|
||||
|
||||
export function UsersSection() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const { data: users = [], isLoading } = useUsers(search);
|
||||
const { data: detail } = useUserDetail(selectedId);
|
||||
|
||||
const handleSearch = useCallback((v: string) => setSearch(v), []);
|
||||
|
||||
if (isLoading) return <LoadingSkeleton count={6} />;
|
||||
if (users.length === 0)
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No users found"
|
||||
description="Try a different search."
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_360px]">
|
||||
<div className="surface flex flex-col gap-2 p-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-soft)]" />
|
||||
<Input
|
||||
mono
|
||||
placeholder="search users…"
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{users.map((u) => {
|
||||
const tier = trustTier(u.trust_score);
|
||||
return (
|
||||
<button
|
||||
key={u.user_id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(u.user_id)}
|
||||
className="flex items-center gap-3 rounded-[var(--radius-r-control)] px-2 py-2 text-left transition-colors hover:bg-[var(--color-surface-2)]"
|
||||
>
|
||||
<Avatar src={u.avatar_url} name={u.username} size={34} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{u.username ?? "unknown"}
|
||||
</div>
|
||||
<div className="mono text-xs text-[var(--color-ink-soft)]">
|
||||
{u.total_messages.toLocaleString()} msg
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone={tier.tone}>{tier.label}</Badge>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface p-4">
|
||||
{detail ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar
|
||||
src={detail.avatar_url}
|
||||
name={detail.username}
|
||||
size={44}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-semibold">{detail.username}</div>
|
||||
<div className="text-xs text-[var(--color-ink-soft)]">
|
||||
{detail.total_messages.toLocaleString()} messages
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<Stat label="Trust" value={`${detail.trust_score ?? 0}`} />
|
||||
<Stat
|
||||
label="Clean streak"
|
||||
value={`${detail.clean_message_streak ?? 0}`}
|
||||
/>
|
||||
<Stat
|
||||
label="Infractions"
|
||||
value={`${detail.total_infractions ?? 0}`}
|
||||
tone="vermilion"
|
||||
/>
|
||||
<Stat
|
||||
label="Flagged"
|
||||
value={`${detail.flagged_count}`}
|
||||
tone="amber"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--color-ink-soft)]">
|
||||
{detail.profile_summary}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--color-ink-soft)]">
|
||||
Select a user to inspect.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "amber" | "vermilion";
|
||||
}) {
|
||||
return (
|
||||
<div className="surface-2 p-2.5">
|
||||
<div className="text-[11px] uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={`mono text-lg font-semibold ${tone === "amber" ? "text-[var(--color-amber)]" : tone === "vermilion" ? "text-[var(--color-vermilion)]" : "text-[var(--color-ink)]"}`}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* EventFeed — horizontal scroll-snap timeline that ingests live events.
|
||||
*
|
||||
* The feed is the central column of the dashboard. Time runs left → right
|
||||
* (older → newer). New events append at the right edge; the feed scrolls
|
||||
* right when the user is at the live edge and pauses when the user drags
|
||||
* back to inspect history.
|
||||
*
|
||||
* Ring buffer keeps the DOM bounded (200 items). A `NowMarker` is inserted
|
||||
* every 10 events or every 30 seconds to break the row rhythm with a pulse
|
||||
* summary — see `useFeedPulse`.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { EventRow, type FeedEvent } from "@/components/feed/event-row";
|
||||
import { ClusterMarker, PulseMarker } from "@/components/feed/now-marker";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const RING_BUFFER_MAX = 200;
|
||||
const PULSE_EVERY_N_EVENTS = 10;
|
||||
const PULSE_EVERY_MS = 30_000;
|
||||
|
||||
export type FeedItem =
|
||||
| { kind: "event"; event: FeedEvent }
|
||||
| {
|
||||
kind: "pulse";
|
||||
key: string;
|
||||
ts: number;
|
||||
label: string;
|
||||
summary: string;
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
}
|
||||
| {
|
||||
kind: "cluster";
|
||||
key: string;
|
||||
ts: number;
|
||||
label: string;
|
||||
bands: {
|
||||
tone: "neutral" | "signal" | "amber" | "vermilion";
|
||||
ratio: number;
|
||||
}[];
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
};
|
||||
|
||||
interface EventFeedProps {
|
||||
initialEvents: FeedEvent[];
|
||||
subscribe: (handler: (e: FeedEvent) => void) => () => void;
|
||||
className?: string;
|
||||
emptyState?: ReactNode;
|
||||
}
|
||||
|
||||
export function EventFeed({
|
||||
initialEvents,
|
||||
subscribe,
|
||||
className,
|
||||
emptyState,
|
||||
}: EventFeedProps) {
|
||||
const [items, setItems] = useState<FeedItem[]>(() =>
|
||||
injectMarkers(initialEvents.slice(-RING_BUFFER_MAX)),
|
||||
);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [following, setFollowing] = useState(true);
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastPulseAt = useRef<number>(Date.now());
|
||||
|
||||
// Live WS ingest
|
||||
useEffect(() => {
|
||||
const unsub = subscribe((e) => {
|
||||
setItems((prev) => appendWithMarker(prev, e));
|
||||
});
|
||||
return unsub;
|
||||
}, [subscribe]);
|
||||
|
||||
// Periodic pulse even if traffic is slow — keeps the feed rhythm alive.
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
setItems((prev) => {
|
||||
if (Date.now() - lastPulseAt.current < PULSE_EVERY_MS) return prev;
|
||||
return appendPulse(prev, "system", "live · standing by");
|
||||
});
|
||||
}, PULSE_EVERY_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Auto-scroll on append when following.
|
||||
useEffect(() => {
|
||||
if (!following) return;
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTo({ left: el.scrollWidth, behavior: "smooth" });
|
||||
}, [following]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
const distFromRight = el.scrollWidth - el.scrollLeft - el.clientWidth;
|
||||
setFollowing(distFromRight < 24);
|
||||
}, []);
|
||||
|
||||
const handleSelect = useCallback((id: string) => {
|
||||
setSelectedId((cur) => (cur === id ? null : id));
|
||||
}, []);
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
if (items.length <= RING_BUFFER_MAX) return items;
|
||||
return items.slice(items.length - RING_BUFFER_MAX);
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-full w-full overflow-hidden",
|
||||
"border-t border-[var(--color-hairline)]",
|
||||
className,
|
||||
)}
|
||||
data-following={following ? "1" : "0"}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] bg-[var(--color-surface)] px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--color-ink-soft)]">
|
||||
<span>event horizon</span>
|
||||
<span>
|
||||
{visibleItems.filter((i) => i.kind === "event").length} events ·{" "}
|
||||
{following ? "live" : "paused"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
onScroll={handleScroll}
|
||||
className={cn(
|
||||
"h-[calc(100%-30px)] overflow-y-auto overflow-x-hidden",
|
||||
"snap-y snap-mandatory",
|
||||
"scroll-pt-2",
|
||||
)}
|
||||
role="feed"
|
||||
aria-live="polite"
|
||||
>
|
||||
{visibleItems.length === 0 && emptyState ? (
|
||||
<div className="flex h-full items-center justify-center p-8 text-center font-mono text-[12px] text-[var(--color-ink-soft)]">
|
||||
{emptyState}
|
||||
</div>
|
||||
) : (
|
||||
visibleItems.map((item) => {
|
||||
if (item.kind === "event") {
|
||||
return (
|
||||
<div key={item.event.id} className="snap-start">
|
||||
<EventRow
|
||||
event={item.event}
|
||||
selected={selectedId === item.event.id}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (item.kind === "cluster") {
|
||||
return (
|
||||
<div key={item.key} className="snap-start">
|
||||
<ClusterMarker
|
||||
label={item.label}
|
||||
timestamp={item.ts}
|
||||
bands={item.bands}
|
||||
tone={item.tone}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={item.key} className="snap-start">
|
||||
<PulseMarker
|
||||
label={item.label}
|
||||
timestamp={item.ts}
|
||||
trailing={item.summary}
|
||||
tone={item.tone}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Ring + pulse helpers ────────────────────────────────────────
|
||||
|
||||
function injectMarkers(events: FeedEvent[]): FeedItem[] {
|
||||
if (events.length === 0) return [];
|
||||
const out: FeedItem[] = [];
|
||||
let count = 0;
|
||||
for (const e of events) {
|
||||
out.push({ kind: "event", event: e });
|
||||
count++;
|
||||
if (count % PULSE_EVERY_N_EVENTS === 0) {
|
||||
out.push({
|
||||
kind: "cluster",
|
||||
key: `cluster-${e.id}`,
|
||||
ts: e.ts,
|
||||
label: "pulse",
|
||||
bands: deriveBands(
|
||||
events.slice(Math.max(0, count - PULSE_EVERY_N_EVENTS), count),
|
||||
),
|
||||
tone: "signal",
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function deriveBands(
|
||||
window: FeedEvent[],
|
||||
): { tone: "neutral" | "signal" | "amber" | "vermilion"; ratio: number }[] {
|
||||
const counts: Record<"neutral" | "signal" | "amber" | "vermilion", number> = {
|
||||
neutral: 0,
|
||||
signal: 0,
|
||||
amber: 0,
|
||||
vermilion: 0,
|
||||
};
|
||||
for (const e of window) counts[e.severity]++;
|
||||
const total = window.length || 1;
|
||||
return (Object.keys(counts) as Array<keyof typeof counts>).map((k) => ({
|
||||
tone: k,
|
||||
ratio: counts[k] / total,
|
||||
}));
|
||||
}
|
||||
|
||||
function appendWithMarker(prev: FeedItem[], e: FeedEvent): FeedItem[] {
|
||||
const next = [...prev, { kind: "event" as const, event: e }];
|
||||
const eventsSinceLastPulse = next.filter((i) => i.kind === "event").length;
|
||||
if (eventsSinceLastPulse % PULSE_EVERY_N_EVENTS === 0) {
|
||||
const recentEvents = next
|
||||
.filter((i) => i.kind === "event")
|
||||
.slice(-PULSE_EVERY_N_EVENTS)
|
||||
.map((i) => (i as { kind: "event"; event: FeedEvent }).event);
|
||||
next.push({
|
||||
kind: "cluster",
|
||||
key: `cluster-${e.id}`,
|
||||
ts: e.ts,
|
||||
label: "pulse",
|
||||
bands: deriveBands(recentEvents),
|
||||
tone: "signal",
|
||||
});
|
||||
}
|
||||
if (next.length > RING_BUFFER_MAX * 2) {
|
||||
return next.slice(next.length - RING_BUFFER_MAX);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function appendPulse(
|
||||
prev: FeedItem[],
|
||||
label: string,
|
||||
summary: string,
|
||||
): FeedItem[] {
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
kind: "pulse",
|
||||
key: `pulse-${Date.now()}`,
|
||||
ts: Date.now(),
|
||||
label,
|
||||
summary,
|
||||
tone: "signal",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* EventRow — single row in the horizontal event-feed timeline.
|
||||
*
|
||||
* No card chrome. The row is a single typographic line: mono timestamp,
|
||||
* severity dot, actor mention, action verb, channel jump, excerpt.
|
||||
*
|
||||
* Hover reveals full excerpt and selection state; click toggles selection
|
||||
* so the right rail / command line can target the event.
|
||||
*/
|
||||
|
||||
import { type ReactNode, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type EventSeverity = "neutral" | "signal" | "amber" | "vermilion";
|
||||
|
||||
export interface FeedEvent {
|
||||
/** Stable id from the upstream record. Used as React key. */
|
||||
id: string;
|
||||
/** Unix epoch ms. */
|
||||
ts: number;
|
||||
/** Severity tone — drives dot color and zebra fill. */
|
||||
severity: EventSeverity;
|
||||
/** Display label for the actor ("alice", "@everyone", "Carl-bot"). */
|
||||
actor: string;
|
||||
/** Verb describing the action ("sent", "flagged", "joined", "muted"). */
|
||||
action: string;
|
||||
/** Channel reference (monogram display only — no chrome). */
|
||||
channel?: string | null;
|
||||
/** Message excerpt or action payload text. Truncated when long. */
|
||||
excerpt: string;
|
||||
/** Optional metadata tag (e.g. "ai:flag", "voice:join"). */
|
||||
tag?: string | null;
|
||||
}
|
||||
|
||||
interface EventRowProps {
|
||||
event: FeedEvent;
|
||||
selected?: boolean;
|
||||
onSelect?: (id: string) => void;
|
||||
}
|
||||
|
||||
const SEVERITY_DOT: Record<EventSeverity, string> = {
|
||||
neutral: "oklch(0.46 0.02 70)",
|
||||
signal: "var(--color-signal)",
|
||||
amber: "var(--color-amber)",
|
||||
vermilion: "var(--color-vermilion)",
|
||||
};
|
||||
|
||||
const SEVERITY_FILL: Record<EventSeverity, string> = {
|
||||
neutral: "transparent",
|
||||
signal: "oklch(0.78 0.17 125 / 0.06)",
|
||||
amber: "oklch(0.80 0.15 70 / 0.07)",
|
||||
vermilion: "oklch(0.62 0.21 25 / 0.08)",
|
||||
};
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
export function EventRow({ event, selected, onSelect }: EventRowProps) {
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect?.(event.id);
|
||||
}, [event.id, onSelect]);
|
||||
|
||||
const dot: ReactNode = (
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block size-1.5 shrink-0 rounded-full"
|
||||
style={{ background: SEVERITY_DOT[event.severity] }}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"group relative flex w-full items-baseline gap-3 px-3 py-1.5 text-left font-mono text-[12px] leading-5 transition-colors",
|
||||
"hover:bg-[oklch(0.92_0.014_80_/_0.6)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-signal)] focus-visible:outline-offset-[-2px]",
|
||||
selected && "bg-[oklch(0.92_0.014_80_/_0.8)]",
|
||||
)}
|
||||
style={{
|
||||
background: selected ? undefined : SEVERITY_FILL[event.severity],
|
||||
}}
|
||||
data-event-id={event.id}
|
||||
data-severity={event.severity}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 w-[2px] origin-center transition-transform",
|
||||
selected ? "scale-y-100" : "scale-y-0 group-hover:scale-y-100",
|
||||
)}
|
||||
style={{ background: SEVERITY_DOT[event.severity] }}
|
||||
/>
|
||||
|
||||
<span className="w-[68px] shrink-0 text-[var(--color-ink-soft)] tabular-nums">
|
||||
{formatTimestamp(event.ts)}
|
||||
</span>
|
||||
|
||||
{dot}
|
||||
|
||||
<span className="w-[120px] shrink-0 truncate text-[var(--color-ink)]">
|
||||
{event.actor}
|
||||
</span>
|
||||
|
||||
<span className="w-[80px] shrink-0 text-[var(--color-ink-soft)]">
|
||||
{event.action}
|
||||
</span>
|
||||
|
||||
{event.channel ? (
|
||||
<span className="w-[140px] shrink-0 truncate text-[var(--color-ink-soft)]">
|
||||
{event.channel}
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-[140px] shrink-0" aria-hidden />
|
||||
)}
|
||||
|
||||
<span className="min-w-0 flex-1 truncate text-[var(--color-ink)]">
|
||||
{event.excerpt}
|
||||
</span>
|
||||
|
||||
{event.tag ? (
|
||||
<span className="shrink-0 rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] px-1.5 py-px text-[10px] uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
{event.tag}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* NowMarker — inline callout that breaks the feed timeline rhythm.
|
||||
*
|
||||
* Two variants: `pulse` (one-line summary) and `cluster` (horizontal stack bar
|
||||
* visualising severity distribution across a recent window). Both use a
|
||||
* border-tip on the left in signal tone; no card chrome, no shadow.
|
||||
*/
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Tone = "signal" | "amber" | "vermilion" | "neutral";
|
||||
|
||||
interface PulseMarkerProps {
|
||||
tone?: Tone;
|
||||
label: string;
|
||||
timestamp: number;
|
||||
/** Optional small caps label on the right. */
|
||||
trailing?: string;
|
||||
}
|
||||
|
||||
interface ClusterMarkerProps {
|
||||
tone?: Tone;
|
||||
label: string;
|
||||
timestamp: number;
|
||||
/** Fractions of each severity band; must sum to 1. */
|
||||
bands: { tone: Tone; ratio: number }[];
|
||||
}
|
||||
|
||||
const TONE_TIP: Record<Tone, string> = {
|
||||
signal: "var(--color-signal)",
|
||||
amber: "var(--color-amber)",
|
||||
vermilion: "var(--color-vermilion)",
|
||||
neutral: "oklch(0.46 0.02 70)",
|
||||
};
|
||||
|
||||
const TONE_FILL: Record<Tone, string> = {
|
||||
signal: "oklch(0.78 0.17 125 / 0.12)",
|
||||
amber: "oklch(0.80 0.15 70 / 0.14)",
|
||||
vermilion: "oklch(0.62 0.21 25 / 0.12)",
|
||||
neutral: "oklch(0.46 0.02 70 / 0.08)",
|
||||
};
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function MarkerShell({
|
||||
tone,
|
||||
label,
|
||||
timestamp,
|
||||
trailing,
|
||||
children,
|
||||
}: {
|
||||
tone: Tone;
|
||||
label: string;
|
||||
timestamp: number;
|
||||
trailing?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="relative my-2 flex items-center gap-3 px-3 py-2 font-mono text-[11px]"
|
||||
style={{ background: TONE_FILL[tone] }}
|
||||
data-marker={tone}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-1 left-0 w-[3px]"
|
||||
style={{ background: TONE_TIP[tone] }}
|
||||
/>
|
||||
<span className="w-[68px] shrink-0 text-[var(--color-ink-soft)] tabular-nums">
|
||||
{formatTimestamp(timestamp)}
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 text-[10px] font-medium uppercase tracking-[0.18em]"
|
||||
style={{ color: TONE_TIP[tone] }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[var(--color-ink)]">
|
||||
{children}
|
||||
</span>
|
||||
{trailing ? (
|
||||
<span className="shrink-0 text-[10px] uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
{trailing}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PulseMarker({
|
||||
tone = "signal",
|
||||
label,
|
||||
timestamp,
|
||||
trailing,
|
||||
}: PulseMarkerProps) {
|
||||
return (
|
||||
<MarkerShell
|
||||
tone={tone}
|
||||
label={label}
|
||||
timestamp={timestamp}
|
||||
trailing={trailing}
|
||||
>
|
||||
{/* children rendered by parent via composition — see NowMarker union below */}
|
||||
</MarkerShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClusterMarker({
|
||||
tone = "signal",
|
||||
label,
|
||||
timestamp,
|
||||
bands,
|
||||
}: ClusterMarkerProps) {
|
||||
return (
|
||||
<MarkerShell tone={tone} label={label} timestamp={timestamp}>
|
||||
<div className="flex h-3 w-full max-w-[280px] overflow-hidden rounded-[var(--radius-r-control)]">
|
||||
{bands.map((b) => (
|
||||
<span
|
||||
key={b.tone}
|
||||
className={cn("h-full")}
|
||||
style={{
|
||||
width: `${Math.max(0, Math.min(1, b.ratio)) * 100}%`,
|
||||
background: TONE_TIP[b.tone],
|
||||
opacity: b.tone === "neutral" ? 0.4 : 1,
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</MarkerShell>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DashLeftRail — 80px vertical monogram nav.
|
||||
*
|
||||
* Each item is a glyph + label. Active state uses an accent bar on the left
|
||||
* and full ink colour. No backgrounds, no boxes.
|
||||
*/
|
||||
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Flag,
|
||||
MessagesSquare,
|
||||
Mic,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
glyph: React.ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const ITEMS: NavItem[] = [
|
||||
{
|
||||
href: "/dashboard",
|
||||
glyph: <BarChart3 className="size-4" />,
|
||||
label: "Console",
|
||||
},
|
||||
{
|
||||
href: "/messages",
|
||||
glyph: <MessagesSquare className="size-4" />,
|
||||
label: "Messages",
|
||||
},
|
||||
{
|
||||
href: "/moderation",
|
||||
glyph: <ShieldCheck className="size-4" />,
|
||||
label: "Moderation",
|
||||
},
|
||||
{ href: "/voice", glyph: <Mic className="size-4" />, label: "Voice" },
|
||||
{ href: "/media", glyph: <Activity className="size-4" />, label: "Media" },
|
||||
{
|
||||
href: "/recordings",
|
||||
glyph: <Flag className="size-4" />,
|
||||
label: "Recordings",
|
||||
},
|
||||
{ href: "/analysis", glyph: <Users className="size-4" />, label: "Analysis" },
|
||||
];
|
||||
|
||||
export function DashLeftRail() {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<nav
|
||||
aria-label="Console navigation"
|
||||
className="flex h-full w-20 shrink-0 flex-col items-center gap-1 border-r border-[var(--color-hairline)] bg-[var(--color-surface)] py-3"
|
||||
>
|
||||
{ITEMS.map((it) => {
|
||||
const active =
|
||||
pathname === it.href || pathname?.startsWith(`${it.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={it.href}
|
||||
href={it.href}
|
||||
className={cn(
|
||||
"group relative flex w-full flex-col items-center gap-1 py-2 text-[10px] uppercase tracking-wide transition-colors",
|
||||
active
|
||||
? "text-[var(--color-ink)]"
|
||||
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
|
||||
)}
|
||||
data-active={active ? "1" : "0"}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"absolute inset-y-2 left-0 w-[2px] origin-center transition-transform",
|
||||
active ? "scale-y-100" : "scale-y-0 group-hover:scale-y-100",
|
||||
)}
|
||||
style={{ background: "var(--color-signal)" }}
|
||||
/>
|
||||
{it.glyph}
|
||||
<span className="font-mono">{it.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DashRightRail — 320px collapsible drawer.
|
||||
*
|
||||
* Holds the live AI verdict stream, active voice speakers, and the latest
|
||||
* moderation actions. Reads from existing hooks (`useVoice`, etc.) — no
|
||||
* new fetches; just re-presentation.
|
||||
*/
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSpeakers } from "@/hooks/use-voice";
|
||||
import type { ActiveSpeaker } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
interface DashRightRailProps {
|
||||
pendingVerdicts?: { id: string; ts: number; text: string }[];
|
||||
recentActions?: { id: string; ts: number; verb: string; target: string }[];
|
||||
}
|
||||
|
||||
export function DashRightRail({
|
||||
pendingVerdicts = [],
|
||||
recentActions = [],
|
||||
}: DashRightRailProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const { subscribe } = useSpeakers();
|
||||
const ws = useWebSocket();
|
||||
const [speakers, _setSpeakers] = useState<ActiveSpeaker[]>([]);
|
||||
useEffect(() => subscribe(ws), [ws, subscribe]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"relative shrink-0 border-l border-[var(--color-hairline)] bg-[var(--color-surface)] font-mono text-[11px] transition-[width]",
|
||||
collapsed ? "w-9" : "w-[320px]",
|
||||
)}
|
||||
aria-label="Live activity rail"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className={cn(
|
||||
"absolute -left-3 top-3 z-10 flex size-6 items-center justify-center rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)] text-[var(--color-ink-soft)] transition-colors hover:text-[var(--color-ink)]",
|
||||
)}
|
||||
aria-label={
|
||||
collapsed
|
||||
? "Expand live activity rail"
|
||||
: "Collapse live activity rail"
|
||||
}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-3 transition-transform",
|
||||
collapsed ? "" : "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{collapsed ? (
|
||||
<div className="flex h-full flex-col items-center gap-4 py-4">
|
||||
<Section title="ai" vertical />
|
||||
<Section title="voice" vertical />
|
||||
<Section title="mod" vertical />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
<Section title="ai verdicts">
|
||||
{pendingVerdicts.length === 0 ? (
|
||||
<Empty msg="no pending verdicts" />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{pendingVerdicts.slice(0, 8).map((v) => (
|
||||
<li key={v.id} className="flex items-baseline gap-2">
|
||||
<span className="shrink-0 text-[var(--color-ink-soft)] tabular-nums">
|
||||
{formatTs(v.ts)}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-[var(--color-ink)]">
|
||||
{v.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="voice">
|
||||
{speakers.length === 0 ? (
|
||||
<Empty msg="no one speaking" />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{speakers.slice(0, 8).map((sp) => (
|
||||
<li key={sp.userId} className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block size-1.5 rounded-full",
|
||||
sp.speaking
|
||||
? "bg-[var(--color-signal)]"
|
||||
: "bg-[var(--color-ink-soft)]",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate text-[var(--color-ink)]">
|
||||
{sp.username ?? sp.userId}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="mod queue">
|
||||
{recentActions.length === 0 ? (
|
||||
<Empty msg="queue empty" />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{recentActions.slice(0, 8).map((a) => (
|
||||
<li key={a.id} className="flex items-baseline gap-2">
|
||||
<span className="shrink-0 text-[var(--color-ink-soft)] tabular-nums">
|
||||
{formatTs(a.ts)}
|
||||
</span>
|
||||
<span className="text-[var(--color-ink-soft)]">
|
||||
{a.verb}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-[var(--color-ink)]">
|
||||
{a.target}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="socket">
|
||||
<div className="flex flex-col gap-0.5 text-[10px]">
|
||||
<span className="text-[var(--color-ink-soft)]">status</span>
|
||||
<span className="text-[var(--color-ink)]">{ws.status}</span>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
children,
|
||||
vertical,
|
||||
}: {
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
vertical?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"border-b border-[var(--color-hairline)] px-3 py-2.5",
|
||||
vertical && "flex flex-col items-center gap-2 border-b-0 py-4",
|
||||
)}
|
||||
>
|
||||
<h3 className="mb-1.5 text-[10px] uppercase tracking-[0.18em] text-[var(--color-ink-soft)]">
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty({ msg }: { msg: string }) {
|
||||
return (
|
||||
<span className="text-[10px] italic text-[var(--color-ink-soft)]">
|
||||
{msg}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTs(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DashTopBar — 48px utility strip.
|
||||
*
|
||||
* No navigation chrome — just brand monogram, guild indicator, WS connection
|
||||
* state, clock, and focus mode. Designed to read as a single line of
|
||||
* instrument readout, not a navbar.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
type FocusMode = "quiet" | "standard" | "triage";
|
||||
const FOCUS_MODES: FocusMode[] = ["quiet", "standard", "triage"];
|
||||
|
||||
interface DashTopBarProps {
|
||||
guildName: string;
|
||||
botName?: string;
|
||||
}
|
||||
|
||||
function formatClock(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
||||
}
|
||||
|
||||
export function DashTopBar({ guildName, botName = "GMW" }: DashTopBarProps) {
|
||||
const ws = useWebSocket();
|
||||
const [now, setNow] = useState<Date | null>(null);
|
||||
const [focus, setFocus] = useState<FocusMode>("standard");
|
||||
const [tz, setTz] = useState<"utc" | "local">("local");
|
||||
|
||||
useEffect(() => {
|
||||
setNow(new Date());
|
||||
const id = window.setInterval(() => setNow(new Date()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const connected = ws.status === "connected";
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex h-12 items-center justify-between gap-4 border-b border-[var(--color-hairline)] bg-[var(--color-surface)] px-4 font-mono text-[11px]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="display text-base font-medium text-[var(--color-ink)]">
|
||||
{botName}
|
||||
</span>
|
||||
<span className="text-[var(--color-ink-soft)]">·</span>
|
||||
<span className="text-[var(--color-ink-soft)]">{guildName}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"inline-block size-1.5 rounded-full",
|
||||
connected
|
||||
? "bg-[var(--color-signal)]"
|
||||
: "bg-[var(--color-vermilion)]",
|
||||
)}
|
||||
style={{
|
||||
boxShadow: connected
|
||||
? "0 0 0 0 oklch(from var(--color-signal) l c h / 0.45)"
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
<span className="uppercase tracking-[0.18em] text-[var(--color-ink-soft)]">
|
||||
{ws.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTz((t) => (t === "utc" ? "local" : "utc"))}
|
||||
className="rounded-[var(--radius-r-control)] px-2 py-0.5 text-[var(--color-ink-soft)] transition-colors hover:bg-[var(--color-surface-2)] hover:text-[var(--color-ink)]"
|
||||
aria-label="Toggle UTC / local timezone"
|
||||
>
|
||||
{now
|
||||
? tz === "utc"
|
||||
? `${formatClock(now)} UTC`
|
||||
: formatLocal(now)
|
||||
: "--:--:--"}
|
||||
</button>
|
||||
|
||||
<div className="flex gap-0.5 rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] p-0.5">
|
||||
{FOCUS_MODES.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setFocus(m)}
|
||||
className={cn(
|
||||
"rounded-[var(--radius-r-control)] px-2 py-0.5 text-[10px] uppercase tracking-wide transition-colors",
|
||||
focus === m
|
||||
? "bg-[var(--color-canvas)] text-[var(--color-ink)]"
|
||||
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
|
||||
)}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function formatLocal(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { navItems } from "@/lib/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const titleFromPath: Record<string, string> = {
|
||||
"/dashboard": "Overview",
|
||||
"/messages": "Messages",
|
||||
"/voice": "Voice",
|
||||
"/media": "Media",
|
||||
"/recordings": "Recordings",
|
||||
"/moderation": "Moderation",
|
||||
"/analysis": "Analysis",
|
||||
};
|
||||
|
||||
export function Spine() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop rail */}
|
||||
<nav className="fixed left-0 top-0 z-30 hidden h-svh w-[68px] flex-col items-center gap-1 border-r border-[var(--color-hairline)] bg-[var(--color-canvas)]/80 py-4 backdrop-blur-md md:flex">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="mb-3 flex size-9 items-center justify-center rounded-[var(--radius-r-control)] bg-[var(--color-signal)] text-sm font-black text-[var(--color-signal-ink)]"
|
||||
aria-label="GMW"
|
||||
>
|
||||
B
|
||||
</Link>
|
||||
{navItems.map((item) => {
|
||||
const active = pathname.startsWith(item.matchPrefix);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"group relative flex size-11 items-center justify-center rounded-[var(--radius-r)] transition-colors",
|
||||
active
|
||||
? "text-[var(--color-signal)]"
|
||||
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
|
||||
)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId="spine-active"
|
||||
className="absolute left-0 top-1/2 size-1 -translate-y-1/2 rounded-full bg-[var(--color-signal)]"
|
||||
transition={{ type: "spring", stiffness: 380, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<Icon className="size-5" />
|
||||
<span className="pointer-events-none absolute left-full ml-2 hidden whitespace-nowrap rounded-[var(--radius-r-control)] bg-[var(--color-ink)] px-2 py-1 text-xs font-medium text-[var(--color-canvas)] opacity-0 transition-opacity group-hover:opacity-100 md:block">
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Mobile bottom tab-bar */}
|
||||
<nav className="fixed inset-x-0 bottom-0 z-30 flex h-16 items-stretch border-t border-[var(--color-hairline)] bg-[var(--color-canvas)]/90 backdrop-blur-md md:hidden">
|
||||
{navItems.map((item) => {
|
||||
const active = pathname.startsWith(item.matchPrefix);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-center justify-center gap-0.5 text-[10px] font-medium transition-colors",
|
||||
active
|
||||
? "text-[var(--color-signal)]"
|
||||
: "text-[var(--color-ink-soft)]",
|
||||
)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageTitle() {
|
||||
const pathname = usePathname();
|
||||
const key =
|
||||
Object.keys(titleFromPath).find((k) => pathname.startsWith(k)) ??
|
||||
"/dashboard";
|
||||
return (
|
||||
<span className="font-semibold max-md:hidden">{titleFromPath[key]}</span>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useChatbot } from "@/components/chatbot/chatbot-context";
|
||||
import { GuildSelector } from "@/components/shared/guild-selector";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
import { PageTitle } from "./spine";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
|
||||
const statusTone: Record<string, string> = {
|
||||
connected: "bg-[var(--color-signal)]",
|
||||
connecting: "bg-[var(--color-amber)]",
|
||||
disconnected: "bg-[var(--color-ink-soft)]",
|
||||
error: "bg-[var(--color-vermilion)]",
|
||||
};
|
||||
|
||||
export function StatusBar({
|
||||
guildId,
|
||||
onGuildChange,
|
||||
}: {
|
||||
guildId: string;
|
||||
onGuildChange: (g: string) => void;
|
||||
}) {
|
||||
const ws = useWebSocket();
|
||||
const { expression } = useChatbot();
|
||||
const [clock, setClock] = useState("--:--:--");
|
||||
|
||||
useEffect(() => {
|
||||
const tick = () =>
|
||||
setClock(new Date().toLocaleTimeString("en-GB", { hour12: false }));
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 flex h-14 shrink-0 items-center gap-2 border-b border-[var(--color-hairline)] bg-[var(--color-canvas)]/85 px-4 backdrop-blur-md md:px-6">
|
||||
<PageTitle />
|
||||
<div className="ms-auto flex items-center gap-3">
|
||||
<span className="hidden items-center gap-1.5 text-xs text-[var(--color-ink-soft)] sm:flex">
|
||||
<span className={cn("size-2 rounded-full", statusTone[ws.status])} />
|
||||
<span className="mono uppercase">{ws.status}</span>
|
||||
</span>
|
||||
<span className="hidden text-xs text-[var(--color-ink-soft)] md:inline">
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono",
|
||||
expression !== "idle" && "text-[var(--color-signal)]",
|
||||
)}
|
||||
>
|
||||
{expression}
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden font-mono text-xs text-[var(--color-ink-soft)] lg:inline">
|
||||
{clock}
|
||||
</span>
|
||||
<GuildSelector
|
||||
value={guildId}
|
||||
onChange={(g) => onGuildChange(g ?? "")}
|
||||
/>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const isDark = resolvedTheme === "dark";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle theme"
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
className="flex size-9 items-center justify-center rounded-[var(--radius-r-control)] text-[var(--color-ink-soft)] transition-colors hover:bg-[var(--color-surface-2)] hover:text-[var(--color-ink)] focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]"
|
||||
>
|
||||
{mounted && (
|
||||
<motion.span
|
||||
key={isDark ? "moon" : "sun"}
|
||||
initial={{ rotate: -90, opacity: 0 }}
|
||||
animate={{ rotate: 0, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 360, damping: 26 }}
|
||||
className={cn(
|
||||
isDark ? "text-[var(--color-signal)]" : "text-[var(--color-amber)]",
|
||||
)}
|
||||
>
|
||||
{isDark ? <Moon className="size-4" /> : <Sun className="size-4" />}
|
||||
</motion.span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Pause, Play, SkipForward, Volume2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { Button } from "@/components/primitives/button";
|
||||
import { useMediaSkip, useMediaState, useMediaWsSync } from "@/hooks";
|
||||
import { useMediaPlayer } from "@/lib/hooks/use-media-player";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
export function MiniPlayer() {
|
||||
const ws = useWebSocket();
|
||||
const { data: state } = useMediaState();
|
||||
const { playing, current } = useMediaPlayer();
|
||||
useMediaWsSync(ws);
|
||||
const skip = useMediaSkip();
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 30 }}
|
||||
className={cn(
|
||||
"pointer-events-auto fixed inset-x-0 bottom-20 z-30 mx-auto w-[calc(100%-2rem)] max-w-[480px]",
|
||||
"surface flex items-center gap-3 px-3 py-2 text-sm",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={current.thumbnailUrl ?? "/favicon.ico"}
|
||||
alt={current.title}
|
||||
className="size-9 rounded-[var(--radius-r-control)] object-cover"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{current.title}</div>
|
||||
<div className="text-xs text-[var(--color-ink-soft)] mono">
|
||||
{current.source}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => skip.mutate()}>
|
||||
<SkipForward className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={playing ? "primary" : "ghost"}
|
||||
onClick={() =>
|
||||
state?.playing ? void skip.mutate() : void skip.mutate()
|
||||
}
|
||||
>
|
||||
{playing ? <Pause className="size-4" /> : <Play className="size-4" />}
|
||||
</Button>
|
||||
<Volume2 className="size-4 text-[var(--color-ink-soft)]" />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { Progress } from "@/components/primitives/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AiAnalysisPanelProps {
|
||||
status?: string | null;
|
||||
severity?: string | null;
|
||||
confidence?: number | null;
|
||||
flags?: string[] | string | null;
|
||||
categories?: string[] | string | null;
|
||||
action?: string | null;
|
||||
score?: number | null;
|
||||
analysis?: string | null;
|
||||
}
|
||||
|
||||
const severityColor: Record<string, string> = {
|
||||
none: "text-[var(--color-ink-soft)]",
|
||||
low: "text-[var(--color-ink-soft)]",
|
||||
medium: "text-[var(--color-amber)]",
|
||||
high: "text-orange-500",
|
||||
critical: "text-[var(--color-vermilion)]",
|
||||
};
|
||||
|
||||
export function AiAnalysisPanel({
|
||||
status,
|
||||
severity,
|
||||
confidence,
|
||||
flags,
|
||||
categories,
|
||||
action,
|
||||
score,
|
||||
analysis,
|
||||
}: AiAnalysisPanelProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (!status || status === "pending") {
|
||||
return (
|
||||
<div className="surface-2 p-3">
|
||||
<span className="text-xs text-[var(--color-ink-soft)]/60">
|
||||
AI analysis pending
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const flagsArray =
|
||||
typeof flags === "string" ? (flags ? JSON.parse(flags) : []) : flags || [];
|
||||
const categoriesArray =
|
||||
typeof categories === "string"
|
||||
? categories
|
||||
? JSON.parse(categories)
|
||||
: []
|
||||
: categories || [];
|
||||
|
||||
const statusTone =
|
||||
status === "clean"
|
||||
? "signal"
|
||||
: status === "flagged"
|
||||
? "vermilion"
|
||||
: status === "warn"
|
||||
? "amber"
|
||||
: "neutral";
|
||||
|
||||
return (
|
||||
<div className="surface-2 flex flex-col gap-2.5 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
AI Analysis
|
||||
</span>
|
||||
<Badge tone={statusTone}>{status}</Badge>
|
||||
</div>
|
||||
|
||||
{severity && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-[var(--color-ink-soft)]/60">Severity:</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono font-medium",
|
||||
severityColor[severity] || "",
|
||||
)}
|
||||
>
|
||||
{severity}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confidence !== null && confidence !== undefined && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-[var(--color-ink-soft)]/60">Confidence</span>
|
||||
<Progress
|
||||
value={confidence * 100}
|
||||
max={100}
|
||||
tone="signal"
|
||||
showLabel
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{score !== null && score !== undefined && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-[var(--color-ink-soft)]/60">Score</span>
|
||||
<span className="font-mono">{score.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{flagsArray.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{flagsArray.map((f: string) => (
|
||||
<Badge key={f} tone="vermilion">
|
||||
{f}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categoriesArray.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{categoriesArray.map((c: string) => (
|
||||
<Badge key={c} tone="neutral">
|
||||
{c}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis && (
|
||||
<div className="border-l-2 border-[var(--color-hairline)] pl-2">
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs leading-relaxed text-[var(--color-ink-soft)]",
|
||||
!expanded && "line-clamp-3",
|
||||
)}
|
||||
>
|
||||
{analysis}
|
||||
</p>
|
||||
{analysis.length > 120 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="mt-1 text-[10px] font-medium uppercase tracking-wide text-[var(--color-ink-soft)]/50 transition-colors hover:text-[var(--color-ink)]"
|
||||
>
|
||||
{expanded ? "Show less" : "Show more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action && action !== "none" && (
|
||||
<div className="text-xs">
|
||||
<span className="text-[var(--color-ink-soft)]/60">Recommended: </span>
|
||||
<span className="font-mono text-[var(--color-amber)]">{action}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { AiSeverity, AiStatus } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const severityTick: Record<NonNullable<AiSeverity>, string> = {
|
||||
none: "border-[var(--color-signal)]/30",
|
||||
low: "border-[var(--color-amber)]/50",
|
||||
medium: "border-[var(--color-amber)]",
|
||||
high: "border-orange-500/80",
|
||||
critical: "border-[var(--color-vermilion)]",
|
||||
};
|
||||
|
||||
const statusBadge: Record<NonNullable<AiStatus>, string> = {
|
||||
pending: "bg-[var(--color-ink-soft)]/20 text-[var(--color-ink-soft)]",
|
||||
processing: "bg-[var(--color-amber)]/15 text-[var(--color-amber)]",
|
||||
clean: "bg-[var(--color-signal)]/15 text-[var(--color-signal)]",
|
||||
warn: "bg-[var(--color-amber)]/15 text-[var(--color-amber)]",
|
||||
flagged: "bg-[var(--color-vermilion)]/15 text-[var(--color-vermilion)]",
|
||||
error: "bg-[var(--color-vermilion)]/15 text-[var(--color-vermilion)]",
|
||||
};
|
||||
|
||||
export function SeverityTick({ severity }: { severity?: AiSeverity | null }) {
|
||||
const cls = severity ? severityTick[severity] : "border-transparent";
|
||||
return (
|
||||
<span
|
||||
className={cn("absolute left-0 top-0 h-full w-0.5 border-l-2", cls)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AiStatusBadge({ status }: { status?: AiStatus | null }) {
|
||||
if (!status) return null;
|
||||
return (
|
||||
<span className={cn("pill", statusBadge[status])}>
|
||||
<span
|
||||
className="size-1.5 rounded-full"
|
||||
style={{ background: "currentColor" }}
|
||||
/>
|
||||
<span className="ml-1 text-[10px] font-medium uppercase">{status}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { AttachmentRef } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AttachmentsGrid({
|
||||
attachments,
|
||||
onOpen,
|
||||
}: {
|
||||
attachments: AttachmentRef[];
|
||||
onOpen: (url: string) => void;
|
||||
}) {
|
||||
if (attachments.length === 0) return null;
|
||||
const images = attachments.filter((a) => /image/i.test(a.contentType ?? ""));
|
||||
if (images.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{images.map((a, i) => (
|
||||
<button
|
||||
key={`${a.url}-${i}`}
|
||||
type="button"
|
||||
onClick={() => onOpen(a.url)}
|
||||
className="group relative aspect-video overflow-hidden rounded-[var(--radius-r-control)] border border-[var(--color-hairline)]"
|
||||
>
|
||||
<img
|
||||
src={a.url}
|
||||
alt={a.name}
|
||||
loading="lazy"
|
||||
className="size-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Dialog } from "@/components/primitives/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Lightbox({
|
||||
open,
|
||||
onClose,
|
||||
src,
|
||||
alt,
|
||||
images = [],
|
||||
initialIndex = 0,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
src?: string;
|
||||
alt?: string;
|
||||
images?: Array<{ src: string; alt?: string }>;
|
||||
initialIndex?: number;
|
||||
}) {
|
||||
const gallery = images.length > 0 ? images : src ? [{ src, alt }] : [];
|
||||
const [idx, setIdx] = useState(initialIndex);
|
||||
useEffect(() => setIdx(initialIndex), [initialIndex]);
|
||||
if (!gallery.length) return null;
|
||||
const current = gallery[idx];
|
||||
const hasNav = gallery.length > 1;
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="p-0 border-0 bg-transparent shadow-none"
|
||||
>
|
||||
<div className="relative flex items-center justify-center p-4">
|
||||
{hasNav && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setIdx((i) => (i - 1 + gallery.length) % gallery.length)
|
||||
}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/40 p-2 text-white hover:bg-black/60"
|
||||
aria-label="Previous"
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
)}
|
||||
<img
|
||||
src={current.src}
|
||||
alt={current.alt ?? alt ?? "attachment"}
|
||||
className="max-h-[80vh] max-w-full rounded-[var(--radius-r)] object-contain"
|
||||
/>
|
||||
{hasNav && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIdx((i) => (i + 1) % gallery.length)}
|
||||
className="absolute right-16 top-1/2 -translate-y-1/2 rounded-full bg-black/40 p-2 text-white hover:bg-black/60"
|
||||
aria-label="Next"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute top-2 right-2 rounded-full bg-black/40 p-1.5 text-white hover:bg-black/60"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Avatar } from "@/components/primitives/avatar";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import {
|
||||
getMessageChannelLabel,
|
||||
renderMessageContent,
|
||||
safeParseJsonArray,
|
||||
} from "@/lib/format";
|
||||
import type { AttachmentRef, MessageRecord } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AiStatusBadge, SeverityTick } from "./ai-status-badge";
|
||||
import { AttachmentsGrid } from "./attachments-grid";
|
||||
import { Lightbox } from "./lightbox";
|
||||
|
||||
function extractAttachments(metadata?: string | null): AttachmentRef[] {
|
||||
if (!metadata) return [];
|
||||
try {
|
||||
const m = JSON.parse(metadata);
|
||||
return (m?.attachments ?? []) as AttachmentRef[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function fmtFull(ts?: number): string {
|
||||
if (!ts) return "";
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
export interface MessageDetailProps {
|
||||
message: MessageRecord;
|
||||
channelLabel?: string;
|
||||
}
|
||||
|
||||
export function MessageDetailView({
|
||||
message: msg,
|
||||
channelLabel,
|
||||
}: MessageDetailProps) {
|
||||
const [img, setImg] = useState<string | null>(null);
|
||||
const severity = msg.ai_severity ?? "none";
|
||||
const flags = safeParseJsonArray(msg.ai_moderation_flags || "[]");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<Avatar src={msg.avatar_url} name={msg.username} size={40} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<span className="font-semibold">{msg.username}</span>
|
||||
<span className="mono text-xs text-[var(--color-ink-soft)]">
|
||||
{fmtFull(msg.created_at)}
|
||||
</span>
|
||||
<AiStatusBadge status={msg.ai_status ?? null} />
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-[var(--color-ink-soft)]">
|
||||
#{channelLabel ?? getMessageChannelLabel(msg)}
|
||||
{msg.thread_id && <span className="mx-1 opacity-40">·</span>}
|
||||
{msg.thread_id && <span>Thread {msg.thread_id.slice(0, 8)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-[var(--radius-r)] p-4",
|
||||
severity === "critical"
|
||||
? "border-l-2 border-[var(--color-vermilion)]"
|
||||
: severity === "high"
|
||||
? "border-l-2 border-[var(--color-amber)]"
|
||||
: "border border-[var(--color-hairline)]",
|
||||
)}
|
||||
>
|
||||
<SeverityTick severity={severity} />
|
||||
<div className="text-sm leading-relaxed">
|
||||
{msg.deleted_at ? (
|
||||
<span className="italic text-[var(--color-ink-soft)]">
|
||||
message deleted
|
||||
</span>
|
||||
) : (
|
||||
renderMessageContent(
|
||||
msg.edited_content ?? msg.content,
|
||||
msg.metadata,
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{flags.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{flags.map((f) => (
|
||||
<Badge key={f} tone="vermilion">
|
||||
{f}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg.ai_analysis && (
|
||||
<div className="mt-3 rounded-[var(--radius-r)] bg-[var(--color-surface-2)] p-3 text-xs">
|
||||
<span className="font-medium text-[var(--color-amber)]">
|
||||
AI analysis:
|
||||
</span>{" "}
|
||||
<span className="text-[var(--color-ink-soft)]">
|
||||
{msg.ai_analysis}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{extractAttachments(msg.metadata).length > 0 && (
|
||||
<AttachmentsGrid
|
||||
attachments={extractAttachments(msg.metadata)}
|
||||
onOpen={(u) => setImg(u)}
|
||||
/>
|
||||
)}
|
||||
<Lightbox
|
||||
open={!!img}
|
||||
onClose={() => setImg(null)}
|
||||
src={img ?? undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar } from "@/components/primitives/avatar";
|
||||
import { renderMessageContent } from "@/lib/format";
|
||||
import type { AiSeverity, MessageRecord } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AiStatusBadge, SeverityTick } from "./ai-status-badge";
|
||||
|
||||
function fmtTime(ts?: number): string {
|
||||
if (!ts) return "";
|
||||
return new Date(ts * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
const severityColor: Record<NonNullable<AiSeverity>, string> = {
|
||||
none: "text-[var(--color-ink-soft)]",
|
||||
low: "text-[var(--color-amber)]",
|
||||
medium: "text-[var(--color-amber)]",
|
||||
high: "text-orange-500",
|
||||
critical: "text-[var(--color-vermilion)]",
|
||||
};
|
||||
|
||||
export interface MessageEntryProps {
|
||||
message: MessageRecord;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onAvatarClick?: () => void;
|
||||
}
|
||||
|
||||
export function MessageEntry({
|
||||
message: msg,
|
||||
selected,
|
||||
onSelect,
|
||||
}: MessageEntryProps) {
|
||||
const severity = (msg.ai_severity ?? "none") as AiSeverity;
|
||||
const status = msg.ai_status ?? null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-selected={selected}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"group relative mb-1.5 flex w-full items-start gap-2.5 rounded-[var(--radius-r)] p-2.5 text-left cursor-pointer",
|
||||
"transition-all hover:bg-[var(--color-surface-2)]",
|
||||
selected && "bg-[var(--color-signal)]/6",
|
||||
)}
|
||||
>
|
||||
<SeverityTick severity={severity} />
|
||||
<Avatar src={msg.avatar_url} name={msg.username} size={32} />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{msg.username}</span>
|
||||
<span className="text-xs text-[var(--color-ink-soft)] mono">
|
||||
{fmtTime(msg.created_at)}
|
||||
</span>
|
||||
{status && <AiStatusBadge status={status} />}
|
||||
{severity !== "none" && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-bold uppercase",
|
||||
severityColor[severity],
|
||||
)}
|
||||
>
|
||||
{severity}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm leading-relaxed">
|
||||
{msg.deleted_at ? (
|
||||
<span className="italic text-[var(--color-ink-soft)]">
|
||||
message deleted
|
||||
</span>
|
||||
) : (
|
||||
renderMessageContent(
|
||||
msg.edited_content ?? msg.content,
|
||||
msg.metadata,
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { MessageRecord } from "@/lib/types";
|
||||
import { MessageEntry } from "./message-entry";
|
||||
|
||||
export { MessageEntry };
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
selectedId,
|
||||
onSelect,
|
||||
hasMore,
|
||||
onLoadMore,
|
||||
isLoadingMore,
|
||||
}: {
|
||||
messages: MessageRecord[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
hasMore?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
isLoadingMore?: boolean;
|
||||
}) {
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="py-10 text-center">
|
||||
<p className="text-sm text-[var(--color-ink-soft)]">
|
||||
No messages found.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-0.5">
|
||||
{messages.map((m) => (
|
||||
<MessageEntry
|
||||
key={m.id}
|
||||
message={m}
|
||||
selected={selectedId === m.id}
|
||||
onSelect={() => onSelect(m.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoadMore}
|
||||
disabled={isLoadingMore}
|
||||
className="mt-3 w-full text-center text-xs text-[var(--color-amber)] hover:underline disabled:opacity-50"
|
||||
>
|
||||
{isLoadingMore ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Search } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Dialog } from "@/components/primitives/dialog";
|
||||
import { Input } from "@/components/primitives/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
content: string;
|
||||
username: string;
|
||||
channel: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface SearchOverlayProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
results: Message[];
|
||||
onSelect: (msg: Message) => void;
|
||||
}
|
||||
|
||||
export function SearchOverlay({
|
||||
open,
|
||||
onClose,
|
||||
results,
|
||||
onSelect,
|
||||
}: SearchOverlayProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filtered = query
|
||||
? results.filter(
|
||||
(m) =>
|
||||
m.content.toLowerCase().includes(query.toLowerCase()) ||
|
||||
m.username.toLowerCase().includes(query.toLowerCase()),
|
||||
)
|
||||
: results;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} className="p-0 max-w-xl">
|
||||
<div className="p-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-soft)]" />
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search messages… (Esc to close)"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-9 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 max-h-[420px] overflow-y-auto">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-[var(--color-ink-soft)]">
|
||||
No results.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{filtered.slice(0, 32).map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(m)}
|
||||
className="group flex flex-col items-start gap-1 rounded-[var(--radius-r-control)] px-2.5 py-2 text-left transition-colors hover:bg-[var(--color-surface-2)]"
|
||||
>
|
||||
<span className="text-xs text-[var(--color-ink-soft)] group-hover:text-[var(--color-ink)]">
|
||||
#{m.channel} · {m.username}
|
||||
</span>
|
||||
<span className="text-sm">{m.content}</span>
|
||||
<span className="text-[10px] text-[var(--color-ink-soft)]/60">
|
||||
{m.time}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
MicOff,
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
UserX,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { EmptyState, LoadingSkeleton } from "@/components/shared";
|
||||
import { useModerationActions, useModerationStats } from "@/hooks";
|
||||
import { renderMessageContent } from "@/lib/format";
|
||||
import type {
|
||||
ModerationAction,
|
||||
ModerationActionType,
|
||||
ModerationStats,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ACTION_META: Record<
|
||||
ModerationActionType,
|
||||
{ label: string; Icon: typeof Trash2; tone: "vermilion" | "amber" }
|
||||
> = {
|
||||
delete_message: { label: "Delete message", Icon: Trash2, tone: "vermilion" },
|
||||
mute_user: { label: "Mute user", Icon: MicOff, tone: "amber" },
|
||||
warn_user: { label: "Warn user", Icon: AlertTriangle, tone: "amber" },
|
||||
kick_user: { label: "Kick user", Icon: UserX, tone: "amber" },
|
||||
ban_user: { label: "Ban user", Icon: Ban, tone: "vermilion" },
|
||||
};
|
||||
|
||||
const STATUS_META: Record<
|
||||
ModerationAction["status"],
|
||||
{ label: string; tone: "signal" | "vermilion" | "amber" }
|
||||
> = {
|
||||
executed: { label: "Executed", tone: "signal" },
|
||||
failed: { label: "Failed", tone: "vermilion" },
|
||||
pending: { label: "Pending", tone: "amber" },
|
||||
};
|
||||
|
||||
function fmtTime(ts: number | null): string {
|
||||
if (!ts) return "—";
|
||||
const d = new Date(ts);
|
||||
const diff = Date.now() - ts;
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const rel =
|
||||
hours < 1
|
||||
? "baru saja"
|
||||
: hours < 24
|
||||
? `${hours} jam lalu`
|
||||
: `${Math.floor(hours / 24)} hari lalu`;
|
||||
return `${d.toLocaleString("id-ID")} (${rel})`;
|
||||
}
|
||||
|
||||
const EMPTY_ACTION_RATE = {
|
||||
total: 0,
|
||||
executed: 0,
|
||||
failed: 0,
|
||||
pending: 0,
|
||||
failed_rate: 0,
|
||||
};
|
||||
|
||||
export function ModerationSection({
|
||||
initialStats,
|
||||
initialActions,
|
||||
}: {
|
||||
initialStats?: ModerationStats;
|
||||
initialActions?: ModerationAction[];
|
||||
} = {}) {
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [actionType, setActionType] = useState<string>("");
|
||||
const { data: stats } = useModerationStats(initialStats);
|
||||
const { data: actions, isLoading: actionsLoading } = useModerationActions(
|
||||
status,
|
||||
actionType,
|
||||
initialActions,
|
||||
);
|
||||
|
||||
const s = stats ?? EMPTY_ACTION_RATE;
|
||||
|
||||
const statusFilters = ["", "executed", "failed", "pending"];
|
||||
const typeFilters = [
|
||||
"",
|
||||
"delete_message",
|
||||
"warn_user",
|
||||
"kick_user",
|
||||
"ban_user",
|
||||
"mute_user",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<SummaryCard label="Total aksi" value={s.total} />
|
||||
<SummaryCard
|
||||
label="Executed"
|
||||
value={s.executed}
|
||||
tone="signal"
|
||||
hint={undefined}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Failed"
|
||||
value={s.failed}
|
||||
tone="vermilion"
|
||||
hint={s.total > 0 ? `${s.failed_rate}%` : undefined}
|
||||
/>
|
||||
<SummaryCard label="Pending" value={s.pending} tone="amber" />
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
Status
|
||||
</span>
|
||||
{statusFilters.map((f) => (
|
||||
<FilterChip
|
||||
key={f || "all"}
|
||||
active={status === f}
|
||||
label={
|
||||
f === ""
|
||||
? "Semua"
|
||||
: STATUS_META[f as keyof typeof STATUS_META].label
|
||||
}
|
||||
onClick={() => setStatus(f)}
|
||||
/>
|
||||
))}
|
||||
<span className="ml-3 text-[10px] font-semibold uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
Tipe
|
||||
</span>
|
||||
{typeFilters.map((f) => (
|
||||
<FilterChip
|
||||
key={f || "all"}
|
||||
active={actionType === f}
|
||||
label={
|
||||
f === "" ? "Semua" : ACTION_META[f as ModerationActionType].label
|
||||
}
|
||||
onClick={() => setActionType(f)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
{actionsLoading && !actions ? (
|
||||
<LoadingSkeleton count={6} height="h-16" />
|
||||
) : !actions || actions.length === 0 ? (
|
||||
<div className="surface p-6">
|
||||
<EmptyState
|
||||
icon={ShieldAlert}
|
||||
title="Belum ada aksi moderasi"
|
||||
description="Aksi auto-moderasi (delete, warn, kick, ban) akan muncul di sini."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{actions.map((a) => (
|
||||
<ActionRow key={a.id} action={a} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-[var(--color-ink-soft)]">
|
||||
{actions?.length ?? 0} aksi ditampilkan · log moderasi gateway Discord
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone?: "signal" | "vermilion" | "amber";
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="surface p-4">
|
||||
<p className="text-[10px] uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 text-2xl font-bold",
|
||||
tone === "signal" && "text-[var(--color-signal)]",
|
||||
tone === "vermilion" && "text-[var(--color-vermilion)]",
|
||||
tone === "amber" && "text-[var(--color-amber)]",
|
||||
!tone && "text-[var(--color-ink)]",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
{hint && (
|
||||
<span className="ml-1 text-xs font-medium opacity-80">({hint})</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({
|
||||
active,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-[11px] transition-colors",
|
||||
active
|
||||
? "bg-[var(--color-signal)] text-[var(--color-signal-ink)]"
|
||||
: "text-[var(--color-ink-soft)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-ink)]",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionRow({ action }: { action: ModerationAction }) {
|
||||
const meta = ACTION_META[action.action_type] ?? ACTION_META.delete_message;
|
||||
const st = STATUS_META[action.status];
|
||||
const Icon = meta.Icon;
|
||||
return (
|
||||
<div className="surface flex items-start gap-3 p-3">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 shrink-0",
|
||||
meta.tone === "vermilion"
|
||||
? "text-[var(--color-vermilion)]"
|
||||
: "text-[var(--color-amber)]",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-semibold text-[var(--color-ink)]">
|
||||
{meta.label}
|
||||
</span>
|
||||
{action.username && (
|
||||
<span className="text-xs text-[var(--color-ink-soft)]">
|
||||
@{action.username}
|
||||
</span>
|
||||
)}
|
||||
<Badge tone={st.tone}>{st.label}</Badge>
|
||||
</div>
|
||||
{action.content && (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-[var(--color-ink-soft)]">
|
||||
{renderMessageContent(action.content, null)}
|
||||
</p>
|
||||
)}
|
||||
{action.reason && (
|
||||
<p className="mt-1 text-[11px] text-[var(--color-ink-soft)]">
|
||||
Alasan: {action.reason}
|
||||
</p>
|
||||
)}
|
||||
{action.error && (
|
||||
<p className="mt-1 text-[11px] text-[var(--color-vermilion)] line-clamp-2">
|
||||
Error: {action.error}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1.5 text-[10px] font-mono text-[var(--color-ink-soft)]">
|
||||
dibuat {fmtTime(action.created_at)}
|
||||
{action.executed_at
|
||||
? ` · dieksekusi ${fmtTime(action.executed_at)}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
{action.status === "executed" ? (
|
||||
<CheckCircle2 className="mt-0.5 size-3.5 shrink-0 text-[var(--color-signal)]" />
|
||||
) : action.status === "failed" ? (
|
||||
<XCircle className="mt-0.5 size-3.5 shrink-0 text-[var(--color-vermilion)]" />
|
||||
) : (
|
||||
<Loader2 className="mt-0.5 size-3.5 shrink-0 animate-spin text-[var(--color-amber)]" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModerationSection;
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { ease } from "./variants";
|
||||
|
||||
export function RouteTransition({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
if (reduce) {
|
||||
return <div key={pathname}>{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.div
|
||||
key={pathname}
|
||||
initial={{ opacity: 0, y: 8, filter: "blur(4px)" }}
|
||||
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
||||
exit={{ opacity: 0, filter: "blur(4px)" }}
|
||||
transition={{ duration: 0.22, ease }}
|
||||
className="contents"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { fadeUp, stagger } from "./variants";
|
||||
|
||||
type V = Variants;
|
||||
|
||||
interface StaggerGroupProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
variants?: V;
|
||||
as?: "div" | "ul" | "section";
|
||||
}
|
||||
|
||||
export function StaggerGroup({
|
||||
children,
|
||||
className,
|
||||
variants = stagger,
|
||||
as = "div",
|
||||
}: StaggerGroupProps) {
|
||||
const Tag = motion[as];
|
||||
return (
|
||||
<Tag
|
||||
className={className}
|
||||
variants={variants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
interface StaggerItemProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
variants?: V;
|
||||
layout?: boolean;
|
||||
}
|
||||
|
||||
export function StaggerItem({
|
||||
children,
|
||||
className,
|
||||
variants = fadeUp,
|
||||
layout,
|
||||
}: StaggerItemProps) {
|
||||
return (
|
||||
<motion.div className={className} variants={variants} layout={layout}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { Transition, Variants } from "motion/react";
|
||||
|
||||
/** Spring tuned for UI micro-interactions. */
|
||||
export const spring: Transition = {
|
||||
type: "spring",
|
||||
stiffness: 260,
|
||||
damping: 24,
|
||||
};
|
||||
|
||||
/** Expressive ease-out for page/section transitions. */
|
||||
export const ease = [0.22, 1, 0.36, 1] as const;
|
||||
|
||||
/** Single-element fade + rise. */
|
||||
export const fadeUp: Variants = {
|
||||
hidden: { opacity: 0, y: 8 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.32, ease } },
|
||||
};
|
||||
|
||||
/** Parent that staggers its children. */
|
||||
export const stagger: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: { staggerChildren: 0.06, delayChildren: 0.08 },
|
||||
},
|
||||
};
|
||||
|
||||
/** Scale-in for emphasis blocks. */
|
||||
export const popIn: Variants = {
|
||||
hidden: { opacity: 0, scale: 0.94 },
|
||||
visible: { opacity: 1, scale: 1, transition: spring },
|
||||
};
|
||||
@@ -1,38 +1,51 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface AvatarProps {
|
||||
src?: string | null;
|
||||
name?: string | null;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function initials(name?: string | null): string {
|
||||
if (!name) return "?";
|
||||
const parts = name.trim().split(/\s+/);
|
||||
const parts = name.replace(/[^\p{L}\p{N} _]/gu, "").trim().split(/\s+/);
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
export function Avatar({ src, name, size = 36, className }: AvatarProps) {
|
||||
export function Avatar({
|
||||
src,
|
||||
name,
|
||||
size = 36,
|
||||
className,
|
||||
ring,
|
||||
}: {
|
||||
src?: string | null;
|
||||
name?: string | null;
|
||||
size?: number;
|
||||
className?: string;
|
||||
ring?: boolean;
|
||||
}) {
|
||||
const dim = { width: size, height: size };
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full",
|
||||
"bg-[var(--color-signal)]/20 text-[var(--color-signal)] font-semibold",
|
||||
"bg-gradient-to-br from-white/10 to-white/[0.02] text-ink-soft",
|
||||
ring && "ring-2 ring-signal/50",
|
||||
className,
|
||||
)}
|
||||
style={{ width: size, height: size, fontSize: size * 0.38 }}
|
||||
style={dim}
|
||||
>
|
||||
{src ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={src}
|
||||
alt={name ?? ""}
|
||||
className="size-full object-cover"
|
||||
alt={name ?? "avatar"}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
initials(name)
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ fontSize: Math.max(10, size * 0.36) }}
|
||||
>
|
||||
{initials(name)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,40 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type BadgeTone = "signal" | "amber" | "vermilion" | "neutral";
|
||||
type Tone = "signal" | "amber" | "vermilion" | "neutral";
|
||||
|
||||
const toneClass: Record<BadgeTone, string> = {
|
||||
signal: "bg-[var(--color-signal)]/15 text-[var(--color-signal)]",
|
||||
amber: "bg-[var(--color-amber)]/15 text-[var(--color-amber)]",
|
||||
vermilion: "bg-[var(--color-vermilion)]/15 text-[var(--color-vermilion)]",
|
||||
neutral: "bg-[var(--color-hairline)] text-[var(--color-ink-soft)]",
|
||||
const tones: Record<Tone, string> = {
|
||||
signal: "bg-signal/12 text-signal border-signal/30",
|
||||
amber: "bg-amber/12 text-amber border-amber/30",
|
||||
vermilion: "bg-vermilion/12 text-vermilion border-vermilion/30",
|
||||
neutral: "bg-white/6 text-ink-soft border-white/10",
|
||||
};
|
||||
|
||||
export interface BadgeProps {
|
||||
tone?: BadgeTone;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
dot?: boolean;
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
tone = "neutral",
|
||||
children,
|
||||
className,
|
||||
dot,
|
||||
}: BadgeProps) {
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
tone?: Tone;
|
||||
dot?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("pill", toneClass[tone], className)}>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[0.7rem] font-semibold tracking-wide",
|
||||
tones[tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{dot && (
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
tone === "signal" && "bg-[var(--color-signal)]",
|
||||
tone === "amber" && "bg-[var(--color-amber)]",
|
||||
tone === "vermilion" && "bg-[var(--color-vermilion)]",
|
||||
tone === "neutral" && "bg-[var(--color-ink-soft)]",
|
||||
)}
|
||||
/>
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-current opacity-60 animate-pulse-ring" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
|
||||
@@ -1,55 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { type HTMLMotionProps, motion, useReducedMotion } from "motion/react";
|
||||
import { forwardRef } from "react";
|
||||
import { Slot } from "./slot";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Variant = "primary" | "ghost" | "danger" | "outline";
|
||||
type Variant = "primary" | "ghost" | "outline" | "danger" | "subtle";
|
||||
type Size = "sm" | "md" | "lg" | "icon";
|
||||
|
||||
const variantClass: Record<Variant, string> = {
|
||||
const variants: Record<Variant, string> = {
|
||||
primary:
|
||||
"bg-[var(--color-signal)] text-[var(--color-signal-ink)] hover:opacity-90",
|
||||
ghost:
|
||||
"bg-transparent text-[var(--color-ink)] hover:bg-[var(--color-surface-2)]",
|
||||
danger: "bg-[var(--color-vermilion)] text-white hover:opacity-90",
|
||||
"bg-signal text-signal-ink hover:brightness-110 shadow-[0_8px_24px_-10px_var(--color-signal-glow)] font-semibold",
|
||||
ghost: "text-ink-soft hover:text-ink hover:bg-white/5",
|
||||
outline:
|
||||
"bg-transparent text-[var(--color-ink)] border border-[var(--color-hairline)] hover:bg-[var(--color-surface-2)]",
|
||||
"border border-hairline bg-white/0 text-ink hover:bg-white/5 hover:border-signal/40",
|
||||
danger:
|
||||
"bg-vermilion text-white hover:brightness-110 shadow-[0_8px_24px_-10px_var(--color-vermilion-glow)] font-semibold",
|
||||
subtle: "bg-white/5 text-ink hover:bg-white/10",
|
||||
};
|
||||
|
||||
const sizeClass: Record<Size, string> = {
|
||||
sm: "h-8 px-3 text-xs rounded-[var(--radius-r-control)]",
|
||||
md: "h-10 px-4 text-sm rounded-[var(--radius-r-control)]",
|
||||
lg: "h-12 px-6 text-base rounded-[var(--radius-r)]",
|
||||
icon: "size-9 rounded-[var(--radius-r-control)]",
|
||||
const sizes: Record<Size, string> = {
|
||||
sm: "h-8 px-3 text-xs rounded-[9px] gap-1.5",
|
||||
md: "h-10 px-4 text-sm rounded-[11px] gap-2",
|
||||
lg: "h-12 px-6 text-base rounded-[13px] gap-2",
|
||||
icon: "h-10 w-10 rounded-[11px]",
|
||||
};
|
||||
|
||||
export interface ButtonProps extends Omit<HTMLMotionProps<"button">, "ref"> {
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{ className, variant = "primary", size = "md", children, ...props },
|
||||
ref,
|
||||
) => {
|
||||
const reduce = useReducedMotion();
|
||||
return (
|
||||
<motion.button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-2 select-none cursor-pointer font-medium outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] disabled:opacity-50 disabled:pointer-events-none transition-colors duration-150",
|
||||
variantClass[variant],
|
||||
sizeClass[size],
|
||||
className,
|
||||
)}
|
||||
whileTap={reduce ? undefined : { scale: 0.97 }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.button>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
export const Button = ({
|
||||
className,
|
||||
variant = "subtle",
|
||||
size = "md",
|
||||
asChild,
|
||||
...props
|
||||
}: ButtonProps) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap transition-all duration-150 select-none",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal/60 disabled:opacity-40 disabled:pointer-events-none",
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Floating glass panel — primary container. */
|
||||
export function GlassPanel({
|
||||
className,
|
||||
children,
|
||||
glow,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & { glow?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"glass p-5",
|
||||
glow && "shadow-[0_0_40px_-18px_var(--color-signal-glow)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Smaller glass sub-panel. */
|
||||
export function GlassCard({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn("glass-2 p-4", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface DialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
labelledBy?: string;
|
||||
}
|
||||
|
||||
export function Dialog({
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
labelledBy,
|
||||
}: DialogProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/55 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
aria-hidden
|
||||
/>
|
||||
<motion.div
|
||||
ref={ref}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={labelledBy}
|
||||
className={cn(
|
||||
"relative z-10 w-full max-w-lg surface-2 shadow-2xl",
|
||||
className,
|
||||
)}
|
||||
initial={
|
||||
reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 12 }
|
||||
}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 12 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 28 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
export { Avatar, type AvatarProps } from "./avatar";
|
||||
export { Badge, type BadgeProps, type BadgeTone } from "./badge";
|
||||
export { Button, type ButtonProps } from "./button";
|
||||
export { Dialog, type DialogProps } from "./dialog";
|
||||
export { Input, type InputProps } from "./input";
|
||||
export { Progress, type ProgressProps } from "./progress";
|
||||
export { Select, type SelectProps } from "./select";
|
||||
export { Sheet, type SheetProps } from "./sheet";
|
||||
export { Skeleton, type SkeletonProps } from "./skeleton";
|
||||
export { Toaster, type ToasterProps, useToast } from "./toast";
|
||||
export { Tooltip, type TooltipProps } from "./tooltip";
|
||||
export { Button } from "./button";
|
||||
export { Badge } from "./badge";
|
||||
export { GlassPanel, GlassCard } from "./card";
|
||||
export { Input, Textarea } from "./input";
|
||||
export { Skeleton } from "./skeleton";
|
||||
export { Avatar } from "./avatar";
|
||||
export { Select } from "./select";
|
||||
export type { SelectOption } from "./select";
|
||||
export { Toaster, toast, useToast } from "./toast";
|
||||
export { Progress, Spinner } from "./progress";
|
||||
export { Tooltip } from "./tooltip";
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
import { forwardRef, type InputHTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
mono?: boolean;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, mono, ...props }, ref) => (
|
||||
export function Input({
|
||||
className,
|
||||
...props
|
||||
}: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"w-full bg-[var(--color-surface-2)] text-[var(--color-ink)] placeholder:text-[var(--color-ink-soft)]/60",
|
||||
"rounded-[var(--radius-r-control)] border border-[var(--color-hairline)] px-3 py-2 text-sm",
|
||||
"outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] transition-colors",
|
||||
mono && "font-mono tracking-tight",
|
||||
"h-10 w-full rounded-[11px] bg-white/5 border border-hairline px-3.5 text-sm text-ink",
|
||||
"placeholder:text-ink-faint transition-colors",
|
||||
"focus:outline-none focus:border-signal/50 focus:bg-white/8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
);
|
||||
}
|
||||
|
||||
export function Textarea({
|
||||
className,
|
||||
...props
|
||||
}: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"w-full rounded-[11px] bg-white/5 border border-hairline px-3.5 py-2.5 text-sm text-ink",
|
||||
"placeholder:text-ink-faint transition-colors resize-none",
|
||||
"focus:outline-none focus:border-signal/50 focus:bg-white/8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,38 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ProgressProps {
|
||||
value: number;
|
||||
max?: number;
|
||||
className?: string;
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
export function Progress({
|
||||
value,
|
||||
max = 100,
|
||||
className,
|
||||
tone = "signal",
|
||||
showLabel,
|
||||
}: ProgressProps) {
|
||||
const pct = Math.max(0, Math.min(100, (value / max) * 100));
|
||||
const stroke = {
|
||||
signal: "var(--color-signal)",
|
||||
amber: "var(--color-amber)",
|
||||
vermilion: "var(--color-vermilion)",
|
||||
}[tone];
|
||||
className,
|
||||
}: {
|
||||
value: number;
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
className?: string;
|
||||
}) {
|
||||
const pct = Math.max(0, Math.min(100, value));
|
||||
const color =
|
||||
tone === "vermilion"
|
||||
? "var(--color-vermilion)"
|
||||
: tone === "amber"
|
||||
? "var(--color-amber)"
|
||||
: "var(--color-signal)";
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<div className="relative h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--color-hairline)]">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full transition-[width] duration-300"
|
||||
style={{ width: `${pct}%`, background: stroke }}
|
||||
/>
|
||||
</div>
|
||||
{showLabel && (
|
||||
<span className="mono text-xs text-[var(--color-ink-soft)]">
|
||||
{Math.round(pct)}%
|
||||
</span>
|
||||
)}
|
||||
<div className={cn("h-1.5 w-full overflow-hidden rounded-full bg-white/8", className)}>
|
||||
<div
|
||||
className="h-full rounded-full transition-[width] duration-500"
|
||||
style={{ width: `${pct}%`, background: color, boxShadow: `0 0 12px -2px ${color}` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Spinner({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block size-4 animate-spin rounded-full border-2 border-white/20 border-t-signal",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,91 @@
|
||||
import { forwardRef, type SelectHTMLAttributes } from "react";
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
mono?: boolean;
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, mono, children, ...props }, ref) => (
|
||||
<select
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"w-full bg-[var(--color-surface-2)] text-[var(--color-ink)] appearance-none cursor-pointer",
|
||||
"rounded-[var(--radius-r-control)] border border-[var(--color-hairline)] px-3 py-2 text-sm",
|
||||
"outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] transition-colors",
|
||||
"bg-[url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 fill=%22none%22 stroke=%22%23aaa%22 stroke-width=%222%22><path d=%22M2 4l4 4 4-4%22/></svg>')] bg-[length:12px] bg-[right_0.75rem_center] bg-no-repeat pr-9",
|
||||
mono && "font-mono tracking-tight",
|
||||
className,
|
||||
export function Select({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = "Select…",
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
value: string | null;
|
||||
onChange: (value: string) => void;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
size?: "sm" | "md";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [open]);
|
||||
|
||||
const selected = options.find((o) => o.value === value);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("relative", className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded-[11px] border border-hairline bg-white/5 text-left text-ink transition-colors hover:border-signal/40",
|
||||
"focus:outline-none focus:border-signal/60",
|
||||
size === "sm" ? "h-9 px-3 text-xs" : "h-10 px-3.5 text-sm",
|
||||
)}
|
||||
>
|
||||
<span className={cn("truncate", !selected && "text-ink-faint")}>
|
||||
{selected?.label ?? placeholder}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn("size-4 shrink-0 text-ink-faint transition-transform", open && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="glass absolute z-50 mt-1.5 max-h-72 w-full overflow-auto p-1.5"
|
||||
style={{ animation: "fade-up 0.14s ease" }}
|
||||
>
|
||||
{options.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-ink-faint">No options</div>
|
||||
)}
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(o.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded-[9px] px-3 py-2 text-left text-sm transition-colors",
|
||||
o.value === value ? "bg-signal/15 text-signal" : "text-ink hover:bg-white/6",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{o.label}</span>
|
||||
{o.hint && <span className="mono text-[0.65rem] text-ink-faint">{o.hint}</span>}
|
||||
{o.value === value && <Check className="size-3.5 shrink-0" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
),
|
||||
);
|
||||
Select.displayName = "Select";
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
side?: "left" | "right" | "bottom";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Sheet({
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
side = "left",
|
||||
className,
|
||||
}: SheetProps) {
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
const dir =
|
||||
side === "left"
|
||||
? { initial: { x: "-100%" }, animate: { x: 0 } }
|
||||
: side === "right"
|
||||
? { initial: { x: "100%" }, animate: { x: 0 } }
|
||||
: { initial: { y: "100%" }, animate: { y: 0 } };
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/55 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
aria-hidden
|
||||
/>
|
||||
<motion.aside
|
||||
className={cn(
|
||||
"absolute bg-[var(--color-canvas)] shadow-2xl",
|
||||
side === "left" &&
|
||||
"left-0 top-0 h-full w-72 border-r border-[var(--color-hairline)]",
|
||||
side === "right" &&
|
||||
"right-0 top-0 h-full w-72 border-l border-[var(--color-hairline)]",
|
||||
side === "bottom" &&
|
||||
"bottom-0 left-0 w-full rounded-t-2xl border-t border-[var(--color-hairline)]",
|
||||
className,
|
||||
)}
|
||||
initial={reduce ? { opacity: 0 } : dir.initial}
|
||||
animate={reduce ? { opacity: 1 } : dir.animate}
|
||||
exit={reduce ? { opacity: 0 } : dir.initial}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 32 }}
|
||||
>
|
||||
{children}
|
||||
</motion.aside>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,16 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SkeletonProps {
|
||||
className?: string;
|
||||
rounded?: boolean;
|
||||
}
|
||||
|
||||
export function Skeleton({ className, rounded }: SkeletonProps) {
|
||||
export function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"animate-shimmer rounded-[var(--radius-r-control)]",
|
||||
"bg-[var(--color-surface-2)]",
|
||||
rounded && "rounded-full",
|
||||
"rounded-[10px] bg-white/[0.06] animate-shimmer relative overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Minimal Slot — merges its props onto its single child element (Radix-style
|
||||
* `asChild`). Enough for wrapping <Link>/<a> in a Button.
|
||||
*/
|
||||
export const Slot = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode }>(
|
||||
({ children, ...slotProps }, ref) => {
|
||||
if (!React.isValidElement(children)) return null;
|
||||
const childProps = children.props as Record<string, unknown>;
|
||||
const merged: Record<string, unknown> = { ...childProps, ...slotProps, ref };
|
||||
// Merge className
|
||||
if (slotProps.className || childProps.className) {
|
||||
merged.className = [childProps.className, slotProps.className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
// Merge style
|
||||
if (slotProps.style || childProps.style) {
|
||||
merged.style = { ...(childProps.style as object), ...(slotProps.style as object) };
|
||||
}
|
||||
return React.cloneElement(children, merged);
|
||||
},
|
||||
);
|
||||
Slot.displayName = "Slot";
|
||||
@@ -1,147 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CheckCircle2, AlertTriangle, Info, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ToastTone = "signal" | "amber" | "vermilion" | "neutral";
|
||||
|
||||
interface ToastItem {
|
||||
type ToastTone = "signal" | "vermilion" | "neutral";
|
||||
interface Toast {
|
||||
id: number;
|
||||
title?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
tone: ToastTone;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toast: (t: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
tone?: ToastTone;
|
||||
}) => void;
|
||||
let nextId = 1;
|
||||
const listeners = new Set<(t: Toast[]) => void>();
|
||||
let store: Toast[] = [];
|
||||
|
||||
function emit() {
|
||||
store = [...store];
|
||||
listeners.forEach((l) => l(store));
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
export function toast(t: {
|
||||
title: string;
|
||||
description?: string;
|
||||
tone?: ToastTone;
|
||||
}) {
|
||||
const item: Toast = { id: nextId++, tone: t.tone ?? "neutral", ...t };
|
||||
store = [...store, item];
|
||||
listeners.forEach((l) => l(store));
|
||||
setTimeout(() => {
|
||||
store = store.filter((x) => x.id !== item.id);
|
||||
emit();
|
||||
}, 4200);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) {
|
||||
return {
|
||||
toast: (_: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
tone?: ToastTone;
|
||||
}) => {},
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
return { toast };
|
||||
}
|
||||
|
||||
const toneBar: Record<ToastTone, string> = {
|
||||
signal: "bg-[var(--color-signal)]",
|
||||
amber: "bg-[var(--color-amber)]",
|
||||
vermilion: "bg-[var(--color-vermilion)]",
|
||||
neutral: "bg-[var(--color-ink-soft)]",
|
||||
const icons = {
|
||||
signal: CheckCircle2,
|
||||
vermilion: AlertTriangle,
|
||||
neutral: Info,
|
||||
};
|
||||
|
||||
export interface ToasterProps {
|
||||
position?: "bottom-right" | "bottom-left" | "top-right" | "top-left";
|
||||
}
|
||||
|
||||
export function Toaster({ position = "bottom-right" }: ToasterProps) {
|
||||
const [items, setItems] = useState<ToastItem[]>([]);
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
const toast = useCallback(
|
||||
(t: { title?: string; description?: string; tone?: ToastTone }) => {
|
||||
const id = Date.now() + Math.random();
|
||||
const item: ToastItem = {
|
||||
id,
|
||||
tone: t.tone ?? "neutral",
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
};
|
||||
setItems((prev) => [...prev, item]);
|
||||
setTimeout(() => {
|
||||
setItems((prev) => prev.filter((i) => i.id !== id));
|
||||
}, 4500);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
export function Toaster({ position = "bottom-right" }: { position?: string }) {
|
||||
const [items, setItems] = useState<Toast[]>([]);
|
||||
useEffect(() => {
|
||||
// expose a no-op provider only; actual provider wraps below
|
||||
listeners.add(setItems);
|
||||
setItems(store);
|
||||
return () => {
|
||||
listeners.delete(setItems);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const posClass =
|
||||
const pos =
|
||||
position === "bottom-right"
|
||||
? "bottom-4 right-4"
|
||||
: position === "bottom-left"
|
||||
? "bottom-4 left-4"
|
||||
: position === "top-right"
|
||||
? "top-4 right-4"
|
||||
: "top-4 left-4";
|
||||
: position === "top-right"
|
||||
? "top-4 right-4"
|
||||
: "bottom-4 left-1/2 -translate-x-1/2";
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast }}>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed z-[60] flex w-[min(92vw,360px)] flex-col gap-2",
|
||||
posClass,
|
||||
)}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{items.map((it) => (
|
||||
<motion.div
|
||||
key={it.id}
|
||||
layout
|
||||
initial={
|
||||
reduce ? { opacity: 0 } : { opacity: 0, x: 40, scale: 0.96 }
|
||||
}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={
|
||||
reduce ? { opacity: 0 } : { opacity: 0, x: 40, scale: 0.96 }
|
||||
}
|
||||
transition={{ type: "spring", stiffness: 360, damping: 30 }}
|
||||
className="surface-2 relative flex gap-3 overflow-hidden p-3 pr-9 shadow-xl"
|
||||
<div className={cn("pointer-events-none fixed z-[100] flex w-[min(92vw,360px)] flex-col gap-2", pos)}>
|
||||
{items.map((t) => {
|
||||
const Icon = icons[t.tone];
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className="glass pointer-events-auto flex items-start gap-3 p-3.5"
|
||||
style={{ animation: "fade-up 0.18s ease" }}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"mt-0.5 size-4 shrink-0",
|
||||
t.tone === "signal" && "text-signal",
|
||||
t.tone === "vermilion" && "text-vermilion",
|
||||
t.tone === "neutral" && "text-ink-soft",
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-ink">{t.title}</div>
|
||||
{t.description && (
|
||||
<div className="mt-0.5 text-xs text-ink-soft">{t.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
store = store.filter((x) => x.id !== t.id);
|
||||
emit();
|
||||
}}
|
||||
className="text-ink-faint hover:text-ink"
|
||||
>
|
||||
<span
|
||||
className={cn("w-1 shrink-0 rounded-full", toneBar[it.tone])}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
{it.title && (
|
||||
<div className="text-sm font-semibold text-[var(--color-ink)]">
|
||||
{it.title}
|
||||
</div>
|
||||
)}
|
||||
{it.description && (
|
||||
<div className="text-xs text-[var(--color-ink-soft)]">
|
||||
{it.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setItems((prev) => prev.filter((i) => i.id !== it.id))
|
||||
}
|
||||
className="absolute right-2 top-2 text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface TooltipProps {
|
||||
content: ReactNode;
|
||||
children: ReactNode;
|
||||
side?: "top" | "bottom" | "left" | "right";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sidePos: Record<NonNullable<TooltipProps["side"]>, string> = {
|
||||
top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
|
||||
bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
|
||||
left: "right-full top-1/2 -translate-y-1/2 mr-2",
|
||||
right: "left-full top-1/2 -translate-y-1/2 ml-2",
|
||||
};
|
||||
|
||||
/** Lightweight hover/focus tooltip. */
|
||||
export function Tooltip({
|
||||
content,
|
||||
label,
|
||||
children,
|
||||
side = "top",
|
||||
className,
|
||||
}: TooltipProps) {
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
side?: "top" | "bottom";
|
||||
}) {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: tooltip wrapper — reveals on hover AND focus (keyboard-accessible via focus handlers above)
|
||||
<span
|
||||
className="relative inline-flex"
|
||||
onMouseEnter={() => setShow(true)}
|
||||
@@ -34,18 +23,18 @@ export function Tooltip({
|
||||
onBlur={() => setShow(false)}
|
||||
>
|
||||
{children}
|
||||
<span
|
||||
role="tooltip"
|
||||
className={cn(
|
||||
"pointer-events-none absolute z-50 whitespace-nowrap rounded-[var(--radius-r-control)] px-2.5 py-1 text-xs font-medium",
|
||||
"bg-[var(--color-ink)] text-[var(--color-canvas)] opacity-0 transition-opacity duration-150",
|
||||
sidePos[side],
|
||||
show && "opacity-100",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
{show && (
|
||||
<span
|
||||
role="tooltip"
|
||||
className={cn(
|
||||
"glass pointer-events-none absolute left-1/2 z-50 -translate-x-1/2 whitespace-nowrap px-2.5 py-1 text-xs text-ink",
|
||||
side === "top" ? "bottom-[calc(100%+6px)]" : "top-[calc(100%+6px)]",
|
||||
)}
|
||||
style={{ animation: "fade-up 0.12s ease" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* RecordingPlayer — canonical single-recording row component.
|
||||
*/
|
||||
import { Delete, Download, Play } from "lucide-react";
|
||||
import { Waveform } from "@/components/charts/waveform";
|
||||
import { Avatar } from "@/components/primitives/avatar";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { Button } from "@/components/primitives/button";
|
||||
import type { VoiceRecording } from "@/lib/types";
|
||||
|
||||
export interface RecordingPlayerProps {
|
||||
recording: VoiceRecording;
|
||||
onSelect?: (rec: VoiceRecording) => void;
|
||||
onDelete?: (rec: VoiceRecording) => void;
|
||||
deleting?: boolean;
|
||||
}
|
||||
|
||||
export function RecordingPlayer({
|
||||
recording,
|
||||
onSelect,
|
||||
onDelete,
|
||||
deleting,
|
||||
}: RecordingPlayerProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-[var(--radius-r)] bg-[var(--color-surface)] p-3">
|
||||
<Waveform
|
||||
seed={recording.id}
|
||||
bars={16}
|
||||
height={36}
|
||||
className="w-20 shrink-0"
|
||||
/>
|
||||
<Avatar name={recording.username} src={recording.avatar_url} size={32} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium">
|
||||
{recording.username ?? "unknown"}
|
||||
</span>
|
||||
<Badge tone="neutral">
|
||||
.{recording.filename.split(".").pop() ?? "mp3"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mono text-xs text-[var(--color-ink-soft)]">
|
||||
{(recording.size_bytes / 1024).toFixed(1)} KB
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{recording.download_url && onSelect && (
|
||||
<Button size="sm" variant="ghost" onClick={() => onSelect(recording)}>
|
||||
<Play className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{recording.download_url && (
|
||||
<a
|
||||
href={recording.download_url}
|
||||
download={recording.filename}
|
||||
className="flex size-8 items-center justify-center rounded-[var(--radius-r-control)] text-[var(--color-ink-soft)] hover:bg-[var(--color-surface-2)]"
|
||||
aria-label="Download"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</a>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={deleting}
|
||||
onClick={() => onDelete(recording)}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<Delete className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Inbox } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: LucideIcon;
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon = Inbox,
|
||||
title = "No data yet",
|
||||
description = "Nothing to display here yet.",
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"surface flex flex-col items-center gap-2 py-12 text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-8 text-[var(--color-ink-soft)]" />
|
||||
<p className="text-sm font-medium text-[var(--color-ink)]">{title}</p>
|
||||
<p className="text-xs text-[var(--color-ink-soft)]">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { Component, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
this.props.fallback || (
|
||||
<div className={cn("surface flex flex-col items-center gap-2 py-8")}>
|
||||
<AlertCircle className="size-6 text-[var(--color-vermilion)]" />
|
||||
<p className="text-sm text-[var(--color-ink)]">
|
||||
{this.state.error?.message || "Something went wrong"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => this.setState({ hasError: false })}
|
||||
className="flex items-center gap-1 text-xs text-[var(--color-signal)] hover:opacity-80 transition-colors"
|
||||
>
|
||||
<RefreshCw className="size-3" /> Try again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/primitives/button";
|
||||
|
||||
interface ErrorStateProps {
|
||||
message: string;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export function ErrorState({ message, onRetry }: ErrorStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<AlertCircle className="size-10 text-[var(--color-vermilion)] mb-3" />
|
||||
<p className="text-sm text-[var(--color-ink-soft)] mb-4 max-w-sm">
|
||||
{message}
|
||||
</p>
|
||||
{onRetry && (
|
||||
<Button variant="outline" onClick={onRetry}>
|
||||
<RefreshCw className="size-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useGuilds, useTextChannels, useVoiceChannels } from "@/hooks";
|
||||
import { Select, type SelectOption } from "@/components/primitives";
|
||||
import type { Guild } from "@/lib/types";
|
||||
|
||||
export function GuildChannelPicker({
|
||||
mode,
|
||||
guildsInitial,
|
||||
guildId,
|
||||
channelId,
|
||||
onChange,
|
||||
}: {
|
||||
mode: "voice" | "text";
|
||||
guildsInitial?: Guild[];
|
||||
guildId: string | null;
|
||||
channelId: string | null;
|
||||
onChange: (guildId: string, channelId: string | null) => void;
|
||||
}) {
|
||||
const { data: guilds } = useGuilds(guildsInitial);
|
||||
// Call both hooks unconditionally (rules of hooks); select by mode.
|
||||
const voiceChannels = useVoiceChannels(guildId ?? "");
|
||||
const textChannels = useTextChannels(guildId ?? "");
|
||||
const channels = mode === "voice" ? voiceChannels.data : textChannels.data;
|
||||
|
||||
const [g, setG] = useState(guildId);
|
||||
const [c, setC] = useState(channelId);
|
||||
|
||||
useEffect(() => setG(guildId), [guildId]);
|
||||
useEffect(() => setC(channelId), [channelId]);
|
||||
|
||||
const guildOpts: SelectOption[] = (guilds ?? []).map((x) => ({
|
||||
value: x.id,
|
||||
label: x.name,
|
||||
}));
|
||||
const channelOpts: SelectOption[] = (channels ?? []).map((x) => ({
|
||||
value: x.id,
|
||||
label: x.name,
|
||||
hint: x.type,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={g}
|
||||
onChange={(v) => {
|
||||
setG(v);
|
||||
setC(null);
|
||||
onChange(v, null);
|
||||
}}
|
||||
options={guildOpts}
|
||||
placeholder="Guild"
|
||||
size="sm"
|
||||
className="w-44"
|
||||
/>
|
||||
<Select
|
||||
value={c}
|
||||
onChange={(v) => {
|
||||
setC(v);
|
||||
if (g) onChange(g, v);
|
||||
}}
|
||||
options={channelOpts}
|
||||
placeholder={mode === "voice" ? "Voice channel" : "Text channel"}
|
||||
size="sm"
|
||||
className="w-52"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { Button } from "@/components/primitives/button";
|
||||
import { Select } from "@/components/primitives/select";
|
||||
import { Skeleton } from "@/components/primitives/skeleton";
|
||||
import { useConfig, useGuilds } from "@/hooks";
|
||||
|
||||
export interface GuildSelectorProps {
|
||||
/** Currently selected guild ID */
|
||||
value: string;
|
||||
/** Called when user selects a different guild */
|
||||
onChange: (guildId: string) => void;
|
||||
/** If true, the bar is hidden when there's only one guild */
|
||||
autoHide?: boolean;
|
||||
}
|
||||
|
||||
export function GuildSelector({
|
||||
value,
|
||||
onChange,
|
||||
autoHide = true,
|
||||
}: GuildSelectorProps) {
|
||||
const { data: guilds = [], isLoading, error, mutate: refetch } = useGuilds();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const initDone = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (value || guilds.length === 0 || initDone.current) return;
|
||||
initDone.current = true;
|
||||
const preferred = config?.monitorGuildId ?? guilds[0].id;
|
||||
if (preferred) onChange(preferred);
|
||||
}, [value, guilds, config, onChange]);
|
||||
|
||||
if (autoHide && guilds.length <= 1 && !isLoading && !error) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-[var(--radius-r)] bg-[var(--color-surface)] p-3">
|
||||
<Skeleton className="h-8 w-36" />
|
||||
<Skeleton rounded className="h-8 w-8" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-[var(--radius-r)] bg-[var(--color-vermilion)]/10 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="size-4 text-[var(--color-vermilion)] shrink-0" />
|
||||
<p className="text-sm text-[var(--color-ink-soft)]">
|
||||
Could not load guilds: {error?.message ?? "Failed to load"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
<RefreshCw className="size-3 mr-1" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (guilds.length === 0) {
|
||||
return (
|
||||
<div className="rounded-[var(--radius-r)] bg-[var(--color-amber)]/10 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="size-4 text-[var(--color-amber)] shrink-0" />
|
||||
<p className="text-sm text-[var(--color-ink-soft)]">
|
||||
No guilds available. Make sure the Discord gateway is connected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-[var(--radius-r)] bg-[var(--color-surface)] p-3">
|
||||
<Badge tone="neutral" className="shrink-0 text-xs font-normal">
|
||||
Guild
|
||||
</Badge>
|
||||
<Select
|
||||
value={value}
|
||||
onChange={(e) => e.target.value && onChange(e.target.value)}
|
||||
className="h-10 w-full max-w-sm"
|
||||
>
|
||||
{guilds.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export { EmptyState } from "./empty-state";
|
||||
export { ErrorBoundary } from "./error-boundary";
|
||||
export { ErrorState } from "./error-state";
|
||||
export { LoadingSkeleton } from "./loading-skeleton";
|
||||
export { SectionHeader, MetricTile } from "./section";
|
||||
export { EmptyState, ErrorState, LoadingState } from "./states";
|
||||
export { GuildChannelPicker } from "./guild-picker";
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface LoadingSkeletonProps {
|
||||
count?: number;
|
||||
height?: string;
|
||||
width?: string;
|
||||
columns?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingSkeleton({
|
||||
count = 4,
|
||||
height = "h-24",
|
||||
width,
|
||||
columns,
|
||||
className,
|
||||
}: LoadingSkeletonProps) {
|
||||
const items = Array.from({ length: count }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"surface-2 overflow-hidden",
|
||||
height,
|
||||
width,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="w-full h-full animate-shimmer" />
|
||||
</div>
|
||||
));
|
||||
|
||||
if (columns) {
|
||||
return (
|
||||
<div className={`grid grid-cols-1 md:grid-cols-${columns} gap-3`}>
|
||||
{items}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="space-y-2">{items}</div>;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function SectionHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
action,
|
||||
className,
|
||||
}: {
|
||||
eyebrow?: string;
|
||||
title: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("mb-3 flex items-end justify-between gap-3", className)}>
|
||||
<div className="min-w-0">
|
||||
{eyebrow && <div className="eyebrow mb-1">{eyebrow}</div>}
|
||||
<h2 className="display text-xl text-ink">{title}</h2>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricTile({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
tone = "neutral",
|
||||
spark,
|
||||
icon,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
hint?: React.ReactNode;
|
||||
tone?: "neutral" | "signal" | "amber" | "vermilion";
|
||||
spark?: number[];
|
||||
icon?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const toneColor =
|
||||
tone === "vermilion"
|
||||
? "var(--color-vermilion)"
|
||||
: tone === "amber"
|
||||
? "var(--color-amber)"
|
||||
: tone === "signal"
|
||||
? "var(--color-signal)"
|
||||
: "var(--color-ink)";
|
||||
return (
|
||||
<div className={cn("glass p-4", className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="eyebrow">{label}</div>
|
||||
{icon && <span className="text-ink-faint">{icon}</span>}
|
||||
</div>
|
||||
<div
|
||||
className="display mt-1 text-[1.9rem] leading-none"
|
||||
style={{ color: tone === "neutral" ? undefined : toneColor }}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
{hint && <div className="mono mt-1 text-[0.68rem] text-ink-faint">{hint}</div>}
|
||||
{spark && spark.length > 1 && (
|
||||
<div className="mt-2">
|
||||
<div
|
||||
className="h-1 w-full overflow-hidden rounded-full"
|
||||
style={{ background: "oklch(1 0 0 / 0.08)" }}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${Math.min(100, (spark[spark.length - 1] / (Math.max(...spark) || 1)) * 100)}%`, background: toneColor, opacity: 0.7 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { AlertTriangle, Inbox, Loader2 } from "lucide-react";
|
||||
import { Spinner } from "@/components/primitives";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function EmptyState({
|
||||
title = "Nothing here yet",
|
||||
description,
|
||||
icon,
|
||||
className,
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center justify-center gap-2 py-12 text-center", className)}>
|
||||
<div className="text-ink-faint">{icon ?? <Inbox className="size-7" />}</div>
|
||||
<div className="text-sm font-medium text-ink-soft">{title}</div>
|
||||
{description && <div className="max-w-xs text-xs text-ink-faint">{description}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({
|
||||
title = "Couldn't load",
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
title?: string;
|
||||
error?: unknown;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
const msg = error instanceof Error ? error.message : String(error ?? "");
|
||||
return (
|
||||
<div className="glass flex flex-col items-center gap-3 p-8 text-center">
|
||||
<AlertTriangle className="size-7 text-vermilion" />
|
||||
<div className="text-sm font-medium text-ink">{title}</div>
|
||||
{msg && <div className="mono max-w-md break-words text-xs text-ink-faint">{msg}</div>}
|
||||
{onRetry && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="mt-1 rounded-[10px] border border-hairline px-3 py-1.5 text-xs text-ink-soft hover:text-ink hover:border-signal/40"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingState({ label = "Syncing" }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-ink-faint">
|
||||
<Spinner />
|
||||
<span className="mono text-xs uppercase tracking-wider">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Loader2 };
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NavRail } from "./nav-rail";
|
||||
import { TopBar } from "./topbar";
|
||||
|
||||
/**
|
||||
* App chrome: slim nav rail + sticky top bar + scrollable content region.
|
||||
* Sits above the fixed AmbientCanvas. Providers (Ambient + WS) are mounted in
|
||||
* the route layout so every page shares one live link and signal context.
|
||||
*/
|
||||
export function AppFrame({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-dvh w-full overflow-hidden">
|
||||
<NavRail />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<TopBar />
|
||||
<main className="min-h-0 flex-1 overflow-y-auto px-5 pb-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { AppFrame } from "./ambient-app";
|
||||
export { NavRail } from "./nav-rail";
|
||||
export { TopBar } from "./topbar";
|
||||
export { ConnectionStatus } from "./status-dot";
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { navItems, isActivePath } from "@/lib/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
} from "@/components/primitives/tooltip";
|
||||
|
||||
export function NavRail() {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<nav className="glass m-3 mr-0 flex w-[68px] flex-col items-center gap-1 rounded-[18px] py-4">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="mb-3 flex size-11 items-center justify-center rounded-[14px] bg-signal/15 text-signal glow-signal"
|
||||
aria-label="GMW home"
|
||||
>
|
||||
<span className="display text-xl">G</span>
|
||||
</Link>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
{navItems.map((item) => {
|
||||
const active = isActivePath(pathname, item.matchPrefix);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Tooltip key={item.href} label={item.label} side="bottom">
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-label={item.label}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"group relative flex size-11 items-center justify-center rounded-[13px] transition-all",
|
||||
active
|
||||
? "bg-signal/15 text-signal"
|
||||
: "text-ink-faint hover:bg-white/5 hover:text-ink-soft",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<span className="absolute -left-3 h-6 w-1 rounded-full bg-signal shadow-[0_0_12px_var(--color-signal-glow)]" />
|
||||
)}
|
||||
<Icon className="size-[18px]" strokeWidth={active ? 2.4 : 2} />
|
||||
</Link>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
import { Tooltip } from "@/components/primitives/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MAP = {
|
||||
connected: { color: "bg-signal", label: "Live link" },
|
||||
connecting: { color: "bg-amber animate-breathe", label: "Connecting…" },
|
||||
disconnected: { color: "bg-ink-faint", label: "Offline" },
|
||||
error: { color: "bg-vermilion", label: "Link error" },
|
||||
} as const;
|
||||
|
||||
export function ConnectionStatus({ compact = false }: { compact?: boolean }) {
|
||||
const { status } = useWebSocket();
|
||||
const s = MAP[status];
|
||||
return (
|
||||
<Tooltip label={s.label}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="relative flex size-2.5">
|
||||
<span className={cn("absolute inline-flex h-full w-full rounded-full opacity-60 animate-pulse-ring", s.color)} />
|
||||
<span className={cn("relative inline-flex size-2.5 rounded-full", s.color)} />
|
||||
</span>
|
||||
{!compact && (
|
||||
<span className="mono text-[0.7rem] uppercase tracking-wider text-ink-soft">
|
||||
{s.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { navItems } from "@/lib/navigation";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { ConnectionStatus } from "./status-dot";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function useActiveLabel() {
|
||||
const pathname = usePathname();
|
||||
const item = [...navItems]
|
||||
.sort((a, b) => b.matchPrefix.length - a.matchPrefix.length)
|
||||
.find((i) => pathname.startsWith(i.matchPrefix));
|
||||
return item?.label ?? "Console";
|
||||
}
|
||||
|
||||
export function TopBar() {
|
||||
const label = useActiveLabel();
|
||||
const { state } = useAmbient();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const signalTone =
|
||||
state.tone === "vermilion"
|
||||
? "text-vermilion"
|
||||
: state.tone === "amber"
|
||||
? "text-amber"
|
||||
: "text-signal";
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 flex items-center gap-4 px-5 py-3.5">
|
||||
<div className="flex min-w-0 items-baseline gap-3">
|
||||
<span className="eyebrow">GMW</span>
|
||||
<h1 className="display truncate text-[1.5rem] text-ink">{label}</h1>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<span className={cn("pill", signalTone)}>
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full bg-current animate-breathe",
|
||||
)}
|
||||
/>
|
||||
{state.label ?? "nominal"}
|
||||
</span>
|
||||
<ConnectionStatus />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open command palette"
|
||||
onClick={() => window.dispatchEvent(new Event("command-palette:open"))}
|
||||
className="hidden items-center gap-1.5 rounded-[11px] border border-hairline bg-white/5 px-2.5 py-1.5 text-xs text-ink-soft transition-colors hover:text-ink hover:border-signal/40 sm:flex"
|
||||
>
|
||||
<span className="mono text-[0.65rem]">⌘K</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle theme"
|
||||
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
|
||||
className="flex size-9 items-center justify-center rounded-[11px] border border-hairline bg-white/5 text-ink-soft transition-colors hover:text-ink hover:border-signal/40"
|
||||
>
|
||||
{mounted && theme === "light" ? (
|
||||
<Moon className="size-4" />
|
||||
) : (
|
||||
<Sun className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { OrbFieldProps } from "./orb-field";
|
||||
import type { SignalFieldProps } from "./signal-field";
|
||||
import { StaticFallback } from "./static-fallback";
|
||||
import { WebGLGuard } from "./webgl-guard";
|
||||
|
||||
const SignalFieldImpl = dynamic(
|
||||
() => import("./signal-field").then((m) => m.SignalField),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
},
|
||||
);
|
||||
|
||||
const OrbFieldImpl = dynamic(
|
||||
() => import("./orb-field").then((m) => m.OrbField),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
},
|
||||
);
|
||||
|
||||
export function SignalField(props: SignalFieldProps) {
|
||||
return (
|
||||
<WebGLGuard
|
||||
fallback={<StaticFallback variant="signal" className={props.className} />}
|
||||
>
|
||||
<SignalFieldImpl {...props} />
|
||||
</WebGLGuard>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrbField(props: OrbFieldProps) {
|
||||
return (
|
||||
<WebGLGuard
|
||||
fallback={
|
||||
<StaticFallback
|
||||
variant="orb"
|
||||
count={props.speakers.length}
|
||||
className={props.className}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<OrbFieldImpl {...props} />
|
||||
</WebGLGuard>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useThreeScene } from "./use-three-scene";
|
||||
|
||||
export interface OrbSpeaker {
|
||||
id: string;
|
||||
name: string;
|
||||
speaking: boolean;
|
||||
severity?: "none" | "low" | "medium" | "high" | "critical";
|
||||
}
|
||||
|
||||
export interface OrbFieldProps {
|
||||
speakers: OrbSpeaker[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const severityColor: Record<string, string> = {
|
||||
none: "oklch(0.82 0.18 125)",
|
||||
low: "oklch(0.80 0.15 70)",
|
||||
medium: "oklch(0.78 0.16 70)",
|
||||
high: "oklch(0.70 0.2 35)",
|
||||
critical: "oklch(0.66 0.22 25)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Voice page hero. Each speaker is a glowing orb; when speaking it rises and
|
||||
* its ring radius expands. Warm palette only.
|
||||
*/
|
||||
export function OrbField({ speakers, className }: OrbFieldProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const speakersRef = useRef(speakers);
|
||||
speakersRef.current = speakers;
|
||||
|
||||
useThreeScene(ref, {
|
||||
setup: (ctx) => {
|
||||
const group = new THREE.Group();
|
||||
ctx.scene.add(group);
|
||||
|
||||
const orbMeshes: Record<string, THREE.Mesh> = {};
|
||||
const ringMeshes: Record<string, THREE.Mesh> = {};
|
||||
|
||||
const layout = () => {
|
||||
const list = speakersRef.current;
|
||||
const n = Math.max(list.length, 1);
|
||||
list.forEach((sp, i) => {
|
||||
const angle = (i / n) * Math.PI * 2;
|
||||
const radius = n === 1 ? 0 : 2.6;
|
||||
const x = Math.cos(angle) * radius;
|
||||
const z = Math.sin(angle) * radius;
|
||||
|
||||
if (!orbMeshes[sp.id]) {
|
||||
const geo = new THREE.SphereGeometry(0.5, 32, 32);
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(severityColor[sp.severity ?? "none"]),
|
||||
emissive: new THREE.Color(severityColor[sp.severity ?? "none"]),
|
||||
emissiveIntensity: 0.6,
|
||||
roughness: 0.4,
|
||||
metalness: 0,
|
||||
});
|
||||
const orb = new THREE.Mesh(geo, mat);
|
||||
orb.position.set(x, 0, z);
|
||||
group.add(orb);
|
||||
orbMeshes[sp.id] = orb;
|
||||
|
||||
const ringGeo = new THREE.TorusGeometry(0.75, 0.03, 16, 64);
|
||||
const ringMat = new THREE.MeshBasicMaterial({
|
||||
color: new THREE.Color(severityColor[sp.severity ?? "none"]),
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
});
|
||||
const ring = new THREE.Mesh(ringGeo, ringMat);
|
||||
ring.rotation.x = Math.PI / 2;
|
||||
ring.position.set(x, 0, z);
|
||||
group.add(ring);
|
||||
ringMeshes[sp.id] = ring;
|
||||
} else {
|
||||
orbMeshes[sp.id].position.x = x;
|
||||
orbMeshes[sp.id].position.z = z;
|
||||
ringMeshes[sp.id].position.x = x;
|
||||
ringMeshes[sp.id].position.z = z;
|
||||
}
|
||||
});
|
||||
// remove orbs no longer present
|
||||
for (const id of Object.keys(orbMeshes)) {
|
||||
if (!list.find((s) => s.id === id)) {
|
||||
group.remove(orbMeshes[id]);
|
||||
(orbMeshes[id].geometry as THREE.BufferGeometry).dispose();
|
||||
group.remove(ringMeshes[id]);
|
||||
(ringMeshes[id].geometry as THREE.BufferGeometry).dispose();
|
||||
delete orbMeshes[id];
|
||||
delete ringMeshes[id];
|
||||
}
|
||||
}
|
||||
};
|
||||
layout();
|
||||
|
||||
const light = new THREE.PointLight(0xffffff, 1.2, 50);
|
||||
light.position.set(0, 4, 6);
|
||||
ctx.scene.add(light);
|
||||
const amb = new THREE.AmbientLight(0xffffff, 0.4);
|
||||
ctx.scene.add(amb);
|
||||
|
||||
(ctx as any)._layout = layout;
|
||||
(ctx as any)._orbs = orbMeshes;
|
||||
(ctx as any)._rings = ringMeshes;
|
||||
|
||||
return () => {};
|
||||
},
|
||||
onFrame: (ctx, t) => {
|
||||
const layout = (ctx as any)._layout as () => void;
|
||||
const orbs = (ctx as any)._orbs as Record<string, THREE.Mesh>;
|
||||
const rings = (ctx as any)._rings as Record<string, THREE.Mesh>;
|
||||
// relayout in case speaker set changed
|
||||
layout();
|
||||
for (const sp of speakersRef.current) {
|
||||
const orb = orbs[sp.id];
|
||||
const ring = rings[sp.id];
|
||||
if (!orb || !ring) continue;
|
||||
const targetY = sp.speaking
|
||||
? 0.6 + Math.sin(t * 4 + (sp.id.charCodeAt(0) || 1)) * 0.15
|
||||
: 0;
|
||||
orb.position.y += (targetY - orb.position.y) * 0.1;
|
||||
const ringScale = sp.speaking ? 1.25 + Math.sin(t * 5) * 0.1 : 1;
|
||||
ring.scale.setScalar(ringScale);
|
||||
(ring.material as THREE.MeshBasicMaterial).opacity = sp.speaking
|
||||
? 0.7
|
||||
: 0.3;
|
||||
}
|
||||
ctx.scene.rotation.y = t * 0.08;
|
||||
},
|
||||
});
|
||||
|
||||
return <div ref={ref} className={className} aria-hidden />;
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useThreeScene } from "./use-three-scene";
|
||||
|
||||
export interface SignalFieldProps {
|
||||
/** 0..1 — scales particle pulse speed + opacity */
|
||||
activity?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard hero. A particle field whose idle rotation + breathing pulse
|
||||
* reflects live activity. Warm signal-lime palette, additive glow, no harsh
|
||||
* white. Pointer parallax via camera lerp.
|
||||
*/
|
||||
export function SignalField({ activity = 0.4, className }: SignalFieldProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const activityRef = useRef(activity);
|
||||
activityRef.current = activity;
|
||||
|
||||
useThreeScene(ref, {
|
||||
setup: (ctx) => {
|
||||
const w = ctx.width;
|
||||
const h = ctx.height;
|
||||
const area = w * h;
|
||||
const count = Math.min(900, Math.max(220, Math.floor(area / 2000)));
|
||||
|
||||
const positions = new Float32Array(count * 3);
|
||||
const phases = new Float32Array(count);
|
||||
const radius = 4.2;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const r = radius * (0.25 + Math.random() * 0.75);
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
positions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
||||
positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta) * 0.6;
|
||||
positions[i * 3 + 2] = r * Math.cos(phi);
|
||||
phases[i] = Math.random() * Math.PI * 2;
|
||||
}
|
||||
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
|
||||
const mat = new THREE.PointsMaterial({
|
||||
color: new THREE.Color("oklch(0.82 0.18 125)"),
|
||||
size: 0.045,
|
||||
transparent: true,
|
||||
opacity: 0.85,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
const points = new THREE.Points(geo, mat);
|
||||
ctx.scene.add(points);
|
||||
|
||||
const key = { x: 0, y: 0 };
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const rect = ctx.container.getBoundingClientRect();
|
||||
key.x = ((e.clientX - rect.left) / rect.width - 0.5) * 2;
|
||||
key.y = ((e.clientY - rect.top) / rect.height - 0.5) * 2;
|
||||
};
|
||||
ctx.container.addEventListener("pointermove", onMove);
|
||||
|
||||
(ctx as any)._key = key;
|
||||
(ctx as any)._points = points;
|
||||
(ctx as any)._phases = phases;
|
||||
|
||||
return () => {
|
||||
ctx.container.removeEventListener("pointermove", onMove);
|
||||
};
|
||||
},
|
||||
onFrame: (ctx, t) => {
|
||||
const points = (ctx as any)._points as THREE.Points;
|
||||
const key = (ctx as any)._key as { x: number; y: number };
|
||||
const phases = (ctx as any)._phases as Float32Array;
|
||||
const act = activityRef.current;
|
||||
const pulse = 1 + Math.sin(t * (1.2 + act * 2.2)) * 0.08 * (0.5 + act);
|
||||
points.scale.setScalar(pulse);
|
||||
points.rotation.y = t * (0.05 + act * 0.12);
|
||||
points.rotation.x = Math.sin(t * 0.2) * 0.1;
|
||||
// parallax
|
||||
ctx.camera.position.x += (key.x * 1.4 - ctx.camera.position.x) * 0.04;
|
||||
ctx.camera.position.y += (-key.y * 1.0 - ctx.camera.position.y) * 0.04;
|
||||
ctx.camera.lookAt(0, 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
return <div ref={ref} className={className} aria-hidden />;
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
"use client";
|
||||
|
||||
export interface StaticFallbackProps {
|
||||
variant?: "signal" | "orb";
|
||||
count?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2D SVG silhouette used when WebGL is unavailable — so the hero still reads
|
||||
* as a living visual, never blank. `variant="signal"` = drifting dot grid;
|
||||
* `variant="orb"` = speaker orbs.
|
||||
*/
|
||||
export function StaticFallback({
|
||||
variant = "signal",
|
||||
count = 60,
|
||||
className,
|
||||
}: StaticFallbackProps) {
|
||||
if (variant === "orb") {
|
||||
const orbs = Array.from({ length: count > 12 ? 12 : count }, (_, i) => {
|
||||
const angle = (i / 12) * Math.PI * 2;
|
||||
const r = 60;
|
||||
return {
|
||||
x: 100 + Math.cos(angle) * r,
|
||||
y: 100 + Math.sin(angle) * r,
|
||||
d: i * 0.3,
|
||||
};
|
||||
});
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 200 200"
|
||||
className={className}
|
||||
aria-hidden
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
>
|
||||
<title>Speaker orbs</title>
|
||||
<rect width="200" height="200" fill="oklch(0.18 0.02 70)" />
|
||||
{orbs.map((o, i) => (
|
||||
<g
|
||||
key={i}
|
||||
style={{ animation: `fade-up 1.2s ${o.d}s infinite alternate` }}
|
||||
>
|
||||
<circle
|
||||
cx={o.x}
|
||||
cy={o.y}
|
||||
r={12}
|
||||
fill="oklch(0.82 0.18 125 / 0.5)"
|
||||
/>
|
||||
<circle
|
||||
cx={o.x}
|
||||
cy={o.y}
|
||||
r={20}
|
||||
fill="none"
|
||||
stroke="oklch(0.82 0.18 125 / 0.3)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const dots = Array.from({ length: count }, (_, i) => ({
|
||||
x: (i * 53) % 200,
|
||||
y: (i * 89) % 200,
|
||||
r: 1.5 + ((i * 7) % 3),
|
||||
}));
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 200 200"
|
||||
className={className}
|
||||
aria-hidden
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
>
|
||||
<title>Signal dots</title>
|
||||
<rect width="200" height="200" fill="oklch(0.18 0.02 70)" />
|
||||
{dots.map((d, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={d.x}
|
||||
cy={d.y}
|
||||
r={d.r}
|
||||
fill="oklch(0.82 0.18 125 / 0.4)"
|
||||
>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.2;0.8;0.2"
|
||||
dur="3s"
|
||||
begin={`${i * 0.05}s`}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
|
||||
export interface ThreeSceneOptions {
|
||||
/** Called once after renderer/scene/camera are created. */
|
||||
setup: (ctx: ThreeSceneCtx) => (() => void) | void;
|
||||
/** Optional per-frame callback. */
|
||||
onFrame?: (ctx: ThreeSceneCtx, t: number) => void;
|
||||
background?: string;
|
||||
}
|
||||
|
||||
export interface ThreeSceneCtx {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
scene: THREE.Scene;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
container: HTMLDivElement;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared Three.js lifecycle hook:
|
||||
* - capped DPR [1, 1.75], high-performance hint
|
||||
* - RAF loop paused when tab hidden
|
||||
* - resize observer
|
||||
* - full geometry/material/renderer dispose on unmount
|
||||
*
|
||||
* `setup` may return a cleanup fn (e.g. to remove its own listeners).
|
||||
*/
|
||||
export function useThreeScene(
|
||||
containerRef: React.RefObject<HTMLDivElement | null>,
|
||||
opts: ThreeSceneOptions,
|
||||
) {
|
||||
const optsRef = useRef(opts);
|
||||
optsRef.current = opts;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | void;
|
||||
let raf = 0;
|
||||
let ctx: ThreeSceneCtx;
|
||||
|
||||
const init = () => {
|
||||
const width = container.clientWidth || 1;
|
||||
const height = container.clientHeight || 1;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75));
|
||||
renderer.setSize(width, height);
|
||||
container.appendChild(renderer.domElement);
|
||||
renderer.domElement.style.display = "block";
|
||||
renderer.domElement.style.width = "100%";
|
||||
renderer.domElement.style.height = "100%";
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
if (optsRef.current.background) {
|
||||
scene.background = new THREE.Color(optsRef.current.background);
|
||||
}
|
||||
scene.fog = new THREE.FogExp2(0x000000, 0.06);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(55, width / height, 0.1, 100);
|
||||
camera.position.set(0, 0, 6);
|
||||
|
||||
ctx = { renderer, scene, camera, container, width, height };
|
||||
const c = optsRef.current.setup(ctx);
|
||||
if (typeof c === "function") cleanup = c;
|
||||
|
||||
const start = performance.now();
|
||||
const loop = () => {
|
||||
if (disposed || document.hidden) {
|
||||
raf = requestAnimationFrame(loop);
|
||||
return;
|
||||
}
|
||||
const t = (performance.now() - start) / 1000;
|
||||
optsRef.current.onFrame?.(ctx, t);
|
||||
renderer.render(scene, camera);
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
const w = container.clientWidth || 1;
|
||||
const h = container.clientHeight || 1;
|
||||
ctx.width = w;
|
||||
ctx.height = h;
|
||||
renderer.setSize(w, h);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
const onVis = () => {
|
||||
/* loop checks document.hidden */
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
|
||||
(ctx as any)._ro = ro;
|
||||
(ctx as any)._onVis = onVis;
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cancelAnimationFrame(raf);
|
||||
if (typeof cleanup === "function") cleanup();
|
||||
const c = ctx as any;
|
||||
if (c?._ro) c._ro.disconnect();
|
||||
if (c?._onVis) document.removeEventListener("visibilitychange", c._onVis);
|
||||
if (ctx) {
|
||||
ctx.scene.traverse((obj) => {
|
||||
const mesh = obj as THREE.Mesh;
|
||||
if (mesh.geometry) mesh.geometry.dispose?.();
|
||||
const mat = mesh.material as
|
||||
| THREE.Material
|
||||
| THREE.Material[]
|
||||
| undefined;
|
||||
if (Array.isArray(mat)) mat.forEach((m) => m.dispose());
|
||||
else mat?.dispose?.();
|
||||
});
|
||||
ctx.renderer.dispose();
|
||||
if (ctx.renderer.domElement.parentNode === container) {
|
||||
container.removeChild(ctx.renderer.domElement);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [containerRef]);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
|
||||
export interface WebGLGuardProps {
|
||||
children: ReactNode;
|
||||
fallback: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects WebGL support. If unavailable (old device / privacy browser /
|
||||
* headless without GPU), renders `fallback` instead of the 3D scene so the
|
||||
* page is never blank.
|
||||
*/
|
||||
export function WebGLGuard({ children, fallback }: WebGLGuardProps) {
|
||||
const supported = useRef<boolean | null>(null);
|
||||
|
||||
if (supported.current === null) {
|
||||
if (typeof window === "undefined") {
|
||||
supported.current = false;
|
||||
} else {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
supported.current = !!(
|
||||
window.WebGLRenderingContext &&
|
||||
(canvas.getContext("webgl2") || canvas.getContext("webgl"))
|
||||
);
|
||||
} catch {
|
||||
supported.current = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return <>{supported.current ? children : fallback}</>;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Avatar } from "@/components/primitives/avatar";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import type { ActiveSpeaker } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ActiveSpeakersPanelProps {
|
||||
speakers: ActiveSpeaker[];
|
||||
}
|
||||
|
||||
export function ActiveSpeakersPanel({ speakers }: ActiveSpeakersPanelProps) {
|
||||
const sorted = useMemo(
|
||||
() => [...speakers].sort((a, b) => Number(b.speaking) - Number(a.speaking)),
|
||||
[speakers],
|
||||
);
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return (
|
||||
<div className="surface p-5 text-center text-sm text-[var(--color-ink-soft)]">
|
||||
No speakers in range.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="surface divide-y divide-[var(--color-hairline)] overflow-hidden">
|
||||
<div className="px-4 py-2.5 text-xs font-medium uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
Active speakers ({sorted.length})
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{sorted.map((s) => (
|
||||
<div
|
||||
key={s.userId}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-2.5 transition-colors",
|
||||
s.speaking && "bg-[var(--color-signal)]/5",
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
<Avatar src={s.avatar} name={s.username} size={34} />
|
||||
{s.speaking && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full bg-[var(--color-signal)] ring-2 ring-[var(--color-canvas)]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium">{s.username}</span>
|
||||
{s.speaking ? (
|
||||
<Badge tone="signal">speaking</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">idle</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Mic, MicOff } from "lucide-react";
|
||||
import type { ActiveSpeaker } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ActivityTimelineProps {
|
||||
data?: ActiveSpeaker[];
|
||||
}
|
||||
|
||||
export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) {
|
||||
const sorted = [...data].sort((a, b) =>
|
||||
a.speaking === b.speaking
|
||||
? String(a.username).localeCompare(b.username)
|
||||
: a.speaking
|
||||
? -1
|
||||
: 1,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="surface p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xs font-semibold tracking-wide uppercase text-[var(--color-ink-soft)]">
|
||||
Voice Activity
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--color-ink-soft)] ml-auto">
|
||||
{sorted.length} speaker{sorted.length !== 1 ? "s" : ""} · live
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<MicOff className="size-8 text-[var(--color-ink-soft)] mb-2" />
|
||||
<p className="text-xs text-[var(--color-ink-soft)]">
|
||||
No speakers in the monitored voice channel.
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] text-[var(--color-ink-soft)]">
|
||||
Connect to a voice channel to see live activity here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{sorted.map((s) => (
|
||||
<div
|
||||
key={s.userId}
|
||||
className="flex items-center gap-2 rounded-[var(--radius-r-panel)] bg-[var(--color-surface-2)] px-3 py-2"
|
||||
>
|
||||
{s.speaking ? (
|
||||
<Mic className="size-3.5 text-[var(--color-signal)] shrink-0" />
|
||||
) : (
|
||||
<MicOff className="size-3.5 text-[var(--color-ink-soft)] shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-sm",
|
||||
s.speaking
|
||||
? "text-[var(--color-ink)] font-medium"
|
||||
: "text-[var(--color-ink-soft)]",
|
||||
)}
|
||||
>
|
||||
{s.username}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto shrink-0 text-[9px] font-semibold uppercase tracking-widest",
|
||||
s.speaking
|
||||
? "text-[var(--color-signal)]"
|
||||
: "text-[var(--color-ink-soft)]",
|
||||
)}
|
||||
>
|
||||
{s.speaking ? "Speaking" : "Listening"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user