From a011423017e53e2c2bd6c9171434caafb09e35f0 Mon Sep 17 00:00:00 2001 From: hurkicorgi Date: Mon, 13 Apr 2026 05:30:23 -0600 Subject: [PATCH] Pass 3 first slice: mod update action, error boundaries, a11y, palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New POST /api/mods/update SSE route: per-file Modrinth lookup → snapshot → download latest → swap old jar → restart + verify (if server-side) → rebuild modpack, with automatic rollback on any failure. - ModManager: "Update" button next to each mod with an available update, plus "Update all (N)" in the installed list header. Reuses the existing install timeline UI (same event shape). SSE reader extracted as consumeSSE helper. - Error boundaries: app/error.tsx (scoped), app/admin/error.tsx (admin subtree retry), app/not-found.tsx, app/global-error.tsx (hard-fail fallback with inline styles, no app shell dependency). - A11y sweep: aria-pressed + aria-label on LogViewer level chips and ModManager side filter; aria-label on admin TabsList; skip-to-content link in Navbar targeting
on public + admin pages; role/aria-live on install/update timeline; global Esc in ModManager clears open confirm prompts and exits search/review wizard steps. - Command palette (cmdk): global Ctrl/⌘+K dialog mounted in Providers. Navigate admin tabs, toggle theme, start/stop/restart server, create backup, re-check mod updates, jump to any cached mod/player/snapshot/ backup. Auth-aware — public users see only Home / Log in / Theme. - AdminTabs listens to hashchange so palette navigation updates the active tab live. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/admin/error.tsx | 53 +++++ app/admin/page.tsx | 4 +- app/api/mods/update/route.ts | 400 ++++++++++++++++++++++++++++++++++ app/error.tsx | 39 ++++ app/global-error.tsx | 70 ++++++ app/not-found.tsx | 23 ++ app/page.tsx | 4 +- app/providers.tsx | 2 + bun.lock | 63 ++++++ components/AdminTabs.tsx | 14 +- components/CommandPalette.tsx | 268 +++++++++++++++++++++++ components/LogViewer.tsx | 2 + components/ModManager.tsx | 195 +++++++++++++++-- components/Navbar.tsx | 6 + lib/modrinth.ts | 2 +- package.json | 1 + 16 files changed, 1124 insertions(+), 22 deletions(-) create mode 100644 app/admin/error.tsx create mode 100644 app/api/mods/update/route.ts create mode 100644 app/error.tsx create mode 100644 app/global-error.tsx create mode 100644 app/not-found.tsx create mode 100644 components/CommandPalette.tsx diff --git a/app/admin/error.tsx b/app/admin/error.tsx new file mode 100644 index 0000000..df045b5 --- /dev/null +++ b/app/admin/error.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export default function AdminError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("Admin error:", error); + }, [error]); + + return ( +
+ + + Admin panel crashed + + A component threw an error. Other tabs may still work. + + + +
+            {error.message}
+            {error.digest ? `\n\nref: ${error.digest}` : ""}
+          
+
+ + +
+
+
+
+ ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index c491749..7499d81 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -21,7 +21,7 @@ export default async function AdminPage() { -
+
@@ -32,7 +32,7 @@ export default async function AdminPage() { Back to dashboard
-
+
); } diff --git a/app/api/mods/update/route.ts b/app/api/mods/update/route.ts new file mode 100644 index 0000000..e4a37ab --- /dev/null +++ b/app/api/mods/update/route.ts @@ -0,0 +1,400 @@ +import { NextRequest } from "next/server"; +import { existsSync, readFileSync, unlinkSync } from "fs"; +import { join } from "path"; +import { exec } from "child_process"; +import { auth } from "@/lib/auth"; +import { downloadMod, getLatestVersion } from "@/lib/modrinth"; +import type { ModSide } from "@/lib/modrinth"; +import { createSnapshot, restoreSnapshot, listSnapshots } from "@/lib/snapshots"; +import { + waitForServerAdaptive, + rebuildModpack, + addModMetadata, + removeModMetadata, + invalidateModsCache, +} from "@/lib/mods"; +import { invalidate as invalidateCache } from "@/lib/cache"; +import { MODS_DIR, CLIENT_MODS_DIR, MOD_METADATA_FILE } from "@/lib/constants"; + +export const dynamic = "force-dynamic"; + +type ModMetadataEntry = { projectId: string; side: ModSide }; + +function restartServer(): Promise { + return new Promise((resolve, reject) => { + exec("sudo systemctl restart minecraft.service", (err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +function locateMod(filename: string): { dir: string; side: ModSide } | null { + if (existsSync(join(MODS_DIR, filename))) { + return { dir: MODS_DIR, side: "both" }; + } + if (existsSync(join(CLIENT_MODS_DIR, filename))) { + return { dir: CLIENT_MODS_DIR, side: "client" }; + } + return null; +} + +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 { filenames } = (await req.json()) as { filenames: string[] }; + + if (!Array.isArray(filenames) || filenames.length === 0) { + return new Response(JSON.stringify({ error: "No mods to update" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + // Validate filenames + for (const f of filenames) { + if (!f || !f.endsWith(".jar") || f.includes("/") || f.includes("\\")) { + return new Response(JSON.stringify({ error: `Invalid filename: ${f}` }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + } + + let metadata: Record; + try { + metadata = JSON.parse(readFileSync(MOD_METADATA_FILE, "utf8")); + } catch { + return new Response( + JSON.stringify({ error: "Mod metadata unavailable" }), + { status: 500, 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 {} + } + + (async () => { + const snapName = `before-update-${Date.now()}`; + + try { + // 1. Resolve targets + await send("step", { + id: "resolve", + status: "active", + message: `Resolving latest versions for ${filenames.length} mod(s)...`, + }); + + type Target = { + oldFilename: string; + dir: string; + side: ModSide; + projectId: string; + newFilename: string; + url: string; + }; + const targets: Target[] = []; + + for (const f of filenames) { + const entry = metadata[f]; + if (!entry?.projectId) { + await send("step", { + id: "resolve", + status: "error", + message: `${f}: unknown origin (not installed via dashboard)`, + }); + await send("done", { + success: false, + message: `Cannot update ${f} — no Modrinth project linked.`, + }); + return; + } + const loc = locateMod(f); + if (!loc) { + await send("step", { + id: "resolve", + status: "error", + message: `${f}: file missing on disk`, + }); + await send("done", { success: false, message: `Mod file missing: ${f}` }); + return; + } + const side: ModSide = entry.side || loc.side; + const latest = await getLatestVersion(entry.projectId); + if (!latest || !latest.files?.[0]) { + await send("step", { + id: "resolve", + status: "error", + message: `${f}: no compatible Modrinth version`, + }); + await send("done", { + success: false, + message: `No update available for ${f}.`, + }); + return; + } + const file = latest.files[0]; + if (file.filename === f) { + // Already up to date + continue; + } + targets.push({ + oldFilename: f, + dir: side === "client" ? CLIENT_MODS_DIR : MODS_DIR, + side, + projectId: entry.projectId, + newFilename: file.filename, + url: file.url, + }); + } + + if (targets.length === 0) { + await send("step", { + id: "resolve", + status: "done", + message: "All selected mods already current", + }); + await send("done", { + success: true, + message: "No updates needed — everything is up to date.", + installed: [], + }); + return; + } + await send("step", { + id: "resolve", + status: "done", + message: `${targets.length} update(s) to apply`, + }); + + // 2. Snapshot + await send("step", { + id: "snapshot", + status: "active", + message: "Creating safety snapshot...", + }); + try { + createSnapshot(snapName); + } catch (e) { + await send("step", { + id: "snapshot", + status: "error", + message: (e as Error).message, + }); + await send("done", { + success: false, + message: `Snapshot failed: ${(e as Error).message}`, + }); + return; + } + await send("step", { id: "snapshot", status: "done", message: "Snapshot created" }); + + // 3. Download + swap + await send("step", { + id: "download", + status: "active", + message: `Downloading ${targets.length} mod(s)...`, + }); + try { + for (const t of targets) { + await downloadMod(t.url, t.newFilename, t.side); + const oldPath = join(t.dir, t.oldFilename); + if (existsSync(oldPath) && t.oldFilename !== t.newFilename) { + unlinkSync(oldPath); + } + removeModMetadata(t.oldFilename); + addModMetadata(t.newFilename, { projectId: t.projectId, side: t.side }); + } + invalidateModsCache(); + invalidateCache("mods:updates"); + } 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", + }); + + // 4. Restart if any server-side updates + const hasServerMods = targets.some((t) => t.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: `Updated ${targets.length} client-only mod(s).`, + installed: targets.map((t) => t.newFilename), + }); + return; + } + + 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: `Restart failed: ${(e as Error).message}`, + rolledBack: true, + }); + return; + } + await send("step", { + id: "restart", + status: "done", + message: "Restart command sent", + }); + + // 5. Verify + await send("step", { + id: "health", + status: "active", + message: "Waiting for server...", + }); + const online = await waitForServerAdaptive(async (p) => { + await send("step", { id: "health", status: "active", message: p.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: `Updated ${targets.length} mod(s). Server is online.`, + installed: targets.map((t) => t.newFilename), + }); + } 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 (p) => { + await send("step", { + id: "rollback", + status: "active", + message: `Rollback: ${p.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 after updating ${targets.length} mod(s). Rolled back.`, + 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", + }, + }); +} diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..a15894f --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useEffect } from "react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("App error:", error); + }, [error]); + + return ( +
+
+

Something went wrong

+

+ {error.message || "An unexpected error occurred."} +

+ {error.digest && ( +

+ ref: {error.digest} +

+ )} +
+ + +
+
+
+ ); +} diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 0000000..db5f3ee --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,70 @@ +"use client"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + +
+

+ The dashboard hit a fatal error +

+

+ {error.message || "Unexpected error"} +

+ {error.digest && ( +

+ ref: {error.digest} +

+ )} + +
+ + + ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..d657688 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,23 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Navbar } from "@/components/Navbar"; + +export default function NotFound() { + return ( + <> + +
+
+

+ 404 +

+

Page not found

+

+ The page you're looking for doesn't exist or has moved. +

+ +
+
+ + ); +} diff --git a/app/page.tsx b/app/page.tsx index 9e1da8f..4934186 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -21,13 +21,13 @@ export default function Home() { {/* Content */} -
+
-
+