Files
CubeAdmin/components/layout/topbar.tsx
2026-03-08 17:01:36 +01:00

493 lines
15 KiB
TypeScript

"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,
DropdownMenuGroup,
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": "Account Settings",
"/server": "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 statusConfigs = {
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",
},
};
const config = statusConfigs[status.status] ?? {
dot: "bg-zinc-500",
text: status.status,
className: "border-zinc-700/50 bg-zinc-800/50 text-zinc-400",
};
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 [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
// Render a placeholder until mounted to avoid SSR/client mismatch
if (!mounted) {
return (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-zinc-400"
aria-label="Toggle theme"
disabled
>
<Moon className="h-4 w-4" />
</Button>
);
}
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">
<DropdownMenuGroup>
<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>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => router.push("/settings")}>
<Settings className="h-4 w-4" />
Settings
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onClick={handleLogout}>
<LogOut className="h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuGroup>
</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);
}