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
+63 -6
View File
@@ -35,6 +35,7 @@ pub struct CustomEdition {
pub name: String, pub name: String,
pub desc: String, pub desc: String,
pub url: String, pub url: String,
pub path: Option<String>,
} }
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
@@ -146,10 +147,10 @@ fn find_executable_recursive(root: &PathBuf, file_name: &str) -> Option<PathBuf>
None None
} }
fn is_macos_runtime_installed(app: &AppHandle) -> bool { fn is_macos_runtime_installed(_app: &AppHandle) -> bool {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
let runtime_dir = get_macos_runtime_dir(app); let runtime_dir = get_macos_runtime_dir(_app);
let toolkit_dir = runtime_dir.join("toolkit"); let toolkit_dir = runtime_dir.join("toolkit");
if !toolkit_dir.exists() { if !toolkit_dir.exists() {
return false; return false;
@@ -280,6 +281,19 @@ fn import_theme(app: AppHandle) -> Result<String, String> {
} }
} }
#[tauri::command]
fn pick_folder() -> Result<String, String> {
let folder = rfd::FileDialog::new()
.set_title("Select Custom TU Folder")
.pick_folder();
if let Some(path) = folder {
Ok(path.to_string_lossy().to_string())
} else {
Err("CANCELED".into())
}
}
#[tauri::command] #[tauri::command]
fn get_available_runners(app: AppHandle) -> Vec<Runner> { fn get_available_runners(app: AppHandle) -> Vec<Runner> {
let mut runners = Vec::new(); let mut runners = Vec::new();
@@ -456,13 +470,36 @@ async fn download_runner(app: AppHandle, state: State<'_, DownloadState>, name:
#[tauri::command] #[tauri::command]
#[allow(non_snake_case)] #[allow(non_snake_case)]
fn check_game_installed(app: AppHandle, instance_id: String) -> bool { fn check_game_installed(app: AppHandle, instance_id: String) -> bool {
let config = load_config(app.clone());
if let Some(ref editions) = config.custom_editions {
if let Some(edition) = editions.iter().find(|e| e.id == instance_id) {
if let Some(ref path) = edition.path {
return PathBuf::from(path).join("Minecraft.Client.exe").exists();
}
}
}
get_app_dir(&app).join("instances").join(&instance_id).join("Minecraft.Client.exe").exists() get_app_dir(&app).join("instances").join(&instance_id).join("Minecraft.Client.exe").exists()
} }
#[tauri::command] #[tauri::command]
#[allow(non_snake_case)] #[allow(non_snake_case)]
fn open_instance_folder(app: AppHandle, instance_id: String) { fn open_instance_folder(app: AppHandle, instance_id: String) {
let dir = get_app_dir(&app).join("instances").join(&instance_id); let config = load_config(app.clone());
let dir = if let Some(ref editions) = config.custom_editions {
if let Some(edition) = editions.iter().find(|e| e.id == instance_id) {
if let Some(ref path) = edition.path {
Some(PathBuf::from(path))
} else {
None
}
} else {
None
}
} else {
None
};
let dir = dir.unwrap_or_else(|| get_app_dir(&app).join("instances").join(&instance_id));
if dir.exists() { if dir.exists() {
let _ = app.opener().open_path(dir.to_str().unwrap(), None::<&str>); let _ = app.opener().open_path(dir.to_str().unwrap(), None::<&str>);
} }
@@ -471,6 +508,15 @@ fn open_instance_folder(app: AppHandle, instance_id: String) {
#[tauri::command] #[tauri::command]
#[allow(non_snake_case)] #[allow(non_snake_case)]
fn delete_instance(app: AppHandle, instance_id: String) -> Result<(), String> { fn delete_instance(app: AppHandle, instance_id: String) -> Result<(), String> {
let config = load_config(app.clone());
if let Some(ref editions) = config.custom_editions {
if let Some(edition) = editions.iter().find(|e| e.id == instance_id) {
if edition.path.is_some() {
// Do not delete files for custom path instances
return Ok(());
}
}
}
let dir = get_app_dir(&app).join("instances").join(&instance_id); let dir = get_app_dir(&app).join("instances").join(&instance_id);
if dir.exists() { if dir.exists() {
let _ = fs::remove_dir_all(dir); let _ = fs::remove_dir_all(dir);
@@ -1187,7 +1233,18 @@ async fn launch_game(app: AppHandle, state: State<'_, GameState>, instance_id: S
} }
let _ = perform_dlc_sync(&app, &instance_dir)?; let _ = perform_dlc_sync(&app, &instance_dir)?;
let game_exe = instance_dir.join("Minecraft.Client.exe");
let mut custom_path = None;
if let Some(ref editions) = config.custom_editions {
if let Some(edition) = editions.iter().find(|e| e.id == instance_id) {
if let Some(ref path) = edition.path {
custom_path = Some(PathBuf::from(path));
}
}
}
let working_dir = custom_path.unwrap_or_else(|| instance_dir.clone());
let game_exe = working_dir.join("Minecraft.Client.exe");
if !game_exe.exists() { if !game_exe.exists() {
return Err("Game executable not found in instance folder.".into()); return Err("Game executable not found in instance folder.".into());
} }
@@ -1226,7 +1283,7 @@ async fn launch_game(app: AppHandle, state: State<'_, GameState>, instance_id: S
} }
cmd.arg(&game_exe) cmd.arg(&game_exe)
.current_dir(&instance_dir); .current_dir(&working_dir);
let child = cmd.spawn().map_err(|e| e.to_string())?; let child = cmd.spawn().map_err(|e| e.to_string())?;
{ {
@@ -1532,7 +1589,7 @@ pub fn run() {
} }
} }
}) })
.invoke_handler(tauri::generate_handler![setup_macos_runtime, launch_game, stop_game, check_game_installed, save_config, load_config, download_and_install, open_instance_folder, cancel_download, get_available_runners, get_external_palettes, import_theme, download_runner, delete_instance, sync_dlc, fetch_skin, workshop_install, workshop_uninstall, workshop_list_installed, get_screenshots, delete_screenshot, open_screenshot_folder, save_global_skin_pck, check_game_update, check_macos_runtime_installed, check_macos_runtime_installed_fast]) .invoke_handler(tauri::generate_handler![setup_macos_runtime, launch_game, stop_game, check_game_installed, save_config, load_config, download_and_install, open_instance_folder, cancel_download, get_available_runners, get_external_palettes, import_theme, pick_folder, download_runner, delete_instance, sync_dlc, fetch_skin, workshop_install, workshop_uninstall, workshop_list_installed, get_screenshots, delete_screenshot, open_screenshot_folder, save_global_skin_pck, check_game_update, check_macos_runtime_installed, check_macos_runtime_installed_fast])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
+40 -15
View File
@@ -18,9 +18,8 @@ const ModalButton = memo(function ModalButton({
onClick={onClick} onClick={onClick}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} 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 ${ 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"
isDanger ? "text-red-500" : "text-white" } ${isHovered ? (isDanger ? "text-red-400" : "text-[#FFFF55]") : ""}`}
} ${isHovered ? (isDanger ? "text-red-400" : "text-[#FFFF55]") : ""}`}
style={{ style={{
backgroundImage: isHovered backgroundImage: isHovered
? "url('/images/button_highlighted.png')" ? "url('/images/button_highlighted.png')"
@@ -41,10 +40,12 @@ export default function CustomTUModal({
playPressSound, playPressSound,
playBackSound, playBackSound,
editingEdition = null, editingEdition = null,
initialPath = "",
}: any) { }: any) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [desc, setDesc] = useState(""); const [desc, setDesc] = useState("");
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [path, setPath] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [focusIndex, setFocusIndex] = useState(0); const [focusIndex, setFocusIndex] = useState(0);
@@ -53,13 +54,17 @@ export default function CustomTUModal({
setName(editingEdition.name); setName(editingEdition.name);
setDesc(editingEdition.desc); setDesc(editingEdition.desc);
setUrl(editingEdition.url); setUrl(editingEdition.url);
setPath(editingEdition.path || "");
} else if (isOpen && initialPath) {
setPath(initialPath);
} else if (!isOpen) { } else if (!isOpen) {
setName(""); setName("");
setDesc(""); setDesc("");
setUrl(""); setUrl("");
setPath("");
setError(""); setError("");
} }
}, [editingEdition, isOpen]); }, [editingEdition, isOpen, initialPath]);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
@@ -88,25 +93,30 @@ export default function CustomTUModal({
}; };
window.addEventListener("keydown", handleKey); window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey);
}, [isOpen, focusIndex, name, desc, url]); }, [isOpen, focusIndex, name, desc, url, path]);
if (!isOpen) return null; if (!isOpen) return null;
const handleImport = () => { const handleImport = () => {
if (!name || !url) { if (!name) {
setError("Name and URL are required"); setError("Name is required");
return; return;
} }
if (!url.startsWith("http")) { if (!url && !path) {
setError("URL or Path is required");
return;
}
if (url && !url.startsWith("http")) {
setError("Invalid URL"); setError("Invalid URL");
return; return;
} }
setError(""); setError("");
onImport({ name, desc: desc || "Custom imported TU", url }); onImport({ name, desc: desc || "Custom imported TU", url, path: path || undefined });
onClose(); onClose();
setName(""); setName("");
setDesc(""); setDesc("");
setUrl(""); setUrl("");
setPath("");
}; };
return ( return (
@@ -114,19 +124,19 @@ export default function CustomTUModal({
<div <div
className="relative w-[400px] p-6 flex flex-col items-center" className="relative w-[400px] p-6 flex flex-col items-center"
style={{ style={{
backgroundImage: "url('/images/Download_Background.png')", backgroundImage: "url('/images/background.png')",
backgroundSize: "100% 100%", backgroundSize: "100% 100%",
backgroundRepeat: "no-repeat", backgroundRepeat: "no-repeat",
imageRendering: "pixelated", 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"} {editingEdition ? "Edit Custom TU" : "Import Custom TU"}
</h2> </h2>
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
<div className="flex flex-col gap-1"> <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 TU Name
</label> </label>
<input <input
@@ -142,7 +152,7 @@ export default function CustomTUModal({
</div> </div>
<div className="flex flex-col gap-1"> <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) Description (Optional)
</label> </label>
<input <input
@@ -157,7 +167,7 @@ export default function CustomTUModal({
</div> </div>
<div className="flex flex-col gap-1"> <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) Download URL (.zip)
</label> </label>
<input <input
@@ -165,12 +175,27 @@ export default function CustomTUModal({
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
onFocus={() => setFocusIndex(2)} 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']" 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" }} style={{ imageRendering: "pixelated" }}
/> />
</div> </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 && ( {error && (
<div className="text-red-500 text-center mc-text-shadow uppercase text-xs tracking-widest"> <div className="text-red-500 text-center mc-text-shadow uppercase text-xs tracking-widest">
{error} {error}
+139 -104
View File
@@ -22,9 +22,8 @@ const DeleteConfirmButton = memo(function DeleteConfirmButton({
onClick={onClick} onClick={onClick}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
className={`w-24 h-10 flex items-center justify-center mc-text-shadow transition-colors ${ className={`w-24 h-10 flex items-center justify-center mc-text-shadow transition-colors ${isDanger ? "text-red-500" : "text-white"
isDanger ? "text-red-500" : "text-white" } ${isHovered ? (isDanger ? "text-red-400" : "text-[#FFFF55]") : ""}`}
} ${isHovered ? (isDanger ? "text-red-400" : "text-[#FFFF55]") : ""}`}
style={{ style={{
backgroundImage: isHovered backgroundImage: isHovered
? "url('/images/button_highlighted.png')" ? "url('/images/button_highlighted.png')"
@@ -43,22 +42,20 @@ const VersionsView = memo(function VersionsView() {
const { profile: selectedProfile, setProfile: setSelectedProfile, animationsEnabled } = useConfig(); const { profile: selectedProfile, setProfile: setSelectedProfile, animationsEnabled } = useConfig();
const { playPressSound, playBackSound } = useAudio(); const { playPressSound, playBackSound } = useAudio();
const { editions, installs: installedVersions, toggleInstall, handleUninstall, handleCancelDownload, deleteCustomEdition: onDeleteEdition, addCustomEdition: onAddEdition, updateCustomEdition: onUpdateEdition, downloadingId, downloadProgress } = useGame(); const { editions, installs: installedVersions, toggleInstall, handleUninstall, handleCancelDownload, deleteCustomEdition: onDeleteEdition, addCustomEdition: onAddEdition, updateCustomEdition: onUpdateEdition, downloadingId, downloadProgress } = useGame();
const [focusIndex, setFocusIndex] = useState<number>(0); const [focusIndex, setFocusIndex] = useState<number>(0);
const [focusBtn, setFocusBtn] = useState<number>(0); const [focusBtn, setFocusBtn] = useState<number>(0);
const [isImportModalOpen, setIsImportModalOpen] = useState(false); const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [editingEdition, setEditingEdition] = useState<any>(null); 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 [deleteConfirmEdition, setDeleteConfirmEdition] = useState<any>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const ITEM_COUNT = editions.length + 3;
const ITEM_COUNT = editions.length + 2; // +1 for "+" button, +1 for Done button
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (document.activeElement?.tagName === "INPUT") return; if (document.activeElement?.tagName === "INPUT") return;
if (e.key === "Escape" || e.key === "Backspace") { if (e.key === "Escape" || e.key === "Backspace") {
playBackSound(); playBackSound();
setActiveView("main"); setActiveView("main");
@@ -139,6 +136,9 @@ const VersionsView = memo(function VersionsView() {
} else if (focusIndex === editions.length) { } else if (focusIndex === editions.length) {
playPressSound(); playPressSound();
setIsImportModalOpen(true); setIsImportModalOpen(true);
} else if (focusIndex === editions.length + 1) {
playPressSound();
handleImportFolder();
} else { } else {
playBackSound(); playBackSound();
setActiveView("main"); setActiveView("main");
@@ -174,7 +174,7 @@ const VersionsView = memo(function VersionsView() {
const handleEditionClick = (edition: any, index: number) => { const handleEditionClick = (edition: any, index: number) => {
const isInstalled = installedVersions.includes(edition.id); const isInstalled = installedVersions.includes(edition.id);
if (isInstalled) { if (isInstalled) {
playPressSound(); playPressSound();
setSelectedProfile(edition.id); setSelectedProfile(edition.id);
@@ -182,6 +182,18 @@ const VersionsView = memo(function VersionsView() {
setFocusIndex(index); 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 ( return (
<motion.div <motion.div
ref={containerRef} ref={containerRef}
@@ -195,7 +207,7 @@ const VersionsView = memo(function VersionsView() {
Versions Versions
</h2> </h2>
<div <div
className="w-full min-w-[480px] p-6 mb-4" className="w-full min-w-[480px] p-6 mb-4"
style={{ style={{
backgroundImage: "url('/images/background.png')", backgroundImage: "url('/images/background.png')",
@@ -222,11 +234,9 @@ const VersionsView = memo(function VersionsView() {
<div <div
key={edition.id} key={edition.id}
data-index={i} data-index={i}
className={`w-[calc(100%-16px)] mx-2 flex items-center gap-3 p-2 rounded-sm ${ className={`w-[calc(100%-16px)] mx-2 flex items-center gap-3 p-2 rounded-sm ${isSelected && !isComingSoon ? "bg-[#404040]/50" : ""
isSelected && !isComingSoon ? "bg-[#404040]/50" : "" } ${isFocused && !isComingSoon ? "ring-2 ring-white" : ""} ${isComingSoon ? "opacity-50 cursor-not-allowed" : ""
} ${isFocused && !isComingSoon ? "ring-2 ring-white" : ""} ${ }`}
isComingSoon ? "opacity-50 cursor-not-allowed" : ""
}`}
onMouseEnter={() => !isComingSoon && setFocusIndex(i)} onMouseEnter={() => !isComingSoon && setFocusIndex(i)}
> >
<div className="w-6 flex items-center justify-center flex-shrink-0"> <div className="w-6 flex items-center justify-center flex-shrink-0">
@@ -261,13 +271,12 @@ const VersionsView = memo(function VersionsView() {
<button <button
onClick={() => !isComingSoon && handleEditionClick(edition, i)} onClick={() => !isComingSoon && handleEditionClick(edition, i)}
disabled={isComingSoon} disabled={isComingSoon}
className={`flex-1 text-left min-w-0 outline-none rounded ${ className={`flex-1 text-left min-w-0 outline-none rounded ${focusIndex === i && focusBtn === 0 && !isComingSoon ? "ring-2 ring-white" : ""
focusIndex === i && focusBtn === 0 && !isComingSoon ? "ring-2 ring-white" : "" } ${isComingSoon ? "cursor-not-allowed" : ""}`}
} ${isComingSoon ? "cursor-not-allowed" : ""}`}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{edition.logo && ( {edition.logo && (
<img <img
src={edition.logo} src={edition.logo}
alt="" alt=""
className="w-5 h-5 object-contain flex-shrink-0" className="w-5 h-5 object-contain flex-shrink-0"
@@ -275,9 +284,8 @@ const VersionsView = memo(function VersionsView() {
/> />
)} )}
<span <span
className={`text-xl tracking-wide truncate ${ className={`text-xl tracking-wide truncate ${isSelected ? "text-white" : "text-black"
isSelected ? "text-white" : "text-black" }`}
}`}
style={{ textShadow: "none" }} style={{ textShadow: "none" }}
> >
{edition.name} {edition.name}
@@ -288,9 +296,8 @@ const VersionsView = memo(function VersionsView() {
</span> </span>
)} )}
</div> </div>
<p className={`text-base font-medium leading-tight ${ <p className={`text-base font-medium leading-tight ${isSelected ? "text-[#DDDDDD]" : "text-[#666666]"
isSelected ? "text-[#DDDDDD]" : "text-[#666666]" }`}>
}`}>
{edition.desc} {edition.desc}
</p> </p>
</button> </button>
@@ -303,7 +310,7 @@ const VersionsView = memo(function VersionsView() {
e.stopPropagation(); e.stopPropagation();
handleCancelDownload(); handleCancelDownload();
}} }}
onMouseEnter={() => setHoveredBtn({row: i, btn: 'cancel'})} onMouseEnter={() => setHoveredBtn({ row: i, btn: 'cancel' })}
onMouseLeave={() => setHoveredBtn(null)} 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-red-600"
style={{ 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"> <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> </svg>
</button> </button>
) : edition.comingSoon ? ( ) : edition.comingSoon ? (
@@ -326,7 +333,7 @@ const VersionsView = memo(function VersionsView() {
e.stopPropagation(); e.stopPropagation();
if (!downloadingId) toggleInstall(edition.id); if (!downloadingId) toggleInstall(edition.id);
}} }}
onMouseEnter={() => setHoveredBtn({row: i, btn: 'download'})} onMouseEnter={() => setHoveredBtn({ row: i, btn: 'download' })}
onMouseLeave={() => setHoveredBtn(null)} onMouseLeave={() => setHoveredBtn(null)}
className={`w-8 h-8 flex items-center justify-center ${downloadingId ? "text-gray-400 cursor-not-allowed" : "text-[#3a3a3a]"}`} className={`w-8 h-8 flex items-center justify-center ${downloadingId ? "text-gray-400 cursor-not-allowed" : "text-[#3a3a3a]"}`}
style={{ style={{
@@ -355,7 +362,7 @@ const VersionsView = memo(function VersionsView() {
e.stopPropagation(); e.stopPropagation();
handleCancelDownload(); handleCancelDownload();
}} }}
onMouseEnter={() => setHoveredBtn({row: i, btn: 'cancel'})} onMouseEnter={() => setHoveredBtn({ row: i, btn: 'cancel' })}
onMouseLeave={() => setHoveredBtn(null)} 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-red-600"
style={{ 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"> <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> </svg>
</button> </button>
) : ( ) : (
@@ -377,7 +384,7 @@ const VersionsView = memo(function VersionsView() {
e.stopPropagation(); e.stopPropagation();
if (!downloadingId) toggleInstall(edition.id); if (!downloadingId) toggleInstall(edition.id);
}} }}
onMouseEnter={() => setHoveredBtn({row: i, btn: 'update'})} onMouseEnter={() => setHoveredBtn({ row: i, btn: 'update' })}
onMouseLeave={() => setHoveredBtn(null)} onMouseLeave={() => setHoveredBtn(null)}
className={`w-8 h-8 flex items-center justify-center ${downloadingId ? "text-gray-400 cursor-not-allowed" : "text-[#3a3a3a]"}`} className={`w-8 h-8 flex items-center justify-center ${downloadingId ? "text-gray-400 cursor-not-allowed" : "text-[#3a3a3a]"}`}
style={{ style={{
@@ -403,82 +410,33 @@ const VersionsView = memo(function VersionsView() {
playPressSound(); playPressSound();
TauriService.openInstanceFolder(edition.id); TauriService.openInstanceFolder(edition.id);
}} }}
onMouseEnter={() => setHoveredBtn({row: i, btn: 'folder'})} 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'})}
onMouseLeave={() => setHoveredBtn(null)} onMouseLeave={() => setHoveredBtn(null)}
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]" className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
style={{ 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_Highlighted.png')"
: "url('/images/Button_Square.png')", : "url('/images/Button_Square.png')",
backgroundSize: "100% 100%", backgroundSize: "100% 100%",
imageRendering: "pixelated", imageRendering: "pixelated",
}} }}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="square"> <img
<path d="M12 20h9"/> src="/images/Folder_Icon.png"
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/> alt="Folder"
</svg> className="w-6 h-6 object-contain"
style={{ imageRendering: "pixelated" }}
/>
</button> </button>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
playBackSound(); playBackSound();
onDeleteEdition(edition.id); setDeleteConfirmEdition(edition);
}} }}
onMouseEnter={() => setHoveredBtn({row: i, btn: 'delete'})} onMouseEnter={() => setHoveredBtn({ row: i, btn: 'delete' })}
onMouseLeave={() => setHoveredBtn(null)} 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={{ style={{
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'delete') || (focusIndex === i && focusBtn === 3) backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === 'delete') || (focusIndex === i && focusBtn === 3)
? "url('/images/Button_Square_Highlighted.png')" ? "url('/images/Button_Square_Highlighted.png')"
@@ -487,29 +445,79 @@ const VersionsView = memo(function VersionsView() {
imageRendering: "pixelated", imageRendering: "pixelated",
}} }}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="square"> <img
<polyline points="3 6 5 6 21 6"/> src="/images/Trash_Bin_Icon.png"
<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"/> alt="Delete"
</svg> className="w-6 h-6 object-contain"
style={{ imageRendering: "pixelated" }}
/>
</button> </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> </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 <button
onClick={() => { onClick={() => {
playPressSound(); playPressSound();
setInitialPath("");
setIsImportModalOpen(true); setIsImportModalOpen(true);
}} }}
onMouseEnter={() => setHoveredBtn({row: editions.length, btn: 'add'})} onMouseEnter={() => setFocusIndex(editions.length)}
onMouseLeave={() => setHoveredBtn(null)} onMouseLeave={() => setHoveredBtn(null)}
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]" className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]"
style={{ 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"> <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> </svg>
</button> </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> </div>
</div> </div>
@@ -531,15 +564,15 @@ const VersionsView = memo(function VersionsView() {
<div className="flex justify-center"> <div className="flex justify-center">
<button <button
data-index={editions.length + 1} data-index={editions.length + 2}
onMouseEnter={() => setFocusIndex(editions.length + 1)} onMouseEnter={() => setFocusIndex(editions.length + 2)}
onClick={() => { onClick={() => {
playBackSound(); playBackSound();
setActiveView("main"); setActiveView("main");
}} }}
className="w-48 h-10 flex items-center justify-center text-xl mc-text-shadow outline-none border-none text-white" className="w-48 h-10 flex items-center justify-center text-xl mc-text-shadow outline-none border-none text-white"
style={{ style={{
backgroundImage: focusIndex === editions.length + 1 backgroundImage: focusIndex === editions.length + 2
? "url('/images/button_highlighted.png')" ? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')", : "url('/images/Button_Background.png')",
backgroundSize: "100% 100%", backgroundSize: "100% 100%",
@@ -555,6 +588,7 @@ const VersionsView = memo(function VersionsView() {
onClose={() => { onClose={() => {
setIsImportModalOpen(false); setIsImportModalOpen(false);
setEditingEdition(null); setEditingEdition(null);
setInitialPath("");
}} }}
onImport={(ed: any) => { onImport={(ed: any) => {
if (editingEdition) { if (editingEdition) {
@@ -567,6 +601,7 @@ const VersionsView = memo(function VersionsView() {
playPressSound={playPressSound} playPressSound={playPressSound}
playBackSound={playBackSound} playBackSound={playBackSound}
editingEdition={editingEdition} editingEdition={editingEdition}
initialPath={initialPath}
/> />
{deleteConfirmEdition && ( {deleteConfirmEdition && (
+2 -2
View File
@@ -218,7 +218,7 @@ export function useGameManager({
}, [profile]); }, [profile]);
const addCustomEdition = useCallback( const addCustomEdition = useCallback(
(edition: { name: string; desc: string; url: string }) => { (edition: { name: string; desc: string; url: string; path?: string }) => {
const id = `custom_${Date.now()}`; const id = `custom_${Date.now()}`;
const newEdition = { const newEdition = {
...edition, ...edition,
@@ -240,7 +240,7 @@ export function useGameManager({
); );
const updateCustomEdition = useCallback( const updateCustomEdition = useCallback(
(id: string, updated: { name: string; desc: string; url: string }) => { (id: string, updated: { name: string; desc: string; url: string; path?: string }) => {
setCustomEditions( setCustomEditions(
customEditions.map((e) => (e.id === id ? { ...e, ...updated } : e)), customEditions.map((e) => (e.id === id ? { ...e, ...updated } : e)),
); );
+5
View File
@@ -18,6 +18,7 @@ export interface CustomEdition {
name: string; name: string;
desc: string; desc: string;
url: string; url: string;
path?: string;
} }
export interface AppConfig { export interface AppConfig {
@@ -198,4 +199,8 @@ export class TauriService {
static async checkGameUpdate(instanceId: string, url: string): Promise<boolean> { static async checkGameUpdate(instanceId: string, url: string): Promise<boolean> {
return invoke("check_game_update", { instanceId, url }); return invoke("check_game_update", { instanceId, url });
} }
static async pickFolder(): Promise<string> {
return invoke("pick_folder");
}
} }