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,30 +0,0 @@
import type { LucideIcon } from "lucide-react";
import { Inbox } from "lucide-react";
import { cn } from "@/lib/utils";
interface EmptyStateProps {
icon?: LucideIcon;
title?: string;
description?: string;
className?: string;
}
export function EmptyState({
icon: Icon = Inbox,
title = "No data yet",
description = "Nothing to display here yet.",
className,
}: EmptyStateProps) {
return (
<div
className={cn(
"surface flex flex-col items-center gap-2 py-12 text-center",
className,
)}
>
<Icon className="size-8 text-[var(--color-ink-soft)]" />
<p className="text-sm font-medium text-[var(--color-ink)]">{title}</p>
<p className="text-xs text-[var(--color-ink-soft)]">{description}</p>
</div>
);
}
@@ -1,43 +0,0 @@
import { AlertCircle, RefreshCw } from "lucide-react";
import { Component, type ReactNode } from "react";
import { cn } from "@/lib/utils";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return (
this.props.fallback || (
<div className={cn("surface flex flex-col items-center gap-2 py-8")}>
<AlertCircle className="size-6 text-[var(--color-vermilion)]" />
<p className="text-sm text-[var(--color-ink)]">
{this.state.error?.message || "Something went wrong"}
</p>
<button
type="button"
onClick={() => this.setState({ hasError: false })}
className="flex items-center gap-1 text-xs text-[var(--color-signal)] hover:opacity-80 transition-colors"
>
<RefreshCw className="size-3" /> Try again
</button>
</div>
)
);
}
return this.props.children;
}
}
@@ -1,24 +0,0 @@
import { AlertCircle, RefreshCw } from "lucide-react";
import { Button } from "@/components/primitives/button";
interface ErrorStateProps {
message: string;
onRetry?: () => void;
}
export function ErrorState({ message, onRetry }: ErrorStateProps) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<AlertCircle className="size-10 text-[var(--color-vermilion)] mb-3" />
<p className="text-sm text-[var(--color-ink-soft)] mb-4 max-w-sm">
{message}
</p>
{onRetry && (
<Button variant="outline" onClick={onRetry}>
<RefreshCw className="size-4 mr-2" />
Retry
</Button>
)}
</div>
);
}
@@ -0,0 +1,70 @@
"use client";
import { useEffect, useState } from "react";
import { useGuilds, useTextChannels, useVoiceChannels } from "@/hooks";
import { Select, type SelectOption } from "@/components/primitives";
import type { Guild } from "@/lib/types";
export function GuildChannelPicker({
mode,
guildsInitial,
guildId,
channelId,
onChange,
}: {
mode: "voice" | "text";
guildsInitial?: Guild[];
guildId: string | null;
channelId: string | null;
onChange: (guildId: string, channelId: string | null) => void;
}) {
const { data: guilds } = useGuilds(guildsInitial);
// Call both hooks unconditionally (rules of hooks); select by mode.
const voiceChannels = useVoiceChannels(guildId ?? "");
const textChannels = useTextChannels(guildId ?? "");
const channels = mode === "voice" ? voiceChannels.data : textChannels.data;
const [g, setG] = useState(guildId);
const [c, setC] = useState(channelId);
useEffect(() => setG(guildId), [guildId]);
useEffect(() => setC(channelId), [channelId]);
const guildOpts: SelectOption[] = (guilds ?? []).map((x) => ({
value: x.id,
label: x.name,
}));
const channelOpts: SelectOption[] = (channels ?? []).map((x) => ({
value: x.id,
label: x.name,
hint: x.type,
}));
return (
<div className="flex flex-wrap items-center gap-2">
<Select
value={g}
onChange={(v) => {
setG(v);
setC(null);
onChange(v, null);
}}
options={guildOpts}
placeholder="Guild"
size="sm"
className="w-44"
/>
<Select
value={c}
onChange={(v) => {
setC(v);
if (g) onChange(g, v);
}}
options={channelOpts}
placeholder={mode === "voice" ? "Voice channel" : "Text channel"}
size="sm"
className="w-52"
/>
</div>
);
}
@@ -1,97 +0,0 @@
"use client";
import { AlertCircle, RefreshCw } from "lucide-react";
import { useEffect, useRef } from "react";
import { Badge } from "@/components/primitives/badge";
import { Button } from "@/components/primitives/button";
import { Select } from "@/components/primitives/select";
import { Skeleton } from "@/components/primitives/skeleton";
import { useConfig, useGuilds } from "@/hooks";
export interface GuildSelectorProps {
/** Currently selected guild ID */
value: string;
/** Called when user selects a different guild */
onChange: (guildId: string) => void;
/** If true, the bar is hidden when there's only one guild */
autoHide?: boolean;
}
export function GuildSelector({
value,
onChange,
autoHide = true,
}: GuildSelectorProps) {
const { data: guilds = [], isLoading, error, mutate: refetch } = useGuilds();
const { data: config } = useConfig();
const initDone = useRef(false);
useEffect(() => {
if (value || guilds.length === 0 || initDone.current) return;
initDone.current = true;
const preferred = config?.monitorGuildId ?? guilds[0].id;
if (preferred) onChange(preferred);
}, [value, guilds, config, onChange]);
if (autoHide && guilds.length <= 1 && !isLoading && !error) return null;
if (isLoading) {
return (
<div className="flex items-center gap-3 rounded-[var(--radius-r)] bg-[var(--color-surface)] p-3">
<Skeleton className="h-8 w-36" />
<Skeleton rounded className="h-8 w-8" />
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-between rounded-[var(--radius-r)] bg-[var(--color-vermilion)]/10 p-3">
<div className="flex items-center gap-2">
<AlertCircle className="size-4 text-[var(--color-vermilion)] shrink-0" />
<p className="text-sm text-[var(--color-ink-soft)]">
Could not load guilds: {error?.message ?? "Failed to load"}
</p>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="size-3 mr-1" />
Retry
</Button>
</div>
);
}
if (guilds.length === 0) {
return (
<div className="rounded-[var(--radius-r)] bg-[var(--color-amber)]/10 p-3">
<div className="flex items-center gap-2">
<AlertCircle className="size-4 text-[var(--color-amber)] shrink-0" />
<p className="text-sm text-[var(--color-ink-soft)]">
No guilds available. Make sure the Discord gateway is connected.
</p>
</div>
</div>
);
}
return (
<div className="flex items-center gap-3 rounded-[var(--radius-r)] bg-[var(--color-surface)] p-3">
<Badge tone="neutral" className="shrink-0 text-xs font-normal">
Guild
</Badge>
<Select
value={value}
onChange={(e) => e.target.value && onChange(e.target.value)}
className="h-10 w-full max-w-sm"
>
{guilds.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</Select>
</div>
);
}
@@ -1,4 +1,3 @@
export { EmptyState } from "./empty-state";
export { ErrorBoundary } from "./error-boundary";
export { ErrorState } from "./error-state";
export { LoadingSkeleton } from "./loading-skeleton";
export { SectionHeader, MetricTile } from "./section";
export { EmptyState, ErrorState, LoadingState } from "./states";
export { GuildChannelPicker } from "./guild-picker";
@@ -1,43 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
interface LoadingSkeletonProps {
count?: number;
height?: string;
width?: string;
columns?: number;
className?: string;
}
export function LoadingSkeleton({
count = 4,
height = "h-24",
width,
columns,
className,
}: LoadingSkeletonProps) {
const items = Array.from({ length: count }, (_, i) => (
<div
key={i}
className={cn(
"surface-2 overflow-hidden",
height,
width,
className,
)}
>
<div className="w-full h-full animate-shimmer" />
</div>
));
if (columns) {
return (
<div className={`grid grid-cols-1 md:grid-cols-${columns} gap-3`}>
{items}
</div>
);
}
return <div className="space-y-2">{items}</div>;
}
@@ -0,0 +1,78 @@
import { cn } from "@/lib/utils";
export function SectionHeader({
eyebrow,
title,
action,
className,
}: {
eyebrow?: string;
title: React.ReactNode;
action?: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("mb-3 flex items-end justify-between gap-3", className)}>
<div className="min-w-0">
{eyebrow && <div className="eyebrow mb-1">{eyebrow}</div>}
<h2 className="display text-xl text-ink">{title}</h2>
</div>
{action}
</div>
);
}
export function MetricTile({
label,
value,
hint,
tone = "neutral",
spark,
icon,
className,
}: {
label: string;
value: React.ReactNode;
hint?: React.ReactNode;
tone?: "neutral" | "signal" | "amber" | "vermilion";
spark?: number[];
icon?: React.ReactNode;
className?: string;
}) {
const toneColor =
tone === "vermilion"
? "var(--color-vermilion)"
: tone === "amber"
? "var(--color-amber)"
: tone === "signal"
? "var(--color-signal)"
: "var(--color-ink)";
return (
<div className={cn("glass p-4", className)}>
<div className="flex items-center justify-between">
<div className="eyebrow">{label}</div>
{icon && <span className="text-ink-faint">{icon}</span>}
</div>
<div
className="display mt-1 text-[1.9rem] leading-none"
style={{ color: tone === "neutral" ? undefined : toneColor }}
>
{value}
</div>
{hint && <div className="mono mt-1 text-[0.68rem] text-ink-faint">{hint}</div>}
{spark && spark.length > 1 && (
<div className="mt-2">
<div
className="h-1 w-full overflow-hidden rounded-full"
style={{ background: "oklch(1 0 0 / 0.08)" }}
>
<div
className="h-full rounded-full"
style={{ width: `${Math.min(100, (spark[spark.length - 1] / (Math.max(...spark) || 1)) * 100)}%`, background: toneColor, opacity: 0.7 }}
/>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,62 @@
import { AlertTriangle, Inbox, Loader2 } from "lucide-react";
import { Spinner } from "@/components/primitives";
import { cn } from "@/lib/utils";
export function EmptyState({
title = "Nothing here yet",
description,
icon,
className,
}: {
title?: string;
description?: string;
icon?: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("flex flex-col items-center justify-center gap-2 py-12 text-center", className)}>
<div className="text-ink-faint">{icon ?? <Inbox className="size-7" />}</div>
<div className="text-sm font-medium text-ink-soft">{title}</div>
{description && <div className="max-w-xs text-xs text-ink-faint">{description}</div>}
</div>
);
}
export function ErrorState({
title = "Couldn't load",
error,
onRetry,
}: {
title?: string;
error?: unknown;
onRetry?: () => void;
}) {
const msg = error instanceof Error ? error.message : String(error ?? "");
return (
<div className="glass flex flex-col items-center gap-3 p-8 text-center">
<AlertTriangle className="size-7 text-vermilion" />
<div className="text-sm font-medium text-ink">{title}</div>
{msg && <div className="mono max-w-md break-words text-xs text-ink-faint">{msg}</div>}
{onRetry && (
<button
type="button"
onClick={onRetry}
className="mt-1 rounded-[10px] border border-hairline px-3 py-1.5 text-xs text-ink-soft hover:text-ink hover:border-signal/40"
>
Retry
</button>
)}
</div>
);
}
export function LoadingState({ label = "Syncing" }: { label?: string }) {
return (
<div className="flex items-center justify-center gap-2 py-12 text-ink-faint">
<Spinner />
<span className="mono text-xs uppercase tracking-wider">{label}</span>
</div>
);
}
export { Loader2 };