diff --git a/apps/oneclient/desktop/src/api/commands.rs b/apps/oneclient/desktop/src/api/commands.rs index 455dbed0..696d9b03 100644 --- a/apps/oneclient/desktop/src/api/commands.rs +++ b/apps/oneclient/desktop/src/api/commands.rs @@ -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>; + + #[taurpc(alias = "getVersions")] + async fn get_versions() -> LauncherResult; } #[taurpc::ipc_type] @@ -72,4 +76,8 @@ impl OneClientApi for OneClientApiImpl { Ok(bundles) } + + async fn get_versions(self) -> LauncherResult { + get_data_storage_versions().await + } } diff --git a/apps/oneclient/desktop/src/oneclient/clusters.rs b/apps/oneclient/desktop/src/oneclient/clusters.rs index 75fbf5f6..9365a93d 100644 --- a/apps/oneclient/desktop/src/oneclient/clusters.rs +++ b/apps/oneclient/desktop/src/oneclient/clusters.rs @@ -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, } -#[derive(Deserialize)] -struct OnlineCluster { +#[derive(specta::Type, Deserialize, Serialize)] +pub struct OnlineCluster { major_version: u8, + name: String, + art: String, entries: Vec, } -#[derive(Deserialize)] -struct OnlineClusterEntry { +#[derive(specta::Type, Deserialize, Serialize)] +pub struct OnlineClusterEntry { minor_version: u8, loader: GameLoader, + tags: Vec, +} + +pub async fn get_data_storage_versions() -> LauncherResult { + let manifest = match fetch_json::( + 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<()> { diff --git a/apps/oneclient/frontend/src/assets/misc/missingLogo.svg b/apps/oneclient/frontend/src/assets/misc/missingLogo.svg new file mode 100644 index 00000000..36b1a4a9 --- /dev/null +++ b/apps/oneclient/frontend/src/assets/misc/missingLogo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/oneclient/frontend/src/bindings.gen.ts b/apps/oneclient/frontend/src/bindings.gen.ts index f9e632ad..1368fe8f 100644 --- a/apps/oneclient/frontend/src/bindings.gen.ts +++ b/apps/oneclient/frontend/src/bindings.gen.ts @@ -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; mc_loader: DbVec; 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 @@ -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, +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, +message: (event: MessagePayload) => Promise, +process: (event: ProcessPayload) => Promise }, +'folders': { fromCluster: (folderName: string) => Promise, +openCluster: (folderName: string) => Promise }, +'oneclient': { openDevTools: () => Promise, +getClustersGroupedByMajor: () => Promise>, +getBundlesFor: (clusterId: number) => Promise, +getVersions: () => Promise }, +'core': { getClusters: () => Promise, getClusterById: (id: number) => Promise, removeCluster: (id: number) => Promise, createCluster: (options: CreateCluster) => Promise, @@ -292,6 +345,7 @@ getPackageBody: (provider: Provider, body: ManagedPackageBody) => Promise Promise, getPackageVersions: (provider: Provider, slug: string, mcVersion: string | null, loader: GameLoader | null, offset: number, limit: number) => Promise>, downloadPackage: (provider: Provider, packageId: string, versionId: string, clusterId: number, skipCompatibility: boolean | null) => Promise, +downloadExternalPackage: (package: ExternalPackage, clusterId: number, force: boolean | null, skipCompatibility: boolean | null) => Promise, getUsersFromAuthor: (provider: Provider, author: PackageAuthor) => Promise, installModpack: (modpack: ModpackArchive, clusterId: number) => Promise, fetchMinecraftProfile: (uuid: string) => Promise, @@ -301,15 +355,7 @@ changeSkin: (accessToken: string, skinUrl: string, skinVariant: SkinVariant) => changeCape: (accessToken: string, capeUuid: string) => Promise, removeCape: (accessToken: string) => Promise, convertUsernameUUID: (usernameUuid: string) => Promise, -open: (input: string) => Promise }, -'folders': { fromCluster: (folderName: string) => Promise, -openCluster: (folderName: string) => Promise }, -'events': { ingress: (event: IngressPayload) => Promise, -message: (event: MessagePayload) => Promise, -process: (event: ProcessPayload) => Promise }, -'oneclient': { openDevTools: () => Promise, -getClustersGroupedByMajor: () => Promise>, -getBundlesFor: (clusterId: number) => Promise } }; +open: (input: string) => Promise } }; export type { InferCommandOutput } diff --git a/apps/oneclient/frontend/src/components/Bundle/Bundle.tsx b/apps/oneclient/frontend/src/components/Bundle/Bundle.tsx new file mode 100644 index 00000000..3fe522a2 --- /dev/null +++ b/apps/oneclient/frontend/src/components/Bundle/Bundle.tsx @@ -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; + cluster: ClusterModel; +} + +export function Bundle({ files, cluster }: BundleProps) { + const { useVerticalGridLayout } = useModCardContext(); + const { setting } = useSettings(); + const useGridLayout = setting('mod_list_use_grid'); + + return ( + +
+ {files.map(file => ( + + ))} +
+
+ + ); +} diff --git a/apps/oneclient/frontend/src/components/Bundle/DownloadModButton.tsx b/apps/oneclient/frontend/src/components/Bundle/DownloadModButton.tsx new file mode 100644 index 00000000..8e66e60e --- /dev/null +++ b/apps/oneclient/frontend/src/components/Bundle/DownloadModButton.tsx @@ -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 ( + + ); +} diff --git a/apps/oneclient/frontend/src/components/Bundle/ModCard.tsx b/apps/oneclient/frontend/src/components/Bundle/ModCard.tsx new file mode 100644 index 00000000..4e101915 --- /dev/null +++ b/apps/oneclient/frontend/src/components/Bundle/ModCard.tsx @@ -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 { + 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, 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 { + 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; +} + +export const ModCardContext = createContext(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({ 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 ( +
+
+
+
+ +
+
+
+
+

{modMetadata.name}

+ {useVerticalGridLayout !== true && } +
+ +

+ by + {' '} + {modMetadata.author ?? 'UNKNOWN'} +

+ {useVerticalGridLayout !== true &&

{modMetadata.description ?? 'No Description'}

} +
+
+ {useVerticalGridLayout === true && modMetadata.description !== null &&

{modMetadata.description}

} + + {showModDownloadButton === true && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/oneclient/frontend/src/components/Bundle/ModList.tsx b/apps/oneclient/frontend/src/components/Bundle/ModList.tsx new file mode 100644 index 00000000..90412275 --- /dev/null +++ b/apps/oneclient/frontend/src/components/Bundle/ModList.tsx @@ -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; + 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 ( + + +
+ {bundles.map(bundle => {getBundleName(bundle.manifest.name)})} +
+ {!useVerticalGridLayout && ( + + )} +
+ + + {bundles.map(bundleData => ( + + + + ))} + +
+ ); +} diff --git a/apps/oneclient/frontend/src/components/Bundle/ModTag.tsx b/apps/oneclient/frontend/src/components/Bundle/ModTag.tsx new file mode 100644 index 00000000..fe2a5be7 --- /dev/null +++ b/apps/oneclient/frontend/src/components/Bundle/ModTag.tsx @@ -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 ( + +

{modData.managed ? 'Modrinth' : 'External'}

+ + ); +} diff --git a/apps/oneclient/frontend/src/components/Bundle/index.ts b/apps/oneclient/frontend/src/components/Bundle/index.ts new file mode 100644 index 00000000..b51fb8e0 --- /dev/null +++ b/apps/oneclient/frontend/src/components/Bundle/index.ts @@ -0,0 +1,5 @@ +export * from './Bundle'; +export * from './DownloadModButton'; +export * from './ModCard'; +export * from './ModList'; +export * from './ModTag'; diff --git a/apps/oneclient/frontend/src/components/DownloadMods.tsx b/apps/oneclient/frontend/src/components/DownloadMods.tsx new file mode 100644 index 00000000..4df49c50 --- /dev/null +++ b/apps/oneclient/frontend/src/components/DownloadMods.tsx @@ -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; +} + +export interface ExternalModData extends BaseModData { + package: ExternalPackage; +} + +export type ModData = ManagedModData | ExternalModData; +export type ModDataArray = Array; + +export function isManagedMod(mod: ModData): mod is ManagedModData { + return mod.managed === true; +} + +export function DownloadMods({ modsPerCluster, ref }: { modsPerCluster: Record>; ref: React.Ref }) { + const navigate = useNavigate(); + const [isOpen, setOpen] = useState(false); + const [mods, setMods] = useState([]); + const [nextPath, setNextPath] = useState('/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 ( + + + + + + + + ); +} + +function downloadModsParallel(items: ModDataArray, limit: number, fn: (mod: ModData, index: number) => Promise) { + 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>; nextPath: string }) { + const navigate = useNavigate(); + const [downloadedMods, setDownloadedMods] = useState(0); + const [modName, setModName] = useState(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 ( + + Downloading Mods + +
+

Downloaded {downloadedMods} / {mods.length}

+
+
0 ? `${(downloadedMods / mods.length) * 100}%` : '0%' }} + > +
+
+ {modName !== null ?

Downloading {modName}

: <>} +
+ +
+ ); +} diff --git a/apps/oneclient/frontend/src/components/index.ts b/apps/oneclient/frontend/src/components/index.ts index 5be4ef73..ef9156dc 100644 --- a/apps/oneclient/frontend/src/components/index.ts +++ b/apps/oneclient/frontend/src/components/index.ts @@ -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'; diff --git a/apps/oneclient/frontend/src/components/overlay/BundleModListModal.tsx b/apps/oneclient/frontend/src/components/overlay/BundleModListModal.tsx new file mode 100644 index 00000000..e1aac7bc --- /dev/null +++ b/apps/oneclient/frontend/src/components/overlay/BundleModListModal.tsx @@ -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(name); + const { mods } = useModCardContext(); + + if (!cluster) + return <>; + + return ( + + + Select Content For + {' '} + {tab} + + +
+

Selected {mods?.length ?? 0} mods

+ +
+
+ ); +} diff --git a/apps/oneclient/frontend/src/components/overlay/Overlay.tsx b/apps/oneclient/frontend/src/components/overlay/Overlay.tsx index b2899277..156012bc 100644 --- a/apps/oneclient/frontend/src/components/overlay/Overlay.tsx +++ b/apps/oneclient/frontend/src/components/overlay/Overlay.tsx @@ -35,6 +35,7 @@ export type OverlayProps = React.ComponentProps & { export function Overlay({ className, children, + isDismissable, ...rest }: OverlayProps) { const { overlay, modal } = modalVariants(); @@ -48,7 +49,7 @@ export function Overlay({ {children} -

Click outside to dismiss

+ {isDismissable ?

Click outside to dismiss

: <>} ); } @@ -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 ( -
- -
+ {isDismissable + ? ( +
+ +
+ ) + : <>} {children}
); }; -Overlay.Title = function OverlayTitle({ children }: { children: React.ReactNode }) { - return

{children}

; +Overlay.Title = function OverlayTitle({ children, className }: { children: React.ReactNode; className?: string | undefined }) { + return

{children}

; }; diff --git a/apps/oneclient/frontend/src/components/overlay/index.ts b/apps/oneclient/frontend/src/components/overlay/index.ts index 26e5d627..5b757394 100644 --- a/apps/oneclient/frontend/src/components/overlay/index.ts +++ b/apps/oneclient/frontend/src/components/overlay/index.ts @@ -1,5 +1,6 @@ export * from './AccountPopup'; export * from './AddAccountModal'; +export * from './BundleModListModal'; export * from './ImportSkinModal'; export * from './NoAccountPopup'; export * from './Overlay'; diff --git a/apps/oneclient/frontend/src/routes/app/cluster/mods.tsx b/apps/oneclient/frontend/src/routes/app/cluster/mods.tsx new file mode 100644 index 00000000..ebfb1d29 --- /dev/null +++ b/apps/oneclient/frontend/src/routes/app/cluster/mods.tsx @@ -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(() => ({ + showModDownloadButton: true, + }), []); + + if (bundles.length === 0) + return

No bundles found {cluster.name}

; + + return ( + +
+ + + +
+
+ ); +} diff --git a/apps/oneclient/frontend/src/routes/app/cluster/overview.tsx b/apps/oneclient/frontend/src/routes/app/cluster/overview.tsx index dd09a1a1..d4b04ac8 100644 --- a/apps/oneclient/frontend/src/routes/app/cluster/overview.tsx +++ b/apps/oneclient/frontend/src/routes/app/cluster/overview.tsx @@ -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 (

Cluster Overview

Welcome to the cluster overview page. Here you can find information about your clusters.

-
- Cluster Name: - {' '} - {cluster.name} - -
-

test

{/* Additional content can be added here */}
); } - -function TestBundles() { - const { cluster } = Route.useRouteContext(); - - const { data: bundles } = useCommandSuspense(['getBundlesFor', cluster.id], () => bindings.oneclient.getBundlesFor(cluster.id)); - - return ( -
-

Bundles

-
    - {bundles.map(bundle => ( -
  • - - {JSON.stringify(bundle, null, 4)} - -
  • - ))} -
-
- ); -} diff --git a/apps/oneclient/frontend/src/routes/app/cluster/route.tsx b/apps/oneclient/frontend/src/routes/app/cluster/route.tsx index 2a4a37a6..65d037c3 100644 --- a/apps/oneclient/frontend/src/routes/app/cluster/route.tsx +++ b/apps/oneclient/frontend/src/routes/app/cluster/route.tsx @@ -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 ( } @@ -71,6 +81,7 @@ function RouteComponent() { > Overview + Mods Logs Browser Settings diff --git a/apps/oneclient/frontend/src/routes/app/settings/developer.tsx b/apps/oneclient/frontend/src/routes/app/settings/developer.tsx index 338b41e7..4a24051f 100644 --- a/apps/oneclient/frontend/src/routes/app/settings/developer.tsx +++ b/apps/oneclient/frontend/src/routes/app/settings/developer.tsx @@ -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() { > + } + title="Use Grid On Mods List" + > + +
diff --git a/apps/oneclient/frontend/src/routes/onboarding/account.tsx b/apps/oneclient/frontend/src/routes/onboarding/account.tsx index c9a64563..81ba813c 100644 --- a/apps/oneclient/frontend/src/routes/onboarding/account.tsx +++ b/apps/oneclient/frontend/src/routes/onboarding/account.tsx @@ -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 ( -
-
-

Account

-

Before you continue, we require you to own a copy of Minecraft: Java Edition.

+ <> +
+
+

Account

+

Before you continue, we require you to own a copy of Minecraft: Java Edition.

+
+ {currentAccount + ? ( + <> + + + ) + : ( + <> + {profile + ? ( + <> + + + ) + : ( + + )} + + )}
- {currentAccount - ? ( - <> - - - ) - : ( - <> - {profile - ? ( - <> - - - ) - : ( - - )} - - )} -
+ + ); } diff --git a/apps/oneclient/frontend/src/routes/onboarding/preferences/mods.tsx b/apps/oneclient/frontend/src/routes/onboarding/preferences/mods.tsx deleted file mode 100644 index ba01587d..00000000 --- a/apps/oneclient/frontend/src/routes/onboarding/preferences/mods.tsx +++ /dev/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 ( -
-
-

Choose Mods

-

- Something something in corporate style fashion about picking your preferred gamemodes and versions and - optionally loader so that oneclient can pick something for them -

- -
- - - Skyblock - Survival - Minigames - PVP - GUI - Utility - Misc - -
-
- 31 Mods selected -
-
-
- - - - -
- {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].map(x => ( -
- {x % 2 === 0 ? : } -
- ))} -
-
-
-
-
-
-
-
- ); -} - -interface ModCardProps { - active: boolean; -} - -function ModCard(props: ModCardProps) { - const { active } = props; - - return ( -
-
- -
-

Chatting

-

- by - {' '} - {' '} - Polyfrost -

-
-
-
-

Chatting is a chat mod adding utilities such as extremely customizable chat tabs, chat shortcuts, chat screenshots, and message copying.

-
-
- ); -} - -function ModCard2(props: ModCardProps) { - const { active } = props; - - return ( -
-
- -
-

Chatting

-

- by - {' '} - {' '} - Polyfrost -

-
-
-
-

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

-
-
- ); -} diff --git a/apps/oneclient/frontend/src/routes/onboarding/preferences/version.tsx b/apps/oneclient/frontend/src/routes/onboarding/preferences/version.tsx new file mode 100644 index 00000000..38712e2f --- /dev/null +++ b/apps/oneclient/frontend/src/routes/onboarding/preferences/version.tsx @@ -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>([]); + + return ( + <> +
+
+ +
+

Starting Versions

+

+ Something something in corporate style fashion about picking your preferred gamemodes and versions and + optionally loader so that oneclient can pick something for them +

+ +
+ {versions.clusters.map((cluster) => { + const versionData = getVersionInfoOrDefault(cluster.major_version); + return cluster.entries.map(entry => ( + + )); + })} +
+ +
+
+
+
+ + + + ); +} + +function VersionCard({ cluster, versionData, version, fullVersionName, setSelectedClusters }: { cluster: OnlineCluster; versionData: VersionInfo; version: OnlineClusterEntry; fullVersionName: string; setSelectedClusters: React.Dispatch>> }) { + const [isSelected, setSelected] = useState(false); + const toggle = () => { + setSelected(prev => !prev); + setSelectedClusters((prev) => { + let updatedClusters: Array = []; + 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 ( + +
+ {`Minecraft + +
+ { + version.tags.map(tag => ( +
+ {tag} +
+ )) + } +
+ +
+ {fullVersionName} +
+
+
+ ); +} diff --git a/apps/oneclient/frontend/src/routes/onboarding/preferences/versionCategory.tsx b/apps/oneclient/frontend/src/routes/onboarding/preferences/versionCategory.tsx new file mode 100644 index 00000000..0c371864 --- /dev/null +++ b/apps/oneclient/frontend/src/routes/onboarding/preferences/versionCategory.tsx @@ -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; + art: string; + modsInfo: Array; + clusterId: number; +} + +function HandleCLuster(cluster: ClusterModel): Array { + const { data: bundle } = useCommandSuspense(['getBundlesFor', cluster.id], () => bindings.oneclient.getBundlesFor(cluster.id)); + return bundle; +} + +function RouteComponent() { + const selectedClusters: Array = 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 = {}; + 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>>( + clusters.reduce((acc, cluster) => { + acc[cluster.id] = []; + return acc; + }, {} as Record>), + ); + + const downloadModsRef = useRef(null); + + return ( + <> +
+
+ +
+
+ {Object.entries(bundlesData).map(([name, bundleData]) => ( + + ))} +
+ +
+ +
+
+
+
+
+ + + ); +} + +function ModCategory({ bundleData, name, modsPerCluster, setModsPerCluster }: { bundleData: BundleData; name: string; modsPerCluster: Record>; setModsPerCluster: React.Dispatch>>> }) { + return ( +
+

{name}

+
+ {bundleData.bundles.map(bundle => ( + setModsPerCluster(prev => ({ ...prev, [bundleData.clusterId]: typeof newMods === 'function' ? newMods(prev[bundleData.clusterId]) : newMods }))} + /> + ))} +
+
+ ); +} + +function ModCategoryCard({ art, fullVersionName, bundle, mods, setMods, clusterId }: { fullVersionName: string; art: string; bundle: ModpackArchive; mods: Array; setMods: React.Dispatch>>; 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(() => ({ + onClickOnMod, + useVerticalGridLayout: true, + mods, + }), [mods]); + + return ( + +
+ {`Minecraft + +
+
+ {files.length} Mods {isSelected ? 'Selected' : ''} +
+
+ + + + + + + + + + + +
+
+ {fullVersionName} +
+
+ +
+
+ ); +} diff --git a/apps/oneclient/frontend/src/routes/onboarding/preferences/versions.tsx b/apps/oneclient/frontend/src/routes/onboarding/preferences/versions.tsx deleted file mode 100644 index e75e7427..00000000 --- a/apps/oneclient/frontend/src/routes/onboarding/preferences/versions.tsx +++ /dev/null @@ -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 ( -
-
-

Starting Versions

-

- Something something in corporate style fashion about picking your preferred gamemodes and versions and - optionally loader so that oneclient can pick something for them -

- -
- {versions.map(version => ( - - ))} -
-
- -
-
-
-
-
- ); -} - -interface VersionCardProps { - version: string; - tags: Array; -} - -export function VersionCard({ version, tags }: VersionCardProps) { - return ( -
-
- {`Minecraft - -
- {tags.map(tag => ( -
- {tag} -
- ))} -
- - - -
- {version} -
-
-
- ); -} diff --git a/apps/oneclient/frontend/src/routes/onboarding/route.tsx b/apps/oneclient/frontend/src/routes/onboarding/route.tsx index 8d0f91ef..05639796 100644 --- a/apps/oneclient/frontend/src/routes/onboarding/route.tsx +++ b/apps/oneclient/frontend/src/routes/onboarding/route.tsx @@ -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; + hideNavigationButtons?: boolean; }; const ONBOARDING_STEPS: Array = [ @@ -66,18 +70,21 @@ const ONBOARDING_STEPS: Array = [ { 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 ( // @@ -122,7 +143,7 @@ function RouteComponent() { - + {LINEAR_ONBOARDING_STEPS[currentLinearStepIndex].hideNavigationButtons ? <> : }
@@ -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; 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 (
@@ -222,9 +252,14 @@ export function OnboardingNavigation() { )}
- - - +
); diff --git a/packages/core/src/api/packages/data.rs b/packages/core/src/api/packages/data.rs index 466370d0..0e0287c4 100644 --- a/packages/core/src/api/packages/data.rs +++ b/packages/core/src/api/packages/data.rs @@ -86,12 +86,11 @@ pub struct ExternalPackage { pub sha1: String, pub size: usize, pub package_type: PackageType, - pub overrides: Option, } #[onelauncher_macro::specta] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ExternalPackageOverrides { +pub struct PackageOverrides { pub icon: Option, pub name: Option, pub authors: Option>, @@ -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, } diff --git a/packages/core/src/api/packages/mod.rs b/packages/core/src/api/packages/mod.rs index 1fcc7bfe..ac530723 100644 --- a/packages/core/src/api/packages/mod.rs +++ b/packages/core/src/api/packages/mod.rs @@ -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, diff --git a/packages/core/src/api/packages/modpack/data.rs b/packages/core/src/api/packages/modpack/data.rs index bff15352..99b19931 100644 --- a/packages/core/src/api/packages/modpack/data.rs +++ b/packages/core/src/api/packages/modpack/data.rs @@ -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, } #[onelauncher_macro::specta] diff --git a/packages/core/src/api/packages/modpack/mrpack.rs b/packages/core/src/api/packages/modpack/mrpack.rs index 33958c7a..523b94ff 100644 --- a/packages/core/src/api/packages/modpack/mrpack.rs +++ b/packages/core/src/api/packages/modpack/mrpack.rs @@ -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) -> LauncherResult> { + #[derive(Clone)] struct ToFetch { project_id: String, version_id: String, + overrides: Option, } let mut to_fetch: Vec = Vec::new(); @@ -316,6 +316,7 @@ async fn to_modpack_files(mrpack_files: &Vec) -> LauncherResult) -> LauncherResult) -> LauncherResult, pub file_size: usize, #[serde(default)] - pub overrides: Option, + pub overrides: Option, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] diff --git a/packages/core/src/api/packages/modpack/polymrpack.rs b/packages/core/src/api/packages/modpack/polymrpack.rs index d1941404..fa5e0604 100644 --- a/packages/core/src/api/packages/modpack/polymrpack.rs +++ b/packages/core/src/api/packages/modpack/polymrpack.rs @@ -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) -> LauncherResult> { + #[derive(Clone)] struct FetchedPackage { version_id: String, enabled: bool, + overrides: Option, } let mut to_fetch: HashMap = HashMap::new(); @@ -226,6 +228,7 @@ async fn to_modpack_files(mrpack_files: &Vec) -> 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) -> 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) -> 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); diff --git a/packages/core/src/api/tauri/commands.rs b/packages/core/src/api/tauri/commands.rs index 71877a8f..e02c990e 100644 --- a/packages/core/src/api/tauri/commands.rs +++ b/packages/core/src/api/tauri/commands.rs @@ -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, ) -> LauncherResult; + #[taurpc(alias = "downloadExternalPackage")] + async fn download_external_package( + package: ExternalPackage, + cluster_id: ClusterId, + force: Option, + skip_compatibility: Option, + ) -> LauncherResult>; + #[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, + skip_compatibility: Option, + ) -> LauncherResult> { + 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 diff --git a/packages/core/src/store/settings.rs b/packages/core/src/store/settings.rs index 4af1c7bd..d951dff2 100644 --- a/packages/core/src/store/settings.rs +++ b/packages/core/src/store/settings.rs @@ -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, diff --git a/packages/web_common/src/components/TabList.tsx b/packages/web_common/src/components/TabList.tsx index 000dc7d1..561e0b4d 100644 --- a/packages/web_common/src/components/TabList.tsx +++ b/packages/web_common/src/components/TabList.tsx @@ -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 ( @@ -33,8 +38,10 @@ export function Tabs({ defaultValue, children }: TabsProps) { ); } + interface TabListProps extends HTMLAttributes { floating?: boolean; + height?: boolean; ref?: Ref; } @@ -42,10 +49,11 @@ export function TabList({ className, children, floating = false, + height = false, ref, }: TabListProps) { return ( -
+
{ 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 (