import { useState, useEffect, useRef, memo } from "react"; import { motion } from "framer-motion"; import { TauriService, PlaytimeResponse } from "../../services/TauriService"; import CustomTUModal from "../modals/CustomTUModal"; import SetUidModal from "../modals/SetUidModal"; import ImportWorldModal from "../modals/ImportWorldModal"; import PlaytimeModal from "../modals/PlaytimeModal"; import CustomizeModal from "../modals/CustomizeModal"; import DownloadDlcModal from "../modals/DownloadDlcModal"; import { useUI, useConfig, useAudio, useGame, } from "../../context/LauncherContext"; import { ScreenshotImage } from "../common/ScreenshotImage"; import type { Edition } from "../../types/edition"; interface DeleteConfirmButtonProps { label: string; onClick: () => void; isDanger?: boolean; } const DeleteConfirmButton = memo(function DeleteConfirmButton({ label, onClick, isDanger = false, }: DeleteConfirmButtonProps) { const [isHovered, setIsHovered] = useState(false); return ( setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} className={`w-24 h-10 flex items-center justify-center mc-text-shadow transition-colors ${ isDanger ? "text-red-500" : "text-white" } ${isHovered ? (isDanger ? "text-red-400" : "text-[#FFFF55]") : ""}`} style={{ backgroundImage: isHovered ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated", }} > {label} ); }); function formatPlaytime(seconds: number): string { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); if (h > 0) return `${h}h ${m}m`; if (m > 0) return `${m}m`; return seconds > 0 ? `${seconds}s` : ""; } const VersionsView = memo(function VersionsView() { const { setActiveView } = useUI(); const { profile: selectedProfile, setProfile: setSelectedProfile, animationsEnabled, } = useConfig(); const { playPressSound, playBackSound } = useAudio(); const { editions, installs: installedVersions, toggleInstall, handleUninstall, handleCancelDownload, deleteCustomEdition: onDeleteEdition, addCustomEdition: onAddEdition, updateCustomEdition: onUpdateEdition, downloadingId, downloadProgress, updatesAvailable, addToSteam, cycleBranch, customizations, updateCustomization, saveCustomPath, } = useGame(); const { isDayTime } = useConfig(); const [focusIndex, setFocusIndex] = useState(0); const [focusBtn, setFocusBtn] = useState(0); const [isImportModalOpen, setIsImportModalOpen] = useState(false); const [isSetUidModalOpen, setIsSetUidModalOpen] = useState(false); const [setUidTargetId, setSetUidTargetId] = useState(""); const [editingEdition, setEditingEdition] = useState(null); const [isImportWorldModalOpen, setIsImportWorldModalOpen] = useState(false); const [importWorldTarget, setImportWorldTarget] = useState<{ id: string; name: string } | null>(null); const [isPlaytimeModalOpen, setIsPlaytimeModalOpen] = useState(false); const [playtimeTarget, setPlaytimeTarget] = useState<{ id: string; name: string } | null>(null); const [isCustomizeModalOpen, setIsCustomizeModalOpen] = useState(false); const [customizeTarget, setCustomizeTarget] = useState(null); const [playtimeMap, setPlaytimeMap] = useState>({}); const [initialPath, setInitialPath] = useState(""); const [hoveredBtn, setHoveredBtn] = useState<{ row: number; btn: string; } | null>(null); const [openMenuId, setOpenMenuId] = useState(null); const [deleteConfirmEdition, setDeleteConfirmEdition] = useState(null); const [isDlcModalOpen, setIsDlcModalOpen] = useState(false); const [dlcTargetEdition, setDlcTargetEdition] = useState(null); const containerRef = useRef(null); const listRef = useRef(null); const ITEM_COUNT = editions.length + 3; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (document.activeElement?.tagName === "INPUT") return; if (e.key === "Escape" || e.key === "Backspace") { playBackSound(); setActiveView("main"); return; } if (e.key === "ArrowDown") { e.preventDefault(); setFocusIndex((prev) => (prev >= ITEM_COUNT - 1 ? 0 : prev + 1)); setFocusBtn(0); } else if (e.key === "ArrowUp") { e.preventDefault(); setFocusIndex((prev) => (prev <= 0 ? ITEM_COUNT - 1 : prev - 1)); setFocusBtn(0); } else if (e.key === "ArrowLeft") { e.preventDefault(); if (focusIndex < editions.length) { const edition = editions[focusIndex]; const isInstalled = installedVersions.includes(edition.id); const isCustom = edition.id.startsWith("custom_"); const maxBtn = isInstalled ? (isCustom ? 6 : 4) : 1; setFocusBtn((prev) => (prev <= 0 ? maxBtn : prev - 1)); } } else if (e.key === "ArrowRight") { e.preventDefault(); if (focusIndex < editions.length) { const edition = editions[focusIndex]; const isInstalled = installedVersions.includes(edition.id); const isCustom = edition.id.startsWith("custom_"); const maxBtn = isInstalled ? (isCustom ? 6 : 4) : 1; setFocusBtn((prev) => (prev >= maxBtn ? 0 : prev + 1)); } } else if (e.key === "Enter") { e.preventDefault(); if (focusIndex < editions.length) { const edition = editions[focusIndex]; const isInstalled = installedVersions.includes(edition.instanceId); const isDownloading = downloadingId === edition.instanceId; if (focusBtn === 0) { if (isInstalled) { playPressSound(); setOpenMenuId(openMenuId === edition.id ? null : edition.id); } else { if (!isDownloading && !downloadingId) { playPressSound(); toggleInstall(edition.instanceId); } else if (isDownloading) { handleCancelDownload(); } } } else if (focusBtn === 1 && !isInstalled) { playPressSound(); setOpenMenuId(openMenuId === edition.id ? null : edition.id); } else if (focusBtn === 2) { playPressSound(); cycleBranch(edition.id); } } else if (focusIndex === editions.length) { playPressSound(); setIsImportModalOpen(true); } else if (focusIndex === editions.length + 1) { playPressSound(); handleImportFolder(); } else { playBackSound(); setActiveView("main"); } } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [ focusIndex, focusBtn, editions, installedVersions, downloadingId, ITEM_COUNT, playPressSound, playBackSound, setSelectedProfile, setActiveView, toggleInstall, handleCancelDownload, addToSteam, isDayTime, ]); useEffect(() => { if (focusIndex < editions.length && listRef.current) { const el = listRef.current.querySelector( `[data-index="${focusIndex}"]`, ) as HTMLElement; if (el) { el.scrollIntoView({ behavior: "smooth", block: "nearest" }); } } }, [focusIndex]); useEffect(() => { const fetchPlaytimes = async () => { const map: Record = {}; await Promise.all(installedVersions.map(async (id) => { try { map[id] = await TauriService.getPlaytime(id); } catch (e) { console.error(e); } })); setPlaytimeMap(map); }; fetchPlaytimes(); }, [installedVersions]); const handleEditionClick = (edition: Edition, index: number) => { const isInstalled = installedVersions.includes(edition.instanceId); if (isInstalled) { playPressSound(); setSelectedProfile(edition.instanceId); } setFocusIndex(index); }; const handleImportFolder = async () => { try { const folder = await TauriService.pickFolder(); if (folder) { setInitialPath(folder); setIsImportModalOpen(true); } } catch (e) { if (e !== "CANCELED") console.error(e); } }; const handleImportWorld = (instanceId: string) => { const edition = editions.find((e: Edition) => e.instanceId === instanceId); setImportWorldTarget({ id: instanceId, name: edition?.name ?? instanceId }); setIsImportWorldModalOpen(true); }; return ( Versions {editions.map((edition: Edition, i: number) => { const isInstalled = installedVersions.includes( edition.instanceId, ); const hasAnyInstall = installedVersions.length > 0; const isSelected = hasAnyInstall && selectedProfile === edition.instanceId; const isFocused = focusIndex === i; const isCustom = edition.id.startsWith("custom_"); const isDownloading = downloadingId === edition.instanceId; const isComingSoon = edition.comingSoon; return ( !isComingSoon && setFocusIndex(i)} > {isDownloading ? ( {Math.floor(downloadProgress || 0)}% ) : edition.logo ? ( edition.logo.startsWith("http") || edition.logo.startsWith("/images") ? ( ) : ( ) ) : ( )} !isComingSoon && handleEditionClick(edition, i) } className={`flex-1 text-left min-w-0 outline-none rounded cursor-pointer ${ focusIndex === i && focusBtn === 0 && !isComingSoon ? "ring-2 ring-white" : "" } ${isComingSoon ? "cursor-not-allowed" : ""}`} > {edition.name} {isInstalled && ( { e.stopPropagation(); playPressSound(); setPlaytimeTarget({ id: edition.instanceId, name: edition.name }); setIsPlaytimeModalOpen(true); }} className="flex items-center gap-1.5 px-2 py-1 bg-black/60 border border-[#555] hover:border-[#FFFF55] group transition-colors flex-shrink-0" title="View playtime" > {playtimeMap[edition.instanceId] ? formatPlaytime(playtimeMap[edition.instanceId].totalSeconds) : ""} )} {edition.category && edition.category.map((cat: string) => ( {cat} ))} {isCustom && !edition.category && ( Custom )} {edition.desc} {!isInstalled && ( { e.stopPropagation(); if (!isDownloading && !downloadingId) { toggleInstall(edition.instanceId); } else if (isDownloading) { handleCancelDownload(); } }} onMouseEnter={() => setHoveredBtn({ row: i, btn: "main" }) } onMouseLeave={() => setHoveredBtn(null)} className={`w-9 h-9 flex items-center justify-center ${ isDownloading || (!!downloadingId && !isInstalled) ? "opacity-50" : "" }`} style={{ backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === "main") || (focusIndex === i && focusBtn === 0) ? "url('/images/Button_Square_Highlighted.png')" : "url('/images/Button_Square.png')", backgroundSize: "100% 100%", imageRendering: "pixelated", }} > )} { e.stopPropagation(); playPressSound(); setOpenMenuId( openMenuId === edition.id ? null : edition.id, ); }} onMouseEnter={() => setHoveredBtn({ row: i, btn: "menu" }) } onMouseLeave={() => setHoveredBtn(null)} className="w-9 h-9 flex flex-col items-center justify-center gap-1 transition-colors relative" style={{ backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === "menu") || (focusIndex === i && (focusBtn === 0 || focusBtn === 1)) ? "url('/images/Button_Square_Highlighted.png')" : "url('/images/Button_Square.png')", backgroundSize: "100% 100%", imageRendering: "pixelated", filter: updatesAvailable?.[edition.instanceId] ? "drop-shadow(0 0 4px rgba(255,255,0,0.8))" : "none", }} > {openMenuId === edition.id && ( {updatesAvailable?.[edition.instanceId] && ( { e.stopPropagation(); playPressSound(); toggleInstall(edition.instanceId); setOpenMenuId(null); }} className="w-full text-left px-3 py-1.5 text-xs text-[#ffff55] hover:text-white hover:bg-[#ffff55]/20 flex items-center gap-2 group transition-colors mc-text-shadow font-bold border-b border-white/5 mb-1" > Update Available! )} {!isInstalled && ( { e.stopPropagation(); playPressSound(); setOpenMenuId(null); try { const folder = await TauriService.pickFolder(); if (folder) { await saveCustomPath(edition.instanceId, folder); toggleInstall(edition.instanceId); } } catch (err) { if (err !== "CANCELED") console.error(err); } }} className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow" > Download to custom path )} {edition.officialDLC && isInstalled ? ( { e.stopPropagation(); playPressSound(); setDlcTargetEdition(edition); setIsDlcModalOpen(true); setOpenMenuId(null); }} className="w-full text-left px-3 py-2 text-[10px] text-[#dddddd] flex items-center gap-2 mc-text-shadow" > Download DLC ) : null} {Array.isArray(edition.branches) && edition.branches.length > 0 && ( { e.stopPropagation(); playPressSound(); cycleBranch(edition.id); }} className="w-full text-left px-3 py-1.5 text-[10px] text-white mc-text-shadow flex items-center justify-between group" > Channel {edition.selectedBranch ?? "Latest"} )} { e.stopPropagation(); playPressSound(); TauriService.openInstanceFolder(edition.instanceId); setOpenMenuId(null); }} className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow" > Open Folder { e.stopPropagation(); playPressSound(); const PANORAMA_PROFILES = [ "legacy_evolved", "vanilla_tu19", "360revived", "vanilla_tu24", ]; const panoId = PANORAMA_PROFILES.includes( edition.id, ) ? edition.id : "vanilla_tu19"; const panoramaUrl = `/panorama/${panoId}_Panorama_Background_${isDayTime ? "Day" : "Night"}.png`; addToSteam( edition.instanceId, edition.name, edition.titleImage ?? "", panoramaUrl, ); setOpenMenuId(null); }} className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow" > Add to Steam { e.stopPropagation(); playPressSound(); handleImportWorld(edition.instanceId); setOpenMenuId(null); }} className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow" > Import World { e.stopPropagation(); playPressSound(); TauriService.backupInstance(edition.instanceId).catch((err) => { if (err !== "CANCELED") console.error(err); }); setOpenMenuId(null); }} className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow" > Backup { e.stopPropagation(); playPressSound(); setOpenMenuId(null); try { await TauriService.restoreInstance(); } catch (err) { if (err !== "CANCELED") console.error(err); } }} className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow" > Restore { e.stopPropagation(); playPressSound(); setCustomizeTarget(edition); setIsCustomizeModalOpen(true); setOpenMenuId(null); }} className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow" > Customize { e.stopPropagation(); playPressSound(); setSetUidTargetId(edition.instanceId); setIsSetUidModalOpen(true); setOpenMenuId(null); }} className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow" > Set UID {isCustom ? ( { e.stopPropagation(); playPressSound(); setEditingEdition(edition); setIsImportModalOpen(true); setOpenMenuId(null); }} className="w-full text-left px-3 py-2 text-xs text-[#aaaaaa] flex items-center gap-2 mc-text-shadow" > Edit Custom ) : null} { e.stopPropagation(); playBackSound(); if (isCustom) { onDeleteEdition(edition.id); } else { setDeleteConfirmEdition(edition); } setOpenMenuId(null); }} className="w-full text-left px-3 py-2 text-xs text-red-500 hover:text-red-400 hover:bg-red-500/10 flex items-center gap-2 transition-colors mc-text-shadow font-bold" > {isCustom ? "Remove Custom" : "Uninstall"} )} ); })} { playPressSound(); setInitialPath(""); setIsImportModalOpen(true); }} onMouseEnter={() => setFocusIndex(editions.length)} onMouseLeave={() => setHoveredBtn(null)} className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]" style={{ backgroundImage: (hoveredBtn?.row === editions.length && hoveredBtn?.btn === "add") || focusIndex === editions.length ? "url('/images/Button_Square_Highlighted.png')" : "url('/images/Button_Square.png')", backgroundSize: "100% 100%", imageRendering: "pixelated", }} > { playPressSound(); handleImportFolder(); }} onMouseEnter={() => setFocusIndex(editions.length + 1)} onMouseLeave={() => setHoveredBtn(null)} title="Import Custom TU" className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]" style={{ backgroundImage: (hoveredBtn?.row === editions.length && hoveredBtn?.btn === "folder_import") || focusIndex === editions.length + 1 ? "url('/images/Button_Square_Highlighted.png')" : "url('/images/Button_Square.png')", backgroundSize: "100% 100%", imageRendering: "pixelated", }} > setFocusIndex(editions.length + 2)} onClick={() => { playBackSound(); setActiveView("main"); }} className="w-48 h-10 flex items-center justify-center text-xl mc-text-shadow outline-none border-none text-white" style={{ backgroundImage: focusIndex === editions.length + 2 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated", }} > Done { setIsImportModalOpen(false); setEditingEdition(null); setInitialPath(""); }} onImport={(ed: { name: string; desc: string; url: string; path?: string }) => { if (editingEdition) { onUpdateEdition(editingEdition.id, ed); } else { const id = onAddEdition(ed); setSelectedProfile(id); } }} playPressSound={playPressSound} playBackSound={playBackSound} editingEdition={editingEdition} initialPath={initialPath} /> setIsSetUidModalOpen(false)} playPressSound={playPressSound} playBackSound={playBackSound} instances={editions} installedVersions={installedVersions} targetInstanceId={setUidTargetId} /> { setIsImportWorldModalOpen(false); setImportWorldTarget(null); }} playPressSound={playPressSound} playBackSound={playBackSound} targetInstanceId={importWorldTarget?.id ?? ""} targetInstanceName={importWorldTarget?.name ?? ""} /> { setIsPlaytimeModalOpen(false); setPlaytimeTarget(null); }} playBackSound={playBackSound} instanceId={playtimeTarget?.id ?? ""} instanceName={playtimeTarget?.name ?? ""} /> { setIsCustomizeModalOpen(false); setCustomizeTarget(null); }} playPressSound={playPressSound} playBackSound={playBackSound} editionName={customizeTarget?.name ?? ""} currentTitleImage={customizeTarget ? customizations[customizeTarget.instanceId]?.titleImage || customizeTarget.titleImage : undefined} currentPanorama={customizeTarget ? customizations[customizeTarget.instanceId]?.panorama || customizeTarget.panorama : undefined} onSave={(updates) => { if (customizeTarget) { updateCustomization(customizeTarget.instanceId, updates); } }} /> { setIsDlcModalOpen(false); setDlcTargetEdition(null); }} playPressSound={playPressSound} playBackSound={playBackSound} editionName={dlcTargetEdition?.name ?? ""} instanceId={dlcTargetEdition?.instanceId ?? ""} officialDLC={dlcTargetEdition?.officialDLC ?? ""} /> {deleteConfirmEdition && ( Delete {deleteConfirmEdition.name}? Warning: All your saves and worlds for this version will be permanently deleted! { playBackSound(); setDeleteConfirmEdition(null); }} /> { playPressSound(); handleUninstall(deleteConfirmEdition.id); setDeleteConfirmEdition(null); }} /> )} ); }); export default VersionsView;
{edition.desc}
Warning: All your saves and worlds for this version will be permanently deleted!