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() {
|
export default function AnalysisPage() {
|
||||||
return (
|
return <AnalysisView />;
|
||||||
<div className="space-y-5" style={{ animation: "fade-up 0.4s ease both" }}>
|
|
||||||
<SearchPanel />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { getActivity, getDashboardStats } from "@/lib/api/server";
|
||||||
import DashboardView from "./view";
|
import { DashboardView } from "./view";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const [stats, activity] = await Promise.allSettled([
|
let stats = undefined;
|
||||||
getDashboardStats().catch(() => undefined),
|
let activity = undefined;
|
||||||
getActivity(14).catch(() => undefined),
|
try {
|
||||||
]);
|
[stats, activity] = await Promise.all([getDashboardStats(), getActivity(14)]);
|
||||||
|
} catch {
|
||||||
return (
|
// Backend unavailable — client hooks will surface the error state.
|
||||||
<DashboardView
|
|
||||||
initialStats={
|
|
||||||
stats.status === "fulfilled" && stats.value ? stats.value : undefined
|
|
||||||
}
|
}
|
||||||
initialActivity={
|
return <DashboardView initialStats={stats} initialActivity={activity} />;
|
||||||
activity.status === "fulfilled" && activity.value
|
|
||||||
? activity.value
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,144 +1,228 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
import { useEffect } from "react";
|
||||||
* Dashboard — Ambient Field layout.
|
import {
|
||||||
*
|
Activity,
|
||||||
* No top bar. No side rail. No grid. No panels.
|
Flag,
|
||||||
*
|
MessageSquare,
|
||||||
* A full-bleed WebGL haze (AmbientField) is the page. Content floats over it:
|
Mic,
|
||||||
* a giant headline bottom-left, a live metric cluster top-right, a drifting
|
Radio,
|
||||||
* event ribbon mid-screen, a command whispher at the very bottom. Whitespace
|
ShieldAlert,
|
||||||
* is the layout — density comes from data, not chrome.
|
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";
|
function deriveSignal(stats?: DashboardStats) {
|
||||||
import { AmbientField } from "@/components/ambient/ambient-field";
|
if (!stats) return { tone: "signal" as const, label: "nominal" };
|
||||||
import { DashCommandLine } from "@/components/command/dash-command-line";
|
const total = stats.total_flagged + stats.total_clean || 1;
|
||||||
import type { DashboardActivity, DashboardStats } from "@/lib/types";
|
const ratio = stats.total_flagged / total;
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
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,
|
initialStats,
|
||||||
initialActivity,
|
initialActivity,
|
||||||
}: {
|
}: {
|
||||||
initialStats?: DashboardStats;
|
initialStats?: DashboardStats;
|
||||||
initialActivity?: DashboardActivity;
|
initialActivity?: Awaited<ReturnType<typeof useActivity>>["data"];
|
||||||
}) {
|
}) {
|
||||||
const ws = useWebSocket();
|
const { data: stats, isLoading, error } = useStats(initialStats);
|
||||||
const [signal, setSignal] = useState<
|
const { data: activity } = useActivity(14, initialActivity as never);
|
||||||
"signal" | "amber" | "vermilion" | "neutral"
|
const { data: reactors } = useTopReactors();
|
||||||
>("signal");
|
const { data: reactions } = useTopReactions();
|
||||||
const [load, setLoad] = useState(0.3);
|
const ambient = useAmbient();
|
||||||
|
|
||||||
const total = initialStats?.total_messages ?? 0;
|
useEffect(() => {
|
||||||
const clean = initialStats?.total_clean ?? 0;
|
const s = deriveSignal(stats);
|
||||||
const flagged = initialStats?.total_flagged ?? 0;
|
ambient.set(s.tone, 0.3 + Math.min(0.5, (stats?.today_flagged ?? 0) / 50), s.label);
|
||||||
const warned = initialStats?.total_warned ?? 0;
|
}, [stats, ambient]);
|
||||||
const ratio = ((clean / (clean + flagged + warned || 1)) * 100).toFixed(1);
|
|
||||||
|
|
||||||
const _subscribe = useCallback(
|
if (error && !stats) return <ErrorState error={error} />;
|
||||||
(handler: (e: { severity: string; ts: number }) => void) => {
|
if (!stats && isLoading) return <LoadingState label="Reading grid" />;
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
const seedEvents = useMemo(() => {
|
const s = stats!;
|
||||||
if (!initialActivity) return [];
|
const total = s.total_flagged + s.total_clean || 1;
|
||||||
return initialActivity.daily.slice(-10).flatMap((d) =>
|
const cleanRatio = s.total_clean / total;
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-[calc(100svh-3rem)] w-full overflow-hidden bg-[var(--color-canvas)]">
|
<div className="space-y-5">
|
||||||
<AmbientField load={load} signal={signal} />
|
{/* Hero */}
|
||||||
|
<GlassPanel glow className="relative overflow-hidden">
|
||||||
{/* Metric cluster — top right, floating, no container */}
|
<div className="scan-line absolute inset-x-0 top-0" />
|
||||||
<div className="absolute right-6 top-6 flex flex-col items-end gap-1 font-mono text-right">
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[var(--color-ink-soft)]">
|
<div>
|
||||||
watched
|
<div className="eyebrow mb-2">GMW · Operations Grid</div>
|
||||||
</span>
|
<h2 className="display text-[2.6rem] leading-none text-ink glow-signal">
|
||||||
<span className="display text-5xl font-medium tabular-nums leading-none text-[var(--color-ink)]">
|
Ambient Field
|
||||||
{total.toLocaleString()}
|
</h2>
|
||||||
</span>
|
<p className="mt-2 max-w-md text-sm text-ink-soft">
|
||||||
<div className="mt-2 flex gap-4 text-[12px]">
|
Real-time moderation, voice & media presence across the monitored
|
||||||
<span className="text-[var(--color-signal)]">
|
guild. {formatNumber(s.total_messages)} messages captured.
|
||||||
{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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-ink-soft">
|
||||||
|
<Radio className="size-4 text-signal animate-breathe" />
|
||||||
|
<span className="mono text-xs uppercase tracking-wider">
|
||||||
|
{deriveSignal(s).label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Event ribbon — mid screen, drifting row */}
|
<div className="mt-5 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<div className="absolute left-1/2 top-1/2 w-[min(90vw,900px)] -translate-x-1/2 -translate-y-1/2">
|
<MetricTile label="Messages" value={formatNumber(s.total_messages)} tone="signal" icon={<MessageSquare className="size-3.5" />} />
|
||||||
<div className="flex flex-col gap-1 font-mono text-[11px]">
|
<MetricTile label="Flagged" value={formatNumber(s.total_flagged)} tone={s.total_flagged > 0 ? "vermilion" : "neutral"} hint={`${s.today_flagged} today`} />
|
||||||
{seedEvents.slice(0, 6).map((e) => (
|
<MetricTile label="Active 24h" value={formatNumber(s.active_users_24h)} tone="signal" icon={<Users className="size-3.5" />} />
|
||||||
<div
|
<MetricTile label="Voice clips" value={formatNumber(s.total_voice_recordings)} icon={<Mic className="size-3.5" />} />
|
||||||
key={e.id}
|
</div>
|
||||||
className="flex items-center gap-2 opacity-70"
|
</GlassPanel>
|
||||||
data-severity={e.severity}
|
|
||||||
>
|
{/* Activity */}
|
||||||
<span
|
<GlassPanel>
|
||||||
className="inline-block size-1.5 rounded-full"
|
<SectionHeader
|
||||||
style={{
|
eyebrow="14-day signal"
|
||||||
background:
|
title={
|
||||||
e.severity === "vermilion"
|
<span className="flex items-center gap-2">
|
||||||
? "var(--color-vermilion)"
|
<Activity className="size-4 text-signal" /> Activity & moderation
|
||||||
: "var(--color-signal)",
|
</span>
|
||||||
}}
|
}
|
||||||
|
action={
|
||||||
|
<div className="flex items-center gap-3 text-xs text-ink-soft">
|
||||||
|
<span className="flex items-center gap-1.5"><span className="size-2 rounded-full bg-signal" /> messages</span>
|
||||||
|
<span className="flex items-center gap-1.5"><span className="size-2 rounded-full bg-vermilion" /> flagged</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<span className="text-[var(--color-ink-soft)] tabular-nums">
|
{activity ? (
|
||||||
{new Date(e.ts).toLocaleTimeString()}
|
<AreaActivity daily={activity.daily} />
|
||||||
</span>
|
) : (
|
||||||
<span className="truncate text-[var(--color-ink)]">
|
<LoadingState label="streaming" />
|
||||||
{e.excerpt}
|
)}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{(reactors ?? []).length === 0 && <EmptyHint />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</GlassPanel>
|
||||||
|
|
||||||
{/* Command whisper — very bottom, minimal */}
|
<GlassPanel>
|
||||||
<div className="absolute inset-x-0 bottom-0">
|
<SectionHeader eyebrow="culture" title="Top reactions" />
|
||||||
<DashCommandLine />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Row({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
{icon}
|
||||||
|
<span className="flex-1 text-ink-soft">{label}</span>
|
||||||
|
<span className="mono text-ink">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Mini({ label, value, tone }: { label: string; value: number; tone: "signal" | "amber" | "vermilion" }) {
|
||||||
|
const color = tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal";
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={`display text-xl ${color}`}>{value}</div>
|
||||||
|
<div className="eyebrow mt-0.5">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyHint() {
|
||||||
|
return <div className="py-6 text-center text-xs text-ink-faint">Awaiting data…</div>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,133 +1,19 @@
|
|||||||
"use client";
|
import { AmbientProvider } from "@/components/ambient/ambient-context";
|
||||||
|
import { WsProvider } from "@/lib/ws/context";
|
||||||
import { usePathname } from "next/navigation";
|
import { AppFrame } from "@/components/shell";
|
||||||
import { Suspense, useEffect, useState } from "react";
|
import { Chatbot } from "@/components/chatbot/chatbot";
|
||||||
import { SWRConfig } from "swr";
|
import { CommandPalette } from "@/components/command/command-palette";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
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/";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SWRConfig
|
<AmbientProvider>
|
||||||
value={{
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
dedupingInterval: 10_000,
|
|
||||||
shouldRetryOnError: (err) =>
|
|
||||||
(err as { statusCode?: number })?.statusCode !== 404,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<WsProvider>
|
<WsProvider>
|
||||||
<MediaPlayerProvider>
|
<AppFrame>{children}</AppFrame>
|
||||||
<ChatbotProvider>
|
<Chatbot />
|
||||||
<ChatbotGuildSync guildId={guildId} />
|
<CommandPalette />
|
||||||
<ChatbotExpressionSync />
|
|
||||||
{isConsole ? (
|
|
||||||
<AmbientShell>{children}</AmbientShell>
|
|
||||||
) : (
|
|
||||||
<ClassicShell guildId={guildId} setGuildId={setGuildId}>
|
|
||||||
{children}
|
|
||||||
</ClassicShell>
|
|
||||||
)}
|
|
||||||
<MiniPlayer />
|
|
||||||
<ChatbotContainer />
|
|
||||||
</ChatbotProvider>
|
|
||||||
</MediaPlayerProvider>
|
|
||||||
</WsProvider>
|
</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 { getMediaStatus } from "@/lib/api/server";
|
||||||
import MediaView from "./view";
|
import { MediaView } from "./view";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function MediaPage() {
|
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} />;
|
return <MediaView initialStatus={status} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,185 +1,149 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Pause, Play, Repeat2, SkipForward, Square, Volume2 } from "lucide-react";
|
import { useEffect, useState } from "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 {
|
import {
|
||||||
useMediaLoop,
|
ListMusic,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
Repeat,
|
||||||
|
SkipForward,
|
||||||
|
Square,
|
||||||
|
Radio,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
import {
|
||||||
|
useMediaState,
|
||||||
useMediaQueue,
|
useMediaQueue,
|
||||||
useMediaSkip,
|
useMediaSkip,
|
||||||
useMediaState,
|
|
||||||
useMediaStop,
|
useMediaStop,
|
||||||
|
useMediaLoop,
|
||||||
useMediaWsSync,
|
useMediaWsSync,
|
||||||
} from "@/hooks";
|
} 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 type { MediaState } from "@/lib/types";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
|
||||||
|
|
||||||
export default function MediaView({
|
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||||
initialStatus,
|
|
||||||
}: {
|
|
||||||
initialStatus?: MediaState;
|
|
||||||
}) {
|
|
||||||
const ws = useWebSocket();
|
const ws = useWebSocket();
|
||||||
const { data: state } = useMediaState(initialStatus);
|
const { data: media, isLoading, error } = useMediaState(initialStatus);
|
||||||
const queueMut = useMediaQueue();
|
const queue = useMediaQueue();
|
||||||
const skip = useMediaSkip();
|
const skip = useMediaSkip();
|
||||||
const stop = useMediaStop();
|
const stop = useMediaStop();
|
||||||
const loopMut = useMediaLoop();
|
const loop = useMediaLoop();
|
||||||
useMediaWsSync(ws);
|
useMediaWsSync(ws);
|
||||||
|
const ambient = useAmbient();
|
||||||
|
|
||||||
const current = state?.current;
|
const [url, setUrl] = useState("");
|
||||||
const playing = state?.playing ?? false;
|
|
||||||
const queue = state?.queue ?? [];
|
|
||||||
const loop = state?.loop ?? false;
|
|
||||||
|
|
||||||
const duration = current?.durationMs ?? 0;
|
const playing = media?.playing ?? false;
|
||||||
const [queueUrl, setQueueUrl] = useState("");
|
const current = media?.current ?? null;
|
||||||
const [screenMode, setScreenMode] = useState(false);
|
const queueList = media?.queue ?? [];
|
||||||
|
|
||||||
const handleQueue = () => {
|
const tone = playing ? "signal" : queueList.length ? "amber" : "signal";
|
||||||
if (!queueUrl.trim()) return;
|
useEffect(() => {
|
||||||
queueMut.mutate({ url: queueUrl.trim(), mode: screenMode ? "screen" : "music" });
|
ambient.set(tone, playing ? 0.5 : 0.25, playing ? "now playing" : "media idle");
|
||||||
setQueueUrl("");
|
}, [tone, playing, ambient]);
|
||||||
|
|
||||||
|
const onPlay = async () => {
|
||||||
|
const u = url.trim();
|
||||||
|
if (!u) {
|
||||||
|
toast({ title: "Enter a media URL", tone: "vermilion" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await queue.mutateAsync({ url: u, mode: "music" });
|
||||||
|
setUrl("");
|
||||||
|
toast({ title: "Queued", tone: "signal" });
|
||||||
|
} catch (e) {
|
||||||
|
toast({ title: "Queue failed", description: String(e), tone: "vermilion" });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (error && !media) return <ErrorState error={error} />;
|
||||||
|
if (!media && isLoading) return <LoadingState label="Reading deck" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="space-y-5">
|
||||||
{/* URL queue input */}
|
<GlassPanel glow className="relative overflow-hidden">
|
||||||
<div className="flex gap-2">
|
<div className="scan-line absolute inset-x-0 top-0" />
|
||||||
<Input
|
<div className="flex flex-col gap-5 sm:flex-row sm:items-center">
|
||||||
placeholder="Queue a URL (YouTube, audio file…)"
|
<div
|
||||||
value={queueUrl}
|
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"}`}
|
||||||
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
|
<div className="flex size-28 items-center justify-center rounded-full bg-canvas/60">
|
||||||
</Button>
|
<ListMusic className="size-10 text-signal" />
|
||||||
<Button
|
</div>
|
||||||
size="sm"
|
|
||||||
onClick={handleQueue}
|
|
||||||
disabled={!queueUrl.trim() || queueMut.isPending}
|
|
||||||
>
|
|
||||||
<Play className="size-4 mr-1.5" />
|
|
||||||
Queue
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Turntable hero */}
|
|
||||||
<div className="flex items-center gap-6 surface scan-tick flex-wrap p-5">
|
|
||||||
{current && (
|
|
||||||
<motion.div
|
|
||||||
className={`relative mx-auto size-[160px] rounded-full ${
|
|
||||||
playing ? "animate-spin-disc" : "animate-spin-disc paused"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={current.thumbnailUrl ?? "/favicon.ico"}
|
|
||||||
alt={current.title ?? "cover"}
|
|
||||||
className="size-full rounded-full object-cover ring-4 ring-[var(--color-signal)]/20"
|
|
||||||
style={{ animationPlayState: playing ? "running" : "paused" }}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="display text-2xl text-[var(--color-signal)]">
|
<div className="eyebrow mb-1">Now playing</div>
|
||||||
{current?.title ?? "No track playing"}
|
<h2 className="display truncate text-2xl text-ink">
|
||||||
</div>
|
{current?.title ?? "Nothing queued"}
|
||||||
<div className="mt-1 mono text-xs text-[var(--color-ink-soft)]">
|
</h2>
|
||||||
{current?.source ?? "idle"} · {duration ? formatMs(duration) : "—"}
|
{current?.source && (
|
||||||
</div>
|
<div className="mono mt-1 truncate text-xs text-ink-faint">{current.source}</div>
|
||||||
<div className="mt-3">
|
)}
|
||||||
<Progress value={42} max={100} tone="signal" />
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
<div className="mt-1 flex justify-between text-[10px] mono text-[var(--color-ink-soft)]">
|
<Button variant="primary" size="sm" onClick={onPlay} disabled={queue.isPending}>
|
||||||
<span>0:00</span>
|
<Play className="size-4" /> Queue & play
|
||||||
<span>{duration ? formatMs(duration) : "—"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Transport */}
|
|
||||||
<StaggerGroup className="flex items-center gap-2">
|
|
||||||
<StaggerItem>
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => skip.mutate()}>
|
|
||||||
<SkipForward className="size-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</StaggerItem>
|
<Button variant="outline" size="sm" onClick={() => skip.mutate()} disabled={skip.isPending}>
|
||||||
<StaggerItem>
|
<SkipForward className="size-4" /> Skip
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => loopMut.mutate(!loop)}
|
|
||||||
>
|
|
||||||
{playing ? <Pause className="size-5" /> : <Play className="size-5" />}
|
|
||||||
</Button>
|
</Button>
|
||||||
</StaggerItem>
|
<Button variant="outline" size="sm" onClick={() => stop.mutate()} disabled={stop.isPending}>
|
||||||
<StaggerItem>
|
<Square className="size-4" /> Stop
|
||||||
<Button size="sm" variant="ghost" onClick={() => stop.mutate()}>
|
|
||||||
<Square className="size-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</StaggerItem>
|
|
||||||
<StaggerItem>
|
|
||||||
<Button
|
<Button
|
||||||
|
variant={media?.loop ? "primary" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={loop ? "primary" : "ghost"}
|
onClick={() => loop.mutate(!media?.loop)}
|
||||||
onClick={() => loopMut.mutate(!loop)}
|
aria-pressed={!!media?.loop}
|
||||||
>
|
>
|
||||||
<Repeat2 className="size-4" />
|
<Repeat className="size-4" /> Loop
|
||||||
</Button>
|
</Button>
|
||||||
</StaggerItem>
|
|
||||||
<StaggerItem>
|
|
||||||
<Volume2 className="size-4 text-[var(--color-ink-soft)]" />
|
|
||||||
</StaggerItem>
|
|
||||||
</StaggerGroup>
|
|
||||||
|
|
||||||
{/* Queue */}
|
|
||||||
{queue.length > 0 && (
|
|
||||||
<div className="surface flex flex-col gap-1.5 p-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-semibold">Queue ({queue.length})</h3>
|
|
||||||
<Badge tone="neutral">{loop ? "loop" : "queue"}</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
</div>
|
||||||
{queue.map((item) => (
|
</div>
|
||||||
<motion.div
|
|
||||||
key={item.id ?? item.source}
|
<div className="mt-5 flex items-center gap-2">
|
||||||
layout
|
<Input
|
||||||
initial={{ opacity: 0, x: 20 }}
|
placeholder="Paste a YouTube / music URL…"
|
||||||
animate={{ opacity: 1, x: 0 }}
|
value={url}
|
||||||
exit={{ opacity: 0, x: 20 }}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
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)]"
|
onKeyDown={(e) => e.key === "Enter" && onPlay()}
|
||||||
>
|
|
||||||
<Waveform
|
|
||||||
seed={item.id ?? item.source}
|
|
||||||
bars={12}
|
|
||||||
height={20}
|
|
||||||
className="w-16"
|
|
||||||
/>
|
/>
|
||||||
<span className="mono truncate">{item.title}</span>
|
</div>
|
||||||
</motion.div>
|
</GlassPanel>
|
||||||
|
|
||||||
|
<GlassPanel>
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="up next"
|
||||||
|
title="Queue"
|
||||||
|
action={<span className="mono text-xs text-ink-faint">{queueList.length} tracks</span>}
|
||||||
|
/>
|
||||||
|
{queueList.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
||||||
|
<Radio className="size-6 text-ink-faint" />
|
||||||
|
<div className="text-sm text-ink-soft">Queue is empty</div>
|
||||||
|
<div className="text-xs text-ink-faint">Paste a URL above to start playback.</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{queueList.map((item, i) => (
|
||||||
|
<div key={`${item.source}-${i}`} className="flex items-center gap-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5">
|
||||||
|
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm text-ink">{item.title}</div>
|
||||||
|
<div className="mono truncate text-[0.65rem] text-ink-faint">{item.source}</div>
|
||||||
|
</div>
|
||||||
|
<span className="pill">{item.mode ?? "music"}</span>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
</GlassPanel>
|
||||||
</div>
|
</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 @@
|
|||||||
/**
|
import { getConfig, getGuilds } from "@/lib/api/server";
|
||||||
* Messages — Server Component.
|
import { MessagesView } from "./view";
|
||||||
* 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";
|
|
||||||
|
|
||||||
export default async function MessagesPage({
|
export const dynamic = "force-dynamic";
|
||||||
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";
|
|
||||||
|
|
||||||
let initialPage: MessagePageResult | undefined;
|
export default async function MessagesPage() {
|
||||||
if (guild) {
|
let config = undefined;
|
||||||
initialPage = await getMessages(guild, channel || undefined).catch(
|
let guilds = undefined;
|
||||||
() => undefined,
|
try {
|
||||||
);
|
[config, guilds] = await Promise.all([getConfig(), getGuilds()]);
|
||||||
|
} catch {
|
||||||
|
/* client hooks surface errors */
|
||||||
}
|
}
|
||||||
|
return <MessagesView initialGuilds={guilds} initialGuildId={config?.monitorGuildId ?? null} />;
|
||||||
return (
|
|
||||||
<MessagesView
|
|
||||||
initialGuild={guild}
|
|
||||||
initialChannel={channel}
|
|
||||||
initialDetailId={selected}
|
|
||||||
initialTab={tab}
|
|
||||||
initialMessagePage={initialPage}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,377 +1,228 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Flag, Image, Loader2, Search, Send, X } from "lucide-react";
|
import { useEffect, useState } from "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 {
|
import {
|
||||||
useImages,
|
MessageSquare,
|
||||||
useLoadMore,
|
Search,
|
||||||
useMessageDetail,
|
Paperclip,
|
||||||
useMessages,
|
Image as ImageIcon,
|
||||||
useMessagesHasMore,
|
ShieldAlert,
|
||||||
useMessagesWsSync,
|
AlertTriangle,
|
||||||
useReview,
|
CheckCircle2,
|
||||||
useTextChannels,
|
Loader2,
|
||||||
} from "@/hooks";
|
} from "lucide-react";
|
||||||
import { renderMessageContent } from "@/lib/format";
|
|
||||||
import type { MessageRecord } from "@/lib/types";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
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";
|
function relTime(ts?: number | null) {
|
||||||
|
if (!ts) return "";
|
||||||
interface MessagesViewProps {
|
const d = Date.now() - ts;
|
||||||
initialGuild?: string;
|
const m = Math.floor(d / 60000);
|
||||||
initialChannel?: string;
|
if (m < 1) return "just now";
|
||||||
initialDetailId?: string | null;
|
if (m < 60) return `${m}m`;
|
||||||
initialTab?: MessagesTab;
|
const h = Math.floor(m / 60);
|
||||||
initialMessagePage?: { data: MessageRecord[]; nextCursor: string | null };
|
if (h < 24) return `${h}h`;
|
||||||
|
return `${Math.floor(h / 24)}d`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MessagesView({
|
function aiTone(s?: AiStatus | null): "signal" | "amber" | "vermilion" | "neutral" {
|
||||||
initialGuild = "",
|
if (s === "clean") return "signal";
|
||||||
initialChannel = "",
|
if (s === "warn") return "amber";
|
||||||
initialDetailId = null,
|
if (s === "flagged" || s === "error") return "vermilion";
|
||||||
initialTab = "all",
|
if (s === "processing" || s === "pending") return "neutral";
|
||||||
initialMessagePage,
|
return "neutral";
|
||||||
}: 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);
|
|
||||||
|
|
||||||
|
export function MessagesView({
|
||||||
|
initialGuilds,
|
||||||
|
initialGuildId,
|
||||||
|
}: {
|
||||||
|
initialGuilds?: Guild[];
|
||||||
|
initialGuildId?: string | null;
|
||||||
|
}) {
|
||||||
const ws = useWebSocket();
|
const ws = useWebSocket();
|
||||||
const { data: channels = [] } = useTextChannels(guildId);
|
const { data: guilds } = useGuilds(initialGuilds);
|
||||||
const {
|
const [guildId, setGuildId] = useState<string | null>(
|
||||||
data: messages,
|
initialGuildId ?? initialGuilds?.[0]?.id ?? null,
|
||||||
error,
|
|
||||||
refetch,
|
|
||||||
} = useMessages(
|
|
||||||
guildId,
|
|
||||||
selectedChannel || undefined,
|
|
||||||
guildId === initialGuild && selectedChannel === initialChannel
|
|
||||||
? initialMessagePage
|
|
||||||
: undefined,
|
|
||||||
);
|
);
|
||||||
const { data: cursorData } = useMessagesHasMore(
|
const [channelId, setChannelId] = useState<string | null>(null);
|
||||||
guildId,
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
selectedChannel || undefined,
|
const [query, setQuery] = useState("");
|
||||||
);
|
|
||||||
const loadMoreMut = useLoadMore();
|
|
||||||
const { data: images } = useImages(guildId);
|
|
||||||
const { data: reviews } = useReview(selectedChannel || undefined);
|
|
||||||
const { message: detailMessage, loading: detailLoading } =
|
|
||||||
useMessageDetail(detailId);
|
|
||||||
|
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams();
|
ambient.set(query ? "amber" : "signal", 0.3, query ? "search" : "messages");
|
||||||
if (guildId) params.set("guild", guildId);
|
}, [query, ambient]);
|
||||||
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]);
|
|
||||||
|
|
||||||
// global Cmd+K
|
const searching = query.trim().length >= 2;
|
||||||
useEffect(() => {
|
const list = searching ? search.data ?? [] : (messages ?? []);
|
||||||
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 ?? [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="space-y-4">
|
||||||
{/* Controls */}
|
<GlassPanel className="flex flex-wrap items-center gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<GuildChannelPicker
|
||||||
<GuildSelector value={guildId} onChange={handleGuildChange} />
|
mode="text"
|
||||||
{channels.length > 0 && (
|
guildsInitial={initialGuilds}
|
||||||
<Select
|
guildId={guildId}
|
||||||
value={selectedChannel}
|
channelId={channelId}
|
||||||
onChange={(e) => setSelectedChannel(e.target.value || "")}
|
onChange={(g, c) => {
|
||||||
className="w-48"
|
setGuildId(g);
|
||||||
>
|
setChannelId(c);
|
||||||
<option value="">All channels</option>
|
setSelected(null);
|
||||||
{channels.map((ch) => (
|
|
||||||
<option key={ch.id} value={ch.id}>
|
|
||||||
# {ch.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSearchOpen(true)}
|
|
||||||
className="ms-auto flex items-center gap-1.5 rounded-[var(--radius-r-control)] px-3 py-1.5 text-xs text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
|
||||||
>
|
|
||||||
<Search className="size-3.5" />
|
|
||||||
Search{" "}
|
|
||||||
<span className="hidden font-mono text-[10px] sm:inline">(⌘K)</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex gap-1 rounded-[var(--radius-r)] bg-[var(--color-surface-2)] p-1">
|
|
||||||
{tabs.map((t) => (
|
|
||||||
<button
|
|
||||||
key={t.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTab(t.id)}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1.5 rounded-[var(--radius-r-control)] px-3 py-1.5 text-xs font-medium transition-colors",
|
|
||||||
tab === t.id
|
|
||||||
? "bg-[var(--color-signal)] text-[var(--color-signal-ink)]"
|
|
||||||
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t.icon}
|
|
||||||
{t.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-4">
|
|
||||||
{/* Left — timeline spine + entries */}
|
|
||||||
<div
|
|
||||||
className={cn("surface p-3", detailId ? "w-1/2 lg:w-2/5" : "w-full")}
|
|
||||||
>
|
|
||||||
{error ? (
|
|
||||||
<ErrorState message={error.message} onRetry={refetch} />
|
|
||||||
) : !messages ? (
|
|
||||||
<LoadingSkeleton count={6} />
|
|
||||||
) : tab === "all" ? (
|
|
||||||
<MessageList
|
|
||||||
messages={currentMessages}
|
|
||||||
selectedId={detailId}
|
|
||||||
onSelect={setDetailId}
|
|
||||||
hasMore={cursorData?.hasMore}
|
|
||||||
onLoadMore={handleLoadMore}
|
|
||||||
isLoadingMore={loadMoreMut.isPending}
|
|
||||||
/>
|
|
||||||
) : tab === "images" ? (
|
|
||||||
<ImageGrid items={images ?? []} onSelect={setDetailId} />
|
|
||||||
) : (
|
|
||||||
<ReviewList items={reviews ?? []} onSelect={setDetailId} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right — detail */}
|
|
||||||
{detailId && (
|
|
||||||
<div className="sticky top-16 hidden w-1/2 self-start md:block lg:w-3/5">
|
|
||||||
<div className="surface h-full p-4">
|
|
||||||
{detailLoading ? (
|
|
||||||
<div className="flex h-40 items-center justify-center">
|
|
||||||
<Loader2 className="size-5 animate-spin text-[var(--color-ink-soft)]" />
|
|
||||||
</div>
|
|
||||||
) : detailMessage ? (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDetailId(null)}
|
|
||||||
className="text-xs text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
|
||||||
>
|
|
||||||
← Back to list
|
|
||||||
</button>
|
|
||||||
<MessageDetailView message={detailMessage} />
|
|
||||||
{detailMessage && (
|
|
||||||
<Lightbox
|
|
||||||
open={!!lightbox}
|
|
||||||
onClose={() => setLightbox(null)}
|
|
||||||
images={extractImages(detailMessage.metadata)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SearchOverlay
|
|
||||||
open={searchOpen}
|
|
||||||
onClose={() => setSearchOpen(false)}
|
|
||||||
results={(currentMessages ?? []).map((m) => ({
|
|
||||||
id: m.id,
|
|
||||||
content: m.edited_content ?? m.content,
|
|
||||||
username: m.username ?? "unknown",
|
|
||||||
channel: m.channel_id,
|
|
||||||
time: m.created_at
|
|
||||||
? new Date(m.created_at * 1000).toLocaleTimeString()
|
|
||||||
: "",
|
|
||||||
}))}
|
|
||||||
onSelect={(msg) => {
|
|
||||||
const found = currentMessages.find((m) => m.id === msg.id);
|
|
||||||
if (found) setDetailId(found.id);
|
|
||||||
setTab("all");
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div className="relative ml-auto w-64">
|
||||||
{lightbox && (
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-ink-faint" />
|
||||||
<Lightbox
|
<Input
|
||||||
open={!!lightbox}
|
className="pl-9"
|
||||||
onClose={() => setLightbox(null)}
|
placeholder="Search messages…"
|
||||||
images={lightbox.images}
|
value={query}
|
||||||
initialIndex={lightbox.index}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</GlassPanel>
|
||||||
}
|
|
||||||
|
|
||||||
function ImageGrid({
|
<div className="grid gap-4 lg:grid-cols-5">
|
||||||
items,
|
<GlassPanel className="lg:col-span-3">
|
||||||
onSelect,
|
<SectionHeader
|
||||||
}: {
|
eyebrow={searching ? "results" : "live feed"}
|
||||||
items: MessageRecord[];
|
title={searching ? `“${query}”` : "Messages"}
|
||||||
onSelect: (id: string) => void;
|
action={
|
||||||
}) {
|
<span className="mono text-xs text-ink-faint">
|
||||||
return !items.length ? (
|
{list.length} shown
|
||||||
<EmptyState
|
</span>
|
||||||
icon={Image}
|
}
|
||||||
title="No images"
|
|
||||||
description="Messages with image attachments will appear here."
|
|
||||||
/>
|
/>
|
||||||
|
{error && !messages ? (
|
||||||
|
<ErrorState error={error} />
|
||||||
|
) : isLoading && !messages ? (
|
||||||
|
<LoadingState label="Capturing" />
|
||||||
|
) : list.length === 0 ? (
|
||||||
|
<EmptyState icon={<MessageSquare className="size-7" />} title="No messages" description="Pick a guild to begin, or run a search." />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-3 gap-2.5">
|
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
|
||||||
{items.map((item) => {
|
{list.map((m) => (
|
||||||
const url = extractFirstImage(item.metadata);
|
|
||||||
return (
|
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={m.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(item.id)}
|
onClick={() => setSelected(m.id)}
|
||||||
className="overflow-hidden rounded-[var(--radius-r)] border border-[var(--color-hairline)]"
|
className={`flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${
|
||||||
|
selected === m.id ? "border-signal/40 bg-signal/8" : "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{url ? (
|
<Avatar src={m.avatar_url} name={m.username} size={34} />
|
||||||
<img
|
<div className="min-w-0 flex-1">
|
||||||
src={url}
|
<div className="flex items-center gap-2">
|
||||||
alt=""
|
<span className="truncate text-sm font-semibold text-ink">{m.username}</span>
|
||||||
className="h-24 w-full object-cover"
|
<span className="mono text-[0.65rem] text-ink-faint">{getMessageChannelLabel(m)}</span>
|
||||||
loading="lazy"
|
<span className="mono ml-auto text-[0.6rem] text-ink-faint">{relTime(m.created_at)}</span>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-24 w-full items-center justify-center text-xs text-[var(--color-ink-soft)]/40">
|
|
||||||
No image
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="mt-0.5 line-clamp-2 text-sm text-ink-soft">
|
||||||
</button>
|
{renderMessageContent(m.content, m.metadata) || <span className="italic text-ink-faint">(empty / embed)</span>}
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
}
|
<AiBadge status={m.ai_status} />
|
||||||
|
|
||||||
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>
|
</button>
|
||||||
</StaggerItem>
|
|
||||||
))}
|
))}
|
||||||
</StaggerGroup>
|
</div>
|
||||||
|
)}
|
||||||
|
</GlassPanel>
|
||||||
|
|
||||||
|
<GlassPanel className="lg:col-span-2">
|
||||||
|
<SectionHeader eyebrow="inspect" title="Detail" />
|
||||||
|
{!selected ? (
|
||||||
|
<EmptyState title="Select a message" description="Click any message to inspect AI analysis, attachments and edit history." />
|
||||||
|
) : detail.loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-20" />
|
||||||
|
<Skeleton className="h-12" />
|
||||||
|
</div>
|
||||||
|
) : detail.message ? (
|
||||||
|
<MessageDetail m={detail.message} attachments={detail.attachments} />
|
||||||
|
) : (
|
||||||
|
<EmptyState title="Not found" />
|
||||||
|
)}
|
||||||
|
</GlassPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractFirstImage(metadata?: string | null): string | null {
|
function AiBadge({ status }: { status?: AiStatus | null }) {
|
||||||
try {
|
if (!status) return null;
|
||||||
if (!metadata) return null;
|
const tone = aiTone(status);
|
||||||
const m = JSON.parse(metadata);
|
const icon =
|
||||||
const atts = m?.attachments ?? [];
|
status === "clean" ? <CheckCircle2 className="size-3" /> :
|
||||||
const img = atts.find(
|
status === "flagged" ? <ShieldAlert className="size-3" /> :
|
||||||
(a: {
|
status === "warn" ? <AlertTriangle className="size-3" /> :
|
||||||
contentType?: string | null;
|
status === "processing" || status === "pending" ? <Loader2 className="size-3 animate-spin" /> :
|
||||||
url?: string;
|
<AlertTriangle className="size-3" />;
|
||||||
discord_url?: string;
|
return <Badge tone={tone} dot={status === "processing" || status === "pending"}>{icon}{status}</Badge>;
|
||||||
}) => /image/i.test(a.contentType ?? ""),
|
|
||||||
);
|
|
||||||
return img?.url ?? img?.discord_url ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractImages(metadata?: string | null) {
|
function MessageDetail({ m, attachments }: { m: MessageRecord; attachments: import("@/lib/types").AttachmentRecord[] }) {
|
||||||
try {
|
const flags = safeParseJsonArray(m.ai_moderation_flags);
|
||||||
if (!metadata) return [];
|
const cats = safeParseJsonArray(m.ai_categories);
|
||||||
const m = JSON.parse(metadata);
|
return (
|
||||||
return (m?.attachments ?? [])
|
<div className="space-y-3 text-sm">
|
||||||
.filter((a: { contentType?: string | null }) =>
|
<div className="flex items-center gap-3">
|
||||||
/image/i.test(a.contentType ?? ""),
|
<Avatar src={m.avatar_url} name={m.username} size={40} />
|
||||||
)
|
<div>
|
||||||
.map((a: { url?: string; discord_url?: string; name?: string }) => ({
|
<div className="font-semibold text-ink">{m.username}</div>
|
||||||
src: a.url ?? a.discord_url ?? "",
|
<div className="mono text-[0.65rem] text-ink-faint">{getMessageChannelLabel(m)} · {relTime(m.created_at)}</div>
|
||||||
alt: a.name,
|
</div>
|
||||||
}));
|
<div className="ml-auto"><AiBadge status={m.ai_status} /></div>
|
||||||
} catch {
|
</div>
|
||||||
return [];
|
|
||||||
}
|
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">
|
||||||
|
{renderMessageContent(m.edited_content ?? m.content, m.metadata) || "(no text)"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{m.ai_analysis && (
|
||||||
|
<div>
|
||||||
|
<div className="eyebrow mb-1">AI analysis</div>
|
||||||
|
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">{m.ai_analysis}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(flags.length > 0 || cats.length > 0) && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{flags.map((f) => <Badge key={f} tone="vermilion">{f}</Badge>)}
|
||||||
|
{cats.map((c) => <Badge key={c} tone="amber">{c}</Badge>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="eyebrow mb-1 flex items-center gap-1.5"><Paperclip className="size-3" /> Attachments ({attachments.length})</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{attachments.map((a) => (
|
||||||
|
<a key={a.id} href={a.discord_url ?? a.uploaded_url ?? "#"} target="_blank" rel="noreferrer" className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft hover:text-ink">
|
||||||
|
<ImageIcon className="size-3.5 text-signal" />
|
||||||
|
<span className="flex-1 truncate">{a.filename}</span>
|
||||||
|
<span className="mono text-ink-faint">{formatBytes(a.size)}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,18 @@
|
|||||||
/**
|
|
||||||
* Moderation — Server Component.
|
|
||||||
* Seeds moderation stats + action log for SSR first paint; live via WS.
|
|
||||||
*/
|
|
||||||
import { getModerationActions, getModerationStats } from "@/lib/api/server";
|
import { getModerationActions, getModerationStats } from "@/lib/api/server";
|
||||||
import ModerationView from "./view";
|
import { ModerationView } from "./view";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function ModerationPage() {
|
export default async function ModerationPage() {
|
||||||
const [stats, actions] = await Promise.allSettled([
|
let stats = undefined;
|
||||||
getModerationStats().catch(() => undefined),
|
let actions = undefined;
|
||||||
getModerationActions(100).catch(() => undefined),
|
try {
|
||||||
|
[stats, actions] = await Promise.all([
|
||||||
|
getModerationStats(),
|
||||||
|
getModerationActions(100),
|
||||||
]);
|
]);
|
||||||
return (
|
} catch {
|
||||||
<ModerationView
|
/* client hooks surface errors */
|
||||||
initialStats={
|
|
||||||
stats.status === "fulfilled" && stats.value ? stats.value : undefined
|
|
||||||
}
|
}
|
||||||
initialActions={
|
return <ModerationView initialStats={stats} initialActions={actions} />;
|
||||||
actions.status === "fulfilled" && actions.value
|
|
||||||
? actions.value
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,188 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ModerationSection } from "@/components/moderation/moderation-section";
|
import { useEffect, useState } from "react";
|
||||||
import type { ModerationAction, ModerationStats } from "@/lib/types";
|
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,
|
initialStats,
|
||||||
initialActions,
|
initialActions,
|
||||||
}: {
|
}: {
|
||||||
initialStats?: ModerationStats;
|
initialStats?: ModerationStats;
|
||||||
initialActions?: ModerationAction[];
|
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 (
|
return (
|
||||||
<ModerationSection
|
<div className="space-y-5">
|
||||||
initialStats={initialStats}
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
initialActions={initialActions}
|
<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 { getRecordings } from "@/lib/api/server";
|
||||||
import RecordingsView from "./view";
|
import { RecordingsView } from "./view";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function RecordingsPage() {
|
export default async function RecordingsPage() {
|
||||||
const data = await getRecordings(50).catch(() => undefined);
|
let recordings = undefined;
|
||||||
return <RecordingsView initialRecordings={data?.items} />;
|
try {
|
||||||
|
recordings = await getRecordings(50);
|
||||||
|
} catch {
|
||||||
|
/* client hooks surface errors */
|
||||||
|
}
|
||||||
|
return <RecordingsView initialItems={recordings?.items} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,206 +1,92 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Delete, Download, Play } from "lucide-react";
|
import { useEffect } from "react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { Headphones, Trash2, Download } from "lucide-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 { useWebSocket } from "@/lib/ws/context";
|
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 {
|
export function RecordingsView({ initialItems }: { initialItems?: VoiceRecording[] }) {
|
||||||
recordings: VoiceRecording[];
|
const ws = useWebSocket();
|
||||||
error: Error | null;
|
const { data: items, isLoading, error } = useRecordings(initialItems);
|
||||||
isLoading: boolean;
|
const del = useDeleteRecording();
|
||||||
deleting: string | null;
|
useRecordingsWsSync(ws);
|
||||||
onSelect: (rec: VoiceRecording) => void;
|
const ambient = useAmbient();
|
||||||
onDelete: (rec: VoiceRecording) => void;
|
|
||||||
preview: VoiceRecording | null;
|
|
||||||
onClosePreview: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RecordingsList({
|
useEffect(() => {
|
||||||
recordings,
|
ambient.set("signal", 0.3, "recordings");
|
||||||
error,
|
}, [ambient]);
|
||||||
isLoading,
|
|
||||||
deleting,
|
const onDelete = async (id: string) => {
|
||||||
onSelect,
|
try {
|
||||||
onDelete,
|
await del.mutateAsync(id);
|
||||||
preview,
|
toast({ title: "Recording deleted", tone: "signal" });
|
||||||
onClosePreview,
|
} catch (e) {
|
||||||
}: RecordingsListProps) {
|
toast({ title: "Delete failed", description: String(e), tone: "vermilion" });
|
||||||
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)]">
|
if (error && !items) return <ErrorState error={error} />;
|
||||||
Failed to load: {error.message}
|
if (!items && isLoading) return <LoadingState label="Loading clips" />;
|
||||||
</p>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<GlassPanel>
|
||||||
<AnimatePresence>
|
<SectionHeader
|
||||||
{recordings.map((rec) => (
|
eyebrow="voice captures"
|
||||||
<StaggerItem key={rec.id} className="surface p-3" layout>
|
title="Recordings"
|
||||||
<motion.div layout className="flex items-center gap-3">
|
action={<span className="mono text-xs text-ink-faint">{(items ?? []).length} clips</span>}
|
||||||
<Waveform
|
|
||||||
seed={rec.id}
|
|
||||||
bars={20}
|
|
||||||
height={40}
|
|
||||||
className="w-20 shrink-0"
|
|
||||||
/>
|
/>
|
||||||
<Avatar name={rec.username} src={rec.avatar_url} size={34} />
|
{(items ?? []).length === 0 ? (
|
||||||
|
<EmptyState icon={<Headphones className="size-7" />} title="No recordings" description="Voice clips captured by the bot appear here." />
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{(items ?? []).map((r) => (
|
||||||
|
<GlassCard key={r.id} className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar src={r.avatar_url} name={r.username} size={38} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="truncate text-sm font-semibold text-ink">{r.username}</div>
|
||||||
<span className="font-medium">
|
<div className="mono text-[0.65rem] text-ink-faint">
|
||||||
{rec.username ?? "unknown"}
|
{r.channel_name ?? "voice"} · {new Date(r.created_at).toLocaleString()}
|
||||||
</span>
|
|
||||||
<Badge
|
|
||||||
tone={
|
|
||||||
rec.upload_status === "uploaded"
|
|
||||||
? "signal"
|
|
||||||
: rec.upload_status === "failed"
|
|
||||||
? "vermilion"
|
|
||||||
: "amber"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
.{rec.filename.split(".").pop() ?? "mp3"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="mono text-xs text-[var(--color-ink-soft)]">
|
|
||||||
{(rec.size_bytes / 1024).toFixed(0)} KB ·{" "}
|
|
||||||
{new Date(rec.created_at * 1000).toLocaleTimeString()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<span className="mono text-[0.65rem] text-ink-faint">{formatBytes(r.size_bytes)}</span>
|
||||||
{rec.download_url && (
|
</div>
|
||||||
<>
|
|
||||||
<Button
|
{r.download_url ? (
|
||||||
size="sm"
|
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||||
variant="ghost"
|
<audio controls src={r.download_url} className="h-9 w-full" preload="none" />
|
||||||
onClick={() => onSelect(rec)}
|
) : (
|
||||||
>
|
<div className="rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-faint">
|
||||||
<Play className="size-4" />
|
Upload pending…
|
||||||
</Button>
|
</div>
|
||||||
<a
|
)}
|
||||||
href={rec.download_url}
|
|
||||||
download={rec.filename}
|
<div className="flex items-center gap-2">
|
||||||
aria-label="Download"
|
{r.download_url && (
|
||||||
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)]"
|
<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
|
||||||
<Download className="size-4" />
|
|
||||||
</a>
|
</a>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="danger"
|
className="ml-auto"
|
||||||
disabled={deleting === rec.id}
|
onClick={() => onDelete(r.id)}
|
||||||
onClick={() => onDelete(rec)}
|
disabled={del.isPending}
|
||||||
aria-label="Delete"
|
|
||||||
>
|
>
|
||||||
<Delete className="size-4" />
|
<Trash2 className="size-3.5" /> Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</GlassCard>
|
||||||
</StaggerItem>
|
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
|
||||||
<PreviewDialog
|
|
||||||
open={!!preview}
|
|
||||||
onClose={onClosePreview}
|
|
||||||
recording={preview}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
}
|
</GlassPanel>
|
||||||
|
|
||||||
function PreviewDialog({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
recording,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
recording: VoiceRecording | null;
|
|
||||||
}) {
|
|
||||||
if (!recording) return null;
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onClose={onClose} className="p-6 max-w-xl">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="display text-lg text-[var(--color-signal)]">
|
|
||||||
{recording.filename}
|
|
||||||
</div>
|
|
||||||
{/* biome-ignore lint/a11y/useMediaCaption: voice recordings are uncaptioned audio previews — no transcript available */}
|
|
||||||
<audio
|
|
||||||
controls
|
|
||||||
src={recording.download_url ?? ""}
|
|
||||||
aria-label={`Audio recording: ${recording.filename}`}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<div className="mono text-xs text-[var(--color-ink-soft)]">
|
|
||||||
{(recording.size_bytes / 1024).toFixed(0)} KB ·{" "}
|
|
||||||
{recording.upload_status}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RecordingsView({
|
|
||||||
initialRecordings,
|
|
||||||
}: {
|
|
||||||
initialRecordings?: VoiceRecording[];
|
|
||||||
}) {
|
|
||||||
const ws = useWebSocket();
|
|
||||||
const {
|
|
||||||
data: recordings = [],
|
|
||||||
error,
|
|
||||||
isLoading,
|
|
||||||
} = useRecordings(initialRecordings);
|
|
||||||
const del = useDeleteRecording();
|
|
||||||
const [deleting, setDeleting] = useState<string | null>(null);
|
|
||||||
useRecordingsWsSync(ws);
|
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
|
||||||
(rec: VoiceRecording) => {
|
|
||||||
setDeleting(rec.id);
|
|
||||||
del.mutate(rec.id);
|
|
||||||
setTimeout(() => setDeleting(null), 800);
|
|
||||||
},
|
|
||||||
[del],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [preview, setPreview] = useState<VoiceRecording | null>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RecordingsList
|
|
||||||
recordings={recordings}
|
|
||||||
error={error}
|
|
||||||
isLoading={isLoading}
|
|
||||||
deleting={deleting}
|
|
||||||
onSelect={setPreview}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
preview={preview}
|
|
||||||
onClosePreview={() => setPreview(null)}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
/**
|
import { getGuilds, getVoiceStatus } from "@/lib/api/server";
|
||||||
* Voice — Server Component.
|
import { VoiceView } from "./view";
|
||||||
* 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.
|
export const dynamic = "force-dynamic";
|
||||||
*/
|
|
||||||
import { getVoiceStatus } from "@/lib/api/server";
|
|
||||||
import type { VoiceStatus } from "@/lib/types";
|
|
||||||
import VoiceView from "./view";
|
|
||||||
|
|
||||||
export default async function VoicePage() {
|
export default async function VoicePage() {
|
||||||
const status = await getVoiceStatus().catch(() => undefined);
|
let status = undefined;
|
||||||
return <VoiceView initialStatus={status} />;
|
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";
|
"use client";
|
||||||
|
|
||||||
import { Headphones, Loader2, Radio, RadioOff } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { SignalField } from "@/components/three";
|
import { Mic, MicOff, Headphones, PhoneOff, Radio, Volume2, Waves } from "lucide-react";
|
||||||
import { WebGLGuard } from "@/components/three/webgl-guard";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
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 {
|
import {
|
||||||
useGuilds,
|
useGuilds,
|
||||||
useMicTransmit,
|
useVoiceStatus,
|
||||||
useSpeakers,
|
|
||||||
useVoiceChannels,
|
|
||||||
useVoiceConnect,
|
useVoiceConnect,
|
||||||
useVoiceDisconnect,
|
useVoiceDisconnect,
|
||||||
|
useSpeakers,
|
||||||
|
useMicTransmit,
|
||||||
useVoiceListen,
|
useVoiceListen,
|
||||||
useVoiceStatus,
|
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import type { VoiceStatus } from "@/lib/types";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import { useWebSocket } from "@/lib/ws/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 ws = useWebSocket();
|
||||||
const [selectedGuild, setSelectedGuild] = useState("");
|
const { data: status, isLoading, error } = useVoiceStatus(initialStatus);
|
||||||
const [selectedChannel, setSelectedChannel] = useState("");
|
const { data: guilds } = useGuilds(initialGuilds);
|
||||||
|
|
||||||
// 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 connect = useVoiceConnect();
|
const connect = useVoiceConnect();
|
||||||
const disconnect = useVoiceDisconnect();
|
const disconnect = useVoiceDisconnect();
|
||||||
const listen = useVoiceListen(ws);
|
|
||||||
const mic = useMicTransmit(ws);
|
const mic = useMicTransmit(ws);
|
||||||
const [micActive, setMicActive] = useState(false);
|
const listen = useVoiceListen(ws);
|
||||||
const [micVolume, setMicVolume] = useState(75);
|
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(() => {
|
useEffect(() => {
|
||||||
const unsub = subscribe(ws);
|
const unsub = subscribe(ws);
|
||||||
return unsub;
|
return unsub;
|
||||||
}, [subscribe, ws]);
|
}, [subscribe, ws]);
|
||||||
|
|
||||||
const active = speakers.filter((s) => s.speaking);
|
useEffect(() => {
|
||||||
|
if (status?.connected) ambient.set("signal", 0.55, "voice live");
|
||||||
|
else ambient.set("vermilion", 0.35, "voice idle");
|
||||||
|
}, [status?.connected, ambient]);
|
||||||
|
|
||||||
|
if (error && !status) return <ErrorState error={error} />;
|
||||||
|
if (!status && isLoading) return <LoadingState label="Linking voice" />;
|
||||||
|
|
||||||
const connected = status?.connected ?? false;
|
const connected = status?.connected ?? false;
|
||||||
|
const listenBars = Array.from(listen.levels.values()).slice(0, 32);
|
||||||
|
|
||||||
const handleMicToggle = async (on: boolean) => {
|
const onConnect = async () => {
|
||||||
if (on) {
|
if (!guildId || !channelId) {
|
||||||
|
toast({ title: "Pick a guild + channel", tone: "vermilion" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await mic.mutateAsync(true);
|
await connect.mutateAsync({ guildId, channelId });
|
||||||
setMicActive(true);
|
toast({ title: "Connected to voice", tone: "signal" });
|
||||||
} catch {
|
} catch (e) {
|
||||||
setMicActive(false);
|
toast({ title: "Connect failed", description: String(e), tone: "vermilion" });
|
||||||
}
|
}
|
||||||
} else {
|
};
|
||||||
setMicActive(false);
|
|
||||||
|
const onMic = async (on: boolean) => {
|
||||||
try {
|
try {
|
||||||
await mic.mutateAsync(false);
|
await mic.mutateAsync(on);
|
||||||
} catch {
|
setMicOn(on);
|
||||||
// Stop already tore down — ignore remote error
|
} catch (e) {
|
||||||
|
toast({ title: "Mic error", description: String(e), tone: "vermilion" });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMicVolume = (v: number) => {
|
|
||||||
setMicVolume(v);
|
|
||||||
mic.setVolume(v);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleListenVolume = (v: number) => {
|
|
||||||
listen.setVolume(v);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGuildChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
const g = e.target.value;
|
|
||||||
setSelectedGuild(g);
|
|
||||||
setSelectedChannel("");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="space-y-5">
|
||||||
{/* Connection bar with guild + voice channel pickers */}
|
<GlassPanel>
|
||||||
<div className="surface flex flex-col gap-3 p-4">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<GuildChannelPicker
|
||||||
<Badge tone={connected ? "signal" : "neutral"} dot>
|
mode="voice"
|
||||||
{connected ? "Connected" : "Disconnected"}
|
guildsInitial={initialGuilds}
|
||||||
</Badge>
|
guildId={guildId}
|
||||||
{connected && status?.activeChannelName && (
|
channelId={channelId}
|
||||||
<span className="hidden items-center gap-1.5 text-xs text-[var(--color-ink-soft)] sm:flex">
|
onChange={(g, c) => {
|
||||||
<Headphones className="size-3.5" />
|
setGuildId(g);
|
||||||
{status.activeChannelName}
|
setChannelId(c);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{connected ? (
|
||||||
|
<Button variant="danger" size="sm" onClick={() => disconnect.mutate()} disabled={disconnect.isPending}>
|
||||||
|
<PhoneOff className="size-4" /> Disconnect
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="primary" size="sm" onClick={onConnect} disabled={connect.isPending}>
|
||||||
|
<Radio className="size-4" /> Connect
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant={micOn ? "primary" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onMic(!micOn)}
|
||||||
|
disabled={mic.isPending}
|
||||||
|
aria-pressed={micOn}
|
||||||
|
>
|
||||||
|
{micOn ? <Mic className="size-4" /> : <MicOff className="size-4" />}
|
||||||
|
{micOn ? "Mic live" : "Push-to-talk"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={listen.active ? "primary" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => listen.toggle(!listen.active)}
|
||||||
|
>
|
||||||
|
{listen.active ? <Headphones className="size-4" /> : <Volume2 className="size-4" />}
|
||||||
|
{listen.active ? "Listening" : "Listen in"}
|
||||||
|
</Button>
|
||||||
|
{listen.active && (
|
||||||
|
<div className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-1.5">
|
||||||
|
<Waves className="size-4 text-signal" />
|
||||||
|
<Equalizer bars={listenBars} className="w-40" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-3">
|
||||||
|
<GlassPanel className="lg:col-span-2">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="stage"
|
||||||
|
title="Live speakers"
|
||||||
|
action={
|
||||||
|
<span className="mono text-xs text-ink-faint">
|
||||||
|
{speakers.length} present
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!connected ? (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Select
|
|
||||||
value={selectedGuild}
|
|
||||||
onChange={handleGuildChange}
|
|
||||||
className="flex-1 min-w-[140px] h-9"
|
|
||||||
>
|
|
||||||
<option value="" disabled>
|
|
||||||
Select guild…
|
|
||||||
</option>
|
|
||||||
{guilds.map((g) => (
|
|
||||||
<option key={g.id} value={g.id}>
|
|
||||||
{g.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={selectedChannel}
|
|
||||||
onChange={(e) => setSelectedChannel(e.target.value)}
|
|
||||||
disabled={!selectedGuild}
|
|
||||||
className="flex-1 min-w-[140px] h-9"
|
|
||||||
>
|
|
||||||
<option value="" disabled>
|
|
||||||
Select channel…
|
|
||||||
</option>
|
|
||||||
{(voiceChannels ?? []).map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>
|
|
||||||
{c.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="primary"
|
|
||||||
onClick={() =>
|
|
||||||
void connect.mutate({
|
|
||||||
guildId: selectedGuild,
|
|
||||||
channelId: selectedChannel,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
disabled={connect.isPending || !selectedGuild || !selectedChannel}
|
/>
|
||||||
>
|
{speakers.length === 0 ? (
|
||||||
{connect.isPending ? (
|
<EmptyState
|
||||||
<Loader2 className="size-3.5 animate-spin mr-1.5" />
|
icon={<MicOff className="size-7" />}
|
||||||
|
title={connected ? "Silent right now" : "Not connected"}
|
||||||
|
description={connected ? "Speakers appear as they talk." : "Connect to a voice channel to see presence."}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Radio className="size-3.5 mr-1.5" />
|
<>
|
||||||
)}
|
<VoiceStage speakers={speakers} />
|
||||||
Connect
|
<div className="mt-2 flex flex-wrap justify-center gap-2">
|
||||||
</Button>
|
{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>
|
</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
|
</GlassPanel>
|
||||||
</Button>
|
|
||||||
|
<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>
|
||||||
|
<div className="mt-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft">
|
||||||
{/* Stage hero */}
|
<span className="mono text-ink-faint">channel</span>{" "}
|
||||||
<div className="relative surface h-[280px] items-end justify-center overflow-hidden rounded-[var(--radius-r)] p-5">
|
{status?.activeChannelName ?? "—"}
|
||||||
<WebGLGuard
|
|
||||||
fallback={
|
|
||||||
<StaticFallback
|
|
||||||
variant="orb"
|
|
||||||
count={Math.max(speakers.length, 3)}
|
|
||||||
className="absolute inset-0"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SignalField
|
|
||||||
activity={speakers.length > 0 ? 0.6 : 0.2}
|
|
||||||
className="absolute inset-0"
|
|
||||||
/>
|
|
||||||
</WebGLGuard>
|
|
||||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2">
|
|
||||||
<SpeakerWaveform speakers={active} />
|
|
||||||
</div>
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,69 +3,66 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@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,
|
* A signal-driven, immersive ops aesthetic. Hierarchy comes from scale/weight
|
||||||
* signal-driven ops-console aesthetic. Hierarchy comes from scale/weight and
|
* and tonal surface blocks, not borders. The whole app sits behind a live
|
||||||
* tonal surface blocks, NOT from borders/shadows. Three semantic signals:
|
* WebGL haze (see components/ambient) tinted by a semantic signal:
|
||||||
* lime = OK / live (--signal)
|
* lime = OK / live
|
||||||
* amber = warn (--amber)
|
* amber = warn
|
||||||
* vermilion = flag/danger (--vermilion)
|
* vermilion = flag / danger
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
/* ── Surfaces ── */
|
/* ── Surfaces (dark-first; light overrides below) ── */
|
||||||
--color-canvas: oklch(0.96 0.012 80);
|
--color-canvas: oklch(0.12 0.014 70);
|
||||||
--color-surface: oklch(0.92 0.014 80);
|
--color-canvas-2: oklch(0.16 0.02 70);
|
||||||
--color-surface-2: oklch(0.88 0.016 80);
|
--color-surface: oklch(0.2 0.022 70 / 0.55);
|
||||||
|
--color-surface-2: oklch(0.26 0.024 70 / 0.45);
|
||||||
|
|
||||||
/* ── Ink ── */
|
/* ── Ink ── */
|
||||||
--color-ink: oklch(0.22 0.02 70);
|
--color-ink: oklch(0.95 0.008 75);
|
||||||
--color-ink-soft: oklch(0.46 0.02 70);
|
--color-ink-soft: oklch(0.66 0.02 75);
|
||||||
|
--color-ink-faint: oklch(0.5 0.02 75);
|
||||||
|
|
||||||
/* ── Structural ── */
|
/* ── Structural ── */
|
||||||
--color-hairline: oklch(0.22 0.02 70 / 0.1);
|
--color-hairline: oklch(1 0 0 / 0.1);
|
||||||
--hairline-w: 1px;
|
--hairline-w: 1px;
|
||||||
|
|
||||||
/* ── Semantic signals ── */
|
/* ── Semantic signals ── */
|
||||||
--color-signal: oklch(0.78 0.17 125);
|
--color-signal: oklch(0.86 0.19 128);
|
||||||
--color-signal-ink: oklch(0.20 0.03 70);
|
--color-signal-ink: oklch(0.18 0.03 70);
|
||||||
--color-signal-glow: oklch(0.78 0.17 125 / 0.35);
|
--color-signal-glow: oklch(0.86 0.19 128 / 0.4);
|
||||||
--color-amber: oklch(0.80 0.15 70);
|
--color-amber: oklch(0.85 0.15 72);
|
||||||
--color-vermilion: oklch(0.62 0.21 25);
|
--color-vermilion: oklch(0.68 0.21 25);
|
||||||
--color-vermilion-soft: oklch(0.62 0.21 25 / 0.15);
|
--color-vermilion-glow: oklch(0.68 0.21 25 / 0.4);
|
||||||
|
|
||||||
--color-ring: var(--color-signal);
|
--color-ring: var(--color-signal);
|
||||||
|
|
||||||
/* ── Fonts ── */
|
/* ── Fonts ── */
|
||||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||||||
--font-display: "Bricolage Grotesque", "Inter", sans-serif;
|
--font-display: "Bricolage Grotesque", "Inter", system-ui, sans-serif;
|
||||||
|
|
||||||
/* ── Radii ── */
|
/* ── Radii ── */
|
||||||
--radius-r: 14px;
|
--radius-r: 16px;
|
||||||
--radius-r-panel: 12px;
|
--radius-r-panel: 12px;
|
||||||
--radius-r-control: 8px;
|
--radius-r-control: 9px;
|
||||||
--radius-r-pill: 9999px;
|
--radius-r-pill: 9999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Dark theme overrides ── */
|
/* ── Light theme overrides ── */
|
||||||
.dark {
|
.light {
|
||||||
--color-canvas: oklch(0.13 0.015 70);
|
--color-canvas: oklch(0.96 0.012 80);
|
||||||
--color-surface: oklch(0.18 0.02 70);
|
--color-canvas-2: oklch(0.92 0.014 80);
|
||||||
--color-surface-2: oklch(0.23 0.022 70);
|
--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: oklch(0.22 0.02 70);
|
||||||
--color-ink-soft: oklch(0.62 0.02 75);
|
--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-hairline: oklch(0.22 0.02 70 / 0.12);
|
||||||
|
|
||||||
--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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -79,41 +76,28 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-canvas text-ink font-sans antialiased;
|
@apply bg-canvas text-ink font-sans antialiased;
|
||||||
/* warm dot-grid texture + faint glow — replaces old bluish radial layers */
|
min-height: 100dvh;
|
||||||
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%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: oklch(0.78 0.17 125 / 0.35);
|
background: var(--color-signal-glow);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar — warm */
|
/* Scrollbar */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 7px;
|
width: 8px;
|
||||||
height: 7px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: oklch(0.4 0.02 70 / 0.22);
|
background: oklch(1 0 0 / 0.14);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: oklch(0.4 0.02 70 / 0.38);
|
background: oklch(1 0 0 / 0.26);
|
||||||
}
|
}
|
||||||
|
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
@@ -122,59 +106,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer components {
|
||||||
/* Tonal block that replaces bordered cards */
|
/* Glass panel — the workhorse container. Floating, blurred, faint glow. */
|
||||||
.surface {
|
.glass {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border-radius: var(--radius-r);
|
|
||||||
border: var(--hairline-w) solid var(--color-hairline);
|
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);
|
background: var(--color-surface-2);
|
||||||
|
border: var(--hairline-w) solid var(--color-hairline);
|
||||||
border-radius: var(--radius-r-panel);
|
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 */
|
/* Section label — small uppercase mono eyebrow */
|
||||||
.scan-tick {
|
.eyebrow {
|
||||||
position: relative;
|
font-family: var(--font-mono);
|
||||||
overflow: hidden;
|
font-size: 0.68rem;
|
||||||
}
|
letter-spacing: 0.18em;
|
||||||
.scan-tick::after {
|
text-transform: uppercase;
|
||||||
content: "";
|
color: var(--color-ink-faint);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill {
|
.pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.4rem;
|
||||||
padding: 0.2rem 0.7rem;
|
padding: 0.22rem 0.7rem;
|
||||||
border-radius: var(--radius-r-pill);
|
border-radius: var(--radius-r-pill);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 600;
|
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 {
|
.mono {
|
||||||
@@ -186,26 +159,58 @@
|
|||||||
.display {
|
.display {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.03em;
|
||||||
line-height: 1.02;
|
line-height: 0.96;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* text tone helpers */
|
@layer utilities {
|
||||||
.text-signal { color: var(--color-signal); }
|
.text-signal { color: var(--color-signal); }
|
||||||
.text-amber { color: var(--color-amber); }
|
.text-amber { color: var(--color-amber); }
|
||||||
.text-vermilion { color: var(--color-vermilion); }
|
.text-vermilion { color: var(--color-vermilion); }
|
||||||
.text-ink-soft { color: var(--color-ink-soft); }
|
.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 {
|
.ring-focus {
|
||||||
transition: box-shadow 0.18s ease;
|
transition: box-shadow 0.18s ease, border-color 0.18s ease;
|
||||||
}
|
}
|
||||||
.ring-focus:hover {
|
.ring-focus:hover {
|
||||||
box-shadow: 0 0 0 1px var(--color-signal-glow);
|
box-shadow: 0 0 0 1px var(--color-signal-glow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Keyframes ──────────────────────────── */
|
/* ── Keyframes ── */
|
||||||
@keyframes scan {
|
@keyframes scan {
|
||||||
0% { background-position: 200% 0; }
|
0% { background-position: 200% 0; }
|
||||||
100% { background-position: -200% 0; }
|
100% { background-position: -200% 0; }
|
||||||
@@ -216,54 +221,52 @@
|
|||||||
60% { transform: scaleY(0.5); }
|
60% { transform: scaleY(0.5); }
|
||||||
}
|
}
|
||||||
@keyframes fade-up {
|
@keyframes fade-up {
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
@keyframes spin-disc {
|
@keyframes spin-disc {
|
||||||
from { transform: rotate(0deg); }
|
from { transform: rotate(0deg); }
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
/* kept for live status dots */
|
|
||||||
@keyframes pulse-ring {
|
@keyframes pulse-ring {
|
||||||
0% { transform: scale(0.8); opacity: 1; }
|
0% { transform: scale(0.85); opacity: 1; }
|
||||||
100% { transform: scale(2.5); opacity: 0; }
|
100% { transform: scale(2.6); opacity: 0; }
|
||||||
}
|
}
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% { background-position: -200% 0; }
|
0% { background-position: -200% 0; }
|
||||||
100% { background-position: 200% 0; }
|
100% { background-position: 200% 0; }
|
||||||
}
|
}
|
||||||
|
@keyframes breathe {
|
||||||
|
0%, 100% { opacity: 0.55; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
.animate-eq {
|
.animate-eq {
|
||||||
animation: eq 0.9s ease-in-out infinite;
|
animation: eq 0.9s ease-in-out infinite;
|
||||||
transform-origin: bottom;
|
transform-origin: bottom;
|
||||||
}
|
}
|
||||||
.animate-spin-disc {
|
.animate-spin-disc { animation: spin-disc 9s linear infinite; }
|
||||||
animation: spin-disc 8s linear infinite;
|
.animate-spin-disc.paused { animation-play-state: paused; }
|
||||||
}
|
.animate-pulse-ring { animation: pulse-ring 1.6s ease-out infinite; }
|
||||||
.animate-spin-disc.paused {
|
|
||||||
animation-play-state: paused;
|
|
||||||
}
|
|
||||||
.animate-pulse-ring {
|
|
||||||
animation: pulse-ring 1.5s ease-out infinite;
|
|
||||||
}
|
|
||||||
.animate-shimmer {
|
.animate-shimmer {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
transparent,
|
transparent,
|
||||||
oklch(0.78 0.17 125 / 0.08),
|
oklch(0.86 0.19 128 / 0.1),
|
||||||
transparent
|
transparent
|
||||||
);
|
);
|
||||||
background-size: 200% 100%;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.scan-tick::after,
|
.scan-line::after,
|
||||||
.animate-eq,
|
.animate-eq,
|
||||||
.animate-spin-disc,
|
.animate-spin-disc,
|
||||||
.animate-pulse-ring,
|
.animate-pulse-ring,
|
||||||
.animate-shimmer {
|
.animate-shimmer,
|
||||||
|
.animate-breathe {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
html { scroll-behavior: auto; }
|
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 type { DailyActivityPoint } from "@/lib/types";
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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({
|
export function AreaActivity({
|
||||||
data,
|
daily,
|
||||||
height = 160,
|
height = 200,
|
||||||
stroke = "var(--color-signal)",
|
}: {
|
||||||
className,
|
daily: DailyActivityPoint[];
|
||||||
label,
|
height?: number;
|
||||||
}: AreaActivityProps) {
|
}) {
|
||||||
const id = useId().replace(/:/g, "");
|
const w = 720;
|
||||||
const reduce = useReducedMotion();
|
const pad = 8;
|
||||||
const width = 600;
|
const n = daily.length;
|
||||||
if (data.length === 0)
|
const max = Math.max(...daily.map((d) => d.messages), 1);
|
||||||
return <div className={className} style={{ height }} />;
|
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 msgLine = daily.map((d, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(d.messages).toFixed(1)}`).join(" ");
|
||||||
const stepX = width / Math.max(data.length - 1, 1);
|
const flagLine = daily.map((d, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(d.flagged).toFixed(1)}`).join(" ");
|
||||||
const pts = data.map((d, i) => {
|
const msgArea = `${msgLine} L${x(n - 1).toFixed(1)},${height - pad} L${x(0).toFixed(1)},${height - pad} Z`;
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg viewBox={`0 0 ${w} ${height}`} preserveAspectRatio="none" className="w-full" style={{ height }}>
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
|
||||||
className={className}
|
|
||||||
style={{ width: "100%", height }}
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
role="img"
|
|
||||||
aria-label={label ?? "Activity chart"}
|
|
||||||
>
|
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={`area-${id}`} x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="area-msg" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.32" />
|
<stop offset="0%" stopColor="var(--color-signal)" stopOpacity="0.3" />
|
||||||
<stop offset="100%" stopColor={stroke} stopOpacity="0.02" />
|
<stop offset="100%" stopColor="var(--color-signal)" stopOpacity="0" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<motion.path
|
{[0.25, 0.5, 0.75].map((g) => (
|
||||||
d={area}
|
<line key={g} x1={pad} x2={w - pad} y1={height * g} y2={height * g} stroke="var(--color-hairline)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||||
fill={`url(#area-${id})`}
|
))}
|
||||||
initial={reduce ? false : { pathLength: 0, opacity: 0.4 }}
|
<path d={msgArea} fill="url(#area-msg)" />
|
||||||
animate={{ pathLength: 1, opacity: 1 }}
|
<path d={msgLine} fill="none" stroke="var(--color-signal)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
|
||||||
transition={{ duration: 0.9, ease: [0.22, 1, 0.36, 1] }}
|
<path d={flagLine} fill="none" stroke="var(--color-vermilion)" strokeWidth={1.5} vectorEffect="non-scaling-stroke" strokeDasharray="3 3" />
|
||||||
/>
|
{daily.map((d, i) =>
|
||||||
<motion.path
|
i % 2 === 0 ? (
|
||||||
d={line}
|
<text key={d.day} x={x(i)} y={height - 1} fill="var(--color-ink-faint)" fontSize={9} textAnchor="middle" className="mono">
|
||||||
fill="none"
|
{d.day.slice(5)}
|
||||||
stroke={stroke}
|
</text>
|
||||||
strokeWidth={2}
|
) : null,
|
||||||
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 }}
|
|
||||||
/>
|
|
||||||
</svg>
|
</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 { cn } from "@/lib/utils";
|
||||||
|
|
||||||
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)",
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/** Circular progress gauge. value 0..1. */
|
||||||
export function RadialGauge({
|
export function RadialGauge({
|
||||||
value,
|
value,
|
||||||
size = 160,
|
|
||||||
label,
|
label,
|
||||||
sublabel,
|
sublabel,
|
||||||
tone = "signal",
|
tone = "signal",
|
||||||
}: RadialGaugeProps) {
|
size = 120,
|
||||||
const id = useId().replace(/:/g, "");
|
}: {
|
||||||
const reduce = useReducedMotion();
|
value: number;
|
||||||
const stroke = 12;
|
label: string;
|
||||||
const r = (size - stroke) / 2;
|
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 c = 2 * Math.PI * r;
|
||||||
const pct = Math.max(0, Math.min(1, value));
|
|
||||||
const dash = c * pct;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
|
||||||
className="relative inline-flex items-center justify-center"
|
<svg width={size} height={size} className="-rotate-90">
|
||||||
style={{ width: size, height: size }}
|
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--color-hairline)" strokeWidth={8} />
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
viewBox={`0 0 ${size} ${size}`}
|
|
||||||
className="-rotate-90"
|
|
||||||
role="img"
|
|
||||||
aria-label={`${Math.round(pct * 100)}% ${label ?? "gauge"}`}
|
|
||||||
>
|
|
||||||
<circle
|
<circle
|
||||||
cx={size / 2}
|
cx={size / 2}
|
||||||
cy={size / 2}
|
cy={size / 2}
|
||||||
r={r}
|
r={r}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="var(--color-hairline)"
|
stroke={stroke}
|
||||||
strokeWidth={stroke}
|
strokeWidth={8}
|
||||||
/>
|
|
||||||
<motion.circle
|
|
||||||
cx={size / 2}
|
|
||||||
cy={size / 2}
|
|
||||||
r={r}
|
|
||||||
fill="none"
|
|
||||||
stroke={toneColor[tone]}
|
|
||||||
strokeWidth={stroke}
|
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeDasharray={c}
|
strokeDasharray={c}
|
||||||
initial={reduce ? false : { strokeDashoffset: c }}
|
strokeDashoffset={c * (1 - v)}
|
||||||
animate={{ strokeDashoffset: c - dash }}
|
style={{ transition: "stroke-dashoffset 0.6s ease", filter: `drop-shadow(0 0 6px ${stroke})` }}
|
||||||
transition={{ duration: 1, ease: [0.22, 1, 0.36, 1] }}
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
<span
|
<span className={cn("display text-xl", tone === "vermilion" && "text-vermilion", tone === "amber" && "text-amber", tone === "signal" && "text-signal")}>
|
||||||
className="display text-2xl mono"
|
|
||||||
style={{ color: toneColor[tone] }}
|
|
||||||
>
|
|
||||||
{Math.round(pct * 100)}%
|
|
||||||
</span>
|
|
||||||
{label && (
|
|
||||||
<span className="text-[11px] font-medium text-[var(--color-ink-soft)] uppercase tracking-wide">
|
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
{sublabel && <span className="mono text-[0.6rem] text-ink-faint">{sublabel}</span>}
|
||||||
{sublabel && (
|
|
||||||
<span className="text-[10px] text-[var(--color-ink-soft)]/70">
|
|
||||||
{sublabel}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</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 { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { useId } from "react";
|
|
||||||
|
|
||||||
export interface SparklineProps {
|
|
||||||
data: number[];
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
stroke?: string;
|
|
||||||
className?: string;
|
|
||||||
fill?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/** Minimal sparkline. Pure SVG, scales to container width. */
|
||||||
export function Sparkline({
|
export function Sparkline({
|
||||||
data,
|
values,
|
||||||
width = 120,
|
|
||||||
height = 36,
|
|
||||||
stroke = "var(--color-signal)",
|
|
||||||
className,
|
className,
|
||||||
|
stroke = "var(--color-signal)",
|
||||||
fill = true,
|
fill = true,
|
||||||
}: SparklineProps) {
|
height = 40,
|
||||||
const id = useId().replace(/:/g, "");
|
}: {
|
||||||
if (data.length < 2)
|
values: number[];
|
||||||
return (
|
className?: string;
|
||||||
<svg
|
stroke?: string;
|
||||||
width={width}
|
fill?: boolean;
|
||||||
height={height}
|
height?: number;
|
||||||
className={className}
|
}) {
|
||||||
role="img"
|
if (values.length === 0) return null;
|
||||||
aria-label="No data"
|
const w = 100;
|
||||||
/>
|
const max = Math.max(...values, 1);
|
||||||
);
|
const min = Math.min(...values, 0);
|
||||||
|
|
||||||
const min = Math.min(...data);
|
|
||||||
const max = Math.max(...data);
|
|
||||||
const span = max - min || 1;
|
const span = max - min || 1;
|
||||||
const stepX = width / (data.length - 1);
|
const pts = values.map((v, i) => {
|
||||||
const pts = data.map((v, i) => {
|
const x = (i / (values.length - 1)) * w;
|
||||||
const x = i * stepX;
|
|
||||||
const y = height - ((v - min) / span) * (height - 4) - 2;
|
const y = height - ((v - min) / span) * (height - 4) - 2;
|
||||||
return [x, y] as const;
|
return [x, y] as const;
|
||||||
});
|
});
|
||||||
const line = pts
|
const line = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(2)},${p[1].toFixed(2)}`).join(" ");
|
||||||
.map(
|
const area = `${line} L${w},${height} L0,${height} Z`;
|
||||||
(p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(1)},${p[1].toFixed(1)}`,
|
const id = `spark-${stroke.replace(/[^a-z0-9]/gi, "")}`;
|
||||||
)
|
|
||||||
.join(" ");
|
|
||||||
const area = `${line} L${width},${height} L0,${height} Z`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg viewBox={`0 0 ${w} ${height}`} preserveAspectRatio="none" className={cn("w-full", className)} style={{ height }}>
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
|
||||||
className={className}
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
role="img"
|
|
||||||
aria-label="Trend sparkline"
|
|
||||||
>
|
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={`spark-${id}`} x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.28" />
|
<stop offset="0%" stopColor={stroke} stopOpacity="0.35" />
|
||||||
<stop offset="100%" stopColor={stroke} stopOpacity="0" />
|
<stop offset="100%" stopColor={stroke} stopOpacity="0" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
{fill && <path d={area} fill={`url(#spark-${id})`} />}
|
{fill && <path d={area} fill={`url(#${id})`} />}
|
||||||
<path
|
<path d={line} fill="none" stroke={stroke} strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
|
||||||
d={line}
|
|
||||||
fill="none"
|
|
||||||
stroke={stroke}
|
|
||||||
strokeWidth={1.6}
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +1,37 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { motion, useReducedMotion } from "motion/react";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface WaveformProps {
|
/** Live equalizer bars. `bars` are 0..1 levels. */
|
||||||
seed: string | number;
|
export function Equalizer({
|
||||||
bars?: number;
|
bars,
|
||||||
height?: number;
|
color = "var(--color-signal)",
|
||||||
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,
|
|
||||||
className,
|
className,
|
||||||
tone = "signal",
|
}: {
|
||||||
}: WaveformProps) {
|
bars: number[];
|
||||||
const reduce = useReducedMotion();
|
color?: string;
|
||||||
const values = useMemo(() => {
|
className?: string;
|
||||||
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];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn("flex h-10 items-end gap-[3px]", className)}>
|
||||||
className={cn("flex items-end gap-[2px]", className)}
|
{bars.length === 0 ? (
|
||||||
style={{ height }}
|
<div className="flex w-full items-end gap-[3px]">
|
||||||
aria-hidden
|
{Array.from({ length: 28 }).map((_, i) => (
|
||||||
>
|
<span key={i} className="flex-1 rounded-full bg-white/10" style={{ height: "12%" }} />
|
||||||
{values.map((v, i) => (
|
))}
|
||||||
<motion.span
|
</div>
|
||||||
|
) : (
|
||||||
|
bars.map((b, i) => (
|
||||||
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
className="flex-1 rounded-[2px]"
|
className="flex-1 rounded-full"
|
||||||
style={{ background: color, height: `${Math.max(8, v * 100)}%` }}
|
style={{
|
||||||
initial={reduce ? false : { scaleY: 0.2, opacity: 0 }}
|
height: `${Math.max(6, b * 100)}%`,
|
||||||
animate={{ scaleY: 1, opacity: 1 }}
|
background: color,
|
||||||
whileHover={{ scaleY: 1.15 }}
|
boxShadow: b > 0.05 ? `0 0 8px ${color}` : "none",
|
||||||
transition={{
|
transition: "height 90ms linear",
|
||||||
duration: 0.3,
|
|
||||||
delay: reduce ? 0 : i * 0.006,
|
|
||||||
ease: [0.22, 1, 0.36, 1],
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Eraser, Send, Sparkles } from "lucide-react";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { useChatbot } from "./chatbot-context";
|
|
||||||
|
|
||||||
interface ChatPanelProps {
|
|
||||||
inputRef?: React.RefObject<HTMLInputElement | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(ts: string): string {
|
|
||||||
const d = new Date(ts);
|
|
||||||
if (Number.isNaN(d.getTime())) return "";
|
|
||||||
return d.toLocaleTimeString("id-ID", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const SUGGESTIONS = [
|
|
||||||
"Gimana suasana server hari ini?",
|
|
||||||
"Channel mana yang paling ramai?",
|
|
||||||
"Total pesan di server?",
|
|
||||||
"Ada pesan bermasalah?",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
|
|
||||||
const { messages, sendMessage, clearMessages, isTyping } = useChatbot();
|
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
|
||||||
const internalInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const inputRef = externalInputRef ?? internalInputRef;
|
|
||||||
|
|
||||||
// Auto-scroll to bottom on new messages
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: re-run on message arrival; scroll is a visual effect keyed on new content
|
|
||||||
useEffect(() => {
|
|
||||||
if (listRef.current) {
|
|
||||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [messages, isTyping]);
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const input = inputRef.current;
|
|
||||||
if (!input || !input.value.trim() || isTyping) return;
|
|
||||||
sendMessage(input.value);
|
|
||||||
input.value = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSuggestion = (text: string) => {
|
|
||||||
if (isTyping) return;
|
|
||||||
sendMessage(text);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
{/* Chat messages */}
|
|
||||||
<div
|
|
||||||
ref={listRef}
|
|
||||||
className="flex-1 space-y-1.5 overflow-y-auto px-2 py-2"
|
|
||||||
>
|
|
||||||
{messages.length === 0 ? (
|
|
||||||
<div className="flex h-full flex-col justify-center gap-3 px-3 text-center">
|
|
||||||
<p className="text-[11px] text-[var(--color-ink-soft)]">
|
|
||||||
Halo! 👋 Aku tau soal server ini — pesan, flag, dan aktivitas.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap justify-center gap-1.5">
|
|
||||||
{SUGGESTIONS.map((s) => (
|
|
||||||
<button
|
|
||||||
key={s}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleSuggestion(s)}
|
|
||||||
disabled={isTyping}
|
|
||||||
className="flex items-center gap-1 rounded-full border border-[var(--color-hairline)] bg-[var(--color-surface-2)] px-2.5 py-1 text-[10px] text-[var(--color-ink-soft)] transition-colors hover:bg-[var(--color-signal)] hover:text-[var(--color-signal-ink)] disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<Sparkles className="size-2.5 text-[var(--color-signal)]" />
|
|
||||||
{s}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
messages.map((msg, i) => (
|
|
||||||
<div
|
|
||||||
key={`${msg.timestamp}-${i}`}
|
|
||||||
className={`flex flex-col ${msg.role === "user" ? "items-end" : "items-start"}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`max-w-[85%] break-words whitespace-pre-wrap rounded-xl px-2.5 py-1.5 text-[11px] leading-relaxed ${
|
|
||||||
msg.role === "user"
|
|
||||||
? "rounded-br-sm bg-[var(--color-signal)] text-[var(--color-signal-ink)]"
|
|
||||||
: "rounded-bl-sm bg-[var(--color-surface-2)] text-[var(--color-ink)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{msg.content}
|
|
||||||
</div>
|
|
||||||
<span className="mt-0.5 px-1 text-[9px] text-[var(--color-ink-soft)]">
|
|
||||||
{formatTime(msg.timestamp)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
{isTyping && (
|
|
||||||
<div className="flex justify-start">
|
|
||||||
<div className="rounded-xl rounded-bl-sm bg-[var(--color-surface-2)] px-2.5 py-2">
|
|
||||||
<span className="inline-flex gap-1">
|
|
||||||
<span
|
|
||||||
className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
|
|
||||||
style={{ animationDelay: "0ms" }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
|
|
||||||
style={{ animationDelay: "150ms" }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
|
|
||||||
style={{ animationDelay: "300ms" }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Input bar */}
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className="flex shrink-0 items-center gap-1.5 border-t border-[var(--color-hairline)] px-2 py-2"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
placeholder="Tanya soal server, pesan, atau statistik…"
|
|
||||||
className="flex-1 bg-transparent text-[11px] text-[var(--color-ink)] outline-none placeholder:text-[var(--color-ink-soft)]/50"
|
|
||||||
disabled={isTyping}
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
{messages.length > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void clearMessages()}
|
|
||||||
className="flex size-6 items-center justify-center rounded transition-colors hover:bg-[var(--color-surface-2)] disabled:opacity-40"
|
|
||||||
disabled={isTyping}
|
|
||||||
aria-label="Hapus riwayat chat"
|
|
||||||
title="Hapus riwayat"
|
|
||||||
>
|
|
||||||
<Eraser className="size-3 text-[var(--color-ink-soft)] hover:text-[var(--color-vermilion)]" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="flex size-7 items-center justify-center rounded-lg bg-[var(--color-signal)] text-[var(--color-signal-ink)] transition-colors hover:opacity-90 disabled:opacity-40"
|
|
||||||
disabled={isTyping}
|
|
||||||
aria-label="Kirim pesan"
|
|
||||||
title="Kirim"
|
|
||||||
>
|
|
||||||
<Send className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Bot, Minimize2 } from "lucide-react";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { ChatPanel } from "./chat-panel";
|
|
||||||
import { useChatbot } from "./chatbot-context";
|
|
||||||
|
|
||||||
export function ChatbotContainer() {
|
|
||||||
const { minimized, setMinimized } = useChatbot();
|
|
||||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
|
||||||
const [dragging, setDragging] = useState(false);
|
|
||||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const handleMouseDown = useCallback(
|
|
||||||
(e: React.MouseEvent) => {
|
|
||||||
setDragging(true);
|
|
||||||
setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
|
|
||||||
},
|
|
||||||
[position],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMouseMove = useCallback(
|
|
||||||
(e: React.MouseEvent) => {
|
|
||||||
if (!dragging) return;
|
|
||||||
setPosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y });
|
|
||||||
},
|
|
||||||
[dragging, dragStart],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMouseUp = useCallback(() => setDragging(false), []);
|
|
||||||
|
|
||||||
// Focus input when chat opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (!minimized) {
|
|
||||||
const id = setTimeout(() => inputRef.current?.focus(), 150);
|
|
||||||
return () => clearTimeout(id);
|
|
||||||
}
|
|
||||||
}, [minimized]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
// biome-ignore lint/a11y/noStaticElementInteractions: drag container — mouse-move gesture surface, not keyboard-interactive content
|
|
||||||
<div
|
|
||||||
className="fixed bottom-4 right-4 z-40 select-none"
|
|
||||||
style={{ transform: `translate(${position.x}px, ${position.y}px)` }}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
onMouseUp={handleMouseUp}
|
|
||||||
onMouseLeave={handleMouseUp}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`surface-2 overflow-hidden shadow-2xl transition-all duration-200 ${
|
|
||||||
minimized ? "h-14 w-14 cursor-pointer" : "h-[460px] w-[320px]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{minimized ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setMinimized(false)}
|
|
||||||
className="flex size-full items-center justify-center"
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
aria-label="Buka chatbot"
|
|
||||||
title="Buka chatbot"
|
|
||||||
>
|
|
||||||
<Bot className="size-6 text-[var(--color-signal)]" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
{/* Drag handle + controls */}
|
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag handle — mouse-only gesture, keyboard users use the buttons in this header */}
|
|
||||||
<div
|
|
||||||
className="flex shrink-0 cursor-grab items-center justify-between border-b border-[var(--color-hairline)] px-3 py-2 active:cursor-grabbing"
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-[var(--color-ink-soft)]">
|
|
||||||
<Bot className="size-3.5 text-[var(--color-signal)]" />
|
|
||||||
Chatbot
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-0.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setMinimized(true)}
|
|
||||||
className="flex size-6 items-center justify-center rounded transition-colors hover:bg-[var(--color-surface-2)]"
|
|
||||||
aria-label="Kecilkan chatbot"
|
|
||||||
title="Kecilkan chatbot"
|
|
||||||
>
|
|
||||||
<Minimize2 className="size-3.5 text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chat panel — always open when bubble is expanded */}
|
|
||||||
<div className="min-h-0 flex-1">
|
|
||||||
<ChatPanel inputRef={inputRef} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
type ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useChatbotUserId } from "@/hooks/use-chatbot-user";
|
|
||||||
import { chatbotApi } from "@/lib/api";
|
|
||||||
|
|
||||||
export type ChatbotExpression =
|
|
||||||
| "idle"
|
|
||||||
| "listening"
|
|
||||||
| "surprise"
|
|
||||||
| "happy"
|
|
||||||
| "sad"
|
|
||||||
| "talking";
|
|
||||||
|
|
||||||
interface ChatbotMessage {
|
|
||||||
role: "user" | "assistant";
|
|
||||||
content: string;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatbotContextValue {
|
|
||||||
/** Expression the chatbot avatar should display */
|
|
||||||
expression: ChatbotExpression;
|
|
||||||
setExpression: (expr: ChatbotExpression) => void;
|
|
||||||
|
|
||||||
/** Whether the enlarged bubble is minimized to a small icon */
|
|
||||||
minimized: boolean;
|
|
||||||
setMinimized: (v: boolean) => void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use `minimized` / `setMinimized` instead.
|
|
||||||
* Legacy toggle alias kept for compatibility.
|
|
||||||
*/
|
|
||||||
isOpen: boolean;
|
|
||||||
setOpen: (open: boolean) => void;
|
|
||||||
toggle: () => void;
|
|
||||||
|
|
||||||
/** Chat messages with real API backend */
|
|
||||||
messages: ChatbotMessage[];
|
|
||||||
sendMessage: (content: string) => Promise<void>;
|
|
||||||
clearMessages: () => Promise<void>;
|
|
||||||
isTyping: boolean;
|
|
||||||
|
|
||||||
/** Active guild context sent to the backend so answers reference the server */
|
|
||||||
guildId: string;
|
|
||||||
setGuildId: (g: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChatbotContext = createContext<ChatbotContextValue | null>(null);
|
|
||||||
|
|
||||||
export function ChatbotProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [expression, setExpression] = useState<ChatbotExpression>("idle");
|
|
||||||
const [minimized, setMinimized] = useState(true);
|
|
||||||
const [messages, setMessages] = useState<ChatbotMessage[]>([]);
|
|
||||||
const [isTyping, setIsTyping] = useState(false);
|
|
||||||
const [guildId, setGuildId] = useState("");
|
|
||||||
const historyFetched = useRef(false);
|
|
||||||
const userId = useChatbotUserId();
|
|
||||||
|
|
||||||
// Derived legacy state
|
|
||||||
const isOpen = !minimized;
|
|
||||||
|
|
||||||
const setOpen = useCallback((open: boolean) => {
|
|
||||||
setMinimized(!open);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
|
||||||
setMinimized((prev) => !prev);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load chat history on first mount (per-device user history)
|
|
||||||
useEffect(() => {
|
|
||||||
if (historyFetched.current || !userId) return;
|
|
||||||
historyFetched.current = true;
|
|
||||||
|
|
||||||
chatbotApi
|
|
||||||
.getHistory(userId)
|
|
||||||
.then((res) => {
|
|
||||||
// Backend returns rows {user_message, bot_response, created_at} —
|
|
||||||
// interleave each user message with its bot reply.
|
|
||||||
const withReplies: ChatbotMessage[] = [];
|
|
||||||
for (const row of res.history ?? []) {
|
|
||||||
withReplies.push({
|
|
||||||
role: "user",
|
|
||||||
content: row.user_message,
|
|
||||||
timestamp: row.created_at,
|
|
||||||
});
|
|
||||||
withReplies.push({
|
|
||||||
role: "assistant",
|
|
||||||
content: row.bot_response,
|
|
||||||
timestamp: row.created_at,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setMessages(withReplies);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// API may not be available yet — silently ignore
|
|
||||||
});
|
|
||||||
}, [userId]);
|
|
||||||
|
|
||||||
const sendMessage = useCallback(
|
|
||||||
async (content: string) => {
|
|
||||||
if (!content.trim()) return;
|
|
||||||
|
|
||||||
const userMsg: ChatbotMessage = {
|
|
||||||
role: "user",
|
|
||||||
content: content.trim(),
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
setMessages((prev) => [...prev, userMsg]);
|
|
||||||
setExpression("listening");
|
|
||||||
setIsTyping(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Send active guild as context so the backend can answer with
|
|
||||||
// real server insights (serverInsights path in chatbot.service),
|
|
||||||
// and the per-device user id so the history stays isolated.
|
|
||||||
const res = await chatbotApi.send(content.trim(), guildId, userId);
|
|
||||||
const botMsg: ChatbotMessage = {
|
|
||||||
role: "assistant",
|
|
||||||
content: res.response,
|
|
||||||
timestamp: res.timestamp ?? new Date().toISOString(),
|
|
||||||
};
|
|
||||||
setMessages((prev) => [...prev, botMsg]);
|
|
||||||
setExpression("happy");
|
|
||||||
} catch {
|
|
||||||
const errorMsg: ChatbotMessage = {
|
|
||||||
role: "assistant",
|
|
||||||
content:
|
|
||||||
"Maaf, aku lagi gagal nyambung ke server. Coba tanya lagi ya 🙏",
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
setMessages((prev) => [...prev, errorMsg]);
|
|
||||||
setExpression("sad");
|
|
||||||
} finally {
|
|
||||||
setIsTyping(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[guildId, userId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearMessages = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await chatbotApi.clearHistory(userId);
|
|
||||||
} catch {
|
|
||||||
// Best-effort clear
|
|
||||||
}
|
|
||||||
setMessages([]);
|
|
||||||
}, [userId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChatbotContext.Provider
|
|
||||||
value={{
|
|
||||||
expression,
|
|
||||||
setExpression,
|
|
||||||
minimized,
|
|
||||||
setMinimized,
|
|
||||||
isOpen,
|
|
||||||
setOpen,
|
|
||||||
toggle,
|
|
||||||
messages,
|
|
||||||
sendMessage,
|
|
||||||
clearMessages,
|
|
||||||
isTyping,
|
|
||||||
guildId,
|
|
||||||
setGuildId,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ChatbotContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useChatbot(): ChatbotContextValue {
|
|
||||||
const ctx = useContext(ChatbotContext);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error("useChatbot must be used within a ChatbotProvider");
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Bot, Send, X, MessageCircle } from "lucide-react";
|
||||||
|
import { chatbotApi } from "@/lib/api";
|
||||||
|
import { useChatbotUserId } from "@/hooks/use-chatbot-user";
|
||||||
|
import { GlassPanel, Input, Button, Avatar } from "@/components/primitives";
|
||||||
|
import { toast } from "@/components/primitives";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface Msg {
|
||||||
|
role: "user" | "bot";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Chatbot() {
|
||||||
|
const userId = useChatbotUserId();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [msgs, setMsgs] = useState<Msg[]>([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !userId) return;
|
||||||
|
chatbotApi
|
||||||
|
.getHistory(userId)
|
||||||
|
.then((res) => {
|
||||||
|
setMsgs(
|
||||||
|
res.history
|
||||||
|
.slice(-12)
|
||||||
|
.flatMap((h) => [
|
||||||
|
{ role: "user" as const, content: h.user_message },
|
||||||
|
{ role: "bot" as const, content: h.bot_response },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [open, userId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
|
||||||
|
}, [msgs, loading]);
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
const text = input.trim();
|
||||||
|
if (!text || loading || !userId) return;
|
||||||
|
setInput("");
|
||||||
|
setMsgs((m) => [...m, { role: "user", content: text }]);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await chatbotApi.send(text, undefined, userId);
|
||||||
|
setMsgs((m) => [...m, { role: "bot", content: res.response }]);
|
||||||
|
} catch (e) {
|
||||||
|
toast({ title: "Chat error", description: String(e), tone: "vermilion" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Open assistant"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="fixed bottom-5 right-5 z-50 flex items-center justify-center rounded-full bg-signal text-signal-ink shadow-[0_10px_30px_-8px_var(--color-signal-glow)] transition-transform hover:scale-105"
|
||||||
|
style={{ width: 52, height: 52 }}
|
||||||
|
>
|
||||||
|
{open ? <X className="size-5" /> : <MessageCircle className="size-5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<GlassPanel
|
||||||
|
className="fixed bottom-20 right-5 z-50 flex w-[min(92vw,360px)] flex-col p-0"
|
||||||
|
style={{ animation: "fade-up 0.16s ease", height: 460 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 border-b border-hairline px-4 py-3">
|
||||||
|
<span className="flex size-8 items-center justify-center rounded-full bg-signal/15 text-signal">
|
||||||
|
<Bot className="size-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-ink">GMW Assistant</div>
|
||||||
|
<div className="mono text-[0.6rem] text-ink-faint">context-aware</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={listRef} className="flex-1 space-y-3 overflow-y-auto px-4 py-3">
|
||||||
|
{msgs.length === 0 && (
|
||||||
|
<div className="py-8 text-center text-xs text-ink-faint">
|
||||||
|
Ask about moderation, voice, or media.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msgs.map((m, i) => (
|
||||||
|
<div key={i} className={cn("flex gap-2", m.role === "user" ? "justify-end" : "justify-start")}>
|
||||||
|
{m.role === "bot" && <Avatar name="GMW" size={26} className="mt-0.5 bg-signal/15 text-signal" />}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"max-w-[80%] rounded-2xl px-3 py-2 text-sm",
|
||||||
|
m.role === "user"
|
||||||
|
? "rounded-br-sm bg-signal/20 text-ink"
|
||||||
|
: "rounded-bl-sm bg-white/5 text-ink-soft",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Avatar name="GMW" size={26} className="bg-signal/15 text-signal" />
|
||||||
|
<div className="rounded-2xl rounded-bl-sm bg-white/5 px-3 py-2 text-sm text-ink-faint">…</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 border-t border-hairline p-3">
|
||||||
|
<Input
|
||||||
|
placeholder="Message…"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && send()}
|
||||||
|
/>
|
||||||
|
<Button variant="primary" size="icon" onClick={send} disabled={loading}>
|
||||||
|
<Send className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { ChatPanel } from "./chat-panel";
|
|
||||||
export { ChatbotContainer } from "./chatbot-container";
|
|
||||||
export { ChatbotProvider, useChatbot } from "./chatbot-context";
|
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
CornerDownLeft,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
Moon,
|
||||||
|
Sun,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { navItems } from "@/lib/navigation";
|
||||||
|
import { GlassPanel } from "@/components/primitives";
|
||||||
|
|
||||||
|
interface Command {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
run: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandPalette() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
|
||||||
|
const commands = useMemo<Command[]>(() => {
|
||||||
|
const nav: Command[] = navItems.map((n) => ({
|
||||||
|
id: `nav:${n.href}`,
|
||||||
|
label: `Go to ${n.label}`,
|
||||||
|
hint: n.href,
|
||||||
|
icon: <n.icon className="size-4 text-signal" />,
|
||||||
|
run: () => router.push(n.href),
|
||||||
|
}));
|
||||||
|
const actions: Command[] = [
|
||||||
|
{
|
||||||
|
id: "act:theme",
|
||||||
|
label: "Toggle theme",
|
||||||
|
hint: "appearance",
|
||||||
|
icon:
|
||||||
|
theme === "light" ? (
|
||||||
|
<Moon className="size-4 text-signal" />
|
||||||
|
) : (
|
||||||
|
<Sun className="size-4 text-signal" />
|
||||||
|
),
|
||||||
|
run: () => setTheme(theme === "light" ? "dark" : "light"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return [...nav, ...actions];
|
||||||
|
}, [router, theme, setTheme]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return commands;
|
||||||
|
return commands.filter(
|
||||||
|
(c) => c.label.toLowerCase().includes(q) || c.hint.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [commands, query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((o) => !o);
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
const onOpen = () => setOpen(true);
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
window.addEventListener("command-palette:open", onOpen);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
window.removeEventListener("command-palette:open", onOpen);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setQuery("");
|
||||||
|
setActive(0);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActive(0);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const runAt = (i: number) => {
|
||||||
|
const c = filtered[i];
|
||||||
|
if (!c) return;
|
||||||
|
setOpen(false);
|
||||||
|
c.run();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[90] flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm"
|
||||||
|
onMouseDown={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<GlassPanel
|
||||||
|
className="w-full max-w-[560px] overflow-hidden p-0"
|
||||||
|
style={{ animation: "fade-up 0.14s ease" }}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 border-b border-hairline px-4 py-3">
|
||||||
|
<Search className="size-4 text-ink-faint" />
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActive((a) => Math.min(a + 1, filtered.length - 1));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActive((a) => Math.max(a - 1, 0));
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
runAt(active);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Type a command or search…"
|
||||||
|
className="flex-1 bg-transparent text-sm text-ink outline-none placeholder:text-ink-faint"
|
||||||
|
/>
|
||||||
|
<kbd className="mono rounded bg-white/8 px-1.5 py-0.5 text-[0.6rem] text-ink-faint">ESC</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[50vh] overflow-y-auto p-2">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-xs text-ink-faint">No commands</div>
|
||||||
|
) : (
|
||||||
|
filtered.map((c, i) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onMouseEnter={() => setActive(i)}
|
||||||
|
onClick={() => runAt(i)}
|
||||||
|
className={`flex w-full items-center gap-3 rounded-[10px] px-3 py-2.5 text-left text-sm transition-colors ${
|
||||||
|
i === active ? "bg-signal/12 text-ink" : "text-ink-soft hover:bg-white/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex size-7 items-center justify-center rounded-[8px] bg-white/5">
|
||||||
|
{c.icon}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1">{c.label}</span>
|
||||||
|
<span className="mono text-[0.65rem] text-ink-faint">{c.hint}</span>
|
||||||
|
{i === active && <CornerDownLeft className="size-3.5 text-ink-faint" />}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 border-t border-hairline px-4 py-2 text-[0.65rem] text-ink-faint">
|
||||||
|
<span className="flex items-center gap-1"><ArrowUp className="size-3" /><ArrowDown className="size-3" /> navigate</span>
|
||||||
|
<span className="flex items-center gap-1"><CornerDownLeft className="size-3" /> select</span>
|
||||||
|
<span className="ml-auto mono">⌘K</span>
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DashCommandLine — sticky bottom prompt for ops actions.
|
|
||||||
*
|
|
||||||
* The signature element of the new dashboard. Pure mono input; parses a
|
|
||||||
* slash-prefixed verb and dispatches to existing APIs or client-side
|
|
||||||
* actions. Autocomplete is intentionally light (suggestions render in
|
|
||||||
* monospace below the input).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
type FormEvent,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type CommandVerb = "mute" | "jump" | "find" | "clear";
|
|
||||||
|
|
||||||
interface CommandResult {
|
|
||||||
ok: boolean;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VERBS: CommandVerb[] = ["mute", "jump", "find", "clear"];
|
|
||||||
|
|
||||||
interface DashCommandLineProps {
|
|
||||||
onCommand?: (verb: CommandVerb, args: string) => CommandResult | undefined;
|
|
||||||
placeholder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashCommandLine({
|
|
||||||
onCommand,
|
|
||||||
placeholder = "type a command — /mute @user 10m, /jump #channel, /find text, /clear",
|
|
||||||
}: DashCommandLineProps) {
|
|
||||||
const [value, setValue] = useState("");
|
|
||||||
const [history, setHistory] = useState<string[]>([]);
|
|
||||||
const [_historyIdx, setHistoryIdx] = useState<number>(-1);
|
|
||||||
const [result, setResult] = useState<CommandResult | null>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
// Global "/" focuses the command line (skip when typing in another input).
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: KeyboardEvent) => {
|
|
||||||
if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
||||||
const t = e.target as HTMLElement | null;
|
|
||||||
const tag = t?.tagName?.toLowerCase();
|
|
||||||
if (tag === "input" || tag === "textarea" || t?.isContentEditable) return;
|
|
||||||
e.preventDefault();
|
|
||||||
inputRef.current?.focus();
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", handler);
|
|
||||||
return () => window.removeEventListener("keydown", handler);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const suggestions = useMemo(() => {
|
|
||||||
const trimmed = value.trimStart();
|
|
||||||
if (!trimmed.startsWith("/")) return [] as CommandVerb[];
|
|
||||||
const verb = trimmed.slice(1).split(/\s+/)[0]?.toLowerCase() ?? "";
|
|
||||||
if (!verb) return VERBS;
|
|
||||||
return VERBS.filter((v) => v.startsWith(verb));
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
const submit = useCallback(
|
|
||||||
(raw: string) => {
|
|
||||||
const trimmed = raw.trim();
|
|
||||||
if (!trimmed.startsWith("/")) {
|
|
||||||
setResult({ ok: false, message: "commands start with /" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const body = trimmed.slice(1);
|
|
||||||
const [verbRaw, ...rest] = body.split(/\s+/);
|
|
||||||
const verb = (verbRaw?.toLowerCase() ?? "") as CommandVerb;
|
|
||||||
if (!VERBS.includes(verb)) {
|
|
||||||
setResult({
|
|
||||||
ok: false,
|
|
||||||
message: `unknown verb "${verbRaw}" — try ${VERBS.join(", ")}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const args = rest.join(" ");
|
|
||||||
try {
|
|
||||||
const ret = onCommand?.(verb, args);
|
|
||||||
const message =
|
|
||||||
(ret && typeof ret === "object" && "message" in ret && ret.message) ||
|
|
||||||
defaultMessage(verb, args);
|
|
||||||
setResult({ ok: true, message });
|
|
||||||
} catch (err) {
|
|
||||||
setResult({
|
|
||||||
ok: false,
|
|
||||||
message: err instanceof Error ? err.message : "command failed",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setHistory((h) => [trimmed, ...h].slice(0, 32));
|
|
||||||
setHistoryIdx(-1);
|
|
||||||
},
|
|
||||||
[onCommand],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onSubmit = (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (value.trim()) {
|
|
||||||
submit(value);
|
|
||||||
setValue("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
setHistoryIdx((idx) => {
|
|
||||||
const next = idx + 1;
|
|
||||||
if (next >= history.length) return idx;
|
|
||||||
setValue(history[next] ?? "");
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} else if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
setHistoryIdx((idx) => {
|
|
||||||
const next = idx - 1;
|
|
||||||
if (next < -1) return idx;
|
|
||||||
setValue(next === -1 ? "" : (history[next] ?? ""));
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
className="sticky bottom-0 z-10 flex h-11 items-center gap-2 border-t border-[var(--color-hairline)] bg-[var(--color-canvas)] px-3 font-mono text-[12px]"
|
|
||||||
role="search"
|
|
||||||
>
|
|
||||||
<span className="shrink-0 text-[var(--color-signal)]">{">"}</span>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => setValue(e.target.value)}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
placeholder={placeholder}
|
|
||||||
spellCheck={false}
|
|
||||||
autoComplete="off"
|
|
||||||
aria-label="Command line"
|
|
||||||
className="min-w-0 flex-1 bg-transparent text-[var(--color-ink)] outline-none placeholder:text-[var(--color-ink-soft)]"
|
|
||||||
/>
|
|
||||||
{result ? (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 truncate text-[10px] uppercase tracking-wide",
|
|
||||||
result.ok
|
|
||||||
? "text-[var(--color-signal)]"
|
|
||||||
: "text-[var(--color-vermilion)]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{result.message}
|
|
||||||
</span>
|
|
||||||
) : suggestions.length > 0 ? (
|
|
||||||
<span className="shrink-0 truncate text-[10px] uppercase tracking-wide text-[var(--color-ink-soft)]">
|
|
||||||
{suggestions.map((s) => `/${s}`).join(" ")}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultMessage(verb: CommandVerb, args: string): string {
|
|
||||||
switch (verb) {
|
|
||||||
case "mute":
|
|
||||||
return args ? `mute queued — ${args}` : "mute needs a target";
|
|
||||||
case "jump":
|
|
||||||
return args ? `jump queued — ${args}` : "jump needs a channel";
|
|
||||||
case "find":
|
|
||||||
return args ? `find queued — ${args}` : "find needs text";
|
|
||||||
case "clear":
|
|
||||||
return "feed cleared";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import type { AreaPoint } from "@/components/charts/area-activity";
|
|
||||||
import { AreaActivity } from "@/components/charts/area-activity";
|
|
||||||
|
|
||||||
export interface ActivityChartProps {
|
|
||||||
data: {
|
|
||||||
day: string;
|
|
||||||
messages: number;
|
|
||||||
flagged: number;
|
|
||||||
active_users: number;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ActivityChart({ data }: ActivityChartProps) {
|
|
||||||
const points: AreaPoint[] = data.map((d) => ({
|
|
||||||
label: d.day,
|
|
||||||
value: d.messages,
|
|
||||||
}));
|
|
||||||
return (
|
|
||||||
<div className="surface p-4">
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-semibold">Daily messages</h3>
|
|
||||||
<span className="pill bg-[var(--color-signal)]/15 text-[var(--color-signal)]">
|
|
||||||
{data.length}d
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ActivityChartInner points={points} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActivityChartInner({ points }: { points: AreaPoint[] }) {
|
|
||||||
return (
|
|
||||||
<AreaActivity data={points} height={180} label="Daily message activity" />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Hash, Search } from "lucide-react";
|
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import { Badge } from "@/components/primitives/badge";
|
|
||||||
import { Button } from "@/components/primitives/button";
|
|
||||||
import { Input } from "@/components/primitives/input";
|
|
||||||
import { EmptyState, LoadingSkeleton } from "@/components/shared";
|
|
||||||
import { useChannelDetail, useChannels } from "@/hooks";
|
|
||||||
import type { DashboardChannel } from "@/lib/types";
|
|
||||||
|
|
||||||
export function ChannelsSection({ guildId }: { guildId?: string }) {
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { data: channels = [], isLoading } = useChannels(guildId ?? "", search);
|
|
||||||
const { data: detail } = useChannelDetail(selectedId);
|
|
||||||
|
|
||||||
const handleSearch = useCallback((v: string) => setSearch(v), []);
|
|
||||||
|
|
||||||
if (isLoading) return <LoadingSkeleton count={8} />;
|
|
||||||
if (channels.length === 0)
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
icon={Hash}
|
|
||||||
title="No channels"
|
|
||||||
description="No channels in this guild."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-3 lg:grid-cols-[1fr_320px]">
|
|
||||||
<div className="surface flex flex-col gap-2 p-3">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-soft)]" />
|
|
||||||
<Input
|
|
||||||
mono
|
|
||||||
placeholder="search channels…"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{channels.map((c) => (
|
|
||||||
<ChannelRow
|
|
||||||
key={c.channel_id}
|
|
||||||
channel={c}
|
|
||||||
selected={selectedId === c.channel_id}
|
|
||||||
onSelect={() => setSelectedId(c.channel_id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="surface p-4">
|
|
||||||
{detail ? (
|
|
||||||
<div className="flex flex-col gap-3.5">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<span className="flex size-9 items-center justify-center rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)]">
|
|
||||||
<Hash className="size-4 text-[var(--color-ink-soft)]" />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold">
|
|
||||||
{detail.channel_name ?? detail.channel_id}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-[var(--color-ink-soft)]">
|
|
||||||
{detail.total_messages.toLocaleString()} messages
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Stat
|
|
||||||
label="Flagged"
|
|
||||||
value={detail.flagged_count}
|
|
||||||
tone="vermilion"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-[var(--color-ink-soft)]">
|
|
||||||
{detail.culture_summary ?? "No data yet."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-[var(--color-ink-soft)]">
|
|
||||||
Select a channel to inspect.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChannelRow({
|
|
||||||
channel,
|
|
||||||
selected,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
channel: DashboardChannel;
|
|
||||||
selected: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
}) {
|
|
||||||
const total = channel.total_messages + channel.flagged_count || 1;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onSelect}
|
|
||||||
className="flex items-center gap-3 rounded-[var(--radius-r-control)] px-2.5 py-2 text-left transition-colors hover:bg-[var(--color-surface-2)] data-[selected]:bg-[var(--color-signal)]/8"
|
|
||||||
data-selected={selected}
|
|
||||||
>
|
|
||||||
<Hash className="size-4 text-[var(--color-ink-soft)]" />
|
|
||||||
<span className="min-w-0 flex-1 truncate text-sm">
|
|
||||||
{channel.channel_name ?? channel.channel_id}
|
|
||||||
</span>
|
|
||||||
<span className="mono text-xs text-[var(--color-ink-soft)]">
|
|
||||||
{channel.flagged_count}/{channel.total_messages}
|
|
||||||
</span>
|
|
||||||
<div className="h-1.5 w-10 overflow-hidden rounded-full bg-[var(--color-hairline)]">
|
|
||||||
<div
|
|
||||||
className="h-full bg-[var(--color-signal)]"
|
|
||||||
style={{ width: `${(channel.flagged_count / total) * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stat({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
tone,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
tone: "vermilion";
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="surface-2 flex items-center justify-between p-2.5">
|
|
||||||
<span className="text-xs text-[var(--color-ink-soft)]">{label}</span>
|
|
||||||
<span className="mono font-semibold text-[var(--color-vermilion)]">
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import type { AreaPoint } from "@/components/charts/area-activity";
|
|
||||||
import { AreaActivity } from "@/components/charts/area-activity";
|
|
||||||
|
|
||||||
export interface HourlyActivityChartProps {
|
|
||||||
data: { hour: number; messages: number; flagged: number }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HourlyActivityChart({ data }: HourlyActivityChartProps) {
|
|
||||||
const points: AreaPoint[] = data.map((d) => ({
|
|
||||||
label: `${d.hour}:00`,
|
|
||||||
value: d.messages,
|
|
||||||
}));
|
|
||||||
return (
|
|
||||||
<div className="surface p-4">
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-semibold">Hourly distribution</h3>
|
|
||||||
<span className="text-xs text-[var(--color-ink-soft)]">
|
|
||||||
00:00 – 23:00
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<AreaActivity
|
|
||||||
data={points}
|
|
||||||
height={140}
|
|
||||||
stroke="var(--color-amber)"
|
|
||||||
label="Hourly message activity"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { RadialGauge } from "@/components/charts/radial-gauge";
|
|
||||||
import type { DashboardStats } from "@/lib/types";
|
|
||||||
|
|
||||||
export interface ModerationDonutProps {
|
|
||||||
stats?: DashboardStats;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModerationDonut({ stats }: ModerationDonutProps) {
|
|
||||||
const clean = stats?.total_clean ?? 0;
|
|
||||||
const flagged = stats?.total_flagged ?? 0;
|
|
||||||
const warned = stats?.total_warned ?? 0;
|
|
||||||
const total = clean + flagged + warned || 1;
|
|
||||||
const ratio = clean / total;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="surface flex flex-col items-center gap-3 p-4">
|
|
||||||
<h3 className="self-start text-sm font-semibold">Moderation health</h3>
|
|
||||||
<RadialGauge
|
|
||||||
value={ratio}
|
|
||||||
size={150}
|
|
||||||
label="Clean"
|
|
||||||
tone={ratio > 0.8 ? "signal" : ratio > 0.6 ? "amber" : "vermilion"}
|
|
||||||
/>
|
|
||||||
<div className="flex w-full flex-col gap-1.5 text-xs">
|
|
||||||
<Row label="Clean" value={clean} tone="var(--color-signal)" />
|
|
||||||
<Row label="Warned" value={warned} tone="var(--color-amber)" />
|
|
||||||
<Row label="Flagged" value={flagged} tone="var(--color-vermilion)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Row({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
tone,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
tone: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="flex items-center gap-2 text-[var(--color-ink-soft)]">
|
|
||||||
<span className="size-2 rounded-full" style={{ background: tone }} />
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span className="mono text-[var(--color-ink)]">
|
|
||||||
{value.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Flame, Heart, SmilePlus } from "lucide-react";
|
|
||||||
import { Avatar } from "@/components/primitives/avatar";
|
|
||||||
import { Badge } from "@/components/primitives/badge";
|
|
||||||
import { EmptyState, LoadingSkeleton } from "@/components/shared";
|
|
||||||
import { useTopReactions, useTopReactors } from "@/hooks";
|
|
||||||
|
|
||||||
export interface ReactionsSectionProps {
|
|
||||||
initialReactions?: Awaited<ReturnType<typeof useTopReactions>>["data"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReactionsSection() {
|
|
||||||
const { data: reactions, isLoading: reactionsLoading } = useTopReactions();
|
|
||||||
const { data: reactors, isLoading: reactorsLoading } = useTopReactors();
|
|
||||||
|
|
||||||
if (reactionsLoading || reactorsLoading) return <LoadingSkeleton count={5} />;
|
|
||||||
|
|
||||||
const topReactions = (reactions ?? []).slice(0, 6);
|
|
||||||
const topReactors = (reactors ?? []).slice(0, 6);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-3 lg:grid-cols-2">
|
|
||||||
<div className="surface p-4">
|
|
||||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold">
|
|
||||||
<Heart className="size-4 text-[var(--color-vermilion)]" />
|
|
||||||
Top reactions
|
|
||||||
</h3>
|
|
||||||
{topReactions.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={SmilePlus}
|
|
||||||
title="No reactions"
|
|
||||||
description="No reactions yet."
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{topReactions
|
|
||||||
.flatMap((m) =>
|
|
||||||
m.top_emojis.map((e) => ({ emoji: e.emoji, count: e.count })),
|
|
||||||
)
|
|
||||||
.reduce<{ emoji: string; count: number }[]>((acc, cur) => {
|
|
||||||
const found = acc.find((x) => x.emoji === cur.emoji);
|
|
||||||
if (found) found.count += cur.count;
|
|
||||||
else acc.push(cur);
|
|
||||||
return acc;
|
|
||||||
}, [])
|
|
||||||
.sort((a, b) => b.count - a.count)
|
|
||||||
.slice(0, 8)
|
|
||||||
.map((r) => (
|
|
||||||
<span
|
|
||||||
key={r.emoji}
|
|
||||||
className="flex items-center gap-1.5 rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] px-2.5 py-1 text-sm"
|
|
||||||
>
|
|
||||||
<span>{r.emoji}</span>
|
|
||||||
<span className="mono text-xs text-[var(--color-ink-soft)]">
|
|
||||||
{r.count}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="surface p-4">
|
|
||||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold">
|
|
||||||
<Flame className="size-4 text-[var(--color-amber)]" />
|
|
||||||
Top reactors
|
|
||||||
</h3>
|
|
||||||
{topReactors.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={SmilePlus}
|
|
||||||
title="No reactors"
|
|
||||||
description="No reactors yet."
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{topReactors.map((r) => (
|
|
||||||
<div key={r.user_id} className="flex items-center gap-3">
|
|
||||||
<Avatar name={r.username} size={28} />
|
|
||||||
<span className="flex-1 text-sm">{r.username}</span>
|
|
||||||
<Badge tone="signal">+{r.net_count}</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { Hash } from "lucide-react";
|
|
||||||
import type { TopChannel } from "@/lib/types";
|
|
||||||
|
|
||||||
export interface TopChannelsChartProps {
|
|
||||||
channels: TopChannel[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TopChannelsChart({ channels }: TopChannelsChartProps) {
|
|
||||||
const max = Math.max(...channels.map((c) => c.message_count), 1);
|
|
||||||
const top = [...channels]
|
|
||||||
.sort((a, b) => b.message_count - a.message_count)
|
|
||||||
.slice(0, 8);
|
|
||||||
return (
|
|
||||||
<div className="surface p-4">
|
|
||||||
<h3 className="mb-3 text-sm font-semibold">Top channels</h3>
|
|
||||||
<div className="flex flex-col gap-2.5">
|
|
||||||
{top.map((c) => (
|
|
||||||
<div key={c.channel_id} className="flex items-center gap-3">
|
|
||||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] text-[var(--color-ink-soft)]">
|
|
||||||
<Hash className="size-3.5" />
|
|
||||||
</span>
|
|
||||||
<span className="w-32 shrink-0 truncate text-xs text-[var(--color-ink)]">
|
|
||||||
{c.channel_name ?? c.channel_id}
|
|
||||||
</span>
|
|
||||||
<div className="relative h-2 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-2)]">
|
|
||||||
<div
|
|
||||||
className="absolute inset-y-0 left-0 rounded-full bg-[var(--color-signal)] transition-[width] duration-500"
|
|
||||||
style={{ width: `${(c.message_count / max) * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="mono w-12 shrink-0 text-right text-xs text-[var(--color-ink-soft)]">
|
|
||||||
{c.message_count.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Search, Users } from "lucide-react";
|
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import { Avatar } from "@/components/primitives/avatar";
|
|
||||||
import { Badge } from "@/components/primitives/badge";
|
|
||||||
import { Input } from "@/components/primitives/input";
|
|
||||||
import { EmptyState, LoadingSkeleton } from "@/components/shared";
|
|
||||||
import { useUserDetail, useUsers } from "@/hooks";
|
|
||||||
|
|
||||||
const TRUST_TIERS = [
|
|
||||||
{ min: 75, label: "Trusted", tone: "signal" as const },
|
|
||||||
{ min: 40, label: "Neutral", tone: "neutral" as const },
|
|
||||||
{ min: 10, label: "At Risk", tone: "amber" as const },
|
|
||||||
{ min: 0, label: "Critical", tone: "vermilion" as const },
|
|
||||||
];
|
|
||||||
|
|
||||||
function trustTier(score?: number | null) {
|
|
||||||
const s = score ?? 0;
|
|
||||||
return (
|
|
||||||
TRUST_TIERS.find((t) => s >= t.min) ?? TRUST_TIERS[TRUST_TIERS.length - 1]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UsersSection() {
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { data: users = [], isLoading } = useUsers(search);
|
|
||||||
const { data: detail } = useUserDetail(selectedId);
|
|
||||||
|
|
||||||
const handleSearch = useCallback((v: string) => setSearch(v), []);
|
|
||||||
|
|
||||||
if (isLoading) return <LoadingSkeleton count={6} />;
|
|
||||||
if (users.length === 0)
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
icon={Users}
|
|
||||||
title="No users found"
|
|
||||||
description="Try a different search."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-3 lg:grid-cols-[1fr_360px]">
|
|
||||||
<div className="surface flex flex-col gap-2 p-3">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-soft)]" />
|
|
||||||
<Input
|
|
||||||
mono
|
|
||||||
placeholder="search users…"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{users.map((u) => {
|
|
||||||
const tier = trustTier(u.trust_score);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={u.user_id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedId(u.user_id)}
|
|
||||||
className="flex items-center gap-3 rounded-[var(--radius-r-control)] px-2 py-2 text-left transition-colors hover:bg-[var(--color-surface-2)]"
|
|
||||||
>
|
|
||||||
<Avatar src={u.avatar_url} name={u.username} size={34} />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate text-sm font-medium">
|
|
||||||
{u.username ?? "unknown"}
|
|
||||||
</div>
|
|
||||||
<div className="mono text-xs text-[var(--color-ink-soft)]">
|
|
||||||
{u.total_messages.toLocaleString()} msg
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge tone={tier.tone}>{tier.label}</Badge>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="surface p-4">
|
|
||||||
{detail ? (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Avatar
|
|
||||||
src={detail.avatar_url}
|
|
||||||
name={detail.username}
|
|
||||||
size={44}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold">{detail.username}</div>
|
|
||||||
<div className="text-xs text-[var(--color-ink-soft)]">
|
|
||||||
{detail.total_messages.toLocaleString()} messages
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
|
||||||
<Stat label="Trust" value={`${detail.trust_score ?? 0}`} />
|
|
||||||
<Stat
|
|
||||||
label="Clean streak"
|
|
||||||
value={`${detail.clean_message_streak ?? 0}`}
|
|
||||||
/>
|
|
||||||
<Stat
|
|
||||||
label="Infractions"
|
|
||||||
value={`${detail.total_infractions ?? 0}`}
|
|
||||||
tone="vermilion"
|
|
||||||
/>
|
|
||||||
<Stat
|
|
||||||
label="Flagged"
|
|
||||||
value={`${detail.flagged_count}`}
|
|
||||||
tone="amber"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-[var(--color-ink-soft)]">
|
|
||||||
{detail.profile_summary}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-[var(--color-ink-soft)]">
|
|
||||||
Select a user to inspect.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stat({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
tone,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
tone?: "amber" | "vermilion";
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="surface-2 p-2.5">
|
|
||||||
<div className="text-[11px] uppercase tracking-wide text-[var(--color-ink-soft)]">
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`mono text-lg font-semibold ${tone === "amber" ? "text-[var(--color-amber)]" : tone === "vermilion" ? "text-[var(--color-vermilion)]" : "text-[var(--color-ink)]"}`}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EventFeed — horizontal scroll-snap timeline that ingests live events.
|
|
||||||
*
|
|
||||||
* The feed is the central column of the dashboard. Time runs left → right
|
|
||||||
* (older → newer). New events append at the right edge; the feed scrolls
|
|
||||||
* right when the user is at the live edge and pauses when the user drags
|
|
||||||
* back to inspect history.
|
|
||||||
*
|
|
||||||
* Ring buffer keeps the DOM bounded (200 items). A `NowMarker` is inserted
|
|
||||||
* every 10 events or every 30 seconds to break the row rhythm with a pulse
|
|
||||||
* summary — see `useFeedPulse`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
type ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { EventRow, type FeedEvent } from "@/components/feed/event-row";
|
|
||||||
import { ClusterMarker, PulseMarker } from "@/components/feed/now-marker";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const RING_BUFFER_MAX = 200;
|
|
||||||
const PULSE_EVERY_N_EVENTS = 10;
|
|
||||||
const PULSE_EVERY_MS = 30_000;
|
|
||||||
|
|
||||||
export type FeedItem =
|
|
||||||
| { kind: "event"; event: FeedEvent }
|
|
||||||
| {
|
|
||||||
kind: "pulse";
|
|
||||||
key: string;
|
|
||||||
ts: number;
|
|
||||||
label: string;
|
|
||||||
summary: string;
|
|
||||||
tone?: "signal" | "amber" | "vermilion";
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: "cluster";
|
|
||||||
key: string;
|
|
||||||
ts: number;
|
|
||||||
label: string;
|
|
||||||
bands: {
|
|
||||||
tone: "neutral" | "signal" | "amber" | "vermilion";
|
|
||||||
ratio: number;
|
|
||||||
}[];
|
|
||||||
tone?: "signal" | "amber" | "vermilion";
|
|
||||||
};
|
|
||||||
|
|
||||||
interface EventFeedProps {
|
|
||||||
initialEvents: FeedEvent[];
|
|
||||||
subscribe: (handler: (e: FeedEvent) => void) => () => void;
|
|
||||||
className?: string;
|
|
||||||
emptyState?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EventFeed({
|
|
||||||
initialEvents,
|
|
||||||
subscribe,
|
|
||||||
className,
|
|
||||||
emptyState,
|
|
||||||
}: EventFeedProps) {
|
|
||||||
const [items, setItems] = useState<FeedItem[]>(() =>
|
|
||||||
injectMarkers(initialEvents.slice(-RING_BUFFER_MAX)),
|
|
||||||
);
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
||||||
const [following, setFollowing] = useState(true);
|
|
||||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const lastPulseAt = useRef<number>(Date.now());
|
|
||||||
|
|
||||||
// Live WS ingest
|
|
||||||
useEffect(() => {
|
|
||||||
const unsub = subscribe((e) => {
|
|
||||||
setItems((prev) => appendWithMarker(prev, e));
|
|
||||||
});
|
|
||||||
return unsub;
|
|
||||||
}, [subscribe]);
|
|
||||||
|
|
||||||
// Periodic pulse even if traffic is slow — keeps the feed rhythm alive.
|
|
||||||
useEffect(() => {
|
|
||||||
const id = window.setInterval(() => {
|
|
||||||
setItems((prev) => {
|
|
||||||
if (Date.now() - lastPulseAt.current < PULSE_EVERY_MS) return prev;
|
|
||||||
return appendPulse(prev, "system", "live · standing by");
|
|
||||||
});
|
|
||||||
}, PULSE_EVERY_MS);
|
|
||||||
return () => window.clearInterval(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Auto-scroll on append when following.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!following) return;
|
|
||||||
const el = scrollerRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
el.scrollTo({ left: el.scrollWidth, behavior: "smooth" });
|
|
||||||
}, [following]);
|
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
|
||||||
const el = scrollerRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const distFromRight = el.scrollWidth - el.scrollLeft - el.clientWidth;
|
|
||||||
setFollowing(distFromRight < 24);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSelect = useCallback((id: string) => {
|
|
||||||
setSelectedId((cur) => (cur === id ? null : id));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const visibleItems = useMemo(() => {
|
|
||||||
if (items.length <= RING_BUFFER_MAX) return items;
|
|
||||||
return items.slice(items.length - RING_BUFFER_MAX);
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative h-full w-full overflow-hidden",
|
|
||||||
"border-t border-[var(--color-hairline)]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
data-following={following ? "1" : "0"}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] bg-[var(--color-surface)] px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--color-ink-soft)]">
|
|
||||||
<span>event horizon</span>
|
|
||||||
<span>
|
|
||||||
{visibleItems.filter((i) => i.kind === "event").length} events ·{" "}
|
|
||||||
{following ? "live" : "paused"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={scrollerRef}
|
|
||||||
onScroll={handleScroll}
|
|
||||||
className={cn(
|
|
||||||
"h-[calc(100%-30px)] overflow-y-auto overflow-x-hidden",
|
|
||||||
"snap-y snap-mandatory",
|
|
||||||
"scroll-pt-2",
|
|
||||||
)}
|
|
||||||
role="feed"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
{visibleItems.length === 0 && emptyState ? (
|
|
||||||
<div className="flex h-full items-center justify-center p-8 text-center font-mono text-[12px] text-[var(--color-ink-soft)]">
|
|
||||||
{emptyState}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
visibleItems.map((item) => {
|
|
||||||
if (item.kind === "event") {
|
|
||||||
return (
|
|
||||||
<div key={item.event.id} className="snap-start">
|
|
||||||
<EventRow
|
|
||||||
event={item.event}
|
|
||||||
selected={selectedId === item.event.id}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (item.kind === "cluster") {
|
|
||||||
return (
|
|
||||||
<div key={item.key} className="snap-start">
|
|
||||||
<ClusterMarker
|
|
||||||
label={item.label}
|
|
||||||
timestamp={item.ts}
|
|
||||||
bands={item.bands}
|
|
||||||
tone={item.tone}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div key={item.key} className="snap-start">
|
|
||||||
<PulseMarker
|
|
||||||
label={item.label}
|
|
||||||
timestamp={item.ts}
|
|
||||||
trailing={item.summary}
|
|
||||||
tone={item.tone}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Ring + pulse helpers ────────────────────────────────────────
|
|
||||||
|
|
||||||
function injectMarkers(events: FeedEvent[]): FeedItem[] {
|
|
||||||
if (events.length === 0) return [];
|
|
||||||
const out: FeedItem[] = [];
|
|
||||||
let count = 0;
|
|
||||||
for (const e of events) {
|
|
||||||
out.push({ kind: "event", event: e });
|
|
||||||
count++;
|
|
||||||
if (count % PULSE_EVERY_N_EVENTS === 0) {
|
|
||||||
out.push({
|
|
||||||
kind: "cluster",
|
|
||||||
key: `cluster-${e.id}`,
|
|
||||||
ts: e.ts,
|
|
||||||
label: "pulse",
|
|
||||||
bands: deriveBands(
|
|
||||||
events.slice(Math.max(0, count - PULSE_EVERY_N_EVENTS), count),
|
|
||||||
),
|
|
||||||
tone: "signal",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deriveBands(
|
|
||||||
window: FeedEvent[],
|
|
||||||
): { tone: "neutral" | "signal" | "amber" | "vermilion"; ratio: number }[] {
|
|
||||||
const counts: Record<"neutral" | "signal" | "amber" | "vermilion", number> = {
|
|
||||||
neutral: 0,
|
|
||||||
signal: 0,
|
|
||||||
amber: 0,
|
|
||||||
vermilion: 0,
|
|
||||||
};
|
|
||||||
for (const e of window) counts[e.severity]++;
|
|
||||||
const total = window.length || 1;
|
|
||||||
return (Object.keys(counts) as Array<keyof typeof counts>).map((k) => ({
|
|
||||||
tone: k,
|
|
||||||
ratio: counts[k] / total,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendWithMarker(prev: FeedItem[], e: FeedEvent): FeedItem[] {
|
|
||||||
const next = [...prev, { kind: "event" as const, event: e }];
|
|
||||||
const eventsSinceLastPulse = next.filter((i) => i.kind === "event").length;
|
|
||||||
if (eventsSinceLastPulse % PULSE_EVERY_N_EVENTS === 0) {
|
|
||||||
const recentEvents = next
|
|
||||||
.filter((i) => i.kind === "event")
|
|
||||||
.slice(-PULSE_EVERY_N_EVENTS)
|
|
||||||
.map((i) => (i as { kind: "event"; event: FeedEvent }).event);
|
|
||||||
next.push({
|
|
||||||
kind: "cluster",
|
|
||||||
key: `cluster-${e.id}`,
|
|
||||||
ts: e.ts,
|
|
||||||
label: "pulse",
|
|
||||||
bands: deriveBands(recentEvents),
|
|
||||||
tone: "signal",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (next.length > RING_BUFFER_MAX * 2) {
|
|
||||||
return next.slice(next.length - RING_BUFFER_MAX);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendPulse(
|
|
||||||
prev: FeedItem[],
|
|
||||||
label: string,
|
|
||||||
summary: string,
|
|
||||||
): FeedItem[] {
|
|
||||||
return [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
kind: "pulse",
|
|
||||||
key: `pulse-${Date.now()}`,
|
|
||||||
ts: Date.now(),
|
|
||||||
label,
|
|
||||||
summary,
|
|
||||||
tone: "signal",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EventRow — single row in the horizontal event-feed timeline.
|
|
||||||
*
|
|
||||||
* No card chrome. The row is a single typographic line: mono timestamp,
|
|
||||||
* severity dot, actor mention, action verb, channel jump, excerpt.
|
|
||||||
*
|
|
||||||
* Hover reveals full excerpt and selection state; click toggles selection
|
|
||||||
* so the right rail / command line can target the event.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { type ReactNode, useCallback } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export type EventSeverity = "neutral" | "signal" | "amber" | "vermilion";
|
|
||||||
|
|
||||||
export interface FeedEvent {
|
|
||||||
/** Stable id from the upstream record. Used as React key. */
|
|
||||||
id: string;
|
|
||||||
/** Unix epoch ms. */
|
|
||||||
ts: number;
|
|
||||||
/** Severity tone — drives dot color and zebra fill. */
|
|
||||||
severity: EventSeverity;
|
|
||||||
/** Display label for the actor ("alice", "@everyone", "Carl-bot"). */
|
|
||||||
actor: string;
|
|
||||||
/** Verb describing the action ("sent", "flagged", "joined", "muted"). */
|
|
||||||
action: string;
|
|
||||||
/** Channel reference (monogram display only — no chrome). */
|
|
||||||
channel?: string | null;
|
|
||||||
/** Message excerpt or action payload text. Truncated when long. */
|
|
||||||
excerpt: string;
|
|
||||||
/** Optional metadata tag (e.g. "ai:flag", "voice:join"). */
|
|
||||||
tag?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EventRowProps {
|
|
||||||
event: FeedEvent;
|
|
||||||
selected?: boolean;
|
|
||||||
onSelect?: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SEVERITY_DOT: Record<EventSeverity, string> = {
|
|
||||||
neutral: "oklch(0.46 0.02 70)",
|
|
||||||
signal: "var(--color-signal)",
|
|
||||||
amber: "var(--color-amber)",
|
|
||||||
vermilion: "var(--color-vermilion)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const SEVERITY_FILL: Record<EventSeverity, string> = {
|
|
||||||
neutral: "transparent",
|
|
||||||
signal: "oklch(0.78 0.17 125 / 0.06)",
|
|
||||||
amber: "oklch(0.80 0.15 70 / 0.07)",
|
|
||||||
vermilion: "oklch(0.62 0.21 25 / 0.08)",
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatTimestamp(ts: number): string {
|
|
||||||
const d = new Date(ts);
|
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
|
||||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EventRow({ event, selected, onSelect }: EventRowProps) {
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
onSelect?.(event.id);
|
|
||||||
}, [event.id, onSelect]);
|
|
||||||
|
|
||||||
const dot: ReactNode = (
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="inline-block size-1.5 shrink-0 rounded-full"
|
|
||||||
style={{ background: SEVERITY_DOT[event.severity] }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClick}
|
|
||||||
className={cn(
|
|
||||||
"group relative flex w-full items-baseline gap-3 px-3 py-1.5 text-left font-mono text-[12px] leading-5 transition-colors",
|
|
||||||
"hover:bg-[oklch(0.92_0.014_80_/_0.6)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-signal)] focus-visible:outline-offset-[-2px]",
|
|
||||||
selected && "bg-[oklch(0.92_0.014_80_/_0.8)]",
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
background: selected ? undefined : SEVERITY_FILL[event.severity],
|
|
||||||
}}
|
|
||||||
data-event-id={event.id}
|
|
||||||
data-severity={event.severity}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-y-0 left-0 w-[2px] origin-center transition-transform",
|
|
||||||
selected ? "scale-y-100" : "scale-y-0 group-hover:scale-y-100",
|
|
||||||
)}
|
|
||||||
style={{ background: SEVERITY_DOT[event.severity] }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span className="w-[68px] shrink-0 text-[var(--color-ink-soft)] tabular-nums">
|
|
||||||
{formatTimestamp(event.ts)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{dot}
|
|
||||||
|
|
||||||
<span className="w-[120px] shrink-0 truncate text-[var(--color-ink)]">
|
|
||||||
{event.actor}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="w-[80px] shrink-0 text-[var(--color-ink-soft)]">
|
|
||||||
{event.action}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{event.channel ? (
|
|
||||||
<span className="w-[140px] shrink-0 truncate text-[var(--color-ink-soft)]">
|
|
||||||
{event.channel}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="w-[140px] shrink-0" aria-hidden />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span className="min-w-0 flex-1 truncate text-[var(--color-ink)]">
|
|
||||||
{event.excerpt}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{event.tag ? (
|
|
||||||
<span className="shrink-0 rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] px-1.5 py-px text-[10px] uppercase tracking-wide text-[var(--color-ink-soft)]">
|
|
||||||
{event.tag}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* NowMarker — inline callout that breaks the feed timeline rhythm.
|
|
||||||
*
|
|
||||||
* Two variants: `pulse` (one-line summary) and `cluster` (horizontal stack bar
|
|
||||||
* visualising severity distribution across a recent window). Both use a
|
|
||||||
* border-tip on the left in signal tone; no card chrome, no shadow.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type Tone = "signal" | "amber" | "vermilion" | "neutral";
|
|
||||||
|
|
||||||
interface PulseMarkerProps {
|
|
||||||
tone?: Tone;
|
|
||||||
label: string;
|
|
||||||
timestamp: number;
|
|
||||||
/** Optional small caps label on the right. */
|
|
||||||
trailing?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ClusterMarkerProps {
|
|
||||||
tone?: Tone;
|
|
||||||
label: string;
|
|
||||||
timestamp: number;
|
|
||||||
/** Fractions of each severity band; must sum to 1. */
|
|
||||||
bands: { tone: Tone; ratio: number }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const TONE_TIP: Record<Tone, string> = {
|
|
||||||
signal: "var(--color-signal)",
|
|
||||||
amber: "var(--color-amber)",
|
|
||||||
vermilion: "var(--color-vermilion)",
|
|
||||||
neutral: "oklch(0.46 0.02 70)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const TONE_FILL: Record<Tone, string> = {
|
|
||||||
signal: "oklch(0.78 0.17 125 / 0.12)",
|
|
||||||
amber: "oklch(0.80 0.15 70 / 0.14)",
|
|
||||||
vermilion: "oklch(0.62 0.21 25 / 0.12)",
|
|
||||||
neutral: "oklch(0.46 0.02 70 / 0.08)",
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatTimestamp(ts: number): string {
|
|
||||||
const d = new Date(ts);
|
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
|
||||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MarkerShell({
|
|
||||||
tone,
|
|
||||||
label,
|
|
||||||
timestamp,
|
|
||||||
trailing,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
tone: Tone;
|
|
||||||
label: string;
|
|
||||||
timestamp: number;
|
|
||||||
trailing?: string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="relative my-2 flex items-center gap-3 px-3 py-2 font-mono text-[11px]"
|
|
||||||
style={{ background: TONE_FILL[tone] }}
|
|
||||||
data-marker={tone}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="absolute inset-y-1 left-0 w-[3px]"
|
|
||||||
style={{ background: TONE_TIP[tone] }}
|
|
||||||
/>
|
|
||||||
<span className="w-[68px] shrink-0 text-[var(--color-ink-soft)] tabular-nums">
|
|
||||||
{formatTimestamp(timestamp)}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="shrink-0 text-[10px] font-medium uppercase tracking-[0.18em]"
|
|
||||||
style={{ color: TONE_TIP[tone] }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1 truncate text-[var(--color-ink)]">
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
{trailing ? (
|
|
||||||
<span className="shrink-0 text-[10px] uppercase tracking-wide text-[var(--color-ink-soft)]">
|
|
||||||
{trailing}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PulseMarker({
|
|
||||||
tone = "signal",
|
|
||||||
label,
|
|
||||||
timestamp,
|
|
||||||
trailing,
|
|
||||||
}: PulseMarkerProps) {
|
|
||||||
return (
|
|
||||||
<MarkerShell
|
|
||||||
tone={tone}
|
|
||||||
label={label}
|
|
||||||
timestamp={timestamp}
|
|
||||||
trailing={trailing}
|
|
||||||
>
|
|
||||||
{/* children rendered by parent via composition — see NowMarker union below */}
|
|
||||||
</MarkerShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ClusterMarker({
|
|
||||||
tone = "signal",
|
|
||||||
label,
|
|
||||||
timestamp,
|
|
||||||
bands,
|
|
||||||
}: ClusterMarkerProps) {
|
|
||||||
return (
|
|
||||||
<MarkerShell tone={tone} label={label} timestamp={timestamp}>
|
|
||||||
<div className="flex h-3 w-full max-w-[280px] overflow-hidden rounded-[var(--radius-r-control)]">
|
|
||||||
{bands.map((b) => (
|
|
||||||
<span
|
|
||||||
key={b.tone}
|
|
||||||
className={cn("h-full")}
|
|
||||||
style={{
|
|
||||||
width: `${Math.max(0, Math.min(1, b.ratio)) * 100}%`,
|
|
||||||
background: TONE_TIP[b.tone],
|
|
||||||
opacity: b.tone === "neutral" ? 0.4 : 1,
|
|
||||||
}}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</MarkerShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DashLeftRail — 80px vertical monogram nav.
|
|
||||||
*
|
|
||||||
* Each item is a glyph + label. Active state uses an accent bar on the left
|
|
||||||
* and full ink colour. No backgrounds, no boxes.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
Activity,
|
|
||||||
BarChart3,
|
|
||||||
Flag,
|
|
||||||
MessagesSquare,
|
|
||||||
Mic,
|
|
||||||
ShieldCheck,
|
|
||||||
Users,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface NavItem {
|
|
||||||
href: string;
|
|
||||||
glyph: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ITEMS: NavItem[] = [
|
|
||||||
{
|
|
||||||
href: "/dashboard",
|
|
||||||
glyph: <BarChart3 className="size-4" />,
|
|
||||||
label: "Console",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/messages",
|
|
||||||
glyph: <MessagesSquare className="size-4" />,
|
|
||||||
label: "Messages",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/moderation",
|
|
||||||
glyph: <ShieldCheck className="size-4" />,
|
|
||||||
label: "Moderation",
|
|
||||||
},
|
|
||||||
{ href: "/voice", glyph: <Mic className="size-4" />, label: "Voice" },
|
|
||||||
{ href: "/media", glyph: <Activity className="size-4" />, label: "Media" },
|
|
||||||
{
|
|
||||||
href: "/recordings",
|
|
||||||
glyph: <Flag className="size-4" />,
|
|
||||||
label: "Recordings",
|
|
||||||
},
|
|
||||||
{ href: "/analysis", glyph: <Users className="size-4" />, label: "Analysis" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function DashLeftRail() {
|
|
||||||
const pathname = usePathname();
|
|
||||||
return (
|
|
||||||
<nav
|
|
||||||
aria-label="Console navigation"
|
|
||||||
className="flex h-full w-20 shrink-0 flex-col items-center gap-1 border-r border-[var(--color-hairline)] bg-[var(--color-surface)] py-3"
|
|
||||||
>
|
|
||||||
{ITEMS.map((it) => {
|
|
||||||
const active =
|
|
||||||
pathname === it.href || pathname?.startsWith(`${it.href}/`);
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={it.href}
|
|
||||||
href={it.href}
|
|
||||||
className={cn(
|
|
||||||
"group relative flex w-full flex-col items-center gap-1 py-2 text-[10px] uppercase tracking-wide transition-colors",
|
|
||||||
active
|
|
||||||
? "text-[var(--color-ink)]"
|
|
||||||
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
|
|
||||||
)}
|
|
||||||
data-active={active ? "1" : "0"}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-y-2 left-0 w-[2px] origin-center transition-transform",
|
|
||||||
active ? "scale-y-100" : "scale-y-0 group-hover:scale-y-100",
|
|
||||||
)}
|
|
||||||
style={{ background: "var(--color-signal)" }}
|
|
||||||
/>
|
|
||||||
{it.glyph}
|
|
||||||
<span className="font-mono">{it.label}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DashRightRail — 320px collapsible drawer.
|
|
||||||
*
|
|
||||||
* Holds the live AI verdict stream, active voice speakers, and the latest
|
|
||||||
* moderation actions. Reads from existing hooks (`useVoice`, etc.) — no
|
|
||||||
* new fetches; just re-presentation.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ChevronRight } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useSpeakers } from "@/hooks/use-voice";
|
|
||||||
import type { ActiveSpeaker } from "@/lib/types";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
|
||||||
|
|
||||||
interface DashRightRailProps {
|
|
||||||
pendingVerdicts?: { id: string; ts: number; text: string }[];
|
|
||||||
recentActions?: { id: string; ts: number; verb: string; target: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashRightRail({
|
|
||||||
pendingVerdicts = [],
|
|
||||||
recentActions = [],
|
|
||||||
}: DashRightRailProps) {
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
|
||||||
const { subscribe } = useSpeakers();
|
|
||||||
const ws = useWebSocket();
|
|
||||||
const [speakers, _setSpeakers] = useState<ActiveSpeaker[]>([]);
|
|
||||||
useEffect(() => subscribe(ws), [ws, subscribe]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside
|
|
||||||
className={cn(
|
|
||||||
"relative shrink-0 border-l border-[var(--color-hairline)] bg-[var(--color-surface)] font-mono text-[11px] transition-[width]",
|
|
||||||
collapsed ? "w-9" : "w-[320px]",
|
|
||||||
)}
|
|
||||||
aria-label="Live activity rail"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCollapsed((c) => !c)}
|
|
||||||
className={cn(
|
|
||||||
"absolute -left-3 top-3 z-10 flex size-6 items-center justify-center rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)] text-[var(--color-ink-soft)] transition-colors hover:text-[var(--color-ink)]",
|
|
||||||
)}
|
|
||||||
aria-label={
|
|
||||||
collapsed
|
|
||||||
? "Expand live activity rail"
|
|
||||||
: "Collapse live activity rail"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ChevronRight
|
|
||||||
className={cn(
|
|
||||||
"size-3 transition-transform",
|
|
||||||
collapsed ? "" : "rotate-180",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{collapsed ? (
|
|
||||||
<div className="flex h-full flex-col items-center gap-4 py-4">
|
|
||||||
<Section title="ai" vertical />
|
|
||||||
<Section title="voice" vertical />
|
|
||||||
<Section title="mod" vertical />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full flex-col overflow-y-auto">
|
|
||||||
<Section title="ai verdicts">
|
|
||||||
{pendingVerdicts.length === 0 ? (
|
|
||||||
<Empty msg="no pending verdicts" />
|
|
||||||
) : (
|
|
||||||
<ul className="flex flex-col gap-1.5">
|
|
||||||
{pendingVerdicts.slice(0, 8).map((v) => (
|
|
||||||
<li key={v.id} className="flex items-baseline gap-2">
|
|
||||||
<span className="shrink-0 text-[var(--color-ink-soft)] tabular-nums">
|
|
||||||
{formatTs(v.ts)}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 truncate text-[var(--color-ink)]">
|
|
||||||
{v.text}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="voice">
|
|
||||||
{speakers.length === 0 ? (
|
|
||||||
<Empty msg="no one speaking" />
|
|
||||||
) : (
|
|
||||||
<ul className="flex flex-col gap-1.5">
|
|
||||||
{speakers.slice(0, 8).map((sp) => (
|
|
||||||
<li key={sp.userId} className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block size-1.5 rounded-full",
|
|
||||||
sp.speaking
|
|
||||||
? "bg-[var(--color-signal)]"
|
|
||||||
: "bg-[var(--color-ink-soft)]",
|
|
||||||
)}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<span className="truncate text-[var(--color-ink)]">
|
|
||||||
{sp.username ?? sp.userId}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="mod queue">
|
|
||||||
{recentActions.length === 0 ? (
|
|
||||||
<Empty msg="queue empty" />
|
|
||||||
) : (
|
|
||||||
<ul className="flex flex-col gap-1.5">
|
|
||||||
{recentActions.slice(0, 8).map((a) => (
|
|
||||||
<li key={a.id} className="flex items-baseline gap-2">
|
|
||||||
<span className="shrink-0 text-[var(--color-ink-soft)] tabular-nums">
|
|
||||||
{formatTs(a.ts)}
|
|
||||||
</span>
|
|
||||||
<span className="text-[var(--color-ink-soft)]">
|
|
||||||
{a.verb}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 truncate text-[var(--color-ink)]">
|
|
||||||
{a.target}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="socket">
|
|
||||||
<div className="flex flex-col gap-0.5 text-[10px]">
|
|
||||||
<span className="text-[var(--color-ink-soft)]">status</span>
|
|
||||||
<span className="text-[var(--color-ink)]">{ws.status}</span>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Section({
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
vertical,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
vertical?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className={cn(
|
|
||||||
"border-b border-[var(--color-hairline)] px-3 py-2.5",
|
|
||||||
vertical && "flex flex-col items-center gap-2 border-b-0 py-4",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<h3 className="mb-1.5 text-[10px] uppercase tracking-[0.18em] text-[var(--color-ink-soft)]">
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
{children}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Empty({ msg }: { msg: string }) {
|
|
||||||
return (
|
|
||||||
<span className="text-[10px] italic text-[var(--color-ink-soft)]">
|
|
||||||
{msg}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTs(ts: number): string {
|
|
||||||
const d = new Date(ts);
|
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
|
||||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DashTopBar — 48px utility strip.
|
|
||||||
*
|
|
||||||
* No navigation chrome — just brand monogram, guild indicator, WS connection
|
|
||||||
* state, clock, and focus mode. Designed to read as a single line of
|
|
||||||
* instrument readout, not a navbar.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
|
||||||
|
|
||||||
type FocusMode = "quiet" | "standard" | "triage";
|
|
||||||
const FOCUS_MODES: FocusMode[] = ["quiet", "standard", "triage"];
|
|
||||||
|
|
||||||
interface DashTopBarProps {
|
|
||||||
guildName: string;
|
|
||||||
botName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatClock(d: Date): string {
|
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
|
||||||
return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashTopBar({ guildName, botName = "GMW" }: DashTopBarProps) {
|
|
||||||
const ws = useWebSocket();
|
|
||||||
const [now, setNow] = useState<Date | null>(null);
|
|
||||||
const [focus, setFocus] = useState<FocusMode>("standard");
|
|
||||||
const [tz, setTz] = useState<"utc" | "local">("local");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setNow(new Date());
|
|
||||||
const id = window.setInterval(() => setNow(new Date()), 1000);
|
|
||||||
return () => window.clearInterval(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const connected = ws.status === "connected";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header
|
|
||||||
className={cn(
|
|
||||||
"flex h-12 items-center justify-between gap-4 border-b border-[var(--color-hairline)] bg-[var(--color-surface)] px-4 font-mono text-[11px]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="display text-base font-medium text-[var(--color-ink)]">
|
|
||||||
{botName}
|
|
||||||
</span>
|
|
||||||
<span className="text-[var(--color-ink-soft)]">·</span>
|
|
||||||
<span className="text-[var(--color-ink-soft)]">{guildName}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className={cn(
|
|
||||||
"inline-block size-1.5 rounded-full",
|
|
||||||
connected
|
|
||||||
? "bg-[var(--color-signal)]"
|
|
||||||
: "bg-[var(--color-vermilion)]",
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
boxShadow: connected
|
|
||||||
? "0 0 0 0 oklch(from var(--color-signal) l c h / 0.45)"
|
|
||||||
: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="uppercase tracking-[0.18em] text-[var(--color-ink-soft)]">
|
|
||||||
{ws.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTz((t) => (t === "utc" ? "local" : "utc"))}
|
|
||||||
className="rounded-[var(--radius-r-control)] px-2 py-0.5 text-[var(--color-ink-soft)] transition-colors hover:bg-[var(--color-surface-2)] hover:text-[var(--color-ink)]"
|
|
||||||
aria-label="Toggle UTC / local timezone"
|
|
||||||
>
|
|
||||||
{now
|
|
||||||
? tz === "utc"
|
|
||||||
? `${formatClock(now)} UTC`
|
|
||||||
: formatLocal(now)
|
|
||||||
: "--:--:--"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex gap-0.5 rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] p-0.5">
|
|
||||||
{FOCUS_MODES.map((m) => (
|
|
||||||
<button
|
|
||||||
key={m}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFocus(m)}
|
|
||||||
className={cn(
|
|
||||||
"rounded-[var(--radius-r-control)] px-2 py-0.5 text-[10px] uppercase tracking-wide transition-colors",
|
|
||||||
focus === m
|
|
||||||
? "bg-[var(--color-canvas)] text-[var(--color-ink)]"
|
|
||||||
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{m}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatLocal(d: Date): string {
|
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
|
||||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { navItems } from "@/lib/navigation";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const titleFromPath: Record<string, string> = {
|
|
||||||
"/dashboard": "Overview",
|
|
||||||
"/messages": "Messages",
|
|
||||||
"/voice": "Voice",
|
|
||||||
"/media": "Media",
|
|
||||||
"/recordings": "Recordings",
|
|
||||||
"/moderation": "Moderation",
|
|
||||||
"/analysis": "Analysis",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Spine() {
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Desktop rail */}
|
|
||||||
<nav className="fixed left-0 top-0 z-30 hidden h-svh w-[68px] flex-col items-center gap-1 border-r border-[var(--color-hairline)] bg-[var(--color-canvas)]/80 py-4 backdrop-blur-md md:flex">
|
|
||||||
<Link
|
|
||||||
href="/dashboard"
|
|
||||||
className="mb-3 flex size-9 items-center justify-center rounded-[var(--radius-r-control)] bg-[var(--color-signal)] text-sm font-black text-[var(--color-signal-ink)]"
|
|
||||||
aria-label="GMW"
|
|
||||||
>
|
|
||||||
B
|
|
||||||
</Link>
|
|
||||||
{navItems.map((item) => {
|
|
||||||
const active = pathname.startsWith(item.matchPrefix);
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className={cn(
|
|
||||||
"group relative flex size-11 items-center justify-center rounded-[var(--radius-r)] transition-colors",
|
|
||||||
active
|
|
||||||
? "text-[var(--color-signal)]"
|
|
||||||
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
|
|
||||||
)}
|
|
||||||
aria-current={active ? "page" : undefined}
|
|
||||||
>
|
|
||||||
{active && (
|
|
||||||
<motion.span
|
|
||||||
layoutId="spine-active"
|
|
||||||
className="absolute left-0 top-1/2 size-1 -translate-y-1/2 rounded-full bg-[var(--color-signal)]"
|
|
||||||
transition={{ type: "spring", stiffness: 380, damping: 30 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Icon className="size-5" />
|
|
||||||
<span className="pointer-events-none absolute left-full ml-2 hidden whitespace-nowrap rounded-[var(--radius-r-control)] bg-[var(--color-ink)] px-2 py-1 text-xs font-medium text-[var(--color-canvas)] opacity-0 transition-opacity group-hover:opacity-100 md:block">
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Mobile bottom tab-bar */}
|
|
||||||
<nav className="fixed inset-x-0 bottom-0 z-30 flex h-16 items-stretch border-t border-[var(--color-hairline)] bg-[var(--color-canvas)]/90 backdrop-blur-md md:hidden">
|
|
||||||
{navItems.map((item) => {
|
|
||||||
const active = pathname.startsWith(item.matchPrefix);
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className={cn(
|
|
||||||
"flex flex-1 flex-col items-center justify-center gap-0.5 text-[10px] font-medium transition-colors",
|
|
||||||
active
|
|
||||||
? "text-[var(--color-signal)]"
|
|
||||||
: "text-[var(--color-ink-soft)]",
|
|
||||||
)}
|
|
||||||
aria-current={active ? "page" : undefined}
|
|
||||||
>
|
|
||||||
<Icon className="size-5" />
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PageTitle() {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const key =
|
|
||||||
Object.keys(titleFromPath).find((k) => pathname.startsWith(k)) ??
|
|
||||||
"/dashboard";
|
|
||||||
return (
|
|
||||||
<span className="font-semibold max-md:hidden">{titleFromPath[key]}</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useChatbot } from "@/components/chatbot/chatbot-context";
|
|
||||||
import { GuildSelector } from "@/components/shared/guild-selector";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
|
||||||
import { PageTitle } from "./spine";
|
|
||||||
import { ThemeToggle } from "./theme-toggle";
|
|
||||||
|
|
||||||
const statusTone: Record<string, string> = {
|
|
||||||
connected: "bg-[var(--color-signal)]",
|
|
||||||
connecting: "bg-[var(--color-amber)]",
|
|
||||||
disconnected: "bg-[var(--color-ink-soft)]",
|
|
||||||
error: "bg-[var(--color-vermilion)]",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function StatusBar({
|
|
||||||
guildId,
|
|
||||||
onGuildChange,
|
|
||||||
}: {
|
|
||||||
guildId: string;
|
|
||||||
onGuildChange: (g: string) => void;
|
|
||||||
}) {
|
|
||||||
const ws = useWebSocket();
|
|
||||||
const { expression } = useChatbot();
|
|
||||||
const [clock, setClock] = useState("--:--:--");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const tick = () =>
|
|
||||||
setClock(new Date().toLocaleTimeString("en-GB", { hour12: false }));
|
|
||||||
tick();
|
|
||||||
const id = setInterval(tick, 1000);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="sticky top-0 z-20 flex h-14 shrink-0 items-center gap-2 border-b border-[var(--color-hairline)] bg-[var(--color-canvas)]/85 px-4 backdrop-blur-md md:px-6">
|
|
||||||
<PageTitle />
|
|
||||||
<div className="ms-auto flex items-center gap-3">
|
|
||||||
<span className="hidden items-center gap-1.5 text-xs text-[var(--color-ink-soft)] sm:flex">
|
|
||||||
<span className={cn("size-2 rounded-full", statusTone[ws.status])} />
|
|
||||||
<span className="mono uppercase">{ws.status}</span>
|
|
||||||
</span>
|
|
||||||
<span className="hidden text-xs text-[var(--color-ink-soft)] md:inline">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"font-mono",
|
|
||||||
expression !== "idle" && "text-[var(--color-signal)]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{expression}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="hidden font-mono text-xs text-[var(--color-ink-soft)] lg:inline">
|
|
||||||
{clock}
|
|
||||||
</span>
|
|
||||||
<GuildSelector
|
|
||||||
value={guildId}
|
|
||||||
onChange={(g) => onGuildChange(g ?? "")}
|
|
||||||
/>
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Moon, Sun } from "lucide-react";
|
|
||||||
import { motion } from "motion/react";
|
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export function ThemeToggle() {
|
|
||||||
const { resolvedTheme, setTheme } = useTheme();
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
useEffect(() => setMounted(true), []);
|
|
||||||
|
|
||||||
const isDark = resolvedTheme === "dark";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Toggle theme"
|
|
||||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
|
||||||
className="flex size-9 items-center justify-center rounded-[var(--radius-r-control)] text-[var(--color-ink-soft)] transition-colors hover:bg-[var(--color-surface-2)] hover:text-[var(--color-ink)] focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]"
|
|
||||||
>
|
|
||||||
{mounted && (
|
|
||||||
<motion.span
|
|
||||||
key={isDark ? "moon" : "sun"}
|
|
||||||
initial={{ rotate: -90, opacity: 0 }}
|
|
||||||
animate={{ rotate: 0, opacity: 1 }}
|
|
||||||
transition={{ type: "spring", stiffness: 360, damping: 26 }}
|
|
||||||
className={cn(
|
|
||||||
isDark ? "text-[var(--color-signal)]" : "text-[var(--color-amber)]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isDark ? <Moon className="size-4" /> : <Sun className="size-4" />}
|
|
||||||
</motion.span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user