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 7d6e4880..2d9b7aac 100644 --- a/apps/oneclient/frontend/src/bindings.gen.ts +++ b/apps/oneclient/frontend/src/bindings.gen.ts @@ -117,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[] } @@ -188,7 +232,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 @@ -255,8 +299,14 @@ export type VersionType = */ "old_beta" -const ARGS_MAP = { 'oneclient':'{"getClustersGroupedByMajor":[],"openDevTools":[],"getBundlesFor":["cluster_id"]}', 'core':'{"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getRunningProcesses":[],"getClusters":[],"updateClusterById":["id","request"],"getGameVersions":[],"getLoadersForVersion":["mc_version"],"getUsers":[],"setDefaultUser":["uuid"],"changeSkin":["access_token","skin_url","skin_variant"],"getWorlds":["id"],"fetchLoggedInProfile":["access_token"],"createCluster":["options"],"getScreenshots":["id"],"searchPackages":["provider","query"],"installModpack":["modpack","cluster_id"],"changeCape":["access_token","cape_uuid"],"getLogs":["id"],"readSettings":[],"getRunningProcessesByClusterId":["cluster_id"],"getUsersFromAuthor":["provider","author"],"createSettingsProfile":["name"],"getPackageBody":["provider","body"],"convertUsernameUUID":["username_uuid"],"getClusterById":["id"],"getUser":["uuid"],"launchCluster":["id","uuid"],"getLogByName":["id","name"],"openMsaLogin":[],"getPackage":["provider","slug"],"getDefaultUser":["fallback"],"updateClusterProfile":["name","profile"],"open":["input"],"getGlobalProfile":[],"isClusterRunning":["cluster_id"],"removeCluster":["id"],"killProcess":["pid"],"writeSettings":["setting"],"removeUser":["uuid"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getMultiplePackages":["provider","slugs"],"fetchMinecraftProfile":["uuid"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"removeCape":["access_token"],"getProfileOrDefault":["name"]}', 'events':'{"message":["event"],"ingress":["event"],"process":["event"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}' } -export type Router = { 'core': { getClusters: () => Promise, +const ARGS_MAP = { 'oneclient':'{"getVersions":[],"openDevTools":[],"getBundlesFor":["cluster_id"],"getClustersGroupedByMajor":[]}', 'core':'{"getRunningProcessesByClusterId":["cluster_id"],"getScreenshots":["id"],"getProfileOrDefault":["name"],"searchPackages":["provider","query"],"changeSkin":["access_token","skin_url","skin_variant"],"launchCluster":["id","uuid"],"getLoadersForVersion":["mc_version"],"fetchMinecraftProfile":["uuid"],"removeCape":["access_token"],"updateClusterById":["id","request"],"createSettingsProfile":["name"],"changeCape":["access_token","cape_uuid"],"getGlobalProfile":[],"getMultiplePackages":["provider","slugs"],"getUsers":[],"removeUser":["uuid"],"getRunningProcesses":[],"readSettings":[],"isClusterRunning":["cluster_id"],"getLogByName":["id","name"],"getClusterById":["id"],"killProcess":["pid"],"writeSettings":["setting"],"getMods":["id"],"getLogs":["id"],"getPackageBody":["provider","body"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"createCluster":["options"],"getUsersFromAuthor":["provider","author"],"getPackage":["provider","slug"],"installModpack":["modpack","cluster_id"],"convertUsernameUUID":["username_uuid"],"getClusters":[],"removeCluster":["id"],"fetchLoggedInProfile":["access_token"],"setDefaultUser":["uuid"],"getWorlds":["id"],"getGameVersions":[],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"open":["input"],"getUser":["uuid"],"getDefaultUser":["fallback"],"updateClusterProfile":["name","profile"],"openMsaLogin":[]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'events':'{"ingress":["event"],"message":["event"],"process":["event"]}' } +export type Router = { '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, @@ -266,6 +316,7 @@ getScreenshots: (id: number) => Promise, getWorlds: (id: number) => Promise, getLogs: (id: number) => Promise, getLogByName: (id: number, name: string) => Promise, +getMods: (id: number) => Promise, getRunningProcesses: () => Promise, getRunningProcessesByClusterId: (clusterId: number) => Promise, isClusterRunning: (clusterId: number) => Promise, @@ -300,11 +351,6 @@ changeCape: (accessToken: string, capeUuid: string) => Promise Promise, convertUsernameUUID: (usernameUuid: string) => Promise, open: (input: string) => Promise }, -'oneclient': { openDevTools: () => Promise, -getClustersGroupedByMajor: () => Promise>, -getBundlesFor: (clusterId: number) => Promise }, -'folders': { fromCluster: (folderName: string) => Promise, -openCluster: (folderName: string) => Promise }, 'events': { ingress: (event: IngressPayload) => Promise, message: (event: MessagePayload) => Promise, process: (event: ProcessPayload) => Promise } }; 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..1f109887 --- /dev/null +++ b/apps/oneclient/frontend/src/components/Bundle/Bundle.tsx @@ -0,0 +1,26 @@ +import type { ClusterModel, ModpackArchive } from '@/bindings.gen'; +import { useSettings } from '@/hooks/useSettings'; +import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; +import { twMerge } from 'tailwind-merge'; +import { ModCard, useModCardContext } from '.'; + +interface BundleProps { + bundleData: ModpackArchive; + cluster: ClusterModel; +} + +export function Bundle({ bundleData, cluster }: BundleProps) { + const { useVerticalGridLayout } = useModCardContext(); + const { setting } = useSettings(); + const useGridLayout = setting('mod_list_use_grid'); + + return ( + +
+ {bundleData.manifest.files.map((file, index) => ( + + ))} +
+
+ ); +} 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..7420627e --- /dev/null +++ b/apps/oneclient/frontend/src/components/Bundle/DownloadModButton.tsx @@ -0,0 +1,31 @@ +import type { ClusterModel, ManagedPackage, ManagedVersion } 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({ pkg, version, cluster }: { pkg: ManagedPackage; version: ManagedVersion; cluster: ClusterModel }) { + const download = useCommandMut(() => bindings.core.downloadPackage(pkg.provider, pkg.id, version.version_id, cluster.id, true)); + + 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..c1260d39 --- /dev/null +++ b/apps/oneclient/frontend/src/components/Bundle/ModCard.tsx @@ -0,0 +1,145 @@ +import type { ClusterModel, ManagedPackage, ManagedVersion, 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; + managed: boolean; + url: string | null; + id: string | null; +} + +interface ModInfoManged extends ModInfo { + pkg: ManagedPackage; + version: ManagedVersion; +} + +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); + if (useVerticalGridLayout) { + const truncated = authors.slice(0, 2); + const extra = authors.length - truncated.length; + const names = truncated.map(author => author.username).join(', '); + if (extra > 0) + return `${names} and ${extra} more`; + else + return names; + } + return authors.map(author => author.username).join(', '); +} + +async function getModMetaData(kind: ModpackFileKind, useVerticalGridLayout?: boolean): Promise { + if ('External' in kind) + return { + name: kind.External.name.replaceAll('.jar', ''), + description: null, + iconURL: null, + author: await getModAuthor(kind, useVerticalGridLayout), + managed: false, + url: null, + id: null, + }; + + return { + name: kind.Managed[0].name, + description: kind.Managed[0].short_desc, + iconURL: kind.Managed[0].icon_url, + author: await getModAuthor(kind, useVerticalGridLayout), + managed: true, + url: `https://modrinth.com/project/${kind.Managed[0].slug}`, + id: kind.Managed[0].id, + pkg: kind.Managed[0], + version: kind.Managed[1], + }; +} + +export function isManagedMod(mod: ModInfo | ModInfoManged): mod is ModInfoManged { + return mod.managed === true; +} + +interface ModCardProps { + file: ModpackFile; + cluster: ClusterModel; +} + +export type onClickOnMod = (file: ModpackFile, setShowOutline: React.Dispatch>, setShowBlueBackground: React.Dispatch>) => void; +export interface ModCardContextApi { + showModDownloadButton?: boolean; + onClickOnMod?: onClickOnMod; + showOutlineOnCard?: boolean; + showBlueBackgroundOnCard?: boolean; + useVerticalGridLayout?: boolean; +} + +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, showOutlineOnCard, showBlueBackgroundOnCard, useVerticalGridLayout } = useModCardContext(); + + const [modMetadata, setModMetadata] = useState({ author: null, description: null, name: 'LOADING', iconURL: null, managed: false, url: null, id: null }); + useEffect(() => { + (async () => setModMetadata(await getModMetaData(file.kind, useVerticalGridLayout)))(); + }, [file, useVerticalGridLayout]); + + const [showOutline, setShowOutline] = useState(showOutlineOnCard ?? false); + const [showBlueBackground, setShowBlueBackground] = useState(showBlueBackgroundOnCard ?? false); + const handleOnClick = () => { + if (onClickOnMod) + onClickOnMod(file, setShowOutline, setShowBlueBackground); + }; + + const { setting } = useSettings(); + const useGridLayout = setting('mod_list_use_grid'); + + return ( +
+
+
+
+ +
+
+
+
+

{modMetadata.name}

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

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

+ {useVerticalGridLayout !== true &&

{modMetadata.description ?? 'No Description'}

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

{modMetadata.description}

} + + {isManagedMod(modMetadata) && 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..6e971ed6 --- /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..74ccd1c8 --- /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..b0ae2de4 --- /dev/null +++ b/apps/oneclient/frontend/src/components/DownloadMods.tsx @@ -0,0 +1,68 @@ +import type { Provider } from '@/bindings.gen'; +import type { BundleData } from '@/routes/onboarding/preferences/version'; +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 { DownloadingMods, Overlay } from './overlay'; + +export interface DownloadModsRef { + openDownloadDialog: (nextPath?: string) => void; +} + +interface ModData { + name: string; + provider: Provider; + id: string; + versionId: string; + clusterId: number; +} + +export function DownloadMods({ bundlesData, ref }: { bundlesData: Record; ref: React.Ref }) { + const navigate = useNavigate(); + const [isOpen, setOpen] = useState(false); + const [mods, setMods] = useState>([]); + const [nextPath, setNextPath] = useState('/app'); + + useEffect(() => { + const modsList: Array = []; + for (const bundle of Object.values(bundlesData)) + for (const mod of bundle.modsInfo[0]) { + if (!('Managed' in mod.kind)) + continue; + const [pkg, version] = mod.kind.Managed; + modsList.push({ + name: pkg.name, + provider: pkg.provider, + id: pkg.id, + versionId: version.version_id, + clusterId: bundle.clusterId, + }); + } + setMods(modsList); + }, [bundlesData]); + + useImperativeHandle(ref, () => { + return { + openDownloadDialog(nextPath?: string) { + if (mods.length !== 0) { + setOpen(true); + setNextPath(nextPath ?? '/app'); + } + else { + navigate({ to: nextPath ?? '/app' }); + } + }, + }; + }, [mods.length, navigate]); + + return ( + + + + + + + + ); +} 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..8034c341 --- /dev/null +++ b/apps/oneclient/frontend/src/components/overlay/BundleModListModal.tsx @@ -0,0 +1,51 @@ +import type { ModpackFile } from '@/bindings.gen'; +import type { ModCardContextApi, onClickOnMod } from '../Bundle'; +import { bindings } from '@/main'; +import { useCommandSuspense } from '@onelauncher/common'; +import { useMemo, useState } from 'react'; +import { ModCardContext, ModList } from '../Bundle'; +import { Overlay } from './Overlay'; + +export function BundleModListModal({ clusterId, name, setMods }: { clusterId: number; name: string; setMods: (value: React.SetStateAction>) => void }) { + const { data: cluster } = useCommandSuspense(['getClusterById'], () => bindings.core.getClusterById(clusterId)); + const { data: bundles } = useCommandSuspense(['getBundlesFor', clusterId], () => bindings.oneclient.getBundlesFor(clusterId)); + + const onClickOnMod: onClickOnMod = (file, setShowOutline, setShowBlueBackground) => { + setMods((prevMods) => { + if (prevMods.includes(file)) + return prevMods.filter(mod => mod !== file); + else + return [file, ...prevMods]; + }); + setShowOutline(prev => !prev); + setShowBlueBackground(prev => !prev); + }; + + const [tab, setSelectedTab] = useState(name); + + const context = useMemo(() => ({ + onClickOnMod, + useVerticalGridLayout: true, + }), []); + + if (!cluster) + return <>; + + return ( + + + Select Content for + {' '} + {tab} + + + + + + ); +} diff --git a/apps/oneclient/frontend/src/components/overlay/DownloadingMods.tsx b/apps/oneclient/frontend/src/components/overlay/DownloadingMods.tsx new file mode 100644 index 00000000..b99b2514 --- /dev/null +++ b/apps/oneclient/frontend/src/components/overlay/DownloadingMods.tsx @@ -0,0 +1,79 @@ +import type { Provider } from '@/bindings.gen'; +import { bindings } from '@/main'; +import { useCommandMut } from '@onelauncher/common'; +import { useNavigate } from '@tanstack/react-router'; +import { useEffect, useState } from 'react'; +import { Overlay } from './Overlay'; + +interface ModData { + name: string; + provider: Provider; + id: string; + versionId: string; + clusterId: number; +} + +export function DownloadingMods({ mods, setOpen, nextPath }: { mods: Array; setOpen: React.Dispatch>; nextPath: string }) { + const navigate = useNavigate(); + const [downloadedMods, setDownloadedMods] = useState(0); + const [modNames, setModNames] = useState>([]); + + const download = useCommandMut(async (mod: ModData) => { + await bindings.core.downloadPackage(mod.provider, mod.id, mod.versionId, mod.clusterId, true); + }); + + useEffect(() => { + const downloadAll = async () => { + const groupSize = 15; + for (let i = 0; i < mods.length; i += groupSize) { + const group = mods.slice(i, i + groupSize); + setModNames(prev => [...prev, ...group.map(mod => mod.name).filter(name => !prev.includes(name))]); + + try { + await Promise.all( + group.map(async (mod) => { + try { + await download.mutateAsync(mod); + setDownloadedMods(prev => prev + 1); + } + finally { + setModNames(prev => prev.filter(name => name !== mod.name)); + } + }), + ); + } + catch (error) { + console.error(error); + } + } + }; + + downloadAll(); + }, [mods]); + + useEffect(() => { + if (downloadedMods >= mods.length) { + setOpen(false); + navigate({ to: nextPath }); + } + }, [downloadedMods, mods]); + + return ( + + Downloading Mods + +
+

Downloaded {downloadedMods} / {mods.length}

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

Downloading {modName}

) : <>} +
+ +
+ ); +} diff --git a/apps/oneclient/frontend/src/components/overlay/Overlay.tsx b/apps/oneclient/frontend/src/components/overlay/Overlay.tsx index b2899277..f55188e9 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,19 +85,25 @@ 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}
diff --git a/apps/oneclient/frontend/src/components/overlay/index.ts b/apps/oneclient/frontend/src/components/overlay/index.ts index 26e5d627..5b1eda40 100644 --- a/apps/oneclient/frontend/src/components/overlay/index.ts +++ b/apps/oneclient/frontend/src/components/overlay/index.ts @@ -1,5 +1,7 @@ export * from './AccountPopup'; export * from './AddAccountModal'; +export * from './BundleModListModal'; +export * from './DownloadingMods'; 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..62985e32 --- /dev/null +++ b/apps/oneclient/frontend/src/routes/app/cluster/mods.tsx @@ -0,0 +1,20 @@ +import { ModList } from '@/components/Bundle'; +import { bindings } from '@/main'; +import { useCommandSuspense } from '@onelauncher/common'; +import { createFileRoute } from '@tanstack/react-router'; + +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)); + + 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..b876c67d --- /dev/null +++ b/apps/oneclient/frontend/src/routes/onboarding/preferences/version.tsx @@ -0,0 +1,132 @@ +import type { GameLoader, ModpackArchive, ModpackFile, 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 { useQueries } from '@tanstack/react-query'; +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 BundleData { + bundles: Array; + art: string; + modsInfo: [Array, React.Dispatch>>]; + clusterId: number; +} + +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 bundleQueries = useQueries({ + queries: clusters.map(cluster => ({ + queryKey: ['getBundlesFor', cluster.id], + queryFn: () => bindings.oneclient.getBundlesFor(cluster.id), + suspense: true, + })), + }); + + const bundlesData: Record = {}; + clusters.forEach((cluster, index) => { + const version = versions.clusters.find(versionCluster => cluster.mc_version.startsWith(`1.${versionCluster.major_version}`)); + const bundles = bundleQueries[index].data ?? []; + // eslint-disable-next-line react-hooks/rules-of-hooks -- TODO: @Kathund Find a better way to do this that isn't useState + bundlesData[cluster.name] = { bundles, art: version?.art ?? '/versions/art/Horse_Update.jpg', modsInfo: useState>([]), clusterId: cluster.id }; + }); + + 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, index) => ( + + )); + })} +
+ +
+
+
+
+ + + + ); +} + +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..743ff247 --- /dev/null +++ b/apps/oneclient/frontend/src/routes/onboarding/preferences/versionCategory.tsx @@ -0,0 +1,141 @@ +import type { ModpackArchive, ModpackFile } from '@/bindings.gen'; +import type { DownloadModsRef } from '@/components'; +import type { BundleData, StrippedCLuster } from './version'; +import { DownloadMods } from '@/components'; +import { BundleModListModal, Overlay } from '@/components/overlay'; +import { bindings } from '@/main'; +import { useCommandSuspense } from '@onelauncher/common'; +import { Button } from '@onelauncher/common/components'; +import { useQueries } from '@tanstack/react-query'; +import { createFileRoute } from '@tanstack/react-router'; +import { DotsVerticalIcon } from '@untitled-theme/icons-react'; +import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; +import { useRef, useState } from 'react'; +import { Button as AriaButton, DialogTrigger } from 'react-aria-components'; +import { twMerge } from 'tailwind-merge'; +import { OnboardingNavigation } from '../route'; + +export const Route = createFileRoute('/onboarding/preferences/versionCategory')({ + component: RouteComponent, +}); + +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 = useQueries({ + queries: clusters.map(cluster => ({ + queryKey: ['getBundlesFor', cluster.id], + queryFn: () => bindings.oneclient.getBundlesFor(cluster.id), + suspense: true, + })), + }); + + const bundlesData: Record = {}; + clusters.forEach((clusterData, index) => { + const selected = selectedClusters.some(strippedCluster => strippedCluster.mc_version === clusterData.mc_version && strippedCluster.mc_loader === clusterData.mc_loader); + if (!selected) + return; + + const version = versions.clusters.find(versionCluster => clusterData.mc_version.startsWith(`1.${versionCluster.major_version}`)); + const bundles = bundleQueries[index].data ?? []; + // eslint-disable-next-line react-hooks/rules-of-hooks -- TODO: @Kathund Find a better way to do this that isn't useState + bundlesData[clusterData.name] = { bundles, art: version?.art ?? '/versions/art/Horse_Update.jpg', modsInfo: useState>([]), clusterId: clusterData.id }; + }); + + const downloadModsRef = useRef(null); + + return ( + <> +
+
+ +
+
+ {Object.entries(bundlesData).map(([name, bundleData], index) => )} +
+ +
+ +
+
+
+
+
+ + + ); +} + +function ModCategory({ bundleData, name }: { bundleData: BundleData; name: string }) { + return ( +
+

{name}

+
+ {bundleData.bundles.map((bundle, index) => { + return ( + + ); + })} +
+
+ ); +} + +function ModCategoryCard({ art, fullVersionName, bundle, setMods, clusterId }: { fullVersionName: string; art: string; bundle: ModpackArchive; setMods: React.Dispatch>>; clusterId: number }) { + const [isSelected, setSelected] = useState(false); + const files = bundle.manifest.files.filter(file => 'Managed' in file.kind); + const handleDownload = () => { + setMods((prevMods) => { + if (isSelected) + return prevMods.filter(mod => !files.includes(mod)); + else + return [...files, ...prevMods]; + }); + setSelected(prev => !prev); + }; + + 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 ef70ec84..0850c9af 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')({ @@ -42,6 +45,7 @@ export interface OnboardingStep { path: string; title: string; subSteps?: Array; + hideNavigationButtons?: boolean; }; const ONBOARDING_STEPS: Array = [ @@ -56,18 +60,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, }, ], }, @@ -86,6 +93,20 @@ const LINEAR_ONBOARDING_STEPS = getLinearSteps(ONBOARDING_STEPS); function RouteComponent() { const location = useLocation(); + // Prefetch data so that onboarding/preferences/version 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 { currentStepIndex } = Route.useLoaderData(); + return ( // @@ -112,7 +133,7 @@ function RouteComponent() { - + {LINEAR_ONBOARDING_STEPS[currentStepIndex].hideNavigationButtons ? <> : }
@@ -197,10 +218,19 @@ function BackgroundGradient() { ); } -export function OnboardingNavigation() { - const { isFirstStep, previousPath, nextPath, currentStepIndex } = Route.useLoaderData(); - const { data: currentAccount } = useCommandSuspense(['getDefaultUser'], () => bindings.core.getDefaultUser(true)); - const forceLoginDisable = currentStepIndex === 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 (
@@ -212,9 +242,14 @@ export function OnboardingNavigation() { )}
- - - +
); diff --git a/packages/core/src/api/cluster/content.rs b/packages/core/src/api/cluster/content.rs index 5476527e..f534e4cf 100644 --- a/packages/core/src/api/cluster/content.rs +++ b/packages/core/src/api/cluster/content.rs @@ -100,3 +100,22 @@ pub async fn get_log_by_name( Ok(Some(content)) } + +/// Returns a list of mods file names +pub async fn get_mods(cluster: &clusters::Model) -> LauncherResult> { + let dir = cluster.folder_name.clone(); + let path = Dirs::get_clusters_dir().await?.join(dir).join("mods"); + + if !path.exists() { + io::create_dir(&path).await?; + return Ok(Vec::new()); + } + + let mut list = vec![]; + let mut files = io::read_dir(path).await?; + while let Ok(Some(entry)) = files.next_entry().await { + list.push(entry.file_name().to_string_lossy().to_string()); + } + + Ok(list) +} diff --git a/packages/core/src/api/tauri/commands.rs b/packages/core/src/api/tauri/commands.rs index 71877a8f..f81f4fed 100644 --- a/packages/core/src/api/tauri/commands.rs +++ b/packages/core/src/api/tauri/commands.rs @@ -58,6 +58,9 @@ pub trait TauriLauncherApi { #[taurpc(alias = "getLogByName")] async fn get_log_by_name(id: ClusterId, name: String) -> LauncherResult>; + + #[taurpc(alias = "getMods")] + async fn get_mods(id: ClusterId) -> LauncherResult>; // endregion: clusters // MARK: API: processes @@ -409,6 +412,14 @@ impl TauriLauncherApi for TauriLauncherApiImpl { api::cluster::content::get_log_by_name(&cluster, &name).await } + + async fn get_mods(self, id: ClusterId) -> LauncherResult> { + let cluster = api::cluster::dao::get_cluster_by_id(id) + .await? + .ok_or_else(|| anyhow::anyhow!("cluster with id {} not found", id))?; + + api::cluster::content::get_mods(&cluster).await + } // endregion: clusters // MARK: Impl: processes diff --git a/packages/core/src/store/settings.rs b/packages/core/src/store/settings.rs index f0ab4f82..e6b184cb 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, @@ -28,6 +29,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..bdae05ee 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,6 +38,7 @@ export function Tabs({ defaultValue, children }: TabsProps) { ); } + interface TabListProps extends HTMLAttributes { floating?: boolean; ref?: Ref;