feat: local custom TUs support

This commit is contained in:
neoapps-dev
2026-04-23 15:44:00 +03:00
parent 754efe1195
commit 341acc9071
5 changed files with 249 additions and 127 deletions
+40 -15
View File
@@ -18,9 +18,8 @@ const ModalButton = memo(function ModalButton({
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={`flex-1 h-12 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none bg-transparent ${
isDanger ? "text-red-500" : "text-white"
} ${isHovered ? (isDanger ? "text-red-400" : "text-[#FFFF55]") : ""}`}
className={`flex-1 h-12 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none bg-transparent ${isDanger ? "text-red-500" : "text-white"
} ${isHovered ? (isDanger ? "text-red-400" : "text-[#FFFF55]") : ""}`}
style={{
backgroundImage: isHovered
? "url('/images/button_highlighted.png')"
@@ -41,10 +40,12 @@ export default function CustomTUModal({
playPressSound,
playBackSound,
editingEdition = null,
initialPath = "",
}: any) {
const [name, setName] = useState("");
const [desc, setDesc] = useState("");
const [url, setUrl] = useState("");
const [path, setPath] = useState("");
const [error, setError] = useState("");
const [focusIndex, setFocusIndex] = useState(0);
@@ -53,13 +54,17 @@ export default function CustomTUModal({
setName(editingEdition.name);
setDesc(editingEdition.desc);
setUrl(editingEdition.url);
setPath(editingEdition.path || "");
} else if (isOpen && initialPath) {
setPath(initialPath);
} else if (!isOpen) {
setName("");
setDesc("");
setUrl("");
setPath("");
setError("");
}
}, [editingEdition, isOpen]);
}, [editingEdition, isOpen, initialPath]);
useEffect(() => {
if (!isOpen) {
@@ -88,25 +93,30 @@ export default function CustomTUModal({
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [isOpen, focusIndex, name, desc, url]);
}, [isOpen, focusIndex, name, desc, url, path]);
if (!isOpen) return null;
const handleImport = () => {
if (!name || !url) {
setError("Name and URL are required");
if (!name) {
setError("Name is required");
return;
}
if (!url.startsWith("http")) {
if (!url && !path) {
setError("URL or Path is required");
return;
}
if (url && !url.startsWith("http")) {
setError("Invalid URL");
return;
}
setError("");
onImport({ name, desc: desc || "Custom imported TU", url });
onImport({ name, desc: desc || "Custom imported TU", url, path: path || undefined });
onClose();
setName("");
setDesc("");
setUrl("");
setPath("");
};
return (
@@ -114,19 +124,19 @@ export default function CustomTUModal({
<div
className="relative w-[400px] p-6 flex flex-col items-center"
style={{
backgroundImage: "url('/images/Download_Background.png')",
backgroundImage: "url('/images/background.png')",
backgroundSize: "100% 100%",
backgroundRepeat: "no-repeat",
imageRendering: "pixelated",
}}
>
<h2 className="text-xl text-white mc-text-shadow mb-4 text-center">
<h2 className="text-xl text-black mc-text-shadow mb-4 text-center">
{editingEdition ? "Edit Custom TU" : "Import Custom TU"}
</h2>
<div className="flex flex-col gap-4 w-full">
<div className="flex flex-col gap-1">
<label className="text-white text-sm mc-text-shadow uppercase tracking-widest">
<label className="text-gray text-sm mc-text-shadow uppercase tracking-widest">
TU Name
</label>
<input
@@ -142,7 +152,7 @@ export default function CustomTUModal({
</div>
<div className="flex flex-col gap-1">
<label className="text-white text-sm mc-text-shadow uppercase tracking-widest">
<label className="text-gray text-sm mc-text-shadow uppercase tracking-widest">
Description (Optional)
</label>
<input
@@ -157,7 +167,7 @@ export default function CustomTUModal({
</div>
<div className="flex flex-col gap-1">
<label className="text-white text-sm mc-text-shadow uppercase tracking-widest">
<label className="text-gray text-sm mc-text-shadow uppercase tracking-widest">
Download URL (.zip)
</label>
<input
@@ -165,12 +175,27 @@ export default function CustomTUModal({
value={url}
onChange={(e) => setUrl(e.target.value)}
onFocus={() => setFocusIndex(2)}
placeholder="https://example.com/mod.zip"
placeholder="optional if path is set"
className="w-full h-10 px-3 bg-black/40 border-2 border-[#373737] text-white text-base outline-none font-['Mojangles']"
style={{ imageRendering: "pixelated" }}
/>
</div>
{path && (
<div className="flex flex-col gap-1">
<label className="text-gray text-sm mc-text-shadow uppercase tracking-widest">
Local Path
</label>
<input
type="text"
readOnly
value={path}
className="w-full h-10 px-3 bg-black/20 border-2 border-[#222] text-black text-xs outline-none font-['Mojangles'] cursor-not-allowed"
style={{ imageRendering: "pixelated" }}
/>
</div>
)}
{error && (
<div className="text-red-500 text-center mc-text-shadow uppercase text-xs tracking-widest">
{error}
+139 -104
View File
@@ -22,9 +22,8 @@ const DeleteConfirmButton = memo(function DeleteConfirmButton({
onClick={onClick}
onMouseEnter={() => 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]") : ""}`}
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')"
@@ -43,22 +42,20 @@ const VersionsView = memo(function VersionsView() {
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 } = useGame();
const [focusIndex, setFocusIndex] = useState<number>(0);
const [focusBtn, setFocusBtn] = useState<number>(0);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [editingEdition, setEditingEdition] = useState<any>(null);
const [hoveredBtn, setHoveredBtn] = useState<{row: number, btn: string} | null>(null);
const [initialPath, setInitialPath] = useState<string>("");
const [hoveredBtn, setHoveredBtn] = useState<{ row: number, btn: string } | null>(null);
const [deleteConfirmEdition, setDeleteConfirmEdition] = useState<any>(null);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const ITEM_COUNT = editions.length + 2; // +1 for "+" button, +1 for Done button
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");
@@ -139,6 +136,9 @@ const VersionsView = memo(function VersionsView() {
} else if (focusIndex === editions.length) {
playPressSound();
setIsImportModalOpen(true);
} else if (focusIndex === editions.length + 1) {
playPressSound();
handleImportFolder();
} else {
playBackSound();
setActiveView("main");
@@ -174,7 +174,7 @@ const VersionsView = memo(function VersionsView() {
const handleEditionClick = (edition: any, index: number) => {
const isInstalled = installedVersions.includes(edition.id);
if (isInstalled) {
playPressSound();
setSelectedProfile(edition.id);
@@ -182,6 +182,18 @@ const VersionsView = memo(function VersionsView() {
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);
}
};
return (
<motion.div
ref={containerRef}
@@ -195,7 +207,7 @@ const VersionsView = memo(function VersionsView() {
Versions
</h2>
<div
<div
className="w-full min-w-[480px] p-6 mb-4"
style={{
backgroundImage: "url('/images/background.png')",
@@ -222,11 +234,9 @@ const VersionsView = memo(function VersionsView() {
<div
key={edition.id}
data-index={i}
className={`w-[calc(100%-16px)] mx-2 flex items-center gap-3 p-2 rounded-sm ${
isSelected && !isComingSoon ? "bg-[#404040]/50" : ""
} ${isFocused && !isComingSoon ? "ring-2 ring-white" : ""} ${
isComingSoon ? "opacity-50 cursor-not-allowed" : ""
}`}
className={`w-[calc(100%-16px)] mx-2 flex items-center gap-3 p-2 rounded-sm ${isSelected && !isComingSoon ? "bg-[#404040]/50" : ""
} ${isFocused && !isComingSoon ? "ring-2 ring-white" : ""} ${isComingSoon ? "opacity-50 cursor-not-allowed" : ""
}`}
onMouseEnter={() => !isComingSoon && setFocusIndex(i)}
>
<div className="w-6 flex items-center justify-center flex-shrink-0">
@@ -261,13 +271,12 @@ const VersionsView = memo(function VersionsView() {
<button
onClick={() => !isComingSoon && handleEditionClick(edition, i)}
disabled={isComingSoon}
className={`flex-1 text-left min-w-0 outline-none rounded ${
focusIndex === i && focusBtn === 0 && !isComingSoon ? "ring-2 ring-white" : ""
} ${isComingSoon ? "cursor-not-allowed" : ""}`}
className={`flex-1 text-left min-w-0 outline-none rounded ${focusIndex === i && focusBtn === 0 && !isComingSoon ? "ring-2 ring-white" : ""
} ${isComingSoon ? "cursor-not-allowed" : ""}`}
>
<div className="flex items-center gap-2">
{edition.logo && (
<img
<img
src={edition.logo}
alt=""
className="w-5 h-5 object-contain flex-shrink-0"
@@ -275,9 +284,8 @@ const VersionsView = memo(function VersionsView() {
/>
)}
<span
className={`text-xl tracking-wide truncate ${
isSelected ? "text-white" : "text-black"
}`}
className={`text-xl tracking-wide truncate ${isSelected ? "text-white" : "text-black"
}`}
style={{ textShadow: "none" }}
>
{edition.name}
@@ -288,9 +296,8 @@ const VersionsView = memo(function VersionsView() {
</span>
)}
</div>
<p className={`text-base font-medium leading-tight ${
isSelected ? "text-[#DDDDDD]" : "text-[#666666]"
}`}>
<p className={`text-base font-medium leading-tight ${isSelected ? "text-[#DDDDDD]" : "text-[#666666]"
}`}>
{edition.desc}
</p>
</button>
@@ -303,7 +310,7 @@ const VersionsView = memo(function VersionsView() {
e.stopPropagation();
handleCancelDownload();
}}
onMouseEnter={() => setHoveredBtn({row: i, btn: 'cancel'})}
onMouseEnter={() => setHoveredBtn({ row: i, btn: 'cancel' })}
onMouseLeave={() => setHoveredBtn(null)}
className="w-8 h-8 flex items-center justify-center text-red-600"
style={{
@@ -315,7 +322,7 @@ const VersionsView = memo(function VersionsView() {
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="square">
<path d="M18 6L6 18M6 6l12 12"/>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
) : edition.comingSoon ? (
@@ -326,7 +333,7 @@ const VersionsView = memo(function VersionsView() {
e.stopPropagation();
if (!downloadingId) toggleInstall(edition.id);
}}
onMouseEnter={() => setHoveredBtn({row: i, btn: 'download'})}
onMouseEnter={() => setHoveredBtn({ row: i, btn: 'download' })}
onMouseLeave={() => setHoveredBtn(null)}
className={`w-8 h-8 flex items-center justify-center ${downloadingId ? "text-gray-400 cursor-not-allowed" : "text-[#3a3a3a]"}`}
style={{
@@ -355,7 +362,7 @@ const VersionsView = memo(function VersionsView() {
e.stopPropagation();
handleCancelDownload();
}}
onMouseEnter={() => setHoveredBtn({row: i, btn: 'cancel'})}
onMouseEnter={() => setHoveredBtn({ row: i, btn: 'cancel' })}
onMouseLeave={() => setHoveredBtn(null)}
className="w-8 h-8 flex items-center justify-center text-red-600"
style={{
@@ -367,7 +374,7 @@ const VersionsView = memo(function VersionsView() {
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="square">
<path d="M18 6L6 18M6 6l12 12"/>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
) : (
@@ -377,7 +384,7 @@ const VersionsView = memo(function VersionsView() {
e.stopPropagation();
if (!downloadingId) toggleInstall(edition.id);
}}
onMouseEnter={() => setHoveredBtn({row: i, btn: 'update'})}
onMouseEnter={() => setHoveredBtn({ row: i, btn: 'update' })}
onMouseLeave={() => setHoveredBtn(null)}
className={`w-8 h-8 flex items-center justify-center ${downloadingId ? "text-gray-400 cursor-not-allowed" : "text-[#3a3a3a]"}`}
style={{
@@ -403,82 +410,33 @@ const VersionsView = memo(function VersionsView() {
playPressSound();
TauriService.openInstanceFolder(edition.id);
}}
onMouseEnter={() => setHoveredBtn({row: i, btn: 'folder'})}
onMouseLeave={() => setHoveredBtn(null)}
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
style={{
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'folder') || (focusIndex === i && focusBtn === 2)
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<img
src="/images/Folder_Icon.png"
alt="Folder"
className="w-6 h-6 object-contain"
style={{ imageRendering: "pixelated" }}
/>
</button>
<button
onClick={(e) => {
e.stopPropagation();
playBackSound();
setDeleteConfirmEdition(edition);
}}
onMouseEnter={() => setHoveredBtn({row: i, btn: 'delete'})}
onMouseLeave={() => setHoveredBtn(null)}
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
style={{
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'delete') || (focusIndex === i && focusBtn === 3)
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<img
src="/images/Trash_Bin_Icon.png"
alt="Delete"
className="w-6 h-6 object-contain"
style={{ imageRendering: "pixelated" }}
/>
</button>
{isCustom && (
<>
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
setEditingEdition(edition);
setIsImportModalOpen(true);
}}
onMouseEnter={() => setHoveredBtn({row: i, btn: 'edit'})}
onMouseEnter={() => setHoveredBtn({ row: i, btn: 'folder' })}
onMouseLeave={() => setHoveredBtn(null)}
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
style={{
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'edit') || (focusIndex === i && focusBtn === 4)
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'folder') || (focusIndex === i && focusBtn === 2)
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="square">
<path d="M12 20h9"/>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
<img
src="/images/Folder_Icon.png"
alt="Folder"
className="w-6 h-6 object-contain"
style={{ imageRendering: "pixelated" }}
/>
</button>
<button
onClick={(e) => {
e.stopPropagation();
playBackSound();
onDeleteEdition(edition.id);
setDeleteConfirmEdition(edition);
}}
onMouseEnter={() => setHoveredBtn({row: i, btn: 'delete'})}
onMouseEnter={() => setHoveredBtn({ row: i, btn: 'delete' })}
onMouseLeave={() => setHoveredBtn(null)}
className="w-8 h-8 flex items-center justify-center text-red-600"
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
style={{
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'delete') || (focusIndex === i && focusBtn === 3)
? "url('/images/Button_Square_Highlighted.png')"
@@ -487,29 +445,79 @@ const VersionsView = memo(function VersionsView() {
imageRendering: "pixelated",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="square">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
<img
src="/images/Trash_Bin_Icon.png"
alt="Delete"
className="w-6 h-6 object-contain"
style={{ imageRendering: "pixelated" }}
/>
</button>
{isCustom && (
<>
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
setEditingEdition(edition);
setIsImportModalOpen(true);
}}
onMouseEnter={() => setHoveredBtn({ row: i, btn: 'edit' })}
onMouseLeave={() => setHoveredBtn(null)}
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
style={{
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'edit') || (focusIndex === i && focusBtn === 4)
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="square">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
playBackSound();
onDeleteEdition(edition.id);
}}
onMouseEnter={() => setHoveredBtn({ row: i, btn: 'delete' })}
onMouseLeave={() => setHoveredBtn(null)}
className="w-8 h-8 flex items-center justify-center text-red-600"
style={{
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'delete') || (focusIndex === i && focusBtn === 3)
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="square">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</>
)}
</>
)}
</>
)}
</>
)}
</div>
</div>
);
})}
<div className="w-full flex items-center justify-center p-2 mt-1">
<div className="w-full flex items-center justify-center gap-4 p-2 mt-1">
<button
onClick={() => {
playPressSound();
setInitialPath("");
setIsImportModalOpen(true);
}}
onMouseEnter={() => setHoveredBtn({row: editions.length, btn: 'add'})}
onMouseEnter={() => setFocusIndex(editions.length)}
onMouseLeave={() => setHoveredBtn(null)}
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
style={{
@@ -521,9 +529,34 @@ const VersionsView = memo(function VersionsView() {
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="square">
<path d="M12 5v14M5 12h14"/>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
<button
onClick={() => {
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",
}}
>
<img
src="/images/Folder_Icon.png"
alt="Import Custom TU"
className="w-5 h-5 object-contain"
style={{ imageRendering: "pixelated" }}
/>
</button>
</div>
</div>
</div>
@@ -531,15 +564,15 @@ const VersionsView = memo(function VersionsView() {
<div className="flex justify-center">
<button
data-index={editions.length + 1}
onMouseEnter={() => setFocusIndex(editions.length + 1)}
data-index={editions.length + 2}
onMouseEnter={() => 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 + 1
backgroundImage: focusIndex === editions.length + 2
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
@@ -555,6 +588,7 @@ const VersionsView = memo(function VersionsView() {
onClose={() => {
setIsImportModalOpen(false);
setEditingEdition(null);
setInitialPath("");
}}
onImport={(ed: any) => {
if (editingEdition) {
@@ -567,6 +601,7 @@ const VersionsView = memo(function VersionsView() {
playPressSound={playPressSound}
playBackSound={playBackSound}
editingEdition={editingEdition}
initialPath={initialPath}
/>
{deleteConfirmEdition && (