feat: home page animations

This commit is contained in:
LynithDev 2025-08-21 11:19:00 +02:00
parent fcab0ba6ed
commit aa72db1e0b
6 changed files with 180 additions and 93 deletions

View file

@ -167,7 +167,7 @@ export type SettingProfileModel = { name: string; java_id: number | null; res: R
export type Settings = { global_game_settings: SettingProfileModel; allow_parallel_running_clusters: boolean; enable_gamemode: boolean; discord_enabled: boolean; max_concurrent_requests: number; settings_version: number; native_window_frame: boolean }
export type SettingsOsExtra = Record<string, never>
export type SettingsOsExtra = { enable_gamemode: boolean | null }
export type Sort = "Relevance" | "Downloads" | "Newest" | "Updated"
@ -230,14 +230,11 @@ export type VersionType =
*/
"old_beta"
const ARGS_MAP = { 'core':'{"getPackageBody":["provider","body"],"getLogByName":["id","name"],"getClusters":[],"updateClusterProfile":["name","profile"],"getPackage":["provider","slug"],"getClusterById":["id"],"getGameVersions":[],"getUsers":[],"setDefaultUser":["uuid"],"openMsaLogin":[],"removeCluster":["id"],"getScreenshots":["id"],"removeUser":["uuid"],"searchPackages":["provider","query"],"getUsersFromAuthor":["provider","author"],"launchCluster":["id","uuid"],"getLogs":["id"],"updateClusterById":["id","request"],"getLoadersForVersion":["mc_version"],"getUser":["uuid"],"getProfileOrDefault":["name"],"fetchMinecraftProfile":["uuid"],"getWorlds":["id"],"writeSettings":["setting"],"getDefaultUser":["fallback"],"readSettings":[],"getGlobalProfile":[],"getMultiplePackages":["provider","slugs"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"createCluster":["options"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'events':'{"message":["event"],"ingress":["event"],"process":["event"]}', 'oneclient':'{"getClustersGroupedByMajor":[],"openDevTools":[]}' }
export type Router = { 'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> },
'oneclient': { openDevTools: () => Promise<void>,
const ARGS_MAP = { 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'events':'{"message":["event"],"ingress":["event"],"process":["event"]}', 'oneclient':'{"openDevTools":[],"getClustersGroupedByMajor":[]}', 'core':'{"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getProfileOrDefault":["name"],"updateClusterById":["id","request"],"createCluster":["options"],"getLogByName":["id","name"],"getGameVersions":[],"getScreenshots":["id"],"writeSettings":["setting"],"getMultiplePackages":["provider","slugs"],"getLogs":["id"],"removeUser":["uuid"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getDefaultUser":["fallback"],"getLoadersForVersion":["mc_version"],"getClusters":[],"setDefaultUser":["uuid"],"getGlobalProfile":[],"getClusterById":["id"],"updateClusterProfile":["name","profile"],"getUsers":[],"getUsersFromAuthor":["provider","author"],"openMsaLogin":[],"launchCluster":["id","uuid"],"searchPackages":["provider","query"],"getWorlds":["id"],"getUser":["uuid"],"getPackageBody":["provider","body"],"readSettings":[],"removeCluster":["id"],"getPackage":["provider","slug"],"fetchMinecraftProfile":["uuid"]}' }
export type Router = { 'oneclient': { openDevTools: () => Promise<void>,
getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>> },
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> },
'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> },
'core': { getClusters: () => Promise<ClusterModel[]>,
getClusterById: (id: number) => Promise<ClusterModel | null>,
removeCluster: (id: number) => Promise<null>,
@ -268,7 +265,10 @@ getMultiplePackages: (provider: Provider, slugs: string[]) => Promise<ManagedPac
getPackageVersions: (provider: Provider, slug: string, mcVersion: string | null, loader: GameLoader | null, offset: number, limit: number) => Promise<Paginated<ManagedVersion>>,
downloadPackage: (provider: Provider, packageId: string, versionId: string, clusterId: number, skipCompatibility: boolean | null) => Promise<PackageModel>,
getUsersFromAuthor: (provider: Provider, author: PackageAuthor) => Promise<ManagedUser[]>,
fetchMinecraftProfile: (uuid: string) => Promise<MojangPlayerProfile> } };
fetchMinecraftProfile: (uuid: string) => Promise<MojangPlayerProfile> },
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> } };
export type { InferCommandOutput }

View file

@ -0,0 +1,33 @@
import { bindings } from '@/main';
import useAppShellStore from '@/stores/appShellStore';
import { useCommandSuspense } from '@onelauncher/common';
export function useLastPlayedClusters() {
return useCommandSuspense(
['getClusters', 'sortedByLastPlayed'],
bindings.core.getClusters,
{
select: data => 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);
}),
},
);
}
export function useActiveCluster() {
const clusterId = useAppShellStore(state => state.activeClusterId);
const { data: clusters } = useLastPlayedClusters();
return clusters.find(c => c.id === clusterId) ?? clusters.at(0);
}

View file

@ -1,16 +1,17 @@
import type { GameLoader } from '@/bindings.gen';
import type { HTMLAttributes } from 'react';
import type { ClusterModel, GameLoader } from '@/bindings.gen';
import type { ButtonProps } from 'react-aria-components';
import { GameBackground } from '@/components';
import { useActiveCluster, useLastPlayedClusters } from '@/hooks/useClusters';
import { bindings } from '@/main';
import useAppShellStore from '@/stores/appShellStore';
import { prettifyLoader } from '@/utils/loaders';
import { animations, transitions } from '@/utils/motion';
import { getVersionInfo } from '@/utils/versionMap';
import { useCommandSuspense } from '@onelauncher/common';
import { getVersionInfo, getVersionInfoOrDefault } from '@/utils/versionMap';
import { Button } from '@onelauncher/common/components';
import { createFileRoute } from '@tanstack/react-router';
import { DotsGridIcon, PlusIcon, Settings04Icon } from '@untitled-theme/icons-react';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { DotsGridIcon, Settings04Icon } from '@untitled-theme/icons-react';
import { motion } from 'motion/react';
import { MouseParallax } from 'react-just-parallax';
import { Button as AriaButton } from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
export const Route = createFileRoute('/app/')({
@ -18,64 +19,35 @@ export const Route = createFileRoute('/app/')({
});
function RouteComponent() {
const { data: clusters } = useCommandSuspense(
['getClusters'],
bindings.core.getClusters,
{
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 { data: lastPlayedClusters } = useLastPlayedClusters();
const aVersion = a.mc_version.replaceAll('.', '');
const bVersion = b.mc_version.replaceAll('.', '');
return Number.parseInt(bVersion) - Number.parseInt(aVersion);
});
const setActiveClusterId = useAppShellStore(state => state.setActiveClusterId);
const activeCluster = useActiveCluster();
return [
sorted[0],
sorted[1],
sorted[3],
];
},
},
);
const navigate = useNavigate({
from: Route.id,
});
return (
<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>
<div className="flex flex-row justify-center items-center gap-2">
<Button size="large">Launch</Button>
<Button color="ghost" size="iconLarge">
<Settings04Icon />
</Button>
</div>
</motion.div>
<ActiveClusterInfo cluster={activeCluster} />
<motion.div {...animations.slideInUp} className="flex flex-row transition-[height] h-52 gap-6">
{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} />
);
})}
{lastPlayedClusters.slice(0, 3).map(cluster => (
<RecentsCard
active={activeCluster?.id === cluster.id}
key={cluster.folder_name}
loader={cluster.mc_loader}
onPress={() => setActiveClusterId(cluster.id)}
version={cluster.mc_version}
/>
))}
<Card blur className="flex flex-col justify-center items-center max-w-24">
<Card
blur
className="flex flex-col justify-center items-center max-w-24"
onPress={() => navigate({ to: '/app/clusters' })}
>
<DotsGridIcon height={48} width={48} />
</Card>
</motion.div>
@ -83,24 +55,81 @@ function RouteComponent() {
);
}
function ActiveClusterInfo({
cluster,
}: {
cluster: ClusterModel | undefined;
}) {
const versionInfo = getVersionInfoOrDefault(cluster?.mc_version);
const navigate = useNavigate({
from: Route.id,
});
const launch = () => {
if (!cluster)
return;
bindings.core.launchCluster(cluster.id, null);
};
const viewCluster = () => {
if (!cluster)
return;
navigate({
to: '/app/cluster/overview',
search: {
clusterId: cluster.id,
},
});
};
return (
<motion.div
{...animations.slideInLeft}
className="flex flex-1 flex-col justify-center items-start gap-2"
key={(cluster?.mc_version ?? Math.random()) + (cluster?.mc_loader ?? '')}
transition={{ ...transitions.spring, delay: 0.2 }}
>
<h1 className="text-6xl font-bold text-fg-primary">{cluster?.mc_version} {prettifyLoader(cluster?.mc_loader ?? 'vanilla')}</h1>
<p className="text-lg font-medium text-fg-secondary">{versionInfo.shortDescription}</p>
<div className="flex flex-row justify-center items-center gap-2">
<Button onPress={launch} size="large">Launch</Button>
<Button color="ghost" onPress={viewCluster} size="iconLarge">
<Settings04Icon />
</Button>
</div>
</motion.div>
);
}
interface RecentsCardProps {
version: string;
loader: GameLoader;
onPress: () => void;
active: boolean;
}
const BLUR = '30px';
function RecentsCard({ version, loader }: RecentsCardProps) {
function RecentsCard({
version,
loader,
onPress,
active,
}: RecentsCardProps) {
const versionInfo = getVersionInfo(version);
if (!versionInfo)
return <Card blur>Unknown Version</Card>;
return (
<Card blur>
<p className="text-lg font-medium text-fg-secondary">Unknown Version</p>
</Card>
);
return (
<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>
<Card className={twMerge(active && 'outline-2 outline-brand')} onPress={onPress}>
<div className="flex w-full h-full justify-start items-end px-6 py-3 hover:brightness-80">
<GameBackground className="absolute -z-10 left-0 top-0 w-full h-full scale-110" name={versionInfo.backgroundName} />
<div
className="absolute top-0 left-0 -z-10 w-full h-full"
@ -110,30 +139,33 @@ function RecentsCard({ version, loader }: RecentsCardProps) {
>
</div>
<h4 className="text-3xl font-semibold">{version} {prettifyLoader(loader)}</h4>
<h4 className="text-2xl font-semibold">{version} {prettifyLoader(loader)}</h4>
</div>
</Card>
);
}
const BLUR = '30px';
function Card({
blur = true,
children,
className,
style,
onPress,
}: {
blur?: boolean;
children?: React.ReactNode;
} & HTMLAttributes<HTMLDivElement>) {
className?: string | undefined;
} & ButtonProps & React.RefAttributes<HTMLButtonElement>) {
return (
<div
<AriaButton
className={twMerge(
'relative overflow-hidden flex-1 rounded-xl inset-ring-2 inset-ring-component-border',
'relative overflow-hidden flex-1 rounded-xl outline outline-component-border',
blur
? 'bg-white/5 hover:bg-white/15 active:bg-white/20'
: 'hover:bg-ghost-overlay-hover active:bg-ghost-overlay-pressed',
className,
)}
onPress={onPress}
style={blur
? {
// shitty hack because webkit breaks with css variables in its backdrop filter
@ -144,6 +176,6 @@ function Card({
: style}
>
{children}
</div>
</AriaButton>
);
}

View file

@ -1,8 +1,10 @@
import type { PropsWithChildren } from 'react';
import { LoaderSuspense, Navbar } from '@/components';
import { GameBackground } from '@/components/GameBackground';
import useAppShellStore from '@/stores/appShellStore';
import { useActiveCluster } from '@/hooks/useClusters';
import { getVersionInfoOrDefault } from '@/utils/versionMap';
import { createFileRoute, Outlet } from '@tanstack/react-router';
import { motion } from 'motion/react';
import { MouseParallax } from 'react-just-parallax';
export const Route = createFileRoute('/app')({
@ -50,10 +52,8 @@ function AppShell({
}
function BackgroundGradient() {
const background = useAppShellStore(state => state.background);
if (background === 'none')
return undefined;
const cluster = useActiveCluster();
const versionInfo = getVersionInfoOrDefault(cluster?.mc_version);
return (
<div className="relative">
@ -83,10 +83,23 @@ function BackgroundGradient() {
</div>
<MouseParallax isAbsolutelyPositioned strength={0.01} zIndex={-50}>
<GameBackground
className="absolute left-0 top-0 w-screen h-screen scale-110"
name="HypixelSkyblockHub"
/>
<motion.div
animate={{
opacity: 1,
left: '0',
}}
className="relative scale-105"
initial={{
opacity: 0,
left: '-10%',
}}
key={(cluster?.mc_version ?? Math.random()) + (cluster?.mc_loader ?? '')}
>
<GameBackground
className="absolute left-0 top-0 w-screen h-screen scale-110"
name={versionInfo.backgroundName}
/>
</motion.div>
</MouseParallax>
</div>
);

View file

@ -1,17 +1,17 @@
import { create } from 'zustand';
export interface AppShellStore {
background: 'gradientOverlay' | 'none';
activeClusterId: number;
}
export interface AppShellStoreActions {
setBackground: (background: AppShellStore['background']) => void;
setActiveClusterId: (cluster: AppShellStore['activeClusterId']) => void;
}
export const useAppShellStore = create<AppShellStore & AppShellStoreActions>()(set => ({
background: 'gradientOverlay',
activeClusterId: 0,
setBackground: background => set({ background }),
setActiveClusterId: cluster => set({ activeClusterId: cluster }),
}));
export default useAppShellStore;

View file

@ -41,4 +41,13 @@ export const animations = {
bottom: '0',
},
},
fadeIn: {
initial: {
opacity: 0,
},
animate: {
opacity: 1,
},
},
} as const satisfies Record<string, MotionProps>;