From 84757bdcf4a1b8f87d9c2876969338c867916675 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sat, 15 Aug 2026 16:21:45 +0700 Subject: [PATCH] feat(console): rombak penuh dashboard layout jadi Event Horizon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/app/(dashboard)/dashboard/view.tsx | 375 ++++++++---------- .../frontend/src/app/(dashboard)/layout.tsx | 94 +++-- .../components/command/dash-command-line.tsx | 182 +++++++++ .../src/components/feed/event-feed.tsx | 273 +++++++++++++ .../src/components/feed/event-row.tsx | 133 +++++++ .../src/components/feed/now-marker.tsx | 138 +++++++ .../src/components/layout/dash-left-rail.tsx | 92 +++++ .../src/components/layout/dash-right-rail.tsx | 183 +++++++++ .../src/components/layout/dash-top-bar.tsx | 115 ++++++ 9 files changed, 1346 insertions(+), 239 deletions(-) create mode 100644 services/frontend/src/components/command/dash-command-line.tsx create mode 100644 services/frontend/src/components/feed/event-feed.tsx create mode 100644 services/frontend/src/components/feed/event-row.tsx create mode 100644 services/frontend/src/components/feed/now-marker.tsx create mode 100644 services/frontend/src/components/layout/dash-left-rail.tsx create mode 100644 services/frontend/src/components/layout/dash-right-rail.tsx create mode 100644 services/frontend/src/components/layout/dash-top-bar.tsx diff --git a/services/frontend/src/app/(dashboard)/dashboard/view.tsx b/services/frontend/src/app/(dashboard)/dashboard/view.tsx index 4e95603..3cf2cf3 100644 --- a/services/frontend/src/app/(dashboard)/dashboard/view.tsx +++ b/services/frontend/src/app/(dashboard)/dashboard/view.tsx @@ -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("stats"); - const [days, setDays] = useState(14); - const reduce = useReducedMotion(); + const ws = useWebSocket(); - const { data: stats } = useStats(initialStats); - const { data: activity } = useActivity( - days, - days === 14 ? initialActivity : undefined, + const seedEvents = useMemo(() => { + 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 ( +
+
+ +
+ + waiting for the first signal … +
+ events will stream in as the bot captures activity. + + } + /> + +
+
+ +
+ ); +} + +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: , - }, - { id: "users", label: "Users", icon: }, - { - id: "channels", - label: "Channels", - icon: , - }, - { - id: "reactions", - label: "Reactions", - icon: , - }, - ]; + const clean = stats?.total_clean ?? 0; + const denom = clean + flagged + warned || 1; + const ratio = clean / denom; return ( -
- - - {/* Tabs */} -
-
- {tabs.map((t) => ( - - ))} +
+
+
+

+ GMW Console +

+

+ {total.toLocaleString()}{" "} + messages watched ·{" "} + + {(stats?.total_users ?? 0).toLocaleString()} + {" "} + users ·{" "} + {stats?.active_users_24h ?? 0}{" "} + active 24h +

-
- {DAYS.map((d) => ( - - ))} -
-
- {/* Ticker row (stats tab) */} - {tab === "stats" && ( - - } - label="Messages" - value={stats?.total_messages ?? 0} - data={spark} +
+ + + + - } - label="Flagged" - value={flagged} - data={flaggedSpark} - tone="vermilion" - /> - } - label="Active 24h" - value={stats?.active_users_24h ?? 0} - data={usersSpark} - tone="amber" - /> - } - label="Recordings" - value={stats?.total_voice_recordings ?? 0} - data={spark} - /> - - )} - -
-
- {tab === "stats" && ( - <> - - - - - )} - {tab === "users" && } - {tab === "channels" && } - {tab === "reactions" && } -
-
-
); } -function Hero({ - stats, - activityRatio, - health, -}: { - stats?: DashboardStats; - activityRatio: number; - health: number; -}) { - return ( -
-
- -
-
-
-
GMW Console
-
- {stats?.total_messages?.toLocaleString() ?? 0} messages watched ·{" "} - {stats?.total_users?.toLocaleString() ?? 0} users -
-
- 0.8 ? "signal" : health > 0.6 ? "amber" : "vermilion"} - /> -
-
- ); -} - -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 ( - -
- {icon} - - {label} - -
-
- {value.toLocaleString()} -
- -
+
+ {label} + + {typeof value === "number" ? value.toLocaleString() : value} + +
); } diff --git a/services/frontend/src/app/(dashboard)/layout.tsx b/services/frontend/src/app/(dashboard)/layout.tsx index 8959c9d..8f8acd3 100644 --- a/services/frontend/src/app/(dashboard)/layout.tsx +++ b/services/frontend/src/app/(dashboard)/layout.tsx @@ -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 `
`, 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 ( +
+ +
+ +
{children}
+
+
+ ); +} + +/** + * Classic shell — used on every other route under /(dashboard). + */ +function ClassicShell({ + children, + guildId, + setGuildId, +}: { + children: React.ReactNode; + guildId: string; + setGuildId: (g: string) => void; +}) { + return ( +
+ +
+ setGuildId(g)} /> +
+
+ +
+
+ } + > + {children} +
+
+
+
+
+ ); +} + export default function DashboardLayout({ children, }: { children: React.ReactNode; }) { const [guildId, setGuildId] = useState(""); + const pathname = usePathname(); + // Match exact /dashboard or /dashboard/ but not /dashboard/ + const isConsole = pathname === "/dashboard" || pathname === "/dashboard/"; return ( -
- -
- setGuildId(g)} - /> -
-
- -
-
- } - > - {children} -
-
-
-
- - -
+ {isConsole ? ( + {children} + ) : ( + + {children} + + )} + + diff --git a/services/frontend/src/components/command/dash-command-line.tsx b/services/frontend/src/components/command/dash-command-line.tsx new file mode 100644 index 0000000..0c63f4d --- /dev/null +++ b/services/frontend/src/components/command/dash-command-line.tsx @@ -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([]); + const [_historyIdx, setHistoryIdx] = useState(-1); + const [result, setResult] = useState(null); + const inputRef = useRef(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) => { + 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 ( +
+ {">"} + 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 ? ( + + {result.message} + + ) : suggestions.length > 0 ? ( + + {suggestions.map((s) => `/${s}`).join(" ")} + + ) : null} +
+ ); +} + +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"; + } +} diff --git a/services/frontend/src/components/feed/event-feed.tsx b/services/frontend/src/components/feed/event-feed.tsx new file mode 100644 index 0000000..c294195 --- /dev/null +++ b/services/frontend/src/components/feed/event-feed.tsx @@ -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(() => + injectMarkers(initialEvents.slice(-RING_BUFFER_MAX)), + ); + const [selectedId, setSelectedId] = useState(null); + const [following, setFollowing] = useState(true); + const scrollerRef = useRef(null); + const lastPulseAt = useRef(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 ( +
+
+ event horizon + + {visibleItems.filter((i) => i.kind === "event").length} events ·{" "} + {following ? "live" : "paused"} + +
+ +
+ {visibleItems.length === 0 && emptyState ? ( +
+ {emptyState} +
+ ) : ( + visibleItems.map((item) => { + if (item.kind === "event") { + return ( +
+ +
+ ); + } + if (item.kind === "cluster") { + return ( +
+ +
+ ); + } + return ( +
+ +
+ ); + }) + )} +
+
+ ); +} + +// ── 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).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", + }, + ]; +} diff --git a/services/frontend/src/components/feed/event-row.tsx b/services/frontend/src/components/feed/event-row.tsx new file mode 100644 index 0000000..7174e5b --- /dev/null +++ b/services/frontend/src/components/feed/event-row.tsx @@ -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 = { + neutral: "oklch(0.46 0.02 70)", + signal: "var(--color-signal)", + amber: "var(--color-amber)", + vermilion: "var(--color-vermilion)", +}; + +const SEVERITY_FILL: Record = { + 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 = ( + + ); + + return ( + + ); +} diff --git a/services/frontend/src/components/feed/now-marker.tsx b/services/frontend/src/components/feed/now-marker.tsx new file mode 100644 index 0000000..22c3f65 --- /dev/null +++ b/services/frontend/src/components/feed/now-marker.tsx @@ -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 = { + signal: "var(--color-signal)", + amber: "var(--color-amber)", + vermilion: "var(--color-vermilion)", + neutral: "oklch(0.46 0.02 70)", +}; + +const TONE_FILL: Record = { + 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 ( +
+ + + {formatTimestamp(timestamp)} + + + {label} + + + {children} + + {trailing ? ( + + {trailing} + + ) : null} +
+ ); +} + +export function PulseMarker({ + tone = "signal", + label, + timestamp, + trailing, +}: PulseMarkerProps) { + return ( + + {/* children rendered by parent via composition — see NowMarker union below */} + + ); +} + +export function ClusterMarker({ + tone = "signal", + label, + timestamp, + bands, +}: ClusterMarkerProps) { + return ( + +
+ {bands.map((b) => ( + + ))} +
+
+ ); +} diff --git a/services/frontend/src/components/layout/dash-left-rail.tsx b/services/frontend/src/components/layout/dash-left-rail.tsx new file mode 100644 index 0000000..403ca9b --- /dev/null +++ b/services/frontend/src/components/layout/dash-left-rail.tsx @@ -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: , + label: "Console", + }, + { + href: "/messages", + glyph: , + label: "Messages", + }, + { + href: "/moderation", + glyph: , + label: "Moderation", + }, + { href: "/voice", glyph: , label: "Voice" }, + { href: "/media", glyph: , label: "Media" }, + { + href: "/recordings", + glyph: , + label: "Recordings", + }, + { href: "/analysis", glyph: , label: "Analysis" }, +]; + +export function DashLeftRail() { + const pathname = usePathname(); + return ( + + ); +} diff --git a/services/frontend/src/components/layout/dash-right-rail.tsx b/services/frontend/src/components/layout/dash-right-rail.tsx new file mode 100644 index 0000000..ed23c0b --- /dev/null +++ b/services/frontend/src/components/layout/dash-right-rail.tsx @@ -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([]); + useEffect(() => subscribe(ws), [ws, subscribe]); + + return ( + + ); +} + +function Section({ + title, + children, + vertical, +}: { + title: string; + children?: React.ReactNode; + vertical?: boolean; +}) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} + +function Empty({ msg }: { msg: string }) { + return ( + + {msg} + + ); +} + +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())}`; +} diff --git a/services/frontend/src/components/layout/dash-top-bar.tsx b/services/frontend/src/components/layout/dash-top-bar.tsx new file mode 100644 index 0000000..5a7a276 --- /dev/null +++ b/services/frontend/src/components/layout/dash-top-bar.tsx @@ -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(null); + const [focus, setFocus] = useState("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 ( +
+
+ + {botName} + + · + {guildName} +
+ +
+
+ + + {ws.status} + +
+ + + +
+ {FOCUS_MODES.map((m) => ( + + ))} +
+
+
+ ); +} + +function formatLocal(d: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +}