- 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>
1172 lines
45 KiB
TypeScript
1172 lines
45 KiB
TypeScript
"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" },
|
||
];
|
||
|
||
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]);
|
||
|
||
// 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("");
|
||
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 });
|
||
},
|
||
});
|
||
|
||
// 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;
|
||
});
|
||
}, []);
|
||
|
||
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)}
|
||
role="button"
|
||
aria-label={`Unselect ${s.title}`}
|
||
>
|
||
{s.title}
|
||
<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>
|
||
)}
|
||
|
||
{/* ── 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">
|
||
<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}`;
|
||
})()})</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)}
|
||
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>
|
||
) : (
|
||
<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>
|
||
);
|
||
}
|