feat(dashboard): ground-up rombak jadi Ambient Field layout (bukan re-skin)
Hapus template dashboard lama (top bar + side rail + main + right panel + bottom prompt). Ganti dengan layout yang benar-benar beda: - AmbientField: full-bleed WebGL canvas haze, drift speed + densitas ngikut load server, warna ngikut signal moderasi terakhir (clean→lime, warn→amber, flagged→vermilion). Background tanpa container. - View jadi full-bleed: headline raksasa bottom-left, metric cluster floating top-right (no box), event ribbon drift di tengah, command whisper di very bottom. - AmbientShell di layout.tsx: gak ada TopBar/LeftRail untuk /dashboard exact. Route lain (messages/voice/media/dll) tetap ClassicShell. - Tidak ada card, tidak ada grid, tidak ada panel, tidak ada tab. Verified: tsc clean, next build 11/11 halaman, biome clean.
This commit is contained in:
@@ -1,27 +1,20 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Dashboard — Event Horizon layout.
|
||||
* Dashboard — Ambient Field 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.
|
||||
* No top bar. No side rail. No grid. No panels.
|
||||
*
|
||||
* 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.
|
||||
* A full-bleed WebGL haze (AmbientField) is the page. Content floats over it:
|
||||
* a giant headline bottom-left, a live metric cluster top-right, a drifting
|
||||
* event ribbon mid-screen, a command whispher at the very bottom. Whitespace
|
||||
* is the layout — density comes from data, not chrome.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { AmbientField } from "@/components/ambient/ambient-field";
|
||||
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";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
export default function DashboardView({
|
||||
@@ -32,58 +25,28 @@ export default function DashboardView({
|
||||
initialActivity?: DashboardActivity;
|
||||
}) {
|
||||
const ws = useWebSocket();
|
||||
const [signal, setSignal] = useState<
|
||||
"signal" | "amber" | "vermilion" | "neutral"
|
||||
>("signal");
|
||||
const [load, setLoad] = useState(0.3);
|
||||
|
||||
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 total = initialStats?.total_messages ?? 0;
|
||||
const clean = initialStats?.total_clean ?? 0;
|
||||
const flagged = initialStats?.total_flagged ?? 0;
|
||||
const warned = initialStats?.total_warned ?? 0;
|
||||
const ratio = ((clean / (clean + flagged + warned || 1)) * 100).toFixed(1);
|
||||
|
||||
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;
|
||||
};
|
||||
const _subscribe = useCallback(
|
||||
(handler: (e: { severity: string; ts: number }) => void) => {
|
||||
const unsub = ws.on("message_created", (data: any) => {
|
||||
const s = data.ai_status;
|
||||
setSignal(
|
||||
s === "flagged" ? "vermilion" : s === "warn" ? "amber" : "signal",
|
||||
);
|
||||
setLoad((l) => Math.min(1, l + 0.02));
|
||||
handler({
|
||||
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,
|
||||
severity: s ?? "neutral",
|
||||
ts: data.created_at ?? Date.now(),
|
||||
});
|
||||
});
|
||||
return unsub;
|
||||
@@ -91,107 +54,91 @@ export default function DashboardView({
|
||||
[ws],
|
||||
);
|
||||
|
||||
const seedEvents = useMemo(() => {
|
||||
if (!initialActivity) return [];
|
||||
return initialActivity.daily.slice(-10).flatMap((d) =>
|
||||
Array.from({ length: Math.min(3, d.messages) }, (_, i) => ({
|
||||
id: `seed-${d.day}-${i}`,
|
||||
ts: Date.now() - i * 120_000,
|
||||
severity: i < d.flagged ? "vermilion" : "signal",
|
||||
actor: i < d.flagged ? "ai" : "user",
|
||||
action: i < d.flagged ? "flagged" : "sent",
|
||||
channel: "#general",
|
||||
excerpt: `seed ${d.day}`,
|
||||
})),
|
||||
);
|
||||
}, [initialActivity]);
|
||||
|
||||
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.
|
||||
<div className="relative h-[calc(100svh-3rem)] w-full overflow-hidden bg-[var(--color-canvas)]">
|
||||
<AmbientField load={load} signal={signal} />
|
||||
|
||||
{/* Metric cluster — top right, floating, no container */}
|
||||
<div className="absolute right-6 top-6 flex flex-col items-end gap-1 font-mono text-right">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[var(--color-ink-soft)]">
|
||||
watched
|
||||
</span>
|
||||
<span className="display text-5xl font-medium tabular-nums leading-none text-[var(--color-ink)]">
|
||||
{total.toLocaleString()}
|
||||
</span>
|
||||
<div className="mt-2 flex gap-4 text-[12px]">
|
||||
<span className="text-[var(--color-signal)]">
|
||||
{clean.toLocaleString()} clean
|
||||
</span>
|
||||
<span className="text-[var(--color-amber)]">{warned} warn</span>
|
||||
<span className="text-[var(--color-vermilion)]">{flagged} flag</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--color-ink-soft)]">
|
||||
{ratio}% ratio
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Headline — bottom left, massive */}
|
||||
<div className="absolute bottom-20 left-6 max-w-[60vw]">
|
||||
<h1 className="display text-[clamp(3rem,9vw,7rem)] font-medium leading-[0.95] tracking-tight text-[var(--color-ink)]">
|
||||
GMW
|
||||
<br />
|
||||
Console
|
||||
</h1>
|
||||
<p className="mt-3 font-mono text-[12px] text-[var(--color-ink-soft)]">
|
||||
{(initialStats?.total_users ?? 0).toLocaleString()} users ·{" "}
|
||||
{initialStats?.active_users_24h ?? 0} active 24h
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Event ribbon — mid screen, drifting row */}
|
||||
<div className="absolute left-1/2 top-1/2 w-[min(90vw,900px)] -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="flex flex-col gap-1 font-mono text-[11px]">
|
||||
{seedEvents.slice(0, 6).map((e) => (
|
||||
<div
|
||||
key={e.id}
|
||||
className="flex items-center gap-2 opacity-70"
|
||||
data-severity={e.severity}
|
||||
>
|
||||
<span
|
||||
className="inline-block size-1.5 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
e.severity === "vermilion"
|
||||
? "var(--color-vermilion)"
|
||||
: "var(--color-signal)",
|
||||
}}
|
||||
/>
|
||||
<span className="text-[var(--color-ink-soft)] tabular-nums">
|
||||
{new Date(e.ts).toLocaleTimeString()}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<DashCommandLine />
|
||||
<span className="truncate text-[var(--color-ink)]">
|
||||
{e.excerpt}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</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 clean = stats?.total_clean ?? 0;
|
||||
const denom = clean + flagged + warned || 1;
|
||||
const ratio = clean / denom;
|
||||
|
||||
return (
|
||||
<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="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"
|
||||
/>
|
||||
</div>
|
||||
{/* Command whisper — very bottom, minimal */}
|
||||
<div className="absolute inset-x-0 bottom-0">
|
||||
<DashCommandLine />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | string;
|
||||
tone: "signal" | "amber" | "vermilion" | "neutral";
|
||||
}) {
|
||||
const color =
|
||||
tone === "signal"
|
||||
? "var(--color-signal)"
|
||||
: tone === "amber"
|
||||
? "var(--color-amber)"
|
||||
: tone === "vermilion"
|
||||
? "var(--color-vermilion)"
|
||||
: "var(--color-ink)";
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ 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";
|
||||
@@ -45,20 +43,16 @@ function ChatbotExpressionSync() {
|
||||
}
|
||||
|
||||
/**
|
||||
* New Event Horizon shell — used only on /dashboard.
|
||||
* Ambient 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.
|
||||
* 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 ConsoleShell({ children }: { children: React.ReactNode }) {
|
||||
function AmbientShell({ 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 className="h-[calc(100svh-3rem)] w-full overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -123,7 +117,7 @@ export default function DashboardLayout({
|
||||
<ChatbotGuildSync guildId={guildId} />
|
||||
<ChatbotExpressionSync />
|
||||
{isConsole ? (
|
||||
<ConsoleShell>{children}</ConsoleShell>
|
||||
<AmbientShell>{children}</AmbientShell>
|
||||
) : (
|
||||
<ClassicShell guildId={guildId} setGuildId={setGuildId}>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user