diff options
Diffstat (limited to 'packages')
35 files changed, 1370 insertions, 1979 deletions
diff --git a/packages/docs/CHANGELOG.md b/packages/docs/CHANGELOG.md new file mode 100644 index 0000000..8f904d9 --- /dev/null +++ b/packages/docs/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## v0.1.0-alpha.1 + +### Chores + +- [`906a042`](https://github.com/HydroRoll-Team/DropOut/commit/906a04226be13a7435b775c0f21a685d95fdbf34): Partially apply docs frontend lint fixes ([#123](https://github.com/HydroRoll-Team/DropOut/pull/123) by @fu050409) diff --git a/packages/docs/package.json b/packages/docs/package.json index 009ed14..523e6be 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -1,6 +1,7 @@ { "name": "@dropout/docs", "type": "module", + "version": "0.1.0-alpha.1", "scripts": { "build": "react-router build", "dev": "react-router dev", diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index d9e5b4d..b1d3254 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## v0.1.0-alpha.4 + +### Chores + +- [`ef478b2`](https://github.com/HydroRoll-Team/DropOut/commit/ef478b29605afbd1c3ec88184b64960e8ad01e71): Fix vite config to integrate with Tauri. ([#128](https://github.com/HydroRoll-Team/DropOut/pull/128) by @fu050409) + +### Refactors + +- [`5b799a1`](https://github.com/HydroRoll-Team/DropOut/commit/5b799a125a970e5e56f29a08b3c86450855fb6c4): Full rewrite instance create with stepper page instead of modal. ([#129](https://github.com/HydroRoll-Team/DropOut/pull/129) by @fu050409) +- [`ffbfce8`](https://github.com/HydroRoll-Team/DropOut/commit/ffbfce895c37e8e8306d426a2e59e73647ed6a86): Refactor game store and rename `HomePage` component. ([#129](https://github.com/HydroRoll-Team/DropOut/pull/129) by @fu050409) +- [`18aceb4`](https://github.com/HydroRoll-Team/DropOut/commit/18aceb4ddf01e964d0b81a4e926e42b72c64e355): Rewrite `ParticleBackground` to modern component design instead of global `window` api call. ([#129](https://github.com/HydroRoll-Team/DropOut/pull/129) by @fu050409) +- [`97fe504`](https://github.com/HydroRoll-Team/DropOut/commit/97fe5046f68b5e4ee5f750945bcc39a27f5eb37b): Rewrite effect instance nullish checking. ([#129](https://github.com/HydroRoll-Team/DropOut/pull/129) by @fu050409) + +### New Features + +- [`32a4d85`](https://github.com/HydroRoll-Team/DropOut/commit/32a4d85af937e4fd882fa671aee8b72878cc564f): Remove all legacy codes in `stores/`. ([#129](https://github.com/HydroRoll-Team/DropOut/pull/129) by @fu050409) + ## v0.1.0-alpha.3 ### Refactors diff --git a/packages/ui/package.json b/packages/ui/package.json index 9f329e4..2c04e98 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,7 +1,7 @@ { "name": "@dropout/ui", "private": true, - "version": "0.1.0-alpha.3", + "version": "0.1.0-alpha.4", "type": "module", "scripts": { "dev": "vite", @@ -46,4 +46,4 @@ "typescript": "~5.9.3", "vite": "npm:rolldown-vite@^7" } -} +}
\ No newline at end of file diff --git a/packages/ui/src/components/bottom-bar.tsx b/packages/ui/src/components/bottom-bar.tsx index 2746e00..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,31 +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?.versionId, selectedVersion, setSelectedVersion]); + const [showLoginModal, setShowLoginModal] = useState(false); const handleInstanceChange = useCallback( async (instanceId: string) => { @@ -74,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/game-console.tsx b/packages/ui/src/components/game-console.tsx.bk index 6980c8c..6980c8c 100644 --- a/packages/ui/src/components/game-console.tsx +++ b/packages/ui/src/components/game-console.tsx.bk 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 ad0c809..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,11 +96,8 @@ 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)}`); @@ -135,7 +134,7 @@ 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"); @@ -149,7 +148,7 @@ 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)}`); 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 84505da..1214480 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} @@ -198,7 +208,9 @@ function FieldError({ <ul className="ml-4 flex list-disc flex-col gap-1"> {uniqueErrors.map( (error) => - error?.message && <li key={error.message}>{error.message}</li>, + error?.message && ( + <li key={`field-error-${error.message}`}>{error.message}</li> + ), )} </ul> ); @@ -212,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/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/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 2f338b5..8c108c1 100644 --- a/packages/ui/src/models/instance.ts +++ b/packages/ui/src/models/instance.ts @@ -20,7 +20,7 @@ interface InstanceState { activeInstance: Instance | null; refresh: () => Promise<void>; - create: (name: string) => Promise<Instance | null>; + create: (name: string) => Promise<Instance>; delete: (id: string) => Promise<void>; update: (instance: Instance) => Promise<void>; setActiveInstance: (instance: Instance) => Promise<void>; @@ -64,17 +64,11 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({ create: async (name) => { const { refresh } = get(); - try { - const instance = await createInstance(name); - await setActiveInstanceCommand(instance.id); - await refresh(); - toast.success(`Instance "${name}" created successfully`); - return instance; - } catch (e) { - console.error("Failed to create instance:", e); - toast.error(String(e)); - return null; - } + const instance = await createInstance(name); + await setActiveInstanceCommand(instance.id); + await refresh(); + toast.success(`Instance "${name}" created successfully`); + return instance; }, delete: async (id) => { diff --git a/packages/ui/src/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 > 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 > 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 b93bb9b..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"> @@ -55,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> </> )} @@ -81,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 7bb3302..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,30 +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, @@ -64,7 +66,7 @@ export function InstancesView() { // Handlers to open modals const openCreate = () => { - setShowCreateModal(true); + navigate("/instances/create"); }; const openEdit = (instance: Instance) => { @@ -151,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" @@ -190,11 +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 @@ -280,22 +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 + (!!runningInstanceId && + runningInstanceId !== instance.id) || + isLaunching || + isStopping } > {isLaunching || isStopping ? ( - <span className="text-xs">...</span> + <EllipsisIcon /> ) : isRunning ? ( <XIcon /> ) : ( @@ -364,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 54f30d3..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 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 }); - }, -})); 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(); -} diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts index 241ca8f..34df162 100644 --- a/packages/ui/vite.config.ts +++ b/packages/ui/vite.config.ts @@ -3,12 +3,31 @@ import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; +const host = process.env.TAURI_DEV_HOST; + // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` + // + // 1. prevent Vite from obscuring rust errors + clearScreen: false, + // 2. tauri expects a fixed port, fail if that port is not available server: { - port: 5173, + port: 1420, strictPort: true, + host: host || false, + hmr: host + ? { + protocol: "ws", + host, + port: 1421, + } + : undefined, + watch: { + // 3. tell Vite to ignore watching `src-tauri` + ignored: ["**/src-tauri/**"], + }, }, resolve: { alias: { |