Merge branch 'oneclient/main' of https://github.com/Polyfrost/OneLauncher into refactor/frontend

This commit is contained in:
emirsassan 2025-06-28 09:41:03 +03:00
commit f025673d9d
12 changed files with 312 additions and 126 deletions

View file

@ -13,7 +13,7 @@ export function Navbar() {
const onClose = () => Window.getCurrent().close();
return (
<nav className="flex flex-row items-center justify-between h-20" data-tauri-drag-region="true">
<nav className="flex flex-row items-center justify-between h-20 px-12 z-50" data-tauri-drag-region="true">
<div className="flex flex-1 pointer-events-none">
<LauncherLogo height={47} width={230} />
</div>

View file

@ -0,0 +1,131 @@
import type { MotionValue } from 'motion/react';
import useAppShellStore from '@/stores/appShellStore';
import { getLocationName } from '@/utils/locationMapping';
import { Button } from '@onelauncher/common/components';
import { Outlet, useNavigate } 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';
export function SheetPage({
headerSmall,
headerLarge,
}: {
headerSmall: React.ReactNode;
headerLarge: React.ReactNode;
}) {
const scrollContainer = useRef<HTMLElement>(null);
const headerLargeRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
axis: 'y',
container: scrollContainer,
target: headerLargeRef,
layoutEffect: false,
offset: ['end start', 'start start'],
});
const smallTop = useTransform(scrollYProgress, [0, 1], ['0', '100%']);
const smallOpacity = useTransform(scrollYProgress, [0, 1], ['1', '0']);
const largeOpacity = useTransform(scrollYProgress, [0, 1], ['0', '1']);
return (
<div className="h-full">
<HeaderBackgrounds scrollYProgress={scrollYProgress} />
<div className="flex flex-col w-full min-h-full h-0">
<div className="sticky top-0 left-0 mx-12 flex flex-row gap-4 h-8 overflow-hidden">
<GoBackButton />
<motion.div
className="relative w-full h-full left-0" style={{
top: smallTop,
opacity: smallOpacity,
}}
>
{headerSmall}
</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">
<motion.div className="w-full" ref={headerLargeRef} style={{ opacity: largeOpacity }}>
{/* <headerLarge ref={headerLargeRef as React.RefObject<HTMLDivElement>} /> */}
{headerLarge}
</motion.div>
<motion.div
animate={{ bottom: 0 }}
className="relative flex flex-col px-10 py-6 mb-8 bg-page-elevated rounded-2xl"
initial={{ bottom: '-80px' }}
>
<Outlet />
</motion.div>
</div>
</OverlayScrollbarsComponent>
</div>
</div>
);
}
function GoBackButton() {
const prevLocation = useAppShellStore(state => state.prevLocation);
const navigate = useNavigate();
const onClick = () => {
if (!prevLocation)
return;
navigate({
to: prevLocation.pathname,
search: prevLocation.search,
hash: prevLocation.hash,
state: prevLocation.state,
});
};
if (!prevLocation)
return undefined;
return (
<Button color="ghost" onClick={onClick}>
<ArrowLeftIcon height={20} />
<span className="h-5">{getLocationName(prevLocation.pathname)}</span>
</Button>
);
}
function HeaderBackgrounds({
scrollYProgress,
}: {
scrollYProgress: MotionValue<number>;
}) {
const top = useTransform(scrollYProgress, [1, 0], ['0px', '-60px']);
return (
<motion.div
className="absolute left-0 h-full w-full"
style={{
top,
}}
>
<div
className="absolute -z-10 -top-20 h-50 left-0 w-screen animate-fade animate-duration-200" 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"
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>
</motion.div>
);
}

View file

@ -5,7 +5,7 @@ import { createRootRouteWithContext, Outlet, useRouter } from '@tanstack/react-r
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools';
import { RouterProvider } from 'react-aria-components';
interface MyRouterContext {
interface AppRouterContext {
queryClient: QueryClient;
}
@ -16,7 +16,7 @@ declare module 'react-aria-components' {
}
}
export const Route = createRootRouteWithContext<MyRouterContext>()({
export const Route = createRootRouteWithContext<AppRouterContext>()({
component: RootRoute,
});
@ -28,7 +28,7 @@ function RootRoute() {
navigate={(to, options) => router.navigate({ to, ...options })}
useHref={to => router.buildLocation({ to }).href}
>
<div className="h-screen flex flex-col overflow-hidden text-fg-primary px-12">
<div className="h-screen flex flex-col overflow-hidden text-fg-primary">
<Outlet />
<TanStackRouterDevtools />

View file

@ -5,5 +5,13 @@ export const Route = createFileRoute('/app/clusters/')({
});
function RouteComponent() {
return <div>Hello "/app/clusters/"!</div>;
return (
<div>
<div>Hello "/app/clusters/"!</div>
<div className="w-px h-screen bg-red-100/10"></div>
<div>Hello "/app/clusters/"!</div>
</div>
);
}

View file

@ -1,4 +1,5 @@
import { AnimatedOutlet, AnimatedOutletProvider } from '@onelauncher/common/components';
import { SheetPage } from '@/components/SheetPage';
import { Button } from '@onelauncher/common/components';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/app/clusters')({
@ -7,51 +8,38 @@ export const Route = createFileRoute('/app/clusters')({
function RouteComponent() {
return (
<div className="flex w-full h-full">
<BackgroundGradients />
<AnimatedOutletProvider>
<AnimatedOutlet
enter={{
initial: {
bottom: '-100%',
opacity: 0,
},
animate: {
bottom: 0,
opacity: 1,
},
}}
exit={{
initial: {
bottom: 0,
opacity: 1,
},
animate: {
bottom: '-100%',
opacity: 0,
},
}}
from={Route.id}
/>
</AnimatedOutletProvider>
</div>
<SheetPage
headerLarge={<HeaderLarge />}
headerSmall={<HeaderSmall />}
/>
);
}
function BackgroundGradients() {
function HeaderLarge({ ref }: { ref?: React.Ref<HTMLDivElement> }) {
return (
<div className="relative">
<div style={{
background: 'rgba(0, 0, 0, 0.80)',
}}
>
</div>
<div style={{
background: 'linear-gradient(180deg, rgba(17, 23, 28, 0.00) 0%, #11171C 60.1%)',
}}
>
<div className="flex flex-row justify-between items-end" ref={ref}>
<div className="flex flex-col">
<h1 className="text-3xl font-semibold">Header</h1>
<p className="text-md font-medium text-fg-secondary">Tailored for Hypixel Skyblock.</p>
</div>
<Button color="primary" size="large">
Launch
</Button>
</div>
);
}
function HeaderSmall() {
return (
<div className="flex flex-row w-full items-center justify-between">
<div className="flex flex-row items-center gap-4">
<h1 className="text-2lg h-full font-medium">Header</h1>
</div>
<Button color="primary">
Launch
</Button>
</div>
);
}

View file

@ -15,46 +15,36 @@ export const Route = createFileRoute('/app/')({
component: RouteComponent,
});
const clusters = [
{
mc_version: '1.8.9',
},
undefined,
{
mc_version: '1.21.4',
},
];
function RouteComponent() {
// const { data: clusters } = useCommandSuspense<Array<ClusterModel | undefined>>(
// '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
const { data: clusters } = useCommandSuspense<Array<ClusterModel | undefined>>(
'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
// 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,
// ];
// }) as (data: Array<ClusterModel | undefined>) => Array<ClusterModel | undefined>,
// },
// );
return [
sorted[0],
sorted[1],
undefined,
];
}) as (data: Array<ClusterModel | undefined>) => Array<ClusterModel | undefined>,
},
);
return (
<div className="flex h-full w-full flex-col justify-center">
<div className="flex h-full w-full flex-col justify-center p-12">
<motion.div {...animations.slideInLeft} className="flex flex-1 flex-col justify-center items-start gap-2" transition={{ ...transitions.spring, delay: 0.2 }}>
<h1 className="text-6xl font-bold text-fg-primary">1.8.9</h1>
<p className="text-lg font-medium text-fg-secondary">The Bountiful Update</p>
@ -65,9 +55,9 @@ function RouteComponent() {
</div>
</motion.div>
<motion.div {...animations.slideInUp} className="flex flex-row transition-[height] max-h-84 h-[28vh] max-xl:h-52 gap-6">
{clusters.map(cluster => (
<RecentsCard key={cluster?.mc_version} version={cluster?.mc_version} />
<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} />
))}
<Card blur className="flex flex-col justify-center items-center max-w-24">

View file

@ -1,7 +1,10 @@
import { useEffect, type PropsWithChildren } from 'react';
import { LoaderSuspense, Navbar } from '@/components';
import { GameBackground } from '@/components/GameBackground';
import useAppShellStore from '@/stores/appShellStore';
import { AnimatedOutlet } from '@onelauncher/common/components';
import { createFileRoute } from '@tanstack/react-router';
import { createFileRoute, useRouter } from '@tanstack/react-router';
import { AnimatePresence } from 'motion/react';
export const Route = createFileRoute('/app')({
component: RouteComponent,
@ -10,12 +13,8 @@ export const Route = createFileRoute('/app')({
function RouteComponent() {
return (
<LoaderSuspense spinner={{ size: 'large' }}>
<div className="flex flex-col h-full w-full">
<Navbar />
<Background />
<div className="h-full w-full pb-12">
<AppShell>
<div className="h-full w-full">
<AnimatedOutlet
enter={{
initial: { opacity: 0 },
@ -29,15 +28,49 @@ function RouteComponent() {
transition={{ duration: 0.3, bounce: 0.1, power: 0.2, type: 'spring' }}
/>
</div>
</div>
</AppShell>
</LoaderSuspense>
);
}
function Background() {
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]);
return (
<div className="flex flex-col h-full w-full">
<AnimatePresence>
<BackgroundGradient />
</AnimatePresence>
<Navbar />
<div className="flex flex-col w-full h-full">
{children}
</div>
</div>
);
}
function BackgroundGradient() {
const background = useAppShellStore(state => state.background);
if (background === 'none')
return undefined;
return (
<div>
{/* Linear gradient: left -> right */}
{/* Linear black gradient: left -> right */}
<div
className="absolute top-0 left-0 w-screen h-screen -z-10"
style={{
@ -46,7 +79,7 @@ function Background() {
>
</div>
{/* Radial Gradient */}
{/* Radial black gradient */}
<div
className="absolute top-0 left-0 w-screen h-screen -z-10" style={{
background: 'radial-gradient(48.29% 48.29% at 77.29% 50%, rgba(0, 0, 0, 0.00) 0%, rgba(0, 0, 0, 0.64) 100%)',
@ -54,7 +87,7 @@ function Background() {
>
</div>
{/* Linear gradient: bottom -> 200 px up */}
{/* Linear black gradient: bottom -> 200 px up */}
<div
className="absolute bottom-0 left-0 w-screen h-50 -z-10" style={{
background: 'linear-gradient(180deg, rgba(17, 23, 28, 0.00) 0%, rgba(0, 0, 0, 0.68) 60%)',

View file

@ -0,0 +1,22 @@
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

@ -1,28 +0,0 @@
import type { ClusterModel } from '@/bindings.gen';
import type { FileRoutesByFullPath } from '@/routeTree.gen';
import { create } from 'zustand';
interface AppStore {
activeCluster: ClusterModel | null;
previousPage: FileRoutesByFullPath | null;
}
interface AppStoreActions {
setInitialData: (data: AppStore) => void;
setActiveCluster: (cluster: ClusterModel | null) => void;
setPreviousPage: (page: FileRoutesByFullPath) => void;
}
const useAppStore = create<AppStore & AppStoreActions>()(set => ({
activeCluster: null,
previousPage: null,
setInitialData: (data: AppStore) => set({
activeCluster: data.activeCluster,
previousPage: data.previousPage,
}),
setPreviousPage: (page: FileRoutesByFullPath) => set({ previousPage: page }),
setActiveCluster: (cluster: ClusterModel | null) => set({ activeCluster: cluster }),
}));
export default useAppStore;

View file

@ -0,0 +1,39 @@
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

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

View file

@ -7,7 +7,7 @@
import type { AnyRoute } from '@tanstack/react-router';
import type { MotionProps } from 'motion/react';
import { Outlet, useMatch, useRouter } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'motion/react';
import { motion } from 'motion/react';
import { createContext, useContext, useEffect, useRef, useState } from 'react';
interface TransitionProps {
@ -21,6 +21,7 @@ interface AnimatedOutletProps {
transition?: MotionProps['transition'];
from: AnyRoute['id'];
clone?: boolean;
children?: (children: React.ReactNode) => React.ReactNode;
}
interface AnimatedOutletWrapperProps {
@ -82,6 +83,7 @@ export function AnimatedOutlet({
transition = { duration: 0.3 },
from,
clone = true,
children,
}: AnimatedOutletProps) {
const [snapshots, setSnapshots] = useState<Array<{ node: HTMLElement; id: number }>>([]);
const [pathname, setPathname] = useState<string | null>(null);
@ -148,7 +150,7 @@ export function AnimatedOutlet({
transition={transition}
>
<div className="w-full h-full">
<Outlet />
{children?.(<Outlet />) ?? <Outlet />}
</div>
</motion.div>
</div>