Pass 3 first slice: mod update action, error boundaries, a11y, palette
- 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 <main id="main"> 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) <noreply@anthropic.com>
This commit is contained in:
parent
f9ae1afac1
commit
a011423017
16 changed files with 1124 additions and 22 deletions
53
app/admin/error.tsx
Normal file
53
app/admin/error.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<Card className="border-red-500/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-red-300">Admin panel crashed</CardTitle>
|
||||
<CardDescription>
|
||||
A component threw an error. Other tabs may still work.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<pre className="rounded-md bg-muted p-3 text-xs text-muted-foreground whitespace-pre-wrap break-words max-h-48 overflow-auto">
|
||||
{error.message}
|
||||
{error.digest ? `\n\nref: ${error.digest}` : ""}
|
||||
</pre>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={reset}>Retry</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (typeof window !== "undefined") window.location.reload();
|
||||
}}
|
||||
>
|
||||
Reload page
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ export default async function AdminPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-5xl mx-auto px-3 py-4 sm:p-6 w-full overflow-x-hidden">
|
||||
<main id="main" className="max-w-5xl mx-auto px-3 py-4 sm:p-6 w-full overflow-x-hidden">
|
||||
<AdminTabs />
|
||||
|
||||
<div className="text-center mt-6">
|
||||
|
|
@ -32,7 +32,7 @@ export default async function AdminPage() {
|
|||
Back to dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
400
app/api/mods/update/route.ts
Normal file
400
app/api/mods/update/route.ts
Normal file
|
|
@ -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<void> {
|
||||
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<string, ModMetadataEntry>;
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
39
app/error.tsx
Normal file
39
app/error.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex-1 flex items-center justify-center p-6">
|
||||
<div className="max-w-md text-center space-y-4">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Something went wrong</h1>
|
||||
<p className="text-sm text-muted-foreground break-words">
|
||||
{error.message || "An unexpected error occurred."}
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="text-xs text-muted-foreground/60 font-mono">
|
||||
ref: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2 justify-center pt-2">
|
||||
<Button onClick={reset}>Try again</Button>
|
||||
<Button variant="ghost" render={<Link href="/" />}>
|
||||
Home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
app/global-error.tsx
Normal file
70
app/global-error.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"use client";
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
style={{
|
||||
fontFamily:
|
||||
"system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif",
|
||||
background: "#0b0b10",
|
||||
color: "#e5e5e7",
|
||||
minHeight: "100vh",
|
||||
margin: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 480, textAlign: "center" }}>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>
|
||||
The dashboard hit a fatal error
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: "#9ca3af",
|
||||
wordBreak: "break-word",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{error.message || "Unexpected error"}
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "#6b7280",
|
||||
fontFamily: "ui-monospace, Menlo, monospace",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
ref: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={reset}
|
||||
style={{
|
||||
background: "#f1f5f9",
|
||||
color: "#0b0b10",
|
||||
border: 0,
|
||||
borderRadius: 6,
|
||||
padding: "8px 16px",
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
23
app/not-found.tsx
Normal file
23
app/not-found.tsx
Normal file
|
|
@ -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 (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="flex-1 flex items-center justify-center p-6">
|
||||
<div className="max-w-md text-center space-y-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
404
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Page not found</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The page you're looking for doesn't exist or has moved.
|
||||
</p>
|
||||
<Button render={<Link href="/" />}>Back to dashboard</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,13 +21,13 @@ export default function Home() {
|
|||
|
||||
{/* Content */}
|
||||
<ClientOnly>
|
||||
<div className="max-w-5xl mx-auto px-3 py-4 sm:p-6 space-y-4 sm:space-y-6">
|
||||
<main id="main" 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>
|
||||
</main>
|
||||
</ClientOnly>
|
||||
|
||||
<footer className="text-center py-6 sm:py-8 text-muted-foreground text-xs">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||
import { SessionProvider } from "next-auth/react";
|
||||
import { Toaster } from "sonner";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CommandPalette } from "@/components/CommandPalette";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
|
|
@ -32,6 +33,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|||
<SessionProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<CommandPalette />
|
||||
<Toaster
|
||||
theme={theme}
|
||||
position="bottom-right"
|
||||
|
|
|
|||
63
bun.lock
63
bun.lock
|
|
@ -11,6 +11,7 @@
|
|||
"adm-zip": "^0.5.17",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"minecraft-server-util": "^5.4.4",
|
||||
"next": "16.2.3",
|
||||
|
|
@ -271,6 +272,40 @@
|
|||
|
||||
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||
|
||||
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
|
||||
|
||||
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
|
||||
|
||||
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
|
||||
|
||||
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
|
||||
|
||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||
|
||||
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
|
||||
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
||||
|
||||
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
|
||||
|
||||
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
|
||||
|
||||
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
|
||||
|
||||
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||
|
||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||
|
||||
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
|
||||
|
|
@ -415,6 +450,8 @@
|
|||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||
|
||||
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
|
||||
|
|
@ -487,6 +524,8 @@
|
|||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
|
||||
|
||||
"code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
|
@ -549,6 +588,8 @@
|
|||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
||||
|
||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||
|
|
@ -699,6 +740,8 @@
|
|||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
|
||||
"get-own-enumerable-keys": ["get-own-enumerable-keys@1.0.0", "", {}, "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
|
@ -1089,6 +1132,12 @@
|
|||
|
||||
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
|
||||
|
||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||
|
|
@ -1281,6 +1330,10 @@
|
|||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||
|
||||
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
|
@ -1349,6 +1402,16 @@
|
|||
|
||||
"@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||
|
|
|
|||
|
|
@ -34,6 +34,15 @@ export function AdminTabs() {
|
|||
if (saved && TABS.some((t) => t.value === saved)) setValue(saved);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onHash = () => {
|
||||
const h = window.location.hash.replace("#", "");
|
||||
if (h && TABS.some((t) => t.value === h)) setValue(h);
|
||||
};
|
||||
window.addEventListener("hashchange", onHash);
|
||||
return () => window.removeEventListener("hashchange", onHash);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
localStorage.setItem("admin-tab", value);
|
||||
|
|
@ -48,7 +57,10 @@ export function AdminTabs() {
|
|||
onValueChange={(v) => setValue(v as string)}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="flex w-full flex-wrap h-auto sm:h-9 gap-1 p-1 overflow-x-auto justify-start sm:justify-center">
|
||||
<TabsList
|
||||
aria-label="Admin sections"
|
||||
className="flex w-full flex-wrap h-auto sm:h-9 gap-1 p-1 overflow-x-auto justify-start sm:justify-center"
|
||||
>
|
||||
{TABS.map((t) => (
|
||||
<TabsTrigger key={t.value} value={t.value} className="text-xs sm:text-sm">
|
||||
{t.label}
|
||||
|
|
|
|||
268
components/CommandPalette.tsx
Normal file
268
components/CommandPalette.tsx
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useSession, signIn, signOut } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type Mod = { filename: string; displayName: string; modId: string };
|
||||
type Snapshot = { dirName: string; name: string };
|
||||
type Backup = { name: string };
|
||||
type PlayerData = {
|
||||
ops: { name: string }[];
|
||||
whitelist: { name: string }[];
|
||||
banned: { name: string }[];
|
||||
};
|
||||
|
||||
const TABS: { value: string; label: string }[] = [
|
||||
{ value: "server", label: "Server" },
|
||||
{ value: "players", label: "Players" },
|
||||
{ value: "chat", label: "Chat" },
|
||||
{ value: "mods", label: "Mods" },
|
||||
{ value: "backups", label: "Backups" },
|
||||
{ value: "logs", label: "Logs" },
|
||||
];
|
||||
|
||||
function toggleTheme() {
|
||||
const html = document.documentElement;
|
||||
const dark = html.classList.contains("dark");
|
||||
const next = dark ? "light" : "dark";
|
||||
html.classList.toggle("dark", next === "dark");
|
||||
html.classList.toggle("light", next === "light");
|
||||
html.style.colorScheme = next;
|
||||
localStorage.setItem("theme", next);
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: session } = useSession();
|
||||
const authed = !!session;
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.key === "k" || e.key === "K") && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setOpen((v) => !v);
|
||||
}
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
const run = useCallback(
|
||||
(fn: () => void | Promise<void>) => () => {
|
||||
close();
|
||||
Promise.resolve(fn()).catch(() => {});
|
||||
},
|
||||
[close]
|
||||
);
|
||||
|
||||
const goToTab = (tab: string) => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!window.location.pathname.startsWith("/admin")) {
|
||||
router.push(`/admin#${tab}`);
|
||||
} else {
|
||||
window.location.hash = tab;
|
||||
// force AdminTabs to sync by dispatching a hashchange
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
}
|
||||
};
|
||||
|
||||
const mods = (queryClient.getQueryData<Mod[]>(["mods"]) || []).slice(0, 20);
|
||||
const snapshots =
|
||||
(queryClient.getQueryData<Snapshot[]>(["snapshots"]) || []).slice(0, 10);
|
||||
const backups = (queryClient.getQueryData<Backup[]>(["backups"]) || []).slice(0, 10);
|
||||
const players = queryClient.getQueryData<PlayerData>(["players"]);
|
||||
const playerList = useMemo(() => {
|
||||
if (!players) return [] as { name: string; group: string }[];
|
||||
const out: { name: string; group: string }[] = [];
|
||||
players.ops.forEach((p) => out.push({ name: p.name, group: "Ops" }));
|
||||
players.whitelist.forEach((p) => out.push({ name: p.name, group: "Whitelist" }));
|
||||
return out.slice(0, 20);
|
||||
}, [players]);
|
||||
|
||||
const serverAction = async (act: "start" | "stop" | "restart") => {
|
||||
const res = await fetch(`/api/server/${act}`, { method: "POST" });
|
||||
if (res.ok) {
|
||||
toast.success(`${act} command sent`);
|
||||
queryClient.invalidateQueries({ queryKey: ["status"] });
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
toast.error(`${act} failed`, { description: data?.error });
|
||||
}
|
||||
};
|
||||
|
||||
const createBackup = async () => {
|
||||
toast.loading("Creating backup...", { id: "cmdk-backup" });
|
||||
const res = await fetch("/api/backups", { method: "POST" });
|
||||
const data = await res.json();
|
||||
toast.dismiss("cmdk-backup");
|
||||
if (res.ok) {
|
||||
toast.success(data.message || "Backup created");
|
||||
queryClient.invalidateQueries({ queryKey: ["backups"] });
|
||||
} else {
|
||||
toast.error("Backup failed", { description: data.error });
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] bg-black/50 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Command palette"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) close();
|
||||
}}
|
||||
>
|
||||
<Command
|
||||
label="Command Menu"
|
||||
className="w-[95%] max-w-lg rounded-xl border border-border bg-popover text-popover-foreground shadow-2xl overflow-hidden"
|
||||
>
|
||||
<div className="border-b border-border px-3 py-2.5 flex items-center gap-2">
|
||||
<span aria-hidden className="text-muted-foreground text-sm">⌘K</span>
|
||||
<Command.Input
|
||||
autoFocus
|
||||
placeholder="Type a command or search..."
|
||||
className="flex-1 bg-transparent outline-none text-sm placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<Command.List className="max-h-[60vh] overflow-y-auto p-1.5">
|
||||
<Command.Empty className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
No matches.
|
||||
</Command.Empty>
|
||||
|
||||
<Command.Group heading="Navigate" className="text-xs text-muted-foreground px-1.5 pt-1.5">
|
||||
<Item onSelect={run(() => router.push("/"))}>Go to Home</Item>
|
||||
{authed ? (
|
||||
TABS.map((t) => (
|
||||
<Item key={t.value} onSelect={run(() => goToTab(t.value))}>
|
||||
Admin · {t.label}
|
||||
</Item>
|
||||
))
|
||||
) : (
|
||||
<Item onSelect={run(() => signIn())}>Log in</Item>
|
||||
)}
|
||||
</Command.Group>
|
||||
|
||||
<Command.Group heading="Appearance" className="text-xs text-muted-foreground px-1.5 pt-1.5">
|
||||
<Item onSelect={run(() => toggleTheme())}>Toggle theme</Item>
|
||||
</Command.Group>
|
||||
|
||||
{authed && (
|
||||
<>
|
||||
<Command.Group heading="Server" className="text-xs text-muted-foreground px-1.5 pt-1.5">
|
||||
<Item onSelect={run(() => serverAction("start"))}>Start server</Item>
|
||||
<Item onSelect={run(() => serverAction("restart"))}>Restart server</Item>
|
||||
<Item onSelect={run(() => serverAction("stop"))}>Stop server</Item>
|
||||
<Item onSelect={run(() => createBackup())}>Create world backup now</Item>
|
||||
<Item
|
||||
onSelect={run(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["mod-updates"] });
|
||||
toast.success("Checking Modrinth for updates...");
|
||||
})}
|
||||
>
|
||||
Check for mod updates
|
||||
</Item>
|
||||
<Item onSelect={run(() => signOut({ callbackUrl: "/" }))}>
|
||||
Log out
|
||||
</Item>
|
||||
</Command.Group>
|
||||
|
||||
{mods.length > 0 && (
|
||||
<Command.Group heading="Installed Mods" className="text-xs text-muted-foreground px-1.5 pt-1.5">
|
||||
{mods.map((m) => (
|
||||
<Item
|
||||
key={m.filename}
|
||||
value={`mod ${m.displayName} ${m.modId} ${m.filename}`}
|
||||
onSelect={run(() => goToTab("mods"))}
|
||||
>
|
||||
<span className="truncate">{m.displayName}</span>
|
||||
</Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{playerList.length > 0 && (
|
||||
<Command.Group heading="Players" className="text-xs text-muted-foreground px-1.5 pt-1.5">
|
||||
{playerList.map((p, i) => (
|
||||
<Item
|
||||
key={`${p.group}-${p.name}-${i}`}
|
||||
value={`player ${p.name} ${p.group}`}
|
||||
onSelect={run(() => goToTab("players"))}
|
||||
>
|
||||
<span>{p.name}</span>
|
||||
<span className="ml-auto text-[10px] text-muted-foreground uppercase">
|
||||
{p.group}
|
||||
</span>
|
||||
</Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{snapshots.length > 0 && (
|
||||
<Command.Group heading="Snapshots" className="text-xs text-muted-foreground px-1.5 pt-1.5">
|
||||
{snapshots.map((s) => (
|
||||
<Item
|
||||
key={s.dirName}
|
||||
value={`snapshot ${s.name} ${s.dirName}`}
|
||||
onSelect={run(() => goToTab("mods"))}
|
||||
>
|
||||
<span className="truncate">{s.name}</span>
|
||||
</Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{backups.length > 0 && (
|
||||
<Command.Group heading="Backups" className="text-xs text-muted-foreground px-1.5 pt-1.5">
|
||||
{backups.map((b) => (
|
||||
<Item
|
||||
key={b.name}
|
||||
value={`backup ${b.name}`}
|
||||
onSelect={run(() => goToTab("backups"))}
|
||||
>
|
||||
<span className="truncate">{b.name}</span>
|
||||
</Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Command.List>
|
||||
<div className="border-t border-border px-3 py-1.5 text-[10px] text-muted-foreground flex justify-between">
|
||||
<span>↑↓ navigate · ↵ select · esc close</span>
|
||||
<span aria-hidden>⌘K</span>
|
||||
</div>
|
||||
</Command>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Item({
|
||||
children,
|
||||
onSelect,
|
||||
value,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onSelect: () => void;
|
||||
value?: string;
|
||||
}) {
|
||||
return (
|
||||
<Command.Item
|
||||
onSelect={onSelect}
|
||||
value={value}
|
||||
className="flex items-center gap-2 rounded-md px-2.5 py-2 text-sm cursor-pointer data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground"
|
||||
>
|
||||
{children}
|
||||
</Command.Item>
|
||||
);
|
||||
}
|
||||
|
|
@ -157,6 +157,8 @@ export function LogViewer() {
|
|||
<button
|
||||
key={lvl}
|
||||
onClick={() => toggleLevel(lvl)}
|
||||
aria-pressed={active}
|
||||
aria-label={`${active ? "Hide" : "Show"} ${lvl} lines`}
|
||||
className={`text-[10px] font-semibold uppercase rounded px-2 py-1 border transition ${
|
||||
active
|
||||
? `${levelClass[lvl]} border-current/40 bg-muted`
|
||||
|
|
|
|||
|
|
@ -92,6 +92,46 @@ const INSTALL_STEPS_CLIENT: Pick<TimelineStep, "id" | "label">[] = [
|
|||
{ id: "modpack", label: "Update modpack" },
|
||||
];
|
||||
|
||||
const UPDATE_STEPS_SERVER: Pick<TimelineStep, "id" | "label">[] = [
|
||||
{ id: "resolve", label: "Resolve latest versions" },
|
||||
{ id: "snapshot", label: "Create snapshot" },
|
||||
{ id: "download", label: "Download updates" },
|
||||
{ id: "restart", label: "Restart server" },
|
||||
{ id: "health", label: "Verify server" },
|
||||
{ id: "modpack", label: "Update modpack" },
|
||||
];
|
||||
|
||||
async function consumeSSE(
|
||||
res: Response,
|
||||
onStep: (data: { id: string; status: TimelineStep["status"]; message?: string }) => void
|
||||
): Promise<{ success: boolean; message: string; installed?: string[]; rolledBack?: boolean }> {
|
||||
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 ev = eventMatch[1];
|
||||
const data = JSON.parse(dataMatch[1]);
|
||||
if (ev === "step") onStep(data);
|
||||
else if (ev === "done") finalResult = data;
|
||||
}
|
||||
}
|
||||
if (!finalResult) throw new Error("Stream ended without result");
|
||||
return finalResult;
|
||||
}
|
||||
|
||||
// ── Side Badge ──────────────────────────────────────────────
|
||||
|
||||
const sideConfig = {
|
||||
|
|
@ -161,6 +201,27 @@ export function ModManager() {
|
|||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||
}, [searchQuery]);
|
||||
|
||||
// Global Esc: clear confirm prompts and exit non-installing wizard steps
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
if (confirmRemove || confirmRestore || confirmDeleteSnap) {
|
||||
setConfirmRemove(null);
|
||||
setConfirmRestore(null);
|
||||
setConfirmDeleteSnap(null);
|
||||
return;
|
||||
}
|
||||
if (step === "searching" || step === "reviewing") {
|
||||
setStep("idle");
|
||||
setSelected(new Map());
|
||||
setResolved(null);
|
||||
setSearchQuery("");
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [confirmRemove, confirmRestore, confirmDeleteSnap, step]);
|
||||
|
||||
const { data: searchResults = [], isFetching: isSearching } = useQuery<SearchResult[]>({
|
||||
queryKey: ["mod-search", debouncedQuery],
|
||||
queryFn: () =>
|
||||
|
|
@ -293,6 +354,74 @@ export function ModManager() {
|
|||
},
|
||||
});
|
||||
|
||||
// Update mod(s)
|
||||
const updateMods = useMutation({
|
||||
mutationFn: async (filenames: string[]) => {
|
||||
const hasServerMod = filenames.some((f) => {
|
||||
const m = mods.find((x) => x.filename === f);
|
||||
return m ? m.side !== "client" : true;
|
||||
});
|
||||
const steps = (hasServerMod ? UPDATE_STEPS_SERVER : INSTALL_STEPS_CLIENT)
|
||||
.map((s) => ({ ...s, status: "pending" as const }));
|
||||
setTimelineSteps(steps);
|
||||
setInstallStatus("Starting update...");
|
||||
|
||||
const res = await fetch("/api/mods/update", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filenames }),
|
||||
});
|
||||
|
||||
return consumeSSE(res, (data) => {
|
||||
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
|
||||
);
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: data.id,
|
||||
label: data.id === "rollback" ? "Rollback" : data.id,
|
||||
status: data.status,
|
||||
message: data.message,
|
||||
},
|
||||
];
|
||||
});
|
||||
setInstallStatus(data.message || "");
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setInstallStatus("");
|
||||
setInstallResult(data);
|
||||
if (data.success) {
|
||||
setTimelineSteps([]);
|
||||
if (data.installed?.length) {
|
||||
setNewlyInstalled(new Set(data.installed));
|
||||
}
|
||||
toast.success(data.message || "Mods updated");
|
||||
} else {
|
||||
toast.error(data.message || "Update failed", {
|
||||
description: data.rolledBack ? "Changes rolled back." : undefined,
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["mods"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["mod-updates"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["status"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
|
||||
},
|
||||
onError: (err) => {
|
||||
setInstallStatus("");
|
||||
setTimelineSteps([]);
|
||||
setInstallResult({ success: false, message: err.message });
|
||||
toast.error("Update failed", { description: err.message });
|
||||
},
|
||||
});
|
||||
|
||||
// Remove mod
|
||||
const removeMod = useMutation({
|
||||
mutationFn: async (filename: string) => {
|
||||
|
|
@ -363,7 +492,13 @@ export function ModManager() {
|
|||
});
|
||||
}, []);
|
||||
|
||||
const isBusy = install.isPending || removeMod.isPending || restoreSnap.isPending;
|
||||
const isBusy =
|
||||
install.isPending ||
|
||||
removeMod.isPending ||
|
||||
restoreSnap.isPending ||
|
||||
updateMods.isPending;
|
||||
const showTimeline =
|
||||
(step === "installing" || updateMods.isPending) && timelineSteps.length > 0;
|
||||
|
||||
const formatDownloads = (n: number) => {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
|
|
@ -683,9 +818,9 @@ export function ModManager() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Installing (Timeline) ─────────── */}
|
||||
{step === "installing" && timelineSteps.length > 0 && (
|
||||
<div className="space-y-1.5 py-2">
|
||||
{/* ── Step 3: Installing / Updating (Timeline) ── */}
|
||||
{showTimeline && (
|
||||
<div className="space-y-1.5 py-2" role="status" aria-live="polite">
|
||||
{timelineSteps.map((s) => (
|
||||
<div key={s.id} className="flex items-start gap-3 px-1">
|
||||
<div className="mt-0.5 shrink-0">
|
||||
|
|
@ -734,8 +869,21 @@ export function ModManager() {
|
|||
<Separator />
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3 mb-3 flex-wrap">
|
||||
<h3 className="text-sm font-semibold">
|
||||
Installed Mods ({(() => {
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
{updates.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
updateMods.mutate(updates.map((u) => u.filename))
|
||||
}
|
||||
disabled={isBusy}
|
||||
className="text-xs h-8 border-amber-500/40 text-amber-300 hover:bg-amber-500/10"
|
||||
>
|
||||
Update all ({updates.length})
|
||||
</Button>
|
||||
)}
|
||||
<span>Installed Mods ({(() => {
|
||||
const q = installedQuery.trim().toLowerCase();
|
||||
const count = mods.filter((m) =>
|
||||
(sideFilter === "all" || m.side === sideFilter) &&
|
||||
|
|
@ -745,7 +893,7 @@ export function ModManager() {
|
|||
m.filename.toLowerCase().includes(q))
|
||||
).length;
|
||||
return count === mods.length ? mods.length : `${count} / ${mods.length}`;
|
||||
})()})
|
||||
})()})</span>
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex gap-1">
|
||||
|
|
@ -753,6 +901,8 @@ export function ModManager() {
|
|||
<button
|
||||
key={s}
|
||||
onClick={() => setSideFilter(s)}
|
||||
aria-pressed={sideFilter === s}
|
||||
aria-label={`Filter by ${s}`}
|
||||
className={`text-[10px] font-semibold uppercase rounded px-2 py-1 border transition ${
|
||||
sideFilter === s
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
|
|
@ -843,15 +993,28 @@ export function ModManager() {
|
|||
</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>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{updateMap.has(mod.filename) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => updateMods.mutate([mod.filename])}
|
||||
disabled={isBusy}
|
||||
className="text-xs h-9 border-amber-500/40 text-amber-300 hover:bg-amber-500/10"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
<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"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ export function Navbar() {
|
|||
|
||||
return (
|
||||
<header className="border-b border-border bg-card">
|
||||
<a
|
||||
href="#main"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded focus:bg-primary focus:text-primary-foreground focus:px-3 focus:py-1.5 focus:text-sm focus:font-medium"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<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
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ type VersionData = {
|
|||
dependencies: VersionDep[];
|
||||
};
|
||||
|
||||
async function getLatestVersion(
|
||||
export async function getLatestVersion(
|
||||
projectId: string
|
||||
): Promise<VersionData | null> {
|
||||
const url = `${API}/project/${projectId}/version?game_versions=["${GAME_VERSION}"]&loaders=["${LOADER}"]`;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"adm-zip": "^0.5.17",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"minecraft-server-util": "^5.4.4",
|
||||
"next": "16.2.3",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue