From e1defccc3f0f668464f61d69b4dad6b45137792b Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Wed, 16 Jul 2025 20:36:17 +0200 Subject: [PATCH] feat: clusters frontend --- apps/oneclient/desktop/src/api/commands.rs | 41 ++- apps/oneclient/desktop/src/lib.rs | 11 +- apps/oneclient/desktop/src/oneclient/mod.rs | 62 ++++ .../frontend/src/components/Navbar.tsx | 13 +- .../frontend/src/components/SheetPage.tsx | 65 +++-- .../frontend/src/components/Tabs.tsx | 44 +++ .../frontend/src/components/index.ts | 1 + .../src/routes/app/cluster/overview.tsx | 27 ++ .../frontend/src/routes/app/cluster/route.tsx | 132 +++++++++ .../frontend/src/routes/app/clusters.tsx | 269 ++++++++++++++++++ .../src/routes/app/clusters/index.tsx | 86 ------ .../frontend/src/routes/app/index.tsx | 94 +++--- .../frontend/src/routes/app/route.tsx | 16 +- .../frontend/src/stores/appShellStore.ts | 5 - .../frontend/src/stores/clusterStore.ts | 45 +++ apps/oneclient/frontend/src/utils/loaders.ts | 18 ++ .../frontend/src/utils/locationMapping.ts | 39 --- .../frontend/src/utils/versionMap.ts | 57 +++- eslint.config.js | 4 +- packages/core/src/api/cluster/dao.rs | 13 + .../src/components/AnimatedOutlet.tsx | 12 +- packages/web_common/src/hooks/useCommand.ts | 48 ++-- 22 files changed, 841 insertions(+), 261 deletions(-) create mode 100644 apps/oneclient/desktop/src/oneclient/mod.rs create mode 100644 apps/oneclient/frontend/src/components/Tabs.tsx create mode 100644 apps/oneclient/frontend/src/routes/app/cluster/overview.tsx create mode 100644 apps/oneclient/frontend/src/routes/app/cluster/route.tsx create mode 100644 apps/oneclient/frontend/src/routes/app/clusters.tsx delete mode 100644 apps/oneclient/frontend/src/routes/app/clusters/index.tsx create mode 100644 apps/oneclient/frontend/src/stores/clusterStore.ts create mode 100644 apps/oneclient/frontend/src/utils/loaders.ts delete mode 100644 apps/oneclient/frontend/src/utils/locationMapping.ts diff --git a/apps/oneclient/desktop/src/api/commands.rs b/apps/oneclient/desktop/src/api/commands.rs index 436ecb42..33da7cb8 100644 --- a/apps/oneclient/desktop/src/api/commands.rs +++ b/apps/oneclient/desktop/src/api/commands.rs @@ -1,11 +1,15 @@ -use onelauncher_core::error::{DaoError, LauncherResult}; +use std::collections::HashMap; + +use onelauncher_core::{entity::clusters, error::LauncherResult}; use tauri::Runtime; #[taurpc::procedures(path = "oneclient", export_to = "../frontend/src/bindings.gen.ts")] pub trait OneClientApi { - async fn return_error() -> LauncherResult<()>; - + #[taurpc(alias = "openDevTools")] async fn open_dev_tools(webview_window: tauri::WebviewWindow); + + #[taurpc(alias = "getClustersGroupedByMajor")] + async fn get_clusters_grouped_by_major() -> LauncherResult>>; } #[taurpc::ipc_type] @@ -13,15 +17,34 @@ pub struct OneClientApiImpl; #[taurpc::resolvers] impl OneClientApi for OneClientApiImpl { - - async fn return_error(self) -> LauncherResult<()> { - let err = DaoError::NotFound.into(); - Err(err) - } - async fn open_dev_tools(self, webview_window: tauri::WebviewWindow) { #[cfg(feature = "devtools")] webview_window.open_devtools(); } + async fn get_clusters_grouped_by_major(self) -> LauncherResult>> { + let clusters = onelauncher_core::api::cluster::dao::get_all_clusters().await?; + + let mut mapped: HashMap> = HashMap::new(); + + for cluster in clusters { + // Assuming `mc_version` is a String and you have a function to parse major version + let split = &mut cluster.mc_version.split('.'); + split.next(); + if let Some(major) = split.next() { + // Convert the major version to a u32 + let major: u32 = match major.parse() { + Ok(v) => v, + Err(_) => continue, // Skip if parsing fails + }; + + mapped.entry(major) + .or_insert_with(Vec::new) + .push(cluster); + } + } + + Ok(mapped) + } + } diff --git a/apps/oneclient/desktop/src/lib.rs b/apps/oneclient/desktop/src/lib.rs index 4e8f7c21..43d9a265 100644 --- a/apps/oneclient/desktop/src/lib.rs +++ b/apps/oneclient/desktop/src/lib.rs @@ -7,10 +7,12 @@ use onelauncher_core::store::{Core, CoreOptions, Dirs, State}; use tauri::{Emitter, Manager}; use crate::api::commands::OneClientApi; +use crate::oneclient::initialize_oneclient; pub mod api; pub mod constants; pub mod ext; +pub mod oneclient; #[derive(Clone, serde::Serialize)] pub struct SingleInstancePayload { @@ -96,14 +98,21 @@ async fn initialize_state(handle: &tauri::AppHandle) -> LauncherResult<()> { } pub async fn run() { - initialize_core().await.expect("failed to initialize core"); + initialize_core() + .await + .expect("failed to initialize core"); + let app = initialize_tauri(tauri::Builder::default()) .await .expect("failed to initialize tauri"); + initialize_state(app.handle()) .await .expect("failed to initialize state"); + initialize_oneclient() + .await; + app.run(|_, _| {}); } diff --git a/apps/oneclient/desktop/src/oneclient/mod.rs b/apps/oneclient/desktop/src/oneclient/mod.rs new file mode 100644 index 00000000..41cde7c6 --- /dev/null +++ b/apps/oneclient/desktop/src/oneclient/mod.rs @@ -0,0 +1,62 @@ +use onelauncher_core::{entity::{clusters::Model as ClusterModel, icon::Icon, loader::GameLoader}, error::LauncherResult, send_error}; + +pub async fn initialize_oneclient() { + init_clusters().await; +} + +struct OnlineCluster { + mc_major_version: u8, + mc_minor_versions: Vec, + mc_loaders: Vec, +} + +async fn init_clusters() { + // TODO: use online metadata stuff + let online_clusters: Vec = vec![ + OnlineCluster { + mc_major_version: 21, + mc_minor_versions: vec![1, 2, 4, 7], + mc_loaders: vec![GameLoader::Fabric, GameLoader::Forge], + }, + OnlineCluster { + mc_major_version: 8, + mc_minor_versions: vec![9], + mc_loaders: vec![GameLoader::Forge], + }, + ]; + + for cluster in online_clusters { + for minor_version in cluster.mc_minor_versions { + let mc_version = format!("1.{}.{}", cluster.mc_major_version, minor_version); + + for loader in &cluster.mc_loaders { + if let Err(e) = create_cluster_if_not_exist( + &format!("{} {}", mc_version, loader), + &mc_version, + *loader, + None, + None, + ).await { + send_error!("failed to create cluster for {}: {}", mc_version, e); + } + } + } + } +} + +async fn create_cluster_if_not_exist( + name: &str, + mc_version: &str, + mc_loader: GameLoader, + mc_loader_version: Option<&str>, + icon_url: Option, +) -> LauncherResult> { + let clusters = onelauncher_core::api::cluster::dao::get_clusters_by_version(mc_version).await?; + if clusters.len() > 1 { + return Ok(None); + } + + onelauncher_core::api::cluster::create_cluster(name, mc_version, mc_loader, mc_loader_version, icon_url) + .await + .map(Some) +} \ No newline at end of file diff --git a/apps/oneclient/frontend/src/components/Navbar.tsx b/apps/oneclient/frontend/src/components/Navbar.tsx index 005a075c..3b81258b 100644 --- a/apps/oneclient/frontend/src/components/Navbar.tsx +++ b/apps/oneclient/frontend/src/components/Navbar.tsx @@ -1,5 +1,5 @@ import type { ButtonProps } from '@onelauncher/common/components'; -import type { LinkProps } from '@tanstack/react-router'; +import type { LinkProps, RegisteredRouter } from '@tanstack/react-router'; import LauncherLogo from '@/assets/logos/oneclient.svg?react'; import { Button } from '@onelauncher/common/components'; import { Link } from '@tanstack/react-router'; @@ -19,9 +19,9 @@ export function Navbar() {
- Home - Clusters - Accounts + Home + Clusters + Accounts
@@ -54,7 +54,7 @@ export function Navbar() { function NavbarLink({ to, children, -}: LinkProps) { +}: LinkProps<'a', RegisteredRouter, '/app'>) { return (
diff --git a/apps/oneclient/frontend/src/components/SheetPage.tsx b/apps/oneclient/frontend/src/components/SheetPage.tsx index 8d1a0cfb..f226abf3 100644 --- a/apps/oneclient/frontend/src/components/SheetPage.tsx +++ b/apps/oneclient/frontend/src/components/SheetPage.tsx @@ -1,26 +1,32 @@ import type { HTMLMotionProps, MotionValue } from 'motion/react'; -import useAppShellStore from '@/stores/appShellStore'; -import { getLocationName } from '@/utils/locationMapping'; import { Button } from '@onelauncher/common/components'; -import { useNavigate } from '@tanstack/react-router'; +import { useCanGoBack, useRouter } from '@tanstack/react-router'; import { ArrowLeftIcon } from '@untitled-theme/icons-react'; import { motion, useScroll, useTransform } from 'motion/react'; import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; -import React, { useRef } from 'react'; +import React, { useCallback, useRef } from 'react'; import { twMerge } from 'tailwind-merge'; export function SheetPage({ headerSmall, headerLarge, children, + scrollContainerRef, }: { headerSmall: React.ReactNode; headerLarge: React.ReactNode; children?: React.ReactNode; + scrollContainerRef?: React.RefObject; }) { const scrollContainer = useRef(null); const headerLargeRef = useRef(null); + const setScrollContainerRef = useCallback((el: HTMLElement | null) => { + scrollContainer.current = el; + if (scrollContainerRef) + scrollContainerRef.current = el; + }, [scrollContainerRef]); + const { scrollYProgress } = useScroll({ axis: 'y', container: scrollContainer, @@ -51,8 +57,8 @@ export function SheetPage({
- scrollContainer.current = ref?.osInstance()?.elements().content ?? null}> -
+ setScrollContainerRef(ref?.osInstance()?.elements().content ?? null)}> +
{headerLarge} @@ -71,9 +77,18 @@ SheetPage.Content = ({ ...rest }: Omit, 'animate' | 'initial'>) => ( {children} @@ -81,28 +96,20 @@ SheetPage.Content = ({ ); function GoBackButton() { - const prevLocation = useAppShellStore(state => state.prevLocation); - const navigate = useNavigate(); + const router = useRouter(); + const canGoBack = useCanGoBack(); - const onClick = () => { - if (!prevLocation) - return; - - navigate({ - to: prevLocation.pathname, - search: prevLocation.search, - hash: prevLocation.hash, - state: prevLocation.state, - }); - }; - - if (!prevLocation) + if (!canGoBack) return undefined; + const onClick = () => { + router.history.back(); + }; + return ( - ); } @@ -122,21 +129,21 @@ function HeaderBackgrounds({ }} >
-
+
); } diff --git a/apps/oneclient/frontend/src/components/Tabs.tsx b/apps/oneclient/frontend/src/components/Tabs.tsx new file mode 100644 index 00000000..8657d4da --- /dev/null +++ b/apps/oneclient/frontend/src/components/Tabs.tsx @@ -0,0 +1,44 @@ +import type { LinkProps } from '@tanstack/react-router'; +import { Link } from '@tanstack/react-router'; +import { twMerge } from 'tailwind-merge'; + +export function TabList({ + className, + children, + floating = false, + ref, +}: React.HTMLAttributes & { + floating?: boolean; + ref?: React.Ref; +}) { + return ( +
+ {children} +
+ ); +} + +export function Tab({ + to, + children, +}: Omit) { + return ( +
+ + {children} + +
+ ); +} diff --git a/apps/oneclient/frontend/src/components/index.ts b/apps/oneclient/frontend/src/components/index.ts index 9ca292a7..97466938 100644 --- a/apps/oneclient/frontend/src/components/index.ts +++ b/apps/oneclient/frontend/src/components/index.ts @@ -2,3 +2,4 @@ export * from './GameBackground'; export * from './Loader'; export * from './Navbar'; export * from './Spinner'; +export * from './Tabs'; diff --git a/apps/oneclient/frontend/src/routes/app/cluster/overview.tsx b/apps/oneclient/frontend/src/routes/app/cluster/overview.tsx new file mode 100644 index 00000000..00c23208 --- /dev/null +++ b/apps/oneclient/frontend/src/routes/app/cluster/overview.tsx @@ -0,0 +1,27 @@ +import { SheetPage } from '@/components/SheetPage'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/app/cluster/overview')({ + component: RouteComponent, + beforeLoad(ctx) { + console.log('beforeLoad overview', ctx.search); + }, +}); + +function RouteComponent() { + const { cluster } = Route.useRouteContext(); + + return ( + +
+

Cluster Overview

+

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

+
+ +
+

test

+ {/* Additional content can be added here */} +
+
+ ); +} diff --git a/apps/oneclient/frontend/src/routes/app/cluster/route.tsx b/apps/oneclient/frontend/src/routes/app/cluster/route.tsx new file mode 100644 index 00000000..8423918b --- /dev/null +++ b/apps/oneclient/frontend/src/routes/app/cluster/route.tsx @@ -0,0 +1,132 @@ +import { Tab, TabList } from '@/components'; +import { SheetPage } from '@/components/SheetPage'; +import { bindings } from '@/main'; +import { prettifyLoader } from '@/utils/loaders'; +import { getVersionInfoOrDefault } from '@/utils/versionMap'; +import { Button } from '@onelauncher/common/components'; +import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'; +import { useMotionValueEvent, useScroll } from 'motion/react'; +import { useRef, useState } from 'react'; + +export interface ClusterRouteSearchParams { + clusterId: number; +} + +export const Route = createFileRoute('/app/cluster')({ + component: RouteComponent, + validateSearch: (search): ClusterRouteSearchParams => { + console.log('validateSearch', search); + return { + clusterId: Number(search.clusterId), + }; + }, + async beforeLoad({ context, search }) { + console.log('beforeLoad', search); + if (!search.clusterId) + throw redirect({ to: '/app/clusters', from: '/app/cluster' }); + + const query = context.queryClient.ensureQueryData({ + queryKey: ['getClusterById', search.clusterId], + queryFn: () => bindings.core.getClusterById(search.clusterId as unknown as bigint), + }); + + const cluster = await query; + if (!cluster) + throw redirect({ to: '/app/clusters', from: '/app/cluster' }); + + return { + cluster, + }; + }, +}); + +function RouteComponent() { + const tabListRef = useRef(null); + const scrollContainerRef = useRef(null); + + const { scrollYProgress } = useScroll({ + axis: 'y', + container: scrollContainerRef, + target: tabListRef, + layoutEffect: false, + offset: ['end end', 'start start'], + }); + + const [floating, setFloating] = useState(false); + useMotionValueEvent(scrollYProgress, 'change', (v) => { + setFloating(v >= 0.9); + }); + + const search = Route.useSearch(); + + return ( + } + headerSmall={} + scrollContainerRef={scrollContainerRef} + > + + Overview + Logs + + +
+ +
+
+ ); +} + +function HeaderLarge() { + const { cluster } = Route.useRouteContext(); + const versionInfo = getVersionInfoOrDefault(cluster.mc_version); + + const launch = () => { + bindings.core.launchCluster(cluster.id as unknown as bigint, null); + }; + + return ( +
+
+
+ {versionInfo.tags.map(tag => ( + + {tag} + + ))} +
+

{prettifyLoader(cluster.mc_loader)} {cluster.mc_version}

+

{versionInfo.longDescription}

+
+ + +
+ ); +} + +function HeaderSmall() { + const { cluster } = Route.useRouteContext(); + + const launch = () => { + bindings.core.launchCluster(cluster.id as unknown as bigint, null); + }; + + return ( +
+

{prettifyLoader(cluster.mc_loader)} {cluster.mc_version}

+ + +
+ ); +} diff --git a/apps/oneclient/frontend/src/routes/app/clusters.tsx b/apps/oneclient/frontend/src/routes/app/clusters.tsx new file mode 100644 index 00000000..a003a87d --- /dev/null +++ b/apps/oneclient/frontend/src/routes/app/clusters.tsx @@ -0,0 +1,269 @@ +import type { GameLoader } from '@/bindings.gen'; +import { GameBackground } from '@/components'; +import { SheetPage } from '@/components/SheetPage'; +import { bindings } from '@/main'; +import useClusterStore from '@/stores/clusterStore'; +import { prettifyLoader } from '@/utils/loaders'; +import { getVersionInfo, getVersionInfoOrDefault, parseMcVersion } from '@/utils/versionMap'; +import { useCommandSuspense } from '@onelauncher/common'; +import { Button, Dropdown } from '@onelauncher/common/components'; +import { createFileRoute } from '@tanstack/react-router'; +import { ArrowRightIcon } from '@untitled-theme/icons-react'; +import { useCallback, useMemo, useState } from 'react'; +import { twMerge } from 'tailwind-merge'; + +export const Route = createFileRoute('/app/clusters')({ + component: RouteComponent, +}); + +function RouteComponent() { + const prevMinorVersions = useClusterStore(state => state.minorVersions); + const setMinorVersion = useClusterStore(state => state.setMinorVersion); + const modLoaders = useClusterStore(state => state.modLoaders); + const setModLoader = useClusterStore(state => state.setModLoader); + + const navigate = Route.useNavigate(); + + const { data: clusters } = useCommandSuspense('getClustersGroupedByMajor', bindings.oneclient.getClustersGroupedByMajor); + + const loadMinorVersion = useCallback((major: number) => { + const existing = prevMinorVersions.find(v => v.major === major); + if (existing) + return existing.minor; + return undefined; + }, [prevMinorVersions]); + + const [majorVersion, setMajorVersion] = useState(() => { + const keys = Object.keys(clusters); + if (keys.length === 0) + return 0; + + return Number.parseInt(keys[0]); + }); + + const [activeMinorVersion, setActiveMinorVersion] = useState(); + + const minorVersions = useMemo(() => { + const list = clusters[majorVersion]; + if (!list || list.length === 0) + return []; + + const versions = list.map(c => parseMcVersion(c.mc_version)?.minor).filter((v): v is number => v !== undefined); + const sorted = Array.from(new Set(versions)).sort((a, b) => a - b); + + // attempt to load the minor version from localStorage + const storedMinor = loadMinorVersion(majorVersion) || sorted[0]; + setActiveMinorVersion(storedMinor); + + return sorted; + }, [clusters, loadMinorVersion, majorVersion]); + + const modLoader = useMemo(() => { + if (!activeMinorVersion) + return undefined; + + return modLoaders[`${majorVersion}.${activeMinorVersion}`]; + }, [modLoaders, majorVersion, activeMinorVersion]); + + const loaders = useMemo(() => { + const list = clusters[majorVersion]; + if (!list || list.length === 0) + return []; + + const loadersSet: Set = new Set(); + for (const cluster of list) + if (cluster.mc_loader !== 'vanilla') + loadersSet.add(cluster.mc_loader); + + return Array.from(loadersSet).sort(); + }, [clusters, majorVersion]); + + const cluster = useMemo(() => { + const list = clusters[majorVersion]; + if (!activeMinorVersion) + return list?.[0]; + + return list?.find((c) => { + const version = c.mc_version.endsWith(activeMinorVersion.toString()); + const loaderCheck = modLoader ? c.mc_loader === modLoader : true; + return version && loaderCheck; + }); + }, [clusters, majorVersion, activeMinorVersion, modLoader]); + + const versionInfo = useMemo(() => getVersionInfoOrDefault(cluster?.mc_version), [cluster]); + + return ( + } + headerSmall={} + > +
+
+ +
+ {Object.keys(clusters).map((majorStr) => { + const major = Number.parseInt(majorStr, 10); + const isSelected = major === majorVersion; + const info = getVersionInfo(major); + + if (!info) + return undefined; + + return ( + setMajorVersion(major)} + tags={[...new Set(clusters[major]?.flatMap(c => prettifyLoader(c.mc_loader)))]} + /> + ); + })} +
+
+ + + + +
+

Version {cluster?.mc_version}

+

{versionInfo.longDescription}

+ + {minorVersions.length > 1 && ( +
+

Minor Version

+ + { + setActiveMinorVersion(Number(selected)); + setMinorVersion(majorVersion, Number(selected)); + }} + selectedKey={activeMinorVersion} + > + {minorVersions.map((minorVersion) => { + const fullVer = `${versionInfo.prettyName}.${minorVersion}`; + return ( + + {fullVer} + + ); + })} + +
+ )} + + {loaders.length > 1 && ( +
+

Mod Loader

+ { + if (!activeMinorVersion) + return; + + setModLoader(`${majorVersion}.${activeMinorVersion}`, selected as GameLoader); + }} + selectedKey={activeMinorVersion ? (modLoaders[`${majorVersion}.${activeMinorVersion}`] ?? loaders[0]) : loaders[0]} + > + {loaders.map(loader => ( + + {prettifyLoader(loader)} + + ))} + +
+ )} + +
+ + +
+ +
+
+
+
+ ); +} + +function HeaderLarge({ ref }: { ref?: React.Ref }) { + return ( +
+
+

Clusters

+

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

+
+
+ ); +} + +function HeaderSmall() { + return ( +

Clusters

+ ); +} + +function ClusterEntry({ + isSelected, + major_version, + onClick, + tags, +}: { + isSelected: boolean; + major_version: number; + onClick: () => unknown; + tags: Array; +}) { + const info = getVersionInfo(major_version); + + if (!info) + return undefined; + + return ( +
+ +
+
+ +
+ {info.tags.concat(tags).map(tag => ( +
+ {tag} +
+ ))} +
+ +

Version {info.prettyName}

+
+ ); +} diff --git a/apps/oneclient/frontend/src/routes/app/clusters/index.tsx b/apps/oneclient/frontend/src/routes/app/clusters/index.tsx deleted file mode 100644 index 901e0346..00000000 --- a/apps/oneclient/frontend/src/routes/app/clusters/index.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { GameBackground } from '@/components'; -import { SheetPage } from '@/components/SheetPage'; -import { Button } from '@onelauncher/common/components'; -import { createFileRoute } from '@tanstack/react-router'; -import { ArrowRightIcon } from '@untitled-theme/icons-react'; - -export const Route = createFileRoute('/app/clusters/')({ - component: RouteComponent, -}); - -function RouteComponent() { - return ( - } - headerSmall={} - > -
-
- -
-
- -
- -
- -
-
- - Hello World! -
- -
- test -
- - - - -
-

Version 1.21

-

- Minecraft's 1.21 update, known as "Tricky Trials," primarily focuses on combat adventures and tinkering, introducing trial chambers, new copper block variants, a new crafting tool, and a new weapon. It also features new hostile mobs, paintings, and gameplay enhancements. -

- -
-

Minor Version

- -
- -
-

Mod Loader

- -
- -
- - -
- -
-
-
-
- ); -} - -function HeaderLarge({ ref }: { ref?: React.Ref }) { - return ( -
-
-

Clusters

-

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

-
-
- ); -} - -function HeaderSmall() { - return ( -

Clusters

- ); -} diff --git a/apps/oneclient/frontend/src/routes/app/index.tsx b/apps/oneclient/frontend/src/routes/app/index.tsx index 9dda4dd0..c15fc4e0 100644 --- a/apps/oneclient/frontend/src/routes/app/index.tsx +++ b/apps/oneclient/frontend/src/routes/app/index.tsx @@ -1,13 +1,14 @@ -import type { ClusterModel } from '@/bindings.gen'; +import type { GameLoader } from '@/bindings.gen'; import type { HTMLAttributes } from 'react'; import { GameBackground } from '@/components'; import { bindings } from '@/main'; +import { prettifyLoader } from '@/utils/loaders'; import { animations, transitions } from '@/utils/motion'; import { getVersionInfo } from '@/utils/versionMap'; import { useCommandSuspense } from '@onelauncher/common'; import { Button } from '@onelauncher/common/components'; import { createFileRoute } from '@tanstack/react-router'; -import { DotsGridIcon, Settings04Icon } from '@untitled-theme/icons-react'; +import { DotsGridIcon, PlusIcon, Settings04Icon } from '@untitled-theme/icons-react'; import { motion } from 'motion/react'; import { MouseParallax } from 'react-just-parallax'; import { twMerge } from 'tailwind-merge'; @@ -17,30 +18,32 @@ export const Route = createFileRoute('/app/')({ }); function RouteComponent() { - const { data: clusters } = useCommandSuspense>( + const { data: clusters } = useCommandSuspense( 'getClusters', bindings.core.getClusters, { - select: ((data: Array) => { - const sorted = data.sort((a, b) => { - if (a.last_played && b.last_played) - return new Date(b.last_played).getTime() - new Date(a.last_played).getTime(); - else if (a.last_played) - return -1; // a has last_played, b does not - else if (b.last_played) - return 1; // b has last_played, a does not + select(data) { + const sorted = data + .filter(cluster => cluster.last_played !== null) + .sort((a, b) => { + if (a.last_played && b.last_played) + return new Date(b.last_played).getTime() - new Date(a.last_played).getTime(); + else if (a.last_played) + return -1; // a has last_played, b does not + else if (b.last_played) + return 1; // b has last_played, a does not - const aVersion = a.mc_version.replaceAll('.', ''); - const bVersion = b.mc_version.replaceAll('.', ''); - return Number.parseInt(bVersion) - Number.parseInt(aVersion); - }); + const aVersion = a.mc_version.replaceAll('.', ''); + const bVersion = b.mc_version.replaceAll('.', ''); + return Number.parseInt(bVersion) - Number.parseInt(aVersion); + }); return [ sorted[0], sorted[1], - undefined, + sorted[3], ]; - }) as (data: Array) => Array, + }, }, ); @@ -52,14 +55,25 @@ function RouteComponent() {
- +
- {clusters.map((cluster, index) => ( - - ))} + {clusters.map((cluster) => { + // eslint-disable-next-line ts/no-unnecessary-condition -- rule broken + return cluster === undefined + ? ( + + + + ) + : ( + + ); + })} @@ -70,32 +84,34 @@ function RouteComponent() { } interface RecentsCardProps { - version: string | undefined; + version: string; + loader: GameLoader; } const BLUR = '30px'; -function RecentsCard({ version }: RecentsCardProps) { +function RecentsCard({ version, loader }: RecentsCardProps) { const versionInfo = getVersionInfo(version); + if (!versionInfo) + return Unknown Version; + return ( - - {versionInfo && ( -
- - - + +
+ + + -
-
- -

{version}

+
- )} + +

{version} {prettifyLoader(loader)}

+
); } diff --git a/apps/oneclient/frontend/src/routes/app/route.tsx b/apps/oneclient/frontend/src/routes/app/route.tsx index 666f810f..b173dd03 100644 --- a/apps/oneclient/frontend/src/routes/app/route.tsx +++ b/apps/oneclient/frontend/src/routes/app/route.tsx @@ -3,8 +3,7 @@ import { LoaderSuspense, Navbar } from '@/components'; import { GameBackground } from '@/components/GameBackground'; import useAppShellStore from '@/stores/appShellStore'; import { AnimatedOutlet } from '@onelauncher/common/components'; -import { createFileRoute, useRouter } from '@tanstack/react-router'; -import { useEffect } from 'react'; +import { createFileRoute } from '@tanstack/react-router'; import { MouseParallax } from 'react-just-parallax'; export const Route = createFileRoute('/app')({ @@ -26,7 +25,7 @@ function RouteComponent() { animate: { opacity: 0 }, }} from={Route.id} - transition={{ duration: 0.3, bounce: 0.1, power: 0.2, type: 'spring' }} + transition={{ duration: 0.25, bounce: 0.1, power: 0.2, type: 'spring' }} />
@@ -37,17 +36,6 @@ function RouteComponent() { function AppShell({ children, }: PropsWithChildren) { - const router = useRouter(); - const setPrevLocation = useAppShellStore(state => state.setPrevLocation); - - useEffect(() => { - const unsub = router.subscribe('onBeforeNavigate', (e) => { - setPrevLocation(e.fromLocation ?? null); - }); - - return () => unsub(); - }, [router, setPrevLocation]); - return (
diff --git a/apps/oneclient/frontend/src/stores/appShellStore.ts b/apps/oneclient/frontend/src/stores/appShellStore.ts index 5dbe305e..e9380efe 100644 --- a/apps/oneclient/frontend/src/stores/appShellStore.ts +++ b/apps/oneclient/frontend/src/stores/appShellStore.ts @@ -1,22 +1,17 @@ -import type { ParsedLocation } from '@tanstack/react-router'; import { create } from 'zustand'; export interface AppShellStore { background: 'gradientOverlay' | 'none'; - prevLocation: ParsedLocation | null; } export interface AppShellStoreActions { setBackground: (background: AppShellStore['background']) => void; - setPrevLocation: (location: ParsedLocation | null) => void; } export const useAppShellStore = create()(set => ({ background: 'gradientOverlay', - prevLocation: null, setBackground: background => set({ background }), - setPrevLocation: location => set({ prevLocation: location }), })); export default useAppShellStore; diff --git a/apps/oneclient/frontend/src/stores/clusterStore.ts b/apps/oneclient/frontend/src/stores/clusterStore.ts new file mode 100644 index 00000000..2e6535be --- /dev/null +++ b/apps/oneclient/frontend/src/stores/clusterStore.ts @@ -0,0 +1,45 @@ +import type { GameLoader } from '@/bindings.gen'; +import type { ParsedMcVersion } from '@/utils/versionMap'; +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export type MajorVersion = number; +export type MinorVersion = number; + +export interface ClusterStore { + modLoaders: Record<`${MajorVersion}.${MinorVersion}`, GameLoader>; + minorVersions: Array; +} + +export interface ClusterStoreActions { + setModLoader: (version: `${MajorVersion}.${MinorVersion}`, loader: GameLoader) => void; + setMinorVersion: (major: number, minor: number) => void; +} + +export const useClusterStore = create()(persist( + set => ({ + modLoaders: {}, + minorVersions: [], + + setModLoader: (version, loader) => set(state => ({ + modLoaders: { + ...state.modLoaders, + [version]: loader, + }, + })), + setMinorVersion: (major, minor) => set((state) => { + const existing = state.minorVersions.find(v => v.major === major); + if (existing) + existing.minor = minor; + else + state.minorVersions.push({ major, minor }); + + return { minorVersions: [...state.minorVersions] }; + }), + }), + { + name: 'oneclient:cluster_list', + }, +)); + +export default useClusterStore; diff --git a/apps/oneclient/frontend/src/utils/loaders.ts b/apps/oneclient/frontend/src/utils/loaders.ts new file mode 100644 index 00000000..27a433e7 --- /dev/null +++ b/apps/oneclient/frontend/src/utils/loaders.ts @@ -0,0 +1,18 @@ +import type { GameLoader } from '@/bindings.gen'; + +export function prettifyLoader(loader: GameLoader): string { + switch (loader) { + case 'fabric': + return 'Fabric'; + case 'forge': + return 'Forge'; + case 'neoforge': + return 'NeoForge'; + case 'quilt': + return 'Quilt'; + case 'vanilla': + return 'Vanilla'; + case 'legacyfabric': + return 'Legacy Fabric'; + } +} diff --git a/apps/oneclient/frontend/src/utils/locationMapping.ts b/apps/oneclient/frontend/src/utils/locationMapping.ts deleted file mode 100644 index 925b3e7f..00000000 --- a/apps/oneclient/frontend/src/utils/locationMapping.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { FileRouteTypes } from '@/routeTree.gen'; - -const mapping: Partial> = { - '/app': 'Home', - '/app/clusters': 'Clusters', -}; - -export function getLocationName( - to: FileRouteTypes['to'] | (string & {}) | undefined | null, -): string | undefined { - if (!to) - return undefined; - - if (to in mapping) - return mapping[to as keyof typeof mapping]; - - const mostSimilarPath = getMostSimilarPathMapping(to); - return mostSimilarPath; -} - -function getMostSimilarPathMapping( - to: string, -): FileRouteTypes['to'] | undefined { - let path = to; - - while (path !== '') { - if (Object.prototype.hasOwnProperty.call(mapping, path)) - return path as FileRouteTypes['to']; - - const lastSlash = path.lastIndexOf('/'); - - if (lastSlash === 0) - return Object.prototype.hasOwnProperty.call(mapping, '/') ? '/' : undefined; - - path = path.substring(0, lastSlash); - } - - return undefined; -} diff --git a/apps/oneclient/frontend/src/utils/versionMap.ts b/apps/oneclient/frontend/src/utils/versionMap.ts index e232f3db..ecf5b963 100644 --- a/apps/oneclient/frontend/src/utils/versionMap.ts +++ b/apps/oneclient/frontend/src/utils/versionMap.ts @@ -2,17 +2,33 @@ import type { GameBackgroundName } from '@/components'; export interface VersionInfo { backgroundName: GameBackgroundName; - description: string; + prettyName: string; + shortDescription: string; + longDescription: string; + tags: Array; } const VERSION_MAP: Record = { [-1]: { backgroundName: 'HypixelSkyblockHub', - description: 'Unknown Version', + shortDescription: 'Unknown Version', + longDescription: 'This version is not recognized or does not have specific information available.', + prettyName: '0.0', + tags: [], }, 8: { backgroundName: 'HypixelSkyblockHub', - description: 'The Bountiful Update', + prettyName: '1.8', + shortDescription: 'The Bountiful Update', + longDescription: 'The Bountiful Update is a major update that introduces new features, items, and gameplay mechanics to enhance the Minecraft experience. It focuses on expanding the game world and providing players with new challenges and adventures.', + tags: ['Legacy', 'Bountiful Update'], + }, + 21: { + backgroundName: 'CavesAndCliffs', + prettyName: '1.21', + shortDescription: 'The Tricky Trials Update', + longDescription: `Minecraft's 1.21 update, known as "Tricky Trials," primarily focuses on combat adventures and tinkering, introducing trial chambers, new copper block variants, a new crafting tool, and a new weapon. It also features new hostile mobs, paintings, and gameplay enhancements.`, + tags: ['Tricky Trials'], }, } as const; @@ -20,13 +36,12 @@ export function getVersionInfo(version: number | string | null | undefined): Ver if (version === undefined || version === null) return undefined; - let majorVersion; + let majorVersion: number | undefined; - if (typeof version === 'string') { - const parts = version.split('.'); - if (parts.length >= 2) - majorVersion = Number.parseInt(parts[1], 10); - } + if (typeof version === 'string') + majorVersion = parseMcVersion(version)?.major; + else if (typeof version === 'number') + majorVersion = version; if (majorVersion) return VERSION_MAP[majorVersion]; @@ -34,7 +49,29 @@ export function getVersionInfo(version: number | string | null | undefined): Ver return undefined; } -export function getVersionInfoOrDefault(version: number | string): VersionInfo { +export function getVersionInfoOrDefault(version: number | string | null | undefined): VersionInfo { const info = getVersionInfo(version); return info ?? VERSION_MAP[-1]; } + +export interface ParsedMcVersion { + major: number; + minor: number | undefined; +} + +export function parseMcVersion(version: string | null | undefined): ParsedMcVersion | undefined { + if (version === undefined || version === null) + return undefined; + + const parts = version.split('.'); + if (parts.length <= 1) // we need a.b.c where c is optional + return undefined; + + const major = Number.parseInt(parts[1], 10); + const minor = parts.length > 1 ? Number.parseInt(parts[2], 10) : undefined; + + return { + major, + minor: Number.isNaN(minor) ? undefined : minor, + }; +} diff --git a/eslint.config.js b/eslint.config.js index fb2c7481..e9292748 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -31,7 +31,9 @@ export default defineConfig( 'ts/no-use-before-define': 'off', 'style/function-paren-newline': ['error', 'consistent'], 'react/no-context-provider': 'off', - "prefer-const": "off" // disabled due to maximum call stack size + "prefer-const": "off", // disabled due to maximum call stack size, + "style/jsx-one-expression-per-line": ["error", { allow: "non-jsx" }], + "unused-imports/no-unused-imports": "off" }, }, ); diff --git a/packages/core/src/api/cluster/dao.rs b/packages/core/src/api/cluster/dao.rs index ad2d7bf7..4ee4693a 100644 --- a/packages/core/src/api/cluster/dao.rs +++ b/packages/core/src/api/cluster/dao.rs @@ -108,6 +108,19 @@ pub async fn get_cluster_by_folder_name(folder_name: &Path) -> LauncherResult LauncherResult> { + let state = State::get().await?; + let db = &state.db; + + let clusters = clusters::Entity::find() + .filter(clusters::Column::McVersion.eq(version)) + .all(db) + .await?; + + Ok(clusters) +} + /// Gets all clusters from the database. pub async fn get_all_clusters() -> LauncherResult> { let state = State::get().await?; diff --git a/packages/web_common/src/components/AnimatedOutlet.tsx b/packages/web_common/src/components/AnimatedOutlet.tsx index e7b83f97..bea25ec0 100644 --- a/packages/web_common/src/components/AnimatedOutlet.tsx +++ b/packages/web_common/src/components/AnimatedOutlet.tsx @@ -16,8 +16,8 @@ interface TransitionProps { } interface AnimatedOutletProps { - enter: TransitionProps; - exit: TransitionProps; + enter?: TransitionProps; + exit?: TransitionProps; transition?: MotionProps['transition']; from: AnyRoute['id']; clone?: boolean; @@ -127,10 +127,10 @@ export function AnimatedOutlet({
{snapshots.map(snapshot => (