feat: clusters frontend

This commit is contained in:
LynithDev 2025-07-16 20:36:17 +02:00
parent 864249dd13
commit e1defccc3f
22 changed files with 841 additions and 261 deletions

View file

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

View file

@ -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(|_, _| {});
}

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

View file

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

View file

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

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

View file

@ -2,3 +2,4 @@ export * from './GameBackground';
export * from './Loader';
export * from './Navbar';
export * from './Spinner';
export * from './Tabs';

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

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

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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