Initial push

This commit is contained in:
2026-03-08 15:49:34 +01:00
parent 8da12bb7d1
commit 47127f276d
101 changed files with 13844 additions and 8 deletions

View File

@@ -0,0 +1,198 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollText, RefreshCw, Search, ChevronLeft, ChevronRight } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
interface AuditEntry {
log: {
id: string;
userId: string;
action: string;
target: string;
targetId: string | null;
details: string | null;
ipAddress: string | null;
createdAt: number;
};
userName: string | null;
userEmail: string | null;
}
const ACTION_COLORS: Record<string, string> = {
"server.start": "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
"server.stop": "bg-zinc-500/20 text-zinc-400 border-zinc-500/30",
"server.restart": "bg-blue-500/20 text-blue-400 border-blue-500/30",
"player.ban": "bg-red-500/20 text-red-400 border-red-500/30",
"player.unban": "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
"player.kick": "bg-amber-500/20 text-amber-400 border-amber-500/30",
"file.upload": "bg-blue-500/20 text-blue-400 border-blue-500/30",
"file.delete": "bg-red-500/20 text-red-400 border-red-500/30",
"plugin.enable": "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
"plugin.disable": "bg-zinc-500/20 text-zinc-400 border-zinc-500/30",
};
function getActionColor(action: string): string {
return ACTION_COLORS[action] ?? "bg-zinc-500/20 text-zinc-400 border-zinc-500/30";
}
export default function AuditPage() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [hasMore, setHasMore] = useState(true);
const LIMIT = 50;
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: String(page),
limit: String(LIMIT),
});
if (search) params.set("action", search);
const res = await fetch(`/api/audit?${params}`);
if (res.ok) {
const data = await res.json();
setEntries(data.logs);
setHasMore(data.logs.length === LIMIT);
}
} finally {
setLoading(false);
}
}, [page, search]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Audit Log</h1>
<p className="text-zinc-400 text-sm mt-1">
Complete history of admin actions
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={fetchLogs}
className="border-zinc-700 text-zinc-400 hover:text-white"
>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
{/* Filter */}
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<Input
placeholder="Filter by action (e.g. player.ban)"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
className="pl-9 bg-zinc-900 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
/>
</div>
<Card className="bg-zinc-900 border-zinc-800">
<CardContent className="p-0">
{loading ? (
<div className="p-4 space-y-3">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full bg-zinc-800" />
))}
</div>
) : entries.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-zinc-600">
<ScrollText className="w-10 h-10 mb-3 opacity-50" />
<p>No audit log entries</p>
</div>
) : (
<div className="divide-y divide-zinc-800">
<div className="grid grid-cols-[auto_1fr_auto_auto] gap-4 px-4 py-2.5 text-xs font-medium text-zinc-500 uppercase tracking-wide">
<span>Action</span>
<span>Details</span>
<span>User</span>
<span>Time</span>
</div>
{entries.map(({ log, userName, userEmail }) => (
<div
key={log.id}
className="grid grid-cols-[auto_1fr_auto_auto] gap-4 px-4 py-3 items-start hover:bg-zinc-800/50 transition-colors"
>
<Badge className={`text-xs shrink-0 mt-0.5 ${getActionColor(log.action)}`}>
{log.action}
</Badge>
<div>
{log.targetId && (
<p className="text-sm text-zinc-300">
Target:{" "}
<span className="font-mono text-xs text-zinc-400">
{log.targetId}
</span>
</p>
)}
{log.details && (
<p className="text-xs text-zinc-500 font-mono mt-0.5 truncate max-w-xs">
{log.details}
</p>
)}
{log.ipAddress && (
<p className="text-xs text-zinc-600 mt-0.5">
IP: {log.ipAddress}
</p>
)}
</div>
<div className="text-right">
<p className="text-sm text-zinc-300">
{userName ?? "Unknown"}
</p>
<p className="text-xs text-zinc-600">{userEmail ?? ""}</p>
</div>
<p className="text-xs text-zinc-500 whitespace-nowrap">
{formatDistanceToNow(new Date(log.createdAt), {
addSuffix: true,
})}
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-sm text-zinc-500">Page {page}</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 1 || loading}
onClick={() => setPage((p) => p - 1)}
className="border-zinc-700 text-zinc-400 hover:text-white"
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={!hasMore || loading}
onClick={() => setPage((p) => p + 1)}
className="border-zinc-700 text-zinc-400 hover:text-white"
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,324 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Archive,
Download,
Trash2,
RefreshCw,
Plus,
MoreHorizontal,
Globe,
Puzzle,
Settings,
Package,
} from "lucide-react";
import { toast } from "sonner";
import { formatDistanceToNow, format } from "date-fns";
interface Backup {
id: string;
name: string;
type: "worlds" | "plugins" | "config" | "full";
size: number;
path: string;
createdAt: number;
status: "pending" | "running" | "completed" | "failed";
triggeredBy: string;
}
const TYPE_CONFIG = {
worlds: { label: "Worlds", icon: Globe, color: "text-blue-400", bg: "bg-blue-500/10" },
plugins: { label: "Plugins", icon: Puzzle, color: "text-amber-400", bg: "bg-amber-500/10" },
config: { label: "Config", icon: Settings, color: "text-violet-400", bg: "bg-violet-500/10" },
full: { label: "Full", icon: Package, color: "text-emerald-400", bg: "bg-emerald-500/10" },
};
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
export default function BackupsPage() {
const [backups, setBackups] = useState<Backup[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const fetchBackups = useCallback(async () => {
try {
const res = await fetch("/api/backups");
if (res.ok) setBackups((await res.json()).backups);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchBackups();
// Poll every 5s to catch running->completed transitions
const interval = setInterval(fetchBackups, 5000);
return () => clearInterval(interval);
}, [fetchBackups]);
const createBackup = async (type: Backup["type"]) => {
setCreating(true);
try {
const res = await fetch("/api/backups", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type }),
});
if (!res.ok) throw new Error((await res.json()).error);
toast.success(`${TYPE_CONFIG[type].label} backup started`);
fetchBackups();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Backup failed");
} finally {
setCreating(false);
}
};
const deleteBackup = async (id: string) => {
try {
const res = await fetch(`/api/backups/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error((await res.json()).error);
toast.success("Backup deleted");
setBackups((prev) => prev.filter((b) => b.id !== id));
} catch (err) {
toast.error(err instanceof Error ? err.message : "Delete failed");
}
setDeleteId(null);
};
const downloadBackup = (id: string) => {
window.open(`/api/backups/${id}`, "_blank");
};
const statusBadge = (status: Backup["status"]) => {
const config = {
pending: "bg-zinc-500/20 text-zinc-400 border-zinc-500/30",
running: "bg-blue-500/20 text-blue-400 border-blue-500/30",
completed: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
failed: "bg-red-500/20 text-red-400 border-red-500/30",
};
return (
<Badge className={`text-xs ${config[status]}`}>
{status === "running" && (
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 mr-1.5 animate-pulse" />
)}
{status.charAt(0).toUpperCase() + status.slice(1)}
</Badge>
);
};
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Backups</h1>
<p className="text-zinc-400 text-sm mt-1">
Create and manage server backups
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={fetchBackups}
className="border-zinc-700 text-zinc-400 hover:text-white"
>
<RefreshCw className="w-4 h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger disabled={creating} className="inline-flex shrink-0 items-center justify-center rounded-lg text-sm font-medium h-7 gap-1 px-2.5 bg-emerald-600 hover:bg-emerald-500 text-white transition-all">
<Plus className="w-4 h-4 mr-1.5" />
New Backup
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-zinc-900 border-zinc-700">
{(Object.entries(TYPE_CONFIG) as [Backup["type"], typeof TYPE_CONFIG.full][]).map(
([type, config]) => {
const Icon = config.icon;
return (
<DropdownMenuItem
key={type}
onClick={() => createBackup(type)}
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
>
<Icon className={`w-4 h-4 mr-2 ${config.color}`} />
{config.label} backup
</DropdownMenuItem>
);
},
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Type summary cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{(Object.entries(TYPE_CONFIG) as [Backup["type"], typeof TYPE_CONFIG.full][]).map(
([type, config]) => {
const Icon = config.icon;
const count = backups.filter(
(b) => b.type === type && b.status === "completed",
).length;
return (
<Card
key={type}
className="bg-zinc-900 border-zinc-800 cursor-pointer hover:border-zinc-700 transition-colors"
onClick={() => createBackup(type)}
>
<CardContent className="p-4 flex items-center gap-3">
<div className={`p-2 rounded-lg ${config.bg}`}>
<Icon className={`w-5 h-5 ${config.color}`} />
</div>
<div>
<p className="text-sm font-medium text-white">
{config.label}
</p>
<p className="text-xs text-zinc-500">{count} backup(s)</p>
</div>
</CardContent>
</Card>
);
},
)}
</div>
{/* Backups list */}
<Card className="bg-zinc-900 border-zinc-800">
<CardContent className="p-0">
{loading ? (
<div className="p-4 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full bg-zinc-800" />
))}
</div>
) : backups.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-zinc-600">
<Archive className="w-10 h-10 mb-3 opacity-50" />
<p>No backups yet</p>
<p className="text-sm mt-1">Create your first backup above</p>
</div>
) : (
<div className="divide-y divide-zinc-800">
<div className="grid grid-cols-[auto_1fr_auto_auto_auto_auto] gap-4 px-4 py-2.5 text-xs font-medium text-zinc-500 uppercase tracking-wide">
<span />
<span>Name</span>
<span>Size</span>
<span>Status</span>
<span>Created</span>
<span />
</div>
{backups.map((backup) => {
const typeConfig = TYPE_CONFIG[backup.type];
const Icon = typeConfig.icon;
return (
<div
key={backup.id}
className="grid grid-cols-[auto_1fr_auto_auto_auto_auto] gap-4 px-4 py-3 items-center hover:bg-zinc-800/50 transition-colors"
>
<div className={`p-1.5 rounded-md ${typeConfig.bg}`}>
<Icon className={`w-4 h-4 ${typeConfig.color}`} />
</div>
<div>
<p className="text-sm font-medium text-white truncate max-w-xs">
{backup.name}
</p>
<p className="text-xs text-zinc-500 capitalize">
{backup.type}
</p>
</div>
<span className="text-sm text-zinc-400">
{backup.size > 0 ? formatBytes(backup.size) : "—"}
</span>
{statusBadge(backup.status)}
<span className="text-sm text-zinc-500">
{formatDistanceToNow(new Date(backup.createdAt), {
addSuffix: true,
})}
</span>
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex shrink-0 items-center justify-center rounded-lg text-sm font-medium w-8 h-8 text-zinc-500 hover:text-white transition-all">
<MoreHorizontal className="w-4 h-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-zinc-900 border-zinc-700">
{backup.status === "completed" && (
<DropdownMenuItem
onClick={() => downloadBackup(backup.id)}
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
>
<Download className="w-4 h-4 mr-2" />
Download
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => setDeleteId(backup.id)}
className="text-red-400 focus:text-red-300 focus:bg-zinc-800"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Delete confirmation */}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent className="bg-zinc-900 border-zinc-700">
<AlertDialogHeader>
<AlertDialogTitle className="text-white">
Delete backup?
</AlertDialogTitle>
<AlertDialogDescription className="text-zinc-400">
This will permanently delete the backup file from disk. This
action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-zinc-700 text-zinc-400">
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteId && deleteBackup(deleteId)}
className="bg-red-600 hover:bg-red-500 text-white"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,254 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { io, Socket } from "socket.io-client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Terminal, Send, Trash2, AlertCircle } from "lucide-react";
import { toast } from "sonner";
interface LogLine {
text: string;
timestamp: number;
type: "info" | "warn" | "error" | "raw";
}
function classifyLine(line: string): LogLine["type"] {
if (/\[WARN\]|WARNING/i.test(line)) return "warn";
if (/\[ERROR\]|SEVERE|Exception|Error/i.test(line)) return "error";
return "info";
}
function LineColor({ type }: { type: LogLine["type"] }) {
const colors = {
info: "text-zinc-300",
warn: "text-amber-400",
error: "text-red-400",
raw: "text-zinc-500",
};
return colors[type];
}
const MAX_LINES = 1000;
export default function ConsolePage() {
const [lines, setLines] = useState<LogLine[]>([]);
const [command, setCommand] = useState("");
const [connected, setConnected] = useState(false);
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const socketRef = useRef<Socket | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
useEffect(() => {
const socket = io("/console", {
transports: ["websocket"],
});
socketRef.current = socket;
socket.on("connect", () => setConnected(true));
socket.on("disconnect", () => setConnected(false));
socket.on("connect_error", () => {
toast.error("Failed to connect to server console");
});
socket.on("output", (data: { line: string; timestamp: number }) => {
setLines((prev) => {
const newLine: LogLine = {
text: data.line,
timestamp: data.timestamp,
type: classifyLine(data.line),
};
const updated = [...prev, newLine];
return updated.length > MAX_LINES ? updated.slice(-MAX_LINES) : updated;
});
});
// Receive buffered history on connect
socket.on("history", (data: { lines: string[] }) => {
const historicalLines = data.lines.map((line) => ({
text: line,
timestamp: Date.now(),
type: classifyLine(line) as LogLine["type"],
}));
setLines(historicalLines);
});
return () => {
socket.disconnect();
};
}, []);
// Auto-scroll to bottom
useEffect(() => {
if (autoScrollRef.current && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [lines]);
const sendCommand = useCallback(() => {
const cmd = command.trim();
if (!cmd || !socketRef.current) return;
// Add to local history
setHistory((prev) => {
const updated = [cmd, ...prev.filter((h) => h !== cmd)].slice(0, 50);
return updated;
});
setHistoryIndex(-1);
// Echo to console
setLines((prev) => [
...prev,
{ text: `> ${cmd}`, timestamp: Date.now(), type: "raw" },
]);
socketRef.current.emit("command", { command: cmd });
setCommand("");
}, [command]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
sendCommand();
} else if (e.key === "ArrowUp") {
e.preventDefault();
const newIndex = Math.min(historyIndex + 1, history.length - 1);
setHistoryIndex(newIndex);
if (history[newIndex]) setCommand(history[newIndex]);
} else if (e.key === "ArrowDown") {
e.preventDefault();
const newIndex = Math.max(historyIndex - 1, -1);
setHistoryIndex(newIndex);
setCommand(newIndex === -1 ? "" : history[newIndex] ?? "");
}
};
const formatTime = (ts: number) =>
new Date(ts).toLocaleTimeString("en", { hour12: false });
return (
<div className="p-6 h-full flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Server Console</h1>
<p className="text-zinc-400 text-sm mt-1">
Real-time server output and command input
</p>
</div>
<div className="flex items-center gap-3">
<Badge
className={
connected
? "bg-emerald-500/20 text-emerald-400 border-emerald-500/30"
: "bg-red-500/20 text-red-400 border-red-500/30"
}
>
<span
className={`w-1.5 h-1.5 rounded-full mr-1.5 ${
connected ? "bg-emerald-500" : "bg-red-500"
} animate-pulse`}
/>
{connected ? "Connected" : "Disconnected"}
</Badge>
<Button
variant="outline"
size="sm"
className="border-zinc-700 text-zinc-400 hover:text-white"
onClick={() => setLines([])}
>
<Trash2 className="w-4 h-4 mr-1.5" />
Clear
</Button>
</div>
</div>
<Card className="bg-zinc-900 border-zinc-800 flex-1 min-h-0 flex flex-col">
<CardHeader className="py-3 px-4 border-b border-zinc-800 flex-row items-center gap-2">
<Terminal className="w-4 h-4 text-emerald-500" />
<CardTitle className="text-sm font-medium text-zinc-400">
Console Output
</CardTitle>
<span className="ml-auto text-xs text-zinc-600">
{lines.length} lines
</span>
</CardHeader>
<CardContent className="p-0 flex-1 min-h-0">
<div
ref={scrollRef}
onScroll={(e) => {
const el = e.currentTarget;
autoScrollRef.current =
el.scrollTop + el.clientHeight >= el.scrollHeight - 20;
}}
className="h-full overflow-y-auto font-mono text-xs p-4 space-y-0.5"
style={{ maxHeight: "calc(100vh - 280px)" }}
>
{lines.length === 0 ? (
<div className="flex flex-col items-center justify-center h-40 text-zinc-600">
<Terminal className="w-8 h-8 mb-2 opacity-50" />
<p>Waiting for server output...</p>
</div>
) : (
lines.map((line, i) => (
<div key={i} className="flex gap-3 leading-5">
<span className="text-zinc-700 shrink-0 select-none">
{formatTime(line.timestamp)}
</span>
<span
className={`break-all ${
{
info: "text-zinc-300",
warn: "text-amber-400",
error: "text-red-400",
raw: "text-emerald-400",
}[line.type]
}`}
>
{line.text}
</span>
</div>
))
)}
</div>
</CardContent>
{/* Command input */}
<div className="p-3 border-t border-zinc-800 flex gap-2">
<div className="flex-1 flex items-center gap-2 bg-zinc-950 border border-zinc-700 rounded-md px-3 focus-within:border-emerald-500/50 transition-colors">
<span className="text-emerald-500 font-mono text-sm select-none">
/
</span>
<input
type="text"
value={command}
onChange={(e) => setCommand(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter server command... (↑↓ for history)"
disabled={!connected}
className="flex-1 bg-transparent py-2 text-sm text-white placeholder:text-zinc-600 outline-none font-mono"
/>
</div>
<Button
onClick={sendCommand}
disabled={!connected || !command.trim()}
className="bg-emerald-600 hover:bg-emerald-500 text-white shrink-0"
>
<Send className="w-4 h-4" />
</Button>
</div>
</Card>
{!connected && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-400 text-sm">
<AlertCircle className="w-4 h-4 shrink-0" />
Console disconnected. The server may be offline or restarting.
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,441 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
FolderOpen,
File,
FileText,
ChevronRight,
Upload,
Download,
Trash2,
RefreshCw,
ArrowLeft,
Home,
} from "lucide-react";
import { toast } from "sonner";
import { useDropzone } from "react-dropzone";
import { formatDistanceToNow } from "date-fns";
import dynamic from "next/dynamic";
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
interface FileEntry {
name: string;
path: string;
isDirectory: boolean;
size: number;
modifiedAt: number;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "—";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
const TEXT_EXTENSIONS = new Set([".txt", ".yml", ".yaml", ".json", ".properties", ".toml", ".cfg", ".conf", ".log", ".md", ".sh", ".ini"]);
function getEditorLanguage(name: string): string {
const ext = name.split(".").pop() ?? "";
const map: Record<string, string> = {
json: "json", yml: "yaml", yaml: "yaml",
properties: "ini", toml: "ini", cfg: "ini",
sh: "shell", md: "markdown", log: "plaintext",
};
return map[ext] ?? "plaintext";
}
function isEditable(name: string): boolean {
const ext = "." + name.split(".").pop()?.toLowerCase();
return TEXT_EXTENSIONS.has(ext);
}
export default function FilesPage() {
const [currentPath, setCurrentPath] = useState("/");
const [entries, setEntries] = useState<FileEntry[]>([]);
const [loading, setLoading] = useState(true);
const [deleteTarget, setDeleteTarget] = useState<FileEntry | null>(null);
const [editFile, setEditFile] = useState<{ path: string; content: string } | null>(null);
const [saving, setSaving] = useState(false);
const fetchEntries = useCallback(async (path: string) => {
setLoading(true);
try {
const res = await fetch(`/api/files/list?path=${encodeURIComponent(path)}`);
if (!res.ok) throw new Error((await res.json()).error);
const data = await res.json();
setEntries(data.entries);
setCurrentPath(data.path);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to load directory");
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchEntries("/"); }, [fetchEntries]);
const handleOpen = (entry: FileEntry) => {
if (entry.isDirectory) {
fetchEntries(entry.path);
} else if (isEditable(entry.name)) {
openEditor(entry);
} else {
window.open(`/api/files/download?path=${encodeURIComponent(entry.path)}`, "_blank");
}
};
const openEditor = async (entry: FileEntry) => {
try {
const res = await fetch(`/api/files/download?path=${encodeURIComponent(entry.path)}`);
const text = await res.text();
setEditFile({ path: entry.path, content: text });
} catch {
toast.error("Failed to open file");
}
};
const saveFile = async () => {
if (!editFile) return;
setSaving(true);
try {
const blob = new Blob([editFile.content], { type: "text/plain" });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const FileClass = (globalThis as any).File ?? Blob;
const file = new FileClass([blob], editFile.path.split("/").pop() ?? "file") as File;
const dir = editFile.path.split("/").slice(0, -1).join("/") || "/";
const form = new FormData();
form.append("file", file);
const res = await fetch(`/api/files/upload?path=${encodeURIComponent(dir)}`, {
method: "POST",
body: form,
});
if (!res.ok) throw new Error((await res.json()).error);
toast.success("File saved");
setEditFile(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
};
const handleDelete = async (entry: FileEntry) => {
try {
const res = await fetch("/api/files/delete", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filePath: entry.path }),
});
if (!res.ok) throw new Error((await res.json()).error);
toast.success(`${entry.name} deleted`);
fetchEntries(currentPath);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Delete failed");
}
setDeleteTarget(null);
};
const { getRootProps, getInputProps, isDragActive, open: openUpload } = useDropzone({
noClick: true,
noKeyboard: true,
onDrop: async (acceptedFiles) => {
for (const file of acceptedFiles) {
const form = new FormData();
form.append("file", file);
try {
const res = await fetch(`/api/files/upload?path=${encodeURIComponent(currentPath)}`, {
method: "POST",
body: form,
});
if (!res.ok) throw new Error((await res.json()).error);
toast.success(`${file.name} uploaded`);
} catch (err) {
toast.error(`Failed to upload ${file.name}`);
}
}
fetchEntries(currentPath);
},
});
// Breadcrumbs
const parts = currentPath === "/" ? [] : currentPath.split("/").filter(Boolean);
const navigateUp = () => {
if (parts.length === 0) return;
const parent = parts.length === 1 ? "/" : "/" + parts.slice(0, -1).join("/");
fetchEntries(parent);
};
if (editFile) {
const fileName = editFile.path.split("/").pop() ?? "file";
return (
<div className="p-6 h-full flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={() => setEditFile(null)}
className="border-zinc-700 text-zinc-400 hover:text-white"
>
<ArrowLeft className="w-4 h-4 mr-1.5" />
Back
</Button>
<div>
<h1 className="text-lg font-bold text-white">{fileName}</h1>
<p className="text-xs text-zinc-500 font-mono">{editFile.path}</p>
</div>
</div>
<Button
onClick={saveFile}
disabled={saving}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
size="sm"
>
{saving ? "Saving..." : "Save File"}
</Button>
</div>
<div className="flex-1 rounded-xl overflow-hidden border border-zinc-800" style={{ minHeight: "500px" }}>
<MonacoEditor
height="100%"
language={getEditorLanguage(fileName)}
theme="vs-dark"
value={editFile.content}
onChange={(v) => setEditFile((prev) => prev ? { ...prev, content: v ?? "" } : null)}
options={{
fontSize: 13,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: "on",
padding: { top: 16, bottom: 16 },
}}
/>
</div>
</div>
);
}
return (
<div className="p-6 space-y-4" {...getRootProps()}>
<input {...getInputProps()} />
{isDragActive && (
<div className="fixed inset-0 z-50 bg-emerald-500/10 border-2 border-dashed border-emerald-500/50 flex items-center justify-center pointer-events-none">
<div className="text-center text-emerald-400">
<Upload className="w-12 h-12 mx-auto mb-2" />
<p className="text-lg font-semibold">Drop files to upload</p>
</div>
</div>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">File Explorer</h1>
<p className="text-zinc-400 text-sm mt-1">
Browse and manage Minecraft server files
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => fetchEntries(currentPath)}
className="border-zinc-700 text-zinc-400 hover:text-white"
>
<RefreshCw className="w-4 h-4" />
</Button>
<Button
size="sm"
onClick={openUpload}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
>
<Upload className="w-4 h-4 mr-1.5" />
Upload
</Button>
</div>
</div>
{/* Breadcrumbs */}
<div className="flex items-center gap-1 text-sm text-zinc-500">
<Button
variant="ghost"
size="sm"
onClick={() => fetchEntries("/")}
className="h-7 px-2 text-zinc-400 hover:text-white"
>
<Home className="w-3.5 h-3.5" />
</Button>
{parts.map((part, i) => (
<span key={i} className="flex items-center gap-1">
<ChevronRight className="w-3.5 h-3.5" />
<Button
variant="ghost"
size="sm"
onClick={() => fetchEntries("/" + parts.slice(0, i + 1).join("/"))}
className="h-7 px-2 text-zinc-400 hover:text-white"
>
{part}
</Button>
</span>
))}
</div>
<Card className="bg-zinc-900 border-zinc-800">
<CardContent className="p-0">
{loading ? (
<div className="p-4 space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full bg-zinc-800" />
))}
</div>
) : entries.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-zinc-600">
<FolderOpen className="w-10 h-10 mb-3 opacity-50" />
<p>Empty directory</p>
</div>
) : (
<div className="divide-y divide-zinc-800">
{parts.length > 0 && (
<button
onClick={navigateUp}
className="flex items-center gap-3 px-4 py-2.5 w-full hover:bg-zinc-800/50 transition-colors text-left"
>
<ArrowLeft className="w-4 h-4 text-zinc-600" />
<span className="text-sm text-zinc-500">..</span>
</button>
)}
{entries.map((entry) => (
<ContextMenu key={entry.path}>
<ContextMenuTrigger>
<button
onDoubleClick={() => handleOpen(entry)}
className="flex items-center gap-3 px-4 py-2.5 w-full hover:bg-zinc-800/50 transition-colors text-left"
>
{entry.isDirectory ? (
<FolderOpen className="w-4 h-4 text-amber-500 shrink-0" />
) : isEditable(entry.name) ? (
<FileText className="w-4 h-4 text-blue-400 shrink-0" />
) : (
<File className="w-4 h-4 text-zinc-500 shrink-0" />
)}
<span className="flex-1 text-sm text-zinc-300 truncate">
{entry.name}
</span>
{!entry.isDirectory && (
<span className="text-xs text-zinc-600 shrink-0">
{formatBytes(entry.size)}
</span>
)}
<span className="text-xs text-zinc-600 shrink-0 hidden sm:block">
{entry.modifiedAt
? formatDistanceToNow(new Date(entry.modifiedAt), {
addSuffix: true,
})
: ""}
</span>
</button>
</ContextMenuTrigger>
<ContextMenuContent className="bg-zinc-900 border-zinc-700">
{entry.isDirectory ? (
<ContextMenuItem
onClick={() => fetchEntries(entry.path)}
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
>
<FolderOpen className="w-4 h-4 mr-2" />
Open
</ContextMenuItem>
) : (
<>
{isEditable(entry.name) && (
<ContextMenuItem
onClick={() => openEditor(entry)}
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
>
<FileText className="w-4 h-4 mr-2" />
Edit
</ContextMenuItem>
)}
<ContextMenuItem
onClick={() =>
window.open(
`/api/files/download?path=${encodeURIComponent(entry.path)}`,
"_blank",
)
}
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
>
<Download className="w-4 h-4 mr-2" />
Download
</ContextMenuItem>
</>
)}
<ContextMenuSeparator className="bg-zinc-700" />
<ContextMenuItem
onClick={() => setDeleteTarget(entry)}
className="text-red-400 focus:text-red-300 focus:bg-zinc-800"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</div>
)}
</CardContent>
</Card>
<p className="text-xs text-zinc-600 text-center">
Double-click to open files/folders Right-click for options Drag and drop to upload
</p>
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent className="bg-zinc-900 border-zinc-700">
<AlertDialogHeader>
<AlertDialogTitle className="text-white">
Delete {deleteTarget?.isDirectory ? "folder" : "file"}?
</AlertDialogTitle>
<AlertDialogDescription className="text-zinc-400">
<span className="font-mono text-white">{deleteTarget?.name}</span>{" "}
will be permanently deleted.
{deleteTarget?.isDirectory && " All contents will be removed."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-zinc-700 text-zinc-400">Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteTarget && handleDelete(deleteTarget)}
className="bg-red-600 hover:bg-red-500 text-white"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { Sidebar } from "@/components/layout/sidebar";
import { Topbar } from "@/components/layout/topbar";
// ---------------------------------------------------------------------------
// Dashboard Layout
// Renders the persistent sidebar + topbar shell around all dashboard pages.
// Auth protection is handled in middleware.ts.
// ---------------------------------------------------------------------------
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex h-screen w-full overflow-hidden bg-zinc-950">
{/* Fixed sidebar */}
<Sidebar />
{/* Main content column */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* Sticky topbar */}
<Topbar />
{/* Scrollable page content */}
<main className="flex-1 overflow-y-auto bg-zinc-950">
<div className="min-h-full p-6">{children}</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Map, ExternalLink, AlertCircle } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
export default function MapPage() {
const [bluemapUrl, setBluemapUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [iframeLoaded, setIframeLoaded] = useState(false);
const [iframeError, setIframeError] = useState(false);
useEffect(() => {
// Fetch BlueMap URL from server settings
fetch("/api/server/settings")
.then((r) => r.json())
.then((data) => {
setBluemapUrl(data?.settings?.bluemapUrl ?? null);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="p-6 space-y-6">
<Skeleton className="h-8 w-48 bg-zinc-800" />
<Skeleton className="h-[70vh] w-full bg-zinc-800 rounded-xl" />
</div>
);
}
if (!bluemapUrl) {
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">3D World Map</h1>
<p className="text-zinc-400 text-sm mt-1">
Interactive BlueMap integration
</p>
</div>
<Card className="bg-zinc-900 border-zinc-800">
<CardContent className="flex flex-col items-center justify-center py-20">
<Map className="w-12 h-12 text-zinc-600 mb-4" />
<h3 className="text-white font-semibold mb-2">
BlueMap not configured
</h3>
<p className="text-zinc-500 text-sm text-center max-w-md mb-6">
BlueMap is a 3D world map plugin for Minecraft servers. Configure
its URL in <strong>Server Settings</strong> to embed it here.
</p>
<a href="/server" className={buttonVariants({ className: "bg-emerald-600 hover:bg-emerald-500 text-white" })}>Go to Server Settings</a>
</CardContent>
</Card>
</div>
);
}
return (
<div className="p-6 space-y-4 h-full flex flex-col">
<div className="flex items-center justify-between shrink-0">
<div>
<h1 className="text-2xl font-bold text-white">3D World Map</h1>
<p className="text-zinc-400 text-sm mt-1">
Powered by BlueMap live world view with player positions
</p>
</div>
<div className="flex items-center gap-3">
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30 text-xs">
BlueMap
</Badge>
<a href={bluemapUrl} target="_blank" rel="noopener noreferrer" className={buttonVariants({ variant: "outline", size: "sm", className: "border-zinc-700 text-zinc-400 hover:text-white" })}>
<ExternalLink className="w-4 h-4 mr-1.5" />
Open in new tab
</a>
</div>
</div>
<div className="flex-1 min-h-0 rounded-xl border border-zinc-800 overflow-hidden bg-zinc-950 relative">
{!iframeLoaded && !iframeError && (
<div className="absolute inset-0 flex items-center justify-center bg-zinc-950 z-10">
<div className="flex flex-col items-center gap-3 text-zinc-500">
<div className="w-8 h-8 border-2 border-emerald-500/30 border-t-emerald-500 rounded-full animate-spin" />
<p className="text-sm">Loading BlueMap...</p>
</div>
</div>
)}
{iframeError && (
<div className="absolute inset-0 flex items-center justify-center bg-zinc-950 z-10">
<div className="flex flex-col items-center gap-3 text-zinc-500 max-w-sm text-center">
<AlertCircle className="w-10 h-10 text-amber-500" />
<p className="text-white font-medium">Could not load BlueMap</p>
<p className="text-sm">
Make sure BlueMap is running at{" "}
<code className="text-emerald-400 text-xs">{bluemapUrl}</code>{" "}
and that the URL is accessible from your browser.
</p>
<Button
variant="outline"
size="sm"
className="border-zinc-700 text-zinc-400"
onClick={() => {
setIframeError(false);
setIframeLoaded(false);
}}
>
Retry
</Button>
</div>
</div>
)}
<iframe
src={bluemapUrl}
className="w-full h-full border-0"
style={{ minHeight: "600px" }}
onLoad={() => setIframeLoaded(true)}
onError={() => setIframeError(true)}
title="BlueMap 3D World Map"
sandbox="allow-scripts allow-same-origin allow-forms"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,269 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { io } from "socket.io-client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
AreaChart,
Area,
} from "recharts";
import { Activity, HardDrive, Clock, Cpu, Server } from "lucide-react";
interface DataPoint {
time: string;
cpu: number;
memory: number;
players: number;
}
interface MonitoringData {
system: {
cpuPercent: number;
totalMemMb: number;
usedMemMb: number;
loadAvg: number[];
uptime: number;
};
server: { running: boolean; uptime?: number };
timestamp: number;
}
const MAX_HISTORY = 60; // 60 data points (e.g. 2 minutes at 2s intervals)
function formatUptime(s: number) {
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m ${s % 60}s`;
}
export default function MonitoringPage() {
const [data, setData] = useState<MonitoringData | null>(null);
const [history, setHistory] = useState<DataPoint[]>([]);
const socketRef = useRef<ReturnType<typeof io> | null>(null);
useEffect(() => {
// Use REST polling (Socket.io monitoring namespace is optional here)
const poll = async () => {
try {
const res = await fetch("/api/monitoring");
if (!res.ok) return;
const json: MonitoringData = await res.json();
setData(json);
const time = new Date(json.timestamp).toLocaleTimeString("en", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
setHistory((prev) => {
const updated = [
...prev,
{
time,
cpu: json.system.cpuPercent,
memory: Math.round(
(json.system.usedMemMb / json.system.totalMemMb) * 100,
),
players: 0,
},
];
return updated.length > MAX_HISTORY
? updated.slice(-MAX_HISTORY)
: updated;
});
} catch {}
};
poll();
const interval = setInterval(poll, 2000);
return () => clearInterval(interval);
}, []);
const memPercent = data
? Math.round((data.system.usedMemMb / data.system.totalMemMb) * 100)
: 0;
const chartTheme = {
grid: "#27272a",
axis: "#52525b",
tooltip: { bg: "#18181b", border: "#3f3f46", text: "#fff" },
};
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">Monitoring</h1>
<p className="text-zinc-400 text-sm mt-1">
Real-time system and server performance metrics
</p>
</div>
{/* Summary cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{
title: "CPU",
value: `${data?.system.cpuPercent ?? 0}%`,
icon: Cpu,
color: "text-blue-500",
bg: "bg-blue-500/10",
},
{
title: "Memory",
value: `${memPercent}%`,
icon: HardDrive,
color: "text-emerald-500",
bg: "bg-emerald-500/10",
sub: `${data?.system.usedMemMb ?? 0} / ${data?.system.totalMemMb ?? 0} MB`,
},
{
title: "Load Avg",
value: data?.system.loadAvg[0].toFixed(2) ?? "—",
icon: Activity,
color: "text-amber-500",
bg: "bg-amber-500/10",
},
{
title: "Uptime",
value: data ? formatUptime(data.system.uptime) : "—",
icon: Clock,
color: "text-violet-500",
bg: "bg-violet-500/10",
},
].map(({ title, value, icon: Icon, color, bg, sub }) => (
<Card key={title} className="bg-zinc-900 border-zinc-800">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<p className="text-sm text-zinc-400">{title}</p>
<div className={`p-2 rounded-lg ${bg}`}>
<Icon className={`w-4 h-4 ${color}`} />
</div>
</div>
<p className="text-2xl font-bold text-white">{value}</p>
{sub && <p className="text-xs text-zinc-500 mt-1">{sub}</p>}
</CardContent>
</Card>
))}
</div>
{/* CPU chart */}
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
<Cpu className="w-4 h-4 text-blue-500" />
CPU Usage Over Time
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={history}>
<defs>
<linearGradient id="cpuGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke={chartTheme.grid} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="time"
stroke={chartTheme.axis}
tick={{ fontSize: 11, fill: "#71717a" }}
interval="preserveStartEnd"
/>
<YAxis
domain={[0, 100]}
stroke={chartTheme.axis}
tick={{ fontSize: 11, fill: "#71717a" }}
tickFormatter={(v) => `${v}%`}
/>
<Tooltip
contentStyle={{
backgroundColor: chartTheme.tooltip.bg,
border: `1px solid ${chartTheme.tooltip.border}`,
borderRadius: 8,
color: chartTheme.tooltip.text,
fontSize: 12,
}}
formatter={(v) => [`${v}%`, "CPU"]}
/>
<Area
type="monotone"
dataKey="cpu"
stroke="#3b82f6"
strokeWidth={2}
fill="url(#cpuGrad)"
dot={false}
activeDot={{ r: 4, fill: "#3b82f6" }}
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Memory chart */}
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
<HardDrive className="w-4 h-4 text-emerald-500" />
Memory Usage Over Time
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={history}>
<defs>
<linearGradient id="memGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke={chartTheme.grid} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="time"
stroke={chartTheme.axis}
tick={{ fontSize: 11, fill: "#71717a" }}
interval="preserveStartEnd"
/>
<YAxis
domain={[0, 100]}
stroke={chartTheme.axis}
tick={{ fontSize: 11, fill: "#71717a" }}
tickFormatter={(v) => `${v}%`}
/>
<Tooltip
contentStyle={{
backgroundColor: chartTheme.tooltip.bg,
border: `1px solid ${chartTheme.tooltip.border}`,
borderRadius: 8,
color: chartTheme.tooltip.text,
fontSize: 12,
}}
formatter={(v) => [`${v}%`, "Memory"]}
/>
<Area
type="monotone"
dataKey="memory"
stroke="#10b981"
strokeWidth={2}
fill="url(#memGrad)"
dot={false}
activeDot={{ r: 4, fill: "#10b981" }}
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
);
}

277
app/(dashboard)/page.tsx Normal file
View File

@@ -0,0 +1,277 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import {
Users,
Puzzle,
HardDrive,
Activity,
Clock,
Zap,
TrendingUp,
AlertTriangle,
} from "lucide-react";
interface MonitoringData {
system: {
cpuPercent: number;
totalMemMb: number;
usedMemMb: number;
loadAvg: number[];
uptime: number;
};
server: {
running: boolean;
uptime?: number;
startedAt?: string;
};
timestamp: number;
}
interface StatsData {
totalPlayers: number;
onlinePlayers: number;
enabledPlugins: number;
totalPlugins: number;
pendingBackups: number;
recentAlerts: string[];
}
function StatCard({
title,
value,
subtitle,
icon: Icon,
accent = "emerald",
loading = false,
}: {
title: string;
value: string | number;
subtitle?: string;
icon: React.ElementType;
accent?: "emerald" | "blue" | "amber" | "red";
loading?: boolean;
}) {
const colors = {
emerald: "text-emerald-500 bg-emerald-500/10",
blue: "text-blue-500 bg-blue-500/10",
amber: "text-amber-500 bg-amber-500/10",
red: "text-red-500 bg-red-500/10",
};
return (
<Card className="bg-zinc-900 border-zinc-800">
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm text-zinc-400 mb-1">{title}</p>
{loading ? (
<Skeleton className="h-8 w-20 bg-zinc-800" />
) : (
<p className="text-2xl font-bold text-white">{value}</p>
)}
{subtitle && (
<p className="text-xs text-zinc-500 mt-1">{subtitle}</p>
)}
</div>
<div className={`p-2.5 rounded-lg ${colors[accent]}`}>
<Icon className="w-5 h-5" />
</div>
</div>
</CardContent>
</Card>
);
}
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
export default function DashboardPage() {
const [monitoring, setMonitoring] = useState<MonitoringData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchMonitoring = async () => {
try {
const res = await fetch("/api/monitoring");
if (res.ok) setMonitoring(await res.json());
} finally {
setLoading(false);
}
};
fetchMonitoring();
const interval = setInterval(fetchMonitoring, 5000);
return () => clearInterval(interval);
}, []);
const memPercent = monitoring
? Math.round((monitoring.system.usedMemMb / monitoring.system.totalMemMb) * 100)
: 0;
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
<p className="text-zinc-400 text-sm mt-1">
Real-time overview of your Minecraft server
</p>
</div>
{/* Status banner */}
<div
className={`flex items-center gap-3 p-4 rounded-xl border ${
monitoring?.server.running
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400"
: "bg-red-500/10 border-red-500/30 text-red-400"
}`}
>
<div
className={`w-2.5 h-2.5 rounded-full animate-pulse ${
monitoring?.server.running ? "bg-emerald-500" : "bg-red-500"
}`}
/>
<span className="font-medium">
Server is {monitoring?.server.running ? "Online" : "Offline"}
</span>
{monitoring?.server.running && monitoring.server.uptime && (
<span className="text-sm opacity-70 ml-auto">
Up for {formatUptime(monitoring.server.uptime)}
</span>
)}
</div>
{/* Stat cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
<StatCard
title="CPU Usage"
value={loading ? "—" : `${monitoring?.system.cpuPercent ?? 0}%`}
subtitle={`Load avg: ${monitoring?.system.loadAvg[0].toFixed(2) ?? "—"}`}
icon={Activity}
accent="blue"
loading={loading}
/>
<StatCard
title="Memory"
value={loading ? "—" : `${monitoring?.system.usedMemMb ?? 0} MB`}
subtitle={`of ${monitoring?.system.totalMemMb ?? 0} MB total`}
icon={HardDrive}
accent={memPercent > 85 ? "red" : memPercent > 60 ? "amber" : "emerald"}
loading={loading}
/>
<StatCard
title="System Uptime"
value={loading ? "—" : formatUptime(monitoring?.system.uptime ?? 0)}
icon={Clock}
accent="emerald"
loading={loading}
/>
<StatCard
title="Server Status"
value={monitoring?.server.running ? "Online" : "Offline"}
icon={Zap}
accent={monitoring?.server.running ? "emerald" : "red"}
loading={loading}
/>
</div>
{/* Resource gauges */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
<Activity className="w-4 h-4 text-blue-500" />
CPU Usage
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-4 w-full bg-zinc-800" />
) : (
<>
<div className="flex justify-between text-sm mb-2">
<span className="text-white font-medium">
{monitoring?.system.cpuPercent ?? 0}%
</span>
<span className="text-zinc-500">100%</span>
</div>
<Progress
value={monitoring?.system.cpuPercent ?? 0}
className="h-2 bg-zinc-800"
/>
</>
)}
</CardContent>
</Card>
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
<HardDrive className="w-4 h-4 text-emerald-500" />
Memory Usage
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-4 w-full bg-zinc-800" />
) : (
<>
<div className="flex justify-between text-sm mb-2">
<span className="text-white font-medium">
{monitoring?.system.usedMemMb ?? 0} MB
</span>
<span className="text-zinc-500">
{monitoring?.system.totalMemMb ?? 0} MB
</span>
</div>
<Progress
value={memPercent}
className="h-2 bg-zinc-800"
/>
</>
)}
</CardContent>
</Card>
</div>
{/* Quick info */}
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader>
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-emerald-500" />
Quick Actions
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ label: "View Console", href: "/console", icon: "⌨️" },
{ label: "Manage Players", href: "/players", icon: "👥" },
{ label: "Plugins", href: "/plugins", icon: "🔌" },
{ label: "Create Backup", href: "/backups", icon: "💾" },
].map(({ label, href, icon }) => (
<a
key={href}
href={href}
className="flex items-center gap-2 p-3 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors text-sm text-zinc-300 hover:text-white"
>
<span>{icon}</span>
<span>{label}</span>
</a>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,386 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Skeleton } from "@/components/ui/skeleton";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Search,
MoreHorizontal,
Ban,
UserX,
Shield,
Clock,
Users,
WifiOff,
RefreshCw,
} from "lucide-react";
import { toast } from "sonner";
import { formatDistanceToNow } from "date-fns";
interface Player {
id: string;
uuid: string | null;
username: string;
firstSeen: number;
lastSeen: number;
isOnline: boolean;
playTime: number;
role: string | null;
isBanned: boolean;
notes: string | null;
}
function PlayerAvatar({ username }: { username: string }) {
return (
<Avatar className="w-8 h-8">
<AvatarImage
src={`https://crafatar.com/avatars/${username}?size=32&overlay`}
alt={username}
/>
<AvatarFallback className="bg-zinc-800 text-zinc-400 text-xs">
{username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
);
}
function formatPlayTime(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (h === 0) return `${m}m`;
return `${h}h ${m}m`;
}
export default function PlayersPage() {
const [players, setPlayers] = useState<Player[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<"all" | "online" | "banned">("all");
const [selectedPlayer, setSelectedPlayer] = useState<Player | null>(null);
const [banDialogOpen, setBanDialogOpen] = useState(false);
const [banReason, setBanReason] = useState("");
const [actionLoading, setActionLoading] = useState(false);
const fetchPlayers = useCallback(async () => {
try {
const params = new URLSearchParams({ q: search, limit: "100" });
if (filter === "online") params.set("online", "true");
if (filter === "banned") params.set("banned", "true");
const res = await fetch(`/api/players?${params}`);
if (res.ok) {
const data = await res.json();
setPlayers(data.players);
}
} finally {
setLoading(false);
}
}, [search, filter]);
useEffect(() => {
setLoading(true);
fetchPlayers();
}, [fetchPlayers]);
const handleBan = async () => {
if (!selectedPlayer || !banReason.trim()) return;
setActionLoading(true);
try {
const res = await fetch(`/api/players/${selectedPlayer.id}?action=ban`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: banReason }),
});
if (!res.ok) throw new Error((await res.json()).error);
toast.success(`${selectedPlayer.username} has been banned`);
setBanDialogOpen(false);
setBanReason("");
fetchPlayers();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to ban player");
} finally {
setActionLoading(false);
}
};
const handleUnban = async (player: Player) => {
try {
const res = await fetch(`/api/players/${player.id}?action=unban`, {
method: "POST",
});
if (!res.ok) throw new Error((await res.json()).error);
toast.success(`${player.username} has been unbanned`);
fetchPlayers();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to unban player");
}
};
const handleKick = async (player: Player) => {
try {
const res = await fetch(`/api/players/${player.id}?action=kick`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: "Kicked by admin" }),
});
if (!res.ok) throw new Error((await res.json()).error);
toast.success(`${player.username} has been kicked`);
fetchPlayers();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to kick player");
}
};
const onlinePlayers = players.filter((p) => p.isOnline).length;
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Players</h1>
<p className="text-zinc-400 text-sm mt-1">
Manage players, bans, and permissions
</p>
</div>
<div className="flex items-center gap-3">
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 mr-1.5 animate-pulse" />
{onlinePlayers} online
</Badge>
<Button
variant="outline"
size="sm"
className="border-zinc-700 text-zinc-400 hover:text-white"
onClick={fetchPlayers}
>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<Input
placeholder="Search players..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 bg-zinc-900 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
/>
</div>
<div className="flex gap-2">
{(["all", "online", "banned"] as const).map((f) => (
<Button
key={f}
variant={filter === f ? "default" : "outline"}
size="sm"
onClick={() => setFilter(f)}
className={
filter === f
? "bg-emerald-600 hover:bg-emerald-500 text-white"
: "border-zinc-700 text-zinc-400 hover:text-white"
}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</Button>
))}
</div>
</div>
{/* Players table */}
<Card className="bg-zinc-900 border-zinc-800">
<CardContent className="p-0">
{loading ? (
<div className="p-4 space-y-3">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full bg-zinc-800" />
))}
</div>
) : players.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-zinc-600">
<Users className="w-10 h-10 mb-3 opacity-50" />
<p>No players found</p>
</div>
) : (
<div className="divide-y divide-zinc-800">
{/* Header */}
<div className="grid grid-cols-[auto_1fr_auto_auto_auto_auto] gap-4 px-4 py-2.5 text-xs font-medium text-zinc-500 uppercase tracking-wide">
<span className="w-8" />
<span>Player</span>
<span>Status</span>
<span>Play Time</span>
<span>Last Seen</span>
<span />
</div>
{players.map((player) => (
<div
key={player.id}
className="grid grid-cols-[auto_1fr_auto_auto_auto_auto] gap-4 px-4 py-3 items-center hover:bg-zinc-800/50 transition-colors"
>
<PlayerAvatar username={player.username} />
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-white">
{player.username}
</span>
{player.role && (
<Badge
variant="outline"
className="text-xs border-zinc-700 text-zinc-400"
>
{player.role}
</Badge>
)}
{player.isBanned && (
<Badge className="text-xs bg-red-500/20 text-red-400 border-red-500/30">
Banned
</Badge>
)}
</div>
<span className="text-xs text-zinc-500">
{player.uuid ?? "UUID unknown"}
</span>
</div>
<div className="flex items-center gap-1.5">
<span
className={`w-1.5 h-1.5 rounded-full ${
player.isOnline ? "bg-emerald-500" : "bg-zinc-600"
}`}
/>
<span
className={`text-sm ${
player.isOnline ? "text-emerald-400" : "text-zinc-500"
}`}
>
{player.isOnline ? "Online" : "Offline"}
</span>
</div>
<span className="text-sm text-zinc-400">
{formatPlayTime(player.playTime)}
</span>
<span className="text-sm text-zinc-500">
{formatDistanceToNow(new Date(player.lastSeen), {
addSuffix: true,
})}
</span>
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex shrink-0 items-center justify-center rounded-lg text-sm font-medium w-8 h-8 text-zinc-500 hover:text-white transition-all">
<MoreHorizontal className="w-4 h-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-zinc-900 border-zinc-700"
>
<DropdownMenuItem
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
onClick={() =>
(window.location.href = `/players/${player.id}`)
}
>
<Shield className="w-4 h-4 mr-2" />
View Profile
</DropdownMenuItem>
{player.isOnline && (
<DropdownMenuItem
className="text-amber-400 focus:text-amber-300 focus:bg-zinc-800"
onClick={() => handleKick(player)}
>
<WifiOff className="w-4 h-4 mr-2" />
Kick
</DropdownMenuItem>
)}
<DropdownMenuSeparator className="bg-zinc-700" />
{player.isBanned ? (
<DropdownMenuItem
className="text-emerald-400 focus:text-emerald-300 focus:bg-zinc-800"
onClick={() => handleUnban(player)}
>
<UserX className="w-4 h-4 mr-2" />
Unban
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="text-red-400 focus:text-red-300 focus:bg-zinc-800"
onClick={() => {
setSelectedPlayer(player);
setBanDialogOpen(true);
}}
>
<Ban className="w-4 h-4 mr-2" />
Ban
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Ban Dialog */}
<Dialog open={banDialogOpen} onOpenChange={setBanDialogOpen}>
<DialogContent className="bg-zinc-900 border-zinc-700">
<DialogHeader>
<DialogTitle className="text-white">
Ban {selectedPlayer?.username}
</DialogTitle>
<DialogDescription className="text-zinc-400">
This will ban the player from the server and record the ban in the
history.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Label className="text-zinc-300">Reason</Label>
<Textarea
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
placeholder="Enter ban reason..."
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 resize-none"
rows={3}
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setBanDialogOpen(false)}
className="border-zinc-700 text-zinc-400"
>
Cancel
</Button>
<Button
onClick={handleBan}
disabled={!banReason.trim() || actionLoading}
className="bg-red-600 hover:bg-red-500 text-white"
>
{actionLoading ? "Banning..." : "Ban Player"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,275 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button, buttonVariants } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Puzzle,
Search,
RefreshCw,
MoreHorizontal,
Power,
RotateCcw,
Upload,
} from "lucide-react";
import { toast } from "sonner";
interface Plugin {
id: string;
name: string;
version: string | null;
description: string | null;
isEnabled: boolean;
jarFile: string | null;
installedAt: number;
}
export default function PluginsPage() {
const [plugins, setPlugins] = useState<Plugin[]>([]);
const [jarFiles, setJarFiles] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [actionLoading, setActionLoading] = useState<string | null>(null);
const fetchPlugins = useCallback(async () => {
try {
const res = await fetch("/api/plugins");
if (res.ok) {
const data = await res.json();
setPlugins(data.plugins);
setJarFiles(data.jarFiles);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchPlugins();
}, [fetchPlugins]);
const handleAction = async (
name: string,
action: "enable" | "disable" | "reload",
) => {
setActionLoading(`${name}-${action}`);
try {
const res = await fetch(`/api/plugins?action=${action}&name=${encodeURIComponent(name)}`, {
method: "POST",
});
if (!res.ok) throw new Error((await res.json()).error);
const labels = { enable: "enabled", disable: "disabled", reload: "reloaded" };
toast.success(`${name} ${labels[action]}`);
fetchPlugins();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Action failed");
} finally {
setActionLoading(null);
}
};
const filtered = plugins.filter((p) =>
p.name.toLowerCase().includes(search.toLowerCase()),
);
const enabledCount = plugins.filter((p) => p.isEnabled).length;
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Plugins</h1>
<p className="text-zinc-400 text-sm mt-1">
Manage your server plugins
</p>
</div>
<div className="flex items-center gap-3">
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30">
{enabledCount} / {plugins.length} active
</Badge>
<Button
variant="outline"
size="sm"
onClick={fetchPlugins}
className="border-zinc-700 text-zinc-400 hover:text-white"
>
<RefreshCw className="w-4 h-4" />
</Button>
<a href="/files?path=plugins" className={buttonVariants({ size: "sm", className: "bg-emerald-600 hover:bg-emerald-500 text-white" })}>
<Upload className="w-4 h-4 mr-1.5" />
Upload Plugin
</a>
</div>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<Input
placeholder="Search plugins..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 bg-zinc-900 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
/>
</div>
{/* Plugins grid */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-32 bg-zinc-800 rounded-xl" />
))}
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-zinc-600">
<Puzzle className="w-10 h-10 mb-3 opacity-50" />
<p>
{plugins.length === 0
? "No plugins installed"
: "No plugins match your search"}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filtered.map((plugin) => (
<Card
key={plugin.id}
className={`bg-zinc-900 border-zinc-800 hover:border-zinc-700 transition-colors ${
!plugin.isEnabled ? "opacity-60" : ""
}`}
>
<CardContent className="p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<div
className={`p-1.5 rounded-md ${
plugin.isEnabled
? "bg-emerald-500/10"
: "bg-zinc-800"
}`}
>
<Puzzle
className={`w-4 h-4 ${
plugin.isEnabled
? "text-emerald-500"
: "text-zinc-500"
}`}
/>
</div>
<div>
<p className="text-sm font-semibold text-white">
{plugin.name}
</p>
{plugin.version && (
<p className="text-xs text-zinc-500">
v{plugin.version}
</p>
)}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex shrink-0 items-center justify-center rounded-lg text-sm font-medium w-7 h-7 text-zinc-500 hover:text-white transition-all">
<MoreHorizontal className="w-4 h-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-zinc-900 border-zinc-700"
>
<DropdownMenuItem
onClick={() =>
handleAction(
plugin.name,
plugin.isEnabled ? "disable" : "enable",
)
}
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
disabled={!!actionLoading}
>
<Power className="w-4 h-4 mr-2" />
{plugin.isEnabled ? "Disable" : "Enable"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleAction(plugin.name, "reload")}
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
disabled={!plugin.isEnabled || !!actionLoading}
>
<RotateCcw className="w-4 h-4 mr-2" />
Reload
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{plugin.description && (
<p className="text-xs text-zinc-500 line-clamp-2 mb-3">
{plugin.description}
</p>
)}
<div className="flex items-center justify-between mt-3 pt-3 border-t border-zinc-800">
<Badge
className={
plugin.isEnabled
? "bg-emerald-500/20 text-emerald-400 border-emerald-500/30 text-xs"
: "bg-zinc-500/20 text-zinc-400 border-zinc-500/30 text-xs"
}
>
{plugin.isEnabled ? "Enabled" : "Disabled"}
</Badge>
<Switch
checked={plugin.isEnabled}
disabled={
actionLoading === `${plugin.name}-enable` ||
actionLoading === `${plugin.name}-disable`
}
onCheckedChange={(checked) =>
handleAction(plugin.name, checked ? "enable" : "disable")
}
className="scale-75"
/>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Jar files not in DB */}
{jarFiles.filter(
(jar) => !plugins.find((p) => p.jarFile === jar || p.name + ".jar" === jar),
).length > 0 && (
<Card className="bg-zinc-900 border-zinc-800 border-dashed">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-zinc-500">
Jar files detected (not yet in database)
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{jarFiles.map((jar) => (
<Badge
key={jar}
variant="outline"
className="border-zinc-700 text-zinc-400 text-xs"
>
{jar}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,346 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Clock, Plus, MoreHorizontal, Trash2, Edit, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { formatDistanceToNow } from "date-fns";
interface Task {
id: string;
name: string;
description: string | null;
cronExpression: string;
command: string;
isEnabled: boolean;
lastRun: number | null;
nextRun: number | null;
createdAt: number;
}
const CRON_PRESETS = [
{ label: "Every minute", value: "* * * * *" },
{ label: "Every 5 minutes", value: "*/5 * * * *" },
{ label: "Every hour", value: "0 * * * *" },
{ label: "Every day at midnight", value: "0 0 * * *" },
{ label: "Every Sunday at 3am", value: "0 3 * * 0" },
];
function TaskForm({
initial,
onSubmit,
onCancel,
loading,
}: {
initial?: Partial<Task>;
onSubmit: (data: Omit<Task, "id" | "lastRun" | "nextRun" | "createdAt">) => void;
onCancel: () => void;
loading: boolean;
}) {
const [name, setName] = useState(initial?.name ?? "");
const [description, setDescription] = useState(initial?.description ?? "");
const [cronExpression, setCronExpression] = useState(initial?.cronExpression ?? "0 0 * * *");
const [command, setCommand] = useState(initial?.command ?? "");
const [isEnabled, setIsEnabled] = useState(initial?.isEnabled ?? true);
return (
<div className="space-y-4">
<div>
<Label className="text-zinc-300">Task Name</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Daily restart"
className="mt-1 bg-zinc-800 border-zinc-700 text-white"
/>
</div>
<div>
<Label className="text-zinc-300">Description (optional)</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description"
className="mt-1 bg-zinc-800 border-zinc-700 text-white"
/>
</div>
<div>
<Label className="text-zinc-300">Cron Expression</Label>
<Input
value={cronExpression}
onChange={(e) => setCronExpression(e.target.value)}
placeholder="* * * * *"
className="mt-1 bg-zinc-800 border-zinc-700 text-white font-mono"
/>
<div className="flex flex-wrap gap-1 mt-2">
{CRON_PRESETS.map((p) => (
<button
key={p.value}
type="button"
onClick={() => setCronExpression(p.value)}
className="text-xs px-2 py-0.5 rounded bg-zinc-700 text-zinc-400 hover:bg-zinc-600 hover:text-white transition-colors"
>
{p.label}
</button>
))}
</div>
</div>
<div>
<Label className="text-zinc-300">Minecraft Command</Label>
<Input
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="e.g. say Server restart in 5 minutes"
className="mt-1 bg-zinc-800 border-zinc-700 text-white font-mono"
/>
<p className="text-xs text-zinc-500 mt-1">
Enter a Minecraft command (without leading /) to execute via RCON.
</p>
</div>
<div className="flex items-center gap-3">
<Switch
checked={isEnabled}
onCheckedChange={setIsEnabled}
/>
<Label className="text-zinc-300">Enable immediately</Label>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel} className="border-zinc-700 text-zinc-400">
Cancel
</Button>
<Button
onClick={() => onSubmit({ name, description: description || null, cronExpression, command, isEnabled })}
disabled={!name || !cronExpression || !command || loading}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
>
{loading ? "Saving..." : "Save Task"}
</Button>
</DialogFooter>
</div>
);
}
export default function SchedulerPage() {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editTask, setEditTask] = useState<Task | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const fetchTasks = useCallback(async () => {
try {
const res = await fetch("/api/scheduler");
if (res.ok) setTasks((await res.json()).tasks);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchTasks(); }, [fetchTasks]);
const handleCreate = async (data: Parameters<typeof TaskForm>[0]["onSubmit"] extends (d: infer D) => void ? D : never) => {
setSaving(true);
try {
const res = await fetch("/api/scheduler", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error((await res.json()).error);
toast.success("Task created");
setDialogOpen(false);
fetchTasks();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to create task");
} finally {
setSaving(false);
}
};
const handleUpdate = async (data: Parameters<typeof TaskForm>[0]["onSubmit"] extends (d: infer D) => void ? D : never) => {
if (!editTask) return;
setSaving(true);
try {
const res = await fetch(`/api/scheduler/${editTask.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error((await res.json()).error);
toast.success("Task updated");
setEditTask(null);
fetchTasks();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to update task");
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
try {
const res = await fetch(`/api/scheduler/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error((await res.json()).error);
toast.success("Task deleted");
setTasks((p) => p.filter((t) => t.id !== id));
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to delete");
}
setDeleteId(null);
};
const toggleTask = async (task: Task) => {
try {
const res = await fetch(`/api/scheduler/${task.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isEnabled: !task.isEnabled }),
});
if (!res.ok) throw new Error((await res.json()).error);
fetchTasks();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to toggle");
}
};
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Scheduler</h1>
<p className="text-zinc-400 text-sm mt-1">Automated recurring tasks</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={fetchTasks} className="border-zinc-700 text-zinc-400 hover:text-white">
<RefreshCw className="w-4 h-4" />
</Button>
<Button size="sm" className="bg-emerald-600 hover:bg-emerald-500 text-white" onClick={() => setDialogOpen(true)}>
<Plus className="w-4 h-4 mr-1.5" /> New Task
</Button>
</div>
</div>
<Card className="bg-zinc-900 border-zinc-800">
<CardContent className="p-0">
{loading ? (
<div className="p-4 space-y-3">
{[1,2,3].map(i => <Skeleton key={i} className="h-16 w-full bg-zinc-800" />)}
</div>
) : tasks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-zinc-600">
<Clock className="w-10 h-10 mb-3 opacity-50" />
<p>No scheduled tasks</p>
<p className="text-sm mt-1">Create a task to automate server commands</p>
</div>
) : (
<div className="divide-y divide-zinc-800">
{tasks.map(task => (
<div key={task.id} className="flex items-center gap-4 px-4 py-4 hover:bg-zinc-800/50 transition-colors">
<Switch checked={task.isEnabled} onCheckedChange={() => toggleTask(task)} className="shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<p className="text-sm font-medium text-white">{task.name}</p>
{task.description && (
<span className="text-xs text-zinc-500"> {task.description}</span>
)}
</div>
<div className="flex items-center gap-3 flex-wrap">
<code className="text-xs text-emerald-400 bg-emerald-500/10 px-1.5 py-0.5 rounded">{task.cronExpression}</code>
<code className="text-xs text-zinc-400 font-mono">{task.command}</code>
</div>
</div>
<div className="text-right shrink-0">
{task.lastRun ? (
<p className="text-xs text-zinc-500">Last: {formatDistanceToNow(new Date(task.lastRun), { addSuffix: true })}</p>
) : (
<p className="text-xs text-zinc-600">Never run</p>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex shrink-0 items-center justify-center rounded-lg text-sm font-medium w-8 h-8 text-zinc-500 hover:text-white transition-all">
<MoreHorizontal className="w-4 h-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-zinc-900 border-zinc-700">
<DropdownMenuItem onClick={() => setEditTask(task)} className="text-zinc-300 focus:text-white focus:bg-zinc-800">
<Edit className="w-4 h-4 mr-2" /> Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteId(task.id)} className="text-red-400 focus:text-red-300 focus:bg-zinc-800">
<Trash2 className="w-4 h-4 mr-2" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Create dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="bg-zinc-900 border-zinc-700">
<DialogHeader>
<DialogTitle className="text-white">New Scheduled Task</DialogTitle>
<DialogDescription className="text-zinc-400">Schedule a Minecraft command to run automatically.</DialogDescription>
</DialogHeader>
<TaskForm onSubmit={handleCreate} onCancel={() => setDialogOpen(false)} loading={saving} />
</DialogContent>
</Dialog>
{/* Edit dialog */}
<Dialog open={!!editTask} onOpenChange={(o) => !o && setEditTask(null)}>
<DialogContent className="bg-zinc-900 border-zinc-700">
<DialogHeader>
<DialogTitle className="text-white">Edit Task</DialogTitle>
</DialogHeader>
{editTask && <TaskForm initial={editTask} onSubmit={handleUpdate} onCancel={() => setEditTask(null)} loading={saving} />}
</DialogContent>
</Dialog>
{/* Delete confirm */}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent className="bg-zinc-900 border-zinc-700">
<AlertDialogHeader>
<AlertDialogTitle className="text-white">Delete task?</AlertDialogTitle>
<AlertDialogDescription className="text-zinc-400">This will stop and remove the scheduled task permanently.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-zinc-700 text-zinc-400">Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => deleteId && handleDelete(deleteId)} className="bg-red-600 hover:bg-red-500 text-white">Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,362 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Settings, RefreshCw, Download, Server, Map } from "lucide-react";
import { toast } from "sonner";
interface ServerSettings {
minecraftPath?: string;
serverJar?: string;
serverVersion?: string;
serverType?: string;
maxRam?: number;
minRam?: number;
rconEnabled?: boolean;
rconPort?: number;
javaArgs?: string;
autoStart?: boolean;
restartOnCrash?: boolean;
backupEnabled?: boolean;
backupSchedule?: string;
bluemapEnabled?: boolean;
bluemapUrl?: string;
}
const SERVER_TYPES = ["vanilla", "paper", "spigot", "bukkit", "fabric", "forge", "bedrock"];
export default function ServerPage() {
const [settings, setSettings] = useState<ServerSettings>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [versions, setVersions] = useState<string[]>([]);
const [loadingVersions, setLoadingVersions] = useState(false);
const [selectedType, setSelectedType] = useState("paper");
const fetchSettings = useCallback(async () => {
try {
const res = await fetch("/api/server/settings");
if (res.ok) {
const data = await res.json();
if (data.settings) {
setSettings(data.settings);
setSelectedType(data.settings.serverType ?? "paper");
}
}
} finally {
setLoading(false);
}
}, []);
const fetchVersions = useCallback(async (type: string) => {
setLoadingVersions(true);
try {
const res = await fetch(`/api/server/versions?type=${type}`);
if (res.ok) setVersions((await res.json()).versions);
} finally {
setLoadingVersions(false);
}
}, []);
useEffect(() => { fetchSettings(); }, [fetchSettings]);
useEffect(() => { fetchVersions(selectedType); }, [selectedType, fetchVersions]);
const save = async (updates: Partial<ServerSettings>) => {
setSaving(true);
try {
const res = await fetch("/api/server/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
if (!res.ok) throw new Error((await res.json()).error);
setSettings((prev) => ({ ...prev, ...updates }));
toast.success("Settings saved");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to save");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="p-6 space-y-6">
<Skeleton className="h-8 w-48 bg-zinc-800" />
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-48 w-full bg-zinc-800 rounded-xl" />
))}
</div>
);
}
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">Server Settings</h1>
<p className="text-zinc-400 text-sm mt-1">
Configure your Minecraft server and CubeAdmin options
</p>
</div>
<Tabs defaultValue="general" className="space-y-4">
<TabsList className="bg-zinc-900 border border-zinc-800">
<TabsTrigger value="general" className="data-[state=active]:bg-zinc-800 data-[state=active]:text-white text-zinc-400">
General
</TabsTrigger>
<TabsTrigger value="performance" className="data-[state=active]:bg-zinc-800 data-[state=active]:text-white text-zinc-400">
Performance
</TabsTrigger>
<TabsTrigger value="updates" className="data-[state=active]:bg-zinc-800 data-[state=active]:text-white text-zinc-400">
Updates
</TabsTrigger>
<TabsTrigger value="integrations" className="data-[state=active]:bg-zinc-800 data-[state=active]:text-white text-zinc-400">
Integrations
</TabsTrigger>
</TabsList>
{/* General */}
<TabsContent value="general" className="space-y-4">
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader>
<CardTitle className="text-base font-medium text-zinc-300 flex items-center gap-2">
<Server className="w-4 h-4 text-emerald-500" />
Server Configuration
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-zinc-300">Minecraft Server Path</Label>
<Input
value={settings.minecraftPath ?? ""}
onChange={(e) => setSettings(p => ({ ...p, minecraftPath: e.target.value }))}
placeholder="/opt/minecraft/server"
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 font-mono text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-zinc-300">Server JAR filename</Label>
<Input
value={settings.serverJar ?? ""}
onChange={(e) => setSettings(p => ({ ...p, serverJar: e.target.value }))}
placeholder="server.jar"
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 font-mono text-sm"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800 border border-zinc-700">
<div>
<p className="text-sm font-medium text-zinc-300">Auto-start on boot</p>
<p className="text-xs text-zinc-500 mt-0.5">Start server when CubeAdmin starts</p>
</div>
<Switch
checked={settings.autoStart ?? false}
onCheckedChange={(v) => save({ autoStart: v })}
/>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800 border border-zinc-700">
<div>
<p className="text-sm font-medium text-zinc-300">Auto-restart on crash</p>
<p className="text-xs text-zinc-500 mt-0.5">Automatically restart if server crashes</p>
</div>
<Switch
checked={settings.restartOnCrash ?? false}
onCheckedChange={(v) => save({ restartOnCrash: v })}
/>
</div>
</div>
<Button
onClick={() => save({ minecraftPath: settings.minecraftPath, serverJar: settings.serverJar })}
disabled={saving}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
size="sm"
>
{saving ? "Saving..." : "Save Changes"}
</Button>
</CardContent>
</Card>
</TabsContent>
{/* Performance */}
<TabsContent value="performance" className="space-y-4">
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader>
<CardTitle className="text-base font-medium text-zinc-300">JVM Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-zinc-300">Min RAM (MB)</Label>
<Input
type="number"
value={settings.minRam ?? 512}
onChange={(e) => setSettings(p => ({ ...p, minRam: parseInt(e.target.value) }))}
className="bg-zinc-800 border-zinc-700 text-white"
min={256}
/>
</div>
<div className="space-y-1.5">
<Label className="text-zinc-300">Max RAM (MB)</Label>
<Input
type="number"
value={settings.maxRam ?? 2048}
onChange={(e) => setSettings(p => ({ ...p, maxRam: parseInt(e.target.value) }))}
className="bg-zinc-800 border-zinc-700 text-white"
min={512}
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-zinc-300">Additional Java Arguments</Label>
<Input
value={settings.javaArgs ?? ""}
onChange={(e) => setSettings(p => ({ ...p, javaArgs: e.target.value }))}
placeholder="-XX:+UseG1GC -XX:MaxGCPauseMillis=200"
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 font-mono text-sm"
/>
<p className="text-xs text-zinc-500">
Aikar&apos;s flags are applied by default with the Docker image.
</p>
</div>
<Button
onClick={() => save({ minRam: settings.minRam, maxRam: settings.maxRam, javaArgs: settings.javaArgs })}
disabled={saving}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
size="sm"
>
{saving ? "Saving..." : "Save Changes"}
</Button>
</CardContent>
</Card>
</TabsContent>
{/* Updates */}
<TabsContent value="updates" className="space-y-4">
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader>
<CardTitle className="text-base font-medium text-zinc-300 flex items-center gap-2">
<Download className="w-4 h-4 text-emerald-500" />
Server Version
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-zinc-300">Server Type</Label>
<Select
value={selectedType}
onValueChange={(v) => { if (v) { setSelectedType(v); fetchVersions(v); } }}
>
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
{SERVER_TYPES.map((t) => (
<SelectItem key={t} value={t} className="text-zinc-300 focus:bg-zinc-700 focus:text-white capitalize">
{t.charAt(0).toUpperCase() + t.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-zinc-300">Version</Label>
<Select
value={settings.serverVersion ?? ""}
onValueChange={(v) => setSettings(p => ({ ...p, serverVersion: v ?? undefined }))}
disabled={loadingVersions}
>
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
<SelectValue placeholder={loadingVersions ? "Loading..." : "Select version"} />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700 max-h-60">
{versions.map((v) => (
<SelectItem key={v} value={v} className="text-zinc-300 focus:bg-zinc-700 focus:text-white">
{v}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-400 text-sm">
<span></span>
<p>Changing server version requires a server restart. Always backup first!</p>
</div>
<Button
onClick={() => save({ serverType: selectedType, serverVersion: settings.serverVersion })}
disabled={saving || !settings.serverVersion}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
size="sm"
>
{saving ? "Saving..." : "Apply Version"}
</Button>
</CardContent>
</Card>
</TabsContent>
{/* Integrations */}
<TabsContent value="integrations" className="space-y-4">
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader>
<CardTitle className="text-base font-medium text-zinc-300 flex items-center gap-2">
<Map className="w-4 h-4 text-emerald-500" />
BlueMap Integration
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800 border border-zinc-700">
<div>
<p className="text-sm font-medium text-zinc-300">Enable BlueMap</p>
<p className="text-xs text-zinc-500 mt-0.5">Show the 3D map in the Map section</p>
</div>
<Switch
checked={settings.bluemapEnabled ?? false}
onCheckedChange={(v) => save({ bluemapEnabled: v })}
/>
</div>
{settings.bluemapEnabled && (
<div className="space-y-1.5">
<Label className="text-zinc-300">BlueMap URL</Label>
<Input
value={settings.bluemapUrl ?? ""}
onChange={(e) => setSettings(p => ({ ...p, bluemapUrl: e.target.value }))}
placeholder="http://localhost:8100"
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 font-mono text-sm"
/>
<p className="text-xs text-zinc-500">
The URL where BlueMap is accessible from your browser.
</p>
</div>
)}
<Button
onClick={() => save({ bluemapEnabled: settings.bluemapEnabled, bluemapUrl: settings.bluemapUrl })}
disabled={saving}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
size="sm"
>
{saving ? "Saving..." : "Save"}
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,239 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Skeleton } from "@/components/ui/skeleton";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { UserPlus, Mail, Clock, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { formatDistanceToNow } from "date-fns";
interface TeamUser {
id: string;
name: string;
email: string;
role: string | null;
createdAt: number;
image: string | null;
}
interface PendingInvite {
id: string;
email: string;
role: string;
expiresAt: number;
createdAt: number;
}
const ROLE_CONFIG: Record<string, { label: string; color: string }> = {
superadmin: { label: "Super Admin", color: "bg-amber-500/20 text-amber-400 border-amber-500/30" },
admin: { label: "Admin", color: "bg-blue-500/20 text-blue-400 border-blue-500/30" },
moderator: { label: "Moderator", color: "bg-violet-500/20 text-violet-400 border-violet-500/30" },
};
export default function TeamPage() {
const [users, setUsers] = useState<TeamUser[]>([]);
const [invites, setInvites] = useState<PendingInvite[]>([]);
const [loading, setLoading] = useState(true);
const [inviteOpen, setInviteOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<"admin" | "moderator">("moderator");
const [inviting, setInviting] = useState(false);
const fetchTeam = useCallback(async () => {
try {
const res = await fetch("/api/team");
if (res.ok) {
const data = await res.json();
setUsers(data.users);
setInvites(data.pendingInvites ?? []);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchTeam(); }, [fetchTeam]);
const handleInvite = async () => {
if (!inviteEmail) return;
setInviting(true);
try {
const res = await fetch("/api/team", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: inviteEmail, role: inviteRole }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
toast.success(`Invitation sent to ${inviteEmail}`);
setInviteOpen(false);
setInviteEmail("");
fetchTeam();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to send invitation");
} finally {
setInviting(false);
}
};
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Team</h1>
<p className="text-zinc-400 text-sm mt-1">
Manage who can access the CubeAdmin panel
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={fetchTeam} className="border-zinc-700 text-zinc-400 hover:text-white">
<RefreshCw className="w-4 h-4" />
</Button>
<Button size="sm" className="bg-emerald-600 hover:bg-emerald-500 text-white" onClick={() => setInviteOpen(true)}>
<UserPlus className="w-4 h-4 mr-1.5" />
Invite Member
</Button>
</div>
</div>
{/* Active members */}
<Card className="bg-zinc-900 border-zinc-800">
<CardContent className="p-0">
{loading ? (
<div className="p-4 space-y-3">
{[1, 2, 3].map(i => <Skeleton key={i} className="h-14 w-full bg-zinc-800" />)}
</div>
) : (
<div className="divide-y divide-zinc-800">
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 uppercase tracking-wide">
Active Members ({users.length})
</div>
{users.map(user => (
<div key={user.id} className="flex items-center gap-4 px-4 py-3 hover:bg-zinc-800/50 transition-colors">
<Avatar className="w-9 h-9 shrink-0">
<AvatarImage src={user.image ?? undefined} />
<AvatarFallback className="bg-zinc-800 text-zinc-400 text-sm">
{user.name?.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white">{user.name}</p>
<p className="text-xs text-zinc-500 truncate">{user.email}</p>
</div>
<Badge className={`text-xs ${ROLE_CONFIG[user.role ?? ""]?.color ?? "bg-zinc-500/20 text-zinc-400"}`}>
{ROLE_CONFIG[user.role ?? ""]?.label ?? user.role ?? "No role"}
</Badge>
<p className="text-xs text-zinc-600 hidden sm:block shrink-0">
Joined {formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Pending invites */}
{invites.length > 0 && (
<Card className="bg-zinc-900 border-zinc-800 border-dashed">
<CardContent className="p-0">
<div className="divide-y divide-zinc-800">
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 uppercase tracking-wide">
Pending Invitations ({invites.length})
</div>
{invites.map(invite => (
<div key={invite.id} className="flex items-center gap-4 px-4 py-3">
<div className="w-9 h-9 rounded-full bg-zinc-800 flex items-center justify-center shrink-0">
<Mail className="w-4 h-4 text-zinc-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-zinc-300">{invite.email}</p>
<p className="text-xs text-zinc-500 flex items-center gap-1 mt-0.5">
<Clock className="w-3 h-3" />
Expires {formatDistanceToNow(new Date(invite.expiresAt), { addSuffix: true })}
</p>
</div>
<Badge className={`text-xs ${ROLE_CONFIG[invite.role]?.color ?? ""}`}>
{ROLE_CONFIG[invite.role]?.label ?? invite.role}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Invite dialog */}
<Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
<DialogContent className="bg-zinc-900 border-zinc-700">
<DialogHeader>
<DialogTitle className="text-white">Invite Team Member</DialogTitle>
<DialogDescription className="text-zinc-400">
They&apos;ll receive an email with a link to create their account.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label className="text-zinc-300">Email Address</Label>
<Input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="teammate@example.com"
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
/>
</div>
<div className="space-y-1.5">
<Label className="text-zinc-300">Role</Label>
<Select value={inviteRole} onValueChange={(v) => { if (v === "admin" || v === "moderator") setInviteRole(v); }}>
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
<SelectItem value="admin" className="text-zinc-300 focus:bg-zinc-700 focus:text-white">
Admin Full access except team management
</SelectItem>
<SelectItem value="moderator" className="text-zinc-300 focus:bg-zinc-700 focus:text-white">
Moderator Player management only
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setInviteOpen(false)} className="border-zinc-700 text-zinc-400">
Cancel
</Button>
<Button
onClick={handleInvite}
disabled={!inviteEmail || inviting}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
>
{inviting ? "Sending..." : "Send Invitation"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}