feat: mods page (#413)

This commit is contained in:
Jacob 2025-12-03 16:02:10 +00:00 • committed by GitHub
parent 9fc4409a18
commit d7c345b24a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1137 additions and 333 deletions

View file

@ -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
}
}

View file

@ -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<()> {

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 KiB

View file

@ -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 }

View 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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -0,0 +1,5 @@
export * from './Bundle';
export * from './DownloadModButton';
export * from './ModCard';
export * from './ModList';
export * from './ModTag';

View 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>
);
}

View file

@ -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';

View file

@ -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>
);
}

View file

@ -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>;
};

View file

@ -1,5 +1,6 @@
export * from './AccountPopup';
export * from './AddAccountModal';
export * from './BundleModListModal';
export * from './ImportSkinModal';
export * from './NoAccountPopup';
export * from './Overlay';

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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>

View file

@ -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} />
</>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);

View file

@ -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,
}

View file

@ -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,

View file

@ -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]

View file

@ -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)]

View file

@ -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);

View file

@ -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

View file

@ -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,

View file

@ -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}
>