aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/ui/src/components/instance-creation-modal.tsx
diff options
context:
space:
mode:
authorJunyan Qin <rockchinq@gmail.com>2026-03-21 00:09:52 +0800
committerJunyan Qin <rockchinq@gmail.com>2026-03-21 00:34:09 +0800
commit5f93323760fb46f06d391996f17e87b7405769d4 (patch)
tree09f41d648c9baa31390cd41115cb2810fd04a139 /packages/ui/src/components/instance-creation-modal.tsx
parente356ab996ad3ef4ad5fb9c90d4a4b4e61ff3342d (diff)
downloadDropOut-5f93323760fb46f06d391996f17e87b7405769d4.tar.gz
DropOut-5f93323760fb46f06d391996f17e87b7405769d4.zip
feat(ui): add real-time download progress to instance creation flow
- Add download-store with throttled event handling for download-start/progress/complete - Rewrite download-monitor with phase-aware progress display (preparing/downloading/finalizing/installing-mod-loader/completed/error) - Add Step 4 (Installing) to instance creation modal with live progress bar - Fix dialog width jittering caused by long filenames (overflow-hidden + w-0 grow)
Diffstat (limited to 'packages/ui/src/components/instance-creation-modal.tsx')
-rw-r--r--packages/ui/src/components/instance-creation-modal.tsx200
1 files changed, 153 insertions, 47 deletions
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>