mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-07-18 00:07:09 +00:00
feat: neoLegacy official DLCs support (#98)
This commit is contained in:
parent
e2a58ab948
commit
e2459777b2
205
src-tauri/src/commands/dlc.rs
Normal file
205
src-tauri/src/commands/dlc.rs
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
use std::fs;
|
||||
use serde::Serialize;
|
||||
use tauri::AppHandle;
|
||||
use crate::util;
|
||||
#[derive(Serialize)]
|
||||
pub struct GitEntry {
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
}
|
||||
|
||||
fn parse_git_url(url: &str) -> Result<(String, String, String, bool), String> {
|
||||
let parsed = url::Url::parse(url).map_err(|e| format!("Invalid URL: {}", e))?;
|
||||
let host = parsed.host_str().ok_or("No host in URL")?.to_string();
|
||||
let segments: Vec<&str> = parsed.path().split('/').filter(|s| !s.is_empty()).collect();
|
||||
if segments.len() < 2 {
|
||||
return Err("Invalid repo URL: need owner and repo".into());
|
||||
}
|
||||
let owner = segments[0].to_string();
|
||||
let repo = segments[1].trim_end_matches(".git").to_string();
|
||||
let is_github = host == "github.com" || host == "raw.githubusercontent.com";
|
||||
Ok((owner, repo, host, is_github))
|
||||
}
|
||||
|
||||
fn get_api_url(host: &str, owner: &str, repo: &str, path: &str, branch: &str, is_github: bool) -> String {
|
||||
let api_path = if path.is_empty() || path == "." || path == "/" {
|
||||
String::new()
|
||||
} else {
|
||||
format!("/{}", path.trim_start_matches('/'))
|
||||
};
|
||||
if is_github {
|
||||
format!("https://api.github.com/repos/{}/{}/contents{}?ref={}", owner, repo, api_path, branch)
|
||||
} else {
|
||||
format!("https://{}/api/v1/repos/{}/{}/contents{}?ref={}", host, owner, repo, api_path, branch)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_raw_url(host: &str, owner: &str, repo: &str, branch: &str, path: &str, is_github: bool) -> String {
|
||||
if is_github {
|
||||
format!("https://raw.githubusercontent.com/{}/{}/{}/{}", owner, repo, branch, path)
|
||||
} else {
|
||||
format!("https://{}/{}/{}/raw/branch/{}/{}", host, owner, repo, branch, path)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_git_directory(
|
||||
repo_url: String,
|
||||
branch: String,
|
||||
path: String,
|
||||
) -> Result<Vec<GitEntry>, String> {
|
||||
list_git_directory_inner(repo_url, branch, path).await
|
||||
}
|
||||
|
||||
fn list_git_directory_inner(
|
||||
repo_url: String,
|
||||
branch: String,
|
||||
path: String,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<GitEntry>, String>> + Send>> {
|
||||
Box::pin(async move {
|
||||
let (owner, repo, host, is_github) = parse_git_url(&repo_url)?;
|
||||
let api_url = get_api_url(&host, &owner, &repo, &path, &branch, is_github);
|
||||
let client = reqwest::Client::new();
|
||||
let response = client.get(&api_url)
|
||||
.header("User-Agent", "Emerald-Launcher")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch directory listing: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
if response.status().as_u16() == 404 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
return Err(format!("Git API returned {}", response.status()));
|
||||
}
|
||||
|
||||
let text = response.text().await.map_err(|e| e.to_string())?;
|
||||
let json: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
let entries = match &json {
|
||||
serde_json::Value::Array(arr) => arr.clone(),
|
||||
serde_json::Value::Object(_) => {
|
||||
let is_dir = json.get("type").and_then(|v| v.as_str()) == Some("dir");
|
||||
if is_dir {
|
||||
let sub_path = json.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
return list_git_directory_inner(repo_url, branch, sub_path).await;
|
||||
}
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
_ => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let mut result = Vec::new();
|
||||
for entry in &entries {
|
||||
if let (Some(name), Some(type_val)) = (
|
||||
entry.get("name").and_then(|v| v.as_str()),
|
||||
entry.get("type").and_then(|v| v.as_str()),
|
||||
) {
|
||||
result.push(GitEntry {
|
||||
name: name.to_string(),
|
||||
is_dir: type_val == "dir",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
async fn collect_files(
|
||||
host: &str,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
branch: &str,
|
||||
root_path: &str,
|
||||
is_github: bool,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let mut files = Vec::new();
|
||||
let mut dirs_to_list = vec![root_path.to_string()];
|
||||
let client = reqwest::Client::new();
|
||||
while let Some(dir) = dirs_to_list.pop() {
|
||||
let api_url = get_api_url(host, owner, repo, &dir, branch, is_github);
|
||||
let response = client.get(&api_url)
|
||||
.header("User-Agent", "Emerald-Launcher")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to list {}: {}", dir, e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Failed to list {}: HTTP {}", dir, response.status()));
|
||||
}
|
||||
|
||||
let text = response.text().await.map_err(|e| e.to_string())?;
|
||||
let json: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("Failed to parse: {}", e))?;
|
||||
let entries = match &json {
|
||||
serde_json::Value::Array(arr) => arr.clone(),
|
||||
serde_json::Value::Object(_) => {
|
||||
let entry_type = json.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if entry_type == "dir" {
|
||||
dirs_to_list.push(dir.clone());
|
||||
continue;
|
||||
}
|
||||
files.push(dir.clone());
|
||||
continue;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
for entry in &entries {
|
||||
if let (Some(name), Some(type_val)) = (
|
||||
entry.get("name").and_then(|v| v.as_str()),
|
||||
entry.get("type").and_then(|v| v.as_str()),
|
||||
) {
|
||||
let full_path = if dir.is_empty() { name.to_string() } else { format!("{}/{}", dir, name) };
|
||||
if type_val == "dir" {
|
||||
dirs_to_list.push(full_path);
|
||||
} else {
|
||||
files.push(full_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_dlc_files(
|
||||
app: AppHandle,
|
||||
instance_id: String,
|
||||
repo_url: String,
|
||||
branch: String,
|
||||
dlc_folder: String,
|
||||
) -> Result<(), String> {
|
||||
let instance_dir = util::get_instance_working_dir(&app, &instance_id);
|
||||
let dlc_dest = instance_dir.join("Windows64Media").join("DLC").join(&dlc_folder);
|
||||
let (owner, repo, host, is_github) = parse_git_url(&repo_url)?;
|
||||
let files_to_download = collect_files(&host, &owner, &repo, &branch, &dlc_folder, is_github).await?;
|
||||
if files_to_download.is_empty() {
|
||||
return Err(format!("No files found in '{}' folder", dlc_folder));
|
||||
}
|
||||
|
||||
fs::create_dir_all(&dlc_dest).map_err(|e| e.to_string())?;
|
||||
let client = reqwest::Client::new();
|
||||
for file_path in &files_to_download {
|
||||
let raw_url = get_raw_url(&host, &owner, &repo, &branch, file_path, is_github);
|
||||
let response = client.get(&raw_url)
|
||||
.header("User-Agent", "Emerald-Launcher")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download {}: {}", file_path, e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Failed to download {}: HTTP {}", file_path, response.status()));
|
||||
}
|
||||
|
||||
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
|
||||
let relative_path = file_path.strip_prefix(&format!("{}/", dlc_folder)).unwrap_or(file_path);
|
||||
let dest_path = dlc_dest.join(relative_path);
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
fs::write(&dest_path, &bytes).map_err(|e| format!("Failed to write {}: {}", file_path, e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod config;
|
||||
pub mod console2lce;
|
||||
pub mod dlc;
|
||||
pub mod download;
|
||||
pub mod file_dialogs;
|
||||
pub mod game;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use std::sync::Arc;
|
|||
use tokio::sync::Mutex;
|
||||
use tauri::{Emitter, Manager};
|
||||
use commands::config as config_cmds;
|
||||
use commands::dlc;
|
||||
use commands::download;
|
||||
use commands::file_dialogs;
|
||||
use commands::game;
|
||||
|
|
@ -62,6 +63,8 @@ pub fn run() {
|
|||
.plugin(tauri_plugin_drpc::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
macos_setup::setup_macos_runtime,
|
||||
dlc::list_git_directory,
|
||||
dlc::download_dlc_files,
|
||||
game::launch_game,
|
||||
game::stop_game,
|
||||
game::check_game_installed,
|
||||
|
|
|
|||
330
src/components/modals/DownloadDlcModal.tsx
Normal file
330
src/components/modals/DownloadDlcModal.tsx
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
interface DlcEntry {
|
||||
name: string;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export default function DownloadDlcModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
playPressSound,
|
||||
playBackSound,
|
||||
editionName,
|
||||
instanceId,
|
||||
officialDLC,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
playPressSound: (s?: string) => void;
|
||||
playBackSound: (s?: string) => void;
|
||||
editionName: string;
|
||||
instanceId: string;
|
||||
officialDLC: string;
|
||||
}) {
|
||||
const [dlcList, setDlcList] = useState<DlcEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloaded, setDownloaded] = useState<string[]>([]);
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [branch, repoUrl] = officialDLC.includes(":")
|
||||
? [
|
||||
officialDLC.slice(0, officialDLC.indexOf(":")),
|
||||
officialDLC.slice(officialDLC.indexOf(":") + 1),
|
||||
]
|
||||
: ["main", officialDLC];
|
||||
|
||||
const fetchDlcs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [gitEntries, instancePath] = await Promise.all([
|
||||
TauriService.listGitDirectory(repoUrl, branch, ""),
|
||||
TauriService.getInstancePath(instanceId),
|
||||
]);
|
||||
|
||||
let existingDlcs: string[] = [];
|
||||
try {
|
||||
const dlcDirEntries = await TauriService.listDirectory(
|
||||
instancePath + "/Windows64Media/DLC",
|
||||
);
|
||||
existingDlcs = dlcDirEntries.filter((e) => e.is_dir).map((e) => e.name);
|
||||
} catch {
|
||||
existingDlcs = [];
|
||||
}
|
||||
|
||||
const folders = gitEntries
|
||||
.filter((e) => e.is_dir)
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
|
||||
setDlcList(
|
||||
folders
|
||||
.filter((name) => !existingDlcs.includes(name))
|
||||
.map((name) => ({
|
||||
name,
|
||||
selected: false,
|
||||
})),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [repoUrl, branch, instanceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setDlcList([]);
|
||||
setError(null);
|
||||
setDownloading(false);
|
||||
setDownloaded([]);
|
||||
setFocusIndex(0);
|
||||
fetchDlcs();
|
||||
}
|
||||
}, [isOpen, fetchDlcs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
if (!downloading) {
|
||||
playBackSound();
|
||||
onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setFocusIndex((prev) => Math.min(prev + 1, dlcList.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setFocusIndex((prev) => Math.max(prev - 1, 0));
|
||||
} else if (e.key === "Enter" && !downloading) {
|
||||
e.preventDefault();
|
||||
if (focusIndex >= 0 && focusIndex < dlcList.length) {
|
||||
toggleDlc(dlcList[focusIndex].name);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [isOpen, dlcList, focusIndex, downloading, playBackSound, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusIndex >= 0 && listRef.current) {
|
||||
const el = listRef.current.querySelector(
|
||||
`[data-dlc-index="${focusIndex}"]`,
|
||||
) as HTMLElement;
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
}
|
||||
}, [focusIndex]);
|
||||
|
||||
const toggleDlc = (name: string) => {
|
||||
playPressSound();
|
||||
setDlcList((prev) =>
|
||||
prev.map((d) => (d.name === name ? { ...d, selected: !d.selected } : d)),
|
||||
);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
playPressSound();
|
||||
setDlcList((prev) => prev.map((d) => ({ ...d, selected: true })));
|
||||
};
|
||||
|
||||
const deselectAll = () => {
|
||||
playPressSound();
|
||||
setDlcList((prev) => prev.map((d) => ({ ...d, selected: false })));
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
const selected = dlcList.filter((d) => d.selected);
|
||||
if (selected.length === 0) return;
|
||||
playPressSound();
|
||||
setDownloading(true);
|
||||
setDownloaded([]);
|
||||
for (const dlc of selected) {
|
||||
try {
|
||||
await TauriService.downloadDlcFiles(
|
||||
instanceId,
|
||||
repoUrl,
|
||||
branch,
|
||||
dlc.name,
|
||||
);
|
||||
setDownloaded((prev) => [...prev, dlc.name]);
|
||||
} catch (e) {
|
||||
setError(
|
||||
`Failed to download ${dlc.name}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
setDownloading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setDownloading(false);
|
||||
};
|
||||
|
||||
const selectedCount = dlcList.filter((d) => d.selected).length;
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 w-screen h-screen z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md outline-none border-none"
|
||||
>
|
||||
<div
|
||||
className="relative w-[480px] max-h-[80vh] p-6 flex flex-col items-center shadow-2xl"
|
||||
style={{
|
||||
backgroundImage: "url('/images/frame_background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-1 w-full text-center uppercase">
|
||||
Download DLC
|
||||
</h2>
|
||||
<p className="text-white text-sm mc-text-shadow mb-4 text-center">
|
||||
{editionName}
|
||||
</p>
|
||||
|
||||
{loading && (
|
||||
<div className="text-white text-sm mc-text-shadow mb-4 py-8">
|
||||
Loading available DLCs...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-red-400 text-sm mc-text-shadow mb-4 text-center max-w-full break-words">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && dlcList.length === 0 && (
|
||||
<div className="text-white text-sm mc-text-shadow mb-4 py-8">
|
||||
No DLC folders found in the repository.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && dlcList.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center justify-between w-full mb-2 gap-2">
|
||||
<span className="text-white text-[10px] mc-text-shadow uppercase tracking-widest">
|
||||
{dlcList.length} available
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
disabled={downloading}
|
||||
className="text-[9px] px-1.5 py-0.5 border border-[#555] text-white bg-black/20 hover:border-[#FFFF55] hover:text-[#FFFF55] mc-text-shadow uppercase tracking-wider transition-colors disabled:opacity-40"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={deselectAll}
|
||||
disabled={downloading}
|
||||
className="text-[9px] px-1.5 py-0.5 border border-[#555] text-white bg-black/20 hover:border-[#FFFF55] hover:text-[#FFFF55] mc-text-shadow uppercase tracking-wider transition-colors disabled:opacity-40"
|
||||
>
|
||||
None
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={listRef}
|
||||
className="w-full max-h-[40vh] overflow-y-auto custom-scrollbar border border-[#373737] bg-black/20"
|
||||
>
|
||||
{dlcList.map((dlc, i) => (
|
||||
<div
|
||||
key={dlc.name}
|
||||
data-dlc-index={i}
|
||||
onClick={() => !downloading && toggleDlc(dlc.name)}
|
||||
className={`flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors hover:bg-white/5 ${
|
||||
focusIndex === i ? "ring-1 ring-white bg-white/5" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-4 h-4 border flex items-center justify-center flex-shrink-0 ${
|
||||
dlc.selected
|
||||
? "border-[#FFFF55] bg-[#FFFF55]/20"
|
||||
: "border-[#555]"
|
||||
}`}
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
>
|
||||
{dlc.selected && (
|
||||
<span className="text-[#FFFF55] text-xs leading-none">
|
||||
✔
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm mc-text-shadow text-white">
|
||||
{dlc.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center gap-4 mt-4">
|
||||
{downloading && (
|
||||
<div className="text-[#FFFF55] text-sm mc-text-shadow">
|
||||
Downloading... ({downloaded.length}/{selectedCount})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-3 mt-3">
|
||||
{!downloading && selectedCount > 0 && (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="w-32 h-10 flex items-center justify-center text-sm mc-text-shadow text-white outline-none border-none"
|
||||
style={{
|
||||
backgroundImage: "url('/images/button_highlighted.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Download ({selectedCount})
|
||||
</button>
|
||||
)}
|
||||
{downloading && downloaded.length === selectedCount && (
|
||||
<button
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
onClose();
|
||||
}}
|
||||
className="w-32 h-10 flex items-center justify-center text-sm mc-text-shadow text-white outline-none border-none"
|
||||
style={{
|
||||
backgroundImage: "url('/images/button_highlighted.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
playBackSound();
|
||||
onClose();
|
||||
}}
|
||||
disabled={downloading}
|
||||
className="w-24 h-10 flex items-center justify-center text-sm mc-text-shadow outline-none border-none text-white disabled:opacity-40"
|
||||
style={{
|
||||
backgroundImage: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ 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,
|
||||
|
|
@ -103,6 +104,8 @@ const VersionsView = memo(function VersionsView() {
|
|||
} | null>(null);
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||
const [deleteConfirmEdition, setDeleteConfirmEdition] = useState<Edition | null>(null);
|
||||
const [isDlcModalOpen, setIsDlcModalOpen] = useState(false);
|
||||
const [dlcTargetEdition, setDlcTargetEdition] = useState<Edition | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const ITEM_COUNT = editions.length + 3;
|
||||
|
|
@ -536,6 +539,33 @@ const VersionsView = memo(function VersionsView() {
|
|||
Download to custom path
|
||||
</button>
|
||||
)}
|
||||
{edition.officialDLC && isInstalled ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
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"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="w-3.5 h-3.5"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
Download DLC
|
||||
</button>
|
||||
) : null}
|
||||
{Array.isArray(edition.branches) &&
|
||||
edition.branches.length > 0 && (
|
||||
<button
|
||||
|
|
@ -935,6 +965,16 @@ const VersionsView = memo(function VersionsView() {
|
|||
}}
|
||||
/>
|
||||
|
||||
<DownloadDlcModal
|
||||
isOpen={isDlcModalOpen}
|
||||
onClose={() => { setIsDlcModalOpen(false); setDlcTargetEdition(null); }}
|
||||
playPressSound={playPressSound}
|
||||
playBackSound={playBackSound}
|
||||
editionName={dlcTargetEdition?.name ?? ""}
|
||||
instanceId={dlcTargetEdition?.instanceId ?? ""}
|
||||
officialDLC={dlcTargetEdition?.officialDLC ?? ""}
|
||||
/>
|
||||
|
||||
{deleteConfirmEdition && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ const BASE_EDITIONS = [
|
|||
supportsSlimSkins: true,
|
||||
logo: "/images/neoLegacy.png",
|
||||
panorama: "legacy_evolved",
|
||||
officialDLC: "main:https://git.neolegacy.dev/neoStudiosLCE/DLCs", //neo: format is {branch}:{git_url}
|
||||
},
|
||||
{
|
||||
id: "revelations",
|
||||
|
|
@ -615,4 +616,4 @@ export function useGameManager({
|
|||
updateCustomization,
|
||||
saveCustomPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -409,6 +409,23 @@ export class TauriService {
|
|||
return invoke("list_directory", { path });
|
||||
}
|
||||
|
||||
static async listGitDirectory(
|
||||
repoUrl: string,
|
||||
branch: string,
|
||||
path: string,
|
||||
): Promise<Array<{ name: string; is_dir: boolean }>> {
|
||||
return invoke("list_git_directory", { repoUrl, branch, path });
|
||||
}
|
||||
|
||||
static async downloadDlcFiles(
|
||||
instanceId: string,
|
||||
repoUrl: string,
|
||||
branch: string,
|
||||
dlcFolder: string,
|
||||
): Promise<void> {
|
||||
return invoke("download_dlc_files", { instanceId, repoUrl, branch, dlcFolder });
|
||||
}
|
||||
|
||||
static async importWorld(
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export interface Edition {
|
|||
instanceId: string;
|
||||
comingSoon?: boolean;
|
||||
category?: string[];
|
||||
officialDLC?: string;
|
||||
}
|
||||
|
||||
export interface CustomEditionInput {
|
||||
|
|
|
|||
Loading…
Reference in a new issue