From 60ae1fb5c37b867a178938c638b029d548a296a9 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Mon, 24 Aug 2026 15:22:27 +0700 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20dashboard=20&=20channels=20co?= =?UTF-8?q?nstellation=20scenes=20=E2=80=94=20publish=20bridge=20+=20float?= =?UTF-8?q?ing=20overlays?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/(dashboard)/channels/view.tsx | 100 ++++- .../src/app/(dashboard)/dashboard/view.tsx | 401 +++++------------- .../components/shell/constellation-frame.tsx | 86 ++-- .../components/shell/constellation-stage.tsx | 15 +- .../src/components/shell/route-scenes.ts | 26 +- .../components/shell/scene-graph-context.tsx | 104 +++++ .../frontend/src/lib/constellation/graph.ts | 21 +- 7 files changed, 423 insertions(+), 330 deletions(-) create mode 100644 services/frontend/src/components/shell/scene-graph-context.tsx diff --git a/services/frontend/src/app/(dashboard)/channels/view.tsx b/services/frontend/src/app/(dashboard)/channels/view.tsx index 9e33728e..11f0370e 100644 --- a/services/frontend/src/app/(dashboard)/channels/view.tsx +++ b/services/frontend/src/app/(dashboard)/channels/view.tsx @@ -1,8 +1,18 @@ "use client"; -import { ChannelCultureGlossary } from "@/components/ChannelCultureGlossary"; +/** + * Channels scene — every channel is a star on the stage; clicking a star + * opens its floating culture dossier here. Search filters the sky. + */ +import { useEffect, useMemo, useState } from "react"; import { SkeletonPanel } from "@/components/shared"; +import { + useSceneFocusSetter, + useSceneGraph, + useScenePublish, +} from "@/components/shell/scene-graph-context"; import { useChannelCultures } from "@/hooks"; +import { culturesToGraph } from "@/lib/constellation/graph"; import type { ChannelCultureRow } from "@/lib/types"; export function ChannelsView({ @@ -11,12 +21,92 @@ export function ChannelsView({ initialCultures?: ChannelCultureRow[]; }) { const { data: cultures } = useChannelCultures(100, initialCultures); + const publish = useScenePublish(); + const setFocus = useSceneFocusSetter(); + const { state } = useSceneGraph(); + const [query, setQuery] = useState(""); + + const rows = cultures ?? []; + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return rows; + return rows.filter( + (r) => + (r.channel_name ?? r.channel_id).toLowerCase().includes(q) || + (r.culture_summary ?? "").toLowerCase().includes(q), + ); + }, [rows, query]); + + const graph = useMemo(() => culturesToGraph(filtered), [filtered]); + + useEffect(() => { + publish({ graph, focus: null }); + }, [graph, publish]); + + useEffect(() => () => setFocus(null), [setFocus]); + + const selectedId = state?.focus ?? null; + const selectedRow = useMemo( + () => + selectedId + ? (rows.find((r) => `channel:${r.channel_id}` === selectedId) ?? null) + : null, + [rows, selectedId], + ); + return ( -
- {cultures ? ( - +
+ {/* Search whisper — top-left */} +
+

Channels · {filtered.length} mapped

+ setQuery(e.target.value)} + placeholder="filter the sky…" + className="w-full rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/60 px-3.5 py-1.5 font-mono text-xs text-[var(--color-ink)] backdrop-blur-md outline-none placeholder:text-[var(--color-ink-faint)] focus:border-[var(--color-signal)]" + /> + {!cultures ? ( +
+ +
+ ) : null} +
+ + {/* Culture dossier for the selected star */} + {selectedRow ? ( + ) : ( - +

+ klik sebuah bintang untuk membuka culture dossier +

)}
); diff --git a/services/frontend/src/app/(dashboard)/dashboard/view.tsx b/services/frontend/src/app/(dashboard)/dashboard/view.tsx index 5e870ee7..b9288964 100644 --- a/services/frontend/src/app/(dashboard)/dashboard/view.tsx +++ b/services/frontend/src/app/(dashboard)/dashboard/view.tsx @@ -1,32 +1,18 @@ "use client"; -import { - Activity, - Flag, - MessageSquare, - Mic, - Radio, - ShieldAlert, - Users, -} from "lucide-react"; -import { useEffect } from "react"; +/** + * Dashboard scene — guild star + channel orbit live on the stage canvas; + * this overlay adds the metric whisper (top-left), activity ribbon + * (bottom-left), and moderation gauge (right) as floating panels. + */ +import { Activity, Flag, ShieldAlert } from "lucide-react"; +import { useEffect, useMemo } from "react"; import { useAmbient } from "@/components/ambient/ambient-context"; import { AreaActivity, RadialGauge } from "@/components/charts"; -import { GlassPanel } from "@/components/primitives"; -import { - ErrorState, - LoadingState, - SkeletonHero, - SkeletonMetricRow, - SkeletonPanel, -} from "@/components/shared"; -import { MetricTile, SectionHeader } from "@/components/shared/section"; -import { - useActivity, - useStats, - useTopReactions, - useTopReactors, -} from "@/hooks"; +import { ErrorState, LoadingState } from "@/components/shared"; +import { useScenePublish } from "@/components/shell/scene-graph-context"; +import { useActivity, useStats } from "@/hooks"; +import { statsToGraph } from "@/lib/constellation/graph"; import { formatNumber } from "@/lib/format"; import type { DashboardStats } from "@/lib/types"; import { staggerDelay } from "@/lib/utils"; @@ -50,11 +36,19 @@ export function DashboardView({ initialStats?: DashboardStats; initialActivity?: Awaited>["data"]; }) { - const { data: stats, isLoading, error } = useStats(initialStats); + const { data: stats, error } = useStats(initialStats); const { data: activity } = useActivity(14, initialActivity as never); - const { data: reactors } = useTopReactors(); - const { data: reactions } = useTopReactions(); const ambient = useAmbient(); + const publish = useScenePublish(); + + const graph = useMemo( + () => (stats ? statsToGraph(stats) : { nodes: [], edges: [] }), + [stats], + ); + + useEffect(() => { + publish({ graph, focus: "guild" }); + }, [graph, publish]); useEffect(() => { const s = deriveSignal(stats); @@ -66,303 +60,134 @@ export function DashboardView({ }, [stats, ambient]); if (error && !stats) return ; - if (!stats && isLoading) - return ( -
- - - -
- - -
-
- ); - if (!stats) return ; + if (!stats) return ; const s = stats; const total = s.total_flagged + s.total_clean || 1; const cleanRatio = s.total_clean / total; return ( -
- {/* Hero */} - -
-
-
-
GMW · Operations Grid
-

- Ambient Field -

-

- Real-time moderation, voice & media presence across the monitored - guild. {formatNumber(s.total_messages)} messages captured. -

-
-
- - - {deriveSignal(s).label} - -
-
- -
- + {/* Metric whisper — top-left under brand */} +
+

Operations · {deriveSignal(s).label}

+
+ } - className="animate-stagger" - style={staggerDelay(0)} + tone="ink" /> - 0 ? "vermilion" : "neutral"} + tone={s.total_flagged > 0 ? "vermilion" : "ink"} hint={`${s.today_flagged} today`} - className="animate-stagger" - style={staggerDelay(1)} /> - } - className="animate-stagger" - style={staggerDelay(2)} /> - } - className="animate-stagger" - style={staggerDelay(3)} + tone="ink" />
- +
- {/* Activity */} - - - Activity & moderation - - } - action={ -
- - messages - - - flagged - -
- } - /> + {/* Activity ribbon — bottom-left */} +
+

+ 14-day signal +

{activity ? ( ) : ( )} - +
- {/* Two-column: channels + moderation */} -
- - -
- {s.top_channels.slice(0, 7).map((c) => { - const pct = - (c.message_count / (s.top_channels[0]?.message_count || 1)) * - 100; - return ( -
- - {c.channel_name ?? c.channel_id.slice(0, 8)} - -
-
-
- - {formatNumber(c.message_count)} - -
- ); - })} -
- - - - -
- 0.8 - ? "signal" - : cleanRatio > 0.6 - ? "amber" - : "vermilion" - } - label={`${Math.round(cleanRatio * 100)}%`} - sublabel="clean" - /> -
- } - label="Clean" - value={formatNumber(s.total_clean)} - /> - } - label="Flagged" - value={formatNumber(s.total_flagged)} - /> - } - label="Warned" - value={formatNumber(s.total_warned)} - /> -
-
-
- - - -
-
-
- - {/* Reactors + reactions */} -
- - -
- {(reactors ?? []).slice(0, 6).map((r, i) => { - const maxNet = reactors?.[0]?.net_count || 1; - const pct = Math.max(4, Math.round((r.net_count / maxNet) * 100)); - return ( -
- {i + 1} - - {r.username} - -
-
-
- - +{formatNumber(r.net_count)} - -
- ); - })} - {(reactors ?? []).length === 0 && } -
- - - - -
- {(reactions ?? []).slice(0, 5).map((m) => ( -
-
- {m.top_emojis.slice(0, 3).map((e, i) => ( - - {e.emoji} - - ))} -
-
-
- {m.content || "(no text)"} -
-
- {m.username} · {m.channel_name ?? m.channel_id.slice(0, 8)} -
-
- - {m.reaction_count} - -
- ))} - {(reactions ?? []).length === 0 && } -
-
-
+ {/* Moderation gauge — right */} +
+

+ Trust +

+ 0.8 + ? "signal" + : cleanRatio > 0.6 + ? "amber" + : "vermilion" + } + label={`${Math.round(cleanRatio * 100)}%`} + sublabel="clean" + /> +
+

+ + flagged + + {formatNumber(s.total_flagged)} +

+

+ warned + {formatNumber(s.total_warned)} +

+

+ pending + {s.moderation_overview.pending} +

+

+ processing + {s.moderation_overview.processing} +

+
+
); } -function Row({ - icon, - label, - value, -}: { - icon: React.ReactNode; - label: string; - value: string; -}) { - return ( -
- {icon} - {label} - {value} -
- ); -} - -function Mini({ +function WhisperRow({ label, value, tone, + hint, }: { label: string; - value: number; - tone: "signal" | "amber" | "vermilion"; + value: string; + tone: "ink" | "signal" | "vermilion"; + hint?: string; }) { const color = tone === "vermilion" ? "text-vermilion" - : tone === "amber" - ? "text-amber" - : "text-signal"; + : tone === "signal" + ? "text-signal" + : "text-ink"; return ( -
-
{value}
-
{label}
-
- ); -} - -function EmptyHint() { - return ( -
- Awaiting data… -
+

+ + {label} + + {value} + {hint ? ( + + {hint} + + ) : null} +

); } diff --git a/services/frontend/src/components/shell/constellation-frame.tsx b/services/frontend/src/components/shell/constellation-frame.tsx index bc5f4205..28ef1e73 100644 --- a/services/frontend/src/components/shell/constellation-frame.tsx +++ b/services/frontend/src/components/shell/constellation-frame.tsx @@ -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 ( + { + // 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 ( -
- { - if (id.startsWith("channel:")) window.location.assign("/channels/"); - }} - /> - - - {/* Overlay content region — scenes place floating panels inside. */} -
-
{children}
-
-
+ +
+ + + + {/* Overlay content region — scenes place floating panels inside. */} +
+
{children}
+
+
+
); } + +interface ConstellationFrameProps_ { + children: ReactNode; + /** Typed SSR seed for the route-scenes fallback builder. */ + sceneSeed?: SceneSeed; +} diff --git a/services/frontend/src/components/shell/constellation-stage.tsx b/services/frontend/src/components/shell/constellation-stage.tsx index 4d73e1eb..7b008207 100644 --- a/services/frontend/src/components/shell/constellation-stage.tsx +++ b/services/frontend/src/components/shell/constellation-stage.tsx @@ -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(null); @@ -34,7 +37,7 @@ export function ConstellationStage({ const layoutRef = useRef([]); const nodeByIdRef = useRef(new Map(graph.nodes.map((n) => [n.id, n]))); const [size, setSize] = useState({ w: 0, h: 0 }); - const [hovered, setHovered] = useState(null); + const hoveredRef = useRef(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( diff --git a/services/frontend/src/components/shell/route-scenes.ts b/services/frontend/src/components/shell/route-scenes.ts index 3b5066b5..d64bea3a 100644 --- a/services/frontend/src/components/shell/route-scenes.ts +++ b/services/frontend/src/components/shell/route-scenes.ts @@ -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: [] }; + } +} diff --git a/services/frontend/src/components/shell/scene-graph-context.tsx b/services/frontend/src/components/shell/scene-graph-context.tsx new file mode 100644 index 00000000..b5bd3a57 --- /dev/null +++ b/services/frontend/src/components/shell/scene-graph-context.tsx @@ -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({ + state: null, + publish: () => {}, + setFocus: () => {}, +}); + +export function SceneGraphProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState(null); + const stateRef = useRef(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 ( + + {children} + + ); +} + +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); +} diff --git a/services/frontend/src/lib/constellation/graph.ts b/services/frontend/src/lib/constellation/graph.ts index 394e3fff..17517bf1 100644 --- a/services/frontend/src/lib/constellation/graph.ts +++ b/services/frontend/src/lib/constellation/graph.ts @@ -2,7 +2,11 @@ * Constellation graph model — pure data, no React/DOM. * Builders convert existing API payloads into star-graph structures. */ -import type { DashboardChannel, DashboardStats } from "@/lib/types"; +import type { + ChannelCultureRow, + DashboardChannel, + DashboardStats, +} from "@/lib/types"; export type NodeKind = | "guild" @@ -94,3 +98,18 @@ export function channelsToGraph( })); return { nodes, edges: [] }; } + +/** Channels scene variant fed by culture-knowledge rows. */ +export function culturesToGraph(rows: ChannelCultureRow[]): ConstellationGraph { + const nodes: GraphNode[] = rows.map((r, i) => ({ + id: `channel:${r.channel_id}`, + label: r.channel_name || r.channel_id, + kind: "channel", + value: r.culture_summary ? 0.4 + (i % 5) * 0.12 : 0.2, + meta: { + culture_summary: r.culture_summary, + last_analyzed_at: r.last_analyzed_at, + }, + })); + return { nodes, edges: [] }; +}