revert(frontend): kembalikan shell usable — hapus total eksperimen constellation (three/d3-force dihapus)
This commit is contained in:
@@ -12,7 +12,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@orpc/client": "1.15.0",
|
"@orpc/client": "1.15.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"d3-force": "^3.0.0",
|
|
||||||
"lucide-react": "^1.27.0",
|
"lucide-react": "^1.27.0",
|
||||||
"motion": "^12.0.0",
|
"motion": "^12.0.0",
|
||||||
"next": "16.2.12",
|
"next": "16.2.12",
|
||||||
@@ -27,8 +26,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.2.0",
|
"@biomejs/biome": "2.2.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/bun": "^1.4.0",
|
|
||||||
"@types/d3-force": "^3.0.10",
|
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
Generated
-56
@@ -14,9 +14,6 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
d3-force:
|
|
||||||
specifier: ^3.0.0
|
|
||||||
version: 3.0.0
|
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.27.0
|
specifier: ^1.27.0
|
||||||
version: 1.28.0(react@19.2.4)
|
version: 1.28.0(react@19.2.4)
|
||||||
@@ -54,12 +51,6 @@ importers:
|
|||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4
|
specifier: ^4
|
||||||
version: 4.3.3
|
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':
|
'@types/node':
|
||||||
specifier: ^20
|
specifier: ^20
|
||||||
version: 20.19.43
|
version: 20.19.43
|
||||||
@@ -576,12 +567,6 @@ packages:
|
|||||||
'@tweenjs/tween.js@23.1.3':
|
'@tweenjs/tween.js@23.1.3':
|
||||||
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
|
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':
|
'@types/node@20.19.43':
|
||||||
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
|
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}
|
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
bun-types@1.4.0:
|
|
||||||
resolution: {integrity: sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q==}
|
|
||||||
|
|
||||||
caniuse-lite@1.0.30001806:
|
caniuse-lite@1.0.30001806:
|
||||||
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
|
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
|
||||||
|
|
||||||
@@ -655,22 +637,6 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
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:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -1495,12 +1461,6 @@ snapshots:
|
|||||||
|
|
||||||
'@tweenjs/tween.js@23.1.3': {}
|
'@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':
|
'@types/node@20.19.43':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
@@ -1548,10 +1508,6 @@ snapshots:
|
|||||||
update-browserslist-db: 1.2.3(browserslist@4.28.7)
|
update-browserslist-db: 1.2.3(browserslist@4.28.7)
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
bun-types@1.4.0:
|
|
||||||
dependencies:
|
|
||||||
'@types/node': 20.19.43
|
|
||||||
|
|
||||||
caniuse-lite@1.0.30001806: {}
|
caniuse-lite@1.0.30001806: {}
|
||||||
|
|
||||||
chromium-bidi@17.0.2(devtools-protocol@0.0.1666840):
|
chromium-bidi@17.0.2(devtools-protocol@0.0.1666840):
|
||||||
@@ -1575,18 +1531,6 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
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:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|||||||
@@ -1,72 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
import { Hash, Search, Sparkles, TrendingUp } from "lucide-react";
|
||||||
* Analysis scene — semantic search lives in a floating console (left);
|
import { useEffect, useState } from "react";
|
||||||
* 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 { useAmbient } from "@/components/ambient/ambient-context";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import { Avatar, Badge } from "@/components/primitives";
|
import { Avatar, Badge, GlassPanel, Input } from "@/components/primitives";
|
||||||
import { EmptyState, SkeletonRows } from "@/components/shared";
|
import { EmptyState, SectionHeader, SkeletonRows } from "@/components/shared";
|
||||||
import { useScenePublish } from "@/components/shell/scene-graph-context";
|
import { useChannels, useMessageSearch, useTopReactors } from "@/hooks";
|
||||||
import { useChannels, useMessageSearch } from "@/hooks";
|
|
||||||
import { aiTone } from "@/lib/ai-status";
|
import { aiTone } from "@/lib/ai-status";
|
||||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
|
||||||
import {
|
import {
|
||||||
formatDuration,
|
formatDuration,
|
||||||
getMessageChannelLabel,
|
getMessageChannelLabel,
|
||||||
renderMessageContent,
|
renderMessageContent,
|
||||||
} from "@/lib/format";
|
} 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() {
|
export function AnalysisView() {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const search = useMessageSearch(query, query.trim().length >= 2);
|
const search = useMessageSearch(query, query.trim().length >= 2);
|
||||||
|
const { data: reactors } = useTopReactors();
|
||||||
const { data: channels } = useChannels();
|
const { data: channels } = useChannels();
|
||||||
const ambient = useAmbient();
|
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(() => {
|
useEffect(() => {
|
||||||
ambient.set(
|
ambient.set(
|
||||||
@@ -77,50 +29,52 @@ export function AnalysisView() {
|
|||||||
}, [query, ambient]);
|
}, [query, ambient]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full">
|
<div className="space-y-5">
|
||||||
{/* Search console — left */}
|
<GlassPanel glow className="relative overflow-hidden">
|
||||||
<section
|
<div className="scan-line absolute inset-x-0 top-0" />
|
||||||
className="pointer-events-auto absolute left-5 top-16 w-[min(26rem,88vw)]"
|
<div className="flex items-center gap-3">
|
||||||
aria-label="Semantic search"
|
<Sparkles className="size-5 text-signal" />
|
||||||
>
|
<div>
|
||||||
<p className="eyebrow mb-2 flex items-center gap-1.5">
|
<div className="eyebrow">Semantic search</div>
|
||||||
<Sparkles className="size-3.5 text-signal" /> Search the archive
|
<h2 className="display text-balance text-2xl text-ink">
|
||||||
</p>
|
Search the archive
|
||||||
<div className="relative">
|
</h2>
|
||||||
<Search className="absolute left-4 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-faint)]" />
|
</div>
|
||||||
<input
|
</div>
|
||||||
type="search"
|
<div className="relative mt-4">
|
||||||
|
<Search className="absolute left-4 top-1/2 size-5 -translate-y-1/2 text-ink-faint" />
|
||||||
|
<Input
|
||||||
|
className="h-12 pl-12 text-base"
|
||||||
|
placeholder="Find messages, patterns, flags…"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Find messages, patterns, flags…"
|
|
||||||
autoFocus
|
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)]"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{query.trim().length > 0 && query.trim().length < 2 ? (
|
{query.trim().length > 0 && query.trim().length < 2 && (
|
||||||
<p className="mt-2 font-mono text-xs text-[var(--color-ink-faint)]">
|
<div className="mono mt-2 text-xs text-ink-faint">
|
||||||
minimal 2 karakter…
|
Type at least 2 characters…
|
||||||
</p>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</section>
|
</GlassPanel>
|
||||||
|
|
||||||
{/* Results dossier — right */}
|
<div className="grid gap-5 lg:grid-cols-5">
|
||||||
<section
|
<GlassPanel className="lg:col-span-3">
|
||||||
className="pointer-events-auto absolute bottom-20 right-5 hidden max-h-[62vh] w-[min(30rem,92vw)] flex-col overflow-hidden rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/75 backdrop-blur-xl md:flex"
|
<SectionHeader
|
||||||
aria-label="Search results"
|
eyebrow="results"
|
||||||
>
|
title="Matches"
|
||||||
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] px-4 py-2.5">
|
action={
|
||||||
<span className="eyebrow">matches</span>
|
<span className="mono text-xs text-ink-faint">
|
||||||
<span className="font-mono text-xs text-[var(--color-ink-faint)]">
|
|
||||||
{(search.data ?? []).length}
|
{(search.data ?? []).length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
}
|
||||||
<div className="overflow-y-auto p-3">
|
/>
|
||||||
{query.trim().length >= 2 && search.isLoading ? (
|
{query.trim().length >= 2 && search.isLoading && (
|
||||||
<SkeletonRows rows={5} />
|
<SkeletonRows rows={6} />
|
||||||
) : (search.data ?? []).length === 0 ? (
|
)}
|
||||||
|
{(search.data ?? []).length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<Search className="size-6" />}
|
icon={<Search className="size-7" />}
|
||||||
title="No matches yet"
|
title="No matches yet"
|
||||||
description="Run a search to surface messages across the guild."
|
description="Run a search to surface messages across the guild."
|
||||||
/>
|
/>
|
||||||
@@ -129,27 +83,27 @@ export function AnalysisView() {
|
|||||||
{(search.data ?? []).map((m) => (
|
{(search.data ?? []).map((m) => (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className="flex items-start gap-3 rounded-xl border border-[var(--color-hairline)] p-2.5"
|
className="flex items-start gap-3 rounded-[12px] border border-hairline bg-white/[0.03] p-3"
|
||||||
>
|
>
|
||||||
<Avatar src={m.avatar_url} name={m.username} size={28} />
|
<Avatar src={m.avatar_url} name={m.username} size={32} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-[var(--color-ink)]">
|
<span className="text-sm font-semibold text-ink">
|
||||||
{m.username}
|
{m.username}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
<span className="mono text-[0.65rem] text-ink-faint">
|
||||||
{getMessageChannelLabel(m)}
|
{getMessageChannelLabel(m)}
|
||||||
</span>
|
</span>
|
||||||
{m.ai_status ? (
|
{m.ai_status && (
|
||||||
<Badge tone={aiTone(m.ai_status)} className="ml-auto">
|
<Badge tone={aiTone(m.ai_status)} className="ml-auto">
|
||||||
{m.ai_analysis_duration_ms &&
|
{m.ai_analysis_duration_ms &&
|
||||||
m.ai_analysis_duration_ms > 0
|
m.ai_analysis_duration_ms > 0
|
||||||
? `${m.ai_status} · ${formatDuration(m.ai_analysis_duration_ms)}`
|
? `${m.ai_status} · ${formatDuration(m.ai_analysis_duration_ms)}`
|
||||||
: m.ai_status}
|
: m.ai_status}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 line-clamp-2 text-sm text-[var(--color-ink-soft)]">
|
<div className="mt-0.5 text-sm text-ink-soft">
|
||||||
{renderMessageContent(m.content, m.metadata) || "(embed)"}
|
{renderMessageContent(m.content, m.metadata) || "(embed)"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,14 +111,86 @@ export function AnalysisView() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</GlassPanel>
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Channels hint — bottom-left */}
|
<div className="space-y-5 lg:col-span-2">
|
||||||
<p className="pointer-events-none absolute bottom-24 left-5 hidden items-center gap-1.5 font-mono text-xs text-[var(--color-ink-faint)] lg:flex">
|
<GlassPanel>
|
||||||
<Hash className="size-3.5" /> {(channels ?? []).length} channels
|
<SectionHeader
|
||||||
terhubung ke hub analitik — klik untuk membuka konstelasi channel
|
eyebrow="culture"
|
||||||
</p>
|
title={
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="size-4 text-signal" /> Top reactors
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(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 (
|
||||||
|
<div
|
||||||
|
key={r.user_id}
|
||||||
|
className="flex items-center gap-3 text-sm"
|
||||||
|
>
|
||||||
|
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||||
|
<span className="w-28 shrink-0 truncate text-ink-soft sm:w-40">
|
||||||
|
{r.username}
|
||||||
|
</span>
|
||||||
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/8">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-signal/70"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="mono w-12 text-right text-xs text-signal">
|
||||||
|
+{r.net_count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{(reactors ?? []).length === 0 && (
|
||||||
|
<div className="py-4 text-center text-xs text-ink-faint">
|
||||||
|
No data
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
|
||||||
|
<GlassPanel>
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="channels"
|
||||||
|
title={
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Hash className="size-4 text-signal" /> Top channels
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(channels ?? []).slice(0, 6).map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.channel_id}
|
||||||
|
className="flex items-center gap-3 text-sm"
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate text-ink-soft">
|
||||||
|
{c.channel_name ?? c.channel_id.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
<span className="mono text-xs text-ink-faint">
|
||||||
|
{c.total_messages}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(channels ?? []).length === 0 && (
|
||||||
|
<div className="py-4 text-center text-xs text-ink-faint">
|
||||||
|
No data
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,8 @@
|
|||||||
"use client";
|
"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 { SkeletonPanel } from "@/components/shared";
|
||||||
import {
|
|
||||||
useSceneFocusSetter,
|
|
||||||
useSceneGraph,
|
|
||||||
useScenePublish,
|
|
||||||
} from "@/components/shell/scene-graph-context";
|
|
||||||
import { useChannelCultures } from "@/hooks";
|
import { useChannelCultures } from "@/hooks";
|
||||||
import { culturesToGraph } from "@/lib/constellation/graph";
|
|
||||||
import type { ChannelCultureRow } from "@/lib/types";
|
import type { ChannelCultureRow } from "@/lib/types";
|
||||||
|
|
||||||
export function ChannelsView({
|
export function ChannelsView({
|
||||||
@@ -21,92 +11,12 @@ export function ChannelsView({
|
|||||||
initialCultures?: ChannelCultureRow[];
|
initialCultures?: ChannelCultureRow[];
|
||||||
}) {
|
}) {
|
||||||
const { data: cultures } = useChannelCultures(100, initialCultures);
|
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 (
|
return (
|
||||||
<div className="min-h-full">
|
<div className="space-y-5">
|
||||||
{/* Search whisper — top-left */}
|
{cultures ? (
|
||||||
<section
|
<ChannelCultureGlossary cultures={cultures} />
|
||||||
className="pointer-events-auto absolute left-5 top-16 w-64"
|
|
||||||
aria-label="Filter channels"
|
|
||||||
>
|
|
||||||
<p className="eyebrow mb-2">Channels · {filtered.length} mapped</p>
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => 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 ? (
|
|
||||||
<div className="mt-3">
|
|
||||||
<SkeletonPanel rows={3} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Culture dossier for the selected star */}
|
|
||||||
{selectedRow ? (
|
|
||||||
<aside
|
|
||||||
className="pointer-events-auto absolute bottom-20 left-1/2 w-[min(34rem,92vw)] -translate-x-1/2 rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/80 p-4 backdrop-blur-xl md:left-auto md:right-5 md:translate-x-0"
|
|
||||||
aria-label={`Culture of ${selectedRow.channel_name ?? selectedRow.channel_id}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute right-3 top-3 font-mono text-xs text-[var(--color-ink-faint)] hover:text-[var(--color-ink)]"
|
|
||||||
onClick={() => setFocus(null)}
|
|
||||||
>
|
|
||||||
esc
|
|
||||||
</button>
|
|
||||||
<p className="eyebrow">channel culture</p>
|
|
||||||
<h3 className="display mt-1 text-xl leading-tight text-ink glow-signal">
|
|
||||||
{selectedRow.channel_name ?? selectedRow.channel_id}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-2 max-h-40 overflow-y-auto text-sm text-pretty text-ink-soft">
|
|
||||||
{selectedRow.culture_summary ?? "Belum dianalisis."}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 font-mono text-[10px] uppercase tracking-wider text-[var(--color-ink-faint)]">
|
|
||||||
{selectedRow.last_analyzed_at
|
|
||||||
? `analyzed ${new Date(selectedRow.last_analyzed_at).toLocaleString()}`
|
|
||||||
: "never analyzed"}
|
|
||||||
</p>
|
|
||||||
</aside>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="pointer-events-none absolute inset-x-0 bottom-24 hidden justify-center font-mono text-xs text-[var(--color-ink-faint)] md:flex">
|
<SkeletonPanel rows={6} />
|
||||||
klik sebuah bintang untuk membuka culture dossier
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
import {
|
||||||
* Dashboard scene — guild star + channel orbit live on the stage canvas;
|
Activity,
|
||||||
* this overlay adds the metric whisper (top-left), activity ribbon
|
Flag,
|
||||||
* (bottom-left), and moderation gauge (right) as floating panels.
|
MessageSquare,
|
||||||
*/
|
Mic,
|
||||||
import { Activity, Flag, ShieldAlert } from "lucide-react";
|
Radio,
|
||||||
import { useEffect, useMemo } from "react";
|
ShieldAlert,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import { AreaActivity, RadialGauge } from "@/components/charts";
|
import { AreaActivity, RadialGauge } from "@/components/charts";
|
||||||
import { ErrorState, LoadingState } from "@/components/shared";
|
import { GlassPanel } from "@/components/primitives";
|
||||||
import { useScenePublish } from "@/components/shell/scene-graph-context";
|
import {
|
||||||
import { useActivity, useStats } from "@/hooks";
|
ErrorState,
|
||||||
import { statsToGraph } from "@/lib/constellation/graph";
|
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 { formatNumber } from "@/lib/format";
|
||||||
import type { DashboardStats } from "@/lib/types";
|
import type { DashboardStats } from "@/lib/types";
|
||||||
import { staggerDelay } from "@/lib/utils";
|
import { staggerDelay } from "@/lib/utils";
|
||||||
@@ -36,19 +50,11 @@ export function DashboardView({
|
|||||||
initialStats?: DashboardStats;
|
initialStats?: DashboardStats;
|
||||||
initialActivity?: Awaited<ReturnType<typeof useActivity>>["data"];
|
initialActivity?: Awaited<ReturnType<typeof useActivity>>["data"];
|
||||||
}) {
|
}) {
|
||||||
const { data: stats, error } = useStats(initialStats);
|
const { data: stats, isLoading, error } = useStats(initialStats);
|
||||||
const { data: activity } = useActivity(14, initialActivity as never);
|
const { data: activity } = useActivity(14, initialActivity as never);
|
||||||
|
const { data: reactors } = useTopReactors();
|
||||||
|
const { data: reactions } = useTopReactions();
|
||||||
const ambient = useAmbient();
|
const ambient = useAmbient();
|
||||||
const publish = useScenePublish();
|
|
||||||
|
|
||||||
const graph = useMemo(
|
|
||||||
() => (stats ? statsToGraph(stats) : { nodes: [], edges: [] }),
|
|
||||||
[stats],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
publish({ graph, focus: "guild" });
|
|
||||||
}, [graph, publish]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const s = deriveSignal(stats);
|
const s = deriveSignal(stats);
|
||||||
@@ -60,69 +66,142 @@ export function DashboardView({
|
|||||||
}, [stats, ambient]);
|
}, [stats, ambient]);
|
||||||
|
|
||||||
if (error && !stats) return <ErrorState error={error} />;
|
if (error && !stats) return <ErrorState error={error} />;
|
||||||
if (!stats) return <LoadingState label="aligning constellation" />;
|
if (!stats && isLoading)
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<SkeletonHero />
|
||||||
|
<SkeletonMetricRow cols={4} />
|
||||||
|
<SkeletonPanel rows={5} />
|
||||||
|
<div className="grid gap-5 lg:grid-cols-5">
|
||||||
|
<SkeletonPanel className="lg:col-span-3" rows={4} />
|
||||||
|
<SkeletonPanel className="lg:col-span-2" rows={4} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (!stats) return <ErrorState error={error ?? new Error("No data")} />;
|
||||||
|
|
||||||
const s = stats;
|
const s = stats;
|
||||||
const total = s.total_flagged + s.total_clean || 1;
|
const total = s.total_flagged + s.total_clean || 1;
|
||||||
const cleanRatio = s.total_clean / total;
|
const cleanRatio = s.total_clean / total;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full">
|
<div className="space-y-5">
|
||||||
{/* Metric whisper — top-left under brand */}
|
{/* Hero */}
|
||||||
<section
|
<GlassPanel glow className="relative overflow-hidden">
|
||||||
className="pointer-events-auto absolute left-5 top-16 w-64 animate-stagger"
|
<div className="scan-line absolute inset-x-0 top-0" />
|
||||||
style={staggerDelay(0)}
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
aria-label="Key metrics"
|
<div>
|
||||||
>
|
<div className="eyebrow mb-2">GMW · Operations Grid</div>
|
||||||
<p className="eyebrow mb-2">Operations · {deriveSignal(s).label}</p>
|
<h2 className="display hero-clamp leading-none text-ink glow-signal">
|
||||||
<div className="space-y-1.5">
|
Ambient Field
|
||||||
<WhisperRow
|
</h2>
|
||||||
label="messages"
|
<p className="mt-2 max-w-md text-pretty text-sm text-ink-soft">
|
||||||
|
Real-time moderation, voice & media presence across the monitored
|
||||||
|
guild. {formatNumber(s.total_messages)} messages captured.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-ink-soft">
|
||||||
|
<Radio className="size-4 text-signal animate-breathe" />
|
||||||
|
<span className="mono text-xs uppercase tracking-wider">
|
||||||
|
{deriveSignal(s).label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
|
<MetricTile
|
||||||
|
label="Messages"
|
||||||
value={formatNumber(s.total_messages)}
|
value={formatNumber(s.total_messages)}
|
||||||
tone="ink"
|
tone="signal"
|
||||||
|
icon={<MessageSquare className="size-3.5" />}
|
||||||
|
className="animate-stagger"
|
||||||
|
style={staggerDelay(0)}
|
||||||
/>
|
/>
|
||||||
<WhisperRow
|
<MetricTile
|
||||||
label="flagged"
|
label="Flagged"
|
||||||
value={formatNumber(s.total_flagged)}
|
value={formatNumber(s.total_flagged)}
|
||||||
tone={s.total_flagged > 0 ? "vermilion" : "ink"}
|
tone={s.total_flagged > 0 ? "vermilion" : "neutral"}
|
||||||
hint={`${s.today_flagged} today`}
|
hint={`${s.today_flagged} today`}
|
||||||
|
className="animate-stagger"
|
||||||
|
style={staggerDelay(1)}
|
||||||
/>
|
/>
|
||||||
<WhisperRow
|
<MetricTile
|
||||||
label="active 24h"
|
label="Active 24h"
|
||||||
value={formatNumber(s.active_users_24h)}
|
value={formatNumber(s.active_users_24h)}
|
||||||
tone="signal"
|
tone="signal"
|
||||||
|
icon={<Users className="size-3.5" />}
|
||||||
|
className="animate-stagger"
|
||||||
|
style={staggerDelay(2)}
|
||||||
/>
|
/>
|
||||||
<WhisperRow
|
<MetricTile
|
||||||
label="voice clips"
|
label="Voice clips"
|
||||||
value={formatNumber(s.total_voice_recordings)}
|
value={formatNumber(s.total_voice_recordings)}
|
||||||
tone="ink"
|
icon={<Mic className="size-3.5" />}
|
||||||
|
className="animate-stagger"
|
||||||
|
style={staggerDelay(3)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</GlassPanel>
|
||||||
|
|
||||||
{/* Activity ribbon — bottom-left */}
|
{/* Activity */}
|
||||||
<section
|
<GlassPanel>
|
||||||
className="pointer-events-auto absolute bottom-20 left-5 hidden w-80 lg:block"
|
<SectionHeader
|
||||||
aria-label="Activity"
|
eyebrow="14-day signal"
|
||||||
>
|
title={
|
||||||
<p className="eyebrow mb-1 flex items-center gap-1.5">
|
<span className="flex items-center gap-2">
|
||||||
<Activity className="size-3.5 text-signal" /> 14-day signal
|
<Activity className="size-4 text-signal" /> Activity & moderation
|
||||||
</p>
|
</span>
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<div className="flex items-center gap-3 text-xs text-ink-soft">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="size-2 rounded-full bg-signal" /> messages
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="size-2 rounded-full bg-vermilion" /> flagged
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{activity ? (
|
{activity ? (
|
||||||
<AreaActivity daily={activity.daily} />
|
<AreaActivity daily={activity.daily} />
|
||||||
) : (
|
) : (
|
||||||
<LoadingState label="streaming" />
|
<LoadingState label="streaming" />
|
||||||
)}
|
)}
|
||||||
</section>
|
</GlassPanel>
|
||||||
|
|
||||||
{/* Moderation gauge — right */}
|
{/* Two-column: channels + moderation */}
|
||||||
<section
|
<div className="grid gap-5 lg:grid-cols-5">
|
||||||
className="pointer-events-auto absolute right-5 top-16 hidden w-56 md:block"
|
<GlassPanel className="lg:col-span-3">
|
||||||
aria-label="Moderation"
|
<SectionHeader eyebrow="throughput" title="Top channels" />
|
||||||
>
|
<div className="space-y-2.5">
|
||||||
<p className="eyebrow mb-2 flex items-center gap-1.5">
|
{s.top_channels.slice(0, 7).map((c) => {
|
||||||
<ShieldAlert className="size-3.5 text-signal" /> Trust
|
const pct =
|
||||||
</p>
|
(c.message_count / (s.top_channels[0]?.message_count || 1)) *
|
||||||
|
100;
|
||||||
|
return (
|
||||||
|
<div key={c.channel_id} className="flex items-center gap-3">
|
||||||
|
<span className="w-28 shrink-0 truncate text-sm text-ink-soft sm:w-40">
|
||||||
|
{c.channel_name ?? c.channel_id.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/8">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-signal/70"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="mono w-14 text-right text-xs text-ink-faint">
|
||||||
|
{formatNumber(c.message_count)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
|
||||||
|
<GlassPanel className="lg:col-span-2">
|
||||||
|
<SectionHeader eyebrow="trust" title="Moderation" />
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
<RadialGauge
|
<RadialGauge
|
||||||
value={cleanRatio}
|
value={cleanRatio}
|
||||||
tone={
|
tone={
|
||||||
@@ -135,59 +214,155 @@ export function DashboardView({
|
|||||||
label={`${Math.round(cleanRatio * 100)}%`}
|
label={`${Math.round(cleanRatio * 100)}%`}
|
||||||
sublabel="clean"
|
sublabel="clean"
|
||||||
/>
|
/>
|
||||||
<div className="mt-3 space-y-1 font-mono text-xs text-[var(--color-ink-soft)]">
|
<div className="flex-1 space-y-2 text-sm">
|
||||||
<p className="flex items-center justify-between">
|
<Row
|
||||||
<span className="inline-flex items-center gap-1.5">
|
icon={<ShieldAlert className="size-4 text-signal" />}
|
||||||
<Flag className="size-3 text-vermilion" /> flagged
|
label="Clean"
|
||||||
|
value={formatNumber(s.total_clean)}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
icon={<Flag className="size-4 text-vermilion" />}
|
||||||
|
label="Flagged"
|
||||||
|
value={formatNumber(s.total_flagged)}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
icon={<Activity className="size-4 text-amber" />}
|
||||||
|
label="Warned"
|
||||||
|
value={formatNumber(s.total_warned)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center justify-around border-t border-hairline pt-3 text-center">
|
||||||
|
<Mini
|
||||||
|
label="pending"
|
||||||
|
value={s.moderation_overview.pending}
|
||||||
|
tone="amber"
|
||||||
|
/>
|
||||||
|
<Mini
|
||||||
|
label="processing"
|
||||||
|
value={s.moderation_overview.processing}
|
||||||
|
tone="signal"
|
||||||
|
/>
|
||||||
|
<Mini
|
||||||
|
label="errors"
|
||||||
|
value={s.moderation_overview.error}
|
||||||
|
tone="vermilion"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reactors + reactions */}
|
||||||
|
<div className="grid gap-5 lg:grid-cols-2">
|
||||||
|
<GlassPanel>
|
||||||
|
<SectionHeader eyebrow="engagement" title="Top reactors" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(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 (
|
||||||
|
<div key={r.user_id} className="flex items-center gap-3">
|
||||||
|
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||||
|
<span className="w-28 shrink-0 truncate text-sm text-ink-soft sm:w-40">
|
||||||
|
{r.username}
|
||||||
</span>
|
</span>
|
||||||
<span>{formatNumber(s.total_flagged)}</span>
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/8">
|
||||||
</p>
|
<div
|
||||||
<p className="flex items-center justify-between">
|
className="h-full rounded-full bg-signal/70"
|
||||||
<span>warned</span>
|
style={{ width: `${pct}%` }}
|
||||||
<span>{formatNumber(s.total_warned)}</span>
|
/>
|
||||||
</p>
|
</div>
|
||||||
<p className="flex items-center justify-between">
|
<span className="mono w-12 text-right text-xs text-signal">
|
||||||
<span>pending</span>
|
+{formatNumber(r.net_count)}
|
||||||
<span>{s.moderation_overview.pending}</span>
|
</span>
|
||||||
</p>
|
</div>
|
||||||
<p className="flex items-center justify-between">
|
);
|
||||||
<span>processing</span>
|
})}
|
||||||
<span>{s.moderation_overview.processing}</span>
|
{(reactors ?? []).length === 0 && <EmptyHint />}
|
||||||
</p>
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
|
||||||
|
<GlassPanel>
|
||||||
|
<SectionHeader eyebrow="culture" title="Top reactions" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(reactions ?? []).slice(0, 5).map((m) => (
|
||||||
|
<div key={m.message_id} className="flex items-start gap-3">
|
||||||
|
<div className="flex flex-wrap gap-1 pt-0.5">
|
||||||
|
{m.top_emojis.slice(0, 3).map((e, i) => (
|
||||||
|
<span
|
||||||
|
key={`${m.message_id}-${i}`}
|
||||||
|
className="text-lg leading-none"
|
||||||
|
>
|
||||||
|
{e.emoji}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm text-ink">
|
||||||
|
{m.content || "(no text)"}
|
||||||
|
</div>
|
||||||
|
<div className="mono text-[0.65rem] text-ink-faint">
|
||||||
|
{m.username} · {m.channel_name ?? m.channel_id.slice(0, 8)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="mono text-xs text-ink-soft">
|
||||||
|
{m.reaction_count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(reactions ?? []).length === 0 && <EmptyHint />}
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WhisperRow({
|
function Row({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
{icon}
|
||||||
|
<span className="flex-1 text-ink-soft">{label}</span>
|
||||||
|
<span className="mono text-ink">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Mini({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
tone,
|
tone,
|
||||||
hint,
|
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: number;
|
||||||
tone: "ink" | "signal" | "vermilion";
|
tone: "signal" | "amber" | "vermilion";
|
||||||
hint?: string;
|
|
||||||
}) {
|
}) {
|
||||||
const color =
|
const color =
|
||||||
tone === "vermilion"
|
tone === "vermilion"
|
||||||
? "text-vermilion"
|
? "text-vermilion"
|
||||||
: tone === "signal"
|
: tone === "amber"
|
||||||
? "text-signal"
|
? "text-amber"
|
||||||
: "text-ink";
|
: "text-signal";
|
||||||
return (
|
return (
|
||||||
<p className="flex items-baseline gap-2">
|
<div>
|
||||||
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-ink-faint)]">
|
<div className={`display text-xl ${color}`}>{value}</div>
|
||||||
{label}
|
<div className="eyebrow mt-0.5">{label}</div>
|
||||||
</span>
|
</div>
|
||||||
<span className={`display text-lg leading-none ${color}`}>{value}</span>
|
);
|
||||||
{hint ? (
|
}
|
||||||
<span className="font-mono text-[10px] text-[var(--color-ink-faint)]">
|
|
||||||
{hint}
|
function EmptyHint() {
|
||||||
</span>
|
return (
|
||||||
) : null}
|
<div className="py-6 text-center text-xs text-ink-faint">
|
||||||
</p>
|
Awaiting data…
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,118 +1,15 @@
|
|||||||
"use client";
|
"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 { SkeletonPanel } from "@/components/shared";
|
||||||
import {
|
import { TermGlossary } from "@/components/TermGlossary";
|
||||||
useSceneFocusSetter,
|
|
||||||
useSceneGraph,
|
|
||||||
useScenePublish,
|
|
||||||
} from "@/components/shell/scene-graph-context";
|
|
||||||
import { useGlossary } from "@/hooks";
|
import { useGlossary } from "@/hooks";
|
||||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
|
||||||
import type { GlossaryRow } from "@/lib/types";
|
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({
|
export function GlossaryView({
|
||||||
initialTerms,
|
initialTerms,
|
||||||
}: {
|
}: {
|
||||||
initialTerms?: GlossaryRow[];
|
initialTerms?: GlossaryRow[];
|
||||||
}) {
|
}) {
|
||||||
const { data: terms } = useGlossary(100, initialTerms);
|
const { data: terms } = useGlossary(100, initialTerms);
|
||||||
const publish = useScenePublish();
|
return terms ? <TermGlossary terms={terms} /> : <SkeletonPanel rows={6} />;
|
||||||
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 (
|
|
||||||
<div className="min-h-full">
|
|
||||||
<section
|
|
||||||
className="pointer-events-auto absolute left-5 top-16 w-64"
|
|
||||||
aria-label="Filter terms"
|
|
||||||
>
|
|
||||||
<p className="eyebrow mb-2">Glossary · {filtered.length} terms</p>
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => 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 ? (
|
|
||||||
<div className="mt-3">
|
|
||||||
<SkeletonPanel rows={3} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{selectedTerm ? (
|
|
||||||
<aside
|
|
||||||
className="pointer-events-auto absolute bottom-20 left-1/2 w-[min(34rem,92vw)] -translate-x-1/2 rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/80 p-4 backdrop-blur-xl md:left-auto md:right-5 md:translate-x-0"
|
|
||||||
aria-label={`Definition of ${selectedTerm.term}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute right-3 top-3 font-mono text-xs text-[var(--color-ink-faint)] hover:text-[var(--color-ink)]"
|
|
||||||
onClick={() => setFocus(null)}
|
|
||||||
>
|
|
||||||
esc
|
|
||||||
</button>
|
|
||||||
<p className="eyebrow">term kb</p>
|
|
||||||
<h3 className="display mt-1 text-xl leading-tight text-ink glow-signal">
|
|
||||||
{selectedTerm.term}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-2 max-h-40 overflow-y-auto text-sm text-pretty text-ink-soft">
|
|
||||||
{selectedTerm.definition}
|
|
||||||
</p>
|
|
||||||
</aside>
|
|
||||||
) : (
|
|
||||||
<p className="pointer-events-none absolute inset-x-0 bottom-24 hidden justify-center font-mono text-xs text-[var(--color-ink-faint)] md:flex">
|
|
||||||
klik sebuah satelit untuk membuka definisi
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { AmbientProvider } from "@/components/ambient/ambient-context";
|
import { AmbientProvider } from "@/components/ambient/ambient-context";
|
||||||
import { Chatbot } from "@/components/chatbot/chatbot";
|
import { Chatbot } from "@/components/chatbot/chatbot";
|
||||||
import { CommandPalette } from "@/components/command/command-palette";
|
import { CommandPalette } from "@/components/command/command-palette";
|
||||||
import { ConstellationFrame } from "@/components/shell";
|
import { AppFrame } from "@/components/shell";
|
||||||
import { WsProvider } from "@/lib/ws/context";
|
import { WsProvider } from "@/lib/ws/context";
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
@@ -10,7 +10,7 @@ export default function DashboardLayout({
|
|||||||
return (
|
return (
|
||||||
<AmbientProvider>
|
<AmbientProvider>
|
||||||
<WsProvider>
|
<WsProvider>
|
||||||
<ConstellationFrame>{children}</ConstellationFrame>
|
<AppFrame>{children}</AppFrame>
|
||||||
<Chatbot />
|
<Chatbot />
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
</WsProvider>
|
</WsProvider>
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
"use client";
|
"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 {
|
import {
|
||||||
ListMusic,
|
ListMusic,
|
||||||
Play,
|
Play,
|
||||||
@@ -14,14 +9,15 @@ import {
|
|||||||
Square,
|
Square,
|
||||||
Volume2,
|
Volume2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import { Button, Input, toast } from "@/components/primitives";
|
import { Button, GlassPanel, Input, toast } from "@/components/primitives";
|
||||||
import { ErrorState, SkeletonHero } from "@/components/shared";
|
|
||||||
import {
|
import {
|
||||||
useSceneFocusSetter,
|
ErrorState,
|
||||||
useScenePublish,
|
SectionHeader,
|
||||||
} from "@/components/shell/scene-graph-context";
|
SkeletonHero,
|
||||||
|
SkeletonPanel,
|
||||||
|
} from "@/components/shared";
|
||||||
import {
|
import {
|
||||||
useMediaLoop,
|
useMediaLoop,
|
||||||
useMediaQueue,
|
useMediaQueue,
|
||||||
@@ -30,37 +26,11 @@ import {
|
|||||||
useMediaStop,
|
useMediaStop,
|
||||||
useMediaWsSync,
|
useMediaWsSync,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
|
||||||
import { formatDuration } from "@/lib/format";
|
import { formatDuration } from "@/lib/format";
|
||||||
import type { MediaState } from "@/lib/types";
|
import type { MediaState } from "@/lib/types";
|
||||||
import { staggerDelay } from "@/lib/utils";
|
import { staggerDelay } from "@/lib/utils";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
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 }) {
|
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||||
const ws = useWebSocket();
|
const ws = useWebSocket();
|
||||||
const { data: media, isLoading, error } = useMediaState(initialStatus);
|
const { data: media, isLoading, error } = useMediaState(initialStatus);
|
||||||
@@ -70,8 +40,6 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
const loop = useMediaLoop();
|
const loop = useMediaLoop();
|
||||||
useMediaWsSync(ws);
|
useMediaWsSync(ws);
|
||||||
const ambient = useAmbient();
|
const ambient = useAmbient();
|
||||||
const publish = useScenePublish();
|
|
||||||
const setFocus = useSceneFocusSetter();
|
|
||||||
|
|
||||||
const [url, setUrl] = useState("");
|
const [url, setUrl] = useState("");
|
||||||
|
|
||||||
@@ -83,19 +51,6 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
0,
|
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";
|
const tone = playing ? "signal" : queueList.length ? "amber" : "signal";
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ambient.set(
|
ambient.set(
|
||||||
@@ -125,19 +80,23 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (error && !media) return <ErrorState error={error} />;
|
if (error && !media) return <ErrorState error={error} />;
|
||||||
if (!media && isLoading) return <SkeletonHero />;
|
if (!media && isLoading)
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<SkeletonHero />
|
||||||
|
<SkeletonPanel rows={4} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full">
|
<div className="space-y-5">
|
||||||
{/* Now playing — top-center whisper */}
|
<GlassPanel glow className="relative overflow-hidden">
|
||||||
<section
|
<div className="scan-line absolute inset-x-0 top-0" />
|
||||||
className="pointer-events-none absolute inset-x-0 top-16 hidden justify-center px-6 md:flex"
|
<div className="flex flex-col gap-5 sm:flex-row sm:items-center">
|
||||||
aria-label="Now playing"
|
|
||||||
>
|
|
||||||
<div className="pointer-events-auto flex max-w-[52vw] items-center gap-3 rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/60 px-4 py-2 backdrop-blur-md">
|
|
||||||
<div
|
<div
|
||||||
className={`flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full border border-[var(--color-hairline)] ${playing ? "animate-spin-disc" : ""}`}
|
className={`flex size-32 shrink-0 items-center justify-center rounded-full border border-hairline bg-gradient-to-br from-white/10 to-white/[0.02] ${playing ? "animate-spin-disc" : "animate-spin-disc paused"}`}
|
||||||
>
|
>
|
||||||
|
<div className="flex size-28 items-center justify-center overflow-hidden rounded-full bg-canvas/60">
|
||||||
{current?.thumbnailUrl ? (
|
{current?.thumbnailUrl ? (
|
||||||
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
|
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
|
||||||
<img
|
<img
|
||||||
@@ -147,55 +106,59 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ListMusic className="size-4 text-signal" />
|
<ListMusic className="size-10 text-signal" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
</div>
|
||||||
<p className="eyebrow flex items-center gap-2">
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="eyebrow mb-1 flex items-center gap-2">
|
||||||
{playing ? (
|
{playing ? (
|
||||||
<span className="flex h-3 items-end gap-[2px]" aria-hidden>
|
<>
|
||||||
|
<span aria-hidden className="flex h-3 items-end gap-[2px]">
|
||||||
{[0, 1, 2].map((i) => (
|
{[0, 1, 2].map((i) => (
|
||||||
<span
|
<span
|
||||||
key={`eq-${i}`}
|
key={`eq-${i}`}
|
||||||
className="w-[3px] animate-eq rounded-full bg-signal"
|
className="w-[3px] animate-eq rounded-full bg-signal"
|
||||||
style={{ animationDelay: `${i * 160}ms`, height: "100%" }}
|
style={{
|
||||||
|
animationDelay: `${i * 160}ms`,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
<span className="text-signal">Now playing</span>
|
||||||
{playing ? "Now playing" : current ? "Paused" : "Nothing queued"}
|
</>
|
||||||
</p>
|
) : current ? (
|
||||||
<p className="truncate text-sm text-[var(--color-ink)]">
|
"Paused"
|
||||||
|
) : (
|
||||||
|
"Nothing queued"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h2 className="display text-balance text-2xl text-ink">
|
||||||
{current?.title ?? "Nothing queued"}
|
{current?.title ?? "Nothing queued"}
|
||||||
{formatDuration(current?.durationMs)
|
</h2>
|
||||||
? ` · ${formatDuration(current?.durationMs)}`
|
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-faint">
|
||||||
: ""}
|
{current?.source && (
|
||||||
</p>
|
<span className="mono truncate">{current.source}</span>
|
||||||
|
)}
|
||||||
|
{current?.mode && (
|
||||||
|
<span className="pill capitalize">{current.mode}</span>
|
||||||
|
)}
|
||||||
|
{formatDuration(current?.durationMs) && (
|
||||||
|
<span className="mono">
|
||||||
|
{formatDuration(current?.durationMs)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Console — bottom-left */}
|
|
||||||
<section
|
|
||||||
className="pointer-events-auto absolute bottom-20 left-5 z-20 w-[min(26rem,92vw)] space-y-2.5 rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/75 p-3 backdrop-blur-xl lg:bottom-24"
|
|
||||||
aria-label="Player console"
|
|
||||||
>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
placeholder="Paste a YouTube / music URL…"
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && onPlay()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onPlay}
|
onClick={onPlay}
|
||||||
disabled={queue.isPending}
|
disabled={queue.isPending}
|
||||||
>
|
>
|
||||||
<Play className="size-4" /> Queue & play
|
<Play className="size-4" /> Queue & play
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -221,9 +184,22 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
>
|
>
|
||||||
<Repeat className="size-4" /> Loop
|
<Repeat className="size-4" /> Loop
|
||||||
</Button>
|
</Button>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
</div>
|
||||||
<Volume2 className="size-4 text-[var(--color-ink-faint)]" />
|
</div>
|
||||||
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-white/10">
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||||
|
<div className="relative min-w-0 flex-1">
|
||||||
|
<Input
|
||||||
|
placeholder="Paste a YouTube / music URL…"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && onPlay()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5">
|
||||||
|
<Volume2 className="size-4 text-ink-faint" />
|
||||||
|
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-white/10">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-signal/70"
|
className="h-full rounded-full bg-signal/70"
|
||||||
style={{
|
style={{
|
||||||
@@ -231,53 +207,47 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="w-9 text-right font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
<span className="mono w-9 text-right text-[0.65rem] text-ink-faint">
|
||||||
{Math.round((media?.musicVolume ?? 0) * 100)}%
|
{Math.round((media?.musicVolume ?? 0) * 100)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</GlassPanel>
|
||||||
|
|
||||||
{/* Up next — right dock */}
|
<GlassPanel>
|
||||||
<section
|
<SectionHeader
|
||||||
className="pointer-events-auto absolute bottom-20 right-5 top-28 hidden w-[min(24rem,92vw)] flex-col overflow-hidden rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/70 backdrop-blur-xl md:flex"
|
eyebrow="up next"
|
||||||
aria-label="Up next"
|
title="Queue"
|
||||||
>
|
action={
|
||||||
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] px-4 py-2.5">
|
<span className="mono text-xs text-ink-faint">
|
||||||
<span className="eyebrow">up next</span>
|
|
||||||
<span className="font-mono text-xs text-[var(--color-ink-faint)]">
|
|
||||||
{queueList.length} track{queueList.length === 1 ? "" : "s"}
|
{queueList.length} track{queueList.length === 1 ? "" : "s"}
|
||||||
{queueTotal ? ` · ${formatDuration(queueTotal)}` : ""}
|
{queueTotal && ` · ${formatDuration(queueTotal)}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
}
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
/>
|
||||||
{queueList.length === 0 ? (
|
{queueList.length === 0 ? (
|
||||||
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
||||||
<Radio className="size-6 text-[var(--color-ink-faint)]" />
|
<Radio className="size-6 text-ink-faint" />
|
||||||
<p className="text-sm text-[var(--color-ink-soft)]">
|
<div className="text-sm text-ink-soft">Queue is empty</div>
|
||||||
Queue is empty
|
<div className="text-xs text-ink-faint">
|
||||||
</p>
|
Paste a URL above to start playback.
|
||||||
<p className="font-mono text-xs text-[var(--color-ink-faint)]">
|
</div>
|
||||||
Paste a URL to start playback.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
{queueList.map((item, i) => {
|
{queueList.map((item, i) => {
|
||||||
const isNext = i === 0 && playing;
|
const isNext = i === 0 && playing;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${item.source}-${i}`}
|
key={`${item.source}-${i}`}
|
||||||
className={`animate-stagger flex items-center gap-3 rounded-xl border px-3 py-2 ${
|
className={`animate-stagger flex items-center gap-3 rounded-[10px] border px-3 py-2.5 ${
|
||||||
isNext
|
isNext
|
||||||
? "border-signal/40 bg-signal/[0.07]"
|
? "border-signal/40 bg-signal/[0.07]"
|
||||||
: "border-[var(--color-hairline)]"
|
: "border-hairline bg-white/5"
|
||||||
}`}
|
}`}
|
||||||
style={staggerDelay(i)}
|
style={staggerDelay(i)}
|
||||||
>
|
>
|
||||||
<span className="w-5 font-mono text-xs text-[var(--color-ink-faint)]">
|
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||||
{i + 1}
|
|
||||||
</span>
|
|
||||||
{item.thumbnailUrl ? (
|
{item.thumbnailUrl ? (
|
||||||
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
|
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
|
||||||
<img
|
<img
|
||||||
@@ -287,33 +257,37 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border border-[var(--color-hairline)]">
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border border-hairline bg-white/5">
|
||||||
<ListMusic className="size-4 text-[var(--color-ink-faint)]" />
|
<ListMusic className="size-4 text-ink-faint" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm text-[var(--color-ink)]">
|
<div className="truncate text-sm text-ink">
|
||||||
{item.title}
|
{item.title}
|
||||||
</p>
|
|
||||||
<p className="truncate font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
|
||||||
{item.source}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{isNext ? (
|
<div className="mono truncate text-[0.65rem] text-ink-faint">
|
||||||
<span className="shrink-0 rounded-full border border-signal/40 bg-signal/10 px-2 py-0.5 font-mono text-[0.6rem] text-signal">
|
{item.source}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isNext && (
|
||||||
|
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-signal/40 bg-signal/10 px-2 py-0.5 text-[0.6rem] font-medium text-signal">
|
||||||
up next
|
up next
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
)}
|
||||||
<span className="w-10 text-right font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
<span className="pill hidden capitalize sm:inline-flex">
|
||||||
|
{item.mode ?? "music"}
|
||||||
|
</span>
|
||||||
|
{formatDuration(item.durationMs) && (
|
||||||
|
<span className="mono w-10 text-right text-[0.65rem] text-ink-faint">
|
||||||
{formatDuration(item.durationMs)}
|
{formatDuration(item.durationMs)}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</GlassPanel>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
"use client";
|
"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 {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Calendar,
|
Calendar,
|
||||||
@@ -21,13 +15,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { ActivityHeatmap } from "@/components/ActivityHeatmap";
|
import { ActivityHeatmap } from "@/components/ActivityHeatmap";
|
||||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import { EditHistory } from "@/components/EditHistory";
|
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 {
|
import {
|
||||||
useSceneFocusSetter,
|
Avatar,
|
||||||
useScenePublish,
|
Badge,
|
||||||
} from "@/components/shell/scene-graph-context";
|
GlassPanel,
|
||||||
|
Input,
|
||||||
|
Skeleton,
|
||||||
|
} from "@/components/primitives";
|
||||||
|
import {
|
||||||
|
EmptyState,
|
||||||
|
ErrorState,
|
||||||
|
SectionHeader,
|
||||||
|
SkeletonRows,
|
||||||
|
} from "@/components/shared";
|
||||||
|
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
||||||
import {
|
import {
|
||||||
useLoadMore,
|
useLoadMore,
|
||||||
useMessageActivity,
|
useMessageActivity,
|
||||||
@@ -41,7 +42,6 @@ import {
|
|||||||
useSemanticSearch,
|
useSemanticSearch,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import { aiTone } from "@/lib/ai-status";
|
import { aiTone } from "@/lib/ai-status";
|
||||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
|
||||||
import {
|
import {
|
||||||
formatBytes,
|
formatBytes,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
@@ -59,24 +59,6 @@ import type {
|
|||||||
import { staggerDelay } from "@/lib/utils";
|
import { staggerDelay } from "@/lib/utils";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
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({
|
export function MessagesView({
|
||||||
initialGuilds,
|
initialGuilds,
|
||||||
initialGuildId,
|
initialGuildId,
|
||||||
@@ -98,11 +80,15 @@ export function MessagesView({
|
|||||||
const [channelId, setChannelId] = useState<string | null>(null);
|
const [channelId, setChannelId] = useState<string | null>(null);
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
const [query, setQuery] = useState("");
|
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);
|
const [semanticMode, setSemanticMode] = useState(false);
|
||||||
|
// feed | timeline: "timeline" groups messages into date-grouped cards.
|
||||||
const [viewMode, setViewMode] = useState<"feed" | "timeline">("feed");
|
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 MAX_OLDER_PAGES = 10;
|
||||||
const [loadedPages, setLoadedPages] = useState(0);
|
const [loadedPages, setLoadedPages] = useState(0);
|
||||||
const [showIntel, setShowIntel] = useState(false);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: messages,
|
data: messages,
|
||||||
@@ -113,7 +99,11 @@ export function MessagesView({
|
|||||||
channelId ?? undefined,
|
channelId ?? undefined,
|
||||||
initialMessages ?? 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);
|
useMessagesStream(ws, guildId ?? "", channelId ?? undefined);
|
||||||
|
// Cursor to the next (older) page + whether more history exists.
|
||||||
const { data: pageInfo } = useMessagesHasMore(
|
const { data: pageInfo } = useMessagesHasMore(
|
||||||
guildId ?? "",
|
guildId ?? "",
|
||||||
channelId ?? undefined,
|
channelId ?? undefined,
|
||||||
@@ -134,12 +124,10 @@ export function MessagesView({
|
|||||||
const edits = useRecentEdits(50, undefined, initialEdits);
|
const edits = useRecentEdits(50, undefined, initialEdits);
|
||||||
const detail = useMessageDetail(selected);
|
const detail = useMessageDetail(selected);
|
||||||
const ambient = useAmbient();
|
const ambient = useAmbient();
|
||||||
const publish = useScenePublish();
|
|
||||||
const setFocus = useSceneFocusSetter();
|
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(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 () => {
|
const loadOlder = useCallback(async () => {
|
||||||
if (!guildId || !hasMore || loadedPages >= MAX_OLDER_PAGES) return;
|
if (!guildId || !hasMore || loadedPages >= MAX_OLDER_PAGES) return;
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
@@ -164,32 +152,21 @@ export function MessagesView({
|
|||||||
const searching = query.trim().length >= 2 && !semanticMode;
|
const searching = query.trim().length >= 2 && !semanticMode;
|
||||||
const semanticSearching = query.trim().length >= 2 && semanticMode;
|
const semanticSearching = query.trim().length >= 2 && semanticMode;
|
||||||
const list = searching ? (search.data ?? []) : (messages ?? []);
|
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 display = useMemo(() => [...list].reverse(), [list]);
|
||||||
|
|
||||||
const graph = useMemo(
|
// Timeline mode: inject date-separator headers above the first message of
|
||||||
() => (searching ? { nodes: [], edges: [] } : beltGraph(list)),
|
// each day. Messages are sorted oldest→newest (display is reversed), so a
|
||||||
[list, searching],
|
// date change means a new group. Produces an array of either "date" or "msg"
|
||||||
);
|
// nodes so the render loop can switch easily.
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
publish({ graph, focus: selected ? `msg:${selected}` : null });
|
|
||||||
}, [graph, selected, publish]);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
publish({ graph: { nodes: [], edges: [] }, focus: null });
|
|
||||||
setFocus(null);
|
|
||||||
},
|
|
||||||
[publish, setFocus],
|
|
||||||
);
|
|
||||||
|
|
||||||
const timelineNodes = useMemo(() => {
|
const timelineNodes = useMemo(() => {
|
||||||
if (viewMode !== "timeline") return null;
|
if (viewMode !== "timeline") return null;
|
||||||
const out: Array<
|
const out: Array<
|
||||||
| { type: "date"; label: string; iso: string }
|
| { type: "date"; label: string; iso: string }
|
||||||
| { type: "msg"; m: (typeof display)[number] }
|
| { type: "msg"; m: (typeof display)[number] }
|
||||||
> = [];
|
> = [];
|
||||||
let prevDate = "";
|
let prev = "";
|
||||||
for (const m of display) {
|
for (const m of display) {
|
||||||
const d = new Date(m.created_at).toLocaleDateString(undefined, {
|
const d = new Date(m.created_at).toLocaleDateString(undefined, {
|
||||||
weekday: "short",
|
weekday: "short",
|
||||||
@@ -197,15 +174,24 @@ export function MessagesView({
|
|||||||
day: "numeric",
|
day: "numeric",
|
||||||
});
|
});
|
||||||
const iso = new Date(m.created_at).toISOString().slice(0, 10);
|
const iso = new Date(m.created_at).toISOString().slice(0, 10);
|
||||||
if (d !== prevDate) {
|
if (d !== prev) {
|
||||||
out.push({ type: "date", label: d, iso });
|
out.push({ type: "date", label: d, iso });
|
||||||
prevDate = d;
|
prev = d;
|
||||||
}
|
}
|
||||||
out.push({ type: "msg", m });
|
out.push({ type: "msg", m });
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}, [display, viewMode]);
|
}, [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<HTMLDivElement>(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);
|
const firstLoadRef = useRef(true);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (firstLoadRef.current && display.length > 0) {
|
if (firstLoadRef.current && display.length > 0) {
|
||||||
@@ -215,6 +201,8 @@ export function MessagesView({
|
|||||||
}
|
}
|
||||||
}, [display.length]);
|
}, [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);
|
const prevLen = useRef(list.length);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searching) return;
|
if (searching) return;
|
||||||
@@ -227,12 +215,8 @@ export function MessagesView({
|
|||||||
}, [list.length, searching]);
|
}, [list.length, searching]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full">
|
<div className="space-y-4">
|
||||||
{/* Console whisper — top-left */}
|
<GlassPanel className="flex flex-wrap items-center gap-3">
|
||||||
<section
|
|
||||||
className="pointer-events-auto absolute left-5 top-16 z-20 w-[min(22rem,90vw)] space-y-2"
|
|
||||||
aria-label="Stream controls"
|
|
||||||
>
|
|
||||||
<GuildChannelPicker
|
<GuildChannelPicker
|
||||||
mode="text"
|
mode="text"
|
||||||
guildsInitial={initialGuilds}
|
guildsInitial={initialGuilds}
|
||||||
@@ -246,24 +230,22 @@ export function MessagesView({
|
|||||||
firstLoadRef.current = true;
|
firstLoadRef.current = true;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="relative ml-auto w-full sm:w-64">
|
||||||
<div className="relative flex-1">
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-ink-faint" />
|
||||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-faint)]" />
|
<Input
|
||||||
<input
|
className="pl-9"
|
||||||
type="search"
|
placeholder="Search messages…"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => 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)]"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSemanticMode((v) => !v)}
|
onClick={() => setSemanticMode((v) => !v)}
|
||||||
className={`rounded-full border px-3 py-1.5 font-mono text-xs transition-colors ${
|
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${
|
||||||
semanticMode
|
semanticMode
|
||||||
? "border-signal/40 bg-signal/10 text-signal"
|
? "border-signal/40 bg-signal/10 text-signal"
|
||||||
: "border-[var(--color-hairline)] text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
: "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]"
|
||||||
}`}
|
}`}
|
||||||
title="Toggle semantic (vector) search over the message archive"
|
title="Toggle semantic (vector) search over the message archive"
|
||||||
>
|
>
|
||||||
@@ -274,51 +256,91 @@ export function MessagesView({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
setViewMode((v) => (v === "feed" ? "timeline" : "feed"))
|
setViewMode((v) => (v === "feed" ? "timeline" : "feed"))
|
||||||
}
|
}
|
||||||
className={`rounded-full border px-3 py-1.5 font-mono text-xs transition-colors ${
|
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${
|
||||||
viewMode === "timeline"
|
viewMode === "timeline"
|
||||||
? "border-signal/40 bg-signal/10 text-signal"
|
? "border-signal/40 bg-signal/10 text-signal"
|
||||||
: "border-[var(--color-hairline)] text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
: "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]"
|
||||||
}`}
|
}`}
|
||||||
title="Toggle timeline (date-grouped) view"
|
title="Toggle timeline (date-grouped) view"
|
||||||
>
|
>
|
||||||
{viewMode === "timeline" ? "Timeline" : "Feed"}
|
{viewMode === "timeline" ? "Timeline" : "Feed"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</GlassPanel>
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Stream dossier — right column */}
|
<div className="grid gap-4 lg:grid-cols-5">
|
||||||
<section
|
{semanticSearching && (
|
||||||
className="pointer-events-auto absolute bottom-20 right-5 top-28 hidden w-[min(30rem,92vw)] flex-col overflow-hidden rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/70 backdrop-blur-xl md:flex"
|
<GlassPanel className="lg:col-span-5">
|
||||||
aria-label="Message stream"
|
<SectionHeader
|
||||||
>
|
eyebrow="semantic"
|
||||||
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] px-4 py-2.5">
|
title={`“${query}”`}
|
||||||
<span className="eyebrow">
|
action={
|
||||||
{searching ? `“${query}”` : "live stream"}
|
<span className="mono text-xs text-ink-faint">
|
||||||
|
{semantic.data?.length ?? 0} matches
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-xs text-[var(--color-ink-faint)]">
|
}
|
||||||
|
/>
|
||||||
|
{semantic.isLoading ? (
|
||||||
|
<SkeletonRows rows={4} />
|
||||||
|
) : semantic.data && semantic.data.length > 0 ? (
|
||||||
|
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
|
||||||
|
{semantic.data.map((r, i) => (
|
||||||
|
<div
|
||||||
|
key={r.message_id ?? i}
|
||||||
|
className="animate-stagger flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3"
|
||||||
|
style={staggerDelay(i)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="mono text-[0.6rem] text-signal">
|
||||||
|
{(r.score * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
|
||||||
|
{formatRelativeTime(r.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 line-clamp-3 text-sm text-ink-soft">
|
||||||
|
{r.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Search className="size-7" />}
|
||||||
|
title="No semantic matches"
|
||||||
|
description="Try different wording — semantic search finds meaning, not exact text."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</GlassPanel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<GlassPanel className="lg:col-span-3">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow={searching ? "results" : "live feed"}
|
||||||
|
title={searching ? `“${query}”` : "Messages"}
|
||||||
|
action={
|
||||||
|
<span className="mono text-xs text-ink-faint">
|
||||||
{list.length} shown
|
{list.length} shown
|
||||||
</span>
|
</span>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
<div className="min-h-0 flex-1 overflow-hidden p-2">
|
{error && !messages ? (
|
||||||
{semanticSearching ? (
|
|
||||||
<SemanticResults semantic={semantic} />
|
|
||||||
) : error && !messages ? (
|
|
||||||
<ErrorState error={error} />
|
<ErrorState error={error} />
|
||||||
) : isLoading && !messages ? (
|
) : isLoading && !messages ? (
|
||||||
<SkeletonRows rows={8} />
|
<SkeletonRows rows={8} />
|
||||||
) : list.length === 0 ? (
|
) : list.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<MessageSquare className="size-6" />}
|
icon={<MessageSquare className="size-7" />}
|
||||||
title="No messages"
|
title="No messages"
|
||||||
description="Pick a guild to begin, or run a search."
|
description="Pick a guild to begin, or run a search."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full flex-col">
|
<div>
|
||||||
{!searching && (
|
{!searching && (
|
||||||
<div className="mb-1.5 flex items-center justify-center gap-2">
|
<div className="mb-2 flex items-center justify-center gap-2">
|
||||||
{loadMore.isPending ? (
|
{loadMore.isPending ? (
|
||||||
<span className="flex items-center gap-1.5 font-mono text-xs text-[var(--color-ink-soft)]">
|
<span className="flex items-center gap-1.5 text-xs text-ink-soft">
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
Loading older…
|
Loading older…
|
||||||
</span>
|
</span>
|
||||||
@@ -326,19 +348,19 @@ export function MessagesView({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={loadOlder}
|
onClick={loadOlder}
|
||||||
className="rounded-full border border-[var(--color-hairline)] px-3 py-1 font-mono text-xs text-[var(--color-ink-soft)] transition-colors hover:text-[var(--color-ink)]"
|
className="rounded-full border border-hairline bg-white/[0.03] px-3 py-1 text-xs text-ink-soft transition-colors hover:bg-white/[0.06]"
|
||||||
>
|
>
|
||||||
↑ Load older messages
|
↑ Load older messages
|
||||||
</button>
|
</button>
|
||||||
) : loadedPages >= MAX_OLDER_PAGES ? (
|
) : loadedPages >= MAX_OLDER_PAGES ? (
|
||||||
<span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
<span className="mono text-[0.65rem] text-ink-faint">
|
||||||
capped at {MAX_OLDER_PAGES} older pages · use search for
|
capped at {MAX_OLDER_PAGES} older pages · use search for
|
||||||
more
|
more
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
messages &&
|
messages &&
|
||||||
messages.length > 0 && (
|
messages.length > 0 && (
|
||||||
<span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
<span className="mono text-[0.65rem] text-ink-faint">
|
||||||
beginning of history
|
beginning of history
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@@ -347,9 +369,11 @@ export function MessagesView({
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
|
className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1"
|
||||||
onScroll={(e) => {
|
onScroll={(e) => {
|
||||||
const el = e.currentTarget;
|
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 =
|
nearBottomRef.current =
|
||||||
el.scrollHeight - el.scrollTop - el.clientHeight < 120;
|
el.scrollHeight - el.scrollTop - el.clientHeight < 120;
|
||||||
if (searching || !hasMore || loadMore.isPending) return;
|
if (searching || !hasMore || loadMore.isPending) return;
|
||||||
@@ -360,11 +384,11 @@ export function MessagesView({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{viewMode === "timeline" && timelineNodes
|
{viewMode === "timeline" && timelineNodes
|
||||||
? timelineNodes.map((node) =>
|
? timelineNodes.map((node, _i) =>
|
||||||
node.type === "date" ? (
|
node.type === "date" ? (
|
||||||
<div
|
<div
|
||||||
key={`date-${node.iso}`}
|
key={`date-${node.iso}`}
|
||||||
className="flex items-center gap-2 px-1 font-mono text-[0.65rem] text-[var(--color-ink-faint)]"
|
className="flex items-center gap-2 px-1 text-[0.65rem] text-ink-faint"
|
||||||
>
|
>
|
||||||
<Calendar className="size-3" />
|
<Calendar className="size-3" />
|
||||||
{node.label}
|
{node.label}
|
||||||
@@ -378,7 +402,7 @@ export function MessagesView({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: display.map((m) => (
|
: display.map((m, _i) => (
|
||||||
<MessageRow
|
<MessageRow
|
||||||
key={m.id}
|
key={m.id}
|
||||||
m={m}
|
m={m}
|
||||||
@@ -389,49 +413,19 @@ export function MessagesView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</GlassPanel>
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Mobile stream — full-width sheet under the console */}
|
<GlassPanel className="lg:col-span-2">
|
||||||
<section
|
<SectionHeader eyebrow="inspect" title="Detail" />
|
||||||
className="pointer-events-auto absolute inset-x-4 bottom-24 top-44 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/80 p-2 backdrop-blur-xl md:hidden"
|
{!selected ? (
|
||||||
aria-label="Message stream mobile"
|
<EmptyState
|
||||||
>
|
title="Select a message"
|
||||||
{error && !messages ? (
|
description="Click any message to inspect AI analysis, attachments and edit history."
|
||||||
<ErrorState error={error} />
|
|
||||||
) : isLoading && !messages ? (
|
|
||||||
<SkeletonRows rows={6} />
|
|
||||||
) : display.length === 0 ? (
|
|
||||||
<EmptyState title="No messages" description="Pick a guild first." />
|
|
||||||
) : (
|
|
||||||
display.map((m) => (
|
|
||||||
<MessageRow
|
|
||||||
key={m.id}
|
|
||||||
m={m}
|
|
||||||
selected={selected}
|
|
||||||
onSelect={setSelected}
|
|
||||||
/>
|
/>
|
||||||
))
|
) : detail.loading ? (
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Inspection dossier — bottom-center */}
|
|
||||||
{selected ? (
|
|
||||||
<aside
|
|
||||||
className="pointer-events-auto absolute bottom-20 left-1/2 z-30 max-h-[52vh] w-[min(34rem,92vw)] -translate-x-1/2 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/85 p-4 backdrop-blur-xl"
|
|
||||||
aria-label="Message detail"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute right-3 top-3 font-mono text-xs text-[var(--color-ink-faint)] hover:text-[var(--color-ink)]"
|
|
||||||
onClick={() => setSelected(null)}
|
|
||||||
>
|
|
||||||
esc
|
|
||||||
</button>
|
|
||||||
{detail.loading ? (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-16" />
|
<Skeleton className="h-20" />
|
||||||
<Skeleton className="h-10" />
|
<Skeleton className="h-12" />
|
||||||
</div>
|
</div>
|
||||||
) : detail.message ? (
|
) : detail.message ? (
|
||||||
<MessageDetail
|
<MessageDetail
|
||||||
@@ -441,71 +435,14 @@ export function MessagesView({
|
|||||||
) : (
|
) : (
|
||||||
<EmptyState title="Not found" />
|
<EmptyState title="Not found" />
|
||||||
)}
|
)}
|
||||||
</aside>
|
</GlassPanel>
|
||||||
) : null}
|
</div>
|
||||||
|
|
||||||
{/* Intel strip — bottom-left */}
|
{activity.data && activity.data.length > 0 && (
|
||||||
<div className="pointer-events-auto absolute bottom-20 left-5 hidden w-72 lg:block">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowIntel((v) => !v)}
|
|
||||||
className={`rounded-full border px-3 py-1 font-mono text-xs transition-colors ${
|
|
||||||
showIntel
|
|
||||||
? "border-signal/40 bg-signal/10 text-signal"
|
|
||||||
: "border-[var(--color-hairline)] text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
intel {showIntel ? "▲" : "▼"}
|
|
||||||
</button>
|
|
||||||
{showIntel ? (
|
|
||||||
<div className="mt-2 max-h-[38vh] space-y-3 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/75 p-3 backdrop-blur-xl">
|
|
||||||
{activity.data && activity.data.length > 0 ? (
|
|
||||||
<ActivityHeatmap buckets={activity.data} />
|
<ActivityHeatmap buckets={activity.data} />
|
||||||
) : null}
|
)}
|
||||||
{edits.data ? <EditHistory edits={edits.data} /> : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SemanticResults({
|
{edits.data && <EditHistory edits={edits.data} />}
|
||||||
semantic,
|
|
||||||
}: {
|
|
||||||
semantic: ReturnType<typeof useSemanticSearch>;
|
|
||||||
}) {
|
|
||||||
if (semantic.isLoading) return <SkeletonRows rows={4} />;
|
|
||||||
if (!semantic.data || semantic.data.length === 0) {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
icon={<Search className="size-6" />}
|
|
||||||
title="No semantic matches"
|
|
||||||
description="Try different wording — semantic search finds meaning, not exact text."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="h-full space-y-1.5 overflow-y-auto pr-1">
|
|
||||||
{semantic.data.map((r, i) => (
|
|
||||||
<div
|
|
||||||
key={r.message_id ?? i}
|
|
||||||
className="animate-stagger rounded-xl border border-[var(--color-hairline)] p-2.5"
|
|
||||||
style={staggerDelay(i)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-mono text-[0.6rem] text-signal">
|
|
||||||
{(r.score * 100).toFixed(0)}%
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
|
|
||||||
{formatRelativeTime(r.created_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-0.5 line-clamp-3 text-sm text-[var(--color-ink-soft)]">
|
|
||||||
{r.content}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -556,12 +493,10 @@ function MessageDetail({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar src={m.avatar_url} name={m.username} size={36} />
|
<Avatar src={m.avatar_url} name={m.username} size={40} />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-[var(--color-ink)]">
|
<div className="font-semibold text-ink">{m.username}</div>
|
||||||
{m.username}
|
<div className="mono text-[0.65rem] text-ink-faint">
|
||||||
</div>
|
|
||||||
<div className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
|
||||||
{getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)}
|
{getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -573,21 +508,21 @@ function MessageDetail({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-[var(--color-hairline)] p-3 text-[var(--color-ink-soft)]">
|
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">
|
||||||
{renderMessageContent(m.edited_content ?? m.content, m.metadata) ||
|
{renderMessageContent(m.edited_content ?? m.content, m.metadata) ||
|
||||||
"(no text)"}
|
"(no text)"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{m.ai_analysis ? (
|
{m.ai_analysis && (
|
||||||
<div>
|
<div>
|
||||||
<div className="eyebrow mb-1">AI analysis</div>
|
<div className="eyebrow mb-1">AI analysis</div>
|
||||||
<div className="rounded-xl border border-[var(--color-hairline)] p-3 text-[var(--color-ink-soft)]">
|
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">
|
||||||
{m.ai_analysis}
|
{m.ai_analysis}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
{flags.length > 0 || cats.length > 0 ? (
|
{(flags.length > 0 || cats.length > 0) && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{flags.map((f) => (
|
{flags.map((f) => (
|
||||||
<Badge key={f} tone="vermilion">
|
<Badge key={f} tone="vermilion">
|
||||||
@@ -600,9 +535,9 @@ function MessageDetail({
|
|||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
{attachments.length > 0 ? (
|
{attachments.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="eyebrow mb-1 flex items-center gap-1.5">
|
<div className="eyebrow mb-1 flex items-center gap-1.5">
|
||||||
<Paperclip className="size-3" /> Attachments ({attachments.length})
|
<Paperclip className="size-3" /> Attachments ({attachments.length})
|
||||||
@@ -614,22 +549,23 @@ function MessageDetail({
|
|||||||
href={a.discord_url ?? a.uploaded_url ?? "#"}
|
href={a.discord_url ?? a.uploaded_url ?? "#"}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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"
|
||||||
>
|
>
|
||||||
<ImageIcon className="size-3.5 text-signal" />
|
<ImageIcon className="size-3.5 text-signal" />
|
||||||
<span className="flex-1 truncate">{a.filename}</span>
|
<span className="flex-1 truncate">{a.filename}</span>
|
||||||
<span className="text-[var(--color-ink-faint)]">
|
<span className="mono text-ink-faint">
|
||||||
{formatBytes(a.size)}
|
{formatBytes(a.size)}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Single message card used by both the live feed and the date-grouped timeline. */
|
||||||
function MessageRow({
|
function MessageRow({
|
||||||
m,
|
m,
|
||||||
selected,
|
selected,
|
||||||
@@ -641,32 +577,31 @@ function MessageRow({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
key={m.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(m.id)}
|
onClick={() => onSelect(m.id)}
|
||||||
className={`animate-stagger flex w-full items-start gap-3 rounded-xl border p-2.5 text-left transition-colors ${
|
className={`animate-stagger flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${
|
||||||
selected === m.id
|
selected === m.id
|
||||||
? "border-signal/40 bg-signal/8"
|
? "border-signal/40 bg-signal/8"
|
||||||
: "border-[var(--color-hairline)] hover:bg-white/[0.04]"
|
: "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Avatar src={m.avatar_url} name={m.username} size={32} />
|
<Avatar src={m.avatar_url} name={m.username} size={34} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="truncate text-sm font-semibold text-[var(--color-ink)]">
|
<span className="truncate text-sm font-semibold text-ink">
|
||||||
{m.username}
|
{m.username}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
<span className="mono text-[0.65rem] text-ink-faint">
|
||||||
{getMessageChannelLabel(m)}
|
{getMessageChannelLabel(m)}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-auto font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
|
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
|
||||||
{formatRelativeTime(m.created_at)}
|
{formatRelativeTime(m.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 line-clamp-2 text-sm text-[var(--color-ink-soft)]">
|
<div className="mt-0.5 line-clamp-2 text-sm text-ink-soft">
|
||||||
{renderMessageContent(m.content, m.metadata) || (
|
{renderMessageContent(m.content, m.metadata) || (
|
||||||
<span className="italic text-[var(--color-ink-faint)]">
|
<span className="italic text-ink-faint">(empty / embed)</span>
|
||||||
(empty / embed)
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,34 +1,40 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* Moderation scene — flagged actions orbit a verdict hub on the stage
|
|
||||||
* (vermilion stars); the overlay carries the metric whisper (left),
|
|
||||||
* the live feed as a bottom ribbon, and an intel dossier (right) with
|
|
||||||
* trends / coverage / domains / heatmap / drilldown.
|
|
||||||
*/
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Ban,
|
Ban,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
Filter,
|
Filter,
|
||||||
MessageSquareWarning,
|
MessageSquareWarning,
|
||||||
MicOff,
|
MicOff,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Trash2,
|
Trash2,
|
||||||
UserX,
|
UserX,
|
||||||
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import { CategoryDrilldown } from "@/components/CategoryDrilldown";
|
import { CategoryDrilldown } from "@/components/CategoryDrilldown";
|
||||||
import { CoverageTiles } from "@/components/CoverageTiles";
|
import { CoverageTiles } from "@/components/CoverageTiles";
|
||||||
import { Donut } from "@/components/charts";
|
import { Donut } from "@/components/charts";
|
||||||
|
import { LiveModerationFeed } from "@/components/LiveModerationFeed";
|
||||||
import { ModerationHeatmap } from "@/components/ModerationHeatmap";
|
import { ModerationHeatmap } from "@/components/ModerationHeatmap";
|
||||||
import { Badge, Select, type SelectOption } from "@/components/primitives";
|
|
||||||
import { ScamDomains } from "@/components/ScamDomains";
|
|
||||||
import { ErrorState } from "@/components/shared";
|
|
||||||
import {
|
import {
|
||||||
useSceneFocusSetter,
|
Badge,
|
||||||
useScenePublish,
|
GlassPanel,
|
||||||
} from "@/components/shell/scene-graph-context";
|
Select,
|
||||||
|
type SelectOption,
|
||||||
|
} from "@/components/primitives";
|
||||||
|
import { ScamDomains } from "@/components/ScamDomains";
|
||||||
|
import {
|
||||||
|
ErrorState,
|
||||||
|
MetricTile,
|
||||||
|
SectionHeader,
|
||||||
|
SkeletonMetricRow,
|
||||||
|
SkeletonPanel,
|
||||||
|
SkeletonRows,
|
||||||
|
} from "@/components/shared";
|
||||||
import { TopChannels } from "@/components/TopChannels";
|
import { TopChannels } from "@/components/TopChannels";
|
||||||
import { TopicTrends } from "@/components/TopicTrends";
|
import { TopicTrends } from "@/components/TopicTrends";
|
||||||
import {
|
import {
|
||||||
@@ -43,7 +49,6 @@ import {
|
|||||||
useTopFlaggedDomains,
|
useTopFlaggedDomains,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import { aiTone } from "@/lib/ai-status";
|
import { aiTone } from "@/lib/ai-status";
|
||||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
|
||||||
import { downloadCsv } from "@/lib/csv";
|
import { downloadCsv } from "@/lib/csv";
|
||||||
import { formatNumber, formatRelativeTime } from "@/lib/format";
|
import { formatNumber, formatRelativeTime } from "@/lib/format";
|
||||||
import type {
|
import type {
|
||||||
@@ -51,6 +56,7 @@ import type {
|
|||||||
ModerationActionType,
|
ModerationActionType,
|
||||||
ModerationStats,
|
ModerationStats,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
import { staggerDelay } from "@/lib/utils";
|
||||||
|
|
||||||
const ACTION_ICON: Record<ModerationActionType, React.ReactNode> = {
|
const ACTION_ICON: Record<ModerationActionType, React.ReactNode> = {
|
||||||
delete_message: <Trash2 className="size-3.5" />,
|
delete_message: <Trash2 className="size-3.5" />,
|
||||||
@@ -68,35 +74,6 @@ const ACTION_LABEL: Record<ModerationActionType, string> = {
|
|||||||
ban_user: "Ban",
|
ban_user: "Ban",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Flagged actions → vermilion satellites around a verdict hub. */
|
|
||||||
function moderationGraph(
|
|
||||||
liveActions: ModerationAction[],
|
|
||||||
stats?: ModerationStats,
|
|
||||||
): ConstellationGraph {
|
|
||||||
const recent = liveActions.slice(0, 24);
|
|
||||||
const nodes = [
|
|
||||||
{ id: "verdict", label: "verdict hub", kind: "guild" as const, value: 1 },
|
|
||||||
...recent.map((a) => ({
|
|
||||||
id: `mod:${a.id}`,
|
|
||||||
label: a.username ?? (a.user_id ?? "unknown").slice(0, 8),
|
|
||||||
kind:
|
|
||||||
a.status === "failed"
|
|
||||||
? ("flagged" as const)
|
|
||||||
: a.status === "pending"
|
|
||||||
? ("message" as const)
|
|
||||||
: ("channel" as const),
|
|
||||||
value: a.status === "executed" ? 0.55 : 0.35,
|
|
||||||
href: undefined,
|
|
||||||
meta: { mod_status: a.status },
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
void stats;
|
|
||||||
return {
|
|
||||||
nodes,
|
|
||||||
edges: recent.map((a) => ({ source: "verdict", target: `mod:${a.id}` })),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModerationView({
|
export function ModerationView({
|
||||||
initialStats,
|
initialStats,
|
||||||
initialActions,
|
initialActions,
|
||||||
@@ -119,17 +96,13 @@ export function ModerationView({
|
|||||||
const { data: hourly } = useHourlyModeration(30);
|
const { data: hourly } = useHourlyModeration(30);
|
||||||
const { data: coverage } = useModerationCoverage(30);
|
const { data: coverage } = useModerationCoverage(30);
|
||||||
const [drilldown, setDrilldown] = useState<string | null>(null);
|
const [drilldown, setDrilldown] = useState<string | null>(null);
|
||||||
const [intelOpen, setIntelOpen] = useState(false);
|
|
||||||
const { data: categoryActions, isValidating: categoryLoading } =
|
const { data: categoryActions, isValidating: categoryLoading } =
|
||||||
useModerationByCategory(drilldown ? 30 : 0, drilldown);
|
useModerationByCategory(drilldown ? 30 : 0, drilldown);
|
||||||
|
|
||||||
const publish = useScenePublish();
|
|
||||||
const setFocus = useSceneFocusSetter();
|
|
||||||
|
|
||||||
const failedRate = stats ? stats.failed_rate * 100 : 0;
|
const failedRate = stats ? stats.failed_rate * 100 : 0;
|
||||||
|
|
||||||
const byAction = stats?.by_action ?? {};
|
const byAction = stats?.by_action ?? {};
|
||||||
const segments = Object.entries(byAction).map(([k]) => ({
|
const segments = Object.entries(byAction).map(([k, _v]) => ({
|
||||||
value: 1,
|
value: 1,
|
||||||
color:
|
color:
|
||||||
k === "ban_user" || k === "kick_user"
|
k === "ban_user" || k === "kick_user"
|
||||||
@@ -140,23 +113,6 @@ export function ModerationView({
|
|||||||
label: k,
|
label: k,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const graph = useMemo(
|
|
||||||
() => moderationGraph(liveActions, stats),
|
|
||||||
[liveActions, stats],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
publish({ graph, focus: null });
|
|
||||||
}, [graph, publish]);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
publish({ graph: { nodes: [], edges: [] }, focus: null });
|
|
||||||
setFocus(null);
|
|
||||||
},
|
|
||||||
[publish, setFocus],
|
|
||||||
);
|
|
||||||
|
|
||||||
const ambient = useAmbient();
|
const ambient = useAmbient();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ambient.set(
|
ambient.set(
|
||||||
@@ -169,9 +125,11 @@ export function ModerationView({
|
|||||||
if (error && !stats) return <ErrorState error={error} />;
|
if (error && !stats) return <ErrorState error={error} />;
|
||||||
if (!stats && isLoading)
|
if (!stats && isLoading)
|
||||||
return (
|
return (
|
||||||
<p className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 text-center font-mono text-sm text-[var(--color-ink-faint)]">
|
<div className="space-y-5">
|
||||||
menghubungkan verdict hub…
|
<SkeletonMetricRow cols={4} />
|
||||||
</p>
|
<SkeletonPanel rows={5} />
|
||||||
|
<SkeletonRows rows={6} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
if (!stats) return <ErrorState error={error ?? new Error("No data")} />;
|
if (!stats) return <ErrorState error={error ?? new Error("No data")} />;
|
||||||
|
|
||||||
@@ -190,84 +148,76 @@ export function ModerationView({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full">
|
<div className="space-y-5">
|
||||||
{/* Metric whisper — left */}
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<section
|
<MetricTile
|
||||||
className="pointer-events-auto absolute left-5 top-16 w-56"
|
label="Total actions"
|
||||||
aria-label="Moderation metrics"
|
|
||||||
>
|
|
||||||
<p className="eyebrow mb-2 flex items-center gap-1.5">
|
|
||||||
<ShieldAlert className="size-3.5 text-signal" /> Enforcement
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Whisper
|
|
||||||
label="total actions"
|
|
||||||
value={formatNumber(stats.total)}
|
value={formatNumber(stats.total)}
|
||||||
tone="ink"
|
tone="signal"
|
||||||
|
icon={<ShieldAlert className="size-3.5" />}
|
||||||
/>
|
/>
|
||||||
<Whisper
|
<MetricTile
|
||||||
label="executed"
|
label="Executed"
|
||||||
value={formatNumber(stats.executed)}
|
value={formatNumber(stats.executed)}
|
||||||
tone="signal"
|
tone="signal"
|
||||||
|
icon={<CheckCircle2 className="size-3.5" />}
|
||||||
/>
|
/>
|
||||||
<Whisper
|
<MetricTile
|
||||||
label="failed"
|
label="Failed"
|
||||||
value={formatNumber(stats.failed)}
|
value={formatNumber(stats.failed)}
|
||||||
tone={stats.failed > 0 ? "vermilion" : "ink"}
|
tone={stats.failed > 0 ? "vermilion" : "neutral"}
|
||||||
|
icon={<XCircle className="size-3.5" />}
|
||||||
/>
|
/>
|
||||||
<Whisper
|
<MetricTile
|
||||||
label="pending"
|
label="Pending"
|
||||||
value={formatNumber(stats.pending)}
|
value={formatNumber(stats?.pending)}
|
||||||
tone={stats.pending > 0 ? "amber" : "ink"}
|
tone={stats?.pending > 0 ? "amber" : "neutral"}
|
||||||
|
icon={<Clock className="size-3.5" />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4">
|
|
||||||
<Donut
|
|
||||||
segments={
|
|
||||||
segments.length
|
|
||||||
? segments
|
|
||||||
: [{ value: 1, color: "var(--color-ink-faint)", label: "none" }]
|
|
||||||
}
|
|
||||||
centerLabel={`${Math.round(failedRate)}%`}
|
|
||||||
centerSub="fail rate"
|
|
||||||
/>
|
|
||||||
<div className="mt-2 space-y-1 font-mono text-xs text-[var(--color-ink-soft)]">
|
|
||||||
{Object.entries(byAction).map(([k, v]) => {
|
|
||||||
const count = typeof v === "number" ? v : null;
|
|
||||||
return (
|
|
||||||
<p key={k} className="flex items-center gap-2">
|
|
||||||
<span>{ACTION_ICON[k as ModerationActionType]}</span>
|
|
||||||
<span className="flex-1">
|
|
||||||
{ACTION_LABEL[k as ModerationActionType] ?? k}
|
|
||||||
</span>
|
|
||||||
{count !== null ? <span>{count}</span> : null}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Intel dossier — right */}
|
<div className="grid gap-5 lg:grid-cols-5">
|
||||||
<div className="pointer-events-auto absolute right-5 top-16 hidden w-[min(24rem,90vw)] md:block">
|
<div className="lg:col-span-2">
|
||||||
<button
|
{trends ? (
|
||||||
type="button"
|
<TopicTrends trends={trends} />
|
||||||
onClick={() => setIntelOpen((v) => !v)}
|
) : (
|
||||||
className={`rounded-full border px-3 py-1 font-mono text-xs transition-colors ${
|
<SkeletonPanel rows={6} />
|
||||||
intelOpen
|
)}
|
||||||
? "border-signal/40 bg-signal/10 text-signal"
|
</div>
|
||||||
: "border-[var(--color-hairline)] bg-[var(--color-canvas)]/60 text-[var(--color-ink-soft)] backdrop-blur-md hover:text-[var(--color-ink)]"
|
|
||||||
}`}
|
<div className="lg:col-span-5">
|
||||||
>
|
<LiveModerationFeed actions={liveActions} />
|
||||||
intel {intelOpen ? "▲" : "▼"}
|
</div>
|
||||||
</button>
|
|
||||||
{intelOpen ? (
|
{coverage ? (
|
||||||
<div className="mt-2 max-h-[64vh] space-y-4 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/78 p-3 backdrop-blur-xl">
|
<CoverageTiles coverage={coverage} />
|
||||||
{trends ? <TopicTrends trends={trends} /> : null}
|
) : (
|
||||||
{coverage ? <CoverageTiles coverage={coverage} /> : null}
|
<SkeletonPanel rows={3} className="lg:col-span-5" />
|
||||||
{domains ? <ScamDomains domains={domains} /> : null}
|
)}
|
||||||
{hourly ? <ModerationHeatmap hours={hourly} /> : null}
|
|
||||||
{channels ? <TopChannels channels={channels} /> : null}
|
<div className="lg:col-span-2">
|
||||||
|
{domains ? (
|
||||||
|
<ScamDomains domains={domains} />
|
||||||
|
) : (
|
||||||
|
<SkeletonPanel rows={6} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
{hourly ? (
|
||||||
|
<ModerationHeatmap hours={hourly} />
|
||||||
|
) : (
|
||||||
|
<SkeletonPanel rows={6} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
{channels ? (
|
||||||
|
<TopChannels channels={channels} />
|
||||||
|
) : (
|
||||||
|
<SkeletonPanel rows={6} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-3">
|
||||||
<CategoryDrilldown
|
<CategoryDrilldown
|
||||||
trends={trends ?? { categories: [], severities: [], actions: [] }}
|
trends={trends ?? { categories: [], severities: [], actions: [] }}
|
||||||
selected={drilldown}
|
selected={drilldown}
|
||||||
@@ -276,33 +226,71 @@ export function ModerationView({
|
|||||||
onSelect={setDrilldown}
|
onSelect={setDrilldown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Live feed ribbon — bottom */}
|
<GlassPanel className="lg:col-span-2">
|
||||||
<section
|
<SectionHeader eyebrow="health" title="Breakdown" />
|
||||||
className="pointer-events-auto absolute inset-x-4 bottom-20 max-h-[46vh] overflow-hidden rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/75 backdrop-blur-xl lg:left-80 lg:right-[26rem]"
|
<div className="flex items-center gap-5">
|
||||||
aria-label="Live moderation feed"
|
<Donut
|
||||||
>
|
segments={
|
||||||
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] px-4 py-2">
|
segments.length
|
||||||
<span className="eyebrow flex items-center gap-2">
|
? segments
|
||||||
<Filter className="size-3 text-[var(--color-ink-faint)]" />
|
: [
|
||||||
action log
|
{
|
||||||
|
value: 1,
|
||||||
|
color: "var(--color-ink-faint)",
|
||||||
|
label: "none",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
centerLabel={`${Math.round(failedRate)}%`}
|
||||||
|
centerSub="fail rate"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 space-y-2 text-sm">
|
||||||
|
{Object.entries(byAction).map(([k, v]) => {
|
||||||
|
const count = typeof v === "number" ? v : null;
|
||||||
|
return (
|
||||||
|
<div key={k} className="flex items-center gap-2.5">
|
||||||
|
<span className="text-ink-soft">
|
||||||
|
{ACTION_ICON[k as ModerationActionType]}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="flex-1 text-ink-soft">
|
||||||
|
{ACTION_LABEL[k as ModerationActionType] ?? k}
|
||||||
|
</span>
|
||||||
|
{count !== null && (
|
||||||
|
<span className="mono text-ink">{count}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{Object.keys(byAction).length === 0 && (
|
||||||
|
<div className="text-xs text-ink-faint">
|
||||||
|
No actions recorded yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
|
||||||
|
<GlassPanel className="lg:col-span-3">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="filter"
|
||||||
|
title="Action log"
|
||||||
|
action={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="size-3.5 text-ink-faint" />
|
||||||
<Select
|
<Select
|
||||||
value={typeFilter}
|
value={typeFilter}
|
||||||
onChange={setTypeFilter}
|
onChange={setTypeFilter}
|
||||||
options={typeOpts}
|
options={typeOpts}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-32"
|
className="w-36"
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={setStatusFilter}
|
onChange={setStatusFilter}
|
||||||
options={statusOpts}
|
options={statusOpts}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-28"
|
className="w-32"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -323,68 +311,27 @@ export function ModerationView({
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="rounded-full border border-[var(--color-hairline)] px-3 py-1 font-mono text-xs text-[var(--color-ink-soft)] transition-colors hover:text-[var(--color-ink)]"
|
className="rounded-full border border-hairline bg-white/[0.03] px-3 py-1 text-xs text-ink-soft transition-colors hover:bg-white/[0.06]"
|
||||||
title="Download moderation actions as CSV"
|
title="Download moderation actions as CSV"
|
||||||
>
|
>
|
||||||
CSV
|
CSV
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="max-h-[40vh] overflow-y-auto p-2">
|
|
||||||
<ActionRows actions={actions ?? []} liveCount={liveActions.length} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
/>
|
||||||
function ActionRows({
|
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
|
||||||
actions,
|
{(actions ?? []).map((a, i) => (
|
||||||
liveCount,
|
|
||||||
}: {
|
|
||||||
actions: ModerationAction[];
|
|
||||||
liveCount: number;
|
|
||||||
}) {
|
|
||||||
if (actions.length === 0 && liveCount === 0) {
|
|
||||||
return (
|
|
||||||
<div className="py-6 text-center font-mono text-xs text-[var(--color-ink-faint)]">
|
|
||||||
No matching actions.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{actions.map((a, i) => (
|
|
||||||
<ActionRow key={a.id} a={a} index={i} />
|
<ActionRow key={a.id} a={a} index={i} />
|
||||||
))}
|
))}
|
||||||
|
{(actions ?? []).length === 0 && (
|
||||||
|
<div className="py-10 text-center text-xs text-ink-faint">
|
||||||
|
No matching actions.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<p className="flex items-baseline gap-2">
|
|
||||||
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-ink-faint)]">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span className={`display text-lg leading-none ${color}`}>{value}</span>
|
|
||||||
</p>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,6 +345,8 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
|
|||||||
const icon = ACTION_ICON[a.action_type] ?? (
|
const icon = ACTION_ICON[a.action_type] ?? (
|
||||||
<AlertTriangle className="size-3.5" />
|
<AlertTriangle className="size-3.5" />
|
||||||
);
|
);
|
||||||
|
// Map moderation severity → design-system tone (reuse aiTone with a
|
||||||
|
// severity→status projection so "none" reads as clean/signal).
|
||||||
const severityTone =
|
const severityTone =
|
||||||
a.severity == null
|
a.severity == null
|
||||||
? null
|
? null
|
||||||
@@ -410,8 +359,8 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="animate-stagger flex items-start gap-3 rounded-xl border border-[var(--color-hairline)] p-3"
|
className="animate-stagger flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3"
|
||||||
style={{ animationDelay: `${index * 30}ms` }}
|
style={staggerDelay(index)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`mt-0.5 ${tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal"}`}
|
className={`mt-0.5 ${tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal"}`}
|
||||||
@@ -420,29 +369,27 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
|
|||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-[var(--color-ink)]">
|
<span className="text-sm font-semibold text-ink">
|
||||||
{a.username ?? "unknown"}
|
{a.username ?? "unknown"}
|
||||||
</span>
|
</span>
|
||||||
<Badge tone={tone}>{a.status}</Badge>
|
<Badge tone={tone}>{a.status}</Badge>
|
||||||
<span className="ml-auto font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
|
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
|
||||||
{formatRelativeTime(a.created_at)}
|
{formatRelativeTime(a.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{a.reason ? (
|
{a.reason && (
|
||||||
<div className="mt-0.5 text-xs text-[var(--color-ink-soft)]">
|
<div className="mt-0.5 text-xs text-ink-soft">“{a.reason}”</div>
|
||||||
“{a.reason}”
|
)}
|
||||||
</div>
|
{severityTone && a.severity && (
|
||||||
) : null}
|
|
||||||
{severityTone && a.severity ? (
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||||
<Badge tone={severityTone}>{a.severity}</Badge>
|
<Badge tone={severityTone}>{a.severity}</Badge>
|
||||||
{a.confidence != null ? (
|
{a.confidence != null && (
|
||||||
<span className="font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
|
<span className="mono text-[0.6rem] text-ink-faint">
|
||||||
conf {(a.confidence * 100).toFixed(0)}%
|
conf {(a.confidence * 100).toFixed(0)}%
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
{a.flags?.length ? (
|
{a.flags?.length ? (
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{a.flags.slice(0, 6).map((f) => (
|
{a.flags.slice(0, 6).map((f) => (
|
||||||
@@ -453,24 +400,24 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{a.evidence?.length ? (
|
{a.evidence?.length ? (
|
||||||
<div className="mt-1 border-l-2 border-[var(--color-hairline)] pl-2 text-xs text-[var(--color-ink-faint)]">
|
<div className="mt-1 border-l-2 border-hairline pl-2 text-xs text-ink-faint">
|
||||||
“{a.evidence[0]}”
|
“{a.evidence[0]}”
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{a.executed_by ? (
|
{a.executed_by && (
|
||||||
<div className="mt-0.5 font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
|
<div className="mono mt-0.5 text-[0.6rem] text-ink-faint">
|
||||||
by {a.executed_by}
|
by {a.executed_by}
|
||||||
{a.executed_at ? ` · ${formatRelativeTime(a.executed_at)}` : ""}
|
{a.executed_at ? ` · ${formatRelativeTime(a.executed_at)}` : ""}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
{a.content ? (
|
{a.content && (
|
||||||
<div className="mt-1 line-clamp-2 rounded-lg bg-white/[0.03] px-2 py-1 text-xs text-[var(--color-ink-faint)]">
|
<div className="mt-1 line-clamp-2 rounded-[8px] bg-white/[0.03] px-2 py-1 text-xs text-ink-faint">
|
||||||
{a.content}
|
{a.content}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
{a.error ? (
|
{a.error && (
|
||||||
<div className="mt-1 text-xs text-vermilion">{a.error}</div>
|
<div className="mt-1 text-xs text-vermilion">{a.error}</div>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,25 +1,18 @@
|
|||||||
"use client";
|
"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 { 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 { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
|
GlassCard,
|
||||||
|
GlassPanel,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
toast,
|
toast,
|
||||||
} from "@/components/primitives";
|
} from "@/components/primitives";
|
||||||
import { EmptyState, ErrorState } from "@/components/shared";
|
import { EmptyState, ErrorState, SectionHeader } from "@/components/shared";
|
||||||
import {
|
|
||||||
useSceneFocusSetter,
|
|
||||||
useScenePublish,
|
|
||||||
} from "@/components/shell/scene-graph-context";
|
|
||||||
import {
|
import {
|
||||||
NowPlayingChip,
|
NowPlayingChip,
|
||||||
RecordingAudioPlayer,
|
RecordingAudioPlayer,
|
||||||
@@ -29,29 +22,11 @@ import {
|
|||||||
useRecordings,
|
useRecordings,
|
||||||
useRecordingsWsSync,
|
useRecordingsWsSync,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
|
||||||
import { formatBytes, formatRelativeTime } from "@/lib/format";
|
import { formatBytes, formatRelativeTime } from "@/lib/format";
|
||||||
import type { VoiceRecording } from "@/lib/types";
|
import type { VoiceRecording } from "@/lib/types";
|
||||||
import { staggerDelay } from "@/lib/utils";
|
import { staggerDelay } from "@/lib/utils";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
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({
|
export function RecordingsView({
|
||||||
initialItems,
|
initialItems,
|
||||||
}: {
|
}: {
|
||||||
@@ -62,25 +37,8 @@ export function RecordingsView({
|
|||||||
const del = useDeleteRecording();
|
const del = useDeleteRecording();
|
||||||
useRecordingsWsSync(ws);
|
useRecordingsWsSync(ws);
|
||||||
const ambient = useAmbient();
|
const ambient = useAmbient();
|
||||||
const publish = useScenePublish();
|
|
||||||
const setFocus = useSceneFocusSetter();
|
|
||||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
const [playingId, setPlayingId] = useState<string | null>(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(() => {
|
useEffect(() => {
|
||||||
ambient.set("signal", 0.3, "recordings");
|
ambient.set("signal", 0.3, "recordings");
|
||||||
}, [ambient]);
|
}, [ambient]);
|
||||||
@@ -101,17 +59,14 @@ export function RecordingsView({
|
|||||||
if (error && !items) return <ErrorState error={error} />;
|
if (error && !items) return <ErrorState error={error} />;
|
||||||
if (!items && isLoading)
|
if (!items && isLoading)
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-auto absolute inset-x-4 bottom-24 top-44 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/70 p-4 backdrop-blur-xl md:inset-x-auto md:right-5 md:top-28 md:w-[min(30rem,92vw)]">
|
<GlassPanel>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<Skeleton className="h-4 w-28" />
|
<Skeleton className="h-4 w-28" />
|
||||||
<Skeleton className="h-3 w-12" />
|
<Skeleton className="h-3 w-12" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<div
|
<GlassCard key={i} className="flex flex-col gap-3">
|
||||||
key={i}
|
|
||||||
className="flex flex-col gap-3 rounded-xl border border-[var(--color-hairline)] p-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Skeleton className="size-9 rounded-full" />
|
<Skeleton className="size-9 rounded-full" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
@@ -120,67 +75,64 @@ export function RecordingsView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-9 w-full" />
|
<Skeleton className="h-9 w-full" />
|
||||||
</div>
|
</GlassCard>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</GlassPanel>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full">
|
<GlassPanel>
|
||||||
{/* Archive dock — right */}
|
<SectionHeader
|
||||||
<section
|
eyebrow="voice captures"
|
||||||
className="pointer-events-auto absolute bottom-20 right-5 top-16 hidden w-[min(30rem,92vw)] flex-col overflow-hidden rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/70 backdrop-blur-xl md:flex"
|
title="Recordings"
|
||||||
aria-label="Voice captures"
|
action={
|
||||||
>
|
<span className="mono text-xs text-ink-faint">
|
||||||
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] px-4 py-2.5">
|
{(items ?? []).length} clips
|
||||||
<span className="eyebrow">voice captures</span>
|
|
||||||
<span className="font-mono text-xs text-[var(--color-ink-faint)]">
|
|
||||||
{list.length} clips
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
}
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-2.5">
|
/>
|
||||||
{list.length === 0 ? (
|
{(items ?? []).length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<Headphones className="size-6" />}
|
icon={<Headphones className="size-7" />}
|
||||||
title="No recordings"
|
title="No recordings"
|
||||||
description="Voice clips captured by the bot appear here."
|
description="Voice clips captured by the bot appear here."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{list.map((r, i) => {
|
{(items ?? []).map((r, i) => {
|
||||||
const up = uploadStatus(r);
|
const up = uploadStatus(r);
|
||||||
const isPlaying = playingId === r.id;
|
const isPlaying = playingId === r.id;
|
||||||
return (
|
return (
|
||||||
<article
|
<GlassCard
|
||||||
key={r.id}
|
key={r.id}
|
||||||
className={`animate-stagger flex flex-col gap-2.5 rounded-xl border p-3 transition-colors ${
|
className={`animate-stagger flex flex-col gap-3 transition-colors hover:bg-white/[0.06] ${
|
||||||
isPlaying
|
isPlaying
|
||||||
? "border-signal/40 shadow-[0_0_36px_-16px_var(--color-signal-glow)]"
|
? "border-signal/40 shadow-[0_0_36px_-16px_var(--color-signal-glow)]"
|
||||||
: "border-[var(--color-hairline)] hover:bg-white/[0.04]"
|
: ""
|
||||||
}`}
|
}`}
|
||||||
style={staggerDelay(i)}
|
style={staggerDelay(i)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar src={r.avatar_url} name={r.username} size={34} />
|
<Avatar src={r.avatar_url} name={r.username} size={38} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-semibold text-[var(--color-ink)]">
|
<div className="truncate text-sm font-semibold text-ink">
|
||||||
{r.username}
|
{r.username}
|
||||||
</p>
|
</div>
|
||||||
<p className="flex items-center gap-1.5 font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
<div className="flex items-center gap-1.5 text-[0.65rem] text-ink-faint">
|
||||||
<Hash className="size-3" />
|
<Hash className="size-3" />
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{r.channel_name ?? "voice"}
|
{r.channel_name ?? "voice"}
|
||||||
</span>
|
</span>
|
||||||
<span>·</span>
|
<span className="text-ink-faint/60">·</span>
|
||||||
<span>{formatRelativeTime(r.created_at)}</span>
|
<span className="mono">
|
||||||
</p>
|
{formatRelativeTime(r.created_at)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isPlaying ? <NowPlayingChip /> : null}
|
</div>
|
||||||
{up && !isPlaying ? (
|
{isPlaying && <NowPlayingChip />}
|
||||||
<Badge tone={up.tone}>{up.label}</Badge>
|
{up && !isPlaying && <Badge tone={up.tone}>{up.label}</Badge>}
|
||||||
) : null}
|
<span className="mono text-[0.65rem] text-ink-faint">
|
||||||
<span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
|
||||||
{formatBytes(r.size_bytes)}
|
{formatBytes(r.size_bytes)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,32 +144,33 @@ export function RecordingsView({
|
|||||||
onPlayStateChange={(active) =>
|
onPlayStateChange={(active) =>
|
||||||
setPlayingId((prev) => {
|
setPlayingId((prev) => {
|
||||||
if (active) return r.id;
|
if (active) return r.id;
|
||||||
|
// Only clear if THIS card was the one playing.
|
||||||
return prev === r.id ? null : prev;
|
return prev === r.id ? null : prev;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="flex items-center gap-1.5 rounded-lg px-1 py-1 font-mono text-xs text-[var(--color-ink-faint)]">
|
<div className="flex items-center gap-1.5 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-faint">
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
{r.upload_status === "pending"
|
{r.upload_status === "pending"
|
||||||
? "Upload pending…"
|
? "Upload pending…"
|
||||||
: r.upload_error
|
: r.upload_error
|
||||||
? r.upload_error
|
? r.upload_error
|
||||||
: "Processing…"}
|
: "Processing…"}
|
||||||
</p>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{r.download_url ? (
|
{r.download_url && (
|
||||||
<a
|
<a
|
||||||
href={r.download_url}
|
href={r.download_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-hairline)] px-2.5 py-1 font-mono text-xs text-[var(--color-ink-soft)] hover:border-signal/40 hover:text-[var(--color-ink)]"
|
className="inline-flex items-center gap-1.5 rounded-[9px] border border-hairline px-2.5 py-1.5 text-xs text-ink-soft hover:text-ink hover:border-signal/40"
|
||||||
>
|
>
|
||||||
<Download className="size-3.5" /> Download
|
<Download className="size-3.5" /> Download
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -228,60 +181,12 @@ export function RecordingsView({
|
|||||||
<Trash2 className="size-3.5" /> Delete
|
<Trash2 className="size-3.5" /> Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</GlassCard>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</GlassPanel>
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Mobile dock */}
|
|
||||||
<section
|
|
||||||
className="pointer-events-auto absolute inset-x-4 bottom-24 top-44 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/80 p-2.5 backdrop-blur-xl md:hidden"
|
|
||||||
aria-label="Voice captures mobile"
|
|
||||||
>
|
|
||||||
{list.length === 0 ? (
|
|
||||||
<EmptyState title="No recordings" />
|
|
||||||
) : (
|
|
||||||
list.map((r) => (
|
|
||||||
<article
|
|
||||||
key={r.id}
|
|
||||||
className={`mb-2 flex flex-col gap-2 rounded-xl border p-3 ${
|
|
||||||
playingId === r.id
|
|
||||||
? "border-signal/40"
|
|
||||||
: "border-[var(--color-hairline)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<p className="truncate text-sm font-semibold text-[var(--color-ink)]">
|
|
||||||
{r.username} · {formatRelativeTime(r.created_at)}
|
|
||||||
</p>
|
|
||||||
{r.download_url ? (
|
|
||||||
<RecordingAudioPlayer
|
|
||||||
src={r.download_url}
|
|
||||||
label={`Voice recording by ${r.username}`}
|
|
||||||
onPlayStateChange={(active) =>
|
|
||||||
setPlayingId((prev) =>
|
|
||||||
active ? r.id : prev === r.id ? null : prev,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p className="font-mono text-xs text-[var(--color-ink-faint)]">
|
|
||||||
processing…
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Hint */}
|
|
||||||
<p className="pointer-events-none absolute bottom-24 left-5 hidden max-w-xs font-mono text-xs text-[var(--color-ink-faint)] lg:block">
|
|
||||||
setiap klip adalah simpel di cincin timeline — yang sedang play menyala
|
|
||||||
di langit
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
"use client";
|
"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 {
|
import {
|
||||||
Headphones,
|
Headphones,
|
||||||
Mic,
|
Mic,
|
||||||
@@ -14,16 +9,17 @@ import {
|
|||||||
Volume2,
|
Volume2,
|
||||||
Waves,
|
Waves,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import { Equalizer } from "@/components/charts";
|
import { Equalizer } from "@/components/charts";
|
||||||
import { Button, toast } from "@/components/primitives";
|
import { Button, GlassPanel, toast } from "@/components/primitives";
|
||||||
import { EmptyState, ErrorState } from "@/components/shared";
|
|
||||||
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
|
||||||
import {
|
import {
|
||||||
useSceneFocusSetter,
|
EmptyState,
|
||||||
useScenePublish,
|
ErrorState,
|
||||||
} from "@/components/shell/scene-graph-context";
|
SectionHeader,
|
||||||
|
SkeletonPanel,
|
||||||
|
} from "@/components/shared";
|
||||||
|
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
||||||
import { VoiceStage } from "@/components/voice/voice-stage";
|
import { VoiceStage } from "@/components/voice/voice-stage";
|
||||||
import {
|
import {
|
||||||
useMicTransmit,
|
useMicTransmit,
|
||||||
@@ -33,37 +29,9 @@ import {
|
|||||||
useVoiceListen,
|
useVoiceListen,
|
||||||
useVoiceStatus,
|
useVoiceStatus,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
|
||||||
import type { Guild, VoiceStatus } from "@/lib/types";
|
import type { Guild, VoiceStatus } from "@/lib/types";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
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({
|
export function VoiceView({
|
||||||
initialStatus,
|
initialStatus,
|
||||||
initialGuilds,
|
initialGuilds,
|
||||||
@@ -79,8 +47,6 @@ export function VoiceView({
|
|||||||
const listen = useVoiceListen(ws);
|
const listen = useVoiceListen(ws);
|
||||||
const { speakers, subscribe } = useSpeakers(initialStatus?.activeSpeakers);
|
const { speakers, subscribe } = useSpeakers(initialStatus?.activeSpeakers);
|
||||||
const ambient = useAmbient();
|
const ambient = useAmbient();
|
||||||
const publish = useScenePublish();
|
|
||||||
const setFocus = useSceneFocusSetter();
|
|
||||||
|
|
||||||
const [guildId, setGuildId] = useState<string | null>(
|
const [guildId, setGuildId] = useState<string | null>(
|
||||||
initialStatus?.activeGuildId ?? initialGuilds?.[0]?.id ?? null,
|
initialStatus?.activeGuildId ?? initialGuilds?.[0]?.id ?? null,
|
||||||
@@ -97,19 +63,6 @@ export function VoiceView({
|
|||||||
return unsub;
|
return unsub;
|
||||||
}, [subscribe, ws]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (status?.connected) ambient.set("signal", 0.55, "voice live");
|
if (status?.connected) ambient.set("signal", 0.55, "voice live");
|
||||||
else ambient.set("vermilion", 0.35, "voice idle");
|
else ambient.set("vermilion", 0.35, "voice idle");
|
||||||
@@ -118,9 +71,13 @@ export function VoiceView({
|
|||||||
if (error && !status) return <ErrorState error={error} />;
|
if (error && !status) return <ErrorState error={error} />;
|
||||||
if (!status && isLoading)
|
if (!status && isLoading)
|
||||||
return (
|
return (
|
||||||
<p className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 text-center font-mono text-sm text-[var(--color-ink-faint)]">
|
<div className="space-y-5">
|
||||||
menyetel ulang stage…
|
<SkeletonPanel rows={2} />
|
||||||
</p>
|
<div className="grid gap-5 lg:grid-cols-3">
|
||||||
|
<SkeletonPanel className="lg:col-span-2" rows={4} />
|
||||||
|
<SkeletonPanel rows={4} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const connected = status?.connected ?? false;
|
const connected = status?.connected ?? false;
|
||||||
@@ -161,64 +118,9 @@ export function VoiceView({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full">
|
<div className="space-y-5">
|
||||||
{/* Stage presence — top-center whisper */}
|
<GlassPanel>
|
||||||
<section
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
className="pointer-events-none absolute inset-x-0 top-16 hidden justify-center md:flex"
|
|
||||||
aria-label="Live speakers"
|
|
||||||
>
|
|
||||||
<div className="pointer-events-auto flex max-w-[60vw] flex-wrap justify-center gap-2">
|
|
||||||
{speakers.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={<MicOff className="size-6" />}
|
|
||||||
title={connected ? "Silent right now" : "Not connected"}
|
|
||||||
description={
|
|
||||||
connected
|
|
||||||
? "Speakers appear as they talk."
|
|
||||||
: "Connect to a voice channel to see presence."
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<VoiceStage speakers={speakers} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Connections — right */}
|
|
||||||
<section
|
|
||||||
className="pointer-events-auto absolute right-5 top-16 hidden w-64 md:block"
|
|
||||||
aria-label="Connections"
|
|
||||||
>
|
|
||||||
<p className="eyebrow mb-2">links · {speakers.length} present</p>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{(status?.connections ?? []).map((c) => (
|
|
||||||
<div
|
|
||||||
key={`${c.guildId}-${c.channelId}`}
|
|
||||||
className="flex items-center gap-2 rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/55 px-3 py-1.5 font-mono text-xs text-[var(--color-ink-soft)] backdrop-blur-md"
|
|
||||||
>
|
|
||||||
<span className="size-2 rounded-full bg-signal" />
|
|
||||||
<span className="flex-1 truncate">{c.channelName}</span>
|
|
||||||
<span className="text-[var(--color-ink-faint)]">
|
|
||||||
{new Date(c.connectedAt).toLocaleTimeString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{(status?.connections ?? []).length === 0 ? (
|
|
||||||
<p className="font-mono text-xs text-[var(--color-ink-faint)]">
|
|
||||||
no active links
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<p className="pt-1 font-mono text-xs text-[var(--color-ink-faint)]">
|
|
||||||
channel: {status?.activeChannelName ?? "—"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Console — bottom-left */}
|
|
||||||
<section
|
|
||||||
className="pointer-events-auto absolute bottom-20 left-5 z-20 w-[min(24rem,92vw)] space-y-2.5 rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/75 p-3 backdrop-blur-xl lg:bottom-24"
|
|
||||||
aria-label="Voice console"
|
|
||||||
>
|
|
||||||
<GuildChannelPicker
|
<GuildChannelPicker
|
||||||
mode="voice"
|
mode="voice"
|
||||||
guildsInitial={initialGuilds}
|
guildsInitial={initialGuilds}
|
||||||
@@ -249,6 +151,10 @@ export function VoiceView({
|
|||||||
<Radio className="size-4" /> Connect
|
<Radio className="size-4" /> Connect
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant={micOn ? "primary" : "outline"}
|
variant={micOn ? "primary" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -259,6 +165,16 @@ export function VoiceView({
|
|||||||
{micOn ? <Mic className="size-4" /> : <MicOff className="size-4" />}
|
{micOn ? <Mic className="size-4" /> : <MicOff className="size-4" />}
|
||||||
{micOn ? "Mic live" : "Push-to-talk"}
|
{micOn ? "Mic live" : "Push-to-talk"}
|
||||||
</Button>
|
</Button>
|
||||||
|
{micOn && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 rounded-[10px] border border-signal/30 bg-signal/[0.06] px-3 py-1.5"
|
||||||
|
role="status"
|
||||||
|
aria-label="Microphone level meter"
|
||||||
|
>
|
||||||
|
<Mic className="size-4 text-signal" />
|
||||||
|
<Equalizer bars={micBars} className="w-28" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant={listen.active ? "primary" : "outline"}
|
variant={listen.active ? "primary" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -271,27 +187,14 @@ export function VoiceView({
|
|||||||
)}
|
)}
|
||||||
{listen.active ? "Listening" : "Listen in"}
|
{listen.active ? "Listening" : "Listen in"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
{listen.active && (
|
||||||
|
<div className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-1.5">
|
||||||
{micOn ? (
|
<Waves className="size-4 text-signal" />
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 px-1"
|
|
||||||
role="status"
|
|
||||||
aria-label="Microphone level meter"
|
|
||||||
>
|
|
||||||
<Mic className="size-3.5 text-signal" />
|
|
||||||
<Equalizer bars={micBars} className="w-28" />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{listen.active ? (
|
|
||||||
<div className="flex items-center gap-2 px-1">
|
|
||||||
<Waves className="size-3.5 text-signal" />
|
|
||||||
<Equalizer bars={listenBars} className="w-40" />
|
<Equalizer bars={listenBars} className="w-40" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
<div className="flex items-center gap-4 pt-1">
|
<label className="flex items-center gap-2 text-xs text-ink-faint">
|
||||||
<label className="flex items-center gap-2 font-mono text-xs text-[var(--color-ink-faint)]">
|
|
||||||
<MicOff className="size-3.5" />
|
<MicOff className="size-3.5" />
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -304,11 +207,11 @@ export function VoiceView({
|
|||||||
mic.setVolume(v);
|
mic.setVolume(v);
|
||||||
}}
|
}}
|
||||||
aria-label="Mic transmit volume"
|
aria-label="Mic transmit volume"
|
||||||
className="h-1 w-20 cursor-pointer accent-[var(--color-signal)]"
|
className="h-1 w-24 cursor-pointer accent-[var(--color-signal)]"
|
||||||
/>
|
/>
|
||||||
<span className="w-8 text-right">{micVol}%</span>
|
<span className="mono w-8 text-right">{micVol}%</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 font-mono text-xs text-[var(--color-ink-faint)]">
|
<label className="flex items-center gap-2 text-xs text-ink-faint">
|
||||||
<Volume2 className="size-3.5" />
|
<Volume2 className="size-3.5" />
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -321,12 +224,88 @@ export function VoiceView({
|
|||||||
listen.setVolume(v);
|
listen.setVolume(v);
|
||||||
}}
|
}}
|
||||||
aria-label="Listen volume"
|
aria-label="Listen volume"
|
||||||
className="h-1 w-20 cursor-pointer accent-[var(--color-signal)]"
|
className="h-1 w-24 cursor-pointer accent-[var(--color-signal)]"
|
||||||
/>
|
/>
|
||||||
<span className="w-8 text-right">{listenVol}%</span>
|
<span className="mono w-8 text-right">{listenVol}%</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-3">
|
||||||
|
<GlassPanel className="lg:col-span-2">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="stage"
|
||||||
|
title="Live speakers"
|
||||||
|
action={
|
||||||
|
<span className="mono text-xs text-ink-faint">
|
||||||
|
{speakers.length} present
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{speakers.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<MicOff className="size-7" />}
|
||||||
|
title={connected ? "Silent right now" : "Not connected"}
|
||||||
|
description={
|
||||||
|
connected
|
||||||
|
? "Speakers appear as they talk."
|
||||||
|
: "Connect to a voice channel to see presence."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<VoiceStage speakers={speakers} />
|
||||||
|
<div className="mt-2 flex flex-wrap justify-center gap-2">
|
||||||
|
{speakers.map((sp) => (
|
||||||
|
<span
|
||||||
|
key={sp.userId}
|
||||||
|
className={`mono flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs ${
|
||||||
|
sp.speaking
|
||||||
|
? "border-signal/40 bg-signal/10 text-signal"
|
||||||
|
: "border-hairline bg-white/5 text-ink-soft"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`size-1.5 rounded-full ${sp.speaking ? "bg-signal animate-breathe" : "bg-ink-faint"}`}
|
||||||
|
/>
|
||||||
|
{sp.username}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</GlassPanel>
|
||||||
|
|
||||||
|
<GlassPanel>
|
||||||
|
<SectionHeader eyebrow="links" title="Connections" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(status?.connections ?? []).map((c) => (
|
||||||
|
<div
|
||||||
|
key={`${c.guildId}-${c.channelId}`}
|
||||||
|
className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="size-2 rounded-full bg-signal" />
|
||||||
|
<span className="flex-1 truncate text-ink-soft">
|
||||||
|
{c.channelName}
|
||||||
|
</span>
|
||||||
|
<span className="mono text-[0.6rem] text-ink-faint">
|
||||||
|
{new Date(c.connectedAt).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(status?.connections ?? []).length === 0 && (
|
||||||
|
<div className="py-6 text-center text-xs text-ink-faint">
|
||||||
|
No active links
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft">
|
||||||
|
<span className="mono text-ink-faint">channel</span>{" "}
|
||||||
|
{status?.activeChannelName ?? "—"}
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
/* ── Surfaces (dark-first; light overrides below) ── */
|
/* ── Surfaces (dark-first; light overrides below) ── */
|
||||||
--color-canvas: oklch(0.12 0.014 70);
|
--color-canvas: oklch(0.12 0.014 70);
|
||||||
--color-canvas-2: oklch(0.16 0.02 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);
|
--color-surface-2: oklch(0.26 0.024 70 / 0.45);
|
||||||
|
|
||||||
/* ── Ink ── */
|
/* ── Ink ── */
|
||||||
@@ -77,11 +77,6 @@
|
|||||||
background: var(--color-signal-glow);
|
background: var(--color-signal-glow);
|
||||||
color: var(--color-ink);
|
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 {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
|
|||||||
@@ -11,7 +11,5 @@ export function PageTransition({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return <div className={cn("animate-fade-up", className)}>{children}</div>;
|
||||||
<div className={cn("animate-fade-up h-full", className)}>{children}</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { MiniPlayer } from "@/components/media/mini-player";
|
||||||
|
import { MobileNav } from "./mobile-nav";
|
||||||
|
import { NavRail } from "./nav-rail";
|
||||||
|
import { TopBar } from "./topbar";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App chrome: slim nav rail + sticky top bar + scrollable content region.
|
||||||
|
* Sits above the fixed AmbientCanvas. Providers (Ambient + WS) are mounted in
|
||||||
|
* the route layout so every page shares one live link and signal context.
|
||||||
|
*
|
||||||
|
* < md the side rail collapses (hidden) and a bottom tab bar (MobileNav)
|
||||||
|
* takes over navigation; the content region gains bottom padding so the last
|
||||||
|
* panel never hides behind the dock. A persistent MiniPlayer floats at the
|
||||||
|
* bottom-right whenever a media track is loaded outside /media.
|
||||||
|
*/
|
||||||
|
export function AppFrame({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-dvh w-full overflow-hidden">
|
||||||
|
<NavRail />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<TopBar />
|
||||||
|
<main className="min-h-0 flex-1 overflow-y-auto px-4 pb-[calc(2rem+env(safe-area-inset-bottom))] pt-4 sm:px-5 sm:pb-[calc(2.5rem+env(safe-area-inset-bottom))]">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<MobileNav />
|
||||||
|
<MiniPlayer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ConstellationFrame — replaces the classic AppFrame chrome.
|
|
||||||
* The stage canvas sits fixed behind everything; page content is an
|
|
||||||
* overlay layer (no top bar / nav rail / scroll shell). Views publish
|
|
||||||
* their live graph via SceneGraphProvider; the frame renders whatever
|
|
||||||
* the active view published (fallback: route-scenes default builder).
|
|
||||||
* Chatbot + CommandPalette keep mounting at the layout level.
|
|
||||||
*/
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { type ReactNode, useMemo } from "react";
|
|
||||||
import { MiniPlayer } from "@/components/media/mini-player";
|
|
||||||
import { ConstellationStage } from "@/components/shell/constellation-stage";
|
|
||||||
import { FloatingChrome } from "@/components/shell/floating-chrome";
|
|
||||||
import {
|
|
||||||
buildDefaultGraph,
|
|
||||||
resolveScene,
|
|
||||||
type SceneSeed,
|
|
||||||
} from "@/components/shell/route-scenes";
|
|
||||||
import { SceneA11yMirror } from "@/components/shell/scene-a11y-mirror";
|
|
||||||
import {
|
|
||||||
SceneGraphProvider,
|
|
||||||
useSceneGraph,
|
|
||||||
} from "@/components/shell/scene-graph-context";
|
|
||||||
|
|
||||||
function StageFromContext({ seed }: { seed?: SceneSeed }) {
|
|
||||||
const pathname = usePathname() ?? "/";
|
|
||||||
const { state, setFocus } = useSceneGraph();
|
|
||||||
const onChannelsRoute = pathname.startsWith("/channels");
|
|
||||||
|
|
||||||
const graph = useMemo(() => {
|
|
||||||
if (state) return state.graph;
|
|
||||||
const scene = resolveScene(pathname);
|
|
||||||
return scene
|
|
||||||
? buildDefaultGraph(scene, seed ?? {})
|
|
||||||
: { nodes: [], edges: [] };
|
|
||||||
}, [state, pathname, seed]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ConstellationStage
|
|
||||||
graph={graph}
|
|
||||||
selectedId={state?.focus ?? null}
|
|
||||||
onNodeClick={(id) => {
|
|
||||||
// On /channels/ a click selects the star (opens its dossier).
|
|
||||||
if (onChannelsRoute && id.startsWith("channel:")) {
|
|
||||||
setFocus((prev) => (prev === id ? null : id));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const meta = state?.graph.nodes.find((n) => n.id === id);
|
|
||||||
if (meta?.href) window.location.assign(meta.href);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConstellationFrame({
|
|
||||||
children,
|
|
||||||
sceneSeed,
|
|
||||||
}: ConstellationFrameProps_) {
|
|
||||||
return (
|
|
||||||
<SceneGraphProvider>
|
|
||||||
<div className="relative h-dvh w-full overflow-hidden">
|
|
||||||
<StageFromContext seed={sceneSeed} />
|
|
||||||
<FloatingChrome />
|
|
||||||
<SceneA11yMirror />
|
|
||||||
<MiniPlayer />
|
|
||||||
{/* Overlay content region — scenes place floating panels inside. */}
|
|
||||||
<main className="pointer-events-none absolute inset-0 z-10 overflow-y-auto overscroll-contain">
|
|
||||||
{/* no pointer-events here: empty areas stay click-through to the sky */}
|
|
||||||
<div className="h-full">{children}</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</SceneGraphProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConstellationFrameProps_ {
|
|
||||||
children: ReactNode;
|
|
||||||
/** Typed SSR seed for the route-scenes fallback builder. */
|
|
||||||
sceneSeed?: SceneSeed;
|
|
||||||
}
|
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ConstellationStage — full-bleed interactive star-field renderer.
|
|
||||||
* Renders the route's graph as glowing nodes + hairline edges over a
|
|
||||||
* three.js orthographic view. Pan = drag, zoom = wheel/pinch, click on
|
|
||||||
* an href-bearing node navigates (plain location.assign — router.push
|
|
||||||
* can no-op under standalone+trailingSlash builds).
|
|
||||||
*/
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import * as THREE from "three";
|
|
||||||
import {
|
|
||||||
type CameraState,
|
|
||||||
easeInOutCubic,
|
|
||||||
fitCamera,
|
|
||||||
flyTo,
|
|
||||||
} from "@/lib/constellation/camera";
|
|
||||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
|
||||||
import { computeLayout, type LayoutNode } from "@/lib/constellation/layout";
|
|
||||||
import { readPalette, type StagePalette } from "@/lib/constellation/palette";
|
|
||||||
|
|
||||||
export interface ConstellationStageProps {
|
|
||||||
graph: ConstellationGraph;
|
|
||||||
seed?: number;
|
|
||||||
/** Node id kept highlighted (scene selection). */
|
|
||||||
selectedId?: string | null;
|
|
||||||
onNodeClick?: (nodeId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Z_MIN = 0.35;
|
|
||||||
const Z_MAX = 3;
|
|
||||||
|
|
||||||
export function ConstellationStage({
|
|
||||||
graph,
|
|
||||||
seed = 42,
|
|
||||||
selectedId = null,
|
|
||||||
onNodeClick,
|
|
||||||
}: ConstellationStageProps) {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
||||||
const labelsRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const camRef = useRef<CameraState>({ x: 0, y: 0, z: 1 });
|
|
||||||
const layoutRef = useRef<LayoutNode[]>([]);
|
|
||||||
const nodeByIdRef = useRef(new Map(graph.nodes.map((n) => [n.id, n])));
|
|
||||||
const [size, setSize] = useState({ w: 0, h: 0 });
|
|
||||||
const hoveredRef = useRef<string | null>(null);
|
|
||||||
const [reduced, setReduced] = useState(false);
|
|
||||||
|
|
||||||
nodeByIdRef.current = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
||||||
|
|
||||||
// Viewport size + reduced-motion preference.
|
|
||||||
useEffect(() => {
|
|
||||||
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
||||||
const sync = () => setReduced(mq.matches);
|
|
||||||
sync();
|
|
||||||
mq.addEventListener("change", sync);
|
|
||||||
const measure = () =>
|
|
||||||
setSize({ w: window.innerWidth, h: window.innerHeight });
|
|
||||||
measure();
|
|
||||||
window.addEventListener("resize", measure);
|
|
||||||
return () => {
|
|
||||||
mq.removeEventListener("change", sync);
|
|
||||||
window.removeEventListener("resize", measure);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Deterministic layout whenever graph or viewport changes.
|
|
||||||
// On scene change we FLY the camera to the new fit instead of snapping.
|
|
||||||
const prevSceneRef = useRef("");
|
|
||||||
const animRef = useRef<{
|
|
||||||
from: CameraState;
|
|
||||||
to: CameraState;
|
|
||||||
t0: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (size.w < 50 || size.h < 50) return;
|
|
||||||
const layout = computeLayout(graph.nodes, graph.edges, {
|
|
||||||
width: size.w,
|
|
||||||
height: size.h,
|
|
||||||
seed,
|
|
||||||
reduced,
|
|
||||||
});
|
|
||||||
layoutRef.current = layout;
|
|
||||||
const target = fitCamera(layout, size.w, size.h);
|
|
||||||
const first = graph.nodes[0]?.id ?? "";
|
|
||||||
const sceneKey = `${graph.nodes.length}:${first}`;
|
|
||||||
if (!reduced && prevSceneRef.current && prevSceneRef.current !== sceneKey) {
|
|
||||||
animRef.current = {
|
|
||||||
from: { ...camRef.current },
|
|
||||||
to: target,
|
|
||||||
t0: performance.now(),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
camRef.current = target;
|
|
||||||
}
|
|
||||||
prevSceneRef.current = sceneKey;
|
|
||||||
}, [graph, size.w, size.h, seed, reduced]);
|
|
||||||
|
|
||||||
// Build + run the renderer.
|
|
||||||
useEffect(() => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas || size.w < 50 || size.h < 50 || layoutRef.current.length === 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
let palette: StagePalette = readPalette();
|
|
||||||
const renderer = new THREE.WebGLRenderer({
|
|
||||||
canvas,
|
|
||||||
antialias: true,
|
|
||||||
alpha: true,
|
|
||||||
});
|
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
||||||
renderer.setSize(size.w, size.h, false);
|
|
||||||
|
|
||||||
const scene = new THREE.Scene();
|
|
||||||
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, -200, 200);
|
|
||||||
|
|
||||||
const group = new THREE.Group();
|
|
||||||
scene.add(group);
|
|
||||||
const disposables: { dispose: () => void }[] = [];
|
|
||||||
const track = <T extends { dispose: () => void }>(d: T): T => {
|
|
||||||
disposables.push(d);
|
|
||||||
return d;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Edges first (under nodes).
|
|
||||||
const byId = new Map(layoutRef.current.map((n) => [n.id, n]));
|
|
||||||
const edgeMat = track(
|
|
||||||
new THREE.LineBasicMaterial({
|
|
||||||
color: palette.inkFaint,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0.35,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
for (const e of graph.edges) {
|
|
||||||
const a = byId.get(e.source);
|
|
||||||
const b = byId.get(e.target);
|
|
||||||
if (!a || !b) continue;
|
|
||||||
const geo = track(
|
|
||||||
new THREE.BufferGeometry().setFromPoints([
|
|
||||||
new THREE.Vector3(a.x, a.y, 0),
|
|
||||||
new THREE.Vector3(b.x, b.y, 0),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
group.add(new THREE.Line(geo, edgeMat));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nodes: glow halo + core disc.
|
|
||||||
interface NodeVisual {
|
|
||||||
id: string;
|
|
||||||
halo: THREE.Mesh<THREE.CircleGeometry, THREE.MeshBasicMaterial>;
|
|
||||||
core: THREE.Mesh<THREE.CircleGeometry, THREE.MeshBasicMaterial>;
|
|
||||||
r: number;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
const visuals: NodeVisual[] = [];
|
|
||||||
for (const n of layoutRef.current) {
|
|
||||||
const meta = nodeByIdRef.current.get(n.id);
|
|
||||||
const kind = meta?.kind ?? "message";
|
|
||||||
const flagged =
|
|
||||||
Number(meta?.meta?.flagged_count ?? 0) > 0 && kind === "channel";
|
|
||||||
const color =
|
|
||||||
kind === "guild"
|
|
||||||
? palette.signal
|
|
||||||
: flagged
|
|
||||||
? palette.vermilion
|
|
||||||
: palette.ink;
|
|
||||||
const haloGeo = track(new THREE.CircleGeometry(n.r * 2.4, 32));
|
|
||||||
const haloMat = track(
|
|
||||||
new THREE.MeshBasicMaterial({
|
|
||||||
color,
|
|
||||||
transparent: true,
|
|
||||||
opacity: kind === "guild" ? 0.14 : 0.08,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const coreGeo = track(new THREE.CircleGeometry(n.r, 32));
|
|
||||||
const coreMat = track(
|
|
||||||
new THREE.MeshBasicMaterial({
|
|
||||||
color,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0.92,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const halo = new THREE.Mesh(haloGeo, haloMat);
|
|
||||||
halo.position.set(n.x, n.y, -1);
|
|
||||||
const core = new THREE.Mesh(coreGeo, coreMat);
|
|
||||||
core.position.set(n.x, n.y, 0);
|
|
||||||
group.add(halo, core);
|
|
||||||
visuals.push({ id: n.id, halo, core, r: n.r, x: n.x, y: n.y });
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyCamera = () => {
|
|
||||||
const { x, y, z } = camRef.current;
|
|
||||||
const hw = size.w / (2 * z);
|
|
||||||
const hh = size.h / (2 * z);
|
|
||||||
camera.left = -hw;
|
|
||||||
camera.right = hw;
|
|
||||||
camera.top = hh;
|
|
||||||
camera.bottom = -hh;
|
|
||||||
camera.position.set(x, y, 100);
|
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- interaction state ---
|
|
||||||
type PointerMode =
|
|
||||||
| { t: "idle" }
|
|
||||||
| { t: "drag"; sx: number; sy: number; cx: number; cy: number };
|
|
||||||
let mode: PointerMode = { t: "idle" };
|
|
||||||
let raf = 0;
|
|
||||||
let disposed = false;
|
|
||||||
|
|
||||||
const pointerToWorld = (clientX: number, clientY: number) => {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const { x, y, z } = camRef.current;
|
|
||||||
return {
|
|
||||||
wx: x + (clientX - rect.left - size.w / 2) / z,
|
|
||||||
wy: y - (clientY - rect.top - size.h / 2) / z,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const pickNode = (clientX: number, clientY: number): NodeVisual | null => {
|
|
||||||
const { wx, wy } = pointerToWorld(clientX, clientY);
|
|
||||||
let best: NodeVisual | null = null;
|
|
||||||
let bestD = Infinity;
|
|
||||||
for (const v of visuals) {
|
|
||||||
const d = Math.hypot(wx - v.x, wy - v.y);
|
|
||||||
if (d <= v.r + 6 && d < bestD) {
|
|
||||||
best = v;
|
|
||||||
bestD = d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPointerDown = (ev: PointerEvent) => {
|
|
||||||
mode = {
|
|
||||||
t: "drag",
|
|
||||||
sx: ev.clientX,
|
|
||||||
sy: ev.clientY,
|
|
||||||
cx: camRef.current.x,
|
|
||||||
cy: camRef.current.y,
|
|
||||||
};
|
|
||||||
canvas.style.cursor = "grabbing";
|
|
||||||
};
|
|
||||||
const onPointerMove = (ev: PointerEvent) => {
|
|
||||||
if (mode.t === "drag") {
|
|
||||||
const z = camRef.current.z;
|
|
||||||
camRef.current.x = mode.cx - (ev.clientX - mode.sx) / z;
|
|
||||||
camRef.current.y = mode.cy + (ev.clientY - mode.sy) / z;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const hit = pickNode(ev.clientX, ev.clientY);
|
|
||||||
hoveredRef.current = hit ? hit.id : null;
|
|
||||||
canvas.style.cursor = hit ? "pointer" : "grab";
|
|
||||||
};
|
|
||||||
const onPointerUp = (ev: PointerEvent) => {
|
|
||||||
const wasDrag =
|
|
||||||
mode.t === "drag" &&
|
|
||||||
Math.hypot(ev.clientX - mode.sx, ev.clientY - mode.sy) > 4;
|
|
||||||
mode = { t: "idle" };
|
|
||||||
canvas.style.cursor = "grab";
|
|
||||||
if (wasDrag) return;
|
|
||||||
const hit = pickNode(ev.clientX, ev.clientY);
|
|
||||||
if (!hit) return;
|
|
||||||
const meta = nodeByIdRef.current.get(hit.id);
|
|
||||||
onNodeClick?.(hit.id);
|
|
||||||
if (meta?.href) window.location.assign(meta.href);
|
|
||||||
};
|
|
||||||
const onWheel = (ev: WheelEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
const old = camRef.current.z;
|
|
||||||
const next = Math.max(
|
|
||||||
Z_MIN,
|
|
||||||
Math.min(Z_MAX, old * Math.exp(-ev.deltaY * 0.0012)),
|
|
||||||
);
|
|
||||||
camRef.current.z = next;
|
|
||||||
};
|
|
||||||
|
|
||||||
canvas.style.cursor = "grab";
|
|
||||||
canvas.addEventListener("pointerdown", onPointerDown);
|
|
||||||
window.addEventListener("pointermove", onPointerMove);
|
|
||||||
window.addEventListener("pointerup", onPointerUp);
|
|
||||||
canvas.addEventListener("wheel", onWheel, { passive: false });
|
|
||||||
|
|
||||||
// Labels (HTML, imperative transforms — cheap for ≤16 nodes).
|
|
||||||
const labelHost = labelsRef.current;
|
|
||||||
const labelEls = new Map<string, HTMLSpanElement>();
|
|
||||||
const labeled = [...layoutRef.current]
|
|
||||||
.sort((a, b) => b.r - a.r)
|
|
||||||
.slice(0, 14);
|
|
||||||
if (labelHost) {
|
|
||||||
for (const n of labeled) {
|
|
||||||
const el = document.createElement("span");
|
|
||||||
el.textContent = nodeByIdRef.current.get(n.id)?.label ?? n.id;
|
|
||||||
el.dataset.nodeLabel = n.id;
|
|
||||||
el.className =
|
|
||||||
"pointer-events-none absolute whitespace-nowrap font-mono text-[11px] tracking-wide text-[var(--color-ink-soft)] transition-colors";
|
|
||||||
labelHost.appendChild(el);
|
|
||||||
labelEls.set(n.id, el);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Theme sync — tokens flip with the .light class.
|
|
||||||
const themeObserver = new MutationObserver(() => {
|
|
||||||
palette = readPalette();
|
|
||||||
for (const v of visuals) {
|
|
||||||
const meta = nodeByIdRef.current.get(v.id);
|
|
||||||
const flagged =
|
|
||||||
Number(meta?.meta?.flagged_count ?? 0) > 0 &&
|
|
||||||
(meta?.kind ?? "") === "channel";
|
|
||||||
const c =
|
|
||||||
meta?.kind === "guild"
|
|
||||||
? palette.signal
|
|
||||||
: flagged
|
|
||||||
? palette.vermilion
|
|
||||||
: palette.ink;
|
|
||||||
v.core.material.color.setHex(c);
|
|
||||||
v.halo.material.color.setHex(c);
|
|
||||||
}
|
|
||||||
edgeMat.color.setHex(palette.inkFaint);
|
|
||||||
});
|
|
||||||
themeObserver.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ["class"],
|
|
||||||
});
|
|
||||||
|
|
||||||
let hidden = document.hidden;
|
|
||||||
const onVis = () => {
|
|
||||||
hidden = document.hidden;
|
|
||||||
};
|
|
||||||
document.addEventListener("visibilitychange", onVis);
|
|
||||||
|
|
||||||
const start = performance.now();
|
|
||||||
const frame = (now: number) => {
|
|
||||||
if (disposed) return;
|
|
||||||
raf = requestAnimationFrame(frame);
|
|
||||||
if (hidden) return;
|
|
||||||
|
|
||||||
// Camera fly-to (scene transitions): eased interpolation over ~900ms.
|
|
||||||
const anim = animRef.current;
|
|
||||||
if (anim) {
|
|
||||||
const raw = (now - anim.t0) / 900;
|
|
||||||
if (raw >= 1) {
|
|
||||||
camRef.current = anim.to;
|
|
||||||
animRef.current = null;
|
|
||||||
} else if (raw > 0) {
|
|
||||||
camRef.current = flyTo(anim.from, anim.to, raw, easeInOutCubic);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyCamera();
|
|
||||||
|
|
||||||
// Gentle breathing glow (visual only — hit positions stay baked).
|
|
||||||
const t = (now - start) / 1000;
|
|
||||||
let i = 0;
|
|
||||||
for (const v of visuals) {
|
|
||||||
if (!reduced) {
|
|
||||||
const pulse = 0.5 + 0.5 * Math.sin(t * 1.4 + i * 0.7);
|
|
||||||
v.halo.material.opacity =
|
|
||||||
(v.id === "guild" ? 0.14 : 0.08) * (0.7 + 0.6 * pulse);
|
|
||||||
}
|
|
||||||
const hov = v.id === hoveredRef.current || v.id === selectedId;
|
|
||||||
v.core.scale.setScalar(hov ? 1.25 : 1);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderer.render(scene, camera);
|
|
||||||
|
|
||||||
if (labelHost) {
|
|
||||||
const { x, y, z } = camRef.current;
|
|
||||||
for (const [id, el] of labelEls) {
|
|
||||||
const v = visuals.find((s) => s.id === id);
|
|
||||||
if (!v) continue;
|
|
||||||
const sx = (v.x - x) * z + size.w / 2;
|
|
||||||
const sy = -(v.y - y) * z + size.h / 2 + v.r + 14;
|
|
||||||
el.style.transform = `translate(${sx}px, ${sy}px) translateX(-50%)`;
|
|
||||||
el.style.color =
|
|
||||||
id === hoveredRef.current || id === selectedId
|
|
||||||
? "var(--color-signal)"
|
|
||||||
: "var(--color-ink-soft)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
raf = requestAnimationFrame(frame);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
cancelAnimationFrame(raf);
|
|
||||||
document.removeEventListener("visibilitychange", onVis);
|
|
||||||
canvas.removeEventListener("pointerdown", onPointerDown);
|
|
||||||
window.removeEventListener("pointermove", onPointerMove);
|
|
||||||
window.removeEventListener("pointerup", onPointerUp);
|
|
||||||
canvas.removeEventListener("wheel", onWheel);
|
|
||||||
themeObserver.disconnect();
|
|
||||||
for (const el of labelEls.values()) el.remove();
|
|
||||||
for (const d of disposables) d.dispose();
|
|
||||||
scene.clear();
|
|
||||||
renderer.dispose();
|
|
||||||
};
|
|
||||||
}, [graph, size.w, size.h, reduced, selectedId, onNodeClick]);
|
|
||||||
|
|
||||||
const emptyGraph = graph.nodes.length === 0;
|
|
||||||
const hint = useMemo(
|
|
||||||
() => (emptyGraph ? "Menunggu data scene…" : null),
|
|
||||||
[emptyGraph],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleReset = useCallback(() => {
|
|
||||||
if (layoutRef.current.length > 0 && size.w > 0) {
|
|
||||||
camRef.current = fitCamera(layoutRef.current, size.w, size.h);
|
|
||||||
}
|
|
||||||
}, [size.w, size.h]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
className="fixed inset-0 -z-10 h-full w-full touch-none select-none"
|
|
||||||
/>
|
|
||||||
<div ref={labelsRef} aria-hidden="true" className="fixed inset-0 -z-10" />
|
|
||||||
{hint ? (
|
|
||||||
<p className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 text-center font-mono text-sm text-[var(--color-ink-faint)]">
|
|
||||||
{hint}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
className="absolute bottom-24 right-5 rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/60 px-3 py-1 font-mono text-xs text-[var(--color-ink-soft)] backdrop-blur-sm hover:text-[var(--color-ink)] md:bottom-20"
|
|
||||||
onClick={handleReset}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
reset view
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FloatingChrome — the only persistent UI over the constellation.
|
|
||||||
* No top bar, no nav rail: brand + live link (top-left), theme toggle
|
|
||||||
* and palette hint (top-right), route switcher (bottom-center).
|
|
||||||
* All internal navigation uses plain <a href> — trailingSlash builds.
|
|
||||||
*/
|
|
||||||
import { Menu, Moon, Sun, X } from "lucide-react";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { navItems } from "@/lib/navigation";
|
|
||||||
import { ConnectionStatus } from "./status-dot";
|
|
||||||
|
|
||||||
function ThemeToggle() {
|
|
||||||
const { theme, setTheme } = useTheme();
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
useEffect(() => setMounted(true), []);
|
|
||||||
if (!mounted) {
|
|
||||||
return <span className="inline-block size-8" aria-hidden="true" />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Toggle theme"
|
|
||||||
className="inline-flex size-8 items-center justify-center rounded-full border border-[var(--color-hairline)] text-[var(--color-ink-soft)] backdrop-blur-sm transition-colors hover:border-[var(--color-signal)] hover:text-[var(--color-signal)]"
|
|
||||||
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
|
|
||||||
>
|
|
||||||
{theme === "light" ? (
|
|
||||||
<Moon className="size-4" aria-hidden="true" />
|
|
||||||
) : (
|
|
||||||
<Sun className="size-4" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FloatingChrome() {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Brand + live link */}
|
|
||||||
<div className="pointer-events-auto absolute left-5 top-5 z-20 flex items-center gap-3">
|
|
||||||
<span className="font-display text-lg font-semibold tracking-tight text-[var(--color-ink)]">
|
|
||||||
GMW
|
|
||||||
</span>
|
|
||||||
<span className="hidden font-mono text-[10px] uppercase tracking-[0.2em] text-[var(--color-ink-faint)] sm:inline">
|
|
||||||
constellation
|
|
||||||
</span>
|
|
||||||
<ConnectionStatus compact />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Theme + palette hint */}
|
|
||||||
<div className="pointer-events-auto absolute right-5 top-5 z-20 flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="hidden rounded-full border border-[var(--color-hairline)] px-3 py-1 font-mono text-xs text-[var(--color-ink-faint)] backdrop-blur-sm transition-colors hover:text-[var(--color-ink)] md:block"
|
|
||||||
onClick={() =>
|
|
||||||
window.dispatchEvent(new Event("command-palette:open"))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
⌘K
|
|
||||||
</button>
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Route switcher */}
|
|
||||||
<nav
|
|
||||||
aria-label="Routes"
|
|
||||||
className="pointer-events-none absolute inset-x-0 bottom-5 z-20 hidden justify-center gap-1 px-4 md:flex"
|
|
||||||
>
|
|
||||||
<div className="pointer-events-auto flex max-w-full items-center gap-1 overflow-x-auto rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/55 px-2 py-1.5 backdrop-blur-md">
|
|
||||||
{navItems.map((item) => {
|
|
||||||
const active =
|
|
||||||
pathname === item.matchPrefix ||
|
|
||||||
pathname.startsWith(item.matchPrefix);
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
data-active={active || undefined}
|
|
||||||
className={`rounded-full px-3 py-1 font-mono text-xs whitespace-nowrap transition-colors ${
|
|
||||||
active
|
|
||||||
? "bg-[var(--color-signal-glow)] text-[var(--color-ink)]"
|
|
||||||
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Mobile: sheet menu instead of a bottom tab bar */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={menuOpen ? "Close menu" : "Open menu"}
|
|
||||||
aria-expanded={menuOpen}
|
|
||||||
className="pointer-events-auto absolute bottom-5 right-5 z-30 inline-flex size-10 items-center justify-center rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/70 text-[var(--color-ink)] backdrop-blur-md md:hidden"
|
|
||||||
onClick={() => setMenuOpen((o) => !o)}
|
|
||||||
>
|
|
||||||
{menuOpen ? (
|
|
||||||
<X className="size-5" aria-hidden="true" />
|
|
||||||
) : (
|
|
||||||
<Menu className="size-5" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{menuOpen ? (
|
|
||||||
<nav
|
|
||||||
aria-label="Routes mobile"
|
|
||||||
className="pointer-events-auto absolute inset-x-4 bottom-20 z-20 flex flex-col gap-1 rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/90 p-2 backdrop-blur-xl md:hidden"
|
|
||||||
>
|
|
||||||
{navItems.map((item) => {
|
|
||||||
const active =
|
|
||||||
pathname === item.matchPrefix ||
|
|
||||||
pathname.startsWith(item.matchPrefix);
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className={`rounded-xl px-4 py-3 font-mono text-sm ${
|
|
||||||
active
|
|
||||||
? "bg-[var(--color-signal-glow)] text-[var(--color-ink)]"
|
|
||||||
: "text-[var(--color-ink-soft)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +1,5 @@
|
|||||||
export { ConstellationFrame } from "./constellation-frame";
|
export { AppFrame } from "./ambient-app";
|
||||||
|
export { MobileNav } from "./mobile-nav";
|
||||||
|
export { NavRail } from "./nav-rail";
|
||||||
export { ConnectionStatus } from "./status-dot";
|
export { ConnectionStatus } from "./status-dot";
|
||||||
|
export { TopBar } from "./topbar";
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { isActivePath, mobileNavItems } from "@/lib/navigation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile bottom tab bar. Shown only < md (the side NavRail is hidden there).
|
||||||
|
* Mirrors the desktop nav items but as a thumb-friendly dock with labels and a
|
||||||
|
* top active indicator. Safe-area aware for notched devices.
|
||||||
|
*/
|
||||||
|
export function MobileNav() {
|
||||||
|
const path = usePathname() ?? "/";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="glass fixed inset-x-0 bottom-0 z-40 flex items-stretch justify-around rounded-t-[20px] px-2 pb-[calc(0.4rem+env(safe-area-inset-bottom))] pt-2 md:hidden">
|
||||||
|
{mobileNavItems.map((item) => {
|
||||||
|
const active = isActivePath(path, item.matchPrefix);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
aria-label={item.label}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"relative flex flex-1 flex-col items-center justify-center rounded-[12px] rounded-t-[20px] px-2 pb-[calc(0.4rem+env(safe-area-inset-bottom))] pt-2 md:hidden",
|
||||||
|
active
|
||||||
|
? "bg-signal/15 text-signal"
|
||||||
|
: "text-ink-faint hover:bg-white/5 hover:text-ink-soft",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{active && (
|
||||||
|
<span className="absolute -top-2 h-8 w-8 rounded-full bg-signal shadow-[0_0_12px_var(--color-signal-glow)]" />
|
||||||
|
)}
|
||||||
|
<item.icon className="size-[20px]" strokeWidth={active ? 2.4 : 2} />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { isActivePath, navItems } from "@/lib/navigation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function NavItem({
|
||||||
|
href,
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
Icon,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
aria-label={label}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"group relative flex size-11 items-center justify-center rounded-[13px] transition-all",
|
||||||
|
active
|
||||||
|
? "bg-signal/15 text-signal"
|
||||||
|
: "text-ink-faint hover:bg-white/5 hover:text-ink-soft",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{active && (
|
||||||
|
<span className="absolute -left-3 h-6 w-1 rounded-full bg-signal shadow-[0_0_12px_var(--color-signal-glow)]" />
|
||||||
|
)}
|
||||||
|
<Icon className="size-[18px]" strokeWidth={active ? 2.4 : 2} />
|
||||||
|
{/* hover tooltip — labels are hidden in the rail, so surface on hover */}
|
||||||
|
<span className="pointer-events-none absolute left-full z-50 ml-3 hidden whitespace-nowrap rounded-[9px] border border-hairline bg-canvas-2 px-2.5 py-1.5 text-xs font-medium text-ink-soft opacity-0 shadow-lg transition-opacity group-hover:opacity-100 md:block">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavRail() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const path = pathname ?? "/";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="glass mb-[calc(0.75rem+env(safe-area-inset-bottom))] ml-[calc(0.75rem+env(safe-area-inset-left))] mt-[calc(0.75rem+env(safe-area-inset-top))] hidden w-[68px] flex-col items-center gap-1 rounded-[18px] py-4 md:flex">
|
||||||
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavItem
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
label={item.label}
|
||||||
|
active={isActivePath(path, item.matchPrefix)}
|
||||||
|
Icon={item.icon}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
/**
|
|
||||||
* Scene config: maps pathname → constellation scene definition.
|
|
||||||
* Pure data; the stage reads it to build graphs and place overlays.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
|
||||||
import {
|
|
||||||
channelsToGraph,
|
|
||||||
culturesToGraph,
|
|
||||||
statsToGraph,
|
|
||||||
} from "@/lib/constellation/graph";
|
|
||||||
|
|
||||||
export interface SceneDef {
|
|
||||||
route: string;
|
|
||||||
label: string;
|
|
||||||
/** Build the graph from typed SSR seed data. */
|
|
||||||
build: (seed: SceneSeed) => ConstellationGraph;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Everything a scene may need — all optional, scenes pick what they use. */
|
|
||||||
export interface SceneSeed {
|
|
||||||
stats?: import("@/lib/types").DashboardStats;
|
|
||||||
channels?: import("@/lib/types").DashboardChannel[];
|
|
||||||
cultures?: import("@/lib/types").ChannelCultureRow[];
|
|
||||||
guildLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SCENES: SceneDef[] = [
|
|
||||||
{
|
|
||||||
route: "/dashboard/",
|
|
||||||
label: "Deck",
|
|
||||||
build: (s) => (s.stats ? statsToGraph(s.stats) : { nodes: [], edges: [] }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
route: "/channels/",
|
|
||||||
label: "Channels",
|
|
||||||
build: (s) => {
|
|
||||||
if (s.cultures && s.cultures.length > 0)
|
|
||||||
return culturesToGraph(s.cultures);
|
|
||||||
if (s.channels) return channelsToGraph(s.channels);
|
|
||||||
return { nodes: [], edges: [] };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function resolveScene(pathname: string): SceneDef | undefined {
|
|
||||||
return SCENES.find((sc) => sc.route === pathname);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildDefaultGraph(
|
|
||||||
scene: SceneDef,
|
|
||||||
seed: SceneSeed,
|
|
||||||
): ConstellationGraph {
|
|
||||||
try {
|
|
||||||
return scene.build(seed);
|
|
||||||
} catch {
|
|
||||||
return { nodes: [], edges: [] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SceneA11yMirror — screen-reader/keyboard mirror of the live constellation.
|
|
||||||
* The canvas itself is decorative (aria-hidden); this list exposes every
|
|
||||||
* node as a real link/button so keyboard and AT users get the same graph.
|
|
||||||
*/
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { useSceneGraph } from "@/components/shell/scene-graph-context";
|
|
||||||
|
|
||||||
export function SceneA11yMirror() {
|
|
||||||
const pathname = usePathname() ?? "/";
|
|
||||||
const { state, setFocus } = useSceneGraph();
|
|
||||||
const nodes = state?.graph.nodes ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav
|
|
||||||
aria-label={`${pathname} constellation map`}
|
|
||||||
className="sr-only focus-within:not-sr-only focus-within:absolute focus-within:left-5 focus-within:top-16 focus-within:z-40 focus-within:max-w-sm focus-within:rounded-2xl focus-within:border focus-within:border-[var(--color-hairline)] focus-within:bg-[var(--color-canvas-2)]/95 focus-within:p-3 focus-within:backdrop-blur-xl"
|
|
||||||
>
|
|
||||||
<p className="mb-1 font-mono text-xs text-[var(--color-ink-faint)]">
|
|
||||||
constellation — {nodes.length} node
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{nodes.map((n) => (
|
|
||||||
<li key={n.id}>
|
|
||||||
{n.href ? (
|
|
||||||
<a
|
|
||||||
href={n.href}
|
|
||||||
className="rounded-lg px-2 py-1 font-mono text-sm text-[var(--color-ink)] outline-none hover:bg-white/5 focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]"
|
|
||||||
>
|
|
||||||
{n.label} ({n.kind})
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setFocus((prev) => (prev === n.id ? null : n.id))
|
|
||||||
}
|
|
||||||
className="w-full rounded-lg px-2 py-1 text-left font-mono text-sm text-[var(--color-ink)] outline-none hover:bg-white/5 focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]"
|
|
||||||
>
|
|
||||||
{n.label} ({n.kind})
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{nodes.length === 0 ? (
|
|
||||||
<li className="px-2 py-1 font-mono text-sm text-[var(--color-ink-faint)]">
|
|
||||||
tidak ada node aktif
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
type ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
/**
|
|
||||||
* SceneGraph bridge — views publish their live graph to the stage.
|
|
||||||
* The frame provides the setter; the stage consumes the graph.
|
|
||||||
* Keeps SSR seed pattern intact: page.tsx seeds view.tsx, view publishes.
|
|
||||||
*/
|
|
||||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
|
||||||
|
|
||||||
export interface SceneGraphState {
|
|
||||||
graph: ConstellationGraph;
|
|
||||||
/** Node id currently focused (drives fly-to / highlight). */
|
|
||||||
focus: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FocusUpdate = string | null | ((prev: string | null) => string | null);
|
|
||||||
|
|
||||||
interface SceneGraphContextValue {
|
|
||||||
state: SceneGraphState | null;
|
|
||||||
publish: (state: SceneGraphState) => void;
|
|
||||||
setFocus: (update: FocusUpdate) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SceneGraphContext = createContext<SceneGraphContextValue>({
|
|
||||||
state: null,
|
|
||||||
publish: () => {},
|
|
||||||
setFocus: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
export function SceneGraphProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [state, setState] = useState<SceneGraphState | null>(null);
|
|
||||||
const stateRef = useRef<SceneGraphState | null>(null);
|
|
||||||
|
|
||||||
const publish = useCallback((next: SceneGraphState) => {
|
|
||||||
const prev = stateRef.current;
|
|
||||||
const sameShape =
|
|
||||||
prev &&
|
|
||||||
prev.graph.nodes.length === next.graph.nodes.length &&
|
|
||||||
prev.graph.edges.length === next.graph.edges.length &&
|
|
||||||
prev.focus === next.focus;
|
|
||||||
if (!sameShape) {
|
|
||||||
stateRef.current = next;
|
|
||||||
setState(next);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Same shape — still update node values/labels in place.
|
|
||||||
let changed = false;
|
|
||||||
if (prev) {
|
|
||||||
for (let i = 0; i < next.graph.nodes.length; i++) {
|
|
||||||
const a = prev.graph.nodes[i];
|
|
||||||
const b = next.graph.nodes[i];
|
|
||||||
if (a?.id !== b?.id || a?.label !== b?.label || a?.value !== b?.value) {
|
|
||||||
changed = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (changed || !prev) {
|
|
||||||
stateRef.current = next;
|
|
||||||
setState(next);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setFocus = useCallback((update: FocusUpdate) => {
|
|
||||||
setState((prev) => {
|
|
||||||
if (!prev) return null;
|
|
||||||
const next = typeof update === "function" ? update(prev.focus) : update;
|
|
||||||
return next === prev.focus ? prev : { ...prev, focus: next };
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const value = useMemo(
|
|
||||||
() => ({ state, publish, setFocus }),
|
|
||||||
[state, publish, setFocus],
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<SceneGraphContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</SceneGraphContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useScenePublish(): (state: SceneGraphState) => void {
|
|
||||||
return useContext(SceneGraphContext).publish;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSceneFocusSetter(): (update: FocusUpdate) => void {
|
|
||||||
return useContext(SceneGraphContext).setFocus;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { FocusUpdate };
|
|
||||||
|
|
||||||
export function useSceneGraph(): SceneGraphContextValue {
|
|
||||||
return useContext(SceneGraphContext);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
|
import { navItems } from "@/lib/navigation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ConnectionStatus } from "./status-dot";
|
||||||
|
|
||||||
|
function useActiveLabel() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const item = [...navItems]
|
||||||
|
.sort((a, b) => b.matchPrefix.length - a.matchPrefix.length)
|
||||||
|
.find((i) => pathname.startsWith(i.matchPrefix));
|
||||||
|
return item?.label ?? "Console";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopBar() {
|
||||||
|
const label = useActiveLabel();
|
||||||
|
const { state } = useAmbient();
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
const signalTone =
|
||||||
|
state.tone === "vermilion"
|
||||||
|
? "text-vermilion"
|
||||||
|
: state.tone === "amber"
|
||||||
|
? "text-amber"
|
||||||
|
: "text-signal";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-40 flex items-center gap-3 px-4 py-3 pt-[calc(0.75rem+env(safe-area-inset-top))] sm:gap-4 sm:px-5 sm:py-3.5">
|
||||||
|
<div className="flex min-w-0 items-baseline gap-2 sm:gap-3">
|
||||||
|
<span className="eyebrow hidden sm:inline">GMW</span>
|
||||||
|
<h1 className="display truncate text-[1.25rem] text-ink sm:text-[1.5rem]">
|
||||||
|
{label}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2 sm:gap-3">
|
||||||
|
<span className={cn("pill hidden sm:flex", signalTone)}>
|
||||||
|
<span
|
||||||
|
className={cn("size-1.5 rounded-full bg-current animate-breathe")}
|
||||||
|
/>
|
||||||
|
{state.label ?? "nominal"}
|
||||||
|
</span>
|
||||||
|
<ConnectionStatus compact />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Open command palette"
|
||||||
|
onClick={() =>
|
||||||
|
window.dispatchEvent(new Event("command-palette:open"))
|
||||||
|
}
|
||||||
|
className="hidden items-center gap-1.5 rounded-[11px] border border-hairline bg-white/5 px-2.5 py-1.5 text-xs text-ink-soft transition-colors hover:text-ink hover:border-signal/40 sm:flex"
|
||||||
|
>
|
||||||
|
<span className="mono text-[0.65rem]">⌘K</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
|
||||||
|
className="flex size-9 items-center justify-center rounded-[11px] border border-hairline bg-white/5 text-ink-soft transition-colors hover:text-ink hover:border-signal/40"
|
||||||
|
>
|
||||||
|
{mounted && theme === "light" ? (
|
||||||
|
<Moon className="size-4" />
|
||||||
|
) : (
|
||||||
|
<Sun className="size-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { fitCamera, flyTo } from "./camera";
|
|
||||||
|
|
||||||
describe("flyTo", () => {
|
|
||||||
const from = { x: 0, y: 0, z: 1 };
|
|
||||||
const to = { x: 100, y: -50, z: 2 };
|
|
||||||
|
|
||||||
test("t=0 returns origin", () => {
|
|
||||||
expect(flyTo(from, to, 0)).toEqual({ x: 0, y: 0, z: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("t=1 arrives exactly", () => {
|
|
||||||
expect(flyTo(from, to, 1)).toEqual(to);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("monotonic progress", () => {
|
|
||||||
let prev = -Infinity;
|
|
||||||
for (let i = 0; i <= 20; i++) {
|
|
||||||
const s = flyTo(from, to, i / 20);
|
|
||||||
expect(s.x).toBeGreaterThan(prev);
|
|
||||||
prev = s.x;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("clamps out-of-range t", () => {
|
|
||||||
expect(flyTo(from, to, -3)).toEqual(from);
|
|
||||||
expect(flyTo(from, to, 7)).toEqual(to);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fitCamera", () => {
|
|
||||||
test("empty scene centers", () => {
|
|
||||||
const c = fitCamera([], 800, 600);
|
|
||||||
expect(c.x).toBe(400);
|
|
||||||
expect(c.y).toBe(300);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("zooms to fit nodes with padding", () => {
|
|
||||||
const nodes = [
|
|
||||||
{ x: 100, y: 100, r: 10 },
|
|
||||||
{ x: 500, y: 400, r: 10 },
|
|
||||||
];
|
|
||||||
const c = fitCamera(nodes, 800, 600);
|
|
||||||
expect(c.x).toBe(300);
|
|
||||||
expect(c.y).toBe(250);
|
|
||||||
expect(c.z).toBeGreaterThan(0);
|
|
||||||
expect(c.z).toBeLessThanOrEqual(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
/**
|
|
||||||
* Camera math for constellation fly-to — pure, easing-based interpolation.
|
|
||||||
*/
|
|
||||||
export interface CameraState {
|
|
||||||
/** Camera center in canvas space. */
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
/** Zoom factor (1 = fit). */
|
|
||||||
z: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Easing = (t: number) => number;
|
|
||||||
|
|
||||||
/** Smooth ease-in-out cubic. Monotonic on [0,1]. */
|
|
||||||
export const easeInOutCubic: Easing = (t) =>
|
|
||||||
t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
|
||||||
|
|
||||||
export function flyTo(
|
|
||||||
from: CameraState,
|
|
||||||
to: CameraState,
|
|
||||||
t: number,
|
|
||||||
ease: Easing = easeInOutCubic,
|
|
||||||
): CameraState {
|
|
||||||
const clamped = Math.max(0, Math.min(1, t));
|
|
||||||
const k = ease(clamped);
|
|
||||||
return {
|
|
||||||
x: from.x + (to.x - from.x) * k,
|
|
||||||
y: from.y + (to.y - from.y) * k,
|
|
||||||
z: from.z + (to.z - from.z) * k,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fit-to-view camera so a scene always starts framed. */
|
|
||||||
export function fitCamera(
|
|
||||||
nodes: { x: number; y: number; r?: number }[],
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
padding = 80,
|
|
||||||
): CameraState {
|
|
||||||
if (nodes.length === 0 || width <= 0 || height <= 0) {
|
|
||||||
return { x: width / 2, y: height / 2, z: 1 };
|
|
||||||
}
|
|
||||||
let minX = Infinity;
|
|
||||||
let minY = Infinity;
|
|
||||||
let maxX = -Infinity;
|
|
||||||
let maxY = -Infinity;
|
|
||||||
for (const n of nodes) {
|
|
||||||
const r = n.r ?? 8;
|
|
||||||
minX = Math.min(minX, n.x - r);
|
|
||||||
maxX = Math.max(maxX, n.x + r);
|
|
||||||
minY = Math.min(minY, n.y - r);
|
|
||||||
maxY = Math.max(maxY, n.y + r);
|
|
||||||
}
|
|
||||||
const w = Math.max(1, maxX - minX);
|
|
||||||
const h = Math.max(1, maxY - minY);
|
|
||||||
const z = Math.min(
|
|
||||||
3,
|
|
||||||
Math.max(
|
|
||||||
0.35,
|
|
||||||
Math.min((width - padding * 2) / w, (height - padding * 2) / h),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
x: (minX + maxX) / 2,
|
|
||||||
y: (minY + maxY) / 2,
|
|
||||||
z,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
/**
|
|
||||||
* Constellation graph model — pure data, no React/DOM.
|
|
||||||
* Builders convert existing API payloads into star-graph structures.
|
|
||||||
*/
|
|
||||||
import type {
|
|
||||||
ChannelCultureRow,
|
|
||||||
DashboardChannel,
|
|
||||||
DashboardStats,
|
|
||||||
} from "@/lib/types";
|
|
||||||
|
|
||||||
export type NodeKind =
|
|
||||||
| "guild"
|
|
||||||
| "channel"
|
|
||||||
| "message"
|
|
||||||
| "flagged"
|
|
||||||
| "speaker"
|
|
||||||
| "media"
|
|
||||||
| "term"
|
|
||||||
| "metric";
|
|
||||||
|
|
||||||
export interface GraphNode {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
kind: NodeKind;
|
|
||||||
/** Optional route this node navigates to (plain <a href>, trailingSlash). */
|
|
||||||
href?: string;
|
|
||||||
/** Relative magnitude used for radius/glow (0..1 normalized by caller). */
|
|
||||||
value?: number;
|
|
||||||
/** Extra scene-specific payload (e.g. flagged count, culture summary). */
|
|
||||||
meta?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GraphEdge {
|
|
||||||
source: string;
|
|
||||||
target: string;
|
|
||||||
/** Edge thickness/pulse driver. Defaults to 0.5. */
|
|
||||||
weight?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConstellationGraph {
|
|
||||||
nodes: GraphNode[];
|
|
||||||
edges: GraphEdge[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const clamp01 = (n: number, max: number): number =>
|
|
||||||
max <= 0 ? 0.25 : Math.min(1, Math.max(0.05, n / max));
|
|
||||||
|
|
||||||
/** Dashboard scene: guild star at center, top channels orbiting it. */
|
|
||||||
export function statsToGraph(stats: DashboardStats): ConstellationGraph {
|
|
||||||
const top = stats.top_channels ?? [];
|
|
||||||
const maxCount = top.reduce((m, c) => Math.max(m, c.message_count), 0);
|
|
||||||
const nodes: GraphNode[] = [
|
|
||||||
{
|
|
||||||
id: "guild",
|
|
||||||
label: "GMW",
|
|
||||||
kind: "guild",
|
|
||||||
value: 1,
|
|
||||||
meta: {
|
|
||||||
total_messages: stats.total_messages,
|
|
||||||
total_flagged: stats.total_flagged,
|
|
||||||
active_users_24h: stats.active_users_24h,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const edges: GraphEdge[] = [];
|
|
||||||
for (const ch of top) {
|
|
||||||
nodes.push({
|
|
||||||
id: `channel:${ch.channel_id}`,
|
|
||||||
label: ch.channel_name || ch.channel_id,
|
|
||||||
kind: "channel",
|
|
||||||
href: "/channels/",
|
|
||||||
value: clamp01(ch.message_count, maxCount),
|
|
||||||
meta: { message_count: ch.message_count },
|
|
||||||
});
|
|
||||||
edges.push({
|
|
||||||
source: "guild",
|
|
||||||
target: `channel:${ch.channel_id}`,
|
|
||||||
weight: clamp01(ch.message_count, maxCount),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { nodes, edges };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Channels scene: every channel is a star; size = traffic, color = flags. */
|
|
||||||
export function channelsToGraph(
|
|
||||||
channels: DashboardChannel[],
|
|
||||||
): ConstellationGraph {
|
|
||||||
const maxMsg = channels.reduce((m, c) => Math.max(m, c.total_messages), 0);
|
|
||||||
const nodes: GraphNode[] = channels.map((c) => ({
|
|
||||||
id: `channel:${c.channel_id}`,
|
|
||||||
label: c.channel_name || c.channel_id,
|
|
||||||
kind: "channel",
|
|
||||||
value: clamp01(c.total_messages, maxMsg),
|
|
||||||
meta: {
|
|
||||||
flagged_count: c.flagged_count,
|
|
||||||
culture_summary: c.culture_summary,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
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: [] };
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { channelsToGraph, statsToGraph } from "./graph";
|
|
||||||
import { computeLayout, radiusFor } from "./layout";
|
|
||||||
|
|
||||||
const statsFixture = {
|
|
||||||
total_messages: 1200,
|
|
||||||
total_users: 40,
|
|
||||||
total_flagged: 30,
|
|
||||||
total_clean: 1100,
|
|
||||||
total_warned: 12,
|
|
||||||
total_error: 2,
|
|
||||||
total_voice_recordings: 8,
|
|
||||||
total_profiles: 40,
|
|
||||||
today_messages: 55,
|
|
||||||
today_flagged: 3,
|
|
||||||
active_users_24h: 18,
|
|
||||||
top_channels: [
|
|
||||||
{ channel_id: "c1", channel_name: "general", message_count: 400 },
|
|
||||||
{ channel_id: "c2", channel_name: "random", message_count: 200 },
|
|
||||||
],
|
|
||||||
moderation_overview: { pending: 1, processing: 0, error: 0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("statsToGraph", () => {
|
|
||||||
test("guild center + channel nodes + edges", () => {
|
|
||||||
const g = statsToGraph(statsFixture);
|
|
||||||
expect(g.nodes.length).toBe(3);
|
|
||||||
expect(g.nodes[0]?.id).toBe("guild");
|
|
||||||
expect(g.edges.length).toBe(2);
|
|
||||||
expect(g.edges[0]?.source).toBe("guild");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("bigger channel gets bigger value", () => {
|
|
||||||
const g = statsToGraph(statsFixture);
|
|
||||||
const c1 = g.nodes.find((n) => n.label === "general");
|
|
||||||
const c2 = g.nodes.find((n) => n.label === "random");
|
|
||||||
expect((c1?.value ?? 0) > (c2?.value ?? 0)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("empty top_channels still yields guild node", () => {
|
|
||||||
const g = statsToGraph({ ...statsFixture, top_channels: [] });
|
|
||||||
expect(g.nodes.length).toBe(1);
|
|
||||||
expect(g.edges.length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("channelsToGraph", () => {
|
|
||||||
test("maps every channel", () => {
|
|
||||||
const chans = [
|
|
||||||
{
|
|
||||||
channel_id: "a",
|
|
||||||
channel_name: "alpha",
|
|
||||||
total_messages: 10,
|
|
||||||
flagged_count: 1,
|
|
||||||
},
|
|
||||||
{ channel_id: "b", total_messages: 5, flagged_count: 0 },
|
|
||||||
];
|
|
||||||
const g = channelsToGraph(chans);
|
|
||||||
expect(g.nodes.length).toBe(2);
|
|
||||||
expect(g.nodes[1]?.label).toBe("b"); // falls back to id when name null
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("computeLayout", () => {
|
|
||||||
const nodes = [
|
|
||||||
{ id: "guild", kind: "guild", value: 1 },
|
|
||||||
{ id: "channel:c1", kind: "channel", value: 0.8 },
|
|
||||||
{ id: "channel:c2", kind: "channel", value: 0.4 },
|
|
||||||
];
|
|
||||||
const edges = [
|
|
||||||
{ source: "guild", target: "channel:c1" },
|
|
||||||
{ source: "guild", target: "channel:c2" },
|
|
||||||
];
|
|
||||||
|
|
||||||
test("deterministic for same seed", () => {
|
|
||||||
const a = computeLayout(nodes, edges, {
|
|
||||||
width: 900,
|
|
||||||
height: 700,
|
|
||||||
seed: 42,
|
|
||||||
});
|
|
||||||
const b = computeLayout(nodes, edges, {
|
|
||||||
width: 900,
|
|
||||||
height: 700,
|
|
||||||
seed: 42,
|
|
||||||
});
|
|
||||||
expect(a.map((n) => [n.id, Math.round(n.x), Math.round(n.y)])).toEqual(
|
|
||||||
b.map((n) => [n.id, Math.round(n.x), Math.round(n.y)]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("all finite positions within canvas bounds", () => {
|
|
||||||
const out = computeLayout(nodes, edges, {
|
|
||||||
width: 900,
|
|
||||||
height: 700,
|
|
||||||
seed: 7,
|
|
||||||
});
|
|
||||||
for (const n of out) {
|
|
||||||
expect(Number.isFinite(n.x)).toBe(true);
|
|
||||||
expect(Number.isFinite(n.y)).toBe(true);
|
|
||||||
expect(n.x).toBeGreaterThanOrEqual(-50);
|
|
||||||
expect(n.x).toBeLessThanOrEqual(950);
|
|
||||||
expect(n.y).toBeGreaterThanOrEqual(-50);
|
|
||||||
expect(n.y).toBeLessThanOrEqual(750);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reduced motion returns static ring", () => {
|
|
||||||
const out = computeLayout(nodes, edges, {
|
|
||||||
width: 900,
|
|
||||||
height: 700,
|
|
||||||
reduced: true,
|
|
||||||
});
|
|
||||||
expect(out.length).toBe(3);
|
|
||||||
expect(Number.isFinite(out[0]?.y ?? NaN)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("radiusFor", () => {
|
|
||||||
test("guild larger than channel larger than message", () => {
|
|
||||||
expect(radiusFor("guild", 1)).toBeGreaterThan(radiusFor("channel", 1));
|
|
||||||
expect(radiusFor("channel", 1)).toBeGreaterThan(radiusFor("message", 1));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
/**
|
|
||||||
* Deterministic constellation layout — pure functions over graph data.
|
|
||||||
* d3-force with a seeded PRNG so every render produces the same sky.
|
|
||||||
* `prefers-reduced-motion` callers use the static radial fallback.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
forceCenter,
|
|
||||||
forceCollide,
|
|
||||||
forceLink,
|
|
||||||
forceManyBody,
|
|
||||||
forceSimulation,
|
|
||||||
type SimulationNodeDatum,
|
|
||||||
} from "d3-force";
|
|
||||||
|
|
||||||
export interface LayoutNode extends SimulationNodeDatum {
|
|
||||||
id: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
/** Screen-space radius in px (already scaled by value + kind). */
|
|
||||||
r: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LayoutOptions {
|
|
||||||
/** Canvas width in CSS px. */
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
seed?: number;
|
|
||||||
/** Static radial ring layout (no simulation). */
|
|
||||||
reduced?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mulberry32 — small, fast, deterministic. */
|
|
||||||
function mulberry32(seed: number): () => number {
|
|
||||||
let a = seed >>> 0;
|
|
||||||
return () => {
|
|
||||||
a |= 0;
|
|
||||||
a = (a + 0x6d2b79f5) | 0;
|
|
||||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
||||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) | 0;
|
|
||||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function radiusFor(kind: string, value = 0.5): number {
|
|
||||||
const base =
|
|
||||||
kind === "guild"
|
|
||||||
? 34
|
|
||||||
: kind === "channel"
|
|
||||||
? 16
|
|
||||||
: kind === "flagged"
|
|
||||||
? 11
|
|
||||||
: 9;
|
|
||||||
return base * (0.55 + 0.9 * Math.max(0, Math.min(1, value)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute final positions. Same inputs (+seed) ⇒ identical output positions.
|
|
||||||
*/
|
|
||||||
export function computeLayout(
|
|
||||||
nodesIn: { id: string; kind: string; value?: number }[],
|
|
||||||
edges: { source: string; target: string }[],
|
|
||||||
opts: LayoutOptions,
|
|
||||||
): LayoutNode[] {
|
|
||||||
const rand = mulberry32(opts.seed ?? 42);
|
|
||||||
const cx = opts.width / 2;
|
|
||||||
const cy = opts.height / 2;
|
|
||||||
|
|
||||||
if (opts.reduced || nodesIn.length === 0) {
|
|
||||||
return nodesIn.map((n, i) => ({
|
|
||||||
...n,
|
|
||||||
x: cx + radiusFor(n.kind, n.value),
|
|
||||||
y:
|
|
||||||
cy +
|
|
||||||
Math.sin((i / Math.max(1, nodesIn.length)) * Math.PI * 2) *
|
|
||||||
Math.min(cx, cy) *
|
|
||||||
0.62,
|
|
||||||
r: radiusFor(n.kind, n.value),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const simNodes = nodesIn.map((n) => ({
|
|
||||||
id: n.id,
|
|
||||||
kind: n.kind,
|
|
||||||
value: n.value ?? 0.5,
|
|
||||||
r: radiusFor(n.kind, n.value),
|
|
||||||
x: cx + (rand() - 0.5) * opts.width * 0.8,
|
|
||||||
y: cy + (rand() - 0.5) * opts.height * 0.8,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const sim = forceSimulation(simNodes)
|
|
||||||
.force("charge", forceManyBody().strength(-420))
|
|
||||||
.force(
|
|
||||||
"link",
|
|
||||||
forceLink(edges.map((e) => ({ ...e })))
|
|
||||||
.id((d) => (d as { id: string }).id)
|
|
||||||
.distance(120)
|
|
||||||
.strength(0.6),
|
|
||||||
)
|
|
||||||
.force("center", forceCenter(cx, cy))
|
|
||||||
.force(
|
|
||||||
"collide",
|
|
||||||
forceCollide((d) => (d as { r: number }).r + 14),
|
|
||||||
)
|
|
||||||
.stop();
|
|
||||||
|
|
||||||
// Fixed tick budget keeps the result deterministic across machines.
|
|
||||||
for (let i = 0; i < 240; i++) sim.tick();
|
|
||||||
sim.on("tick", null);
|
|
||||||
|
|
||||||
return simNodes.map((n) => ({
|
|
||||||
id: n.id,
|
|
||||||
x: Number.isFinite(n.x) ? n.x : cx,
|
|
||||||
y: Number.isFinite(n.y) ? n.y : cy,
|
|
||||||
r: n.r,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { cssColorToHexInt, oklchToHexInt } from "./palette";
|
|
||||||
|
|
||||||
describe("oklchToHexInt", () => {
|
|
||||||
test("white", () => {
|
|
||||||
expect(oklchToHexInt(1, 0, 0)).toBe(0xffffff);
|
|
||||||
});
|
|
||||||
test("black", () => {
|
|
||||||
expect(oklchToHexInt(0, 0, 0)).toBe(0x000000);
|
|
||||||
});
|
|
||||||
test("green-ish signal stays recognizable", () => {
|
|
||||||
const hex = oklchToHexInt(0.86, 0.19, 128);
|
|
||||||
expect(hex).not.toBeNull();
|
|
||||||
if (hex === null) throw new Error("hex is null");
|
|
||||||
const r = (hex >> 16) & 0xff;
|
|
||||||
const g = (hex >> 8) & 0xff;
|
|
||||||
expect(g).toBeGreaterThan(r);
|
|
||||||
});
|
|
||||||
test("invalid input returns null", () => {
|
|
||||||
expect(oklchToHexInt(Number.NaN, 0, 0)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("cssColorToHexInt", () => {
|
|
||||||
test("parses oklch()", () => {
|
|
||||||
expect(cssColorToHexInt("oklch(1 0 0)")).toBe(0xffffff);
|
|
||||||
});
|
|
||||||
test("parses rgb()", () => {
|
|
||||||
expect(cssColorToHexInt("rgb(255, 0, 0)")).toBe(0xff0000);
|
|
||||||
});
|
|
||||||
test("parses hex", () => {
|
|
||||||
expect(cssColorToHexInt("#ff8000")).toBe(0xff8000);
|
|
||||||
});
|
|
||||||
test("garbage returns null", () => {
|
|
||||||
expect(cssColorToHexInt("nonsense")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
/**
|
|
||||||
* Color helpers for the constellation stage.
|
|
||||||
* Reads CSS custom properties (oklch strings) and converts them to
|
|
||||||
* THREE-friendly hex integers. Pure math + DOM reader separated.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** oklch(L C H) → sRGB hex integer (#rrggbb). Returns null on invalid input. */
|
|
||||||
export function oklchToHexInt(l: number, c: number, h: number): number | null {
|
|
||||||
if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// oklch → oklab
|
|
||||||
const hr = (h * Math.PI) / 180;
|
|
||||||
const a = c * Math.cos(hr);
|
|
||||||
const b = c * Math.sin(hr);
|
|
||||||
|
|
||||||
const l_ = l + 0.3963377774 * a + 0.2158037573 * b;
|
|
||||||
const m_ = l - 0.1055613458 * a - 0.0638541728 * b;
|
|
||||||
const s_ = l - 0.0894841775 * a - 1.291485548 * b;
|
|
||||||
|
|
||||||
const L = l_ * l_ * l_;
|
|
||||||
const M = m_ * m_ * m_;
|
|
||||||
const S = s_ * s_ * s_;
|
|
||||||
|
|
||||||
let r = 4.0767416621 * L - 3.3077115913 * M + 0.2309699292 * S;
|
|
||||||
let g = -1.2684380046 * L + 2.6097574011 * M - 0.3413193965 * S;
|
|
||||||
let bb = -0.0041960863 * L - 0.7034186147 * M + 1.707614701 * S;
|
|
||||||
|
|
||||||
r = gamma(r);
|
|
||||||
g = gamma(g);
|
|
||||||
bb = gamma(bb);
|
|
||||||
|
|
||||||
if ([r, g, bb].some((v) => !Number.isFinite(v))) return null;
|
|
||||||
|
|
||||||
const to255 = (v: number) => Math.max(0, Math.min(255, Math.round(v * 255)));
|
|
||||||
return (to255(r) << 16) | (to255(g) << 8) | to255(bb);
|
|
||||||
}
|
|
||||||
|
|
||||||
function gamma(v: number): number {
|
|
||||||
const abs = Math.abs(v);
|
|
||||||
if (abs <= 0.0031308) return 12.92 * v;
|
|
||||||
return (Math.sign(v) || 1) * (1.055 * abs ** (1 / 2.4) - 0.055);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse "oklch(0.86 0.19 128)" (or legacy "rgb(...)") into hex int. */
|
|
||||||
export function cssColorToHexInt(color: string): number | null {
|
|
||||||
const oklch = color.match(/oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+([\d.-]+)/i);
|
|
||||||
if (oklch) {
|
|
||||||
const lStr = oklch[1] ?? "";
|
|
||||||
const l = lStr.endsWith("%")
|
|
||||||
? (Number.parseFloat(lStr) || 0) / 100
|
|
||||||
: Number.parseFloat(lStr);
|
|
||||||
return oklchToHexInt(
|
|
||||||
l,
|
|
||||||
Number.parseFloat(oklch[2] ?? "0"),
|
|
||||||
Number.parseFloat(oklch[3] ?? "0"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const rgb = color.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
|
|
||||||
if (rgb) {
|
|
||||||
return (
|
|
||||||
(Math.round(Number(rgb[1])) << 16) |
|
|
||||||
(Math.round(Number(rgb[2])) << 8) |
|
|
||||||
Math.round(Number(rgb[3]))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const hex = color.match(/^#([0-9a-f]{6})$/i);
|
|
||||||
if (hex?.[1]) return Number.parseInt(hex[1], 16);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StagePalette {
|
|
||||||
signal: number;
|
|
||||||
vermilion: number;
|
|
||||||
amber: number;
|
|
||||||
ink: number;
|
|
||||||
inkSoft: number;
|
|
||||||
inkFaint: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FALLBACK_DARK: StagePalette = {
|
|
||||||
signal: 0x7dd87a,
|
|
||||||
vermilion: 0xe05642,
|
|
||||||
amber: 0xd9a441,
|
|
||||||
ink: 0xf2ede2,
|
|
||||||
inkSoft: 0xa89f90,
|
|
||||||
inkFaint: 0x807767,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Read theme tokens off :root computed style; fall back to dark set. */
|
|
||||||
export function readPalette(): StagePalette {
|
|
||||||
if (typeof window === "undefined") return FALLBACK_DARK;
|
|
||||||
const cs = getComputedStyle(document.documentElement);
|
|
||||||
const pick = (name: string, fb: number): number =>
|
|
||||||
cssColorToHexInt(cs.getPropertyValue(name).trim()) ?? fb;
|
|
||||||
return {
|
|
||||||
signal: pick("--color-signal", FALLBACK_DARK.signal),
|
|
||||||
vermilion: pick("--color-vermilion", FALLBACK_DARK.vermilion),
|
|
||||||
amber: pick("--color-amber", FALLBACK_DARK.amber),
|
|
||||||
ink: pick("--color-ink", FALLBACK_DARK.ink),
|
|
||||||
inkSoft: pick("--color-ink-soft", FALLBACK_DARK.inkSoft),
|
|
||||||
inkFaint: pick("--color-ink-faint", FALLBACK_DARK.inkFaint),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user