diff --git a/services/frontend/src/app/(dashboard)/analysis/view.tsx b/services/frontend/src/app/(dashboard)/analysis/view.tsx index 1b6dc1c2..ae1c3bb8 100644 --- a/services/frontend/src/app/(dashboard)/analysis/view.tsx +++ b/services/frontend/src/app/(dashboard)/analysis/view.tsx @@ -1,24 +1,72 @@ "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 { Avatar, Badge, GlassPanel, Input } from "@/components/primitives"; -import { EmptyState, SectionHeader, SkeletonRows } from "@/components/shared"; -import { useChannels, useMessageSearch, useTopReactors } from "@/hooks"; +import { Avatar, Badge } from "@/components/primitives"; +import { EmptyState, SkeletonRows } from "@/components/shared"; +import { useScenePublish } from "@/components/shell/scene-graph-context"; +import { useChannels, useMessageSearch } from "@/hooks"; import { aiTone } from "@/lib/ai-status"; +import type { ConstellationGraph } from "@/lib/constellation/graph"; import { formatDuration, getMessageChannelLabel, renderMessageContent, } from "@/lib/format"; +function analysisGraph( + channels: + | { + channel_id: string; + channel_name?: string | null; + total_messages: number; + }[] + | undefined, +): ConstellationGraph { + const rows = (channels ?? []).slice(0, 10); + const maxMsg = rows.reduce((m, c) => Math.max(m, c.total_messages), 0); + const clamp = (n: number) => (maxMsg <= 0 ? 0.3 : Math.max(0.15, n / maxMsg)); + return { + nodes: [ + { id: "hub", label: "analysis", kind: "guild", value: 1 }, + ...rows.map((c) => ({ + id: `channel:${c.channel_id}`, + label: c.channel_name ?? c.channel_id.slice(0, 8), + kind: "channel" as const, + href: "/channels/", + value: clamp(c.total_messages), + })), + ], + edges: rows.map((c) => ({ + source: "hub", + target: `channel:${c.channel_id}`, + })), + }; +} + export function AnalysisView() { const [query, setQuery] = useState(""); const search = useMessageSearch(query, query.trim().length >= 2); - const { data: reactors } = useTopReactors(); const { data: channels } = useChannels(); const ambient = useAmbient(); + const publish = useScenePublish(); + + const graph = useMemo(() => analysisGraph(channels), [channels]); + + useEffect(() => { + publish({ graph, focus: null }); + }, [graph, publish]); + + useEffect( + () => () => publish({ graph: { nodes: [], edges: [] }, focus: null }), + [publish], + ); useEffect(() => { ambient.set( @@ -29,52 +77,50 @@ export function AnalysisView() { }, [query, ambient]); return ( -
- -
-
- -
-
Semantic search
-

- Search the archive -

-
-
-
- - + {/* Search console — left */} +
+

+ Search the archive +

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

+ minimal 2 karakter… +

+ ) : null} +
-
- - - {(search.data ?? []).length} - - } - /> - {query.trim().length >= 2 && search.isLoading && ( - - )} - {(search.data ?? []).length === 0 ? ( + {/* Results dossier — right */} +
+
+ matches + + {(search.data ?? []).length} + +
+
+ {query.trim().length >= 2 && search.isLoading ? ( + + ) : (search.data ?? []).length === 0 ? ( } + icon={} title="No matches yet" description="Run a search to surface messages across the guild." /> @@ -83,27 +129,27 @@ export function AnalysisView() { {(search.data ?? []).map((m) => (
- +
- + {m.username} - + {getMessageChannelLabel(m)} - {m.ai_status && ( + {m.ai_status ? ( {m.ai_analysis_duration_ms && m.ai_analysis_duration_ms > 0 ? `${m.ai_status} · ${formatDuration(m.ai_analysis_duration_ms)}` : m.ai_status} - )} + ) : null}
-
+
{renderMessageContent(m.content, m.metadata) || "(embed)"}
@@ -111,86 +157,14 @@ export function AnalysisView() { ))}
)} - - -
- - - Top reactors - - } - /> -
- {(reactors ?? []).slice(0, 6).map((r, i) => { - const maxNet = reactors?.[0]?.net_count || 1; - const pct = Math.max( - 4, - Math.round((r.net_count / maxNet) * 100), - ); - return ( -
- {i + 1} - - {r.username} - -
-
-
- - +{r.net_count} - -
- ); - })} - {(reactors ?? []).length === 0 && ( -
- No data -
- )} -
- - - - - Top channels - - } - /> -
- {(channels ?? []).slice(0, 6).map((c) => ( -
- - {c.channel_name ?? c.channel_id.slice(0, 8)} - - - {c.total_messages} - -
- ))} - {(channels ?? []).length === 0 && ( -
- No data -
- )} -
-
-
+
+ + {/* Channels hint — bottom-left */} +

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

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

Glossary · {filtered.length} terms

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

+ klik sebuah satelit untuk membuka definisi +

+ )} +
+ ); }