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:
hurkicorgi 2026-04-13 00:46:58 -06:00
commit dd69c17c3b
77 changed files with 7007 additions and 0 deletions

View 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",
},
});
}

View 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,
});
}
}

View 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
View 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 }
);
}
}

View 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 }
);
}
}