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 ( <> - - + + +
+
+
+ Header Image +
+
+ +
+

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({
-
-
- - - - - ); -} - 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)