feat(frontend): dashboard & channels constellation scenes — publish bridge + floating overlays

This commit is contained in:
asepharyana
2026-08-24 15:22:27 +07:00
parent 1fafebb16d
commit 60ae1fb5c3
7 changed files with 423 additions and 330 deletions
@@ -3,7 +3,9 @@
/**
* ConstellationFrame — replaces the classic AppFrame chrome.
* The stage canvas sits fixed behind everything; page content is an
* overlay layer (absolute, no top bar / nav rail / scroll shell).
* 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";
@@ -11,39 +13,67 @@ 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 { resolveScene, type SceneSeed } from "@/components/shell/route-scenes";
import {
buildDefaultGraph,
resolveScene,
type SceneSeed,
} from "@/components/shell/route-scenes";
import {
SceneGraphProvider,
useSceneGraph,
} from "@/components/shell/scene-graph-context";
export interface ConstellationFrameProps {
children: ReactNode;
/** Typed SSR seed consumed by the active scene's graph builder. */
sceneSeed?: SceneSeed;
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) {
const pathname = usePathname() ?? "/";
const scene = useMemo(() => resolveScene(pathname), [pathname]);
const graph = useMemo(
() => (scene ? scene.build(sceneSeed ?? {}) : { nodes: [], edges: [] }),
[scene, sceneSeed],
);
}: ConstellationFrameProps_) {
return (
<div className="relative h-dvh w-full overflow-hidden">
<ConstellationStage
graph={graph}
onNodeClick={(id) => {
if (id.startsWith("channel:")) window.location.assign("/channels/");
}}
/>
<FloatingChrome />
<MiniPlayer />
{/* Overlay content region — scenes place floating panels inside. */}
<main className="pointer-events-none absolute inset-0 z-10 overflow-y-auto overscroll-contain">
<div className="pointer-events-auto min-h-full">{children}</div>
</main>
</div>
<SceneGraphProvider>
<div className="relative h-dvh w-full overflow-hidden">
<StageFromContext seed={sceneSeed} />
<FloatingChrome />
<MiniPlayer />
{/* Overlay content region — scenes place floating panels inside. */}
<main className="pointer-events-none absolute inset-0 z-10 overflow-y-auto overscroll-contain">
<div className="pointer-events-auto min-h-full">{children}</div>
</main>
</div>
</SceneGraphProvider>
);
}
interface ConstellationFrameProps_ {
children: ReactNode;
/** Typed SSR seed for the route-scenes fallback builder. */
sceneSeed?: SceneSeed;
}
@@ -17,6 +17,8 @@ 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;
}
@@ -26,6 +28,7 @@ const Z_MAX = 3;
export function ConstellationStage({
graph,
seed = 42,
selectedId = null,
onNodeClick,
}: ConstellationStageProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
@@ -34,7 +37,7 @@ export function ConstellationStage({
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 [hovered, setHovered] = useState<string | null>(null);
const hoveredRef = useRef<string | null>(null);
const [reduced, setReduced] = useState(false);
nodeByIdRef.current = new Map(graph.nodes.map((n) => [n.id, n]));
@@ -222,7 +225,7 @@ export function ConstellationStage({
return;
}
const hit = pickNode(ev.clientX, ev.clientY);
setHovered(hit ? hit.id : null);
hoveredRef.current = hit ? hit.id : null;
canvas.style.cursor = hit ? "pointer" : "grab";
};
const onPointerUp = (ev: PointerEvent) => {
@@ -318,7 +321,7 @@ export function ConstellationStage({
v.halo.material.opacity =
(v.id === "guild" ? 0.14 : 0.08) * (0.7 + 0.6 * pulse);
}
const hov = v.id === hovered;
const hov = v.id === hoveredRef.current || v.id === selectedId;
v.core.scale.setScalar(hov ? 1.25 : 1);
i += 1;
}
@@ -334,7 +337,9 @@ export function ConstellationStage({
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 === hovered ? "var(--color-signal)" : "var(--color-ink-soft)";
id === hoveredRef.current || id === selectedId
? "var(--color-signal)"
: "var(--color-ink-soft)";
}
}
};
@@ -354,7 +359,7 @@ export function ConstellationStage({
scene.clear();
renderer.dispose();
};
}, [graph, size.w, size.h, reduced, hovered, onNodeClick]);
}, [graph, size.w, size.h, reduced, selectedId, onNodeClick]);
const emptyGraph = graph.nodes.length === 0;
const hint = useMemo(
@@ -4,7 +4,11 @@
*/
import type { ConstellationGraph } from "@/lib/constellation/graph";
import { channelsToGraph, statsToGraph } from "@/lib/constellation/graph";
import {
channelsToGraph,
culturesToGraph,
statsToGraph,
} from "@/lib/constellation/graph";
export interface SceneDef {
route: string;
@@ -17,6 +21,7 @@ export interface SceneDef {
export interface SceneSeed {
stats?: import("@/lib/types").DashboardStats;
channels?: import("@/lib/types").DashboardChannel[];
cultures?: import("@/lib/types").ChannelCultureRow[];
guildLabel?: string;
}
@@ -29,11 +34,26 @@ export const SCENES: SceneDef[] = [
{
route: "/channels/",
label: "Channels",
build: (s) =>
s.channels ? channelsToGraph(s.channels) : { nodes: [], edges: [] },
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: [] };
}
}
@@ -0,0 +1,104 @@
"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);
}