revert(frontend): kembalikan shell usable — hapus total eksperimen constellation (three/d3-force dihapus)

This commit is contained in:
asepharyana
2026-08-24 16:32:16 +07:00
parent 25d5097edb
commit eda5c752b7
32 changed files with 1540 additions and 3131 deletions
@@ -11,7 +11,5 @@ export function PageTransition({
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("animate-fade-up h-full", className)}>{children}</div>
);
return <div className={cn("animate-fade-up", className)}>{children}</div>;
}
@@ -0,0 +1,30 @@
import { MiniPlayer } from "@/components/media/mini-player";
import { MobileNav } from "./mobile-nav";
import { NavRail } from "./nav-rail";
import { TopBar } from "./topbar";
/**
* App chrome: slim nav rail + sticky top bar + scrollable content region.
* Sits above the fixed AmbientCanvas. Providers (Ambient + WS) are mounted in
* the route layout so every page shares one live link and signal context.
*
* < md the side rail collapses (hidden) and a bottom tab bar (MobileNav)
* takes over navigation; the content region gains bottom padding so the last
* panel never hides behind the dock. A persistent MiniPlayer floats at the
* bottom-right whenever a media track is loaded outside /media.
*/
export function AppFrame({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-dvh w-full overflow-hidden">
<NavRail />
<div className="flex min-w-0 flex-1 flex-col">
<TopBar />
<main className="min-h-0 flex-1 overflow-y-auto px-4 pb-[calc(2rem+env(safe-area-inset-bottom))] pt-4 sm:px-5 sm:pb-[calc(2.5rem+env(safe-area-inset-bottom))]">
{children}
</main>
</div>
<MobileNav />
<MiniPlayer />
</div>
);
}
@@ -1,82 +0,0 @@
"use client";
/**
* ConstellationFrame — replaces the classic AppFrame chrome.
* The stage canvas sits fixed behind everything; page content is an
* overlay layer (no top bar / nav rail / scroll shell). Views publish
* their live graph via SceneGraphProvider; the frame renders whatever
* the active view published (fallback: route-scenes default builder).
* Chatbot + CommandPalette keep mounting at the layout level.
*/
import { usePathname } from "next/navigation";
import { type ReactNode, useMemo } from "react";
import { MiniPlayer } from "@/components/media/mini-player";
import { ConstellationStage } from "@/components/shell/constellation-stage";
import { FloatingChrome } from "@/components/shell/floating-chrome";
import {
buildDefaultGraph,
resolveScene,
type SceneSeed,
} from "@/components/shell/route-scenes";
import { SceneA11yMirror } from "@/components/shell/scene-a11y-mirror";
import {
SceneGraphProvider,
useSceneGraph,
} from "@/components/shell/scene-graph-context";
function StageFromContext({ seed }: { seed?: SceneSeed }) {
const pathname = usePathname() ?? "/";
const { state, setFocus } = useSceneGraph();
const onChannelsRoute = pathname.startsWith("/channels");
const graph = useMemo(() => {
if (state) return state.graph;
const scene = resolveScene(pathname);
return scene
? buildDefaultGraph(scene, seed ?? {})
: { nodes: [], edges: [] };
}, [state, pathname, seed]);
return (
<ConstellationStage
graph={graph}
selectedId={state?.focus ?? null}
onNodeClick={(id) => {
// On /channels/ a click selects the star (opens its dossier).
if (onChannelsRoute && id.startsWith("channel:")) {
setFocus((prev) => (prev === id ? null : id));
return;
}
const meta = state?.graph.nodes.find((n) => n.id === id);
if (meta?.href) window.location.assign(meta.href);
}}
/>
);
}
export function ConstellationFrame({
children,
sceneSeed,
}: ConstellationFrameProps_) {
return (
<SceneGraphProvider>
<div className="relative h-dvh w-full overflow-hidden">
<StageFromContext seed={sceneSeed} />
<FloatingChrome />
<SceneA11yMirror />
<MiniPlayer />
{/* Overlay content region — scenes place floating panels inside. */}
<main className="pointer-events-none absolute inset-0 z-10 overflow-y-auto overscroll-contain">
{/* no pointer-events here: empty areas stay click-through to the sky */}
<div className="h-full">{children}</div>
</main>
</div>
</SceneGraphProvider>
);
}
interface ConstellationFrameProps_ {
children: ReactNode;
/** Typed SSR seed for the route-scenes fallback builder. */
sceneSeed?: SceneSeed;
}
@@ -1,435 +0,0 @@
"use client";
/**
* ConstellationStage — full-bleed interactive star-field renderer.
* Renders the route's graph as glowing nodes + hairline edges over a
* three.js orthographic view. Pan = drag, zoom = wheel/pinch, click on
* an href-bearing node navigates (plain location.assign — router.push
* can no-op under standalone+trailingSlash builds).
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as THREE from "three";
import {
type CameraState,
easeInOutCubic,
fitCamera,
flyTo,
} from "@/lib/constellation/camera";
import type { ConstellationGraph } from "@/lib/constellation/graph";
import { computeLayout, type LayoutNode } from "@/lib/constellation/layout";
import { readPalette, type StagePalette } from "@/lib/constellation/palette";
export interface ConstellationStageProps {
graph: ConstellationGraph;
seed?: number;
/** Node id kept highlighted (scene selection). */
selectedId?: string | null;
onNodeClick?: (nodeId: string) => void;
}
const Z_MIN = 0.35;
const Z_MAX = 3;
export function ConstellationStage({
graph,
seed = 42,
selectedId = null,
onNodeClick,
}: ConstellationStageProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const labelsRef = useRef<HTMLDivElement | null>(null);
const camRef = useRef<CameraState>({ x: 0, y: 0, z: 1 });
const layoutRef = useRef<LayoutNode[]>([]);
const nodeByIdRef = useRef(new Map(graph.nodes.map((n) => [n.id, n])));
const [size, setSize] = useState({ w: 0, h: 0 });
const hoveredRef = useRef<string | null>(null);
const [reduced, setReduced] = useState(false);
nodeByIdRef.current = new Map(graph.nodes.map((n) => [n.id, n]));
// Viewport size + reduced-motion preference.
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const sync = () => setReduced(mq.matches);
sync();
mq.addEventListener("change", sync);
const measure = () =>
setSize({ w: window.innerWidth, h: window.innerHeight });
measure();
window.addEventListener("resize", measure);
return () => {
mq.removeEventListener("change", sync);
window.removeEventListener("resize", measure);
};
}, []);
// Deterministic layout whenever graph or viewport changes.
// On scene change we FLY the camera to the new fit instead of snapping.
const prevSceneRef = useRef("");
const animRef = useRef<{
from: CameraState;
to: CameraState;
t0: number;
} | null>(null);
useEffect(() => {
if (size.w < 50 || size.h < 50) return;
const layout = computeLayout(graph.nodes, graph.edges, {
width: size.w,
height: size.h,
seed,
reduced,
});
layoutRef.current = layout;
const target = fitCamera(layout, size.w, size.h);
const first = graph.nodes[0]?.id ?? "";
const sceneKey = `${graph.nodes.length}:${first}`;
if (!reduced && prevSceneRef.current && prevSceneRef.current !== sceneKey) {
animRef.current = {
from: { ...camRef.current },
to: target,
t0: performance.now(),
};
} else {
camRef.current = target;
}
prevSceneRef.current = sceneKey;
}, [graph, size.w, size.h, seed, reduced]);
// Build + run the renderer.
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || size.w < 50 || size.h < 50 || layoutRef.current.length === 0)
return;
let palette: StagePalette = readPalette();
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(size.w, size.h, false);
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, -200, 200);
const group = new THREE.Group();
scene.add(group);
const disposables: { dispose: () => void }[] = [];
const track = <T extends { dispose: () => void }>(d: T): T => {
disposables.push(d);
return d;
};
// Edges first (under nodes).
const byId = new Map(layoutRef.current.map((n) => [n.id, n]));
const edgeMat = track(
new THREE.LineBasicMaterial({
color: palette.inkFaint,
transparent: true,
opacity: 0.35,
}),
);
for (const e of graph.edges) {
const a = byId.get(e.source);
const b = byId.get(e.target);
if (!a || !b) continue;
const geo = track(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(a.x, a.y, 0),
new THREE.Vector3(b.x, b.y, 0),
]),
);
group.add(new THREE.Line(geo, edgeMat));
}
// Nodes: glow halo + core disc.
interface NodeVisual {
id: string;
halo: THREE.Mesh<THREE.CircleGeometry, THREE.MeshBasicMaterial>;
core: THREE.Mesh<THREE.CircleGeometry, THREE.MeshBasicMaterial>;
r: number;
x: number;
y: number;
}
const visuals: NodeVisual[] = [];
for (const n of layoutRef.current) {
const meta = nodeByIdRef.current.get(n.id);
const kind = meta?.kind ?? "message";
const flagged =
Number(meta?.meta?.flagged_count ?? 0) > 0 && kind === "channel";
const color =
kind === "guild"
? palette.signal
: flagged
? palette.vermilion
: palette.ink;
const haloGeo = track(new THREE.CircleGeometry(n.r * 2.4, 32));
const haloMat = track(
new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: kind === "guild" ? 0.14 : 0.08,
}),
);
const coreGeo = track(new THREE.CircleGeometry(n.r, 32));
const coreMat = track(
new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.92,
}),
);
const halo = new THREE.Mesh(haloGeo, haloMat);
halo.position.set(n.x, n.y, -1);
const core = new THREE.Mesh(coreGeo, coreMat);
core.position.set(n.x, n.y, 0);
group.add(halo, core);
visuals.push({ id: n.id, halo, core, r: n.r, x: n.x, y: n.y });
}
const applyCamera = () => {
const { x, y, z } = camRef.current;
const hw = size.w / (2 * z);
const hh = size.h / (2 * z);
camera.left = -hw;
camera.right = hw;
camera.top = hh;
camera.bottom = -hh;
camera.position.set(x, y, 100);
camera.updateProjectionMatrix();
};
// --- interaction state ---
type PointerMode =
| { t: "idle" }
| { t: "drag"; sx: number; sy: number; cx: number; cy: number };
let mode: PointerMode = { t: "idle" };
let raf = 0;
let disposed = false;
const pointerToWorld = (clientX: number, clientY: number) => {
const rect = canvas.getBoundingClientRect();
const { x, y, z } = camRef.current;
return {
wx: x + (clientX - rect.left - size.w / 2) / z,
wy: y - (clientY - rect.top - size.h / 2) / z,
};
};
const pickNode = (clientX: number, clientY: number): NodeVisual | null => {
const { wx, wy } = pointerToWorld(clientX, clientY);
let best: NodeVisual | null = null;
let bestD = Infinity;
for (const v of visuals) {
const d = Math.hypot(wx - v.x, wy - v.y);
if (d <= v.r + 6 && d < bestD) {
best = v;
bestD = d;
}
}
return best;
};
const onPointerDown = (ev: PointerEvent) => {
mode = {
t: "drag",
sx: ev.clientX,
sy: ev.clientY,
cx: camRef.current.x,
cy: camRef.current.y,
};
canvas.style.cursor = "grabbing";
};
const onPointerMove = (ev: PointerEvent) => {
if (mode.t === "drag") {
const z = camRef.current.z;
camRef.current.x = mode.cx - (ev.clientX - mode.sx) / z;
camRef.current.y = mode.cy + (ev.clientY - mode.sy) / z;
return;
}
const hit = pickNode(ev.clientX, ev.clientY);
hoveredRef.current = hit ? hit.id : null;
canvas.style.cursor = hit ? "pointer" : "grab";
};
const onPointerUp = (ev: PointerEvent) => {
const wasDrag =
mode.t === "drag" &&
Math.hypot(ev.clientX - mode.sx, ev.clientY - mode.sy) > 4;
mode = { t: "idle" };
canvas.style.cursor = "grab";
if (wasDrag) return;
const hit = pickNode(ev.clientX, ev.clientY);
if (!hit) return;
const meta = nodeByIdRef.current.get(hit.id);
onNodeClick?.(hit.id);
if (meta?.href) window.location.assign(meta.href);
};
const onWheel = (ev: WheelEvent) => {
ev.preventDefault();
const old = camRef.current.z;
const next = Math.max(
Z_MIN,
Math.min(Z_MAX, old * Math.exp(-ev.deltaY * 0.0012)),
);
camRef.current.z = next;
};
canvas.style.cursor = "grab";
canvas.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
canvas.addEventListener("wheel", onWheel, { passive: false });
// Labels (HTML, imperative transforms — cheap for ≤16 nodes).
const labelHost = labelsRef.current;
const labelEls = new Map<string, HTMLSpanElement>();
const labeled = [...layoutRef.current]
.sort((a, b) => b.r - a.r)
.slice(0, 14);
if (labelHost) {
for (const n of labeled) {
const el = document.createElement("span");
el.textContent = nodeByIdRef.current.get(n.id)?.label ?? n.id;
el.dataset.nodeLabel = n.id;
el.className =
"pointer-events-none absolute whitespace-nowrap font-mono text-[11px] tracking-wide text-[var(--color-ink-soft)] transition-colors";
labelHost.appendChild(el);
labelEls.set(n.id, el);
}
}
// Theme sync — tokens flip with the .light class.
const themeObserver = new MutationObserver(() => {
palette = readPalette();
for (const v of visuals) {
const meta = nodeByIdRef.current.get(v.id);
const flagged =
Number(meta?.meta?.flagged_count ?? 0) > 0 &&
(meta?.kind ?? "") === "channel";
const c =
meta?.kind === "guild"
? palette.signal
: flagged
? palette.vermilion
: palette.ink;
v.core.material.color.setHex(c);
v.halo.material.color.setHex(c);
}
edgeMat.color.setHex(palette.inkFaint);
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
let hidden = document.hidden;
const onVis = () => {
hidden = document.hidden;
};
document.addEventListener("visibilitychange", onVis);
const start = performance.now();
const frame = (now: number) => {
if (disposed) return;
raf = requestAnimationFrame(frame);
if (hidden) return;
// Camera fly-to (scene transitions): eased interpolation over ~900ms.
const anim = animRef.current;
if (anim) {
const raw = (now - anim.t0) / 900;
if (raw >= 1) {
camRef.current = anim.to;
animRef.current = null;
} else if (raw > 0) {
camRef.current = flyTo(anim.from, anim.to, raw, easeInOutCubic);
}
}
applyCamera();
// Gentle breathing glow (visual only — hit positions stay baked).
const t = (now - start) / 1000;
let i = 0;
for (const v of visuals) {
if (!reduced) {
const pulse = 0.5 + 0.5 * Math.sin(t * 1.4 + i * 0.7);
v.halo.material.opacity =
(v.id === "guild" ? 0.14 : 0.08) * (0.7 + 0.6 * pulse);
}
const hov = v.id === hoveredRef.current || v.id === selectedId;
v.core.scale.setScalar(hov ? 1.25 : 1);
i += 1;
}
renderer.render(scene, camera);
if (labelHost) {
const { x, y, z } = camRef.current;
for (const [id, el] of labelEls) {
const v = visuals.find((s) => s.id === id);
if (!v) continue;
const sx = (v.x - x) * z + size.w / 2;
const sy = -(v.y - y) * z + size.h / 2 + v.r + 14;
el.style.transform = `translate(${sx}px, ${sy}px) translateX(-50%)`;
el.style.color =
id === hoveredRef.current || id === selectedId
? "var(--color-signal)"
: "var(--color-ink-soft)";
}
}
};
raf = requestAnimationFrame(frame);
return () => {
disposed = true;
cancelAnimationFrame(raf);
document.removeEventListener("visibilitychange", onVis);
canvas.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
canvas.removeEventListener("wheel", onWheel);
themeObserver.disconnect();
for (const el of labelEls.values()) el.remove();
for (const d of disposables) d.dispose();
scene.clear();
renderer.dispose();
};
}, [graph, size.w, size.h, reduced, selectedId, onNodeClick]);
const emptyGraph = graph.nodes.length === 0;
const hint = useMemo(
() => (emptyGraph ? "Menunggu data scene…" : null),
[emptyGraph],
);
const handleReset = useCallback(() => {
if (layoutRef.current.length > 0 && size.w > 0) {
camRef.current = fitCamera(layoutRef.current, size.w, size.h);
}
}, [size.w, size.h]);
return (
<>
<canvas
ref={canvasRef}
className="fixed inset-0 -z-10 h-full w-full touch-none select-none"
/>
<div ref={labelsRef} aria-hidden="true" className="fixed inset-0 -z-10" />
{hint ? (
<p className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 text-center font-mono text-sm text-[var(--color-ink-faint)]">
{hint}
</p>
) : null}
<button
className="absolute bottom-24 right-5 rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/60 px-3 py-1 font-mono text-xs text-[var(--color-ink-soft)] backdrop-blur-sm hover:text-[var(--color-ink)] md:bottom-20"
onClick={handleReset}
type="button"
>
reset view
</button>
</>
);
}
@@ -1,139 +0,0 @@
"use client";
/**
* FloatingChrome — the only persistent UI over the constellation.
* No top bar, no nav rail: brand + live link (top-left), theme toggle
* and palette hint (top-right), route switcher (bottom-center).
* All internal navigation uses plain <a href> — trailingSlash builds.
*/
import { Menu, Moon, Sun, X } from "lucide-react";
import { usePathname } from "next/navigation";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { navItems } from "@/lib/navigation";
import { ConnectionStatus } from "./status-dot";
function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
return <span className="inline-block size-8" aria-hidden="true" />;
}
return (
<button
type="button"
aria-label="Toggle theme"
className="inline-flex size-8 items-center justify-center rounded-full border border-[var(--color-hairline)] text-[var(--color-ink-soft)] backdrop-blur-sm transition-colors hover:border-[var(--color-signal)] hover:text-[var(--color-signal)]"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
>
{theme === "light" ? (
<Moon className="size-4" aria-hidden="true" />
) : (
<Sun className="size-4" aria-hidden="true" />
)}
</button>
);
}
export function FloatingChrome() {
const pathname = usePathname();
const [menuOpen, setMenuOpen] = useState(false);
return (
<>
{/* Brand + live link */}
<div className="pointer-events-auto absolute left-5 top-5 z-20 flex items-center gap-3">
<span className="font-display text-lg font-semibold tracking-tight text-[var(--color-ink)]">
GMW
</span>
<span className="hidden font-mono text-[10px] uppercase tracking-[0.2em] text-[var(--color-ink-faint)] sm:inline">
constellation
</span>
<ConnectionStatus compact />
</div>
{/* Theme + palette hint */}
<div className="pointer-events-auto absolute right-5 top-5 z-20 flex items-center gap-2">
<button
type="button"
className="hidden rounded-full border border-[var(--color-hairline)] px-3 py-1 font-mono text-xs text-[var(--color-ink-faint)] backdrop-blur-sm transition-colors hover:text-[var(--color-ink)] md:block"
onClick={() =>
window.dispatchEvent(new Event("command-palette:open"))
}
>
K
</button>
<ThemeToggle />
</div>
{/* Route switcher */}
<nav
aria-label="Routes"
className="pointer-events-none absolute inset-x-0 bottom-5 z-20 hidden justify-center gap-1 px-4 md:flex"
>
<div className="pointer-events-auto flex max-w-full items-center gap-1 overflow-x-auto rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/55 px-2 py-1.5 backdrop-blur-md">
{navItems.map((item) => {
const active =
pathname === item.matchPrefix ||
pathname.startsWith(item.matchPrefix);
return (
<a
key={item.href}
href={item.href}
data-active={active || undefined}
className={`rounded-full px-3 py-1 font-mono text-xs whitespace-nowrap transition-colors ${
active
? "bg-[var(--color-signal-glow)] text-[var(--color-ink)]"
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
}`}
>
{item.label}
</a>
);
})}
</div>
</nav>
{/* Mobile: sheet menu instead of a bottom tab bar */}
<button
type="button"
aria-label={menuOpen ? "Close menu" : "Open menu"}
aria-expanded={menuOpen}
className="pointer-events-auto absolute bottom-5 right-5 z-30 inline-flex size-10 items-center justify-center rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/70 text-[var(--color-ink)] backdrop-blur-md md:hidden"
onClick={() => setMenuOpen((o) => !o)}
>
{menuOpen ? (
<X className="size-5" aria-hidden="true" />
) : (
<Menu className="size-5" aria-hidden="true" />
)}
</button>
{menuOpen ? (
<nav
aria-label="Routes mobile"
className="pointer-events-auto absolute inset-x-4 bottom-20 z-20 flex flex-col gap-1 rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/90 p-2 backdrop-blur-xl md:hidden"
>
{navItems.map((item) => {
const active =
pathname === item.matchPrefix ||
pathname.startsWith(item.matchPrefix);
return (
<a
key={item.href}
href={item.href}
className={`rounded-xl px-4 py-3 font-mono text-sm ${
active
? "bg-[var(--color-signal-glow)] text-[var(--color-ink)]"
: "text-[var(--color-ink-soft)]"
}`}
>
{item.label}
</a>
);
})}
</nav>
) : null}
</>
);
}
@@ -1,2 +1,5 @@
export { ConstellationFrame } from "./constellation-frame";
export { AppFrame } from "./ambient-app";
export { MobileNav } from "./mobile-nav";
export { NavRail } from "./nav-rail";
export { ConnectionStatus } from "./status-dot";
export { TopBar } from "./topbar";
@@ -0,0 +1,43 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { isActivePath, mobileNavItems } from "@/lib/navigation";
import { cn } from "@/lib/utils";
/**
* Mobile bottom tab bar. Shown only < md (the side NavRail is hidden there).
* Mirrors the desktop nav items but as a thumb-friendly dock with labels and a
* top active indicator. Safe-area aware for notched devices.
*/
export function MobileNav() {
const path = usePathname() ?? "/";
return (
<nav className="glass fixed inset-x-0 bottom-0 z-40 flex items-stretch justify-around rounded-t-[20px] px-2 pb-[calc(0.4rem+env(safe-area-inset-bottom))] pt-2 md:hidden">
{mobileNavItems.map((item) => {
const active = isActivePath(path, item.matchPrefix);
return (
<Link
key={item.href}
href={item.href}
aria-label={item.label}
aria-current={active ? "page" : undefined}
className={cn(
"relative flex flex-1 flex-col items-center justify-center rounded-[12px] rounded-t-[20px] px-2 pb-[calc(0.4rem+env(safe-area-inset-bottom))] pt-2 md:hidden",
active
? "bg-signal/15 text-signal"
: "text-ink-faint hover:bg-white/5 hover:text-ink-soft",
)}
>
{active && (
<span className="absolute -top-2 h-8 w-8 rounded-full bg-signal shadow-[0_0_12px_var(--color-signal-glow)]" />
)}
<item.icon className="size-[20px]" strokeWidth={active ? 2.4 : 2} />
{item.label}
</Link>
);
})}
</nav>
);
}
@@ -0,0 +1,61 @@
"use client";
import { usePathname } from "next/navigation";
import { isActivePath, navItems } from "@/lib/navigation";
import { cn } from "@/lib/utils";
function NavItem({
href,
label,
active,
Icon,
}: {
href: string;
label: string;
active: boolean;
Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) {
return (
<a
href={href}
aria-label={label}
aria-current={active ? "page" : undefined}
className={cn(
"group relative flex size-11 items-center justify-center rounded-[13px] transition-all",
active
? "bg-signal/15 text-signal"
: "text-ink-faint hover:bg-white/5 hover:text-ink-soft",
)}
>
{active && (
<span className="absolute -left-3 h-6 w-1 rounded-full bg-signal shadow-[0_0_12px_var(--color-signal-glow)]" />
)}
<Icon className="size-[18px]" strokeWidth={active ? 2.4 : 2} />
{/* hover tooltip — labels are hidden in the rail, so surface on hover */}
<span className="pointer-events-none absolute left-full z-50 ml-3 hidden whitespace-nowrap rounded-[9px] border border-hairline bg-canvas-2 px-2.5 py-1.5 text-xs font-medium text-ink-soft opacity-0 shadow-lg transition-opacity group-hover:opacity-100 md:block">
{label}
</span>
</a>
);
}
export function NavRail() {
const pathname = usePathname();
const path = pathname ?? "/";
return (
<nav className="glass mb-[calc(0.75rem+env(safe-area-inset-bottom))] ml-[calc(0.75rem+env(safe-area-inset-left))] mt-[calc(0.75rem+env(safe-area-inset-top))] hidden w-[68px] flex-col items-center gap-1 rounded-[18px] py-4 md:flex">
<div className="flex flex-1 flex-col gap-1">
{navItems.map((item) => (
<NavItem
key={item.href}
href={item.href}
label={item.label}
active={isActivePath(path, item.matchPrefix)}
Icon={item.icon}
/>
))}
</div>
</nav>
);
}
@@ -1,59 +0,0 @@
/**
* Scene config: maps pathname → constellation scene definition.
* Pure data; the stage reads it to build graphs and place overlays.
*/
import type { ConstellationGraph } from "@/lib/constellation/graph";
import {
channelsToGraph,
culturesToGraph,
statsToGraph,
} from "@/lib/constellation/graph";
export interface SceneDef {
route: string;
label: string;
/** Build the graph from typed SSR seed data. */
build: (seed: SceneSeed) => ConstellationGraph;
}
/** Everything a scene may need — all optional, scenes pick what they use. */
export interface SceneSeed {
stats?: import("@/lib/types").DashboardStats;
channels?: import("@/lib/types").DashboardChannel[];
cultures?: import("@/lib/types").ChannelCultureRow[];
guildLabel?: string;
}
export const SCENES: SceneDef[] = [
{
route: "/dashboard/",
label: "Deck",
build: (s) => (s.stats ? statsToGraph(s.stats) : { nodes: [], edges: [] }),
},
{
route: "/channels/",
label: "Channels",
build: (s) => {
if (s.cultures && s.cultures.length > 0)
return culturesToGraph(s.cultures);
if (s.channels) return channelsToGraph(s.channels);
return { nodes: [], edges: [] };
},
},
];
export function resolveScene(pathname: string): SceneDef | undefined {
return SCENES.find((sc) => sc.route === pathname);
}
export function buildDefaultGraph(
scene: SceneDef,
seed: SceneSeed,
): ConstellationGraph {
try {
return scene.build(seed);
} catch {
return { nodes: [], edges: [] };
}
}
@@ -1,55 +0,0 @@
"use client";
/**
* SceneA11yMirror — screen-reader/keyboard mirror of the live constellation.
* The canvas itself is decorative (aria-hidden); this list exposes every
* node as a real link/button so keyboard and AT users get the same graph.
*/
import { usePathname } from "next/navigation";
import { useSceneGraph } from "@/components/shell/scene-graph-context";
export function SceneA11yMirror() {
const pathname = usePathname() ?? "/";
const { state, setFocus } = useSceneGraph();
const nodes = state?.graph.nodes ?? [];
return (
<nav
aria-label={`${pathname} constellation map`}
className="sr-only focus-within:not-sr-only focus-within:absolute focus-within:left-5 focus-within:top-16 focus-within:z-40 focus-within:max-w-sm focus-within:rounded-2xl focus-within:border focus-within:border-[var(--color-hairline)] focus-within:bg-[var(--color-canvas-2)]/95 focus-within:p-3 focus-within:backdrop-blur-xl"
>
<p className="mb-1 font-mono text-xs text-[var(--color-ink-faint)]">
constellation {nodes.length} node
</p>
<ul className="space-y-1">
{nodes.map((n) => (
<li key={n.id}>
{n.href ? (
<a
href={n.href}
className="rounded-lg px-2 py-1 font-mono text-sm text-[var(--color-ink)] outline-none hover:bg-white/5 focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]"
>
{n.label} ({n.kind})
</a>
) : (
<button
type="button"
onClick={() =>
setFocus((prev) => (prev === n.id ? null : n.id))
}
className="w-full rounded-lg px-2 py-1 text-left font-mono text-sm text-[var(--color-ink)] outline-none hover:bg-white/5 focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]"
>
{n.label} ({n.kind})
</button>
)}
</li>
))}
{nodes.length === 0 ? (
<li className="px-2 py-1 font-mono text-sm text-[var(--color-ink-faint)]">
tidak ada node aktif
</li>
) : null}
</ul>
</nav>
);
}
@@ -1,104 +0,0 @@
"use client";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from "react";
/**
* SceneGraph bridge — views publish their live graph to the stage.
* The frame provides the setter; the stage consumes the graph.
* Keeps SSR seed pattern intact: page.tsx seeds view.tsx, view publishes.
*/
import type { ConstellationGraph } from "@/lib/constellation/graph";
export interface SceneGraphState {
graph: ConstellationGraph;
/** Node id currently focused (drives fly-to / highlight). */
focus: string | null;
}
type FocusUpdate = string | null | ((prev: string | null) => string | null);
interface SceneGraphContextValue {
state: SceneGraphState | null;
publish: (state: SceneGraphState) => void;
setFocus: (update: FocusUpdate) => void;
}
const SceneGraphContext = createContext<SceneGraphContextValue>({
state: null,
publish: () => {},
setFocus: () => {},
});
export function SceneGraphProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<SceneGraphState | null>(null);
const stateRef = useRef<SceneGraphState | null>(null);
const publish = useCallback((next: SceneGraphState) => {
const prev = stateRef.current;
const sameShape =
prev &&
prev.graph.nodes.length === next.graph.nodes.length &&
prev.graph.edges.length === next.graph.edges.length &&
prev.focus === next.focus;
if (!sameShape) {
stateRef.current = next;
setState(next);
return;
}
// Same shape — still update node values/labels in place.
let changed = false;
if (prev) {
for (let i = 0; i < next.graph.nodes.length; i++) {
const a = prev.graph.nodes[i];
const b = next.graph.nodes[i];
if (a?.id !== b?.id || a?.label !== b?.label || a?.value !== b?.value) {
changed = true;
break;
}
}
}
if (changed || !prev) {
stateRef.current = next;
setState(next);
}
}, []);
const setFocus = useCallback((update: FocusUpdate) => {
setState((prev) => {
if (!prev) return null;
const next = typeof update === "function" ? update(prev.focus) : update;
return next === prev.focus ? prev : { ...prev, focus: next };
});
}, []);
const value = useMemo(
() => ({ state, publish, setFocus }),
[state, publish, setFocus],
);
return (
<SceneGraphContext.Provider value={value}>
{children}
</SceneGraphContext.Provider>
);
}
export function useScenePublish(): (state: SceneGraphState) => void {
return useContext(SceneGraphContext).publish;
}
export function useSceneFocusSetter(): (update: FocusUpdate) => void {
return useContext(SceneGraphContext).setFocus;
}
export type { FocusUpdate };
export function useSceneGraph(): SceneGraphContextValue {
return useContext(SceneGraphContext);
}
@@ -0,0 +1,76 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { usePathname } from "next/navigation";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import { navItems } from "@/lib/navigation";
import { cn } from "@/lib/utils";
import { ConnectionStatus } from "./status-dot";
function useActiveLabel() {
const pathname = usePathname();
const item = [...navItems]
.sort((a, b) => b.matchPrefix.length - a.matchPrefix.length)
.find((i) => pathname.startsWith(i.matchPrefix));
return item?.label ?? "Console";
}
export function TopBar() {
const label = useActiveLabel();
const { state } = useAmbient();
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const signalTone =
state.tone === "vermilion"
? "text-vermilion"
: state.tone === "amber"
? "text-amber"
: "text-signal";
return (
<header className="sticky top-0 z-40 flex items-center gap-3 px-4 py-3 pt-[calc(0.75rem+env(safe-area-inset-top))] sm:gap-4 sm:px-5 sm:py-3.5">
<div className="flex min-w-0 items-baseline gap-2 sm:gap-3">
<span className="eyebrow hidden sm:inline">GMW</span>
<h1 className="display truncate text-[1.25rem] text-ink sm:text-[1.5rem]">
{label}
</h1>
</div>
<div className="ml-auto flex items-center gap-2 sm:gap-3">
<span className={cn("pill hidden sm:flex", signalTone)}>
<span
className={cn("size-1.5 rounded-full bg-current animate-breathe")}
/>
{state.label ?? "nominal"}
</span>
<ConnectionStatus compact />
<button
type="button"
aria-label="Open command palette"
onClick={() =>
window.dispatchEvent(new Event("command-palette:open"))
}
className="hidden items-center gap-1.5 rounded-[11px] border border-hairline bg-white/5 px-2.5 py-1.5 text-xs text-ink-soft transition-colors hover:text-ink hover:border-signal/40 sm:flex"
>
<span className="mono text-[0.65rem]">K</span>
</button>
<button
type="button"
aria-label="Toggle theme"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
className="flex size-9 items-center justify-center rounded-[11px] border border-hairline bg-white/5 text-ink-soft transition-colors hover:text-ink hover:border-signal/40"
>
{mounted && theme === "light" ? (
<Moon className="size-4" />
) : (
<Sun className="size-4" />
)}
</button>
</div>
</header>
);
}