feat: mods page (#413)
This commit is contained in:
parent
9fc4409a18
commit
d7c345b24a
33 changed files with 1137 additions and 333 deletions
|
|
@ -7,6 +7,7 @@ use onelauncher_core::error::LauncherResult;
|
|||
use tauri::Runtime;
|
||||
|
||||
use crate::oneclient::bundles::BundlesManager;
|
||||
use crate::oneclient::clusters::{OnlineClusterManifest, get_data_storage_versions};
|
||||
|
||||
#[taurpc::procedures(path = "oneclient", export_to = "../frontend/src/bindings.gen.ts")]
|
||||
pub trait OneClientApi {
|
||||
|
|
@ -18,6 +19,9 @@ pub trait OneClientApi {
|
|||
|
||||
#[taurpc(alias = "getBundlesFor")]
|
||||
async fn get_bundles_for(cluster_id: ClusterId) -> LauncherResult<Vec<ModpackArchive>>;
|
||||
|
||||
#[taurpc(alias = "getVersions")]
|
||||
async fn get_versions() -> LauncherResult<OnlineClusterManifest>;
|
||||
}
|
||||
|
||||
#[taurpc::ipc_type]
|
||||
|
|
@ -72,4 +76,8 @@ impl OneClientApi for OneClientApiImpl {
|
|||
|
||||
Ok(bundles)
|
||||
}
|
||||
|
||||
async fn get_versions(self) -> LauncherResult<OnlineClusterManifest> {
|
||||
get_data_storage_versions().await
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use onelauncher_core::error::LauncherResult;
|
|||
use onelauncher_core::send_error;
|
||||
use onelauncher_core::utils::http::fetch_json;
|
||||
use reqwest::Method;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
///
|
||||
/// e.g.
|
||||
|
|
@ -15,44 +15,73 @@ use serde::Deserialize;
|
|||
/// "clusters": [
|
||||
/// {
|
||||
/// "major_version": 21,
|
||||
/// "name": "Tricky Trials",
|
||||
/// "art": "/versions/art/Tricky_Trials.png",
|
||||
/// "entries": [
|
||||
/// {
|
||||
/// "minor_version": 5,
|
||||
/// "loader": "fabric"
|
||||
/// "loader": "fabric",
|
||||
/// "tags": ["PvP", "Survival"]
|
||||
/// },
|
||||
/// {
|
||||
/// "minor_version": 5,
|
||||
/// "loader": "forge"
|
||||
/// "loader": "forge",
|
||||
/// "tags": ["PvP", "Survival"]
|
||||
/// }
|
||||
/// ]
|
||||
/// },
|
||||
/// {
|
||||
/// "major_version": 20,
|
||||
/// "name": "Trails & Tales",
|
||||
/// "art": "/versions/art/Trails_Tales.png",
|
||||
/// "entries": [
|
||||
/// {
|
||||
/// "minor_version": 5,
|
||||
/// "loader": "fabric"
|
||||
/// "loader": "fabric",
|
||||
/// "tags": ["PvP", "Survival"]
|
||||
/// }
|
||||
/// ]
|
||||
/// }
|
||||
/// ]
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Deserialize)]
|
||||
struct OnlineClusterManifest {
|
||||
#[derive(specta::Type, Deserialize, Serialize)]
|
||||
pub struct OnlineClusterManifest {
|
||||
clusters: Vec<OnlineCluster>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OnlineCluster {
|
||||
#[derive(specta::Type, Deserialize, Serialize)]
|
||||
pub struct OnlineCluster {
|
||||
major_version: u8,
|
||||
name: String,
|
||||
art: String,
|
||||
entries: Vec<OnlineClusterEntry>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OnlineClusterEntry {
|
||||
#[derive(specta::Type, Deserialize, Serialize)]
|
||||
pub struct OnlineClusterEntry {
|
||||
minor_version: u8,
|
||||
loader: GameLoader,
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn get_data_storage_versions() -> LauncherResult<OnlineClusterManifest> {
|
||||
let manifest = match fetch_json::<OnlineClusterManifest>(
|
||||
Method::GET,
|
||||
&format!("{}/versions/versions.json", crate::constants::META_URL_BASE),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
send_error!("failed to fetch clusters manifest: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
pub async fn init_clusters() -> LauncherResult<()> {
|
||||
|
|
|
|||
4
apps/oneclient/frontend/src/assets/misc/missingLogo.svg
Normal file
4
apps/oneclient/frontend/src/assets/misc/missingLogo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.1 KiB |
|
|
@ -25,9 +25,7 @@ export type DirectoryError = { type: "BaseDir"; data: string }
|
|||
|
||||
export type DiscordError = { type: "MissingClientId"; data: string } | { type: "ConnectError"; data: string }
|
||||
|
||||
export type ExternalPackage = { name: string; url: string; sha1: string; size: number; package_type: PackageType; overrides: ExternalPackageOverrides | null }
|
||||
|
||||
export type ExternalPackageOverrides = { icon: string | null; name: string | null; authors: string[] | null; description: string | null }
|
||||
export type ExternalPackage = { name: string; url: string; sha1: string; size: number; package_type: PackageType }
|
||||
|
||||
export type Filters = { game_versions: string[] | null; loaders: GameLoader[] | null; categories: PackageCategories | null; package_type: PackageType | null }
|
||||
|
||||
|
|
@ -101,7 +99,7 @@ expires: string }
|
|||
|
||||
export type ModpackArchive = { manifest: ModpackManifest; path: string; format: ModpackFormat }
|
||||
|
||||
export type ModpackFile = { enabled: boolean; kind: ModpackFileKind }
|
||||
export type ModpackFile = { enabled: boolean; kind: ModpackFileKind; overrides?: PackageOverrides | null }
|
||||
|
||||
export type ModpackFileKind = { Managed: [ManagedPackage, ManagedVersion] } | { External: ExternalPackage }
|
||||
|
||||
|
|
@ -119,6 +117,50 @@ export type MojangSkin = { id: string; state: string; url: string; variant: Skin
|
|||
|
||||
export type MowojangProfile = { id: string; username: string }
|
||||
|
||||
export type OnlineCluster = { major_version: number; name: string; art: string; entries: OnlineClusterEntry[] }
|
||||
|
||||
export type OnlineClusterEntry = { minor_version: number; loader: GameLoader; tags: string[] }
|
||||
|
||||
/**
|
||||
* e.g.
|
||||
* ```json
|
||||
* {
|
||||
* "clusters": [
|
||||
* {
|
||||
* "major_version": 21,
|
||||
* "name": "Tricky Trials",
|
||||
* "art": "/versions/art/Tricky_Trials.png",
|
||||
* "entries": [
|
||||
* {
|
||||
* "minor_version": 5,
|
||||
* "loader": "fabric",
|
||||
* "tags": ["PvP", "Survival"]
|
||||
* },
|
||||
* {
|
||||
* "minor_version": 5,
|
||||
* "loader": "forge",
|
||||
* "tags": ["PvP", "Survival"]
|
||||
* }
|
||||
* ]
|
||||
* },
|
||||
* {
|
||||
* "major_version": 20,
|
||||
* "name": "Trails & Tales",
|
||||
* "art": "/versions/art/Trails_Tales.png",
|
||||
* "entries": [
|
||||
* {
|
||||
* "minor_version": 5,
|
||||
* "loader": "fabric",
|
||||
* "tags": ["PvP", "Survival"]
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export type OnlineClusterManifest = { clusters: OnlineCluster[] }
|
||||
|
||||
export type PackageAuthor = { Team: { team_id: string; org_id: string | null } } | { Users: ManagedUser[] }
|
||||
|
||||
export type PackageCategories = { Mod: PackageModCategory[] } | { ResourcePack: PackageResourcePackCategory[] } | { Shader: PackageShaderCategory[] } | { DataPack: PackageModCategory[] } | { ModPack: PackageModPackCategory[] }
|
||||
|
|
@ -152,6 +194,8 @@ export type PackageModPackCategory = "Technology" | "Quests" | "Optimization" |
|
|||
|
||||
export type PackageModel = { hash: string; file_name: string; version_id: string; published_at: string; display_name: string; display_version: string; package_type: PackageType; provider: Provider; package_id: string; mc_versions: DbVec<string>; mc_loader: DbVec<GameLoader>; icon: Icon | null }
|
||||
|
||||
export type PackageOverrides = { icon: string | null; name: string | null; authors: string[] | null; description: string | null }
|
||||
|
||||
export type PackageReleaseType = "release" | "beta" | "alpha"
|
||||
|
||||
export type PackageResourcePackCategory = "X8" | "X16" | "X32" | "X48" | "X64" | "X128" | "X256" | "X512" | "VanillaLike" | "Utility" | "Tweaks" | "Themed" | "Simplistic" | "Realistic" | "Modded" | "Decoration" | "Cursed" | "Combat" | "Audio" | "Blocks" | "CoreShaders" | "Gui" | "Fonts" | "Equipment" | "Environment" | "Entities" | "Items" | "Locale" | "Models"
|
||||
|
|
@ -190,7 +234,7 @@ gallery: string[] }
|
|||
|
||||
export type SettingProfileModel = { name: string; java_id: number | null; res: Resolution | null; force_fullscreen: boolean | null; mem_max: number | null; launch_args: string | null; launch_env: string | null; hook_pre: string | null; hook_wrapper: string | null; hook_post: string | null; os_extra: SettingsOsExtra | null }
|
||||
|
||||
export type Settings = { global_game_settings: SettingProfileModel; allow_parallel_running_clusters: boolean; enable_gamemode: boolean; discord_enabled: boolean; seen_onboarding: boolean; max_concurrent_requests: number; settings_version: number; native_window_frame: boolean; show_tanstack_dev_tools: boolean }
|
||||
export type Settings = { global_game_settings: SettingProfileModel; allow_parallel_running_clusters: boolean; enable_gamemode: boolean; discord_enabled: boolean; seen_onboarding: boolean; mod_list_use_grid: boolean; max_concurrent_requests: number; settings_version: number; native_window_frame: boolean; show_tanstack_dev_tools: boolean }
|
||||
|
||||
export type SettingsOsExtra = Record<string, never>
|
||||
|
||||
|
|
@ -257,8 +301,17 @@ export type VersionType =
|
|||
*/
|
||||
"old_beta"
|
||||
|
||||
const ARGS_MAP = { 'oneclient':'{"getBundlesFor":["cluster_id"],"getClustersGroupedByMajor":[],"openDevTools":[]}', 'events':'{"ingress":["event"],"message":["event"],"process":["event"]}', 'core':'{"changeCape":["access_token","cape_uuid"],"getUser":["uuid"],"updateClusterById":["id","request"],"getGlobalProfile":[],"isClusterRunning":["cluster_id"],"writeSettings":["setting"],"removeCluster":["id"],"getProfileOrDefault":["name"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getGameVersions":[],"getUsers":[],"killProcess":["pid"],"getRunningProcesses":[],"removeUser":["uuid"],"getDefaultUser":["fallback"],"setDefaultUser":["uuid"],"getPackage":["provider","slug"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"getLogByName":["id","name"],"getWorlds":["id"],"changeSkin":["access_token","skin_url","skin_variant"],"getClusters":[],"getScreenshots":["id"],"createSettingsProfile":["name"],"getLogs":["id"],"readSettings":[],"fetchMinecraftProfile":["uuid"],"removeCape":["access_token"],"installModpack":["modpack","cluster_id"],"getMultiplePackages":["provider","slugs"],"launchCluster":["id","uuid"],"open":["input"],"getLoadersForVersion":["mc_version"],"getClusterById":["id"],"getRunningProcessesByClusterId":["cluster_id"],"openMsaLogin":[],"fetchLoggedInProfile":["access_token"],"createCluster":["options"],"getUsersFromAuthor":["provider","author"],"convertUsernameUUID":["username_uuid"],"searchPackages":["provider","query"],"updateClusterProfile":["name","profile"],"getPackageBody":["provider","body"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}' }
|
||||
export type Router = { 'core': { getClusters: () => Promise<ClusterModel[]>,
|
||||
const ARGS_MAP = { 'oneclient':'{"openDevTools":[],"getVersions":[],"getBundlesFor":["cluster_id"],"getClustersGroupedByMajor":[]}', 'core':'{"open":["input"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"changeSkin":["access_token","skin_url","skin_variant"],"writeSettings":["setting"],"launchCluster":["id","uuid"],"installModpack":["modpack","cluster_id"],"getUsersFromAuthor":["provider","author"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"removeCape":["access_token"],"getUser":["uuid"],"getLogs":["id"],"readSettings":[],"removeCluster":["id"],"getClusterById":["id"],"getLogByName":["id","name"],"openMsaLogin":[],"isClusterRunning":["cluster_id"],"getUsers":[],"createSettingsProfile":["name"],"getRunningProcessesByClusterId":["cluster_id"],"createCluster":["options"],"getMultiplePackages":["provider","slugs"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"getDefaultUser":["fallback"],"convertUsernameUUID":["username_uuid"],"getScreenshots":["id"],"getClusters":[],"updateClusterProfile":["name","profile"],"getLoadersForVersion":["mc_version"],"fetchLoggedInProfile":["access_token"],"fetchMinecraftProfile":["uuid"],"getProfileOrDefault":["name"],"updateClusterById":["id","request"],"getGameVersions":[],"setDefaultUser":["uuid"],"getGlobalProfile":[],"getWorlds":["id"],"getRunningProcesses":[],"removeUser":["uuid"],"killProcess":["pid"],"searchPackages":["provider","query"],"getPackage":["provider","slug"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"getPackageBody":["provider","body"],"changeCape":["access_token","cape_uuid"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'events':'{"message":["event"],"ingress":["event"],"process":["event"]}' }
|
||||
export type Router = { 'events': { ingress: (event: IngressPayload) => Promise<void>,
|
||||
message: (event: MessagePayload) => Promise<void>,
|
||||
process: (event: ProcessPayload) => Promise<void> },
|
||||
'folders': { fromCluster: (folderName: string) => Promise<string>,
|
||||
openCluster: (folderName: string) => Promise<null> },
|
||||
'oneclient': { openDevTools: () => Promise<void>,
|
||||
getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>>,
|
||||
getBundlesFor: (clusterId: number) => Promise<ModpackArchive[]>,
|
||||
getVersions: () => Promise<OnlineClusterManifest> },
|
||||
'core': { getClusters: () => Promise<ClusterModel[]>,
|
||||
getClusterById: (id: number) => Promise<ClusterModel | null>,
|
||||
removeCluster: (id: number) => Promise<null>,
|
||||
createCluster: (options: CreateCluster) => Promise<ClusterModel>,
|
||||
|
|
@ -292,6 +345,7 @@ getPackageBody: (provider: Provider, body: ManagedPackageBody) => Promise<string
|
|||
getMultiplePackages: (provider: Provider, slugs: string[]) => Promise<ManagedPackage[]>,
|
||||
getPackageVersions: (provider: Provider, slug: string, mcVersion: string | null, loader: GameLoader | null, offset: number, limit: number) => Promise<Paginated<ManagedVersion>>,
|
||||
downloadPackage: (provider: Provider, packageId: string, versionId: string, clusterId: number, skipCompatibility: boolean | null) => Promise<PackageModel>,
|
||||
downloadExternalPackage: (package: ExternalPackage, clusterId: number, force: boolean | null, skipCompatibility: boolean | null) => Promise<PackageModel | null>,
|
||||
getUsersFromAuthor: (provider: Provider, author: PackageAuthor) => Promise<ManagedUser[]>,
|
||||
installModpack: (modpack: ModpackArchive, clusterId: number) => Promise<null>,
|
||||
fetchMinecraftProfile: (uuid: string) => Promise<MojangPlayerProfile>,
|
||||
|
|
@ -301,15 +355,7 @@ changeSkin: (accessToken: string, skinUrl: string, skinVariant: SkinVariant) =>
|
|||
changeCape: (accessToken: string, capeUuid: string) => Promise<MojangFullPlayerProfile>,
|
||||
removeCape: (accessToken: string) => Promise<MojangFullPlayerProfile>,
|
||||
convertUsernameUUID: (usernameUuid: string) => Promise<MowojangProfile>,
|
||||
open: (input: string) => Promise<null> },
|
||||
'folders': { fromCluster: (folderName: string) => Promise<string>,
|
||||
openCluster: (folderName: string) => Promise<null> },
|
||||
'events': { ingress: (event: IngressPayload) => Promise<void>,
|
||||
message: (event: MessagePayload) => Promise<void>,
|
||||
process: (event: ProcessPayload) => Promise<void> },
|
||||
'oneclient': { openDevTools: () => Promise<void>,
|
||||
getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>>,
|
||||
getBundlesFor: (clusterId: number) => Promise<ModpackArchive[]> } };
|
||||
open: (input: string) => Promise<null> } };
|
||||
|
||||
|
||||
export type { InferCommandOutput }
|
||||
|
|
|
|||
27
apps/oneclient/frontend/src/components/Bundle/Bundle.tsx
Normal file
27
apps/oneclient/frontend/src/components/Bundle/Bundle.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { ClusterModel, ModpackFile } from '@/bindings.gen';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { ModCard, useModCardContext } from '.';
|
||||
|
||||
interface BundleProps {
|
||||
files: Array<ModpackFile>;
|
||||
cluster: ClusterModel;
|
||||
}
|
||||
|
||||
export function Bundle({ files, cluster }: BundleProps) {
|
||||
const { useVerticalGridLayout } = useModCardContext();
|
||||
const { setting } = useSettings();
|
||||
const useGridLayout = setting('mod_list_use_grid');
|
||||
|
||||
return (
|
||||
<OverlayScrollbarsComponent className="bg-page-elevated rounded-lg">
|
||||
<div className={twMerge('grid gap-2 p-1', useGridLayout || useVerticalGridLayout ? 'grid-cols-3' : 'grid-cols-1', useVerticalGridLayout ? 'max-h-112' : 'max-h-148')}>
|
||||
{files.map(file => (
|
||||
<ModCard cluster={cluster} file={file} key={'Managed' in file.kind ? file.kind.Managed[0].id : file.kind.External.url} />
|
||||
))}
|
||||
<div className="h-1" />
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import type { ClusterModel, ModpackFile } from '@/bindings.gen';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { bindings } from '@/main';
|
||||
import { useCommandMut } from '@onelauncher/common';
|
||||
import { Button } from '@onelauncher/common/components';
|
||||
import { Download01Icon } from '@untitled-theme/icons-react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function DownloadModButton({ cluster, file }: { cluster: ClusterModel; file: ModpackFile }) {
|
||||
const download = useCommandMut(async () => {
|
||||
if ('Managed' in file.kind) {
|
||||
const [pkg, version] = file.kind.Managed;
|
||||
if (version.dependencies.length > 0)
|
||||
for (const dependency of version.dependencies) {
|
||||
const slug = dependency.project_id ?? '';
|
||||
const versions = await bindings.core.getPackageVersions(pkg.provider, slug, cluster.mc_version, cluster.mc_loader, 0, 1);
|
||||
await bindings.core.downloadPackage(pkg.provider, slug, versions.items[0].version_id, cluster.id, null);
|
||||
}
|
||||
|
||||
await bindings.core.downloadPackage(pkg.provider, pkg.id, version.version_id, cluster.id, null);
|
||||
}
|
||||
else {
|
||||
await bindings.core.downloadExternalPackage(file.kind.External, cluster.id, null, null);
|
||||
}
|
||||
});
|
||||
|
||||
const handleDownload = () => {
|
||||
(async () => {
|
||||
await download.mutateAsync();
|
||||
})();
|
||||
};
|
||||
|
||||
const { setting } = useSettings();
|
||||
const useGridLayout = setting('mod_list_use_grid');
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={twMerge('flex flex-col items-center justify-center', useGridLayout ? 'w-full' : '')}
|
||||
color="primary"
|
||||
onClick={handleDownload}
|
||||
size={useGridLayout ? 'large' : 'iconLarge'}
|
||||
>
|
||||
<Download01Icon />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
157
apps/oneclient/frontend/src/components/Bundle/ModCard.tsx
Normal file
157
apps/oneclient/frontend/src/components/Bundle/ModCard.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import type { ClusterModel, ModpackFile, ModpackFileKind } from '@/bindings.gen';
|
||||
import MissingLogo from '@/assets/misc/missingLogo.svg';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { bindings } from '@/main';
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { DownloadModButton, ModTag } from '.';
|
||||
|
||||
export interface ModInfo {
|
||||
name: string;
|
||||
description: string | null;
|
||||
author: string | null;
|
||||
iconURL: string | null;
|
||||
url: string | null;
|
||||
managed: boolean;
|
||||
packageSlug: string | null;
|
||||
}
|
||||
|
||||
async function getModAuthor(kind: ModpackFileKind, useVerticalGridLayout?: boolean): Promise<string | null> {
|
||||
if ('External' in kind)
|
||||
return null;
|
||||
if (!('Managed' in kind))
|
||||
return null;
|
||||
|
||||
const authors = await bindings.core.getUsersFromAuthor(kind.Managed[0].provider, kind.Managed[0].author);
|
||||
return parseAuthors(authors.map(author => author.username), useVerticalGridLayout);
|
||||
}
|
||||
|
||||
function parseAuthors(usernames: Array<string>, useVerticalGridLayout?: boolean): string | null {
|
||||
if (usernames.length === 0)
|
||||
return null;
|
||||
|
||||
if (useVerticalGridLayout) {
|
||||
let visibleCount = usernames.length;
|
||||
while (visibleCount >= 1) {
|
||||
const hiddenUsers = usernames.length - visibleCount;
|
||||
let string = usernames.slice(0, visibleCount).join(', ');
|
||||
if (hiddenUsers > 0)
|
||||
string += ` and ${hiddenUsers} more`;
|
||||
if (string.length <= 21)
|
||||
return string;
|
||||
visibleCount--;
|
||||
}
|
||||
return `${usernames[0]} and ${usernames.length - 1} more`;
|
||||
}
|
||||
return usernames.join(', ');
|
||||
}
|
||||
|
||||
export function getModMetaDataName(file: ModpackFile): string {
|
||||
if ('External' in file.kind)
|
||||
return file.overrides?.name ?? file.kind.External.name.replaceAll('.jar', '');
|
||||
if ('Managed' in file.kind)
|
||||
return file.overrides?.name ?? file.kind.Managed[0].name;
|
||||
else return 'UNKNOWN';
|
||||
}
|
||||
|
||||
async function getModMetaData(file: ModpackFile, useVerticalGridLayout?: boolean): Promise<ModInfo> {
|
||||
if ('External' in file.kind)
|
||||
return {
|
||||
name: getModMetaDataName(file),
|
||||
description: file.overrides?.description ?? null,
|
||||
author: parseAuthors(file.overrides?.authors ?? [], useVerticalGridLayout),
|
||||
iconURL: file.overrides?.icon ?? null,
|
||||
url: null,
|
||||
managed: false,
|
||||
packageSlug: null,
|
||||
};
|
||||
|
||||
return {
|
||||
name: getModMetaDataName(file),
|
||||
description: file.overrides?.description ?? file.kind.Managed[0].short_desc,
|
||||
author: (file.overrides?.authors ?? []).length > 1 ? parseAuthors(file.overrides?.authors ?? []) : await getModAuthor(file.kind, useVerticalGridLayout),
|
||||
iconURL: file.overrides?.icon ?? file.kind.Managed[0].icon_url,
|
||||
url: `https://modrinth.com/project/${file.kind.Managed[0].slug}`,
|
||||
managed: true,
|
||||
packageSlug: file.kind.Managed[0].id,
|
||||
};
|
||||
}
|
||||
|
||||
interface ModCardProps {
|
||||
file: ModpackFile;
|
||||
cluster: ClusterModel;
|
||||
}
|
||||
|
||||
export type onClickOnMod = (file: ModpackFile) => void;
|
||||
export interface ModCardContextApi {
|
||||
showModDownloadButton?: boolean;
|
||||
onClickOnMod?: onClickOnMod;
|
||||
useVerticalGridLayout?: boolean;
|
||||
mods?: Array<ModpackFile>;
|
||||
}
|
||||
|
||||
export const ModCardContext = createContext<ModCardContextApi | null>(null);
|
||||
export function useModCardContext() {
|
||||
const ctx = useContext(ModCardContext);
|
||||
|
||||
if (!ctx)
|
||||
throw new Error('useModCardContext must be used within a ModCardContext.Provider');
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function ModCard({ file, cluster }: ModCardProps) {
|
||||
const { showModDownloadButton, onClickOnMod, useVerticalGridLayout, mods } = useModCardContext();
|
||||
|
||||
const [modMetadata, setModMetadata] = useState<ModInfo>({ name: 'LOADING', description: null, author: null, iconURL: null, url: null, managed: false, packageSlug: null });
|
||||
useEffect(() => {
|
||||
(async () => setModMetadata(await getModMetaData(file, useVerticalGridLayout)))();
|
||||
}, [file, useVerticalGridLayout]);
|
||||
|
||||
const [isSelected, setSelected] = useState(mods?.includes(file) ?? false);
|
||||
useEffect(() => {
|
||||
setSelected(mods?.includes(file) ?? false);
|
||||
}, [mods, file]);
|
||||
|
||||
const handleOnClick = () => {
|
||||
if (onClickOnMod)
|
||||
onClickOnMod(file);
|
||||
};
|
||||
|
||||
const { setting } = useSettings();
|
||||
let useGridLayout = setting('mod_list_use_grid');
|
||||
if (useVerticalGridLayout)
|
||||
useGridLayout = true;
|
||||
|
||||
return (
|
||||
<div className={twMerge('rounded-lg m-1 break-inside-avoid flex bg-component-bg border border-gray-100/5 p-2 gap-2 outline-2 outline-component-bg', useVerticalGridLayout && useGridLayout ? '' : 'justify-between', useGridLayout ? 'flex-col' : 'flex-row', isSelected ? 'outline-brand bg-brand/20' : '')} onClick={handleOnClick}>
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className={twMerge('flex flex-col items-center justify-center', useGridLayout ? (useVerticalGridLayout ? 'size-14' : 'size-20') : 'size-18')}>
|
||||
<div className={twMerge('rounded-lg bg-component-bg-disabled border border-gray-100/5', useGridLayout ? (useVerticalGridLayout ? 'size-14' : 'size-20') : 'size-18')}>
|
||||
<img className={twMerge('rounded-lg', modMetadata.iconURL === null ? 'hidden' : '', useGridLayout ? (useVerticalGridLayout ? 'size-14' : 'size-20') : 'size-18')} src={modMetadata.iconURL ?? MissingLogo} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={twMerge('flex flex-col', useVerticalGridLayout && useGridLayout ? 'justify-center' : '')}>
|
||||
<div className="flex flex-row flex-wrap">
|
||||
<p className={twMerge('text-fg-primary break-words', useGridLayout ? 'text-lg' : 'text-xl', useGridLayout && !useVerticalGridLayout ? 'max-w-3/5' : '')}>{modMetadata.name}</p>
|
||||
{useVerticalGridLayout !== true && <ModTag cluster={cluster} modData={modMetadata} />}
|
||||
</div>
|
||||
|
||||
<p className={twMerge(modMetadata.description === null ? 'text-fg-secondary/25' : 'text-fg-secondary', useGridLayout ? 'text-sm' : 'text-base')}>
|
||||
by
|
||||
{' '}
|
||||
<span className="font-semibold">{modMetadata.author ?? 'UNKNOWN'}</span>
|
||||
</p>
|
||||
{useVerticalGridLayout !== true && <p className={twMerge('font-normal', modMetadata.description === null ? 'text-fg-secondary/25' : 'text-fg-secondary', useGridLayout ? 'text-sm' : 'text-base')}>{modMetadata.description ?? 'No Description'}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{useVerticalGridLayout === true && modMetadata.description !== null && <p className={twMerge('font-normal text-fg-secondary', useGridLayout ? 'text-sm' : 'text-base')}>{modMetadata.description}</p>}
|
||||
|
||||
{showModDownloadButton === true && (
|
||||
<div className={twMerge('flex flex-col items-center justify-center', useGridLayout ? '' : 'pr-2')}>
|
||||
<DownloadModButton cluster={cluster} file={file} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
apps/oneclient/frontend/src/components/Bundle/ModList.tsx
Normal file
44
apps/oneclient/frontend/src/components/Bundle/ModList.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { ClusterModel, ModpackArchive } from '@/bindings.gen';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { Button, Tab, TabContent, TabList, TabPanel, Tabs } from '@onelauncher/common/components';
|
||||
import { Bundle, useModCardContext } from '.';
|
||||
|
||||
function getBundleName(name: string): string {
|
||||
return (name.match(/\[(.*?)\]/)?.[1]) ?? 'LOADING';
|
||||
}
|
||||
|
||||
interface ModListProps {
|
||||
bundles: Array<ModpackArchive>;
|
||||
cluster: ClusterModel;
|
||||
selectedTab?: string;
|
||||
onTabChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function ModList({ bundles, cluster, selectedTab, onTabChange }: ModListProps) {
|
||||
const { useVerticalGridLayout } = useModCardContext();
|
||||
const { createSetting } = useSettings();
|
||||
const [useGridLayout, setUseGrid] = createSetting('mod_list_use_grid');
|
||||
|
||||
return (
|
||||
<Tabs defaultValue={selectedTab ?? getBundleName(bundles[0].manifest.name)} onTabChange={onTabChange}>
|
||||
<TabList className="justify-between px-4 py-3" height={false}>
|
||||
<div className="flex flex-row gap-6 px-2">
|
||||
{bundles.map(bundle => <Tab key={bundle.path} value={getBundleName(bundle.manifest.name)}>{getBundleName(bundle.manifest.name)}</Tab>)}
|
||||
</div>
|
||||
{!useVerticalGridLayout && (
|
||||
<Button onPress={() => setUseGrid(!useGridLayout)}>
|
||||
Toggle {useGridLayout ? 'Row' : 'Grid'}
|
||||
</Button>
|
||||
)}
|
||||
</TabList>
|
||||
|
||||
<TabContent className={useVerticalGridLayout ? 'pt-0' : ''}>
|
||||
{bundles.map(bundleData => (
|
||||
<TabPanel className={useVerticalGridLayout ? 'max-w-192' : ''} key={getBundleName(bundleData.manifest.name)} value={getBundleName(bundleData.manifest.name)}>
|
||||
<Bundle cluster={cluster} files={bundleData.manifest.files} />
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
16
apps/oneclient/frontend/src/components/Bundle/ModTag.tsx
Normal file
16
apps/oneclient/frontend/src/components/Bundle/ModTag.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { ClusterModel } from '@/bindings.gen';
|
||||
import type { ModInfo } from '.';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function ModTag({ modData, cluster }: { modData: ModInfo; cluster: ClusterModel }) {
|
||||
const { setting } = useSettings();
|
||||
const useGridLayout = setting('mod_list_use_grid');
|
||||
|
||||
return (
|
||||
<Link className={twMerge('flex flex-row items-center justify-center px-4 rounded-full font-normal bg-component-bg border border-gray-100/5 h-8 scale-90', useGridLayout ? '-my-1' : '')} search={{ provider: 'Modrinth', packageId: modData.packageSlug ?? '', clusterId: cluster.id }} to="/app/cluster/browser/package">
|
||||
<p>{modData.managed ? 'Modrinth' : 'External'}</p>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
5
apps/oneclient/frontend/src/components/Bundle/index.ts
Normal file
5
apps/oneclient/frontend/src/components/Bundle/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from './Bundle';
|
||||
export * from './DownloadModButton';
|
||||
export * from './ModCard';
|
||||
export * from './ModList';
|
||||
export * from './ModTag';
|
||||
170
apps/oneclient/frontend/src/components/DownloadMods.tsx
Normal file
170
apps/oneclient/frontend/src/components/DownloadMods.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import type { ExternalPackage, ManagedVersionDependency, ModpackFile, Provider } from '@/bindings.gen';
|
||||
import { bindings } from '@/main';
|
||||
import { useCommandMut } from '@onelauncher/common';
|
||||
import { Button } from '@onelauncher/common/components';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useEffect, useImperativeHandle, useState } from 'react';
|
||||
import { DialogTrigger } from 'react-aria-components';
|
||||
import { getModMetaDataName } from './Bundle';
|
||||
import { Overlay } from './overlay';
|
||||
|
||||
export interface DownloadModsRef {
|
||||
openDownloadDialog: (nextPath?: string) => void;
|
||||
}
|
||||
|
||||
export interface BaseModData {
|
||||
name: string;
|
||||
clusterId: number;
|
||||
managed: boolean;
|
||||
}
|
||||
|
||||
export interface ManagedModData extends BaseModData {
|
||||
provider: Provider;
|
||||
id: string;
|
||||
versionId: string;
|
||||
dependencies: Array<ManagedVersionDependency>;
|
||||
}
|
||||
|
||||
export interface ExternalModData extends BaseModData {
|
||||
package: ExternalPackage;
|
||||
}
|
||||
|
||||
export type ModData = ManagedModData | ExternalModData;
|
||||
export type ModDataArray = Array<ModData>;
|
||||
|
||||
export function isManagedMod(mod: ModData): mod is ManagedModData {
|
||||
return mod.managed === true;
|
||||
}
|
||||
|
||||
export function DownloadMods({ modsPerCluster, ref }: { modsPerCluster: Record<string, Array<ModpackFile>>; ref: React.Ref<DownloadModsRef> }) {
|
||||
const navigate = useNavigate();
|
||||
const [isOpen, setOpen] = useState<boolean>(false);
|
||||
const [mods, setMods] = useState<ModDataArray>([]);
|
||||
const [nextPath, setNextPath] = useState<string>('/app');
|
||||
|
||||
useEffect(() => {
|
||||
const modsList: ModDataArray = [];
|
||||
for (const [clusterId, mods] of Object.entries(modsPerCluster))
|
||||
for (const mod of mods) {
|
||||
if ('External' in mod.kind)
|
||||
modsList.push({
|
||||
name: getModMetaDataName(mod),
|
||||
clusterId: Number(clusterId),
|
||||
managed: false,
|
||||
package: mod.kind.External,
|
||||
});
|
||||
|
||||
if ('Managed' in mod.kind) {
|
||||
const [pkg, version] = mod.kind.Managed;
|
||||
modsList.push({
|
||||
name: getModMetaDataName(mod),
|
||||
clusterId: Number(clusterId),
|
||||
managed: true,
|
||||
provider: pkg.provider,
|
||||
id: pkg.id,
|
||||
versionId: version.version_id,
|
||||
dependencies: version.dependencies,
|
||||
});
|
||||
}
|
||||
}
|
||||
setMods(modsList);
|
||||
}, [modsPerCluster]);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
openDownloadDialog(nextPath?: string) {
|
||||
if (mods.length !== 0) {
|
||||
setOpen(true);
|
||||
setNextPath(nextPath ?? '/app');
|
||||
}
|
||||
else {
|
||||
navigate({ to: nextPath ?? '/app' });
|
||||
}
|
||||
},
|
||||
};
|
||||
}, [mods.length, navigate]);
|
||||
|
||||
return (
|
||||
<DialogTrigger>
|
||||
<Button className="mb-4" isDisabled={mods.length === 0} onPress={() => setOpen(prev => !prev)}>Download Mods</Button>
|
||||
|
||||
<Overlay isDismissable={false} isOpen={isOpen}>
|
||||
<DownloadingMods mods={mods} nextPath={nextPath} setOpen={setOpen} />
|
||||
</Overlay>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function downloadModsParallel(items: ModDataArray, limit: number, fn: (mod: ModData, index: number) => Promise<void>) {
|
||||
let index = 0;
|
||||
const workers = Array.from({ length: limit }).fill(null).map(async () => {
|
||||
while (index < items.length) {
|
||||
const i = index++;
|
||||
await fn(items[i], i);
|
||||
}
|
||||
});
|
||||
return Promise.all(workers);
|
||||
}
|
||||
|
||||
function DownloadingMods({ mods, setOpen, nextPath }: { mods: ModDataArray; setOpen: React.Dispatch<React.SetStateAction<boolean>>; nextPath: string }) {
|
||||
const navigate = useNavigate();
|
||||
const [downloadedMods, setDownloadedMods] = useState(0);
|
||||
const [modName, setModName] = useState<string | null>(null);
|
||||
const download = useCommandMut(async (mod: ModData) => {
|
||||
if (isManagedMod(mod)) {
|
||||
if (mod.dependencies.length > 0)
|
||||
for (const dependency of mod.dependencies) {
|
||||
const cluster = await bindings.core.getClusterById(mod.clusterId);
|
||||
if (!cluster)
|
||||
continue;
|
||||
const slug = dependency.project_id ?? '';
|
||||
const versions = await bindings.core.getPackageVersions(mod.provider, slug, cluster.mc_version, cluster.mc_loader, 0, 1);
|
||||
await bindings.core.downloadPackage(mod.provider, slug, versions.items[0].version_id, cluster.id, null);
|
||||
}
|
||||
await bindings.core.downloadPackage(mod.provider, mod.id, mod.versionId, mod.clusterId, true);
|
||||
}
|
||||
else { await bindings.core.downloadExternalPackage(mod.package, mod.clusterId, null, null); }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const downloadAll = async () => {
|
||||
await downloadModsParallel(mods, 25, async (mod) => {
|
||||
setModName(mod.name);
|
||||
try {
|
||||
await download.mutateAsync(mod);
|
||||
}
|
||||
finally {
|
||||
setDownloadedMods(prev => prev + 1);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
downloadAll();
|
||||
}, [mods]);
|
||||
|
||||
useEffect(() => {
|
||||
if (downloadedMods >= mods.length) {
|
||||
setOpen(false);
|
||||
navigate({ to: nextPath });
|
||||
}
|
||||
}, [downloadedMods, mods, navigate, nextPath, setOpen]);
|
||||
|
||||
return (
|
||||
<Overlay.Dialog isDismissable={false}>
|
||||
<Overlay.Title>Downloading Mods</Overlay.Title>
|
||||
|
||||
<div className="w-full flex flex-col items-center gap-2">
|
||||
<p>Downloaded {downloadedMods} / {mods.length}</p>
|
||||
<div className="w-1/2 h-4 bg-component-bg-disabled rounded-full outline-2 outline-ghost-overlay">
|
||||
<div
|
||||
className="h-full bg-brand rounded-full transition-all duration-300"
|
||||
style={{ width: mods.length > 0 ? `${(downloadedMods / mods.length) * 100}%` : '0%' }}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{modName !== null ? <p>Downloading {modName}</p> : <></>}
|
||||
</div>
|
||||
|
||||
</Overlay.Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
export * from './AccountAvatar';
|
||||
export * from './BrowserPackageItem';
|
||||
export * from './DeleteAccountButton';
|
||||
export * from './DownloadMods';
|
||||
export * from './GameBackground';
|
||||
export * from './Loader';
|
||||
export * from './LogViewer';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import { bindings } from '@/main';
|
||||
import { useCommandSuspense } from '@onelauncher/common';
|
||||
import { Button } from '@onelauncher/common/components';
|
||||
import { useState } from 'react';
|
||||
import { ModList, useModCardContext } from '../Bundle';
|
||||
import { Overlay } from './Overlay';
|
||||
|
||||
export function BundleModListModal({ clusterId, name }: { clusterId: number; name: string }) {
|
||||
const { data: cluster } = useCommandSuspense(['getClusterById'], () => bindings.core.getClusterById(clusterId));
|
||||
const { data: bundles } = useCommandSuspense(['getBundlesFor', clusterId], () => bindings.oneclient.getBundlesFor(clusterId));
|
||||
const [tab, setSelectedTab] = useState<string>(name);
|
||||
const { mods } = useModCardContext();
|
||||
|
||||
if (!cluster)
|
||||
return <></>;
|
||||
|
||||
return (
|
||||
<Overlay.Dialog className="bg-page items-start flex flex-col">
|
||||
<Overlay.Title className="px-4">
|
||||
Select Content For
|
||||
{' '}
|
||||
<span className="text-brand">{tab}</span>
|
||||
</Overlay.Title>
|
||||
<ModList
|
||||
bundles={bundles}
|
||||
cluster={cluster}
|
||||
onTabChange={setSelectedTab}
|
||||
selectedTab={name}
|
||||
/>
|
||||
<div className="w-full flex flex-row gap-6 items-center justify-end">
|
||||
<p className="font-normal text-lg text-fg-secondary">Selected {mods?.length ?? 0} mods</p>
|
||||
<Button
|
||||
className="px-12"
|
||||
color="primary"
|
||||
size="large"
|
||||
slot="close"
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</Overlay.Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ export type OverlayProps = React.ComponentProps<typeof ModalOverlay> & {
|
|||
export function Overlay({
|
||||
className,
|
||||
children,
|
||||
isDismissable,
|
||||
...rest
|
||||
}: OverlayProps) {
|
||||
const { overlay, modal } = modalVariants();
|
||||
|
|
@ -48,7 +49,7 @@ export function Overlay({
|
|||
<Modal className={modal()}>
|
||||
{children}
|
||||
</Modal>
|
||||
<p className="fixed bottom-0 left-1/2 -translate-x-1/2 text-xs text-fg-secondary text-center py-2 pointer-events-none">Click outside to dismiss</p>
|
||||
{isDismissable ? <p className="fixed bottom-0 left-1/2 -translate-x-1/2 text-xs text-fg-secondary text-center py-2 pointer-events-none">Click outside to dismiss</p> : <></>}
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
|
|
@ -84,25 +85,31 @@ Overlay.Buttons = function OverlayButtons({
|
|||
Overlay.Dialog = function OverlayDialog({
|
||||
className,
|
||||
children,
|
||||
isDismissable,
|
||||
}: {
|
||||
className?: string | undefined;
|
||||
children?: React.ReactNode | undefined;
|
||||
isDismissable?: boolean | undefined;
|
||||
}) {
|
||||
const { dialog } = modalVariants();
|
||||
|
||||
return (
|
||||
<Dialog className={twMerge(dialog(), className)} role="dialog">
|
||||
<div className="absolute top-5.5 right-5.5">
|
||||
<Button color="ghost" size="icon" slot="close">
|
||||
<XCloseIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{isDismissable
|
||||
? (
|
||||
<div className="absolute top-5.5 right-5.5">
|
||||
<Button color="ghost" size="icon" slot="close">
|
||||
<XCloseIcon />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
: <></>}
|
||||
|
||||
{children}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
Overlay.Title = function OverlayTitle({ children }: { children: React.ReactNode }) {
|
||||
return <h2 className="text-2xl font-semibold">{children}</h2>;
|
||||
Overlay.Title = function OverlayTitle({ children, className }: { children: React.ReactNode; className?: string | undefined }) {
|
||||
return <h2 className={twMerge('text-2xl font-semibold', className)}>{children}</h2>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * from './AccountPopup';
|
||||
export * from './AddAccountModal';
|
||||
export * from './BundleModListModal';
|
||||
export * from './ImportSkinModal';
|
||||
export * from './NoAccountPopup';
|
||||
export * from './Overlay';
|
||||
|
|
|
|||
33
apps/oneclient/frontend/src/routes/app/cluster/mods.tsx
Normal file
33
apps/oneclient/frontend/src/routes/app/cluster/mods.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { ModCardContextApi } from '@/components/Bundle';
|
||||
import { ModCardContext, ModList } from '@/components/Bundle';
|
||||
import { bindings } from '@/main';
|
||||
import { useCommandSuspense } from '@onelauncher/common';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const Route = createFileRoute('/app/cluster/mods')({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { cluster } = Route.useRouteContext();
|
||||
const { data: bundles } = useCommandSuspense(['getBundlesFor', cluster.id], () => bindings.oneclient.getBundlesFor(cluster.id));
|
||||
|
||||
const context = useMemo<ModCardContextApi>(() => ({
|
||||
showModDownloadButton: true,
|
||||
}), []);
|
||||
|
||||
if (bundles.length === 0)
|
||||
return <p>No bundles found {cluster.name}</p>;
|
||||
|
||||
return (
|
||||
<OverlayScrollbarsComponent className="bg-none">
|
||||
<div className="min-h-148">
|
||||
<ModCardContext.Provider value={context}>
|
||||
<ModList bundles={bundles} cluster={cluster} />
|
||||
</ModCardContext.Provider>
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
import { SheetPage } from '@/components/SheetPage';
|
||||
import { bindings } from '@/main';
|
||||
import { useCommandSuspense } from '@onelauncher/common';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/app/cluster/overview')({
|
||||
|
|
@ -8,44 +6,14 @@ export const Route = createFileRoute('/app/cluster/overview')({
|
|||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { cluster } = Route.useRouteContext();
|
||||
|
||||
return (
|
||||
<SheetPage.Content>
|
||||
<div className="flex flex-col gap-4">
|
||||
<h1 className="text-2xl font-semibold">Cluster Overview</h1>
|
||||
<p>Welcome to the cluster overview page. Here you can find information about your clusters.</p>
|
||||
<div style={{ height: '1500px' }}>
|
||||
Cluster Name:
|
||||
{' '}
|
||||
{cluster.name}
|
||||
|
||||
<TestBundles />
|
||||
</div>
|
||||
<p>test</p>
|
||||
{/* Additional content can be added here */}
|
||||
</div>
|
||||
</SheetPage.Content>
|
||||
);
|
||||
}
|
||||
|
||||
function TestBundles() {
|
||||
const { cluster } = Route.useRouteContext();
|
||||
|
||||
const { data: bundles } = useCommandSuspense(['getBundlesFor', cluster.id], () => bindings.oneclient.getBundlesFor(cluster.id));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Bundles</h2>
|
||||
<ul>
|
||||
{bundles.map(bundle => (
|
||||
<li key={bundle.path}>
|
||||
<code>
|
||||
{JSON.stringify(bundle, null, 4)}
|
||||
</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@ import { bindings } from '@/main';
|
|||
import { prettifyLoader } from '@/utils/loaders';
|
||||
import { getVersionInfoOrDefault } from '@/utils/versionMap';
|
||||
import { Button } from '@onelauncher/common/components';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
|
||||
import { FolderIcon } from '@untitled-theme/icons-react';
|
||||
import { useMotionValueEvent, useScroll } from 'motion/react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export interface ClusterRouteSearchParams {
|
||||
clusterId: number;
|
||||
|
|
@ -63,6 +64,15 @@ function RouteComponent() {
|
|||
|
||||
const search = Route.useSearch();
|
||||
|
||||
// Prefetch data so that app/cluster/mods is fast
|
||||
const queryClient = useQueryClient();
|
||||
useEffect(() => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['getBundlesFor', cluster.id],
|
||||
queryFn: () => bindings.oneclient.getBundlesFor(cluster.id),
|
||||
});
|
||||
}, [cluster, queryClient]);
|
||||
|
||||
return (
|
||||
<SheetPage
|
||||
headerLarge={<HeaderLarge />}
|
||||
|
|
@ -71,6 +81,7 @@ function RouteComponent() {
|
|||
>
|
||||
<TabList floating={floating} ref={tabListRef}>
|
||||
<Tab from={Route.id} search={search} to="/app/cluster/overview">Overview</Tab>
|
||||
<Tab from={Route.id} search={search} to="/app/cluster/mods">Mods</Tab>
|
||||
<Tab from={Route.id} search={search} to="/app/cluster/logs">Logs</Tab>
|
||||
<Tab from={Route.id} search={{ ...search, packageType: 'mod' }} to="/app/cluster/browser">Browser</Tab>
|
||||
<Tab from={Route.id} search={search} to="/app/cluster/settings">Settings</Tab>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useSettings } from '@/hooks/useSettings';
|
|||
import { bindings } from '@/main';
|
||||
import { Button } from '@onelauncher/common/components';
|
||||
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||
import { Code02Icon, Truck01Icon } from '@untitled-theme/icons-react';
|
||||
import { BatteryFullIcon, Code02Icon, Truck01Icon } from '@untitled-theme/icons-react';
|
||||
import Sidebar from './route';
|
||||
|
||||
export const Route = createFileRoute('/app/settings/developer')({
|
||||
|
|
@ -52,6 +52,13 @@ function RouteComponent() {
|
|||
>
|
||||
<SettingsSwitch setting={createSetting('seen_onboarding')} />
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
description="Use Grid On Mods List"
|
||||
icon={<BatteryFullIcon />}
|
||||
title="Use Grid On Mods List"
|
||||
>
|
||||
<SettingsSwitch setting={createSetting('mod_list_use_grid')} />
|
||||
</SettingsRow>
|
||||
|
||||
</div>
|
||||
</Sidebar.Page>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useCommandMut, useCommandSuspense } from '@onelauncher/common';
|
|||
import { Button } from '@onelauncher/common/components';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { OnboardingNavigation } from './route';
|
||||
|
||||
export const Route = createFileRoute('/onboarding/account')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -65,37 +66,40 @@ function RouteComponent() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full px-12 gap-4">
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold mb-2">Account</h1>
|
||||
<p className="text-slate-400 text-lg mb-2">Before you continue, we require you to own a copy of Minecraft: Java Edition.</p>
|
||||
<>
|
||||
<div className="flex flex-col h-full px-12 gap-4">
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold mb-2">Account</h1>
|
||||
<p className="text-slate-400 text-lg mb-2">Before you continue, we require you to own a copy of Minecraft: Java Edition.</p>
|
||||
</div>
|
||||
{currentAccount
|
||||
? (
|
||||
<>
|
||||
<AccountPreview profile={currentAccount} />
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{profile
|
||||
? (
|
||||
<>
|
||||
<AccountPreview profile={profile} />
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
color="primary"
|
||||
isPending={isPending}
|
||||
onClick={onClick}
|
||||
size="large"
|
||||
>
|
||||
Add Account
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{currentAccount
|
||||
? (
|
||||
<>
|
||||
<AccountPreview profile={currentAccount} />
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{profile
|
||||
? (
|
||||
<>
|
||||
<AccountPreview profile={profile} />
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
color="primary"
|
||||
isPending={isPending}
|
||||
onClick={onClick}
|
||||
size="large"
|
||||
>
|
||||
Add Account
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<OnboardingNavigation disableNext={currentAccount === null} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,108 +0,0 @@
|
|||
import { Tab, TabContent, TabList, TabPanel, Tabs } from '@onelauncher/common/components';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export const Route = createFileRoute('/onboarding/preferences/mods')({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<div className="min-h-screen px-7">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-4xl font-semibold mb-2">Choose Mods</h1>
|
||||
<p className="text-slate-400 text-lg mb-2">
|
||||
Something something in corporate style fashion about picking your preferred gamemodes and versions and
|
||||
optionally loader so that oneclient can pick something for them
|
||||
</p>
|
||||
|
||||
<div className="relative">
|
||||
<Tabs defaultValue="skyblock">
|
||||
<TabList className="gap-6">
|
||||
<Tab value="skyblock">Skyblock</Tab>
|
||||
<Tab value="survival">Survival</Tab>
|
||||
<Tab value="minigames">Minigames</Tab>
|
||||
<Tab value="pvp">PVP</Tab>
|
||||
<Tab value="gui">GUI</Tab>
|
||||
<Tab value="util">Utility</Tab>
|
||||
<Tab value="misc">Misc</Tab>
|
||||
|
||||
<div className="absolute right-4">
|
||||
<div className="bg-[#D0D7F3] rounded-xl text-brand text-sm px-2 py-1">
|
||||
31 Mods selected
|
||||
</div>
|
||||
</div>
|
||||
</TabList>
|
||||
|
||||
<TabContent>
|
||||
<TabPanel value="skyblock">
|
||||
<OverlayScrollbarsComponent>
|
||||
<div className="h-96 grid grid-cols-3 gap-2">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].map(x => (
|
||||
<div className="space-y-2" key={x}>
|
||||
{x % 2 === 0 ? <ModCard2 active={x % 2 === 0} /> : <ModCard active={x % 2 === 0} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
</TabPanel>
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModCardProps {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
function ModCard(props: ModCardProps) {
|
||||
const { active } = props;
|
||||
|
||||
return (
|
||||
<div className={twMerge('p-2 rounded-lg mb-2 break-inside-avoid', active ? 'bg-brand/20 border border-brand' : 'bg-component-bg border border-gray-100/5')}>
|
||||
<div className="flex flex-row gap-2">
|
||||
<img className="rounded-xl size-16" src="https://cdn.modrinth.com/data/8pJYUDNi/790dfffda5974fccda843477cfe8ed19b1347ea3_96.webp" />
|
||||
<div className="flex flex-col">
|
||||
<p className="text-fg-primary text-xl">Chatting</p>
|
||||
<p className="text-fg-secondary">
|
||||
by
|
||||
{' '}
|
||||
{' '}
|
||||
<span className="font-semibold">Polyfrost</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-1 text-fg-secondary font-normal">
|
||||
<p>Chatting is a chat mod adding utilities such as extremely customizable chat tabs, chat shortcuts, chat screenshots, and message copying.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModCard2(props: ModCardProps) {
|
||||
const { active } = props;
|
||||
|
||||
return (
|
||||
<div className={twMerge('p-2 rounded-lg mb-2 break-inside-avoid', active ? 'bg-brand/20 border border-brand' : 'bg-component-bg border border-gray-100/5')}>
|
||||
<div className="flex flex-row gap-2">
|
||||
<img className="rounded-xl size-16" src="https://cdn.modrinth.com/data/8pJYUDNi/790dfffda5974fccda843477cfe8ed19b1347ea3_96.webp" />
|
||||
<div className="flex flex-col">
|
||||
<p className="text-fg-primary text-xl">Chatting</p>
|
||||
<p className="text-fg-secondary">
|
||||
by
|
||||
{' '}
|
||||
{' '}
|
||||
<span className="font-semibold">Polyfrost</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-1 text-fg-secondary font-normal">
|
||||
<p>Chatting is a chat mod adding utilities such as extremely customizable chat tabs, chat shortcuts, chat screenshots, and message copying. Chatting is a chat mod adding utilities such as extremely customizable chat tabs, chat shortcuts, chat screenshots, and message copying Chatting is a chat mod adding utilities such as extremely customizable chat tabs, chat shortcuts, chat screenshots, and message copying</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import type { GameLoader, OnlineCluster, OnlineClusterEntry } from '@/bindings.gen';
|
||||
import type { VersionInfo } from '@/utils/versionMap';
|
||||
import { bindings } from '@/main';
|
||||
import { getVersionInfoOrDefault } from '@/utils/versionMap';
|
||||
import { useCommandSuspense } from '@onelauncher/common';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
||||
import { useState } from 'react';
|
||||
import { Button as AriaButton } from 'react-aria-components';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { OnboardingNavigation } from '../route';
|
||||
|
||||
export interface StrippedCLuster {
|
||||
mc_version: string;
|
||||
mc_loader: GameLoader;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/onboarding/preferences/version')({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { data: versions } = useCommandSuspense(['getVersions'], () => bindings.oneclient.getVersions());
|
||||
// const { data: clusters } = useCommandSuspense(['getClusters'], () => bindings.core.getClusters());
|
||||
|
||||
const [selectedClusters, setSelectedClusters] = useState<Array<StrippedCLuster>>([]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen px-7">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<OverlayScrollbarsComponent>
|
||||
<div className="h-164">
|
||||
<h1 className="text-4xl font-semibold mb-2">Starting Versions</h1>
|
||||
<p className="text-slate-400 text-lg mb-2">
|
||||
Something something in corporate style fashion about picking your preferred gamemodes and versions and
|
||||
optionally loader so that oneclient can pick something for them
|
||||
</p>
|
||||
|
||||
<div className="bg-page-elevated p-4 rounded-xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6">
|
||||
{versions.clusters.map((cluster) => {
|
||||
const versionData = getVersionInfoOrDefault(cluster.major_version);
|
||||
return cluster.entries.map(entry => (
|
||||
<VersionCard
|
||||
cluster={cluster}
|
||||
fullVersionName={`${versionData.prettyName}.${entry.minor_version}`}
|
||||
key={`${versionData.prettyName}.${entry.minor_version}-${entry.loader}`}
|
||||
setSelectedClusters={setSelectedClusters}
|
||||
version={entry}
|
||||
versionData={versionData}
|
||||
/>
|
||||
));
|
||||
})}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OnboardingNavigation disableNext={selectedClusters.length === 0} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionCard({ cluster, versionData, version, fullVersionName, setSelectedClusters }: { cluster: OnlineCluster; versionData: VersionInfo; version: OnlineClusterEntry; fullVersionName: string; setSelectedClusters: React.Dispatch<React.SetStateAction<Array<StrippedCLuster>>> }) {
|
||||
const [isSelected, setSelected] = useState<boolean>(false);
|
||||
const toggle = () => {
|
||||
setSelected(prev => !prev);
|
||||
setSelectedClusters((prev) => {
|
||||
let updatedClusters: Array<StrippedCLuster> = [];
|
||||
const exists = prev.some(strippedCluster => strippedCluster.mc_version === fullVersionName && strippedCluster.mc_loader === version.loader);
|
||||
if (exists)
|
||||
updatedClusters = prev.filter(strippedCluster => !(strippedCluster.mc_version === fullVersionName && strippedCluster.mc_loader === version.loader));
|
||||
else
|
||||
updatedClusters = [...prev, { mc_version: fullVersionName, mc_loader: version.loader }];
|
||||
|
||||
localStorage.setItem('selectedClusters', JSON.stringify(updatedClusters));
|
||||
return updatedClusters;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AriaButton className={twMerge('group overflow-hidden cursor-pointer w-full rounded-xl transition-[outline] outline-2 hover:outline-brand', isSelected ? 'outline-brand' : 'outline-ghost-overlay')} onPress={toggle}>
|
||||
<div className="relative w-full">
|
||||
<img
|
||||
alt={`Minecraft ${versionData.prettyName} landscape`}
|
||||
className={twMerge('w-full rounded-xl h-32 object-cover transition-[filter] group-hover:brightness-100 group-hover:grayscale-0', isSelected ? 'brightness-100 grayscale-0' : 'brightness-70 grayscale-25')}
|
||||
src={`https://raw.githubusercontent.com/PolyFrost/DataStorage/refs/heads/main/oneclient${cluster.art}`}
|
||||
/>
|
||||
|
||||
<div className="absolute top-3 left-3 flex flex-wrap gap-1">
|
||||
{
|
||||
version.tags.map(tag => (
|
||||
<div className="bg-[#D0D7F3] rounded-xl text-brand text-sm px-2 py-1" key={tag}>
|
||||
{tag}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-3 left-3">
|
||||
<span className="text-white font-bold px-3 py-1 text-xl">{fullVersionName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</AriaButton>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
import type { ClusterModel, ModpackArchive, ModpackFile } from '@/bindings.gen';
|
||||
import type { DownloadModsRef } from '@/components';
|
||||
import type { ModCardContextApi, onClickOnMod } from '@/components/Bundle';
|
||||
import type { StrippedCLuster } from './version';
|
||||
import { DownloadMods } from '@/components';
|
||||
import { ModCardContext } from '@/components/Bundle';
|
||||
import { BundleModListModal, Overlay } from '@/components/overlay';
|
||||
import { bindings } from '@/main';
|
||||
import { useCommandSuspense } from '@onelauncher/common';
|
||||
import { Button } from '@onelauncher/common/components';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { DotsVerticalIcon } from '@untitled-theme/icons-react';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { Button as AriaButton } from 'react-aria-components';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { OnboardingNavigation } from '../route';
|
||||
|
||||
export const Route = createFileRoute('/onboarding/preferences/versionCategory')({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
export interface BundleData {
|
||||
bundles: Array<ModpackArchive>;
|
||||
art: string;
|
||||
modsInfo: Array<ModpackFile>;
|
||||
clusterId: number;
|
||||
}
|
||||
|
||||
function HandleCLuster(cluster: ClusterModel): Array<ModpackArchive> {
|
||||
const { data: bundle } = useCommandSuspense(['getBundlesFor', cluster.id], () => bindings.oneclient.getBundlesFor(cluster.id));
|
||||
return bundle;
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const selectedClusters: Array<StrippedCLuster> = JSON.parse(localStorage.getItem('selectedClusters') ?? '[]');
|
||||
|
||||
const { data: versions } = useCommandSuspense(['getVersions'], () => bindings.oneclient.getVersions());
|
||||
const { data: clusters } = useCommandSuspense(['getClusters'], () => bindings.core.getClusters());
|
||||
const bundleQueries = clusters.map(cluster => HandleCLuster(cluster));
|
||||
const bundlesData: Record<string, BundleData> = {};
|
||||
clusters.forEach((cluster, i) => {
|
||||
const selected = selectedClusters.some(selectedCluster => selectedCluster.mc_version === cluster.mc_version && selectedCluster.mc_loader === cluster.mc_loader);
|
||||
if (!selected)
|
||||
return;
|
||||
const version = versions.clusters.find(version => cluster.mc_version.startsWith(`1.${version.major_version}`));
|
||||
bundlesData[cluster.name] = {
|
||||
bundles: bundleQueries[i],
|
||||
art: version?.art ?? '/versions/art/Horse_Update.jpg',
|
||||
modsInfo: [],
|
||||
clusterId: cluster.id,
|
||||
};
|
||||
});
|
||||
|
||||
const [modsPerCluster, setModsPerCluster] = useState<Record<string, Array<ModpackFile>>>(
|
||||
clusters.reduce((acc, cluster) => {
|
||||
acc[cluster.id] = [];
|
||||
return acc;
|
||||
}, {} as Record<string, Array<ModpackFile>>),
|
||||
);
|
||||
|
||||
const downloadModsRef = useRef<DownloadModsRef>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen px-7">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<OverlayScrollbarsComponent>
|
||||
<div className="h-164">
|
||||
<div className="flex flex-col gap-6">
|
||||
{Object.entries(bundlesData).map(([name, bundleData]) => (
|
||||
<ModCategory
|
||||
bundleData={bundleData}
|
||||
key={name}
|
||||
modsPerCluster={modsPerCluster}
|
||||
name={name}
|
||||
setModsPerCluster={setModsPerCluster}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden">
|
||||
<DownloadMods modsPerCluster={modsPerCluster} ref={downloadModsRef} />
|
||||
</div>
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
</div>
|
||||
</div>
|
||||
<OnboardingNavigation disableNext={selectedClusters.length === 0} ref={downloadModsRef} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModCategory({ bundleData, name, modsPerCluster, setModsPerCluster }: { bundleData: BundleData; name: string; modsPerCluster: Record<string, Array<ModpackFile>>; setModsPerCluster: React.Dispatch<React.SetStateAction<Record<string, Array<ModpackFile>>>> }) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold my-2">{name}</h1>
|
||||
<div className="bg-page-elevated p-4 rounded-xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6">
|
||||
{bundleData.bundles.map(bundle => (
|
||||
<ModCategoryCard
|
||||
art={bundleData.art}
|
||||
bundle={bundle}
|
||||
clusterId={bundleData.clusterId}
|
||||
fullVersionName={bundle.manifest.name.match(/\[(.*?)\]/)?.[1] ?? 'LOADING'}
|
||||
key={bundle.manifest.name}
|
||||
mods={modsPerCluster[bundleData.clusterId]}
|
||||
setMods={newMods => setModsPerCluster(prev => ({ ...prev, [bundleData.clusterId]: typeof newMods === 'function' ? newMods(prev[bundleData.clusterId]) : newMods }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModCategoryCard({ art, fullVersionName, bundle, mods, setMods, clusterId }: { fullVersionName: string; art: string; bundle: ModpackArchive; mods: Array<ModpackFile>; setMods: React.Dispatch<React.SetStateAction<Array<ModpackFile>>>; clusterId: number }) {
|
||||
const files = bundle.manifest.files;
|
||||
const isSelected = files.every(file => mods.includes(file));
|
||||
const handleDownload = () => {
|
||||
setMods((prevMods) => {
|
||||
if (isSelected)
|
||||
return prevMods.filter(mod => !files.includes(mod));
|
||||
else
|
||||
return [...files, ...prevMods];
|
||||
});
|
||||
};
|
||||
|
||||
const onClickOnMod: onClickOnMod = (file) => {
|
||||
setMods((prevMods) => {
|
||||
if (prevMods.includes(file))
|
||||
return prevMods.filter(mod => mod !== file);
|
||||
else
|
||||
return [file, ...prevMods];
|
||||
});
|
||||
};
|
||||
|
||||
const context = useMemo<ModCardContextApi>(() => ({
|
||||
onClickOnMod,
|
||||
useVerticalGridLayout: true,
|
||||
mods,
|
||||
}), [mods]);
|
||||
|
||||
return (
|
||||
<AriaButton className={twMerge('group cursor-pointer w-full rounded-xl transition-[outline] outline-2 hover:outline-brand', isSelected ? 'outline-brand' : 'outline-ghost-overlay')} onPress={handleDownload}>
|
||||
<div className="relative w-full">
|
||||
<img
|
||||
alt={`Minecraft ${fullVersionName} landscape`}
|
||||
className={twMerge('w-full rounded-xl h-32 object-cover transition-[filter] group-hover:brightness-100 group-hover:grayscale-0', isSelected ? 'brightness-100 grayscale-0' : 'brightness-70 grayscale-25')}
|
||||
src={`https://raw.githubusercontent.com/Polyfrost/DataStorage/refs/heads/main/oneclient${art}`}
|
||||
/>
|
||||
|
||||
<div className={twMerge('absolute -top-2 right-3', isSelected ? 'block' : 'hidden group-hover:block')}>
|
||||
<div className="bg-[#D0D7F3] rounded-xl text-brand text-sm px-2 py-1">
|
||||
{files.length} Mods {isSelected ? 'Selected' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModCardContext.Provider value={context}>
|
||||
<Overlay.Trigger>
|
||||
<Button className="absolute bottom-3 right-3 p-1 transition-colors" color="ghost" size="icon">
|
||||
<DotsVerticalIcon className="w-4 h-4 text-white" />
|
||||
</Button>
|
||||
|
||||
<Overlay>
|
||||
<BundleModListModal clusterId={clusterId} name={fullVersionName} />
|
||||
</Overlay>
|
||||
</Overlay.Trigger>
|
||||
</ModCardContext.Provider>
|
||||
|
||||
<div className="absolute bottom-3 left-3">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<span className="text-white font-bold px-3 py-1 text-xl">{fullVersionName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</AriaButton>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import CavesAndCliffs from '@/assets/backgrounds/CavesAndCliffs.jpg';
|
||||
import { Button } from '@onelauncher/common/components';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { DotsVerticalIcon, PlusIcon } from '@untitled-theme/icons-react';
|
||||
|
||||
export const Route = createFileRoute('/onboarding/preferences/versions')({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
// placeholder
|
||||
const versions = [
|
||||
{
|
||||
version: '1.8.9',
|
||||
tags: ['PvP', 'Skyblock', 'Bedwars', 'UHC'],
|
||||
},
|
||||
{
|
||||
version: '1.21.4',
|
||||
tags: ['Nostalgia', 'Survival', 'UHC'],
|
||||
},
|
||||
{
|
||||
version: '1.21.4',
|
||||
tags: ['PvP', 'Minigames', 'Survival', 'UHC'],
|
||||
},
|
||||
];
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<div className="min-h-screen px-7">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-4xl font-semibold mb-2">Starting Versions</h1>
|
||||
<p className="text-slate-400 text-lg mb-2">
|
||||
Something something in corporate style fashion about picking your preferred gamemodes and versions and
|
||||
optionally loader so that oneclient can pick something for them
|
||||
</p>
|
||||
|
||||
<div className="bg-page-elevated h-full p-4 rounded-xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{versions.map(version => (
|
||||
<VersionCard
|
||||
key={version.version}
|
||||
tags={version.tags}
|
||||
version={version.version}
|
||||
/>
|
||||
))}
|
||||
<div className="border border-component-bg-hover rounded-xl">
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<PlusIcon className="text-component-bg-hover size-12" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VersionCardProps {
|
||||
version: string;
|
||||
tags: Array<string>;
|
||||
}
|
||||
|
||||
export function VersionCard({ version, tags }: VersionCardProps) {
|
||||
return (
|
||||
<div className="overflow-hidden transition-colors cursor-pointer">
|
||||
<div className="relative">
|
||||
<img
|
||||
alt={`Minecraft ${version} landscape`}
|
||||
className="w-full rounded-xl h-32 object-cover"
|
||||
src={CavesAndCliffs}
|
||||
/>
|
||||
|
||||
<div className="absolute top-3 left-3 flex flex-wrap gap-1">
|
||||
{tags.map(tag => (
|
||||
<div className="bg-[#D0D7F3] rounded-xl text-brand text-sm px-2 py-1" key={tag}>
|
||||
{tag}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button className="absolute bottom-3 right-3 p-1 transition-colors" color="ghost" size="icon">
|
||||
<DotsVerticalIcon className="w-4 h-4 text-white" />
|
||||
</Button>
|
||||
|
||||
<div className="absolute bottom-3 left-3">
|
||||
<span className="text-white font-bold px-3 py-1">{version}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import type { DownloadModsRef } from '@/components';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import LauncherLogo from '@/assets/logos/oneclient.svg?react';
|
||||
import { LoaderSuspense, NavbarButton } from '@/components';
|
||||
|
|
@ -6,10 +7,12 @@ import { Stepper } from '@/components/Stepper';
|
|||
import { bindings } from '@/main';
|
||||
import { useCommandSuspense } from '@onelauncher/common';
|
||||
import { Button } from '@onelauncher/common/components';
|
||||
import { createFileRoute, Link, Outlet, useLocation } from '@tanstack/react-router';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { createFileRoute, Link, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
|
||||
import { Window } from '@tauri-apps/api/window';
|
||||
import { MinusIcon, SquareIcon, XCloseIcon } from '@untitled-theme/icons-react';
|
||||
import { motion } from 'motion/react';
|
||||
import { useEffect } from 'react';
|
||||
import { MouseParallax } from 'react-just-parallax';
|
||||
|
||||
export const Route = createFileRoute('/onboarding')({
|
||||
|
|
@ -52,6 +55,7 @@ export interface OnboardingStep {
|
|||
path: string;
|
||||
title: string;
|
||||
subSteps?: Array<OnboardingStep>;
|
||||
hideNavigationButtons?: boolean;
|
||||
};
|
||||
|
||||
const ONBOARDING_STEPS: Array<OnboardingStep> = [
|
||||
|
|
@ -66,18 +70,21 @@ const ONBOARDING_STEPS: Array<OnboardingStep> = [
|
|||
{
|
||||
path: '/onboarding/account',
|
||||
title: 'Account',
|
||||
hideNavigationButtons: true,
|
||||
},
|
||||
{
|
||||
path: '/onboarding/preferences/',
|
||||
title: 'Preferences',
|
||||
subSteps: [
|
||||
{
|
||||
path: '/onboarding/preferences/versions',
|
||||
path: '/onboarding/preferences/version',
|
||||
title: 'Versions',
|
||||
hideNavigationButtons: true,
|
||||
},
|
||||
{
|
||||
path: '/onboarding/preferences/mods',
|
||||
title: 'Mods',
|
||||
path: '/onboarding/preferences/versionCategory',
|
||||
title: 'Versions Category',
|
||||
hideNavigationButtons: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -96,6 +103,20 @@ const LINEAR_ONBOARDING_STEPS = getLinearSteps(ONBOARDING_STEPS);
|
|||
function RouteComponent() {
|
||||
const location = useLocation();
|
||||
|
||||
// Prefetch data so that onboarding/preferences/versionCategory is fast
|
||||
const queryClient = useQueryClient();
|
||||
const { data: clusters } = useCommandSuspense(['getClusters'], () => bindings.core.getClusters());
|
||||
useEffect(() => {
|
||||
clusters.forEach((cluster) => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['getBundlesFor', cluster.id],
|
||||
queryFn: () => bindings.oneclient.getBundlesFor(cluster.id),
|
||||
});
|
||||
});
|
||||
}, [clusters, queryClient]);
|
||||
|
||||
const { currentLinearStepIndex } = Route.useLoaderData();
|
||||
|
||||
return (
|
||||
// <LoaderSuspense spinner={{ size: 'large' }}>
|
||||
<AppShell>
|
||||
|
|
@ -122,7 +143,7 @@ function RouteComponent() {
|
|||
|
||||
</motion.div>
|
||||
|
||||
<OnboardingNavigation />
|
||||
{LINEAR_ONBOARDING_STEPS[currentLinearStepIndex].hideNavigationButtons ? <></> : <OnboardingNavigation />}
|
||||
</LoaderSuspense>
|
||||
</div>
|
||||
</AppShell>
|
||||
|
|
@ -207,10 +228,19 @@ function BackgroundGradient() {
|
|||
);
|
||||
}
|
||||
|
||||
export function OnboardingNavigation() {
|
||||
const { isFirstStep, previousPath, nextPath, currentLinearStepIndex } = Route.useLoaderData();
|
||||
const { data: currentAccount } = useCommandSuspense(['getDefaultUser'], () => bindings.core.getDefaultUser(true));
|
||||
const forceLoginDisable = currentLinearStepIndex === 2 && currentAccount === null;
|
||||
export function OnboardingNavigation({ ref, disableNext }: { ref?: React.RefObject<DownloadModsRef | null>; disableNext?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
const { isFirstStep, previousPath, nextPath } = Route.useLoaderData();
|
||||
|
||||
function handleNextClick() {
|
||||
if (disableNext)
|
||||
return;
|
||||
|
||||
if (ref && ref.current !== null)
|
||||
ref.current.openDownloadDialog(nextPath ?? '/app');
|
||||
else
|
||||
navigate({ to: nextPath ?? '/app' });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-2 right-2 flex flex-row gap-2">
|
||||
|
|
@ -222,9 +252,14 @@ export function OnboardingNavigation() {
|
|||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Link disabled={forceLoginDisable} to={nextPath ?? '/app'}>
|
||||
<Button className={`w-32 ${forceLoginDisable ? 'line-through' : ''}`} color={forceLoginDisable ? 'secondary' : 'primary'}>Next</Button>
|
||||
</Link>
|
||||
<Button
|
||||
className={`w-32 ${disableNext ? 'line-through' : ''}`}
|
||||
color={disableNext ? 'secondary' : 'primary'}
|
||||
isDisabled={disableNext}
|
||||
onClick={handleNextClick}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -86,12 +86,11 @@ pub struct ExternalPackage {
|
|||
pub sha1: String,
|
||||
pub size: usize,
|
||||
pub package_type: PackageType,
|
||||
pub overrides: Option<ExternalPackageOverrides>,
|
||||
}
|
||||
|
||||
#[onelauncher_macro::specta]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ExternalPackageOverrides {
|
||||
pub struct PackageOverrides {
|
||||
pub icon: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub authors: Option<Vec<String>>,
|
||||
|
|
@ -308,7 +307,7 @@ pub enum PackageStatus {
|
|||
#[default]
|
||||
#[serde(alias = "approved")]
|
||||
Active,
|
||||
#[serde(alias = "archived", alias = "inactive")]
|
||||
#[serde(alias = "archived", alias = "inactive", alias = "unlisted")]
|
||||
Abandoned,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -145,7 +145,8 @@ pub async fn download_external_package(
|
|||
let dir = Dirs::get_clusters_dir()
|
||||
.await?
|
||||
.join(cluster.folder_name.clone())
|
||||
.join(package.package_type.folder_name());
|
||||
.join(package.package_type.folder_name())
|
||||
.join(package.name.clone());
|
||||
|
||||
http::download_advanced(
|
||||
Method::GET,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ use std::path::PathBuf;
|
|||
use onelauncher_entity::loader::GameLoader;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::api::packages::data::{ExternalPackage, ManagedPackage, ManagedVersion};
|
||||
use crate::api::packages::data::{
|
||||
ExternalPackage, ManagedPackage, ManagedVersion, PackageOverrides,
|
||||
};
|
||||
use crate::api::packages::modpack::ModpackFormat;
|
||||
|
||||
#[onelauncher_macro::specta]
|
||||
|
|
@ -30,6 +32,8 @@ pub struct ModpackManifest {
|
|||
pub struct ModpackFile {
|
||||
pub enabled: bool,
|
||||
pub kind: ModpackFileKind,
|
||||
#[serde(default)]
|
||||
pub overrides: Option<PackageOverrides>,
|
||||
}
|
||||
|
||||
#[onelauncher_macro::specta]
|
||||
|
|
|
|||
|
|
@ -10,9 +10,7 @@ use serde::Deserialize;
|
|||
use tokio::sync::OnceCell;
|
||||
|
||||
use crate::api::cluster::ClusterError;
|
||||
use crate::api::packages::data::{
|
||||
ExternalPackage, ExternalPackageOverrides, ManagedVersion, PackageSide,
|
||||
};
|
||||
use crate::api::packages::data::{ExternalPackage, ManagedVersion, PackageOverrides, PackageSide};
|
||||
use crate::api::packages::modpack::data::{
|
||||
ModpackArchive, ModpackFile, ModpackFileKind, ModpackManifest,
|
||||
};
|
||||
|
|
@ -283,9 +281,11 @@ pub(super) async fn copy_overrides_folder(
|
|||
}
|
||||
|
||||
async fn to_modpack_files(mrpack_files: &Vec<MrPackFile>) -> LauncherResult<Vec<ModpackFile>> {
|
||||
#[derive(Clone)]
|
||||
struct ToFetch {
|
||||
project_id: String,
|
||||
version_id: String,
|
||||
overrides: Option<PackageOverrides>,
|
||||
}
|
||||
|
||||
let mut to_fetch: Vec<ToFetch> = Vec::new();
|
||||
|
|
@ -316,6 +316,7 @@ async fn to_modpack_files(mrpack_files: &Vec<MrPackFile>) -> LauncherResult<Vec<
|
|||
to_fetch.push(ToFetch {
|
||||
project_id: project_id.to_string(),
|
||||
version_id: version_id.to_string(),
|
||||
overrides: file.overrides.clone(),
|
||||
});
|
||||
} else {
|
||||
tracing::error!("invalid modrinth file URL: '{}'", url);
|
||||
|
|
@ -346,8 +347,8 @@ async fn to_modpack_files(mrpack_files: &Vec<MrPackFile>) -> LauncherResult<Vec<
|
|||
sha1: file.hashes.sha1.clone(),
|
||||
size: file.file_size,
|
||||
package_type,
|
||||
overrides: file.overrides.clone(),
|
||||
}),
|
||||
overrides: file.overrides.clone(),
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -377,9 +378,15 @@ async fn to_modpack_files(mrpack_files: &Vec<MrPackFile>) -> LauncherResult<Vec<
|
|||
|
||||
for fetched_pkg in managed_packages {
|
||||
if let Some(version) = version_map.remove(&fetched_pkg.id) {
|
||||
let overrides = to_fetch
|
||||
.iter()
|
||||
.find(|f| f.project_id == fetched_pkg.id)
|
||||
.and_then(|f| f.overrides.clone());
|
||||
|
||||
files.push(ModpackFile {
|
||||
kind: ModpackFileKind::Managed((fetched_pkg, version)),
|
||||
enabled: true,
|
||||
overrides,
|
||||
});
|
||||
} else {
|
||||
tracing::error!("no version found for managed package '{}'", fetched_pkg.id);
|
||||
|
|
@ -409,7 +416,7 @@ pub(super) struct MrPackFile {
|
|||
pub downloads: Vec<String>,
|
||||
pub file_size: usize,
|
||||
#[serde(default)]
|
||||
pub overrides: Option<ExternalPackageOverrides>,
|
||||
pub overrides: Option<PackageOverrides>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use onelauncher_entity::package::{PackageType, Provider};
|
|||
use serde::Deserialize;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
use crate::api::packages::data::{ExternalPackage, ManagedVersion};
|
||||
use crate::api::packages::data::{ExternalPackage, ManagedVersion, PackageOverrides};
|
||||
use crate::api::packages::modpack::data::{
|
||||
ModpackArchive, ModpackFile, ModpackFileKind, ModpackManifest,
|
||||
};
|
||||
|
|
@ -185,9 +185,11 @@ impl InstallableModpackFormatExt for PolyMrPackFormatImpl {
|
|||
}
|
||||
|
||||
async fn to_modpack_files(mrpack_files: &Vec<PolyMrPackFile>) -> LauncherResult<Vec<ModpackFile>> {
|
||||
#[derive(Clone)]
|
||||
struct FetchedPackage {
|
||||
version_id: String,
|
||||
enabled: bool,
|
||||
overrides: Option<PackageOverrides>,
|
||||
}
|
||||
|
||||
let mut to_fetch: HashMap<String, FetchedPackage> = HashMap::new();
|
||||
|
|
@ -226,6 +228,7 @@ async fn to_modpack_files(mrpack_files: &Vec<PolyMrPackFile>) -> LauncherResult<
|
|||
FetchedPackage {
|
||||
version_id: version_id.to_string(),
|
||||
enabled: file.enabled,
|
||||
overrides: file.base.overrides.clone(),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
|
|
@ -260,8 +263,8 @@ async fn to_modpack_files(mrpack_files: &Vec<PolyMrPackFile>) -> LauncherResult<
|
|||
sha1: file.base.hashes.sha1.clone(),
|
||||
size: file.base.file_size,
|
||||
package_type,
|
||||
overrides: file.base.overrides.clone(),
|
||||
}),
|
||||
overrides: file.base.overrides.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -285,14 +288,11 @@ async fn to_modpack_files(mrpack_files: &Vec<PolyMrPackFile>) -> LauncherResult<
|
|||
|
||||
for fetched_pkg in managed_packages {
|
||||
if let Some(version) = version_map.remove(&fetched_pkg.id) {
|
||||
let enabled = to_fetch
|
||||
.get(&fetched_pkg.id)
|
||||
.map(|f| f.enabled)
|
||||
.unwrap_or(true);
|
||||
|
||||
let fetched = to_fetch.get(&fetched_pkg.id).unwrap();
|
||||
files.push(ModpackFile {
|
||||
enabled,
|
||||
enabled: fetched.enabled,
|
||||
kind: ModpackFileKind::Managed((fetched_pkg, version)),
|
||||
overrides: fetched.overrides.clone(),
|
||||
});
|
||||
} else {
|
||||
tracing::error!("no version found for managed package '{}'", fetched_pkg.id);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use crate::api::packages::data::{
|
||||
ManagedPackage, ManagedPackageBody, ManagedUser, ManagedVersion, PackageAuthor, SearchQuery,
|
||||
SearchResult,
|
||||
ExternalPackage, ManagedPackage, ManagedPackageBody, ManagedUser, ManagedVersion,
|
||||
PackageAuthor, SearchQuery, SearchResult,
|
||||
};
|
||||
use crate::api::packages::modpack::data::ModpackArchive;
|
||||
use crate::api::packages::provider::ProviderExt;
|
||||
|
|
@ -183,6 +183,14 @@ pub trait TauriLauncherApi {
|
|||
skip_compatibility: Option<bool>,
|
||||
) -> LauncherResult<packages::Model>;
|
||||
|
||||
#[taurpc(alias = "downloadExternalPackage")]
|
||||
async fn download_external_package(
|
||||
package: ExternalPackage,
|
||||
cluster_id: ClusterId,
|
||||
force: Option<bool>,
|
||||
skip_compatibility: Option<bool>,
|
||||
) -> LauncherResult<Option<packages::Model>>;
|
||||
|
||||
#[taurpc(alias = "getUsersFromAuthor")]
|
||||
async fn get_users_from_author(
|
||||
provider: Provider,
|
||||
|
|
@ -700,6 +708,29 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
|
|||
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
async fn download_external_package(
|
||||
self,
|
||||
package: ExternalPackage,
|
||||
cluster_id: ClusterId,
|
||||
force: Option<bool>,
|
||||
skip_compatibility: Option<bool>,
|
||||
) -> LauncherResult<Option<packages::Model>> {
|
||||
let cluster = api::cluster::dao::get_cluster_by_id(cluster_id)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("cluster with id {} not found", cluster_id))?;
|
||||
|
||||
let model = api::packages::download_external_package(
|
||||
&package,
|
||||
&cluster,
|
||||
force,
|
||||
skip_compatibility,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(model)
|
||||
}
|
||||
// endregion: package
|
||||
|
||||
// MARK: Impl: modpack
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ pub struct Settings {
|
|||
pub enable_gamemode: bool,
|
||||
pub discord_enabled: bool,
|
||||
pub seen_onboarding: bool,
|
||||
pub mod_list_use_grid: bool,
|
||||
pub max_concurrent_requests: usize,
|
||||
pub settings_version: u32,
|
||||
pub native_window_frame: bool,
|
||||
|
|
@ -30,6 +31,7 @@ impl Default for Settings {
|
|||
allow_parallel_running_clusters: false,
|
||||
discord_enabled: false,
|
||||
seen_onboarding: false,
|
||||
mod_list_use_grid: true,
|
||||
enable_gamemode: false,
|
||||
max_concurrent_requests: 25,
|
||||
settings_version: 1,
|
||||
|
|
|
|||
|
|
@ -20,11 +20,16 @@ function useTabs() {
|
|||
|
||||
interface TabsProps {
|
||||
defaultValue: string;
|
||||
children: ReactNode;
|
||||
onTabChange?: (value: string) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Tabs({ defaultValue, children }: TabsProps) {
|
||||
const [activeTab, setActiveTab] = useState(defaultValue);
|
||||
export function Tabs({ defaultValue, onTabChange, children }: TabsProps) {
|
||||
const [activeTab, setActiveTabInternal] = useState(defaultValue);
|
||||
const setActiveTab = (value: string) => {
|
||||
setActiveTabInternal(value)
|
||||
if (onTabChange) onTabChange(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
|
||||
|
|
@ -33,8 +38,10 @@ export function Tabs({ defaultValue, children }: TabsProps) {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
interface TabListProps extends HTMLAttributes<HTMLDivElement> {
|
||||
floating?: boolean;
|
||||
height?: boolean;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
|
|
@ -42,10 +49,11 @@ export function TabList({
|
|||
className,
|
||||
children,
|
||||
floating = false,
|
||||
height = false,
|
||||
ref,
|
||||
}: TabListProps) {
|
||||
return (
|
||||
<div className="pointer-events-none sticky top-0 z-10 min-h-[74px] h-[74px] max-h-[74px] w-full" ref={ref}>
|
||||
<div className={twMerge("pointer-events-none sticky top-0 z-10 w-full", height ? "min-h-[74px] h-[74px] max-h-[74px]" : "")} ref={ref}>
|
||||
<div
|
||||
className={twMerge(
|
||||
'pointer-events-auto flex flex-row gap-2 border border-transparent bg-page-elevated transition-all',
|
||||
|
|
@ -101,7 +109,7 @@ interface TabPanelProps extends HTMLAttributes<HTMLDivElement> {
|
|||
value: string;
|
||||
}
|
||||
|
||||
export function TabPanel({ children, value, ...rest }: TabPanelProps) {
|
||||
export function TabPanel({ children, value, className, ...rest }: TabPanelProps) {
|
||||
const { activeTab } = useTabs();
|
||||
|
||||
if (activeTab !== value)
|
||||
|
|
@ -109,7 +117,7 @@ export function TabPanel({ children, value, ...rest }: TabPanelProps) {
|
|||
|
||||
return (
|
||||
<div
|
||||
className="bg-page-elevated px-3 pt-3 w-full rounded-2xl h-full"
|
||||
className={twMerge("bg-page-elevated px-3 pt-3 w-full rounded-2xl h-full", className)}
|
||||
role="tabpanel"
|
||||
{...rest}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue