feat(frontend): glossary satellite ring + analysis search console scenes
This commit is contained in:
@@ -1,24 +1,72 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Hash, Search, Sparkles, TrendingUp } from "lucide-react";
|
/**
|
||||||
import { useEffect, useState } from "react";
|
* Analysis scene — semantic search lives in a floating console (left);
|
||||||
|
* results stream into a translucent dossier (right). The stage shows an
|
||||||
|
* analysis hub wired to the guild's top channels.
|
||||||
|
*/
|
||||||
|
import { Hash, Search, Sparkles } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import { Avatar, Badge, GlassPanel, Input } from "@/components/primitives";
|
import { Avatar, Badge } from "@/components/primitives";
|
||||||
import { EmptyState, SectionHeader, SkeletonRows } from "@/components/shared";
|
import { EmptyState, SkeletonRows } from "@/components/shared";
|
||||||
import { useChannels, useMessageSearch, useTopReactors } from "@/hooks";
|
import { useScenePublish } from "@/components/shell/scene-graph-context";
|
||||||
|
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(
|
||||||
@@ -29,52 +77,50 @@ export function AnalysisView() {
|
|||||||
}, [query, ambient]);
|
}, [query, ambient]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="min-h-full">
|
||||||
<GlassPanel glow className="relative overflow-hidden">
|
{/* Search console — left */}
|
||||||
<div className="scan-line absolute inset-x-0 top-0" />
|
<section
|
||||||
<div className="flex items-center gap-3">
|
className="pointer-events-auto absolute left-5 top-16 w-[min(26rem,88vw)]"
|
||||||
<Sparkles className="size-5 text-signal" />
|
aria-label="Semantic search"
|
||||||
<div>
|
>
|
||||||
<div className="eyebrow">Semantic search</div>
|
<p className="eyebrow mb-2 flex items-center gap-1.5">
|
||||||
<h2 className="display text-balance text-2xl text-ink">
|
<Sparkles className="size-3.5 text-signal" /> Search the archive
|
||||||
Search the archive
|
</p>
|
||||||
</h2>
|
<div className="relative">
|
||||||
</div>
|
<Search className="absolute left-4 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-faint)]" />
|
||||||
</div>
|
<input
|
||||||
<div className="relative mt-4">
|
type="search"
|
||||||
<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 ? (
|
||||||
<div className="mono mt-2 text-xs text-ink-faint">
|
<p className="mt-2 font-mono text-xs text-[var(--color-ink-faint)]">
|
||||||
Type at least 2 characters…
|
minimal 2 karakter…
|
||||||
</div>
|
</p>
|
||||||
)}
|
) : null}
|
||||||
</GlassPanel>
|
</section>
|
||||||
|
|
||||||
<div className="grid gap-5 lg:grid-cols-5">
|
{/* Results dossier — right */}
|
||||||
<GlassPanel className="lg:col-span-3">
|
<section
|
||||||
<SectionHeader
|
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"
|
||||||
eyebrow="results"
|
aria-label="Search results"
|
||||||
title="Matches"
|
>
|
||||||
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">matches</span>
|
||||||
|
<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={6} />
|
<SkeletonRows rows={5} />
|
||||||
)}
|
) : (search.data ?? []).length === 0 ? (
|
||||||
{(search.data ?? []).length === 0 ? (
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<Search className="size-7" />}
|
icon={<Search className="size-6" />}
|
||||||
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."
|
||||||
/>
|
/>
|
||||||
@@ -83,27 +129,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-[12px] border border-hairline bg-white/[0.03] p-3"
|
className="flex items-start gap-3 rounded-xl border border-[var(--color-hairline)] p-2.5"
|
||||||
>
|
>
|
||||||
<Avatar src={m.avatar_url} name={m.username} size={32} />
|
<Avatar src={m.avatar_url} name={m.username} size={28} />
|
||||||
<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-ink">
|
<span className="text-sm font-semibold text-[var(--color-ink)]">
|
||||||
{m.username}
|
{m.username}
|
||||||
</span>
|
</span>
|
||||||
<span className="mono text-[0.65rem] text-ink-faint">
|
<span className="font-mono text-[0.65rem] text-[var(--color-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 text-sm text-ink-soft">
|
<div className="mt-0.5 line-clamp-2 text-sm text-[var(--color-ink-soft)]">
|
||||||
{renderMessageContent(m.content, m.metadata) || "(embed)"}
|
{renderMessageContent(m.content, m.metadata) || "(embed)"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,86 +157,14 @@ export function AnalysisView() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</GlassPanel>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="space-y-5 lg:col-span-2">
|
{/* Channels hint — bottom-left */}
|
||||||
<GlassPanel>
|
<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">
|
||||||
<SectionHeader
|
<Hash className="size-3.5" /> {(channels ?? []).length} channels
|
||||||
eyebrow="culture"
|
terhubung ke hub analitik — klik untuk membuka konstelasi channel
|
||||||
title={
|
</p>
|
||||||
<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,15 +1,118 @@
|
|||||||
"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 { TermGlossary } from "@/components/TermGlossary";
|
import {
|
||||||
|
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);
|
||||||
return terms ? <TermGlossary terms={terms} /> : <SkeletonPanel rows={6} />;
|
const publish = useScenePublish();
|
||||||
|
const setFocus = useSceneFocusSetter();
|
||||||
|
const { state } = useSceneGraph();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const rows = terms ?? [];
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return rows;
|
||||||
|
return rows.filter(
|
||||||
|
(t) =>
|
||||||
|
t.term.toLowerCase().includes(q) ||
|
||||||
|
t.definition.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [rows, query]);
|
||||||
|
|
||||||
|
const graph = useMemo(() => termsToGraph(filtered), [filtered]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
publish({ graph, focus: null });
|
||||||
|
}, [graph, publish]);
|
||||||
|
|
||||||
|
useEffect(() => () => setFocus(null), [setFocus]);
|
||||||
|
|
||||||
|
const selectedId = state?.focus ?? null;
|
||||||
|
const selectedTerm = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedId
|
||||||
|
? (rows.find((t) => `term:${t.term}` === selectedId) ?? null)
|
||||||
|
: null,
|
||||||
|
[rows, selectedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user