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>
844 lines
31 KiB
TypeScript
844 lines
31 KiB
TypeScript
"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>
|
|
);
|
|
}
|