"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[] = [ { 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[] = [ { 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 ( {config.label} ); } // ── Component ─────────────────────────────────────────────── export function ModManager() { const queryClient = useQueryClient(); const [step, setStep] = useState("idle"); const [searchQuery, setSearchQuery] = useState(""); const [selected, setSelected] = useState>(new Map()); const [resolved, setResolved] = useState(null); const [installStatus, setInstallStatus] = useState(""); const [timelineSteps, setTimelineSteps] = useState([]); const [newlyInstalled, setNewlyInstalled] = useState>(new Set()); const [installResult, setInstallResult] = useState<{ success: boolean; message: string; rolledBack?: boolean; } | null>(null); const [confirmRemove, setConfirmRemove] = useState(null); const [confirmRestore, setConfirmRestore] = useState(null); const [confirmDeleteSnap, setConfirmDeleteSnap] = useState(null); const [installedQuery, setInstalledQuery] = useState(""); const [sideFilter, setSideFilter] = useState<"all" | ModSide>("all"); const debounceRef = useRef>(null); // Installed mods const { data: mods = [] } = useQuery({ 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({ 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({ 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 = {}; 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 (
{/* ── Mod Manager Card ──────────────────────────────── */}
Mod Manager Search Modrinth, auto-resolve dependencies, install with rollback safety
{step === "idle" && ( )} {step !== "idle" && step !== "installing" && ( )}
{/* Step indicator */} {step !== "idle" && (
    {[ { 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 (
  1. {thisIdx + 1} {s.label} {i < arr.length - 1 && ( )}
  2. ); })}
)} {/* Result feedback */} {installResult && !isBusy && step === "idle" && ( {installResult.message} {installResult.rolledBack && ( Changes were automatically rolled back. )} )} {/* ── Step 1: Search & Select ─────────────────── */} {step === "searching" && (
setSearchQuery(e.target.value)} autoFocus /> {/* Selected queue */} {selected.size > 0 && (
{Array.from(selected.values()).map((s) => ( toggleSelect(s)} > {s.title} x ))}
)} {/* Search results */} {isSearching && (

Searching...

)} {debouncedQuery.length >= 2 && (
    {searchResults.map((result) => { const isSelected = selected.has(result.project_id); return (
  • 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 ? ( ) : (
    )}
    {result.title} {formatDownloads(result.downloads)} downloads

    {result.description}

    {isSelected && ( Selected )}
  • ); })}
)} {/* Next button */} {selected.size > 0 && (
)}
)} {/* ── Step 2: Review & Validate ───────────────── */} {step === "reviewing" && resolved && (
{/* Conflicts */} {resolved.conflicts.length > 0 && ( Issues found:
    {resolved.conflicts.map((c, i) => (
  • {c}
  • ))}
)} {/* Mods to install */} {resolved.toInstall.length > 0 && (

Will be installed ({resolved.toInstall.length})

    {resolved.toInstall.map((mod) => (
  • {mod.title} {mod.isDependency && ( dependency )}

    {mod.filename}

  • ))}
)} {/* Skipped (already installed) */} {resolved.skipped.length > 0 && (

Already installed (skipped)

    {resolved.skipped.map((mod) => (
  • {mod.title} installed
  • ))}
)} {/* Actions */}
)} {/* ── Step 3: Installing (Timeline) ─────────── */} {step === "installing" && timelineSteps.length > 0 && (
{timelineSteps.map((s) => (
{s.status === "done" && ( )} {s.status === "active" && ( )} {s.status === "error" && ( )} {s.status === "pending" && ( )}

{s.label}

{s.message && s.status === "active" && (

{s.message}

)} {s.message && s.status === "error" && (

{s.message}

)}
))}

Do not close this page.

)} {/* ── Installed mods list ─────────────────────── */} {step === "idle" && ( <>

Installed Mods ({(() => { const q = installedQuery.trim().toLowerCase(); const count = mods.filter((m) => (sideFilter === "all" || m.side === sideFilter) && (!q || m.displayName.toLowerCase().includes(q) || m.modId.toLowerCase().includes(q) || m.filename.toLowerCase().includes(q)) ).length; return count === mods.length ? mods.length : `${count} / ${mods.length}`; })()})

{(["all", "both", "server", "client"] as const).map((s) => ( ))}
setInstalledQuery(e.target.value)} className="h-8 text-sm max-w-[200px]" />
    {mods .filter((m) => { if (sideFilter !== "all" && m.side !== sideFilter) return false; const q = installedQuery.trim().toLowerCase(); if (!q) return true; return ( m.displayName.toLowerCase().includes(q) || m.modId.toLowerCase().includes(q) || m.filename.toLowerCase().includes(q) ); }) .map((mod) => (
  • {mod.displayName} {mod.version && ( {mod.version} )} {newlyInstalled.has(mod.filename) && ( New )}

    {mod.filename} — {mod.size}

    {confirmRemove === mod.filename ? (
    ) : ( )}
  • ))}
)}
{/* ── Snapshots Card ────────────────────────────────── */} {step === "idle" && snapshots.length > 0 && ( Snapshots Restore a previous mod configuration
    {snapshots.map((snap) => (
  • {snap.name} {snap.modCount} mods

    {new Date(snap.createdAt).toLocaleString()}

    {confirmRestore === snap.dirName ? ( <> ) : confirmDeleteSnap === snap.dirName ? ( <> ) : ( <> )}
  • ))}
)}
); }