Initial push
This commit is contained in:
72
components/layout/providers.tsx
Normal file
72
components/layout/providers.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QueryClient – created once per client session
|
||||
// ---------------------------------------------------------------------------
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Don't refetch on window focus in development
|
||||
refetchOnWindowFocus: process.env.NODE_ENV === "production",
|
||||
// 30 second stale time by default
|
||||
staleTime: 30_000,
|
||||
// Retry failed queries up to 2 times
|
||||
retry: 2,
|
||||
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
|
||||
},
|
||||
mutations: {
|
||||
// Don't retry mutations by default
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Providers
|
||||
// ---------------------------------------------------------------------------
|
||||
interface ProvidersProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Providers({ children }: ProvidersProps) {
|
||||
// useState ensures the QueryClient is only created once per component mount
|
||||
// and is not shared between different users on SSR.
|
||||
const [queryClient] = useState(() => makeQueryClient());
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider delay={400}>
|
||||
{children}
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
theme="dark"
|
||||
richColors
|
||||
closeButton
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: "oklch(0.205 0 0)",
|
||||
border: "1px solid oklch(1 0 0 / 10%)",
|
||||
color: "oklch(0.985 0 0)",
|
||||
},
|
||||
className: "cubeadmin-toast",
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
481
components/layout/sidebar.tsx
Normal file
481
components/layout/sidebar.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
"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,
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
||||
<Settings className="h-4 w-4" />
|
||||
Account Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
458
components/layout/topbar.tsx
Normal file
458
components/layout/topbar.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTheme } from "next-themes";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Moon,
|
||||
Sun,
|
||||
Bell,
|
||||
Play,
|
||||
Square,
|
||||
RotateCcw,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
Settings,
|
||||
LogOut,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page title map
|
||||
// ---------------------------------------------------------------------------
|
||||
const PAGE_TITLES: Record<string, string> = {
|
||||
"/dashboard": "Dashboard",
|
||||
"/console": "Console",
|
||||
"/monitoring": "Monitoring",
|
||||
"/scheduler": "Scheduler",
|
||||
"/players": "Players",
|
||||
"/map": "World Map",
|
||||
"/plugins": "Plugins",
|
||||
"/files": "File Manager",
|
||||
"/backups": "Backups",
|
||||
"/settings": "Server Settings",
|
||||
"/updates": "Updates",
|
||||
"/team": "Team",
|
||||
"/audit": "Audit Log",
|
||||
};
|
||||
|
||||
function usePageTitle(): string {
|
||||
const pathname = usePathname();
|
||||
// Exact match first
|
||||
if (PAGE_TITLES[pathname]) return PAGE_TITLES[pathname];
|
||||
// Find the longest matching prefix
|
||||
const match = Object.keys(PAGE_TITLES)
|
||||
.filter((key) => pathname.startsWith(key + "/"))
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
return match ? PAGE_TITLES[match] : "CubeAdmin";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
interface ServerStatus {
|
||||
online: boolean;
|
||||
status: "online" | "offline" | "starting" | "stopping";
|
||||
playerCount?: number;
|
||||
maxPlayers?: number;
|
||||
}
|
||||
|
||||
type ServerAction = "start" | "stop" | "restart";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status badge
|
||||
// ---------------------------------------------------------------------------
|
||||
function ServerStatusBadge({ status }: { status: ServerStatus | undefined }) {
|
||||
if (!status) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-zinc-700/50 bg-zinc-800/50 px-2 py-0.5 text-xs font-medium text-zinc-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-zinc-500" />
|
||||
Unknown
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const config = {
|
||||
online: {
|
||||
dot: "bg-emerald-500",
|
||||
text: "Online",
|
||||
className: "border-emerald-500/20 bg-emerald-500/10 text-emerald-400",
|
||||
},
|
||||
offline: {
|
||||
dot: "bg-red-500",
|
||||
text: "Offline",
|
||||
className: "border-red-500/20 bg-red-500/10 text-red-400",
|
||||
},
|
||||
starting: {
|
||||
dot: "bg-yellow-500 animate-pulse",
|
||||
text: "Starting…",
|
||||
className: "border-yellow-500/20 bg-yellow-500/10 text-yellow-400",
|
||||
},
|
||||
stopping: {
|
||||
dot: "bg-orange-500 animate-pulse",
|
||||
text: "Stopping…",
|
||||
className: "border-orange-500/20 bg-orange-500/10 text-orange-400",
|
||||
},
|
||||
}[status.status];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||
config.className
|
||||
)}
|
||||
>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", config.dot)} />
|
||||
{config.text}
|
||||
{status.online && status.playerCount !== undefined && (
|
||||
<span className="text-[10px] opacity-70">
|
||||
{status.playerCount}/{status.maxPlayers}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server action button with confirmation dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
interface ActionButtonProps {
|
||||
action: ServerAction;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
onConfirm: () => void;
|
||||
serverStatus: ServerStatus | undefined;
|
||||
}
|
||||
|
||||
const ACTION_CONFIG: Record<
|
||||
ServerAction,
|
||||
{
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
variant: "default" | "outline" | "destructive" | "ghost" | "secondary" | "link";
|
||||
confirmTitle: string;
|
||||
confirmDescription: string;
|
||||
confirmLabel: string;
|
||||
showWhen: (status: ServerStatus | undefined) => boolean;
|
||||
}
|
||||
> = {
|
||||
start: {
|
||||
label: "Start",
|
||||
icon: Play,
|
||||
variant: "outline",
|
||||
confirmTitle: "Start the server?",
|
||||
confirmDescription:
|
||||
"This will start the Minecraft server. Players will be able to connect once it finishes booting.",
|
||||
confirmLabel: "Start Server",
|
||||
showWhen: (s) => !s || s.status === "offline",
|
||||
},
|
||||
stop: {
|
||||
label: "Stop",
|
||||
icon: Square,
|
||||
variant: "outline",
|
||||
confirmTitle: "Stop the server?",
|
||||
confirmDescription:
|
||||
"This will gracefully stop the server. All online players will be disconnected. Unsaved data will be saved first.",
|
||||
confirmLabel: "Stop Server",
|
||||
showWhen: (s) => s?.status === "online",
|
||||
},
|
||||
restart: {
|
||||
label: "Restart",
|
||||
icon: RotateCcw,
|
||||
variant: "outline",
|
||||
confirmTitle: "Restart the server?",
|
||||
confirmDescription:
|
||||
"This will gracefully restart the server. All online players will be temporarily disconnected.",
|
||||
confirmLabel: "Restart Server",
|
||||
showWhen: (s) => s?.status === "online",
|
||||
},
|
||||
};
|
||||
|
||||
function ServerActionButton({
|
||||
action,
|
||||
disabled,
|
||||
isLoading,
|
||||
onConfirm,
|
||||
serverStatus,
|
||||
}: ActionButtonProps) {
|
||||
const config = ACTION_CONFIG[action];
|
||||
const Icon = config.icon;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!config.showWhen(serverStatus)) return null;
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger
|
||||
disabled={disabled || isLoading}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center justify-center rounded-lg border text-xs font-medium h-7 gap-1.5 px-2.5 transition-all",
|
||||
action === "stop" &&
|
||||
"border-red-500/20 text-red-400 hover:bg-red-500/10 hover:border-red-500/30",
|
||||
action === "restart" &&
|
||||
"border-yellow-500/20 text-yellow-400 hover:bg-yellow-500/10 hover:border-yellow-500/30",
|
||||
action === "start" &&
|
||||
"border-emerald-500/20 text-emerald-400 hover:bg-emerald-500/10 hover:border-emerald-500/30"
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Icon className="h-3 w-3" />
|
||||
)}
|
||||
{config.label}
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{config.confirmTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{config.confirmDescription}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onConfirm();
|
||||
}}
|
||||
className={cn(
|
||||
action === "stop" &&
|
||||
"bg-red-600 hover:bg-red-700 text-white border-0",
|
||||
action === "restart" &&
|
||||
"bg-yellow-600 hover:bg-yellow-700 text-white border-0",
|
||||
action === "start" &&
|
||||
"bg-emerald-600 hover:bg-emerald-700 text-white border-0"
|
||||
)}
|
||||
>
|
||||
{config.confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notifications bell
|
||||
// ---------------------------------------------------------------------------
|
||||
function NotificationBell() {
|
||||
// TODO: fetch real notification count from /api/notifications
|
||||
const count = 0;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative h-8 w-8 text-zinc-400 hover:text-zinc-100"
|
||||
aria-label={`Notifications${count > 0 ? ` (${count} unread)` : ""}`}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
{count > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-emerald-500 text-[9px] font-bold text-white ring-2 ring-[#0a0a0a]">
|
||||
{count > 9 ? "9+" : count}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme toggle
|
||||
// ---------------------------------------------------------------------------
|
||||
function ThemeToggle() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const isDark = resolvedTheme === "dark";
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-zinc-400 hover:text-zinc-100"
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
aria-label={`Switch to ${isDark ? "light" : "dark"} mode`}
|
||||
>
|
||||
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User avatar dropdown (topbar version)
|
||||
// ---------------------------------------------------------------------------
|
||||
function UserMenu() {
|
||||
const router = useRouter();
|
||||
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");
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition-colors hover:bg-white/[0.05] outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-emerald-600/30 ring-1 ring-emerald-500/20 flex-shrink-0">
|
||||
{session?.user?.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={session.user.image}
|
||||
alt=""
|
||||
className="h-6 w-6 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[10px] font-semibold text-emerald-400">
|
||||
{(session?.user?.name ?? session?.user?.email ?? "?")
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className="h-3 w-3 text-zinc-500" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" sideOffset={8} className="w-48">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">{session?.user?.name ?? "—"}</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{session?.user?.email ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={handleLogout}>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Topbar
|
||||
// ---------------------------------------------------------------------------
|
||||
export function Topbar() {
|
||||
const title = usePageTitle();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: serverStatus } = 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 controlMutation = useMutation({
|
||||
mutationFn: async (action: ServerAction) => {
|
||||
const res = await fetch("/api/server/control", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.message ?? `Failed to ${action} server`);
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onMutate: (action) => {
|
||||
toast.loading(`${capitalize(action)}ing server…`, {
|
||||
id: "server-control",
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, action) => {
|
||||
toast.success(`Server ${action} command sent`, { id: "server-control" });
|
||||
// Refetch status after a short delay
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["server-status"] });
|
||||
}, 2000);
|
||||
},
|
||||
onError: (err: Error, action) => {
|
||||
toast.error(`Failed to ${action} server: ${err.message}`, {
|
||||
id: "server-control",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isOperating = controlMutation.isPending;
|
||||
|
||||
return (
|
||||
<header className="flex h-14 flex-shrink-0 items-center justify-between border-b border-white/[0.06] bg-[#0a0a0a]/80 px-6 backdrop-blur-sm">
|
||||
{/* Left: page title */}
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-sm font-semibold text-zinc-100">{title}</h1>
|
||||
<ServerStatusBadge status={serverStatus} />
|
||||
</div>
|
||||
|
||||
{/* Right: controls */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Server quick-actions */}
|
||||
<div className="mr-2 flex items-center gap-1.5">
|
||||
{(["start", "stop", "restart"] as ServerAction[]).map((action) => (
|
||||
<ServerActionButton
|
||||
key={action}
|
||||
action={action}
|
||||
serverStatus={serverStatus}
|
||||
disabled={isOperating}
|
||||
isLoading={isOperating && controlMutation.variables === action}
|
||||
onConfirm={() => controlMutation.mutate(action)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-white/[0.08]" />
|
||||
|
||||
<NotificationBell />
|
||||
<ThemeToggle />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function capitalize(s: string) {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
Reference in New Issue
Block a user