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,161 +0,0 @@
"use client";
import { Eraser, Send, Sparkles } from "lucide-react";
import { useEffect, useRef } from "react";
import { useChatbot } from "./chatbot-context";
interface ChatPanelProps {
inputRef?: React.RefObject<HTMLInputElement | null>;
}
function formatTime(ts: string): string {
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleTimeString("id-ID", {
hour: "2-digit",
minute: "2-digit",
});
}
const SUGGESTIONS = [
"Gimana suasana server hari ini?",
"Channel mana yang paling ramai?",
"Total pesan di server?",
"Ada pesan bermasalah?",
];
export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
const { messages, sendMessage, clearMessages, isTyping } = useChatbot();
const listRef = useRef<HTMLDivElement>(null);
const internalInputRef = useRef<HTMLInputElement>(null);
const inputRef = externalInputRef ?? internalInputRef;
// Auto-scroll to bottom on new messages
// biome-ignore lint/correctness/useExhaustiveDependencies: re-run on message arrival; scroll is a visual effect keyed on new content
useEffect(() => {
if (listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, [messages, isTyping]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const input = inputRef.current;
if (!input || !input.value.trim() || isTyping) return;
sendMessage(input.value);
input.value = "";
};
const handleSuggestion = (text: string) => {
if (isTyping) return;
sendMessage(text);
};
return (
<div className="flex h-full flex-col">
{/* Chat messages */}
<div
ref={listRef}
className="flex-1 space-y-1.5 overflow-y-auto px-2 py-2"
>
{messages.length === 0 ? (
<div className="flex h-full flex-col justify-center gap-3 px-3 text-center">
<p className="text-[11px] text-[var(--color-ink-soft)]">
Halo! 👋 Aku tau soal server ini pesan, flag, dan aktivitas.
</p>
<div className="flex flex-wrap justify-center gap-1.5">
{SUGGESTIONS.map((s) => (
<button
key={s}
type="button"
onClick={() => handleSuggestion(s)}
disabled={isTyping}
className="flex items-center gap-1 rounded-full border border-[var(--color-hairline)] bg-[var(--color-surface-2)] px-2.5 py-1 text-[10px] text-[var(--color-ink-soft)] transition-colors hover:bg-[var(--color-signal)] hover:text-[var(--color-signal-ink)] disabled:opacity-40"
>
<Sparkles className="size-2.5 text-[var(--color-signal)]" />
{s}
</button>
))}
</div>
</div>
) : (
messages.map((msg, i) => (
<div
key={`${msg.timestamp}-${i}`}
className={`flex flex-col ${msg.role === "user" ? "items-end" : "items-start"}`}
>
<div
className={`max-w-[85%] break-words whitespace-pre-wrap rounded-xl px-2.5 py-1.5 text-[11px] leading-relaxed ${
msg.role === "user"
? "rounded-br-sm bg-[var(--color-signal)] text-[var(--color-signal-ink)]"
: "rounded-bl-sm bg-[var(--color-surface-2)] text-[var(--color-ink)]"
}`}
>
{msg.content}
</div>
<span className="mt-0.5 px-1 text-[9px] text-[var(--color-ink-soft)]">
{formatTime(msg.timestamp)}
</span>
</div>
))
)}
{isTyping && (
<div className="flex justify-start">
<div className="rounded-xl rounded-bl-sm bg-[var(--color-surface-2)] px-2.5 py-2">
<span className="inline-flex gap-1">
<span
className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
style={{ animationDelay: "0ms" }}
/>
<span
className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
style={{ animationDelay: "150ms" }}
/>
<span
className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
style={{ animationDelay: "300ms" }}
/>
</span>
</div>
</div>
)}
</div>
{/* Input bar */}
<form
onSubmit={handleSubmit}
className="flex shrink-0 items-center gap-1.5 border-t border-[var(--color-hairline)] px-2 py-2"
>
<input
ref={inputRef}
type="text"
placeholder="Tanya soal server, pesan, atau statistik…"
className="flex-1 bg-transparent text-[11px] text-[var(--color-ink)] outline-none placeholder:text-[var(--color-ink-soft)]/50"
disabled={isTyping}
autoComplete="off"
/>
{messages.length > 0 && (
<button
type="button"
onClick={() => void clearMessages()}
className="flex size-6 items-center justify-center rounded transition-colors hover:bg-[var(--color-surface-2)] disabled:opacity-40"
disabled={isTyping}
aria-label="Hapus riwayat chat"
title="Hapus riwayat"
>
<Eraser className="size-3 text-[var(--color-ink-soft)] hover:text-[var(--color-vermilion)]" />
</button>
)}
<button
type="submit"
className="flex size-7 items-center justify-center rounded-lg bg-[var(--color-signal)] text-[var(--color-signal-ink)] transition-colors hover:opacity-90 disabled:opacity-40"
disabled={isTyping}
aria-label="Kirim pesan"
title="Kirim"
>
<Send className="size-3.5" />
</button>
</form>
</div>
);
}
@@ -1,100 +0,0 @@
"use client";
import { Bot, Minimize2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ChatPanel } from "./chat-panel";
import { useChatbot } from "./chatbot-context";
export function ChatbotContainer() {
const { minimized, setMinimized } = useChatbot();
const [position, setPosition] = useState({ x: 0, y: 0 });
const [dragging, setDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const inputRef = useRef<HTMLInputElement>(null);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
setDragging(true);
setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
},
[position],
);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!dragging) return;
setPosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y });
},
[dragging, dragStart],
);
const handleMouseUp = useCallback(() => setDragging(false), []);
// Focus input when chat opens
useEffect(() => {
if (!minimized) {
const id = setTimeout(() => inputRef.current?.focus(), 150);
return () => clearTimeout(id);
}
}, [minimized]);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: drag container — mouse-move gesture surface, not keyboard-interactive content
<div
className="fixed bottom-4 right-4 z-40 select-none"
style={{ transform: `translate(${position.x}px, ${position.y}px)` }}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div
className={`surface-2 overflow-hidden shadow-2xl transition-all duration-200 ${
minimized ? "h-14 w-14 cursor-pointer" : "h-[460px] w-[320px]"
}`}
>
{minimized ? (
<button
type="button"
onClick={() => setMinimized(false)}
className="flex size-full items-center justify-center"
onMouseDown={handleMouseDown}
aria-label="Buka chatbot"
title="Buka chatbot"
>
<Bot className="size-6 text-[var(--color-signal)]" />
</button>
) : (
<div className="flex h-full flex-col">
{/* Drag handle + controls */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag handle — mouse-only gesture, keyboard users use the buttons in this header */}
<div
className="flex shrink-0 cursor-grab items-center justify-between border-b border-[var(--color-hairline)] px-3 py-2 active:cursor-grabbing"
onMouseDown={handleMouseDown}
>
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-[var(--color-ink-soft)]">
<Bot className="size-3.5 text-[var(--color-signal)]" />
Chatbot
</span>
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={() => setMinimized(true)}
className="flex size-6 items-center justify-center rounded transition-colors hover:bg-[var(--color-surface-2)]"
aria-label="Kecilkan chatbot"
title="Kecilkan chatbot"
>
<Minimize2 className="size-3.5 text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]" />
</button>
</div>
</div>
{/* Chat panel — always open when bubble is expanded */}
<div className="min-h-0 flex-1">
<ChatPanel inputRef={inputRef} />
</div>
</div>
)}
</div>
</div>
);
}
@@ -1,188 +0,0 @@
"use client";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { useChatbotUserId } from "@/hooks/use-chatbot-user";
import { chatbotApi } from "@/lib/api";
export type ChatbotExpression =
| "idle"
| "listening"
| "surprise"
| "happy"
| "sad"
| "talking";
interface ChatbotMessage {
role: "user" | "assistant";
content: string;
timestamp: string;
}
interface ChatbotContextValue {
/** Expression the chatbot avatar should display */
expression: ChatbotExpression;
setExpression: (expr: ChatbotExpression) => void;
/** Whether the enlarged bubble is minimized to a small icon */
minimized: boolean;
setMinimized: (v: boolean) => void;
/**
* @deprecated Use `minimized` / `setMinimized` instead.
* Legacy toggle alias kept for compatibility.
*/
isOpen: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
/** Chat messages with real API backend */
messages: ChatbotMessage[];
sendMessage: (content: string) => Promise<void>;
clearMessages: () => Promise<void>;
isTyping: boolean;
/** Active guild context sent to the backend so answers reference the server */
guildId: string;
setGuildId: (g: string) => void;
}
const ChatbotContext = createContext<ChatbotContextValue | null>(null);
export function ChatbotProvider({ children }: { children: ReactNode }) {
const [expression, setExpression] = useState<ChatbotExpression>("idle");
const [minimized, setMinimized] = useState(true);
const [messages, setMessages] = useState<ChatbotMessage[]>([]);
const [isTyping, setIsTyping] = useState(false);
const [guildId, setGuildId] = useState("");
const historyFetched = useRef(false);
const userId = useChatbotUserId();
// Derived legacy state
const isOpen = !minimized;
const setOpen = useCallback((open: boolean) => {
setMinimized(!open);
}, []);
const toggle = useCallback(() => {
setMinimized((prev) => !prev);
}, []);
// Load chat history on first mount (per-device user history)
useEffect(() => {
if (historyFetched.current || !userId) return;
historyFetched.current = true;
chatbotApi
.getHistory(userId)
.then((res) => {
// Backend returns rows {user_message, bot_response, created_at} —
// interleave each user message with its bot reply.
const withReplies: ChatbotMessage[] = [];
for (const row of res.history ?? []) {
withReplies.push({
role: "user",
content: row.user_message,
timestamp: row.created_at,
});
withReplies.push({
role: "assistant",
content: row.bot_response,
timestamp: row.created_at,
});
}
setMessages(withReplies);
})
.catch(() => {
// API may not be available yet — silently ignore
});
}, [userId]);
const sendMessage = useCallback(
async (content: string) => {
if (!content.trim()) return;
const userMsg: ChatbotMessage = {
role: "user",
content: content.trim(),
timestamp: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMsg]);
setExpression("listening");
setIsTyping(true);
try {
// Send active guild as context so the backend can answer with
// real server insights (serverInsights path in chatbot.service),
// and the per-device user id so the history stays isolated.
const res = await chatbotApi.send(content.trim(), guildId, userId);
const botMsg: ChatbotMessage = {
role: "assistant",
content: res.response,
timestamp: res.timestamp ?? new Date().toISOString(),
};
setMessages((prev) => [...prev, botMsg]);
setExpression("happy");
} catch {
const errorMsg: ChatbotMessage = {
role: "assistant",
content:
"Maaf, aku lagi gagal nyambung ke server. Coba tanya lagi ya 🙏",
timestamp: new Date().toISOString(),
};
setMessages((prev) => [...prev, errorMsg]);
setExpression("sad");
} finally {
setIsTyping(false);
}
},
[guildId, userId],
);
const clearMessages = useCallback(async () => {
try {
await chatbotApi.clearHistory(userId);
} catch {
// Best-effort clear
}
setMessages([]);
}, [userId]);
return (
<ChatbotContext.Provider
value={{
expression,
setExpression,
minimized,
setMinimized,
isOpen,
setOpen,
toggle,
messages,
sendMessage,
clearMessages,
isTyping,
guildId,
setGuildId,
}}
>
{children}
</ChatbotContext.Provider>
);
}
export function useChatbot(): ChatbotContextValue {
const ctx = useContext(ChatbotContext);
if (!ctx) {
throw new Error("useChatbot must be used within a ChatbotProvider");
}
return ctx;
}
@@ -0,0 +1,132 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Bot, Send, X, MessageCircle } from "lucide-react";
import { chatbotApi } from "@/lib/api";
import { useChatbotUserId } from "@/hooks/use-chatbot-user";
import { GlassPanel, Input, Button, Avatar } from "@/components/primitives";
import { toast } from "@/components/primitives";
import { cn } from "@/lib/utils";
interface Msg {
role: "user" | "bot";
content: string;
}
export function Chatbot() {
const userId = useChatbotUserId();
const [open, setOpen] = useState(false);
const [msgs, setMsgs] = useState<Msg[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open || !userId) return;
chatbotApi
.getHistory(userId)
.then((res) => {
setMsgs(
res.history
.slice(-12)
.flatMap((h) => [
{ role: "user" as const, content: h.user_message },
{ role: "bot" as const, content: h.bot_response },
]),
);
})
.catch(() => {});
}, [open, userId]);
useEffect(() => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
}, [msgs, loading]);
const send = async () => {
const text = input.trim();
if (!text || loading || !userId) return;
setInput("");
setMsgs((m) => [...m, { role: "user", content: text }]);
setLoading(true);
try {
const res = await chatbotApi.send(text, undefined, userId);
setMsgs((m) => [...m, { role: "bot", content: res.response }]);
} catch (e) {
toast({ title: "Chat error", description: String(e), tone: "vermilion" });
} finally {
setLoading(false);
}
};
return (
<>
<button
type="button"
aria-label="Open assistant"
onClick={() => setOpen((o) => !o)}
className="fixed bottom-5 right-5 z-50 flex items-center justify-center rounded-full bg-signal text-signal-ink shadow-[0_10px_30px_-8px_var(--color-signal-glow)] transition-transform hover:scale-105"
style={{ width: 52, height: 52 }}
>
{open ? <X className="size-5" /> : <MessageCircle className="size-5" />}
</button>
{open && (
<GlassPanel
className="fixed bottom-20 right-5 z-50 flex w-[min(92vw,360px)] flex-col p-0"
style={{ animation: "fade-up 0.16s ease", height: 460 }}
>
<div className="flex items-center gap-2 border-b border-hairline px-4 py-3">
<span className="flex size-8 items-center justify-center rounded-full bg-signal/15 text-signal">
<Bot className="size-4" />
</span>
<div>
<div className="text-sm font-semibold text-ink">GMW Assistant</div>
<div className="mono text-[0.6rem] text-ink-faint">context-aware</div>
</div>
</div>
<div ref={listRef} className="flex-1 space-y-3 overflow-y-auto px-4 py-3">
{msgs.length === 0 && (
<div className="py-8 text-center text-xs text-ink-faint">
Ask about moderation, voice, or media.
</div>
)}
{msgs.map((m, i) => (
<div key={i} className={cn("flex gap-2", m.role === "user" ? "justify-end" : "justify-start")}>
{m.role === "bot" && <Avatar name="GMW" size={26} className="mt-0.5 bg-signal/15 text-signal" />}
<div
className={cn(
"max-w-[80%] rounded-2xl px-3 py-2 text-sm",
m.role === "user"
? "rounded-br-sm bg-signal/20 text-ink"
: "rounded-bl-sm bg-white/5 text-ink-soft",
)}
>
{m.content}
</div>
</div>
))}
{loading && (
<div className="flex gap-2">
<Avatar name="GMW" size={26} className="bg-signal/15 text-signal" />
<div className="rounded-2xl rounded-bl-sm bg-white/5 px-3 py-2 text-sm text-ink-faint"></div>
</div>
)}
</div>
<div className="flex items-center gap-2 border-t border-hairline p-3">
<Input
placeholder="Message…"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send()}
/>
<Button variant="primary" size="icon" onClick={send} disabled={loading}>
<Send className="size-4" />
</Button>
</div>
</GlassPanel>
)}
</>
);
}
@@ -1,3 +0,0 @@
export { ChatPanel } from "./chat-panel";
export { ChatbotContainer } from "./chatbot-container";
export { ChatbotProvider, useChatbot } from "./chatbot-context";