feat: cluster editing

This commit is contained in:
emirsassan 2025-06-14 12:03:13 +03:00
parent 014cd863e6
commit 8385f9415d
15 changed files with 621 additions and 203 deletions

View file

@ -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",

View file

@ -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">

View file

@ -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} />

View file

@ -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>
);
}

View file

@ -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 />}

View file

@ -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>
</>
);
}

View file

@ -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';
}

View file

@ -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())
}

View file

@ -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 {

View file

@ -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"
}
}

View file

@ -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;

View 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;

View file

@ -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>
);
}

View file

@ -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
View file

@ -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)