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:
@@ -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 };
|
||||
Reference in New Issue
Block a user