diff options
| author | 2026-01-23 20:51:28 +0800 | |
|---|---|---|
| committer | 2026-01-23 20:51:28 +0800 | |
| commit | 9430bee86fbf943283eb5a6f63bd750b875ff433 (patch) | |
| tree | 2271b2dee546339add4607dead56c230c03e6afe /packages/ui-new/src/pages/versions-view.tsx | |
| parent | ef560813c68c113325d8d84ff13cd419eb6583df (diff) | |
| download | DropOut-9430bee86fbf943283eb5a6f63bd750b875ff433.tar.gz DropOut-9430bee86fbf943283eb5a6f63bd750b875ff433.zip | |
feat(ui): add new ui project
Diffstat (limited to 'packages/ui-new/src/pages/versions-view.tsx')
| -rw-r--r-- | packages/ui-new/src/pages/versions-view.tsx | 662 |
1 files changed, 662 insertions, 0 deletions
diff --git a/packages/ui-new/src/pages/versions-view.tsx b/packages/ui-new/src/pages/versions-view.tsx new file mode 100644 index 0000000..7f44611 --- /dev/null +++ b/packages/ui-new/src/pages/versions-view.tsx @@ -0,0 +1,662 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { Coffee, Loader2, Search, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +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 { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useGameStore } from "../stores/game-store"; +import { useInstancesStore } from "../stores/instances-store"; +import type { Version } from "../types/bindings/manifest"; + +interface InstalledModdedVersion { + id: string; + javaVersion?: number; +} + +type TypeFilter = "all" | "release" | "snapshot" | "installed"; + +export function VersionsView() { + const { versions, selectedVersion, loadVersions, setSelectedVersion } = + useGameStore(); + const { activeInstanceId } = useInstancesStore(); + + const [searchQuery, setSearchQuery] = useState(""); + const [typeFilter, setTypeFilter] = useState<TypeFilter>("all"); + const [installedModdedVersions, setInstalledModdedVersions] = useState< + InstalledModdedVersion[] + >([]); + const [, setIsLoadingModded] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [versionToDelete, setVersionToDelete] = useState<string | null>(null); + const [isDeleting, setIsDeleting] = useState(false); + const [selectedVersionMetadata, setSelectedVersionMetadata] = useState<{ + id: string; + javaVersion?: number; + isInstalled: boolean; + } | null>(null); + const [isLoadingMetadata, setIsLoadingMetadata] = useState(false); + const [showModLoaderSelector, setShowModLoaderSelector] = useState(false); + + const normalizedQuery = searchQuery.trim().toLowerCase().replace(/。/g, "."); + + // Load installed modded versions with Java version info + const loadInstalledModdedVersions = useCallback(async () => { + if (!activeInstanceId) { + setInstalledModdedVersions([]); + setIsLoadingModded(false); + return; + } + + setIsLoadingModded(true); + try { + const allInstalled = await invoke<Array<{ id: string; type: string }>>( + "list_installed_versions", + { instanceId: activeInstanceId }, + ); + + const moddedIds = allInstalled + .filter((v) => v.type === "fabric" || v.type === "forge") + .map((v) => v.id); + + const versionsWithJava = await Promise.all( + moddedIds.map(async (id) => { + try { + const javaVersion = await invoke<number | null>( + "get_version_java_version", + { + instanceId: activeInstanceId, + versionId: id, + }, + ); + return { + id, + javaVersion: javaVersion ?? undefined, + }; + } catch (e) { + console.error(`Failed to get Java version for ${id}:`, e); + return { id, javaVersion: undefined }; + } + }), + ); + + setInstalledModdedVersions(versionsWithJava); + } catch (e) { + console.error("Failed to load installed modded versions:", e); + toast.error("Error loading modded versions"); + } finally { + setIsLoadingModded(false); + } + }, [activeInstanceId]); + + // Combined versions list (vanilla + modded) + const allVersions = (() => { + const moddedVersions: Version[] = installedModdedVersions.map((v) => { + const versionType = v.id.startsWith("fabric-loader-") + ? "fabric" + : v.id.includes("-forge-") + ? "forge" + : "fabric"; + return { + id: v.id, + type: versionType, + url: "", + time: "", + releaseTime: new Date().toISOString(), + javaVersion: BigInt(v.javaVersion ?? 0), + isInstalled: true, + }; + }); + return [...moddedVersions, ...versions]; + })(); + + // Filter versions based on search and type filter + const filteredVersions = allVersions.filter((version) => { + if (typeFilter === "release" && version.type !== "release") return false; + if (typeFilter === "snapshot" && version.type !== "snapshot") return false; + if (typeFilter === "installed" && !version.isInstalled) return false; + + if ( + normalizedQuery && + !version.id.toLowerCase().includes(normalizedQuery) + ) { + return false; + } + + return true; + }); + + // Get version badge styling + const getVersionBadge = (type: string) => { + switch (type) { + case "release": + return { + text: "Release", + variant: "default" as const, + className: "bg-emerald-500 hover:bg-emerald-600", + }; + case "snapshot": + return { + text: "Snapshot", + variant: "secondary" as const, + className: "bg-amber-500 hover:bg-amber-600", + }; + case "fabric": + return { + text: "Fabric", + variant: "outline" as const, + className: "border-indigo-500 text-indigo-700 dark:text-indigo-300", + }; + case "forge": + return { + text: "Forge", + variant: "outline" as const, + className: "border-orange-500 text-orange-700 dark:text-orange-300", + }; + case "modpack": + return { + text: "Modpack", + variant: "outline" as const, + className: "border-purple-500 text-purple-700 dark:text-purple-300", + }; + default: + return { + text: type, + variant: "outline" as const, + className: "border-gray-500 text-gray-700 dark:text-gray-300", + }; + } + }; + + // Load version metadata + const loadVersionMetadata = useCallback( + async (versionId: string) => { + if (!versionId || !activeInstanceId) { + setSelectedVersionMetadata(null); + return; + } + + setIsLoadingMetadata(true); + try { + const metadata = await invoke<{ + id: string; + javaVersion?: number; + isInstalled: boolean; + }>("get_version_metadata", { + instanceId: activeInstanceId, + versionId, + }); + setSelectedVersionMetadata(metadata); + } catch (e) { + console.error("Failed to load version metadata:", e); + setSelectedVersionMetadata(null); + } finally { + setIsLoadingMetadata(false); + } + }, + [activeInstanceId], + ); + + // Get base version for mod loader selector + const selectedBaseVersion = (() => { + if (!selectedVersion) return ""; + + if (selectedVersion.startsWith("fabric-loader-")) { + const parts = selectedVersion.split("-"); + return parts[parts.length - 1]; + } + if (selectedVersion.includes("-forge-")) { + return selectedVersion.split("-forge-")[0]; + } + + const version = versions.find((v) => v.id === selectedVersion); + return version ? selectedVersion : ""; + })(); + + // Handle version deletion + const handleDeleteVersion = async () => { + if (!versionToDelete || !activeInstanceId) return; + + setIsDeleting(true); + try { + await invoke("delete_version", { + instanceId: activeInstanceId, + versionId: versionToDelete, + }); + + if (selectedVersion === versionToDelete) { + setSelectedVersion(""); + } + + setShowDeleteDialog(false); + setVersionToDelete(null); + toast.success("Version deleted successfully"); + + await loadVersions(activeInstanceId); + await loadInstalledModdedVersions(); + } catch (e) { + console.error("Failed to delete version:", e); + toast.error(`Failed to delete version: ${e}`); + } finally { + setIsDeleting(false); + } + }; + + // Show delete confirmation dialog + const showDeleteConfirmation = (versionId: string, e: React.MouseEvent) => { + e.stopPropagation(); + setVersionToDelete(versionId); + setShowDeleteDialog(true); + }; + + // Setup event listeners for version updates + useEffect(() => { + let unlisteners: UnlistenFn[] = []; + + const setupEventListeners = async () => { + try { + const versionDeletedUnlisten = await listen( + "version-deleted", + async () => { + await loadVersions(activeInstanceId ?? undefined); + await loadInstalledModdedVersions(); + }, + ); + + const downloadCompleteUnlisten = await listen( + "download-complete", + async () => { + await loadVersions(activeInstanceId ?? undefined); + await loadInstalledModdedVersions(); + }, + ); + + const versionInstalledUnlisten = await listen( + "version-installed", + async () => { + await loadVersions(activeInstanceId ?? undefined); + await loadInstalledModdedVersions(); + }, + ); + + const fabricInstalledUnlisten = await listen( + "fabric-installed", + async () => { + await loadVersions(activeInstanceId ?? undefined); + await loadInstalledModdedVersions(); + }, + ); + + const forgeInstalledUnlisten = await listen( + "forge-installed", + async () => { + await loadVersions(activeInstanceId ?? undefined); + await loadInstalledModdedVersions(); + }, + ); + + unlisteners = [ + versionDeletedUnlisten, + downloadCompleteUnlisten, + versionInstalledUnlisten, + fabricInstalledUnlisten, + forgeInstalledUnlisten, + ]; + } catch (e) { + console.error("Failed to setup event listeners:", e); + } + }; + + setupEventListeners(); + loadInstalledModdedVersions(); + + return () => { + unlisteners.forEach((unlisten) => { + unlisten(); + }); + }; + }, [activeInstanceId, loadVersions, loadInstalledModdedVersions]); + + // Load metadata when selected version changes + useEffect(() => { + if (selectedVersion) { + loadVersionMetadata(selectedVersion); + } else { + setSelectedVersionMetadata(null); + } + }, [selectedVersion, loadVersionMetadata]); + + return ( + <div className="h-full flex flex-col p-6 overflow-hidden"> + <div className="flex items-center justify-between mb-6"> + <h2 className="text-3xl font-black bg-clip-text text-transparent bg-linear-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/60"> + Version Manager + </h2> + <div className="text-sm dark:text-white/40 text-black/50"> + Select a version to play or modify + </div> + </div> + + <div className="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-6 overflow-hidden"> + {/* Left: Version List */} + <div className="lg:col-span-2 flex flex-col gap-4 overflow-hidden"> + {/* Search and Filters */} + <div className="flex gap-3"> + <div className="relative flex-1"> + <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> + <Input + type="text" + placeholder="Search versions..." + className="pl-9 bg-white/60 dark:bg-black/20 border-black/10 dark:border-white/10 dark:text-white text-gray-900 placeholder-black/30 dark:placeholder-white/30 focus:border-indigo-500/50 dark:focus:bg-black/40 focus:bg-white/80 backdrop-blur-sm" + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + /> + </div> + </div> + + {/* Type Filter Tabs */} + <Tabs + value={typeFilter} + onValueChange={(v) => setTypeFilter(v as TypeFilter)} + className="w-full" + > + <TabsList className="grid grid-cols-4 bg-white/60 dark:bg-black/20 border-black/5 dark:border-white/5"> + <TabsTrigger value="all">All</TabsTrigger> + <TabsTrigger value="release">Release</TabsTrigger> + <TabsTrigger value="snapshot">Snapshot</TabsTrigger> + <TabsTrigger value="installed">Installed</TabsTrigger> + </TabsList> + </Tabs> + + {/* Version List */} + <ScrollArea className="flex-1 pr-2"> + {versions.length === 0 ? ( + <div className="flex items-center justify-center h-40 dark:text-white/30 text-black/30 italic animate-pulse"> + Loading versions... + </div> + ) : filteredVersions.length === 0 ? ( + <div className="flex flex-col items-center justify-center h-40 dark:text-white/30 text-black/30 gap-2"> + <span className="text-2xl">👻</span> + <span>No matching versions found</span> + </div> + ) : ( + <div className="space-y-2"> + {filteredVersions.map((version) => { + const badge = getVersionBadge(version.type); + const isSelected = selectedVersion === version.id; + + return ( + <Card + key={version.id} + className={`w-full cursor-pointer transition-all duration-200 relative overflow-hidden group ${ + isSelected + ? "border-indigo-200 dark:border-indigo-500/50 bg-indigo-50 dark:bg-indigo-600/20 shadow-[0_0_20px_rgba(99,102,241,0.2)]" + : "border-black/5 dark:border-white/5 bg-white/40 dark:bg-white/5 hover:bg-white/60 dark:hover:bg-white/10 hover:border-black/10 dark:hover:border-white/10 hover:translate-x-1" + }`} + onClick={() => setSelectedVersion(version.id)} + > + {isSelected && ( + <div className="absolute inset-0 bg-linear-to-r from-indigo-500/10 to-transparent pointer-events-none" /> + )} + + <CardContent className="p-4"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-4 flex-1"> + <Badge + variant={badge.variant} + className={badge.className} + > + {badge.text} + </Badge> + <div className="flex-1"> + <div + className={`font-bold font-mono text-lg tracking-tight ${ + isSelected + ? "text-black dark:text-white" + : "text-gray-700 dark:text-zinc-300 group-hover:text-black dark:group-hover:text-white" + }`} + > + {version.id} + </div> + <div className="flex items-center gap-2 mt-0.5"> + {version.releaseTime && + version.type !== "fabric" && + version.type !== "forge" && ( + <div className="text-xs dark:text-white/30 text-black/30"> + {new Date( + version.releaseTime, + ).toLocaleDateString()} + </div> + )} + {version.javaVersion && ( + <div className="flex items-center gap-1 text-xs dark:text-white/40 text-black/40"> + <Coffee className="h-3 w-3 opacity-60" /> + <span className="font-medium"> + Java {version.javaVersion} + </span> + </div> + )} + </div> + </div> + </div> + + <div className="flex items-center gap-2"> + {version.isInstalled && ( + <Button + variant="ghost" + size="icon" + className="opacity-0 group-hover:opacity-100 transition-opacity text-red-500 dark:text-red-400 hover:bg-red-500/10 dark:hover:bg-red-500/20" + onClick={(e) => + showDeleteConfirmation(version.id, e) + } + title="Delete version" + > + <Trash2 className="h-4 w-4" /> + </Button> + )} + </div> + </div> + </CardContent> + </Card> + ); + })} + </div> + )} + </ScrollArea> + </div> + + {/* Right: Version Details */} + <div className="flex flex-col gap-6"> + <Card className="border-black/5 dark:border-white/5 bg-white/40 dark:bg-white/5 backdrop-blur-sm"> + <CardHeader> + <CardTitle className="text-lg">Version Details</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {selectedVersion ? ( + <> + <div> + <div className="text-sm text-muted-foreground mb-1"> + Selected Version + </div> + <div className="font-mono text-xl font-bold"> + {selectedVersion} + </div> + </div> + + {isLoadingMetadata ? ( + <div className="flex items-center gap-2"> + <Loader2 className="h-4 w-4 animate-spin" /> + <span className="text-sm">Loading metadata...</span> + </div> + ) : selectedVersionMetadata ? ( + <div className="space-y-3"> + <div> + <div className="text-sm text-muted-foreground mb-1"> + Installation Status + </div> + <Badge + variant={ + selectedVersionMetadata.isInstalled + ? "default" + : "outline" + } + > + {selectedVersionMetadata.isInstalled + ? "Installed" + : "Not Installed"} + </Badge> + </div> + + {selectedVersionMetadata.javaVersion && ( + <div> + <div className="text-sm text-muted-foreground mb-1"> + Java Version + </div> + <div className="flex items-center gap-2"> + <Coffee className="h-4 w-4" /> + <span> + Java {selectedVersionMetadata.javaVersion} + </span> + </div> + </div> + )} + + {!selectedVersionMetadata.isInstalled && ( + <Button + className="w-full" + onClick={() => setShowModLoaderSelector(true)} + > + Install with Mod Loader + </Button> + )} + </div> + ) : null} + </> + ) : ( + <div className="text-center py-8 text-muted-foreground"> + Select a version to view details + </div> + )} + </CardContent> + </Card> + + {/* Mod Loader Installation */} + {showModLoaderSelector && selectedBaseVersion && ( + <Card className="border-black/5 dark:border-white/5 bg-white/40 dark:bg-white/5 backdrop-blur-sm"> + <CardHeader> + <CardTitle className="text-lg">Install Mod Loader</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="text-sm text-muted-foreground"> + Install {selectedBaseVersion} with Fabric or Forge + </div> + <div className="flex gap-2"> + <Button + variant="outline" + className="flex-1" + onClick={async () => { + if (!activeInstanceId) return; + try { + await invoke("install_fabric", { + instanceId: activeInstanceId, + gameVersion: selectedBaseVersion, + loaderVersion: "latest", + }); + toast.success("Fabric installation started"); + setShowModLoaderSelector(false); + } catch (e) { + console.error("Failed to install Fabric:", e); + toast.error(`Failed to install Fabric: ${e}`); + } + }} + > + Install Fabric + </Button> + <Button + variant="outline" + className="flex-1" + onClick={async () => { + if (!activeInstanceId) return; + try { + await invoke("install_forge", { + instanceId: activeInstanceId, + gameVersion: selectedBaseVersion, + installerVersion: "latest", + }); + toast.success("Forge installation started"); + setShowModLoaderSelector(false); + } catch (e) { + console.error("Failed to install Forge:", e); + toast.error(`Failed to install Forge: ${e}`); + } + }} + > + Install Forge + </Button> + </div> + <Button + variant="ghost" + size="sm" + onClick={() => setShowModLoaderSelector(false)} + > + Cancel + </Button> + </CardContent> + </Card> + )} + </div> + </div> + + {/* Delete Confirmation Dialog */} + <Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}> + <DialogContent> + <DialogHeader> + <DialogTitle>Delete Version</DialogTitle> + <DialogDescription> + Are you sure you want to delete version "{versionToDelete}"? This + action cannot be undone. + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setShowDeleteDialog(false); + setVersionToDelete(null); + }} + disabled={isDeleting} + > + Cancel + </Button> + <Button + variant="destructive" + onClick={handleDeleteVersion} + disabled={isDeleting} + > + {isDeleting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + Deleting... + </> + ) : ( + "Delete" + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </div> + ); +} |