feat(frontend): dashboard & channels constellation scenes — publish bridge + floating overlays
This commit is contained in:
@@ -1,8 +1,18 @@
|
|||||||
"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({
|
||||||
@@ -11,12 +21,92 @@ 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="space-y-5">
|
<div className="min-h-full">
|
||||||
{cultures ? (
|
{/* Search whisper — top-left */}
|
||||||
<ChannelCultureGlossary cultures={cultures} />
|
<section
|
||||||
|
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>
|
||||||
) : (
|
) : (
|
||||||
<SkeletonPanel rows={6} />
|
<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 bintang untuk membuka culture dossier
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,32 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
/**
|
||||||
Activity,
|
* Dashboard scene — guild star + channel orbit live on the stage canvas;
|
||||||
Flag,
|
* this overlay adds the metric whisper (top-left), activity ribbon
|
||||||
MessageSquare,
|
* (bottom-left), and moderation gauge (right) as floating panels.
|
||||||
Mic,
|
*/
|
||||||
Radio,
|
import { Activity, Flag, ShieldAlert } from "lucide-react";
|
||||||
ShieldAlert,
|
import { useEffect, useMemo } from "react";
|
||||||
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 { GlassPanel } from "@/components/primitives";
|
import { ErrorState, LoadingState } from "@/components/shared";
|
||||||
import {
|
import { useScenePublish } from "@/components/shell/scene-graph-context";
|
||||||
ErrorState,
|
import { useActivity, useStats } from "@/hooks";
|
||||||
LoadingState,
|
import { statsToGraph } from "@/lib/constellation/graph";
|
||||||
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";
|
||||||
@@ -50,11 +36,19 @@ export function DashboardView({
|
|||||||
initialStats?: DashboardStats;
|
initialStats?: DashboardStats;
|
||||||
initialActivity?: Awaited<ReturnType<typeof useActivity>>["data"];
|
initialActivity?: Awaited<ReturnType<typeof useActivity>>["data"];
|
||||||
}) {
|
}) {
|
||||||
const { data: stats, isLoading, error } = useStats(initialStats);
|
const { data: stats, 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);
|
||||||
@@ -66,303 +60,134 @@ export function DashboardView({
|
|||||||
}, [stats, ambient]);
|
}, [stats, ambient]);
|
||||||
|
|
||||||
if (error && !stats) return <ErrorState error={error} />;
|
if (error && !stats) return <ErrorState error={error} />;
|
||||||
if (!stats && isLoading)
|
if (!stats) return <LoadingState label="aligning constellation" />;
|
||||||
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="space-y-5">
|
<div className="min-h-full">
|
||||||
{/* Hero */}
|
{/* Metric whisper — top-left under brand */}
|
||||||
<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-64 animate-stagger"
|
||||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
style={staggerDelay(0)}
|
||||||
<div>
|
aria-label="Key metrics"
|
||||||
<div className="eyebrow mb-2">GMW · Operations Grid</div>
|
>
|
||||||
<h2 className="display hero-clamp leading-none text-ink glow-signal">
|
<p className="eyebrow mb-2">Operations · {deriveSignal(s).label}</p>
|
||||||
Ambient Field
|
<div className="space-y-1.5">
|
||||||
</h2>
|
<WhisperRow
|
||||||
<p className="mt-2 max-w-md text-pretty text-sm text-ink-soft">
|
label="messages"
|
||||||
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="signal"
|
tone="ink"
|
||||||
icon={<MessageSquare className="size-3.5" />}
|
|
||||||
className="animate-stagger"
|
|
||||||
style={staggerDelay(0)}
|
|
||||||
/>
|
/>
|
||||||
<MetricTile
|
<WhisperRow
|
||||||
label="Flagged"
|
label="flagged"
|
||||||
value={formatNumber(s.total_flagged)}
|
value={formatNumber(s.total_flagged)}
|
||||||
tone={s.total_flagged > 0 ? "vermilion" : "neutral"}
|
tone={s.total_flagged > 0 ? "vermilion" : "ink"}
|
||||||
hint={`${s.today_flagged} today`}
|
hint={`${s.today_flagged} today`}
|
||||||
className="animate-stagger"
|
|
||||||
style={staggerDelay(1)}
|
|
||||||
/>
|
/>
|
||||||
<MetricTile
|
<WhisperRow
|
||||||
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)}
|
|
||||||
/>
|
/>
|
||||||
<MetricTile
|
<WhisperRow
|
||||||
label="Voice clips"
|
label="voice clips"
|
||||||
value={formatNumber(s.total_voice_recordings)}
|
value={formatNumber(s.total_voice_recordings)}
|
||||||
icon={<Mic className="size-3.5" />}
|
tone="ink"
|
||||||
className="animate-stagger"
|
|
||||||
style={staggerDelay(3)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</GlassPanel>
|
</section>
|
||||||
|
|
||||||
{/* Activity */}
|
{/* Activity ribbon — bottom-left */}
|
||||||
<GlassPanel>
|
<section
|
||||||
<SectionHeader
|
className="pointer-events-auto absolute bottom-20 left-5 hidden w-80 lg:block"
|
||||||
eyebrow="14-day signal"
|
aria-label="Activity"
|
||||||
title={
|
>
|
||||||
<span className="flex items-center gap-2">
|
<p className="eyebrow mb-1 flex items-center gap-1.5">
|
||||||
<Activity className="size-4 text-signal" /> Activity & moderation
|
<Activity className="size-3.5 text-signal" /> 14-day signal
|
||||||
</span>
|
</p>
|
||||||
}
|
|
||||||
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" />
|
||||||
)}
|
)}
|
||||||
</GlassPanel>
|
</section>
|
||||||
|
|
||||||
{/* Two-column: channels + moderation */}
|
{/* Moderation gauge — right */}
|
||||||
<div className="grid gap-5 lg:grid-cols-5">
|
<section
|
||||||
<GlassPanel className="lg:col-span-3">
|
className="pointer-events-auto absolute right-5 top-16 hidden w-56 md:block"
|
||||||
<SectionHeader eyebrow="throughput" title="Top channels" />
|
aria-label="Moderation"
|
||||||
<div className="space-y-2.5">
|
>
|
||||||
{s.top_channels.slice(0, 7).map((c) => {
|
<p className="eyebrow mb-2 flex items-center gap-1.5">
|
||||||
const pct =
|
<ShieldAlert className="size-3.5 text-signal" /> Trust
|
||||||
(c.message_count / (s.top_channels[0]?.message_count || 1)) *
|
</p>
|
||||||
100;
|
<RadialGauge
|
||||||
return (
|
value={cleanRatio}
|
||||||
<div key={c.channel_id} className="flex items-center gap-3">
|
tone={
|
||||||
<span className="w-28 shrink-0 truncate text-sm text-ink-soft sm:w-40">
|
cleanRatio > 0.8
|
||||||
{c.channel_name ?? c.channel_id.slice(0, 8)}
|
? "signal"
|
||||||
</span>
|
: cleanRatio > 0.6
|
||||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/8">
|
? "amber"
|
||||||
<div
|
: "vermilion"
|
||||||
className="h-full rounded-full bg-signal/70"
|
}
|
||||||
style={{ width: `${pct}%` }}
|
label={`${Math.round(cleanRatio * 100)}%`}
|
||||||
/>
|
sublabel="clean"
|
||||||
</div>
|
/>
|
||||||
<span className="mono w-14 text-right text-xs text-ink-faint">
|
<div className="mt-3 space-y-1 font-mono text-xs text-[var(--color-ink-soft)]">
|
||||||
{formatNumber(c.message_count)}
|
<p className="flex items-center justify-between">
|
||||||
</span>
|
<span className="inline-flex items-center gap-1.5">
|
||||||
</div>
|
<Flag className="size-3 text-vermilion" /> flagged
|
||||||
);
|
</span>
|
||||||
})}
|
<span>{formatNumber(s.total_flagged)}</span>
|
||||||
</div>
|
</p>
|
||||||
</GlassPanel>
|
<p className="flex items-center justify-between">
|
||||||
|
<span>warned</span>
|
||||||
<GlassPanel className="lg:col-span-2">
|
<span>{formatNumber(s.total_warned)}</span>
|
||||||
<SectionHeader eyebrow="trust" title="Moderation" />
|
</p>
|
||||||
<div className="flex items-center gap-5">
|
<p className="flex items-center justify-between">
|
||||||
<RadialGauge
|
<span>pending</span>
|
||||||
value={cleanRatio}
|
<span>{s.moderation_overview.pending}</span>
|
||||||
tone={
|
</p>
|
||||||
cleanRatio > 0.8
|
<p className="flex items-center justify-between">
|
||||||
? "signal"
|
<span>processing</span>
|
||||||
: cleanRatio > 0.6
|
<span>{s.moderation_overview.processing}</span>
|
||||||
? "amber"
|
</p>
|
||||||
: "vermilion"
|
</div>
|
||||||
}
|
</section>
|
||||||
label={`${Math.round(cleanRatio * 100)}%`}
|
|
||||||
sublabel="clean"
|
|
||||||
/>
|
|
||||||
<div className="flex-1 space-y-2 text-sm">
|
|
||||||
<Row
|
|
||||||
icon={<ShieldAlert className="size-4 text-signal" />}
|
|
||||||
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>
|
|
||||||
<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">
|
|
||||||
+{formatNumber(r.net_count)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{(reactors ?? []).length === 0 && <EmptyHint />}
|
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Row({
|
function WhisperRow({
|
||||||
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: number;
|
value: string;
|
||||||
tone: "signal" | "amber" | "vermilion";
|
tone: "ink" | "signal" | "vermilion";
|
||||||
|
hint?: string;
|
||||||
}) {
|
}) {
|
||||||
const color =
|
const color =
|
||||||
tone === "vermilion"
|
tone === "vermilion"
|
||||||
? "text-vermilion"
|
? "text-vermilion"
|
||||||
: tone === "amber"
|
: tone === "signal"
|
||||||
? "text-amber"
|
? "text-signal"
|
||||||
: "text-signal";
|
: "text-ink";
|
||||||
return (
|
return (
|
||||||
<div>
|
<p className="flex items-baseline gap-2">
|
||||||
<div className={`display text-xl ${color}`}>{value}</div>
|
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-ink-faint)]">
|
||||||
<div className="eyebrow mt-0.5">{label}</div>
|
{label}
|
||||||
</div>
|
</span>
|
||||||
);
|
<span className={`display text-lg leading-none ${color}`}>{value}</span>
|
||||||
}
|
{hint ? (
|
||||||
|
<span className="font-mono text-[10px] text-[var(--color-ink-faint)]">
|
||||||
function EmptyHint() {
|
{hint}
|
||||||
return (
|
</span>
|
||||||
<div className="py-6 text-center text-xs text-ink-faint">
|
) : null}
|
||||||
Awaiting data…
|
</p>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
/**
|
/**
|
||||||
* ConstellationFrame — replaces the classic AppFrame chrome.
|
* ConstellationFrame — replaces the classic AppFrame chrome.
|
||||||
* The stage canvas sits fixed behind everything; page content is an
|
* The stage canvas sits fixed behind everything; page content is an
|
||||||
* overlay layer (absolute, no top bar / nav rail / scroll shell).
|
* overlay layer (no top bar / nav rail / scroll shell). Views publish
|
||||||
|
* their live graph via SceneGraphProvider; the frame renders whatever
|
||||||
|
* the active view published (fallback: route-scenes default builder).
|
||||||
* Chatbot + CommandPalette keep mounting at the layout level.
|
* Chatbot + CommandPalette keep mounting at the layout level.
|
||||||
*/
|
*/
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
@@ -11,39 +13,67 @@ import { type ReactNode, useMemo } from "react";
|
|||||||
import { MiniPlayer } from "@/components/media/mini-player";
|
import { MiniPlayer } from "@/components/media/mini-player";
|
||||||
import { ConstellationStage } from "@/components/shell/constellation-stage";
|
import { ConstellationStage } from "@/components/shell/constellation-stage";
|
||||||
import { FloatingChrome } from "@/components/shell/floating-chrome";
|
import { FloatingChrome } from "@/components/shell/floating-chrome";
|
||||||
import { resolveScene, type SceneSeed } from "@/components/shell/route-scenes";
|
import {
|
||||||
|
buildDefaultGraph,
|
||||||
|
resolveScene,
|
||||||
|
type SceneSeed,
|
||||||
|
} from "@/components/shell/route-scenes";
|
||||||
|
import {
|
||||||
|
SceneGraphProvider,
|
||||||
|
useSceneGraph,
|
||||||
|
} from "@/components/shell/scene-graph-context";
|
||||||
|
|
||||||
export interface ConstellationFrameProps {
|
function StageFromContext({ seed }: { seed?: SceneSeed }) {
|
||||||
children: ReactNode;
|
const pathname = usePathname() ?? "/";
|
||||||
/** Typed SSR seed consumed by the active scene's graph builder. */
|
const { state, setFocus } = useSceneGraph();
|
||||||
sceneSeed?: SceneSeed;
|
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({
|
export function ConstellationFrame({
|
||||||
children,
|
children,
|
||||||
sceneSeed,
|
sceneSeed,
|
||||||
}: ConstellationFrameProps) {
|
}: ConstellationFrameProps_) {
|
||||||
const pathname = usePathname() ?? "/";
|
|
||||||
const scene = useMemo(() => resolveScene(pathname), [pathname]);
|
|
||||||
const graph = useMemo(
|
|
||||||
() => (scene ? scene.build(sceneSeed ?? {}) : { nodes: [], edges: [] }),
|
|
||||||
[scene, sceneSeed],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-dvh w-full overflow-hidden">
|
<SceneGraphProvider>
|
||||||
<ConstellationStage
|
<div className="relative h-dvh w-full overflow-hidden">
|
||||||
graph={graph}
|
<StageFromContext seed={sceneSeed} />
|
||||||
onNodeClick={(id) => {
|
<FloatingChrome />
|
||||||
if (id.startsWith("channel:")) window.location.assign("/channels/");
|
<MiniPlayer />
|
||||||
}}
|
{/* Overlay content region — scenes place floating panels inside. */}
|
||||||
/>
|
<main className="pointer-events-none absolute inset-0 z-10 overflow-y-auto overscroll-contain">
|
||||||
<FloatingChrome />
|
<div className="pointer-events-auto min-h-full">{children}</div>
|
||||||
<MiniPlayer />
|
</main>
|
||||||
{/* Overlay content region — scenes place floating panels inside. */}
|
</div>
|
||||||
<main className="pointer-events-none absolute inset-0 z-10 overflow-y-auto overscroll-contain">
|
</SceneGraphProvider>
|
||||||
<div className="pointer-events-auto min-h-full">{children}</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ConstellationFrameProps_ {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Typed SSR seed for the route-scenes fallback builder. */
|
||||||
|
sceneSeed?: SceneSeed;
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { readPalette, type StagePalette } from "@/lib/constellation/palette";
|
|||||||
export interface ConstellationStageProps {
|
export interface ConstellationStageProps {
|
||||||
graph: ConstellationGraph;
|
graph: ConstellationGraph;
|
||||||
seed?: number;
|
seed?: number;
|
||||||
|
/** Node id kept highlighted (scene selection). */
|
||||||
|
selectedId?: string | null;
|
||||||
onNodeClick?: (nodeId: string) => void;
|
onNodeClick?: (nodeId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ const Z_MAX = 3;
|
|||||||
export function ConstellationStage({
|
export function ConstellationStage({
|
||||||
graph,
|
graph,
|
||||||
seed = 42,
|
seed = 42,
|
||||||
|
selectedId = null,
|
||||||
onNodeClick,
|
onNodeClick,
|
||||||
}: ConstellationStageProps) {
|
}: ConstellationStageProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
@@ -34,7 +37,7 @@ export function ConstellationStage({
|
|||||||
const layoutRef = useRef<LayoutNode[]>([]);
|
const layoutRef = useRef<LayoutNode[]>([]);
|
||||||
const nodeByIdRef = useRef(new Map(graph.nodes.map((n) => [n.id, n])));
|
const nodeByIdRef = useRef(new Map(graph.nodes.map((n) => [n.id, n])));
|
||||||
const [size, setSize] = useState({ w: 0, h: 0 });
|
const [size, setSize] = useState({ w: 0, h: 0 });
|
||||||
const [hovered, setHovered] = useState<string | null>(null);
|
const hoveredRef = useRef<string | null>(null);
|
||||||
const [reduced, setReduced] = useState(false);
|
const [reduced, setReduced] = useState(false);
|
||||||
|
|
||||||
nodeByIdRef.current = new Map(graph.nodes.map((n) => [n.id, n]));
|
nodeByIdRef.current = new Map(graph.nodes.map((n) => [n.id, n]));
|
||||||
@@ -222,7 +225,7 @@ export function ConstellationStage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hit = pickNode(ev.clientX, ev.clientY);
|
const hit = pickNode(ev.clientX, ev.clientY);
|
||||||
setHovered(hit ? hit.id : null);
|
hoveredRef.current = hit ? hit.id : null;
|
||||||
canvas.style.cursor = hit ? "pointer" : "grab";
|
canvas.style.cursor = hit ? "pointer" : "grab";
|
||||||
};
|
};
|
||||||
const onPointerUp = (ev: PointerEvent) => {
|
const onPointerUp = (ev: PointerEvent) => {
|
||||||
@@ -318,7 +321,7 @@ export function ConstellationStage({
|
|||||||
v.halo.material.opacity =
|
v.halo.material.opacity =
|
||||||
(v.id === "guild" ? 0.14 : 0.08) * (0.7 + 0.6 * pulse);
|
(v.id === "guild" ? 0.14 : 0.08) * (0.7 + 0.6 * pulse);
|
||||||
}
|
}
|
||||||
const hov = v.id === hovered;
|
const hov = v.id === hoveredRef.current || v.id === selectedId;
|
||||||
v.core.scale.setScalar(hov ? 1.25 : 1);
|
v.core.scale.setScalar(hov ? 1.25 : 1);
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
@@ -334,7 +337,9 @@ export function ConstellationStage({
|
|||||||
const sy = -(v.y - y) * z + size.h / 2 + v.r + 14;
|
const sy = -(v.y - y) * z + size.h / 2 + v.r + 14;
|
||||||
el.style.transform = `translate(${sx}px, ${sy}px) translateX(-50%)`;
|
el.style.transform = `translate(${sx}px, ${sy}px) translateX(-50%)`;
|
||||||
el.style.color =
|
el.style.color =
|
||||||
id === hovered ? "var(--color-signal)" : "var(--color-ink-soft)";
|
id === hoveredRef.current || id === selectedId
|
||||||
|
? "var(--color-signal)"
|
||||||
|
: "var(--color-ink-soft)";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -354,7 +359,7 @@ export function ConstellationStage({
|
|||||||
scene.clear();
|
scene.clear();
|
||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
};
|
};
|
||||||
}, [graph, size.w, size.h, reduced, hovered, onNodeClick]);
|
}, [graph, size.w, size.h, reduced, selectedId, onNodeClick]);
|
||||||
|
|
||||||
const emptyGraph = graph.nodes.length === 0;
|
const emptyGraph = graph.nodes.length === 0;
|
||||||
const hint = useMemo(
|
const hint = useMemo(
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
||||||
import { channelsToGraph, statsToGraph } from "@/lib/constellation/graph";
|
import {
|
||||||
|
channelsToGraph,
|
||||||
|
culturesToGraph,
|
||||||
|
statsToGraph,
|
||||||
|
} from "@/lib/constellation/graph";
|
||||||
|
|
||||||
export interface SceneDef {
|
export interface SceneDef {
|
||||||
route: string;
|
route: string;
|
||||||
@@ -17,6 +21,7 @@ export interface SceneDef {
|
|||||||
export interface SceneSeed {
|
export interface SceneSeed {
|
||||||
stats?: import("@/lib/types").DashboardStats;
|
stats?: import("@/lib/types").DashboardStats;
|
||||||
channels?: import("@/lib/types").DashboardChannel[];
|
channels?: import("@/lib/types").DashboardChannel[];
|
||||||
|
cultures?: import("@/lib/types").ChannelCultureRow[];
|
||||||
guildLabel?: string;
|
guildLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,11 +34,26 @@ export const SCENES: SceneDef[] = [
|
|||||||
{
|
{
|
||||||
route: "/channels/",
|
route: "/channels/",
|
||||||
label: "Channels",
|
label: "Channels",
|
||||||
build: (s) =>
|
build: (s) => {
|
||||||
s.channels ? channelsToGraph(s.channels) : { nodes: [], edges: [] },
|
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 {
|
export function resolveScene(pathname: string): SceneDef | undefined {
|
||||||
return SCENES.find((sc) => sc.route === pathname);
|
return SCENES.find((sc) => sc.route === pathname);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildDefaultGraph(
|
||||||
|
scene: SceneDef,
|
||||||
|
seed: SceneSeed,
|
||||||
|
): ConstellationGraph {
|
||||||
|
try {
|
||||||
|
return scene.build(seed);
|
||||||
|
} catch {
|
||||||
|
return { nodes: [], edges: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
/**
|
||||||
|
* SceneGraph bridge — views publish their live graph to the stage.
|
||||||
|
* The frame provides the setter; the stage consumes the graph.
|
||||||
|
* Keeps SSR seed pattern intact: page.tsx seeds view.tsx, view publishes.
|
||||||
|
*/
|
||||||
|
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
||||||
|
|
||||||
|
export interface SceneGraphState {
|
||||||
|
graph: ConstellationGraph;
|
||||||
|
/** Node id currently focused (drives fly-to / highlight). */
|
||||||
|
focus: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FocusUpdate = string | null | ((prev: string | null) => string | null);
|
||||||
|
|
||||||
|
interface SceneGraphContextValue {
|
||||||
|
state: SceneGraphState | null;
|
||||||
|
publish: (state: SceneGraphState) => void;
|
||||||
|
setFocus: (update: FocusUpdate) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SceneGraphContext = createContext<SceneGraphContextValue>({
|
||||||
|
state: null,
|
||||||
|
publish: () => {},
|
||||||
|
setFocus: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function SceneGraphProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [state, setState] = useState<SceneGraphState | null>(null);
|
||||||
|
const stateRef = useRef<SceneGraphState | null>(null);
|
||||||
|
|
||||||
|
const publish = useCallback((next: SceneGraphState) => {
|
||||||
|
const prev = stateRef.current;
|
||||||
|
const sameShape =
|
||||||
|
prev &&
|
||||||
|
prev.graph.nodes.length === next.graph.nodes.length &&
|
||||||
|
prev.graph.edges.length === next.graph.edges.length &&
|
||||||
|
prev.focus === next.focus;
|
||||||
|
if (!sameShape) {
|
||||||
|
stateRef.current = next;
|
||||||
|
setState(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Same shape — still update node values/labels in place.
|
||||||
|
let changed = false;
|
||||||
|
if (prev) {
|
||||||
|
for (let i = 0; i < next.graph.nodes.length; i++) {
|
||||||
|
const a = prev.graph.nodes[i];
|
||||||
|
const b = next.graph.nodes[i];
|
||||||
|
if (a?.id !== b?.id || a?.label !== b?.label || a?.value !== b?.value) {
|
||||||
|
changed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed || !prev) {
|
||||||
|
stateRef.current = next;
|
||||||
|
setState(next);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setFocus = useCallback((update: FocusUpdate) => {
|
||||||
|
setState((prev) => {
|
||||||
|
if (!prev) return null;
|
||||||
|
const next = typeof update === "function" ? update(prev.focus) : update;
|
||||||
|
return next === prev.focus ? prev : { ...prev, focus: next };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ state, publish, setFocus }),
|
||||||
|
[state, publish, setFocus],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<SceneGraphContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</SceneGraphContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScenePublish(): (state: SceneGraphState) => void {
|
||||||
|
return useContext(SceneGraphContext).publish;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSceneFocusSetter(): (update: FocusUpdate) => void {
|
||||||
|
return useContext(SceneGraphContext).setFocus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { FocusUpdate };
|
||||||
|
|
||||||
|
export function useSceneGraph(): SceneGraphContextValue {
|
||||||
|
return useContext(SceneGraphContext);
|
||||||
|
}
|
||||||
@@ -2,7 +2,11 @@
|
|||||||
* Constellation graph model — pure data, no React/DOM.
|
* Constellation graph model — pure data, no React/DOM.
|
||||||
* Builders convert existing API payloads into star-graph structures.
|
* Builders convert existing API payloads into star-graph structures.
|
||||||
*/
|
*/
|
||||||
import type { DashboardChannel, DashboardStats } from "@/lib/types";
|
import type {
|
||||||
|
ChannelCultureRow,
|
||||||
|
DashboardChannel,
|
||||||
|
DashboardStats,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
export type NodeKind =
|
export type NodeKind =
|
||||||
| "guild"
|
| "guild"
|
||||||
@@ -94,3 +98,18 @@ export function channelsToGraph(
|
|||||||
}));
|
}));
|
||||||
return { nodes, edges: [] };
|
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: [] };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user