import React, { useState, useEffect, memo, useCallback, useRef, useContext, useMemo, } from "react"; import { useTranslation } from "react-i18next"; import { motion, AnimatePresence } from "framer-motion"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import { useUI, useAudio, useConfig, GameContext, useGame, } from "../../context/LauncherContext"; import { TauriService, InstalledWorkshopPackage, type CustomEdition, } from "../../services/TauriService"; import { PluginManager } from "../../plugins/PluginManager"; import { usePlatform } from "../../hooks/usePlatform"; import { BASE_EDITIONS } from "../../hooks/useGameManager"; const REGISTRY_URL = "https://raw.githubusercontent.com/LCE-Hub/LCE-Workshop/refs/heads/main/registry.json"; const VERSIONS_URL = "https://raw.githubusercontent.com/LCE-Hub/LCE-Workshop/refs/heads/main/versions.json"; const PLUGINS_URL = "https://raw.githubusercontent.com/LCE-Hub/LCE-Workshop/refs/heads/main/plugins.json"; const RAW_BASE = "https://raw.githubusercontent.com/LCE-Hub/LCE-Workshop/refs/heads/main"; const VERSIONS_BASE = "https://raw.githubusercontent.com/LCE-Hub/LCE-Workshop/refs/heads/main/.00versions"; //const PLUGINS_BASE = // "https://raw.githubusercontent.com/LCE-Hub/LCE-Workshop/refs/heads/main/.00plugins"; const LCEONLINE_BASE = "https://api.mclegacyedition.xyz"; const CATEGORY_TABS = [ "Skin", "Texture", "World", "Mod", "DLC", "Plugins", ] as const; const UTILITY_TABS = ["Versions", "Installed", "Search"] as const; const SERVER_TABS = ["Server", "Server Plugins"] as const; const ALL_TABS = [...CATEGORY_TABS, ...UTILITY_TABS, ...SERVER_TABS] as const; type TabType = (typeof ALL_TABS)[number]; interface RegistryPackage { id: string; name: string; author: string; description: string; extended_description?: string; category: string[]; thumbnail: string; zips?: Record; version: string; logo?: string; url?: string; likes?: number; download_count?: number; game_version?: string; github_url?: string; download_url?: string; file_size?: number; server_address?: string; server_discord?: string; server_type?: string; main?: string; permissions?: string[]; files?: string[]; dependencies?: string[]; required_versions?: string[]; } interface ServerListing { id: string; owner: string; name: string; short_descripton: string; description: string; discord?: string; version: string; type: string; ip: string; port: string; icon: string; } interface LCEOnlineAddon { id: string; name: string; short_description: string; description: string; category: string; game_version: string; github_url?: string; created_at: string; likes: number; downloads: number; download_url: string; file_size: number; icon: string; has_icon: boolean; author: string; } interface PluginRegistryEntry { id: string; name: string; version: string; author: string; description: string; extended_description?: string; main: string; permissions?: string[]; files?: string[]; } const COLS = 4; interface ZipGroup { main: string; parts: string[]; dest: string; } function groupZips(zips: Record): ZipGroup[] { const entries = Object.entries(zips).sort(([a], [b]) => a.localeCompare(b)); const consumed = new Set(); const groups: ZipGroup[] = []; for (const [name, dest] of entries) { if (consumed.has(name)) continue; if (!name.toLowerCase().endsWith(".zip")) continue; const base = name.slice(0, -4); const parts = [name]; consumed.add(name); let n = 1; for (;;) { const cand = `${base}.z${String(n).padStart(2, "0")}`; if (zips[cand] !== undefined) { parts.push(cand); consumed.add(cand); n++; } else { break; } } if (parts.length === 1) { n = 1; for (;;) { const cand = `${base}.zip.${String(n).padStart(3, "0")}`; if (zips[cand] !== undefined) { parts.push(cand); consumed.add(cand); n++; } else { break; } } } groups.push({ main: name, parts, dest }); } for (const [name, dest] of entries) { if (!consumed.has(name)) { consumed.add(name); groups.push({ main: name, parts: [name], dest }); } } return groups; } interface WorkshopViewProps { workshopTarget?: { id: string; type?: string } | null; onClearWorkshopTarget?: () => void; } const WorkshopView = memo(function WorkshopView({ workshopTarget, onClearWorkshopTarget, }: WorkshopViewProps) { const { t } = useTranslation(); const { setActiveView } = useUI(); const { playPressSound, playBackSound } = useAudio(); const { isAndroid } = usePlatform(); const config = useConfig(); const containerRef = useRef(null); const gridRef = useRef(null); const searchRef = useRef(null); const [activeTab, setActiveTab] = useState("Skin"); const [allPackages, setAllPackages] = useState([]); const [versionPackages, setVersionPackages] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [focusedIdx, setFocusedIdx] = useState(null); const [search, setSearch] = useState(""); const [selectedPkg, setSelectedPkg] = useState(null); const [installedPkgs, setInstalledPkgs] = useState< InstalledWorkshopPackage[] >([]); const [serverPlugins, setServerPlugins] = useState([]); const [serverCategory, setServerCategory] = useState("all"); const [serverListings, setServerListings] = useState([]); const [serverListingCategory, setServerListingCategory] = useState("all"); const [savedServers, setSavedServers] = useState>(new Set()); const [pluginPackages, setPluginPackages] = useState([]); const [installedPluginIds, setInstalledPluginIds] = useState>( new Set(), ); const refreshInstalled = useCallback(async () => { try { const data = await TauriService.workshopListInstalled(); setInstalledPkgs(data); } catch { setInstalledPkgs([]); } }, []); const refreshInstalledPlugins = useCallback(() => { const ids = new Set(); PluginManager.instance.plugins.forEach((_, id) => ids.add(id)); setInstalledPluginIds(ids); }, []); useEffect(() => { containerRef.current?.focus(); refreshInstalled(); }, [refreshInstalled]); useEffect(() => { TauriService.loadConfig() .then((cfg) => { setSavedServers(new Set((cfg.savedServers || []).map((s) => s.ip))); }) .catch(() => {}); }, []); useEffect(() => { setLoading(true); setError(null); Promise.all([ fetch(REGISTRY_URL).then((r) => r.json()), fetch(VERSIONS_URL).then((r) => r.json()), fetch(PLUGINS_URL) .then((r) => r.json()) .catch(() => null), ]) .then(([registryData, versionsData, pluginsData]) => { setAllPackages(registryData.packages ?? []); setVersionPackages(versionsData.versionlist ?? []); if (pluginsData?.pluginlist) { setPluginPackages( pluginsData.pluginlist.map((entry: PluginRegistryEntry) => ({ id: entry.id, name: entry.name, version: entry.version, author: entry.author, description: entry.description, extended_description: entry.extended_description || "", category: ["Plugin"], thumbnail: "", main: entry.main, permissions: entry.permissions, files: entry.files, })), ); } setLoading(false); }) .catch((e) => { setError(e.message ?? t("workshop.failedToLoadRegistry")); setLoading(false); }); refreshInstalledPlugins(); }, [refreshInstalledPlugins]); useEffect(() => { fetch(`${LCEONLINE_BASE}/plugins`) .then((r) => r.json()) .then((data: LCEOnlineAddon[]) => { setServerPlugins( data.map((a) => ({ id: a.id, name: a.name, author: a.author, description: a.short_description, extended_description: a.description, category: [a.category], thumbnail: a.icon, version: "1.0", likes: a.likes, download_count: a.downloads, game_version: a.game_version, github_url: a.github_url, download_url: a.download_url, file_size: a.file_size, })), ); }) .catch(() => {}); }, []); useEffect(() => { fetch(`${LCEONLINE_BASE}/servers`) .then((r) => r.json()) .then((data: ServerListing[]) => { setServerListings( data.map((s) => ({ id: s.id, name: s.name, author: s.owner, description: s.ip, extended_description: `**Server:** ${s.type}\n**Version:** ${s.version}\n**Owner:** ${s.owner}`, category: [s.type], thumbnail: s.icon, version: s.version, server_address: s.ip, server_discord: s.discord ?? "", server_type: s.type, })), ); }) .catch(() => {}); }, []); const serverCategories = useMemo(() => { const cats = new Set(serverPlugins.flatMap((p) => p.category)); return ["all", ...cats]; }, [serverPlugins]); const serverListingCategories = useMemo(() => { const cats = new Set(serverListings.flatMap((p) => p.category)); return ["all", ...cats]; }, [serverListings]); const getInstalledEntries = useCallback( (pkgId: string, pkgVersion?: string) => { if (activeTab === "Versions") { const isAdded = config.customEditions?.some( (e: CustomEdition) => e.id === pkgId || e.url === versionPackages.find((p) => p.id === pkgId)?.url, ); if (isAdded) { const vPkg = versionPackages.find((p) => p.id === pkgId); return [ { packageId: pkgId, instanceId: pkgId, version: vPkg?.version || "0.0.0", }, ] as InstalledWorkshopPackage[]; } return []; } if (activeTab === "Plugins") { return installedPluginIds.has(pkgId) ? ([ { packageId: pkgId, instanceId: pkgId, version: pkgVersion || "0.0.0", }, ] as InstalledWorkshopPackage[]) : []; } if (activeTab === "Server Plugins" || activeTab === "Server") return []; return installedPkgs.filter((p) => p.packageId === pkgId); }, [ installedPkgs, activeTab, config.customEditions, versionPackages, installedPluginIds, ], ); const isInstalled = useCallback( (pkgId: string) => { if (activeTab === "Plugins") return installedPluginIds.has(pkgId); if (activeTab === "Server Plugins" || activeTab === "Server") return false; if (activeTab === "Versions") { return ( config.customEditions?.some( (e: CustomEdition) => e.id === pkgId || e.url === versionPackages.find((p) => p.id === pkgId)?.url, ) ?? false ); } return installedPkgs.some((p) => p.packageId === pkgId); }, [ installedPkgs, activeTab, config.customEditions, versionPackages, installedPluginIds, ], ); const hasUpdate = useCallback( (pkg: RegistryPackage) => { if (activeTab === "Plugins") { return false; } if ( activeTab === "Versions" || activeTab === "Server Plugins" || activeTab === "Server" ) return false; const entries = installedPkgs.filter((p) => p.packageId === pkg.id); return ( entries.length > 0 && entries.some((e) => e.version !== pkg.version) ); }, [installedPkgs, activeTab], ); const installedPackageList = allPackages.filter((pkg) => isInstalled(pkg.id)); const filteredItems = activeTab === "Installed" ? search.trim() ? installedPackageList.filter((pkg) => { const q = search.toLowerCase(); return ( pkg.name.toLowerCase().includes(q) || pkg.author.toLowerCase().includes(q) || pkg.description.toLowerCase().includes(q) ); }) : installedPackageList : activeTab === "Server Plugins" ? search.trim() ? serverPlugins.filter((pkg) => { if ( serverCategory !== "all" && !pkg.category.includes(serverCategory) ) return false; const q = search.toLowerCase(); return ( pkg.name.toLowerCase().includes(q) || pkg.author.toLowerCase().includes(q) || pkg.description.toLowerCase().includes(q) ); }) : serverCategory === "all" ? serverPlugins : serverPlugins.filter((pkg) => pkg.category.includes(serverCategory), ) : activeTab === "Server" ? search.trim() ? serverListings.filter((pkg) => { if ( serverListingCategory !== "all" && !pkg.category.includes(serverListingCategory) ) return false; const q = search.toLowerCase(); return ( pkg.name.toLowerCase().includes(q) || pkg.author.toLowerCase().includes(q) || pkg.description.toLowerCase().includes(q) ); }) : serverListingCategory === "all" ? serverListings : serverListings.filter((pkg) => pkg.category.includes(serverListingCategory), ) : activeTab === "Plugins" ? search.trim() ? pluginPackages.filter((pkg) => { const q = search.toLowerCase(); return ( pkg.name.toLowerCase().includes(q) || pkg.author.toLowerCase().includes(q) || pkg.description.toLowerCase().includes(q) ); }) : pluginPackages : (activeTab === "Versions" ? versionPackages : allPackages).filter( (pkg) => { const matchesTab = activeTab === "Search" || activeTab === "Versions" ? true : pkg.category.includes(activeTab); if (!matchesTab) return false; if (!search.trim()) return activeTab === "Search" ? false : true; const q = search.toLowerCase(); return ( pkg.name.toLowerCase().includes(q) || pkg.author.toLowerCase().includes(q) || pkg.description.toLowerCase().includes(q) ); }, ); useEffect(() => { setFocusedIdx(null); if (activeTab === "Search") { setTimeout(() => searchRef.current?.focus(), 50); } else { setSearch(""); } }, [activeTab]); useEffect(() => { if (focusedIdx !== null && gridRef.current) { const el = gridRef.current.querySelector( `[data-card="${focusedIdx}"]`, ) as HTMLElement; el?.scrollIntoView({ block: "nearest" }); } }, [focusedIdx]); const cycleTab = useCallback( (direction: "next" | "prev") => { playPressSound(); setActiveTab((prev) => { const idx = ALL_TABS.indexOf(prev); if (direction === "next") return ALL_TABS[(idx + 1) % ALL_TABS.length]; return ALL_TABS[(idx - 1 + ALL_TABS.length) % ALL_TABS.length]; }); }, [playPressSound], ); const selectTab = useCallback( (tab: TabType) => { if (tab !== activeTab) { playPressSound(); setActiveTab(tab); } }, [activeTab, playPressSound], ); const openModal = useCallback( (pkg: RegistryPackage) => { playPressSound(); setSelectedPkg(pkg); }, [playPressSound], ); const closeModal = useCallback(() => { playBackSound(); setSelectedPkg(null); }, [playBackSound]); useEffect(() => { if (!workshopTarget || loading) return; if (workshopTarget.type === "lceonline" && serverPlugins.length === 0) return; if (workshopTarget.type === "plugin" && pluginPackages.length === 0) return; if (workshopTarget.type === "version" && versionPackages.length === 0) return; if (!workshopTarget.type && allPackages.length === 0) return; let pkg: RegistryPackage | undefined; let tab: TabType | undefined; switch (workshopTarget.type) { case "lceonline": pkg = serverPlugins.find((p) => p.id === workshopTarget.id); tab = "Server Plugins"; break; case "plugin": pkg = pluginPackages.find((p) => p.id === workshopTarget.id); tab = "Plugins"; break; case "version": pkg = versionPackages.find((p) => p.id === workshopTarget.id); tab = "Versions"; break; default: pkg = allPackages.find((p) => p.id === workshopTarget.id); if (!pkg) { pkg = versionPackages.find((p) => p.id === workshopTarget.id); if (pkg) tab = "Versions"; } if (!pkg) { pkg = serverPlugins.find((p) => p.id === workshopTarget.id); if (pkg) tab = "Server Plugins"; } if (!pkg) { pkg = serverListings.find((p) => p.id === workshopTarget.id); if (pkg) tab = "Server"; } if (!pkg) { pkg = pluginPackages.find((p) => p.id === workshopTarget.id); if (pkg) tab = "Plugins"; } break; } if (pkg) { if (tab) setActiveTab(tab); openModal(pkg); onClearWorkshopTarget?.(); } }, [ workshopTarget, loading, allPackages, serverPlugins, pluginPackages, versionPackages, serverListings, openModal, onClearWorkshopTarget, ]); const toggleSavedServer = useCallback(async (serverPkg: RegistryPackage) => { if (!serverPkg.server_address) return; const cfg = await TauriService.loadConfig(); const current = cfg.savedServers || []; const exists = current.some((s) => s.ip === serverPkg.server_address); const newSaved = exists ? current.filter((s) => s.ip !== serverPkg.server_address) : [ ...current, { name: serverPkg.name, ip: serverPkg.server_address, port: 25565 }, ]; await TauriService.saveConfig({ ...cfg, savedServers: newSaved }); setSavedServers(new Set(newSaved.map((s) => s.ip))); }, []); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (selectedPkg) return; const isSearchInput = document.activeElement === searchRef.current; if (isSearchInput) { if (e.key === "Escape") { setSearch(""); containerRef.current?.focus(); } return; } const count = filteredItems.length; if (e.key === "Escape" || e.key === "Backspace") { playBackSound(); setActiveView("main"); return; } if (e.key === "e" || e.key === "E") { cycleTab("next"); return; } if (e.key === "q" || e.key === "Q") { cycleTab("prev"); return; } if (count === 0) return; if (e.key === "ArrowRight") { e.preventDefault(); setFocusedIdx((p) => Math.min((p ?? -1) + 1, count - 1)); playPressSound(); } else if (e.key === "ArrowLeft") { e.preventDefault(); setFocusedIdx((p) => Math.max((p ?? 1) - 1, 0)); playPressSound(); } else if (e.key === "ArrowDown") { e.preventDefault(); setFocusedIdx((p) => Math.min((p ?? -1) + (isPluginTab ? 1 : COLS), count - 1), ); playPressSound(); } else if (e.key === "ArrowUp") { e.preventDefault(); setFocusedIdx((p) => Math.max((p ?? 1) - (isPluginTab ? 1 : COLS), 0)); playPressSound(); } else if (e.key === "Enter" && focusedIdx !== null) { e.preventDefault(); const pkg = filteredItems[focusedIdx]; if (pkg) openModal(pkg); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [ playBackSound, playPressSound, setActiveView, cycleTab, filteredItems, focusedIdx, selectedPkg, openModal, ]); const tabLabel = (tab: TabType): string => { switch (tab) { case "Skin": return t("workshop.tabs.skin"); case "Texture": return t("workshop.tabs.texture"); case "World": return t("workshop.tabs.world"); case "Mod": return t("workshop.tabs.mod"); case "DLC": return t("workshop.tabs.dlc"); case "Plugins": return t("workshop.tabs.plugins"); case "Versions": return t("workshop.tabs.versions"); case "Installed": return t("workshop.tabs.installed"); case "Search": return t("workshop.tabs.search"); case "Server": return t("workshop.tabs.server"); case "Server Plugins": return t("workshop.tabs.serverPlugins"); default: return tab; } }; const isSearchTab = activeTab === "Search"; const isInstalledTab = activeTab === "Installed"; const isVersionTab = activeTab === "Versions"; const isPluginTab = activeTab === "Plugins"; const showSearch = isSearchTab || isInstalledTab || isVersionTab || isPluginTab || activeTab === "Server Plugins" || activeTab === "Server"; return (
{CATEGORY_TABS.map((tab, i) => { const isActive = tab === activeTab; return ( {i > 0 &&
} ); })}
{UTILITY_TABS.map((tab, i) => { const isActive = tab === activeTab; const updateCount = tab === "Installed" ? allPackages.filter((p) => hasUpdate(p)).length : 0; return ( {i > 0 &&
} ); })}
{SERVER_TABS.map((tab, i) => { const isActive = tab === activeTab; return ( {i > 0 &&
} ); })}
{showSearch ? (
{ setSearch(e.target.value); setFocusedIdx(null); }} placeholder={ isInstalledTab ? t("workshop.filterInstalled") : isVersionTab ? t("workshop.filterVersions") : isPluginTab ? t("workshop.filterPlugins") : activeTab === "Server Plugins" ? t("workshop.filterPlugins") : activeTab === "Server" ? t("workshop.filterServers") : t("workshop.enterKeywords") } spellCheck={false} autoFocus={isSearchTab} className="bg-transparent border-none outline-none text-white text-lg mc-text-shadow w-full placeholder-white/40 font-[var(--font-base)] tracking-widest" /> {search && ( )}
{activeTab === "Server Plugins" && serverCategories.length > 1 && (
{serverCategories.map((cat) => ( ))}
)} {activeTab === "Server" && serverListingCategories.length > 1 && (
{serverListingCategories.map((cat) => ( ))}
)}
{isInstalledTab && !search.trim() && filteredItems.length > 0 && (
)} {isSearchTab && !search.trim() ? (
{t("workshop.startTypingToSearch")}
) : loading ? (
{t("workshop.searchingArchives")}
) : filteredItems.length === 0 ? (
{isInstalledTab ? t("workshop.nothingInstalled") : activeTab === "Plugins" ? t("workshop.noPluginsAvailable") : activeTab === "Server Plugins" ? t("workshop.noPluginsAvailable") : activeTab === "Server" ? t("workshop.noServersAvailable") : t("common.noResults")}
) : isPluginTab ? (
{filteredItems.map((pkg, i) => ( setFocusedIdx(i)} onClick={() => openModal(pkg)} installed={isInstalled(pkg.id)} hasUpdate={hasUpdate(pkg)} isVersionTab={isVersionTab} isPluginTab={isPluginTab} /> ))}
) : (
{filteredItems.map((pkg, i) => ( setFocusedIdx(i)} onClick={() => openModal(pkg)} installed={isInstalled(pkg.id)} hasUpdate={hasUpdate(pkg)} isVersionTab={isVersionTab} /> ))}
)} {(activeTab === "Server Plugins" || activeTab === "Server") && (
LCEOnline TauriService.openUrl("https://mclegacyedition.xyz/") } />
)}
) : loading ? (
{t("workshop.searchingArchives")}
) : error ? (
{error}
) : (
{filteredItems.length === 0 ? (
{t("workshop.emptyCategory")}
) : isPluginTab ? (
{filteredItems.map((pkg, i) => ( setFocusedIdx(i)} onClick={() => openModal(pkg)} installed={isInstalled(pkg.id)} hasUpdate={hasUpdate(pkg)} isVersionTab={isVersionTab} isPluginTab={isPluginTab} /> ))}
) : (
{filteredItems.map((pkg, i) => ( setFocusedIdx(i)} onClick={() => openModal(pkg)} installed={isInstalled(pkg.id)} hasUpdate={hasUpdate(pkg)} isVersionTab={isVersionTab} isPluginTab={isPluginTab} /> ))}
)}
)}
{!isAndroid && (
)} {selectedPkg && ( { refreshInstalled(); refreshInstalledPlugins(); }} onUninstallComplete={() => { refreshInstalled(); refreshInstalledPlugins(); }} isVersionTab={activeTab === "Versions"} isServerTab={activeTab === "Server Plugins"} isGameServerTab={activeTab === "Server"} isPluginTab={isPluginTab} isSaved={ selectedPkg.server_address ? savedServers.has(selectedPkg.server_address) : false } onToggleSave={toggleSavedServer} /> )} ); }); function PackageCard({ pkg, index, focused, onHover, onClick, installed, hasUpdate, isVersionTab, isPluginTab, }: { pkg: RegistryPackage; index: number; focused: boolean; onHover: () => void; onClick: () => void; installed: boolean; hasUpdate: boolean; isVersionTab?: boolean; isPluginTab?: boolean; }) { const { t } = useTranslation(); const thumbnailUrl = pkg.thumbnail.startsWith("http") ? pkg.thumbnail : isVersionTab ? `${VERSIONS_BASE}/${pkg.id}/${pkg.thumbnail}` : `${RAW_BASE}/${pkg.id}/${pkg.thumbnail}`; const [imgError, setImgError] = useState(false); return (
{pkg.thumbnail ? (
{!thumbnailUrl || imgError ? ( {t("workshop.noImage")} ) : ( {pkg.name} setImgError(true)} /> )}
{pkg.category.slice(0, 1).map((c) => ( {c} ))}
{hasUpdate && (
{t("workshop.update")}
)} {installed && !hasUpdate && (
{isVersionTab ? t("workshop.added") : t("workshop.installed")}
)}
) : null}
{pkg.name}
v{pkg.version} {pkg.author}

{pkg.description}

); } function PackageModal({ pkg, allPackages, versionPackages, customEditions, installedPkgs, onClose, playPressSound, installedEntries, onInstallComplete, onUninstallComplete, isVersionTab, isServerTab, isGameServerTab, isPluginTab, isSaved, onToggleSave, }: { pkg: RegistryPackage; allPackages: RegistryPackage[]; versionPackages: RegistryPackage[]; customEditions: CustomEdition[]; installedPkgs: InstalledWorkshopPackage[]; onClose: () => void; playPressSound: () => void; installedEntries: InstalledWorkshopPackage[]; onInstallComplete: () => void; onUninstallComplete: () => void; isVersionTab?: boolean; isServerTab?: boolean; isGameServerTab?: boolean; isPluginTab?: boolean; isSaved?: boolean; onToggleSave?: (pkg: RegistryPackage) => void; }) { const { t } = useTranslation(); const { addCustomEdition } = useGame(); const thumbnailUrl = pkg.thumbnail.startsWith("http") ? pkg.thumbnail : isVersionTab ? `${VERSIONS_BASE}/${pkg.id}/${pkg.thumbnail}` : isPluginTab || !pkg.thumbnail ? "" : `${RAW_BASE}/${pkg.id}/${pkg.thumbnail}`; const [imgError, setImgError] = useState(false); const [modalFocus, setModalFocus] = useState< "install" | "uninstall" | "close" >("install"); const [showInstall, setShowInstall] = useState(false); const [showUninstall, setShowUninstall] = useState(false); const [confirmingDeps, setConfirmingDeps] = useState(false); const hasInstalled = installedEntries.length > 0; const needsUpdate = hasInstalled && installedEntries.some((e) => e.version !== pkg.version); const unresolvedDeps = useMemo( () => (pkg.dependencies || []).filter( (depId) => !installedPkgs.some((p) => p.packageId === depId), ), [pkg.dependencies, installedPkgs], ); const focusOptions: Array<"install" | "uninstall" | "close"> = isGameServerTab ? ["install", "close"] : isServerTab ? ["install", "close"] : hasInstalled || isVersionTab ? ["install", "uninstall", "close"] : ["install", "close"]; useEffect(() => { if (showInstall || showUninstall) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape" || e.key === "Backspace") { onClose(); } else if ( e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "Tab" ) { e.preventDefault(); playPressSound(); setModalFocus((p) => { const idx = focusOptions.indexOf(p); return focusOptions[(idx + 1) % focusOptions.length]; }); } else if (e.key === "Enter") { e.preventDefault(); if (modalFocus === "close") onClose(); else if (modalFocus === "install") handleAction(); else if (modalFocus === "uninstall") setShowUninstall(true); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [ modalFocus, showInstall, showUninstall, onClose, playPressSound, focusOptions, ]); const handleAction = async () => { if (isGameServerTab) { playPressSound(); if (onToggleSave) onToggleSave(pkg); } else if (isServerTab) { playPressSound(); try { const path = await TauriService.saveFileDialog( t("workshop.saveServerPlugin"), `${pkg.name}.dll`, ["*.dll", "*"], ); if (!path) return; const response = await fetch(`${pkg.download_url}`); const blob = await response.blob(); const buffer = await blob.arrayBuffer(); await TauriService.writeBinaryFile(path, new Uint8Array(buffer)); } catch (e) { console.error(e); } } else if (isVersionTab) { if (hasInstalled) return; playPressSound(); try { const logoUrl = `${VERSIONS_BASE}/${pkg.id}/${pkg.logo}`; const localLogoPath = await TauriService.downloadLogo(pkg.id, logoUrl); addCustomEdition({ id: pkg.id, name: pkg.name, desc: pkg.description, url: pkg.url!, category: pkg.category, logo: localLogoPath, }); onInstallComplete(); } catch (e) { console.error(e); } } else if (isPluginTab) { setShowInstall(true); } else { const unresolvedDeps = (pkg.dependencies || []).filter( (depId) => !installedPkgs.some((p) => p.packageId === depId), ); if (unresolvedDeps.length > 0) { setConfirmingDeps(true); } else { setShowInstall(true); } } }; const installLabel = isGameServerTab ? isSaved ? t("workshop.added") : t("workshop.add") : isServerTab ? t("workshop.download") : isVersionTab ? hasInstalled ? t("workshop.added") : t("workshop.add") : isPluginTab ? hasInstalled ? needsUpdate ? t("workshop.update") : t("workshop.reinstall") : t("workshop.install") : !hasInstalled ? t("workshop.install") : needsUpdate ? t("workshop.update") : t("workshop.reinstall"); return ( <>
e.stopPropagation()} className="flex flex-col w-[640px] max-h-[85vh] overflow-hidden font-[var(--font-base)] mc-options-bg" > {isPluginTab ? (
{pkg.name} {t("workshop.byAuthor", { author: pkg.author })}
) : (
{!thumbnailUrl || imgError ? (
{t("workshop.noImage")}
) : ( {pkg.name} setImgError(true)} /> )}
{pkg.name} {t("workshop.byAuthor", { author: pkg.author })}
{needsUpdate && (
{t("workshop.updateAvailable")}
)} {hasInstalled && !needsUpdate && (
{t("workshop.installed")}
)} {isGameServerTab && isSaved && (
{t("workshop.saved")}
)}
)}
{pkg.description && pkg.description.trim() !== "" && (

{pkg.description}

)} {pkg.extended_description && pkg.extended_description.trim() !== "" && (
{t("workshop.details")}
{pkg.extended_description}
)}
{t("workshop.metadata")}
{isGameServerTab ? ( <>
{t("workshop.address")} {pkg.server_address || "N/A"}
{t("workshop.type")} {pkg.server_type || "N/A"}
{t("workshop.version")} {pkg.version}
{t("workshop.owner")} {pkg.author}
{pkg.server_discord && ( )} ) : isServerTab ? ( <>
{t("workshop.downloads")} {pkg.download_count?.toLocaleString() ?? 0}
{t("workshop.likes")} {pkg.likes?.toLocaleString() ?? 0}
{t("workshop.serverType")} {pkg.game_version || "N/A"}
{t("workshop.file")} {pkg.name || "N/A"}
{pkg.file_size && (
{t("workshop.fileSize")} {(pkg.file_size / 1024).toFixed(1)} KB
)} {pkg.github_url && ( )} ) : ( <>
{t("workshop.version")} v{pkg.version}
{t("workshop.packageId")} {pkg.id}
)} {hasInstalled && (
{t("workshop.installed")} v{installedEntries[0]?.version} {needsUpdate ? t("workshop.outdated") : ""}
)}
{t("workshop.categories")}
{pkg.category.map((c) => ( {c} ))}
{pkg.dependencies && pkg.dependencies.length > 0 && (
{t("workshop.requiredDependencies")}
{pkg.dependencies.map((depId) => { const dep = allPackages.find((p) => p.id === depId); const isDepInstalled = installedPkgs.some( (p) => p.packageId === depId, ); return ( {dep?.name || depId} {isDepInstalled ? t("workshop.installedSuffix") : ""} ); })}
)} {pkg.required_versions && pkg.required_versions.length > 0 && (
{t("workshop.requiredEditions")}
{pkg.required_versions.map((verId) => { const builtin = BASE_EDITIONS.find((e) => e.id === verId); const versionPkg = versionPackages.find( (v) => v.id === verId, ); const customEd = customEditions.find((e) => e.id === verId); const displayName = builtin?.name || customEd?.name || versionPkg?.name; if (!displayName) return null; return ( {displayName} ); })}
)} {pkg.zips && Object.keys(pkg.zips).length > 0 && (
{t("workshop.files")}
{groupZips(pkg.zips).map((group) => { const isSplit = group.parts.length > 1; return (
{group.main} {isSplit && ( {t("workshop.splitArchive", { count: group.parts.length, })} )} {isSplit && ( {group.parts.slice(1).join(", ")} )}
{group.dest && ( {group.dest} )}
); })}
)}
{hasInstalled && ( )}
{confirmingDeps && ( setConfirmingDeps(false)} > e.stopPropagation()} className="flex flex-col w-[520px] font-[var(--font-base)] text-white border-2 border-[#555] rounded-sm overflow-hidden" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated", }} >
{t("workshop.dependenciesRequired")} {t("workshop.dependenciesRequiredDesc")}
{unresolvedDeps.map((depId) => { const dep = allPackages.find((p) => p.id === depId); return (
{dep?.name || depId} v{dep?.version || "?"}
); })} {t("workshop.dependenciesAutoInstall")}
)}
{showInstall && ( { setShowInstall(false); onInstallComplete(); }} playPressSound={playPressSound} isPluginTab={isPluginTab} /> )} {showUninstall && ( { setShowUninstall(false); onUninstallComplete(); }} playPressSound={playPressSound} isVersionTab={isVersionTab} isPluginTab={isPluginTab} /> )} ); } function InstallModal({ pkg, allPackages, dependencies, onClose, playPressSound, isPluginTab, }: { pkg: RegistryPackage; allPackages: RegistryPackage[]; dependencies: string[]; onClose: () => void; playPressSound: () => void; isPluginTab?: boolean; }) { const { t } = useTranslation(); const game = useContext(GameContext); const matchingEditions = pkg.required_versions && pkg.required_versions.length > 0 ? (game?.editions || []).filter((e) => pkg.required_versions!.some( (rv) => e.id === rv || e.id.startsWith(rv + "_"), ), ) : game?.editions || []; const editionOptions = matchingEditions.flatMap((ed) => { const branches = ed.branches && ed.branches.length > 0 ? ed.branches : ["Stable"]; return branches .map((branch) => ({ label: branches.length > 1 ? `${ed.name} (${branch})` : ed.name, instanceId: branch === "Stable" ? ed.id : `${ed.id}_${branch}`, })) .filter((opt) => game?.installs.includes(opt.instanceId)); }); const [focusedIdx, setFocusedIdx] = useState(0); const [status, setStatus] = useState< "idle" | "installing" | "success" | "error" >("idle"); const [errorMsg, setErrorMsg] = useState(null); const [progress, setProgress] = useState(0); const [progressLabel, setProgressLabel] = useState(null); const [pkgPct, setPkgPct] = useState>({}); const installTargets = useMemo(() => { const targets: { id: string; zips: number }[] = []; for (const depId of dependencies) { const dep = allPackages.find((p) => p.id === depId); if (dep?.zips) targets.push({ id: dep.id, zips: Object.keys(dep.zips).length }); } if (pkg.zips) targets.push({ id: pkg.id, zips: Object.keys(pkg.zips).length }); return targets; }, [dependencies, allPackages, pkg.zips, pkg.id]); const totalZips = installTargets.reduce((acc, t) => acc + t.zips, 0); const overallProgress = useMemo(() => { if (totalZips === 0) return 0; let done = 0; for (const t of installTargets) { const pct = pkgPct[t.id]; if (pct !== undefined) done += (pct / 100) * t.zips; } return Math.min(100, Math.round((done / totalZips) * 100)); }, [pkgPct, installTargets, totalZips]); const installPlugin = useCallback(async () => { setStatus("installing"); setErrorMsg(null); setProgress(0); setProgressLabel(null); playPressSound(); try { const pluginsDir = await TauriService.getPluginsDir(); const pluginDir = `${pluginsDir}/${pkg.id}`; await TauriService.createPluginDir(pkg.id); const encoder = new TextEncoder(); const manifest = { id: pkg.id, name: pkg.name, version: pkg.version, author: pkg.author, description: pkg.description, extended_description: pkg.extended_description || "", main: pkg.main || "main.js", permissions: pkg.permissions || [], }; await TauriService.writeBinaryFile( `${pluginDir}/plugin.json`, encoder.encode(JSON.stringify(manifest, null, 2)), ); const pluginBaseUrl = `${RAW_BASE}/.00plugins/${pkg.id}`; const allFiles = [pkg.main || "main.js", ...(pkg.files || [])]; for (let i = 0; i < allFiles.length; i++) { const file = allFiles[i]; setProgressLabel(file); setProgress(Math.round((i / allFiles.length) * 100)); const res = await TauriService.httpProxyRequest( "GET", `${pluginBaseUrl}/${file}`, null, {}, ); if (res.status !== 200) throw new Error(`Failed to download ${file}`); await TauriService.writeBinaryFile( `${pluginDir}/${file}`, encoder.encode(res.body), ); setProgress(Math.round(((i + 1) / allFiles.length) * 100)); } await PluginManager.instance.reload(); setStatus("success"); } catch (e: unknown) { console.error(e); setStatus("error"); setErrorMsg( e instanceof Error ? e.message : typeof e === "string" ? e : "Unknown error", ); } }, [pkg, playPressSound]); useEffect(() => { if (isPluginTab && status === "idle") { installPlugin(); } }, [isPluginTab, status, installPlugin]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { e.stopPropagation(); if (status === "installing") return; if (status === "success") { if (e.key === "Escape" || e.key === "Backspace" || e.key === "Enter") { e.preventDefault(); onClose(); } return; } if (e.key === "Escape" || e.key === "Backspace") { onClose(); } else if (e.key === "ArrowUp") { e.preventDefault(); playPressSound(); setFocusedIdx((p) => Math.max(p - 1, 0)); } else if (e.key === "ArrowDown") { e.preventDefault(); playPressSound(); setFocusedIdx((p) => Math.min(p + 1, editionOptions.length - 1)); } else if (e.key === "Enter") { e.preventDefault(); if (editionOptions.length > 0) { installTo(editionOptions[focusedIdx].instanceId); } } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [editionOptions, focusedIdx, status, onClose, playPressSound]); const installDeps = async (instanceId: string) => { for (const depId of dependencies) { const depPkg = allPackages.find((p) => p.id === depId); if (!depPkg || !depPkg.zips) continue; setProgressLabel(depPkg.name); try { await TauriService.workshopInstall( instanceId, depPkg.id, depPkg.zips, depPkg.version, ); } catch (e) { console.error(`Failed to install dependency ${depId}:`, e); } } }; const installTo = async (instanceId: string) => { setStatus("installing"); setErrorMsg(null); setProgress(0); setProgressLabel(null); setPkgPct({}); playPressSound(); const unlisten = await TauriService.onWorkshopProgress((data) => { setPkgPct((prev) => ({ ...prev, [data.packageId]: data.percent })); }); try { if (dependencies.length > 0) { await installDeps(instanceId); } setProgressLabel(pkg.name); await TauriService.workshopInstall( instanceId, pkg.id, pkg.zips!, pkg.version, ); setStatus("success"); } catch (e: unknown) { console.error(e); setStatus("error"); setErrorMsg( e instanceof Error ? e.message : typeof e === "string" ? e : "Unknown error", ); } finally { unlisten(); } }; return ( e.stopPropagation()} className="flex flex-col w-[520px] font-[var(--font-base)] text-white border-2 border-[#555] rounded-sm overflow-hidden" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated", }} >
{isPluginTab ? t("workshop.installPluginTitle") : t("workshop.installContentTitle")} {isPluginTab ? t("workshop.installingPackage", { name: pkg.name }) : t("workshop.targetEdition", { name: pkg.name })}
{status === "installing" && (
{dependencies.length > 0 ? t("workshop.installingDependencies") : t("workshop.installing")} {isPluginTab ? t("workshop.downloadingPluginFiles") : dependencies.length > 0 ? t("workshop.downloadingDependencies") : t("workshop.downloadingAssets")}
{progressLabel || (isPluginTab ? t("workshop.downloadingPluginFiles") : t("workshop.downloadingAssets"))} {Math.floor(isPluginTab ? progress : overallProgress)}%
{dependencies.length > 0 && (
{dependencies.map((depId) => { const dep = allPackages.find((p) => p.id === depId); return ( {dep?.name || depId} ); })}
)} {isPluginTab && pkg.permissions && pkg.permissions.length > 0 && (
{t("workshop.requestedPermissions")}
{pkg.permissions.map((perm) => ( {perm} ))}
)}
)} {status === "success" && (
{t("workshop.installedSuccessfully")} {t("workshop.pressAnyKeyToContinue")}
)} {status === "error" && (
{t("workshop.installationFailed")} {errorMsg}
)} {status === "idle" && !isPluginTab && (editionOptions.length === 0 ? (
{t("workshop.noInstalledEditions")}
) : ( editionOptions.map((opt, i) => (
installTo(opt.instanceId)} onMouseEnter={() => setFocusedIdx(i)} className={`flex flex-col p-3 cursor-pointer border-2 transition-none ${focusedIdx === i ? "border-[#FFFF55] bg-black/40" : "border-[#444] bg-black/20"}`} > {opt.label}
)) ))}
); } function UninstallModal({ pkg, installedEntries, onClose, playPressSound, isVersionTab, isPluginTab, }: { pkg: RegistryPackage; installedEntries: InstalledWorkshopPackage[]; onClose: () => void; playPressSound: () => void; isVersionTab?: boolean; isPluginTab?: boolean; }) { const { t } = useTranslation(); const { deleteCustomEdition } = useGame(); const game = useContext(GameContext); const [focusedIdx, setFocusedIdx] = useState(0); const [status, setStatus] = useState< "idle" | "removing" | "success" | "error" >("idle"); const [errorMsg, setErrorMsg] = useState(null); const editionName = (instanceId: string) => { const ed = game?.editions.find((e) => e.id === instanceId); return ed?.name ?? instanceId; }; const uninstallPlugin = useCallback(async () => { setStatus("removing"); setErrorMsg(null); playPressSound(); try { await TauriService.removePluginDir(pkg.id); await PluginManager.instance.reload(); setStatus("success"); } catch (e: unknown) { console.error(e); setStatus("error"); setErrorMsg( e instanceof Error ? e.message : typeof e === "string" ? e : "Unknown error", ); } }, [pkg.id, playPressSound]); useEffect(() => { if (isPluginTab && status === "idle") { uninstallPlugin(); } }, [isPluginTab, status, uninstallPlugin]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { e.stopPropagation(); if (status === "removing") return; if (status === "success") { if (e.key === "Escape" || e.key === "Backspace" || e.key === "Enter") { e.preventDefault(); onClose(); } return; } if (e.key === "Escape" || e.key === "Backspace") { onClose(); } else if (e.key === "ArrowUp") { e.preventDefault(); playPressSound(); setFocusedIdx((p) => Math.max(p - 1, 0)); } else if (e.key === "ArrowDown") { e.preventDefault(); playPressSound(); setFocusedIdx((p) => Math.min(p + 1, installedEntries.length - 1)); } else if (e.key === "Enter") { e.preventDefault(); if (installedEntries.length > 0) { uninstallFrom(installedEntries[focusedIdx].instanceId); } } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [installedEntries, focusedIdx, status, onClose, playPressSound]); const uninstallFrom = async (instanceId: string) => { setStatus("removing"); setErrorMsg(null); playPressSound(); try { if (isVersionTab) { deleteCustomEdition(pkg.id); } else { await TauriService.workshopUninstall(instanceId, pkg.id); } setStatus("success"); } catch (e: unknown) { console.error(e); setStatus("error"); setErrorMsg( e instanceof Error ? e.message : typeof e === "string" ? e : "Unknown error", ); } }; return ( e.stopPropagation()} className="flex flex-col w-[520px] font-[var(--font-base)] text-white border-2 border-[#555] rounded-sm overflow-hidden" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated", }} >
{isPluginTab ? t("workshop.removePluginTitle") : t("workshop.removeContentTitle")} {isPluginTab ? t("workshop.removePackage", { name: pkg.name }) : t("workshop.selectEditionToRemove", { name: pkg.name })}
{status === "removing" && (
{t("workshop.removing")} {t("workshop.deletingFiles")}
)} {status === "success" && (
{t("workshop.removedSuccessfully")} {t("workshop.pressAnyKeyToContinue")}
)} {status === "error" && (
{t("workshop.removalFailed")} {errorMsg}
)} {status === "idle" && !isPluginTab && installedEntries.map((entry, i) => (
uninstallFrom(entry.instanceId)} onMouseEnter={() => setFocusedIdx(i)} className={`flex items-center justify-between p-3 cursor-pointer border-2 transition-none ${focusedIdx === i ? "border-[#FF5555] bg-black/40" : "border-[#444] bg-black/20"}`} > {editionName(entry.instanceId)} v{entry.version}
))}
); } export default WorkshopView;