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

41
.gitignore vendored Normal file
View file

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
AGENTS.md Normal file
View file

@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
CLAUDE.md Normal file
View file

@ -0,0 +1 @@
@AGENTS.md

36
README.md Normal file
View file

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

53
app/admin/page.tsx Normal file
View 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>
</>
);
}

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

View file

@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

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

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

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

98
app/api/players/route.ts Normal file
View 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
View 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 }
);
}
}

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

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

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

106
app/globals.css Normal file
View 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
View 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
View 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
View 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
View 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>
);
}

1479
bun.lock Normal file

File diff suppressed because it is too large Load diff

25
components.json Normal file
View file

@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

177
components/Analytics.tsx Normal file
View file

@ -0,0 +1,177 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
type MetricEntry = {
ts: string;
tps: number;
ramUsedMB: number;
ramTotalMB: number;
cpuPercent: number;
playersOnline: number;
};
function Sparkline({
data,
color,
max,
height = 80,
label,
unit,
currentValue,
}: {
data: number[];
color: string;
max?: number;
height?: number;
label: string;
unit: string;
currentValue: string;
}) {
if (data.length < 2) {
return (
<div className="rounded-lg bg-muted p-4">
<div className="flex items-baseline justify-between mb-2">
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-lg font-bold">
{currentValue}
<span className="text-xs text-muted-foreground ml-1">{unit}</span>
</p>
</div>
</div>
<Skeleton className="w-full" style={{ height }} />
<p className="text-xs text-muted-foreground mt-2">Collecting data...</p>
</div>
);
}
const dataMax = max || Math.max(...data, 1);
const w = 300;
const points = data.map((v, i) => {
const x = (i / (data.length - 1)) * w;
const y = height - (v / dataMax) * (height - 10) - 5;
return `${x},${y}`;
});
const pathD = `M${points.join(" L")}`;
const areaD = `${pathD} L${w},${height} L0,${height} Z`;
return (
<div className="rounded-lg bg-muted p-4">
<div className="flex items-baseline justify-between mb-2">
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-lg font-bold">
{currentValue}
<span className="text-xs text-muted-foreground ml-1">{unit}</span>
</p>
</div>
</div>
<svg viewBox={`0 0 ${w} ${height}`} className="w-full" preserveAspectRatio="none">
<defs>
<linearGradient id={`grad-${label}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
<stop offset="100%" stopColor={color} stopOpacity="0" />
</linearGradient>
</defs>
<path d={areaD} fill={`url(#grad-${label})`} />
<path d={pathD} fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
);
}
export function Analytics() {
const [hours, setHours] = useState(6);
const { data: metrics = [] } = useQuery<MetricEntry[]>({
queryKey: ["analytics", hours],
queryFn: () =>
fetch(`/api/analytics?hours=${hours}`).then((r) => r.json()),
refetchInterval: 60_000,
});
const latest = metrics.length > 0 ? metrics[metrics.length - 1] : null;
const ranges = [
{ label: "1h", value: 1 },
{ label: "6h", value: 6 },
{ label: "24h", value: 24 },
];
return (
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
<CardTitle>Server Analytics</CardTitle>
<CardDescription>
{metrics.length} data points
</CardDescription>
</div>
<div className="flex gap-1 bg-muted p-1 rounded-lg w-fit shrink-0">
{ranges.map((r) => (
<button
key={r.value}
onClick={() => setHours(r.value)}
className={`px-3 py-1 rounded-md text-xs font-medium transition ${
hours === r.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{r.label}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Sparkline
data={metrics.map((m) => m.tps)}
color="#4ade80"
max={22}
label="TPS"
unit=""
currentValue={latest ? latest.tps.toFixed(1) : "-"}
/>
<Sparkline
data={metrics.map((m) => m.ramUsedMB)}
color="#60a5fa"
label="RAM"
unit="MB"
currentValue={
latest ? `${(latest.ramUsedMB / 1024).toFixed(1)} GB` : "-"
}
/>
<Sparkline
data={metrics.map((m) => m.cpuPercent)}
color="#f59e0b"
max={100}
label="CPU"
unit="%"
currentValue={latest ? latest.cpuPercent.toFixed(0) : "-"}
/>
<Sparkline
data={metrics.map((m) => m.playersOnline)}
color="#a78bfa"
label="Players"
unit=""
currentValue={latest ? latest.playersOnline.toString() : "0"}
/>
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,192 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
type Backup = {
name: string;
size: string;
createdAt: string;
};
export function BackupManager() {
const queryClient = useQueryClient();
const [confirmRestore, setConfirmRestore] = useState<string | null>(null);
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
const { data: backups = [] } = useQuery<Backup[]>({
queryKey: ["backups"],
queryFn: () => fetch("/api/backups").then((r) => r.json()),
staleTime: 30_000,
});
const createBackup = useMutation({
mutationFn: async () => {
const res = await fetch("/api/backups", { method: "POST" });
return res.json();
},
onSuccess: (data) => {
setResult({ ok: true, message: data.message || "Backup created" });
queryClient.invalidateQueries({ queryKey: ["backups"] });
},
onError: (err) => {
setResult({ ok: false, message: err.message });
},
});
const restore = useMutation({
mutationFn: async (name: string) => {
setConfirmRestore(null);
const res = await fetch("/api/backups/restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
return res.json();
},
onSuccess: (data) => {
setResult({ ok: data.success, message: data.message });
queryClient.invalidateQueries({ queryKey: ["status"] });
},
onError: (err) => {
setResult({ ok: false, message: err.message });
},
});
const deleteBackup = useMutation({
mutationFn: async (name: string) => {
await fetch("/api/backups", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["backups"] });
},
});
const isBusy = createBackup.isPending || restore.isPending;
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>World Backups</CardTitle>
<CardDescription>
Auto-backup every 6 hours. {backups.length} backup(s) stored.
</CardDescription>
</div>
<Button
onClick={() => createBackup.mutate()}
disabled={isBusy}
>
{createBackup.isPending ? "Creating..." : "Backup Now"}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{result && (
<Alert className={result.ok ? "border-emerald-500/20 bg-emerald-500/5" : "border-red-500/20 bg-red-500/5"}>
<AlertDescription className={result.ok ? "text-emerald-300" : "text-red-300"}>
{result.message}
</AlertDescription>
</Alert>
)}
{restore.isPending && (
<Alert className="border-blue-500/20 bg-blue-500/5">
<AlertDescription className="text-blue-300 flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-blue-400 animate-pulse" />
Restoring world backup... Server will restart. This may take a minute.
</AlertDescription>
</Alert>
)}
{backups.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No backups yet. Click &quot;Backup Now&quot; to create one.
</p>
) : (
<ul className="space-y-1 max-h-[300px] overflow-y-auto">
{backups.map((b) => (
<li
key={b.name}
className="flex items-center justify-between px-3 py-2.5 rounded-md bg-muted/50 group"
>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{b.name}</p>
<p className="text-xs text-muted-foreground">
{new Date(b.createdAt).toLocaleString()} {b.size}
</p>
</div>
<div className="flex gap-1 shrink-0 flex-wrap justify-end">
{confirmRestore === b.name ? (
<>
<Button
size="sm"
variant="default"
onClick={() => restore.mutate(b.name)}
disabled={isBusy}
className="text-xs h-9"
>
Confirm Restore
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setConfirmRestore(null)}
className="text-xs h-9"
>
Cancel
</Button>
</>
) : (
<>
<a
href={`/api/backups/download?name=${encodeURIComponent(b.name)}`}
className="sm:opacity-0 sm:group-hover:opacity-100 transition"
>
<Button size="sm" variant="ghost" className="text-xs h-9">
Download
</Button>
</a>
<Button
size="sm"
variant="ghost"
onClick={() => setConfirmRestore(b.name)}
disabled={isBusy}
className="text-xs h-9 sm:opacity-0 sm:group-hover:opacity-100 transition"
>
Restore
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteBackup.mutate(b.name)}
disabled={isBusy}
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
>
Delete
</Button>
</>
)}
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}

148
components/ChatBridge.tsx Normal file
View file

@ -0,0 +1,148 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useState, useRef, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
type ChatMessage = {
time: string;
type: "chat" | "join" | "leave" | "death" | "server";
player: string;
message: string;
};
export function ChatBridge() {
const queryClient = useQueryClient();
const chatRef = useRef<HTMLDivElement>(null);
const [message, setMessage] = useState("");
const autoScrollRef = useRef(true);
const { data: messages = [] } = useQuery<ChatMessage[]>({
queryKey: ["chat"],
queryFn: () => fetch("/api/chat?lines=50").then((r) => r.json()),
refetchInterval: 3000,
});
const send = useMutation({
mutationFn: async (msg: string) => {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: msg }),
});
if (!res.ok) throw new Error("Failed to send");
return res.json();
},
onSuccess: () => {
setMessage("");
autoScrollRef.current = true;
setTimeout(() => queryClient.invalidateQueries({ queryKey: ["chat"] }), 500);
},
});
useEffect(() => {
if (chatRef.current && autoScrollRef.current) {
chatRef.current.scrollTop = chatRef.current.scrollHeight;
}
}, [messages]);
const handleScroll = () => {
if (!chatRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = chatRef.current;
autoScrollRef.current = scrollHeight - scrollTop - clientHeight < 60;
};
const handleSend = () => {
const msg = message.trim();
if (!msg) return;
send.mutate(msg);
};
const typeColors: Record<string, string> = {
chat: "text-foreground",
join: "text-emerald-400",
leave: "text-amber-400",
death: "text-red-400",
server: "text-blue-400",
};
return (
<Card>
<CardHeader>
<CardTitle>Chat Bridge</CardTitle>
<CardDescription>
Live in-game chat auto-refreshes every 3 seconds
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{/* Chat window */}
<div
ref={chatRef}
onScroll={handleScroll}
className="h-[250px] sm:h-[300px] overflow-y-auto rounded-lg border border-border bg-background p-3 space-y-0.5"
>
{messages.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No chat messages yet
</p>
) : (
messages.map((msg, i) => (
<div key={`${msg.time}-${i}`} className="flex gap-2 text-sm leading-relaxed min-w-0">
<span className="text-muted-foreground text-xs shrink-0 mt-0.5 font-mono">
{msg.time}
</span>
{msg.type === "chat" ? (
<span className={`${typeColors.chat} break-words min-w-0`}>
<strong>&lt;{msg.player}&gt;</strong> {msg.message}
</span>
) : msg.type === "join" ? (
<span className={`${typeColors.join} break-words`}>
{msg.player} joined the game
</span>
) : msg.type === "leave" ? (
<span className={`${typeColors.leave} break-words`}>
{msg.player} left the game
</span>
) : msg.type === "death" ? (
<span className={`${typeColors.death} break-words`}>
{msg.player} {msg.message}
</span>
) : (
<span className={`${typeColors.server} break-words`}>
[Server] {msg.message}
</span>
)}
</div>
))
)}
</div>
{/* Send message */}
<div className="flex gap-2">
<Input
placeholder="Send a message as [Server]..."
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSend();
}}
maxLength={256}
className="flex-1"
/>
<Button onClick={handleSend} disabled={!message.trim() || send.isPending}>
Send
</Button>
</div>
</CardContent>
</Card>
);
}

15
components/ClientOnly.tsx Normal file
View file

@ -0,0 +1,15 @@
"use client";
import { useState, useEffect } from "react";
export function ClientOnly({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return <>{children}</>;
}

View file

@ -0,0 +1,75 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
export function DownloadCard() {
return (
<Card>
<CardHeader className="pb-0">
<CardTitle className="text-base">Join the Server</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-2.5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<a
href="/download/installer"
className={cn(buttonVariants({ size: "lg" }), "w-full min-h-[44px]")}
>
Windows (.bat)
</a>
<a
href="/download/installer-linux"
className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full min-h-[44px]")}
>
Linux (.sh)
</a>
</div>
<a
href="/download/modpack"
className={cn(
buttonVariants({ variant: "secondary", size: "lg" }),
"w-full min-h-[44px]"
)}
>
Download Mods Only (.zip)
</a>
</div>
<Separator />
<div>
<h3 className="text-sm font-semibold mb-2">How to install</h3>
<ol className="list-decimal pl-5 space-y-1.5 text-muted-foreground text-sm">
<li>
Make sure you have{" "}
<strong className="text-foreground">Minecraft Java Edition</strong>{" "}
installed
</li>
<li>
Download the{" "}
<strong className="text-foreground">Installer</strong> for your OS
</li>
<li>
<strong className="text-foreground">Windows:</strong> double-click
the .bat file.{" "}
<strong className="text-foreground">Linux:</strong>{" "}
run <code className="text-xs bg-muted px-1 py-0.5 rounded">bash install-modpack.sh</code>
</li>
<li>
Open Minecraft, select{" "}
<strong className="text-foreground">Forge 1.20.1</strong>
</li>
<li>
Go to <strong className="text-foreground">Multiplayer</strong> and
add{" "}
<strong className="text-foreground">
minecraft.hurkicorgi.com
</strong>
</li>
</ol>
</div>
</CardContent>
</Card>
);
}

108
components/LogViewer.tsx Normal file
View file

@ -0,0 +1,108 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useRef, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export function LogViewer() {
const logRef = useRef<HTMLPreElement>(null);
const [autoRefresh, setAutoRefresh] = useState(false);
const [lines, setLines] = useState(100);
const [hasLoaded, setHasLoaded] = useState(false);
const autoScrollRef = useRef(true);
const { data, refetch, isFetching, isError, error } = useQuery<{ logs: string }>({
queryKey: ["logs", lines],
queryFn: async () => {
const res = await fetch(`/api/logs?lines=${lines}`);
if (!res.ok) throw new Error(`Failed to load logs (${res.status})`);
return res.json();
},
enabled: hasLoaded,
refetchInterval: autoRefresh ? 3000 : false,
});
// Auto-scroll only if user is near the bottom
useEffect(() => {
if (logRef.current && autoScrollRef.current) {
logRef.current.scrollTop = logRef.current.scrollHeight;
}
}, [data]);
const handleScroll = () => {
if (!logRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = logRef.current;
autoScrollRef.current = scrollHeight - scrollTop - clientHeight < 80;
};
// Fetch on mount
useEffect(() => {
setHasLoaded(true);
}, []);
return (
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
<CardTitle>Console Logs</CardTitle>
<CardDescription>Server output from journalctl</CardDescription>
</div>
<div className="flex items-center gap-2">
<select
value={lines}
onChange={(e) => {
setLines(Number(e.target.value));
setTimeout(() => refetch(), 50);
}}
className="h-10 rounded-md border border-input bg-muted px-2 text-sm text-foreground focus:outline-none"
>
<option value={50}>50 lines</option>
<option value={100}>100 lines</option>
<option value={200}>200 lines</option>
</select>
<Button
size="sm"
variant={autoRefresh ? "default" : "outline"}
onClick={() => {
setAutoRefresh(!autoRefresh);
if (!autoRefresh) autoScrollRef.current = true;
}}
className={`text-xs ${autoRefresh ? "animate-pulse" : ""}`}
>
{autoRefresh ? "Live" : "Auto"}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => refetch()}
disabled={isFetching}
>
{isFetching ? "..." : "Refresh"}
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<pre
ref={logRef}
onScroll={handleScroll}
className={`rounded-lg border bg-background p-3 sm:p-4 h-[300px] sm:h-[450px] overflow-y-auto font-mono text-xs leading-relaxed whitespace-pre-wrap break-all ${
isError ? "border-red-500/30 text-red-300" : "border-border text-muted-foreground"
}`}
>
{isError
? `Failed to load logs: ${error instanceof Error ? error.message : "unknown error"}`
: data?.logs || (isFetching ? "Loading logs..." : "No logs available.")}
</pre>
</CardContent>
</Card>
);
}

57
components/ModList.tsx Normal file
View file

@ -0,0 +1,57 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
type Mod = {
modId: string;
displayName: string;
version: string;
filename: string;
size: string;
};
export function ModList() {
const { data: mods = [] } = useQuery<Mod[]>({
queryKey: ["mods"],
queryFn: () => fetch("/api/mods").then((r) => r.json()),
staleTime: 5 * 60 * 1000,
});
return (
<Card>
<CardHeader className="pb-0">
<CardTitle className="text-base">
Installed Mods ({mods.length})
</CardTitle>
</CardHeader>
<CardContent className="">
<ul className="max-h-[350px] sm:max-h-[400px] overflow-y-auto -mx-1">
{mods.map((mod, i) => (
<li
key={mod.filename}
className={`flex justify-between items-center px-3 py-2.5 rounded-md ${
i % 2 === 1 ? "bg-muted/50" : ""
}`}
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium truncate">
{mod.displayName}
</span>
{mod.version && (
<Badge variant="secondary" className="text-xs px-1.5 py-0 shrink-0 hidden sm:inline-flex">
{mod.version}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap ml-3 tabular-nums">
{mod.size}
</span>
</li>
))}
</ul>
</CardContent>
</Card>
);
}

844
components/ModManager.tsx Normal file
View file

@ -0,0 +1,844 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useState, useCallback, useRef, useEffect } from "react";
import { CheckCircle2, XCircle, Circle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
// ── Types ───────────────────────────────────────────────────
type ModSide = "client" | "server" | "both";
type ModMeta = {
modId: string;
displayName: string;
version: string;
filename: string;
size: string;
side: ModSide;
};
type SearchResult = {
slug: string;
title: string;
description: string;
icon_url: string;
downloads: number;
project_id: string;
};
type ModDownload = {
projectId: string;
versionId: string;
title: string;
filename: string;
url: string;
isDependency: boolean;
alreadyInstalled: boolean;
side: ModSide;
};
type ResolveResult = {
toInstall: ModDownload[];
skipped: ModDownload[];
conflicts: string[];
};
type SnapshotInfo = {
name: string;
dirName: string;
createdAt: string;
modCount: number;
mods: string[];
};
type WizardStep = "idle" | "searching" | "reviewing" | "installing";
type TimelineStep = {
id: string;
label: string;
status: "pending" | "active" | "done" | "error";
message?: string;
};
const INSTALL_STEPS_SERVER: Pick<TimelineStep, "id" | "label">[] = [
{ id: "snapshot", label: "Create snapshot" },
{ id: "download", label: "Download mods" },
{ id: "restart", label: "Restart server" },
{ id: "health", label: "Verify server" },
{ id: "modpack", label: "Update modpack" },
];
const INSTALL_STEPS_CLIENT: Pick<TimelineStep, "id" | "label">[] = [
{ id: "snapshot", label: "Create snapshot" },
{ id: "download", label: "Download mods" },
{ id: "modpack", label: "Update modpack" },
];
// ── Side Badge ──────────────────────────────────────────────
const sideConfig = {
client: { label: "Client", className: "border-purple-500/30 text-purple-400" },
server: { label: "Server", className: "border-orange-500/30 text-orange-400" },
both: { label: "Both", className: "border-green-500/30 text-green-400" },
} as const;
function SideBadge({ side }: { side: ModSide }) {
const config = sideConfig[side];
return (
<Badge variant="outline" className={`text-xs px-1.5 py-0 ${config.className}`}>
{config.label}
</Badge>
);
}
// ── Component ───────────────────────────────────────────────
export function ModManager() {
const queryClient = useQueryClient();
const [step, setStep] = useState<WizardStep>("idle");
const [searchQuery, setSearchQuery] = useState("");
const [selected, setSelected] = useState<Map<string, SearchResult>>(new Map());
const [resolved, setResolved] = useState<ResolveResult | null>(null);
const [installStatus, setInstallStatus] = useState("");
const [timelineSteps, setTimelineSteps] = useState<TimelineStep[]>([]);
const [newlyInstalled, setNewlyInstalled] = useState<Set<string>>(new Set());
const [installResult, setInstallResult] = useState<{
success: boolean;
message: string;
rolledBack?: boolean;
} | null>(null);
const [confirmRemove, setConfirmRemove] = useState<string | null>(null);
const [confirmRestore, setConfirmRestore] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
// Installed mods
const { data: mods = [] } = useQuery<ModMeta[]>({
queryKey: ["mods"],
queryFn: () => fetch("/api/mods").then((r) => r.json()),
staleTime: 30_000,
});
// Search (debounced)
const [debouncedQuery, setDebouncedQuery] = useState("");
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setDebouncedQuery(searchQuery);
}, 300);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
}, [searchQuery]);
const { data: searchResults = [], isFetching: isSearching } = useQuery<SearchResult[]>({
queryKey: ["mod-search", debouncedQuery],
queryFn: () =>
fetch(`/api/mods/search?q=${encodeURIComponent(debouncedQuery)}`).then((r) =>
r.json()
),
enabled: debouncedQuery.length >= 2 && step === "searching",
staleTime: 60_000,
});
// Snapshots
const { data: snapshots = [] } = useQuery<SnapshotInfo[]>({
queryKey: ["snapshots"],
queryFn: () => fetch("/api/snapshots").then((r) => r.json()),
staleTime: 30_000,
});
// Resolve dependencies
const resolve = useMutation({
mutationFn: async () => {
const projectIds = Array.from(selected.keys());
const titles: Record<string, string> = {};
selected.forEach((v, k) => (titles[k] = v.title));
const res = await fetch("/api/mods/resolve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectIds, titles }),
});
return (await res.json()) as ResolveResult;
},
onSuccess: (data) => {
setResolved(data);
setStep("reviewing");
},
});
// Batch install (SSE streaming)
const install = useMutation({
mutationFn: async () => {
if (!resolved) throw new Error("No resolved mods");
const modsToInstall = resolved.toInstall.filter((m) => !m.alreadyInstalled);
const snapshotName = `before-${modsToInstall.map((m) => m.title).join("-").slice(0, 40)}`;
const hasServerMods = modsToInstall.some((m) => m.side !== "client");
// Initialize timeline
const steps = (hasServerMods ? INSTALL_STEPS_SERVER : INSTALL_STEPS_CLIENT)
.map((s) => ({ ...s, status: "pending" as const }));
setTimelineSteps(steps);
setInstallStatus("Starting installation...");
const res = await fetch("/api/mods/batch-install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mods: modsToInstall, snapshotName }),
});
if (!res.body) throw new Error("No response stream");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let finalResult: { success: boolean; message: string; installed?: string[]; rolledBack?: boolean } | null = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const frames = buffer.split("\n\n");
buffer = frames.pop()!;
for (const frame of frames) {
if (!frame.trim()) continue;
const eventMatch = frame.match(/^event:\s*(\w+)/m);
const dataMatch = frame.match(/^data:\s*(.+)/m);
if (!eventMatch || !dataMatch) continue;
const event = eventMatch[1];
const data = JSON.parse(dataMatch[1]);
if (event === "step") {
setTimelineSteps((prev) => {
const exists = prev.some((s) => s.id === data.id);
if (exists) {
return prev.map((s) =>
s.id === data.id ? { ...s, status: data.status, message: data.message } : s
);
}
// Dynamically add rollback step
return [...prev, { id: data.id, label: "Rollback", status: data.status, message: data.message }];
});
setInstallStatus(data.message);
} else if (event === "done") {
finalResult = data;
}
}
}
if (!finalResult) throw new Error("Stream ended without result");
return finalResult;
},
onSuccess: (data) => {
setInstallStatus("");
setInstallResult(data);
if (data.success) {
setStep("idle");
setSelected(new Map());
setResolved(null);
setTimelineSteps([]);
if (data.installed?.length) {
setNewlyInstalled(new Set(data.installed));
}
}
queryClient.invalidateQueries({ queryKey: ["mods"] });
queryClient.invalidateQueries({ queryKey: ["status"] });
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
},
onError: (err) => {
setInstallStatus("");
setTimelineSteps([]);
setInstallResult({ success: false, message: err.message });
},
});
// Remove mod
const removeMod = useMutation({
mutationFn: async (filename: string) => {
setConfirmRemove(null);
const res = await fetch("/api/mods/remove", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename }),
});
return res.json();
},
onSuccess: (data) => {
setInstallResult(data);
queryClient.invalidateQueries({ queryKey: ["mods"] });
queryClient.invalidateQueries({ queryKey: ["status"] });
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
},
});
// Restore snapshot
const restoreSnap = useMutation({
mutationFn: async (dirName: string) => {
setConfirmRestore(null);
const res = await fetch("/api/snapshots/restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dirName }),
});
return res.json();
},
onSuccess: (data) => {
setInstallResult(data);
queryClient.invalidateQueries({ queryKey: ["mods"] });
queryClient.invalidateQueries({ queryKey: ["status"] });
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
},
});
// Delete snapshot
const deleteSnap = useMutation({
mutationFn: async (dirName: string) => {
await fetch("/api/snapshots", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dirName }),
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
},
});
const toggleSelect = useCallback((result: SearchResult) => {
setSelected((prev) => {
const next = new Map(prev);
if (next.has(result.project_id)) {
next.delete(result.project_id);
} else {
next.set(result.project_id, result);
}
return next;
});
}, []);
const isBusy = install.isPending || removeMod.isPending || restoreSnap.isPending;
const formatDownloads = (n: number) => {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return n.toString();
};
return (
<div className="space-y-6">
{/* ── Mod Manager Card ──────────────────────────────── */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Mod Manager</CardTitle>
<CardDescription>
Search Modrinth, auto-resolve dependencies, install with rollback safety
</CardDescription>
</div>
{step === "idle" && (
<Button onClick={() => setStep("searching")} disabled={isBusy}>
Add Mods
</Button>
)}
{step !== "idle" && step !== "installing" && (
<Button
variant="ghost"
onClick={() => {
setStep("idle");
setSelected(new Map());
setResolved(null);
setSearchQuery("");
}}
>
Cancel
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Step indicator */}
{step !== "idle" && (
<ol className="flex items-center gap-2 text-xs">
{[
{ key: "searching", label: "Search" },
{ key: "reviewing", label: "Review" },
{ key: "installing", label: "Install" },
].map((s, i, arr) => {
const currentIdx = arr.findIndex((x) => x.key === step);
const thisIdx = i;
const state =
thisIdx < currentIdx ? "done" : thisIdx === currentIdx ? "active" : "pending";
return (
<li key={s.key} className="flex items-center gap-2">
<span
className={`flex h-5 w-5 items-center justify-center rounded-full border text-[10px] font-semibold ${
state === "active"
? "border-primary bg-primary text-primary-foreground"
: state === "done"
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-300"
: "border-border text-muted-foreground"
}`}
>
{thisIdx + 1}
</span>
<span
className={
state === "active"
? "font-medium text-foreground"
: "text-muted-foreground"
}
>
{s.label}
</span>
{i < arr.length - 1 && (
<span className="w-6 h-px bg-border mx-1" />
)}
</li>
);
})}
</ol>
)}
{/* Result feedback */}
{installResult && !isBusy && step === "idle" && (
<Alert
className={
installResult.success
? "border-emerald-500/20 bg-emerald-500/5"
: "border-red-500/20 bg-red-500/5"
}
>
<AlertDescription
className={installResult.success ? "text-emerald-300" : "text-red-300"}
>
{installResult.message}
{installResult.rolledBack && (
<span className="block text-amber-300 mt-1 text-xs">
Changes were automatically rolled back.
</span>
)}
</AlertDescription>
</Alert>
)}
{/* ── Step 1: Search & Select ─────────────────── */}
{step === "searching" && (
<div className="space-y-4">
<Input
placeholder="Search mods on Modrinth..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoFocus
/>
{/* Selected queue */}
{selected.size > 0 && (
<div className="flex flex-wrap gap-1.5">
{Array.from(selected.values()).map((s) => (
<Badge
key={s.project_id}
variant="secondary"
className="gap-1 cursor-pointer hover:bg-destructive/20"
onClick={() => toggleSelect(s)}
>
{s.title}
<span className="text-muted-foreground">x</span>
</Badge>
))}
</div>
)}
{/* Search results */}
{isSearching && (
<p className="text-sm text-muted-foreground">Searching...</p>
)}
{debouncedQuery.length >= 2 && (
<ul className="space-y-1 max-h-[350px] overflow-y-auto">
{searchResults.map((result) => {
const isSelected = selected.has(result.project_id);
return (
<li
key={result.project_id}
onClick={() => toggleSelect(result)}
className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition ${
isSelected
? "bg-primary/10 border border-primary/30"
: "bg-muted/50 hover:bg-muted border border-transparent"
}`}
>
{result.icon_url ? (
<img
src={result.icon_url}
alt=""
className="w-10 h-10 rounded-md shrink-0"
/>
) : (
<div className="w-10 h-10 rounded-md bg-muted shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">
{result.title}
</span>
<span className="text-xs text-muted-foreground shrink-0">
{formatDownloads(result.downloads)} downloads
</span>
</div>
<p className="text-xs text-muted-foreground truncate">
{result.description}
</p>
</div>
{isSelected && (
<Badge variant="default" className="shrink-0 text-xs">
Selected
</Badge>
)}
</li>
);
})}
</ul>
)}
{/* Next button */}
{selected.size > 0 && (
<div className="flex justify-end">
<Button
onClick={() => resolve.mutate()}
disabled={resolve.isPending}
>
{resolve.isPending
? "Resolving dependencies..."
: `Next (${selected.size} selected)`}
</Button>
</div>
)}
</div>
)}
{/* ── Step 2: Review & Validate ───────────────── */}
{step === "reviewing" && resolved && (
<div className="space-y-4">
{/* Conflicts */}
{resolved.conflicts.length > 0 && (
<Alert className="border-red-500/20 bg-red-500/5">
<AlertDescription className="text-red-300">
<strong>Issues found:</strong>
<ul className="list-disc pl-4 mt-1 space-y-0.5">
{resolved.conflicts.map((c, i) => (
<li key={i}>{c}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{/* Mods to install */}
{resolved.toInstall.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-2">
Will be installed ({resolved.toInstall.length})
</h3>
<ul className="space-y-1">
{resolved.toInstall.map((mod) => (
<li
key={mod.projectId}
className="px-3 py-2 rounded-md bg-muted/50"
>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">
{mod.title}
</span>
<SideBadge side={mod.side} />
{mod.isDependency && (
<Badge
variant="outline"
className="text-xs px-1.5 py-0 border-blue-500/30 text-blue-300"
>
dependency
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground truncate">
{mod.filename}
</p>
</li>
))}
</ul>
</div>
)}
{/* Skipped (already installed) */}
{resolved.skipped.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-2 text-muted-foreground">
Already installed (skipped)
</h3>
<ul className="space-y-1">
{resolved.skipped.map((mod) => (
<li
key={mod.projectId}
className="flex items-center gap-2 px-3 py-2 rounded-md bg-muted/30 opacity-60"
>
<span className="text-sm">{mod.title}</span>
<SideBadge side={mod.side} />
<Badge variant="secondary" className="text-xs px-1.5 py-0">
installed
</Badge>
</li>
))}
</ul>
</div>
)}
{/* Actions */}
<div className="flex justify-between">
<Button
variant="ghost"
onClick={() => {
setStep("searching");
setResolved(null);
}}
>
Back
</Button>
<Button
onClick={() => {
setStep("installing");
install.mutate();
}}
disabled={
resolved.toInstall.length === 0 ||
resolved.conflicts.length > 0
}
>
Install {resolved.toInstall.length} mod(s)
</Button>
</div>
</div>
)}
{/* ── Step 3: Installing (Timeline) ─────────── */}
{step === "installing" && timelineSteps.length > 0 && (
<div className="space-y-1.5 py-2">
{timelineSteps.map((s) => (
<div key={s.id} className="flex items-start gap-3 px-1">
<div className="mt-0.5 shrink-0">
{s.status === "done" && (
<CheckCircle2 className="h-4 w-4 text-emerald-300" />
)}
{s.status === "active" && (
<span className="flex h-4 w-4 items-center justify-center">
<span className="h-2 w-2 rounded-full bg-blue-400 animate-pulse" />
</span>
)}
{s.status === "error" && (
<XCircle className="h-4 w-4 text-red-300" />
)}
{s.status === "pending" && (
<Circle className="h-4 w-4 text-muted-foreground/40" />
)}
</div>
<div className="min-w-0">
<p className={`text-sm font-medium ${
s.status === "done" ? "text-emerald-300" :
s.status === "active" ? "text-blue-300" :
s.status === "error" ? "text-red-300" :
"text-muted-foreground/60"
}`}>
{s.label}
</p>
{s.message && s.status === "active" && (
<p className="text-xs text-muted-foreground">{s.message}</p>
)}
{s.message && s.status === "error" && (
<p className="text-xs text-red-300/80">{s.message}</p>
)}
</div>
</div>
))}
<p className="text-xs text-muted-foreground px-1 pt-1">
Do not close this page.
</p>
</div>
)}
{/* ── Installed mods list ─────────────────────── */}
{step === "idle" && (
<>
<Separator />
<div>
<h3 className="text-sm font-semibold mb-3">
Installed Mods ({mods.length})
</h3>
<ul className="max-h-[400px] overflow-y-auto space-y-1">
{mods.map((mod) => (
<li
key={mod.filename}
className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">
{mod.displayName}
</span>
{mod.version && (
<Badge
variant="secondary"
className="text-xs px-1.5 py-0"
>
{mod.version}
</Badge>
)}
<SideBadge side={mod.side} />
{newlyInstalled.has(mod.filename) && (
<Badge variant="outline" className="text-xs px-1.5 py-0 border-emerald-500/30 text-emerald-300">
New
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground truncate">
{mod.filename} {mod.size}
</p>
</div>
{confirmRemove === mod.filename ? (
<div className="flex gap-1 shrink-0">
<Button
size="sm"
variant="destructive"
onClick={() => removeMod.mutate(mod.filename)}
disabled={isBusy}
className="text-xs h-9"
>
Confirm
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setConfirmRemove(null)}
className="text-xs h-9"
>
Cancel
</Button>
</div>
) : (
<Button
size="sm"
variant="ghost"
onClick={() => setConfirmRemove(mod.filename)}
disabled={isBusy}
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition shrink-0"
>
Remove
</Button>
)}
</li>
))}
</ul>
</div>
</>
)}
</CardContent>
</Card>
{/* ── Snapshots Card ────────────────────────────────── */}
{step === "idle" && snapshots.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Snapshots</CardTitle>
<CardDescription>
Restore a previous mod configuration
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-1">
{snapshots.map((snap) => (
<li
key={snap.dirName}
className="flex items-center justify-between px-3 py-2.5 rounded-md bg-muted/50 group"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{snap.name}</span>
<Badge
variant="secondary"
className="text-xs px-1.5 py-0"
>
{snap.modCount} mods
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{new Date(snap.createdAt).toLocaleString()}
</p>
</div>
<div className="flex gap-1 shrink-0 flex-wrap justify-end">
{confirmRestore === snap.dirName ? (
<>
<Button
size="sm"
variant="default"
onClick={() => restoreSnap.mutate(snap.dirName)}
disabled={isBusy}
className="text-xs h-9"
>
Confirm
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setConfirmRestore(null)}
className="text-xs h-9"
>
Cancel
</Button>
</>
) : (
<>
<Button
size="sm"
variant="outline"
onClick={() => setConfirmRestore(snap.dirName)}
disabled={isBusy}
className="text-xs h-9 sm:opacity-0 sm:group-hover:opacity-100 transition"
>
Restore
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteSnap.mutate(snap.dirName)}
disabled={isBusy}
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
>
Delete
</Button>
</>
)}
</div>
</li>
))}
</ul>
</CardContent>
</Card>
)}
</div>
);
}

40
components/Navbar.tsx Normal file
View file

@ -0,0 +1,40 @@
"use client";
import { useSession, signOut } from "next-auth/react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
export function Navbar() {
const { data: session } = useSession();
return (
<header className="border-b border-border bg-card">
<div className="max-w-5xl mx-auto flex items-center justify-between px-3 sm:px-6 py-2.5 sm:py-3">
<Link href="/" className="font-bold text-primary text-base sm:text-lg tracking-tight">
HurkiCorgi MC
</Link>
<div className="flex items-center gap-1 sm:gap-2">
{session ? (
<>
<Button variant="ghost" size="sm" render={<Link href="/admin" />}>
Admin
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => signOut({ callbackUrl: "/" })}
className="text-muted-foreground"
>
Logout
</Button>
</>
) : (
<Button variant="ghost" size="sm" render={<Link href="/login" />}>
Login
</Button>
)}
</div>
</div>
</header>
);
}

View file

@ -0,0 +1,281 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
type PlayerData = {
ops: { name: string; uuid: string; level: number }[];
whitelist: { name: string; uuid: string }[];
banned: { name: string; uuid: string; reason: string }[];
};
type Tab = "ops" | "whitelist" | "banned";
export function PlayerManager() {
const queryClient = useQueryClient();
const [tab, setTab] = useState<Tab>("ops");
const [playerName, setPlayerName] = useState("");
const [banReason, setBanReason] = useState("");
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
const { data = { ops: [], whitelist: [], banned: [] } } = useQuery<PlayerData>({
queryKey: ["players"],
queryFn: async () => {
const res = await fetch("/api/players");
if (!res.ok) throw new Error("Failed to fetch players");
return res.json();
},
staleTime: 10_000,
refetchInterval: 15_000,
});
const action = useMutation({
mutationFn: async (params: { action: string; player: string; reason?: string }) => {
const res = await fetch("/api/players", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
return data;
},
onSuccess: (data) => {
setResult({ ok: true, message: data.response || "Done" });
setPlayerName("");
setBanReason("");
queryClient.invalidateQueries({ queryKey: ["players"] });
},
onError: (err) => {
setResult({ ok: false, message: err.message });
},
});
const tabs: { key: Tab; label: string; count: number }[] = [
{ key: "ops", label: "Operators", count: data.ops.length },
{ key: "whitelist", label: "Whitelist", count: data.whitelist.length },
{ key: "banned", label: "Banned", count: data.banned.length },
];
const MC_NAME_RE = /^[A-Za-z0-9_]{3,16}$/;
const trimmed = playerName.trim();
const nameValid = MC_NAME_RE.test(trimmed);
const nameError =
trimmed.length === 0
? null
: !nameValid
? "316 chars, letters/numbers/underscores only"
: null;
const handleAction = (act: string, player?: string) => {
const name = player || trimmed;
if (!name) return;
if (!player && !nameValid) return;
action.mutate({ action: act, player: name, reason: banReason || undefined });
};
return (
<Card>
<CardHeader>
<CardTitle>Player Management</CardTitle>
<CardDescription>
Manage operators, whitelist, and bans via RCON
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Tabs */}
<div className="flex gap-1 bg-muted p-1 rounded-lg">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => { setTab(t.key); setResult(null); }}
className={`flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition min-h-[40px] ${
tab === t.key
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t.label}
{t.count > 0 && (
<Badge variant="secondary" className="ml-1.5 text-xs px-1.5 py-0">
{t.count}
</Badge>
)}
</button>
))}
</div>
{/* Add player input */}
<div className="flex flex-col sm:flex-row gap-2">
<div className="flex-1">
<Input
placeholder="Player name"
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
aria-invalid={!!nameError}
maxLength={16}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (tab === "ops") handleAction("op");
else if (tab === "whitelist") handleAction("whitelist add");
else if (tab === "banned") handleAction("ban");
}
}}
className="w-full"
/>
{nameError && (
<p className="text-xs text-red-300 mt-1">{nameError}</p>
)}
</div>
{tab === "banned" && (
<Input
placeholder="Reason (optional)"
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
className="flex-1"
/>
)}
<Button
onClick={() => {
if (tab === "ops") handleAction("op");
else if (tab === "whitelist") handleAction("whitelist add");
else if (tab === "banned") handleAction("ban");
}}
disabled={!nameValid || action.isPending}
className="w-full sm:w-auto"
>
{tab === "ops" ? "Add OP" : tab === "whitelist" ? "Add" : "Ban"}
</Button>
</div>
{/* Feedback */}
{result && (
<Alert className={result.ok ? "border-emerald-500/20 bg-emerald-500/5" : "border-red-500/20 bg-red-500/5"}>
<AlertDescription className={result.ok ? "text-emerald-300" : "text-red-300"}>
{result.message}
</AlertDescription>
</Alert>
)}
<Separator />
{/* Player lists */}
{tab === "ops" && (
<ul className="space-y-1">
{data.ops.length === 0 && (
<li className="text-sm text-muted-foreground py-2 text-center">No operators</li>
)}
{data.ops.map((p) => (
<li
key={p.uuid || p.name}
className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group"
>
<div className="flex items-center gap-2 min-w-0">
<img
src={`https://mc-heads.net/avatar/${p.name}/24`}
alt=""
className="w-6 h-6 rounded shrink-0"
/>
<span className="text-sm font-medium truncate">{p.name}</span>
<Badge variant="secondary" className="text-xs px-1.5 py-0 shrink-0">
Lv{p.level}
</Badge>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleAction("deop", p.name)}
disabled={action.isPending}
className="text-xs h-9 shrink-0 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
>
Deop
</Button>
</li>
))}
</ul>
)}
{tab === "whitelist" && (
<ul className="space-y-1">
{data.whitelist.length === 0 && (
<li className="text-sm text-muted-foreground py-2 text-center">Whitelist is empty</li>
)}
{data.whitelist.map((p) => (
<li
key={p.uuid || p.name}
className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group"
>
<div className="flex items-center gap-2 min-w-0">
<img
src={`https://mc-heads.net/avatar/${p.name}/24`}
alt=""
className="w-6 h-6 rounded shrink-0"
/>
<span className="text-sm font-medium truncate">{p.name}</span>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleAction("whitelist remove", p.name)}
disabled={action.isPending}
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
>
Remove
</Button>
</li>
))}
</ul>
)}
{tab === "banned" && (
<ul className="space-y-1">
{data.banned.length === 0 && (
<li className="text-sm text-muted-foreground py-2 text-center">No banned players</li>
)}
{data.banned.map((p) => (
<li
key={p.uuid || p.name}
className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<img
src={`https://mc-heads.net/avatar/${p.name}/24`}
alt=""
className="w-6 h-6 rounded shrink-0"
/>
<span className="text-sm font-medium truncate">{p.name}</span>
</div>
{p.reason && (
<p className="text-xs text-muted-foreground truncate ml-8">{p.reason}</p>
)}
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleAction("pardon", p.name)}
disabled={action.isPending}
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
>
Unban
</Button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,287 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { StatusBadge, statusFromServer } from "@/components/StatusBadge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
type ServerStatus = {
online: boolean;
starting?: boolean;
players: { online: number; max: number };
version?: string;
motd?: string;
};
type Action = "start" | "stop" | "restart";
const ACTION_LABEL: Record<Action, string> = {
start: "Start",
stop: "Stop",
restart: "Restart",
};
export function ServerControls() {
const queryClient = useQueryClient();
const [confirm, setConfirm] = useState<Action | null>(null);
const { data: status, isLoading } = useQuery<ServerStatus>({
queryKey: ["status"],
queryFn: () => fetch("/api/status").then((r) => r.json()),
refetchInterval: 10000,
});
const action = useMutation({
mutationFn: async (act: Action) => {
const res = await fetch(`/api/server/${act}`, { method: "POST" });
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Request failed");
}
return { ...(await res.json()), act };
},
onSuccess: () => {
setTimeout(
() => queryClient.invalidateQueries({ queryKey: ["status"] }),
3000
);
},
});
const isOnline = status?.online ?? false;
const lastAction = action.data?.act as Action | undefined;
const trigger = (act: Action) => {
if (act === "start") {
action.mutate(act);
} else {
setConfirm(act);
}
};
const confirmRun = () => {
if (confirm) {
action.mutate(confirm);
setConfirm(null);
}
};
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<CardTitle>Server Controls</CardTitle>
<CardDescription className="hidden sm:block">Manage the Minecraft server process</CardDescription>
</div>
{isLoading || !status ? (
<Skeleton className="h-5 w-20" />
) : (
<StatusBadge status={statusFromServer(status)} />
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats row */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 sm:gap-3">
<div className="rounded-lg bg-muted p-3 text-center">
<p className="text-xs text-muted-foreground mb-0.5">Players</p>
{isLoading || !status ? (
<Skeleton className="h-7 w-12 mx-auto" />
) : (
<p className="text-xl font-bold tabular-nums">
{isOnline ? `${status.players.online}/${status.players.max}` : "-"}
</p>
)}
</div>
<div className="rounded-lg bg-muted p-3 text-center">
<p className="text-xs text-muted-foreground mb-0.5">Version</p>
{isLoading || !status ? (
<Skeleton className="h-5 w-16 mx-auto mt-1" />
) : (
<p className="text-sm font-semibold mt-1">{status.version || "-"}</p>
)}
</div>
<div className="rounded-lg bg-muted p-3 text-center">
<p className="text-xs text-muted-foreground mb-0.5">Address</p>
<p className="text-xs font-mono font-semibold mt-1">
minecraft.hurkicorgi.com
</p>
</div>
</div>
{/* Confirmation */}
{confirm && (
<Alert className="border-amber-500/30 bg-amber-500/5">
<AlertDescription className="text-amber-300 flex items-center justify-between gap-3 flex-wrap">
<span>
{confirm === "stop"
? "Stop the server? Players will be disconnected."
: "Restart the server? Players will be disconnected briefly."}
</span>
<span className="flex gap-1">
<Button size="sm" variant="destructive" onClick={confirmRun}>
Confirm {ACTION_LABEL[confirm]}
</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirm(null)}>
Cancel
</Button>
</span>
</AlertDescription>
</Alert>
)}
{/* Action buttons */}
<div className="grid grid-cols-3 gap-2">
<Button
variant="outline"
onClick={() => trigger("start")}
disabled={action.isPending || isOnline || !!confirm}
className="w-full border-emerald-500/30 text-emerald-300 hover:bg-emerald-500/10 hover:text-emerald-200 disabled:opacity-40"
>
Start
</Button>
<Button
variant="outline"
onClick={() => trigger("stop")}
disabled={action.isPending || !isOnline || !!confirm}
className="w-full border-red-500/30 text-red-300 hover:bg-red-500/10 hover:text-red-200 disabled:opacity-40"
>
Stop
</Button>
<Button
variant="outline"
onClick={() => trigger("restart")}
disabled={action.isPending || !isOnline || !!confirm}
className="w-full border-amber-500/30 text-amber-300 hover:bg-amber-500/10 hover:text-amber-200 disabled:opacity-40"
>
Restart
</Button>
</div>
{/* Feedback */}
{action.isPending && (
<Alert>
<AlertDescription className="text-muted-foreground flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-blue-400 animate-pulse" />
Sending {action.variables ? ACTION_LABEL[action.variables] : ""} command...
</AlertDescription>
</Alert>
)}
{action.isSuccess && !action.isPending && (
<Alert className="border-emerald-500/20 bg-emerald-500/5">
<AlertDescription className="text-emerald-300">
{lastAction ? `${ACTION_LABEL[lastAction]} command sent.` : "Command sent."} Status updates in a few seconds.
</AlertDescription>
</Alert>
)}
{action.isError && (
<Alert className="border-red-500/20 bg-red-500/5">
<AlertDescription className="text-red-300">
{action.error.message}
</AlertDescription>
</Alert>
)}
<Separator />
{/* Scheduled restart */}
<ScheduledRestart />
</CardContent>
</Card>
);
}
function ScheduledRestart() {
const { data: schedule } = useQuery<{ enabled: boolean; hour: number; minute: number }>({
queryKey: ["schedule"],
queryFn: () => fetch("/api/schedule").then((r) => r.json()),
staleTime: 30_000,
initialData: { enabled: false, hour: 4, minute: 0 },
});
const [hour, setHour] = useState<number | null>(null);
const [minute, setMinute] = useState<number | null>(null);
const h = hour ?? schedule.hour;
const m = minute ?? schedule.minute;
const update = useMutation({
mutationFn: async (params: { enabled: boolean; hour: number; minute: number }) => {
const res = await fetch("/api/schedule", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
return res.json();
},
});
const pad = (n: number) => String(n).padStart(2, "0");
return (
<div className="space-y-3">
<div>
<p className="text-sm font-semibold">Scheduled Restart</p>
<p className="text-xs text-muted-foreground">
{schedule.enabled
? `Daily at ${pad(schedule.hour)}:${pad(schedule.minute)} server time — warns players before restarting`
: "Set a time for daily auto-restart with player warnings (uses server's local time)"}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<select
value={h}
onChange={(e) => setHour(parseInt(e.target.value))}
className="h-10 w-20 rounded-md border border-input bg-muted px-3 text-sm text-foreground"
>
{Array.from({ length: 24 }, (_, i) => (
<option key={i} value={i}>
{pad(i)}
</option>
))}
</select>
<span className="text-muted-foreground font-bold">:</span>
<select
value={m}
onChange={(e) => setMinute(parseInt(e.target.value))}
className="h-10 w-20 rounded-md border border-input bg-muted px-3 text-sm text-foreground"
>
{[0, 15, 30, 45].map((v) => (
<option key={v} value={v}>
{pad(v)}
</option>
))}
</select>
<span className="text-xs text-muted-foreground">server time</span>
{schedule.enabled ? (
<Button
variant="ghost"
onClick={() => update.mutate({ enabled: false, hour: h, minute: m })}
disabled={update.isPending}
className="text-muted-foreground"
>
Disable
</Button>
) : (
<Button
onClick={() => update.mutate({ enabled: true, hour: h, minute: m })}
disabled={update.isPending}
>
Enable
</Button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,40 @@
import { Badge } from "@/components/ui/badge";
type Status = "online" | "starting" | "offline";
const config = {
online: {
label: "Online",
className: "border-emerald-500/30 bg-emerald-500/10 text-emerald-300",
dot: "bg-emerald-400 shadow-[0_0_6px_var(--color-emerald-400)]",
pulse: false,
},
starting: {
label: "Starting",
className: "border-amber-500/30 bg-amber-500/10 text-amber-300",
dot: "bg-amber-400",
pulse: true,
},
offline: {
label: "Offline",
className: "border-red-500/30 bg-red-500/10 text-red-300",
dot: "bg-red-400",
pulse: false,
},
} as const;
export function StatusBadge({ status }: { status: Status }) {
const c = config[status];
return (
<Badge variant="outline" className={`gap-1.5 ${c.className}`}>
<span className={`h-1.5 w-1.5 rounded-full ${c.dot} ${c.pulse ? "animate-pulse" : ""}`} />
{c.label}
</Badge>
);
}
export function statusFromServer(s: { online: boolean; starting?: boolean }): Status {
if (s.online) return "online";
if (s.starting) return "starting";
return "offline";
}

102
components/StatusCard.tsx Normal file
View file

@ -0,0 +1,102 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { StatusBadge, statusFromServer } from "@/components/StatusBadge";
type ServerStatus = {
online: boolean;
starting?: boolean;
players: { online: number; max: number };
version?: string;
motd?: string;
};
export function StatusCard() {
const [copied, setCopied] = useState(false);
const { data, isLoading } = useQuery<ServerStatus>({
queryKey: ["status"],
queryFn: () => fetch("/api/status").then((r) => r.json()),
refetchInterval: 15000,
});
const copyIP = () => {
navigator.clipboard.writeText("minecraft.hurkicorgi.com");
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<Card>
<CardHeader className="pb-0">
<CardTitle className="text-base">Server Status</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Status */}
<div className="rounded-lg bg-muted p-3 sm:p-4 text-center">
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-2">
Status
</p>
{isLoading || !data ? (
<Skeleton className="h-5 w-20 mx-auto" />
) : (
<StatusBadge status={statusFromServer(data)} />
)}
</div>
{/* Players */}
<div className="rounded-lg bg-muted p-3 sm:p-4 text-center">
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Players
</p>
{isLoading || !data ? (
<Skeleton className="h-8 w-16 mx-auto" />
) : (
<p className="text-2xl font-bold tabular-nums">
{data.online ? `${data.players.online}/${data.players.max}` : "0"}
</p>
)}
</div>
{/* Version */}
<div className="rounded-lg bg-muted p-3 sm:p-4 text-center">
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Version
</p>
{isLoading || !data ? (
<Skeleton className="h-5 w-20 mx-auto" />
) : (
<p className="text-sm font-semibold">{data.version || "-"}</p>
)}
</div>
{/* Connect */}
<div className="rounded-lg bg-muted p-3 sm:p-4 text-center">
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Connect
</p>
<button
onClick={copyIP}
className="w-full rounded-md border border-dashed border-border px-2 py-2 hover:border-primary/50 transition cursor-pointer min-h-[44px]"
>
<p className="font-mono text-xs font-semibold text-foreground">
minecraft.hurkicorgi.com
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{copied ? (
<span className="text-emerald-300">Copied!</span>
) : (
"tap to copy"
)}
</p>
</button>
</div>
</div>
</CardContent>
</Card>
);
}

76
components/ui/alert.tsx Normal file
View file

@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-heading font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }

52
components/ui/badge.tsx Normal file
View file

@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

58
components/ui/button.tsx Normal file
View file

@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

103
components/ui/card.tsx Normal file
View file

@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex w-full min-w-0 overflow-x-clip flex-col gap-4 rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-3 sm:px-4 min-w-0 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-3 sm:px-4 min-w-0 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

20
components/ui/input.tsx Normal file
View file

@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

20
components/ui/label.tsx Normal file
View file

@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View file

@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View file

@ -0,0 +1,10 @@
import { cn } from "@/lib/utils";
export function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted-foreground/10", className)}
{...props}
/>
);
}

82
components/ui/tabs.tsx Normal file
View file

@ -0,0 +1,82 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

18
eslint.config.mjs Normal file
View file

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

30
lib/auth.ts Normal file
View file

@ -0,0 +1,30 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Credentials({
name: "Admin Login",
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (
credentials?.username === process.env.ADMIN_USERNAME &&
credentials?.password === process.env.ADMIN_PASSWORD
) {
return { id: "1", name: "Admin" };
}
return null;
},
}),
],
pages: {
signIn: "/login",
},
session: {
strategy: "jwt",
maxAge: 24 * 60 * 60,
},
});

9
lib/constants.ts Normal file
View file

@ -0,0 +1,9 @@
export const MC_SERVER_IP = "127.0.0.1";
export const MC_SERVER_PORT = 25565;
export const MODS_DIR = "/home/minecraft/server/mods";
export const CLIENT_MODS_DIR = "/home/minecraft/server/client-mods";
export const MOD_METADATA_FILE = "/home/minecraft/server/mod-metadata.json";
export const MODPACK_ZIP = "/var/www/minecraft/modpack.zip";
export const INSTALLER_BAT = "/var/www/minecraft/install-modpack.bat";
export const RCON_PORT = 25575;
export const RCON_PASSWORD = process.env.RCON_PASSWORD || "";

229
lib/modrinth.ts Normal file
View file

@ -0,0 +1,229 @@
import { existsSync, mkdirSync, writeFileSync } from "fs";
import { join } from "path";
import { MODS_DIR, CLIENT_MODS_DIR } from "./constants";
import { getModDetails } from "./mods";
const API = "https://api.modrinth.com/v2";
const GAME_VERSION = "1.20.1";
const LOADER = "forge";
export type ModSide = "client" | "server" | "both";
export type SearchResult = {
slug: string;
title: string;
description: string;
icon_url: string;
downloads: number;
project_id: string;
};
export type ModDownload = {
projectId: string;
versionId: string;
title: string;
filename: string;
url: string;
isDependency: boolean;
alreadyInstalled: boolean;
side: ModSide;
};
export type ResolveResult = {
toInstall: ModDownload[];
skipped: ModDownload[];
conflicts: string[];
};
// ── Search ──────────────────────────────────────────────────
export async function searchMods(query: string): Promise<SearchResult[]> {
const facets = JSON.stringify([
[`categories:${LOADER}`],
[`versions:${GAME_VERSION}`],
["project_type:mod"],
]);
const url = `${API}/search?query=${encodeURIComponent(query)}&facets=${encodeURIComponent(facets)}&limit=12`;
const res = await fetch(url, {
headers: { "User-Agent": "HurkiCorgiMC/1.0 (minecraft.hurkicorgi.com)" },
});
if (!res.ok) throw new Error(`Modrinth search failed: ${res.status}`);
const data = await res.json();
return data.hits.map((h: Record<string, unknown>) => ({
slug: h.slug,
title: h.title,
description: h.description,
icon_url: h.icon_url || "",
downloads: h.downloads,
project_id: h.project_id,
}));
}
// ── Get project details (title + side info) ─────────────────
type ProjectDetails = {
title: string;
client_side: string;
server_side: string;
};
async function getProjectDetails(
projectId: string
): Promise<ProjectDetails | null> {
try {
const res = await fetch(`${API}/project/${projectId}`, {
headers: {
"User-Agent": "HurkiCorgiMC/1.0 (minecraft.hurkicorgi.com)",
},
});
if (!res.ok) return null;
const data = await res.json();
return {
title: data.title || projectId,
client_side: data.client_side || "optional",
server_side: data.server_side || "optional",
};
} catch {
return null;
}
}
export function classifySide(
client_side: string,
server_side: string
): ModSide {
if (server_side === "unsupported") return "client";
if (client_side === "unsupported") return "server";
return "both";
}
// ── Get latest version for a project ────────────────────────
type VersionFile = { url: string; filename: string };
type VersionDep = {
project_id: string | null;
dependency_type: string;
};
type VersionData = {
id: string;
name: string;
files: VersionFile[];
dependencies: VersionDep[];
};
async function getLatestVersion(
projectId: string
): Promise<VersionData | null> {
const url = `${API}/project/${projectId}/version?game_versions=["${GAME_VERSION}"]&loaders=["${LOADER}"]`;
const res = await fetch(url, {
headers: { "User-Agent": "HurkiCorgiMC/1.0 (minecraft.hurkicorgi.com)" },
});
if (!res.ok) return null;
const versions: VersionData[] = await res.json();
return versions.length > 0 ? versions[0] : null;
}
// ── Resolve dependencies ────────────────────────────────────
export async function resolveDependencies(
projectIds: string[],
titles: Record<string, string>
): Promise<ResolveResult> {
// Cache installed mods once for the entire resolution
const installed = getModDetails();
const toInstall: ModDownload[] = [];
const skipped: ModDownload[] = [];
const conflicts: string[] = [];
const visited = new Set<string>();
const queue = projectIds.map((id) => ({ id, isDep: false }));
while (queue.length > 0) {
const item = queue.shift()!;
if (visited.has(item.id)) continue;
visited.add(item.id);
const [version, details] = await Promise.all([
getLatestVersion(item.id),
getProjectDetails(item.id),
]);
if (!version || version.files.length === 0) {
if (!item.isDep) {
conflicts.push(
`"${titles[item.id] || item.id}" has no Forge 1.20.1 version available`
);
}
continue;
}
const title = titles[item.id] || details?.title || item.id;
const side = details
? classifySide(details.client_side, details.server_side)
: "both";
const file = version.files[0];
const mod: ModDownload = {
projectId: item.id,
versionId: version.id,
title,
filename: file.filename,
url: file.url,
isDependency: item.isDep,
alreadyInstalled: false,
side,
};
// Check if already installed (by filename as rough proxy)
const filenameBase = file.filename.replace(/-[\d.]+.*\.jar$/, "").toLowerCase();
const isInstalled = installed.some(
(m) =>
m.filename === file.filename ||
m.filename.toLowerCase().startsWith(filenameBase)
);
if (isInstalled) {
mod.alreadyInstalled = true;
skipped.push(mod);
} else {
toInstall.push(mod);
}
// Queue required dependencies
for (const dep of version.dependencies) {
if (
dep.dependency_type === "required" &&
dep.project_id &&
!visited.has(dep.project_id)
) {
queue.push({ id: dep.project_id, isDep: true });
}
}
}
return { toInstall, skipped, conflicts };
}
// ── Download a mod ──────────────────────────────────────────
export async function downloadMod(
url: string,
filename: string,
side: ModSide = "both"
): Promise<void> {
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to download ${filename}: ${res.status}`);
const targetDir = side === "client" ? CLIENT_MODS_DIR : MODS_DIR;
if (side === "client" && !existsSync(CLIENT_MODS_DIR)) {
mkdirSync(CLIENT_MODS_DIR, { recursive: true });
}
const buffer = Buffer.from(await res.arrayBuffer());
writeFileSync(join(targetDir, filename), buffer);
}

277
lib/mods.ts Normal file
View file

@ -0,0 +1,277 @@
import AdmZip from "adm-zip";
import { execSync } from "child_process";
import {
existsSync,
readdirSync,
readFileSync,
statSync,
unlinkSync,
writeFileSync,
} from "fs";
import { join } from "path";
import { status } from "minecraft-server-util";
import {
MODS_DIR,
CLIENT_MODS_DIR,
MOD_METADATA_FILE,
MC_SERVER_IP,
MC_SERVER_PORT,
} from "./constants";
import { sendCommand } from "./rcon";
import type { ModSide } from "./modrinth";
const MODPACK_ZIP = "/var/www/minecraft/modpack.zip";
const MODPACK_MODS = "/var/www/minecraft/mods";
export type ModMeta = {
modId: string;
displayName: string;
version: string;
filename: string;
size: string;
side: ModSide;
};
// ── Mod metadata persistence ───────────────────────────────
type ModMetadataEntry = { projectId: string; side: ModSide };
type ModMetadataMap = Record<string, ModMetadataEntry>;
function loadModMetadata(): ModMetadataMap {
try {
return JSON.parse(readFileSync(MOD_METADATA_FILE, "utf8"));
} catch {
return {};
}
}
function saveModMetadata(metadata: ModMetadataMap): void {
writeFileSync(MOD_METADATA_FILE, JSON.stringify(metadata, null, 2));
}
export function addModMetadata(
filename: string,
entry: ModMetadataEntry
): void {
const metadata = loadModMetadata();
metadata[filename] = entry;
saveModMetadata(metadata);
}
export function removeModMetadata(filename: string): void {
const metadata = loadModMetadata();
delete metadata[filename];
saveModMetadata(metadata);
}
// ── Read mod metadata from JAR files ────────────────────────
function extractToml(content: string, key: string): string | null {
const regex = new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, "m");
const match = content.match(regex);
return match ? match[1] : null;
}
function extractModMeta(
dir: string,
filename: string
): Omit<ModMeta, "side"> {
const filePath = join(dir, filename);
const stat = statSync(filePath);
let modId = "unknown";
let displayName = filename
.replace(/-(\d)/, " $1")
.replace(".jar", "")
.replace(/-/g, " ");
let version = "";
try {
const zip = new AdmZip(filePath);
const toml = zip.getEntry("META-INF/mods.toml");
if (toml) {
const content = toml.getData().toString("utf8");
modId = extractToml(content, "modId") || modId;
displayName = extractToml(content, "displayName") || displayName;
version = extractToml(content, "version") || "";
}
} catch {
// Use filename-based defaults
}
return {
modId,
displayName,
version,
filename,
size: (stat.size / 1024 / 1024).toFixed(1) + " MB",
};
}
export function getModDetails(): ModMeta[] {
const metadata = loadModMetadata();
// Server mods
const serverFiles = readdirSync(MODS_DIR).filter((f) => f.endsWith(".jar"));
const serverMods: ModMeta[] = serverFiles.map((f) => {
const meta = extractModMeta(MODS_DIR, f);
const stored = metadata[f];
const side: ModSide = stored?.side === "server" ? "server" : stored?.side || "both";
return { ...meta, side };
});
// Client mods
let clientMods: ModMeta[] = [];
if (existsSync(CLIENT_MODS_DIR)) {
const clientFiles = readdirSync(CLIENT_MODS_DIR).filter((f) =>
f.endsWith(".jar")
);
clientMods = clientFiles.map((f) => ({
...extractModMeta(CLIENT_MODS_DIR, f),
side: "client" as ModSide,
}));
}
return [...serverMods, ...clientMods];
}
// ── Remove a single mod ─────────────────────────────────────
export function removeMod(filename: string): void {
const serverPath = join(MODS_DIR, filename);
const clientPath = join(CLIENT_MODS_DIR, filename);
if (existsSync(serverPath)) {
unlinkSync(serverPath);
} else if (existsSync(clientPath)) {
unlinkSync(clientPath);
} else {
throw new Error(`Mod "${filename}" not found`);
}
removeModMetadata(filename);
}
export function isClientOnlyMod(filename: string): boolean {
return (
!existsSync(join(MODS_DIR, filename)) &&
existsSync(join(CLIENT_MODS_DIR, filename))
);
}
// ── Server health check ─────────────────────────────────────
export type HealthProgress = {
phase: "waiting" | "process-alive" | "port-open" | "rcon-ready" | "failed" | "process-died";
elapsed: number;
message: string;
};
export async function waitForServerAdaptive(
onProgress?: (p: HealthProgress) => void | Promise<void>,
options?: { initialDelayMs?: number; maxWaitMs?: number }
): Promise<boolean> {
const { initialDelayMs = 8000, maxWaitMs = 600000 } = options ?? {};
const start = Date.now();
const elapsed = () => Date.now() - start;
const elapsedSec = () => Math.round(elapsed() / 1000);
const report = async (phase: HealthProgress["phase"], message: string) => {
await onProgress?.({ phase, elapsed: elapsed(), message });
};
await report("waiting", "Waiting for server process...");
await sleep(initialDelayMs);
let rconFailsWhileActive = 0;
while (elapsed() < maxWaitMs) {
// Tier 1: is the process alive?
let serviceState: string;
try {
serviceState = execSync("systemctl is-active minecraft.service", {
encoding: "utf8",
}).trim();
} catch {
await report("process-died", "Server process is not running");
return false;
}
if (serviceState !== "active" && serviceState !== "activating") {
await report("process-died", `Server process state: ${serviceState}`);
return false;
}
await report("process-alive", `Server process alive (${elapsedSec()}s)`);
// Tier 2: try RCON directly — this is the definitive "server is ready" signal
// RCON only starts after the server is fully loaded ("Done!")
try {
await sendCommand("list");
await report("rcon-ready", "Server is fully online");
return true;
} catch {
rconFailsWhileActive++;
}
// Tier 3: if RCON keeps failing, try MC protocol ping as fallback
// (handles case where RCON is misconfigured but server is actually ready)
if (rconFailsWhileActive >= 6) {
try {
await status(MC_SERVER_IP, MC_SERVER_PORT, { timeout: 3000 });
await report("port-open", "Server is online (port verified, RCON unavailable)");
return true;
} catch {}
}
await sleep(3000);
}
await report("failed", `Server did not respond within ${Math.round(maxWaitMs / 1000)}s`);
return false;
}
export async function waitForServer(timeoutMs = 600000): Promise<boolean> {
return waitForServerAdaptive(undefined, { maxWaitMs: timeoutMs });
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
// ── Modpack rebuild ─────────────────────────────────────────
export function rebuildModpack(): void {
// Zip server mods
execSync(
`cd ${MODS_DIR} && rm -f /tmp/modpack-rebuild.zip && zip -j /tmp/modpack-rebuild.zip *.jar`,
{ encoding: "utf8" }
);
// Add client mods to the same zip
if (existsSync(CLIENT_MODS_DIR)) {
try {
execSync(
`cd ${CLIENT_MODS_DIR} && zip -j /tmp/modpack-rebuild.zip *.jar 2>/dev/null || true`,
{ encoding: "utf8" }
);
} catch {}
}
// Copy to web root
execSync(`sudo cp /tmp/modpack-rebuild.zip ${MODPACK_ZIP}`);
execSync(`sudo rm -f ${MODPACK_MODS}/*.jar`);
execSync(`sudo cp ${MODS_DIR}/*.jar ${MODPACK_MODS}/`);
if (existsSync(CLIENT_MODS_DIR)) {
try {
execSync(
`sudo cp ${CLIENT_MODS_DIR}/*.jar ${MODPACK_MODS}/ 2>/dev/null || true`
);
} catch {}
}
// Generate modlist from both directories
execSync(
`(ls ${MODS_DIR}/*.jar -1 2>/dev/null; ls ${CLIENT_MODS_DIR}/*.jar -1 2>/dev/null) | xargs -I{} basename {} | sort | sudo tee /var/www/minecraft/modlist.txt > /dev/null`
);
}

18
lib/rcon.ts Normal file
View file

@ -0,0 +1,18 @@
import { Rcon } from "rcon-client";
import { MC_SERVER_IP, RCON_PORT, RCON_PASSWORD } from "./constants";
export async function sendCommand(command: string): Promise<string> {
const rcon = await Rcon.connect({
host: MC_SERVER_IP,
port: RCON_PORT,
password: RCON_PASSWORD,
timeout: 5000,
});
try {
const response = await rcon.send(command);
return response;
} finally {
rcon.end();
}
}

160
lib/snapshots.ts Normal file
View file

@ -0,0 +1,160 @@
import {
existsSync,
mkdirSync,
readdirSync,
copyFileSync,
rmSync,
writeFileSync,
readFileSync,
unlinkSync,
} from "fs";
import { join } from "path";
import { MODS_DIR, CLIENT_MODS_DIR, MOD_METADATA_FILE } from "./constants";
const SNAPSHOTS_DIR = "/home/minecraft/server/snapshots";
const MAX_SNAPSHOTS = 10;
export type SnapshotMeta = {
name: string;
createdAt: string;
modCount: number;
mods: string[];
};
function ensureDir(dir: string) {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
export function createSnapshot(name: string): SnapshotMeta {
ensureDir(SNAPSHOTS_DIR);
// Sanitize name
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 60);
const dirName = `${safeName}_${Date.now()}`;
const snapDir = join(SNAPSHOTS_DIR, dirName);
mkdirSync(snapDir);
// Back up server mods
const mods = readdirSync(MODS_DIR).filter((f) => f.endsWith(".jar"));
for (const file of mods) {
copyFileSync(join(MODS_DIR, file), join(snapDir, file));
}
// Back up client mods
if (existsSync(CLIENT_MODS_DIR)) {
const clientMods = readdirSync(CLIENT_MODS_DIR).filter((f) =>
f.endsWith(".jar")
);
if (clientMods.length > 0) {
const clientSnapDir = join(snapDir, "client-mods");
mkdirSync(clientSnapDir);
for (const file of clientMods) {
copyFileSync(join(CLIENT_MODS_DIR, file), join(clientSnapDir, file));
}
}
}
// Back up mod metadata
if (existsSync(MOD_METADATA_FILE)) {
copyFileSync(MOD_METADATA_FILE, join(snapDir, "mod-metadata.json"));
}
const meta: SnapshotMeta = {
name: safeName,
createdAt: new Date().toISOString(),
modCount: mods.length,
mods,
};
writeFileSync(join(snapDir, "meta.json"), JSON.stringify(meta, null, 2));
// Enforce max snapshots
pruneOldSnapshots();
return meta;
}
export function restoreSnapshot(dirName: string): void {
const snapDir = join(SNAPSHOTS_DIR, dirName);
if (!existsSync(snapDir)) {
throw new Error(`Snapshot "${dirName}" not found`);
}
// Clear current server mods
const currentMods = readdirSync(MODS_DIR).filter((f) => f.endsWith(".jar"));
for (const file of currentMods) {
unlinkSync(join(MODS_DIR, file));
}
// Copy snapshot server mods back
const snapFiles = readdirSync(snapDir).filter((f) => f.endsWith(".jar"));
for (const file of snapFiles) {
copyFileSync(join(snapDir, file), join(MODS_DIR, file));
}
// Restore client mods
if (existsSync(CLIENT_MODS_DIR)) {
const currentClient = readdirSync(CLIENT_MODS_DIR).filter((f) =>
f.endsWith(".jar")
);
for (const file of currentClient) {
unlinkSync(join(CLIENT_MODS_DIR, file));
}
}
const clientSnapDir = join(snapDir, "client-mods");
if (existsSync(clientSnapDir)) {
ensureDir(CLIENT_MODS_DIR);
const snapClientFiles = readdirSync(clientSnapDir).filter((f) =>
f.endsWith(".jar")
);
for (const file of snapClientFiles) {
copyFileSync(join(clientSnapDir, file), join(CLIENT_MODS_DIR, file));
}
}
// Restore mod metadata
const metaBackup = join(snapDir, "mod-metadata.json");
if (existsSync(metaBackup)) {
copyFileSync(metaBackup, MOD_METADATA_FILE);
}
}
export function listSnapshots(): (SnapshotMeta & { dirName: string })[] {
ensureDir(SNAPSHOTS_DIR);
const dirs = readdirSync(SNAPSHOTS_DIR).filter((d) => {
const metaPath = join(SNAPSHOTS_DIR, d, "meta.json");
return existsSync(metaPath);
});
return dirs
.map((dirName) => {
const meta = JSON.parse(
readFileSync(join(SNAPSHOTS_DIR, dirName, "meta.json"), "utf8")
) as SnapshotMeta;
return { ...meta, dirName };
})
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
export function deleteSnapshot(dirName: string): void {
const snapDir = join(SNAPSHOTS_DIR, dirName);
if (!existsSync(snapDir)) {
throw new Error(`Snapshot "${dirName}" not found`);
}
rmSync(snapDir, { recursive: true });
}
function pruneOldSnapshots(): void {
const snapshots = listSnapshots();
if (snapshots.length > MAX_SNAPSHOTS) {
const toDelete = snapshots.slice(MAX_SNAPSHOTS);
for (const snap of toDelete) {
deleteSnapshot(snap.dirName);
}
}
}

6
lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

5
middleware.ts Normal file
View file

@ -0,0 +1,5 @@
export { auth as middleware } from "@/lib/auth";
export const config = {
matcher: ["/admin/:path*"],
};

12
next.config.ts Normal file
View file

@ -0,0 +1,12 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ["adm-zip"],
experimental: {
serverActions: {
bodySizeLimit: "100mb",
},
},
};
export default nextConfig;

48
package.json Normal file
View file

@ -0,0 +1,48 @@
{
"name": "dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start -p 3001",
"lint": "eslint"
},
"dependencies": {
"@auth/core": "^0.34.3",
"@base-ui/react": "^1.3.0",
"@tanstack/react-query": "^5.96.2",
"adm-zip": "^0.5.17",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.8.0",
"minecraft-server-util": "^5.4.4",
"next": "16.2.3",
"next-auth": "^5.0.0-beta.30",
"rcon-client": "^4.2.5",
"react": "19.2.4",
"react-dom": "19.2.4",
"shadcn": "^4.2.0",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/adm-zip": "^0.5.8",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.3",
"tailwindcss": "^4",
"typescript": "^5"
},
"ignoreScripts": [
"sharp",
"unrs-resolver"
],
"trustedDependencies": [
"sharp",
"unrs-resolver"
]
}

7
postcss.config.mjs Normal file
View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1 KiB

5
public/icon.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#1a1a2e"/>
<rect x="96" y="96" width="320" height="320" rx="32" fill="#2a2a4e"/>
<text x="256" y="300" font-family="sans-serif" font-size="200" font-weight="bold" fill="#8b5cf6" text-anchor="middle">MC</text>
</svg>

After

Width:  |  Height:  |  Size: 330 B

28
public/manifest.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "HurkiCorgi MC Server",
"short_name": "HurkiCorgi MC",
"description": "Minecraft Server Dashboard — Create & Engineering | Raids | Survival",
"start_url": "/",
"display": "standalone",
"background_color": "#0f0f13",
"theme_color": "#1a1a2e",
"orientation": "any",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icon.svg",
"sizes": "192x192",
"type": "image/svg+xml"
},
{
"src": "/icon.svg",
"sizes": "512x512",
"type": "image/svg+xml"
}
]
}

1
public/next.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

56
scripts/backup-world.sh Executable file
View file

@ -0,0 +1,56 @@
#!/bin/bash
# Automated world backup — called by cron every 6 hours
BACKUP_DIR="/home/minecraft/server/backups"
WORLD_DIR="/home/minecraft/server/world"
MAX_BACKUPS=20
RCON_PASS="23991818cc169249f181436f2a29a013"
TIMESTAMP=$(date +%Y-%m-%d_%H-%M)
mkdir -p "$BACKUP_DIR"
# Check if server is running
if systemctl is-active --quiet minecraft.service; then
# Disable saving and flush
node -e "
const { Rcon } = require('/home/minecraft/dashboard/node_modules/rcon-client');
(async () => {
try {
const rcon = await Rcon.connect({ host: '127.0.0.1', port: 25575, password: '$RCON_PASS' });
await rcon.send('save-off');
await rcon.send('save-all flush');
rcon.end();
} catch {}
})();
"
sleep 3
fi
# Create backup
tar czf "$BACKUP_DIR/world_${TIMESTAMP}.tar.gz" -C /home/minecraft/server world
# Validate backup was created and is non-empty
if [ ! -s "$BACKUP_DIR/world_${TIMESTAMP}.tar.gz" ]; then
echo "ERROR: Backup file is empty or missing"
exit 1
fi
# Re-enable saving
if systemctl is-active --quiet minecraft.service; then
node -e "
const { Rcon } = require('/home/minecraft/dashboard/node_modules/rcon-client');
(async () => {
try {
const rcon = await Rcon.connect({ host: '127.0.0.1', port: 25575, password: '$RCON_PASS' });
await rcon.send('save-on');
rcon.end();
} catch {}
})();
"
fi
# Prune old backups
cd "$BACKUP_DIR"
ls -t world_*.tar.gz 2>/dev/null | tail -n +$((MAX_BACKUPS + 1)) | xargs -r rm -f
echo "Backup complete: world_${TIMESTAMP}.tar.gz"

103
scripts/collect-metrics.js Executable file
View file

@ -0,0 +1,103 @@
#!/usr/bin/env node
// Collect server metrics — called by cron every minute
// Appends JSON lines to analytics.jsonl
const { Rcon } = require("rcon-client");
const { execSync } = require("child_process");
const { appendFileSync, readFileSync, writeFileSync } = require("fs");
const ANALYTICS_FILE = "/home/minecraft/server/analytics.jsonl";
const RCON_HOST = "127.0.0.1";
const RCON_PORT = 25575;
const RCON_PASS = "23991818cc169249f181436f2a29a013";
async function main() {
// Check if server is running
try {
const active = execSync("systemctl is-active minecraft.service", { encoding: "utf8" }).trim();
if (active !== "active") process.exit(0);
} catch {
process.exit(0);
}
// Get MC PID
let mcPid = "";
try {
mcPid = execSync("pgrep -f 'java.*forge' | head -1", { encoding: "utf8" }).trim();
} catch {}
let tps = 0;
let playersOnline = 0;
let players = [];
// RCON queries
try {
const rcon = await Rcon.connect({ host: RCON_HOST, port: RCON_PORT, password: RCON_PASS });
// TPS
try {
const tpsRaw = await rcon.send("forge tps");
const match = tpsRaw.match(/Overall.*Mean TPS:\s*(\d+\.?\d*)/i) || tpsRaw.match(/Mean TPS:\s*(\d+\.?\d*)/);
if (match) tps = parseFloat(match[1]);
} catch {}
// Players
try {
const listRaw = await rcon.send("list");
const countMatch = listRaw.match(/(\d+)\s+of/);
if (countMatch) playersOnline = parseInt(countMatch[1]);
if (playersOnline > 0) {
const namesStr = listRaw.split(":").pop() || "";
players = namesStr.split(",").map(n => n.trim()).filter(Boolean);
}
} catch {}
rcon.end();
} catch {
// RCON not available
}
// RAM
let ramUsedMB = 0;
const ramTotalMB = 8192;
if (mcPid) {
try {
const status = readFileSync(`/proc/${mcPid}/status`, "utf8");
const match = status.match(/VmRSS:\s+(\d+)/);
if (match) ramUsedMB = Math.round(parseInt(match[1]) / 1024);
} catch {}
}
// CPU (normalize to total system CPU — ps reports per-core)
let cpuPercent = 0;
const numCpus = parseInt(execSync("nproc", { encoding: "utf8" }).trim()) || 1;
if (mcPid) {
try {
const cpu = execSync(`ps -p ${mcPid} -o %cpu --no-headers`, { encoding: "utf8" }).trim();
cpuPercent = (parseFloat(cpu) || 0) / numCpus;
} catch {}
}
// Write
const entry = {
ts: new Date().toISOString(),
tps: Math.round(tps * 10) / 10,
ramUsedMB,
ramTotalMB,
cpuPercent: Math.round(cpuPercent * 10) / 10,
playersOnline,
players,
};
appendFileSync(ANALYTICS_FILE, JSON.stringify(entry) + "\n");
// Prune to last 2880 lines (48h)
try {
const lines = readFileSync(ANALYTICS_FILE, "utf8").split("\n").filter(Boolean);
if (lines.length > 3000) {
writeFileSync(ANALYTICS_FILE, lines.slice(-2880).join("\n") + "\n");
}
} catch {}
}
main().catch(() => process.exit(0));

View file

@ -0,0 +1,83 @@
#!/bin/bash
# Collect server metrics every minute — called by cron
# Appends JSON lines to analytics.jsonl
ANALYTICS_FILE="/home/minecraft/server/analytics.jsonl"
RCON_PASS="23991818cc169249f181436f2a29a013"
# Check if server is running
if ! systemctl is-active --quiet minecraft.service; then
exit 0
fi
# Get MC server PID
MC_PID=$(pgrep -f 'minecraft.*server' | head -1)
if [ -z "$MC_PID" ]; then
MC_PID=$(pgrep -f 'java.*forge' | head -1)
fi
# TPS via RCON
TPS="0"
TPS_RAW=$(echo -e "\x00" | timeout 3 python3 -c "
from rcon.source import Client
try:
with Client('127.0.0.1', 25575, passwd='${RCON_PASS}') as c:
r = c.run('forge tps')
print(r)
except: pass
" 2>/dev/null)
if echo "$TPS_RAW" | grep -q "Overall"; then
TPS=$(echo "$TPS_RAW" | grep "Overall" | grep -oP '[\d.]+(?= TPS)' | head -1)
elif echo "$TPS_RAW" | grep -q "Dim 0"; then
TPS=$(echo "$TPS_RAW" | grep "Dim 0\|overworld" | grep -oP '[\d.]+(?= TPS)' | head -1)
fi
[ -z "$TPS" ] && TPS="0"
# RAM from /proc
RAM_USED=0
RAM_TOTAL=8192
if [ -n "$MC_PID" ] && [ -f "/proc/$MC_PID/status" ]; then
RAM_KB=$(grep VmRSS "/proc/$MC_PID/status" 2>/dev/null | awk '{print $2}')
[ -n "$RAM_KB" ] && RAM_USED=$((RAM_KB / 1024))
fi
# CPU
CPU="0"
if [ -n "$MC_PID" ]; then
CPU=$(ps -p "$MC_PID" -o %cpu --no-headers 2>/dev/null | tr -d ' ')
fi
[ -z "$CPU" ] && CPU="0"
# Players via RCON
PLAYERS_JSON="[]"
PLAYERS_ONLINE=0
PLAYERS_RAW=$(timeout 3 python3 -c "
from rcon.source import Client
try:
with Client('127.0.0.1', 25575, passwd='${RCON_PASS}') as c:
r = c.run('list')
print(r)
except: pass
" 2>/dev/null)
if echo "$PLAYERS_RAW" | grep -q "players online"; then
PLAYERS_ONLINE=$(echo "$PLAYERS_RAW" | grep -oP '\d+(?= of)' | head -1)
NAMES=$(echo "$PLAYERS_RAW" | sed 's/.*: //')
if [ "$PLAYERS_ONLINE" -gt 0 ] 2>/dev/null && [ -n "$NAMES" ]; then
PLAYERS_JSON=$(echo "$NAMES" | python3 -c "import sys,json; print(json.dumps([n.strip() for n in sys.stdin.read().split(',') if n.strip()]))")
fi
fi
[ -z "$PLAYERS_ONLINE" ] && PLAYERS_ONLINE=0
# Timestamp
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# Write JSON line
echo "{\"ts\":\"$TS\",\"tps\":$TPS,\"ramUsedMB\":$RAM_USED,\"ramTotalMB\":$RAM_TOTAL,\"cpuPercent\":$CPU,\"playersOnline\":$PLAYERS_ONLINE,\"players\":$PLAYERS_JSON}" >> "$ANALYTICS_FILE"
# Keep only last 48 hours of data (2880 lines at 1/min)
if [ $(wc -l < "$ANALYTICS_FILE") -gt 3000 ]; then
tail -n 2880 "$ANALYTICS_FILE" > "$ANALYTICS_FILE.tmp"
mv "$ANALYTICS_FILE.tmp" "$ANALYTICS_FILE"
fi

32
scripts/scheduled-restart.sh Executable file
View file

@ -0,0 +1,32 @@
#!/bin/bash
# Scheduled server restart with player warnings
RCON_PASS="23991818cc169249f181436f2a29a013"
send_rcon() {
node -e "
const { Rcon } = require('/home/minecraft/dashboard/node_modules/rcon-client');
(async () => {
try {
const rcon = await Rcon.connect({ host: '127.0.0.1', port: 25575, password: '$RCON_PASS' });
await rcon.send('say $1');
rcon.end();
} catch {}
})();
"
}
# 5 minute warning
send_rcon '§c[Server] Restarting in 5 minutes!'
sleep 240
# 1 minute warning
send_rcon '§c[Server] Restarting in 1 minute!'
sleep 50
# 10 second warning
send_rcon '§c[Server] Restarting in 10 seconds!'
sleep 10
# Restart
sudo systemctl restart minecraft.service

34
tsconfig.json Normal file
View file

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}