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:
commit
dd69c17c3b
77 changed files with 7007 additions and 0 deletions
844
components/ModManager.tsx
Normal file
844
components/ModManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue