feat: Cluster creation modal improvements

This commit is contained in:
LynithDev 2024-07-22 16:13:36 +02:00
parent a95513b6b9
commit 8664610560
17 changed files with 354 additions and 67 deletions

View file

@ -29,6 +29,7 @@
"@tauri-apps/plugin-clipboard-manager": "2.1.0-beta.5",
"@tauri-apps/plugin-shell": "github:tauri-apps/tauri-plugin-shell#v2",
"@thisbeyond/solid-dnd": "^0.7.5",
"@thisbeyond/solid-select": "^0.15.0",
"@unocss/reset": "^0.61.4",
"@untitled-theme/icons-solid": "^0.10.4",
"overlayscrollbars": "^2.9.2",

View file

@ -18,6 +18,7 @@ fn main() {
"get_settings",
"set_settings",
"get_program_info",
"get_minecraft_versions"
]),
),
)

View file

@ -13,5 +13,6 @@ permissions = [
"allow-get-manifest",
"allow-get-settings",
"allow-set-settings",
"allow-get-program-info"
"allow-get-program-info",
"allow-get-minecraft-versions"
]

View file

@ -2,7 +2,9 @@ use std::collections::HashMap;
use std::path::PathBuf;
use std::str::FromStr;
use anyhow::anyhow;
use chrono::DateTime;
use interpulse::api::minecraft::Version;
use onelauncher::constants::{NATIVE_ARCH, TARGET_OS, VERSION};
use onelauncher::data::{ClusterMeta, Loader, MinecraftCredentials, PackageData, Settings};
use onelauncher::store::{Cluster, ClusterPath, MinecraftLogin};
@ -31,7 +33,8 @@ macro_rules! collect_commands {
get_clusters,
get_settings,
set_settings,
get_program_info
get_program_info,
get_minecraft_versions
])
}};
}
@ -132,16 +135,22 @@ pub async fn get_clusters() -> Result<HashMap<ClusterPath, Cluster>, String> {
// cluster::list(None).await.map_err(|op| op.into())
}
#[specta::specta]
#[tauri::command]
pub async fn get_minecraft_versions() -> Result<Vec<Version>, String> {
Ok(onelauncher::api::metadata::get_minecraft_versions().await?.versions)
}
#[specta::specta]
#[tauri::command]
pub async fn get_settings() -> Result<Settings, String> {
settings::get().await.map_err(|err| err.into())
Ok(settings::get().await?)
}
#[specta::specta]
#[tauri::command]
pub async fn set_settings(settings: Settings) -> Result<(), String> {
settings::set(settings).await.map_err(|err| err.into())
Ok(settings::set(settings).await?)
}
#[derive(Serialize, Deserialize, Type)]
@ -174,19 +183,19 @@ pub fn get_program_info() -> ProgramInfo {
#[specta::specta]
#[tauri::command]
pub async fn get_users() -> Result<Vec<MinecraftCredentials>, String> {
minecraft::users().await.map_err(|err| err.into())
Ok(minecraft::users().await?)
}
#[specta::specta]
#[tauri::command]
pub async fn get_user(uuid: Uuid) -> Result<MinecraftCredentials, String> {
minecraft::get_user(uuid).await.map_err(|err| err.into())
Ok(minecraft::get_user(uuid).await?)
}
#[specta::specta]
#[tauri::command]
pub async fn begin_msa() -> Result<MinecraftLogin, String> {
minecraft::begin().await.map_err(|err| err.into())
Ok(minecraft::begin().await?)
}
#[specta::specta]
@ -195,13 +204,11 @@ pub async fn finish_msa(
code: String,
login: MinecraftLogin,
) -> Result<MinecraftCredentials, String> {
minecraft::finish(code.as_str(), login)
.await
.map_err(|err| err.into())
Ok(minecraft::finish(code.as_str(), login).await?)
}
#[specta::specta]
#[tauri::command]
pub async fn remove_user(uuid: Uuid) -> Result<(), String> {
minecraft::remove_user(uuid).await.map_err(|err| err.into())
Ok(minecraft::remove_user(uuid).await?)
}

View file

@ -2,7 +2,7 @@
@apply py-1.5 px-3 rounded-lg flex flex-row justify-center items-center gap-x-2.5;
&__primary {
@apply text-onbrand hover:text-onbrand-hover active:text-onbrand-pressed disabled:text-onbrand-disabled;
@apply text-onbrand hover:text-onbrand-hover active:text-onbrand-pressed disabled:text-onbrand-disabled;
@apply bg-brand hover:bg-brand-hover active:bg-brand-pressed disabled:bg-brand-disabled;
}
@ -17,11 +17,11 @@
}
&__ghost {
@apply hover:bg-gray-05 active:bg-gray-10;
@apply hover:bg-gray-05 [&[aria-checked="true"]]:bg-gray-10 active:bg-gray-10;
}
&__icon, &__iconSecondary {
@apply p-1.5 hover:bg-gray-05 active:bg-gray-10 aspect-square box-border w-8 h-8;
@apply p-1.5 hover:bg-gray-05 [&[aria-checked="true"]]:bg-gray-10 active:bg-gray-10 aspect-square box-border w-8 h-8;
&.large {
@apply w-9 h-9;

View file

@ -1,5 +1,5 @@
import type { JSX, ParentProps } from 'solid-js';
import { mergeProps, splitProps } from 'solid-js';
import { createSignal, mergeProps, splitProps } from 'solid-js';
import styles from './Button.module.scss';
type ButtonProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> & ParentProps & {
@ -22,4 +22,32 @@ function Button(props: ButtonProps) {
);
}
type ButtonToggleProps = ButtonProps & {
defaultChecked?: boolean;
onChecked?: (checked: boolean) => any;
};
Button.Toggle = (props: ButtonToggleProps) => {
const [checked, setChecked] = createSignal(props.defaultChecked || false);
const [split, rest] = splitProps(props, ['defaultChecked', 'onChecked', 'onClick', 'class']);
function toggle() {
const newValue = !checked();
setChecked(newValue);
props.onChecked?.(newValue);
}
return (
<Button
onClick={(e) => {
toggle();
// @ts-expect-error -- type error which i cba to resolve
split.onClick?.(e);
}}
aria-checked={checked()}
{...rest}
/>
);
};
export default Button;

View file

@ -0,0 +1,20 @@
.checkbox {
@apply flex flex-row gap-x-1 items-center;
.box {
@apply w-[18px] h-[18px] flex flex-col justify-center items-center rounded-md bg-component-bg hover:bg-component-bg-hover active:bg-component-bg-pressed border border-gray-05 transition-all;
> svg {
@apply w-[18px] h-[18px] mt-px opacity-0 transition-opacity;
}
&.checked {
@apply bg-brand hover:bg-brand-hover active:bg-brand-pressed border-transparent;
> svg {
@apply opacity-100;
}
}
}
}

View file

@ -0,0 +1,34 @@
import { type JSX, createSignal, splitProps } from 'solid-js';
import { CheckIcon } from '@untitled-theme/icons-solid';
import styles from './Checkbox.module.scss';
type CheckboxProps = JSX.HTMLAttributes<HTMLDivElement> & {
defaultChecked?: boolean;
onChecked?: (checked: boolean) => void;
};
function Checkbox(props: CheckboxProps) {
const [checked, setChecked] = createSignal(props.defaultChecked || false);
const [split, rest] = splitProps(props, ['class', 'defaultChecked', 'onChecked', 'text']);
function toggle() {
const newValue = !checked();
setChecked(newValue);
props.onChecked?.(newValue);
}
return (
<div
onClick={() => toggle()}
class={`${styles.checkbox} ${` ${split.class}` ?? ''}`}
{...rest}
>
<div class={`${styles.box} ${checked() ? styles.checked : ''}`}>
<CheckIcon />
</div>
{props.children}
</div>
);
}
export default Checkbox;

View file

@ -1,15 +1,22 @@
.dropdown {
@apply relative h-full;
.list {
@apply bg-secondary rounded-lg border border-gray-05 p-1 shadow-md shadow-black/30;
@apply flex-col gap-2;
@apply max-h-46 overflow-auto;
@apply hidden;
.dropdown_elements_container {
@apply flex flex-col gap-1;
}
&.visible {
@apply flex;
}
.dropdown_element {
@apply bg-secondary rounded-lg border border-gray-05 p-1 shadow-md shadow-black/30 h-full;
}
.list {
@apply max-h-46 overflow-auto;
@apply flex-col gap-1;
@apply hidden;
}
.visible .list {
@apply flex;
}
.row {

View file

@ -1,4 +1,4 @@
import { type Accessor, Index, type JSX, Match, type ParentProps, type ResolvedJSXElement, type Setter, Switch, children, createSignal, splitProps } from 'solid-js';
import { type Accessor, Index, type JSX, Match, type ParentProps, type ResolvedJSXElement, type Setter, Show, Switch, children, createSignal, splitProps } from 'solid-js';
import { ChevronDownIcon, ChevronUpIcon } from '@untitled-theme/icons-solid';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-solid';
import Popup from '../overlay/Popup';
@ -10,6 +10,7 @@ export type DropdownProps = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'onChange'>
text?: string;
selected?: number;
dropdownClass?: string;
listToolRow?: () => JSX.Element;
component?: (
props: {
visible: Accessor<boolean>;
@ -20,7 +21,7 @@ export type DropdownProps = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'onChange'>
};
function Dropdown(props: DropdownProps) {
const [split, rest] = splitProps(props, ['children', 'class', 'text', 'onChange', 'selected', 'component', 'dropdownClass']);
const [split, rest] = splitProps(props, ['children', 'class', 'text', 'onChange', 'selected', 'component', 'dropdownClass', 'listToolRow']);
const [visible, setVisible] = createSignal(false);
// eslint-disable-next-line solid/reactivity -- todo
@ -72,18 +73,26 @@ function Dropdown(props: DropdownProps) {
ref.classList.add(...split.dropdownClass.split(' '));
}}
>
<OverlayScrollbarsComponent class={`${styles.list} ${visible() ? styles.visible : ''}`}>
<Index each={items()}>
{(item, index) => (
<div onClick={() => select(index)}>
<div class="hover:bg-gray-05 p-2 rounded-lg flex flex-row gap-2 justify-between items-center">
{item()}
<div class={styles.selected! + (selected() === index ? ` ${styles.visible}` : '')} />
<div class={`${styles.dropdown_elements_container} ${visible() ? styles.visible : ''}`}>
<OverlayScrollbarsComponent class={`${styles.list} ${styles.dropdown_element}`}>
{/* TODO(perf): Optimise */}
<Index each={items()}>
{(item, index) => (
<div onClick={() => select(index)}>
<div class="hover:bg-gray-05 p-2 rounded-lg flex flex-row gap-2 justify-between items-center">
{item()}
<div class={styles.selected! + (selected() === index ? ` ${styles.visible}` : '')} />
</div>
</div>
</div>
)}
</Index>
</OverlayScrollbarsComponent>
)}
</Index>
</OverlayScrollbarsComponent>
<Show when={split.listToolRow !== undefined}>
<div class={styles.dropdown_element}>
{split.listToolRow!()}
</div>
</Show>
</div>
</Popup>
</div>
);

View file

@ -0,0 +1,12 @@
.select_list {
@apply bg-component-bg rounded-lg overflow-hidden border border-gray-05;
.row {
@apply min-h-4 py-1 px-2 text-left;
@apply [&:nth-child(even)]:bg-white/01 hover:bg-component-bg-hover active:bg-component-bg-pressed;
&.selected {
@apply bg-white/10;
}
}
}

View file

@ -0,0 +1,66 @@
import { OverlayScrollbarsComponent } from 'overlayscrollbars-solid';
import { type Context, type JSX, type ParentProps, type Signal, createContext, createSignal, splitProps, useContext } from 'solid-js';
import styles from './SelectList.module.scss';
type SelectListContextHelpers = Signal<number | undefined>;
const SelectListContext = createContext<SelectListContextHelpers>() as Context<SelectListContextHelpers>;
function SelectListContextProvider(props: ParentProps) {
const [selected, setSelected] = createSignal<number>();
return (
<SelectListContext.Provider value={[selected, setSelected]}>
{props.children}
</SelectListContext.Provider>
);
}
function useSelectListContext() {
return useContext(SelectListContext);
}
export type SelectListProps = JSX.HTMLAttributes<HTMLDivElement>;
function SelectList(props: SelectListProps) {
const [split, rest] = splitProps(props, ['class']);
return (
<SelectListContextProvider>
<div {...rest} class={`${styles.select_list} ${split.class || ''}`}>
<OverlayScrollbarsComponent class="max-h-full">
<div>
{props.children}
</div>
</OverlayScrollbarsComponent>
</div>
</SelectListContextProvider>
);
}
export type SelectListRowProps = JSX.HTMLAttributes<HTMLDivElement> & {
index?: number;
};
SelectList.Row = (props: SelectListRowProps) => {
const [selected, setSelected] = useSelectListContext();
const [split, rest] = splitProps(props, ['index', 'class', 'onClick']);
return (
<div
{...rest}
tabIndex={0}
onClick={(e) => {
if (props.index !== undefined)
setSelected(props.index);
if (split.onClick)
// @ts-expect-error -- la
split.onClick(e);
}}
class={`${styles.row} ${props.index !== undefined && selected() === props.index ? styles.selected : ''} ${split.class || ''}`}
>
{props.children}
</div>
);
};
export default SelectList;

View file

@ -3,7 +3,6 @@ import { type JSX, createSignal, splitProps } from 'solid-js';
type ToggleProps = JSX.HTMLAttributes<HTMLDivElement> & {
defaultChecked?: boolean;
onChecked?: (checked: boolean) => void;
showText?: 'left' | 'right';
};
function Toggle(props: ToggleProps) {

View file

@ -1,5 +1,7 @@
import { TextInputIcon } from '@untitled-theme/icons-solid';
import { For, type JSX, createEffect, createSignal, on, splitProps } from 'solid-js';
import { Camera01Icon, FilterFunnel01Icon, TextInputIcon } from '@untitled-theme/icons-solid';
import { For, Index, type JSX, Show, createEffect, createMemo, createSignal, on, onMount, splitProps } from 'solid-js';
import { Select } from '@thisbeyond/solid-select';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-solid';
import type { ClusterStepProps } from './ClusterCreationModal';
import Dropdown from '~ui/components/base/Dropdown';
import TextField from '~ui/components/base/TextField';
@ -7,6 +9,15 @@ import VanillaImage from '~assets/logos/vanilla.png';
import FabricImage from '~assets/logos/fabric.png';
import ForgeImage from '~assets/logos/forge.png';
import QuiltImage from '~assets/logos/quilt.png';
import useCommand from '~ui/hooks/useCommand';
import { bridge } from '~imports';
import Button from '~ui/components/base/Button';
import Tooltip from '~ui/components/base/Tooltip';
import SelectList from '~ui/components/base/SelectList';
import Checkbox from '~ui/components/base/Checkbox';
import { getEnumMembers } from '~utils/primitives';
import type { VersionType } from '~bindings';
import { formatVersionRelease } from '~utils/helpers';
const loaders: {
name: string;
@ -55,32 +66,92 @@ export function ClusterStepTwo(props: ClusterStepProps) {
/>
</Option>
<div class="flex flex-row gap-x-2">
<Option class="flex-1" header="Versions">
<Dropdown>
<For each={Array.from({ length: 300 }, (_, i) => i).reverse()}>
{version => (
<Dropdown.Row>{version}</Dropdown.Row>
)}
</For>
</Dropdown>
</Option>
<Option class="w-32" header="Loader">
<Dropdown>
<For each={loaders}>
{loader => (
<Dropdown.Row>
<div class="flex flex-row gap-x-2">
<div class="w-4 h-4">
<loader.icon />
</div>
{loader.name}
<Option header="Versions">
<VersionSelector />
</Option>
<Option header="Loader">
<Dropdown>
<For each={loaders}>
{loader => (
<Dropdown.Row>
<div class="flex flex-row gap-x-2">
<div class="w-4 h-4">
<loader.icon />
</div>
</Dropdown.Row>
)}
</For>
</Dropdown>
</Option>
{loader.name}
</div>
</Dropdown.Row>
)}
</For>
</Dropdown>
</Option>
</div>
);
}
type VersionReleaseFilters = {
[key in VersionType]: boolean;
};
function VersionSelector() {
const [versions] = useCommand(bridge.commands.getMinecraftVersions);
const [filteredVersions, setFilteredVersions] = createSignal<bridge.Version[]>([]);
const [filters, setFilters] = createSignal<VersionReleaseFilters>({
old_alpha: false,
old_beta: false,
release: false,
snapshot: false,
});
function refresh(filters: VersionReleaseFilters, versions: bridge.Version[]) {
setFilteredVersions(() => {
if (Object.values(filters).every(value => value === false))
return versions;
return versions.filter((version) => {
return filters[version.type];
});
});
}
function toggleFilter(name: string) {
setFilters((prev) => {
return {
...prev,
[name]: !prev[name as keyof VersionReleaseFilters],
};
});
}
createEffect(() => {
const list = versions();
if (list !== undefined)
refresh(filters(), list);
});
createEffect(() => {
setFilteredVersions(versions()!);
});
return (
<div class="flex flex-row flex-1 gap-2">
<SelectList class="max-h-40 min-w-3/5">
<Show when={filteredVersions() !== undefined}>
<Index each={filteredVersions()}>
{(version, index) => (
<SelectList.Row index={index}>{version().id}</SelectList.Row>
)}
</Index>
</Show>
</SelectList>
<div class="bg-component-bg rounded-lg overflow-hidden border border-gray-05 flex-1 p-2 flex flex-col gap-y-2">
<For each={Object.keys(filters())}>
{name => (
<Checkbox onChecked={() => toggleFilter(name)}>{formatVersionRelease(name as VersionType)}</Checkbox>
)}
</For>
</div>
</div>
);
@ -96,9 +167,9 @@ function Option(props: OptionProps) {
return (
<div {...rest} class={`flex flex-col gap-y-2 items-stretch ${split.class || ''}`}>
<h3 class="text-md font-semibold uppercase text-fg-secondary text-left">{props.header}</h3>
<div class="max-h-8">
{props.children}
</div>
{/* <div class="max-h-8"> */}
{props.children}
{/* </div> */}
</div>
);
}

View file

@ -0,0 +1,12 @@
import type { VersionType } from '~bindings';
export function formatVersionRelease(release: VersionType): string {
const mapping: { [key in VersionType]: string } = {
old_alpha: 'Alpha',
old_beta: 'Beta',
release: 'Release',
snapshot: 'Snapshot',
};
return mapping[release];
}

View file

@ -17,6 +17,12 @@ export function abbreviateNumber(n: number) {
return `${n}`;
};
export function getEnumMembers(obj: any): string[] {
return Object.keys(obj).filter((item) => {
return Number.isNaN(Number(item));
});
}
export default {
upperFirst,
abbreviateNumber,

13
pnpm-lock.yaml generated
View file

@ -86,6 +86,9 @@ importers:
'@thisbeyond/solid-dnd':
specifier: ^0.7.5
version: 0.7.5(solid-js@1.8.18)
'@thisbeyond/solid-select':
specifier: ^0.15.0
version: 0.15.0(solid-js@1.8.18)
'@unocss/reset':
specifier: ^0.61.4
version: 0.61.4
@ -1026,6 +1029,12 @@ packages:
peerDependencies:
solid-js: ^1.5
'@thisbeyond/solid-select@0.15.0':
resolution: {integrity: sha512-L3QnA5vm09JWNqLR4QqEIi7cgfs9/1sJbFjyEnPskjKpxEvb4G+Iy5cyd1qhZ2vAIZ9vaLd3udts3kefayPsMg==}
engines: {node: '>=18.0.0', pnpm: '>=9.0.0'}
peerDependencies:
solid-js: ^1.8
'@trysound/sax@0.2.0':
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
engines: {node: '>=10.13.0'}
@ -4104,6 +4113,10 @@ snapshots:
dependencies:
solid-js: 1.8.18
'@thisbeyond/solid-select@0.15.0(solid-js@1.8.18)':
dependencies:
solid-js: 1.8.18
'@trysound/sax@0.2.0': {}
'@tsconfig/node10@1.0.11': {}