feat: clusters frontend
This commit is contained in:
parent
864249dd13
commit
e1defccc3f
22 changed files with 841 additions and 261 deletions
|
|
@ -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<R: Runtime>(webview_window: tauri::WebviewWindow<R>);
|
||||
|
||||
#[taurpc(alias = "getClustersGroupedByMajor")]
|
||||
async fn get_clusters_grouped_by_major() -> LauncherResult<HashMap<u32, Vec<clusters::Model>>>;
|
||||
}
|
||||
|
||||
#[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<R: Runtime>(self, webview_window: tauri::WebviewWindow<R>) {
|
||||
#[cfg(feature = "devtools")]
|
||||
webview_window.open_devtools();
|
||||
}
|
||||
|
||||
async fn get_clusters_grouped_by_major(self) -> LauncherResult<HashMap<u32, Vec<clusters::Model>>> {
|
||||
let clusters = onelauncher_core::api::cluster::dao::get_all_clusters().await?;
|
||||
|
||||
let mut mapped: HashMap<u32, Vec<clusters::Model>> = 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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(|_, _| {});
|
||||
}
|
||||
|
||||
|
|
|
|||
62
apps/oneclient/desktop/src/oneclient/mod.rs
Normal file
62
apps/oneclient/desktop/src/oneclient/mod.rs
Normal file
|
|
@ -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<u8>,
|
||||
mc_loaders: Vec<GameLoader>,
|
||||
}
|
||||
|
||||
async fn init_clusters() {
|
||||
// TODO: use online metadata stuff
|
||||
let online_clusters: Vec<OnlineCluster> = 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<Icon>,
|
||||
) -> LauncherResult<Option<ClusterModel>> {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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() {
|
|||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center pointer-events-none gap-6">
|
||||
<NavbarLink to="/app">Home</NavbarLink>
|
||||
<NavbarLink to="/app/clusters">Clusters</NavbarLink>
|
||||
<NavbarLink to="/">Accounts</NavbarLink>
|
||||
<NavbarLink to=".">Home</NavbarLink>
|
||||
<NavbarLink to="./clusters">Clusters</NavbarLink>
|
||||
<NavbarLink to=".">Accounts</NavbarLink>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-end gap-2 pointer-events-none">
|
||||
|
|
@ -54,7 +54,7 @@ export function Navbar() {
|
|||
function NavbarLink({
|
||||
to,
|
||||
children,
|
||||
}: LinkProps) {
|
||||
}: LinkProps<'a', RegisteredRouter, '/app'>) {
|
||||
return (
|
||||
<div className="flex-1 flex justify-center items-center">
|
||||
<Link
|
||||
|
|
@ -65,8 +65,9 @@ function NavbarLink({
|
|||
className: 'text-fg-primary font-semibold partial-underline-75% pointer-events-none',
|
||||
}}
|
||||
className="text-center text-2lg transition-all duration-100 after:duration-100 after:transition-all"
|
||||
from="/app"
|
||||
inactiveProps={{
|
||||
className: 'text-fg-secondary font-normal partial-underline-0% hover:partial-underline-50% hover:font-medium hover:text-fg-secondary-hover after:text-fg-secondary-hover pointer-events-auto',
|
||||
className: 'text-fg-secondary font-normal partial-underline-0% hover:partial-underline-60% hover:text-fg-secondary-hover after:text-fg-secondary-hover pointer-events-auto',
|
||||
}}
|
||||
to={to}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement | null>;
|
||||
}) {
|
||||
const scrollContainer = useRef<HTMLElement>(null);
|
||||
const headerLargeRef = useRef<HTMLDivElement>(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({
|
|||
</motion.div>
|
||||
</div>
|
||||
|
||||
<OverlayScrollbarsComponent className="flex flex-col flex-1 mask-t-from-97% pt-4 relative" ref={ref => scrollContainer.current = ref?.osInstance()?.elements().content ?? null}>
|
||||
<div className="flex flex-col mx-12 gap-4">
|
||||
<OverlayScrollbarsComponent className="flex flex-col flex-1 mask-t-from-97% pt-4 relative" ref={ref => setScrollContainerRef(ref?.osInstance()?.elements().content ?? null)}>
|
||||
<div className="flex flex-col mx-12 gap-4 flex-1 min-h-full">
|
||||
<motion.div className="w-full" ref={headerLargeRef} style={{ opacity: largeOpacity }}>
|
||||
{headerLarge}
|
||||
</motion.div>
|
||||
|
|
@ -71,9 +77,18 @@ SheetPage.Content = ({
|
|||
...rest
|
||||
}: Omit<HTMLMotionProps<'div'>, 'animate' | 'initial'>) => (
|
||||
<motion.div
|
||||
animate={{ bottom: 0 }}
|
||||
className={twMerge('relative flex flex-col px-10 py-6 mb-8 bg-page-elevated rounded-2xl', className)}
|
||||
initial={{ bottom: '-80px' }}
|
||||
animate={{
|
||||
bottom: 0,
|
||||
opacity: 1,
|
||||
}}
|
||||
className={twMerge('relative flex flex-col px-10 py-6 last:mb-8 h-full bg-page-elevated rounded-2xl', className)}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
}}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
}}
|
||||
transition={{ duration: 0.25 }}
|
||||
{...rest}
|
||||
>
|
||||
{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 (
|
||||
<Button color="ghost" onClick={onClick}>
|
||||
<Button color="ghost" onPress={onClick}>
|
||||
<ArrowLeftIcon height={20} />
|
||||
<span className="h-5">{getLocationName(prevLocation.pathname)}</span>
|
||||
<span className="h-5">Back</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -122,21 +129,21 @@ function HeaderBackgrounds({
|
|||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute -z-10 -top-20 h-50 left-0 w-screen animate-fade animate-duration-200" style={{
|
||||
className="absolute -z-10 -top-20 h-50 left-0 w-screen" style={{
|
||||
background: 'rgba(0, 0, 0, 0.20)',
|
||||
}}
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative h-50 top-0 left-0 w-screen animate-fade animate-duration-200"
|
||||
className="relative h-50 top-0 left-0 w-screen"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, rgba(17, 23, 28, 0.00) 0%, #11171C 60.1%)',
|
||||
}}
|
||||
>
|
||||
</div>
|
||||
|
||||
<div className="relative top-0 left-0 w-full h-full bg-page animate-fade animate-duration-200"></div>
|
||||
<div className="relative top-0 left-0 w-full h-full bg-page "></div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
44
apps/oneclient/frontend/src/components/Tabs.tsx
Normal file
44
apps/oneclient/frontend/src/components/Tabs.tsx
Normal file
|
|
@ -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<HTMLDivElement> & {
|
||||
floating?: boolean;
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}) {
|
||||
return (
|
||||
<div className={twMerge('flex flex-row gap-2 px-10 border border-transparent bg-page-elevated transition-all', floating ? 'py-3 shadow-lg border-ghost-overlay rounded-xl' : 'py-6 rounded-2xl', className)} ref={ref}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tab({
|
||||
to,
|
||||
children,
|
||||
}: Omit<LinkProps, 'activeOptions' | 'activeProps' | 'inactiveProps' | 'className'>) {
|
||||
return (
|
||||
<div className="relative flex justify-center items-center">
|
||||
<Link
|
||||
activeOptions={{
|
||||
exact: true,
|
||||
}}
|
||||
activeProps={{
|
||||
className: 'text-fg-primary font-semibold partial-underline-75% pointer-events-none',
|
||||
}}
|
||||
className="text-center text-lg transition-all duration-100 after:duration-100 after:transition-all"
|
||||
inactiveProps={{
|
||||
className: 'text-fg-secondary font-normal partial-underline-0% hover:partial-underline-60% hover:text-fg-secondary-hover after:text-fg-secondary-hover pointer-events-auto',
|
||||
}}
|
||||
to={to}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,3 +2,4 @@ export * from './GameBackground';
|
|||
export * from './Loader';
|
||||
export * from './Navbar';
|
||||
export * from './Spinner';
|
||||
export * from './Tabs';
|
||||
|
|
|
|||
27
apps/oneclient/frontend/src/routes/app/cluster/overview.tsx
Normal file
27
apps/oneclient/frontend/src/routes/app/cluster/overview.tsx
Normal file
|
|
@ -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 (
|
||||
<SheetPage.Content>
|
||||
<div className="flex flex-col gap-4">
|
||||
<h1 className="text-2xl font-semibold">Cluster Overview</h1>
|
||||
<p>Welcome to the cluster overview page. Here you can find information about your clusters.</p>
|
||||
<div style={{ height: '1500px' }}>
|
||||
|
||||
</div>
|
||||
<p>test</p>
|
||||
{/* Additional content can be added here */}
|
||||
</div>
|
||||
</SheetPage.Content>
|
||||
);
|
||||
}
|
||||
132
apps/oneclient/frontend/src/routes/app/cluster/route.tsx
Normal file
132
apps/oneclient/frontend/src/routes/app/cluster/route.tsx
Normal file
|
|
@ -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<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLElement>(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 (
|
||||
<SheetPage
|
||||
headerLarge={<HeaderLarge />}
|
||||
headerSmall={<HeaderSmall />}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
>
|
||||
<TabList className="sticky top-0 z-10 shadow-lg" floating={floating} ref={tabListRef}>
|
||||
<Tab from={Route.id} search={search} to="/app/cluster/overview">Overview</Tab>
|
||||
<Tab>Logs</Tab>
|
||||
</TabList>
|
||||
|
||||
<div className="relative">
|
||||
<Outlet />
|
||||
</div>
|
||||
</SheetPage>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-row justify-between items-end gap-16">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-row gap-2 flex-wrap">
|
||||
{versionInfo.tags.map(tag => (
|
||||
<span className="text-sm font-medium text-fg-secondary bg-component-bg/40 px-2 py-1 rounded" key={tag}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold">{prettifyLoader(cluster.mc_loader)} {cluster.mc_version}</h1>
|
||||
<p className="text-md font-medium text-fg-secondary">{versionInfo.longDescription}</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={launch}
|
||||
size="large"
|
||||
>
|
||||
Launch
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderSmall() {
|
||||
const { cluster } = Route.useRouteContext();
|
||||
|
||||
const launch = () => {
|
||||
bindings.core.launchCluster(cluster.id as unknown as bigint, null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-row justify-between items-center h-full">
|
||||
<h1 className="text-2lg h-full font-medium">{prettifyLoader(cluster.mc_loader)} {cluster.mc_version}</h1>
|
||||
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={launch}
|
||||
>
|
||||
Launch
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
269
apps/oneclient/frontend/src/routes/app/clusters.tsx
Normal file
269
apps/oneclient/frontend/src/routes/app/clusters.tsx
Normal file
|
|
@ -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<number>(() => {
|
||||
const keys = Object.keys(clusters);
|
||||
if (keys.length === 0)
|
||||
return 0;
|
||||
|
||||
return Number.parseInt(keys[0]);
|
||||
});
|
||||
|
||||
const [activeMinorVersion, setActiveMinorVersion] = useState<number | undefined>();
|
||||
|
||||
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<GameLoader> = 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 (
|
||||
<SheetPage
|
||||
headerLarge={<HeaderLarge />}
|
||||
headerSmall={<HeaderSmall />}
|
||||
>
|
||||
<div className="relative flex flex-row gap-4">
|
||||
<div className="flex flex-col flex-1">
|
||||
|
||||
<div className="grid grid-cols-2 2xl:grid-cols-3 gap-4">
|
||||
{Object.keys(clusters).map((majorStr) => {
|
||||
const major = Number.parseInt(majorStr, 10);
|
||||
const isSelected = major === majorVersion;
|
||||
const info = getVersionInfo(major);
|
||||
|
||||
if (!info)
|
||||
return undefined;
|
||||
|
||||
return (
|
||||
<ClusterEntry
|
||||
isSelected={isSelected}
|
||||
key={major}
|
||||
major_version={major}
|
||||
onClick={() => setMajorVersion(major)}
|
||||
tags={[...new Set(clusters[major]?.flatMap(c => prettifyLoader(c.mc_loader)))]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetPage.Content className="sticky top-8 w-86 h-full flex flex-col p-2 gap-2 outline outline-ghost-overlay">
|
||||
<GameBackground className="aspect-video w-full rounded-xl outline-2 outline-ghost-overlay" name={versionInfo.backgroundName} />
|
||||
|
||||
<div className="flex flex-col px-4 pt-2 pb-4 gap-2">
|
||||
<h2 className="text-xxl font-medium">Version {cluster?.mc_version}</h2>
|
||||
<p className="text-sm text-fg-secondary">{versionInfo.longDescription}</p>
|
||||
|
||||
{minorVersions.length > 1 && (
|
||||
<div className="flex flex-row items-center justify-between mt-3">
|
||||
<p>Minor Version</p>
|
||||
|
||||
<Dropdown
|
||||
aria-label="Minor Version Dropdown"
|
||||
onSelectionChange={(selected) => {
|
||||
setActiveMinorVersion(Number(selected));
|
||||
setMinorVersion(majorVersion, Number(selected));
|
||||
}}
|
||||
selectedKey={activeMinorVersion}
|
||||
>
|
||||
{minorVersions.map((minorVersion) => {
|
||||
const fullVer = `${versionInfo.prettyName}.${minorVersion}`;
|
||||
return (
|
||||
<Dropdown.Item
|
||||
id={minorVersion}
|
||||
key={minorVersion}
|
||||
textValue={fullVer}
|
||||
>
|
||||
{fullVer}
|
||||
</Dropdown.Item>
|
||||
);
|
||||
})}
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loaders.length > 1 && (
|
||||
<div className="flex flex-row items-center justify-between mb-2">
|
||||
<p>Mod Loader</p>
|
||||
<Dropdown
|
||||
aria-label="Mod Loader Dropdown"
|
||||
onSelectionChange={(selected) => {
|
||||
if (!activeMinorVersion)
|
||||
return;
|
||||
|
||||
setModLoader(`${majorVersion}.${activeMinorVersion}`, selected as GameLoader);
|
||||
}}
|
||||
selectedKey={activeMinorVersion ? (modLoaders[`${majorVersion}.${activeMinorVersion}`] ?? loaders[0]) : loaders[0]}
|
||||
>
|
||||
{loaders.map(loader => (
|
||||
<Dropdown.Item
|
||||
id={loader}
|
||||
key={loader}
|
||||
>
|
||||
{prettifyLoader(loader)}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full flex flex-row gap-4 mt-2">
|
||||
<Button className="flex-1" size="large">Launch</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
color="secondary"
|
||||
onPress={() => {
|
||||
if (!cluster)
|
||||
return;
|
||||
|
||||
navigate({
|
||||
to: `/app/cluster/overview`,
|
||||
search: {
|
||||
clusterId: cluster.id as unknown as number,
|
||||
},
|
||||
});
|
||||
}}
|
||||
size="large"
|
||||
>
|
||||
View
|
||||
<ArrowRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</SheetPage.Content>
|
||||
</div>
|
||||
</SheetPage>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderLarge({ ref }: { ref?: React.Ref<HTMLDivElement> }) {
|
||||
return (
|
||||
<div className="flex flex-row justify-between items-end gap-8" ref={ref}>
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-3xl font-semibold">Clusters</h1>
|
||||
<p className="text-md font-medium text-fg-secondary">Something something in corporate style fashion about picking your preferred gamemodes and versions and optionally loader so that oneclient can pick something for them</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderSmall() {
|
||||
return (
|
||||
<h1 className="text-2lg h-full font-medium">Clusters</h1>
|
||||
);
|
||||
}
|
||||
|
||||
function ClusterEntry({
|
||||
isSelected,
|
||||
major_version,
|
||||
onClick,
|
||||
tags,
|
||||
}: {
|
||||
isSelected: boolean;
|
||||
major_version: number;
|
||||
onClick: () => unknown;
|
||||
tags: Array<string>;
|
||||
}) {
|
||||
const info = getVersionInfo(major_version);
|
||||
|
||||
if (!info)
|
||||
return undefined;
|
||||
|
||||
return (
|
||||
<div className={twMerge('flex flex-col justify-between relative aspect-video transition-[filter] px-4', !isSelected && 'brightness-70 grayscale-25 hover:brightness-80 hover:grayscale-0')} key={major_version} onClick={onClick}>
|
||||
<GameBackground className={twMerge('opacity-90 absolute w-full h-full -z-10 rounded-md transition-[outline] outline-2', isSelected ? 'outline-brand' : 'outline-ghost-overlay')} name={info.backgroundName} />
|
||||
<div
|
||||
className="absolute -z-10 top-0 left-0 w-full h-full rounded-md overflow-hidden " style={{
|
||||
background: 'radial-gradient(48.93% 47.95% at 49.92% 28.42%, rgba(0, 0, 0, 0.00) 0%, rgba(0, 0, 0, 0.30) 52.26%, rgba(0, 0, 0, 0.60) 100%)',
|
||||
}}
|
||||
>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row flex-wrap gap-2 mt-3">
|
||||
{info.tags.concat(tags).map(tag => (
|
||||
<div className="bg-fg-primary text-brand px-2 text-sm py-0.5 rounded-full" key={tag}>
|
||||
{tag}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 className="text-fg-primary text-2xl font-semibold mb-2">Version {info.prettyName}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<SheetPage
|
||||
headerLarge={<HeaderLarge />}
|
||||
headerSmall={<HeaderSmall />}
|
||||
>
|
||||
<div className="relative flex flex-row gap-4">
|
||||
<div className="flex flex-col flex-1">
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="rounded-xl overflow-hidden aspect-video brightness-50 inset-2 inset-ring inset-red-500">
|
||||
<GameBackground className="w-full h-full -z-10 relative" name="CavesAndCliffs" />
|
||||
</div>
|
||||
|
||||
<div className="aspect-video">
|
||||
<GameBackground className="rounded-xl w-full h-full border-2 border-ghost-overlay" name="HypixelSkyblockHub" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span>Hello World!</span>
|
||||
<div className="h-screen w-px">
|
||||
|
||||
</div>
|
||||
<span>test</span>
|
||||
</div>
|
||||
|
||||
<SheetPage.Content className="sticky top-8 w-86 h-full flex flex-col p-2 gap-2 border border-ghost-overlay">
|
||||
<GameBackground className="aspect-video w-full rounded-xl border-2 border-ghost-overlay" name="CavesAndCliffs" />
|
||||
|
||||
<div className="flex flex-col px-4 pt-2 pb-4 gap-2">
|
||||
<h2 className="text-xxl font-medium">Version 1.21</h2>
|
||||
<p className="text-sm text-fg-secondary">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-row items-center justify-between mt-3">
|
||||
<p>Minor Version</p>
|
||||
<Button className="w-26" color="secondary">1.21.5</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between mb-4">
|
||||
<p>Mod Loader</p>
|
||||
<Button className="w-26" color="secondary">Forge</Button>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-row gap-4">
|
||||
<Button className="flex-1" size="large">Launch</Button>
|
||||
<Button className="flex-1" color="secondary" size="large">
|
||||
View
|
||||
<ArrowRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</SheetPage.Content>
|
||||
</div>
|
||||
</SheetPage>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderLarge({ ref }: { ref?: React.Ref<HTMLDivElement> }) {
|
||||
return (
|
||||
<div className="flex flex-row justify-between items-end gap-8" ref={ref}>
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-3xl font-semibold">Clusters</h1>
|
||||
<p className="text-md font-medium text-fg-secondary">Something something in corporate style fashion about picking your preferred gamemodes and versions and optionally loader so that oneclient can pick something for them</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderSmall() {
|
||||
return (
|
||||
<h1 className="text-2lg h-full font-medium">Clusters</h1>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Array<ClusterModel | undefined>>(
|
||||
const { data: clusters } = useCommandSuspense(
|
||||
'getClusters',
|
||||
bindings.core.getClusters,
|
||||
{
|
||||
select: ((data: Array<ClusterModel>) => {
|
||||
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<ClusterModel | undefined>) => Array<ClusterModel | undefined>,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -52,14 +55,25 @@ function RouteComponent() {
|
|||
|
||||
<div className="flex flex-row justify-center items-center gap-2">
|
||||
<Button size="large">Launch</Button>
|
||||
<Button color="ghost" size="iconLarge"><Settings04Icon /></Button>
|
||||
<Button color="ghost" size="iconLarge">
|
||||
<Settings04Icon />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div {...animations.slideInUp} className="flex flex-row transition-[height] h-52 gap-6">
|
||||
{clusters.map((cluster, index) => (
|
||||
<RecentsCard key={cluster?.folder_name ?? index} version={cluster?.mc_version} />
|
||||
))}
|
||||
{clusters.map((cluster) => {
|
||||
// eslint-disable-next-line ts/no-unnecessary-condition -- rule broken
|
||||
return cluster === undefined
|
||||
? (
|
||||
<Card blur className="group flex justify-center items-center" key={`undefined_${Math.random()}`}>
|
||||
<PlusIcon className="text-fg-secondary opacity-40 stroke-2 group-hover:text-fg-primary group-hover:opacity-90" height={64} width={64} />
|
||||
</Card>
|
||||
)
|
||||
: (
|
||||
<RecentsCard key={cluster.folder_name} loader={cluster.mc_loader} version={cluster.mc_version} />
|
||||
);
|
||||
})}
|
||||
|
||||
<Card blur className="flex flex-col justify-center items-center max-w-24">
|
||||
<DotsGridIcon height={48} width={48} />
|
||||
|
|
@ -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 <Card blur>Unknown Version</Card>;
|
||||
|
||||
return (
|
||||
<Card blur={versionInfo === undefined}>
|
||||
{versionInfo && (
|
||||
<div className="flex w-full h-full justify-start items-end px-6 py-3">
|
||||
<MouseParallax isAbsolutelyPositioned strength={0.005} zIndex={-10}>
|
||||
<GameBackground className="absolute left-0 top-0 w-full h-full scale-110" name={versionInfo.backgroundName} />
|
||||
</MouseParallax>
|
||||
<Card>
|
||||
<div className="flex w-full h-full justify-start items-end px-6 py-3">
|
||||
<MouseParallax isAbsolutelyPositioned strength={0.005} zIndex={-10}>
|
||||
<GameBackground className="absolute left-0 top-0 w-full h-full scale-110" name={versionInfo.backgroundName} />
|
||||
</MouseParallax>
|
||||
|
||||
<div
|
||||
className="absolute top-0 left-0 -z-10 w-full h-full"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, rgba(25, 25, 25, 0.00) 24.52%, rgba(17, 17, 21, 0.75) 65%)',
|
||||
}}
|
||||
>
|
||||
</div>
|
||||
|
||||
<h4 className="text-3xl font-semibold">{version}</h4>
|
||||
<div
|
||||
className="absolute top-0 left-0 -z-10 w-full h-full"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, rgba(25, 25, 25, 0.00) 24.52%, rgba(17, 17, 21, 0.75) 65%)',
|
||||
}}
|
||||
>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h4 className="text-3xl font-semibold">{version} {prettifyLoader(loader)}</h4>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }}
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
|
|
@ -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 (
|
||||
<div className="flex flex-col h-full w-full">
|
||||
<BackgroundGradient />
|
||||
|
|
|
|||
|
|
@ -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<AppShellStore & AppShellStoreActions>()(set => ({
|
||||
background: 'gradientOverlay',
|
||||
prevLocation: null,
|
||||
|
||||
setBackground: background => set({ background }),
|
||||
setPrevLocation: location => set({ prevLocation: location }),
|
||||
}));
|
||||
|
||||
export default useAppShellStore;
|
||||
|
|
|
|||
45
apps/oneclient/frontend/src/stores/clusterStore.ts
Normal file
45
apps/oneclient/frontend/src/stores/clusterStore.ts
Normal file
|
|
@ -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<ParsedMcVersion>;
|
||||
}
|
||||
|
||||
export interface ClusterStoreActions {
|
||||
setModLoader: (version: `${MajorVersion}.${MinorVersion}`, loader: GameLoader) => void;
|
||||
setMinorVersion: (major: number, minor: number) => void;
|
||||
}
|
||||
|
||||
export const useClusterStore = create<ClusterStore & ClusterStoreActions>()(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;
|
||||
18
apps/oneclient/frontend/src/utils/loaders.ts
Normal file
18
apps/oneclient/frontend/src/utils/loaders.ts
Normal file
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import type { FileRouteTypes } from '@/routeTree.gen';
|
||||
|
||||
const mapping: Partial<Record<FileRouteTypes['to'], string>> = {
|
||||
'/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;
|
||||
}
|
||||
|
|
@ -2,17 +2,33 @@ import type { GameBackgroundName } from '@/components';
|
|||
|
||||
export interface VersionInfo {
|
||||
backgroundName: GameBackgroundName;
|
||||
description: string;
|
||||
prettyName: string;
|
||||
shortDescription: string;
|
||||
longDescription: string;
|
||||
tags: Array<string>;
|
||||
}
|
||||
|
||||
const VERSION_MAP: Record<number, VersionInfo> = {
|
||||
[-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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -108,6 +108,19 @@ pub async fn get_cluster_by_folder_name(folder_name: &Path) -> LauncherResult<Op
|
|||
Ok(model)
|
||||
}
|
||||
|
||||
/// Gets all clusters by version from the database.
|
||||
pub async fn get_clusters_by_version(version: &str) -> LauncherResult<Vec<clusters::Model>> {
|
||||
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<Vec<clusters::Model>> {
|
||||
let state = State::get().await?;
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<div className="relative w-full h-full">
|
||||
{snapshots.map(snapshot => (
|
||||
<motion.div
|
||||
animate={exit.animate}
|
||||
animate={exit?.animate || { opacity: 1 }}
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 pointer-events-none w-full h-full"
|
||||
initial={exit.initial}
|
||||
initial={exit?.initial || { opacity: 1 }}
|
||||
key={snapshot.id}
|
||||
onAnimationComplete={() => handleAnimationComplete(snapshot.id)}
|
||||
ref={(el) => {
|
||||
|
|
@ -142,9 +142,9 @@ export function AnimatedOutlet({
|
|||
))}
|
||||
|
||||
<motion.div
|
||||
animate={enter.animate}
|
||||
animate={enter?.animate}
|
||||
className="relative w-full h-full"
|
||||
initial={enter.initial}
|
||||
initial={enter?.initial}
|
||||
key={nextId.current}
|
||||
ref={outletRef}
|
||||
transition={transition}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ type RegisterConfig = Register extends {
|
|||
commands: infer Keys;
|
||||
} ? Keys : never;
|
||||
|
||||
type CommandCacheKey = RegisterConfig[number] | (string & {}) | false;
|
||||
|
||||
async function fetchCommand<T>(command: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
const result = await command();
|
||||
|
|
@ -26,12 +28,15 @@ async function fetchCommand<T>(command: () => Promise<T>): Promise<T> {
|
|||
}
|
||||
}
|
||||
|
||||
export function useCommand<T>(
|
||||
cacheKey: RegisterConfig[number] | (string & {}) | false,
|
||||
command: () => Promise<T>,
|
||||
options?: Omit<UndefinedInitialDataOptions<T>, 'queryKey' | 'queryFn'>,
|
||||
): UseQueryResult<T, Error> {
|
||||
return useQuery({
|
||||
export function useCommand<TQueryFnData, TSelected = TQueryFnData>(
|
||||
cacheKey: CommandCacheKey,
|
||||
command: () => Promise<TQueryFnData>,
|
||||
options?: Omit<
|
||||
UndefinedInitialDataOptions<TQueryFnData, Error, TSelected, [CommandCacheKey] | []>,
|
||||
'queryKey' | 'queryFn'
|
||||
>,
|
||||
): UseQueryResult<TSelected, Error> {
|
||||
return useQuery<TQueryFnData, Error, TSelected, [CommandCacheKey] | []>({
|
||||
queryKey: cacheKey === false ? [] : [cacheKey],
|
||||
queryFn: () => fetchCommand(command),
|
||||
enabled: true,
|
||||
|
|
@ -39,24 +44,35 @@ export function useCommand<T>(
|
|||
});
|
||||
}
|
||||
|
||||
export function useCommandSuspense<T>(
|
||||
cacheKey: RegisterConfig[number] | (string & {}) | false,
|
||||
command: () => Promise<T>,
|
||||
options?: Omit<UseSuspenseQueryOptions<T>, 'queryKey' | 'queryFn'>,
|
||||
): UseSuspenseQueryResult<T, Error> {
|
||||
return useSuspenseQuery({
|
||||
export function useCommandSuspense<TQueryFnData, TSelected = TQueryFnData>(
|
||||
cacheKey: CommandCacheKey,
|
||||
command: () => Promise<TQueryFnData>,
|
||||
options?: Omit<
|
||||
UseSuspenseQueryOptions<TQueryFnData, Error, TSelected, [CommandCacheKey] | []>,
|
||||
'queryKey' | 'queryFn'
|
||||
>,
|
||||
): UseSuspenseQueryResult<TSelected, Error> {
|
||||
return useSuspenseQuery<TQueryFnData, Error, TSelected, [CommandCacheKey] | []>({
|
||||
queryKey: cacheKey === false ? [] : [cacheKey],
|
||||
queryFn: () => fetchCommand(command),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCommandMut<TData, TError = Error, TVariables = void, TContext = unknown>(
|
||||
command: () => Promise<TData>,
|
||||
options?: Omit<UseMutationOptions<TData, TError, TVariables, TContext>, 'mutationFn'>,
|
||||
export function useCommandMut<
|
||||
TData,
|
||||
TError = Error,
|
||||
TVariables = void,
|
||||
TContext = unknown,
|
||||
>(
|
||||
command: (variables: TVariables) => Promise<TData>,
|
||||
options?: Omit<
|
||||
UseMutationOptions<TData, TError, TVariables, TContext>,
|
||||
'mutationFn'
|
||||
>,
|
||||
): UseMutationResult<TData, TError, TVariables, TContext> {
|
||||
return useMutation<TData, TError, TVariables, TContext>({
|
||||
mutationFn: () => fetchCommand(command),
|
||||
mutationFn: command,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue