Merge branch 'onelauncher/main' of https://github.com/Polyfrost/OneLauncher into onelauncher/main

This commit is contained in:
Szedann 2025-07-20 18:07:26 +02:00
commit 7c1aee0190

View file

@ -1,4 +1,4 @@
import type { GameLoader, Version, VersionType } from '@/bindings.gen';
import type { GameLoader, Version } from '@/bindings.gen';
import type { JSX, RefAttributes } from 'react';
import DefaultBanner from '@/assets/images/default_banner.png';
import LauncherIcon from '@/components/content/LauncherIcon';
@ -10,17 +10,17 @@ import { useCommand, useCommandMut } from '@onelauncher/common';
import { Button, Dropdown, SelectList, TextField } from '@onelauncher/common/components';
import { useQueryClient } from '@tanstack/react-query';
import { ArrowRightIcon, PlusIcon, SearchMdIcon, Server01Icon, TextInputIcon, User03Icon } from '@untitled-theme/icons-react';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Checkbox } from 'react-aria-components';
import LoaderIcon from '../LoaderIcon';
const STEPS = [
{
id: 'loader',
id: 'loader' as const,
title: 'Select Provider',
},
{
id: 'info',
id: 'info' as const,
title: 'Game Setup',
},
] as const;
@ -32,6 +32,12 @@ interface ClusterFormData {
clusterProvider: string;
}
interface FormValidation {
isProviderValid: boolean;
isNameValid: boolean;
isVersionValid: boolean;
}
export function NewClusterCreate() {
const queryClient = useQueryClient();
const [currentStepIndex, setCurrentStepIndex] = useState(0);
@ -42,6 +48,12 @@ export function NewClusterCreate() {
clusterProvider: '',
});
const validation = useMemo((): FormValidation => ({
isProviderValid: Boolean(formData.clusterProvider),
isNameValid: Boolean(formData.clusterName.trim()),
isVersionValid: Boolean(formData.clusterVersion.trim()),
}), [formData.clusterProvider, formData.clusterName, formData.clusterVersion]);
const create = useCommandMut(() => bindings.core.createCluster({
icon: null,
mc_loader: formData.loader,
@ -54,126 +66,183 @@ export function NewClusterCreate() {
},
});
function handleCreate() {
if (!isLastInputStep && !formData.clusterName)
const handleCreate = useCallback(() => {
if (!validation.isNameValid || !validation.isVersionValid) {
console.warn('Form validation failed');
return;
}
create.mutate();
}, [create, validation.isNameValid, validation.isVersionValid]);
if (create.isError)
console.error(create.error.message);
}
const handleNext = useCallback(async () => {
if (currentStepIndex === STEPS.length - 1)
const handleNext = useCallback(() => {
if (currentStepIndex === STEPS.length - 1) {
handleCreate();
return;
}
if (currentStepIndex < STEPS.length - 1)
setCurrentStepIndex(currentStepIndex + 1);
}, [currentStepIndex]);
setCurrentStepIndex(prev => prev + 1);
}, [currentStepIndex, handleCreate]);
const handleBack = useCallback(() => {
if (currentStepIndex > 0)
setCurrentStepIndex(currentStepIndex - 1);
setCurrentStepIndex(prev => prev - 1);
}, [currentStepIndex]);
const formHandlers = useMemo(() => ({
onProviderChange: (provider: string) =>
setFormData(prev => ({ ...prev, clusterProvider: provider })),
onLoaderChange: (loader: GameLoader) =>
setFormData(prev => ({ ...prev, loader })),
onNameChange: (name: string) =>
setFormData(prev => ({ ...prev, clusterName: name })),
onVersionChange: (version: string) =>
setFormData(prev => ({ ...prev, clusterVersion: version })),
}), []);
const currentStepConfig = STEPS[currentStepIndex];
let stepContent: JSX.Element;
switch (currentStepConfig.id) {
case 'loader': {
stepContent = (
<ClusterLoader onSelectProvider={provider => setFormData(prev => ({ ...prev, clusterProvider: provider }))} selectedProvider={formData.clusterProvider} />
);
break;
const stepContent = useMemo((): JSX.Element => {
switch (currentStepConfig.id) {
case 'loader':
return (
<ClusterLoader
onSelectProvider={formHandlers.onProviderChange}
selectedProvider={formData.clusterProvider}
/>
);
case 'info':
return (
<ClusterInformation
clusterLoader={formData.loader}
clusterName={formData.clusterName}
clusterVersion={formData.clusterVersion}
onClusterLoaderChange={formHandlers.onLoaderChange}
onClusterNameChange={formHandlers.onNameChange}
onClusterVersionChange={formHandlers.onVersionChange}
/>
);
default:
return <div>Unknown step</div>;
}
case 'info': {
stepContent = (
<ClusterInformation
clusterLoader={formData.loader}
clusterName={formData.clusterName}
clusterVersion={formData.clusterVersion}
onClusterLoaderChange={loader => setFormData(prev => ({ ...prev, loader }))}
onClusterNameChange={name => setFormData(prev => ({ ...prev, clusterName: name }))}
onClusterVersionChange={version =>
setFormData(prev => ({ ...prev, clusterVersion: version }))}
/>
);
break;
}
}
}, [currentStepConfig.id, formData, formHandlers]);
const isFirstStep = currentStepIndex === 0;
const isLastInputStep = currentStepIndex === STEPS.length - 1;
const nextButtonText = isLastInputStep ? 'Create' : 'Next';
let isNextDisabled = false;
if (currentStepConfig.id === 'loader' && !formData.clusterProvider)
isNextDisabled = true;
else if (
currentStepConfig.id === 'info'
&& (!formData.clusterName.trim() || !formData.clusterVersion.trim())
)
isNextDisabled = false;
const isNextDisabled = useMemo(() => {
switch (currentStepConfig.id) {
case 'loader':
return !validation.isProviderValid;
case 'info':
return !validation.isNameValid || !validation.isVersionValid;
default:
return false;
}
}, [currentStepConfig.id, validation]);
return (
<>
<div className="flex flex-row justify-between w-full">
<div>
<TextField className="p-1" iconLeft={<SearchMdIcon className="size-4" />} placeholder="Search for something..." />
</div>
<Modal.Trigger>
<Button>
<PlusIcon className="stroke-width-[2.2] size-5" />
New cluster
</Button>
<Modal>
<div className="min-w-sm flex flex-col rounded-lg bg-page text-center">
<div className="theme-OneLauncher-Dark relative h-25 flex">
<div className="absolute left-0 top-0 h-full w-full">
<img alt="Header Image" className="h-full w-full rounded-t-lg" src={DefaultBanner} />
</div>
<div
className="absolute left-0 top-0 h-full flex w-full flex-row items-center justify-start gap-x-4 bg-[radial-gradient(at_center,#00000077,transparent)] px-10"
>
<Server01Icon className="h-8 w-8 text-fg-primary" />
<div className="flex flex-col items-start justify-center">
<h1 className="h-10 text-fg-primary text-2xl font-semibold">New Cluster</h1>
<span className="text-fg-primary">{STEPS[currentStepIndex].title}</span>
</div>
</div>
</div>
<div className="flex flex-col rounded-b-lg border border-white/5">
<div className="p-3">
{stepContent}
</div>
<div className="flex flex-row justify-end gap-x-2 p-3 pt-0">
<Button
children="Previous"
color="ghost"
isDisabled={isFirstStep}
onClick={handleBack}
/>
<Button
color="primary"
isDisabled={isNextDisabled}
onClick={handleNext}
>
{nextButtonText}
{' '}
<ArrowRightIcon />
</Button>
</div>
</div>
</div>
</Modal>
</Modal.Trigger>
<div className="flex flex-row justify-between w-full">
<div>
<TextField
aria-label="Cluster Search"
className="p-1"
iconLeft={<SearchMdIcon className="size-4" />}
placeholder="Search for something..."
/>
</div>
</>
<Modal.Trigger>
<Button>
<PlusIcon className="stroke-width-[2.2] size-5" />
New cluster
</Button>
<Modal>
<div className="min-w-sm flex flex-col rounded-lg bg-page text-center">
<ModalHeader currentStep={currentStepConfig} />
<div className="flex flex-col rounded-b-lg border border-white/5">
<div className="p-3">
{stepContent}
</div>
<ModalFooter
isFirstStep={isFirstStep}
isNextDisabled={isNextDisabled}
nextButtonText={nextButtonText}
onBack={handleBack}
onNext={handleNext}
/>
</div>
</div>
</Modal>
</Modal.Trigger>
</div>
);
}
interface ModalHeaderProps {
currentStep: typeof STEPS[number];
}
function ModalHeader({ currentStep }: ModalHeaderProps) {
return (
<div className="theme-OneLauncher-Dark relative h-25 flex">
<div className="absolute left-0 top-0 h-full w-full">
<img
alt="Header Image"
className="h-full w-full rounded-t-lg"
src={DefaultBanner}
/>
</div>
<div className="absolute left-0 top-0 h-full flex w-full flex-row items-center justify-start gap-x-4 bg-[radial-gradient(at_center,#00000077,transparent)] px-10">
<Server01Icon className="h-8 w-8 text-fg-primary" />
<div className="flex flex-col items-start justify-center">
<h1 className="h-10 text-fg-primary text-2xl font-semibold">New Cluster</h1>
<span className="text-fg-primary">{currentStep.title}</span>
</div>
</div>
</div>
);
}
interface ModalFooterProps {
isFirstStep: boolean;
isNextDisabled: boolean;
nextButtonText: string;
onBack: () => void;
onNext: () => void;
}
function ModalFooter({
isFirstStep,
isNextDisabled,
nextButtonText,
onBack,
onNext,
}: ModalFooterProps) {
return (
<div className="flex flex-row justify-end gap-x-2 p-3 pt-0">
<Button
color="ghost"
isDisabled={isFirstStep}
onClick={onBack}
>
Previous
</Button>
<Button
color="primary"
isDisabled={isNextDisabled}
onClick={onNext}
>
{nextButtonText}
{' '}
<ArrowRightIcon />
</Button>
</div>
);
}
@ -183,49 +252,60 @@ interface ClusterLoaderProps {
}
function ClusterLoader({ selectedProvider, onSelectProvider }: ClusterLoaderProps) {
return (
<>
<div className="grid grid-cols-3 gap-2">
<ProviderCard
icon={<User03Icon />}
name="New"
selected={selectedProvider === 'new'}
setSelected={() => onSelectProvider('new')}
/>
const providers = useMemo(() => [
{ id: 'new', name: 'New', icon: <User03Icon /> },
...LAUNCHER_IMPORT_TYPES.map(data => ({
id: data,
name: data,
icon: <LauncherIcon launcher={data} />,
})),
], []);
{LAUNCHER_IMPORT_TYPES.map((data, i) => (
<ProviderCard
icon={<LauncherIcon launcher={data} />}
key={i}
name={data}
selected={selectedProvider === data}
setSelected={() => onSelectProvider(data)}
/>
))}
</div>
</>
return (
<div className="grid grid-cols-3 gap-2">
{providers.map(provider => (
<ProviderCard
icon={provider.icon}
key={provider.id}
name={provider.name}
onSelect={() => onSelectProvider(provider.id)}
selected={selectedProvider === provider.id}
/>
))}
</div>
);
}
interface ProviderCardProps {
setSelected: () => void;
onSelect: () => void;
selected: boolean;
icon: JSX.Element;
name: string;
}
function ProviderCard(props: ProviderCardProps) {
const { selected, setSelected, icon, name } = props;
function ProviderCard({ selected, onSelect, icon, name }: ProviderCardProps) {
const cardClassName = useMemo(() => {
const baseClasses = 'flex flex-col justify-center items-center gap-y-3 py-2 px-4 hover:bg-component-bg-hover active:bg-component-bg-pressed rounded-lg cursor-pointer transition-colors';
return selected ? `${baseClasses} bg-component-bg` : baseClasses;
}, [selected]);
return (
<div
className={`flex flex-col justify-center items-center gap-y-3 py-2 px-4 hover:bg-component-bg-hover active:bg-component-bg-pressed rounded-lg ${selected ? 'bg-component-bg' : ''}`}
onClick={() => setSelected()}
className={cardClassName}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect();
}
}}
role="button"
tabIndex={0}
>
<div className="h-8 w-8 flex items-center justify-center [&>svg]:(w-8 h-8!)">
{icon}
</div>
<span>{name}</span>
<span className="text-sm font-medium">{name}</span>
</div>
);
}
@ -253,6 +333,7 @@ function ClusterInformation({
<div className="flex flex-col gap-y-4">
<Option header="Name">
<TextField
aria-label="Cluster Name"
className="w-full"
iconLeft={<TextInputIcon className="size-4" />}
onChange={e => onClusterNameChange(e.target.value)}
@ -296,61 +377,62 @@ interface VersionReleaseFilters {
snapshot: boolean;
}
function VersionSelector(props: VersionSelectorProps) {
const { versions, selectedVersion, onChange } = props;
const [filteredVersions, setFilteredVersions] = useState<Array<Version>>([]);
const [filters, setFilters] = useState<VersionReleaseFilters>({
old_alpha: false,
old_beta: false,
release: true,
snapshot: false,
});
const DEFAULT_FILTERS: VersionReleaseFilters = {
old_alpha: false,
old_beta: false,
release: true,
snapshot: false,
};
const refresh = useCallback((filters: VersionReleaseFilters, versions: Array<Version>) => {
setFilteredVersions(() => {
if (Object.values(filters).every(value => value === false))
return versions;
function VersionSelector({ versions, selectedVersion, onChange }: VersionSelectorProps) {
const [filters, setFilters] = useState<VersionReleaseFilters>(DEFAULT_FILTERS);
return versions.filter(version => filters[version.type]);
});
}, []);
const filteredVersions = useMemo(() => {
if (!versions)
return [];
const toggleFilter = (name: keyof VersionReleaseFilters) => {
if (Object.values(filters).every(value => !value))
return versions;
return versions.filter(version => filters[version.type]);
}, [versions, filters]);
const toggleFilter = useCallback((name: keyof VersionReleaseFilters) => {
setFilters(prev => ({
...prev,
[name]: !prev[name],
}));
};
}, []);
useEffect(() => {
if (versions)
refresh(filters, versions);
}, [filters, versions, refresh]);
useEffect(() => {
if (versions)
setFilteredVersions(versions);
}, [versions]);
const filterEntries = useMemo(() =>
Object.entries(filters) as Array<[keyof VersionReleaseFilters, boolean]>, [filters]);
return (
<div className="flex flex-1 flex-row gap-2">
<SelectList className="min-h-40 min-w-3/5 max-h-40 border border-component-border bg-component-bg" onChange={onChange} value={selectedVersion}>
<SelectList
className="min-h-40 min-w-3/5 max-h-40 border border-component-border bg-component-bg"
onChange={onChange}
value={selectedVersion}
>
<ScrollableContainer>
{filteredVersions.map(version => (
<SelectList.Item key={version.id} value={version.id}>{version.id}</SelectList.Item>
<SelectList.Item key={version.id} value={version.id}>
{version.id}
</SelectList.Item>
))}
</ScrollableContainer>
</SelectList>
<div className="flex flex-1 flex-col gap-y-2 overflow-hidden border border-component-border bg-component-bg rounded-lg p-2">
{Object.keys(filters).map(filter => (
{filterEntries.map(([filterName, isSelected]) => (
<Checkbox
className="selected:bg-component-bg-pressed rounded-lg"
isSelected={filters[filter as VersionType]}
key={filter}
onChange={() => toggleFilter(filter as VersionType)}
aria-label={`Filter ${filterName.replace('_', ' ')}`}
className="selected:bg-component-bg-pressed rounded-lg p-1 text-sm"
isSelected={isSelected}
key={filterName}
onChange={() => toggleFilter(filterName)}
>
{filter}
<span className="capitalize">{filterName.replace('_', ' ')}</span>
</Checkbox>
))}
</div>
@ -363,11 +445,13 @@ type OptionProps = {
children?: JSX.Element | Array<JSX.Element>;
} & RefAttributes<HTMLDivElement>;
function Option(props: OptionProps) {
function Option({ header, children, ...divProps }: OptionProps) {
return (
<div {...props} className="flex flex-col gap-y-2 items-stretch">
<h3 className="text-left text-md text-fg-secondary font-semibold uppercase">{props.header}</h3>
{props.children}
<div {...divProps} className="flex flex-col gap-y-2 items-stretch">
<h3 className="text-left text-md text-fg-secondary font-semibold uppercase">
{header}
</h3>
{children}
</div>
);
}