aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages
diff options
context:
space:
mode:
author苏向夜 <fu050409@163.com>2026-03-29 21:32:54 +0800
committer苏向夜 <fu050409@163.com>2026-03-29 21:32:54 +0800
commit5b799a125a970e5e56f29a08b3c86450855fb6c4 (patch)
tree0467ce21983865b189f7daa9fe293faf04a149f2 /packages
parentffbfce895c37e8e8306d426a2e59e73647ed6a86 (diff)
downloadDropOut-5b799a125a970e5e56f29a08b3c86450855fb6c4.tar.gz
DropOut-5b799a125a970e5e56f29a08b3c86450855fb6c4.zip
refactor(ui): rewrite instance create
Diffstat (limited to 'packages')
-rw-r--r--packages/ui/src/components/bottom-bar.tsx17
-rw-r--r--packages/ui/src/components/instance-creation-modal.tsx544
-rw-r--r--packages/ui/src/components/ui/accordion.tsx77
-rw-r--r--packages/ui/src/components/ui/button.tsx2
-rw-r--r--packages/ui/src/components/ui/field.tsx12
-rw-r--r--packages/ui/src/models/instance.ts18
-rw-r--r--packages/ui/src/pages/instances/create.tsx746
-rw-r--r--packages/ui/src/pages/instances/index.tsx462
-rw-r--r--packages/ui/src/pages/instances/routes.ts19
9 files changed, 1323 insertions, 574 deletions
diff --git a/packages/ui/src/components/bottom-bar.tsx b/packages/ui/src/components/bottom-bar.tsx
index fd4a681..f73ace4 100644
--- a/packages/ui/src/components/bottom-bar.tsx
+++ b/packages/ui/src/components/bottom-bar.tsx
@@ -1,5 +1,5 @@
import { Play, User, XIcon } from "lucide-react";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useState } from "react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/models/auth";
@@ -29,18 +29,8 @@ export function BottomBar() {
stopGame,
} = useGameStore();
- const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
const [showLoginModal, setShowLoginModal] = useState(false);
- useEffect(() => {
- const nextVersion = activeInstance?.versionId ?? "";
- if (selectedVersion === nextVersion) {
- return;
- }
-
- setSelectedVersion(nextVersion);
- }, [activeInstance?.versionId, selectedVersion]);
-
const handleInstanceChange = useCallback(
async (instanceId: string) => {
if (activeInstance?.id === instanceId) {
@@ -70,10 +60,7 @@ export function BottomBar() {
return;
}
- await startGame(
- activeInstance.id,
- selectedVersion || activeInstance.versionId,
- );
+ await startGame(activeInstance.id, activeInstance.versionId ?? "");
};
const handleStopGame = async () => {
diff --git a/packages/ui/src/components/instance-creation-modal.tsx b/packages/ui/src/components/instance-creation-modal.tsx
deleted file mode 100644
index 7c46d0f..0000000
--- a/packages/ui/src/components/instance-creation-modal.tsx
+++ /dev/null
@@ -1,544 +0,0 @@
-import { Loader2, Search } from "lucide-react";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { toast } from "sonner";
-import {
- getFabricLoadersForVersion,
- getForgeVersionsForGame,
- installFabric,
- installForge,
- installVersion,
-} from "@/client";
-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 { useInstanceStore } from "@/models/instance";
-import { useGameStore } from "@/stores/game-store";
-import type {
- FabricLoaderEntry,
- ForgeVersion as ForgeVersionEntry,
- Version,
-} from "@/types";
-
-interface Props {
- open: boolean;
- onOpenChange: (open: boolean) => void;
-}
-
-export function InstanceCreationModal({ open, onOpenChange }: Props) {
- const gameStore = useGameStore();
- const instancesStore = useInstanceStore();
-
- // 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 getFabricLoadersForVersion(selectedVersionUI.id);
- setFabricLoaders(loaders || []);
- if (loaders && loaders.length > 0) {
- setSelectedFabricLoader(loaders[0].loader.version);
- } else {
- setSelectedFabricLoader("");
- }
- } else if (modLoaderType === "forge") {
- 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("");
- }
- }
- } 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 instancesStore.create(instanceName.trim());
-
- // If selectedVersion provided, install it
- if (selectedVersionUI && instance) {
- try {
- 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)}`,
- );
- }
- }
-
- // If mod loader selected, install it
- if (modLoaderType === "fabric" && selectedFabricLoader && instance) {
- try {
- await installFabric(
- instance?.id,
- selectedVersionUI?.id ?? "",
- selectedFabricLoader,
- );
- } catch (err) {
- console.error("Failed to install Fabric:", err);
- toast.error(`Failed to install Fabric: ${String(err)}`);
- }
- } else if (modLoaderType === "forge" && selectedForgeLoader && instance) {
- try {
- await installForge(
- instance?.id,
- selectedVersionUI?.id ?? "",
- selectedForgeLoader,
- );
- } catch (err) {
- console.error("Failed to install Forge:", err);
- toast.error(`Failed to install Forge: ${String(err)}`);
- }
- }
-
- // Refresh instances list
- await instancesStore.refresh();
-
- 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;
diff --git a/packages/ui/src/components/ui/accordion.tsx b/packages/ui/src/components/ui/accordion.tsx
new file mode 100644
index 0000000..02ba45c
--- /dev/null
+++ b/packages/ui/src/components/ui/accordion.tsx
@@ -0,0 +1,77 @@
+import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
+import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
+ return (
+ <AccordionPrimitive.Root
+ data-slot="accordion"
+ className={cn("flex w-full flex-col", className)}
+ {...props}
+ />
+ );
+}
+
+function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
+ return (
+ <AccordionPrimitive.Item
+ data-slot="accordion-item"
+ className={cn("not-last:border-b", className)}
+ {...props}
+ />
+ );
+}
+
+function AccordionTrigger({
+ className,
+ children,
+ ...props
+}: AccordionPrimitive.Trigger.Props) {
+ return (
+ <AccordionPrimitive.Header className="flex">
+ <AccordionPrimitive.Trigger
+ data-slot="accordion-trigger"
+ className={cn(
+ "group/accordion-trigger relative flex flex-1 items-start justify-between rounded-none border border-transparent py-2.5 text-left text-xs font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ <ChevronDownIcon
+ data-slot="accordion-trigger-icon"
+ className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
+ />
+ <ChevronUpIcon
+ data-slot="accordion-trigger-icon"
+ className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
+ />
+ </AccordionPrimitive.Trigger>
+ </AccordionPrimitive.Header>
+ );
+}
+
+function AccordionContent({
+ className,
+ children,
+ ...props
+}: AccordionPrimitive.Panel.Props) {
+ return (
+ <AccordionPrimitive.Panel
+ data-slot="accordion-content"
+ className="overflow-hidden text-xs data-open:animate-accordion-down data-closed:animate-accordion-up"
+ {...props}
+ >
+ <div
+ className={cn(
+ "h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
+ className,
+ )}
+ >
+ {children}
+ </div>
+ </AccordionPrimitive.Panel>
+ );
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/packages/ui/src/components/ui/button.tsx b/packages/ui/src/components/ui/button.tsx
index 7dee494..60ad9ca 100644
--- a/packages/ui/src/components/ui/button.tsx
+++ b/packages/ui/src/components/ui/button.tsx
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
- "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-none border border-transparent bg-clip-padding text-xs font-medium focus-visible:ring-1 aria-invalid:ring-1 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
+ "cursor-pointer focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-none border border-transparent bg-clip-padding text-xs font-medium focus-visible:ring-1 aria-invalid:ring-1 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
{
variants: {
variant: {
diff --git a/packages/ui/src/components/ui/field.tsx b/packages/ui/src/components/ui/field.tsx
index 10bd9c9..d6937c4 100644
--- a/packages/ui/src/components/ui/field.tsx
+++ b/packages/ui/src/components/ui/field.tsx
@@ -98,8 +98,12 @@ function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
function FieldLabel({
className,
+ required,
+ children,
...props
-}: React.ComponentProps<typeof Label>) {
+}: React.ComponentProps<typeof Label> & {
+ required?: boolean;
+}) {
return (
<Label
data-slot="field-label"
@@ -108,8 +112,12 @@ function FieldLabel({
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className,
)}
+ aria-required={!!required}
{...props}
- />
+ >
+ {children}
+ {required && <span className="text-red-700 dark:text-red-500">*</span>}
+ </Label>
);
}
diff --git a/packages/ui/src/models/instance.ts b/packages/ui/src/models/instance.ts
index 2f338b5..8c108c1 100644
--- a/packages/ui/src/models/instance.ts
+++ b/packages/ui/src/models/instance.ts
@@ -20,7 +20,7 @@ interface InstanceState {
activeInstance: Instance | null;
refresh: () => Promise<void>;
- create: (name: string) => Promise<Instance | null>;
+ create: (name: string) => Promise<Instance>;
delete: (id: string) => Promise<void>;
update: (instance: Instance) => Promise<void>;
setActiveInstance: (instance: Instance) => Promise<void>;
@@ -64,17 +64,11 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
create: async (name) => {
const { refresh } = get();
- try {
- const instance = await createInstance(name);
- await setActiveInstanceCommand(instance.id);
- await refresh();
- toast.success(`Instance "${name}" created successfully`);
- return instance;
- } catch (e) {
- console.error("Failed to create instance:", e);
- toast.error(String(e));
- return null;
- }
+ const instance = await createInstance(name);
+ await setActiveInstanceCommand(instance.id);
+ await refresh();
+ toast.success(`Instance "${name}" created successfully`);
+ return instance;
},
delete: async (id) => {
diff --git a/packages/ui/src/pages/instances/create.tsx b/packages/ui/src/pages/instances/create.tsx
new file mode 100644
index 0000000..57efea2
--- /dev/null
+++ b/packages/ui/src/pages/instances/create.tsx
@@ -0,0 +1,746 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { defineStepper } from "@stepperize/react";
+import { open } from "@tauri-apps/plugin-shell";
+import { ArrowLeftIcon, Link2Icon, XIcon } from "lucide-react";
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from "react";
+import {
+ Controller,
+ FormProvider,
+ useForm,
+ useFormContext,
+ Watch,
+} from "react-hook-form";
+import { useNavigate } from "react-router";
+import { toast } from "sonner";
+import z from "zod";
+import {
+ getFabricLoadersForVersion,
+ getForgeVersionsForGame,
+ getVersions,
+ installFabric,
+ installForge,
+ installVersion,
+ updateInstance,
+} from "@/client";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Field,
+ FieldContent,
+ FieldDescription,
+ FieldError,
+ FieldLabel,
+ FieldSet,
+ FieldTitle,
+} from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Separator } from "@/components/ui/separator";
+import { Spinner } from "@/components/ui/spinner";
+import { Textarea } from "@/components/ui/textarea";
+import { cn } from "@/lib/utils";
+import { useInstanceStore } from "@/models/instance";
+import type { FabricLoaderEntry, ForgeVersion, Version } from "@/types";
+
+const versionSchema = z.object({
+ versionId: z.string("Version is required"),
+});
+
+function VersionComponent() {
+ const {
+ control,
+ formState: { errors },
+ } = useFormContext<z.infer<typeof versionSchema>>();
+
+ const [versionSearch, setVersionSearch] = useState<string>("");
+ const [versionFilter, setVersionFilter] = useState<
+ "all" | "release" | "snapshot" | "old_alpha" | "old_beta" | null
+ >("release");
+
+ const [versions, setVersions] = useState<Version[] | null>(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
+ const loadVersions = useCallback(async () => {
+ setErrorMessage(null);
+ setIsLoading(true);
+ try {
+ const versions = await getVersions();
+ setVersions(versions);
+ } catch (e) {
+ console.error("Failed to load versions:", e);
+ setErrorMessage(`Failed to load versions: ${String(e)}`);
+ return;
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+ useEffect(() => {
+ if (!versions) loadVersions();
+ }, [versions, loadVersions]);
+
+ const filteredVersions = useMemo(() => {
+ if (!versions) return null;
+ const all = 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;
+ }, [versions, versionFilter, versionSearch]);
+
+ return (
+ <div className="flex flex-col min-h-0 h-full overflow-hidden">
+ <div className="flex flex-row items-center mb-4 space-x-2">
+ <div className="flex flex-row space-x-2 w-full">
+ <FieldLabel className="text-nowrap">Versions</FieldLabel>
+ <Input
+ placeholder="Search versions..."
+ value={versionSearch}
+ onChange={(e) => setVersionSearch(e.target.value)}
+ />
+ </div>
+ <div className="flex flex-row space-x-2">
+ <FieldLabel className="text-nowrap">Type</FieldLabel>
+ <Select
+ value={versionFilter}
+ onValueChange={(value) => setVersionFilter(value)}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Filter by type" />
+ </SelectTrigger>
+ <SelectContent alignItemWithTrigger={false}>
+ <SelectItem value="all">All Versions</SelectItem>
+ <SelectItem value="release">Release Versions</SelectItem>
+ <SelectItem value="snapshot">Snapshot Versions</SelectItem>
+ <SelectItem value="old_alpha">Old Alpha Versions</SelectItem>
+ <SelectItem value="old_beta">Old Beta Versions</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ <Button onClick={loadVersions} disabled={isLoading}>
+ Refresh
+ </Button>
+ </div>
+ {errorMessage && (
+ <div className="size-full flex flex-col items-center justify-center space-y-2">
+ <p className="text-red-500">{errorMessage}</p>
+ <Button variant="outline" onClick={loadVersions}>
+ Retry
+ </Button>
+ </div>
+ )}
+ {isLoading && !errorMessage ? (
+ <div className="size-full flex flex-col items-center justify-center">
+ <Spinner />
+ <p>Loading versions...</p>
+ </div>
+ ) : (
+ <div className="flex-1 overflow-hidden">
+ <ScrollArea className="size-full pr-2">
+ <Controller
+ name="versionId"
+ control={control}
+ render={({ field }) => (
+ <RadioGroup
+ {...field}
+ value={field.value || ""}
+ className="space-y-2"
+ >
+ {filteredVersions?.map((version) => (
+ <FieldLabel key={version.id} htmlFor={version.id}>
+ <Field orientation="horizontal" className="py-2">
+ <FieldContent>
+ <FieldTitle>
+ {version.id}
+ <Badge variant="outline">{version.type}</Badge>
+ </FieldTitle>
+ <FieldDescription>
+ {new Date(version.releaseTime).toLocaleString()}
+ </FieldDescription>
+ </FieldContent>
+ <div className="flex flex-row space-x-2 items-center">
+ <Button
+ size="icon"
+ variant="ghost"
+ onClick={() => {
+ open(
+ `https://zh.minecraft.wiki/w/Java%E7%89%88${version.id}`,
+ );
+ }}
+ >
+ <Link2Icon />
+ </Button>
+ <RadioGroupItem value={version.id} id={version.id} />
+ </div>
+ </Field>
+ </FieldLabel>
+ ))}
+ </RadioGroup>
+ )}
+ ></Controller>
+ </ScrollArea>
+ </div>
+ )}
+ {errors.versionId && <FieldError errors={[errors.versionId]} />}
+ </div>
+ );
+}
+
+const instanceSchema = z.object({
+ name: z.string().min(1, "Instance name is required"),
+ notes: z.string().max(100, "Notes must be at most 100 characters").optional(),
+ modLoader: z.enum(["fabric", "forge"]).optional(),
+ modLoaderVersion: z.string().optional(),
+});
+
+function InstanceComponent() {
+ const {
+ control,
+ register,
+ formState: { errors },
+ } = useFormContext<z.infer<typeof instanceSchema>>();
+
+ const versionId = useVersionId();
+
+ const [forgeVersions, setForgeVersions] = useState<ForgeVersion[] | null>(
+ null,
+ );
+ const [fabricVersions, setFabricVersions] = useState<
+ FabricLoaderEntry[] | null
+ >(null);
+
+ const [isLoadingForge, setIsLoadingForge] = useState(false);
+ const [isLoadingFabric, setIsLoadingFabric] = useState(false);
+ const loadForgeVersions = useCallback(async () => {
+ if (forgeVersions) return;
+ if (!versionId) return toast.error("Version ID is not set");
+ setIsLoadingForge(true);
+ try {
+ const versions = await getForgeVersionsForGame(versionId);
+ setForgeVersions(versions);
+ } catch (e) {
+ console.error("Failed to load Forge versions:", e);
+ toast.error(`Failed to load Forge versions: ${String(e)}`);
+ } finally {
+ setIsLoadingForge(false);
+ }
+ }, [versionId, forgeVersions]);
+ const loadFabricVersions = useCallback(async () => {
+ if (fabricVersions) return;
+ if (!versionId) return toast.error("Version ID is not set");
+ setIsLoadingFabric(true);
+ try {
+ const versions = await getFabricLoadersForVersion(versionId);
+ setFabricVersions(versions);
+ } catch (e) {
+ console.error("Failed to load Fabric versions:", e);
+ toast.error(`Failed to load Fabric versions: ${String(e)}`);
+ } finally {
+ setIsLoadingFabric(false);
+ }
+ }, [versionId, fabricVersions]);
+
+ const modLoaderField = register("modLoader");
+ const modLoaderVersionField = register("modLoaderVersion");
+
+ return (
+ <ScrollArea className="size-full pr-2">
+ <div className="h-full flex flex-col space-y-4">
+ <div className="bg-card w-full p-6 shadow shrink-0">
+ <FieldSet className="w-full">
+ <Field orientation="horizontal">
+ <FieldLabel htmlFor="name" className="text-nowrap" required>
+ Instance Name
+ </FieldLabel>
+ <Input {...register("name")} aria-invalid={!!errors.name} />
+ {errors.name && <FieldError errors={[errors.name]} />}
+ </Field>
+ <Field>
+ <FieldLabel htmlFor="notes" className="text-nowrap">
+ Instance Notes
+ </FieldLabel>
+ <Textarea
+ className="resize-none min-h-0"
+ {...register("notes")}
+ rows={1}
+ />
+ {errors.notes && <FieldError errors={[errors.notes]} />}
+ </Field>
+ </FieldSet>
+ </div>
+
+ <Accordion className="border">
+ <AccordionItem
+ value="forge"
+ onOpenChange={(open) => {
+ if (open) loadForgeVersions();
+ }}
+ >
+ <Watch
+ control={control}
+ render={({ modLoader, modLoaderVersion }) => (
+ <AccordionTrigger
+ className="border-b px-4 py-3"
+ disabled={modLoader && modLoader !== "forge"}
+ >
+ <div className="flex flex-row w-full items-center space-x-4">
+ <span className="font-bold">Forge</span>
+ {modLoader === "forge" && (
+ <>
+ <span className="text-nowrap font-bold">
+ {modLoaderVersion}
+ </span>
+ <Button
+ size="icon"
+ variant="ghost"
+ nativeButton={false}
+ onClick={(e) => {
+ e.stopPropagation();
+ modLoaderField.onChange({
+ target: {
+ name: modLoaderField.name,
+ value: null,
+ },
+ });
+ modLoaderVersionField.onChange({
+ target: {
+ name: modLoaderVersionField.name,
+ value: null,
+ },
+ });
+ }}
+ render={(domProps) => (
+ <div {...domProps}>
+ <XIcon />
+ </div>
+ )}
+ />
+ </>
+ )}
+ </div>
+ </AccordionTrigger>
+ )}
+ />
+ <AccordionContent>
+ {isLoadingForge ? (
+ <div className="h-full flex flex-col items-center justify-center">
+ <Spinner />
+ <p>Loading Forge versions...</p>
+ </div>
+ ) : (
+ <div className="h-full flex flex-col">
+ {forgeVersions?.map((version, idx) => (
+ <React.Fragment
+ key={`forge-${version.version}-${version.minecraftVersion}`}
+ >
+ <Button
+ variant="ghost"
+ className="p-3 py-6 border-b justify-start"
+ onClick={() => {
+ modLoaderField.onChange({
+ target: {
+ name: modLoaderField.name,
+ value: "forge",
+ },
+ });
+ modLoaderVersionField.onChange({
+ target: {
+ name: modLoaderVersionField.name,
+ value: version.version,
+ },
+ });
+ }}
+ >
+ Forge {version.version} for Minecraft{" "}
+ {version.minecraftVersion}
+ </Button>
+ {idx !== forgeVersions.length - 1 && <Separator />}
+ </React.Fragment>
+ ))}
+ </div>
+ )}
+ </AccordionContent>
+ </AccordionItem>
+ <AccordionItem
+ value="fabric"
+ onOpenChange={(open) => {
+ if (open) loadFabricVersions();
+ }}
+ >
+ <Watch
+ control={control}
+ render={({ modLoader, modLoaderVersion }) => (
+ <AccordionTrigger
+ className="border-b px-4 py-3"
+ disabled={modLoader && modLoader !== "fabric"}
+ >
+ <div className="flex flex-row w-full items-center space-x-4">
+ <span className="font-bold">Fabric</span>
+ {modLoader === "fabric" && (
+ <>
+ <span className="text-nowrap font-bold">
+ {modLoaderVersion}
+ </span>
+ <Button
+ size="icon"
+ variant="ghost"
+ nativeButton={false}
+ onClick={(e) => {
+ e.stopPropagation();
+ modLoaderField.onChange({
+ target: {
+ name: modLoaderField.name,
+ value: null,
+ },
+ });
+ modLoaderVersionField.onChange({
+ target: {
+ name: modLoaderVersionField.name,
+ value: null,
+ },
+ });
+ }}
+ render={(domProps) => (
+ <div {...domProps}>
+ <XIcon />
+ </div>
+ )}
+ />
+ </>
+ )}
+ </div>
+ </AccordionTrigger>
+ )}
+ />
+
+ <AccordionContent>
+ {isLoadingFabric ? (
+ <div className="h-full flex flex-col items-center justify-center">
+ <Spinner />
+ <p>Loading Fabric versions...</p>
+ </div>
+ ) : (
+ <div className="h-full flex flex-col">
+ {fabricVersions?.map((version, idx) => (
+ <React.Fragment
+ key={`fabric-${version.loader.version}-${version.intermediary.version}`}
+ >
+ <Button
+ variant="ghost"
+ className="p-3 py-6 border-b justify-start"
+ onClick={() => {
+ modLoaderField.onChange({
+ target: {
+ name: modLoaderField.name,
+ value: "fabric",
+ },
+ });
+ modLoaderVersionField.onChange({
+ target: {
+ name: modLoaderVersionField.name,
+ value: version.loader.version,
+ },
+ });
+ }}
+ >
+ Fabric {version.loader.version} for Minecraft{" "}
+ {version.intermediary.version}
+ </Button>
+ {idx !== fabricVersions.length - 1 && <Separator />}
+ </React.Fragment>
+ ))}
+ </div>
+ )}
+ </AccordionContent>
+ </AccordionItem>
+ </Accordion>
+ </div>
+ </ScrollArea>
+ );
+}
+
+const VersionIdContext = createContext<string | null>(null);
+export const useVersionId = () => useContext(VersionIdContext);
+
+const { useStepper, Stepper } = defineStepper(
+ {
+ id: "version",
+ title: "Version",
+ Component: VersionComponent,
+ schema: versionSchema,
+ },
+ {
+ id: "instance",
+ title: "Instance",
+ Component: InstanceComponent,
+ schema: instanceSchema,
+ },
+);
+
+export function CreateInstancePage() {
+ const stepper = useStepper();
+ const schema = stepper.state.current.data.schema;
+ const form = useForm<z.infer<typeof schema>>({
+ resolver: zodResolver(schema),
+ });
+ const navigate = useNavigate();
+
+ const instanceStore = useInstanceStore();
+
+ const [versions, setVersions] = useState<Version[] | null>(null);
+ useEffect(() => {
+ const loadVersions = async () => {
+ const versions = await getVersions();
+ setVersions(versions);
+ };
+ if (!versions) loadVersions();
+ }, [versions]);
+
+ // Step 2
+ const [versionId, setVersionId] = useState<string | null>(null);
+
+ // Step 2
+ // 这里不要动,后面会做一个download页面,需要迁移到download-models
+ const [_instanceMeta, setInstanceMeta] = useState<z.infer<
+ typeof instanceSchema
+ > | null>(null);
+
+ const [isCreating, setIsCreating] = useState(false);
+ const handleSubmit = useCallback(
+ async (data: z.infer<typeof schema>) => {
+ switch (stepper.state.current.data.id) {
+ case "version":
+ setVersionId((data as z.infer<typeof versionSchema>).versionId);
+ return await stepper.navigation.next();
+ case "instance":
+ setInstanceMeta(data as z.infer<typeof instanceSchema>);
+ }
+
+ if (!versionId) return toast.error("Please select a version first");
+
+ setIsCreating(true);
+
+ // 这里不要动,React数据是异步更新,直接用的数据才是实时的
+ const instanceMeta = data as z.infer<typeof instanceSchema>;
+
+ try {
+ const instance = await instanceStore.create(instanceMeta.name);
+ instance.notes = instanceMeta.notes ?? null;
+ await updateInstance(instance);
+
+ await installVersion(instance.id, versionId);
+ switch (instanceMeta.modLoader) {
+ case "fabric":
+ if (!instanceMeta.modLoaderVersion) {
+ toast.error("Please select a Fabric loader version");
+ return;
+ }
+ await installFabric(
+ instance.id,
+ versionId,
+ instanceMeta.modLoaderVersion,
+ );
+ break;
+ case "forge":
+ if (!instanceMeta.modLoaderVersion) {
+ toast.error("Please select a Forge loader version");
+ return;
+ }
+ await installForge(
+ instance.id,
+ versionId,
+ instanceMeta.modLoaderVersion,
+ );
+ break;
+ default:
+ toast.error("Unsupported mod loader");
+ break;
+ }
+
+ navigate("/instances");
+ } catch (error) {
+ console.error(error);
+ toast.error("Failed to create instance");
+ } finally {
+ setIsCreating(false);
+ }
+ },
+ [stepper, instanceStore.create, versionId, navigate],
+ );
+
+ return (
+ <FormProvider {...form}>
+ <Stepper.List className="w-full flex list-none flex-row items-center justify-center px-6 mb-6">
+ {stepper.state.all.map((step, idx) => {
+ const current = stepper.state.current;
+ const isInactive = stepper.state.current.data.id !== step.id;
+ const isLast = stepper.lookup.getLast().id === step.id;
+ return (
+ <React.Fragment key={`stepper-item-${step.id}`}>
+ <Stepper.Item step={step.id}>
+ <Stepper.Trigger
+ render={(domProps) => (
+ <Button
+ className="rounded-full"
+ variant={isInactive ? "secondary" : "default"}
+ size="icon"
+ disabled={isInactive}
+ {...domProps}
+ >
+ <Stepper.Indicator>{idx + 1}</Stepper.Indicator>
+ </Button>
+ )}
+ />
+ </Stepper.Item>
+ {!isLast && (
+ <Stepper.Separator
+ orientation="horizontal"
+ data-status={current.status}
+ className={cn(
+ "w-full h-0.5 mx-2",
+ "bg-muted data-[status=success]:bg-primary data-disabled:opacity-50",
+ "transition-all duration-300 ease-in-out",
+ )}
+ />
+ )}
+ </React.Fragment>
+ );
+ })}
+ </Stepper.List>
+ <form
+ className="flex flex-col flex-1 min-h-0 space-y-4 px-6"
+ onSubmit={form.handleSubmit(handleSubmit)}
+ >
+ <div className="flex-1 overflow-hidden w-full max-w-xl mx-auto">
+ <VersionIdContext.Provider value={versionId}>
+ {stepper.flow.switch({
+ version: ({ Component }) => <Component />,
+ instance: ({ Component }) => <Component />,
+ })}
+ </VersionIdContext.Provider>
+ </div>
+ <div className="w-full flex flex-row justify-between">
+ <Stepper.Prev
+ render={(domProps) => (
+ <Button
+ type="button"
+ variant="secondary"
+ disabled={isCreating}
+ {...domProps}
+ >
+ Previous
+ </Button>
+ )}
+ />
+ {stepper.state.isLast ? (
+ <Button type="submit" disabled={isCreating}>
+ {isCreating ? (
+ <>
+ <Spinner />
+ Creating
+ </>
+ ) : (
+ "Create"
+ )}
+ </Button>
+ ) : (
+ <Button type="submit">Next</Button>
+ )}
+ </div>
+ </form>
+ </FormProvider>
+ );
+}
+
+function PageWrapper() {
+ const navigate = useNavigate();
+ const [showCancelDialog, setShowCancelDialog] = useState(false);
+
+ return (
+ <div className="flex size-full overflow-hidden px-6 py-8">
+ <Stepper.Root
+ className="flex flex-col flex-1 space-y-4"
+ orientation="horizontal"
+ >
+ {({ stepper }) => (
+ <>
+ <div className="flex flex-row space-x-4">
+ <Button
+ variant="secondary"
+ size="icon"
+ onClick={() => {
+ if (stepper.state.isFirst) return navigate(-1);
+ setShowCancelDialog(true);
+ }}
+ >
+ <ArrowLeftIcon />
+ </Button>
+ <h1 className="text-2xl font-bold">Create Instance</h1>
+ </div>
+ <p className="text-sm text-muted-foreground">
+ Create a new Minecraft instance.
+ </p>
+ <CreateInstancePage />
+ </>
+ )}
+ </Stepper.Root>
+
+ <AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
+ <AlertDialogDescription>
+ All your progress will be lost.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
+ <AlertDialogAction
+ variant="destructive"
+ onClick={() => navigate(-1)}
+ >
+ Continue
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ </div>
+ );
+}
+
+export default PageWrapper;
diff --git a/packages/ui/src/pages/instances/index.tsx b/packages/ui/src/pages/instances/index.tsx
new file mode 100644
index 0000000..e6cd734
--- /dev/null
+++ b/packages/ui/src/pages/instances/index.tsx
@@ -0,0 +1,462 @@
+import { open, save } from "@tauri-apps/plugin-dialog";
+import {
+ CopyIcon,
+ EditIcon,
+ EllipsisIcon,
+ FolderOpenIcon,
+ Plus,
+ RocketIcon,
+ Trash2Icon,
+ XIcon,
+} from "lucide-react";
+import { useEffect, useState } from "react";
+import { useNavigate } from "react-router";
+import { toast } from "sonner";
+import { openFileExplorer } from "@/client";
+import InstanceEditorModal from "@/components/instance-editor-modal";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { cn } from "@/lib/utils";
+import { useAuthStore } from "@/models/auth";
+import { useGameStore } from "@/models/game";
+import { useInstanceStore } from "@/models/instance";
+import type { Instance } from "@/types";
+
+export function InstancesPage() {
+ const instancesStore = useInstanceStore();
+ const navigate = useNavigate();
+
+ const account = useAuthStore((state) => state.account);
+ const {
+ startGame,
+ runningInstanceId,
+ stoppingInstanceId,
+ launchingInstanceId,
+ stopGame,
+ } = useGameStore();
+
+ const [showEditModal, setShowEditModal] = useState(false);
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [showDuplicateModal, setShowDuplicateModal] = useState(false);
+
+ const [isImporting, setIsImporting] = useState(false);
+ const [repairing, setRepairing] = useState(false);
+ const [exportingId, setExportingId] = useState<string | null>(null);
+
+ // Selected / editing instance state
+ const [selectedInstance, setSelectedInstance] = useState<Instance | null>(
+ null,
+ );
+ const [editingInstance, setEditingInstance] = useState<Instance | null>(null);
+
+ // Form fields
+ const [duplicateName, setDuplicateName] = useState("");
+
+ useEffect(() => {
+ instancesStore.refresh();
+ }, [instancesStore.refresh]);
+
+ // Handlers to open modals
+ const openCreate = () => {
+ navigate("/instances/create");
+ };
+
+ const openEdit = (instance: Instance) => {
+ setEditingInstance({ ...instance });
+ setShowEditModal(true);
+ };
+
+ const openDelete = (instance: Instance) => {
+ setSelectedInstance(instance);
+ setShowDeleteConfirm(true);
+ };
+
+ const openDuplicate = (instance: Instance) => {
+ setSelectedInstance(instance);
+ setDuplicateName(`${instance.name} (Copy)`);
+ setShowDuplicateModal(true);
+ };
+
+ const confirmDelete = async () => {
+ if (!selectedInstance) return;
+ await instancesStore.delete(selectedInstance.id);
+ setSelectedInstance(null);
+ setShowDeleteConfirm(false);
+ };
+
+ const confirmDuplicate = async () => {
+ if (!selectedInstance) return;
+ const name = duplicateName.trim();
+ if (!name) return;
+ await instancesStore.duplicate(selectedInstance.id, name);
+ setSelectedInstance(null);
+ setDuplicateName("");
+ setShowDuplicateModal(false);
+ };
+
+ const handleImport = async () => {
+ setIsImporting(true);
+ try {
+ const selected = await open({
+ multiple: false,
+ filters: [{ name: "Zip Archive", extensions: ["zip"] }],
+ });
+
+ if (typeof selected !== "string") {
+ return;
+ }
+
+ await instancesStore.importArchive(selected);
+ } finally {
+ setIsImporting(false);
+ }
+ };
+
+ const handleRepair = async () => {
+ setRepairing(true);
+ try {
+ await instancesStore.repair();
+ } finally {
+ setRepairing(false);
+ }
+ };
+
+ const handleExport = async (instance: Instance) => {
+ setExportingId(instance.id);
+ try {
+ const filePath = await save({
+ defaultPath: `${instance.name.replace(/[\\/:*?"<>|]/g, "_")}.zip`,
+ filters: [{ name: "Zip Archive", extensions: ["zip"] }],
+ });
+
+ if (!filePath) {
+ return;
+ }
+
+ await instancesStore.exportArchive(instance.id, filePath);
+ } finally {
+ setExportingId(null);
+ }
+ };
+
+ return (
+ <div className="h-full flex flex-col gap-4 p-6 overflow-y-auto">
+ <div className="flex items-center justify-between">
+ <h1 className="text-2xl font-bold text-gray-900 dark:text-white">
+ Instances
+ </h1>
+ <div className="flex flex-row space-x-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleImport}
+ disabled={isImporting}
+ >
+ {isImporting ? "Importing..." : "Import"}
+ </Button>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleRepair}
+ disabled={repairing}
+ >
+ {repairing ? "Repairing..." : "Repair Index"}
+ </Button>
+ <Button
+ type="button"
+ onClick={openCreate}
+ className="px-4 py-2 transition-colors"
+ >
+ <Plus size={18} />
+ Create Instance
+ </Button>
+ </div>
+ </div>
+
+ {instancesStore.instances.length === 0 ? (
+ <div className="flex-1 flex items-center justify-center">
+ <div className="text-center text-gray-500 dark:text-gray-400">
+ <p className="text-lg mb-2">No instances yet</p>
+ <p className="text-sm">Create your first instance to get started</p>
+ </div>
+ </div>
+ ) : (
+ <ul className="flex flex-col space-y-3">
+ {instancesStore.instances.map((instance) => {
+ const isActive = instancesStore.activeInstance?.id === instance.id;
+ const isLaunching = launchingInstanceId === instance.id;
+ const isStopping = stoppingInstanceId === instance.id;
+ const isRunning = runningInstanceId === instance.id;
+
+ return (
+ <li
+ key={instance.id}
+ onClick={() => instancesStore.setActiveInstance(instance)}
+ onKeyDown={async (e) => {
+ if (e.key === "Enter") {
+ try {
+ await instancesStore.setActiveInstance(instance);
+ } catch (e) {
+ console.error("Failed to set active instance:", e);
+ toast.error("Error setting active instance");
+ }
+ }
+ }}
+ className="cursor-pointer"
+ >
+ <div
+ className={cn(
+ "flex flex-row space-x-3 p-3 justify-between",
+ "border bg-card/5 backdrop-blur-xl",
+ "hover:bg-accent/50 transition-colors",
+ isActive && "border-primary",
+ )}
+ >
+ <div className="flex flex-row space-x-4">
+ {instance.iconPath ? (
+ <div className="w-12 h-12 rounded overflow-hidden">
+ <img
+ src={instance.iconPath}
+ alt={instance.name}
+ className="w-full h-full object-cover"
+ />
+ </div>
+ ) : (
+ <div className="w-12 h-12 rounded bg-linear-to-br from-blue-500 to-purple-600 flex items-center justify-center">
+ <span className="text-white font-bold text-lg">
+ {instance.name.charAt(0).toUpperCase()}
+ </span>
+ </div>
+ )}
+
+ <div className="flex flex-col">
+ <h3 className="text-lg font-semibold">{instance.name}</h3>
+ {instance.versionId ? (
+ <p className="text-sm text-muted-foreground">
+ {instance.versionId}
+ </p>
+ ) : (
+ <p className="text-sm text-muted-foreground">
+ No version selected
+ </p>
+ )}
+ </div>
+ </div>
+
+ <div className="flex items-center">
+ <div className="flex flex-row space-x-2">
+ <Button
+ variant={isRunning ? "destructive" : "ghost"}
+ size="icon"
+ onClick={async (e) => {
+ e.stopPropagation();
+
+ try {
+ await instancesStore.setActiveInstance(instance);
+ } catch (error) {
+ console.error(
+ "Failed to set active instance:",
+ error,
+ );
+ toast.error("Error setting active instance");
+ return;
+ }
+
+ if (isRunning) {
+ await stopGame(instance.id);
+ return;
+ }
+
+ if (!instance.versionId) {
+ toast.error("No version selected or installed");
+ return;
+ }
+
+ if (!account) {
+ toast.info("Please login first");
+ return;
+ }
+
+ try {
+ await startGame(instance.id, instance.versionId);
+ } catch (error) {
+ console.error("Failed to start game:", error);
+ toast.error("Error starting game");
+ }
+ }}
+ disabled={
+ (!!runningInstanceId &&
+ runningInstanceId !== instance.id) ||
+ isLaunching ||
+ isStopping
+ }
+ >
+ {isLaunching || isStopping ? (
+ <EllipsisIcon />
+ ) : isRunning ? (
+ <XIcon />
+ ) : (
+ <RocketIcon />
+ )}
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={(e) => {
+ e.stopPropagation();
+ void openFileExplorer(instance.gameDir);
+ }}
+ >
+ <FolderOpenIcon />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={(e) => {
+ e.stopPropagation();
+ void handleExport(instance);
+ }}
+ disabled={exportingId === instance.id}
+ >
+ <span className="text-xs">
+ {exportingId === instance.id ? "..." : "ZIP"}
+ </span>
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={(e) => {
+ e.stopPropagation();
+ openDuplicate(instance);
+ }}
+ >
+ <CopyIcon />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={(e) => {
+ e.stopPropagation();
+ openEdit(instance);
+ }}
+ >
+ <EditIcon />
+ </Button>
+ <Button
+ variant="destructive"
+ size="icon"
+ onClick={(e) => {
+ e.stopPropagation();
+ openDelete(instance);
+ }}
+ >
+ <Trash2Icon />
+ </Button>
+ </div>
+ </div>
+ </div>
+ </li>
+ );
+ })}
+ </ul>
+ )}
+
+ {/*<InstanceCreationModal
+ open={showCreateModal}
+ onOpenChange={setShowCreateModal}
+ />*/}
+
+ <InstanceEditorModal
+ open={showEditModal}
+ instance={editingInstance}
+ onOpenChange={(open) => {
+ setShowEditModal(open);
+ if (!open) setEditingInstance(null);
+ }}
+ />
+
+ {/* Delete Confirmation */}
+ <Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Delete Instance</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to delete "{selectedInstance?.name}"? This
+ action cannot be undone.
+ </DialogDescription>
+ </DialogHeader>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ setShowDeleteConfirm(false);
+ setSelectedInstance(null);
+ }}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="button"
+ onClick={confirmDelete}
+ className="bg-red-600 text-white hover:bg-red-500"
+ >
+ Delete
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* Duplicate Modal */}
+ <Dialog open={showDuplicateModal} onOpenChange={setShowDuplicateModal}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Duplicate Instance</DialogTitle>
+ <DialogDescription>
+ Provide a name for the duplicated instance.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="mt-4">
+ <Input
+ value={duplicateName}
+ onChange={(e) => setDuplicateName(e.target.value)}
+ placeholder="New instance name"
+ onKeyDown={(e) => e.key === "Enter" && confirmDuplicate()}
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ setShowDuplicateModal(false);
+ setSelectedInstance(null);
+ setDuplicateName("");
+ }}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="button"
+ onClick={confirmDuplicate}
+ disabled={!duplicateName.trim()}
+ >
+ Duplicate
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+}
diff --git a/packages/ui/src/pages/instances/routes.ts b/packages/ui/src/pages/instances/routes.ts
new file mode 100644
index 0000000..cd1255d
--- /dev/null
+++ b/packages/ui/src/pages/instances/routes.ts
@@ -0,0 +1,19 @@
+import type { RouteObject } from "react-router";
+import CreateInstancePage from "./create";
+import { InstancesPage } from "./index";
+
+const routes = {
+ path: "/instances",
+ children: [
+ {
+ index: true,
+ Component: InstancesPage,
+ },
+ {
+ path: "create",
+ Component: CreateInstancePage,
+ },
+ ],
+} satisfies RouteObject;
+
+export default routes;