aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/ui/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'packages/ui/src/components')
-rw-r--r--packages/ui/src/components/download-monitor.tsx186
-rw-r--r--packages/ui/src/components/instance-creation-modal.tsx200
2 files changed, 295 insertions, 91 deletions
diff --git a/packages/ui/src/components/download-monitor.tsx b/packages/ui/src/components/download-monitor.tsx
index f3902d9..6916ca5 100644
--- a/packages/ui/src/components/download-monitor.tsx
+++ b/packages/ui/src/components/download-monitor.tsx
@@ -1,62 +1,160 @@
-import { X } from "lucide-react";
-import { useState } from "react";
+import { CheckCircle, Download, Loader2, Package, XCircle } from "lucide-react";
+import { useDownloadStore } from "@/stores/download-store";
-export function DownloadMonitor() {
- const [isVisible, setIsVisible] = useState(true);
+function formatBytes(bytes: number): string {
+ if (bytes === 0) return "0 B";
+ const units = ["B", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+ const value = bytes / 1024 ** i;
+ return `${value.toFixed(value < 10 ? 1 : 0)} ${units[i]}`;
+}
+
+function shortenFileName(path: string): string {
+ const parts = path.replace(/\\/g, "/").split("/");
+ return parts[parts.length - 1] || path;
+}
+
+/**
+ * Inline progress display for use inside dialogs or pages.
+ * Reads from the global download store.
+ */
+export function DownloadProgress() {
+ const {
+ phase,
+ totalFiles,
+ completedFiles,
+ currentFile,
+ currentFileStatus,
+ currentFileDownloaded,
+ currentFileTotal,
+ totalDownloadedBytes,
+ errorMessage,
+ phaseLabel,
+ } = useDownloadStore();
+
+ if (phase === "idle") return null;
- if (!isVisible) return null;
+ const overallPercent =
+ totalFiles > 0 ? Math.round((completedFiles / totalFiles) * 100) : 0;
+
+ const filePercent =
+ currentFileTotal > 0
+ ? Math.round((currentFileDownloaded / currentFileTotal) * 100)
+ : 0;
return (
- <div className="bg-zinc-900/80 backdrop-blur-md border border-zinc-700 rounded-lg shadow-2xl overflow-hidden">
- {/* Header */}
- <div className="flex items-center justify-between px-4 py-3 bg-zinc-800/50 border-b border-zinc-700">
- <div className="flex items-center gap-2">
- <div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></div>
- <span className="text-sm font-medium text-white">Downloads</span>
- </div>
- <button
- type="button"
- onClick={() => setIsVisible(false)}
- className="text-zinc-400 hover:text-white transition-colors p-1"
- >
- <X size={16} />
- </button>
+ <div className="space-y-4 min-w-0 overflow-hidden tabular-nums">
+ {/* Phase header */}
+ <div className="flex items-center gap-2 min-w-0">
+ {phase === "preparing" && (
+ <Loader2 className="h-4 w-4 shrink-0 text-indigo-400 animate-spin" />
+ )}
+ {phase === "downloading" && (
+ <Download className="h-4 w-4 shrink-0 text-indigo-400 animate-pulse" />
+ )}
+ {phase === "finalizing" && (
+ <Loader2 className="h-4 w-4 shrink-0 text-indigo-400 animate-spin" />
+ )}
+ {phase === "installing-mod-loader" && (
+ <Package className="h-4 w-4 shrink-0 text-indigo-400 animate-pulse" />
+ )}
+ {phase === "completed" && (
+ <CheckCircle className="h-4 w-4 shrink-0 text-emerald-400" />
+ )}
+ {phase === "error" && (
+ <XCircle className="h-4 w-4 shrink-0 text-red-400" />
+ )}
+ <span className="text-sm font-medium truncate">{phaseLabel}</span>
</div>
- {/* Content */}
- <div className="p-4">
- <div className="space-y-3">
- {/* Download Item */}
+ {/* Preparing phase — no file counts yet */}
+ {phase === "preparing" && (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Loader2 className="h-3 w-3 shrink-0 animate-spin" />
+ <span className="truncate">Resolving version and assets...</span>
+ </div>
+ )}
+
+ {/* Overall progress */}
+ {phase === "downloading" && totalFiles > 0 && (
+ <div className="space-y-2 min-w-0">
+ {/* Overall bar */}
<div className="space-y-1">
- <div className="flex justify-between text-xs">
- <span className="text-zinc-300">Minecraft 1.20.4</span>
- <span className="text-zinc-400">65%</span>
+ <div className="flex justify-between text-xs text-muted-foreground">
+ <span>
+ Overall: {completedFiles} / {totalFiles} files
+ </span>
+ <span className="shrink-0 ml-2 w-10 text-right">
+ {overallPercent}%
+ </span>
</div>
- <div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
+ <div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
- className="h-full bg-emerald-500 rounded-full transition-all duration-300"
- style={{ width: "65%" }}
- ></div>
+ className="h-full bg-indigo-500 rounded-full transition-all duration-300"
+ style={{ width: `${overallPercent}%` }}
+ />
</div>
- <div className="flex justify-between text-[10px] text-zinc-500">
- <span>142 MB / 218 MB</span>
- <span>2.1 MB/s • 36s remaining</span>
+ <div className="text-xs text-muted-foreground">
+ {formatBytes(totalDownloadedBytes)} downloaded
</div>
</div>
- {/* Download Item */}
- <div className="space-y-1">
- <div className="flex justify-between text-xs">
- <span className="text-zinc-300">Java 17</span>
- <span className="text-zinc-400">100%</span>
- </div>
- <div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
- <div className="h-full bg-emerald-500 rounded-full"></div>
- </div>
- <div className="text-[10px] text-emerald-400">Completed</div>
+ {/* Current file — always reserve space to avoid layout shifts */}
+ <div className="min-h-[2.5rem] border-t border-border pt-2 min-w-0">
+ {currentFile && currentFileStatus !== "Finished" && (
+ <div className="space-y-1">
+ <div className="flex items-center gap-2 text-xs">
+ <span className="text-muted-foreground truncate w-0 grow">
+ {shortenFileName(currentFile)}
+ </span>
+ <span className="text-muted-foreground shrink-0 w-16 text-right">
+ {currentFileStatus === "Downloading"
+ ? `${filePercent}%`
+ : currentFileStatus}
+ </span>
+ </div>
+ {currentFileStatus === "Downloading" &&
+ currentFileTotal > 0 && (
+ <div className="h-1 bg-secondary rounded-full overflow-hidden">
+ <div
+ className="h-full bg-indigo-400/60 rounded-full transition-all duration-150"
+ style={{ width: `${filePercent}%` }}
+ />
+ </div>
+ )}
+ </div>
+ )}
</div>
</div>
- </div>
+ )}
+
+ {/* Finalizing phase — downloads done, waiting for install to finish */}
+ {phase === "finalizing" && (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Loader2 className="h-3 w-3 shrink-0 animate-spin" />
+ <span className="truncate">Verifying installation...</span>
+ </div>
+ )}
+
+ {/* Mod loader install phase */}
+ {phase === "installing-mod-loader" && (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
+ <Loader2 className="h-3 w-3 shrink-0 animate-spin" />
+ <span className="truncate">{phaseLabel}</span>
+ </div>
+ )}
+
+ {/* Error */}
+ {phase === "error" && errorMessage && (
+ <div className="text-sm text-red-400 break-words">{errorMessage}</div>
+ )}
+
+ {/* Completed */}
+ {phase === "completed" && (
+ <div className="text-sm text-emerald-400">
+ Successfully installed {totalFiles > 0 ? `${totalFiles} files` : ""}
+ </div>
+ )}
</div>
);
}
diff --git a/packages/ui/src/components/instance-creation-modal.tsx b/packages/ui/src/components/instance-creation-modal.tsx
index 7c46d0f..5b50513 100644
--- a/packages/ui/src/components/instance-creation-modal.tsx
+++ b/packages/ui/src/components/instance-creation-modal.tsx
@@ -8,6 +8,7 @@ import {
installForge,
installVersion,
} from "@/client";
+import { DownloadProgress } from "@/components/download-monitor";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -20,6 +21,7 @@ import {
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useInstanceStore } from "@/models/instance";
+import { useDownloadStore } from "@/stores/download-store";
import { useGameStore } from "@/stores/game-store";
import type {
FabricLoaderEntry,
@@ -35,8 +37,9 @@ interface Props {
export function InstanceCreationModal({ open, onOpenChange }: Props) {
const gameStore = useGameStore();
const instancesStore = useInstanceStore();
+ const downloadStore = useDownloadStore();
- // Steps: 1 = name, 2 = version, 3 = mod loader
+ // Steps: 1 = name, 2 = version, 3 = mod loader, 4 = installing
const [step, setStep] = useState<number>(1);
// Step 1
@@ -61,6 +64,9 @@ export function InstanceCreationModal({ open, onOpenChange }: Props) {
const [selectedForgeLoader, setSelectedForgeLoader] = useState<string>("");
const [loadingLoaders, setLoadingLoaders] = useState(false);
+ // Step 4 - installing
+ const [installFinished, setInstallFinished] = useState(false);
+
const loadModLoaders = useCallback(async () => {
if (!selectedVersionUI) return;
setLoadingLoaders(true);
@@ -79,7 +85,6 @@ export function InstanceCreationModal({ open, onOpenChange }: Props) {
const versions = await getForgeVersionsForGame(selectedVersionUI.id);
setForgeVersions(versions || []);
if (versions && versions.length > 0) {
- // Binding `ForgeVersion` uses `version` (not `id`) — use `.version` here.
setSelectedForgeLoader(versions[0].version);
} else {
setSelectedForgeLoader("");
@@ -118,6 +123,18 @@ export function InstanceCreationModal({ open, onOpenChange }: Props) {
return list;
}, [gameStore.versions, versionFilter, versionSearch]);
+ // Initialize download store event listeners when modal opens
+ useEffect(() => {
+ if (open) {
+ downloadStore.init();
+ }
+ return () => {
+ if (!open) {
+ downloadStore.cleanup();
+ }
+ };
+ }, [open, downloadStore.init, downloadStore.cleanup]);
+
// Reset when opened/closed
useEffect(() => {
if (open) {
@@ -135,8 +152,10 @@ export function InstanceCreationModal({ open, onOpenChange }: Props) {
setSelectedForgeLoader("");
setErrorMessage("");
setCreating(false);
+ setInstallFinished(false);
+ downloadStore.reset();
}
- }, [open, gameStore.loadVersions]);
+ }, [open, gameStore.loadVersions, downloadStore.reset]);
function validateStep1(): boolean {
if (!instanceName.trim()) {
@@ -176,6 +195,11 @@ export function InstanceCreationModal({ open, onOpenChange }: Props) {
if (!validateStep1() || !validateStep2()) return;
setCreating(true);
setErrorMessage("");
+ setInstallFinished(false);
+
+ // Move to step 4 (installing)
+ setStep(4);
+ downloadStore.reset();
try {
// Step 1: create instance
@@ -183,83 +207,127 @@ export function InstanceCreationModal({ open, onOpenChange }: Props) {
// If selectedVersion provided, install it
if (selectedVersionUI && instance) {
+ // Show preparing phase while Rust resolves version JSON, fetches
+ // asset index, and builds the download task list.
+ downloadStore.setPhase(
+ "preparing",
+ `Preparing Minecraft ${selectedVersionUI.id}...`,
+ );
try {
- await installVersion(instance?.id, selectedVersionUI.id);
+ await installVersion(instance.id, selectedVersionUI.id);
} catch (err) {
console.error("Failed to install base version:", err);
- // continue - instance created but version install failed
toast.error(
`Failed to install version ${selectedVersionUI.id}: ${String(err)}`,
);
+ downloadStore.setError(`Failed to install version: ${String(err)}`);
+ setInstallFinished(true);
+ setCreating(false);
+ return;
}
}
// If mod loader selected, install it
if (modLoaderType === "fabric" && selectedFabricLoader && instance) {
+ downloadStore.setPhase(
+ "installing-mod-loader",
+ `Installing Fabric ${selectedFabricLoader}...`,
+ );
try {
await installFabric(
- instance?.id,
+ instance.id,
selectedVersionUI?.id ?? "",
selectedFabricLoader,
);
} catch (err) {
console.error("Failed to install Fabric:", err);
toast.error(`Failed to install Fabric: ${String(err)}`);
+ downloadStore.setError(`Failed to install Fabric: ${String(err)}`);
+ setInstallFinished(true);
+ setCreating(false);
+ return;
}
} else if (modLoaderType === "forge" && selectedForgeLoader && instance) {
+ downloadStore.setPhase(
+ "installing-mod-loader",
+ `Installing Forge ${selectedForgeLoader}...`,
+ );
try {
await installForge(
- instance?.id,
+ instance.id,
selectedVersionUI?.id ?? "",
selectedForgeLoader,
);
} catch (err) {
console.error("Failed to install Forge:", err);
toast.error(`Failed to install Forge: ${String(err)}`);
+ downloadStore.setError(`Failed to install Forge: ${String(err)}`);
+ setInstallFinished(true);
+ setCreating(false);
+ return;
}
}
// Refresh instances list
await instancesStore.refresh();
+ downloadStore.setPhase("completed", "Installation complete");
+ setInstallFinished(true);
toast.success("Instance created successfully");
- onOpenChange(false);
} catch (e) {
console.error("Failed to create instance:", e);
setErrorMessage(String(e));
+ downloadStore.setError(String(e));
toast.error(`Failed to create instance: ${e}`);
+ setInstallFinished(true);
} finally {
setCreating(false);
}
}
// UI pieces
+ const stepKeys = ["name", "version", "loader", "install"] as const;
const StepIndicator = () => (
<div className="flex gap-2 w-full">
- <div
- className={`flex-1 h-1 rounded ${step >= 1 ? "bg-indigo-500" : "bg-zinc-700"}`}
- />
- <div
- className={`flex-1 h-1 rounded ${step >= 2 ? "bg-indigo-500" : "bg-zinc-700"}`}
- />
- <div
- className={`flex-1 h-1 rounded ${step >= 3 ? "bg-indigo-500" : "bg-zinc-700"}`}
- />
+ {stepKeys.map((key, i) => (
+ <div
+ key={key}
+ className={`flex-1 h-1 rounded ${step >= i + 1 ? "bg-indigo-500" : "bg-zinc-700"}`}
+ />
+ ))}
</div>
);
+ const stepTitles: Record<number, string> = {
+ 1: "Create New Instance",
+ 2: "Select Version",
+ 3: "Mod Loader",
+ 4: "Installing",
+ };
+
+ const stepDescriptions: Record<number, string> = {
+ 1: "Give your instance a name to get started.",
+ 2: "Choose a Minecraft version to install.",
+ 3: "Optionally select a mod loader.",
+ 4: "Downloading and installing game files...",
+ };
+
return (
- <Dialog open={open} onOpenChange={onOpenChange}>
+ <Dialog
+ open={open}
+ onOpenChange={(newOpen) => {
+ // Prevent closing during active installation
+ if (step === 4 && !installFinished) return;
+ onOpenChange(newOpen);
+ }}
+ >
<DialogContent className="w-full max-w-3xl max-h-[90vh] overflow-hidden">
<DialogHeader>
- <DialogTitle>Create New Instance</DialogTitle>
- <DialogDescription>
- Multi-step wizard — create an instance and optionally install a
- version or mod loader.
- </DialogDescription>
+ <DialogTitle>{stepTitles[step]}</DialogTitle>
+ <DialogDescription>{stepDescriptions[step]}</DialogDescription>
</DialogHeader>
- <div className="px-6">
+ <div className="px-6 overflow-hidden">
<div className="pt-4 pb-6">
<StepIndicator />
</div>
@@ -463,7 +531,6 @@ export function InstanceCreationModal({ open, onOpenChange }: Props) {
className="w-full px-3 py-2 rounded border bg-transparent"
>
{forgeVersions.map((f) => (
- // binding ForgeVersion uses `version` as the identifier
<option key={f.version} value={f.version}>
{f.version}
</option>
@@ -480,7 +547,39 @@ export function InstanceCreationModal({ open, onOpenChange }: Props) {
</div>
)}
- {errorMessage && (
+ {/* Step 4 - Installing with progress */}
+ {step === 4 && (
+ <div className="space-y-4 py-2">
+ {/* Summary of what's being installed */}
+ <div className="rounded-lg border border-border bg-card/50 p-3 space-y-1">
+ <div className="text-xs text-muted-foreground">Instance</div>
+ <div className="text-sm font-medium">{instanceName}</div>
+ <div className="text-xs text-muted-foreground mt-2">
+ Version
+ </div>
+ <div className="text-sm font-mono">{selectedVersionUI?.id}</div>
+ {modLoaderType !== "vanilla" && (
+ <>
+ <div className="text-xs text-muted-foreground mt-2">
+ Mod Loader
+ </div>
+ <div className="text-sm">
+ {modLoaderType === "fabric"
+ ? `Fabric ${selectedFabricLoader}`
+ : `Forge ${selectedForgeLoader}`}
+ </div>
+ </>
+ )}
+ </div>
+
+ {/* Download progress */}
+ <div className="rounded-lg border border-border bg-card/50 p-4">
+ <DownloadProgress />
+ </div>
+ </div>
+ )}
+
+ {errorMessage && step !== 4 && (
<div className="text-sm text-red-400 mt-3">{errorMessage}</div>
)}
</div>
@@ -488,21 +587,29 @@ export function InstanceCreationModal({ open, onOpenChange }: Props) {
<DialogFooter>
<div className="w-full flex justify-between items-center">
<div>
- <Button
- type="button"
- variant="ghost"
- onClick={() => {
- // cancel
- onOpenChange(false);
- }}
- disabled={creating}
- >
- Cancel
- </Button>
+ {step === 4 ? (
+ <Button
+ type="button"
+ variant="ghost"
+ onClick={() => onOpenChange(false)}
+ disabled={!installFinished}
+ >
+ {installFinished ? "Close" : "Installing..."}
+ </Button>
+ ) : (
+ <Button
+ type="button"
+ variant="ghost"
+ onClick={() => onOpenChange(false)}
+ disabled={creating}
+ >
+ Cancel
+ </Button>
+ )}
</div>
<div className="flex gap-2">
- {step > 1 && (
+ {step > 1 && step < 4 && (
<Button
type="button"
variant="outline"
@@ -517,21 +624,20 @@ export function InstanceCreationModal({ open, onOpenChange }: Props) {
<Button type="button" onClick={handleNext} disabled={creating}>
Next
</Button>
- ) : (
+ ) : step === 3 ? (
<Button
type="button"
onClick={handleCreate}
disabled={creating}
>
- {creating ? (
- <>
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
- Creating...
- </>
- ) : (
- "Create"
- )}
+ Create
</Button>
+ ) : (
+ installFinished && (
+ <Button type="button" onClick={() => onOpenChange(false)}>
+ Done
+ </Button>
+ )
)}
</div>
</div>