From 397cbb34b327a0addfdf8e36f859b456956b66fe Mon Sep 17 00:00:00 2001 From: 苏向夜 Date: Sun, 29 Mar 2026 01:19:25 +0800 Subject: fix(lint): apply ui code lint --- packages/ui/src/components/bottom-bar.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) (limited to 'packages/ui/src/components/bottom-bar.tsx') diff --git a/packages/ui/src/components/bottom-bar.tsx b/packages/ui/src/components/bottom-bar.tsx index 8f70985..2746e00 100644 --- a/packages/ui/src/components/bottom-bar.tsx +++ b/packages/ui/src/components/bottom-bar.tsx @@ -21,13 +21,17 @@ 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 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 launchingInstanceId = useGameStore( + (state) => state.launchingInstanceId, + ); const stoppingInstanceId = useGameStore((state) => state.stoppingInstanceId); const [showLoginModal, setShowLoginModal] = useState(false); @@ -39,7 +43,7 @@ export function BottomBar() { } setSelectedVersion(nextVersion); - }, [activeInstance?.id, activeInstance?.versionId, selectedVersion, setSelectedVersion]); + }, [activeInstance?.versionId, selectedVersion, setSelectedVersion]); const handleInstanceChange = useCallback( async (instanceId: string) => { @@ -47,7 +51,9 @@ export function BottomBar() { return; } - const nextInstance = instances.find((instance) => instance.id === instanceId); + const nextInstance = instances.find( + (instance) => instance.id === instanceId, + ); if (!nextInstance) { return; } -- cgit v1.2.3-70-g09d2 From ffbfce895c37e8e8306d426a2e59e73647ed6a86 Mon Sep 17 00:00:00 2001 From: 苏向夜 Date: Sun, 29 Mar 2026 02:46:51 +0800 Subject: refactor(ui): rewrite game store --- .changes/game-store.md | 5 + packages/ui/src/components/bottom-bar.tsx | 31 +- packages/ui/src/models/game.ts | 113 ++++++++ packages/ui/src/pages/home-view.tsx | 102 ------- packages/ui/src/pages/home.tsx | 102 +++++++ packages/ui/src/pages/index.tsx | 12 +- packages/ui/src/pages/instances-view.tsx | 457 ------------------------------ packages/ui/src/pages/routes.ts | 6 +- packages/ui/src/stores/game-store.ts | 184 ------------ 9 files changed, 236 insertions(+), 776 deletions(-) create mode 100644 .changes/game-store.md create mode 100644 packages/ui/src/models/game.ts delete mode 100644 packages/ui/src/pages/home-view.tsx create mode 100644 packages/ui/src/pages/home.tsx delete mode 100644 packages/ui/src/pages/instances-view.tsx delete mode 100644 packages/ui/src/stores/game-store.ts (limited to 'packages/ui/src/components/bottom-bar.tsx') diff --git a/.changes/game-store.md b/.changes/game-store.md new file mode 100644 index 0000000..0b2f7f6 --- /dev/null +++ b/.changes/game-store.md @@ -0,0 +1,5 @@ +--- +"@dropout/ui": "patch:refactor" +--- + +Refactor game store and rename `HomePage` component. diff --git a/packages/ui/src/components/bottom-bar.tsx b/packages/ui/src/components/bottom-bar.tsx index 2746e00..fd4a681 100644 --- a/packages/ui/src/components/bottom-bar.tsx +++ b/packages/ui/src/components/bottom-bar.tsx @@ -3,8 +3,8 @@ import { useCallback, useEffect, 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,21 +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 { instances, activeInstance, setActiveInstance } = useInstanceStore(); + const { + runningInstanceId, + launchingInstanceId, + stoppingInstanceId, + startGame, + stopGame, + } = useGameStore(); + + const [selectedVersion, setSelectedVersion] = useState(null); const [showLoginModal, setShowLoginModal] = useState(false); useEffect(() => { @@ -43,7 +39,7 @@ export function BottomBar() { } setSelectedVersion(nextVersion); - }, [activeInstance?.versionId, selectedVersion, setSelectedVersion]); + }, [activeInstance?.versionId, selectedVersion]); const handleInstanceChange = useCallback( async (instanceId: string) => { @@ -75,11 +71,8 @@ export function BottomBar() { } await startGame( - account, - () => setShowLoginModal(true), activeInstance.id, selectedVersion || activeInstance.versionId, - () => undefined, ); }; 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; + stopGame: (instanceId?: string | null) => Promise; +} + +export const useGameStore = create((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("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/pages/home-view.tsx b/packages/ui/src/pages/home-view.tsx deleted file mode 100644 index da7238f..0000000 --- a/packages/ui/src/pages/home-view.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { useState } from "react"; -import { BottomBar } from "@/components/bottom-bar"; -import { useSaturnEffect } from "@/components/particle-background"; - -export function HomeView() { - const [mouseX, setMouseX] = useState(0); - const [mouseY, setMouseY] = useState(0); - const saturn = useSaturnEffect(); - - const handleMouseMove = (e: React.MouseEvent) => { - const x = (e.clientX / window.innerWidth) * 2 - 1; - const y = (e.clientY / window.innerHeight) * 2 - 1; - setMouseX(x); - setMouseY(y); - - // Forward mouse move to SaturnEffect (if available) for parallax/rotation interactions - saturn?.handleMouseMove(e.clientX); - }; - - const handleSaturnMouseDown = (e: React.MouseEvent) => { - saturn?.handleMouseDown(e.clientX); - }; - - const handleSaturnMouseUp = () => { - saturn?.handleMouseUp(); - }; - - const handleSaturnMouseLeave = () => { - // Treat leaving the area as mouse-up for the effect - saturn?.handleMouseUp(); - }; - - const handleSaturnTouchStart = (e: React.TouchEvent) => { - if (e.touches && e.touches.length === 1) { - const clientX = e.touches[0].clientX; - saturn?.handleTouchStart(clientX); - } - }; - - const handleSaturnTouchMove = (e: React.TouchEvent) => { - if (e.touches && e.touches.length === 1) { - const clientX = e.touches[0].clientX; - saturn?.handleTouchMove(clientX); - } - }; - - const handleSaturnTouchEnd = () => { - saturn?.handleTouchEnd(); - }; - - return ( -
- {/* Hero Section (Full Height) - Interactive area */} -
- {/* 3D Floating Hero Text */} -
-
-
- - Launcher Active - -
- -

- MINECRAFT -

- -
-
- Java Edition -
-
-
- - {/* Action Area */} -
-
- > Ready to launch session. -
-
- - -
-
- ); -} diff --git a/packages/ui/src/pages/home.tsx b/packages/ui/src/pages/home.tsx new file mode 100644 index 0000000..dc1413d --- /dev/null +++ b/packages/ui/src/pages/home.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { BottomBar } from "@/components/bottom-bar"; +import { useSaturnEffect } from "@/components/particle-background"; + +export function HomePage() { + const [mouseX, setMouseX] = useState(0); + const [mouseY, setMouseY] = useState(0); + const saturn = useSaturnEffect(); + + const handleMouseMove = (e: React.MouseEvent) => { + const x = (e.clientX / window.innerWidth) * 2 - 1; + const y = (e.clientY / window.innerHeight) * 2 - 1; + setMouseX(x); + setMouseY(y); + + // Forward mouse move to SaturnEffect (if available) for parallax/rotation interactions + saturn?.handleMouseMove(e.clientX); + }; + + const handleSaturnMouseDown = (e: React.MouseEvent) => { + saturn?.handleMouseDown(e.clientX); + }; + + const handleSaturnMouseUp = () => { + saturn?.handleMouseUp(); + }; + + const handleSaturnMouseLeave = () => { + // Treat leaving the area as mouse-up for the effect + saturn?.handleMouseUp(); + }; + + const handleSaturnTouchStart = (e: React.TouchEvent) => { + if (e.touches && e.touches.length === 1) { + const clientX = e.touches[0].clientX; + saturn?.handleTouchStart(clientX); + } + }; + + const handleSaturnTouchMove = (e: React.TouchEvent) => { + if (e.touches && e.touches.length === 1) { + const clientX = e.touches[0].clientX; + saturn?.handleTouchMove(clientX); + } + }; + + const handleSaturnTouchEnd = () => { + saturn?.handleTouchEnd(); + }; + + return ( +
+ {/* Hero Section (Full Height) - Interactive area */} +
+ {/* 3D Floating Hero Text */} +
+
+
+ + Launcher Active + +
+ +

+ MINECRAFT +

+ +
+
+ Java Edition +
+
+
+ + {/* Action Area */} +
+
+ > Ready to launch session. +
+
+ + +
+
+ ); +} diff --git a/packages/ui/src/pages/index.tsx b/packages/ui/src/pages/index.tsx index 2acd377..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,15 +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 (
diff --git a/packages/ui/src/pages/instances-view.tsx b/packages/ui/src/pages/instances-view.tsx deleted file mode 100644 index 7bb3302..0000000 --- a/packages/ui/src/pages/instances-view.tsx +++ /dev/null @@ -1,457 +0,0 @@ -import { open, save } from "@tauri-apps/plugin-dialog"; -import { - CopyIcon, - EditIcon, - FolderOpenIcon, - Plus, - RocketIcon, - Trash2Icon, - XIcon, -} from "lucide-react"; -import { useEffect, useState } from "react"; -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 { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { cn } from "@/lib/utils"; -import { useAuthStore } from "@/models/auth"; -import { useInstanceStore } from "@/models/instance"; -import { useGameStore } from "@/stores/game-store"; -import type { Instance } from "@/types"; - -export function InstancesView() { - const account = useAuthStore((state) => state.account); - 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(null); - - // Modal / UI state - const [showCreateModal, setShowCreateModal] = useState(false); - const [showEditModal, setShowEditModal] = useState(false); - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const [showDuplicateModal, setShowDuplicateModal] = useState(false); - - // Selected / editing instance state - const [selectedInstance, setSelectedInstance] = useState( - null, - ); - const [editingInstance, setEditingInstance] = useState(null); - - // Form fields - const [duplicateName, setDuplicateName] = useState(""); - - useEffect(() => { - instancesStore.refresh(); - }, [instancesStore.refresh]); - - // Handlers to open modals - const openCreate = () => { - setShowCreateModal(true); - }; - - const openEdit = (instance: Instance) => { - setEditingInstance({ ...instance }); - setShowEditModal(true); - }; - - const openDelete = (instance: Instance) => { - setSelectedInstance(instance); - setShowDeleteConfirm(true); - }; - - const openDuplicate = (instance: Instance) => { - setSelectedInstance(instance); - setDuplicateName(`${instance.name} (Copy)`); - setShowDuplicateModal(true); - }; - - const confirmDelete = async () => { - if (!selectedInstance) return; - await instancesStore.delete(selectedInstance.id); - setSelectedInstance(null); - setShowDeleteConfirm(false); - }; - - const confirmDuplicate = async () => { - if (!selectedInstance) return; - const name = duplicateName.trim(); - if (!name) return; - await instancesStore.duplicate(selectedInstance.id, name); - setSelectedInstance(null); - setDuplicateName(""); - setShowDuplicateModal(false); - }; - - const handleImport = async () => { - setIsImporting(true); - try { - const selected = await open({ - multiple: false, - filters: [{ name: "Zip Archive", extensions: ["zip"] }], - }); - - if (typeof selected !== "string") { - return; - } - - await instancesStore.importArchive(selected); - } finally { - setIsImporting(false); - } - }; - - const handleRepair = async () => { - setRepairing(true); - try { - await instancesStore.repair(); - } finally { - setRepairing(false); - } - }; - - const handleExport = async (instance: Instance) => { - setExportingId(instance.id); - try { - const filePath = await save({ - defaultPath: `${instance.name.replace(/[\\/:*?"<>|]/g, "_")}.zip`, - filters: [{ name: "Zip Archive", extensions: ["zip"] }], - }); - - if (!filePath) { - return; - } - - await instancesStore.exportArchive(instance.id, filePath); - } finally { - setExportingId(null); - } - }; - - return ( -
-
-

- Instances -

-
- - - -
-
- - {instancesStore.instances.length === 0 ? ( -
-
-

No instances yet

-

Create your first instance to get started

-
-
- ) : ( -
    - {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; - - return ( -
  • instancesStore.setActiveInstance(instance)} - onKeyDown={async (e) => { - if (e.key === "Enter") { - try { - await instancesStore.setActiveInstance(instance); - } catch (e) { - console.error("Failed to set active instance:", e); - toast.error("Error setting active instance"); - } - } - }} - className="cursor-pointer" - > -
    -
    - {instance.iconPath ? ( -
    - {instance.name} -
    - ) : ( -
    - - {instance.name.charAt(0).toUpperCase()} - -
    - )} - -
    -

    {instance.name}

    - {instance.versionId ? ( -

    - {instance.versionId} -

    - ) : ( -

    - No version selected -

    - )} -
    -
    - -
    -
    - - - - - - -
    -
    -
    -
  • - ); - })} -
- )} - - - - { - setShowEditModal(open); - if (!open) setEditingInstance(null); - }} - /> - - {/* Delete Confirmation */} - - - - Delete Instance - - Are you sure you want to delete "{selectedInstance?.name}"? This - action cannot be undone. - - - - - - - - - - - {/* Duplicate Modal */} - - - - Duplicate Instance - - Provide a name for the duplicated instance. - - - -
- setDuplicateName(e.target.value)} - placeholder="New instance name" - onKeyDown={(e) => e.key === "Enter" && confirmDuplicate()} - /> -
- - - - - -
-
-
- ); -} diff --git a/packages/ui/src/pages/routes.ts b/packages/ui/src/pages/routes.ts index 8d105d4..55eb8fd 100644 --- a/packages/ui/src/pages/routes.ts +++ b/packages/ui/src/pages/routes.ts @@ -1,6 +1,6 @@ import { createHashRouter } from "react-router"; -import { IndexPage } from "."; -import { HomeView } from "./home-view"; +import { HomePage } from "./home"; +import { IndexPage } from "./index"; import instanceRoute from "./instances/routes"; import { SettingsPage } from "./settings"; @@ -11,7 +11,7 @@ const router = createHashRouter([ children: [ { index: true, - Component: HomeView, + Component: HomePage, }, { path: "settings", diff --git a/packages/ui/src/stores/game-store.ts b/packages/ui/src/stores/game-store.ts deleted file mode 100644 index 7b6e746..0000000 --- a/packages/ui/src/stores/game-store.ts +++ /dev/null @@ -1,184 +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; - loadVersions: (instanceId?: string) => Promise; - startGame: ( - currentAccount: Account | null, - openLoginModal: () => void, - activeInstanceId: string | null, - versionId: string | null, - setView: (view: string) => void, - ) => Promise; - stopGame: (instanceId?: string | null) => Promise; - setSelectedVersion: (version: string) => void; - setVersions: (versions: Version[]) => void; -} - -export const useGameStore = create((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("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 }); - }, -})); -- cgit v1.2.3-70-g09d2 From 5b799a125a970e5e56f29a08b3c86450855fb6c4 Mon Sep 17 00:00:00 2001 From: 苏向夜 Date: Sun, 29 Mar 2026 21:32:54 +0800 Subject: refactor(ui): rewrite instance create --- .changes/rewrite-instance-create.md | 5 + packages/ui/src/components/bottom-bar.tsx | 17 +- .../ui/src/components/instance-creation-modal.tsx | 544 --------------- packages/ui/src/components/ui/accordion.tsx | 77 +++ packages/ui/src/components/ui/button.tsx | 2 +- packages/ui/src/components/ui/field.tsx | 12 +- packages/ui/src/models/instance.ts | 18 +- packages/ui/src/pages/instances/create.tsx | 746 +++++++++++++++++++++ packages/ui/src/pages/instances/index.tsx | 462 +++++++++++++ packages/ui/src/pages/instances/routes.ts | 19 + 10 files changed, 1328 insertions(+), 574 deletions(-) create mode 100644 .changes/rewrite-instance-create.md delete mode 100644 packages/ui/src/components/instance-creation-modal.tsx create mode 100644 packages/ui/src/components/ui/accordion.tsx create mode 100644 packages/ui/src/pages/instances/create.tsx create mode 100644 packages/ui/src/pages/instances/index.tsx create mode 100644 packages/ui/src/pages/instances/routes.ts (limited to 'packages/ui/src/components/bottom-bar.tsx') diff --git a/.changes/rewrite-instance-create.md b/.changes/rewrite-instance-create.md new file mode 100644 index 0000000..5443389 --- /dev/null +++ b/.changes/rewrite-instance-create.md @@ -0,0 +1,5 @@ +--- +"@dropout/ui": "patch:refactor" +--- + +Full rewrite instance create with stepper page instead of modal. diff --git a/packages/ui/src/components/bottom-bar.tsx b/packages/ui/src/components/bottom-bar.tsx index fd4a681..f73ace4 100644 --- a/packages/ui/src/components/bottom-bar.tsx +++ b/packages/ui/src/components/bottom-bar.tsx @@ -1,5 +1,5 @@ import { Play, User, XIcon } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { useAuthStore } from "@/models/auth"; @@ -29,18 +29,8 @@ export function BottomBar() { stopGame, } = useGameStore(); - const [selectedVersion, setSelectedVersion] = useState(null); const [showLoginModal, setShowLoginModal] = useState(false); - useEffect(() => { - const nextVersion = activeInstance?.versionId ?? ""; - if (selectedVersion === nextVersion) { - return; - } - - setSelectedVersion(nextVersion); - }, [activeInstance?.versionId, selectedVersion]); - const handleInstanceChange = useCallback( async (instanceId: string) => { if (activeInstance?.id === instanceId) { @@ -70,10 +60,7 @@ export function BottomBar() { return; } - await startGame( - activeInstance.id, - selectedVersion || activeInstance.versionId, - ); + await startGame(activeInstance.id, activeInstance.versionId ?? ""); }; const handleStopGame = async () => { diff --git a/packages/ui/src/components/instance-creation-modal.tsx b/packages/ui/src/components/instance-creation-modal.tsx deleted file mode 100644 index 7c46d0f..0000000 --- a/packages/ui/src/components/instance-creation-modal.tsx +++ /dev/null @@ -1,544 +0,0 @@ -import { Loader2, Search } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; -import { - getFabricLoadersForVersion, - getForgeVersionsForGame, - installFabric, - installForge, - installVersion, -} from "@/client"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { useInstanceStore } from "@/models/instance"; -import { useGameStore } from "@/stores/game-store"; -import type { - FabricLoaderEntry, - ForgeVersion as ForgeVersionEntry, - Version, -} from "@/types"; - -interface Props { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function InstanceCreationModal({ open, onOpenChange }: Props) { - const gameStore = useGameStore(); - const instancesStore = useInstanceStore(); - - // Steps: 1 = name, 2 = version, 3 = mod loader - const [step, setStep] = useState(1); - - // Step 1 - const [instanceName, setInstanceName] = useState(""); - - // Step 2 - const [versionSearch, setVersionSearch] = useState(""); - const [versionFilter, setVersionFilter] = useState< - "all" | "release" | "snapshot" - >("release"); - const [selectedVersionUI, setSelectedVersionUI] = useState( - null, - ); - - // Step 3 - const [modLoaderType, setModLoaderType] = useState< - "vanilla" | "fabric" | "forge" - >("vanilla"); - const [fabricLoaders, setFabricLoaders] = useState([]); - const [forgeVersions, setForgeVersions] = useState([]); - const [selectedFabricLoader, setSelectedFabricLoader] = useState(""); - const [selectedForgeLoader, setSelectedForgeLoader] = useState(""); - 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(""); - - // 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 = () => ( -
-
= 1 ? "bg-indigo-500" : "bg-zinc-700"}`} - /> -
= 2 ? "bg-indigo-500" : "bg-zinc-700"}`} - /> -
= 3 ? "bg-indigo-500" : "bg-zinc-700"}`} - /> -
- ); - - return ( - - - - Create New Instance - - Multi-step wizard — create an instance and optionally install a - version or mod loader. - - - -
-
- -
- - {/* Step 1 - Name */} - {step === 1 && ( -
-
- - setInstanceName(e.target.value)} - disabled={creating} - /> -
-

- Give your instance a memorable name. -

-
- )} - - {/* Step 2 - Version selection */} - {step === 2 && ( -
-
-
- - setVersionSearch(e.target.value)} - placeholder="Search versions..." - className="pl-9" - /> -
- -
- - - -
-
- - -
- {gameStore.versions.length === 0 ? ( -
- - Loading versions... -
- ) : filteredVersions.length === 0 ? ( -
- No matching versions found -
- ) : ( - filteredVersions.map((v) => { - const isSelected = selectedVersionUI?.id === v.id; - return ( - - ); - }) - )} -
-
-
- )} - - {/* Step 3 - Mod loader */} - {step === 3 && ( -
-
-
Mod Loader Type
-
- - - -
-
- - {modLoaderType === "fabric" && ( -
- {loadingLoaders ? ( -
- - Loading Fabric versions... -
- ) : fabricLoaders.length > 0 ? ( -
- -
- ) : ( -

- No Fabric loaders available for this version -

- )} -
- )} - - {modLoaderType === "forge" && ( -
- {loadingLoaders ? ( -
- - Loading Forge versions... -
- ) : forgeVersions.length > 0 ? ( -
- -
- ) : ( -

- No Forge versions available for this version -

- )} -
- )} -
- )} - - {errorMessage && ( -
{errorMessage}
- )} -
- - -
-
- -
- -
- {step > 1 && ( - - )} - - {step < 3 ? ( - - ) : ( - - )} -
-
-
-
-
- ); -} - -export default InstanceCreationModal; diff --git a/packages/ui/src/components/ui/accordion.tsx b/packages/ui/src/components/ui/accordion.tsx new file mode 100644 index 0000000..02ba45c --- /dev/null +++ b/packages/ui/src/components/ui/accordion.tsx @@ -0,0 +1,77 @@ +import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"; +import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) { + return ( + + ); +} + +function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) { + return ( + + ); +} + +function AccordionTrigger({ + className, + children, + ...props +}: AccordionPrimitive.Trigger.Props) { + return ( + + + {children} + + + + + ); +} + +function AccordionContent({ + className, + children, + ...props +}: AccordionPrimitive.Panel.Props) { + return ( + +
+ {children} +
+
+ ); +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/packages/ui/src/components/ui/button.tsx b/packages/ui/src/components/ui/button.tsx index 7dee494..60ad9ca 100644 --- a/packages/ui/src/components/ui/button.tsx +++ b/packages/ui/src/components/ui/button.tsx @@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-none border border-transparent bg-clip-padding text-xs font-medium focus-visible:ring-1 aria-invalid:ring-1 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none", + "cursor-pointer focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-none border border-transparent bg-clip-padding text-xs font-medium focus-visible:ring-1 aria-invalid:ring-1 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none", { variants: { variant: { diff --git a/packages/ui/src/components/ui/field.tsx b/packages/ui/src/components/ui/field.tsx index 10bd9c9..d6937c4 100644 --- a/packages/ui/src/components/ui/field.tsx +++ b/packages/ui/src/components/ui/field.tsx @@ -98,8 +98,12 @@ function FieldContent({ className, ...props }: React.ComponentProps<"div">) { function FieldLabel({ className, + required, + children, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + required?: boolean; +}) { return ( ); } diff --git a/packages/ui/src/models/instance.ts b/packages/ui/src/models/instance.ts index 2f338b5..8c108c1 100644 --- a/packages/ui/src/models/instance.ts +++ b/packages/ui/src/models/instance.ts @@ -20,7 +20,7 @@ interface InstanceState { activeInstance: Instance | null; refresh: () => Promise; - create: (name: string) => Promise; + create: (name: string) => Promise; delete: (id: string) => Promise; update: (instance: Instance) => Promise; setActiveInstance: (instance: Instance) => Promise; @@ -64,17 +64,11 @@ export const useInstanceStore = create((set, get) => ({ create: async (name) => { const { refresh } = get(); - try { - const instance = await createInstance(name); - await setActiveInstanceCommand(instance.id); - await refresh(); - toast.success(`Instance "${name}" created successfully`); - return instance; - } catch (e) { - console.error("Failed to create instance:", e); - toast.error(String(e)); - return null; - } + const instance = await createInstance(name); + await setActiveInstanceCommand(instance.id); + await refresh(); + toast.success(`Instance "${name}" created successfully`); + return instance; }, delete: async (id) => { diff --git a/packages/ui/src/pages/instances/create.tsx b/packages/ui/src/pages/instances/create.tsx new file mode 100644 index 0000000..57efea2 --- /dev/null +++ b/packages/ui/src/pages/instances/create.tsx @@ -0,0 +1,746 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { defineStepper } from "@stepperize/react"; +import { open } from "@tauri-apps/plugin-shell"; +import { ArrowLeftIcon, Link2Icon, XIcon } from "lucide-react"; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { + Controller, + FormProvider, + useForm, + useFormContext, + Watch, +} from "react-hook-form"; +import { useNavigate } from "react-router"; +import { toast } from "sonner"; +import z from "zod"; +import { + getFabricLoadersForVersion, + getForgeVersionsForGame, + getVersions, + installFabric, + installForge, + installVersion, + updateInstance, +} from "@/client"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Field, + FieldContent, + FieldDescription, + FieldError, + FieldLabel, + FieldSet, + FieldTitle, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Spinner } from "@/components/ui/spinner"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { useInstanceStore } from "@/models/instance"; +import type { FabricLoaderEntry, ForgeVersion, Version } from "@/types"; + +const versionSchema = z.object({ + versionId: z.string("Version is required"), +}); + +function VersionComponent() { + const { + control, + formState: { errors }, + } = useFormContext>(); + + const [versionSearch, setVersionSearch] = useState(""); + const [versionFilter, setVersionFilter] = useState< + "all" | "release" | "snapshot" | "old_alpha" | "old_beta" | null + >("release"); + + const [versions, setVersions] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(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 ( +
+
+
+ Versions + setVersionSearch(e.target.value)} + /> +
+
+ Type + +
+ +
+ {errorMessage && ( +
+

{errorMessage}

+ +
+ )} + {isLoading && !errorMessage ? ( +
+ +

Loading versions...

+
+ ) : ( +
+ + ( + + {filteredVersions?.map((version) => ( + + + + + {version.id} + {version.type} + + + {new Date(version.releaseTime).toLocaleString()} + + +
+ + +
+
+
+ ))} +
+ )} + >
+
+
+ )} + {errors.versionId && } +
+ ); +} + +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>(); + + const versionId = useVersionId(); + + const [forgeVersions, setForgeVersions] = useState( + 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 ( + +
+
+
+ + + Instance Name + + + {errors.name && } + + + + Instance Notes + +