Minecraft 26.2 Fabric server stack with CraftDeck management panel
- docker-compose: itzg/minecraft-server (Fabric, offline mode, whitelist enforced, Modrinth-managed mods), itzg/mc-backup sidecar, custom dashboard - Gated auth for offline mode: approve username -> whitelisted with a short registration window -> EasyAuth /register + /login; auto-revoke on expiry - CraftDeck panel (Next.js 16, zero native deps): live console (SSE), players & stats, lag diagnostics (server TPS vs player ping via spark), performance graphs, Modrinth mod search/manager, backups & restore, sandboxed file browser/editor, .env config editor with compose apply, cron schedules, Discord alerts, scrypt-hashed panel login - scripts/setup.sh generates secrets; docs/ covers auth, mods, updating, backups, networking (Tailscale), troubleshooting Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
279d137ce5
98 files changed
+9925
No files matched your search
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Package, Search, Trash2, Power, Download, RefreshCw, Lock } from "lucide-react";
|
||||
import { api, useApi, toast } from "@/lib/api";
|
||||
import { Card, Button, Badge, Input, EmptyState, Modal } from "@/components/ui";
|
||||
import { fmtBytes } from "@/lib/format";
|
||||
|
||||
type ModsData = {
|
||||
installed: Array<{
|
||||
file: string;
|
||||
name: string;
|
||||
id: string | null;
|
||||
version: string | null;
|
||||
disabled: boolean;
|
||||
size: number;
|
||||
}>;
|
||||
managed: string[];
|
||||
mcVersion: string;
|
||||
};
|
||||
|
||||
type SearchHit = {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
downloads: number;
|
||||
icon_url: string | null;
|
||||
server_side: string;
|
||||
client_side: string;
|
||||
};
|
||||
|
||||
const PROTECTED = new Set(["fabric-api", "easyauth"]);
|
||||
|
||||
export default function ModsPage() {
|
||||
const { data, refresh } = useApi<ModsData>("/api/mods", 0);
|
||||
const [query, setQuery] = useState("");
|
||||
const [hits, setHits] = useState<SearchHit[] | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [needsApply, setNeedsApply] = useState(false);
|
||||
const [confirmApply, setConfirmApply] = useState(false);
|
||||
|
||||
const search = async () => {
|
||||
if (!query.trim()) return;
|
||||
setSearching(true);
|
||||
try {
|
||||
setHits(await api<SearchHit[]>(`/api/mods/search?q=${encodeURIComponent(query.trim())}`));
|
||||
} catch (err) {
|
||||
toast((err as Error).message, "error");
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const manage = async (label: string, body: Record<string, unknown>, message: string) => {
|
||||
setBusy(label);
|
||||
try {
|
||||
const res = await api<{ needsApply?: boolean; needsRestart?: boolean }>("/api/mods/manage", { body });
|
||||
toast(message, "success");
|
||||
if (res.needsApply || res.needsRestart) setNeedsApply(true);
|
||||
void refresh();
|
||||
} catch (err) {
|
||||
toast((err as Error).message, "error");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const apply = async () => {
|
||||
setConfirmApply(false);
|
||||
setApplying(true);
|
||||
try {
|
||||
await api("/api/mods/apply", { method: "POST" });
|
||||
toast("Server recreated — mods re-resolved for the current version", "success");
|
||||
setNeedsApply(false);
|
||||
void refresh();
|
||||
} catch (err) {
|
||||
toast((err as Error).message, "error");
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const managedSet = new Set((data?.managed ?? []).map((s) => s.toLowerCase()));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Mods</h1>
|
||||
<p className="mt-0.5 text-xs text-ink2">
|
||||
Managed mods auto-update to the newest build for Minecraft {data?.mcVersion ?? "…"} on every apply
|
||||
</p>
|
||||
</div>
|
||||
<Button variant={needsApply ? "primary" : "ghost"} onClick={() => setConfirmApply(true)} busy={applying}>
|
||||
<RefreshCw size={14} /> {needsApply ? "Apply changes (restarts server)" : "Update all & restart"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* Modrinth search */}
|
||||
<Card title="Add from Modrinth">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && void search()}
|
||||
placeholder={`Search Fabric mods for ${data?.mcVersion ?? "26.2"}…`}
|
||||
className="w-full"
|
||||
/>
|
||||
<Button onClick={() => void search()} busy={searching}>
|
||||
<Search size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="scroll-slim mt-3 max-h-96 space-y-2 overflow-y-auto">
|
||||
{hits === null && (
|
||||
<p className="py-6 text-center text-xs text-muted">
|
||||
Search Modrinth — results are pre-filtered to Fabric + your server version.
|
||||
</p>
|
||||
)}
|
||||
{hits?.length === 0 && <p className="py-6 text-center text-xs text-muted">No compatible mods found.</p>}
|
||||
{hits?.map((h) => {
|
||||
const already = managedSet.has(h.slug.toLowerCase());
|
||||
return (
|
||||
<div key={h.slug} className="flex items-center gap-3 rounded-lg border border-border bg-surface2 p-2.5">
|
||||
{h.icon_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={h.icon_url} alt="" className="h-9 w-9 rounded-md bg-surface3" />
|
||||
) : (
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-surface3">
|
||||
<Package size={16} className="text-muted" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{h.title}</span>
|
||||
{h.client_side === "required" && (
|
||||
<Badge color="warn">needs client install</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-muted">{h.description}</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={already ? "ghost" : "primary"}
|
||||
disabled={already}
|
||||
busy={busy === `add-${h.slug}`}
|
||||
onClick={() => void manage(`add-${h.slug}`, { op: "add", slug: h.slug }, `${h.title} added`)}
|
||||
>
|
||||
<Download size={12} /> {already ? "Added" : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* managed list */}
|
||||
<Card title={`Managed mods (${data?.managed.length ?? 0})`}>
|
||||
<p className="mb-3 text-[11px] leading-relaxed text-muted">
|
||||
These Modrinth projects are pinned in <code>.env</code> (MODRINTH_PROJECTS). The server resolves the
|
||||
newest compatible build on every start, so updating Minecraft also updates these automatically.
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{(data?.managed ?? []).map((slug) => (
|
||||
<li key={slug} className="flex items-center gap-2 rounded-lg border border-border bg-surface2 px-3 py-2">
|
||||
<span className="font-mono text-sm">{slug}</span>
|
||||
{PROTECTED.has(slug.toLowerCase()) && (
|
||||
<Badge color="info">
|
||||
<Lock size={9} /> required
|
||||
</Badge>
|
||||
)}
|
||||
<span className="ml-auto">
|
||||
{!PROTECTED.has(slug.toLowerCase()) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
busy={busy === `remove-${slug}`}
|
||||
onClick={() => void manage(`remove-${slug}`, { op: "remove", slug }, `${slug} removed`)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* installed jars */}
|
||||
<Card title="Installed jar files" pad={false}>
|
||||
{!data || data.installed.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Package size={28} />}
|
||||
title="No mod jars found"
|
||||
hint="Jars appear here once the server has started and downloaded its mods. You can also upload jars via the Files page (mods/ folder)."
|
||||
/>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-muted">
|
||||
<th className="px-4 py-2 font-medium">Mod</th>
|
||||
<th className="px-4 py-2 font-medium">Version</th>
|
||||
<th className="px-4 py-2 font-medium">File</th>
|
||||
<th className="px-4 py-2 font-medium">Size</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.installed.map((m) => (
|
||||
<tr key={m.file} className={`border-b border-border/50 last:border-0 ${m.disabled ? "opacity-50" : ""}`}>
|
||||
<td className="px-4 py-2.5 font-medium">
|
||||
{m.name} {m.disabled && <Badge color="muted">disabled</Badge>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-ink2">{m.version ?? "—"}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-[11px] text-muted">{m.file}</td>
|
||||
<td className="px-4 py-2.5 text-ink2">{fmtBytes(m.size)}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
busy={busy === `toggle-${m.file}`}
|
||||
onClick={() =>
|
||||
void manage(
|
||||
`toggle-${m.file}`,
|
||||
{ op: "toggle", file: m.file },
|
||||
m.disabled ? `${m.name} enabled` : `${m.name} disabled`
|
||||
)
|
||||
}
|
||||
title={m.disabled ? "Enable" : "Disable (renames to .disabled)"}
|
||||
>
|
||||
<Power size={12} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
busy={busy === `delete-${m.file}`}
|
||||
onClick={() => void manage(`delete-${m.file}`, { op: "delete", file: m.file }, `${m.file} deleted`)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal open={confirmApply} onClose={() => setConfirmApply(false)} title="Apply mod changes?">
|
||||
<p className="text-sm leading-relaxed text-ink2">
|
||||
This recreates the server container: online players are disconnected for a minute or two while the
|
||||
server restarts and re-resolves every managed mod against Minecraft {data?.mcVersion}.
|
||||
</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button onClick={() => setConfirmApply(false)}>Cancel</Button>
|
||||
<Button variant="primary" onClick={() => void apply()}>
|
||||
Apply & restart
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user