diff options
Diffstat (limited to 'packages/ui/src')
| -rw-r--r-- | packages/ui/src/components/bottom-bar.tsx | 17 | ||||
| -rw-r--r-- | packages/ui/src/components/instance-creation-modal.tsx | 544 | ||||
| -rw-r--r-- | packages/ui/src/components/ui/accordion.tsx | 77 | ||||
| -rw-r--r-- | packages/ui/src/components/ui/button.tsx | 2 | ||||
| -rw-r--r-- | packages/ui/src/components/ui/field.tsx | 12 | ||||
| -rw-r--r-- | packages/ui/src/models/instance.ts | 18 | ||||
| -rw-r--r-- | packages/ui/src/pages/instances/create.tsx | 746 | ||||
| -rw-r--r-- | packages/ui/src/pages/instances/index.tsx | 462 | ||||
| -rw-r--r-- | packages/ui/src/pages/instances/routes.ts | 19 |
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; |