Initial commit: Minecraft dashboard
Next.js 16 + Tailwind v4 + shadcn v4 dashboard for managing a modded Forge 1.20.1 server. Includes server controls, player management, mod manager with Modrinth search and dependency resolution, world backups, snapshots, analytics, logs, and chat bridge. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
dd69c17c3b
77 changed files with 7007 additions and 0 deletions
53
app/admin/page.tsx
Normal file
53
app/admin/page.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { auth } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { ClientOnly } from "@/components/ClientOnly";
|
||||
import { ServerControls } from "@/components/ServerControls";
|
||||
import { Analytics } from "@/components/Analytics";
|
||||
import { PlayerManager } from "@/components/PlayerManager";
|
||||
import { ChatBridge } from "@/components/ChatBridge";
|
||||
import { ModManager } from "@/components/ModManager";
|
||||
import { BackupManager } from "@/components/BackupManager";
|
||||
import { LogViewer } from "@/components/LogViewer";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function AdminPage() {
|
||||
const session = await auth();
|
||||
if (!session) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
<div className="border-b border-border bg-card py-4 sm:py-6">
|
||||
<div className="max-w-5xl mx-auto px-3 sm:px-6">
|
||||
<h1 className="text-xl sm:text-2xl font-bold tracking-tight">Admin Panel</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage your Minecraft server
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ClientOnly>
|
||||
<div className="max-w-5xl mx-auto px-3 py-4 sm:p-6 space-y-4 sm:space-y-6 w-full overflow-x-hidden">
|
||||
<ServerControls />
|
||||
<Analytics />
|
||||
<PlayerManager />
|
||||
<ChatBridge />
|
||||
<ModManager />
|
||||
<BackupManager />
|
||||
<LogViewer />
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition"
|
||||
>
|
||||
Back to dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</>
|
||||
);
|
||||
}
|
||||
56
app/api/analytics/route.ts
Normal file
56
app/api/analytics/route.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ANALYTICS_FILE = "/home/minecraft/server/analytics.jsonl";
|
||||
|
||||
type MetricEntry = {
|
||||
ts: string;
|
||||
tps: number;
|
||||
ramUsedMB: number;
|
||||
ramTotalMB: number;
|
||||
cpuPercent: number;
|
||||
playersOnline: number;
|
||||
players: string[];
|
||||
};
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const hours = Math.min(
|
||||
parseInt(req.nextUrl.searchParams.get("hours") || "6"),
|
||||
48
|
||||
);
|
||||
|
||||
if (!existsSync(ANALYTICS_FILE)) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
try {
|
||||
const lines = readFileSync(ANALYTICS_FILE, "utf8")
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
|
||||
const cutoff = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const entries: MetricEntry[] = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line) as MetricEntry;
|
||||
if (entry.ts >= cutoff) entries.push(entry);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return NextResponse.json(entries);
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: (e as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
3
app/api/auth/[...nextauth]/route.ts
Normal file
3
app/api/auth/[...nextauth]/route.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
38
app/api/backups/download/route.ts
Normal file
38
app/api/backups/download/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { existsSync, createReadStream, statSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { Readable } from "stream";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const BACKUP_DIR = "/home/minecraft/server/backups";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const name = req.nextUrl.searchParams.get("name");
|
||||
if (!name || !name.endsWith(".tar.gz") || name.includes("/") || name.includes("..")) {
|
||||
return NextResponse.json({ error: "Invalid name" }, { status: 400 });
|
||||
}
|
||||
|
||||
const filePath = join(BACKUP_DIR, name);
|
||||
if (!existsSync(filePath)) {
|
||||
return NextResponse.json({ error: "Backup not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const stat = statSync(filePath);
|
||||
const stream = createReadStream(filePath);
|
||||
const webStream = Readable.toWeb(stream) as ReadableStream;
|
||||
|
||||
return new Response(webStream, {
|
||||
headers: {
|
||||
"Content-Type": "application/gzip",
|
||||
"Content-Disposition": `attachment; filename="${name}"`,
|
||||
"Content-Length": stat.size.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
66
app/api/backups/restore/route.ts
Normal file
66
app/api/backups/restore/route.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { execSync, exec } from "child_process";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { waitForServer } from "@/lib/mods";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const BACKUP_DIR = "/home/minecraft/server/backups";
|
||||
const WORLD_DIR = "/home/minecraft/server/world";
|
||||
const SERVER_DIR = "/home/minecraft/server";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { name } = await req.json();
|
||||
if (!name || !name.endsWith(".tar.gz") || name.includes("/") || name.includes("..")) {
|
||||
return NextResponse.json({ error: "Invalid name" }, { status: 400 });
|
||||
}
|
||||
|
||||
const filePath = join(BACKUP_DIR, name);
|
||||
if (!existsSync(filePath)) {
|
||||
return NextResponse.json({ error: "Backup not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Stop server
|
||||
execSync("sudo systemctl stop minecraft.service", { timeout: 30000 });
|
||||
|
||||
// Wait for it to stop
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
|
||||
// Remove current world
|
||||
execSync(`rm -rf ${WORLD_DIR}`);
|
||||
|
||||
// Extract backup
|
||||
execSync(`tar xzf ${filePath} -C ${SERVER_DIR}`);
|
||||
|
||||
// Start server
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
exec("sudo systemctl start minecraft.service", (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const online = await waitForServer(90000);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
online,
|
||||
message: online
|
||||
? `World restored from "${name}". Server is online.`
|
||||
: `World restored from "${name}". Server is starting...`,
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: (e as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
75
app/api/backups/route.ts
Normal file
75
app/api/backups/route.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { readdirSync, statSync, unlinkSync, existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { execSync, exec } from "child_process";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const BACKUP_DIR = "/home/minecraft/server/backups";
|
||||
const BACKUP_SCRIPT = "/home/minecraft/dashboard/scripts/backup-world.sh";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (!existsSync(BACKUP_DIR)) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const files = readdirSync(BACKUP_DIR)
|
||||
.filter((f) => f.endsWith(".tar.gz"))
|
||||
.map((f) => {
|
||||
const stat = statSync(join(BACKUP_DIR, f));
|
||||
return {
|
||||
name: f,
|
||||
size: (stat.size / 1024 / 1024).toFixed(1) + " MB",
|
||||
sizeBytes: stat.size,
|
||||
createdAt: stat.mtime.toISOString(),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
return NextResponse.json(files);
|
||||
}
|
||||
|
||||
// Create backup now
|
||||
export async function POST() {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`bash ${BACKUP_SCRIPT}`, { encoding: "utf8", timeout: 60000 });
|
||||
return NextResponse.json({ ok: true, message: "Backup created" });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: (e as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete backup
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { name } = await req.json();
|
||||
if (!name || !name.endsWith(".tar.gz") || name.includes("/") || name.includes("..")) {
|
||||
return NextResponse.json({ error: "Invalid name" }, { status: 400 });
|
||||
}
|
||||
|
||||
const filePath = join(BACKUP_DIR, name);
|
||||
if (!existsSync(filePath)) {
|
||||
return NextResponse.json({ error: "Backup not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
unlinkSync(filePath);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
124
app/api/chat/route.ts
Normal file
124
app/api/chat/route.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sendCommand } from "@/lib/rcon";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const LOG_FILE = "/home/minecraft/server/logs/latest.log";
|
||||
|
||||
type ChatMessage = {
|
||||
time: string;
|
||||
type: "chat" | "join" | "leave" | "death" | "server";
|
||||
player: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
function parseLogLine(line: string): ChatMessage | null {
|
||||
// [HH:MM:SS] [Server thread/INFO] [minecraft/DedicatedServer]: <Player> message
|
||||
const chatMatch = line.match(
|
||||
/\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:DedicatedServer|MinecraftServer)\]:\s*<(\w+)>\s*(.*)/
|
||||
);
|
||||
if (chatMatch) {
|
||||
return { time: chatMatch[1], type: "chat", player: chatMatch[2], message: chatMatch[3] };
|
||||
}
|
||||
|
||||
// Player joins
|
||||
const joinMatch = line.match(
|
||||
/\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:PlayerList|ServerPlayer)\]:\s*(\w+)\s+joined the game/
|
||||
);
|
||||
if (joinMatch) {
|
||||
return { time: joinMatch[1], type: "join", player: joinMatch[2], message: "joined the game" };
|
||||
}
|
||||
|
||||
// Player leaves
|
||||
const leaveMatch = line.match(
|
||||
/\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:PlayerList|ServerPlayer)\]:\s*(\w+)\s+left the game/
|
||||
);
|
||||
if (leaveMatch) {
|
||||
return { time: leaveMatch[1], type: "leave", player: leaveMatch[2], message: "left the game" };
|
||||
}
|
||||
|
||||
// Deaths
|
||||
const deathMatch = line.match(
|
||||
/\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:DedicatedServer|MinecraftServer)\]:\s*(\w+)\s+(was |died|drowned|burned|fell|starved|suffocated|hit|blew|withered|tried|experienced|went|walked|froze|was prick|was stung|was impaled|was squashed|was skewered|was squished|was pummeled|discovered)(.*)/
|
||||
);
|
||||
if (deathMatch) {
|
||||
return {
|
||||
time: deathMatch[1],
|
||||
type: "death",
|
||||
player: deathMatch[2],
|
||||
message: deathMatch[3] + (deathMatch[4] || ""),
|
||||
};
|
||||
}
|
||||
|
||||
// Server say command
|
||||
const sayMatch = line.match(
|
||||
/\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:DedicatedServer|MinecraftServer)\]:\s*\[Server\]\s*(.*)/
|
||||
);
|
||||
if (sayMatch) {
|
||||
return { time: sayMatch[1], type: "server", player: "Server", message: sayMatch[2] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (!existsSync(LOG_FILE)) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const maxLines = parseInt(req.nextUrl.searchParams.get("lines") || "100");
|
||||
|
||||
try {
|
||||
const content = readFileSync(LOG_FILE, "utf8");
|
||||
const lines = content.split("\n");
|
||||
const messages: ChatMessage[] = [];
|
||||
|
||||
// Parse from the end, collect up to maxLines relevant messages
|
||||
for (let i = lines.length - 1; i >= 0 && messages.length < maxLines; i--) {
|
||||
const msg = parseLogLine(lines[i]);
|
||||
if (msg) messages.unshift(msg);
|
||||
}
|
||||
|
||||
return NextResponse.json(messages);
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: (e as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { message } = await req.json();
|
||||
if (!message || typeof message !== "string" || message.length > 256) {
|
||||
return NextResponse.json({ error: "Invalid message" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Sanitize: strip newlines/carriage returns to prevent RCON command injection
|
||||
const sanitized = message.replace(/[\r\n]/g, "").trim();
|
||||
if (!sanitized) {
|
||||
return NextResponse.json({ error: "Empty message" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sendCommand(`say ${sanitized}`);
|
||||
return NextResponse.json({ ok: true, response });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: (e as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
30
app/api/logs/route.ts
Normal file
30
app/api/logs/route.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { execSync } from "child_process";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const lines = Math.min(
|
||||
parseInt(req.nextUrl.searchParams.get("lines") || "50"),
|
||||
200
|
||||
);
|
||||
|
||||
try {
|
||||
const logs = execSync(
|
||||
`sudo journalctl -u minecraft.service --no-pager -n ${lines}`,
|
||||
{ encoding: "utf8", timeout: 5000 }
|
||||
);
|
||||
return NextResponse.json({ logs });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: (e as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
180
app/api/mods/batch-install/route.ts
Normal file
180
app/api/mods/batch-install/route.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { NextRequest } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { downloadMod } from "@/lib/modrinth";
|
||||
import type { ModSide } from "@/lib/modrinth";
|
||||
import { createSnapshot, restoreSnapshot, listSnapshots } from "@/lib/snapshots";
|
||||
import { waitForServerAdaptive, rebuildModpack, addModMetadata } from "@/lib/mods";
|
||||
import { exec } from "child_process";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type ModToInstall = {
|
||||
projectId: string;
|
||||
versionId: string;
|
||||
filename: string;
|
||||
url: string;
|
||||
title: string;
|
||||
side: ModSide;
|
||||
};
|
||||
|
||||
function restartServer(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec("sudo systemctl restart minecraft.service", (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const { mods, snapshotName } = (await req.json()) as {
|
||||
mods: ModToInstall[];
|
||||
snapshotName: string;
|
||||
};
|
||||
|
||||
if (!Array.isArray(mods) || mods.length === 0) {
|
||||
return new Response(JSON.stringify({ error: "No mods to install" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const stream = new TransformStream();
|
||||
const writer = stream.writable.getWriter();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
async function send(event: string, data: object) {
|
||||
try {
|
||||
await writer.write(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Run the pipeline in the background, streaming events as we go
|
||||
(async () => {
|
||||
const name = snapshotName || `before-install-${Date.now()}`;
|
||||
|
||||
try {
|
||||
// 1. Create snapshot
|
||||
await send("step", { id: "snapshot", status: "active", message: "Creating safety snapshot..." });
|
||||
try {
|
||||
createSnapshot(name);
|
||||
} catch (e) {
|
||||
await send("step", { id: "snapshot", status: "error", message: (e as Error).message });
|
||||
await send("done", { success: false, message: `Failed to create snapshot: ${(e as Error).message}` });
|
||||
return;
|
||||
}
|
||||
await send("step", { id: "snapshot", status: "done", message: "Snapshot created" });
|
||||
|
||||
// 2. Download mods
|
||||
await send("step", { id: "download", status: "active", message: `Downloading ${mods.length} mod(s)...` });
|
||||
try {
|
||||
for (const mod of mods) {
|
||||
await downloadMod(mod.url, mod.filename, mod.side);
|
||||
addModMetadata(mod.filename, { projectId: mod.projectId, side: mod.side });
|
||||
}
|
||||
} catch (e) {
|
||||
await send("step", { id: "download", status: "error", message: (e as Error).message });
|
||||
await send("step", { id: "rollback", status: "active", message: "Rolling back..." });
|
||||
try {
|
||||
const snaps = listSnapshots();
|
||||
if (snaps.length > 0) restoreSnapshot(snaps[0].dirName);
|
||||
} catch {}
|
||||
await send("step", { id: "rollback", status: "done", message: "Rolled back to snapshot" });
|
||||
await send("done", { success: false, message: `Download failed: ${(e as Error).message}`, rolledBack: true });
|
||||
return;
|
||||
}
|
||||
await send("step", { id: "download", status: "done", message: "All mods downloaded" });
|
||||
|
||||
// 3. Check if server-side mods need a restart
|
||||
const hasServerMods = mods.some((m) => m.side !== "client");
|
||||
|
||||
if (!hasServerMods) {
|
||||
await send("step", { id: "modpack", status: "active", message: "Rebuilding modpack..." });
|
||||
try { rebuildModpack(); } catch {}
|
||||
await send("step", { id: "modpack", status: "done", message: "Modpack updated" });
|
||||
await send("done", {
|
||||
success: true,
|
||||
message: `Installed ${mods.length} client-only mod(s). No server restart needed.`,
|
||||
installed: mods.map((m) => m.filename),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Restart server
|
||||
await send("step", { id: "restart", status: "active", message: "Restarting server..." });
|
||||
try {
|
||||
await restartServer();
|
||||
} catch (e) {
|
||||
await send("step", { id: "restart", status: "error", message: (e as Error).message });
|
||||
await send("step", { id: "rollback", status: "active", message: "Rolling back..." });
|
||||
try {
|
||||
const snaps = listSnapshots();
|
||||
if (snaps.length > 0) restoreSnapshot(snaps[0].dirName);
|
||||
} catch {}
|
||||
await send("step", { id: "rollback", status: "done", message: "Rolled back to snapshot" });
|
||||
await send("done", { success: false, message: `Server restart failed: ${(e as Error).message}`, rolledBack: true });
|
||||
return;
|
||||
}
|
||||
await send("step", { id: "restart", status: "done", message: "Restart command sent" });
|
||||
|
||||
// 5. Wait for server with progress reporting
|
||||
await send("step", { id: "health", status: "active", message: "Waiting for server..." });
|
||||
const online = await waitForServerAdaptive(async (progress) => {
|
||||
await send("step", { id: "health", status: "active", message: progress.message });
|
||||
});
|
||||
|
||||
if (online) {
|
||||
await send("step", { id: "health", status: "done", message: "Server is online" });
|
||||
await send("step", { id: "modpack", status: "active", message: "Rebuilding modpack..." });
|
||||
try { rebuildModpack(); } catch {}
|
||||
await send("step", { id: "modpack", status: "done", message: "Modpack updated" });
|
||||
await send("done", {
|
||||
success: true,
|
||||
message: `Installed ${mods.length} mod(s). Server is online.`,
|
||||
installed: mods.map((m) => m.filename),
|
||||
});
|
||||
} else {
|
||||
await send("step", { id: "health", status: "error", message: "Server failed to start" });
|
||||
await send("step", { id: "rollback", status: "active", message: "Rolling back to snapshot..." });
|
||||
try {
|
||||
const snaps = listSnapshots();
|
||||
if (snaps.length > 0) {
|
||||
restoreSnapshot(snaps[0].dirName);
|
||||
await restartServer();
|
||||
await waitForServerAdaptive(async (progress) => {
|
||||
await send("step", { id: "rollback", status: "active", message: `Rollback: ${progress.message}` });
|
||||
});
|
||||
try { rebuildModpack(); } catch {}
|
||||
}
|
||||
} catch {}
|
||||
await send("step", { id: "rollback", status: "done", message: "Rolled back and server restarted" });
|
||||
await send("done", {
|
||||
success: false,
|
||||
message: `Server failed to start after installing ${mods.length} mod(s). Rolled back to snapshot.`,
|
||||
rolledBack: true,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
await send("done", { success: false, message: (e as Error).message });
|
||||
} finally {
|
||||
try { writer.close(); } catch {}
|
||||
}
|
||||
})();
|
||||
|
||||
return new Response(stream.readable, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
101
app/api/mods/remove/route.ts
Normal file
101
app/api/mods/remove/route.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { exec } from "child_process";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { removeMod, isClientOnlyMod, waitForServer, rebuildModpack } from "@/lib/mods";
|
||||
import { createSnapshot, restoreSnapshot, listSnapshots } from "@/lib/snapshots";
|
||||
import { MODS_DIR, CLIENT_MODS_DIR } from "@/lib/constants";
|
||||
|
||||
function restartServer(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec("sudo systemctl restart minecraft.service", (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { filename } = await req.json();
|
||||
|
||||
if (!filename || !filename.endsWith(".jar")) {
|
||||
return NextResponse.json({ error: "Invalid filename" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (filename.includes("/") || filename.includes("\\")) {
|
||||
return NextResponse.json({ error: "Invalid filename" }, { status: 400 });
|
||||
}
|
||||
|
||||
const inServerDir = existsSync(join(MODS_DIR, filename));
|
||||
const inClientDir = existsSync(join(CLIENT_MODS_DIR, filename));
|
||||
|
||||
if (!inServerDir && !inClientDir) {
|
||||
return NextResponse.json({ error: "Mod not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Create snapshot before removing
|
||||
const snapName = `before-remove-${filename.replace(".jar", "")}`;
|
||||
createSnapshot(snapName);
|
||||
|
||||
const clientOnly = !inServerDir && inClientDir;
|
||||
|
||||
// Remove the mod
|
||||
removeMod(filename);
|
||||
|
||||
if (clientOnly) {
|
||||
// Client-only mod — no restart needed
|
||||
try { rebuildModpack(); } catch {}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Removed client-only mod "${filename}". No server restart needed.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Server mod — restart and verify
|
||||
try {
|
||||
await restartServer();
|
||||
} catch (e) {
|
||||
const snaps = listSnapshots();
|
||||
const snap = snaps[0];
|
||||
if (snap) restoreSnapshot(snap.dirName);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: `Restart failed: ${(e as Error).message}`,
|
||||
rolledBack: true,
|
||||
});
|
||||
}
|
||||
|
||||
const online = await waitForServer(90000);
|
||||
|
||||
if (online) {
|
||||
try { rebuildModpack(); } catch {}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Removed "${filename}". Server is online.`,
|
||||
});
|
||||
} else {
|
||||
// Rollback
|
||||
const snaps = listSnapshots();
|
||||
const snap = snaps[0];
|
||||
if (snap) {
|
||||
restoreSnapshot(snap.dirName);
|
||||
try {
|
||||
await restartServer();
|
||||
await waitForServer(90000);
|
||||
try { rebuildModpack(); } catch {}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: `Server failed after removing "${filename}". Rolled back.`,
|
||||
rolledBack: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
28
app/api/mods/resolve/route.ts
Normal file
28
app/api/mods/resolve/route.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { resolveDependencies } from "@/lib/modrinth";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { projectIds, titles } = await req.json();
|
||||
if (!Array.isArray(projectIds) || projectIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "No mods selected" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await resolveDependencies(projectIds, titles || {});
|
||||
return NextResponse.json(result);
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: (e as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
16
app/api/mods/route.ts
Normal file
16
app/api/mods/route.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getModDetails } from "@/lib/mods";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const mods = getModDetails();
|
||||
return NextResponse.json(mods);
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: (e as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
21
app/api/mods/search/route.ts
Normal file
21
app/api/mods/search/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { searchMods } from "@/lib/modrinth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const query = req.nextUrl.searchParams.get("q");
|
||||
if (!query || query.length < 2) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await searchMods(query);
|
||||
return NextResponse.json(results);
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: (e as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
98
app/api/players/route.ts
Normal file
98
app/api/players/route.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { readFileSync } from "fs";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sendCommand } from "@/lib/rcon";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const OPS_FILE = "/home/minecraft/server/ops.json";
|
||||
const WHITELIST_FILE = "/home/minecraft/server/whitelist.json";
|
||||
const BANNED_FILE = "/home/minecraft/server/banned-players.json";
|
||||
|
||||
type OpsEntry = { uuid: string; name: string; level: number };
|
||||
type WhitelistEntry = { uuid: string; name: string };
|
||||
type BannedEntry = { uuid: string; name: string; reason: string; created: string; expires: string };
|
||||
|
||||
function readJson<T>(path: string): T[] {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// GET — list ops, whitelist, banned players
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const ops = readJson<OpsEntry>(OPS_FILE).map((e) => ({
|
||||
name: e.name,
|
||||
uuid: e.uuid,
|
||||
level: e.level,
|
||||
}));
|
||||
|
||||
const whitelist = readJson<WhitelistEntry>(WHITELIST_FILE).map((e) => ({
|
||||
name: e.name,
|
||||
uuid: e.uuid,
|
||||
}));
|
||||
|
||||
const banned = readJson<BannedEntry>(BANNED_FILE).map((e) => ({
|
||||
name: e.name,
|
||||
uuid: e.uuid,
|
||||
reason: e.reason,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ ops, whitelist, banned });
|
||||
}
|
||||
|
||||
// POST — execute a player management command
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { action, player, reason } = await req.json();
|
||||
|
||||
if (!player || !/^[a-zA-Z0-9_]{1,16}$/.test(player)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid player name" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const allowedActions = ["op", "deop", "whitelist add", "whitelist remove", "ban", "pardon"];
|
||||
if (!allowedActions.includes(action)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid action" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let command = `${action} ${player}`;
|
||||
if (action === "ban" && reason) {
|
||||
command += ` ${reason}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sendCommand(command);
|
||||
|
||||
// Force server to sync JSON files
|
||||
if (action.startsWith("whitelist")) {
|
||||
await sendCommand("whitelist reload");
|
||||
}
|
||||
|
||||
// Wait for server to write JSON files to disk
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
return NextResponse.json({ ok: true, response });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `RCON failed: ${(e as Error).message}. Is the server online?` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
76
app/api/schedule/route.ts
Normal file
76
app/api/schedule/route.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { execSync } from "child_process";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const SCRIPT = "/home/minecraft/dashboard/scripts/scheduled-restart.sh";
|
||||
const CRON_MARKER = "# mc-scheduled-restart";
|
||||
|
||||
function getCurrentSchedule(): { enabled: boolean; hour: number; minute: number } | null {
|
||||
try {
|
||||
const crontab = execSync("crontab -l 2>/dev/null", { encoding: "utf8" });
|
||||
const match = crontab.match(
|
||||
new RegExp(`^(\\d+)\\s+(\\d+)\\s+\\*\\s+\\*\\s+\\*\\s+.*${CRON_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, "m")
|
||||
);
|
||||
if (match) {
|
||||
return { enabled: true, minute: parseInt(match[1]), hour: parseInt(match[2]) };
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const schedule = getCurrentSchedule();
|
||||
return NextResponse.json(schedule || { enabled: false, hour: 4, minute: 0 });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { enabled, hour, minute } = await req.json();
|
||||
|
||||
try {
|
||||
// Get existing crontab without our entry
|
||||
let crontab = "";
|
||||
try {
|
||||
crontab = execSync("crontab -l 2>/dev/null", { encoding: "utf8" });
|
||||
} catch {}
|
||||
|
||||
// Remove old entry
|
||||
const lines = crontab.split("\n").filter((l) => !l.includes(CRON_MARKER));
|
||||
|
||||
if (enabled) {
|
||||
const h = Math.min(Math.max(parseInt(hour) || 4, 0), 23);
|
||||
const m = Math.min(Math.max(parseInt(minute) || 0, 0), 59);
|
||||
lines.push(`${m} ${h} * * * bash ${SCRIPT} ${CRON_MARKER}`);
|
||||
}
|
||||
|
||||
const newCrontab = lines.filter(Boolean).join("\n") + "\n";
|
||||
const { writeFileSync, unlinkSync } = require("fs");
|
||||
const tmpFile = `/tmp/crontab-${Date.now()}.tmp`;
|
||||
writeFileSync(tmpFile, newCrontab, { mode: 0o600 });
|
||||
execSync(`crontab ${tmpFile}`, { encoding: "utf8" });
|
||||
unlinkSync(tmpFile);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
enabled,
|
||||
hour: parseInt(hour) || 4,
|
||||
minute: parseInt(minute) || 0,
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: (e as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
32
app/api/server/[action]/route.ts
Normal file
32
app/api/server/[action]/route.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { exec } from "child_process";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
const ALLOWED_ACTIONS = ["start", "stop", "restart"];
|
||||
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ action: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { action } = await params;
|
||||
if (!ALLOWED_ACTIONS.includes(action)) {
|
||||
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
||||
}
|
||||
|
||||
return new Promise<NextResponse>((resolve) => {
|
||||
exec(`sudo systemctl ${action} minecraft.service`, (err) => {
|
||||
if (err) {
|
||||
resolve(
|
||||
NextResponse.json({ error: err.message }, { status: 500 })
|
||||
);
|
||||
} else {
|
||||
resolve(NextResponse.json({ ok: true, action }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
47
app/api/snapshots/restore/route.ts
Normal file
47
app/api/snapshots/restore/route.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { exec } from "child_process";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { restoreSnapshot } from "@/lib/snapshots";
|
||||
import { rebuildModpack, waitForServer } from "@/lib/mods";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { dirName } = await req.json();
|
||||
if (!dirName || dirName.includes("/") || dirName.includes("..")) {
|
||||
return NextResponse.json({ error: "Invalid snapshot" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
restoreSnapshot(dirName);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
exec("sudo systemctl restart minecraft.service", (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const online = await waitForServer(90000);
|
||||
|
||||
try {
|
||||
rebuildModpack();
|
||||
} catch {}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
online,
|
||||
message: online
|
||||
? "Snapshot restored. Server is online."
|
||||
: "Snapshot restored. Server is starting...",
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: (e as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
48
app/api/snapshots/route.ts
Normal file
48
app/api/snapshots/route.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { listSnapshots, deleteSnapshot } from "@/lib/snapshots";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
return NextResponse.json(listSnapshots());
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: (e as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: Request) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { dirName } = await req.json();
|
||||
if (!dirName) {
|
||||
return NextResponse.json({ error: "Missing dirName" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Prevent path traversal
|
||||
if (dirName.includes("/") || dirName.includes("\\") || dirName.includes("..")) {
|
||||
return NextResponse.json({ error: "Invalid name" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
deleteSnapshot(dirName);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: (e as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
50
app/api/status/route.ts
Normal file
50
app/api/status/route.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { status } from "minecraft-server-util";
|
||||
import { execSync } from "child_process";
|
||||
import { MC_SERVER_IP, MC_SERVER_PORT } from "@/lib/constants";
|
||||
import { sendCommand } from "@/lib/rcon";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
// Tier 1: MC protocol ping (fastest, gives player/version info)
|
||||
try {
|
||||
const result = await status(MC_SERVER_IP, MC_SERVER_PORT, {
|
||||
timeout: 5000,
|
||||
});
|
||||
return NextResponse.json({
|
||||
online: true,
|
||||
players: { online: result.players.online, max: result.players.max },
|
||||
version: result.version.name,
|
||||
motd: result.motd.clean,
|
||||
});
|
||||
} catch {}
|
||||
|
||||
// Tier 2: RCON (server is online but protocol ping failed)
|
||||
try {
|
||||
const response = await sendCommand("list");
|
||||
const match = response.match(/There are (\d+) of a max of (\d+) players/);
|
||||
return NextResponse.json({
|
||||
online: true,
|
||||
players: {
|
||||
online: match ? parseInt(match[1], 10) : 0,
|
||||
max: match ? parseInt(match[2], 10) : 20,
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
|
||||
// Tier 3: systemctl (process alive but not yet accepting connections)
|
||||
let starting = false;
|
||||
try {
|
||||
const out = execSync("systemctl is-active minecraft.service", {
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
starting = out === "active" || out === "activating";
|
||||
} catch {}
|
||||
|
||||
return NextResponse.json({
|
||||
online: false,
|
||||
starting,
|
||||
players: { online: 0, max: 0 },
|
||||
});
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
106
app/globals.css
Normal file
106
app/globals.css
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-heading: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.13 0.005 285);
|
||||
--foreground: oklch(0.93 0.005 285);
|
||||
--card: oklch(0.18 0.008 285);
|
||||
--card-foreground: oklch(0.93 0.005 285);
|
||||
--popover: oklch(0.18 0.008 285);
|
||||
--popover-foreground: oklch(0.93 0.005 285);
|
||||
--primary: oklch(0.65 0.18 280);
|
||||
--primary-foreground: oklch(0.98 0 0);
|
||||
--secondary: oklch(0.24 0.01 285);
|
||||
--secondary-foreground: oklch(0.88 0.005 285);
|
||||
--muted: oklch(0.22 0.008 285);
|
||||
--muted-foreground: oklch(0.65 0.01 285);
|
||||
--accent: oklch(0.24 0.015 285);
|
||||
--accent-foreground: oklch(0.93 0.005 285);
|
||||
--destructive: oklch(0.65 0.2 25);
|
||||
--border: oklch(0.28 0.01 285);
|
||||
--input: oklch(0.28 0.01 285);
|
||||
--ring: oklch(0.65 0.18 280);
|
||||
--chart-1: oklch(0.65 0.18 280);
|
||||
--chart-2: oklch(0.7 0.15 160);
|
||||
--chart-3: oklch(0.75 0.15 60);
|
||||
--chart-4: oklch(0.7 0.18 330);
|
||||
--chart-5: oklch(0.65 0.2 25);
|
||||
--sidebar: oklch(0.16 0.008 285);
|
||||
--sidebar-foreground: oklch(0.93 0.005 285);
|
||||
--sidebar-primary: oklch(0.65 0.18 280);
|
||||
--sidebar-primary-foreground: oklch(0.98 0 0);
|
||||
--sidebar-accent: oklch(0.24 0.015 285);
|
||||
--sidebar-accent-foreground: oklch(0.93 0.005 285);
|
||||
--sidebar-border: oklch(0.28 0.01 285);
|
||||
--sidebar-ring: oklch(0.65 0.18 280);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: oklch(0.15 0.005 285); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb { background: oklch(0.35 0.01 285); border-radius: 3px; }
|
||||
57
app/layout.tsx
Normal file
57
app/layout.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Providers } from "./providers";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "HurkiCorgi MC",
|
||||
description: "Create & Engineering | Raids | Survival - Minecraft Server",
|
||||
manifest: "/manifest.json",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "black-translucent",
|
||||
title: "HurkiCorgi MC",
|
||||
},
|
||||
icons: {
|
||||
icon: "/icon.svg",
|
||||
apple: "/icon.svg",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
themeColor: "#1a1a2e",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`dark ${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col overflow-x-hidden">
|
||||
<Providers>
|
||||
<div className="flex flex-col flex-1 w-full overflow-x-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
76
app/login/page.tsx
Normal file
76
app/login/page.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } 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";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
|
||||
const res = await signIn("credentials", {
|
||||
username: formData.get("username"),
|
||||
password: formData.get("password"),
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (res?.error) {
|
||||
setError("Invalid credentials");
|
||||
setLoading(false);
|
||||
} else {
|
||||
router.push("/admin");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center p-3 sm:p-6">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Admin Login</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input id="username" name="username" type="text" required />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input id="password" name="password" type="password" required />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-destructive text-sm text-center">{error}</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<a
|
||||
href="/"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition"
|
||||
>
|
||||
Back to dashboard
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
app/page.tsx
Normal file
38
app/page.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { Navbar } from "@/components/Navbar";
|
||||
import { StatusCard } from "@/components/StatusCard";
|
||||
import { DownloadCard } from "@/components/DownloadCard";
|
||||
import { ModList } from "@/components/ModList";
|
||||
import { ClientOnly } from "@/components/ClientOnly";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
{/* Header */}
|
||||
<div className="border-b border-border bg-card py-8 sm:py-12 text-center px-4">
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold tracking-tight text-primary">
|
||||
HurkiCorgi MC
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground text-sm sm:text-base">
|
||||
Create & Engineering | Raids | Survival
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ClientOnly>
|
||||
<div className="max-w-5xl mx-auto px-3 py-4 sm:p-6 space-y-4 sm:space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6">
|
||||
<StatusCard />
|
||||
<DownloadCard />
|
||||
</div>
|
||||
<ModList />
|
||||
</div>
|
||||
</ClientOnly>
|
||||
|
||||
<footer className="text-center py-6 sm:py-8 text-muted-foreground text-xs">
|
||||
HurkiCorgi MC — Forge 1.20.1
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
24
app/providers.tsx
Normal file
24
app/providers.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<SessionProvider>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue