mc-dashboard/components/ModManager.tsx
hurkicorgi 7ada9ec7d9 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

1172 lines
45 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}