diff --git a/apps/onelauncher/frontend/package.json b/apps/onelauncher/frontend/package.json
index 6f234a87..9469aff8 100644
--- a/apps/onelauncher/frontend/package.json
+++ b/apps/onelauncher/frontend/package.json
@@ -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",
diff --git a/apps/onelauncher/frontend/src/components/Navbar.tsx b/apps/onelauncher/frontend/src/components/Navbar.tsx
index f84e01f6..e5ab57d7 100644
--- a/apps/onelauncher/frontend/src/components/Navbar.tsx
+++ b/apps/onelauncher/frontend/src/components/Navbar.tsx
@@ -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() {
{/*
*/}
+
diff --git a/apps/onelauncher/frontend/src/components/WindowFrame.tsx b/apps/onelauncher/frontend/src/components/WindowFrame.tsx
index 8ef44e8b..d7c90fdf 100644
--- a/apps/onelauncher/frontend/src/components/WindowFrame.tsx
+++ b/apps/onelauncher/frontend/src/components/WindowFrame.tsx
@@ -32,10 +32,6 @@ function WindowFrame() {
-
- OneLauncher
-
-
diff --git a/apps/onelauncher/frontend/src/components/launcher/cluster/ClusterCreation.tsx b/apps/onelauncher/frontend/src/components/launcher/cluster/ClusterCreation.tsx
index 7f805670..7eb4cb06 100644
--- a/apps/onelauncher/frontend/src/components/launcher/cluster/ClusterCreation.tsx
+++ b/apps/onelauncher/frontend/src/components/launcher/cluster/ClusterCreation.tsx
@@ -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 (
<>
-
-
+
+
+ } placeholder="Search for something..." />
+
-
-
-
-
-

+
+
+
+
+
+
+
+

+
+
+
+
+
New Cluster
+ {STEPS[currentStepIndex].title}
+
+
-
-
-
-
New Cluster
-
{STEPS[currentStepIndex].title}
+
+
+ {stepContent}
+
+
+
+
+
-
-
- {stepContent}
-
-
-
-
-
-
-
-
-
-
+
+
+
>
);
}
@@ -195,18 +239,145 @@ function ClusterInformation({
clusterVersion,
onClusterVersionChange,
}: ClusterInformationProps) {
+ const versions = useCommand('getGameVersions', bindings.core.getGameVersions);
+
return (
-
onClusterNameChange(e.target.value)}
- placeholder="My Awesome Server"
- value={clusterName}
- />
-
- Slmalr
- Slmalr 2
-
+
+
+
+
+
+
+ );
+}
+
+interface VersionSelectorProps {
+ versions: Array | undefined;
+ selectedVersion: string | Array;
+ onChange: (value: string | Array) => 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>([]);
+ const [filters, setFilters] = useState({
+ old_alpha: false,
+ old_beta: false,
+ release: true,
+ snapshot: false,
+ });
+
+ const refresh = useCallback((filters: VersionReleaseFilters, versions: Array) => {
+ 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 (
+
+
+
+ {filteredVersions.map(version => (
+ {version.id}
+ ))}
+
+
+
+
+ {Object.keys(filters).map(filter => (
+ toggleFilter(filter as VersionType)}
+ >
+ {filter}
+
+ ))}
+
+
+ );
+}
+
+type OptionProps = {
+ header: string;
+ children?: JSX.Element | Array;
+} & RefAttributes;
+
+function Option(props: OptionProps) {
+ return (
+
+
{props.header}
+ {props.children}
);
}
diff --git a/apps/onelauncher/frontend/src/routes/app/cluster/index.tsx b/apps/onelauncher/frontend/src/routes/app/cluster/index.tsx
index cfeadd8b..18084b63 100644
--- a/apps/onelauncher/frontend/src/routes/app/cluster/index.tsx
+++ b/apps/onelauncher/frontend/src/routes/app/cluster/index.tsx
@@ -20,6 +20,7 @@ function RouteComponent() {
const { id } = Route.useSearch();
const [newCover, setNewCover] = useState('');
const [newName, setNewName] = useState('');
+ const [edit, setEdit] = useState(false);
// dumbass fix ik
const cluster = useCommand('getClusterById', () => bindings.core.getClusterById(Number(id.toString()) as unknown as bigint));
@@ -31,7 +32,7 @@ function RouteComponent() {
Cluster Actions
Edit
+
)}
description="Edit the cluster name and cover image."
icon={}
@@ -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({
-
+
}
diff --git a/apps/onelauncher/frontend/src/routes/app/index.tsx b/apps/onelauncher/frontend/src/routes/app/index.tsx
index 4cd3f908..9b6b95ff 100644
--- a/apps/onelauncher/frontend/src/routes/app/index.tsx
+++ b/apps/onelauncher/frontend/src/routes/app/index.tsx
@@ -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() {
@@ -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() {
Create a cluster
- {/*
+
You've played
{' '}
- {cluster()!.meta.mc_version}
+ {cluster?.name}
{' '}
- {upperFirst(cluster()!.meta.loader || 'Unknown')}
+ {upperFirst(cluster?.mc_loader || 'Unknown')}
{' '}
for
{' '}
- {formatAsDuration((cluster()!.meta.overall_played || 0))}
+ {cluster?.overall_played || 0}
.
- */}
+
@@ -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 (
- <>
-
-
-
-
-
-
-
-

-
-
-
-
-
New Cluster
- {/* asd */}
-
-
-
-
-
-
-
- >
- );
-}
-
interface ClusterGroupProps {
clusters: Array
| undefined;
isFetching?: boolean;
@@ -256,12 +194,10 @@ function ClusterCard({
{/*
*/}
-
+
-
-
>
);
}
diff --git a/apps/onelauncher/frontend/src/utils/index.ts b/apps/onelauncher/frontend/src/utils/index.ts
index 498f81ca..a82318da 100644
--- a/apps/onelauncher/frontend/src/utils/index.ts
+++ b/apps/onelauncher/frontend/src/utils/index.ts
@@ -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
(object: T): Capitalize {
+ const string = object.toString();
+ return (string.charAt(0).toUpperCase() + string.slice(1)) as Capitalize;
+}
+
+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';
+}
diff --git a/packages/core/src/api/game/metadata.rs b/packages/core/src/api/game/metadata.rs
index bf3792d8..b1e8af0d 100644
--- a/packages/core/src/api/game/metadata.rs
+++ b/packages/core/src/api/game/metadata.rs
@@ -428,9 +428,9 @@ pub async fn get_loader_version(
#[tracing::instrument]
pub async fn get_game_versions() -> LauncherResult> {
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())
}
diff --git a/packages/core/src/api/tauri/commands.rs b/packages/core/src/api/tauri/commands.rs
index cd8bfaaf..e7444efe 100644
--- a/packages/core/src/api/tauri/commands.rs
+++ b/packages/core/src/api/tauri/commands.rs
@@ -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) -> 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 {
diff --git a/packages/web_common/package.json b/packages/web_common/package.json
index 01c54673..3de7df24 100644
--- a/packages/web_common/package.json
+++ b/packages/web_common/package.json
@@ -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"
}
}
diff --git a/packages/web_common/src/components/Dropdown.tsx b/packages/web_common/src/components/Dropdown.tsx
index 92b996ba..80509ae5 100644
--- a/packages/web_common/src/components/Dropdown.tsx
+++ b/packages/web_common/src/components/Dropdown.tsx
@@ -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 extends Omit, '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;
+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;
+
+export interface DropdownProps
+ extends Omit, 'className' | 'children'>,
+ DropdownStyleProps,
+ ClassNameString {
+ label?: string;
+ labelClassName?: string;
+ children: React.ReactNode;
+ listToolRow?: React.ReactNode;
+ triggerTextPrefix?: string;
+
+ customMinimalIcon?: React.ReactNode;
+ popoverClassName?: string;
+}
+
+export function Dropdown(props: DropdownProps) {
+ const {
+ className,
+ label,
+ labelClassName,
+ children,
+ listToolRow,
+ triggerTextPrefix,
+ customMinimalIcon,
+ minimal,
+ popoverClassName,
+ ...rest
+ } = props;
+
+ const styles = dropdownStyles({ minimal, className });
-export function Dropdown({
- className,
- label,
- children,
- ...rest
-}: ComboBoxWrapperProps) {
return (
-
- {label && {label}}
-
-
-
- {children}
-
-
+
+ {({ isOpen }) => (
+ <>
+ {label && {label}}
+
+
+
+
+
+
+ {children}
+
+ {listToolRow && (
+
+ {listToolRow}
+
+ )}
+
+
+ >
+ )}
);
}
-export default Dropdown;
+export interface DropdownItemProps extends AriaListBoxItemProps {
+}
-Dropdown.Item = ({
- className,
- ...rest
-}: AriaListBoxItemProps & ClassNameString) => (
-
-);
+Dropdown.Item = (props: DropdownItemProps) => {
+ const itemStyles = dropdownItemStyles();
+
+ const { className, children } = props;
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default Dropdown;
diff --git a/packages/web_common/src/components/SelectList.tsx b/packages/web_common/src/components/SelectList.tsx
new file mode 100644
index 00000000..ada5fb10
--- /dev/null
+++ b/packages/web_common/src/components/SelectList.tsx
@@ -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;
+ onChange: (value: string | Array) => void;
+ className?: string;
+ multiple?: boolean;
+ children: JSX.Element;
+}
+
+interface SelectListItemProps {
+ value: string;
+ children: JSX.Element | any;
+ className?: string;
+}
+
+const SelectListContext = createContext(null);
+
+export const SelectList: React.FC & { Item: React.FC } = ({
+ 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 (
+
+
+ {children}
+
+
+ );
+};
+
+const SelectListItem: React.FC = ({ 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 (
+ onSelect(value)}
+ >
+ {children}
+
+ );
+};
+
+SelectList.Item = SelectListItem;
diff --git a/packages/web_common/src/components/TextField.tsx b/packages/web_common/src/components/TextField.tsx
index dcf6be14..3c562af1 100644
--- a/packages/web_common/src/components/TextField.tsx
+++ b/packages/web_common/src/components/TextField.tsx
@@ -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;
export type TextFieldProps = AriaInputProps & RefAttributes & {
className?: string;
+ iconLeft?: JSX.Element;
+ iconRight?: JSX.Element;
};
export function TextField({
className,
+ iconLeft,
+ iconRight,
...props
}: TextFieldProps) {
+ const styles = textFieldVariants();
+
return (
- <>
-
- >
+
+ {iconLeft && (
+
+ {iconLeft}
+
+ )}
+
+ {iconRight && (
+
+ {iconRight}
+
+ )}
+
);
}
diff --git a/packages/web_common/src/components/index.ts b/packages/web_common/src/components/index.ts
index dfd448b4..2782b304 100644
--- a/packages/web_common/src/components/index.ts
+++ b/packages/web_common/src/components/index.ts
@@ -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';
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f2a6a1e7..f0717eb3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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)