aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/ui-new/src/components/instance-creation-modal.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/ui-new/src/components/instance-creation-modal.tsx')
-rw-r--r--packages/ui-new/src/components/instance-creation-modal.tsx566
1 files changed, 566 insertions, 0 deletions
diff --git a/packages/ui-new/src/components/instance-creation-modal.tsx b/packages/ui-new/src/components/instance-creation-modal.tsx
new file mode 100644
index 0000000..bdc1a6f
--- /dev/null
+++ b/packages/ui-new/src/components/instance-creation-modal.tsx
@@ -0,0 +1,566 @@
+import { invoke } from "@tauri-apps/api/core";
+import { Loader2, Search } from "lucide-react";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { useGameStore } from "@/stores/game-store";
+import { useInstancesStore } from "@/stores/instances-store";
+import type { Version } from "@/types/bindings/manifest";
+import type { FabricLoaderEntry } from "../types/bindings/fabric";
+import type { ForgeVersion as ForgeVersionEntry } from "../types/bindings/forge";
+import type { Instance } from "../types/bindings/instance";
+
+interface Props {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+/**
+ * InstanceCreationModal
+ * 3-step wizard:
+ * 1) Name
+ * 2) Select base Minecraft version
+ * 3) Optional: choose mod loader (vanilla/fabric/forge) and loader version
+ *
+ * Behavior:
+ * - On Create: invoke("create_instance", { name })
+ * - If a base version selected: invoke("install_version", { instanceId, versionId })
+ * - If Fabric selected: invoke("install_fabric", { instanceId, gameVersion, loaderVersion })
+ * - If Forge selected: invoke("install_forge", { instanceId, gameVersion, forgeVersion })
+ * - Reload instances via instancesStore.loadInstances()
+ */
+export function InstanceCreationModal({ open, onOpenChange }: Props) {
+ const gameStore = useGameStore();
+ const instancesStore = useInstancesStore();
+
+ // Steps: 1 = name, 2 = version, 3 = mod loader
+ const [step, setStep] = useState<number>(1);
+
+ // Step 1
+ const [instanceName, setInstanceName] = useState<string>("");
+
+ // Step 2
+ const [versionSearch, setVersionSearch] = useState<string>("");
+ const [versionFilter, setVersionFilter] = useState<
+ "all" | "release" | "snapshot"
+ >("release");
+ const [selectedVersionUI, setSelectedVersionUI] = useState<Version | null>(
+ null,
+ );
+
+ // Step 3
+ const [modLoaderType, setModLoaderType] = useState<
+ "vanilla" | "fabric" | "forge"
+ >("vanilla");
+ const [fabricLoaders, setFabricLoaders] = useState<FabricLoaderEntry[]>([]);
+ const [forgeVersions, setForgeVersions] = useState<ForgeVersionEntry[]>([]);
+ const [selectedFabricLoader, setSelectedFabricLoader] = useState<string>("");
+ const [selectedForgeLoader, setSelectedForgeLoader] = useState<string>("");
+ const [loadingLoaders, setLoadingLoaders] = useState(false);
+
+ const loadModLoaders = useCallback(async () => {
+ if (!selectedVersionUI) return;
+ setLoadingLoaders(true);
+ setFabricLoaders([]);
+ setForgeVersions([]);
+ try {
+ if (modLoaderType === "fabric") {
+ const loaders = await invoke<FabricLoaderEntry[]>(
+ "get_fabric_loaders_for_version",
+ {
+ gameVersion: selectedVersionUI.id,
+ },
+ );
+ setFabricLoaders(loaders || []);
+ if (loaders && loaders.length > 0) {
+ setSelectedFabricLoader(loaders[0].loader.version);
+ } else {
+ setSelectedFabricLoader("");
+ }
+ } else if (modLoaderType === "forge") {
+ const versions = await invoke<ForgeVersionEntry[]>(
+ "get_forge_versions_for_game",
+ {
+ gameVersion: selectedVersionUI.id,
+ },
+ );
+ setForgeVersions(versions || []);
+ if (versions && versions.length > 0) {
+ // Binding `ForgeVersion` uses `version` (not `id`) — use `.version` here.
+ setSelectedForgeLoader(versions[0].version);
+ } else {
+ setSelectedForgeLoader("");
+ }
+ }
+ } catch (e) {
+ console.error("Failed to load mod loaders:", e);
+ toast.error("Failed to fetch mod loader versions");
+ } finally {
+ setLoadingLoaders(false);
+ }
+ }, [modLoaderType, selectedVersionUI]);
+
+ // When entering step 3 and a base version exists, fetch loaders if needed
+ useEffect(() => {
+ if (step === 3 && modLoaderType !== "vanilla" && selectedVersionUI) {
+ loadModLoaders();
+ }
+ }, [step, modLoaderType, selectedVersionUI, loadModLoaders]);
+
+ // Creating state
+ const [creating, setCreating] = useState(false);
+ const [errorMessage, setErrorMessage] = useState<string>("");
+
+ // Derived filtered versions
+ const filteredVersions = useMemo(() => {
+ const all = gameStore.versions || [];
+ let list = all.slice();
+ if (versionFilter !== "all") {
+ list = list.filter((v) => v.type === versionFilter);
+ }
+ if (versionSearch.trim()) {
+ const q = versionSearch.trim().toLowerCase().replace(/。/g, ".");
+ list = list.filter((v) => v.id.toLowerCase().includes(q));
+ }
+ return list;
+ }, [gameStore.versions, versionFilter, versionSearch]);
+
+ // Reset when opened/closed
+ useEffect(() => {
+ if (open) {
+ // ensure versions are loaded
+ gameStore.loadVersions();
+ setStep(1);
+ setInstanceName("");
+ setVersionSearch("");
+ setVersionFilter("release");
+ setSelectedVersionUI(null);
+ setModLoaderType("vanilla");
+ setFabricLoaders([]);
+ setForgeVersions([]);
+ setSelectedFabricLoader("");
+ setSelectedForgeLoader("");
+ setErrorMessage("");
+ setCreating(false);
+ }
+ }, [open, gameStore.loadVersions]);
+
+ function validateStep1(): boolean {
+ if (!instanceName.trim()) {
+ setErrorMessage("Please enter an instance name");
+ return false;
+ }
+ setErrorMessage("");
+ return true;
+ }
+
+ function validateStep2(): boolean {
+ if (!selectedVersionUI) {
+ setErrorMessage("Please select a Minecraft version");
+ return false;
+ }
+ setErrorMessage("");
+ return true;
+ }
+
+ async function handleNext() {
+ setErrorMessage("");
+ if (step === 1) {
+ if (!validateStep1()) return;
+ setStep(2);
+ } else if (step === 2) {
+ if (!validateStep2()) return;
+ setStep(3);
+ }
+ }
+
+ function handleBack() {
+ setErrorMessage("");
+ setStep((s) => Math.max(1, s - 1));
+ }
+
+ async function handleCreate() {
+ if (!validateStep1() || !validateStep2()) return;
+ setCreating(true);
+ setErrorMessage("");
+
+ try {
+ // Step 1: create instance
+ const instance = await invoke<Instance>("create_instance", {
+ name: instanceName.trim(),
+ });
+
+ // If selectedVersion provided, install it
+ if (selectedVersionUI) {
+ try {
+ await invoke("install_version", {
+ instanceId: instance.id,
+ versionId: 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)}`,
+ );
+ }
+ }
+
+ // If mod loader selected, install it
+ if (modLoaderType === "fabric" && selectedFabricLoader) {
+ try {
+ await invoke("install_fabric", {
+ instanceId: instance.id,
+ gameVersion: selectedVersionUI?.id ?? "",
+ loaderVersion: selectedFabricLoader,
+ });
+ } catch (err) {
+ console.error("Failed to install Fabric:", err);
+ toast.error(`Failed to install Fabric: ${String(err)}`);
+ }
+ } else if (modLoaderType === "forge" && selectedForgeLoader) {
+ try {
+ await invoke("install_forge", {
+ instanceId: instance.id,
+ gameVersion: selectedVersionUI?.id ?? "",
+ installerVersion: selectedForgeLoader,
+ });
+ } catch (err) {
+ console.error("Failed to install Forge:", err);
+ toast.error(`Failed to install Forge: ${String(err)}`);
+ }
+ }
+
+ // Refresh instances list
+ await instancesStore.loadInstances();
+
+ toast.success("Instance created successfully");
+ onOpenChange(false);
+ } catch (e) {
+ console.error("Failed to create instance:", e);
+ setErrorMessage(String(e));
+ toast.error(`Failed to create instance: ${e}`);
+ } finally {
+ setCreating(false);
+ }
+ }
+
+ // UI pieces
+ 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"}`}
+ />
+ </div>
+ );
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <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>
+ </DialogHeader>
+
+ <div className="px-6">
+ <div className="pt-4 pb-6">
+ <StepIndicator />
+ </div>
+
+ {/* Step 1 - Name */}
+ {step === 1 && (
+ <div className="space-y-4">
+ <div>
+ <label
+ htmlFor="instance-name"
+ className="block text-sm font-medium mb-2"
+ >
+ Instance Name
+ </label>
+ <Input
+ id="instance-name"
+ placeholder="My Minecraft Instance"
+ value={instanceName}
+ onChange={(e) => setInstanceName(e.target.value)}
+ disabled={creating}
+ />
+ </div>
+ <p className="text-xs text-muted-foreground">
+ Give your instance a memorable name.
+ </p>
+ </div>
+ )}
+
+ {/* Step 2 - Version selection */}
+ {step === 2 && (
+ <div className="space-y-4">
+ <div className="flex gap-3">
+ <div className="relative flex-1">
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
+ <Input
+ value={versionSearch}
+ onChange={(e) => setVersionSearch(e.target.value)}
+ placeholder="Search versions..."
+ className="pl-9"
+ />
+ </div>
+
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant={versionFilter === "all" ? "default" : "outline"}
+ onClick={() => setVersionFilter("all")}
+ >
+ All
+ </Button>
+ <Button
+ type="button"
+ variant={
+ versionFilter === "release" ? "default" : "outline"
+ }
+ onClick={() => setVersionFilter("release")}
+ >
+ Release
+ </Button>
+ <Button
+ type="button"
+ variant={
+ versionFilter === "snapshot" ? "default" : "outline"
+ }
+ onClick={() => setVersionFilter("snapshot")}
+ >
+ Snapshot
+ </Button>
+ </div>
+ </div>
+
+ <ScrollArea className="max-h-[36vh]">
+ <div className="space-y-2 py-2">
+ {gameStore.versions.length === 0 ? (
+ <div className="flex items-center justify-center py-8 text-muted-foreground">
+ <Loader2 className="animate-spin mr-2" />
+ Loading versions...
+ </div>
+ ) : filteredVersions.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ No matching versions found
+ </div>
+ ) : (
+ filteredVersions.map((v) => {
+ const isSelected = selectedVersionUI?.id === v.id;
+ return (
+ <button
+ key={v.id}
+ type="button"
+ onClick={() => setSelectedVersionUI(v)}
+ className={`w-full text-left p-3 rounded-lg border transition-colors ${
+ isSelected
+ ? "bg-indigo-50 dark:bg-indigo-600/20 border-indigo-200"
+ : "bg-white/40 dark:bg-white/5 border-black/5 dark:border-white/5 hover:bg-white/60"
+ }`}
+ >
+ <div className="flex items-center justify-between">
+ <div>
+ <div className="font-mono font-bold">{v.id}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ {v.type}{" "}
+ {v.releaseTime
+ ? ` • ${new Date(v.releaseTime).toLocaleDateString()}`
+ : ""}
+ </div>
+ </div>
+ {v.javaVersion && (
+ <div className="text-sm">
+ Java {v.javaVersion}
+ </div>
+ )}
+ </div>
+ </button>
+ );
+ })
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ )}
+
+ {/* Step 3 - Mod loader */}
+ {step === 3 && (
+ <div className="space-y-4">
+ <div>
+ <div className="text-sm font-medium mb-2">Mod Loader Type</div>
+ <div className="flex gap-3">
+ <Button
+ type="button"
+ variant={
+ modLoaderType === "vanilla" ? "default" : "outline"
+ }
+ onClick={() => setModLoaderType("vanilla")}
+ >
+ Vanilla
+ </Button>
+ <Button
+ type="button"
+ variant={modLoaderType === "fabric" ? "default" : "outline"}
+ onClick={() => setModLoaderType("fabric")}
+ >
+ Fabric
+ </Button>
+ <Button
+ type="button"
+ variant={modLoaderType === "forge" ? "default" : "outline"}
+ onClick={() => setModLoaderType("forge")}
+ >
+ Forge
+ </Button>
+ </div>
+ </div>
+
+ {modLoaderType === "fabric" && (
+ <div>
+ {loadingLoaders ? (
+ <div className="flex items-center gap-2">
+ <Loader2 className="animate-spin" />
+ Loading Fabric versions...
+ </div>
+ ) : fabricLoaders.length > 0 ? (
+ <div className="space-y-2">
+ <select
+ value={selectedFabricLoader}
+ onChange={(e) =>
+ setSelectedFabricLoader(e.target.value)
+ }
+ className="w-full px-3 py-2 rounded border bg-transparent"
+ >
+ {fabricLoaders.map((f) => (
+ <option
+ key={f.loader.version}
+ value={f.loader.version}
+ >
+ {f.loader.version}{" "}
+ {f.loader.stable ? "(Stable)" : "(Beta)"}
+ </option>
+ ))}
+ </select>
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">
+ No Fabric loaders available for this version
+ </p>
+ )}
+ </div>
+ )}
+
+ {modLoaderType === "forge" && (
+ <div>
+ {loadingLoaders ? (
+ <div className="flex items-center gap-2">
+ <Loader2 className="animate-spin" />
+ Loading Forge versions...
+ </div>
+ ) : forgeVersions.length > 0 ? (
+ <div className="space-y-2">
+ <select
+ value={selectedForgeLoader}
+ onChange={(e) => setSelectedForgeLoader(e.target.value)}
+ 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>
+ ))}
+ </select>
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">
+ No Forge versions available for this version
+ </p>
+ )}
+ </div>
+ )}
+ </div>
+ )}
+
+ {errorMessage && (
+ <div className="text-sm text-red-400 mt-3">{errorMessage}</div>
+ )}
+ </div>
+
+ <DialogFooter>
+ <div className="w-full flex justify-between items-center">
+ <div>
+ <Button
+ type="button"
+ variant="ghost"
+ onClick={() => {
+ // cancel
+ onOpenChange(false);
+ }}
+ disabled={creating}
+ >
+ Cancel
+ </Button>
+ </div>
+
+ <div className="flex gap-2">
+ {step > 1 && (
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleBack}
+ disabled={creating}
+ >
+ Back
+ </Button>
+ )}
+
+ {step < 3 ? (
+ <Button type="button" onClick={handleNext} disabled={creating}>
+ Next
+ </Button>
+ ) : (
+ <Button
+ type="button"
+ onClick={handleCreate}
+ disabled={creating}
+ >
+ {creating ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ Creating...
+ </>
+ ) : (
+ "Create"
+ )}
+ </Button>
+ )}
+ </div>
+ </div>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+export default InstanceCreationModal;