mc-dashboard/components/ModManager.tsx

1173 lines
45 KiB
TypeScript
Raw Normal View History

"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useState, useCallback, useRef, useEffect } from "react";
import Image from "next/image";
import { toast } from "sonner";
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 { timeAgo, formatBytes } from "@/lib/time";
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;
author: string;
date_modified: string;
follows: number;
};
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[];
sizeBytes?: number;
};
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" },
];
2026-04-13 05:30:23 -06:00
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 = {
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 [confirmDeleteSnap, setConfirmDeleteSnap] = useState<string | null>(null);
const [installedQuery, setInstalledQuery] = useState("");
const [sideFilter, setSideFilter] = useState<"all" | ModSide>("all");
const [typedConfirm, setTypedConfirm] = useState("");
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,
});
// Updates available (from Modrinth)
const { data: updates = [] } = useQuery<{ filename: string; latestFilename: string; dateModified: string }[]>({
queryKey: ["mod-updates"],
queryFn: () =>
fetch("/api/mods/updates").then((r) => (r.ok ? r.json() : [])),
staleTime: 15 * 60 * 1000,
refetchOnMount: false,
});
const updateMap = new Map(updates.map((u) => [u.filename, u]));
// 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]);
2026-04-13 05:30:23 -06:00
// 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);
setTypedConfirm("");
2026-04-13 05:30:23 -06:00
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: () =>
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));
}
toast.success(data.message || "Mods installed");
} else {
toast.error(data.message || "Install failed", {
description: data.rolledBack ? "Changes rolled back." : undefined,
});
}
queryClient.invalidateQueries({ queryKey: ["mods"] });
queryClient.invalidateQueries({ queryKey: ["status"] });
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
},
onError: (err) => {
setInstallStatus("");
setTimelineSteps([]);
setInstallResult({ success: false, message: err.message });
toast.error("Install failed", { description: err.message });
},
});
2026-04-13 05:30:23 -06:00
// 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) => {
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);
(data.success ? toast.success : toast.error)(data.message || "Mod removed");
queryClient.invalidateQueries({ queryKey: ["mods"] });
queryClient.invalidateQueries({ queryKey: ["status"] });
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
},
onError: (err) => toast.error("Remove failed", { description: err.message }),
});
// 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);
(data.success ? toast.success : toast.error)(data.message || "Snapshot restored");
queryClient.invalidateQueries({ queryKey: ["mods"] });
queryClient.invalidateQueries({ queryKey: ["status"] });
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
},
onError: (err) => toast.error("Restore failed", { description: err.message }),
});
// 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: () => {
toast.success("Snapshot deleted");
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
},
onError: (err) => toast.error("Delete failed", { description: err.message }),
});
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;
});
}, []);
2026-04-13 05:30:23 -06:00
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`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return n.toString();
};
return (
<div className="space-y-4 sm: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)}
Bundle analyzer, task run-now/toggle/weekly, error reporter, OG image, a11y - Bundle analyzer: @next/bundle-analyzer wired through next.config.ts, new `bun run analyze` script (ANALYZE=true next build). - Scheduled tasks gain: - enabled flag round-tripped via a #DISABLED prefix on the crontab line (preserves the mc-task marker + payload for re-enable). - PATCH /api/schedule/tasks to toggle enabled. - POST /api/schedule/tasks/run to execute a task immediately via the same buildCommand used for the cron line (60s timeout, kills child on client abort). - Weekly preset in the UI (day-of-week selector), broader aria-labels on all form selects. Human-readable cron renders "Tue at 04:30" and next-run calc accounts for weekly. - Hover-reveal Run now / Enable / Disable / Remove actions; disabled tasks render at 60% opacity with a "disabled" badge and no next-run. - /api/errors: minimal append-only JSONL reporter (200ms throttle, UA and IP captured, fields length-bounded). Falls back to a stable path under /home/minecraft/logs/ and never fails the client on logging errors. - ErrorReporter client (production-only) listens to window.error and unhandledrejection, fingerprint-dedups via a bounded in-memory set, sends with keepalive so unloads still flush. - app/opengraph-image.tsx: 1200x630 dynamic PNG with live status dot (green/red), player count, address. 60s memo on the status probe. - A11y: aria-labels on log-line count select, scheduled-restart hour and minute selects, copy-server-address button (plus focus-visible ring). ModManager selected-mod pill upgraded to role=button with a real accessible name and a proper × glyph. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 06:08:48 -06:00
role="button"
aria-label={`Unselect ${s.title}`}
>
{s.title}
Bundle analyzer, task run-now/toggle/weekly, error reporter, OG image, a11y - Bundle analyzer: @next/bundle-analyzer wired through next.config.ts, new `bun run analyze` script (ANALYZE=true next build). - Scheduled tasks gain: - enabled flag round-tripped via a #DISABLED prefix on the crontab line (preserves the mc-task marker + payload for re-enable). - PATCH /api/schedule/tasks to toggle enabled. - POST /api/schedule/tasks/run to execute a task immediately via the same buildCommand used for the cron line (60s timeout, kills child on client abort). - Weekly preset in the UI (day-of-week selector), broader aria-labels on all form selects. Human-readable cron renders "Tue at 04:30" and next-run calc accounts for weekly. - Hover-reveal Run now / Enable / Disable / Remove actions; disabled tasks render at 60% opacity with a "disabled" badge and no next-run. - /api/errors: minimal append-only JSONL reporter (200ms throttle, UA and IP captured, fields length-bounded). Falls back to a stable path under /home/minecraft/logs/ and never fails the client on logging errors. - ErrorReporter client (production-only) listens to window.error and unhandledrejection, fingerprint-dedups via a bounded in-memory set, sends with keepalive so unloads still flush. - app/opengraph-image.tsx: 1200x630 dynamic PNG with live status dot (green/red), player count, address. 60s memo on the status probe. - A11y: aria-labels on log-line count select, scheduled-restart hour and minute selects, copy-server-address button (plus focus-visible ring). ModManager selected-mod pill upgraded to role=button with a real accessible name and a proper × glyph. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 06:08:48 -06:00
<span aria-hidden className="text-muted-foreground">×</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 ? (
<Image
src={result.icon_url}
alt=""
width={40}
height={40}
unoptimized
className="w-10 h-10 rounded-md shrink-0 bg-muted object-cover"
/>
) : (
<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 flex-wrap">
<span className="text-sm font-medium truncate">
{result.title}
</span>
{result.author && (
<span className="text-xs text-muted-foreground shrink-0">
by {result.author}
</span>
)}
</div>
<p className="text-xs text-muted-foreground truncate">
{result.description}
</p>
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-muted-foreground/80 tabular-nums">
<span>{formatDownloads(result.downloads)} downloads</span>
{result.date_modified && (
<>
<span aria-hidden>·</span>
<span title={new Date(result.date_modified).toLocaleString()}>
updated {timeAgo(result.date_modified)}
</span>
</>
)}
</div>
</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>
)}
2026-04-13 05:30:23 -06:00
{/* ── 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">
{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>
<div className="flex items-center justify-between gap-3 mb-3 flex-wrap">
2026-04-13 05:30:23 -06:00
<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) &&
(!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}`;
2026-04-13 05:30:23 -06:00
})()})</span>
</h3>
<div className="flex items-center gap-2 flex-wrap">
<div className="flex gap-1">
{(["all", "both", "server", "client"] as const).map((s) => (
<button
key={s}
onClick={() => setSideFilter(s)}
2026-04-13 05:30:23 -06:00
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"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{s}
</button>
))}
</div>
<Input
placeholder="Search installed..."
value={installedQuery}
onChange={(e) => setInstalledQuery(e.target.value)}
className="h-8 text-sm max-w-[200px]"
/>
</div>
</div>
<ul className="max-h-[400px] overflow-y-auto space-y-1">
{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) => (
<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>
)}
{updateMap.has(mod.filename) && (
<Badge
variant="outline"
className="text-xs px-1.5 py-0 border-amber-500/40 text-amber-300"
title={`Latest: ${updateMap.get(mod.filename)?.latestFilename}`}
>
Update available
</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>
) : (
2026-04-13 05:30:23 -06:00
<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>
))}
</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) => {
const pending =
confirmRestore === snap.dirName || confirmDeleteSnap === snap.dirName;
const mode = confirmRestore === snap.dirName ? "restore" : "delete";
const matched = typedConfirm === snap.name;
return (
<li
key={snap.dirName}
className="rounded-md bg-muted/50 group"
>
<div className="flex items-center justify-between gap-2 px-3 py-2.5">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{snap.name}</span>
<Badge variant="secondary" className="text-xs px-1.5 py-0">
{snap.modCount} mods
</Badge>
{typeof snap.sizeBytes === "number" && snap.sizeBytes > 0 && (
<Badge variant="secondary" className="text-xs px-1.5 py-0">
{formatBytes(snap.sizeBytes)}
</Badge>
)}
</div>
<p
className="text-xs text-muted-foreground"
title={new Date(snap.createdAt).toLocaleString()}
>
{timeAgo(snap.createdAt)}
</p>
</div>
<div className="flex gap-1 shrink-0 flex-wrap justify-end">
{pending ? (
<Button
size="sm"
variant="ghost"
onClick={() => {
setConfirmRestore(null);
setConfirmDeleteSnap(null);
setTypedConfirm("");
}}
className="text-xs h-9"
>
Cancel
</Button>
) : (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
setConfirmRestore(snap.dirName);
setConfirmDeleteSnap(null);
setTypedConfirm("");
}}
disabled={isBusy}
className="text-xs h-9 sm:opacity-0 sm:group-hover:opacity-100 transition"
>
Restore
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setConfirmDeleteSnap(snap.dirName);
setConfirmRestore(null);
setTypedConfirm("");
}}
disabled={isBusy}
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
>
Delete
</Button>
</>
)}
</div>
</div>
{pending && (
<div className="px-3 pb-3 pt-0 border-t border-border/60">
<p className="text-xs text-muted-foreground mt-2 mb-1.5">
Type <span className="font-mono text-foreground">{snap.name}</span> to{" "}
{mode === "restore" ? "confirm restore" : "confirm delete"}
</p>
<div className="flex gap-2">
<Input
value={typedConfirm}
autoFocus
onChange={(e) => setTypedConfirm(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && matched) {
if (mode === "restore") restoreSnap.mutate(snap.dirName);
else {
deleteSnap.mutate(snap.dirName);
setConfirmDeleteSnap(null);
setTypedConfirm("");
}
}
}}
placeholder={snap.name}
className="h-9 text-sm flex-1 font-mono"
/>
<Button
size="sm"
variant={mode === "restore" ? "default" : "destructive"}
onClick={() => {
if (mode === "restore") restoreSnap.mutate(snap.dirName);
else {
deleteSnap.mutate(snap.dirName);
setConfirmDeleteSnap(null);
setTypedConfirm("");
}
}}
disabled={!matched || isBusy}
className="text-xs h-9"
>
{mode === "restore" ? "Confirm Restore" : "Confirm Delete"}
</Button>
</div>
</div>
)}
</li>
);
})}
</ul>
</CardContent>
</Card>
)}
</div>
);
}