revert(frontend): kembalikan shell usable — hapus total eksperimen constellation (three/d3-force dihapus)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user