aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages
diff options
context:
space:
mode:
author苏向夜 <46275354+fu050409@users.noreply.github.com>2026-03-29 21:35:34 +0800
committerGitHub <noreply@github.com>2026-03-29 21:35:34 +0800
commit70348cefb7de8c1e044800296a99177309c5a81e (patch)
treeeb0fdfbcc880574e9b386a3f2fc9b3a89489e5b5 /packages
parentf2f5383a1b615a7493316d558dc55271198e772a (diff)
parent1c115141cc7b676e6a07786594155c3ac293fe34 (diff)
downloadDropOut-70348cefb7de8c1e044800296a99177309c5a81e.tar.gz
DropOut-70348cefb7de8c1e044800296a99177309c5a81e.zip
refactor(ui): full rewrite instance and code struct (#129)
## Summary by Sourcery Refactor the UI to modernize effect handling, routing, and legacy APIs while adding a reusable alert dialog component and cleaning up obsolete stores. New Features: - Introduce a shared SaturnEffect context via ParticleBackground so pages can access the effect without relying on global window APIs. - Add a Base UI–powered alert dialog component for consistent confirmation and warning flows across the app. - Define a central router configuration module with instance routes to standardize page wiring. Bug Fixes: - Ensure SaturnEffect nullish checks are handled safely when forwarding pointer and touch events from the home view. Enhancements: - Rewrite ParticleBackground to manage its own SaturnEffect lifecycle via React state and context instead of global accessors. - Update the home view to use the SaturnEffect hook, simplify pointer/touch handlers, and remove legacy game and release store usage. - Refine layout and accessibility attributes for various form field and label components, including field grouping and error rendering keys. - Simplify sidebar navigation and adjust the user dropdown trigger to work with the updated dropdown menu API. - Wrap the root outlet for the home route with ParticleBackground only on the index path to limit the effect to the intended view. - Clean up imports and code style in radio group and other UI primitives for consistency. Chores: - Remove deprecated UI stores and utility modules that are no longer used with the new architecture. - Add changeset entries documenting the Saturn effect refactor, ParticleBackground rewrite, and removal of legacy store code.
Diffstat (limited to 'packages')
-rw-r--r--packages/ui/src/components/bottom-bar.tsx44
-rw-r--r--packages/ui/src/components/instance-creation-modal.tsx544
-rw-r--r--packages/ui/src/components/instance-editor-modal.tsx29
-rw-r--r--packages/ui/src/components/particle-background.tsx68
-rw-r--r--packages/ui/src/components/sidebar.tsx12
-rw-r--r--packages/ui/src/components/ui/accordion.tsx77
-rw-r--r--packages/ui/src/components/ui/alert-dialog.tsx186
-rw-r--r--packages/ui/src/components/ui/button.tsx2
-rw-r--r--packages/ui/src/components/ui/field.tsx46
-rw-r--r--packages/ui/src/components/ui/label.tsx2
-rw-r--r--packages/ui/src/components/ui/radio-group.tsx14
-rw-r--r--packages/ui/src/lib/effects/saturn.ts (renamed from packages/ui/src/lib/effects/SaturnEffect.ts)18
-rw-r--r--packages/ui/src/lib/tsrs-utils.ts67
-rw-r--r--packages/ui/src/main.tsx28
-rw-r--r--packages/ui/src/models/assistant-store.ts.bk (renamed from packages/ui/src/stores/assistant-store.ts)0
-rw-r--r--packages/ui/src/models/auth.ts5
-rw-r--r--packages/ui/src/models/game.ts113
-rw-r--r--packages/ui/src/models/instance.ts23
-rw-r--r--packages/ui/src/models/logs-store.ts.bk (renamed from packages/ui/src/stores/logs-store.ts)0
-rw-r--r--packages/ui/src/models/settings-store.ts.bk (renamed from packages/ui/src/stores/settings-store.ts)0
-rw-r--r--packages/ui/src/pages/assistant-view.tsx.bk485
-rw-r--r--packages/ui/src/pages/home.tsx (renamed from packages/ui/src/pages/home-view.tsx)100
-rw-r--r--packages/ui/src/pages/index.tsx17
-rw-r--r--packages/ui/src/pages/instances/create.tsx746
-rw-r--r--packages/ui/src/pages/instances/index.tsx (renamed from packages/ui/src/pages/instances-view.tsx)77
-rw-r--r--packages/ui/src/pages/instances/routes.ts19
-rw-r--r--packages/ui/src/pages/routes.ts25
-rw-r--r--packages/ui/src/stores/auth-store.ts296
-rw-r--r--packages/ui/src/stores/game-store.ts182
-rw-r--r--packages/ui/src/stores/releases-store.ts63
-rw-r--r--packages/ui/src/stores/ui-store.ts42
31 files changed, 1350 insertions, 1980 deletions
diff --git a/packages/ui/src/components/bottom-bar.tsx b/packages/ui/src/components/bottom-bar.tsx
index 8f70985..f73ace4 100644
--- a/packages/ui/src/components/bottom-bar.tsx
+++ b/packages/ui/src/components/bottom-bar.tsx
@@ -1,10 +1,10 @@
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";
+import { useGameStore } from "@/models/game";
import { useInstanceStore } from "@/models/instance";
-import { useGameStore } from "@/stores/game-store";
import { LoginModal } from "./login-modal";
import { Button } from "./ui/button";
import {
@@ -19,27 +19,17 @@ import { Spinner } from "./ui/spinner";
export function BottomBar() {
const account = useAuthStore((state) => state.account);
- const instances = useInstanceStore((state) => state.instances);
- const activeInstance = useInstanceStore((state) => state.activeInstance);
- const setActiveInstance = useInstanceStore((state) => state.setActiveInstance);
- const selectedVersion = useGameStore((state) => state.selectedVersion);
- const setSelectedVersion = useGameStore((state) => state.setSelectedVersion);
- const startGame = useGameStore((state) => state.startGame);
- const stopGame = useGameStore((state) => state.stopGame);
- const runningInstanceId = useGameStore((state) => state.runningInstanceId);
- const launchingInstanceId = useGameStore((state) => state.launchingInstanceId);
- const stoppingInstanceId = useGameStore((state) => state.stoppingInstanceId);
- const [showLoginModal, setShowLoginModal] = useState(false);
-
- useEffect(() => {
- const nextVersion = activeInstance?.versionId ?? "";
- if (selectedVersion === nextVersion) {
- return;
- }
+ const { instances, activeInstance, setActiveInstance } = useInstanceStore();
+ const {
+ runningInstanceId,
+ launchingInstanceId,
+ stoppingInstanceId,
+ startGame,
+ stopGame,
+ } = useGameStore();
- setSelectedVersion(nextVersion);
- }, [activeInstance?.id, activeInstance?.versionId, selectedVersion, setSelectedVersion]);
+ const [showLoginModal, setShowLoginModal] = useState(false);
const handleInstanceChange = useCallback(
async (instanceId: string) => {
@@ -47,7 +37,9 @@ export function BottomBar() {
return;
}
- const nextInstance = instances.find((instance) => instance.id === instanceId);
+ const nextInstance = instances.find(
+ (instance) => instance.id === instanceId,
+ );
if (!nextInstance) {
return;
}
@@ -68,13 +60,7 @@ export function BottomBar() {
return;
}
- await startGame(
- account,
- () => setShowLoginModal(true),
- activeInstance.id,
- selectedVersion || activeInstance.versionId,
- () => undefined,
- );
+ 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/instance-editor-modal.tsx b/packages/ui/src/components/instance-editor-modal.tsx
index d964185..105d7e9 100644
--- a/packages/ui/src/components/instance-editor-modal.tsx
+++ b/packages/ui/src/components/instance-editor-modal.tsx
@@ -1,8 +1,12 @@
-import { invoke } from "@tauri-apps/api/core";
+import { toNumber } from "es-toolkit/compat";
import { Folder, Loader2, Save, Trash2, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
-
+import {
+ deleteInstanceFile,
+ listInstanceDirectory,
+ openFileExplorer,
+} from "@/client";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -14,8 +18,6 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
-
-import { toNumber } from "@/lib/tsrs-utils";
import { useInstanceStore } from "@/models/instance";
import { useSettingsStore } from "@/models/settings";
import type { FileInfo } from "../types/bindings/core";
@@ -94,14 +96,11 @@ export function InstanceEditorModal({ open, instance, onOpenChange }: Props) {
if (!instance) return;
setLoadingFiles(true);
try {
- const files = await invoke<FileInfo[]>("list_instance_directory", {
- instanceId: instance.id,
- folder,
- });
- setFileList(files || []);
+ const files = await listInstanceDirectory(instance.id, folder);
+ setFileList(files);
} catch (err) {
console.error("Failed to load files:", err);
- toast.error("Failed to load files: " + String(err));
+ toast.error(`Failed to load files: ${String(err)}`);
setFileList([]);
} finally {
setLoadingFiles(false);
@@ -135,13 +134,13 @@ export function InstanceEditorModal({ open, instance, onOpenChange }: Props) {
}
setDeletingPath(filePath);
try {
- await invoke("delete_instance_file", { path: filePath });
+ await deleteInstanceFile(filePath);
// refresh the currently selected folder
await loadFileList(selectedFileFolder);
toast.success("Deleted");
} catch (err) {
console.error("Failed to delete file:", err);
- toast.error("Failed to delete file: " + String(err));
+ toast.error(`Failed to delete file: ${String(err)}`);
} finally {
setDeletingPath(null);
}
@@ -149,10 +148,10 @@ export function InstanceEditorModal({ open, instance, onOpenChange }: Props) {
async function openInExplorer(filePath: string) {
try {
- await invoke("open_file_explorer", { path: filePath });
+ await openFileExplorer(filePath);
} catch (err) {
console.error("Failed to open in explorer:", err);
- toast.error("Failed to open file explorer: " + String(err));
+ toast.error(`Failed to open file explorer: ${String(err)}`);
}
}
@@ -184,7 +183,7 @@ export function InstanceEditorModal({ open, instance, onOpenChange }: Props) {
} catch (err) {
console.error("Failed to save instance:", err);
setErrorMessage(String(err));
- toast.error("Failed to save instance: " + String(err));
+ toast.error(`Failed to save instance: ${String(err)}`);
} finally {
setSaving(false);
}
diff --git a/packages/ui/src/components/particle-background.tsx b/packages/ui/src/components/particle-background.tsx
index 2e0b15a..2bf6793 100644
--- a/packages/ui/src/components/particle-background.tsx
+++ b/packages/ui/src/components/particle-background.tsx
@@ -1,63 +1,55 @@
-import { useEffect, useRef } from "react";
-import { SaturnEffect } from "../lib/effects/SaturnEffect";
+import { createContext, useContext, useEffect, useRef, useState } from "react";
+import { SaturnEffect } from "@/lib/effects/saturn";
-export function ParticleBackground() {
+const SaturnEffectContext = createContext<SaturnEffect | null>(null);
+
+export function useSaturnEffect() {
+ return useContext(SaturnEffectContext);
+}
+
+export function ParticleBackground({
+ children,
+}: {
+ children?: React.ReactNode;
+}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
- const effectRef = useRef<SaturnEffect | null>(null);
+ const [effect, setEffect] = useState<SaturnEffect | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
- // Instantiate SaturnEffect and attach to canvas
- let effect: SaturnEffect | null = null;
+ let saturnEffect: SaturnEffect | null = null;
try {
- effect = new SaturnEffect(canvas);
- effectRef.current = effect;
+ saturnEffect = new SaturnEffect(canvas);
+ setEffect(saturnEffect);
} catch (err) {
- // If effect fails, silently degrade (keep background blank)
- // eslint-disable-next-line no-console
console.warn("SaturnEffect initialization failed:", err);
}
const resizeHandler = () => {
- if (effectRef.current) {
- try {
- effectRef.current.resize(window.innerWidth, window.innerHeight);
- } catch {
- // ignore
- }
- }
+ saturnEffect?.resize(window.innerWidth, window.innerHeight);
};
window.addEventListener("resize", resizeHandler);
- // Expose getter for HomeView interactions (getSaturnEffect)
- // HomeView will call window.getSaturnEffect()?.handleMouseDown/Move/Up
- (
- window as unknown as { getSaturnEffect?: () => SaturnEffect | null }
- ).getSaturnEffect = () => effectRef.current;
-
return () => {
window.removeEventListener("resize", resizeHandler);
- if (effectRef.current) {
- try {
- effectRef.current.destroy();
- } catch {
- // ignore
- }
- }
- effectRef.current = null;
- (
- window as unknown as { getSaturnEffect?: () => SaturnEffect | null }
- ).getSaturnEffect = undefined;
+ saturnEffect?.destroy();
+
+ setEffect(null);
};
}, []);
return (
- <canvas
- ref={canvasRef}
- className="absolute inset-0 z-0 pointer-events-none"
- />
+ <SaturnEffectContext.Provider value={effect}>
+ <canvas
+ ref={canvasRef}
+ className="absolute inset-0 -z-10 pointer-events-none"
+ />
+ {children}
+ </SaturnEffectContext.Provider>
);
}
+
+export default ParticleBackground;
diff --git a/packages/ui/src/components/sidebar.tsx b/packages/ui/src/components/sidebar.tsx
index d81156f..e615274 100644
--- a/packages/ui/src/components/sidebar.tsx
+++ b/packages/ui/src/components/sidebar.tsx
@@ -23,10 +23,6 @@ function NavItem({ Icon, label, to }: NavItemProps) {
const location = useLocation();
const isActive = location.pathname === to;
- const handleClick = () => {
- navigate(to);
- };
-
return (
<Button
variant="ghost"
@@ -35,7 +31,7 @@ function NavItem({ Icon, label, to }: NavItemProps) {
isActive && "relative bg-accent",
)}
size="lg"
- onClick={handleClick}
+ onClick={() => navigate(to)}
>
<Icon className="size-5" strokeWidth={isActive ? 2.5 : 2} />
<span className="hidden lg:block text-sm relative z-10">{label}</span>
@@ -185,7 +181,11 @@ export function Sidebar() {
<div className="w-full lg:px-3 flex-1 flex flex-col justify-end">
<DropdownMenu>
- <DropdownMenuTrigger render={renderUserAvatar()} className="w-full">
+ <DropdownMenuTrigger
+ render={renderUserAvatar()}
+ nativeButton={false}
+ className="w-full"
+ >
Open
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="right" sideOffset={20}>
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/alert-dialog.tsx b/packages/ui/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..27c9f77
--- /dev/null
+++ b/packages/ui/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,186 @@
+"use client";
+
+import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
+import type * as React from "react";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
+ return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
+}
+
+function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
+ return (
+ <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
+ );
+}
+
+function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
+ return (
+ <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
+ );
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: AlertDialogPrimitive.Backdrop.Props) {
+ return (
+ <AlertDialogPrimitive.Backdrop
+ data-slot="alert-dialog-overlay"
+ className={cn(
+ "fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AlertDialogContent({
+ className,
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Popup.Props & {
+ size?: "default" | "sm";
+}) {
+ return (
+ <AlertDialogPortal>
+ <AlertDialogOverlay />
+ <AlertDialogPrimitive.Popup
+ data-slot="alert-dialog-content"
+ data-size={size}
+ className={cn(
+ "group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-none bg-background p-4 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
+ className,
+ )}
+ {...props}
+ />
+ </AlertDialogPortal>
+ );
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="alert-dialog-header"
+ className={cn(
+ "grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="alert-dialog-footer"
+ className={cn(
+ "flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AlertDialogMedia({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="alert-dialog-media"
+ className={cn(
+ "mb-2 inline-flex size-10 items-center justify-center rounded-none bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
+ return (
+ <AlertDialogPrimitive.Title
+ data-slot="alert-dialog-title"
+ className={cn(
+ "text-sm font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
+ return (
+ <AlertDialogPrimitive.Description
+ data-slot="alert-dialog-description"
+ className={cn(
+ "text-xs/relaxed text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps<typeof Button>) {
+ return (
+ <Button
+ data-slot="alert-dialog-action"
+ className={cn(className)}
+ {...props}
+ />
+ );
+}
+
+function AlertDialogCancel({
+ className,
+ variant = "outline",
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Close.Props &
+ Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
+ return (
+ <AlertDialogPrimitive.Close
+ data-slot="alert-dialog-cancel"
+ className={cn(className)}
+ render={<Button variant={variant} size={size} />}
+ {...props}
+ />
+ );
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+};
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 ab9fb71..226e302 100644
--- a/packages/ui/src/components/ui/field.tsx
+++ b/packages/ui/src/components/ui/field.tsx
@@ -9,7 +9,7 @@ function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
<fieldset
data-slot="field-set"
className={cn(
- "gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col",
+ "flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className,
)}
{...props}
@@ -40,7 +40,7 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="field-group"
className={cn(
- "gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4 group/field-group @container/field-group flex w-full flex-col",
+ "group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className,
)}
{...props}
@@ -49,15 +49,15 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
}
const fieldVariants = cva(
- "data-[invalid=true]:text-destructive gap-2 group/field flex w-full",
+ "group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
- "flex-row items-center *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
- "flex-col *:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:*:data-[slot=field-label]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
@@ -72,7 +72,9 @@ function Field({
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
+ // biome-ignore lint/a11y/useSemanticElements: shadcn component
<div
+ role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
@@ -86,7 +88,7 @@ function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="field-content"
className={cn(
- "gap-0.5 group/field-content flex flex-1 flex-col leading-snug",
+ "group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className,
)}
{...props}
@@ -96,18 +98,26 @@ 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"
className={cn(
- "has-data-checked:bg-primary/5 has-data-checked:border-primary/30 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 gap-2 group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-none has-[>[data-slot=field]]:border *:data-[slot=field]:p-2 group/field-label peer/field-label flex w-fit leading-snug",
+ "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-none has-[>[data-slot=field]]:border *:data-[slot=field]:p-2 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"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>
);
}
@@ -116,7 +126,7 @@ function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="field-label"
className={cn(
- "gap-2 text-xs/relaxed group-data-[disabled=true]/field:opacity-50 flex w-fit items-center leading-snug",
+ "flex w-fit items-center gap-2 text-xs/relaxed leading-snug group-data-[disabled=true]/field:opacity-50",
className,
)}
{...props}
@@ -129,9 +139,9 @@ function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
<p
data-slot="field-description"
className={cn(
- "text-muted-foreground text-left text-xs/relaxed [[data-variant=legend]+&]:-mt-1.5 leading-normal font-normal group-has-data-horizontal/field:text-balance",
+ "text-left text-xs/relaxed leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
- "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
+ "[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className,
)}
{...props}
@@ -151,7 +161,7 @@ function FieldSeparator({
data-slot="field-separator"
data-content={!!children}
className={cn(
- "-my-2 h-5 text-xs group-data-[variant=outline]/field-group:-mb-2 relative",
+ "relative -my-2 h-5 text-xs group-data-[variant=outline]/field-group:-mb-2",
className,
)}
{...props}
@@ -159,7 +169,7 @@ function FieldSeparator({
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
- className="text-muted-foreground px-2 bg-background relative mx-auto block w-fit"
+ className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
@@ -197,11 +207,9 @@ function FieldError({
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
- (error, index) =>
+ (error) =>
error?.message && (
- <li key={`${error.message.slice(6)}-${index}`}>
- {error.message}
- </li>
+ <li key={`field-error-${error.message}`}>{error.message}</li>
),
)}
</ul>
@@ -216,7 +224,7 @@ function FieldError({
<div
role="alert"
data-slot="field-error"
- className={cn("text-destructive text-xs font-normal", className)}
+ className={cn("text-xs font-normal text-destructive", className)}
{...props}
>
{content}
diff --git a/packages/ui/src/components/ui/label.tsx b/packages/ui/src/components/ui/label.tsx
index 9a998c7..0d40c81 100644
--- a/packages/ui/src/components/ui/label.tsx
+++ b/packages/ui/src/components/ui/label.tsx
@@ -8,7 +8,7 @@ function Label({ className, ...props }: React.ComponentProps<"label">) {
<label
data-slot="label"
className={cn(
- "gap-2 text-xs leading-none group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
+ "flex items-center gap-2 text-xs leading-none select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
diff --git a/packages/ui/src/components/ui/radio-group.tsx b/packages/ui/src/components/ui/radio-group.tsx
index d8b39dd..df831e8 100644
--- a/packages/ui/src/components/ui/radio-group.tsx
+++ b/packages/ui/src/components/ui/radio-group.tsx
@@ -1,7 +1,7 @@
-import { Radio as RadioPrimitive } from "@base-ui/react/radio"
-import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
+import { Radio as RadioPrimitive } from "@base-ui/react/radio";
+import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group";
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils";
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
return (
@@ -10,7 +10,7 @@ function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
className={cn("grid w-full gap-2", className)}
{...props}
/>
- )
+ );
}
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
@@ -19,7 +19,7 @@ function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
data-slot="radio-group-item"
className={cn(
"border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:aria-invalid:border-destructive/50 group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3",
- className
+ className,
)}
{...props}
>
@@ -30,7 +30,7 @@ function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
<span className="bg-primary-foreground absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full" />
</RadioPrimitive.Indicator>
</RadioPrimitive.Root>
- )
+ );
}
-export { RadioGroup, RadioGroupItem }
+export { RadioGroup, RadioGroupItem };
diff --git a/packages/ui/src/lib/effects/SaturnEffect.ts b/packages/ui/src/lib/effects/saturn.ts
index 497a340..f7fcfe5 100644
--- a/packages/ui/src/lib/effects/SaturnEffect.ts
+++ b/packages/ui/src/lib/effects/saturn.ts
@@ -1,21 +1,3 @@
-/**
- * Ported SaturnEffect for the React UI (ui-new).
- * Adapted from the original Svelte implementation but written as a standalone
- * TypeScript class that manages a 2D canvas particle effect resembling a
- * rotating "Saturn" with rings. Designed to be instantiated and controlled
- * from a React component (e.g. ParticleBackground).
- *
- * Usage:
- * const effect = new SaturnEffect(canvasElement);
- * effect.handleMouseDown(clientX);
- * effect.handleMouseMove(clientX);
- * effect.handleMouseUp();
- * // on resize:
- * effect.resize(width, height);
- * // on unmount:
- * effect.destroy();
- */
-
export class SaturnEffect {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
diff --git a/packages/ui/src/lib/tsrs-utils.ts b/packages/ui/src/lib/tsrs-utils.ts
deleted file mode 100644
index f48f851..0000000
--- a/packages/ui/src/lib/tsrs-utils.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-export type Maybe<T> = T | null | undefined;
-
-export function toNumber(
- value: Maybe<number | bigint | string>,
- fallback = 0,
-): number {
- if (value === null || value === undefined) return fallback;
-
- if (typeof value === "number") {
- if (Number.isFinite(value)) return value;
- return fallback;
- }
-
- if (typeof value === "bigint") {
- // safe conversion for typical values (timestamps, sizes). Might overflow for huge bigint.
- return Number(value);
- }
-
- if (typeof value === "string") {
- const n = Number(value);
- return Number.isFinite(n) ? n : fallback;
- }
-
- return fallback;
-}
-
-/**
- * Like `toNumber` but ensures non-negative result (clamps at 0).
- */
-export function toNonNegativeNumber(
- value: Maybe<number | bigint | string>,
- fallback = 0,
-): number {
- const n = toNumber(value, fallback);
- return n < 0 ? 0 : n;
-}
-
-export function toDate(
- value: Maybe<number | bigint | string>,
- opts?: { isSeconds?: boolean },
-): Date | null {
- if (value === null || value === undefined) return null;
-
- const isSeconds = opts?.isSeconds ?? true;
-
- // accept bigint, number, numeric string
- const n = toNumber(value, NaN);
- if (Number.isNaN(n)) return null;
-
- const ms = isSeconds ? Math.floor(n) * 1000 : Math.floor(n);
- return new Date(ms);
-}
-
-/**
- * Convert a binding boolean-ish value (0/1, "true"/"false", boolean) to boolean.
- */
-export function toBoolean(value: unknown, fallback = false): boolean {
- if (value === null || value === undefined) return fallback;
- if (typeof value === "boolean") return value;
- if (typeof value === "number") return value !== 0;
- if (typeof value === "string") {
- const s = value.toLowerCase().trim();
- if (s === "true" || s === "1") return true;
- if (s === "false" || s === "0") return false;
- }
- return fallback;
-}
diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx
index c5cbfc8..912fea8 100644
--- a/packages/ui/src/main.tsx
+++ b/packages/ui/src/main.tsx
@@ -1,33 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
-import { createHashRouter, RouterProvider } from "react-router";
+import { RouterProvider } from "react-router";
import { Toaster } from "./components/ui/sonner";
-import { HomeView } from "./pages/home-view";
-import { IndexPage } from "./pages/index";
-import { InstancesView } from "./pages/instances-view";
-import { SettingsPage } from "./pages/settings";
-
-const router = createHashRouter([
- {
- path: "/",
- element: <IndexPage />,
- children: [
- {
- index: true,
- element: <HomeView />,
- },
- {
- path: "instances",
- element: <InstancesView />,
- },
- {
- path: "settings",
- element: <SettingsPage />,
- },
- ],
- },
-]);
+import router from "./pages/routes";
const root = createRoot(document.getElementById("root") as HTMLElement);
root.render(
diff --git a/packages/ui/src/stores/assistant-store.ts b/packages/ui/src/models/assistant-store.ts.bk
index 180031b..180031b 100644
--- a/packages/ui/src/stores/assistant-store.ts
+++ b/packages/ui/src/models/assistant-store.ts.bk
diff --git a/packages/ui/src/models/auth.ts b/packages/ui/src/models/auth.ts
index 9c814d2..d64b67a 100644
--- a/packages/ui/src/models/auth.ts
+++ b/packages/ui/src/models/auth.ts
@@ -95,7 +95,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
} catch (error) {
const message = getAuthErrorMessage(error);
console.error("Failed to start Microsoft login:", error);
- set({ loginMode: null, statusMessage: `Failed to start login: ${message}` });
+ set({
+ loginMode: null,
+ statusMessage: `Failed to start login: ${message}`,
+ });
toast.error(`Failed to start Microsoft login: ${message}`);
}
},
diff --git a/packages/ui/src/models/game.ts b/packages/ui/src/models/game.ts
new file mode 100644
index 0000000..5342078
--- /dev/null
+++ b/packages/ui/src/models/game.ts
@@ -0,0 +1,113 @@
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { toast } from "sonner";
+import { create } from "zustand";
+import {
+ startGame as startGameCommand,
+ stopGame as stopGameCommand,
+} from "@/client";
+import type { GameExitedEvent } from "@/types/bindings/core";
+
+interface GameState {
+ runningInstanceId: string | null;
+ runningVersionId: string | null;
+ launchingInstanceId: string | null;
+ stoppingInstanceId: string | null;
+ lifecycleUnlisten: UnlistenFn | null;
+
+ isGameRunning: boolean;
+ startGame: (instanceId: string, versionId: string) => Promise<string | null>;
+ stopGame: (instanceId?: string | null) => Promise<string | null>;
+}
+
+export const useGameStore = create<GameState>((set, get) => ({
+ runningInstanceId: null,
+ runningVersionId: null,
+ launchingInstanceId: null,
+ stoppingInstanceId: null,
+ lifecycleUnlisten: null,
+
+ get isGameRunning() {
+ return get().runningInstanceId !== null;
+ },
+
+ startGame: async (instanceId, versionId) => {
+ const { isGameRunning, lifecycleUnlisten } = get();
+
+ if (isGameRunning) {
+ toast.info("A game is already running");
+ return null;
+ } else {
+ lifecycleUnlisten?.();
+ }
+
+ set({
+ launchingInstanceId: instanceId,
+ });
+ toast.info(`Preparing to launch ${versionId}...`);
+
+ const unlisten = await listen<GameExitedEvent>("game-exited", (event) => {
+ const { instanceId, versionId, wasStopped, exitCode } = event.payload;
+
+ set({
+ runningInstanceId: null,
+ runningVersionId: null,
+ launchingInstanceId: null,
+ stoppingInstanceId: null,
+ });
+
+ if (wasStopped) {
+ toast.success(
+ `Stopped Minecraft ${versionId} for instance ${instanceId}`,
+ );
+ } else {
+ toast.info(
+ `Minecraft ${versionId} exited with code ${exitCode} for instance ${instanceId}`,
+ );
+ }
+ });
+
+ set({ lifecycleUnlisten: unlisten });
+
+ try {
+ const message = await startGameCommand(instanceId, versionId);
+ set({
+ launchingInstanceId: null,
+ runningInstanceId: instanceId,
+ runningVersionId: versionId,
+ });
+ toast.success(message);
+ return message;
+ } catch (e) {
+ console.error(e);
+ set({ launchingInstanceId: null });
+ toast.error(`Error: ${e}`);
+ return null;
+ }
+ },
+
+ stopGame: async (instanceId) => {
+ const { runningInstanceId } = get();
+
+ if (!runningInstanceId) {
+ toast.info("No running game found");
+ return null;
+ }
+
+ if (instanceId !== runningInstanceId) {
+ toast.info("That instance is not the one currently running");
+ return null;
+ }
+
+ set({ stoppingInstanceId: runningInstanceId });
+
+ try {
+ return await stopGameCommand();
+ } catch (e) {
+ console.error("Failed to stop game:", e);
+ toast.error(`Failed to stop game: ${e}`);
+ return null;
+ } finally {
+ set({ stoppingInstanceId: null });
+ }
+ },
+}));
diff --git a/packages/ui/src/models/instance.ts b/packages/ui/src/models/instance.ts
index e1eb7c1..8c108c1 100644
--- a/packages/ui/src/models/instance.ts
+++ b/packages/ui/src/models/instance.ts
@@ -20,13 +20,16 @@ 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>;
duplicate: (id: string, newName: string) => Promise<Instance | null>;
exportArchive: (id: string, archivePath: string) => Promise<void>;
- importArchive: (archivePath: string, newName?: string) => Promise<Instance | null>;
+ importArchive: (
+ archivePath: string,
+ newName?: string,
+ ) => Promise<Instance | null>;
repair: () => Promise<void>;
get: (id: string) => Promise<Instance | null>;
}
@@ -61,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/stores/logs-store.ts b/packages/ui/src/models/logs-store.ts.bk
index b19f206..b19f206 100644
--- a/packages/ui/src/stores/logs-store.ts
+++ b/packages/ui/src/models/logs-store.ts.bk
diff --git a/packages/ui/src/stores/settings-store.ts b/packages/ui/src/models/settings-store.ts.bk
index 0bfc1e1..0bfc1e1 100644
--- a/packages/ui/src/stores/settings-store.ts
+++ b/packages/ui/src/models/settings-store.ts.bk
diff --git a/packages/ui/src/pages/assistant-view.tsx.bk b/packages/ui/src/pages/assistant-view.tsx.bk
deleted file mode 100644
index 56f827b..0000000
--- a/packages/ui/src/pages/assistant-view.tsx.bk
+++ /dev/null
@@ -1,485 +0,0 @@
-import {
- AlertTriangle,
- Bot,
- Brain,
- ChevronDown,
- Loader2,
- RefreshCw,
- Send,
- Settings,
- Trash2,
-} from "lucide-react";
-import { marked } from "marked";
-import { useCallback, useEffect, useRef, useState } from "react";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import { Card, CardContent } from "@/components/ui/card";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { Separator } from "@/components/ui/separator";
-import { Textarea } from "@/components/ui/textarea";
-import { toNumber } from "@/lib/tsrs-utils";
-import { type Message, useAssistantStore } from "../stores/assistant-store";
-import { useSettingsStore } from "../stores/settings-store";
-import { useUiStore } from "../stores/ui-store";
-
-interface ParsedMessage {
- thinking: string | null;
- content: string;
- isThinking: boolean;
-}
-
-function parseMessageContent(content: string): ParsedMessage {
- if (!content) return { thinking: null, content: "", isThinking: false };
-
- // Support both <thinking> and <think> (DeepSeek uses <think>)
- let startTag = "<thinking>";
- let endTag = "</thinking>";
- let startIndex = content.indexOf(startTag);
-
- if (startIndex === -1) {
- startTag = "<think>";
- endTag = "</think>";
- startIndex = content.indexOf(startTag);
- }
-
- // Also check for encoded tags if they weren't decoded properly
- if (startIndex === -1) {
- startTag = "\u003cthink\u003e";
- endTag = "\u003c/think\u003e";
- startIndex = content.indexOf(startTag);
- }
-
- if (startIndex !== -1) {
- const endIndex = content.indexOf(endTag, startIndex);
-
- if (endIndex !== -1) {
- // Completed thinking block
- const before = content.substring(0, startIndex);
- const thinking = content
- .substring(startIndex + startTag.length, endIndex)
- .trim();
- const after = content.substring(endIndex + endTag.length);
-
- return {
- thinking,
- content: (before + after).trim(),
- isThinking: false,
- };
- } else {
- // Incomplete thinking block (still streaming)
- const before = content.substring(0, startIndex);
- const thinking = content.substring(startIndex + startTag.length).trim();
-
- return {
- thinking,
- content: before.trim(),
- isThinking: true,
- };
- }
- }
-
- return { thinking: null, content, isThinking: false };
-}
-
-function renderMarkdown(content: string): string {
- if (!content) return "";
- try {
- return marked(content, { breaks: true, gfm: true }) as string;
- } catch {
- return content;
- }
-}
-
-export function AssistantView() {
- const {
- messages,
- isProcessing,
- isProviderHealthy,
- streamingContent,
- init,
- checkHealth,
- sendMessage,
- clearHistory,
- } = useAssistantStore();
- const { settings } = useSettingsStore();
- const { setView } = useUiStore();
-
- const [input, setInput] = useState("");
- const messagesEndRef = useRef<HTMLDivElement>(null);
- const messagesContainerRef = useRef<HTMLDivElement>(null);
-
- const provider = settings.assistant.llmProvider;
- const endpoint =
- provider === "ollama"
- ? settings.assistant.ollamaEndpoint
- : settings.assistant.openaiEndpoint;
- const model =
- provider === "ollama"
- ? settings.assistant.ollamaModel
- : settings.assistant.openaiModel;
-
- const getProviderName = (): string => {
- if (provider === "ollama") {
- return `Ollama (${model})`;
- } else if (provider === "openai") {
- return `OpenAI (${model})`;
- }
- return provider;
- };
-
- const getProviderHelpText = (): string => {
- if (provider === "ollama") {
- return `Please ensure Ollama is installed and running at ${endpoint}.`;
- } else if (provider === "openai") {
- return "Please check your OpenAI API key in Settings > AI Assistant.";
- }
- return "";
- };
-
- const scrollToBottom = useCallback(() => {
- if (messagesContainerRef.current) {
- setTimeout(() => {
- if (messagesContainerRef.current) {
- messagesContainerRef.current.scrollTop =
- messagesContainerRef.current.scrollHeight;
- }
- }, 0);
- }
- }, []);
-
- useEffect(() => {
- init();
- }, [init]);
-
- useEffect(() => {
- if (messages.length > 0 || isProcessing) {
- scrollToBottom();
- }
- }, [messages.length, isProcessing, scrollToBottom]);
-
- const handleSubmit = async () => {
- if (!input.trim() || isProcessing) return;
- const text = input;
- setInput("");
- await sendMessage(text, settings.assistant.enabled, provider, endpoint);
- };
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- handleSubmit();
- }
- };
-
- const renderMessage = (message: Message, index: number) => {
- const isUser = message.role === "user";
- const parsed = parseMessageContent(message.content);
-
- return (
- <div
- key={index}
- className={`flex ${isUser ? "justify-end" : "justify-start"} mb-4`}
- >
- <div
- className={`max-w-[80%] rounded-2xl px-4 py-3 ${
- isUser
- ? "bg-indigo-500 text-white rounded-br-none"
- : "bg-zinc-800 text-zinc-100 rounded-bl-none"
- }`}
- >
- {!isUser && parsed.thinking && (
- <div className="mb-3 max-w-full overflow-hidden">
- <details className="group" open={parsed.isThinking}>
- <summary className="list-none cursor-pointer flex items-center gap-2 text-zinc-500 hover:text-zinc-300 transition-colors text-xs font-medium select-none bg-black/20 p-2 rounded-lg border border-white/5 w-fit mb-2 outline-none">
- <Brain className="h-3 w-3" />
- <span>Thinking Process</span>
- <ChevronDown className="h-3 w-3 transition-transform duration-200 group-open:rotate-180" />
- </summary>
- <div className="pl-3 border-l-2 border-zinc-700 text-zinc-500 text-xs italic leading-relaxed whitespace-pre-wrap font-mono max-h-96 overflow-y-auto custom-scrollbar bg-black/10 p-2 rounded-r-md">
- {parsed.thinking}
- {parsed.isThinking && (
- <span className="inline-block w-1.5 h-3 bg-zinc-500 ml-1 animate-pulse align-middle" />
- )}
- </div>
- </details>
- </div>
- )}
- <div
- className="prose prose-invert max-w-none"
- dangerouslySetInnerHTML={{
- __html: renderMarkdown(parsed.content),
- }}
- />
- {!isUser && message.stats && (
- <div className="mt-2 pt-2 border-t border-zinc-700/50">
- <div className="text-xs text-zinc-400">
- {message.stats.evalCount} tokens ·{" "}
- {Math.round(toNumber(message.stats.totalDuration) / 1000000)}
- ms
- </div>
- </div>
- )}
- </div>
- </div>
- );
- };
-
- return (
- <div className="h-full w-full flex flex-col gap-4 p-4 lg:p-8">
- <div className="flex items-center justify-between mb-2">
- <div className="flex items-center gap-3">
- <div className="p-2 bg-indigo-500/20 rounded-lg text-indigo-400">
- <Bot size={24} />
- </div>
- <div>
- <h2 className="text-2xl font-bold">Game Assistant</h2>
- <p className="text-zinc-400 text-sm">
- Powered by {getProviderName()}
- </p>
- </div>
- </div>
-
- <div className="flex items-center gap-2">
- {!settings.assistant.enabled ? (
- <Badge
- variant="outline"
- className="bg-zinc-500/10 text-zinc-400 border-zinc-500/20"
- >
- <AlertTriangle className="h-3 w-3 mr-1" />
- Disabled
- </Badge>
- ) : !isProviderHealthy ? (
- <Badge
- variant="outline"
- className="bg-red-500/10 text-red-400 border-red-500/20"
- >
- <AlertTriangle className="h-3 w-3 mr-1" />
- Offline
- </Badge>
- ) : (
- <Badge
- variant="outline"
- className="bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
- >
- <div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse mr-1" />
- Online
- </Badge>
- )}
-
- <Button
- variant="ghost"
- size="icon"
- onClick={checkHealth}
- title="Check Connection"
- disabled={isProcessing}
- >
- <RefreshCw
- className={`h-4 w-4 ${isProcessing ? "animate-spin" : ""}`}
- />
- </Button>
-
- <Button
- variant="ghost"
- size="icon"
- onClick={clearHistory}
- title="Clear History"
- disabled={isProcessing}
- >
- <Trash2 className="h-4 w-4" />
- </Button>
-
- <Button
- variant="ghost"
- size="icon"
- onClick={() => setView("settings")}
- title="Settings"
- >
- <Settings className="h-4 w-4" />
- </Button>
- </div>
- </div>
-
- {/* Chat Area */}
- <div className="flex-1 bg-black/20 border border-white/5 rounded-xl overflow-hidden flex flex-col relative">
- {/* Warning when assistant is disabled */}
- {!settings.assistant.enabled && (
- <div className="absolute top-4 left-1/2 transform -translate-x-1/2 z-10">
- <Card className="bg-yellow-500/10 border-yellow-500/20">
- <CardContent className="p-3 flex items-center gap-2">
- <AlertTriangle className="h-4 w-4 text-yellow-500" />
- <span className="text-yellow-500 text-sm font-medium">
- Assistant is disabled. Enable it in Settings &gt; AI
- Assistant.
- </span>
- </CardContent>
- </Card>
- </div>
- )}
-
- {/* Provider offline warning */}
- {settings.assistant.enabled && !isProviderHealthy && (
- <div className="absolute top-4 left-1/2 transform -translate-x-1/2 z-10">
- <Card className="bg-red-500/10 border-red-500/20">
- <CardContent className="p-3 flex items-center gap-2">
- <AlertTriangle className="h-4 w-4 text-red-500" />
- <div className="flex flex-col">
- <span className="text-red-500 text-sm font-medium">
- Assistant is offline
- </span>
- <span className="text-red-400 text-xs">
- {getProviderHelpText()}
- </span>
- </div>
- </CardContent>
- </Card>
- </div>
- )}
-
- {/* Messages Container */}
- <ScrollArea className="flex-1 p-4 lg:p-6" ref={messagesContainerRef}>
- {messages.length === 0 ? (
- <div className="flex flex-col items-center justify-center h-full text-zinc-400 gap-4 mt-8">
- <div className="p-4 bg-zinc-800/50 rounded-full">
- <Bot className="h-12 w-12" />
- </div>
- <h3 className="text-xl font-medium">How can I help you today?</h3>
- <p className="text-center max-w-md text-sm">
- I can analyze your game logs, diagnose crashes, or explain mod
- features.
- {!settings.assistant.enabled && (
- <span className="block mt-2 text-yellow-500">
- Assistant is disabled. Enable it in{" "}
- <button
- type="button"
- onClick={() => setView("settings")}
- className="text-indigo-400 hover:underline"
- >
- Settings &gt; AI Assistant
- </button>
- .
- </span>
- )}
- </p>
- <div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-2 max-w-lg">
- <Button
- variant="outline"
- className="text-left h-auto py-3"
- onClick={() =>
- setInput("How do I fix Minecraft crashing on launch?")
- }
- disabled={isProcessing}
- >
- <div className="text-sm">
- How do I fix Minecraft crashing on launch?
- </div>
- </Button>
- <Button
- variant="outline"
- className="text-left h-auto py-3"
- onClick={() =>
- setInput("What's the best way to improve FPS?")
- }
- disabled={isProcessing}
- >
- <div className="text-sm">
- What's the best way to improve FPS?
- </div>
- </Button>
- <Button
- variant="outline"
- className="text-left h-auto py-3"
- onClick={() =>
- setInput(
- "Can you help me install Fabric for Minecraft 1.20.4?",
- )
- }
- disabled={isProcessing}
- >
- <div className="text-sm">
- Can you help me install Fabric for 1.20.4?
- </div>
- </Button>
- <Button
- variant="outline"
- className="text-left h-auto py-3"
- onClick={() =>
- setInput("What mods do you recommend for performance?")
- }
- disabled={isProcessing}
- >
- <div className="text-sm">
- What mods do you recommend for performance?
- </div>
- </Button>
- </div>
- </div>
- ) : (
- <>
- {messages.map((message, index) => renderMessage(message, index))}
- {isProcessing && streamingContent && (
- <div className="flex justify-start mb-4">
- <div className="max-w-[80%] bg-zinc-800 text-zinc-100 rounded-2xl rounded-bl-none px-4 py-3">
- <div
- className="prose prose-invert max-w-none"
- dangerouslySetInnerHTML={{
- __html: renderMarkdown(streamingContent),
- }}
- />
- <div className="flex items-center gap-1 mt-2 text-xs text-zinc-400">
- <Loader2 className="h-3 w-3 animate-spin" />
- <span>Assistant is typing...</span>
- </div>
- </div>
- </div>
- )}
- </>
- )}
- <div ref={messagesEndRef} />
- </ScrollArea>
-
- <Separator />
-
- {/* Input Area */}
- <div className="p-3 lg:p-4">
- <div className="flex gap-2">
- <Textarea
- placeholder={
- settings.assistant.enabled
- ? "Ask about your game..."
- : "Assistant is disabled. Enable it in Settings to use."
- }
- value={input}
- onChange={(e) => setInput(e.target.value)}
- onKeyDown={handleKeyDown}
- className="min-h-11 max-h-50 resize-none border-zinc-700 bg-zinc-900/50 focus:bg-zinc-900/80"
- disabled={!settings.assistant.enabled || isProcessing}
- />
- <Button
- onClick={handleSubmit}
- disabled={
- !settings.assistant.enabled || !input.trim() || isProcessing
- }
- className="px-6 bg-indigo-600 hover:bg-indigo-700 text-white"
- >
- {isProcessing ? (
- <Loader2 className="h-4 w-4 animate-spin" />
- ) : (
- <Send className="h-4 w-4" />
- )}
- </Button>
- </div>
- <div className="mt-2 flex items-center justify-between">
- <div className="text-xs text-zinc-500">
- {settings.assistant.enabled
- ? "Press Enter to send, Shift+Enter for new line"
- : "Enable the assistant in Settings to use"}
- </div>
- <div className="text-xs text-zinc-500">
- Model: {model} • Provider: {provider}
- </div>
- </div>
- </div>
- </div>
- </div>
- );
-}
diff --git a/packages/ui/src/pages/home-view.tsx b/packages/ui/src/pages/home.tsx
index 4f80cb0..dc1413d 100644
--- a/packages/ui/src/pages/home-view.tsx
+++ b/packages/ui/src/pages/home.tsx
@@ -1,18 +1,11 @@
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { BottomBar } from "@/components/bottom-bar";
-import type { SaturnEffect } from "@/lib/effects/SaturnEffect";
-import { useGameStore } from "../stores/game-store";
-import { useReleasesStore } from "../stores/releases-store";
+import { useSaturnEffect } from "@/components/particle-background";
-export function HomeView() {
- const gameStore = useGameStore();
- const releasesStore = useReleasesStore();
+export function HomePage() {
const [mouseX, setMouseX] = useState(0);
const [mouseY, setMouseY] = useState(0);
-
- useEffect(() => {
- releasesStore.loadReleases();
- }, [releasesStore.loadReleases]);
+ const saturn = useSaturnEffect();
const handleMouseMove = (e: React.MouseEvent) => {
const x = (e.clientX / window.innerWidth) * 2 - 1;
@@ -21,100 +14,42 @@ export function HomeView() {
setMouseY(y);
// Forward mouse move to SaturnEffect (if available) for parallax/rotation interactions
- try {
- const saturn = (
- window as unknown as {
- getSaturnEffect?: () => SaturnEffect;
- }
- ).getSaturnEffect?.();
- if (saturn?.handleMouseMove) {
- saturn.handleMouseMove(e.clientX);
- }
- } catch {
- /* best-effort, ignore errors from effect */
- }
+ saturn?.handleMouseMove(e.clientX);
};
const handleSaturnMouseDown = (e: React.MouseEvent) => {
- try {
- const saturn = (window as any).getSaturnEffect?.();
- if (saturn?.handleMouseDown) {
- saturn.handleMouseDown(e.clientX);
- }
- } catch {
- /* ignore */
- }
+ saturn?.handleMouseDown(e.clientX);
};
const handleSaturnMouseUp = () => {
- try {
- const saturn = (window as any).getSaturnEffect?.();
- if (saturn?.handleMouseUp) {
- saturn.handleMouseUp();
- }
- } catch {
- /* ignore */
- }
+ saturn?.handleMouseUp();
};
const handleSaturnMouseLeave = () => {
// Treat leaving the area as mouse-up for the effect
- try {
- const saturn = (window as any).getSaturnEffect?.();
- if (saturn?.handleMouseUp) {
- saturn.handleMouseUp();
- }
- } catch {
- /* ignore */
- }
+ saturn?.handleMouseUp();
};
const handleSaturnTouchStart = (e: React.TouchEvent) => {
if (e.touches && e.touches.length === 1) {
- try {
- const clientX = e.touches[0].clientX;
- const saturn = (window as any).getSaturnEffect?.();
- if (saturn?.handleTouchStart) {
- saturn.handleTouchStart(clientX);
- }
- } catch {
- /* ignore */
- }
+ const clientX = e.touches[0].clientX;
+ saturn?.handleTouchStart(clientX);
}
};
const handleSaturnTouchMove = (e: React.TouchEvent) => {
if (e.touches && e.touches.length === 1) {
- try {
- const clientX = e.touches[0].clientX;
- const saturn = (window as any).getSaturnEffect?.();
- if (saturn?.handleTouchMove) {
- saturn.handleTouchMove(clientX);
- }
- } catch {
- /* ignore */
- }
+ const clientX = e.touches[0].clientX;
+ saturn?.handleTouchMove(clientX);
}
};
const handleSaturnTouchEnd = () => {
- try {
- const saturn = (window as any).getSaturnEffect?.();
- if (saturn?.handleTouchEnd) {
- saturn.handleTouchEnd();
- }
- } catch {
- /* ignore */
- }
+ saturn?.handleTouchEnd();
};
return (
- <div
- className="relative z-10 h-full overflow-y-auto custom-scrollbar scroll-smooth"
- style={{
- overflow: releasesStore.isLoading ? "hidden" : "auto",
- }}
- >
+ <div className="relative z-10 h-full overflow-y-auto custom-scrollbar scroll-smooth">
{/* Hero Section (Full Height) - Interactive area */}
<div
role="tab"
@@ -150,13 +85,6 @@ export function HomeView() {
<div className="bg-white/10 backdrop-blur-md border border-white/10 px-3 py-1 rounded-sm text-xs font-bold uppercase tracking-widest text-white shadow-sm">
Java Edition
</div>
- <div className="h-4 w-px bg-white/20"></div>
- <div className="text-sm text-zinc-400">
- Latest Release{" "}
- <span className="text-white font-medium">
- {gameStore.latestRelease?.id || "..."}
- </span>
- </div>
</div>
</div>
diff --git a/packages/ui/src/pages/index.tsx b/packages/ui/src/pages/index.tsx
index 209a1b2..d12646b 100644
--- a/packages/ui/src/pages/index.tsx
+++ b/packages/ui/src/pages/index.tsx
@@ -5,13 +5,11 @@ import { Sidebar } from "@/components/sidebar";
import { useAuthStore } from "@/models/auth";
import { useInstanceStore } from "@/models/instance";
import { useSettingsStore } from "@/models/settings";
-import { useGameStore } from "@/stores/game-store";
export function IndexPage() {
const authStore = useAuthStore();
const settingsStore = useSettingsStore();
const instanceStore = useInstanceStore();
- const initGameLifecycle = useGameStore((state) => state.initLifecycle);
const location = useLocation();
@@ -19,10 +17,7 @@ export function IndexPage() {
authStore.init();
settingsStore.refresh();
instanceStore.refresh();
- void initGameLifecycle().catch((error) => {
- console.error("Failed to initialize game lifecycle:", error);
- });
- }, [authStore.init, settingsStore.refresh, instanceStore.refresh, initGameLifecycle]);
+ }, [authStore.init, settingsStore.refresh, instanceStore.refresh]);
return (
<div className="relative h-screen w-full overflow-hidden bg-background font-sans">
@@ -50,8 +45,6 @@ export function IndexPage() {
<div className="absolute inset-0 opacity-100 bg-linear-to-br from-emerald-100 via-gray-100 to-indigo-100"></div>
)}
- {location.pathname === "/" && <ParticleBackground />}
-
<div className="absolute inset-0 bg-linear-to-t from-zinc-900 via-transparent to-black/50 dark:from-zinc-900 dark:to-black/50"></div>
</>
)}
@@ -76,7 +69,13 @@ export function IndexPage() {
<Sidebar />
<main className="size-full overflow-hidden">
- <Outlet />
+ {location.pathname === "/" ? (
+ <ParticleBackground>
+ <Outlet />
+ </ParticleBackground>
+ ) : (
+ <Outlet />
+ )}
</main>
</div>
</div>
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-view.tsx b/packages/ui/src/pages/instances/index.tsx
index 07a2135..e6cd734 100644
--- a/packages/ui/src/pages/instances-view.tsx
+++ b/packages/ui/src/pages/instances/index.tsx
@@ -2,6 +2,7 @@ import { open, save } from "@tauri-apps/plugin-dialog";
import {
CopyIcon,
EditIcon,
+ EllipsisIcon,
FolderOpenIcon,
Plus,
RocketIcon,
@@ -9,9 +10,9 @@ import {
XIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
+import { useNavigate } from "react-router";
import { toast } from "sonner";
import { openFileExplorer } from "@/client";
-import InstanceCreationModal from "@/components/instance-creation-modal";
import InstanceEditorModal from "@/components/instance-editor-modal";
import { Button } from "@/components/ui/button";
import {
@@ -25,28 +26,31 @@ import {
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 { useGameStore } from "@/stores/game-store";
import type { Instance } from "@/types";
-export function InstancesView() {
- const account = useAuthStore((state) => state.account);
+export function InstancesPage() {
const instancesStore = useInstanceStore();
- const startGame = useGameStore((state) => state.startGame);
- const stopGame = useGameStore((state) => state.stopGame);
- const runningInstanceId = useGameStore((state) => state.runningInstanceId);
- const launchingInstanceId = useGameStore((state) => state.launchingInstanceId);
- const stoppingInstanceId = useGameStore((state) => state.stoppingInstanceId);
- const [isImporting, setIsImporting] = useState(false);
- const [repairing, setRepairing] = useState(false);
- const [exportingId, setExportingId] = useState<string | null>(null);
+ const navigate = useNavigate();
+
+ const account = useAuthStore((state) => state.account);
+ const {
+ startGame,
+ runningInstanceId,
+ stoppingInstanceId,
+ launchingInstanceId,
+ stopGame,
+ } = useGameStore();
- // Modal / UI state
- const [showCreateModal, setShowCreateModal] = useState(false);
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,
@@ -62,7 +66,7 @@ export function InstancesView() {
// Handlers to open modals
const openCreate = () => {
- setShowCreateModal(true);
+ navigate("/instances/create");
};
const openEdit = (instance: Instance) => {
@@ -149,7 +153,7 @@ export function InstancesView() {
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Instances
</h1>
- <div className="flex items-center gap-2">
+ <div className="flex flex-row space-x-2">
<Button
type="button"
variant="outline"
@@ -188,10 +192,9 @@ export function InstancesView() {
<ul className="flex flex-col space-y-3">
{instancesStore.instances.map((instance) => {
const isActive = instancesStore.activeInstance?.id === instance.id;
- const isRunning = runningInstanceId === instance.id;
const isLaunching = launchingInstanceId === instance.id;
const isStopping = stoppingInstanceId === instance.id;
- const otherInstanceRunning = runningInstanceId !== null && !isRunning;
+ const isRunning = runningInstanceId === instance.id;
return (
<li
@@ -259,7 +262,10 @@ export function InstancesView() {
try {
await instancesStore.setActiveInstance(instance);
} catch (error) {
- console.error("Failed to set active instance:", error);
+ console.error(
+ "Failed to set active instance:",
+ error,
+ );
toast.error("Error setting active instance");
return;
}
@@ -274,20 +280,27 @@ export function InstancesView() {
return;
}
- await startGame(
- account,
- () => {
- toast.info("Please login first");
- },
- instance.id,
- instance.versionId,
- () => undefined,
- );
+ 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={otherInstanceRunning || isLaunching || isStopping}
+ disabled={
+ (!!runningInstanceId &&
+ runningInstanceId !== instance.id) ||
+ isLaunching ||
+ isStopping
+ }
>
{isLaunching || isStopping ? (
- <span className="text-xs">...</span>
+ <EllipsisIcon />
) : isRunning ? (
<XIcon />
) : (
@@ -356,10 +369,10 @@ export function InstancesView() {
</ul>
)}
- <InstanceCreationModal
+ {/*<InstanceCreationModal
open={showCreateModal}
onOpenChange={setShowCreateModal}
- />
+ />*/}
<InstanceEditorModal
open={showEditModal}
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;
diff --git a/packages/ui/src/pages/routes.ts b/packages/ui/src/pages/routes.ts
new file mode 100644
index 0000000..55eb8fd
--- /dev/null
+++ b/packages/ui/src/pages/routes.ts
@@ -0,0 +1,25 @@
+import { createHashRouter } from "react-router";
+import { HomePage } from "./home";
+import { IndexPage } from "./index";
+import instanceRoute from "./instances/routes";
+import { SettingsPage } from "./settings";
+
+const router = createHashRouter([
+ {
+ path: "/",
+ Component: IndexPage,
+ children: [
+ {
+ index: true,
+ Component: HomePage,
+ },
+ {
+ path: "settings",
+ Component: SettingsPage,
+ },
+ instanceRoute,
+ ],
+ },
+]);
+
+export default router;
diff --git a/packages/ui/src/stores/auth-store.ts b/packages/ui/src/stores/auth-store.ts
deleted file mode 100644
index bf7e3c5..0000000
--- a/packages/ui/src/stores/auth-store.ts
+++ /dev/null
@@ -1,296 +0,0 @@
-import { invoke } from "@tauri-apps/api/core";
-import { listen, type UnlistenFn } from "@tauri-apps/api/event";
-import { open } from "@tauri-apps/plugin-shell";
-import { toast } from "sonner";
-import { create } from "zustand";
-import type { Account, DeviceCodeResponse } from "../types/bindings/auth";
-
-interface AuthState {
- // State
- currentAccount: Account | null;
- isLoginModalOpen: boolean;
- isLogoutConfirmOpen: boolean;
- loginMode: "select" | "offline" | "microsoft";
- offlineUsername: string;
- deviceCodeData: DeviceCodeResponse | null;
- msLoginLoading: boolean;
- msLoginStatus: string;
-
- // Private state
- pollInterval: ReturnType<typeof setInterval> | null;
- isPollingRequestActive: boolean;
- authProgressUnlisten: UnlistenFn | null;
-
- // Actions
- checkAccount: () => Promise<void>;
- openLoginModal: () => void;
- openLogoutConfirm: () => void;
- cancelLogout: () => void;
- confirmLogout: () => Promise<void>;
- closeLoginModal: () => void;
- resetLoginState: () => void;
- performOfflineLogin: () => Promise<void>;
- startMicrosoftLogin: () => Promise<void>;
- checkLoginStatus: (deviceCode: string) => Promise<void>;
- stopPolling: () => void;
- cancelMicrosoftLogin: () => void;
- setLoginMode: (mode: "select" | "offline" | "microsoft") => void;
- setOfflineUsername: (username: string) => void;
-}
-
-export const useAuthStore = create<AuthState>((set, get) => ({
- // Initial state
- currentAccount: null,
- isLoginModalOpen: false,
- isLogoutConfirmOpen: false,
- loginMode: "select",
- offlineUsername: "",
- deviceCodeData: null,
- msLoginLoading: false,
- msLoginStatus: "Waiting for authorization...",
-
- // Private state
- pollInterval: null,
- isPollingRequestActive: false,
- authProgressUnlisten: null,
-
- // Actions
- checkAccount: async () => {
- try {
- const acc = await invoke<Account | null>("get_active_account");
- set({ currentAccount: acc });
- } catch (error) {
- console.error("Failed to check account:", error);
- }
- },
-
- openLoginModal: () => {
- const { currentAccount } = get();
- if (currentAccount) {
- // Show custom logout confirmation dialog
- set({ isLogoutConfirmOpen: true });
- return;
- }
- get().resetLoginState();
- set({ isLoginModalOpen: true });
- },
-
- openLogoutConfirm: () => {
- set({ isLogoutConfirmOpen: true });
- },
-
- cancelLogout: () => {
- set({ isLogoutConfirmOpen: false });
- },
-
- confirmLogout: async () => {
- set({ isLogoutConfirmOpen: false });
- try {
- await invoke("logout");
- set({ currentAccount: null });
- } catch (error) {
- console.error("Logout failed:", error);
- }
- },
-
- closeLoginModal: () => {
- get().stopPolling();
- set({ isLoginModalOpen: false });
- },
-
- resetLoginState: () => {
- set({
- loginMode: "select",
- offlineUsername: "",
- deviceCodeData: null,
- msLoginLoading: false,
- msLoginStatus: "Waiting for authorization...",
- });
- },
-
- performOfflineLogin: async () => {
- const { offlineUsername } = get();
- if (!offlineUsername.trim()) return;
-
- try {
- const account = await invoke<Account>("login_offline", {
- username: offlineUsername,
- });
- set({
- currentAccount: account,
- isLoginModalOpen: false,
- offlineUsername: "",
- });
- } catch (error) {
- // Keep UI-friendly behavior consistent with prior code
- alert("Login failed: " + String(error));
- }
- },
-
- startMicrosoftLogin: async () => {
- // Prepare UI state
- set({
- msLoginLoading: true,
- msLoginStatus: "Waiting for authorization...",
- loginMode: "microsoft",
- deviceCodeData: null,
- });
-
- // Listen to general launcher logs so we can display progress to the user.
- // The backend emits logs via "launcher-log"; using that keeps this store decoupled
- // from a dedicated auth event channel (backend may reuse launcher-log).
- try {
- const unlisten = await listen("launcher-log", (event) => {
- const payload = event.payload;
- // Normalize payload to string if possible
- const message =
- typeof payload === "string"
- ? payload
- : (payload?.toString?.() ?? JSON.stringify(payload));
- set({ msLoginStatus: message });
- });
- set({ authProgressUnlisten: unlisten });
- } catch (err) {
- console.warn("Failed to attach launcher-log listener:", err);
- }
-
- try {
- const deviceCodeData = await invoke<DeviceCodeResponse>(
- "start_microsoft_login",
- );
- set({ deviceCodeData });
-
- if (deviceCodeData) {
- // Try to copy user code to clipboard for convenience (best-effort)
- try {
- await navigator.clipboard?.writeText(deviceCodeData.userCode ?? "");
- } catch (err) {
- // ignore clipboard errors
- console.debug("Clipboard copy failed:", err);
- }
-
- // Open verification URI in default browser
- try {
- if (deviceCodeData.verificationUri) {
- await open(deviceCodeData.verificationUri);
- }
- } catch (err) {
- console.debug("Failed to open verification URI:", err);
- }
-
- // Start polling for completion
- // `interval` from the bindings is a bigint (seconds). Convert safely to number.
- const intervalSeconds =
- deviceCodeData.interval !== undefined &&
- deviceCodeData.interval !== null
- ? Number(deviceCodeData.interval)
- : 5;
- const intervalMs = intervalSeconds * 1000;
- const pollInterval = setInterval(
- () => get().checkLoginStatus(deviceCodeData.deviceCode),
- intervalMs,
- );
- set({ pollInterval });
- }
- } catch (error) {
- toast.error(`Failed to start Microsoft login: ${error}`);
- set({ loginMode: "select" });
- // cleanup listener if present
- const { authProgressUnlisten } = get();
- if (authProgressUnlisten) {
- authProgressUnlisten();
- set({ authProgressUnlisten: null });
- }
- } finally {
- set({ msLoginLoading: false });
- }
- },
-
- checkLoginStatus: async (deviceCode: string) => {
- const { isPollingRequestActive } = get();
- if (isPollingRequestActive) return;
-
- set({ isPollingRequestActive: true });
-
- try {
- const account = await invoke<Account>("complete_microsoft_login", {
- deviceCode,
- });
-
- // On success, stop polling and cleanup listener
- get().stopPolling();
- const { authProgressUnlisten } = get();
- if (authProgressUnlisten) {
- authProgressUnlisten();
- set({ authProgressUnlisten: null });
- }
-
- set({
- currentAccount: account,
- isLoginModalOpen: false,
- });
- } catch (error: unknown) {
- const errStr = String(error);
- if (errStr.includes("authorization_pending")) {
- // Still waiting — keep polling
- } else {
- set({ msLoginStatus: "Error: " + errStr });
-
- if (
- errStr.includes("expired_token") ||
- errStr.includes("access_denied")
- ) {
- // Terminal errors — stop polling and reset state
- get().stopPolling();
- const { authProgressUnlisten } = get();
- if (authProgressUnlisten) {
- authProgressUnlisten();
- set({ authProgressUnlisten: null });
- }
- alert("Login failed: " + errStr);
- set({ loginMode: "select" });
- }
- }
- } finally {
- set({ isPollingRequestActive: false });
- }
- },
-
- stopPolling: () => {
- const { pollInterval, authProgressUnlisten } = get();
- if (pollInterval) {
- try {
- clearInterval(pollInterval);
- } catch (err) {
- console.debug("Failed to clear poll interval:", err);
- }
- set({ pollInterval: null });
- }
- if (authProgressUnlisten) {
- try {
- authProgressUnlisten();
- } catch (err) {
- console.debug("Failed to unlisten auth progress:", err);
- }
- set({ authProgressUnlisten: null });
- }
- },
-
- cancelMicrosoftLogin: () => {
- get().stopPolling();
- set({
- deviceCodeData: null,
- msLoginLoading: false,
- msLoginStatus: "",
- loginMode: "select",
- });
- },
-
- setLoginMode: (mode: "select" | "offline" | "microsoft") => {
- set({ loginMode: mode });
- },
-
- setOfflineUsername: (username: string) => {
- set({ offlineUsername: username });
- },
-}));
diff --git a/packages/ui/src/stores/game-store.ts b/packages/ui/src/stores/game-store.ts
deleted file mode 100644
index 1eaf7e7..0000000
--- a/packages/ui/src/stores/game-store.ts
+++ /dev/null
@@ -1,182 +0,0 @@
-import { listen, type UnlistenFn } from "@tauri-apps/api/event";
-import { toast } from "sonner";
-import { create } from "zustand";
-import {
- getVersions,
- getVersionsOfInstance,
- startGame as startGameCommand,
- stopGame as stopGameCommand,
-} from "@/client";
-import type { Account } from "@/types/bindings/auth";
-import type { GameExitedEvent } from "@/types/bindings/core";
-import type { Version } from "@/types/bindings/manifest";
-
-interface GameState {
- versions: Version[];
- selectedVersion: string;
- runningInstanceId: string | null;
- runningVersionId: string | null;
- launchingInstanceId: string | null;
- stoppingInstanceId: string | null;
- lifecycleUnlisten: UnlistenFn | null;
-
- latestRelease: Version | undefined;
- isGameRunning: boolean;
-
- initLifecycle: () => Promise<void>;
- loadVersions: (instanceId?: string) => Promise<void>;
- startGame: (
- currentAccount: Account | null,
- openLoginModal: () => void,
- activeInstanceId: string | null,
- versionId: string | null,
- setView: (view: string) => void,
- ) => Promise<string | null>;
- stopGame: (instanceId?: string | null) => Promise<string | null>;
- setSelectedVersion: (version: string) => void;
- setVersions: (versions: Version[]) => void;
-}
-
-export const useGameStore = create<GameState>((set, get) => ({
- versions: [],
- selectedVersion: "",
- runningInstanceId: null,
- runningVersionId: null,
- launchingInstanceId: null,
- stoppingInstanceId: null,
- lifecycleUnlisten: null,
-
- get latestRelease() {
- return get().versions.find((v) => v.type === "release");
- },
-
- get isGameRunning() {
- return get().runningInstanceId !== null;
- },
-
- initLifecycle: async () => {
- if (get().lifecycleUnlisten) {
- return;
- }
-
- const unlisten = await listen<GameExitedEvent>("game-exited", (event) => {
- const { instanceId, versionId, wasStopped } = event.payload;
-
- set({
- runningInstanceId: null,
- runningVersionId: null,
- launchingInstanceId: null,
- stoppingInstanceId: null,
- });
-
- if (wasStopped) {
- toast.success(`Stopped Minecraft ${versionId} for instance ${instanceId}`);
- } else {
- toast.info(`Minecraft ${versionId} exited for instance ${instanceId}`);
- }
- });
-
- set({ lifecycleUnlisten: unlisten });
- },
-
- loadVersions: async (instanceId?: string) => {
- try {
- const versions = instanceId
- ? await getVersionsOfInstance(instanceId)
- : await getVersions();
- set({ versions: versions ?? [] });
- } catch (e) {
- console.error("Failed to load versions:", e);
- set({ versions: [] });
- }
- },
-
- startGame: async (
- currentAccount,
- openLoginModal,
- activeInstanceId,
- versionId,
- setView,
- ) => {
- const { isGameRunning } = get();
- const targetVersion = versionId ?? get().selectedVersion;
-
- if (!currentAccount) {
- toast.info("Please login first");
- openLoginModal();
- return null;
- }
-
- if (!targetVersion) {
- toast.info("Please select a version first");
- return null;
- }
-
- if (!activeInstanceId) {
- toast.info("Please select an instance first");
- setView("instances");
- return null;
- }
-
- if (isGameRunning) {
- toast.info("A game is already running");
- return null;
- }
-
- set({
- launchingInstanceId: activeInstanceId,
- selectedVersion: targetVersion,
- });
- toast.info(`Preparing to launch ${targetVersion}...`);
-
- try {
- const message = await startGameCommand(activeInstanceId, targetVersion);
- set({
- launchingInstanceId: null,
- runningInstanceId: activeInstanceId,
- runningVersionId: targetVersion,
- });
- toast.success(message);
- return message;
- } catch (e) {
- console.error(e);
- set({ launchingInstanceId: null });
- toast.error(`Error: ${e}`);
- return null;
- }
- },
-
- stopGame: async (instanceId) => {
- const { runningInstanceId } = get();
-
- if (!runningInstanceId) {
- toast.info("No running game found");
- return null;
- }
-
- if (instanceId && instanceId !== runningInstanceId) {
- toast.info("That instance is not the one currently running");
- return null;
- }
-
- set({ stoppingInstanceId: runningInstanceId });
-
- try {
- return await stopGameCommand();
- } catch (e) {
- console.error("Failed to stop game:", e);
- toast.error(`Failed to stop game: ${e}`);
- return null;
- } finally {
- set({ stoppingInstanceId: null });
- }
- },
-
- setSelectedVersion: (version: string) => {
- set({ selectedVersion: version });
- },
-
- setVersions: (versions: Version[]) => {
- set({ versions });
- },
-}));
diff --git a/packages/ui/src/stores/releases-store.ts b/packages/ui/src/stores/releases-store.ts
deleted file mode 100644
index 56afa08..0000000
--- a/packages/ui/src/stores/releases-store.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { invoke } from "@tauri-apps/api/core";
-import { create } from "zustand";
-import type { GithubRelease } from "@/types/bindings/core";
-
-interface ReleasesState {
- // State
- releases: GithubRelease[];
- isLoading: boolean;
- isLoaded: boolean;
- error: string | null;
-
- // Actions
- loadReleases: () => Promise<void>;
- setReleases: (releases: GithubRelease[]) => void;
- setIsLoading: (isLoading: boolean) => void;
- setIsLoaded: (isLoaded: boolean) => void;
- setError: (error: string | null) => void;
-}
-
-export const useReleasesStore = create<ReleasesState>((set, get) => ({
- // Initial state
- releases: [],
- isLoading: false,
- isLoaded: false,
- error: null,
-
- // Actions
- loadReleases: async () => {
- const { isLoaded, isLoading } = get();
-
- // If already loaded or currently loading, skip to prevent duplicate requests
- if (isLoaded || isLoading) return;
-
- set({ isLoading: true, error: null });
-
- try {
- const releases = await invoke<GithubRelease[]>("get_github_releases");
- set({ releases, isLoaded: true });
- } catch (e) {
- const error = e instanceof Error ? e.message : String(e);
- console.error("Failed to load releases:", e);
- set({ error });
- } finally {
- set({ isLoading: false });
- }
- },
-
- setReleases: (releases) => {
- set({ releases });
- },
-
- setIsLoading: (isLoading) => {
- set({ isLoading });
- },
-
- setIsLoaded: (isLoaded) => {
- set({ isLoaded });
- },
-
- setError: (error) => {
- set({ error });
- },
-}));
diff --git a/packages/ui/src/stores/ui-store.ts b/packages/ui/src/stores/ui-store.ts
deleted file mode 100644
index 89b9191..0000000
--- a/packages/ui/src/stores/ui-store.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { create } from "zustand";
-
-export type ViewType = "home" | "versions" | "settings" | "guide" | "instances";
-
-interface UIState {
- // State
- currentView: ViewType;
- showConsole: boolean;
- appVersion: string;
-
- // Actions
- toggleConsole: () => void;
- setView: (view: ViewType) => void;
- setAppVersion: (version: string) => void;
-}
-
-export const useUIStore = create<UIState>((set) => ({
- // Initial state
- currentView: "home",
- showConsole: false,
- appVersion: "...",
-
- // Actions
- toggleConsole: () => {
- set((state) => ({ showConsole: !state.showConsole }));
- },
-
- setView: (view: ViewType) => {
- set({ currentView: view });
- },
-
- setAppVersion: (version: string) => {
- set({ appVersion: version });
- },
-}));
-
-// Provide lowercase alias for compatibility with existing imports.
-// Use a function wrapper to ensure the named export exists as a callable value
-// at runtime (some bundlers/tree-shakers may remove simple aliases).
-export function useUiStore() {
- return useUIStore();
-}