diff --git a/services/frontend/package.json b/services/frontend/package.json index 3d21ce6f..cb2d5957 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -12,7 +12,6 @@ "dependencies": { "@orpc/client": "1.15.0", "clsx": "^2.1.1", - "d3-force": "^3.0.0", "lucide-react": "^1.27.0", "motion": "^12.0.0", "next": "16.2.12", @@ -27,8 +26,6 @@ "devDependencies": { "@biomejs/biome": "2.2.0", "@tailwindcss/postcss": "^4", - "@types/bun": "^1.4.0", - "@types/d3-force": "^3.0.10", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/services/frontend/pnpm-lock.yaml b/services/frontend/pnpm-lock.yaml index 0f8bf7fa..f13c6ae4 100644 --- a/services/frontend/pnpm-lock.yaml +++ b/services/frontend/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 - d3-force: - specifier: ^3.0.0 - version: 3.0.0 lucide-react: specifier: ^1.27.0 version: 1.28.0(react@19.2.4) @@ -54,12 +51,6 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.3.3 - '@types/bun': - specifier: ^1.4.0 - version: 1.4.0 - '@types/d3-force': - specifier: ^3.0.10 - version: 3.0.10 '@types/node': specifier: ^20 version: 20.19.43 @@ -576,12 +567,6 @@ packages: '@tweenjs/tween.js@23.1.3': resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} - '@types/bun@1.4.0': - resolution: {integrity: sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ==} - - '@types/d3-force@3.0.10': - resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} - '@types/node@20.19.43': resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} @@ -626,9 +611,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - bun-types@1.4.0: - resolution: {integrity: sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q==} - caniuse-lite@1.0.30001806: resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} @@ -655,22 +637,6 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - d3-dispatch@3.0.1: - resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} - engines: {node: '>=12'} - - d3-force@3.0.0: - resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} - engines: {node: '>=12'} - - d3-quadtree@3.0.1: - resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} - engines: {node: '>=12'} - - d3-timer@3.0.1: - resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} - engines: {node: '>=12'} - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1495,12 +1461,6 @@ snapshots: '@tweenjs/tween.js@23.1.3': {} - '@types/bun@1.4.0': - dependencies: - bun-types: 1.4.0 - - '@types/d3-force@3.0.10': {} - '@types/node@20.19.43': dependencies: undici-types: 6.21.0 @@ -1548,10 +1508,6 @@ snapshots: update-browserslist-db: 1.2.3(browserslist@4.28.7) optional: true - bun-types@1.4.0: - dependencies: - '@types/node': 20.19.43 - caniuse-lite@1.0.30001806: {} chromium-bidi@17.0.2(devtools-protocol@0.0.1666840): @@ -1575,18 +1531,6 @@ snapshots: csstype@3.2.3: {} - d3-dispatch@3.0.1: {} - - d3-force@3.0.0: - dependencies: - d3-dispatch: 3.0.1 - d3-quadtree: 3.0.1 - d3-timer: 3.0.1 - - d3-quadtree@3.0.1: {} - - d3-timer@3.0.1: {} - debug@4.4.3: dependencies: ms: 2.1.3 diff --git a/services/frontend/src/app/(dashboard)/analysis/view.tsx b/services/frontend/src/app/(dashboard)/analysis/view.tsx index ae1c3bb8..1b6dc1c2 100644 --- a/services/frontend/src/app/(dashboard)/analysis/view.tsx +++ b/services/frontend/src/app/(dashboard)/analysis/view.tsx @@ -1,72 +1,24 @@ "use client"; -/** - * Analysis scene — semantic search lives in a floating console (left); - * results stream into a translucent dossier (right). The stage shows an - * analysis hub wired to the guild's top channels. - */ -import { Hash, Search, Sparkles } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { Hash, Search, Sparkles, TrendingUp } from "lucide-react"; +import { useEffect, useState } from "react"; import { useAmbient } from "@/components/ambient/ambient-context"; -import { Avatar, Badge } from "@/components/primitives"; -import { EmptyState, SkeletonRows } from "@/components/shared"; -import { useScenePublish } from "@/components/shell/scene-graph-context"; -import { useChannels, useMessageSearch } from "@/hooks"; +import { Avatar, Badge, GlassPanel, Input } from "@/components/primitives"; +import { EmptyState, SectionHeader, SkeletonRows } from "@/components/shared"; +import { useChannels, useMessageSearch, useTopReactors } from "@/hooks"; import { aiTone } from "@/lib/ai-status"; -import type { ConstellationGraph } from "@/lib/constellation/graph"; import { formatDuration, getMessageChannelLabel, renderMessageContent, } from "@/lib/format"; -function analysisGraph( - channels: - | { - channel_id: string; - channel_name?: string | null; - total_messages: number; - }[] - | undefined, -): ConstellationGraph { - const rows = (channels ?? []).slice(0, 10); - const maxMsg = rows.reduce((m, c) => Math.max(m, c.total_messages), 0); - const clamp = (n: number) => (maxMsg <= 0 ? 0.3 : Math.max(0.15, n / maxMsg)); - return { - nodes: [ - { id: "hub", label: "analysis", kind: "guild", value: 1 }, - ...rows.map((c) => ({ - id: `channel:${c.channel_id}`, - label: c.channel_name ?? c.channel_id.slice(0, 8), - kind: "channel" as const, - href: "/channels/", - value: clamp(c.total_messages), - })), - ], - edges: rows.map((c) => ({ - source: "hub", - target: `channel:${c.channel_id}`, - })), - }; -} - export function AnalysisView() { const [query, setQuery] = useState(""); const search = useMessageSearch(query, query.trim().length >= 2); + const { data: reactors } = useTopReactors(); const { data: channels } = useChannels(); const ambient = useAmbient(); - const publish = useScenePublish(); - - const graph = useMemo(() => analysisGraph(channels), [channels]); - - useEffect(() => { - publish({ graph, focus: null }); - }, [graph, publish]); - - useEffect( - () => () => publish({ graph: { nodes: [], edges: [] }, focus: null }), - [publish], - ); useEffect(() => { ambient.set( @@ -77,50 +29,52 @@ export function AnalysisView() { }, [query, ambient]); return ( -
- {/* Search console — left */} -
-

- Search the archive -

-
- - + +
+
+ +
+
Semantic search
+

+ Search the archive +

+
+
+
+ + setQuery(e.target.value)} - placeholder="Find messages, patterns, flags…" autoFocus - className="h-11 w-full rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/70 pl-11 pr-4 text-sm text-[var(--color-ink)] backdrop-blur-md outline-none placeholder:text-[var(--color-ink-faint)] focus:border-[var(--color-signal)]" />
- {query.trim().length > 0 && query.trim().length < 2 ? ( -

- minimal 2 karakter… -

- ) : null} -
+ {query.trim().length > 0 && query.trim().length < 2 && ( +
+ Type at least 2 characters… +
+ )} + - {/* Results dossier — right */} -
-
- matches - - {(search.data ?? []).length} - -
-
- {query.trim().length >= 2 && search.isLoading ? ( - - ) : (search.data ?? []).length === 0 ? ( +
+ + + {(search.data ?? []).length} + + } + /> + {query.trim().length >= 2 && search.isLoading && ( + + )} + {(search.data ?? []).length === 0 ? ( } + icon={} title="No matches yet" description="Run a search to surface messages across the guild." /> @@ -129,27 +83,27 @@ export function AnalysisView() { {(search.data ?? []).map((m) => (
- +
- + {m.username} - + {getMessageChannelLabel(m)} - {m.ai_status ? ( + {m.ai_status && ( {m.ai_analysis_duration_ms && m.ai_analysis_duration_ms > 0 ? `${m.ai_status} · ${formatDuration(m.ai_analysis_duration_ms)}` : m.ai_status} - ) : null} + )}
-
+
{renderMessageContent(m.content, m.metadata) || "(embed)"}
@@ -157,14 +111,86 @@ export function AnalysisView() { ))}
)} -
-
+ - {/* Channels hint — bottom-left */} -

- {(channels ?? []).length} channels - terhubung ke hub analitik — klik untuk membuka konstelasi channel -

+
+ + + Top reactors + + } + /> +
+ {(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} + +
+
+
+ + +{r.net_count} + +
+ ); + })} + {(reactors ?? []).length === 0 && ( +
+ No data +
+ )} +
+ + + + + Top channels + + } + /> +
+ {(channels ?? []).slice(0, 6).map((c) => ( +
+ + {c.channel_name ?? c.channel_id.slice(0, 8)} + + + {c.total_messages} + +
+ ))} + {(channels ?? []).length === 0 && ( +
+ No data +
+ )} +
+
+
+
); } diff --git a/services/frontend/src/app/(dashboard)/channels/view.tsx b/services/frontend/src/app/(dashboard)/channels/view.tsx index 11f0370e..9e33728e 100644 --- a/services/frontend/src/app/(dashboard)/channels/view.tsx +++ b/services/frontend/src/app/(dashboard)/channels/view.tsx @@ -1,18 +1,8 @@ "use client"; -/** - * 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 { ChannelCultureGlossary } from "@/components/ChannelCultureGlossary"; 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({ @@ -21,92 +11,12 @@ 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 ( -
- {/* 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 ? ( - +
+ {cultures ? ( + ) : ( -

- 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 b9288964..5e870ee7 100644 --- a/services/frontend/src/app/(dashboard)/dashboard/view.tsx +++ b/services/frontend/src/app/(dashboard)/dashboard/view.tsx @@ -1,18 +1,32 @@ "use client"; -/** - * 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 { + Activity, + Flag, + MessageSquare, + Mic, + Radio, + ShieldAlert, + Users, +} from "lucide-react"; +import { useEffect } from "react"; import { useAmbient } from "@/components/ambient/ambient-context"; import { AreaActivity, RadialGauge } from "@/components/charts"; -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 { 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 { formatNumber } from "@/lib/format"; import type { DashboardStats } from "@/lib/types"; import { staggerDelay } from "@/lib/utils"; @@ -36,19 +50,11 @@ export function DashboardView({ initialStats?: DashboardStats; initialActivity?: Awaited>["data"]; }) { - const { data: stats, error } = useStats(initialStats); + const { data: stats, isLoading, 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); @@ -60,134 +66,303 @@ export function DashboardView({ }, [stats, ambient]); if (error && !stats) return ; - if (!stats) return ; + if (!stats && isLoading) + return ( +
+ + + +
+ + +
+
+ ); + if (!stats) return ; const s = stats; const total = s.total_flagged + s.total_clean || 1; const cleanRatio = s.total_clean / total; return ( -
- {/* Metric whisper — top-left under brand */} -
-

Operations · {deriveSignal(s).label}

-
- + {/* 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} + +
+
+ +
+ } + className="animate-stagger" + style={staggerDelay(0)} /> - 0 ? "vermilion" : "ink"} + tone={s.total_flagged > 0 ? "vermilion" : "neutral"} hint={`${s.today_flagged} today`} + className="animate-stagger" + style={staggerDelay(1)} /> - } + className="animate-stagger" + style={staggerDelay(2)} /> - } + className="animate-stagger" + style={staggerDelay(3)} />
-
+ - {/* Activity ribbon — bottom-left */} -
-

- 14-day signal -

+ {/* Activity */} + + + Activity & moderation + + } + action={ +
+ + messages + + + flagged + +
+ } + /> {activity ? ( ) : ( )} -
+ - {/* 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} -

-
-
+ {/* 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 && } +
+
+
); } -function WhisperRow({ +function Row({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: string; +}) { + return ( +
+ {icon} + {label} + {value} +
+ ); +} + +function Mini({ label, value, tone, - hint, }: { label: string; - value: string; - tone: "ink" | "signal" | "vermilion"; - hint?: string; + value: number; + tone: "signal" | "amber" | "vermilion"; }) { const color = tone === "vermilion" ? "text-vermilion" - : tone === "signal" - ? "text-signal" - : "text-ink"; + : tone === "amber" + ? "text-amber" + : "text-signal"; return ( -

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

+
+
{value}
+
{label}
+
+ ); +} + +function EmptyHint() { + return ( +
+ Awaiting data… +
); } diff --git a/services/frontend/src/app/(dashboard)/glossary/view.tsx b/services/frontend/src/app/(dashboard)/glossary/view.tsx index fe36d0c8..19098096 100644 --- a/services/frontend/src/app/(dashboard)/glossary/view.tsx +++ b/services/frontend/src/app/(dashboard)/glossary/view.tsx @@ -1,118 +1,15 @@ "use client"; -/** - * Glossary scene — term KB as a satellite ring on the stage; the search - * whisper filters nodes live, and a term's definition floats in a dossier. - */ -import { useEffect, useMemo, useState } from "react"; import { SkeletonPanel } from "@/components/shared"; -import { - useSceneFocusSetter, - useSceneGraph, - useScenePublish, -} from "@/components/shell/scene-graph-context"; +import { TermGlossary } from "@/components/TermGlossary"; import { useGlossary } from "@/hooks"; -import type { ConstellationGraph } from "@/lib/constellation/graph"; import type { GlossaryRow } from "@/lib/types"; -/** Terms → satellite ring (deterministic positions come from layout). */ -function termsToGraph(rows: GlossaryRow[]): ConstellationGraph { - return { - nodes: rows.slice(0, 60).map((t) => ({ - id: `term:${t.term}`, - label: t.term, - kind: "term" as const, - value: Math.min(1, 0.3 + t.definition.length / 400), - })), - edges: [], - }; -} - export function GlossaryView({ initialTerms, }: { initialTerms?: GlossaryRow[]; }) { const { data: terms } = useGlossary(100, initialTerms); - const publish = useScenePublish(); - const setFocus = useSceneFocusSetter(); - const { state } = useSceneGraph(); - const [query, setQuery] = useState(""); - - const rows = terms ?? []; - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - if (!q) return rows; - return rows.filter( - (t) => - t.term.toLowerCase().includes(q) || - t.definition.toLowerCase().includes(q), - ); - }, [rows, query]); - - const graph = useMemo(() => termsToGraph(filtered), [filtered]); - - useEffect(() => { - publish({ graph, focus: null }); - }, [graph, publish]); - - useEffect(() => () => setFocus(null), [setFocus]); - - const selectedId = state?.focus ?? null; - const selectedTerm = useMemo( - () => - selectedId - ? (rows.find((t) => `term:${t.term}` === selectedId) ?? null) - : null, - [rows, selectedId], - ); - - return ( -
-
-

Glossary · {filtered.length} terms

- setQuery(e.target.value)} - placeholder="filter terms…" - 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)]" - /> - {!terms ? ( -
- -
- ) : null} -
- - {selectedTerm ? ( - - ) : ( -

- klik sebuah satelit untuk membuka definisi -

- )} -
- ); + return terms ? : ; } diff --git a/services/frontend/src/app/(dashboard)/layout.tsx b/services/frontend/src/app/(dashboard)/layout.tsx index 4e504646..eaa0fbdf 100644 --- a/services/frontend/src/app/(dashboard)/layout.tsx +++ b/services/frontend/src/app/(dashboard)/layout.tsx @@ -1,7 +1,7 @@ import { AmbientProvider } from "@/components/ambient/ambient-context"; import { Chatbot } from "@/components/chatbot/chatbot"; import { CommandPalette } from "@/components/command/command-palette"; -import { ConstellationFrame } from "@/components/shell"; +import { AppFrame } from "@/components/shell"; import { WsProvider } from "@/lib/ws/context"; export default function DashboardLayout({ @@ -10,7 +10,7 @@ export default function DashboardLayout({ return ( - {children} + {children} diff --git a/services/frontend/src/app/(dashboard)/media/view.tsx b/services/frontend/src/app/(dashboard)/media/view.tsx index 1972387a..35598492 100644 --- a/services/frontend/src/app/(dashboard)/media/view.tsx +++ b/services/frontend/src/app/(dashboard)/media/view.tsx @@ -1,10 +1,5 @@ "use client"; -/** - * Media scene — the queue becomes a spiral of nodes on the stage; the - * player console (disc + transport + URL bar) floats bottom-left, and - * the up-next list docks right. - */ import { ListMusic, Play, @@ -14,14 +9,15 @@ import { Square, Volume2, } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { useAmbient } from "@/components/ambient/ambient-context"; -import { Button, Input, toast } from "@/components/primitives"; -import { ErrorState, SkeletonHero } from "@/components/shared"; +import { Button, GlassPanel, Input, toast } from "@/components/primitives"; import { - useSceneFocusSetter, - useScenePublish, -} from "@/components/shell/scene-graph-context"; + ErrorState, + SectionHeader, + SkeletonHero, + SkeletonPanel, +} from "@/components/shared"; import { useMediaLoop, useMediaQueue, @@ -30,37 +26,11 @@ import { useMediaStop, useMediaWsSync, } from "@/hooks"; -import type { ConstellationGraph } from "@/lib/constellation/graph"; import { formatDuration } from "@/lib/format"; import type { MediaState } from "@/lib/types"; import { staggerDelay } from "@/lib/utils"; import { useWebSocket } from "@/lib/ws/context"; -function queueGraph(media: MediaState | undefined): ConstellationGraph { - const items = (media?.queue ?? []).slice(0, 24); - return { - nodes: [ - { - id: "now-playing", - label: media?.current?.title ?? "media", - kind: "guild", - value: 1, - }, - ...items.map((it, i) => ({ - id: `track:${i}:${it.source}`, - label: it.title, - kind: "media" as const, - value: Math.max(0.2, 0.8 - i * 0.05), - href: undefined, - })), - ], - edges: items.map((it, i) => ({ - source: "now-playing", - target: `track:${i}:${it.source}`, - })), - }; -} - export function MediaView({ initialStatus }: { initialStatus?: MediaState }) { const ws = useWebSocket(); const { data: media, isLoading, error } = useMediaState(initialStatus); @@ -70,8 +40,6 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) { const loop = useMediaLoop(); useMediaWsSync(ws); const ambient = useAmbient(); - const publish = useScenePublish(); - const setFocus = useSceneFocusSetter(); const [url, setUrl] = useState(""); @@ -83,19 +51,6 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) { 0, ); - const graph = useMemo(() => queueGraph(media), [media]); - useEffect(() => { - publish({ graph, focus: null }); - }, [graph, publish]); - - useEffect( - () => () => { - publish({ graph: { nodes: [], edges: [] }, focus: null }); - setFocus(null); - }, - [publish, setFocus], - ); - const tone = playing ? "signal" : queueList.length ? "amber" : "signal"; useEffect(() => { ambient.set( @@ -125,105 +80,126 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) { }; if (error && !media) return ; - if (!media && isLoading) return ; + if (!media && isLoading) + return ( +
+ + +
+ ); return ( -
- {/* Now playing — top-center whisper */} -
-
+
+ +
+
- {current?.thumbnailUrl ? ( - // biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist - - ) : ( - - )} +
+ {current?.thumbnailUrl ? ( + // biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist + + ) : ( + + )} +
-
-

- {playing ? ( - - {[0, 1, 2].map((i) => ( - - ))} - - ) : null} - {playing ? "Now playing" : current ? "Paused" : "Nothing queued"} -

-

- {current?.title ?? "Nothing queued"} - {formatDuration(current?.durationMs) - ? ` · ${formatDuration(current?.durationMs)}` - : ""} -

-
-
-
- {/* Console — bottom-left */} -
-
- setUrl(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && onPlay()} - /> +
+
+ {playing ? ( + <> + + {[0, 1, 2].map((i) => ( + + ))} + + Now playing + + ) : current ? ( + "Paused" + ) : ( + "Nothing queued" + )} +
+

+ {current?.title ?? "Nothing queued"} +

+
+ {current?.source && ( + {current.source} + )} + {current?.mode && ( + {current.mode} + )} + {formatDuration(current?.durationMs) && ( + + {formatDuration(current?.durationMs)} + + )} +
+
+ + + + +
+
-
- - - - -
- -
+ +
+
+ setUrl(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && onPlay()} + /> +
+
+ +
- + {Math.round((media?.musicVolume ?? 0) * 100)}%
-
+ - {/* Up next — right dock */} -
-
- up next - - {queueList.length} track{queueList.length === 1 ? "" : "s"} - {queueTotal ? ` · ${formatDuration(queueTotal)}` : ""} - -
-
- {queueList.length === 0 ? ( -
- -

- Queue is empty -

-

- Paste a URL to start playback. -

+ + + {queueList.length} track{queueList.length === 1 ? "" : "s"} + {queueTotal && ` · ${formatDuration(queueTotal)}`} + + } + /> + {queueList.length === 0 ? ( +
+ +
Queue is empty
+
+ Paste a URL above to start playback.
- ) : ( -
- {queueList.map((item, i) => { - const isNext = i === 0 && playing; - return ( -
- - {i + 1} +
+ ) : ( +
+ {queueList.map((item, i) => { + const isNext = i === 0 && playing; + return ( +
+ {i + 1} + {item.thumbnailUrl ? ( + // biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist + + ) : ( + + - {item.thumbnailUrl ? ( - // biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist - - ) : ( - - - - )} -
-

- {item.title} -

-

- {item.source} -

+ )} +
+
+ {item.title}
- {isNext ? ( - - up next - - ) : null} - +
+ {item.source} +
+
+ {isNext && ( + + up next + + )} + + {item.mode ?? "music"} + + {formatDuration(item.durationMs) && ( + {formatDuration(item.durationMs)} -
- ); - })} -
- )} -
-
+ )} +
+ ); + })} +
+ )} +
); } diff --git a/services/frontend/src/app/(dashboard)/messages/view.tsx b/services/frontend/src/app/(dashboard)/messages/view.tsx index dacccbc9..093f1871 100644 --- a/services/frontend/src/app/(dashboard)/messages/view.tsx +++ b/services/frontend/src/app/(dashboard)/messages/view.tsx @@ -1,11 +1,5 @@ "use client"; -/** - * Messages scene — the live feed becomes an orbital belt of nodes on the - * stage; the functional console (picker/search/modes) floats top-left, - * the stream itself is a translucent dossier column, and a selected - * message opens its inspection dossier bottom-center. - */ import { AlertTriangle, Calendar, @@ -21,13 +15,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityHeatmap } from "@/components/ActivityHeatmap"; import { useAmbient } from "@/components/ambient/ambient-context"; import { EditHistory } from "@/components/EditHistory"; -import { Avatar, Badge, Skeleton } from "@/components/primitives"; -import { EmptyState, ErrorState, SkeletonRows } from "@/components/shared"; -import { GuildChannelPicker } from "@/components/shared/guild-picker"; import { - useSceneFocusSetter, - useScenePublish, -} from "@/components/shell/scene-graph-context"; + Avatar, + Badge, + GlassPanel, + Input, + Skeleton, +} from "@/components/primitives"; +import { + EmptyState, + ErrorState, + SectionHeader, + SkeletonRows, +} from "@/components/shared"; +import { GuildChannelPicker } from "@/components/shared/guild-picker"; import { useLoadMore, useMessageActivity, @@ -41,7 +42,6 @@ import { useSemanticSearch, } from "@/hooks"; import { aiTone } from "@/lib/ai-status"; -import type { ConstellationGraph } from "@/lib/constellation/graph"; import { formatBytes, formatDuration, @@ -59,24 +59,6 @@ import type { import { staggerDelay } from "@/lib/utils"; import { useWebSocket } from "@/lib/ws/context"; -/** Recent messages → orbital belt (chained edges give the belt shape). */ -function beltGraph(list: MessageRecord[]): ConstellationGraph { - const recent = list.slice(0, 36); - const nodes = recent.map((m, i) => ({ - id: `msg:${m.id}`, - label: m.username, - kind: - m.ai_status === "flagged" ? ("flagged" as const) : ("message" as const), - value: Math.max(0.15, 1 - i / Math.max(1, recent.length)), - href: undefined, - })); - const edges = nodes.slice(0, -1).map((n, i) => ({ - source: n.id, - target: nodes[i + 1]?.id ?? n.id, - })); - return { nodes, edges }; -} - export function MessagesView({ initialGuilds, initialGuildId, @@ -98,11 +80,15 @@ export function MessagesView({ const [channelId, setChannelId] = useState(null); const [selected, setSelected] = useState(null); const [query, setQuery] = useState(""); + // Search mode: "exact" (substring match over captured messages) or + // "semantic" (vector similarity over the persistent Qdrant archive). const [semanticMode, setSemanticMode] = useState(false); + // feed | timeline: "timeline" groups messages into date-grouped cards. const [viewMode, setViewMode] = useState<"feed" | "timeline">("feed"); + // Guard against loading the entire history on a long scroll: cap how many + // older pages we append. Each page is 50 messages (backend limit default). const MAX_OLDER_PAGES = 10; const [loadedPages, setLoadedPages] = useState(0); - const [showIntel, setShowIntel] = useState(false); const { data: messages, @@ -113,7 +99,11 @@ export function MessagesView({ channelId ?? undefined, initialMessages ?? undefined, ); + // Stream history one message per WS frame (replaces the 50-row batched fetch). + // Drives snapshots into the SWR list above as they arrive; falls back to the + // SSR `initialMessages` seed if WS is unavailable. useMessagesStream(ws, guildId ?? "", channelId ?? undefined); + // Cursor to the next (older) page + whether more history exists. const { data: pageInfo } = useMessagesHasMore( guildId ?? "", channelId ?? undefined, @@ -134,12 +124,10 @@ export function MessagesView({ const edits = useRecentEdits(50, undefined, initialEdits); const detail = useMessageDetail(selected); const ambient = useAmbient(); - const publish = useScenePublish(); - const setFocus = useSceneFocusSetter(); - - const scrollRef = useRef(null); - const nearBottomRef = useRef(true); + // Fetch the next (older) page via cursor and bump the loaded-page counter. + // Older messages prepend at the top, so preserve the viewport by offsetting + // scrollTop by the height added above (Discord keeps your place while loading). const loadOlder = useCallback(async () => { if (!guildId || !hasMore || loadedPages >= MAX_OLDER_PAGES) return; const el = scrollRef.current; @@ -164,32 +152,21 @@ export function MessagesView({ const searching = query.trim().length >= 2 && !semanticMode; const semanticSearching = query.trim().length >= 2 && semanticMode; const list = searching ? (search.data ?? []) : (messages ?? []); + // Discord-style order: oldest at the top, newest at the bottom. The backend + // returns DESC (newest first); reverse so the feed reads top→bottom like DC. const display = useMemo(() => [...list].reverse(), [list]); - const graph = useMemo( - () => (searching ? { nodes: [], edges: [] } : beltGraph(list)), - [list, searching], - ); - - useEffect(() => { - publish({ graph, focus: selected ? `msg:${selected}` : null }); - }, [graph, selected, publish]); - - useEffect( - () => () => { - publish({ graph: { nodes: [], edges: [] }, focus: null }); - setFocus(null); - }, - [publish, setFocus], - ); - + // Timeline mode: inject date-separator headers above the first message of + // each day. Messages are sorted oldest→newest (display is reversed), so a + // date change means a new group. Produces an array of either "date" or "msg" + // nodes so the render loop can switch easily. const timelineNodes = useMemo(() => { if (viewMode !== "timeline") return null; const out: Array< | { type: "date"; label: string; iso: string } | { type: "msg"; m: (typeof display)[number] } > = []; - let prevDate = ""; + let prev = ""; for (const m of display) { const d = new Date(m.created_at).toLocaleDateString(undefined, { weekday: "short", @@ -197,15 +174,24 @@ export function MessagesView({ day: "numeric", }); const iso = new Date(m.created_at).toISOString().slice(0, 10); - if (d !== prevDate) { + if (d !== prev) { out.push({ type: "date", label: d, iso }); - prevDate = d; + prev = d; } out.push({ type: "msg", m }); } return out; }, [display, viewMode]); + // Ref to the scroll container so we can manage scroll position like Discord: + // open at the bottom (newest), keep the viewport stable when prepending older + // messages at the top, and follow new live messages only when already near + // the bottom. + const scrollRef = useRef(null); + const nearBottomRef = useRef(true); + + // Scroll to the bottom on the first load / when switching guild-channel, so + // the newest messages are visible (Discord behaviour). const firstLoadRef = useRef(true); useEffect(() => { if (firstLoadRef.current && display.length > 0) { @@ -215,6 +201,8 @@ export function MessagesView({ } }, [display.length]); + // When a new live message lands (list grows, still searching off), follow it + // to the bottom only if the user was already near the bottom. const prevLen = useRef(list.length); useEffect(() => { if (searching) return; @@ -227,12 +215,8 @@ export function MessagesView({ }, [list.length, searching]); return ( -
- {/* Console whisper — top-left */} -
+
+ -
-
- - setQuery(e.target.value)} - placeholder="Search messages…" - className="h-9 w-full rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/70 pl-9 pr-3 font-mono text-xs text-[var(--color-ink)] backdrop-blur-md outline-none placeholder:text-[var(--color-ink-faint)] focus:border-[var(--color-signal)]" +
+ + setQuery(e.target.value)} + /> +
+ + + + +
+ {semanticSearching && ( + + + {semantic.data?.length ?? 0} matches + + } /> -
- - -
-
- - {/* Stream dossier — right column */} -
-
- - {searching ? `“${query}”` : "live stream"} - - - {list.length} shown - -
- -
- {semanticSearching ? ( - - ) : error && !messages ? ( + /> + {error && !messages ? ( ) : isLoading && !messages ? ( ) : list.length === 0 ? ( } + icon={} title="No messages" description="Pick a guild to begin, or run a search." /> ) : ( -
+
{!searching && ( -
+
{loadMore.isPending ? ( - + Loading older… @@ -326,19 +348,19 @@ export function MessagesView({ ) : loadedPages >= MAX_OLDER_PAGES ? ( - + capped at {MAX_OLDER_PAGES} older pages · use search for more ) : ( messages && messages.length > 0 && ( - + beginning of history ) @@ -347,9 +369,11 @@ export function MessagesView({ )}
{ const el = e.currentTarget; + // Track whether the user is near the bottom (to follow live + // messages) and auto-load older messages when scrolled to top. nearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 120; if (searching || !hasMore || loadMore.isPending) return; @@ -360,11 +384,11 @@ export function MessagesView({ }} > {viewMode === "timeline" && timelineNodes - ? timelineNodes.map((node) => + ? timelineNodes.map((node, _i) => node.type === "date" ? (
{node.label} @@ -378,7 +402,7 @@ export function MessagesView({ /> ), ) - : display.map((m) => ( + : display.map((m, _i) => (
)} -
-
+ - {/* Mobile stream — full-width sheet under the console */} -
- {error && !messages ? ( - - ) : isLoading && !messages ? ( - - ) : display.length === 0 ? ( - - ) : ( - display.map((m) => ( - + + {!selected ? ( + - )) - )} -
- - {/* Inspection dossier — bottom-center */} - {selected ? ( - - ) : null} - - {/* Intel strip — bottom-left */} -
- - {showIntel ? ( -
- {activity.data && activity.data.length > 0 ? ( - - ) : null} - {edits.data ? : null} -
- ) : null} +
-
- ); -} -function SemanticResults({ - semantic, -}: { - semantic: ReturnType; -}) { - if (semantic.isLoading) return ; - if (!semantic.data || semantic.data.length === 0) { - return ( - } - title="No semantic matches" - description="Try different wording — semantic search finds meaning, not exact text." - /> - ); - } - return ( -
- {semantic.data.map((r, i) => ( -
-
- - {(r.score * 100).toFixed(0)}% - - - {formatRelativeTime(r.created_at)} - -
-

- {r.content} -

-
- ))} + {activity.data && activity.data.length > 0 && ( + + )} + + {edits.data && }
); } @@ -556,12 +493,10 @@ function MessageDetail({ return (
- +
-
- {m.username} -
-
+
{m.username}
+
{getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)}
@@ -573,21 +508,21 @@ function MessageDetail({
-
+
{renderMessageContent(m.edited_content ?? m.content, m.metadata) || "(no text)"}
- {m.ai_analysis ? ( + {m.ai_analysis && (
AI analysis
-
+
{m.ai_analysis}
- ) : null} + )} - {flags.length > 0 || cats.length > 0 ? ( + {(flags.length > 0 || cats.length > 0) && (
{flags.map((f) => ( @@ -600,9 +535,9 @@ function MessageDetail({ ))}
- ) : null} + )} - {attachments.length > 0 ? ( + {attachments.length > 0 && (
Attachments ({attachments.length}) @@ -614,22 +549,23 @@ function MessageDetail({ href={a.discord_url ?? a.uploaded_url ?? "#"} target="_blank" rel="noreferrer" - className="flex items-center gap-2 rounded-xl border border-[var(--color-hairline)] px-3 py-2 font-mono text-xs text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]" + className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft hover:text-ink" > {a.filename} - + {formatBytes(a.size)} ))}
- ) : null} + )}
); } +/** Single message card used by both the live feed and the date-grouped timeline. */ function MessageRow({ m, selected, @@ -641,32 +577,31 @@ function MessageRow({ }) { return ( - {intelOpen ? ( -
- {trends ? : null} - {coverage ? : null} - {domains ? : null} - {hourly ? : null} - {channels ? : null} - -
- ) : null} +
+
+ } + /> + } + /> + 0 ? "vermilion" : "neutral"} + icon={} + /> + 0 ? "amber" : "neutral"} + icon={} + />
- {/* Live feed ribbon — bottom */} -
-
- - - action log - -
- - + centerLabel={`${Math.round(failedRate)}%`} + centerSub="fail rate" + /> +
+ {Object.entries(byAction).map(([k, v]) => { + const count = typeof v === "number" ? v : null; + return ( +
+ + {ACTION_ICON[k as ModerationActionType]} + + + {ACTION_LABEL[k as ModerationActionType] ?? k} + + {count !== null && ( + {count} + )} +
+ ); + })} + {Object.keys(byAction).length === 0 && ( +
+ No actions recorded yet. +
+ )} +
-
-
- -
-
-
- ); -} + -function ActionRows({ - actions, - liveCount, -}: { - actions: ModerationAction[]; - liveCount: number; -}) { - if (actions.length === 0 && liveCount === 0) { - return ( -
- No matching actions. + + + + + +
+ } + /> +
+ {(actions ?? []).map((a, i) => ( + + ))} + {(actions ?? []).length === 0 && ( +
+ No matching actions. +
+ )} +
+
- ); - } - return ( -
- {actions.map((a, i) => ( - - ))}
); } -function Whisper({ - label, - value, - tone, -}: { - label: string; - value: string; - tone: "ink" | "signal" | "vermilion" | "amber"; -}) { - const color = - tone === "vermilion" - ? "text-vermilion" - : tone === "amber" - ? "text-amber" - : tone === "signal" - ? "text-signal" - : "text-ink"; - return ( -

- - {label} - - {value} -

- ); -} - function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) { const tone = a.status === "executed" @@ -398,6 +345,8 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) { const icon = ACTION_ICON[a.action_type] ?? ( ); + // Map moderation severity → design-system tone (reuse aiTone with a + // severity→status projection so "none" reads as clean/signal). const severityTone = a.severity == null ? null @@ -410,8 +359,8 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) { ); return (
- + {a.username ?? "unknown"} {a.status} - + {formatRelativeTime(a.created_at)}
- {a.reason ? ( -
- “{a.reason}” -
- ) : null} - {severityTone && a.severity ? ( + {a.reason && ( +
“{a.reason}”
+ )} + {severityTone && a.severity && (
{a.severity} - {a.confidence != null ? ( - + {a.confidence != null && ( + conf {(a.confidence * 100).toFixed(0)}% - ) : null} + )}
- ) : null} + )} {a.flags?.length ? (
{a.flags.slice(0, 6).map((f) => ( @@ -453,24 +400,24 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
) : null} {a.evidence?.length ? ( -
+
“{a.evidence[0]}”
) : null} - {a.executed_by ? ( -
+ {a.executed_by && ( +
by {a.executed_by} {a.executed_at ? ` · ${formatRelativeTime(a.executed_at)}` : ""}
- ) : null} - {a.content ? ( -
+ )} + {a.content && ( +
{a.content}
- ) : null} - {a.error ? ( + )} + {a.error && (
{a.error}
- ) : null} + )}
); diff --git a/services/frontend/src/app/(dashboard)/recordings/view.tsx b/services/frontend/src/app/(dashboard)/recordings/view.tsx index 839a9602..4268b809 100644 --- a/services/frontend/src/app/(dashboard)/recordings/view.tsx +++ b/services/frontend/src/app/(dashboard)/recordings/view.tsx @@ -1,25 +1,18 @@ "use client"; -/** - * Recordings scene — voice clips orbit as a timeline ring on the stage; - * the clip archive docks right with inline audio players, playing clip - * highlighted both in the dock and on the sky. - */ import { Download, Hash, Headphones, Loader2, Trash2 } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { useAmbient } from "@/components/ambient/ambient-context"; import { Avatar, Badge, Button, + GlassCard, + GlassPanel, Skeleton, toast, } from "@/components/primitives"; -import { EmptyState, ErrorState } from "@/components/shared"; -import { - useSceneFocusSetter, - useScenePublish, -} from "@/components/shell/scene-graph-context"; +import { EmptyState, ErrorState, SectionHeader } from "@/components/shared"; import { NowPlayingChip, RecordingAudioPlayer, @@ -29,29 +22,11 @@ import { useRecordings, useRecordingsWsSync, } from "@/hooks"; -import type { ConstellationGraph } from "@/lib/constellation/graph"; import { formatBytes, formatRelativeTime } from "@/lib/format"; import type { VoiceRecording } from "@/lib/types"; import { staggerDelay } from "@/lib/utils"; import { useWebSocket } from "@/lib/ws/context"; -function recordingsGraph(items: VoiceRecording[]): ConstellationGraph { - const recent = items.slice(0, 28); - return { - nodes: [ - { id: "archive", label: "archive", kind: "guild", value: 1 }, - ...recent.map((r) => ({ - id: `rec:${r.id}`, - label: r.username, - kind: "media" as const, - value: 0.5, - href: undefined, - })), - ], - edges: recent.map((r) => ({ source: "archive", target: `rec:${r.id}` })), - }; -} - export function RecordingsView({ initialItems, }: { @@ -62,25 +37,8 @@ export function RecordingsView({ const del = useDeleteRecording(); useRecordingsWsSync(ws); const ambient = useAmbient(); - const publish = useScenePublish(); - const setFocus = useSceneFocusSetter(); const [playingId, setPlayingId] = useState(null); - const list = items ?? []; - - const graph = useMemo(() => recordingsGraph(list), [list]); - useEffect(() => { - publish({ graph, focus: playingId ? `rec:${playingId}` : null }); - }, [graph, playingId, publish]); - - useEffect( - () => () => { - publish({ graph: { nodes: [], edges: [] }, focus: null }); - setFocus(null); - }, - [publish, setFocus], - ); - useEffect(() => { ambient.set("signal", 0.3, "recordings"); }, [ambient]); @@ -101,17 +59,14 @@ export function RecordingsView({ if (error && !items) return ; if (!items && isLoading) return ( -
+
-
- {Array.from({ length: 5 }).map((_, i) => ( -
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
@@ -120,168 +75,118 @@ export function RecordingsView({
-
+ ))}
-
+
); return ( -
- {/* Archive dock — right */} -
-
- voice captures - - {list.length} clips + + + {(items ?? []).length} clips -
-
- {list.length === 0 ? ( - } - title="No recordings" - description="Voice clips captured by the bot appear here." - /> - ) : ( -
- {list.map((r, i) => { - const up = uploadStatus(r); - const isPlaying = playingId === r.id; - return ( -
-
- -
-

- {r.username} -

-

- - - {r.channel_name ?? "voice"} - - · - {formatRelativeTime(r.created_at)} -

-
- {isPlaying ? : null} - {up && !isPlaying ? ( - {up.label} - ) : null} - - {formatBytes(r.size_bytes)} + } + /> + {(items ?? []).length === 0 ? ( + } + title="No recordings" + description="Voice clips captured by the bot appear here." + /> + ) : ( +
+ {(items ?? []).map((r, i) => { + const up = uploadStatus(r); + const isPlaying = playingId === r.id; + return ( + +
+ +
+
+ {r.username} +
+
+ + + {r.channel_name ?? "voice"} + + · + + {formatRelativeTime(r.created_at)}
+
+ {isPlaying && } + {up && !isPlaying && {up.label}} + + {formatBytes(r.size_bytes)} + +
- {r.download_url ? ( - - setPlayingId((prev) => { - if (active) return r.id; - return prev === r.id ? null : prev; - }) - } - /> - ) : ( -

- - {r.upload_status === "pending" - ? "Upload pending…" - : r.upload_error - ? r.upload_error - : "Processing…"} -

- )} + {r.download_url ? ( + + setPlayingId((prev) => { + if (active) return r.id; + // Only clear if THIS card was the one playing. + return prev === r.id ? null : prev; + }) + } + /> + ) : ( +
+ + {r.upload_status === "pending" + ? "Upload pending…" + : r.upload_error + ? r.upload_error + : "Processing…"} +
+ )} -
- {r.download_url ? ( - - Download - - ) : null} - -
-
- ); - })} -
- )} +
+ {r.download_url && ( + + Download + + )} + +
+ + ); + })}
-
- - {/* Mobile dock */} -
- {list.length === 0 ? ( - - ) : ( - list.map((r) => ( -
-

- {r.username} · {formatRelativeTime(r.created_at)} -

- {r.download_url ? ( - - setPlayingId((prev) => - active ? r.id : prev === r.id ? null : prev, - ) - } - /> - ) : ( -

- processing… -

- )} -
- )) - )} -
- - {/* Hint */} -

- setiap klip adalah simpel di cincin timeline — yang sedang play menyala - di langit -

-
+ )} + ); } diff --git a/services/frontend/src/app/(dashboard)/voice/view.tsx b/services/frontend/src/app/(dashboard)/voice/view.tsx index efa99c69..789e59e0 100644 --- a/services/frontend/src/app/(dashboard)/voice/view.tsx +++ b/services/frontend/src/app/(dashboard)/voice/view.tsx @@ -1,10 +1,5 @@ "use client"; -/** - * Voice scene — the stage node sits center; live speakers orbit it with - * glow (published to the constellation). Controls float bottom-left, - * connections whisper right, and the mic/listen meters stay docked. - */ import { Headphones, Mic, @@ -14,16 +9,17 @@ import { Volume2, Waves, } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { useAmbient } from "@/components/ambient/ambient-context"; import { Equalizer } from "@/components/charts"; -import { Button, toast } from "@/components/primitives"; -import { EmptyState, ErrorState } from "@/components/shared"; -import { GuildChannelPicker } from "@/components/shared/guild-picker"; +import { Button, GlassPanel, toast } from "@/components/primitives"; import { - useSceneFocusSetter, - useScenePublish, -} from "@/components/shell/scene-graph-context"; + EmptyState, + ErrorState, + SectionHeader, + SkeletonPanel, +} from "@/components/shared"; +import { GuildChannelPicker } from "@/components/shared/guild-picker"; import { VoiceStage } from "@/components/voice/voice-stage"; import { useMicTransmit, @@ -33,37 +29,9 @@ import { useVoiceListen, useVoiceStatus, } from "@/hooks"; -import type { ConstellationGraph } from "@/lib/constellation/graph"; import type { Guild, VoiceStatus } from "@/lib/types"; import { useWebSocket } from "@/lib/ws/context"; -function speakerGraph( - speakers: { - userId: string; - username: string; - speaking: boolean; - level?: number; - }[], -): ConstellationGraph { - return { - nodes: [ - { id: "stage", label: "voice stage", kind: "guild", value: 1 }, - ...speakers.map((sp) => ({ - id: `speaker:${sp.userId}`, - label: sp.username, - kind: "speaker" as const, - value: sp.speaking ? 0.8 : 0.4, - href: undefined, - meta: { speaking: sp.speaking }, - })), - ], - edges: speakers.map((sp) => ({ - source: "stage", - target: `speaker:${sp.userId}`, - })), - }; -} - export function VoiceView({ initialStatus, initialGuilds, @@ -79,8 +47,6 @@ export function VoiceView({ const listen = useVoiceListen(ws); const { speakers, subscribe } = useSpeakers(initialStatus?.activeSpeakers); const ambient = useAmbient(); - const publish = useScenePublish(); - const setFocus = useSceneFocusSetter(); const [guildId, setGuildId] = useState( initialStatus?.activeGuildId ?? initialGuilds?.[0]?.id ?? null, @@ -97,19 +63,6 @@ export function VoiceView({ return unsub; }, [subscribe, ws]); - const graph = useMemo(() => speakerGraph(speakers), [speakers]); - useEffect(() => { - publish({ graph, focus: null }); - }, [graph, publish]); - - useEffect( - () => () => { - publish({ graph: { nodes: [], edges: [] }, focus: null }); - setFocus(null); - }, - [publish, setFocus], - ); - useEffect(() => { if (status?.connected) ambient.set("signal", 0.55, "voice live"); else ambient.set("vermilion", 0.35, "voice idle"); @@ -118,9 +71,13 @@ export function VoiceView({ if (error && !status) return ; if (!status && isLoading) return ( -

- menyetel ulang stage… -

+
+ +
+ + +
+
); const connected = status?.connected ?? false; @@ -161,94 +118,43 @@ export function VoiceView({ }; return ( -
- {/* Stage presence — top-center whisper */} -
-
- {speakers.length === 0 ? ( - } - title={connected ? "Silent right now" : "Not connected"} - description={ - connected - ? "Speakers appear as they talk." - : "Connect to a voice channel to see presence." - } - /> - ) : ( - - )} +
+ +
+ { + setGuildId(g); + setChannelId(c); + }} + /> +
+ {connected ? ( + + ) : ( + + )} +
-
- {/* Connections — right */} -
-

links · {speakers.length} present

-
- {(status?.connections ?? []).map((c) => ( -
- - {c.channelName} - - {new Date(c.connectedAt).toLocaleTimeString()} - -
- ))} - {(status?.connections ?? []).length === 0 ? ( -

- no active links -

- ) : null} -

- channel: {status?.activeChannelName ?? "—"} -

-
-
- - {/* Console — bottom-left */} -
- { - setGuildId(g); - setChannelId(c); - }} - /> -
- {connected ? ( - - ) : ( - - )} +
+ {micOn && ( +
+ + +
+ )}
- - {micOn ? ( -
- - + {listen.active && ( +
+ + +
+ )} +
+ +
- ) : null} - {listen.active ? ( -
- - -
- ) : null} - -
- -
-
+ + +
+ + + {speakers.length} present + + } + /> + {speakers.length === 0 ? ( + } + title={connected ? "Silent right now" : "Not connected"} + description={ + connected + ? "Speakers appear as they talk." + : "Connect to a voice channel to see presence." + } + /> + ) : ( + <> + +
+ {speakers.map((sp) => ( + + + {sp.username} + + ))} +
+ + )} +
+ + + +
+ {(status?.connections ?? []).map((c) => ( +
+ + + {c.channelName} + + + {new Date(c.connectedAt).toLocaleTimeString()} + +
+ ))} + {(status?.connections ?? []).length === 0 && ( +
+ No active links +
+ )} +
+
+ channel{" "} + {status?.activeChannelName ?? "—"} +
+
+
); } diff --git a/services/frontend/src/app/globals.css b/services/frontend/src/app/globals.css index 7baaa57a..e37ca79e 100644 --- a/services/frontend/src/app/globals.css +++ b/services/frontend/src/app/globals.css @@ -17,7 +17,7 @@ /* ── Surfaces (dark-first; light overrides below) ── */ --color-canvas: oklch(0.12 0.014 70); --color-canvas-2: oklch(0.16 0.02 70); - --color-surface: oklch(0.2 0.022 70 / 0.55); + --color-surface: oklch(0.2 0.022 70 / 0.45); --color-surface-2: oklch(0.26 0.024 70 / 0.45); /* ── Ink ── */ @@ -77,11 +77,6 @@ background: var(--color-signal-glow); color: var(--color-ink); } -/* Constellation stage: soften glow layers so stars stay legible on pale sky */ -.light .glass-glow, -.light [class*="shadow-[0_0"] { - --tw-shadow-color: oklch(0.5 0.1 150 / 0.18); -} @layer base { * { diff --git a/services/frontend/src/components/shared/page-transition.tsx b/services/frontend/src/components/shared/page-transition.tsx index 543ec1b1..216ca416 100644 --- a/services/frontend/src/components/shared/page-transition.tsx +++ b/services/frontend/src/components/shared/page-transition.tsx @@ -11,7 +11,5 @@ export function PageTransition({ children: React.ReactNode; className?: string; }) { - return ( -
{children}
- ); + return
{children}
; } diff --git a/services/frontend/src/components/shell/ambient-app.tsx b/services/frontend/src/components/shell/ambient-app.tsx new file mode 100644 index 00000000..0704e1ef --- /dev/null +++ b/services/frontend/src/components/shell/ambient-app.tsx @@ -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 ( +
+ +
+ +
+ {children} +
+
+ + +
+ ); +} diff --git a/services/frontend/src/components/shell/constellation-frame.tsx b/services/frontend/src/components/shell/constellation-frame.tsx deleted file mode 100644 index b59ec5a5..00000000 --- a/services/frontend/src/components/shell/constellation-frame.tsx +++ /dev/null @@ -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 ( - { - // 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 ( - -
- - - - - {/* Overlay content region — scenes place floating panels inside. */} -
- {/* no pointer-events here: empty areas stay click-through to the sky */} -
{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 deleted file mode 100644 index d8cac212..00000000 --- a/services/frontend/src/components/shell/constellation-stage.tsx +++ /dev/null @@ -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(null); - const labelsRef = useRef(null); - const camRef = useRef({ x: 0, y: 0, z: 1 }); - const layoutRef = useRef([]); - const nodeByIdRef = useRef(new Map(graph.nodes.map((n) => [n.id, n]))); - const [size, setSize] = useState({ w: 0, h: 0 }); - const hoveredRef = useRef(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 = 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; - core: THREE.Mesh; - 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(); - 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 ( - <> - -