feat(console): rombak penuh dashboard layout jadi Event Horizon
Layout baru single-screen ops console: - TopBar 48px (brand monogram, guild, ws status, clock UTC/local, focus mode) - LeftRail 80px (icon+label nav, signal accent bar, no boxes) - Hero strip (display headline + mono counters: clean/warned/flagged/ratio) - EventFeed (vertical timeline of message events, severity dots, no cards) - NowMarker (inline pulse + cluster band insert per 10 events / 30s) - RightRail 320px collapsible (ai verdicts / voice / mod queue / socket) - DashCommandLine bottom 44px (mono prompt, '/' focuses, /mute /jump /find /clear) Replace Spine + StatusBar lama untuk /dashboard via pathname branch di (dashboard)/layout.tsx — route lain (messages/voice/media/dll) tetap pakai ClassicShell, tidak ter-regress. SSR seed tetap lewat page.tsx (server fetch stats + activity), synthetic seed events dari daily buckets sampai WS message_created kick in. WS event mapper: severity di-derive dari ai_status + ai_severity, excerpt dipotong 140 char, channel tail 4 char. No card chrome, no shadow, no bento grid, no tab panels.
This commit is contained in:
@@ -1,32 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity as ActivityIcon,
|
||||
Flag,
|
||||
MessagesSquare,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { RadialGauge } from "@/components/charts/radial-gauge";
|
||||
import { Sparkline } from "@/components/charts/sparkline";
|
||||
import { ActivityChart } from "@/components/dashboard/activity-chart";
|
||||
import { ChannelsSection } from "@/components/dashboard/channels-section";
|
||||
import { HourlyActivityChart } from "@/components/dashboard/hourly-activity-chart";
|
||||
import { ModerationDonut } from "@/components/dashboard/moderation-donut";
|
||||
import { ReactionsSection } from "@/components/dashboard/reactions-section";
|
||||
import { TopChannelsChart } from "@/components/dashboard/top-channels-chart";
|
||||
import { UsersSection } from "@/components/dashboard/users-section";
|
||||
import { StaggerGroup, StaggerItem } from "@/components/motion/stagger";
|
||||
import { SignalField } from "@/components/three";
|
||||
import { useActivity, useStats } from "@/hooks";
|
||||
/**
|
||||
* Dashboard — Event Horizon layout.
|
||||
*
|
||||
* Renders inside `ConsoleShell` from `/(dashboard)/layout.tsx`, so this view
|
||||
* just paints the central column: hero strip (mono headline + counters) and
|
||||
* the live event feed. Time runs vertically through the feed; the right
|
||||
* rail lives in the shell. The bottom command line is the signature —
|
||||
* press `/` anywhere to focus.
|
||||
*
|
||||
* SSR seed is preserved: the server component (page.tsx) hands us
|
||||
* `initialActivity` and `initialStats`; we use activity's daily buckets as
|
||||
* synthetic seed events so the feed has something to render before WS
|
||||
* kicks in. Then WS subscribe replaces the stream with live messages.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { DashCommandLine } from "@/components/command/dash-command-line";
|
||||
import { EventFeed } from "@/components/feed/event-feed";
|
||||
import type { FeedEvent } from "@/components/feed/event-row";
|
||||
import { DashRightRail } from "@/components/layout/dash-right-rail";
|
||||
import type { DashboardActivity, DashboardStats } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Tab = "stats" | "users" | "channels" | "reactions";
|
||||
|
||||
const DAYS = [7, 14, 30] as const;
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
export default function DashboardView({
|
||||
initialStats,
|
||||
@@ -35,216 +31,167 @@ export default function DashboardView({
|
||||
initialStats?: DashboardStats;
|
||||
initialActivity?: DashboardActivity;
|
||||
}) {
|
||||
const [tab, setTab] = useState<Tab>("stats");
|
||||
const [days, setDays] = useState<number>(14);
|
||||
const reduce = useReducedMotion();
|
||||
const ws = useWebSocket();
|
||||
|
||||
const { data: stats } = useStats(initialStats);
|
||||
const { data: activity } = useActivity(
|
||||
days,
|
||||
days === 14 ? initialActivity : undefined,
|
||||
const seedEvents = useMemo<FeedEvent[]>(() => {
|
||||
if (!initialActivity) return [];
|
||||
// Map daily buckets aren't per-message; derive a synthetic sequence from
|
||||
// daily counts so the feed has something to render before WS kicks in.
|
||||
const out: FeedEvent[] = [];
|
||||
const ts = Date.now();
|
||||
const days = [...initialActivity.daily].reverse();
|
||||
for (const d of days) {
|
||||
const total = d.messages;
|
||||
const flagged = d.flagged ?? 0;
|
||||
for (let i = 0; i < Math.min(6, total); i++) {
|
||||
const flaggedRow = i < flagged;
|
||||
out.push({
|
||||
id: `seed-${d.day ?? ""}-${i}`,
|
||||
ts: ts - i * 90_000,
|
||||
severity: flaggedRow ? "vermilion" : "signal",
|
||||
actor: flaggedRow ? "ai-moderator" : `seed-user-${i + 1}`,
|
||||
action: flaggedRow ? "flagged" : "sent",
|
||||
channel: `#general`,
|
||||
excerpt: flaggedRow
|
||||
? `seed: synthetic flagged event (${d.day ?? ""})`
|
||||
: `seed: synthetic clean message (${d.day ?? ""})`,
|
||||
tag: flaggedRow ? "ai:flag" : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out.slice(-48).reverse();
|
||||
}, [initialActivity]);
|
||||
|
||||
const subscribe = useCallback(
|
||||
(handler: (e: FeedEvent) => void) => {
|
||||
const unsub = ws.on("message_created", (data) => {
|
||||
const m = data as unknown as {
|
||||
id: string;
|
||||
created_at: number;
|
||||
ai_status?: string | null;
|
||||
ai_severity?: string | null;
|
||||
username?: string;
|
||||
content: string;
|
||||
channel_id?: string;
|
||||
};
|
||||
handler({
|
||||
id: m.id,
|
||||
ts: m.created_at ?? Date.now(),
|
||||
severity: severityFromAi(m.ai_status, m.ai_severity),
|
||||
actor: m.username ?? "unknown",
|
||||
action: "sent",
|
||||
channel: m.channel_id ? `#${m.channel_id.slice(-4)}` : null,
|
||||
excerpt: (m.content ?? "").slice(0, 140),
|
||||
tag:
|
||||
m.ai_status && m.ai_status !== "clean" ? `ai:${m.ai_status}` : null,
|
||||
});
|
||||
});
|
||||
return unsub;
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
|
||||
const clean = stats?.total_clean ?? 0;
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full flex-1">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Hero stats={initialStats} />
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<EventFeed
|
||||
initialEvents={seedEvents}
|
||||
subscribe={subscribe}
|
||||
className={cn("min-h-0 flex-1")}
|
||||
emptyState={
|
||||
<span>
|
||||
waiting for the first signal …
|
||||
<br />
|
||||
events will stream in as the bot captures activity.
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<DashCommandLine />
|
||||
</div>
|
||||
</div>
|
||||
<DashRightRail />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function severityFromAi(
|
||||
status?: string | null,
|
||||
sev?: string | null,
|
||||
): FeedEvent["severity"] {
|
||||
if (!status) return "neutral";
|
||||
if (status === "flagged") return sev === "critical" ? "vermilion" : "amber";
|
||||
if (status === "warn") return "amber";
|
||||
if (status === "clean") return "signal";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function Hero({ stats }: { stats?: DashboardStats }) {
|
||||
const total = stats?.total_messages ?? 0;
|
||||
const flagged = stats?.total_flagged ?? 0;
|
||||
const warned = stats?.total_warned ?? 0;
|
||||
const total = clean + flagged + warned || 1;
|
||||
const health = clean / total;
|
||||
const activityRatio = Math.min(
|
||||
1,
|
||||
(activity?.daily.at(-1)?.messages ?? 0) /
|
||||
(Math.max(...(activity?.daily.map((d) => d.messages) ?? [1]), 1) || 1),
|
||||
);
|
||||
|
||||
const daily = activity?.daily ?? [];
|
||||
const spark = daily.map((d) => d.messages);
|
||||
const flaggedSpark = daily.map((d) => d.flagged);
|
||||
const usersSpark = daily.map((d) => d.active_users);
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
id: "stats",
|
||||
label: "Stats",
|
||||
icon: <MessagesSquare className="size-3.5" />,
|
||||
},
|
||||
{ id: "users", label: "Users", icon: <Users className="size-3.5" /> },
|
||||
{
|
||||
id: "channels",
|
||||
label: "Channels",
|
||||
icon: <ActivityIcon className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
id: "reactions",
|
||||
label: "Reactions",
|
||||
icon: <Flag className="size-3.5" />,
|
||||
},
|
||||
];
|
||||
const clean = stats?.total_clean ?? 0;
|
||||
const denom = clean + flagged + warned || 1;
|
||||
const ratio = clean / denom;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Hero stats={stats} activityRatio={activityRatio} health={health} />
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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 className="border-b border-[var(--color-hairline)] bg-[var(--color-surface)] px-5 py-4 font-mono">
|
||||
<div className="flex items-baseline justify-between gap-6">
|
||||
<div className="min-w-0">
|
||||
<h1 className="display text-[28px] font-medium leading-tight text-[var(--color-ink)]">
|
||||
GMW Console
|
||||
</h1>
|
||||
<p className="mt-1 text-[12px] text-[var(--color-ink-soft)]">
|
||||
<span className="tabular-nums">{total.toLocaleString()}</span>{" "}
|
||||
messages watched ·{" "}
|
||||
<span className="tabular-nums">
|
||||
{(stats?.total_users ?? 0).toLocaleString()}
|
||||
</span>{" "}
|
||||
users ·{" "}
|
||||
<span className="tabular-nums">{stats?.active_users_24h ?? 0}</span>{" "}
|
||||
active 24h
|
||||
</p>
|
||||
</div>
|
||||
<div className="ms-auto flex gap-1">
|
||||
{DAYS.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
onClick={() => setDays(d)}
|
||||
className={cn(
|
||||
"rounded-[var(--radius-r-control)] px-2.5 py-1 text-xs font-mono transition-colors",
|
||||
days === d
|
||||
? "bg-[var(--color-surface-2)] text-[var(--color-ink)]"
|
||||
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
|
||||
)}
|
||||
>
|
||||
{d}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ticker row (stats tab) */}
|
||||
{tab === "stats" && (
|
||||
<StaggerGroup className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Ticker
|
||||
icon={<MessagesSquare className="size-4" />}
|
||||
label="Messages"
|
||||
value={stats?.total_messages ?? 0}
|
||||
data={spark}
|
||||
<div className="grid grid-cols-4 gap-x-6 gap-y-1 text-[11px] uppercase tracking-[0.18em]">
|
||||
<Stat label="clean" value={clean} tone="signal" />
|
||||
<Stat label="warned" value={warned} tone="amber" />
|
||||
<Stat label="flagged" value={flagged} tone="vermilion" />
|
||||
<Stat
|
||||
label="ratio"
|
||||
value={`${(ratio * 100).toFixed(1)}%`}
|
||||
tone="neutral"
|
||||
/>
|
||||
<Ticker
|
||||
icon={<Flag className="size-4" />}
|
||||
label="Flagged"
|
||||
value={flagged}
|
||||
data={flaggedSpark}
|
||||
tone="vermilion"
|
||||
/>
|
||||
<Ticker
|
||||
icon={<Users className="size-4" />}
|
||||
label="Active 24h"
|
||||
value={stats?.active_users_24h ?? 0}
|
||||
data={usersSpark}
|
||||
tone="amber"
|
||||
/>
|
||||
<Ticker
|
||||
icon={<ShieldCheck className="size-4" />}
|
||||
label="Recordings"
|
||||
value={stats?.total_voice_recordings ?? 0}
|
||||
data={spark}
|
||||
/>
|
||||
</StaggerGroup>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<div className="space-y-4 xl:col-span-2">
|
||||
{tab === "stats" && (
|
||||
<>
|
||||
<ActivityChart data={daily} />
|
||||
<HourlyActivityChart data={activity?.hourly ?? []} />
|
||||
<TopChannelsChart channels={stats?.top_channels ?? []} />
|
||||
</>
|
||||
)}
|
||||
{tab === "users" && <UsersSection />}
|
||||
{tab === "channels" && <ChannelsSection />}
|
||||
{tab === "reactions" && <ReactionsSection />}
|
||||
</div>
|
||||
<div className="xl:col-span-1">
|
||||
<ModerationDonut stats={stats} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Hero({
|
||||
stats,
|
||||
activityRatio,
|
||||
health,
|
||||
}: {
|
||||
stats?: DashboardStats;
|
||||
activityRatio: number;
|
||||
health: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-[var(--radius-r)] bg-[var(--color-surface)] p-5">
|
||||
<div className="absolute inset-0 opacity-50">
|
||||
<SignalField activity={activityRatio} className="size-full" />
|
||||
</div>
|
||||
<div className="relative z-10 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="display text-3xl">GMW Console</div>
|
||||
<div className="mt-1 text-sm text-[var(--color-ink-soft)]">
|
||||
{stats?.total_messages?.toLocaleString() ?? 0} messages watched ·{" "}
|
||||
{stats?.total_users?.toLocaleString() ?? 0} users
|
||||
</div>
|
||||
</div>
|
||||
<RadialGauge
|
||||
value={health}
|
||||
size={132}
|
||||
label="Clean"
|
||||
tone={health > 0.8 ? "signal" : health > 0.6 ? "amber" : "vermilion"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Ticker({
|
||||
icon,
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
data,
|
||||
tone = "signal",
|
||||
tone,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: number;
|
||||
data: number[];
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
value: number | string;
|
||||
tone: "signal" | "amber" | "vermilion" | "neutral";
|
||||
}) {
|
||||
const color = {
|
||||
signal: "var(--color-signal)",
|
||||
amber: "var(--color-amber)",
|
||||
vermilion: "var(--color-vermilion)",
|
||||
}[tone];
|
||||
const color =
|
||||
tone === "signal"
|
||||
? "var(--color-signal)"
|
||||
: tone === "amber"
|
||||
? "var(--color-amber)"
|
||||
: tone === "vermilion"
|
||||
? "var(--color-vermilion)"
|
||||
: "var(--color-ink)";
|
||||
return (
|
||||
<StaggerItem className="surface scan-tick flex flex-col gap-2 p-4">
|
||||
<div className="flex items-center gap-2 text-[var(--color-ink-soft)]">
|
||||
<span style={{ color }}>{icon}</span>
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="display text-2xl" style={{ color }}>
|
||||
{value.toLocaleString()}
|
||||
</div>
|
||||
<Sparkline
|
||||
data={data}
|
||||
width={220}
|
||||
height={32}
|
||||
stroke={color}
|
||||
className="w-full"
|
||||
/>
|
||||
</StaggerItem>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-[var(--color-ink-soft)]">{label}</span>
|
||||
<span className="tabular-nums text-base" style={{ color }}>
|
||||
{typeof value === "number" ? value.toLocaleString() : value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { SWRConfig } from "swr";
|
||||
import { ChatbotContainer } from "@/components/chatbot/chatbot-container";
|
||||
@@ -7,11 +8,12 @@ import {
|
||||
ChatbotProvider,
|
||||
useChatbot,
|
||||
} from "@/components/chatbot/chatbot-context";
|
||||
import { DashLeftRail } from "@/components/layout/dash-left-rail";
|
||||
import { DashTopBar } from "@/components/layout/dash-top-bar";
|
||||
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 { GuildSelector } from "@/components/shared/guild-selector";
|
||||
import { MediaPlayerProvider } from "@/lib/hooks/use-media-player";
|
||||
import { useWebSocket, WsProvider } from "@/lib/ws/context";
|
||||
|
||||
@@ -42,12 +44,69 @@ function ChatbotExpressionSync() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* New Event Horizon shell — used only on /dashboard.
|
||||
*
|
||||
* No `Spine`, no `StatusBar`, no padded `<main>`, no 1440px max-width.
|
||||
* Full-bleed single-screen layout. Other dashboard routes keep the
|
||||
* classic shell so the rest of the app is untouched.
|
||||
*/
|
||||
function ConsoleShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-svh w-full flex-col overflow-hidden bg-[var(--color-canvas)]">
|
||||
<DashTopBar guildName="GMW Console" />
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<DashLeftRail />
|
||||
<main className="min-w-0 flex-1 overflow-hidden">{children}</main>
|
||||
</div>
|
||||
</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({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [guildId, setGuildId] = useState("");
|
||||
const pathname = usePathname();
|
||||
// Match exact /dashboard or /dashboard/ but not /dashboard/<subroute>
|
||||
const isConsole = pathname === "/dashboard" || pathname === "/dashboard/";
|
||||
|
||||
return (
|
||||
<SWRConfig
|
||||
@@ -63,30 +122,15 @@ export default function DashboardLayout({
|
||||
<ChatbotProvider>
|
||||
<ChatbotGuildSync guildId={guildId} />
|
||||
<ChatbotExpressionSync />
|
||||
<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>
|
||||
<MiniPlayer />
|
||||
<ChatbotContainer />
|
||||
</div>
|
||||
{isConsole ? (
|
||||
<ConsoleShell>{children}</ConsoleShell>
|
||||
) : (
|
||||
<ClassicShell guildId={guildId} setGuildId={setGuildId}>
|
||||
{children}
|
||||
</ClassicShell>
|
||||
)}
|
||||
<MiniPlayer />
|
||||
<ChatbotContainer />
|
||||
</ChatbotProvider>
|
||||
</MediaPlayerProvider>
|
||||
</WsProvider>
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
"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",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
"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())}`;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"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())}`;
|
||||
}
|
||||
Reference in New Issue
Block a user