diff --git a/jsconfig.json b/jsconfig.json
new file mode 100644
index 0000000..957ea60
--- /dev/null
+++ b/jsconfig.json
@@ -0,0 +1,3 @@
+{
+ "include": ["src/**/*.js"]
+}
diff --git a/package-lock.json b/package-lock.json
index 2979c6d..a08f05b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "rplauncher",
- "version": "1.4.5",
+ "version": "1.5.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "rplauncher",
- "version": "1.4.5",
+ "version": "1.5.0",
"cpu": [
"x64",
"arm64"
diff --git a/package.json b/package.json
index ba3de7a..673e644 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "rplauncher",
- "version": "1.4.5",
+ "version": "1.5.0",
"description": "rPLauncher is simple, yet powerful Minecraft custom launcher with a strong focus on the user experience",
"keywords": [
"minecraft",
diff --git a/src/app/desktop/utils/downloader.js b/src/app/desktop/utils/downloader.js
index 043d65a..834fe75 100644
--- a/src/app/desktop/utils/downloader.js
+++ b/src/app/desktop/utils/downloader.js
@@ -118,6 +118,111 @@ const downloadFileInstance = async (fileName, url, sha1, legacyPath) => {
}
};
+/**
+ * @param {{ path: string, hashes: { sha1: string, sha512: string }, downloads: string[] }[]} files
+ * @param {string} instancePath
+ * @param {number} updatePercentage
+ * @param {number} threads
+ */
+export const downloadInstanceFilesWithFallbacks = async (
+ files,
+ instancePath,
+ updatePercentage,
+ threads = 4
+) => {
+ let downloaded = 0;
+ await pMap(
+ files,
+ async file => {
+ let counter = 0;
+ let res = false;
+ do {
+ counter += 1;
+ if (counter !== 1) {
+ await new Promise(resolve => setTimeout(resolve, 5000));
+ }
+
+ try {
+ res = await downloadFileInstanceWithFallbacks(file, instancePath);
+ } catch {
+ // Do nothing
+ }
+ } while (!res && counter < 3);
+ downloaded += 1;
+ if (
+ updatePercentage &&
+ (downloaded % 5 === 0 || downloaded === files.length)
+ ) {
+ updatePercentage(downloaded);
+ }
+ },
+ { concurrency: threads }
+ );
+};
+
+/**
+ * @param {{ path: string, hashes: { [algo: string]: string }, downloads: string[] }[]} file
+ * @param {string} instancePath
+ */
+const downloadFileInstanceWithFallbacks = async (file, instancePath) => {
+ const filePath = path.join(instancePath, file.path);
+ const dirPath = path.dirname(filePath);
+ try {
+ await fs.access(filePath);
+
+ let allChecksumsMatch = false;
+ for (const algo of Object.keys(file.hashes)) {
+ const checksum = await computeFileHash(filePath, algo);
+ if (file.hashes[algo] === checksum) {
+ allChecksumsMatch = true;
+ }
+ }
+ if (allChecksumsMatch) {
+ // the file already exists on disk, skip it
+ return true;
+ }
+ } catch {
+ await makeDir(dirPath);
+ }
+
+ // this loop exits as soon as a download has been successful
+ for (const url of file.downloads) {
+ const encodedUrl = getUri(url);
+ try {
+ const { data } = await axios.get(encodedUrl, {
+ responseType: 'stream',
+ responseEncoding: null,
+ adapter,
+ timeout: 60000 * 20
+ });
+
+ const wStream = fss.createWriteStream(filePath, {
+ encoding: null
+ });
+
+ data.pipe(wStream);
+
+ await new Promise((resolve, reject) => {
+ data.on('error', err => {
+ console.error(err);
+ reject(err);
+ });
+
+ data.on('end', () => {
+ wStream.end();
+ resolve();
+ });
+ });
+
+ return true;
+ } catch (e) {
+ console.error(
+ `Error while downloading <${url} | ${encodedUrl}> to <${file.path}> --> ${e.message}`
+ );
+ }
+ }
+};
+
export const downloadFile = async (fileName, url, onProgress) => {
await makeDir(path.dirname(fileName));
diff --git a/src/app/desktop/utils/index.js b/src/app/desktop/utils/index.js
index a1f6484..5378b8c 100644
--- a/src/app/desktop/utils/index.js
+++ b/src/app/desktop/utils/index.js
@@ -2,7 +2,7 @@ import fss, { promises as fs } from 'fs';
import originalFs from 'original-fs';
import fse from 'fs-extra';
import axios from 'axios';
-import { extractFull } from 'node-7z';
+import * as Seven from 'node-7z';
import jimp from 'jimp/es';
import makeDir from 'make-dir';
import { promisify } from 'util';
@@ -16,11 +16,12 @@ import {
FORGE,
LATEST_JAVA_VERSION
} from '../../../common/utils/constants';
-
import {
+ addQuotes,
removeDuplicates,
sortByForgeVersionDesc
} from '../../../common/utils';
+// eslint-disable-next-line import/no-cycle
import {
getAddon,
getAddonFile,
@@ -400,6 +401,38 @@ export const get7zPath = async () => {
get7zPath();
+export const extract = async (source, destination, args = {}, funcs = {}) => {
+ const sevenZipPath = await get7zPath();
+ const extraction = Seven.extract(source, destination, {
+ ...args,
+ yes: true,
+ $bin: sevenZipPath,
+ $spawnOptions: { shell: true }
+ });
+ let extractedParentDir = null;
+ await new Promise((resolve, reject) => {
+ if (funcs.progress) {
+ extraction.on('progress', ({ percent }) => {
+ funcs.progress(percent);
+ });
+ }
+ extraction.on('data', data => {
+ if (!extractedParentDir) {
+ [extractedParentDir] = data.file.split('/');
+ }
+ });
+ extraction.on('end', () => {
+ funcs.end?.();
+ resolve(extractedParentDir);
+ });
+ extraction.on('error', err => {
+ funcs.error?.();
+ reject(err);
+ });
+ });
+ return { extraction, extractedParentDir };
+};
+
export const extractAll = async (
source,
destination,
@@ -407,7 +440,7 @@ export const extractAll = async (
funcs = {}
) => {
const sevenZipPath = await get7zPath();
- const extraction = extractFull(source, destination, {
+ const extraction = Seven.extractFull(source, destination, {
...args,
yes: true,
$bin: sevenZipPath,
@@ -576,13 +609,14 @@ export const getJVMArguments112 = (
hideAccessToken,
jvmOptions = []
) => {
+ const needsQuote = process.platform !== 'win32';
const args = [];
args.push('-cp');
args.push(
[...libraries, mcjar]
.filter(l => !l.natives)
- .map(l => `"${l.path}"`)
+ .map(l => `${addQuotes(needsQuote, l.path)}`)
.join(process.platform === 'win32' ? ';' : ':')
);
@@ -594,8 +628,15 @@ export const getJVMArguments112 = (
args.push(`-Xmx${memory}m`);
args.push(`-Xms${memory}m`);
args.push(...jvmOptions);
- args.push(`-Djava.library.path="${path.join(instancePath, 'natives')}"`);
- args.push(`-Dminecraft.applet.TargetDirectory="${instancePath}"`);
+ args.push(
+ `-Djava.library.path=${addQuotes(
+ needsQuote,
+ path.join(instancePath, 'natives')
+ )}`
+ );
+ args.push(
+ `-Dminecraft.applet.TargetDirectory=${addQuotes(needsQuote, instancePath)}`
+ );
if (mcJson.logging) {
args.push(mcJson?.logging?.client?.argument || '');
}
@@ -617,13 +658,13 @@ export const getJVMArguments112 = (
val = mcJson.id;
break;
case 'game_directory':
- val = `"${instancePath}"`;
+ val = `${addQuotes(needsQuote, instancePath)}`;
break;
case 'assets_root':
- val = `"${assetsPath}"`;
+ val = `${addQuotes(needsQuote, assetsPath)}`;
break;
case 'game_assets':
- val = `"${path.join(assetsPath, 'virtual', 'legacy')}"`;
+ val = `${path.join(assetsPath, 'virtual', 'legacy')}`;
break;
case 'assets_index_name':
val = mcJson.assets;
@@ -652,6 +693,9 @@ export const getJVMArguments112 = (
if (val != null) {
mcArgs[i] = val;
}
+ if (typeof args[i] === 'string' && !needsQuote) {
+ args[i] = args[i].replaceAll('"', '');
+ }
}
}
@@ -679,6 +723,7 @@ export const getJVMArguments113 = (
) => {
const argDiscovery = /\${*(.*)}/;
let args = mcJson.arguments.jvm.filter(v => !skipLibrary(v));
+ const needsQuote = process.platform !== 'win32';
// if (process.platform === "darwin") {
// args.push("-Xdock:name=instancename");
@@ -687,7 +732,9 @@ export const getJVMArguments113 = (
args.push(`-Xmx${memory}m`);
args.push(`-Xms${memory}m`);
- args.push(`-Dminecraft.applet.TargetDirectory="${instancePath}"`);
+ args.push(
+ `-Dminecraft.applet.TargetDirectory=${addQuotes(needsQuote, instancePath)}`
+ );
if (mcJson.logging) {
args.push(mcJson?.logging?.client?.argument || '');
}
@@ -705,9 +752,9 @@ export const getJVMArguments113 = (
for (let i = 0; i < args.length; i += 1) {
if (typeof args[i] === 'object' && args[i].rules) {
if (typeof args[i].value === 'string') {
- args[i] = `"${args[i].value}"`;
+ args[i] = `${addQuotes(needsQuote, args[i].value)}`;
} else if (typeof args[i].value === 'object') {
- args.splice(i, 1, ...args[i].value.map(v => `"${v}"`));
+ args.splice(i, 1, ...args[i].value.map(v => `${v}`));
}
i -= 1;
} else if (typeof args[i] === 'string') {
@@ -722,10 +769,10 @@ export const getJVMArguments113 = (
val = mcJson.id;
break;
case 'game_directory':
- val = `"${instancePath}"`;
+ val = `${addQuotes(needsQuote, instancePath)}`;
break;
case 'assets_root':
- val = `"${assetsPath}"`;
+ val = `${addQuotes(needsQuote, assetsPath)}`;
break;
case 'assets_index_name':
val = mcJson.assets;
@@ -751,7 +798,7 @@ export const getJVMArguments113 = (
case 'natives_directory':
val = args[i].replace(
argDiscovery,
- `"${path.join(instancePath, 'natives')}"`
+ `${addQuotes(needsQuote, path.join(instancePath, 'natives'))}`
);
break;
case 'launcher_name':
@@ -763,7 +810,7 @@ export const getJVMArguments113 = (
case 'classpath':
val = [...libraries, mcjar]
.filter(l => !l.natives)
- .map(l => `"${l.path}"`)
+ .map(l => `${addQuotes(needsQuote, l.path)}`)
.join(process.platform === 'win32' ? ';' : ':');
break;
default:
@@ -773,6 +820,7 @@ export const getJVMArguments113 = (
args[i] = val;
}
}
+ if (!needsQuote) args[i] = args[i].replaceAll('"', '');
}
}
diff --git a/src/app/desktop/views/Login.js b/src/app/desktop/views/Login.js
index 61bf54f..9c38fda 100644
--- a/src/app/desktop/views/Login.js
+++ b/src/app/desktop/views/Login.js
@@ -336,7 +336,7 @@ const Login = () => {
setTwofactor(value)}
css={`
diff --git a/src/common/api.js b/src/common/api.js
index 8418829..c1dd1d5 100644
--- a/src/common/api.js
+++ b/src/common/api.js
@@ -1,6 +1,9 @@
// @flow
import axios from 'axios';
import qs from 'querystring';
+import path from 'path';
+import fse from 'fs-extra';
+import os from 'os';
import {
MOJANG_APIS,
ELYBY_APIS,
@@ -14,15 +17,30 @@ import {
MICROSOFT_XSTS_AUTH_URL,
MINECRAFT_SERVICES_URL,
FTB_API_URL,
+ MODRINTH_API_URL,
JAVA_LATEST_MANIFEST_URL
} from './utils/constants';
import { sortByDate } from './utils';
import ga from './utils/analytics';
+import { downloadFile } from '../app/desktop/utils/downloader';
+// eslint-disable-next-line import/no-cycle
+import { extractAll } from '../app/desktop/utils';
+
+const modrinthClient = axios.create({
+ baseURL: MODRINTH_API_URL,
+ headers: {
+ // 'User-Agent': `rePublic-Studios/rPLauncher/${appVersion}`
+ }
+});
const trackFTBAPI = () => {
ga.sendCustomEvent('FTBAPICall');
};
+const trackModrinthAPI = () => {
+ ga.sendCustomEvent('ModrinthAPICall');
+};
+
// Microsoft Auth
export const msExchangeCodeForAccessToken = (
clientId,
@@ -341,7 +359,7 @@ export const getAddonFileChangelog = async (projectID, fileID) => {
return data?.data;
};
-export const getAddonCategories = async () => {
+export const getCurseForgeCategories = async () => {
const url = `${FORGESVC_URL}/categories?gameId=432`;
const { data } = await axios.get(url);
return data.data;
@@ -353,7 +371,7 @@ export const getCFVersionIds = async () => {
return data.data;
};
-export const getSearch = async (
+export const getCurseForgeSearch = async (
type,
searchFilter,
pageSize,
@@ -442,11 +460,262 @@ export const getFTBChangelog = async (modpackId, versionId) => {
export const getFTBMostPlayed = async () => {
trackFTBAPI();
const url = `${FTB_API_URL}/modpack/popular/plays/1000`;
- return axios.get(url);
+ const { data } = await axios.get(url);
+ return data;
};
export const getFTBSearch = async searchText => {
trackFTBAPI();
const url = `${FTB_API_URL}/modpack/search/1000?term=${searchText}`;
- return axios.get(url);
+ const { data } = axios.get(url);
+ return data;
+};
+/**
+ * @param {number} offset
+ * @returns {Promise
}
+ */
+export const getModrinthMostPlayedModpacks = async (offset = 0) => {
+ trackModrinthAPI();
+ const url = `/search?limit=20&offset=${offset}&index=downloads&facets=[["project_type:modpack"]]`;
+ const { data } = await modrinthClient.get(url);
+ return data;
+};
+
+/**
+ * @param {string} query
+ * @param {'mod'|'modpack'} projectType
+ * @param {string} gameVersion
+ * @param {string[]} categories
+ * @param {number} index
+ * @param {number} offset
+ * @returns {Promise}
+ */
+export const getModrinthSearchResults = async (
+ query,
+ projectType,
+ gameVersion = null,
+ categories = [],
+ index = 'relevance',
+ offset = 0
+) => {
+ trackModrinthAPI();
+ const facets = [];
+
+ if (projectType === 'MOD') {
+ facets.push(['project_type:mod']);
+ }
+ if (projectType === 'MODPACK') {
+ facets.push(['project_type:modpack']);
+ }
+ if (gameVersion) {
+ facets.push([`versions:${gameVersion}`]);
+ }
+ // remove falsy values (i.e. null/undefined) from categories before constructing facets
+ const filteredCategories = categories.filter(cat => !!cat);
+ if (filteredCategories) {
+ facets.push(...filteredCategories.map(cat => [`categories:${cat}`]));
+ }
+
+ const { data } = await modrinthClient.get(`/search`, {
+ params: {
+ limit: 20,
+ query: query ?? undefined,
+ facets: facets ? JSON.stringify(facets) : undefined,
+ index: index ?? undefined,
+ offset: offset ?? undefined
+ }
+ });
+
+ return data;
+};
+
+/**
+ * @param {string} projectId
+ * @returns {Promise}
+ */
+export const getModrinthProject = async projectId => {
+ return (await getModrinthProjects([projectId])).at(0) ?? null;
+};
+
+/**
+ * @param {string[]} projectIds
+ * @returns {Promise}
+ */
+export const getModrinthProjects = async projectIds => {
+ trackModrinthAPI();
+ try {
+ const url = `/projects?ids=${JSON.stringify(projectIds)}`;
+ const { data } = await modrinthClient.get(url);
+ return data.map(fixModrinthProjectObject);
+ } catch {
+ return { status: 'error' };
+ }
+};
+
+/**
+ * @param {string} projectId
+ * @returns {Promise}
+ */
+export const getModrinthProjectVersions = async projectId => {
+ trackModrinthAPI();
+ try {
+ const url = `/project/${projectId}/version`;
+ const { data } = await modrinthClient.get(url);
+ return data;
+ } catch {
+ return { status: 'error' };
+ }
+};
+
+/**
+ * @param {string} versionId
+ * @returns {Promise}
+ */
+export const getModrinthVersion = async versionId => {
+ return (await getModrinthVersions([versionId])).at(0) ?? null;
+};
+
+/**
+ * @param {string[]} versionIds
+ * @returns {Promise}
+ */
+export const getModrinthVersions = async versionIds => {
+ trackModrinthAPI();
+ try {
+ const url = `versions?ids=${JSON.stringify(versionIds)}`;
+ const { data } = await modrinthClient.get(url);
+ return data || [];
+ } catch (err) {
+ console.error(err);
+ }
+};
+
+// TODO: Move override logic out of this function
+// TODO: Do overrides need to be applied after the pack is installed?
+/**
+ * @param {string} versionId
+ * @param {string} instancePath
+ * @returns {Promise}
+ */
+export const getModrinthVersionManifest = async (versionId, instancePath) => {
+ try {
+ // get download link for the metadata archive
+ const version = await getModrinthVersion(versionId);
+ const file = version.files.find(f => f.filename.endsWith('.mrpack'));
+
+ // clean temp directory
+ const tmp = path.join(os.tmpdir(), 'rPLauncher_Download');
+ await fse.rm(tmp, { recursive: true, force: true });
+
+ // download metadata archive
+ await downloadFile(path.join(tmp, file.filename), file.url);
+
+ // Wait 500ms to avoid `The process cannot access the file because it is being used by another process.`
+ await new Promise(resolve => {
+ setTimeout(() => resolve(), 500);
+ });
+
+ // extract archive to temp folder
+ await extractAll(path.join(tmp, file.filename), tmp, { yes: true });
+
+ await fse.move(path.join(tmp, 'overrides'), path.join(instancePath), {
+ overwrite: true
+ });
+
+ // move manifest to instance root
+ await fse.move(
+ path.join(tmp, 'modrinth.index.json'),
+ path.join(instancePath, 'modrinth.index.json'),
+ { overwrite: true }
+ );
+
+ // clean temp directory
+ await fse.rm(tmp, { recursive: true, force: true });
+
+ const manifest = await fse.readJson(
+ path.join(instancePath, 'modrinth.index.json')
+ );
+
+ return manifest;
+ } catch (err) {
+ console.error(err);
+
+ return { status: 'error' };
+ }
+};
+
+/**
+ * @param {string} versionId
+ * @returns {Promise}
+ */
+export const getModrinthVersionChangelog = async versionId => {
+ return (await getModrinthVersion(versionId)).changelog;
+};
+
+/**
+ * @param {string} userId
+ * @returns {Promise}
+ */
+export const getModrinthUser = async userId => {
+ trackModrinthAPI();
+ try {
+ const url = `/user/${userId}`;
+ const { data } = await modrinthClient.get(url);
+ return data;
+ } catch (err) {
+ console.error(err);
+ }
+};
+
+//! HACK
+const fixModrinthProjectObject = project => {
+ return {
+ ...project,
+ name: project.title
+ };
+};
+
+/**
+ * @returns {Promise}
+ */
+export const getModrinthCategories = async () => {
+ trackModrinthAPI();
+ try {
+ const url = '/tag/category';
+ const { data } = await modrinthClient.get(url);
+ return data;
+ } catch (err) {
+ console.error(err);
+ }
+};
+
+/**
+ * @param {string} projectId
+ * @returns {Promise}
+ */
+export const getModrinthProjectMembers = async projectId => {
+ trackModrinthAPI();
+ try {
+ const url = `/project/${projectId}/members`;
+ const { data } = await modrinthClient.get(url);
+ return data;
+ } catch (err) {
+ console.error(err);
+ }
+};
+
+/**
+ * @param {string[]} hashes
+ * @param {'sha1' | 'sha512'} algorithm
+ * @returns {Promise<{[hash: string]: ModrinthVersion}[]>}
+ */
+export const getVersionsFromHashes = async (hashes, algorithm) => {
+ trackModrinthAPI();
+ try {
+ const url = '/version_files';
+ const { data } = await modrinthClient.post(url, { hashes, algorithm });
+ return data;
+ } catch (err) {
+ console.error(err);
+ }
};
diff --git a/src/common/assets/modrinthIcon.webp b/src/common/assets/modrinthIcon.webp
new file mode 100644
index 0000000..abab8f3
Binary files /dev/null and b/src/common/assets/modrinthIcon.webp differ
diff --git a/src/common/modals/AddAccount.js b/src/common/modals/AddAccount.js
index e3c82fa..b73bb30 100644
--- a/src/common/modals/AddAccount.js
+++ b/src/common/modals/AddAccount.js
@@ -129,7 +129,7 @@ const AddAccount = ({ username, _accountType, loginmessage }) => {
onChange={e => setPassword(e.target.value)}
/>
setTwofactor(e.target.value)}
/>
diff --git a/src/common/modals/AddInstance/Content.js b/src/common/modals/AddInstance/Content.js
index b51cdee..2fd0a21 100644
--- a/src/common/modals/AddInstance/Content.js
+++ b/src/common/modals/AddInstance/Content.js
@@ -16,6 +16,8 @@ import NewInstance from './NewInstance';
import minecraftIcon from '../../assets/minecraftIcon.png';
import curseForgeIcon from '../../assets/curseforgeIcon.webp';
import ftbIcon from '../../assets/ftbIcon.webp';
+import modrinthIcon from '../../assets/modrinthIcon.webp';
+import ModrinthModpacks from './ModrinthModpacks';
const Content = ({
in: inProp,
@@ -49,6 +51,11 @@ const Content = ({
setVersion={setVersion}
setStep={setStep}
setModpack={setModpack}
+ />,
+
];
@@ -116,6 +123,17 @@ const Content = ({
/>
FTB
+
+
+ Modrinth
+
- props.version || props.importZipPath
- ? props.theme.action.hover
- : 'transparent'};
+ props.version || props.importZipPath
+ ? props.theme.action.hover
+ : 'transparent'};
}
`}
onClick={async () => {
diff --git a/src/common/modals/AddInstance/CurseForgeModpacks/index.js b/src/common/modals/AddInstance/CurseForgeModpacks/index.js
index ccbbaf6..6d8dfce 100644
--- a/src/common/modals/AddInstance/CurseForgeModpacks/index.js
+++ b/src/common/modals/AddInstance/CurseForgeModpacks/index.js
@@ -7,7 +7,7 @@ import AutoSizer from 'react-virtualized-auto-sizer';
import { useSelector } from 'react-redux';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faBomb, faExclamationCircle } from '@fortawesome/free-solid-svg-icons';
-import { getSearch } from '../../../api';
+import { getCurseForgeSearch } from '../../../api';
import ModpacksListWrapper from './ModpacksListWrapper';
let lastRequest;
@@ -17,8 +17,8 @@ const CurseForgeModpacks = ({ setStep, setVersion, setModpack }) => {
const infiniteLoaderRef = useRef(null);
const [modpacks, setModpacks] = useState([]);
const [loading, setLoading] = useState(true);
- const [minecraftVersion, setMinecraftVersion] = useState(null);
- const [categoryId, setCategoryId] = useState(null);
+ const [minecraftVersion, setMinecraftVersion] = useState('');
+ const [categoryId, setCategoryId] = useState(0);
const [sortBy, setSortBy] = useState('Featured');
const [searchText, setSearchText] = useState('');
const [hasNextPage, setHasNextPage] = useState(false);
@@ -46,7 +46,7 @@ const CurseForgeModpacks = ({ setStep, setVersion, setModpack }) => {
if (error) {
setError(false);
}
- data = await getSearch(
+ data = await getCurseForgeSearch(
'modpacks',
searchText,
40,
@@ -78,10 +78,10 @@ const CurseForgeModpacks = ({ setStep, setVersion, setModpack }) => {
- All Versions
+ All Versions
{(mcVersions || [])
.filter(v => v?.type === 'release')
.map(v => (
@@ -93,10 +93,10 @@ const CurseForgeModpacks = ({ setStep, setVersion, setModpack }) => {
-
+
All Categories
{(categories || [])
diff --git a/src/common/modals/AddInstance/FTBModpacks/index.js b/src/common/modals/AddInstance/FTBModpacks/index.js
index 65a27b0..aeb2914 100644
--- a/src/common/modals/AddInstance/FTBModpacks/index.js
+++ b/src/common/modals/AddInstance/FTBModpacks/index.js
@@ -32,6 +32,7 @@ const FTBModpacks = ({ setStep, setModpack, setVersion }) => {
} else {
data = await getFTBSearch(searchText);
}
+
setModpackIds(data.packs || []);
updateModpacks();
};
@@ -81,7 +82,6 @@ const FTBModpacks = ({ setStep, setModpack, setVersion }) => {
setModpacks(newModpacks);
}
};
-
return (
diff --git a/src/common/modals/AddInstance/InstanceName.js b/src/common/modals/AddInstance/InstanceName.js
index d509e73..127ca90 100644
--- a/src/common/modals/AddInstance/InstanceName.js
+++ b/src/common/modals/AddInstance/InstanceName.js
@@ -24,8 +24,20 @@ import {
import { _getInstancesPath, _getTempPath } from '../../utils/selectors';
import bgImage from '../../assets/mcCube.jpg';
import { downloadFile } from '../../../app/desktop/utils/downloader';
-import { FABRIC, VANILLA, FORGE, FTB, CURSEFORGE } from '../../utils/constants';
-import { getFTBModpackVersionData } from '../../api';
+import {
+ FABRIC,
+ VANILLA,
+ FORGE,
+ FTB,
+ CURSEFORGE,
+ MODRINTH
+} from '../../utils/constants';
+import {
+ getFTBModpackVersionData,
+ getModrinthVersion,
+ getModrinthVersionManifest,
+ getModrinthVersions
+} from '../../api';
const InstanceName = ({
in: inProp,
@@ -78,15 +90,24 @@ const InstanceName = ({
const imageURL = useMemo(() => {
if (!modpack) return null;
// Curseforge
- if (!modpack.synopsis) {
- return modpack?.logo?.thumbnailUrl;
- } else {
+ if (modpack.art) {
// FTB
const image = modpack?.art?.reduce((prev, curr) => {
if (!prev || curr.size < prev.size) return curr;
return prev;
});
return image.url;
+ } else if (modpack.gallery) {
+ // Modrinth
+ return (
+ modpack.gallery?.find(img => img.featured)?.url ||
+ modpack.gallery?.at(0)?.url ||
+ modpack.icon_url ||
+ ''
+ );
+ } else {
+ // Curseforge
+ return modpack?.logo?.thumbnailUrl;
}
}, [modpack]);
@@ -103,6 +124,7 @@ const InstanceName = ({
const isCurseForgeModpack = Boolean(version?.source === CURSEFORGE);
const isFTBModpack = Boolean(modpack?.art);
+ const isModrinthModpack = Boolean(modpack?.project_type);
let manifest;
// If it's a curseforge modpack grab the manfiest and detect the loader
@@ -232,10 +254,10 @@ const InstanceName = ({
data.targets[0].name === FABRIC
? forgeModloader?.version
: convertcurseForgeToCanonical(
- forgeModloader?.version,
- mcVersion,
- forgeManifest
- ),
+ forgeModloader?.version,
+ mcVersion,
+ forgeManifest
+ ),
fileID: version?.fileID,
projectID: version?.projectID,
source: FTB,
@@ -288,6 +310,66 @@ const InstanceName = ({
ramAmount ? { javaMemory: ramAmount } : null
)
);
+ } else if (isModrinthModpack) {
+ const manifest = await getModrinthVersionManifest(
+ version?.fileID,
+ path.join(instancesPath, localInstanceName)
+ );
+
+ const mcVersion = manifest.dependencies.minecraft;
+ const dependencies = Object.keys(manifest.dependencies);
+ let loaderType;
+ let loaderVersion;
+ if (dependencies.includes('fabric-loader')) {
+ loaderType = FABRIC;
+ loaderVersion = manifest.dependencies['fabric-loader'];
+ } else if (dependencies.includes('forge')) {
+ loaderType = FORGE;
+ loaderVersion = convertcurseForgeToCanonical(
+ manifest.dependencies['forge'],
+ mcVersion,
+ forgeManifest
+ );
+ } else if (dependencies.includes('quilt-loader')) {
+ // we don't support Quilt yet, so we can't proceed with the installation
+ dispatch(closeModal());
+ throw Error('Quilt modpacks are not yet supported.');
+
+ // loaderType = QUILT;
+ // loaderVersion = manifest.dependencies['quilt-loader'];
+ }
+
+ const loader = {
+ loaderType,
+ mcVersion,
+ loaderVersion,
+ fileID: version?.fileID,
+ projectID: version?.projectID,
+ source: MODRINTH,
+ sourceName: originalMcName
+ };
+
+ if (imageURL) {
+ await downloadFile(
+ path.join(
+ instancesPath,
+ localInstanceName,
+ `background${path.extname(imageURL)}`
+ ),
+ imageURL
+ );
+ }
+
+ dispatch(
+ addToQueue(
+ localInstanceName,
+ loader,
+ manifest,
+ `background${path.extname(imageURL)}`,
+ null,
+ null
+ )
+ );
} else if (importZipPath) {
manifest = await importAddonZip(
importZipPath,
@@ -419,6 +501,12 @@ const InstanceName = ({
size="large"
placeholder={mcName}
onChange={e => setInstanceName(e.target.value)}
+ onKeyDown={e => {
+ if (e.key === 'Enter') {
+ createInstance(instanceName || mcName);
+ setClicked(true);
+ }
+ }}
css={`
opacity: ${({ state }) =>
state === 'entering' || state === 'entered'
@@ -576,7 +664,7 @@ const ModpackName = styled.span`
font-weight: bold;
font-size: 45px;
animation: ${({ state }) =>
- state === 'entering' || state === 'entered' ? ModpackNameKeyframe : null}
+ state === 'entering' || state === 'entered' ? ModpackNameKeyframe : null}
0.2s ease-in-out forwards;
box-sizing: border-box;
text-align: center;
@@ -589,7 +677,7 @@ const ModpackName = styled.span`
box-sizing: border-box;
position: absolute;
border: ${({ state }) =>
- state === 'entering' || state === 'entered' ? 4 : 0}px
+ state === 'entering' || state === 'entered' ? 4 : 0}px
solid transparent;
width: 0;
height: 0;
@@ -600,23 +688,23 @@ const ModpackName = styled.span`
border-top-color: white;
border-right-color: white;
animation: ${({ state }) =>
- state === 'entering' || state === 'entered'
- ? ModpackNameBorderKeyframe
- : null}
+ state === 'entering' || state === 'entered'
+ ? ModpackNameBorderKeyframe
+ : null}
2s infinite;
}
&::after {
bottom: 0;
right: 0;
animation: ${({ state }) =>
- state === 'entering' || state === 'entered'
- ? ModpackNameBorderKeyframe
- : null}
+ state === 'entering' || state === 'entered'
+ ? ModpackNameBorderKeyframe
+ : null}
2s 1s infinite,
${({ state }) =>
- state === 'entering' || state === 'entered'
- ? ModpackNameBorderColorKeyframe
- : null}
+ state === 'entering' || state === 'entered'
+ ? ModpackNameBorderColorKeyframe
+ : null}
2s 1s infinite;
}
`;
diff --git a/src/common/modals/AddInstance/ModrinthModpacks/ModpacksListWrapper.js b/src/common/modals/AddInstance/ModrinthModpacks/ModpacksListWrapper.js
new file mode 100644
index 0000000..bc9649f
--- /dev/null
+++ b/src/common/modals/AddInstance/ModrinthModpacks/ModpacksListWrapper.js
@@ -0,0 +1,266 @@
+import React, { forwardRef, memo, useContext, useEffect } from 'react';
+import styled, { ThemeContext } from 'styled-components';
+import { useDispatch } from 'react-redux';
+import { FixedSizeList as List } from 'react-window';
+import InfiniteLoader from 'react-window-infinite-loader';
+import ContentLoader from 'react-content-loader';
+import { transparentize } from 'polished';
+import { openModal } from '../../../reducers/modals/actions';
+import { MODRINTH } from '../../../utils/constants';
+import { getModrinthProject, getModrinthProjectVersions } from '../../../api';
+
+const selectModrinthModpack = async (
+ projectID,
+ setVersion,
+ setModpack,
+ setStep
+) => {
+ // with a bit more fiddling the `getModrinthProject` call can be removed
+ const modpack = await getModrinthProject(projectID);
+ const version = (await getModrinthProjectVersions(projectID)).sort(
+ (a, b) => Date.parse(b.date_published) - Date.parse(a.date_published)
+ )[0];
+
+ // modpack versions should only ever have 1 loader and 1 minecraft version
+ // if this is not the case, the pack was configured incorrectly and would not have worked anyway
+ const loaderType = version.loaders[0];
+ const mcVersion = version.game_versions[0];
+
+ setVersion({
+ loaderType,
+ mcVersion,
+ projectID,
+ fileID: version.id,
+ source: MODRINTH
+ });
+ setModpack(modpack);
+ setStep(1);
+};
+
+const ModpacksListWrapper = ({
+ // Are there more items to load?
+ // (This information comes from the most recent API request.)
+ hasNextPage,
+
+ // Are we currently loading a page of items?
+ // (This may be an in-flight flag in your Redux store for example.)
+ isNextPageLoading,
+
+ // Array of items loaded so far.
+ items,
+
+ height,
+
+ width,
+
+ setStep,
+
+ setModpack,
+
+ setVersion,
+ // Callback function responsible for loading the next page of items.
+ loadNextPage,
+
+ infiniteLoaderRef
+}) => {
+ const dispatch = useDispatch();
+ // If there are more items to be loaded then add an extra row to hold a loading indicator.
+ const itemCount = hasNextPage ? items.length + 1 : items.length;
+ // Only load 1 page of items at a time.
+ // Pass an empty callback to InfiniteLoader in case it asks us to load more than once.
+ const loadMoreItems = isNextPageLoading ? () => {} : loadNextPage;
+ // Every row is loaded except for our loading indicator row.
+ const isItemLoaded = index => !hasNextPage || index < items.length;
+
+ // Render an item or a loading indicator.
+ const Item = memo(({ index, style }) => {
+ const modpack = items[index];
+ if (!modpack) {
+ return (
+
+ );
+ }
+
+ const primaryImage =
+ modpack.gallery?.find(img => img.featured)?.url ||
+ modpack.gallery?.at(0)?.url ||
+ modpack.icon_url ||
+ '';
+
+ return (
+
+
+ {modpack.title}
+
+
+ {
+ selectModrinthModpack(
+ modpack.project_id,
+ setVersion,
+ setModpack,
+ setStep
+ );
+ }}
+ >
+ Download Latest
+
+ {
+ const realModpack = await getModrinthProject(modpack.project_id);
+ dispatch(
+ openModal('ModpackDescription', {
+ modpack: realModpack,
+ setStep,
+ setVersion,
+ setModpack,
+ type: MODRINTH
+ })
+ );
+ }}
+ >
+ Explore / Versions
+
+
+
+ );
+ });
+
+ const innerElementType = forwardRef(({ style, ...rest }, ref) => (
+
+ ));
+
+ return (
+ loadMoreItems()}
+ >
+ {({ onItemsRendered }) => (
+ {
+ // Manually bind ref to reset scroll
+ // eslint-disable-next-line
+ infiniteLoaderRef.current = list;
+ }}
+ >
+ {Item}
+
+ )}
+
+ );
+};
+
+export default memo(ModpacksListWrapper);
+
+const Modpack = styled.div`
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 20px;
+ padding: 0 10px;
+ font-weight: 700;
+ background: ${props => transparentize(0.2, props.theme.palette.grey[700])};
+`;
+
+const ModpackHover = styled.div`
+ position: absolute;
+ display: flex;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: ${props => transparentize(0.4, props.theme.palette.grey[900])};
+ opacity: 0;
+ padding-left: 40%;
+ will-change: opacity;
+ transition: opacity 0.1s ease-in-out, background 0.1s ease-in-out;
+ div {
+ flex: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ cursor: pointer;
+ background-color: transparent;
+ border-radius: 4px;
+ transition: background-color 0.1s ease-in-out;
+ &:hover {
+ background-color: ${props => props.theme.palette.primary.main};
+ }
+ }
+ &:hover {
+ opacity: 1;
+ }
+`;
+
+const ModpackLoader = memo(
+ ({ width, top, height, isNextPageLoading, hasNextPage, loadNextPage }) => {
+ const ContextTheme = useContext(ThemeContext);
+
+ useEffect(() => {
+ if (hasNextPage && isNextPageLoading) {
+ loadNextPage();
+ }
+ }, []);
+ return (
+
+
+
+ );
+ }
+);
diff --git a/src/common/modals/AddInstance/ModrinthModpacks/index.js b/src/common/modals/AddInstance/ModrinthModpacks/index.js
new file mode 100644
index 0000000..e87295e
--- /dev/null
+++ b/src/common/modals/AddInstance/ModrinthModpacks/index.js
@@ -0,0 +1,174 @@
+/* eslint-disable */
+import React, { useState, useEffect, useRef } from 'react';
+import styled from 'styled-components';
+import { Input } from 'antd';
+import { useDebouncedCallback } from 'use-debounce';
+import AutoSizer from 'react-virtualized-auto-sizer';
+import ModpacksListWrapper from './ModpacksListWrapper';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faBomb, faExclamationCircle } from '@fortawesome/free-solid-svg-icons';
+import {
+ getModrinthMostPlayedModpacks,
+ getModrinthSearchResults
+} from '../../../api';
+
+const ModrinthModpacks = ({ setStep, setModpack, setVersion }) => {
+ const infiniteLoaderRef = useRef(null);
+ const [modpacks, setModpacks] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [searchText, setSearchText] = useState('');
+ const [hasNextPage, setHasNextPage] = useState(false);
+ const [error, setError] = useState(false);
+
+ useEffect(() => {
+ const init = async () => {
+ updateModpacks();
+ };
+ init();
+ }, [searchText]);
+
+ const updateModpacks = useDebouncedCallback(() => {
+ if (infiniteLoaderRef?.current?.scrollToItem) {
+ infiniteLoaderRef.current.scrollToItem(0);
+ }
+ loadMoreModpacks(true);
+ }, 250);
+
+ const loadMoreModpacks = async (reset = false) => {
+ const searchResult =
+ searchText.length < 3
+ ? await getModrinthMostPlayedModpacks()
+ : await getModrinthSearchResults(searchText, 'MODPACK');
+
+ if (!searchResult || modpacks.length == searchResult.total_hits) return;
+
+ setLoading(true);
+
+ if (reset) {
+ setModpacks([]);
+ setHasNextPage(false);
+ }
+ let data = null;
+ try {
+ setError(false);
+
+ const offset = reset ? 0 : modpacks.length || 0;
+ data =
+ searchText.length < 3
+ ? await getModrinthMostPlayedModpacks(offset)
+ : await getModrinthSearchResults(
+ searchText,
+ 'MODPACK',
+ null,
+ [],
+ null,
+ offset
+ );
+ } catch (err) {
+ setError(err);
+ return;
+ }
+
+ const newModpacks = reset ? data.hits : [...modpacks, ...data.hits];
+
+ setHasNextPage(newModpacks.length < searchResult.total_hits);
+ setModpacks(newModpacks);
+
+ setLoading(false);
+ };
+
+ return (
+
+
+ setSearchText(e.target.value)}
+ style={{ width: 200 }}
+ />
+
+
+ {!error ? (
+ !loading && modpacks.length == 0 ? (
+
+
+
+ No modpack has been found with the current filters.
+
+
+ ) : (
+
+ {({ height, width }) => (
+
+ )}
+
+ )
+ ) : (
+
+
+
+ An error occurred while loading the modpacks list...
+
+
+ )}
+
+
+ );
+};
+
+export default React.memo(ModrinthModpacks);
+
+const Container = styled.div`
+ width: 100%;
+ height: 100%;
+`;
+
+const StyledInput = styled(Input.Search)``;
+
+const HeaderContainer = styled.div`
+ display: flex;
+ justify-content: center;
+`;
+
+const ModpacksContainer = styled.div`
+ height: calc(100% - 15px);
+ overflow: hidden;
+ padding: 10px 0;
+`;
diff --git a/src/common/modals/CurseForgeModsBrowser.js b/src/common/modals/CurseForgeModsBrowser.js
new file mode 100644
index 0000000..be4a5db
--- /dev/null
+++ b/src/common/modals/CurseForgeModsBrowser.js
@@ -0,0 +1,597 @@
+/* eslint-disable no-nested-ternary */
+import React, {
+ memo,
+ useEffect,
+ useState,
+ forwardRef,
+ useContext
+} from 'react';
+import { ipcRenderer } from 'electron';
+import AutoSizer from 'react-virtualized-auto-sizer';
+import styled, { ThemeContext } from 'styled-components';
+import memoize from 'memoize-one';
+import InfiniteLoader from 'react-window-infinite-loader';
+import ContentLoader from 'react-content-loader';
+import { Input, Select, Button } from 'antd';
+import { useDispatch, useSelector } from 'react-redux';
+import { useDebouncedCallback } from 'use-debounce';
+import { FixedSizeList as List } from 'react-window';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faCheckCircle } from '@fortawesome/free-regular-svg-icons';
+import {
+ faBomb,
+ faExclamationCircle,
+ faWrench,
+ faDownload
+} from '@fortawesome/free-solid-svg-icons';
+import { getCurseForgeSearch, getAddonFiles } from '../api';
+import { openModal } from '../reducers/modals/actions';
+import { _getInstance } from '../utils/selectors';
+import { installMod } from '../reducers/actions';
+import { FABRIC, FORGE, CURSEFORGE } from '../utils/constants';
+import {
+ getFirstPreferredCandidate,
+ filterFabricFilesByVersion,
+ filterForgeFilesByVersion,
+ getPatchedInstanceType
+} from '../../app/desktop/utils';
+
+const RowContainer = styled.div`
+ display: flex;
+ position: relative;
+ justify-content: space-between;
+ align-items: center;
+ width: calc(100% - 30px) !important;
+ border-radius: 4px;
+ padding: 11px 21px;
+ background: ${props => props.theme.palette.grey[800]};
+ ${props =>
+ props.isInstalled &&
+ `border: 2px solid ${props.theme.palette.colors.green};`}
+`;
+
+const RowInnerContainer = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-style: normal;
+ font-weight: bold;
+ font-size: 15px;
+ line-height: 18px;
+ color: ${props => props.theme.palette.text.secondary};
+`;
+
+const RowContainerImg = styled.div`
+ width: 38px;
+ height: 38px;
+ background-repeat: no-repeat;
+ background-size: cover;
+ background-position: center;
+ border-radius: 5px;
+ margin-right: 20px;
+`;
+
+const ModInstalledIcon = styled(FontAwesomeIcon)`
+ position: absolute;
+ top: -10px;
+ left: -10px;
+ color: ${props => props.theme.palette.colors.green};
+ font-size: 25px;
+ z-index: 1;
+`;
+
+const ModsIconBg = styled.div`
+ position: absolute;
+ top: -10px;
+ left: -10px;
+ background: ${props => props.theme.palette.grey[800]};
+ width: 25px;
+ height: 25px;
+ border-radius: 50%;
+ z-index: 0;
+`;
+
+const ModsListWrapper = ({
+ // Are there more items to load?
+ // (This information comes from the most recent API request.)
+ hasNextPage,
+
+ // Are we currently loading a page of items?
+ // (This may be an in-flight flag in your Redux store for example.)
+ isNextPageLoading,
+
+ // Array of items loaded so far.
+ items,
+
+ // Callback function responsible for loading the next page of items.
+ loadNextPage,
+ searchQuery,
+ width,
+ height,
+ itemData
+}) => {
+ // If there are more items to be loaded then add an extra row to hold a loading indicator.
+ const itemCount = hasNextPage ? items.length + 3 : items.length;
+
+ // Only load 1 page of items at a time.
+ // Pass an empty callback to InfiniteLoader in case it asks us to load more than once.
+
+ // const loadMoreItems = loadNextPage;
+ const loadMoreItems = isNextPageLoading ? () => {} : loadNextPage;
+
+ // Every row is loaded except for our loading indicator row.
+ const isItemLoaded = index => !hasNextPage || index < items.length;
+
+ const innerElementType = forwardRef(({ style, ...rest }, ref) => (
+
+ ));
+
+ const Row = memo(({ index, style, data }) => {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const curseReleaseChannel = useSelector(
+ state => state.settings.curseReleaseChannel
+ );
+ const dispatch = useDispatch();
+ const { instanceName, gameVersions, installedMods, instance } = data;
+
+ const item = items[index];
+
+ const isInstalled = installedMods.find(v => v.projectID === item?.id);
+ const primaryImage = item?.logo;
+
+ if (!item) {
+ return (
+
+ );
+ }
+
+ const openModOverview = () => {
+ dispatch(
+ openModal('ModOverview', {
+ modSource: CURSEFORGE,
+ gameVersions,
+ projectID: item.id,
+ ...(isInstalled && { fileID: isInstalled.fileID }),
+ ...(isInstalled && { fileName: isInstalled.fileName }),
+ instanceName
+ })
+ );
+ };
+
+ return (
+
+ {isInstalled && }
+ {isInstalled && }
+
+
+
+ props.theme.palette.text.third};
+ &:hover {
+ color: ${props => props.theme.palette.text.primary};
+ }
+ transition: color 0.1s ease-in-out;
+ cursor: pointer;
+ `}
+ onClick={openModOverview}
+ >
+ {item.name}
+
+
+ {!isInstalled ? (
+ error || (
+
+
+
+ )
+ ) : (
+
+ )}
+
+ );
+ });
+
+ return (
+ loadMoreItems(searchQuery)}
+ threshold={20}
+ >
+ {({ onItemsRendered, ref }) => (
+
+ {Row}
+
+ )}
+
+ );
+};
+
+const createItemData = memoize(
+ (
+ items,
+ instanceName,
+ gameVersions,
+ installedMods,
+ instance,
+ isNextPageLoading
+ ) => ({
+ items,
+ instanceName,
+ gameVersions,
+ installedMods,
+ instance,
+ isNextPageLoading
+ })
+);
+
+let lastRequest;
+const CurseForgeModsBrowser = ({ instanceName, gameVersions }) => {
+ const itemsNumber = 50;
+
+ const [mods, setMods] = useState([]);
+ const [areModsLoading, setAreModsLoading] = useState(true);
+ const [filterType, setFilterType] = useState('Featured');
+ const [searchQuery, setSearchQuery] = useState('');
+ const [hasNextPage, setHasNextPage] = useState(false);
+ const [categoryId, setCategoryId] = useState(null);
+ const [error, setError] = useState(false);
+ const instance = useSelector(state => _getInstance(state)(instanceName));
+ const categories = useSelector(state => state.app.curseforgeCategories);
+
+ const installedMods = instance?.mods;
+
+ const loadMoreModsDebounced = useDebouncedCallback(
+ (s, reset) => {
+ loadMoreMods(s, reset);
+ },
+ 500,
+ { leading: false, trailing: true }
+ );
+
+ useEffect(() => {
+ loadMoreMods(searchQuery, true);
+ }, [filterType, categoryId]);
+
+ useEffect(() => {
+ loadMoreMods();
+ }, []);
+
+ const loadMoreMods = async (searchP = '', reset) => {
+ const reqObj = {};
+ lastRequest = reqObj;
+ if (!areModsLoading) {
+ setAreModsLoading(true);
+ }
+
+ const isReset = reset !== undefined ? reset : false;
+ let data = null;
+ try {
+ if (error) {
+ setError(false);
+ }
+ data = await getCurseForgeSearch(
+ 'mods',
+ searchP,
+ itemsNumber,
+ isReset ? 0 : mods.length,
+ filterType,
+ filterType !== 'Author' && filterType !== 'Name',
+ gameVersions,
+ categoryId,
+ getPatchedInstanceType(instance)
+ );
+ } catch (err) {
+ console.error(err);
+ setError(err);
+ }
+
+ const newMods = reset ? data : mods.concat(data);
+ if (lastRequest === reqObj) {
+ setAreModsLoading(false);
+ setMods(newMods || []);
+ setHasNextPage((newMods || []).length % itemsNumber === 0);
+ }
+ };
+
+ const itemData = createItemData(
+ mods,
+ instanceName,
+ gameVersions,
+ installedMods,
+ instance,
+ areModsLoading
+ );
+
+ return (
+
+
+
+ {!error ? (
+ !areModsLoading && mods.length === 0 ? (
+
+
+
+ No mods has been found with the current filters.
+
+
+ ) : (
+
+ {({ height, width }) => (
+
+ )}
+
+ )
+ ) : (
+
+
+
+ An error occurred while loading the mods list...
+
+
+ )}
+
+ );
+};
+
+export default memo(CurseForgeModsBrowser);
+
+const ModsLoader = memo(
+ ({ width, top, isNextPageLoading, hasNextPage, loadNextPage }) => {
+ const ContextTheme = useContext(ThemeContext);
+
+ useEffect(() => {
+ if (hasNextPage && isNextPageLoading) {
+ loadNextPage();
+ }
+ }, []);
+
+ return (
+
+
+
+ );
+ }
+);
+
+const Container = styled.div`
+ height: 100%;
+ width: 100%;
+`;
+
+const Header = styled.div`
+ width: 100%;
+ height: 50px;
+ display: flex;
+ flex-direction: row;
+ justify-content: space-around;
+ align-items: center;
+`;
diff --git a/src/common/modals/InstanceDeleteConfirmation.js b/src/common/modals/InstanceDeleteConfirmation.js
index 3351c05..9bc9974 100644
--- a/src/common/modals/InstanceDeleteConfirmation.js
+++ b/src/common/modals/InstanceDeleteConfirmation.js
@@ -53,7 +53,7 @@ const InstanceDeleteConfirmation = ({ instanceName }) => {
data you have in this instance
setTimeout(resolve, 1000));
- await rollBackInstanceZip(
- isUpdate,
- instancesPath,
- instanceName,
- tempPath,
- dispatch,
- updateInstanceConfig
- );
+ // if instanceName is ever empty, this will delete ALL instances, so don't run it if we don't have a name
+ if (instanceName) {
+ await rollBackInstanceZip(
+ isUpdate,
+ instancesPath,
+ instanceName,
+ tempPath,
+ dispatch,
+ updateInstanceConfig
+ );
+ }
setLoading(false);
dispatch(addNextInstanceToCurrentDownload());
diff --git a/src/common/modals/InstanceManager/Modpack.js b/src/common/modals/InstanceManager/Modpack.js
index 378b71b..4c15353 100644
--- a/src/common/modals/InstanceManager/Modpack.js
+++ b/src/common/modals/InstanceManager/Modpack.js
@@ -4,19 +4,23 @@ import { Select, Button } from 'antd';
import { useDispatch, useSelector } from 'react-redux';
import ReactHtmlParser from 'react-html-parser';
import path from 'path';
+import pMap from 'p-map';
import {
getAddonFiles,
getAddonFileChangelog,
getFTBModpackData,
getFTBChangelog,
- getFTBModpackVersionData
+ getFTBModpackVersionData,
+ getModrinthProject,
+ getModrinthVersions
} from '../../api';
import { changeModpackVersion } from '../../reducers/actions';
import { closeModal } from '../../reducers/modals/actions';
import { _getInstancesPath, _getTempPath } from '../../utils/selectors';
import { makeInstanceRestorePoint } from '../../utils';
+import { CURSEFORGE, FTB, MODRINTH } from '../../utils/constants';
-const Modpack = ({ modpackId, instanceName, manifest, fileID }) => {
+const Modpack = ({ modpackId, instanceName, source, manifest, fileID }) => {
const [files, setFiles] = useState([]);
const [versionName, setVersionName] = useState(null);
const [selectedIndex, setSelectedIndex] = useState(null);
@@ -37,51 +41,99 @@ const Modpack = ({ modpackId, instanceName, manifest, fileID }) => {
}
};
+ const convertModrinthReleaseType = type => {
+ switch (type) {
+ case 'release':
+ return 1;
+ case 'beta':
+ return 2;
+ default:
+ return 3;
+ }
+ };
+
const initData = async () => {
setLoading(true);
- if (manifest) {
- setVersionName(`${manifest?.name} - ${manifest?.version}`);
- const data = await getAddonFiles(modpackId);
- const mappedFiles = await Promise.all(
- data.map(async v => {
- const changelog = await getAddonFileChangelog(modpackId, v.id);
- return {
- ...v,
- changelog
- };
- })
- );
- setFiles(mappedFiles);
- } else {
- const ftbModpack = await getFTBModpackData(modpackId);
- setVersionName(
- `${ftbModpack.name} - ${
- ftbModpack.versions.find(modpack => modpack.id === fileID).name
- }`
- );
+ switch (source) {
+ case CURSEFORGE: {
+ setVersionName(`${manifest?.name} - ${manifest?.version}`);
+ const data = await getAddonFiles(modpackId);
+ const mappedFiles = await Promise.all(
+ data.map(async v => {
+ const changelog = await getAddonFileChangelog(modpackId, v.id);
+ return {
+ ...v,
+ changelog
+ };
+ })
+ );
+ setFiles(mappedFiles);
+ break;
+ }
+ case FTB: {
+ const ftbModpack = await getFTBModpackData(modpackId);
+ setVersionName(
+ `${ftbModpack.name} - ${
+ ftbModpack.versions.find(modpack => modpack.id === fileID).name
+ }`
+ );
- const mappedVersions = await Promise.all(
- ftbModpack.versions.map(async version => {
- const changelog = await getFTBChangelog(modpackId, version.id);
- const newModpack = await getFTBModpackVersionData(
- modpackId,
- version.id
- );
+ const mappedVersions = await Promise.all(
+ ftbModpack.versions.map(async version => {
+ const changelog = await getFTBChangelog(modpackId, version.id);
+ const newModpack = await getFTBModpackVersionData(
+ modpackId,
+ version.id
+ );
+ return {
+ displayName: `${ftbModpack.name} ${version.name}`,
+ id: version.id,
+ gameVersions: [newModpack.targets[1]?.version],
+ releaseType: convertFtbReleaseType(version.type),
+ fileDate: version.updated * 1000,
+ imageUrl: ftbModpack.art[0].url,
+ changelog: changelog.content
+ };
+ })
+ );
+
+ setFiles(mappedVersions);
+ break;
+ }
+ case MODRINTH: {
+ const modpack = await getModrinthProject(modpackId);
+ const versions = (await getModrinthVersions(modpack.versions)).sort(
+ (a, b) => Date.parse(b.date_published) - Date.parse(a.date_published)
+ );
+ setVersionName(
+ `${modpack.name} - ${
+ versions.find(version => version.id === fileID).name
+ }`
+ );
+ const mappedVersions = await pMap(versions, async version => {
return {
- displayName: `${ftbModpack.name} ${version.name}`,
+ displayName: `${modpack.name} ${version.name}`,
id: version.id,
- gameVersions: [newModpack.targets[1]?.version],
- releaseType: convertFtbReleaseType(version.type),
- fileDate: version.updated * 1000,
- imageUrl: ftbModpack.art[0].url,
- changelog: changelog.content
+ gameVersions: version.game_versions,
+ releaseType: convertModrinthReleaseType(version.version_type),
+ fileDate: Date.parse(version.date_published),
+ imageUrl:
+ modpack.gallery?.find(img => img.featured)?.url ||
+ modpack.gallery?.at(0)?.url ||
+ modpack.icon_url ||
+ '',
+ changelog: version.changelog
};
- })
- );
+ });
- setFiles(mappedVersions);
+ setFiles(mappedVersions || []);
+ break;
+ }
+ default: {
+ throw Error(`Unknown modpack source: ${source}`);
+ }
}
setLoading(false);
};
@@ -147,7 +199,7 @@ const Modpack = ({ modpackId, instanceName, manifest, fileID }) => {
disabled={loading}
virtual={false}
>
- {(files || []).map((file, index) => (
+ {files.map((file, index) => (
{
{
- if (!item.fileID) return;
- dispatch(
- openModal('ModOverview', {
- projectID: item.projectID,
- fileID: item.fileID,
- fileName: item.fileName,
- gameVersions,
- instanceName
- })
- );
+ if (item.fileID) {
+ dispatch(
+ openModal('ModOverview', {
+ modSource: item.modSource,
+ projectID: item.projectID,
+ fileID: item.fileID,
+ fileName: item.fileName,
+ gameVersions,
+ instanceName
+ })
+ );
+ } else {
+ console.error(
+ `Mod "${name}" does not have a valid file/version ID. Cannot open Mod Overview.`
+ );
+ }
}}
className="rowCenterContent"
>
diff --git a/src/common/modals/InstanceManager/index.js b/src/common/modals/InstanceManager/index.js
index 4865582..555e6c1 100644
--- a/src/common/modals/InstanceManager/index.js
+++ b/src/common/modals/InstanceManager/index.js
@@ -424,6 +424,7 @@ const InstanceManager = ({ instanceName }) => {
modpackId={instance?.loader?.projectID}
fileID={instance?.loader?.fileID}
background={background}
+ source={instance?.loader?.source}
manifest={manifest}
/>
diff --git a/src/common/modals/ModChangelog.js b/src/common/modals/ModChangelog.js
index 9d103d5..b4745b8 100644
--- a/src/common/modals/ModChangelog.js
+++ b/src/common/modals/ModChangelog.js
@@ -5,10 +5,15 @@ import ReactHtmlParser from 'react-html-parser';
import { Select } from 'antd';
import ReactMarkdown from 'react-markdown';
import Modal from '../components/Modal';
-import { getAddonFileChangelog, getFTBChangelog } from '../api';
+import {
+ getAddonFileChangelog,
+ getFTBChangelog,
+ getModrinthVersionChangelog
+} from '../api';
+import { CURSEFORGE, FTB, MODRINTH } from '../utils/constants';
let latest = {};
-const ModChangelog = ({ modpackId, files, type, modpackName }) => {
+const ModChangelog = ({ projectID, files, type, projectName }) => {
const [changelog, setChangelog] = useState(null);
const [loading, setLoading] = useState(false);
const [selectedId, setSelectedId] = useState(null);
@@ -19,10 +24,18 @@ const ModChangelog = ({ modpackId, files, type, modpackName }) => {
setLoading(true);
let data;
try {
- if (type === 'ftb') {
- data = await getFTBChangelog(modpackId, id);
- } else {
- data = await getAddonFileChangelog(modpackId, id);
+ switch (type) {
+ case FTB:
+ data = await getFTBChangelog(projectID, id);
+ break;
+ case CURSEFORGE:
+ data = await getAddonFileChangelog(projectID, id);
+ break;
+ case MODRINTH:
+ data = await getModrinthVersionChangelog(id);
+ break;
+ default:
+ throw Error(`Unknown type: ${type}`);
}
} catch (err) {
console.error(err);
@@ -78,12 +91,16 @@ const ModChangelog = ({ modpackId, files, type, modpackName }) => {
{(files || []).map(v => (
- {type === 'ftb' ? `${modpackName} - ${v.name}` : v.displayName}
+ {type === 'ftb' || type === MODRINTH
+ ? `${projectName} - ${v.name}`
+ : v.displayName}
))}
@@ -96,23 +113,23 @@ const ModChangelog = ({ modpackId, files, type, modpackName }) => {
margin-bottom: 40px;
`}
>
- {type === 'ftb'
- ? `${modpackName} - ${
+ {type === 'ftb' || type === MODRINTH
+ ? `${projectName} - ${
(files || []).find(v => v.id === selectedId)?.name
}`
: (files || []).find(v => v.id === selectedId)?.displayName}
- {type === 'ftb' ? (
+ {type === CURSEFORGE ? (
+ ReactHtmlParser(changelog)
+ ) : (
- {changelog.content}
+ {changelog.content || changelog}
- ) : (
- ReactHtmlParser(changelog)
)}
>
) : (
diff --git a/src/common/modals/ModOverview.js b/src/common/modals/ModOverview.js
index 228b0b9..cf41c4d 100644
--- a/src/common/modals/ModOverview.js
+++ b/src/common/modals/ModOverview.js
@@ -3,19 +3,33 @@ import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import { useDispatch, useSelector } from 'react-redux';
import ReactHtmlParser from 'react-html-parser';
+import ReactMarkdown from 'react-markdown';
import path from 'path';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExternalLinkAlt, faInfo } from '@fortawesome/free-solid-svg-icons';
import { Button, Select } from 'antd';
import Modal from '../components/Modal';
import { transparentize } from 'polished';
-import { getAddonDescription, getAddonFiles, getAddon } from '../api';
+import {
+ getAddonDescription,
+ getAddonFiles,
+ getAddon,
+ getModrinthProject,
+ getModrinthVersions,
+ getModrinthUser
+} from '../api';
import CloseButton from '../components/CloseButton';
import { closeModal, openModal } from '../reducers/modals/actions';
import { installMod, updateInstanceConfig } from '../reducers/actions';
import { remove } from 'fs-extra';
import { _getInstancesPath, _getInstance } from '../utils/selectors';
-import { FABRIC, FORGE, CURSEFORGE_URL } from '../utils/constants';
+import {
+ FABRIC,
+ FORGE,
+ CURSEFORGE_URL,
+ CURSEFORGE,
+ MODRINTH
+} from '../utils/constants';
import { formatNumber, formatDate } from '../utils';
import {
filterFabricFilesByVersion,
@@ -24,6 +38,7 @@ import {
} from '../../app/desktop/utils';
const ModOverview = ({
+ modSource,
projectID,
fileID,
gameVersions,
@@ -32,7 +47,15 @@ const ModOverview = ({
}) => {
const dispatch = useDispatch();
const [description, setDescription] = useState(null);
+ // curseforge only
const [addon, setAddon] = useState(null);
+ // modrinth only
+ const [mod, setMod] = useState(null);
+ const [author, setAuthor] = useState('');
+ const [downloadCount, setDownloadCount] = useState(0);
+ const [updatedDate, setUpdatedDate] = useState(0);
+ const [gameVersion, setGameVersion] = useState('');
+ const [url, setUrl] = useState('');
const [files, setFiles] = useState([]);
const [selectedItem, setSelectedItem] = useState(fileID);
const [installedData, setInstalledData] = useState({ fileID, fileName });
@@ -44,32 +67,60 @@ const ModOverview = ({
useEffect(() => {
const init = async () => {
setLoadingFiles(true);
- await Promise.all([
- getAddon(projectID).then(data => setAddon(data)),
- getAddonDescription(projectID).then(data => {
- // Replace the beginning of all relative URLs with the Curseforge URL
- const modifiedData = data.replace(
- /href="(?!http)/g,
- `href="${CURSEFORGE_URL}`
- );
- setDescription(modifiedData);
- }),
- getAddonFiles(projectID).then(async data => {
- const isFabric =
- getPatchedInstanceType(instance) === FABRIC && projectID !== 361988;
- const isForge =
- getPatchedInstanceType(instance) === FORGE || projectID === 361988;
- let filteredFiles = [];
- if (isFabric) {
- filteredFiles = filterFabricFilesByVersion(data, gameVersions);
- } else if (isForge) {
- filteredFiles = filterForgeFilesByVersion(data, gameVersions);
- }
+ if (modSource === CURSEFORGE) {
+ await Promise.all([
+ getAddon(projectID).then(addon => {
+ setAddon(addon);
+ setAuthor(addon.author || addon.authors?.at(0).name);
+ setDownloadCount(addon.downloadCount);
+ setUpdatedDate(Date.parse(addon.dateModified));
+ setGameVersion(addon.latestFilesIndexes[0].gameVersion);
+ setUrl(addon.links?.websiteUrl);
+ }),
+ getAddonDescription(projectID).then(data => {
+ // Replace the beginning of all relative URLs with the Curseforge URL
+ const modifiedData = data.replace(
+ /href="(?!http)/g,
+ `href="${CURSEFORGE_URL}`
+ );
+ setDescription(modifiedData);
+ }),
+ getAddonFiles(projectID).then(async data => {
+ const isFabric =
+ getPatchedInstanceType(instance) === FABRIC &&
+ projectID !== 361988;
+ const isForge =
+ getPatchedInstanceType(instance) === FORGE ||
+ projectID === 361988;
+ let filteredFiles = [];
+ if (isFabric) {
+ filteredFiles = filterFabricFilesByVersion(data, gameVersions);
+ } else if (isForge) {
+ filteredFiles = filterForgeFilesByVersion(data, gameVersions);
+ }
- setFiles(filteredFiles);
- setLoadingFiles(false);
- })
- ]);
+ setFiles(filteredFiles);
+ setLoadingFiles(false);
+ })
+ ]);
+ } else if (modSource === MODRINTH) {
+ const project = await getModrinthProject(projectID);
+ setMod(project);
+
+ setDescription(project.body);
+ const versions = (await getModrinthVersions(project.versions)).sort(
+ (a, b) => Date.parse(b.date_published) - Date.parse(a.date_published)
+ );
+ setFiles(versions);
+ setLoadingFiles(false);
+ getModrinthUser(versions[0].author_id).then(user => {
+ setAuthor(user.username);
+ });
+ setDownloadCount(project.downloads);
+ setUpdatedDate(Date.parse(project.updated));
+ setGameVersion(versions[0].game_versions[0]);
+ setUrl(`https://modrinth.com/mod/${project.slug}`);
+ }
};
init();
@@ -86,8 +137,11 @@ const ModOverview = ({
};
const getReleaseType = id => {
+ if (typeof id === 'string' || id instanceof String) id = id.toUpperCase();
+
switch (id) {
case 1:
+ case 'RELEASE':
return (
);
case 2:
+ case 'BETA':
return (
);
case 3:
- default:
+ case 'ALPHA':
return (
);
+ default:
+ return (
+ props.theme.palette.colors.red};
+ `}
+ >
+ [Unknown]
+
+ );
}
};
const handleChange = value => setSelectedItem(JSON.parse(value));
- const primaryImage = addon?.logo;
+ const primaryImage = addon?.logo || mod?.icon_url;
return (
dispatch(closeModal())} />
-
+
- {addon?.name}
+ {addon?.name || mod?.name}
- {addon?.authors[0].name}
+ {author}
- {addon?.downloadCount && (
+ {downloadCount && (
- {formatNumber(addon?.downloadCount)}
+ {formatNumber(downloadCount)}
)}
- {' '}
- {formatDate(addon?.dateModified)}
+ {formatDate(updatedDate)}
@@ -162,7 +226,7 @@ const ModOverview = ({
- {ReactHtmlParser(description)}
+
+ {modSource === CURSEFORGE ? (
+ ReactHtmlParser(description)
+ ) : (
+ {description}
+ )}
+
)}
{(files || []).map(file => (
@@ -253,7 +324,7 @@ const ModOverview = ({
align-items: center;
`}
>
- {file.displayName}
+ {file.displayName || file.name}
-
{gameVersions}
-
{getReleaseType(file.releaseType)}
+
+ {modSource === CURSEFORGE
+ ? gameVersions
+ : file.game_versions[0]}
+
+
+ {getReleaseType(file.releaseType || file.version_type)}
+
- {new Date(file.fileDate).toLocaleDateString(undefined, {
+ {new Date(
+ file.fileDate || file.date_published
+ ).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric'
diff --git a/src/common/modals/ModpackDescription.js b/src/common/modals/ModpackDescription.js
index 7886c5b..4a88efc 100644
--- a/src/common/modals/ModpackDescription.js
+++ b/src/common/modals/ModpackDescription.js
@@ -10,10 +10,22 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Checkbox, TextField, Cascader, Button, Input, Select } from 'antd';
import Modal from '../components/Modal';
import { transparentize } from 'polished';
-import { getAddonDescription, getAddonFiles } from '../api';
+import {
+ getAddonDescription,
+ getAddonFiles,
+ getModrinthVersions,
+ getModrinthUser
+} from '../api';
import CloseButton from '../components/CloseButton';
import { closeModal, openModal } from '../reducers/modals/actions';
-import { FORGE, CURSEFORGE_URL, FTB_MODPACK_URL } from '../utils/constants';
+import {
+ FORGE,
+ CURSEFORGE_URL,
+ FTB_MODPACK_URL,
+ MODRINTH,
+ CURSEFORGE,
+ FTB
+} from '../utils/constants';
import { formatNumber, formatDate } from '../utils';
const ModpackDescription = ({
@@ -28,30 +40,70 @@ const ModpackDescription = ({
const [files, setFiles] = useState(null);
const [selectedId, setSelectedId] = useState(false);
const [loading, setLoading] = useState(true);
+ const [author, setAuthor] = useState('');
+ const [downloadCount, setDownloadCount] = useState(0);
+ const [updatedDate, setUpdatedDate] = useState(0);
+ const [gameVersion, setGameVersion] = useState('');
+ const [url, setUrl] = useState('');
useEffect(() => {
const init = async () => {
setLoading(true);
- if (type === 'curseforge') {
- await Promise.all([
- getAddonDescription(modpack.id).then(data => {
- // Replace the beginning of all relative URLs with the Curseforge URL
- const modifiedData = data.replace(
- /href="(?!http)/g,
- `href="${CURSEFORGE_URL}`
- );
+ switch (type) {
+ case CURSEFORGE: {
+ await Promise.all([
+ getAddonDescription(modpack.id).then(data => {
+ // Replace the beginning of all relative URLs with the Curseforge URL
+ const modifiedData = data.replace(
+ /href="(?!http)/g,
+ `href="${CURSEFORGE_URL}`
+ );
- setDescription(modifiedData);
- }),
- getAddonFiles(modpack.id).then(async data => {
- setFiles(data);
- setLoading(false);
- })
- ]);
- } else if (type === 'ftb') {
- setDescription(modpack.description);
- setFiles(modpack.versions.slice().reverse());
- setLoading(false);
+ setDescription(modifiedData);
+ }),
+ getAddonFiles(modpack.id).then(data => {
+ setFiles(data);
+ setAuthor(modpack.author || modpack.authors?.at(0).name);
+ setDownloadCount(modpack.downloadCount);
+ setUpdatedDate(Date.parse(modpack.dateModified));
+ setGameVersion(modpack.latestFilesIndexes[0].gameVersion);
+ setUrl(modpack.websiteUrl);
+ })
+ ]);
+
+ setLoading(false);
+ break;
+ }
+ case FTB: {
+ setDescription(modpack.description);
+ setFiles(modpack.versions.slice().reverse());
+ setAuthor(modpack.author || modpack.authors?.at(0).name);
+ setDownloadCount(modpack.installs);
+ setUpdatedDate(modpack.refreshed * 1000);
+ setGameVersion(modpack.tags[0]?.name || '-');
+ setUrl(parseLink(modpack.name));
+
+ setLoading(false);
+ break;
+ }
+ case MODRINTH: {
+ setDescription(modpack.body);
+ const versions = (await getModrinthVersions(modpack.versions)).sort(
+ (a, b) =>
+ Date.parse(b.date_published) - Date.parse(a.date_published)
+ );
+ setFiles(versions);
+ getModrinthUser(versions[0].author_id).then(user => {
+ setAuthor(user?.username);
+ });
+ setDownloadCount(modpack.downloads);
+ setUpdatedDate(Date.parse(modpack.updated));
+ setGameVersion(versions[0].game_versions[0]);
+ setUrl(`https://modrinth.com/modpack/${modpack.slug}`);
+
+ setLoading(false);
+ break;
+ }
}
};
init();
@@ -60,9 +112,10 @@ const ModpackDescription = ({
const handleChange = value => setSelectedId(value);
const getReleaseType = id => {
+ if (typeof id === 'string' || id instanceof String) id = id.toUpperCase();
switch (id) {
case 1:
- case 'Release':
+ case 'RELEASE':
return (
);
case 2:
- case 'Beta':
+ case 'BETA':
return (
);
case 3:
- case 'Alpha':
+ case 'ALPHA':
default:
return (
img.featured)?.url ||
+ modpack.gallery?.at(0)?.url ||
+ modpack.icon_url ||
+ ''
+ );
}
}, [modpack, type]);
@@ -140,33 +200,23 @@ const ModpackDescription = ({
- {modpack.authors[0].name}
+ {author}
- {type === 'ftb'
- ? formatNumber(modpack.installs)
- : formatNumber(modpack.downloadCount)}
+ {downloadCount}
- {type === 'ftb'
- ? formatDate(modpack.refreshed * 1000)
- : formatDate(modpack.dateModified)}
+ {formatDate(updatedDate)}
- {type === 'ftb'
- ? modpack.tags[0]?.name || '-'
- : modpack.latestFilesIndexes[0].gameVersion}
+ {gameVersion}
{type === 'ftb'
? modpack.tags[0]?.name || '-'
- : file.gameVersions[0]}
+ : type === CURSEFORGE
+ ? file.gameVersions[0]
+ : file.game_versions.sort().at(-1)}
{getReleaseType(
- type === 'ftb' ? file.type : file.releaseType
+ type === 'ftb'
+ ? file.type
+ : type === CURSEFORGE
+ ? file.releaseType
+ : file.version_type
)}
@@ -288,7 +344,11 @@ const ModpackDescription = ({
>
{new Date(
- type === 'ftb' ? file.updated * 1000 : file.fileDate
+ type === 'ftb'
+ ? file.updated * 1000
+ : type === CURSEFORGE
+ ? file.fileDate
+ : file.date_published
).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
diff --git a/src/common/modals/ModrinthModsBrowser.js b/src/common/modals/ModrinthModsBrowser.js
new file mode 100644
index 0000000..0f0870b
--- /dev/null
+++ b/src/common/modals/ModrinthModsBrowser.js
@@ -0,0 +1,589 @@
+/* eslint-disable no-nested-ternary */
+import React, {
+ memo,
+ useEffect,
+ useState,
+ forwardRef,
+ useContext
+} from 'react';
+import { ipcRenderer } from 'electron';
+import AutoSizer from 'react-virtualized-auto-sizer';
+import styled, { ThemeContext } from 'styled-components';
+import memoize from 'memoize-one';
+import InfiniteLoader from 'react-window-infinite-loader';
+import ContentLoader from 'react-content-loader';
+import { Input, Select, Button } from 'antd';
+import { useDispatch, useSelector } from 'react-redux';
+import { useDebouncedCallback } from 'use-debounce';
+import { FixedSizeList as List } from 'react-window';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faCheckCircle } from '@fortawesome/free-regular-svg-icons';
+import {
+ faBomb,
+ faExclamationCircle,
+ faWrench,
+ faDownload
+} from '@fortawesome/free-solid-svg-icons';
+import { getModrinthSearchResults, getModrinthProjectVersions } from '../api';
+import { openModal } from '../reducers/modals/actions';
+import { _getInstance } from '../utils/selectors';
+import { installModrinthMod } from '../reducers/actions';
+import { MODRINTH } from '../utils/constants';
+
+const RowContainer = styled.div`
+ display: flex;
+ position: relative;
+ justify-content: space-between;
+ align-items: center;
+ width: calc(100% - 30px) !important;
+ border-radius: 4px;
+ padding: 11px 21px;
+ background: ${props => props.theme.palette.grey[800]};
+ ${props =>
+ props.isInstalled &&
+ `border: 2px solid ${props.theme.palette.colors.green};`}
+`;
+
+const RowInnerContainer = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-style: normal;
+ font-weight: bold;
+ font-size: 15px;
+ line-height: 18px;
+ color: ${props => props.theme.palette.text.secondary};
+`;
+
+const RowContainerImg = styled.div`
+ width: 38px;
+ height: 38px;
+ background-repeat: no-repeat;
+ background-size: cover;
+ background-position: center;
+ border-radius: 5px;
+ margin-right: 20px;
+`;
+
+const ModInstalledIcon = styled(FontAwesomeIcon)`
+ position: absolute;
+ top: -10px;
+ left: -10px;
+ color: ${props => props.theme.palette.colors.green};
+ font-size: 25px;
+ z-index: 1;
+`;
+
+const ModsIconBg = styled.div`
+ position: absolute;
+ top: -10px;
+ left: -10px;
+ background: ${props => props.theme.palette.grey[800]};
+ width: 25px;
+ height: 25px;
+ border-radius: 50%;
+ z-index: 0;
+`;
+
+const ModsListWrapper = ({
+ // Are there more items to load?
+ // (This information comes from the most recent API request.)
+ hasNextPage,
+
+ // Are we currently loading a page of items?
+ // (This may be an in-flight flag in your Redux store for example.)
+ isNextPageLoading,
+
+ // Array of items loaded so far.
+ items,
+
+ // Callback function responsible for loading the next page of items.
+ loadNextPage,
+ searchQuery,
+ width,
+ height,
+ itemData
+}) => {
+ // If there are more items to be loaded then add an extra row to hold a loading indicator.
+ const itemCount = hasNextPage ? items.length + 3 : items.length;
+
+ // Only load 1 page of items at a time.
+ // Pass an empty callback to InfiniteLoader in case it asks us to load more than once.
+
+ // const loadMoreItems = loadNextPage;
+ const loadMoreItems = isNextPageLoading ? () => {} : loadNextPage;
+
+ // Every row is loaded except for our loading indicator row.
+ const isItemLoaded = index => !hasNextPage || index < items.length;
+
+ const innerElementType = forwardRef(({ style, ...rest }, ref) => (
+
+ ));
+
+ const Row = memo(({ index, style, data }) => {
+ const [loading, setLoading] = useState(false);
+ const [error] = useState(null);
+ const dispatch = useDispatch();
+
+ const { instanceName, gameVersion, installedMods } = data;
+
+ const item = items[index];
+
+ const isInstalled = installedMods.find(
+ v => v.projectID === item?.project_id
+ );
+ const iconUrl = item?.icon_url || '';
+
+ if (!item) {
+ return (
+
+ );
+ }
+
+ const openModOverview = () => {
+ dispatch(
+ openModal('ModOverview', {
+ modSource: MODRINTH,
+ gameVersion,
+ projectID: item.project_id,
+ ...(isInstalled && { fileID: isInstalled.fileID }),
+ ...(isInstalled && { fileName: isInstalled.fileName }),
+ instanceName
+ })
+ );
+ };
+
+ return (
+
+ {isInstalled && }
+ {isInstalled && }
+
+
+
+ props.theme.palette.text.third};
+ &:hover {
+ color: ${props => props.theme.palette.text.primary};
+ }
+ transition: color 0.1s ease-in-out;
+ cursor: pointer;
+ `}
+ onClick={openModOverview}
+ >
+ {item.title}
+
+
+ {!isInstalled ? (
+ error || (
+
+
+
+ )
+ ) : (
+
+ )}
+
+ );
+ });
+
+ return (
+
loadMoreItems(searchQuery)}
+ threshold={20}
+ >
+ {({ onItemsRendered, ref }) => (
+
+ {Row}
+
+ )}
+
+ );
+};
+
+const createItemData = memoize(
+ (
+ items,
+ instanceName,
+ gameVersion,
+ installedMods,
+ instance,
+ isNextPageLoading
+ ) => ({
+ items,
+ instanceName,
+ gameVersion,
+ installedMods,
+ instance,
+ isNextPageLoading
+ })
+);
+
+const ModrinthModsBrowser = ({ instanceName, gameVersion }) => {
+ const [mods, setMods] = useState([]);
+ const [areModsLoading, setAreModsLoading] = useState(true);
+ const [filterType, setFilterType] = useState('relevance');
+ const [searchQuery, setSearchQuery] = useState('');
+ const [hasNextPage, setHasNextPage] = useState(false);
+ const [categoryId, setCategoryId] = useState(undefined);
+ const [error, setError] = useState(false);
+ const instance = useSelector(state => _getInstance(state)(instanceName));
+ const categories = useSelector(state =>
+ state.app.modrinthCategories
+ .filter(cat => cat.project_type === 'mod')
+ .map(cat => {
+ return {
+ id: cat.name,
+ displayName: cat.name[0].toUpperCase() + cat.name.slice(1),
+ icon: cat.icon
+ .replace('xmlns="http://www.w3.org/2000/svg"', '')
+ .replace('