feat(frontend): rebuild as Ambient/WebGL console with all pages + command palette

Ground-up rombak UI: hapus semua component/page lama, bangun ulang dengan
desain sistem Ambient (WebGL haze + drifting motes, signal-driven color)
di atas kontrak API/WS/type yang sudah ada.

- Design system: globals.css tokens + primitives (glass, button, badge,
  select, avatar, toast, chart SVG murni).
- Shell: nav rail, topbar (status WS + pill signal + theme), AppFrame.
- 8 halaman: dashboard, voice (orbital stage), media, messages (live feed +
  detail AI), moderation, analysis (search), recordings, + chatbot floating.
- Command palette (Cmd/Ctrl+K) untuk navigasi cepat.
- Server fetch di-page di-try/catch agar render graceful saat backend mati.

Verified: tsc clean, next build 8/8 halaman, semua route 200.
This commit is contained in:
asepharyana
2026-08-15 17:53:48 +07:00
parent b98101c576
commit 1b56212d1a
104 changed files with 2905 additions and 7048 deletions
@@ -1,11 +1,7 @@
"use client";
import { AnalysisView } from "./view";
import { SearchPanel } from "@/components/analysis/search-panel";
export const dynamic = "force-dynamic";
export default function AnalysisPage() {
return (
<div className="space-y-5" style={{ animation: "fade-up 0.4s ease both" }}>
<SearchPanel />
</div>
);
return <AnalysisView />;
}
@@ -0,0 +1,112 @@
"use client";
import { useEffect, useState } from "react";
import { Search, Sparkles, TrendingUp, Hash } from "lucide-react";
import { useMessageSearch, useTopReactors, useChannels } from "@/hooks";
import { useAmbient } from "@/components/ambient/ambient-context";
import { GlassPanel, GlassCard, Avatar, Input, Badge } from "@/components/primitives";
import { SectionHeader, EmptyState, LoadingState } from "@/components/shared";
import { renderMessageContent, getMessageChannelLabel } from "@/lib/format";
import type { AiStatus } from "@/lib/types";
function aiTone(s?: AiStatus | null): "signal" | "amber" | "vermilion" | "neutral" {
if (s === "clean") return "signal";
if (s === "warn") return "amber";
if (s === "flagged" || s === "error") return "vermilion";
return "neutral";
}
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();
useEffect(() => {
ambient.set(query ? "amber" : "signal", 0.3, query ? "analyzing" : "search");
}, [query, ambient]);
return (
<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-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)}
autoFocus
/>
</div>
{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>
<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 && <LoadingState label="Scanning" />}
{(search.data ?? []).length === 0 ? (
<EmptyState icon={<Search className="size-7" />} title="No matches yet" description="Run a search to surface messages across the guild." />
) : (
<div className="space-y-1.5">
{(search.data ?? []).map((m) => (
<div key={m.id} 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={32} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-ink">{m.username}</span>
<span className="mono text-[0.65rem] text-ink-faint">{getMessageChannelLabel(m)}</span>
{m.ai_status && <Badge tone={aiTone(m.ai_status)} className="ml-auto">{m.ai_status}</Badge>}
</div>
<div className="mt-0.5 text-sm text-ink-soft">{renderMessageContent(m.content, m.metadata) || "(embed)"}</div>
</div>
</div>
))}
</div>
)}
</GlassPanel>
<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) => (
<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="flex-1 truncate text-ink">{r.username}</span>
<span className="mono 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>
);
}