feat: add app header, sidebar, and mobile navigation components
Deploy to VPS / deploy (push) Failing after 1m45s

- Implemented AppHeader component with theme toggle and connection status.
- Created AppSidebar component for navigation with connection status indicator.
- Added MobileNav component for mobile navigation with responsive design.
- Introduced shared components: DetailStat, EmptyState, ErrorState, LoadingSkeleton, and StatCard for consistent UI.
- Developed hooks for async data fetching: useAsync, useConfig, useDashboard, useGuilds, useMedia, useMessages, useRecordings, and useVoice.
- Added chatbot API functions for sending messages and managing chat history.
This commit is contained in:
asepharyana
2026-07-26 16:14:32 +07:00
parent 726ea8fca5
commit d5a547eb25
35 changed files with 2385 additions and 1733 deletions
@@ -22,11 +22,11 @@ import {
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { mascotApi } from "@/lib/api";
import { chatbotApi } from "@/lib/api";
import type { ChatHistoryMessage } from "@/lib/types";
import { cn } from "@/lib/utils";
export function MascotChatbot() {
export function Chatbot() {
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState<ChatHistoryMessage[]>([]);
const [input, setInput] = useState("");
@@ -35,7 +35,7 @@ export function MascotChatbot() {
useEffect(() => {
if (!open) return;
mascotApi
chatbotApi
.getHistory()
.then(setMessages)
.catch(() => {});
@@ -49,7 +49,7 @@ export function MascotChatbot() {
const handleClear = useCallback(async () => {
try {
await mascotApi.clearHistory();
await chatbotApi.clearHistory();
setMessages([]);
} catch {
// ignore
@@ -69,7 +69,7 @@ export function MascotChatbot() {
]);
try {
const resp = await mascotApi.send(text);
const resp = await chatbotApi.send(text);
setMessages((prev) => [
...prev,
{
@@ -115,7 +115,7 @@ export function MascotChatbot() {
<div className="flex size-6 items-center justify-center rounded-full bg-primary/10">
<Bot className="size-3.5 text-primary" />
</div>
Mascot
Chatbot
<Sparkles className="size-3 text-primary/60 ml-0.5" />
<div className="flex-1" />
{messages.length > 0 && (
@@ -0,0 +1,122 @@
"use client";
import { useEffect, useState } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { dashboardApi, voiceApi } from "@/lib/api";
import { cn } from "@/lib/utils";
interface LiveStats {
totalMessages: number | null;
todayMessages: number | null;
totalFlagged: number | null;
totalRecordings: number | null;
guildCount: number;
wsConnected: boolean;
}
export function LiveStats() {
const [stats, setStats] = useState<LiveStats>({
totalMessages: null,
todayMessages: null,
totalFlagged: null,
totalRecordings: null,
guildCount: 0,
wsConnected: false,
});
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function fetch() {
try {
const [dashStats, guilds] = await Promise.all([
dashboardApi.getStats().catch(() => null),
voiceApi.getGuilds().catch(() => [] as { id: string }[]),
]);
if (cancelled) return;
setStats({
totalMessages: dashStats?.total_messages ?? null,
todayMessages: dashStats?.today_messages ?? null,
totalFlagged: dashStats?.total_flagged ?? null,
totalRecordings: dashStats?.total_voice_recordings ?? null,
guildCount: guilds.length,
wsConnected: false,
});
} catch {
// ignore
} finally {
if (!cancelled) setLoading(false);
}
}
fetch();
return () => {
cancelled = true;
};
}, []);
if (loading) {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-3xl mx-auto">
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
);
}
const items = [
{
label: "Messages Captured",
value: stats.totalMessages ?? "—",
color: "from-sky-500/20 to-cyan-500/10 border-sky-500/30",
},
{
label: "Today",
value: stats.todayMessages ?? "—",
color: "from-emerald-500/20 to-teal-500/10 border-emerald-500/30",
},
{
label: "Flagged",
value: stats.totalFlagged ?? "—",
color: "from-rose-500/20 to-pink-500/10 border-rose-500/30",
},
{
label: "Voice Recordings",
value: stats.totalRecordings ?? "—",
color: "from-violet-500/20 to-purple-500/10 border-violet-500/30",
},
];
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-3xl mx-auto">
{items.map((item) => (
<div
key={item.label}
className={cn(
"rounded-xl border bg-gradient-to-br p-4 text-center backdrop-blur-sm",
item.color,
)}
>
<div className="text-2xl md:text-3xl font-bold tabular-nums tracking-tight">
{typeof item.value === "number"
? item.value.toLocaleString()
: item.value}
</div>
<div className="text-xs text-muted-foreground mt-1">{item.label}</div>
</div>
))}
<div className="col-span-full text-center mt-2">
<div className="inline-flex items-center gap-2 text-xs text-muted-foreground">
<span className="relative flex size-2">
<span className="absolute inline-flex size-full rounded-full bg-green-400 opacity-75 animate-ping" />
<span className="relative inline-flex size-2 rounded-full bg-green-500" />
</span>
{stats.guildCount > 0
? `Monitoring ${stats.guildCount} guild${stats.guildCount > 1 ? "s" : ""}`
: "Connecting to gateway…"}
</div>
</div>
</div>
);
}
@@ -0,0 +1,163 @@
"use client";
import { Moon, PanelLeft, Sun } from "lucide-react";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { navItems } from "@/lib/navigation";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
export function AppHeader() {
const pathname = usePathname();
const { status } = useWebSocket();
const [theme, setTheme] = useState<"light" | "dark">("dark");
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => {
const stored = localStorage.getItem("theme") as "light" | "dark" | null;
if (stored) setTheme(stored);
}, []);
const toggleTheme = () => {
const next = theme === "dark" ? "light" : "dark";
setTheme(next);
localStorage.setItem("theme", next);
document.documentElement.classList.remove("light", "dark");
document.documentElement.classList.add(next);
};
const pageTitle =
navItems
.filter((n) =>
n.matchPrefix === "/dashboard"
? pathname === "/dashboard"
: pathname.startsWith(n.matchPrefix),
)
.map((n) => n.label)
.at(0) ?? "Dashboard";
const statusVariant =
status === "connected"
? "default"
: status === "connecting"
? "secondary"
: "destructive";
const statusLabel =
status === "connected"
? "Connected"
: status === "connecting"
? "Connecting"
: "Disconnected";
return (
<>
<header className="flex h-14 items-center gap-3 border-b border-border/50 bg-background/60 backdrop-blur-lg px-4 shrink-0">
{/* Mobile menu button */}
<Button
variant="ghost"
size="icon"
className="md:hidden size-8 -ml-1 text-muted-foreground"
onClick={() => setMobileOpen(!mobileOpen)}
aria-label="Toggle menu"
>
<PanelLeft className="size-4" />
</Button>
<h1 className="text-sm font-semibold">{pageTitle}</h1>
<div className="flex-1" />
<Badge
variant={statusVariant}
className="gap-1.5 px-2.5 py-1 cursor-default select-none"
>
<span
className={cn(
"size-1.5 rounded-full",
status === "connected" &&
"bg-green-500 shadow-[0_0_6px] shadow-green-500/60",
status === "connecting" && "bg-yellow-500 animate-pulse",
status === "disconnected" && "bg-destructive",
status === "error" && "bg-destructive",
)}
/>
<span className="hidden sm:inline text-xs">{statusLabel}</span>
</Badge>
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
aria-label="Toggle theme"
className="size-8"
>
<Sun
className={cn(
"size-4 transition-all absolute",
theme === "dark"
? "opacity-0 rotate-90 scale-75"
: "opacity-100 rotate-0 scale-100",
)}
/>
<Moon
className={cn(
"size-4 transition-all absolute",
theme === "dark"
? "opacity-100 rotate-0 scale-100"
: "opacity-0 -rotate-90 scale-75",
)}
/>
</Button>
</header>
{/* Mobile overlay menu */}
{mobileOpen && (
<div className="fixed inset-0 z-50 md:hidden">
{/* biome-ignore lint/a11y/noStaticElementInteractions: overlay backdrop */}
<div
className="absolute inset-0 bg-black/50"
role="button"
tabIndex={0}
onClick={() => setMobileOpen(false)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setMobileOpen(false);
}}
/>
<aside className="absolute left-0 top-0 bottom-0 w-60 bg-sidebar border-r border-sidebar-border p-2 space-y-0.5">
{navItems.map(({ href, label, icon: Icon, matchPrefix }) => {
const active = isActivePath(pathname, matchPrefix);
return (
<button
key={href}
type="button"
onClick={() => {
window.location.href = href;
setMobileOpen(false);
}}
className={cn(
"flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-all text-left",
active
? "bg-sidebar-accent text-sidebar-accent-foreground font-medium"
: "text-sidebar-foreground/70 hover:bg-sidebar-accent/50",
)}
>
<Icon className="size-4 shrink-0" />
<span>{label}</span>
</button>
);
})}
</aside>
</div>
)}
</>
);
}
function isActivePath(pathname: string, prefix: string) {
if (prefix === "/dashboard") return pathname === "/dashboard";
return pathname.startsWith(prefix);
}
@@ -0,0 +1,106 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { navItems } from "@/lib/navigation";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
export function AppSidebar() {
const pathname = usePathname();
const router = useRouter();
const { status } = useWebSocket();
const isActive = (prefix: string) => {
if (prefix === "/dashboard") return pathname === "/dashboard";
return pathname.startsWith(prefix);
};
const connectionDot = {
connected: "bg-green-500",
connecting: "bg-yellow-500 animate-pulse",
disconnected: "bg-destructive",
error: "bg-destructive",
}[status];
const connectionLabel = {
connected: "Connected",
connecting: "Connecting",
disconnected: "Disconnected",
error: "Error",
}[status];
return (
<aside className="hidden md:flex md:w-60 flex-col border-r border-border/50 bg-sidebar shrink-0">
{/* Brand */}
<div className="flex h-14 items-center gap-2.5 border-b border-sidebar-border/50 px-4 shrink-0">
<div className="flex size-8 items-center justify-center rounded-lg bg-gradient-to-br from-sky-500 to-cyan-400 text-white text-xs font-bold">
D
</div>
<div>
<div className="text-sm font-bold tracking-tight">
<span className="text-gradient">DC Automod</span>
</div>
<div className="text-[10px] text-muted-foreground tracking-widest uppercase leading-none">
Dashboard
</div>
</div>
</div>
{/* Nav */}
<nav className="flex-1 overflow-y-auto p-2 space-y-0.5">
{navItems.map(({ href, label, icon: Icon, matchPrefix }) => {
const active = isActive(matchPrefix);
return (
<button
key={href}
type="button"
onClick={() => router.push(href)}
className={cn(
"flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-all duration-150 text-left",
active
? "bg-sidebar-accent text-sidebar-accent-foreground font-medium"
: "text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
)}
>
<Icon
className={cn(
"size-4 shrink-0 transition-all",
active && "text-sky-400",
)}
/>
<span className="truncate">{label}</span>
{active && (
<div className="ml-auto w-0.5 h-4 rounded-full bg-gradient-to-b from-sky-400 to-cyan-400" />
)}
</button>
);
})}
</nav>
{/* Connection status */}
<div className="border-t border-sidebar-border/50 p-3 shrink-0">
<div className="flex items-center gap-2">
<span className="relative flex size-2 shrink-0">
<span
className={cn(
"absolute inline-flex size-full rounded-full opacity-75",
connectionDot,
status === "connected" && "animate-ping",
)}
/>
<span
className={cn(
"relative inline-flex size-2 rounded-full",
connectionDot,
)}
/>
</span>
<span className="text-xs text-muted-foreground truncate">
{connectionLabel}
</span>
</div>
</div>
</aside>
);
}
@@ -1,136 +0,0 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { SidebarTrigger } from "@/components/ui/sidebar";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { navItems } from "@/lib/navigation";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
function usePageTitle(): string {
const pathname = usePathname();
// Exact match first, then prefix match
const item = navItems.find((n) => {
if (n.matchPrefix === "/dashboard") return pathname === "/dashboard";
return pathname.startsWith(n.matchPrefix);
});
if (item) return item.label;
// Fallback: derive from pathname
const segment = pathname.split("/").filter(Boolean)[0];
if (segment) {
return segment.charAt(0).toUpperCase() + segment.slice(1);
}
return "Dashboard";
}
export function Header() {
const { status } = useWebSocket();
const pageTitle = usePageTitle();
const [theme, setTheme] = useState<"light" | "dark">("dark");
useEffect(() => {
const stored = localStorage.getItem("theme") as "light" | "dark" | null;
if (stored) setTheme(stored);
}, []);
const toggleTheme = () => {
const next = theme === "dark" ? "light" : "dark";
setTheme(next);
localStorage.setItem("theme", next);
document.documentElement.classList.remove("light", "dark");
document.documentElement.classList.add(next);
};
const statusVariant =
status === "connected"
? "default"
: status === "connecting"
? "secondary"
: "destructive";
const statusLabel =
status === "connected"
? "Connected"
: status === "connecting"
? "Connecting"
: status === "error"
? "Error"
: "Disconnected";
return (
<header className="sticky top-0 z-10 flex h-14 items-center gap-3 border-b border-border/50 bg-background/60 backdrop-blur-lg px-4 md:px-6">
<SidebarTrigger className="-ml-1 size-8 text-muted-foreground hover:text-foreground" />
<h1 className="text-sm font-semibold hidden sm:block">{pageTitle}</h1>
<div className="flex-1" />
{/* Connection status */}
<Tooltip>
<TooltipTrigger>
<span>
<Badge
variant={statusVariant}
className="gap-1.5 px-2.5 py-1 cursor-default select-none"
>
<span
className={cn(
"size-1.5 rounded-full",
status === "connected" &&
"bg-green-500 shadow-[0_0_6px] shadow-green-500/60",
status === "connecting" && "bg-yellow-500 animate-pulse",
(status === "disconnected" || status === "error") &&
"bg-destructive",
)}
/>
<span className="hidden sm:inline text-xs">{statusLabel}</span>
</Badge>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>WebSocket: {statusLabel}</p>
</TooltipContent>
</Tooltip>
{/* Theme toggle */}
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
aria-label="Toggle theme"
className="size-8"
>
<div className="relative size-4">
<Sun
className={cn(
"absolute inset-0 size-4 transition-all duration-300",
theme === "dark"
? "opacity-0 rotate-90 scale-75"
: "opacity-100 rotate-0 scale-100",
)}
/>
<Moon
className={cn(
"absolute inset-0 size-4 transition-all duration-300",
theme === "dark"
? "opacity-100 rotate-0 scale-100"
: "opacity-0 -rotate-90 scale-75",
)}
/>
</div>
</Button>
</header>
);
}
@@ -6,12 +6,12 @@ import { usePathname } from "next/navigation";
import { mobileNavItems } from "@/lib/navigation";
import { cn } from "@/lib/utils";
export function MobileTabBar() {
export function MobileNav() {
const pathname = usePathname();
const isActive = (matchPrefix: string) => {
if (matchPrefix === "/dashboard") return pathname === "/dashboard";
return pathname.startsWith(matchPrefix);
const isActive = (prefix: string) => {
if (prefix === "/dashboard") return pathname === "/dashboard";
return pathname.startsWith(prefix);
};
return (
@@ -24,10 +24,8 @@ export function MobileTabBar() {
key={href}
href={href}
className={cn(
"flex-1 flex flex-col items-center gap-0.5 py-2 text-xs font-medium transition-all duration-200 relative",
active
? "text-sky-400"
: "text-muted-foreground hover:text-foreground",
"flex-1 flex flex-col items-center gap-0.5 py-2 text-xs font-medium transition-all relative",
active ? "text-sky-400" : "text-muted-foreground",
)}
>
<Icon className="size-5" />
@@ -1,142 +0,0 @@
"use client";
import { Radio } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import {
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
Sidebar as SidebarPrimitive,
useSidebar,
} from "@/components/ui/sidebar";
import { navItems } from "@/lib/navigation";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
export function Sidebar() {
const pathname = usePathname();
const router = useRouter();
const { state } = useSidebar();
const { status } = useWebSocket();
const collapsed = state === "collapsed";
const isActive = (matchPrefix: string) => {
if (matchPrefix === "/dashboard") return pathname === "/dashboard";
return pathname.startsWith(matchPrefix);
};
const connectionLabel = {
connected: "Connected",
connecting: "Connecting",
disconnected: "Disconnected",
error: "Error",
}[status];
const connectionColor = {
connected: "bg-green-500",
connecting: "bg-yellow-500",
disconnected: "bg-destructive",
error: "bg-destructive",
}[status];
return (
<SidebarPrimitive variant="sidebar" collapsible="icon">
<SidebarHeader className="border-b border-sidebar-border/50">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
size="lg"
className="group-data-[collapsible=icon]:!p-0"
onClick={() => router.push("/dashboard")}
>
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-gradient-to-br from-sky-500 to-cyan-400 text-sidebar-primary-foreground">
<Radio className="size-4" />
</div>
<div
className={cn(
"flex flex-col gap-0.5 leading-none",
collapsed && "hidden",
)}
>
<span className="text-base font-bold tracking-tight">
<span className="text-gradient">Bete</span>
</span>
<span className="text-[10px] text-muted-foreground tracking-widest uppercase">
Dashboard
</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{navItems.map(({ href, label, icon: Icon, matchPrefix }) => {
const active = isActive(matchPrefix);
return (
<SidebarMenuItem key={href}>
<SidebarMenuButton
isActive={active}
tooltip={collapsed ? label : undefined}
className={cn(
"relative transition-all duration-200",
active &&
"bg-sidebar-accent/80 text-sidebar-accent-foreground font-medium",
)}
onClick={() => router.push(href)}
>
<Icon
className={cn(
"size-4 transition-all duration-200",
active && "text-sky-400 scale-110",
)}
/>
<span>{label}</span>
{active && (
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-full bg-gradient-to-b from-sky-400 to-cyan-400" />
)}
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="border-t border-sidebar-border/50 p-3">
<div className="flex items-center gap-2">
<span className="relative flex size-2 shrink-0">
<span
className={cn(
"absolute inline-flex size-full rounded-full opacity-75",
connectionColor,
status === "connected" && "animate-ping",
)}
/>
<span
className={cn(
"relative inline-flex size-2 rounded-full",
connectionColor,
)}
/>
</span>
{!collapsed && (
<span className="text-xs text-muted-foreground truncate">
{connectionLabel}
</span>
)}
</div>
</SidebarFooter>
</SidebarPrimitive>
);
}
@@ -0,0 +1,38 @@
import { Card, CardContent } from "@/components/ui/card";
import { formatNumber } from "@/lib/format";
import { cn } from "@/lib/utils";
interface DetailStatProps {
label: string;
value: number;
variant?: "default" | "danger" | "success";
suffix?: string;
}
/**
* Small stat label used inside detail views.
*/
export function DetailStat({
label,
value,
variant = "default",
suffix,
}: DetailStatProps) {
return (
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p
className={cn(
"text-lg font-bold tabular-nums",
variant === "danger" && "text-destructive",
variant === "success" && "text-green-500",
)}
>
{formatNumber(value)}
{suffix}
</p>
</CardContent>
</Card>
);
}
@@ -0,0 +1,26 @@
import type { LucideIcon } from "lucide-react";
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description?: string;
}
/**
* Consistent empty state for data-fetching pages.
*/
export function EmptyState({
icon: Icon,
title,
description,
}: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Icon className="size-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">{title}</p>
{description && (
<p className="text-xs text-muted-foreground/60 mt-1">{description}</p>
)}
</div>
);
}
@@ -0,0 +1,27 @@
import { AlertCircle, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
interface ErrorStateProps {
message: string;
onRetry?: () => void;
}
/**
* Consistent error state for data-fetching pages.
* Shows the error message with an optional retry button.
*/
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-destructive mb-3" />
<p className="text-sm text-muted-foreground 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,5 @@
export { DetailStat } from "./detail-stat";
export { EmptyState } from "./empty-state";
export { ErrorState } from "./error-state";
export { LoadingSkeleton } from "./loading-skeleton";
export { StatCard } from "./stat-card";
@@ -0,0 +1,38 @@
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
interface LoadingSkeletonProps {
/** Number of skeleton rows */
count?: number;
/** Height per skeleton row */
height?: string;
/** Grid layout: columns */
columns?: number;
/** Additional classes */
className?: string;
}
/**
* Consistent loading skeleton for data-fetching pages.
* Renders a grid of skeleton placeholders.
*/
export function LoadingSkeleton({
count = 4,
height = "h-28",
columns = 1,
className,
}: LoadingSkeletonProps) {
return (
<div
className={cn(
"grid gap-3",
columns > 1 ? `grid-cols-1 md:grid-cols-${columns}` : "grid-cols-1",
className,
)}
>
{Array.from({ length: count }, (_, i) => (
<Skeleton key={i} className={cn(height, "rounded-xl")} />
))}
</div>
);
}
@@ -0,0 +1,55 @@
import type { LucideIcon } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { formatNumber } from "@/lib/format";
import { cn } from "@/lib/utils";
interface StatCardProps {
label: string;
value: number;
icon: LucideIcon;
variant?: "default" | "danger" | "success";
}
/**
* Metric card used across dashboard and landing pages.
*/
export function StatCard({
label,
value,
icon: Icon,
variant = "default",
}: StatCardProps) {
return (
<Card>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground">{label}</p>
<p
className={cn(
"text-2xl font-bold tabular-nums tracking-tight",
variant === "danger" && "text-destructive",
variant === "success" && "text-green-500",
)}
>
{formatNumber(value)}
</p>
</div>
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg",
variant === "danger"
? "bg-destructive/10 text-destructive"
: variant === "success"
? "bg-green-500/10 text-green-500"
: "bg-primary/10 text-primary",
)}
>
<Icon className="size-4" />
</div>
</div>
</CardContent>
</Card>
);
}