feat: implement new settings UI and components

This commit is contained in:
emirsassan 2025-05-29 23:45:39 +03:00
parent 46cca44837
commit 12eea80950
13 changed files with 561 additions and 48 deletions

View file

@ -218,7 +218,7 @@
use std::ops::Deref;
use onelauncher_core::{api::cluster, entity::{clusters::Model, icon::Icon, loader::GameLoader}, error::LauncherResult};
use onelauncher_core::{api::{cluster, setting_profiles}, entity::{clusters::Model, icon::Icon, loader::GameLoader}, error::LauncherResult, migration::sea_orm};
use serde::{Deserialize, Serialize};
use specta::Type;
use crate::api::error::SerializableResult;
@ -237,7 +237,14 @@ pub struct CreateCluster {
pub async fn create_cluster(options: CreateCluster) -> SerializableResult<Model> {
let thing = cluster::create_cluster(&options.name, &options.mc_version, options.mc_loader, options.mc_loader_version.as_deref(), options.icon_url).await?;
cluster::prepare_cluster(&mut thing.clone(), Some(false));
// cluster::prepare_cluster(&mut thing.clone(), Some(false));
if setting_profiles::dao::get_profile_by_name(&options.name).await?.is_none() {
setting_profiles::create_profile(&options.name, async |mut profile| {
profile.mem_max = sea_orm::ActiveValue::Set(Some(2048));
Ok(profile)
}).await?;
}
Ok(thing)
}

View file

@ -1,11 +1,11 @@
use onelauncher_core::entity::setting_profiles::Model;
use onelauncher_core::entity::setting_profiles::Model as SettingsModel;
use onelauncher_core::api::setting_profiles;
use crate::api::error::SerializableResult;
#[specta::specta]
#[tauri::command]
pub async fn get_global_profile() -> SerializableResult<Model> {
pub async fn get_global_profile() -> SerializableResult<SettingsModel> {
Ok(setting_profiles::get_global_profile().await)
}
@ -13,6 +13,6 @@ pub async fn get_global_profile() -> SerializableResult<Model> {
#[tauri::command]
pub async fn get_profile_or_default(
name: Option<String>,
) -> SerializableResult<Model> {
) -> SerializableResult<SettingsModel> {
Ok(setting_profiles::dao::get_profile_or_default(name.as_ref()).await?)
}

View file

@ -5,26 +5,37 @@ import { Switch as AriaSwitch } from 'react-aria-components';
import { tv } from 'tailwind-variants';
export const switchVariants = tv({
base: [
'w-[40px] h-[22px] p-3 flex flex-row relative rounded-full transition-colors overflow-hidden bg-brand',
],
})
base: [
'group inline-flex h-6 w-11 items-center rounded-full transition-colors',
'bg-component-bg-pressed data-[selected]:bg-blue-600',
'focus:outline-none',
'disabled:cursor-not-allowed disabled:opacity-50',
],
});
export const switchThumbVariants = tv({
base: [
'pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow ring-0 transition-transform',
'translate-x-1 group-data-[selected]:translate-x-6',
],
});
export type SwitchProps = AriaSwitchProps & RefAttributes<HTMLLabelElement> & {
className?: string;
className?: string;
children?: React.ReactNode;
};
function Switch({
className,
...props
className,
children,
...props
}: SwitchProps) {
return (
<AriaSwitch className={switchVariants({ className })} {...props}>
<div className="bg-orange-400" />
{props.children}
return (
<AriaSwitch className={switchVariants({ className })} {...props}>
<span className={switchThumbVariants()} />
{children && <span className="ml-3 text-sm font-medium text-gray-900">{children}</span>}
</AriaSwitch>
);
);
}
export default Switch;
export default Switch;

View file

@ -0,0 +1,59 @@
import type React from 'react';
import type { RefAttributes } from 'react';
import type { TooltipTriggerComponentProps as AriaTooltipTriggerProps } from 'react-aria-components';
import {
TooltipTrigger as AriaTooltipTrigger,
Tooltip as AriaTooltip,
OverlayArrow
} from 'react-aria-components';
import { tv } from 'tailwind-variants';
export const tooltipVariants = tv({
base: [
'px-3 py-2 text-sm font-medium text-white bg-gray-900 rounded-lg shadow-lg',
'border border-gray-700 max-w-xs z-50',
'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',
],
});
export const tooltipArrowVariants = tv({
base: [
'fill-gray-900 stroke-gray-700 stroke-1',
],
});
export type TooltipProps = AriaTooltipTriggerProps & RefAttributes<HTMLElement> & {
text: string;
children: React.ReactElement;
placement?: 'top' | 'bottom' | 'left' | 'right';
className?: string;
delay?: number;
closeDelay?: number;
};
function Tooltip({
text,
children,
placement = 'top',
className,
delay = 700,
closeDelay = 0,
...props
}: TooltipProps) {
return (
<AriaTooltipTrigger delay={delay} closeDelay={closeDelay} {...props}>
{children}
<AriaTooltip placement={placement} className={tooltipVariants({ className })}>
<OverlayArrow>
<svg width={8} height={8} viewBox="0 0 8 8" className={tooltipArrowVariants()}>
<path d="M0 0 L4 4 L8 0" />
</svg>
</OverlayArrow>
{text}
</AriaTooltip>
</AriaTooltipTrigger>
);
}
export default Tooltip;

View file

@ -58,7 +58,7 @@ function RouteComponent() {
<SettingsRow.Header>Cluster Actions</SettingsRow.Header>
<SettingsRow
children={(
<ToggleButton color='secondary'>Edit</ToggleButton>
<Button color='secondary'>Edit</Button>
)}
description="Edit the cluster name and cover image."
icon={<Edit02Icon />}

View file

@ -3,6 +3,7 @@ import Sidebar from '../settings/route'
import ScrollableContainer from '@/components/ScrollableContainer'
import useCommand from '@/hooks/useCommand'
import { bindings } from '@/main'
import { GameSettings, ProcessSettings } from '../settings/minecraft'
export const Route = createFileRoute('/app/cluster/settings')({
component: RouteComponent,
@ -11,14 +12,22 @@ export const Route = createFileRoute('/app/cluster/settings')({
function RouteComponent() {
const { id } = Route.useSearch()
const result = useCommand("getProfileOrDefault", () => bindings.commands.getProfileOrDefault(id.toString()))
const cluster = useCommand("getClusterById", () => bindings.commands.getClusterById(Number(id.toString()) as unknown as bigint))
const result = useCommand("getProfileOrDefault", () => bindings.commands.getProfileOrDefault(cluster.data?.name as string), {
enabled: !!cluster.data?.name
})
console.log(result.data)
return (
<Sidebar.Page>
<h1>Settings</h1>
<ScrollableContainer>
<div className="h-full">
<pre>{JSON.stringify(result.data, null, 2)}</pre>
<h1>Minecraft Settings</h1>
<GameSettings />
<ProcessSettings />
</div>
</ScrollableContainer>
</Sidebar.Page>

View file

@ -8,7 +8,8 @@ import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import DefaultInstancePhoto from "@/assets/images/default_instance_cover.jpg"
import Modal from '@/components/overlay/Modal';
import { Server01Icon } from '@untitled-theme/icons-react';
import HeaderImage from "@/assets/images/default_instance_cover.jpg";
import HeaderImage from "@/assets/images/default_banner.png";
import { TextField } from '@/components/base/TextField';
export const Route = createFileRoute('/app/')({
component: RouteComponent,
@ -150,13 +151,13 @@ function ClusterCreate() {
<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>
{/* <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">
<pre>asdasdasdasds</pre>
<TextField placeholder='Epik cluster name' className={"px-2"} />
</div>
<div className="flex flex-row justify-end gap-x-2 p-3 pt-0">

View file

@ -0,0 +1,133 @@
import { createFileRoute } from '@tanstack/react-router'
import Sidebar from './route'
import ScrollableContainer from '@/components/ScrollableContainer'
import Button from '@/components/base/Button'
import { InfoCircleIcon, Trash01Icon, UserPlus02Icon } from '@untitled-theme/icons-react'
import PlayerHead from '@/components/content/PlayerHead'
import { useState } from 'react'
import { Show } from '@/components/base/Show'
import Tooltip from '@/components/base/Tooltip'
import useCommand from '@/hooks/useCommand'
import { bindings } from '@/main'
export const Route = createFileRoute('/app/settings/accounts')({
component: RouteComponent,
})
interface Account {
id: string;
username: string;
}
function RouteComponent() {
const { data: usersData, isLoading: usersLoading } = useCommand("getUsers", bindings.commands.getUsers)
const { data: defaultUserData, isLoading: defaultUserLoading } = useCommand("getDefaultUser", () => bindings.commands.getDefaultUser(false))
if (usersLoading || defaultUserLoading) {
return <Sidebar.Page><h1>Accounts</h1><p>Loading accounts...</p></Sidebar.Page>;
}
return (
<Sidebar.Page>
<h1>Accounts</h1>
<ScrollableContainer>
<div className="h-full space-y-2">
<Show when={usersData && usersData.length > 0} fallback={<p>No accounts found.</p>}>
{usersData?.map((account) => (
<AccountRow
key={account.id}
account={account}
isCurrent={account.id === defaultUserData?.id}
/>
))}
</Show>
</div>
</ScrollableContainer>
<div className="mt-auto pt-2 flex flex-row items-end justify-end">
<Button color="primary">
<UserPlus02Icon /> Add Account
</Button>
</div>
</Sidebar.Page>
)
}
interface AccountRowProps {
account: Account;
isCurrent?: boolean;
};
function AccountRow({ account, isCurrent }: AccountRowProps) {
const [errored, setErrored] = useState(false);
const setDefaultUserCommand = useCommand(
"setDefaultUser",
() => bindings.commands.setDefaultUser(account.id),
{
enabled: false,
subscribed: false,
}
);
const removeUserCommand = useCommand(
"removeUser",
() => bindings.commands.removeUser(account.id),
{
enabled: false,
subscribed: false,
}
);
const handleSetDefault = () => {
setDefaultUserCommand.refetch();
};
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
removeUserCommand.refetch();
};
return (
<div
className={`flex flex-row bg-component-bg hover:bg-component-bg-hover active:bg-component-bg-pressed rounded-xl gap-3.5 p-4 items-center box-border border ${isCurrent ? 'border-brand' : 'border-transparent'} cursor-pointer`} // Added cursor-pointer
onClick={handleSetDefault}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleSetDefault(); }}
>
<div className="h-12 w-12 flex items-center justify-center flex-shrink-0">
<PlayerHead
className="h-12 w-12 rounded-md"
onError={() => setErrored(true)}
uuid={account.id}
/>
</div>
<div className={`flex flex-col gap-1 flex-1 min-w-0 ${errored ? 'text-danger' : ''}`}>
<div className="flex flex-row items-center gap-1">
<h3 className="text-lg font-medium truncate">{account.username}</h3>
<Show when={errored}>
<Tooltip
text="Could not fetch this account's game profile"
>
<InfoCircleIcon className="h-4 w-4 flex-shrink-0" />
</Tooltip>
</Show>
</div>
<p className="text-xs text-fg-secondary truncate">{account.id}</p>
</div>
<div className="ml-auto flex-shrink-0">
<Button
color="danger"
size='icon'
onClick={handleDelete}
aria-label={`Remove account ${account.username}`}
>
<Trash01Icon />
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,115 @@
import { createFileRoute } from '@tanstack/react-router'
import Sidebar from './route'
import ScrollableContainer from '@/components/ScrollableContainer'
import { THEMES } from '@/utils/theming'
import SettingsRow from '@/components/SettingsRow'
import { Monitor01Icon, PackageIcon, Speedometer04Icon } from '@untitled-theme/icons-react'
import ToggleButton from '@/components/base/ToggleButton'
import Switch from '@/components/base/Switch'
export const Route = createFileRoute('/app/settings/appearance')({
component: RouteComponent,
})
function RouteComponent() {
return (
<Sidebar.Page>
<ScrollableContainer>
<div className="h-full">
<h1>Appearance</h1>
<div className="flex flex-row items-center gap-4">
<PrimaryThemeCard />
<div className="grid grid-cols-3 h-full gap-4">
{/* <For each={Object.keys(THEMES) as (keyof typeof THEMES)[]}>
{theme => (
<For each={THEMES[theme ?? 'OneLauncher'].variants}>
{variant => (
<ThemeCard
setTheme={setTheme}
themeSelector={`${theme}-${variant.name}`}
/>
)}
</For>
)}
</For> */}
{Object.keys(THEMES).map((theme) => {
return THEMES[theme as keyof typeof THEMES].variants.map((variant, i: number) => {
return (
<ThemeCard themeSelector={`${theme}-${variant.name}`} setTheme={() => { }} key={i} />
)
})
})}
</div>
</div>
<SettingsRow
description="Change the look of the package list."
icon={<PackageIcon />}
title="Package List Style"
>
<p>i hope i'll fix this</p>
</SettingsRow>
<SettingsRow
description="Uses custom window frame for the launcher."
icon={<Monitor01Icon />}
title="Custom Window Frame"
>
<Switch />
</SettingsRow>
<SettingsRow
description="Disables all animations in the launcher."
icon={<Speedometer04Icon />}
title="Disable Animations"
>
<Switch />
</SettingsRow>
</div>
</ScrollableContainer>
</Sidebar.Page>
)
}
interface ThemeCardProps {
themeSelector: string;
setTheme: (merged: string) => void;
};
function ThemeCard(props: ThemeCardProps) {
return (
<div className={`h-min theme-${props.themeSelector}`} onClick={() => props.setTheme(props.themeSelector)}>
<svg fill="none" height="78" viewBox="0 0 126 78" width="126" xmlns="http://www.w3.org/2000/svg">
<rect className='fill-page-elevated' height="78" rx="8" width="126" />
<rect height="77" rx="7.5" className='stroke-border' stroke-opacity="0.1" width="125" x="0.5" y="0.5" />
<path d="M8 16H116" className='stroke-fg-primary' stroke-linecap="round" stroke-width="5" />
<path d="M8 26H56" className="stroke-fg-secondary" stroke-linecap="round" stroke-width="3" />
<rect className="fill-brand" fill-opacity="0.5" height="16" rx="4" width="64" x="54" y="54" />
<path d="M54 58C54 55.7909 55.7909 54 58 54H86V70H58C55.7909 70 54 68.2091 54 66V58Z" fill="rgb(var(--clr-brand))" />
</svg>
</div>
);
}
function PrimaryThemeCard() {
return (
<div className='w-[296] h-[183]'>
<svg fill="none" height="183" viewBox="0 0 296 183" width="296" xmlns="http://www.w3.org/2000/svg">
<rect className="fill-page" height="183" rx="16" width="296" />
<rect className="stroke-border/10" height="180" rx="14.5" strokeWidth="1.5" width="293" x="1.5" y="1.5" />
<rect className="fill-page-elevated" height="123" rx="8" width="168" x="112" y="44" />
<rect className="stroke-border/5" height="122" rx="7.5" width="167" x="112.5" y="44.5" />
<rect className="fill-brand/50" height="16" rx="4" width="64" x="208" y="143" />
<path className="stroke-fg-primary" d="M132 64H240" strokeLinecap="round" strokeWidth="5" />
<path className="stroke-fg-secondary" d="M132 74H180" strokeLinecap="round" strokeWidth="3" />
<path className="stroke-code-error" d="M132 94H216" strokeLinecap="round" strokeWidth="4" />
<path className="stroke-code-warn" d="M132 104H192" strokeLinecap="round" strokeWidth="4" />
<path className="stroke-code-info" d="M132 114H210" strokeLinecap="round" strokeWidth="4" />
<path className="fill-brand" d="M208 147C208 144.791 209.791 143 212 143H240V159H212C209.791 159 208 157.209 208 155V147Z" />
</svg>
</div>
);
}

View file

@ -4,8 +4,9 @@ import useCommand from '@/hooks/useCommand'
import { bindings } from '@/main'
import SettingsRow from '@/components/SettingsRow'
import DiscordIcon from "@/assets/logos/discord.svg"
import ToggleButton from '@/components/base/ToggleButton'
import Switch from '@/components/base/Switch'
import Button from '@/components/base/Button'
import { FolderIcon, LinkExternal01Icon, XIcon } from '@untitled-theme/icons-react'
export const Route = createFileRoute('/app/settings/')({
component: RouteComponent,
@ -20,14 +21,42 @@ function RouteComponent() {
<h1>General Settings</h1>
<SettingsRow
description="Enable Discord Rich Presence."
icon={<img src={DiscordIcon} className="w-6 invert-100" />}
title="Discord RPC"
description="Enable Discord Rich Presence."
icon={<img src={DiscordIcon} className="w-6 invert-100" />}
title="Discord RPC"
>
<Switch />
</SettingsRow>
<SettingsRow
description="Hide the confirmation dialog when closing the launcher."
icon={<XIcon />}
title="Hide Close Dialog"
>
<Switch />
</SettingsRow>
{/* <SettingsRow
description="Sends errors and crash logs using Sentry to help developers fix issues. (// TODO)"
icon={<AlertSquareIcon />}
title="Error Analytics"
>
<Switch>Thing</Switch>
</SettingsRow>
<pre>{JSON.stringify(result.data, null, 2)}</pre>
<Toggle
checked={() => !(settings().disable_analytics ?? false)}
onChecked={value => settings().disable_analytics = !value}
/>
</SettingsRow> */}
<SettingsRow.Header>Folders and Files</SettingsRow.Header>
<SettingsRow
description={'Unknown for now'}
icon={<FolderIcon />}
title="Launcher Folder"
>
<Button size='normal'>
<LinkExternal01Icon /> Open
</Button>
</SettingsRow>
</div>
</Sidebar.Page>
)

View file

@ -1,8 +1,14 @@
import { createFileRoute } from '@tanstack/react-router'
import Sidebar from './route'
import SettingsRow from '@/components/SettingsRow'
import { Maximize01Icon } from '@untitled-theme/icons-react'
import { Database01Icon, EyeIcon, FilePlus02Icon, FileX02Icon, LayoutTopIcon, Maximize01Icon, ParagraphWrapIcon, XIcon } from '@untitled-theme/icons-react'
import Button from '@/components/base/Button'
import useCommand from '@/hooks/useCommand'
import { bindings } from '@/main'
import { TextField } from '@/components/base/TextField'
import ToggleButton from '@/components/base/ToggleButton'
import ScrollableContainer from '@/components/ScrollableContainer'
import Switch from '@/components/base/Switch'
export const Route = createFileRoute('/app/settings/minecraft')({
component: RouteComponent,
@ -11,19 +17,126 @@ export const Route = createFileRoute('/app/settings/minecraft')({
function RouteComponent() {
return (
<Sidebar.Page>
<div className="h-full">
<h1>Minecraft Settings</h1>
<ScrollableContainer>
<div className="h-full">
<h1>Minecraft Settings</h1>
<SettingsRow.Header>Game</SettingsRow.Header>
<GameSettings />
<SettingsRow
description="Force Minecraft to start in fullscreen mode."
icon={<Maximize01Icon />}
title="Force Fullscreen"
>
<Button>something</Button>
</SettingsRow>
</div>
{/* <LauncherSettings /> */}
<ProcessSettings />
</div>
</ScrollableContainer>
</Sidebar.Page>
)
}
export function GameSettings() {
return <>
<SettingsRow.Header>Game</SettingsRow.Header>
<SettingsRow
description="Force Minecraft to start in fullscreen mode."
icon={<Maximize01Icon />}
title="Force Fullscreen"
>
<Switch />
</SettingsRow>
<SettingsRow
description="The game window resolution in pixels."
icon={<LayoutTopIcon />}
title="Resolution"
>
<div className="grid grid-cols-[70px_16px_70px] gap-2 grid-justify-center grid-items-center">
<TextField
type='number'
className="text-center"
/>
<XIcon className="size-4 self-center" />
<TextField
type='number'
className="text-center"
/>
</div>
</SettingsRow>
<SettingsRow
description="The amount of memory in megabytes allocated for the game."
icon={<Database01Icon />}
title="Memory"
>
<div className="flex items-center gap-x-4 flex-justify-center">
<div className="flex flex-row items-center gap-x-2">
<span>Min:</span>
<TextField
className="text-center"
type='number'
max={2024}
min={1}
/>
</div>
<div className="flex flex-row items-center gap-x-2">
<span>Max:</span>
<TextField
className="text-center"
type='number'
max={2024}
min={1}
/>
</div>
</div>
</SettingsRow>
</>
}
function LauncherSettings() {
return <>
<SettingsRow.Header>Launcher</SettingsRow.Header>
<SettingsRow
description="Hide the launcher whenever you start a game."
icon={<EyeIcon />}
title="Hide On Launch"
>
<ToggleButton color='primary' children="false" />
</SettingsRow>
</>
}
export function ProcessSettings() {
return <>
<SettingsRow.Header>Process</SettingsRow.Header>
<SettingsRow
description="Command to run before launching the game."
icon={<FilePlus02Icon />}
title="Pre-Launch Command"
>
<TextField
placeholder="echo 'Game started'"
/>
</SettingsRow>
<SettingsRow
description="Command to run when launching the game."
icon={<ParagraphWrapIcon />}
title="Wrapper Command"
>
<TextField
placeholder="gamescope"
/>
</SettingsRow>
<SettingsRow
description="Command to run after exiting the game."
icon={<FileX02Icon />}
title="Post-Exit Command"
>
<TextField
placeholder="echo 'Game exited'"
/>
</SettingsRow>
</>
}

View file

@ -0,0 +1,26 @@
export const DEFAULT_THEME = 'OneLauncher-Dark';
export const THEMES = {
OneLauncher: {
variants: [
{
name: 'Dark',
type: 'dark',
},
{
name: 'Light',
type: 'light',
},
],
},
} as const satisfies Record<string, Theme>;
export interface Theme {
variants: ThemeVariant[];
}
export type ThemeTypes = 'dark' | 'light';
export interface ThemeVariant {
type: ThemeTypes;
name: string;
};

View file

@ -16,9 +16,16 @@ pub fn specta(attr: proc_macro::TokenStream, item: proc_macro::TokenStream) -> p
proc_macro2::TokenStream::new()
};
let specta_type = quote! {
#[cfg_attr(feature = "specta", derive(specta::Type))]
};
let specta_type = if let Some(rename) = attrs.rename {
quote! {
#[cfg_attr(feature = "specta", derive(specta::Type))]
#[cfg_attr(feature = "specta", specta(rename = #rename))]
}
} else {
quote! {
#[cfg_attr(feature = "specta", derive(specta::Type))]
}
};
let expanded = quote! {
#specta_event_type
@ -32,12 +39,15 @@ pub fn specta(attr: proc_macro::TokenStream, item: proc_macro::TokenStream) -> p
#[derive(Default)]
struct SpectaAttributes {
event: bool,
rename: Option<String>,
}
impl SpectaAttributes {
fn parse(&mut self, meta: ParseNestedMeta) -> syn::Result<()> {
if meta.path.is_ident("event") {
self.event = true;
} else if meta.path.is_ident("rename") {
self.rename = Some(meta.value()?.to_string());
}
Ok(())
}