revert(frontend): kembalikan shell usable — hapus total eksperimen constellation (three/d3-force dihapus)
This commit is contained in:
@@ -1,72 +1,24 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Analysis scene — semantic search lives in a floating console (left);
|
||||
* results stream into a translucent dossier (right). The stage shows an
|
||||
* analysis hub wired to the guild's top channels.
|
||||
*/
|
||||
import { Hash, Search, Sparkles } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Hash, Search, Sparkles, TrendingUp } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { Avatar, Badge } from "@/components/primitives";
|
||||
import { EmptyState, SkeletonRows } from "@/components/shared";
|
||||
import { useScenePublish } from "@/components/shell/scene-graph-context";
|
||||
import { useChannels, useMessageSearch } from "@/hooks";
|
||||
import { Avatar, Badge, GlassPanel, Input } from "@/components/primitives";
|
||||
import { EmptyState, SectionHeader, SkeletonRows } from "@/components/shared";
|
||||
import { useChannels, useMessageSearch, useTopReactors } from "@/hooks";
|
||||
import { aiTone } from "@/lib/ai-status";
|
||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
||||
import {
|
||||
formatDuration,
|
||||
getMessageChannelLabel,
|
||||
renderMessageContent,
|
||||
} from "@/lib/format";
|
||||
|
||||
function analysisGraph(
|
||||
channels:
|
||||
| {
|
||||
channel_id: string;
|
||||
channel_name?: string | null;
|
||||
total_messages: number;
|
||||
}[]
|
||||
| undefined,
|
||||
): ConstellationGraph {
|
||||
const rows = (channels ?? []).slice(0, 10);
|
||||
const maxMsg = rows.reduce((m, c) => Math.max(m, c.total_messages), 0);
|
||||
const clamp = (n: number) => (maxMsg <= 0 ? 0.3 : Math.max(0.15, n / maxMsg));
|
||||
return {
|
||||
nodes: [
|
||||
{ id: "hub", label: "analysis", kind: "guild", value: 1 },
|
||||
...rows.map((c) => ({
|
||||
id: `channel:${c.channel_id}`,
|
||||
label: c.channel_name ?? c.channel_id.slice(0, 8),
|
||||
kind: "channel" as const,
|
||||
href: "/channels/",
|
||||
value: clamp(c.total_messages),
|
||||
})),
|
||||
],
|
||||
edges: rows.map((c) => ({
|
||||
source: "hub",
|
||||
target: `channel:${c.channel_id}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function AnalysisView() {
|
||||
const [query, setQuery] = useState("");
|
||||
const search = useMessageSearch(query, query.trim().length >= 2);
|
||||
const { data: reactors } = useTopReactors();
|
||||
const { data: channels } = useChannels();
|
||||
const ambient = useAmbient();
|
||||
const publish = useScenePublish();
|
||||
|
||||
const graph = useMemo(() => analysisGraph(channels), [channels]);
|
||||
|
||||
useEffect(() => {
|
||||
publish({ graph, focus: null });
|
||||
}, [graph, publish]);
|
||||
|
||||
useEffect(
|
||||
() => () => publish({ graph: { nodes: [], edges: [] }, focus: null }),
|
||||
[publish],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
ambient.set(
|
||||
@@ -77,50 +29,52 @@ export function AnalysisView() {
|
||||
}, [query, ambient]);
|
||||
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
{/* Search console — left */}
|
||||
<section
|
||||
className="pointer-events-auto absolute left-5 top-16 w-[min(26rem,88vw)]"
|
||||
aria-label="Semantic search"
|
||||
>
|
||||
<p className="eyebrow mb-2 flex items-center gap-1.5">
|
||||
<Sparkles className="size-3.5 text-signal" /> Search the archive
|
||||
</p>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-faint)]" />
|
||||
<input
|
||||
type="search"
|
||||
<div className="space-y-5">
|
||||
<GlassPanel glow className="relative overflow-hidden">
|
||||
<div className="scan-line absolute inset-x-0 top-0" />
|
||||
<div className="flex items-center gap-3">
|
||||
<Sparkles className="size-5 text-signal" />
|
||||
<div>
|
||||
<div className="eyebrow">Semantic search</div>
|
||||
<h2 className="display text-balance text-2xl text-ink">
|
||||
Search the archive
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<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}
|
||||
onChange={(e) => 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)]"
|
||||
/>
|
||||
</div>
|
||||
{query.trim().length > 0 && query.trim().length < 2 ? (
|
||||
<p className="mt-2 font-mono text-xs text-[var(--color-ink-faint)]">
|
||||
minimal 2 karakter…
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
{query.trim().length > 0 && query.trim().length < 2 && (
|
||||
<div className="mono mt-2 text-xs text-ink-faint">
|
||||
Type at least 2 characters…
|
||||
</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
|
||||
{/* Results dossier — right */}
|
||||
<section
|
||||
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"
|
||||
aria-label="Search results"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] px-4 py-2.5">
|
||||
<span className="eyebrow">matches</span>
|
||||
<span className="font-mono text-xs text-[var(--color-ink-faint)]">
|
||||
{(search.data ?? []).length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-y-auto p-3">
|
||||
{query.trim().length >= 2 && search.isLoading ? (
|
||||
<SkeletonRows rows={5} />
|
||||
) : (search.data ?? []).length === 0 ? (
|
||||
<div className="grid gap-5 lg:grid-cols-5">
|
||||
<GlassPanel className="lg:col-span-3">
|
||||
<SectionHeader
|
||||
eyebrow="results"
|
||||
title="Matches"
|
||||
action={
|
||||
<span className="mono text-xs text-ink-faint">
|
||||
{(search.data ?? []).length}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{query.trim().length >= 2 && search.isLoading && (
|
||||
<SkeletonRows rows={6} />
|
||||
)}
|
||||
{(search.data ?? []).length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Search className="size-6" />}
|
||||
icon={<Search className="size-7" />}
|
||||
title="No matches yet"
|
||||
description="Run a search to surface messages across the guild."
|
||||
/>
|
||||
@@ -129,27 +83,27 @@ export function AnalysisView() {
|
||||
{(search.data ?? []).map((m) => (
|
||||
<div
|
||||
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="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}
|
||||
</span>
|
||||
<span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
||||
<span className="mono text-[0.65rem] text-ink-faint">
|
||||
{getMessageChannelLabel(m)}
|
||||
</span>
|
||||
{m.ai_status ? (
|
||||
{m.ai_status && (
|
||||
<Badge tone={aiTone(m.ai_status)} className="ml-auto">
|
||||
{m.ai_analysis_duration_ms &&
|
||||
m.ai_analysis_duration_ms > 0
|
||||
? `${m.ai_status} · ${formatDuration(m.ai_analysis_duration_ms)}`
|
||||
: m.ai_status}
|
||||
</Badge>
|
||||
) : null}
|
||||
)}
|
||||
</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)"}
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,14 +111,86 @@ export function AnalysisView() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</GlassPanel>
|
||||
|
||||
{/* Channels hint — bottom-left */}
|
||||
<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">
|
||||
<Hash className="size-3.5" /> {(channels ?? []).length} channels
|
||||
terhubung ke hub analitik — klik untuk membuka konstelasi channel
|
||||
</p>
|
||||
<div className="space-y-5 lg:col-span-2">
|
||||
<GlassPanel>
|
||||
<SectionHeader
|
||||
eyebrow="culture"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Channels scene — every channel is a star on the stage; clicking a star
|
||||
* opens its floating culture dossier here. Search filters the sky.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChannelCultureGlossary } from "@/components/ChannelCultureGlossary";
|
||||
import { SkeletonPanel } from "@/components/shared";
|
||||
import {
|
||||
useSceneFocusSetter,
|
||||
useSceneGraph,
|
||||
useScenePublish,
|
||||
} from "@/components/shell/scene-graph-context";
|
||||
import { useChannelCultures } from "@/hooks";
|
||||
import { culturesToGraph } from "@/lib/constellation/graph";
|
||||
import type { ChannelCultureRow } from "@/lib/types";
|
||||
|
||||
export function ChannelsView({
|
||||
@@ -21,92 +11,12 @@ export function ChannelsView({
|
||||
initialCultures?: ChannelCultureRow[];
|
||||
}) {
|
||||
const { data: cultures } = useChannelCultures(100, initialCultures);
|
||||
const publish = useScenePublish();
|
||||
const setFocus = useSceneFocusSetter();
|
||||
const { state } = useSceneGraph();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const rows = cultures ?? [];
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter(
|
||||
(r) =>
|
||||
(r.channel_name ?? r.channel_id).toLowerCase().includes(q) ||
|
||||
(r.culture_summary ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [rows, query]);
|
||||
|
||||
const graph = useMemo(() => culturesToGraph(filtered), [filtered]);
|
||||
|
||||
useEffect(() => {
|
||||
publish({ graph, focus: null });
|
||||
}, [graph, publish]);
|
||||
|
||||
useEffect(() => () => setFocus(null), [setFocus]);
|
||||
|
||||
const selectedId = state?.focus ?? null;
|
||||
const selectedRow = useMemo(
|
||||
() =>
|
||||
selectedId
|
||||
? (rows.find((r) => `channel:${r.channel_id}` === selectedId) ?? null)
|
||||
: null,
|
||||
[rows, selectedId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
{/* Search whisper — top-left */}
|
||||
<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>
|
||||
<div className="space-y-5">
|
||||
{cultures ? (
|
||||
<ChannelCultureGlossary cultures={cultures} />
|
||||
) : (
|
||||
<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>
|
||||
<SkeletonPanel rows={6} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Dashboard scene — guild star + channel orbit live on the stage canvas;
|
||||
* this overlay adds the metric whisper (top-left), activity ribbon
|
||||
* (bottom-left), and moderation gauge (right) as floating panels.
|
||||
*/
|
||||
import { Activity, Flag, ShieldAlert } from "lucide-react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Flag,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
Radio,
|
||||
ShieldAlert,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { AreaActivity, RadialGauge } from "@/components/charts";
|
||||
import { ErrorState, LoadingState } from "@/components/shared";
|
||||
import { useScenePublish } from "@/components/shell/scene-graph-context";
|
||||
import { useActivity, useStats } from "@/hooks";
|
||||
import { statsToGraph } from "@/lib/constellation/graph";
|
||||
import { GlassPanel } from "@/components/primitives";
|
||||
import {
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
SkeletonHero,
|
||||
SkeletonMetricRow,
|
||||
SkeletonPanel,
|
||||
} from "@/components/shared";
|
||||
import { MetricTile, SectionHeader } from "@/components/shared/section";
|
||||
import {
|
||||
useActivity,
|
||||
useStats,
|
||||
useTopReactions,
|
||||
useTopReactors,
|
||||
} from "@/hooks";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import type { DashboardStats } from "@/lib/types";
|
||||
import { staggerDelay } from "@/lib/utils";
|
||||
@@ -36,19 +50,11 @@ export function DashboardView({
|
||||
initialStats?: DashboardStats;
|
||||
initialActivity?: Awaited<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: reactors } = useTopReactors();
|
||||
const { data: reactions } = useTopReactions();
|
||||
const ambient = useAmbient();
|
||||
const publish = useScenePublish();
|
||||
|
||||
const graph = useMemo(
|
||||
() => (stats ? statsToGraph(stats) : { nodes: [], edges: [] }),
|
||||
[stats],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
publish({ graph, focus: "guild" });
|
||||
}, [graph, publish]);
|
||||
|
||||
useEffect(() => {
|
||||
const s = deriveSignal(stats);
|
||||
@@ -60,134 +66,303 @@ export function DashboardView({
|
||||
}, [stats, ambient]);
|
||||
|
||||
if (error && !stats) return <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 total = s.total_flagged + s.total_clean || 1;
|
||||
const cleanRatio = s.total_clean / total;
|
||||
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
{/* Metric whisper — top-left under brand */}
|
||||
<section
|
||||
className="pointer-events-auto absolute left-5 top-16 w-64 animate-stagger"
|
||||
style={staggerDelay(0)}
|
||||
aria-label="Key metrics"
|
||||
>
|
||||
<p className="eyebrow mb-2">Operations · {deriveSignal(s).label}</p>
|
||||
<div className="space-y-1.5">
|
||||
<WhisperRow
|
||||
label="messages"
|
||||
<div className="space-y-5">
|
||||
{/* Hero */}
|
||||
<GlassPanel glow className="relative overflow-hidden">
|
||||
<div className="scan-line absolute inset-x-0 top-0" />
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="eyebrow mb-2">GMW · Operations Grid</div>
|
||||
<h2 className="display hero-clamp leading-none text-ink glow-signal">
|
||||
Ambient Field
|
||||
</h2>
|
||||
<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)}
|
||||
tone="ink"
|
||||
tone="signal"
|
||||
icon={<MessageSquare className="size-3.5" />}
|
||||
className="animate-stagger"
|
||||
style={staggerDelay(0)}
|
||||
/>
|
||||
<WhisperRow
|
||||
label="flagged"
|
||||
<MetricTile
|
||||
label="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`}
|
||||
className="animate-stagger"
|
||||
style={staggerDelay(1)}
|
||||
/>
|
||||
<WhisperRow
|
||||
label="active 24h"
|
||||
<MetricTile
|
||||
label="Active 24h"
|
||||
value={formatNumber(s.active_users_24h)}
|
||||
tone="signal"
|
||||
icon={<Users className="size-3.5" />}
|
||||
className="animate-stagger"
|
||||
style={staggerDelay(2)}
|
||||
/>
|
||||
<WhisperRow
|
||||
label="voice clips"
|
||||
<MetricTile
|
||||
label="Voice clips"
|
||||
value={formatNumber(s.total_voice_recordings)}
|
||||
tone="ink"
|
||||
icon={<Mic className="size-3.5" />}
|
||||
className="animate-stagger"
|
||||
style={staggerDelay(3)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</GlassPanel>
|
||||
|
||||
{/* Activity ribbon — bottom-left */}
|
||||
<section
|
||||
className="pointer-events-auto absolute bottom-20 left-5 hidden w-80 lg:block"
|
||||
aria-label="Activity"
|
||||
>
|
||||
<p className="eyebrow mb-1 flex items-center gap-1.5">
|
||||
<Activity className="size-3.5 text-signal" /> 14-day signal
|
||||
</p>
|
||||
{/* Activity */}
|
||||
<GlassPanel>
|
||||
<SectionHeader
|
||||
eyebrow="14-day signal"
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Activity className="size-4 text-signal" /> Activity & moderation
|
||||
</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 ? (
|
||||
<AreaActivity daily={activity.daily} />
|
||||
) : (
|
||||
<LoadingState label="streaming" />
|
||||
)}
|
||||
</section>
|
||||
</GlassPanel>
|
||||
|
||||
{/* Moderation gauge — right */}
|
||||
<section
|
||||
className="pointer-events-auto absolute right-5 top-16 hidden w-56 md:block"
|
||||
aria-label="Moderation"
|
||||
>
|
||||
<p className="eyebrow mb-2 flex items-center gap-1.5">
|
||||
<ShieldAlert className="size-3.5 text-signal" /> Trust
|
||||
</p>
|
||||
<RadialGauge
|
||||
value={cleanRatio}
|
||||
tone={
|
||||
cleanRatio > 0.8
|
||||
? "signal"
|
||||
: cleanRatio > 0.6
|
||||
? "amber"
|
||||
: "vermilion"
|
||||
}
|
||||
label={`${Math.round(cleanRatio * 100)}%`}
|
||||
sublabel="clean"
|
||||
/>
|
||||
<div className="mt-3 space-y-1 font-mono text-xs text-[var(--color-ink-soft)]">
|
||||
<p className="flex items-center justify-between">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Flag className="size-3 text-vermilion" /> flagged
|
||||
</span>
|
||||
<span>{formatNumber(s.total_flagged)}</span>
|
||||
</p>
|
||||
<p className="flex items-center justify-between">
|
||||
<span>warned</span>
|
||||
<span>{formatNumber(s.total_warned)}</span>
|
||||
</p>
|
||||
<p className="flex items-center justify-between">
|
||||
<span>pending</span>
|
||||
<span>{s.moderation_overview.pending}</span>
|
||||
</p>
|
||||
<p className="flex items-center justify-between">
|
||||
<span>processing</span>
|
||||
<span>{s.moderation_overview.processing}</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
{/* Two-column: channels + moderation */}
|
||||
<div className="grid gap-5 lg:grid-cols-5">
|
||||
<GlassPanel className="lg:col-span-3">
|
||||
<SectionHeader eyebrow="throughput" title="Top channels" />
|
||||
<div className="space-y-2.5">
|
||||
{s.top_channels.slice(0, 7).map((c) => {
|
||||
const pct =
|
||||
(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
|
||||
value={cleanRatio}
|
||||
tone={
|
||||
cleanRatio > 0.8
|
||||
? "signal"
|
||||
: cleanRatio > 0.6
|
||||
? "amber"
|
||||
: "vermilion"
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
value,
|
||||
tone,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone: "ink" | "signal" | "vermilion";
|
||||
hint?: string;
|
||||
value: number;
|
||||
tone: "signal" | "amber" | "vermilion";
|
||||
}) {
|
||||
const color =
|
||||
tone === "vermilion"
|
||||
? "text-vermilion"
|
||||
: tone === "signal"
|
||||
? "text-signal"
|
||||
: "text-ink";
|
||||
: tone === "amber"
|
||||
? "text-amber"
|
||||
: "text-signal";
|
||||
return (
|
||||
<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>
|
||||
{hint ? (
|
||||
<span className="font-mono text-[10px] text-[var(--color-ink-faint)]">
|
||||
{hint}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
<div>
|
||||
<div className={`display text-xl ${color}`}>{value}</div>
|
||||
<div className="eyebrow mt-0.5">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyHint() {
|
||||
return (
|
||||
<div className="py-6 text-center text-xs text-ink-faint">
|
||||
Awaiting data…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,118 +1,15 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Glossary scene — term KB as a satellite ring on the stage; the search
|
||||
* whisper filters nodes live, and a term's definition floats in a dossier.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { SkeletonPanel } from "@/components/shared";
|
||||
import {
|
||||
useSceneFocusSetter,
|
||||
useSceneGraph,
|
||||
useScenePublish,
|
||||
} from "@/components/shell/scene-graph-context";
|
||||
import { TermGlossary } from "@/components/TermGlossary";
|
||||
import { useGlossary } from "@/hooks";
|
||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
||||
import type { GlossaryRow } from "@/lib/types";
|
||||
|
||||
/** Terms → satellite ring (deterministic positions come from layout). */
|
||||
function termsToGraph(rows: GlossaryRow[]): ConstellationGraph {
|
||||
return {
|
||||
nodes: rows.slice(0, 60).map((t) => ({
|
||||
id: `term:${t.term}`,
|
||||
label: t.term,
|
||||
kind: "term" as const,
|
||||
value: Math.min(1, 0.3 + t.definition.length / 400),
|
||||
})),
|
||||
edges: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function GlossaryView({
|
||||
initialTerms,
|
||||
}: {
|
||||
initialTerms?: GlossaryRow[];
|
||||
}) {
|
||||
const { data: terms } = useGlossary(100, initialTerms);
|
||||
const publish = useScenePublish();
|
||||
const setFocus = useSceneFocusSetter();
|
||||
const { state } = useSceneGraph();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const rows = terms ?? [];
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter(
|
||||
(t) =>
|
||||
t.term.toLowerCase().includes(q) ||
|
||||
t.definition.toLowerCase().includes(q),
|
||||
);
|
||||
}, [rows, query]);
|
||||
|
||||
const graph = useMemo(() => termsToGraph(filtered), [filtered]);
|
||||
|
||||
useEffect(() => {
|
||||
publish({ graph, focus: null });
|
||||
}, [graph, publish]);
|
||||
|
||||
useEffect(() => () => setFocus(null), [setFocus]);
|
||||
|
||||
const selectedId = state?.focus ?? null;
|
||||
const selectedTerm = useMemo(
|
||||
() =>
|
||||
selectedId
|
||||
? (rows.find((t) => `term:${t.term}` === selectedId) ?? null)
|
||||
: null,
|
||||
[rows, selectedId],
|
||||
);
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
return terms ? <TermGlossary terms={terms} /> : <SkeletonPanel rows={6} />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AmbientProvider } from "@/components/ambient/ambient-context";
|
||||
import { Chatbot } from "@/components/chatbot/chatbot";
|
||||
import { CommandPalette } from "@/components/command/command-palette";
|
||||
import { ConstellationFrame } from "@/components/shell";
|
||||
import { AppFrame } from "@/components/shell";
|
||||
import { WsProvider } from "@/lib/ws/context";
|
||||
|
||||
export default function DashboardLayout({
|
||||
@@ -10,7 +10,7 @@ export default function DashboardLayout({
|
||||
return (
|
||||
<AmbientProvider>
|
||||
<WsProvider>
|
||||
<ConstellationFrame>{children}</ConstellationFrame>
|
||||
<AppFrame>{children}</AppFrame>
|
||||
<Chatbot />
|
||||
<CommandPalette />
|
||||
</WsProvider>
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Media scene — the queue becomes a spiral of nodes on the stage; the
|
||||
* player console (disc + transport + URL bar) floats bottom-left, and
|
||||
* the up-next list docks right.
|
||||
*/
|
||||
import {
|
||||
ListMusic,
|
||||
Play,
|
||||
@@ -14,14 +9,15 @@ import {
|
||||
Square,
|
||||
Volume2,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { Button, Input, toast } from "@/components/primitives";
|
||||
import { ErrorState, SkeletonHero } from "@/components/shared";
|
||||
import { Button, GlassPanel, Input, toast } from "@/components/primitives";
|
||||
import {
|
||||
useSceneFocusSetter,
|
||||
useScenePublish,
|
||||
} from "@/components/shell/scene-graph-context";
|
||||
ErrorState,
|
||||
SectionHeader,
|
||||
SkeletonHero,
|
||||
SkeletonPanel,
|
||||
} from "@/components/shared";
|
||||
import {
|
||||
useMediaLoop,
|
||||
useMediaQueue,
|
||||
@@ -30,37 +26,11 @@ import {
|
||||
useMediaStop,
|
||||
useMediaWsSync,
|
||||
} from "@/hooks";
|
||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
||||
import { formatDuration } from "@/lib/format";
|
||||
import type { MediaState } from "@/lib/types";
|
||||
import { staggerDelay } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
function queueGraph(media: MediaState | undefined): ConstellationGraph {
|
||||
const items = (media?.queue ?? []).slice(0, 24);
|
||||
return {
|
||||
nodes: [
|
||||
{
|
||||
id: "now-playing",
|
||||
label: media?.current?.title ?? "media",
|
||||
kind: "guild",
|
||||
value: 1,
|
||||
},
|
||||
...items.map((it, i) => ({
|
||||
id: `track:${i}:${it.source}`,
|
||||
label: it.title,
|
||||
kind: "media" as const,
|
||||
value: Math.max(0.2, 0.8 - i * 0.05),
|
||||
href: undefined,
|
||||
})),
|
||||
],
|
||||
edges: items.map((it, i) => ({
|
||||
source: "now-playing",
|
||||
target: `track:${i}:${it.source}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||
const ws = useWebSocket();
|
||||
const { data: media, isLoading, error } = useMediaState(initialStatus);
|
||||
@@ -70,8 +40,6 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||
const loop = useMediaLoop();
|
||||
useMediaWsSync(ws);
|
||||
const ambient = useAmbient();
|
||||
const publish = useScenePublish();
|
||||
const setFocus = useSceneFocusSetter();
|
||||
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
@@ -83,19 +51,6 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||
0,
|
||||
);
|
||||
|
||||
const graph = useMemo(() => queueGraph(media), [media]);
|
||||
useEffect(() => {
|
||||
publish({ graph, focus: null });
|
||||
}, [graph, publish]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
publish({ graph: { nodes: [], edges: [] }, focus: null });
|
||||
setFocus(null);
|
||||
},
|
||||
[publish, setFocus],
|
||||
);
|
||||
|
||||
const tone = playing ? "signal" : queueList.length ? "amber" : "signal";
|
||||
useEffect(() => {
|
||||
ambient.set(
|
||||
@@ -125,105 +80,126 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||
};
|
||||
|
||||
if (error && !media) return <ErrorState error={error} />;
|
||||
if (!media && isLoading) return <SkeletonHero />;
|
||||
if (!media && isLoading)
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<SkeletonHero />
|
||||
<SkeletonPanel rows={4} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
{/* Now playing — top-center whisper */}
|
||||
<section
|
||||
className="pointer-events-none absolute inset-x-0 top-16 hidden justify-center px-6 md:flex"
|
||||
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 className="space-y-5">
|
||||
<GlassPanel glow className="relative overflow-hidden">
|
||||
<div className="scan-line absolute inset-x-0 top-0" />
|
||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-center">
|
||||
<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"}`}
|
||||
>
|
||||
{current?.thumbnailUrl ? (
|
||||
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
|
||||
<img
|
||||
src={current.thumbnailUrl}
|
||||
alt=""
|
||||
className="size-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<ListMusic className="size-4 text-signal" />
|
||||
)}
|
||||
<div className="flex size-28 items-center justify-center overflow-hidden rounded-full bg-canvas/60">
|
||||
{current?.thumbnailUrl ? (
|
||||
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
|
||||
<img
|
||||
src={current.thumbnailUrl}
|
||||
alt=""
|
||||
className="size-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<ListMusic className="size-10 text-signal" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="eyebrow flex items-center gap-2">
|
||||
{playing ? (
|
||||
<span className="flex h-3 items-end gap-[2px]" aria-hidden>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={`eq-${i}`}
|
||||
className="w-[3px] animate-eq rounded-full bg-signal"
|
||||
style={{ animationDelay: `${i * 160}ms`, height: "100%" }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
{playing ? "Now playing" : current ? "Paused" : "Nothing queued"}
|
||||
</p>
|
||||
<p className="truncate text-sm text-[var(--color-ink)]">
|
||||
{current?.title ?? "Nothing queued"}
|
||||
{formatDuration(current?.durationMs)
|
||||
? ` · ${formatDuration(current?.durationMs)}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</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 className="min-w-0 flex-1">
|
||||
<div className="eyebrow mb-1 flex items-center gap-2">
|
||||
{playing ? (
|
||||
<>
|
||||
<span aria-hidden className="flex h-3 items-end gap-[2px]">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={`eq-${i}`}
|
||||
className="w-[3px] animate-eq rounded-full bg-signal"
|
||||
style={{
|
||||
animationDelay: `${i * 160}ms`,
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="text-signal">Now playing</span>
|
||||
</>
|
||||
) : current ? (
|
||||
"Paused"
|
||||
) : (
|
||||
"Nothing queued"
|
||||
)}
|
||||
</div>
|
||||
<h2 className="display text-balance text-2xl text-ink">
|
||||
{current?.title ?? "Nothing queued"}
|
||||
</h2>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-faint">
|
||||
{current?.source && (
|
||||
<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 className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onPlay}
|
||||
disabled={queue.isPending}
|
||||
>
|
||||
<Play className="size-4" /> Queue & play
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => skip.mutate()}
|
||||
disabled={skip.isPending}
|
||||
>
|
||||
<SkipForward className="size-4" /> Skip
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => stop.mutate()}
|
||||
disabled={stop.isPending}
|
||||
>
|
||||
<Square className="size-4" /> Stop
|
||||
</Button>
|
||||
<Button
|
||||
variant={media?.loop ? "primary" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => loop.mutate(!media?.loop)}
|
||||
aria-pressed={!!media?.loop}
|
||||
>
|
||||
<Repeat className="size-4" /> Loop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onPlay}
|
||||
disabled={queue.isPending}
|
||||
>
|
||||
<Play className="size-4" /> Queue & play
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => skip.mutate()}
|
||||
disabled={skip.isPending}
|
||||
>
|
||||
<SkipForward className="size-4" /> Skip
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => stop.mutate()}
|
||||
disabled={stop.isPending}
|
||||
>
|
||||
<Square className="size-4" /> Stop
|
||||
</Button>
|
||||
<Button
|
||||
variant={media?.loop ? "primary" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => loop.mutate(!media?.loop)}
|
||||
aria-pressed={!!media?.loop}
|
||||
>
|
||||
<Repeat className="size-4" /> Loop
|
||||
</Button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Volume2 className="size-4 text-[var(--color-ink-faint)]" />
|
||||
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-white/10">
|
||||
|
||||
<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
|
||||
className="h-full rounded-full bg-signal/70"
|
||||
style={{
|
||||
@@ -231,89 +207,87 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||
}}
|
||||
/>
|
||||
</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)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</GlassPanel>
|
||||
|
||||
{/* Up next — right dock */}
|
||||
<section
|
||||
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"
|
||||
aria-label="Up next"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] px-4 py-2.5">
|
||||
<span className="eyebrow">up next</span>
|
||||
<span className="font-mono text-xs text-[var(--color-ink-faint)]">
|
||||
{queueList.length} track{queueList.length === 1 ? "" : "s"}
|
||||
{queueTotal ? ` · ${formatDuration(queueTotal)}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{queueList.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
||||
<Radio className="size-6 text-[var(--color-ink-faint)]" />
|
||||
<p className="text-sm text-[var(--color-ink-soft)]">
|
||||
Queue is empty
|
||||
</p>
|
||||
<p className="font-mono text-xs text-[var(--color-ink-faint)]">
|
||||
Paste a URL to start playback.
|
||||
</p>
|
||||
<GlassPanel>
|
||||
<SectionHeader
|
||||
eyebrow="up next"
|
||||
title="Queue"
|
||||
action={
|
||||
<span className="mono text-xs text-ink-faint">
|
||||
{queueList.length} track{queueList.length === 1 ? "" : "s"}
|
||||
{queueTotal && ` · ${formatDuration(queueTotal)}`}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{queueList.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
||||
<Radio className="size-6 text-ink-faint" />
|
||||
<div className="text-sm text-ink-soft">Queue is empty</div>
|
||||
<div className="text-xs text-ink-faint">
|
||||
Paste a URL above to start playback.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{queueList.map((item, i) => {
|
||||
const isNext = i === 0 && playing;
|
||||
return (
|
||||
<div
|
||||
key={`${item.source}-${i}`}
|
||||
className={`animate-stagger flex items-center gap-3 rounded-xl border px-3 py-2 ${
|
||||
isNext
|
||||
? "border-signal/40 bg-signal/[0.07]"
|
||||
: "border-[var(--color-hairline)]"
|
||||
}`}
|
||||
style={staggerDelay(i)}
|
||||
>
|
||||
<span className="w-5 font-mono text-xs text-[var(--color-ink-faint)]">
|
||||
{i + 1}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{queueList.map((item, i) => {
|
||||
const isNext = i === 0 && playing;
|
||||
return (
|
||||
<div
|
||||
key={`${item.source}-${i}`}
|
||||
className={`animate-stagger flex items-center gap-3 rounded-[10px] border px-3 py-2.5 ${
|
||||
isNext
|
||||
? "border-signal/40 bg-signal/[0.07]"
|
||||
: "border-hairline bg-white/5"
|
||||
}`}
|
||||
style={staggerDelay(i)}
|
||||
>
|
||||
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||
{item.thumbnailUrl ? (
|
||||
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
|
||||
<img
|
||||
src={item.thumbnailUrl}
|
||||
alt=""
|
||||
className="size-9 shrink-0 rounded-md object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border border-hairline bg-white/5">
|
||||
<ListMusic className="size-4 text-ink-faint" />
|
||||
</span>
|
||||
{item.thumbnailUrl ? (
|
||||
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
|
||||
<img
|
||||
src={item.thumbnailUrl}
|
||||
alt=""
|
||||
className="size-9 shrink-0 rounded-md object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border border-[var(--color-hairline)]">
|
||||
<ListMusic className="size-4 text-[var(--color-ink-faint)]" />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-[var(--color-ink)]">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="truncate font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
||||
{item.source}
|
||||
</p>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-ink">
|
||||
{item.title}
|
||||
</div>
|
||||
{isNext ? (
|
||||
<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">
|
||||
up next
|
||||
</span>
|
||||
) : null}
|
||||
<span className="w-10 text-right font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
||||
<div className="mono truncate text-[0.65rem] text-ink-faint">
|
||||
{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
|
||||
</span>
|
||||
)}
|
||||
<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)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Messages scene — the live feed becomes an orbital belt of nodes on the
|
||||
* stage; the functional console (picker/search/modes) floats top-left,
|
||||
* the stream itself is a translucent dossier column, and a selected
|
||||
* message opens its inspection dossier bottom-center.
|
||||
*/
|
||||
import {
|
||||
AlertTriangle,
|
||||
Calendar,
|
||||
@@ -21,13 +15,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ActivityHeatmap } from "@/components/ActivityHeatmap";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { EditHistory } from "@/components/EditHistory";
|
||||
import { Avatar, Badge, Skeleton } from "@/components/primitives";
|
||||
import { EmptyState, ErrorState, SkeletonRows } from "@/components/shared";
|
||||
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
||||
import {
|
||||
useSceneFocusSetter,
|
||||
useScenePublish,
|
||||
} from "@/components/shell/scene-graph-context";
|
||||
Avatar,
|
||||
Badge,
|
||||
GlassPanel,
|
||||
Input,
|
||||
Skeleton,
|
||||
} from "@/components/primitives";
|
||||
import {
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
SectionHeader,
|
||||
SkeletonRows,
|
||||
} from "@/components/shared";
|
||||
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
||||
import {
|
||||
useLoadMore,
|
||||
useMessageActivity,
|
||||
@@ -41,7 +42,6 @@ import {
|
||||
useSemanticSearch,
|
||||
} from "@/hooks";
|
||||
import { aiTone } from "@/lib/ai-status";
|
||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
||||
import {
|
||||
formatBytes,
|
||||
formatDuration,
|
||||
@@ -59,24 +59,6 @@ import type {
|
||||
import { staggerDelay } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
/** Recent messages → orbital belt (chained edges give the belt shape). */
|
||||
function beltGraph(list: MessageRecord[]): ConstellationGraph {
|
||||
const recent = list.slice(0, 36);
|
||||
const nodes = recent.map((m, i) => ({
|
||||
id: `msg:${m.id}`,
|
||||
label: m.username,
|
||||
kind:
|
||||
m.ai_status === "flagged" ? ("flagged" as const) : ("message" as const),
|
||||
value: Math.max(0.15, 1 - i / Math.max(1, recent.length)),
|
||||
href: undefined,
|
||||
}));
|
||||
const edges = nodes.slice(0, -1).map((n, i) => ({
|
||||
source: n.id,
|
||||
target: nodes[i + 1]?.id ?? n.id,
|
||||
}));
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
export function MessagesView({
|
||||
initialGuilds,
|
||||
initialGuildId,
|
||||
@@ -98,11 +80,15 @@ export function MessagesView({
|
||||
const [channelId, setChannelId] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
// Search mode: "exact" (substring match over captured messages) or
|
||||
// "semantic" (vector similarity over the persistent Qdrant archive).
|
||||
const [semanticMode, setSemanticMode] = useState(false);
|
||||
// feed | timeline: "timeline" groups messages into date-grouped cards.
|
||||
const [viewMode, setViewMode] = useState<"feed" | "timeline">("feed");
|
||||
// Guard against loading the entire history on a long scroll: cap how many
|
||||
// older pages we append. Each page is 50 messages (backend limit default).
|
||||
const MAX_OLDER_PAGES = 10;
|
||||
const [loadedPages, setLoadedPages] = useState(0);
|
||||
const [showIntel, setShowIntel] = useState(false);
|
||||
|
||||
const {
|
||||
data: messages,
|
||||
@@ -113,7 +99,11 @@ export function MessagesView({
|
||||
channelId ?? undefined,
|
||||
initialMessages ?? undefined,
|
||||
);
|
||||
// Stream history one message per WS frame (replaces the 50-row batched fetch).
|
||||
// Drives snapshots into the SWR list above as they arrive; falls back to the
|
||||
// SSR `initialMessages` seed if WS is unavailable.
|
||||
useMessagesStream(ws, guildId ?? "", channelId ?? undefined);
|
||||
// Cursor to the next (older) page + whether more history exists.
|
||||
const { data: pageInfo } = useMessagesHasMore(
|
||||
guildId ?? "",
|
||||
channelId ?? undefined,
|
||||
@@ -134,12 +124,10 @@ export function MessagesView({
|
||||
const edits = useRecentEdits(50, undefined, initialEdits);
|
||||
const detail = useMessageDetail(selected);
|
||||
const ambient = useAmbient();
|
||||
const publish = useScenePublish();
|
||||
const setFocus = useSceneFocusSetter();
|
||||
|
||||
const scrollRef = useRef<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 () => {
|
||||
if (!guildId || !hasMore || loadedPages >= MAX_OLDER_PAGES) return;
|
||||
const el = scrollRef.current;
|
||||
@@ -164,32 +152,21 @@ export function MessagesView({
|
||||
const searching = query.trim().length >= 2 && !semanticMode;
|
||||
const semanticSearching = query.trim().length >= 2 && semanticMode;
|
||||
const list = searching ? (search.data ?? []) : (messages ?? []);
|
||||
// Discord-style order: oldest at the top, newest at the bottom. The backend
|
||||
// returns DESC (newest first); reverse so the feed reads top→bottom like DC.
|
||||
const display = useMemo(() => [...list].reverse(), [list]);
|
||||
|
||||
const graph = useMemo(
|
||||
() => (searching ? { nodes: [], edges: [] } : beltGraph(list)),
|
||||
[list, searching],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
publish({ graph, focus: selected ? `msg:${selected}` : null });
|
||||
}, [graph, selected, publish]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
publish({ graph: { nodes: [], edges: [] }, focus: null });
|
||||
setFocus(null);
|
||||
},
|
||||
[publish, setFocus],
|
||||
);
|
||||
|
||||
// Timeline mode: inject date-separator headers above the first message of
|
||||
// each day. Messages are sorted oldest→newest (display is reversed), so a
|
||||
// date change means a new group. Produces an array of either "date" or "msg"
|
||||
// nodes so the render loop can switch easily.
|
||||
const timelineNodes = useMemo(() => {
|
||||
if (viewMode !== "timeline") return null;
|
||||
const out: Array<
|
||||
| { type: "date"; label: string; iso: string }
|
||||
| { type: "msg"; m: (typeof display)[number] }
|
||||
> = [];
|
||||
let prevDate = "";
|
||||
let prev = "";
|
||||
for (const m of display) {
|
||||
const d = new Date(m.created_at).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
@@ -197,15 +174,24 @@ export function MessagesView({
|
||||
day: "numeric",
|
||||
});
|
||||
const iso = new Date(m.created_at).toISOString().slice(0, 10);
|
||||
if (d !== prevDate) {
|
||||
if (d !== prev) {
|
||||
out.push({ type: "date", label: d, iso });
|
||||
prevDate = d;
|
||||
prev = d;
|
||||
}
|
||||
out.push({ type: "msg", m });
|
||||
}
|
||||
return out;
|
||||
}, [display, viewMode]);
|
||||
|
||||
// Ref to the scroll container so we can manage scroll position like Discord:
|
||||
// open at the bottom (newest), keep the viewport stable when prepending older
|
||||
// messages at the top, and follow new live messages only when already near
|
||||
// the bottom.
|
||||
const scrollRef = useRef<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);
|
||||
useEffect(() => {
|
||||
if (firstLoadRef.current && display.length > 0) {
|
||||
@@ -215,6 +201,8 @@ export function MessagesView({
|
||||
}
|
||||
}, [display.length]);
|
||||
|
||||
// When a new live message lands (list grows, still searching off), follow it
|
||||
// to the bottom only if the user was already near the bottom.
|
||||
const prevLen = useRef(list.length);
|
||||
useEffect(() => {
|
||||
if (searching) return;
|
||||
@@ -227,12 +215,8 @@ export function MessagesView({
|
||||
}, [list.length, searching]);
|
||||
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
{/* Console whisper — top-left */}
|
||||
<section
|
||||
className="pointer-events-auto absolute left-5 top-16 z-20 w-[min(22rem,90vw)] space-y-2"
|
||||
aria-label="Stream controls"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<GlassPanel className="flex flex-wrap items-center gap-3">
|
||||
<GuildChannelPicker
|
||||
mode="text"
|
||||
guildsInitial={initialGuilds}
|
||||
@@ -246,79 +230,117 @@ export function MessagesView({
|
||||
firstLoadRef.current = true;
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-faint)]" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
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 className="relative ml-auto w-full sm:w-64">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-ink-faint" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Search messages…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSemanticMode((v) => !v)}
|
||||
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${
|
||||
semanticMode
|
||||
? "border-signal/40 bg-signal/10 text-signal"
|
||||
: "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]"
|
||||
}`}
|
||||
title="Toggle semantic (vector) search over the message archive"
|
||||
>
|
||||
{semanticMode ? "Semantic" : "Exact"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setViewMode((v) => (v === "feed" ? "timeline" : "feed"))
|
||||
}
|
||||
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${
|
||||
viewMode === "timeline"
|
||||
? "border-signal/40 bg-signal/10 text-signal"
|
||||
: "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]"
|
||||
}`}
|
||||
title="Toggle timeline (date-grouped) view"
|
||||
>
|
||||
{viewMode === "timeline" ? "Timeline" : "Feed"}
|
||||
</button>
|
||||
</GlassPanel>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-5">
|
||||
{semanticSearching && (
|
||||
<GlassPanel className="lg:col-span-5">
|
||||
<SectionHeader
|
||||
eyebrow="semantic"
|
||||
title={`“${query}”`}
|
||||
action={
|
||||
<span className="mono text-xs text-ink-faint">
|
||||
{semantic.data?.length ?? 0} matches
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSemanticMode((v) => !v)}
|
||||
className={`rounded-full border px-3 py-1.5 font-mono text-xs transition-colors ${
|
||||
semanticMode
|
||||
? "border-signal/40 bg-signal/10 text-signal"
|
||||
: "border-[var(--color-hairline)] text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
||||
}`}
|
||||
title="Toggle semantic (vector) search over the message archive"
|
||||
>
|
||||
{semanticMode ? "Semantic" : "Exact"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setViewMode((v) => (v === "feed" ? "timeline" : "feed"))
|
||||
{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
|
||||
</span>
|
||||
}
|
||||
className={`rounded-full border px-3 py-1.5 font-mono text-xs transition-colors ${
|
||||
viewMode === "timeline"
|
||||
? "border-signal/40 bg-signal/10 text-signal"
|
||||
: "border-[var(--color-hairline)] text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
||||
}`}
|
||||
title="Toggle timeline (date-grouped) view"
|
||||
>
|
||||
{viewMode === "timeline" ? "Timeline" : "Feed"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stream dossier — right column */}
|
||||
<section
|
||||
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"
|
||||
aria-label="Message stream"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] px-4 py-2.5">
|
||||
<span className="eyebrow">
|
||||
{searching ? `“${query}”` : "live stream"}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-[var(--color-ink-faint)]">
|
||||
{list.length} shown
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden p-2">
|
||||
{semanticSearching ? (
|
||||
<SemanticResults semantic={semantic} />
|
||||
) : error && !messages ? (
|
||||
/>
|
||||
{error && !messages ? (
|
||||
<ErrorState error={error} />
|
||||
) : isLoading && !messages ? (
|
||||
<SkeletonRows rows={8} />
|
||||
) : list.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<MessageSquare className="size-6" />}
|
||||
icon={<MessageSquare className="size-7" />}
|
||||
title="No messages"
|
||||
description="Pick a guild to begin, or run a search."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full flex-col">
|
||||
<div>
|
||||
{!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 ? (
|
||||
<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" />
|
||||
Loading older…
|
||||
</span>
|
||||
@@ -326,19 +348,19 @@ export function MessagesView({
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
) : 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
|
||||
more
|
||||
</span>
|
||||
) : (
|
||||
messages &&
|
||||
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
|
||||
</span>
|
||||
)
|
||||
@@ -347,9 +369,11 @@ export function MessagesView({
|
||||
)}
|
||||
<div
|
||||
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) => {
|
||||
const el = e.currentTarget;
|
||||
// Track whether the user is near the bottom (to follow live
|
||||
// messages) and auto-load older messages when scrolled to top.
|
||||
nearBottomRef.current =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight < 120;
|
||||
if (searching || !hasMore || loadMore.isPending) return;
|
||||
@@ -360,11 +384,11 @@ export function MessagesView({
|
||||
}}
|
||||
>
|
||||
{viewMode === "timeline" && timelineNodes
|
||||
? timelineNodes.map((node) =>
|
||||
? timelineNodes.map((node, _i) =>
|
||||
node.type === "date" ? (
|
||||
<div
|
||||
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" />
|
||||
{node.label}
|
||||
@@ -378,7 +402,7 @@ export function MessagesView({
|
||||
/>
|
||||
),
|
||||
)
|
||||
: display.map((m) => (
|
||||
: display.map((m, _i) => (
|
||||
<MessageRow
|
||||
key={m.id}
|
||||
m={m}
|
||||
@@ -389,49 +413,19 @@ export function MessagesView({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</GlassPanel>
|
||||
|
||||
{/* Mobile stream — full-width sheet under the console */}
|
||||
<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 backdrop-blur-xl md:hidden"
|
||||
aria-label="Message stream mobile"
|
||||
>
|
||||
{error && !messages ? (
|
||||
<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}
|
||||
<GlassPanel className="lg:col-span-2">
|
||||
<SectionHeader eyebrow="inspect" title="Detail" />
|
||||
{!selected ? (
|
||||
<EmptyState
|
||||
title="Select a message"
|
||||
description="Click any message to inspect AI analysis, attachments and edit history."
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</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 ? (
|
||||
) : detail.loading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16" />
|
||||
<Skeleton className="h-10" />
|
||||
<Skeleton className="h-20" />
|
||||
<Skeleton className="h-12" />
|
||||
</div>
|
||||
) : detail.message ? (
|
||||
<MessageDetail
|
||||
@@ -441,71 +435,14 @@ export function MessagesView({
|
||||
) : (
|
||||
<EmptyState title="Not found" />
|
||||
)}
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
{/* Intel strip — bottom-left */}
|
||||
<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} />
|
||||
) : null}
|
||||
{edits.data ? <EditHistory edits={edits.data} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</GlassPanel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SemanticResults({
|
||||
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>
|
||||
))}
|
||||
{activity.data && activity.data.length > 0 && (
|
||||
<ActivityHeatmap buckets={activity.data} />
|
||||
)}
|
||||
|
||||
{edits.data && <EditHistory edits={edits.data} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -556,12 +493,10 @@ function MessageDetail({
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
<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 className="font-semibold text-[var(--color-ink)]">
|
||||
{m.username}
|
||||
</div>
|
||||
<div className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
||||
<div className="font-semibold text-ink">{m.username}</div>
|
||||
<div className="mono text-[0.65rem] text-ink-faint">
|
||||
{getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -573,21 +508,21 @@ function MessageDetail({
|
||||
</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) ||
|
||||
"(no text)"}
|
||||
</div>
|
||||
|
||||
{m.ai_analysis ? (
|
||||
{m.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}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{flags.length > 0 || cats.length > 0 ? (
|
||||
{(flags.length > 0 || cats.length > 0) && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{flags.map((f) => (
|
||||
<Badge key={f} tone="vermilion">
|
||||
@@ -600,9 +535,9 @@ function MessageDetail({
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{attachments.length > 0 ? (
|
||||
{attachments.length > 0 && (
|
||||
<div>
|
||||
<div className="eyebrow mb-1 flex items-center gap-1.5">
|
||||
<Paperclip className="size-3" /> Attachments ({attachments.length})
|
||||
@@ -614,22 +549,23 @@ function MessageDetail({
|
||||
href={a.discord_url ?? a.uploaded_url ?? "#"}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 rounded-xl border border-[var(--color-hairline)] px-3 py-2 font-mono text-xs text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
||||
className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft hover:text-ink"
|
||||
>
|
||||
<ImageIcon className="size-3.5 text-signal" />
|
||||
<span className="flex-1 truncate">{a.filename}</span>
|
||||
<span className="text-[var(--color-ink-faint)]">
|
||||
<span className="mono text-ink-faint">
|
||||
{formatBytes(a.size)}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Single message card used by both the live feed and the date-grouped timeline. */
|
||||
function MessageRow({
|
||||
m,
|
||||
selected,
|
||||
@@ -641,32 +577,31 @@ function MessageRow({
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
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
|
||||
? "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="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}
|
||||
</span>
|
||||
<span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
||||
<span className="mono text-[0.65rem] text-ink-faint">
|
||||
{getMessageChannelLabel(m)}
|
||||
</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)}
|
||||
</span>
|
||||
</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) || (
|
||||
<span className="italic text-[var(--color-ink-faint)]">
|
||||
(empty / embed)
|
||||
</span>
|
||||
<span className="italic text-ink-faint">(empty / embed)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,34 +1,40 @@
|
||||
"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 {
|
||||
AlertTriangle,
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Filter,
|
||||
MessageSquareWarning,
|
||||
MicOff,
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
UserX,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { CategoryDrilldown } from "@/components/CategoryDrilldown";
|
||||
import { CoverageTiles } from "@/components/CoverageTiles";
|
||||
import { Donut } from "@/components/charts";
|
||||
import { LiveModerationFeed } from "@/components/LiveModerationFeed";
|
||||
import { ModerationHeatmap } from "@/components/ModerationHeatmap";
|
||||
import { Badge, Select, type SelectOption } from "@/components/primitives";
|
||||
import { ScamDomains } from "@/components/ScamDomains";
|
||||
import { ErrorState } from "@/components/shared";
|
||||
import {
|
||||
useSceneFocusSetter,
|
||||
useScenePublish,
|
||||
} from "@/components/shell/scene-graph-context";
|
||||
Badge,
|
||||
GlassPanel,
|
||||
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 { TopicTrends } from "@/components/TopicTrends";
|
||||
import {
|
||||
@@ -43,7 +49,6 @@ import {
|
||||
useTopFlaggedDomains,
|
||||
} from "@/hooks";
|
||||
import { aiTone } from "@/lib/ai-status";
|
||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
||||
import { downloadCsv } from "@/lib/csv";
|
||||
import { formatNumber, formatRelativeTime } from "@/lib/format";
|
||||
import type {
|
||||
@@ -51,6 +56,7 @@ import type {
|
||||
ModerationActionType,
|
||||
ModerationStats,
|
||||
} from "@/lib/types";
|
||||
import { staggerDelay } from "@/lib/utils";
|
||||
|
||||
const ACTION_ICON: Record<ModerationActionType, React.ReactNode> = {
|
||||
delete_message: <Trash2 className="size-3.5" />,
|
||||
@@ -68,35 +74,6 @@ const ACTION_LABEL: Record<ModerationActionType, string> = {
|
||||
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({
|
||||
initialStats,
|
||||
initialActions,
|
||||
@@ -119,17 +96,13 @@ export function ModerationView({
|
||||
const { data: hourly } = useHourlyModeration(30);
|
||||
const { data: coverage } = useModerationCoverage(30);
|
||||
const [drilldown, setDrilldown] = useState<string | null>(null);
|
||||
const [intelOpen, setIntelOpen] = useState(false);
|
||||
const { data: categoryActions, isValidating: categoryLoading } =
|
||||
useModerationByCategory(drilldown ? 30 : 0, drilldown);
|
||||
|
||||
const publish = useScenePublish();
|
||||
const setFocus = useSceneFocusSetter();
|
||||
|
||||
const failedRate = stats ? stats.failed_rate * 100 : 0;
|
||||
|
||||
const byAction = stats?.by_action ?? {};
|
||||
const segments = Object.entries(byAction).map(([k]) => ({
|
||||
const segments = Object.entries(byAction).map(([k, _v]) => ({
|
||||
value: 1,
|
||||
color:
|
||||
k === "ban_user" || k === "kick_user"
|
||||
@@ -140,23 +113,6 @@ export function ModerationView({
|
||||
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();
|
||||
useEffect(() => {
|
||||
ambient.set(
|
||||
@@ -169,9 +125,11 @@ export function ModerationView({
|
||||
if (error && !stats) return <ErrorState error={error} />;
|
||||
if (!stats && isLoading)
|
||||
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)]">
|
||||
menghubungkan verdict hub…
|
||||
</p>
|
||||
<div className="space-y-5">
|
||||
<SkeletonMetricRow cols={4} />
|
||||
<SkeletonPanel rows={5} />
|
||||
<SkeletonRows rows={6} />
|
||||
</div>
|
||||
);
|
||||
if (!stats) return <ErrorState error={error ?? new Error("No data")} />;
|
||||
|
||||
@@ -190,204 +148,193 @@ export function ModerationView({
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
{/* Metric whisper — left */}
|
||||
<section
|
||||
className="pointer-events-auto absolute left-5 top-16 w-56"
|
||||
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)}
|
||||
tone="ink"
|
||||
/>
|
||||
<Whisper
|
||||
label="executed"
|
||||
value={formatNumber(stats.executed)}
|
||||
tone="signal"
|
||||
/>
|
||||
<Whisper
|
||||
label="failed"
|
||||
value={formatNumber(stats.failed)}
|
||||
tone={stats.failed > 0 ? "vermilion" : "ink"}
|
||||
/>
|
||||
<Whisper
|
||||
label="pending"
|
||||
value={formatNumber(stats.pending)}
|
||||
tone={stats.pending > 0 ? "amber" : "ink"}
|
||||
/>
|
||||
</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="pointer-events-auto absolute right-5 top-16 hidden w-[min(24rem,90vw)] md:block">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIntelOpen((v) => !v)}
|
||||
className={`rounded-full border px-3 py-1 font-mono text-xs transition-colors ${
|
||||
intelOpen
|
||||
? "border-signal/40 bg-signal/10 text-signal"
|
||||
: "border-[var(--color-hairline)] bg-[var(--color-canvas)]/60 text-[var(--color-ink-soft)] backdrop-blur-md hover:text-[var(--color-ink)]"
|
||||
}`}
|
||||
>
|
||||
intel {intelOpen ? "▲" : "▼"}
|
||||
</button>
|
||||
{intelOpen ? (
|
||||
<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">
|
||||
{trends ? <TopicTrends trends={trends} /> : null}
|
||||
{coverage ? <CoverageTiles coverage={coverage} /> : null}
|
||||
{domains ? <ScamDomains domains={domains} /> : null}
|
||||
{hourly ? <ModerationHeatmap hours={hourly} /> : null}
|
||||
{channels ? <TopChannels channels={channels} /> : null}
|
||||
<CategoryDrilldown
|
||||
trends={trends ?? { categories: [], severities: [], actions: [] }}
|
||||
selected={drilldown}
|
||||
actions={categoryActions ?? []}
|
||||
loading={categoryLoading}
|
||||
onSelect={setDrilldown}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile
|
||||
label="Total actions"
|
||||
value={formatNumber(stats.total)}
|
||||
tone="signal"
|
||||
icon={<ShieldAlert className="size-3.5" />}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Executed"
|
||||
value={formatNumber(stats.executed)}
|
||||
tone="signal"
|
||||
icon={<CheckCircle2 className="size-3.5" />}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Failed"
|
||||
value={formatNumber(stats.failed)}
|
||||
tone={stats.failed > 0 ? "vermilion" : "neutral"}
|
||||
icon={<XCircle className="size-3.5" />}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Pending"
|
||||
value={formatNumber(stats?.pending)}
|
||||
tone={stats?.pending > 0 ? "amber" : "neutral"}
|
||||
icon={<Clock className="size-3.5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Live feed ribbon — bottom */}
|
||||
<section
|
||||
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]"
|
||||
aria-label="Live moderation feed"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] px-4 py-2">
|
||||
<span className="eyebrow flex items-center gap-2">
|
||||
<Filter className="size-3 text-[var(--color-ink-faint)]" />
|
||||
action log
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onChange={setTypeFilter}
|
||||
options={typeOpts}
|
||||
size="sm"
|
||||
className="w-32"
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
options={statusOpts}
|
||||
size="sm"
|
||||
className="w-28"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
downloadCsv(
|
||||
"moderation-actions.csv",
|
||||
(actions ?? []).map((a) => ({
|
||||
id: a.id,
|
||||
user: a.username ?? a.user_id,
|
||||
action_type: a.action_type,
|
||||
status: a.status,
|
||||
severity: a.severity ?? "",
|
||||
categories: (a.categories ?? []).join("|"),
|
||||
reason: a.reason ?? "",
|
||||
created_at: a.created_at
|
||||
? new Date(a.created_at).toISOString()
|
||||
: "",
|
||||
})),
|
||||
)
|
||||
<div className="grid gap-5 lg:grid-cols-5">
|
||||
<div className="lg:col-span-2">
|
||||
{trends ? (
|
||||
<TopicTrends trends={trends} />
|
||||
) : (
|
||||
<SkeletonPanel rows={6} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-5">
|
||||
<LiveModerationFeed actions={liveActions} />
|
||||
</div>
|
||||
|
||||
{coverage ? (
|
||||
<CoverageTiles coverage={coverage} />
|
||||
) : (
|
||||
<SkeletonPanel rows={3} className="lg:col-span-5" />
|
||||
)}
|
||||
|
||||
<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
|
||||
trends={trends ?? { categories: [], severities: [], actions: [] }}
|
||||
selected={drilldown}
|
||||
actions={categoryActions ?? []}
|
||||
loading={categoryLoading}
|
||||
onSelect={setDrilldown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GlassPanel className="lg:col-span-2">
|
||||
<SectionHeader eyebrow="health" title="Breakdown" />
|
||||
<div className="flex items-center gap-5">
|
||||
<Donut
|
||||
segments={
|
||||
segments.length
|
||||
? segments
|
||||
: [
|
||||
{
|
||||
value: 1,
|
||||
color: "var(--color-ink-faint)",
|
||||
label: "none",
|
||||
},
|
||||
]
|
||||
}
|
||||
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)]"
|
||||
title="Download moderation actions as CSV"
|
||||
>
|
||||
CSV
|
||||
</button>
|
||||
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 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>
|
||||
</div>
|
||||
<div className="max-h-[40vh] overflow-y-auto p-2">
|
||||
<ActionRows actions={actions ?? []} liveCount={liveActions.length} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</GlassPanel>
|
||||
|
||||
function ActionRows({
|
||||
actions,
|
||||
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.
|
||||
<GlassPanel className="lg:col-span-3">
|
||||
<SectionHeader
|
||||
eyebrow="filter"
|
||||
title="Action log"
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="size-3.5 text-ink-faint" />
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onChange={setTypeFilter}
|
||||
options={typeOpts}
|
||||
size="sm"
|
||||
className="w-36"
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
options={statusOpts}
|
||||
size="sm"
|
||||
className="w-32"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
downloadCsv(
|
||||
"moderation-actions.csv",
|
||||
(actions ?? []).map((a) => ({
|
||||
id: a.id,
|
||||
user: a.username ?? a.user_id,
|
||||
action_type: a.action_type,
|
||||
status: a.status,
|
||||
severity: a.severity ?? "",
|
||||
categories: (a.categories ?? []).join("|"),
|
||||
reason: a.reason ?? "",
|
||||
created_at: a.created_at
|
||||
? new Date(a.created_at).toISOString()
|
||||
: "",
|
||||
})),
|
||||
)
|
||||
}
|
||||
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"
|
||||
>
|
||||
CSV
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
|
||||
{(actions ?? []).map((a, 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>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{actions.map((a, i) => (
|
||||
<ActionRow key={a.id} a={a} index={i} />
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
|
||||
const tone =
|
||||
a.status === "executed"
|
||||
@@ -398,6 +345,8 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
|
||||
const icon = ACTION_ICON[a.action_type] ?? (
|
||||
<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 =
|
||||
a.severity == null
|
||||
? null
|
||||
@@ -410,8 +359,8 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className="animate-stagger flex items-start gap-3 rounded-xl border border-[var(--color-hairline)] p-3"
|
||||
style={{ animationDelay: `${index * 30}ms` }}
|
||||
className="animate-stagger flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3"
|
||||
style={staggerDelay(index)}
|
||||
>
|
||||
<span
|
||||
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>
|
||||
<div className="min-w-0 flex-1">
|
||||
<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"}
|
||||
</span>
|
||||
<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)}
|
||||
</span>
|
||||
</div>
|
||||
{a.reason ? (
|
||||
<div className="mt-0.5 text-xs text-[var(--color-ink-soft)]">
|
||||
“{a.reason}”
|
||||
</div>
|
||||
) : null}
|
||||
{severityTone && a.severity ? (
|
||||
{a.reason && (
|
||||
<div className="mt-0.5 text-xs text-ink-soft">“{a.reason}”</div>
|
||||
)}
|
||||
{severityTone && a.severity && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<Badge tone={severityTone}>{a.severity}</Badge>
|
||||
{a.confidence != null ? (
|
||||
<span className="font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
|
||||
{a.confidence != null && (
|
||||
<span className="mono text-[0.6rem] text-ink-faint">
|
||||
conf {(a.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
{a.flags?.length ? (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{a.flags.slice(0, 6).map((f) => (
|
||||
@@ -453,24 +400,24 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
|
||||
</div>
|
||||
) : null}
|
||||
{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]}”
|
||||
</div>
|
||||
) : null}
|
||||
{a.executed_by ? (
|
||||
<div className="mt-0.5 font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
|
||||
{a.executed_by && (
|
||||
<div className="mono mt-0.5 text-[0.6rem] text-ink-faint">
|
||||
by {a.executed_by}
|
||||
{a.executed_at ? ` · ${formatRelativeTime(a.executed_at)}` : ""}
|
||||
</div>
|
||||
) : null}
|
||||
{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)]">
|
||||
)}
|
||||
{a.content && (
|
||||
<div className="mt-1 line-clamp-2 rounded-[8px] bg-white/[0.03] px-2 py-1 text-xs text-ink-faint">
|
||||
{a.content}
|
||||
</div>
|
||||
) : null}
|
||||
{a.error ? (
|
||||
)}
|
||||
{a.error && (
|
||||
<div className="mt-1 text-xs text-vermilion">{a.error}</div>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Recordings scene — voice clips orbit as a timeline ring on the stage;
|
||||
* the clip archive docks right with inline audio players, playing clip
|
||||
* highlighted both in the dock and on the sky.
|
||||
*/
|
||||
import { Download, Hash, Headphones, Loader2, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
GlassCard,
|
||||
GlassPanel,
|
||||
Skeleton,
|
||||
toast,
|
||||
} from "@/components/primitives";
|
||||
import { EmptyState, ErrorState } from "@/components/shared";
|
||||
import {
|
||||
useSceneFocusSetter,
|
||||
useScenePublish,
|
||||
} from "@/components/shell/scene-graph-context";
|
||||
import { EmptyState, ErrorState, SectionHeader } from "@/components/shared";
|
||||
import {
|
||||
NowPlayingChip,
|
||||
RecordingAudioPlayer,
|
||||
@@ -29,29 +22,11 @@ import {
|
||||
useRecordings,
|
||||
useRecordingsWsSync,
|
||||
} from "@/hooks";
|
||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
||||
import { formatBytes, formatRelativeTime } from "@/lib/format";
|
||||
import type { VoiceRecording } from "@/lib/types";
|
||||
import { staggerDelay } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
function recordingsGraph(items: VoiceRecording[]): ConstellationGraph {
|
||||
const recent = items.slice(0, 28);
|
||||
return {
|
||||
nodes: [
|
||||
{ id: "archive", label: "archive", kind: "guild", value: 1 },
|
||||
...recent.map((r) => ({
|
||||
id: `rec:${r.id}`,
|
||||
label: r.username,
|
||||
kind: "media" as const,
|
||||
value: 0.5,
|
||||
href: undefined,
|
||||
})),
|
||||
],
|
||||
edges: recent.map((r) => ({ source: "archive", target: `rec:${r.id}` })),
|
||||
};
|
||||
}
|
||||
|
||||
export function RecordingsView({
|
||||
initialItems,
|
||||
}: {
|
||||
@@ -62,25 +37,8 @@ export function RecordingsView({
|
||||
const del = useDeleteRecording();
|
||||
useRecordingsWsSync(ws);
|
||||
const ambient = useAmbient();
|
||||
const publish = useScenePublish();
|
||||
const setFocus = useSceneFocusSetter();
|
||||
const [playingId, setPlayingId] = useState<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(() => {
|
||||
ambient.set("signal", 0.3, "recordings");
|
||||
}, [ambient]);
|
||||
@@ -101,17 +59,14 @@ export function RecordingsView({
|
||||
if (error && !items) return <ErrorState error={error} />;
|
||||
if (!items && isLoading)
|
||||
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">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col gap-3 rounded-xl border border-[var(--color-hairline)] p-3"
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<GlassCard key={i} className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-9 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
@@ -120,168 +75,118 @@ export function RecordingsView({
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</div>
|
||||
</GlassCard>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
{/* Archive dock — right */}
|
||||
<section
|
||||
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"
|
||||
aria-label="Voice captures"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] px-4 py-2.5">
|
||||
<span className="eyebrow">voice captures</span>
|
||||
<span className="font-mono text-xs text-[var(--color-ink-faint)]">
|
||||
{list.length} clips
|
||||
<GlassPanel>
|
||||
<SectionHeader
|
||||
eyebrow="voice captures"
|
||||
title="Recordings"
|
||||
action={
|
||||
<span className="mono text-xs text-ink-faint">
|
||||
{(items ?? []).length} clips
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2.5">
|
||||
{list.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Headphones className="size-6" />}
|
||||
title="No recordings"
|
||||
description="Voice clips captured by the bot appear here."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{list.map((r, i) => {
|
||||
const up = uploadStatus(r);
|
||||
const isPlaying = playingId === r.id;
|
||||
return (
|
||||
<article
|
||||
key={r.id}
|
||||
className={`animate-stagger flex flex-col gap-2.5 rounded-xl border p-3 transition-colors ${
|
||||
isPlaying
|
||||
? "border-signal/40 shadow-[0_0_36px_-16px_var(--color-signal-glow)]"
|
||||
: "border-[var(--color-hairline)] hover:bg-white/[0.04]"
|
||||
}`}
|
||||
style={staggerDelay(i)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar src={r.avatar_url} name={r.username} size={34} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-[var(--color-ink)]">
|
||||
{r.username}
|
||||
</p>
|
||||
<p className="flex items-center gap-1.5 font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
||||
<Hash className="size-3" />
|
||||
<span className="truncate">
|
||||
{r.channel_name ?? "voice"}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{formatRelativeTime(r.created_at)}</span>
|
||||
</p>
|
||||
</div>
|
||||
{isPlaying ? <NowPlayingChip /> : null}
|
||||
{up && !isPlaying ? (
|
||||
<Badge tone={up.tone}>{up.label}</Badge>
|
||||
) : null}
|
||||
<span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
||||
{formatBytes(r.size_bytes)}
|
||||
}
|
||||
/>
|
||||
{(items ?? []).length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Headphones className="size-7" />}
|
||||
title="No recordings"
|
||||
description="Voice clips captured by the bot appear here."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{(items ?? []).map((r, i) => {
|
||||
const up = uploadStatus(r);
|
||||
const isPlaying = playingId === r.id;
|
||||
return (
|
||||
<GlassCard
|
||||
key={r.id}
|
||||
className={`animate-stagger flex flex-col gap-3 transition-colors hover:bg-white/[0.06] ${
|
||||
isPlaying
|
||||
? "border-signal/40 shadow-[0_0_36px_-16px_var(--color-signal-glow)]"
|
||||
: ""
|
||||
}`}
|
||||
style={staggerDelay(i)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar src={r.avatar_url} name={r.username} size={38} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-ink">
|
||||
{r.username}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[0.65rem] text-ink-faint">
|
||||
<Hash className="size-3" />
|
||||
<span className="truncate">
|
||||
{r.channel_name ?? "voice"}
|
||||
</span>
|
||||
<span className="text-ink-faint/60">·</span>
|
||||
<span className="mono">
|
||||
{formatRelativeTime(r.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isPlaying && <NowPlayingChip />}
|
||||
{up && !isPlaying && <Badge tone={up.tone}>{up.label}</Badge>}
|
||||
<span className="mono text-[0.65rem] text-ink-faint">
|
||||
{formatBytes(r.size_bytes)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{r.download_url ? (
|
||||
<RecordingAudioPlayer
|
||||
src={r.download_url}
|
||||
label={`Voice recording by ${r.username}`}
|
||||
onPlayStateChange={(active) =>
|
||||
setPlayingId((prev) => {
|
||||
if (active) return r.id;
|
||||
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)]">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{r.upload_status === "pending"
|
||||
? "Upload pending…"
|
||||
: r.upload_error
|
||||
? r.upload_error
|
||||
: "Processing…"}
|
||||
</p>
|
||||
)}
|
||||
{r.download_url ? (
|
||||
<RecordingAudioPlayer
|
||||
src={r.download_url}
|
||||
label={`Voice recording by ${r.username}`}
|
||||
onPlayStateChange={(active) =>
|
||||
setPlayingId((prev) => {
|
||||
if (active) return r.id;
|
||||
// Only clear if THIS card was the one playing.
|
||||
return prev === r.id ? null : prev;
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<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" />
|
||||
{r.upload_status === "pending"
|
||||
? "Upload pending…"
|
||||
: r.upload_error
|
||||
? r.upload_error
|
||||
: "Processing…"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{r.download_url ? (
|
||||
<a
|
||||
href={r.download_url}
|
||||
target="_blank"
|
||||
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)]"
|
||||
>
|
||||
<Download className="size-3.5" /> Download
|
||||
</a>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => onDelete(r.id)}
|
||||
disabled={del.isPending}
|
||||
>
|
||||
<Trash2 className="size-3.5" /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{r.download_url && (
|
||||
<a
|
||||
href={r.download_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
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
|
||||
</a>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => onDelete(r.id)}
|
||||
disabled={del.isPending}
|
||||
>
|
||||
<Trash2 className="size-3.5" /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
</GlassPanel>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Voice scene — the stage node sits center; live speakers orbit it with
|
||||
* glow (published to the constellation). Controls float bottom-left,
|
||||
* connections whisper right, and the mic/listen meters stay docked.
|
||||
*/
|
||||
import {
|
||||
Headphones,
|
||||
Mic,
|
||||
@@ -14,16 +9,17 @@ import {
|
||||
Volume2,
|
||||
Waves,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { Equalizer } from "@/components/charts";
|
||||
import { Button, toast } from "@/components/primitives";
|
||||
import { EmptyState, ErrorState } from "@/components/shared";
|
||||
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
||||
import { Button, GlassPanel, toast } from "@/components/primitives";
|
||||
import {
|
||||
useSceneFocusSetter,
|
||||
useScenePublish,
|
||||
} from "@/components/shell/scene-graph-context";
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
SectionHeader,
|
||||
SkeletonPanel,
|
||||
} from "@/components/shared";
|
||||
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
||||
import { VoiceStage } from "@/components/voice/voice-stage";
|
||||
import {
|
||||
useMicTransmit,
|
||||
@@ -33,37 +29,9 @@ import {
|
||||
useVoiceListen,
|
||||
useVoiceStatus,
|
||||
} from "@/hooks";
|
||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
||||
import type { Guild, VoiceStatus } from "@/lib/types";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
function speakerGraph(
|
||||
speakers: {
|
||||
userId: string;
|
||||
username: string;
|
||||
speaking: boolean;
|
||||
level?: number;
|
||||
}[],
|
||||
): ConstellationGraph {
|
||||
return {
|
||||
nodes: [
|
||||
{ id: "stage", label: "voice stage", kind: "guild", value: 1 },
|
||||
...speakers.map((sp) => ({
|
||||
id: `speaker:${sp.userId}`,
|
||||
label: sp.username,
|
||||
kind: "speaker" as const,
|
||||
value: sp.speaking ? 0.8 : 0.4,
|
||||
href: undefined,
|
||||
meta: { speaking: sp.speaking },
|
||||
})),
|
||||
],
|
||||
edges: speakers.map((sp) => ({
|
||||
source: "stage",
|
||||
target: `speaker:${sp.userId}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function VoiceView({
|
||||
initialStatus,
|
||||
initialGuilds,
|
||||
@@ -79,8 +47,6 @@ export function VoiceView({
|
||||
const listen = useVoiceListen(ws);
|
||||
const { speakers, subscribe } = useSpeakers(initialStatus?.activeSpeakers);
|
||||
const ambient = useAmbient();
|
||||
const publish = useScenePublish();
|
||||
const setFocus = useSceneFocusSetter();
|
||||
|
||||
const [guildId, setGuildId] = useState<string | null>(
|
||||
initialStatus?.activeGuildId ?? initialGuilds?.[0]?.id ?? null,
|
||||
@@ -97,19 +63,6 @@ export function VoiceView({
|
||||
return unsub;
|
||||
}, [subscribe, ws]);
|
||||
|
||||
const graph = useMemo(() => speakerGraph(speakers), [speakers]);
|
||||
useEffect(() => {
|
||||
publish({ graph, focus: null });
|
||||
}, [graph, publish]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
publish({ graph: { nodes: [], edges: [] }, focus: null });
|
||||
setFocus(null);
|
||||
},
|
||||
[publish, setFocus],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (status?.connected) ambient.set("signal", 0.55, "voice live");
|
||||
else ambient.set("vermilion", 0.35, "voice idle");
|
||||
@@ -118,9 +71,13 @@ export function VoiceView({
|
||||
if (error && !status) return <ErrorState error={error} />;
|
||||
if (!status && isLoading)
|
||||
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)]">
|
||||
menyetel ulang stage…
|
||||
</p>
|
||||
<div className="space-y-5">
|
||||
<SkeletonPanel rows={2} />
|
||||
<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;
|
||||
@@ -161,94 +118,43 @@ export function VoiceView({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
{/* Stage presence — top-center whisper */}
|
||||
<section
|
||||
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 className="space-y-5">
|
||||
<GlassPanel>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<GuildChannelPicker
|
||||
mode="voice"
|
||||
guildsInitial={initialGuilds}
|
||||
guildId={guildId}
|
||||
channelId={channelId}
|
||||
onChange={(g, c) => {
|
||||
setGuildId(g);
|
||||
setChannelId(c);
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{connected ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => disconnect.mutate()}
|
||||
disabled={disconnect.isPending}
|
||||
>
|
||||
<PhoneOff className="size-4" /> Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onConnect}
|
||||
disabled={connect.isPending}
|
||||
>
|
||||
<Radio className="size-4" /> Connect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</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
|
||||
mode="voice"
|
||||
guildsInitial={initialGuilds}
|
||||
guildId={guildId}
|
||||
channelId={channelId}
|
||||
onChange={(g, c) => {
|
||||
setGuildId(g);
|
||||
setChannelId(c);
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{connected ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => disconnect.mutate()}
|
||||
disabled={disconnect.isPending}
|
||||
>
|
||||
<PhoneOff className="size-4" /> Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onConnect}
|
||||
disabled={connect.isPending}
|
||||
>
|
||||
<Radio className="size-4" /> Connect
|
||||
</Button>
|
||||
)}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant={micOn ? "primary" : "outline"}
|
||||
size="sm"
|
||||
@@ -259,6 +165,16 @@ export function VoiceView({
|
||||
{micOn ? <Mic className="size-4" /> : <MicOff className="size-4" />}
|
||||
{micOn ? "Mic live" : "Push-to-talk"}
|
||||
</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
|
||||
variant={listen.active ? "primary" : "outline"}
|
||||
size="sm"
|
||||
@@ -271,62 +187,125 @@ export function VoiceView({
|
||||
)}
|
||||
{listen.active ? "Listening" : "Listen in"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{micOn ? (
|
||||
<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" />
|
||||
{listen.active && (
|
||||
<div className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-1.5">
|
||||
<Waves className="size-4 text-signal" />
|
||||
<Equalizer bars={listenBars} className="w-40" />
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-xs text-ink-faint">
|
||||
<MicOff className="size-3.5" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={micVol}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
setMicVol(v);
|
||||
mic.setVolume(v);
|
||||
}}
|
||||
aria-label="Mic transmit volume"
|
||||
className="h-1 w-24 cursor-pointer accent-[var(--color-signal)]"
|
||||
/>
|
||||
<span className="mono w-8 text-right">{micVol}%</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-ink-faint">
|
||||
<Volume2 className="size-3.5" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={listenVol}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
setListenVol(v);
|
||||
listen.setVolume(v);
|
||||
}}
|
||||
aria-label="Listen volume"
|
||||
className="h-1 w-24 cursor-pointer accent-[var(--color-signal)]"
|
||||
/>
|
||||
<span className="mono w-8 text-right">{listenVol}%</span>
|
||||
</label>
|
||||
</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" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-4 pt-1">
|
||||
<label className="flex items-center gap-2 font-mono text-xs text-[var(--color-ink-faint)]">
|
||||
<MicOff className="size-3.5" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={micVol}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
setMicVol(v);
|
||||
mic.setVolume(v);
|
||||
}}
|
||||
aria-label="Mic transmit volume"
|
||||
className="h-1 w-20 cursor-pointer accent-[var(--color-signal)]"
|
||||
/>
|
||||
<span className="w-8 text-right">{micVol}%</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 font-mono text-xs text-[var(--color-ink-faint)]">
|
||||
<Volume2 className="size-3.5" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={listenVol}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
setListenVol(v);
|
||||
listen.setVolume(v);
|
||||
}}
|
||||
aria-label="Listen volume"
|
||||
className="h-1 w-20 cursor-pointer accent-[var(--color-signal)]"
|
||||
/>
|
||||
<span className="w-8 text-right">{listenVol}%</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user