diff options
Diffstat (limited to 'packages/ui')
| -rw-r--r-- | packages/ui/src/components/bottom-bar.tsx | 31 | ||||
| -rw-r--r-- | packages/ui/src/models/game.ts | 113 | ||||
| -rw-r--r-- | packages/ui/src/pages/home.tsx (renamed from packages/ui/src/pages/home-view.tsx) | 2 | ||||
| -rw-r--r-- | packages/ui/src/pages/index.tsx | 12 | ||||
| -rw-r--r-- | packages/ui/src/pages/instances-view.tsx | 457 | ||||
| -rw-r--r-- | packages/ui/src/pages/routes.ts | 6 | ||||
| -rw-r--r-- | packages/ui/src/stores/game-store.ts | 184 |
7 files changed, 130 insertions, 675 deletions
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<string | null>(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<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/pages/home-view.tsx b/packages/ui/src/pages/home.tsx index da7238f..dc1413d 100644 --- a/packages/ui/src/pages/home-view.tsx +++ b/packages/ui/src/pages/home.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { BottomBar } from "@/components/bottom-bar"; import { useSaturnEffect } from "@/components/particle-background"; -export function HomeView() { +export function HomePage() { const [mouseX, setMouseX] = useState(0); const [mouseY, setMouseY] = useState(0); const saturn = useSaturnEffect(); 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 ( <div className="relative h-screen w-full overflow-hidden bg-background font-sans"> 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<string | null>(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<Instance | null>( - null, - ); - const [editingInstance, setEditingInstance] = useState<Instance | null>(null); - - // Form fields - const [duplicateName, setDuplicateName] = useState(""); - - useEffect(() => { - instancesStore.refresh(); - }, [instancesStore.refresh]); - - // Handlers to open modals - const openCreate = () => { - 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 ( - <div className="h-full flex flex-col gap-4 p-6 overflow-y-auto"> - <div className="flex items-center justify-between"> - <h1 className="text-2xl font-bold text-gray-900 dark:text-white"> - Instances - </h1> - <div className="flex items-center gap-2"> - <Button - type="button" - variant="outline" - onClick={handleImport} - disabled={isImporting} - > - {isImporting ? "Importing..." : "Import"} - </Button> - <Button - type="button" - variant="outline" - onClick={handleRepair} - disabled={repairing} - > - {repairing ? "Repairing..." : "Repair Index"} - </Button> - <Button - type="button" - onClick={openCreate} - className="px-4 py-2 transition-colors" - > - <Plus size={18} /> - Create Instance - </Button> - </div> - </div> - - {instancesStore.instances.length === 0 ? ( - <div className="flex-1 flex items-center justify-center"> - <div className="text-center text-gray-500 dark:text-gray-400"> - <p className="text-lg mb-2">No instances yet</p> - <p className="text-sm">Create your first instance to get started</p> - </div> - </div> - ) : ( - <ul className="flex flex-col space-y-3"> - {instancesStore.instances.map((instance) => { - const isActive = instancesStore.activeInstance?.id === instance.id; - const isRunning = runningInstanceId === instance.id; - const isLaunching = launchingInstanceId === instance.id; - const isStopping = stoppingInstanceId === instance.id; - const otherInstanceRunning = - runningInstanceId !== null && !isRunning; - - return ( - <li - key={instance.id} - onClick={() => instancesStore.setActiveInstance(instance)} - onKeyDown={async (e) => { - if (e.key === "Enter") { - try { - await instancesStore.setActiveInstance(instance); - } catch (e) { - console.error("Failed to set active instance:", e); - toast.error("Error setting active instance"); - } - } - }} - className="cursor-pointer" - > - <div - className={cn( - "flex flex-row space-x-3 p-3 justify-between", - "border bg-card/5 backdrop-blur-xl", - "hover:bg-accent/50 transition-colors", - isActive && "border-primary", - )} - > - <div className="flex flex-row space-x-4"> - {instance.iconPath ? ( - <div className="w-12 h-12 rounded overflow-hidden"> - <img - src={instance.iconPath} - alt={instance.name} - className="w-full h-full object-cover" - /> - </div> - ) : ( - <div className="w-12 h-12 rounded bg-linear-to-br from-blue-500 to-purple-600 flex items-center justify-center"> - <span className="text-white font-bold text-lg"> - {instance.name.charAt(0).toUpperCase()} - </span> - </div> - )} - - <div className="flex flex-col"> - <h3 className="text-lg font-semibold">{instance.name}</h3> - {instance.versionId ? ( - <p className="text-sm text-muted-foreground"> - {instance.versionId} - </p> - ) : ( - <p className="text-sm text-muted-foreground"> - No version selected - </p> - )} - </div> - </div> - - <div className="flex items-center"> - <div className="flex flex-row space-x-2"> - <Button - variant={isRunning ? "destructive" : "ghost"} - size="icon" - onClick={async (e) => { - e.stopPropagation(); - - try { - await instancesStore.setActiveInstance(instance); - } catch (error) { - console.error( - "Failed to set active instance:", - error, - ); - toast.error("Error setting active instance"); - return; - } - - if (isRunning) { - await stopGame(instance.id); - return; - } - - if (!instance.versionId) { - toast.error("No version selected or installed"); - return; - } - - await startGame( - account, - () => { - toast.info("Please login first"); - }, - instance.id, - instance.versionId, - () => undefined, - ); - }} - disabled={ - otherInstanceRunning || isLaunching || isStopping - } - > - {isLaunching || isStopping ? ( - <span className="text-xs">...</span> - ) : isRunning ? ( - <XIcon /> - ) : ( - <RocketIcon /> - )} - </Button> - <Button - variant="ghost" - size="icon" - onClick={(e) => { - e.stopPropagation(); - void openFileExplorer(instance.gameDir); - }} - > - <FolderOpenIcon /> - </Button> - <Button - variant="ghost" - size="icon" - onClick={(e) => { - e.stopPropagation(); - void handleExport(instance); - }} - disabled={exportingId === instance.id} - > - <span className="text-xs"> - {exportingId === instance.id ? "..." : "ZIP"} - </span> - </Button> - <Button - variant="ghost" - size="icon" - onClick={(e) => { - e.stopPropagation(); - openDuplicate(instance); - }} - > - <CopyIcon /> - </Button> - <Button - variant="ghost" - size="icon" - onClick={(e) => { - e.stopPropagation(); - openEdit(instance); - }} - > - <EditIcon /> - </Button> - <Button - variant="destructive" - size="icon" - onClick={(e) => { - e.stopPropagation(); - openDelete(instance); - }} - > - <Trash2Icon /> - </Button> - </div> - </div> - </div> - </li> - ); - })} - </ul> - )} - - <InstanceCreationModal - open={showCreateModal} - onOpenChange={setShowCreateModal} - /> - - <InstanceEditorModal - open={showEditModal} - instance={editingInstance} - onOpenChange={(open) => { - setShowEditModal(open); - if (!open) setEditingInstance(null); - }} - /> - - {/* Delete Confirmation */} - <Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}> - <DialogContent> - <DialogHeader> - <DialogTitle>Delete Instance</DialogTitle> - <DialogDescription> - Are you sure you want to delete "{selectedInstance?.name}"? This - action cannot be undone. - </DialogDescription> - </DialogHeader> - - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => { - setShowDeleteConfirm(false); - setSelectedInstance(null); - }} - > - Cancel - </Button> - <Button - type="button" - onClick={confirmDelete} - className="bg-red-600 text-white hover:bg-red-500" - > - Delete - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - - {/* Duplicate Modal */} - <Dialog open={showDuplicateModal} onOpenChange={setShowDuplicateModal}> - <DialogContent> - <DialogHeader> - <DialogTitle>Duplicate Instance</DialogTitle> - <DialogDescription> - Provide a name for the duplicated instance. - </DialogDescription> - </DialogHeader> - - <div className="mt-4"> - <Input - value={duplicateName} - onChange={(e) => setDuplicateName(e.target.value)} - placeholder="New instance name" - onKeyDown={(e) => e.key === "Enter" && confirmDuplicate()} - /> - </div> - - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => { - setShowDuplicateModal(false); - setSelectedInstance(null); - setDuplicateName(""); - }} - > - Cancel - </Button> - <Button - type="button" - onClick={confirmDuplicate} - disabled={!duplicateName.trim()} - > - Duplicate - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - </div> - ); -} diff --git a/packages/ui/src/pages/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<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 }); - }, -})); |