feat: cluster editing
This commit is contained in:
parent
014cd863e6
commit
8385f9415d
15 changed files with 621 additions and 203 deletions
|
|
@ -12,7 +12,6 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@fontsource/poppins": "^5.2.6",
|
||||
"@formatjs/intl-durationformat": "^0.7.4",
|
||||
"@onelauncher/common": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@tanstack/match-sorter-utils": "^8.19.4",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Button, Menu } from '@onelauncher/common/components';
|
|||
import { Link } from '@tanstack/react-router';
|
||||
import { Bell01Icon, Cloud01Icon, TerminalBrowserIcon } from '@untitled-theme/icons-react';
|
||||
import { MenuTrigger } from 'react-aria-components';
|
||||
import OneLauncherText from '../assets/logos/onelauncher_text.svg';
|
||||
import PlayerHead from './content/PlayerHead';
|
||||
import AccountPopup from './overlay/popups/AccountPopup';
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ function Navbar() {
|
|||
<div className="flex items-start justify-start">
|
||||
<div className="flex items-start justify-start transition-transform active:scale-90">
|
||||
{/* <OneLauncherText width={260} /> */}
|
||||
<img src={OneLauncherText} width={260} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-x-10 py-1">
|
||||
|
|
|
|||
|
|
@ -32,10 +32,6 @@ function WindowFrame() {
|
|||
<TitlebarButton icon={ChevronLeftIcon} onClick={back} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-row items-center justify-center pointer-events-none">
|
||||
<span className="text-base text-center">OneLauncher</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-row items-center justify-end pointer-events-none">
|
||||
<TitlebarButton icon={MinusIcon} onClick={minimize} />
|
||||
<TitlebarButton icon={isMaximized ? Minimize01Icon : Maximize02Icon} onClick={toggleMaximize} />
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import type { JSX } from 'react';
|
||||
import type { IngressPayload, Version, VersionType } from '@/bindings.gen';
|
||||
import type { JSX, RefAttributes } from 'react';
|
||||
import DefaultBanner from '@/assets/images/default_banner.png';
|
||||
import DefaultClusterBanner from '@/assets/images/default_instance_cover.jpg';
|
||||
import LauncherIcon from '@/components/content/LauncherIcon';
|
||||
import Modal from '@/components/overlay/Modal';
|
||||
import ScrollableContainer from '@/components/ScrollableContainer';
|
||||
import { bindings } from '@/main';
|
||||
import { LAUNCHER_IMPORT_TYPES } from '@/utils';
|
||||
import { useCommand } from '@onelauncher/common';
|
||||
import { Button, Dropdown, TextField } from '@onelauncher/common/components';
|
||||
import { Server01Icon, User03Icon } from '@untitled-theme/icons-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
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 { Checkbox } from 'react-aria-components';
|
||||
import LoaderIcon from '../LoaderIcon';
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
|
|
@ -28,7 +34,35 @@ export function NewClusterCreate() {
|
|||
clusterVersion: '',
|
||||
});
|
||||
|
||||
const create = useCommand('createCluster', () => bindings.core.createCluster({
|
||||
icon: DefaultClusterBanner,
|
||||
mc_loader: 'vanilla',
|
||||
mc_loader_version: null,
|
||||
mc_version: formData.clusterVersion,
|
||||
name: formData.clusterName,
|
||||
}), {
|
||||
enabled: false,
|
||||
subscribed: false,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
function handleCreate() {
|
||||
if (!isLastInputStep)
|
||||
return;
|
||||
|
||||
create.refetch();
|
||||
|
||||
if (create.isError)
|
||||
console.error(create.error.message);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['getClusters'], exact: true });
|
||||
}
|
||||
|
||||
const handleNext = useCallback(async () => {
|
||||
if (currentStepIndex === STEPS.length - 1)
|
||||
handleCreate();
|
||||
|
||||
if (currentStepIndex < STEPS.length - 1)
|
||||
setCurrentStepIndex(currentStepIndex + 1);
|
||||
}, [currentStepIndex]);
|
||||
|
|
@ -68,7 +102,7 @@ export function NewClusterCreate() {
|
|||
|
||||
const isFirstStep = currentStepIndex === 0;
|
||||
const isLastInputStep = currentStepIndex === STEPS.length - 1;
|
||||
const nextButtonText = isLastInputStep ? 'Create Cluster' : 'Next';
|
||||
const nextButtonText = isLastInputStep ? 'Create' : 'Next';
|
||||
|
||||
let isNextDisabled = false;
|
||||
if (currentStepConfig.id === 'loader' && !formData.loader)
|
||||
|
|
@ -77,54 +111,64 @@ export function NewClusterCreate() {
|
|||
currentStepConfig.id === 'info'
|
||||
&& (!formData.clusterName.trim() || !formData.clusterVersion.trim())
|
||||
)
|
||||
isNextDisabled = true;
|
||||
isNextDisabled = false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Trigger>
|
||||
<Button
|
||||
children="New Cluster"
|
||||
/>
|
||||
<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>
|
||||
<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} />
|
||||
<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="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 -mt-2">New Cluster</h1>
|
||||
<span className="text-fg-primary">{STEPS[currentStepIndex].title}</span>
|
||||
<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>
|
||||
<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="Back"
|
||||
color="secondary"
|
||||
isDisabled={isFirstStep}
|
||||
onClick={handleBack}
|
||||
/>
|
||||
<Button
|
||||
children={nextButtonText}
|
||||
color="primary"
|
||||
isDisabled={isNextDisabled}
|
||||
onClick={handleNext}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Modal.Trigger>
|
||||
</Modal>
|
||||
</Modal.Trigger>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -195,18 +239,145 @@ function ClusterInformation({
|
|||
clusterVersion,
|
||||
onClusterVersionChange,
|
||||
}: ClusterInformationProps) {
|
||||
const versions = useCommand('getGameVersions', bindings.core.getGameVersions);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<TextField
|
||||
className="w-full"
|
||||
onChange={e => onClusterNameChange(e.target.value)}
|
||||
placeholder="My Awesome Server"
|
||||
value={clusterName}
|
||||
/>
|
||||
<Dropdown placeholder="Select a version">
|
||||
<Dropdown.Item>Slmalr</Dropdown.Item>
|
||||
<Dropdown.Item>Slmalr 2</Dropdown.Item>
|
||||
</Dropdown>
|
||||
<Option header="Name">
|
||||
<TextField
|
||||
className="w-full"
|
||||
iconLeft={<TextInputIcon className="size-4" />}
|
||||
onChange={e => onClusterNameChange(e.target.value)}
|
||||
placeholder="Name"
|
||||
value={clusterName}
|
||||
/>
|
||||
</Option>
|
||||
|
||||
<Option header="Versions">
|
||||
<VersionSelector onChange={e => onClusterVersionChange(e as string)} selectedVersion={clusterVersion} versions={versions.data} />
|
||||
</Option>
|
||||
|
||||
<Option header="Loader">
|
||||
<Dropdown className="w-full" defaultSelectedKey="vanilla">
|
||||
<Dropdown.Item>
|
||||
<div className="flex flex-row">
|
||||
<LoaderIcon className="size-5" loader="vanilla" />
|
||||
{' '}
|
||||
Vanilla
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<div className="flex flex-row">
|
||||
<LoaderIcon className="size-5" loader="fabric" />
|
||||
{' '}
|
||||
Fabric
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<div className="flex flex-row">
|
||||
<LoaderIcon className="size-5" loader="quilt" />
|
||||
{' '}
|
||||
Quilt
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<div className="flex flex-row">
|
||||
<LoaderIcon className="size-5" loader="forge" />
|
||||
{' '}
|
||||
Forge
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
</Dropdown>
|
||||
</Option>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VersionSelectorProps {
|
||||
versions: Array<Version> | undefined;
|
||||
selectedVersion: string | Array<string>;
|
||||
onChange: (value: string | Array<string>) => void;
|
||||
}
|
||||
|
||||
interface VersionReleaseFilters {
|
||||
old_alpha: boolean;
|
||||
old_beta: boolean;
|
||||
release: boolean;
|
||||
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 refresh = useCallback((filters: VersionReleaseFilters, versions: Array<Version>) => {
|
||||
setFilteredVersions(() => {
|
||||
if (Object.values(filters).every(value => value === false))
|
||||
return versions;
|
||||
|
||||
return versions.filter(version => filters[version.type]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleFilter = (name: keyof VersionReleaseFilters) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[name]: !prev[name],
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (versions)
|
||||
refresh(filters, versions);
|
||||
}, [filters, versions, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (versions)
|
||||
setFilteredVersions(versions);
|
||||
}, [versions]);
|
||||
|
||||
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}>
|
||||
<ScrollableContainer>
|
||||
{filteredVersions.map(version => (
|
||||
<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 => (
|
||||
<Checkbox
|
||||
className="selected:bg-component-bg-pressed rounded-lg"
|
||||
isSelected={filters[filter as VersionType]}
|
||||
key={filter}
|
||||
onChange={() => toggleFilter(filter as VersionType)}
|
||||
>
|
||||
{filter}
|
||||
</Checkbox>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type OptionProps = {
|
||||
header: string;
|
||||
children?: JSX.Element | Array<JSX.Element>;
|
||||
} & RefAttributes<HTMLDivElement>;
|
||||
|
||||
function Option(props: 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ function RouteComponent() {
|
|||
const { id } = Route.useSearch();
|
||||
const [newCover, setNewCover] = useState<string>('');
|
||||
const [newName, setNewName] = useState<string>('');
|
||||
const [edit, setEdit] = useState<boolean>(false);
|
||||
|
||||
// dumbass fix ik
|
||||
const cluster = useCommand('getClusterById', () => bindings.core.getClusterById(Number(id.toString()) as unknown as bigint));
|
||||
|
|
@ -31,7 +32,7 @@ function RouteComponent() {
|
|||
<div className="h-full">
|
||||
<Banner
|
||||
cluster={cluster.data}
|
||||
editMode={false}
|
||||
editMode={edit}
|
||||
newCover={newCover}
|
||||
newName={newName}
|
||||
refetch={cluster.refetch}
|
||||
|
|
@ -57,7 +58,7 @@ function RouteComponent() {
|
|||
<SettingsRow.Header>Cluster Actions</SettingsRow.Header>
|
||||
<SettingsRow
|
||||
children={(
|
||||
<Button color="secondary">Edit</Button>
|
||||
<Button color="secondary" onClick={() => setEdit(!edit)}>{edit ? 'Save' : 'Edit'}</Button>
|
||||
)}
|
||||
description="Edit the cluster name and cover image."
|
||||
icon={<Edit02Icon />}
|
||||
|
|
@ -123,6 +124,17 @@ function Banner({
|
|||
|
||||
// props.setNewCover(selected);
|
||||
// }
|
||||
const launch = useCommand('launchCluster', () => bindings.core.launchCluster(cluster?.id as bigint, null), {
|
||||
enabled: false,
|
||||
subscribed: false,
|
||||
});
|
||||
|
||||
const handleLaunch = () => {
|
||||
launch.refetch();
|
||||
|
||||
if (launch.error)
|
||||
console.error(launch.error.message);
|
||||
};
|
||||
|
||||
function updateName(name: string) {
|
||||
if (name.length > 30 || name.length <= 0)
|
||||
|
|
@ -187,7 +199,7 @@ function Banner({
|
|||
</div>
|
||||
|
||||
<div className="flex flex-row items-end gap-x-2.5 *:h-8">
|
||||
<Button children="Launch" />
|
||||
<Button children="Launch" onClick={handleLaunch} />
|
||||
|
||||
<Button
|
||||
children={<Share07Icon />}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ import DefaultBanner from '@/assets/images/default_banner.png';
|
|||
import DefaultInstancePhoto from '@/assets/images/default_instance_cover.jpg';
|
||||
import { NewClusterCreate } from '@/components/launcher/cluster/ClusterCreation';
|
||||
import Modal from '@/components/overlay/Modal';
|
||||
import useRecentCluster from '@/hooks/useCluster';
|
||||
import { bindings } from '@/main';
|
||||
import { upperFirst } from '@/utils';
|
||||
import { useCommand } from '@onelauncher/common';
|
||||
import { Button, TextField } from '@onelauncher/common/components';
|
||||
import { Button, Show, TextField } from '@onelauncher/common/components';
|
||||
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||
import { PlayIcon, Server01Icon } from '@untitled-theme/icons-react';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
|
||||
|
|
@ -28,9 +30,7 @@ function RouteComponent() {
|
|||
<Banner />
|
||||
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex flex-row items-center gap-x-2">
|
||||
<NewClusterCreate />
|
||||
</div>
|
||||
<NewClusterCreate />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
|
|
@ -49,7 +49,8 @@ function Banner() {
|
|||
*
|
||||
* If there are no clusters, display a generic background with the button action creating a new cluster.
|
||||
*/
|
||||
// const cluster = useRecentCluster();
|
||||
const cluster = useRecentCluster();
|
||||
|
||||
// const launch = useLaunchCluster(() => cluster()?.uuid);
|
||||
// const navigate = useNavigate();
|
||||
|
||||
|
|
@ -58,30 +59,30 @@ function Banner() {
|
|||
<div className="absolute h-52 min-h-52 w-full">
|
||||
<img
|
||||
alt="Default Banner"
|
||||
className="top-0 left-0 h-full w-full object-cover"
|
||||
src={DefaultBanner}
|
||||
className="top-0 left-0 h-full rounded-xl w-full object-cover"
|
||||
src={cluster?.icon_url || DefaultBanner}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 h-full flex flex-col items-start justify-between px-8 py-6">
|
||||
<div className="theme-OneLauncher-Dark flex flex-col gap-y-2 text-fg-primary">
|
||||
<h1 className="text-2xl font-medium text-shadow-black text-shadow-2xs">Create a cluster</h1>
|
||||
{/* <Show when={cluster() !== undefined}>
|
||||
<Show when={cluster !== undefined}>
|
||||
<p>
|
||||
You've played
|
||||
{' '}
|
||||
<strong>
|
||||
{cluster()!.meta.mc_version}
|
||||
{cluster?.name}
|
||||
{' '}
|
||||
{upperFirst(cluster()!.meta.loader || 'Unknown')}
|
||||
{upperFirst(cluster?.mc_loader || 'Unknown')}
|
||||
</strong>
|
||||
{' '}
|
||||
for
|
||||
{' '}
|
||||
<strong>{formatAsDuration((cluster()!.meta.overall_played || 0))}</strong>
|
||||
<strong>{cluster?.overall_played || 0}</strong>
|
||||
.
|
||||
</p>
|
||||
</Show> */}
|
||||
</Show>
|
||||
</div>
|
||||
<div className="w-full flex flex-row items-end justify-between">
|
||||
<div className="flex flex-row items-center gap-x-4">
|
||||
|
|
@ -111,69 +112,6 @@ function Banner() {
|
|||
);
|
||||
}
|
||||
|
||||
function ClusterCreate() {
|
||||
const result = useCommand('createCluster', () => bindings.core.createCluster({
|
||||
icon: null,
|
||||
mc_loader: 'vanilla',
|
||||
mc_version: '1.8.9',
|
||||
name: 'Test Cluster',
|
||||
mc_loader_version: null,
|
||||
}), {
|
||||
enabled: false,
|
||||
subscribed: false,
|
||||
});
|
||||
|
||||
const testThingy = () => {
|
||||
result.refetch();
|
||||
|
||||
if (result.isError)
|
||||
console.error(result.error.message);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Trigger>
|
||||
<Button
|
||||
children="New Cluster"
|
||||
// onClick={testThingy}
|
||||
/>
|
||||
|
||||
<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 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 -mt-2">New Cluster</h1>
|
||||
{/* <span className="text-fg-primary">asd</span> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col border border-white/5 rounded-b-lg">
|
||||
<div className="p-3">
|
||||
<TextField className="px-2" placeholder="Epik cluster name" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end gap-x-2 p-3 pt-0">
|
||||
<Button
|
||||
children="Create"
|
||||
color="primary"
|
||||
onClick={testThingy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Modal.Trigger>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ClusterGroupProps {
|
||||
clusters: Array<Model> | undefined;
|
||||
isFetching?: boolean;
|
||||
|
|
@ -256,12 +194,10 @@ function ClusterCard({
|
|||
</div>
|
||||
|
||||
{/* <LaunchButton cluster={props} iconOnly /> */}
|
||||
|
||||
<Button onClick={handleLaunch} size="icon"><PlayIcon /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Button onClick={handleLaunch} size="icon"><PlayIcon /></Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,3 +15,65 @@ export function pluralize(n: number, word: string, locale: string = 'en'): strin
|
|||
const pluralForm = pluralRules.select(n);
|
||||
return pluralForm === 'one' ? word : `${word}s`;
|
||||
}
|
||||
|
||||
export function upperFirst<T extends string>(object: T): Capitalize<T> {
|
||||
const string = object.toString();
|
||||
return (string.charAt(0).toUpperCase() + string.slice(1)) as Capitalize<T>;
|
||||
}
|
||||
|
||||
export function uint32ToBigInt([high, low]: [number, number]): bigint {
|
||||
return (BigInt(high >>> 0) << 32n) | BigInt(low >>> 0);
|
||||
}
|
||||
|
||||
export function int32ToBigInt([high, low]: [number, number]): bigint {
|
||||
return (BigInt(high | 0) << 32n) | BigInt(low >>> 0);
|
||||
}
|
||||
|
||||
export function randomString(len: number = 6, charSet: string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789') {
|
||||
let randomString = '';
|
||||
for (let i = 0; i < len; i++) {
|
||||
const randomPos = Math.floor(Math.random() * charSet.length);
|
||||
randomString += charSet.substring(randomPos, randomPos + 1);
|
||||
}
|
||||
return randomString;
|
||||
}
|
||||
|
||||
export function abbreviateNumber(n: number | bigint, locale: string = 'en-US'): string {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(n);
|
||||
};
|
||||
|
||||
export function formatAsRelative(
|
||||
seconds: number | bigint | Date,
|
||||
locale: string = 'en',
|
||||
style: 'long' | 'short' | 'narrow' | 'digital' = 'long',
|
||||
): string {
|
||||
let elapsed: number | undefined;
|
||||
|
||||
if (seconds instanceof Date)
|
||||
elapsed = seconds.getTime();
|
||||
else
|
||||
elapsed = Number(seconds);
|
||||
|
||||
elapsed -= Date.now();
|
||||
|
||||
const units = {
|
||||
year: 31536000000,
|
||||
month: 2592000000,
|
||||
week: 604800000,
|
||||
day: 86400000,
|
||||
hour: 3600000,
|
||||
minute: 60000,
|
||||
second: 1000,
|
||||
};
|
||||
|
||||
const formatter = new Intl.RelativeTimeFormat(locale, { style: style as Intl.RelativeTimeFormatStyle });
|
||||
for (const [unit, ms] of Object.entries(units))
|
||||
if (Math.abs(elapsed) > ms || unit === 'second')
|
||||
return formatter.format(Math.round(elapsed / ms), unit as Intl.RelativeTimeFormatUnit);
|
||||
|
||||
return 'now';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -428,9 +428,9 @@ pub async fn get_loader_version(
|
|||
#[tracing::instrument]
|
||||
pub async fn get_game_versions() -> LauncherResult<Vec<Version>> {
|
||||
let state = State::get().await?;
|
||||
let metadata = state.metadata.read().await;
|
||||
let mut metadata = state.metadata.write().await;
|
||||
|
||||
let manifest = metadata.get_vanilla()?;
|
||||
let manifest = metadata.get_vanilla_or_fetch().await?;
|
||||
Ok(manifest.versions.clone())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use interpulse::api::minecraft::Version;
|
||||
use onelauncher_entity::{icon::Icon, loader::GameLoader, prelude::entity};
|
||||
use onelauncher_entity::{clusters, icon::Icon, loader::GameLoader, prelude::entity};
|
||||
use sea_orm::ActiveValue::Set;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
|
||||
use crate::{api::{self, cluster::dao::ClusterId}, error::LauncherResult, store::{credentials::MinecraftCredentials, Core}};
|
||||
|
|
@ -25,6 +26,9 @@ pub trait TauriLauncherApi {
|
|||
#[taurpc(alias = "launchCluster")]
|
||||
async fn launch_cluster(id: ClusterId, uuid: Option<uuid::Uuid>) -> LauncherResult<()>;
|
||||
|
||||
#[taurpc(alias = "updateClusterById")]
|
||||
async fn update_cluster_by_id(id: ClusterId, request: Cluster) -> LauncherResult<()>;
|
||||
|
||||
|
||||
// Setting Profiles
|
||||
#[taurpc(alias = "getProfileOrDefault")]
|
||||
|
|
@ -116,6 +120,22 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_cluster_by_id(id: ClusterId, request: clusters::ActiveModel) -> LauncherResult<()> {
|
||||
let updated_cluster = api::cluster::dao::update_cluster_by_id(id, |mut active_model| async move {
|
||||
if let Some(name) = request.name {
|
||||
active_model.name = Set(name);
|
||||
}
|
||||
|
||||
active_model.updated_at = Set(chrono::Utc::now().naive_utc());
|
||||
|
||||
Ok(active_model)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
// Setting Profiles
|
||||
async fn get_global_profile(self) -> SettingsProfile {
|
||||
|
|
|
|||
|
|
@ -38,5 +38,8 @@
|
|||
"rollup-plugin-dts": "^6.2.1",
|
||||
"rollup-plugin-esbuild": "^6.2.1",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@untitled-theme/icons-react": "^0.14.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,60 +4,165 @@ import type {
|
|||
} from 'react-aria-components';
|
||||
import type { VariantProps } from 'tailwind-variants';
|
||||
import type { ClassNameString } from '../types/global';
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@untitled-theme/icons-react';
|
||||
import React from 'react';
|
||||
import {
|
||||
Label as AriaLabel,
|
||||
ListBox as AriaListBox,
|
||||
ListBoxItem as AriaListBoxItem,
|
||||
Popover as AriaPopover,
|
||||
Select as AriaSelect,
|
||||
SelectValue,
|
||||
SelectValue as AriaSelectValue,
|
||||
} from 'react-aria-components';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { tv } from 'tailwind-variants';
|
||||
import { Button } from '.';
|
||||
import { Button } from './Button';
|
||||
|
||||
export interface ComboBoxWrapperProps<T extends object> extends Omit<AriaSelectProps<T>, 'className'>, ClassNameString {
|
||||
label?: string;
|
||||
}
|
||||
const dropdownStyles = tv({
|
||||
slots: {
|
||||
root: 'relative h-full data-[disabled]:opacity-10 data-[disabled]:pointer-events-none',
|
||||
|
||||
const dropdownVariants = tv({
|
||||
base: [
|
||||
'relative h-full',
|
||||
],
|
||||
trigger: [
|
||||
'h-full w-full px-3 py-1.5 rounded-lg flex items-center justify-between text-left',
|
||||
'border border-component-border',
|
||||
'bg-component-bg',
|
||||
],
|
||||
|
||||
triggerValueContent: 'flex-1 h-full flex flex-row items-center gap-1 overflow-hidden text-nowrap',
|
||||
|
||||
triggerChevronIcon: 'w-4 h-4 ml-2 flex-shrink-0 transition-transform data-[open]:rotate-180',
|
||||
|
||||
triggerCustomIcon: 'w-4 h-4',
|
||||
|
||||
popover: [
|
||||
'mt-1 rounded-lg shadow-md',
|
||||
'bg-component-bg border border-component-border',
|
||||
'data-[entering]:animate-in data-[entering]:fade-in data-[entering]:zoom-in-95',
|
||||
'data-[exiting]:animate-out data-[exiting]:fade-out data-[exiting]:zoom-out-95',
|
||||
],
|
||||
|
||||
popoverContentWrapper: 'flex flex-col gap-1',
|
||||
|
||||
listBox: 'max-h-46 overflow-auto outline-none flex flex-col gap-0.5',
|
||||
|
||||
listToolRowWrapper: 'p-1 mt-1',
|
||||
},
|
||||
variants: {
|
||||
minimal: {
|
||||
true: {
|
||||
root: 'w-auto h-auto',
|
||||
trigger: [
|
||||
'p-1.5',
|
||||
'bg-transparent hover:bg-dropdown-minimal-trigger-bg-hover border-transparent',
|
||||
],
|
||||
triggerValueContent: 'hidden',
|
||||
popover: 'w-max',
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
minimal: false,
|
||||
},
|
||||
});
|
||||
|
||||
export type DropdownVariantsProps = VariantProps<typeof dropdownVariants>;
|
||||
const dropdownItemStyles = tv({
|
||||
slots: {
|
||||
container: [
|
||||
'group/item flex flex-row items-center justify-between gap-2 rounded-lg p-2 w-full',
|
||||
'text-dropdown-item-text hover:bg-component-bg-hover data-[focused]:bg-component-bg-pressed',
|
||||
'outline-none hover:cursor-pointer',
|
||||
],
|
||||
|
||||
selectedIndicator: [
|
||||
'w-1.5 h-1.5 rounded-full flex-shrink-0 opacity-0 transition-opacity',
|
||||
'group-data-[selected]/item:opacity-100',
|
||||
],
|
||||
|
||||
contentRowLayout: 'w-full flex flex-row justify-start items-center gap-1.5 h-5 text-nowrap overflow-hidden',
|
||||
},
|
||||
});
|
||||
|
||||
export type DropdownStyleProps = VariantProps<typeof dropdownStyles>;
|
||||
|
||||
export interface DropdownProps<T extends object>
|
||||
extends Omit<AriaSelectProps<T>, 'className' | 'children'>,
|
||||
DropdownStyleProps,
|
||||
ClassNameString {
|
||||
label?: string;
|
||||
labelClassName?: string;
|
||||
children: React.ReactNode;
|
||||
listToolRow?: React.ReactNode;
|
||||
triggerTextPrefix?: string;
|
||||
|
||||
customMinimalIcon?: React.ReactNode;
|
||||
popoverClassName?: string;
|
||||
}
|
||||
|
||||
export function Dropdown<TValue extends object>(props: DropdownProps<TValue>) {
|
||||
const {
|
||||
className,
|
||||
label,
|
||||
labelClassName,
|
||||
children,
|
||||
listToolRow,
|
||||
triggerTextPrefix,
|
||||
customMinimalIcon,
|
||||
minimal,
|
||||
popoverClassName,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const styles = dropdownStyles({ minimal, className });
|
||||
|
||||
export function Dropdown<T extends object>({
|
||||
className,
|
||||
label,
|
||||
children,
|
||||
...rest
|
||||
}: ComboBoxWrapperProps<T>) {
|
||||
return (
|
||||
<AriaSelect {...rest} className={dropdownVariants({ className })}>
|
||||
{label && <AriaLabel>{label}</AriaLabel>}
|
||||
<Button className="border w-full border-component-bg py-1" color="ghost">
|
||||
<SelectValue />
|
||||
<span aria-hidden="true">▼</span>
|
||||
</Button>
|
||||
<AriaPopover className="border border-component-bg rounded-lg p-1">
|
||||
<AriaListBox className="max-h-47 overflow-auto flex-col gap-1">
|
||||
{children}
|
||||
</AriaListBox>
|
||||
</AriaPopover>
|
||||
<AriaSelect {...rest} className={styles.root()}>
|
||||
{({ isOpen }) => (
|
||||
<>
|
||||
{label && <AriaLabel className={labelClassName}>{label}</AriaLabel>}
|
||||
|
||||
<Button className={styles.trigger()} color="ghost">
|
||||
<div className={styles.triggerValueContent()}>
|
||||
{triggerTextPrefix && <span>{triggerTextPrefix}</span>}
|
||||
<AriaSelectValue />
|
||||
</div>
|
||||
{
|
||||
isOpen
|
||||
? <ChevronUpIcon className={styles.triggerChevronIcon()} data-open />
|
||||
: <ChevronDownIcon className={styles.triggerChevronIcon()} />
|
||||
}
|
||||
</Button>
|
||||
|
||||
<AriaPopover className={twMerge(styles.popover(), popoverClassName)}>
|
||||
<div className={styles.popoverContentWrapper()}>
|
||||
<AriaListBox className={styles.listBox()}>
|
||||
{children}
|
||||
</AriaListBox>
|
||||
{listToolRow && (
|
||||
<div className={styles.listToolRowWrapper()}>
|
||||
{listToolRow}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AriaPopover>
|
||||
</>
|
||||
)}
|
||||
</AriaSelect>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dropdown;
|
||||
export interface DropdownItemProps extends AriaListBoxItemProps {
|
||||
}
|
||||
|
||||
Dropdown.Item = ({
|
||||
className,
|
||||
...rest
|
||||
}: AriaListBoxItemProps & ClassNameString) => (
|
||||
<AriaListBoxItem
|
||||
className={twMerge('hover:bg-component-bg-hover px-2 py-1 rounded-md w-full', className)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
Dropdown.Item = (props: DropdownItemProps) => {
|
||||
const itemStyles = dropdownItemStyles();
|
||||
|
||||
const { className, children } = props;
|
||||
|
||||
return (
|
||||
<AriaListBoxItem {...props} className={twMerge(itemStyles.container(), className?.toString())}>
|
||||
{children}
|
||||
</AriaListBoxItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dropdown;
|
||||
|
|
|
|||
87
packages/web_common/src/components/SelectList.tsx
Normal file
87
packages/web_common/src/components/SelectList.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import type { JSX, ReactNode } from 'react';
|
||||
import React, { createContext, useContext } from 'react';
|
||||
|
||||
interface SelectListContextType {
|
||||
onSelect: (value: string) => void;
|
||||
isSelected: (value: string) => boolean;
|
||||
multiple: boolean;
|
||||
}
|
||||
|
||||
interface SelectListProps {
|
||||
value: string | Array<string>;
|
||||
onChange: (value: string | Array<string>) => void;
|
||||
className?: string;
|
||||
multiple?: boolean;
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
interface SelectListItemProps {
|
||||
value: string;
|
||||
children: JSX.Element | any;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SelectListContext = createContext<SelectListContextType | null>(null);
|
||||
|
||||
export const SelectList: React.FC<SelectListProps> & { Item: React.FC<SelectListItemProps> } = ({
|
||||
value = '',
|
||||
onChange,
|
||||
className = '',
|
||||
multiple = false,
|
||||
children,
|
||||
}) => {
|
||||
const handleSelect = (itemValue: string): void => {
|
||||
if (multiple) {
|
||||
const currentValues = Array.isArray(value) ? value : [];
|
||||
const newValues = currentValues.includes(itemValue)
|
||||
? currentValues.filter(v => v !== itemValue)
|
||||
: [...currentValues, itemValue];
|
||||
onChange(newValues);
|
||||
}
|
||||
else {
|
||||
onChange(itemValue);
|
||||
}
|
||||
};
|
||||
|
||||
const isSelected = (itemValue: string): boolean => {
|
||||
if (multiple)
|
||||
return Array.isArray(value) && value.includes(itemValue);
|
||||
|
||||
return value === itemValue;
|
||||
};
|
||||
|
||||
const contextValue: SelectListContextType = {
|
||||
onSelect: handleSelect,
|
||||
isSelected,
|
||||
multiple,
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectListContext.Provider value={contextValue}>
|
||||
<div className={`border border-component-bg rounded-md ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
</SelectListContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const SelectListItem: React.FC<SelectListItemProps> = ({ value, children, className = '' }) => {
|
||||
const context = useContext(SelectListContext);
|
||||
|
||||
if (!context)
|
||||
throw new Error('SelectList.Item must be used within a SelectList');
|
||||
|
||||
const { onSelect, isSelected } = context;
|
||||
const selected = isSelected(value);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`cursor-pointer ${selected && 'bg-component-bg-pressed'} ${className}`}
|
||||
onClick={() => onSelect(value)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SelectList.Item = SelectListItem;
|
||||
|
|
@ -1,27 +1,35 @@
|
|||
import type { RefAttributes } from 'react';
|
||||
import type { JSX, RefAttributes } from 'react';
|
||||
import type { InputProps as AriaInputProps } from 'react-aria-components';
|
||||
import type { VariantProps } from 'tailwind-variants';
|
||||
import { Input } from 'react-aria-components';
|
||||
import { TextField as AriaTextField, Input } from 'react-aria-components';
|
||||
import { tv } from 'tailwind-variants';
|
||||
|
||||
export const textFieldVariants = tv({
|
||||
base: [
|
||||
'bg-component-bg',
|
||||
'[&:not(:focus-within)]:hover:bg-component-bg-hover',
|
||||
'focus-within:bg-component-bg-pressed',
|
||||
'disabled:bg-component-bg-disabled',
|
||||
'border-component-border border fill-fg-primary',
|
||||
'rounded-lg px-2.5 py-1.5 min-h-8',
|
||||
'flex flex-row items-center justify-start gap-x-1.5',
|
||||
'text-sm text-fg-primary font-medium',
|
||||
'bg-transparent outline-none appearance-none p-0 m-0 box-border w-full font-medium',
|
||||
'[&::selection]:bg-brand/30',
|
||||
'placeholder:text-fg-secondary/40 placeholder:font-normal',
|
||||
],
|
||||
slots: {
|
||||
container: [
|
||||
'bg-component-bg',
|
||||
'[&:not(:focus-within)]:hover:bg-component-bg-hover',
|
||||
'focus-within:bg-component-bg-pressed',
|
||||
'border-component-border border',
|
||||
'rounded-lg px-2.5 py-1.5 min-h-8',
|
||||
'flex flex-row items-center justify-start gap-x-1.5',
|
||||
'text-sm text-fg-primary font-medium',
|
||||
],
|
||||
input: [
|
||||
'outline-none appearance-none bg-transparent',
|
||||
'text-sm text-fg-primary font-medium',
|
||||
'flex-1 min-w-0',
|
||||
'[&::selection]:bg-brand/30',
|
||||
'placeholder:text-fg-secondary/40 placeholder:font-normal',
|
||||
],
|
||||
icon: [
|
||||
'text-fg-secondary flex-shrink-0',
|
||||
],
|
||||
},
|
||||
variants: {
|
||||
invalid: {
|
||||
true: {
|
||||
base: 'border-danger',
|
||||
container: 'border-danger',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -31,15 +39,31 @@ export type TextFieldVariantsProps = VariantProps<typeof textFieldVariants>;
|
|||
|
||||
export type TextFieldProps = AriaInputProps & RefAttributes<HTMLInputElement> & {
|
||||
className?: string;
|
||||
iconLeft?: JSX.Element;
|
||||
iconRight?: JSX.Element;
|
||||
};
|
||||
|
||||
export function TextField({
|
||||
className,
|
||||
iconLeft,
|
||||
iconRight,
|
||||
...props
|
||||
}: TextFieldProps) {
|
||||
const styles = textFieldVariants();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input className={textFieldVariants({ className })} {...props} />
|
||||
</>
|
||||
<AriaTextField className={styles.container({ className })}>
|
||||
{iconLeft && (
|
||||
<span className={styles.icon()}>
|
||||
{iconLeft}
|
||||
</span>
|
||||
)}
|
||||
<Input className={styles.input()} {...props} />
|
||||
{iconRight && (
|
||||
<span className={styles.icon()}>
|
||||
{iconRight}
|
||||
</span>
|
||||
)}
|
||||
</AriaTextField>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export * from './Button';
|
|||
export * from './ContextMenu';
|
||||
export * from './Dropdown';
|
||||
export * from './Menu';
|
||||
export * from './SelectList';
|
||||
export * from './Show';
|
||||
export * from './Switch';
|
||||
export * from './TextField';
|
||||
|
|
|
|||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
|
|
@ -204,9 +204,6 @@ importers:
|
|||
'@fontsource/poppins':
|
||||
specifier: ^5.2.6
|
||||
version: 5.2.6
|
||||
'@formatjs/intl-durationformat':
|
||||
specifier: ^0.7.4
|
||||
version: 0.7.4
|
||||
'@onelauncher/common':
|
||||
specifier: workspace:*
|
||||
version: link:../../../packages/web_common
|
||||
|
|
@ -353,6 +350,9 @@ importers:
|
|||
'@tanstack/react-router':
|
||||
specifier: ^1.114.3
|
||||
version: 1.119.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@untitled-theme/icons-react':
|
||||
specifier: ^0.14.0
|
||||
version: 0.14.0(@types/react@19.1.2)(react@19.1.0)
|
||||
motion:
|
||||
specifier: ^12.10.5
|
||||
version: 12.10.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue