489 lines
14 KiB
TypeScript
489 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
import Link from "next/link";
|
|
import { usePathname, useRouter } from "next/navigation";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { cn } from "@/lib/utils";
|
|
import { authClient } from "@/lib/auth/client";
|
|
import {
|
|
LayoutDashboard,
|
|
Terminal,
|
|
Activity,
|
|
Clock,
|
|
Users,
|
|
Map,
|
|
Puzzle,
|
|
FolderOpen,
|
|
Archive,
|
|
Settings,
|
|
RefreshCw,
|
|
UserPlus,
|
|
ScrollText,
|
|
LogOut,
|
|
ChevronRight,
|
|
} from "lucide-react";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuGroup,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
interface NavItem {
|
|
label: string;
|
|
href: string;
|
|
icon: React.ElementType;
|
|
}
|
|
|
|
interface NavSection {
|
|
title: string;
|
|
items: NavItem[];
|
|
}
|
|
|
|
interface ServerStatus {
|
|
online: boolean;
|
|
status: "online" | "offline" | "starting" | "stopping";
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Navigation structure
|
|
// ---------------------------------------------------------------------------
|
|
const NAV_SECTIONS: NavSection[] = [
|
|
{
|
|
title: "Overview",
|
|
items: [
|
|
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
|
],
|
|
},
|
|
{
|
|
title: "Server",
|
|
items: [
|
|
{ label: "Console", href: "/console", icon: Terminal },
|
|
{ label: "Monitoring", href: "/monitoring", icon: Activity },
|
|
{ label: "Scheduler", href: "/scheduler", icon: Clock },
|
|
],
|
|
},
|
|
{
|
|
title: "World",
|
|
items: [
|
|
{ label: "Players", href: "/players", icon: Users },
|
|
{ label: "Map", href: "/map", icon: Map },
|
|
],
|
|
},
|
|
{
|
|
title: "Manage",
|
|
items: [
|
|
{ label: "Plugins", href: "/plugins", icon: Puzzle },
|
|
{ label: "Files", href: "/files", icon: FolderOpen },
|
|
{ label: "Backups", href: "/backups", icon: Archive },
|
|
{ label: "Settings", href: "/settings", icon: Settings },
|
|
{ label: "Updates", href: "/updates", icon: RefreshCw },
|
|
],
|
|
},
|
|
{
|
|
title: "Admin",
|
|
items: [
|
|
{ label: "Team", href: "/team", icon: UserPlus },
|
|
{ label: "Audit Log", href: "/audit", icon: ScrollText },
|
|
],
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CubeAdmin Logo SVG
|
|
// ---------------------------------------------------------------------------
|
|
function CubeIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 32 32"
|
|
fill="none"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
{/* Top face */}
|
|
<polygon
|
|
points="16,4 28,10 16,16 4,10"
|
|
fill="#059669"
|
|
opacity="0.9"
|
|
/>
|
|
{/* Left face */}
|
|
<polygon
|
|
points="4,10 16,16 16,28 4,22"
|
|
fill="#047857"
|
|
opacity="0.95"
|
|
/>
|
|
{/* Right face */}
|
|
<polygon
|
|
points="28,10 16,16 16,28 28,22"
|
|
fill="#10b981"
|
|
opacity="0.85"
|
|
/>
|
|
{/* Edge highlights */}
|
|
<polyline
|
|
points="4,10 16,4 28,10 16,16 4,10"
|
|
stroke="#34d399"
|
|
strokeWidth="0.75"
|
|
strokeLinejoin="round"
|
|
fill="none"
|
|
opacity="0.6"
|
|
/>
|
|
<line
|
|
x1="16" y1="16" x2="16" y2="28"
|
|
stroke="#34d399"
|
|
strokeWidth="0.75"
|
|
opacity="0.4"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Server status indicator
|
|
// ---------------------------------------------------------------------------
|
|
function ServerStatusBadge({ collapsed }: { collapsed: boolean }) {
|
|
const { data: status } = useQuery<ServerStatus>({
|
|
queryKey: ["server-status"],
|
|
queryFn: async () => {
|
|
const res = await fetch("/api/server/status");
|
|
if (!res.ok) return { online: false, status: "offline" as const };
|
|
return res.json();
|
|
},
|
|
refetchInterval: 10_000,
|
|
staleTime: 8_000,
|
|
});
|
|
|
|
const isOnline = status?.online ?? false;
|
|
const label = status?.status
|
|
? status.status.charAt(0).toUpperCase() + status.status.slice(1)
|
|
: "Unknown";
|
|
|
|
const dotColor = {
|
|
online: "bg-emerald-500",
|
|
offline: "bg-red-500",
|
|
starting: "bg-yellow-500 animate-pulse",
|
|
stopping: "bg-orange-500 animate-pulse",
|
|
}[status?.status ?? "offline"] ?? "bg-zinc-500";
|
|
|
|
if (collapsed) {
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<div className="flex items-center justify-center p-1">
|
|
<span
|
|
className={cn("block h-2.5 w-2.5 rounded-full", dotColor)}
|
|
aria-label={`Server ${label}`}
|
|
/>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right">
|
|
<span>Server: {label}</span>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-2 rounded-md bg-white/[0.04] px-2.5 py-1.5 ring-1 ring-white/[0.06]">
|
|
<span
|
|
className={cn("block h-2 w-2 flex-shrink-0 rounded-full", dotColor)}
|
|
/>
|
|
<span
|
|
className={cn(
|
|
"text-xs font-medium",
|
|
isOnline ? "text-emerald-400" : "text-zinc-400"
|
|
)}
|
|
>
|
|
{label}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Individual nav item
|
|
// ---------------------------------------------------------------------------
|
|
function NavLink({
|
|
item,
|
|
isActive,
|
|
collapsed,
|
|
}: {
|
|
item: NavItem;
|
|
isActive: boolean;
|
|
collapsed: boolean;
|
|
}) {
|
|
const Icon = item.icon;
|
|
|
|
const linkContent = (
|
|
<Link
|
|
href={item.href}
|
|
className={cn(
|
|
"group flex items-center gap-3 rounded-md px-2.5 py-2 text-sm font-medium transition-all duration-150 outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50",
|
|
collapsed && "justify-center px-2",
|
|
isActive
|
|
? "bg-emerald-600/20 text-emerald-400 ring-1 ring-emerald-500/20"
|
|
: "text-zinc-400 hover:bg-white/[0.05] hover:text-zinc-100"
|
|
)}
|
|
aria-current={isActive ? "page" : undefined}
|
|
>
|
|
<Icon
|
|
className={cn(
|
|
"h-4 w-4 flex-shrink-0 transition-colors duration-150",
|
|
isActive
|
|
? "text-emerald-400"
|
|
: "text-zinc-500 group-hover:text-zinc-300"
|
|
)}
|
|
/>
|
|
{!collapsed && (
|
|
<span className="truncate leading-none">{item.label}</span>
|
|
)}
|
|
{!collapsed && isActive && (
|
|
<ChevronRight className="ml-auto h-3 w-3 text-emerald-500/60" />
|
|
)}
|
|
</Link>
|
|
);
|
|
|
|
if (collapsed) {
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger>{linkContent}</TooltipTrigger>
|
|
<TooltipContent side="right">
|
|
<span>{item.label}</span>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
return linkContent;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sidebar
|
|
// ---------------------------------------------------------------------------
|
|
export function Sidebar() {
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
const [mounted, setMounted] = useState(false);
|
|
|
|
// Responsive: auto-collapse on small screens
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
const mq = window.matchMedia("(max-width: 1023px)");
|
|
const handler = (e: MediaQueryListEvent) => setCollapsed(e.matches);
|
|
setCollapsed(mq.matches);
|
|
mq.addEventListener("change", handler);
|
|
return () => mq.removeEventListener("change", handler);
|
|
}, []);
|
|
|
|
const { data: session } = useQuery({
|
|
queryKey: ["session"],
|
|
queryFn: async () => {
|
|
const { data } = await authClient.getSession();
|
|
return data;
|
|
},
|
|
staleTime: 5 * 60_000,
|
|
});
|
|
|
|
async function handleLogout() {
|
|
await authClient.signOut();
|
|
router.push("/login");
|
|
}
|
|
|
|
if (!mounted) return null;
|
|
|
|
const sidebarWidth = collapsed ? "w-[60px]" : "w-[240px]";
|
|
|
|
return (
|
|
<aside
|
|
className={cn(
|
|
"relative flex h-screen flex-shrink-0 flex-col border-r border-white/[0.06] bg-[#0a0a0a] transition-[width] duration-200 ease-in-out",
|
|
sidebarWidth
|
|
)}
|
|
aria-label="Main navigation"
|
|
>
|
|
{/* Logo */}
|
|
<div
|
|
className={cn(
|
|
"flex h-14 flex-shrink-0 items-center border-b border-white/[0.06]",
|
|
collapsed ? "justify-center px-0" : "gap-2.5 px-4"
|
|
)}
|
|
>
|
|
<CubeIcon className="h-7 w-7 flex-shrink-0" />
|
|
{!collapsed && (
|
|
<span className="text-sm font-semibold tracking-tight text-emerald-400">
|
|
CubeAdmin
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Server status */}
|
|
<div
|
|
className={cn(
|
|
"flex-shrink-0 border-b border-white/[0.06]",
|
|
collapsed ? "px-2 py-2" : "px-3 py-2.5"
|
|
)}
|
|
>
|
|
<ServerStatusBadge collapsed={collapsed} />
|
|
</div>
|
|
|
|
{/* Navigation */}
|
|
<nav className="flex-1 overflow-y-auto overflow-x-hidden py-3 scrollbar-thin">
|
|
<div className={cn("space-y-4", collapsed ? "px-1.5" : "px-2")}>
|
|
{NAV_SECTIONS.map((section) => {
|
|
const hasActiveItem = section.items.some(
|
|
(item) =>
|
|
pathname === item.href || pathname.startsWith(item.href + "/")
|
|
);
|
|
|
|
return (
|
|
<div key={section.title}>
|
|
{!collapsed && (
|
|
<p className="mb-1 px-2.5 text-[10px] font-semibold uppercase tracking-widest text-zinc-600">
|
|
{section.title}
|
|
</p>
|
|
)}
|
|
{collapsed && hasActiveItem && (
|
|
<div className="mb-1 h-px w-full bg-emerald-500/20 rounded-full" />
|
|
)}
|
|
<div className="space-y-0.5">
|
|
{section.items.map((item) => {
|
|
const isActive =
|
|
pathname === item.href ||
|
|
pathname.startsWith(item.href + "/");
|
|
return (
|
|
<NavLink
|
|
key={item.href}
|
|
item={item}
|
|
isActive={isActive}
|
|
collapsed={collapsed}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</nav>
|
|
|
|
{/* Collapse toggle */}
|
|
<button
|
|
onClick={() => setCollapsed((c) => !c)}
|
|
className={cn(
|
|
"flex-shrink-0 flex items-center gap-2 border-t border-white/[0.06] px-3 py-2 text-xs text-zinc-600 transition-colors hover:bg-white/[0.04] hover:text-zinc-400",
|
|
collapsed && "justify-center"
|
|
)}
|
|
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
>
|
|
<ChevronRight
|
|
className={cn(
|
|
"h-3.5 w-3.5 transition-transform duration-200",
|
|
!collapsed && "rotate-180"
|
|
)}
|
|
/>
|
|
{!collapsed && <span>Collapse</span>}
|
|
</button>
|
|
|
|
{/* User menu */}
|
|
<div
|
|
className={cn(
|
|
"flex-shrink-0 border-t border-white/[0.06]",
|
|
collapsed ? "p-2" : "p-3"
|
|
)}
|
|
>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
className={cn(
|
|
"flex w-full items-center gap-2.5 rounded-md p-1.5 text-left transition-colors hover:bg-white/[0.05] outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50",
|
|
collapsed && "justify-center"
|
|
)}
|
|
>
|
|
{/* Avatar */}
|
|
<div className="relative flex-shrink-0">
|
|
{session?.user?.image ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={session.user.image}
|
|
alt={session.user.name ?? "User avatar"}
|
|
className="h-7 w-7 rounded-full object-cover ring-1 ring-white/10"
|
|
/>
|
|
) : (
|
|
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-emerald-600/30 ring-1 ring-emerald-500/20">
|
|
<span className="text-xs font-semibold text-emerald-400">
|
|
{(session?.user?.name ?? session?.user?.email ?? "?")
|
|
.charAt(0)
|
|
.toUpperCase()}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{/* Online indicator */}
|
|
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full bg-emerald-500 ring-1 ring-[#0a0a0a]" />
|
|
</div>
|
|
|
|
{!collapsed && (
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-xs font-medium text-zinc-200">
|
|
{session?.user?.name ?? "Loading…"}
|
|
</p>
|
|
<p className="truncate text-[10px] text-zinc-500">
|
|
{(session?.user as { role?: string } | undefined)?.role ?? "Admin"}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</DropdownMenuTrigger>
|
|
|
|
<DropdownMenuContent
|
|
side={collapsed ? "right" : "top"}
|
|
align="start"
|
|
sideOffset={8}
|
|
className="w-52"
|
|
>
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuLabel className="font-normal">
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-sm font-medium text-foreground">
|
|
{session?.user?.name ?? "—"}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground truncate">
|
|
{session?.user?.email ?? "—"}
|
|
</span>
|
|
</div>
|
|
</DropdownMenuLabel>
|
|
</DropdownMenuGroup>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
|
<Settings className="h-4 w-4" />
|
|
Account Settings
|
|
</DropdownMenuItem>
|
|
</DropdownMenuGroup>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
onClick={handleLogout}
|
|
>
|
|
<LogOut className="h-4 w-4" />
|
|
Sign out
|
|
</DropdownMenuItem>
|
|
</DropdownMenuGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|