feat: accounts

This commit is contained in:
LynithDev 2025-08-18 14:25:59 +02:00
parent fdf74258a0
commit 80ebf6e057
36 changed files with 926 additions and 78 deletions

6
Cargo.lock generated
View file

@ -3400,6 +3400,7 @@ dependencies = [
"chrono",
"interpulse",
"onelauncher_core",
"reqwest",
"serde",
"serde_json",
"specta",
@ -6060,9 +6061,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-fs"
version = "2.2.1"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88371e340ad2f07409a3b68294abe73f20bc9c1bc1b631a31dc37a3d0161f682"
checksum = "33ead0daec5d305adcefe05af9d970fc437bcc7996052d564e7393eb291252da"
dependencies = [
"anyhow",
"dunce",
@ -6078,7 +6079,6 @@ dependencies = [
"thiserror 2.0.12",
"toml",
"url",
"uuid 1.16.0",
]
[[package]]

View file

@ -60,6 +60,7 @@ tokio = { workspace = true }
# utils
thiserror = { workspace = true }
anyhow = { workspace = true }
reqwest = { workspace = true }
[dependencies.tauri]
workspace = true

View file

@ -35,6 +35,7 @@
"react-aria-components": "catalog:",
"react-dom": "catalog:",
"react-just-parallax": "catalog:",
"skinview3d": "catalog:",
"tailwind-merge": "catalog:",
"tailwind-variants": "catalog:",
"tailwindcss": "catalog:",

View file

@ -94,6 +94,8 @@ refresh_token: string;
*/
expires: string }
export type MojangPlayerProfile = { uuid: string; username: string; is_slim: boolean; skin_url: string | null; cape_url: string | null }
export type PackageAuthor = { Team: { team_id: string; org_id: string | null } } | { Users: ManagedUser[] }
export type PackageCategories = { Mod: PackageModCategory[] } | { ResourcePack: PackageResourcePackCategory[] } | { Shader: PackageShaderCategory[] } | { DataPack: PackageModCategory[] } | { ModPack: PackageModPackCategory[] }
@ -228,8 +230,15 @@ export type VersionType =
*/
"old_beta"
const ARGS_MAP = { 'oneclient':'{"openDevTools":[],"getClustersGroupedByMajor":[]}', 'core':'{"openMsaLogin":[],"readSettings":[],"getProfileOrDefault":["name"],"getLogByName":["id","name"],"getWorlds":["id"],"launchCluster":["id","uuid"],"getGameVersions":[],"getUser":["uuid"],"removeUser":["uuid"],"getUsersFromAuthor":["provider","author"],"getPackage":["provider","slug"],"setDefaultUser":["uuid"],"getMultiplePackages":["provider","slugs"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"updateClusterById":["id","request"],"getGlobalProfile":[],"getLoadersForVersion":["mc_version"],"getPackageBody":["provider","body"],"writeSettings":["setting"],"getScreenshots":["id"],"getLogs":["id"],"getDefaultUser":["fallback"],"getClusterById":["id"],"removeCluster":["id"],"updateClusterProfile":["name","profile"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"createCluster":["options"],"getClusters":[],"searchPackages":["provider","query"],"getUsers":[]}', 'events':'{"ingress":["event"],"process":["event"],"message":["event"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}' }
export type Router = { 'core': { getClusters: () => Promise<ClusterModel[]>,
const ARGS_MAP = { 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'events':'{"ingress":["event"],"message":["event"],"process":["event"]}', 'oneclient':'{"getClustersGroupedByMajor":[],"openDevTools":[]}', 'core':'{"removeUser":["uuid"],"openMsaLogin":[],"getLogByName":["id","name"],"setDefaultUser":["uuid"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getUser":["uuid"],"getClusters":[],"getProfileOrDefault":["name"],"updateClusterProfile":["name","profile"],"getMultiplePackages":["provider","slugs"],"getWorlds":["id"],"getUsersFromAuthor":["provider","author"],"fetchMinecraftProfile":["uuid"],"getLogs":["id"],"getUsers":[],"launchCluster":["id","uuid"],"writeSettings":["setting"],"getLoadersForVersion":["mc_version"],"getGlobalProfile":[],"getGameVersions":[],"searchPackages":["provider","query"],"removeCluster":["id"],"getDefaultUser":["fallback"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"updateClusterById":["id","request"],"getPackage":["provider","slug"],"getScreenshots":["id"],"readSettings":[],"getClusterById":["id"],"createCluster":["options"],"getPackageBody":["provider","body"]}' }
export type Router = { 'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> },
'oneclient': { openDevTools: () => Promise<void>,
getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>> },
'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> },
'core': { getClusters: () => Promise<ClusterModel[]>,
getClusterById: (id: number) => Promise<ClusterModel | null>,
removeCluster: (id: number) => Promise<null>,
createCluster: (options: CreateCluster) => Promise<ClusterModel>,
@ -258,14 +267,8 @@ getPackageBody: (provider: Provider, body: ManagedPackageBody) => Promise<string
getMultiplePackages: (provider: Provider, slugs: string[]) => Promise<ManagedPackage[]>,
getPackageVersions: (provider: Provider, slug: string, mcVersion: string | null, loader: GameLoader | null, offset: number, limit: number) => Promise<Paginated<ManagedVersion>>,
downloadPackage: (provider: Provider, packageId: string, versionId: string, clusterId: number, skipCompatibility: boolean | null) => Promise<PackageModel>,
getUsersFromAuthor: (provider: Provider, author: PackageAuthor) => Promise<ManagedUser[]> },
'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> },
'oneclient': { openDevTools: () => Promise<void>,
getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>> },
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> } };
getUsersFromAuthor: (provider: Provider, author: PackageAuthor) => Promise<ManagedUser[]>,
fetchMinecraftProfile: (uuid: string) => Promise<MojangPlayerProfile> } };
export type { InferCommandOutput }

View file

@ -0,0 +1,35 @@
import type { ImgHTMLAttributes } from 'react';
import { usePlayerProfile } from '@/hooks/usePlayerProfile';
import { getSkinUrl } from '@/utils/minecraft';
import { renderHeadToDataUrl } from '@onelauncher/common';
import { useEffect, useState } from 'react';
export interface AccountAvatarProps extends ImgHTMLAttributes<HTMLImageElement> {
uuid: string | undefined | null;
}
export function AccountAvatar({
uuid,
alt,
style,
...rest
}: AccountAvatarProps) {
const { data: profile } = usePlayerProfile(uuid);
const [headUrl, setHeadUrl] = useState<string | null>(null);
useEffect(() => {
renderHeadToDataUrl(getSkinUrl(profile?.skin_url)).then(setHeadUrl);
}, [profile]);
return (
<img
alt={alt || `${profile?.username}'s avatars`}
src={headUrl || undefined}
style={{
...style,
imageRendering: 'pixelated',
}}
{...rest}
/>
);
}

View file

@ -1,19 +1,25 @@
import type { ButtonProps } from '@onelauncher/common/components';
import type { LinkProps, RegisteredRouter } from '@tanstack/react-router';
import LauncherLogo from '@/assets/logos/oneclient.svg?react';
import { Button } from '@onelauncher/common/components';
import { bindings } from '@/main';
import { useCommand } from '@onelauncher/common';
import { Button, Popup } from '@onelauncher/common/components';
import { Link } from '@tanstack/react-router';
import { Window } from '@tauri-apps/api/window';
import { MinusIcon, SquareIcon, XCloseIcon } from '@untitled-theme/icons-react';
import { twMerge } from 'tailwind-merge';
import { AccountAvatar } from './AccountAvatar';
import { AccountPopup } from './overlay/AccountPopup';
export function Navbar() {
const { data: currentAccount } = useCommand(['getDefaultUser'], () => bindings.core.getDefaultUser(true));
const onMinimize = () => Window.getCurrent().minimize();
const onMaximize = () => Window.getCurrent().toggleMaximize();
const onClose = () => Window.getCurrent().close();
return (
<nav className="flex flex-row items-center justify-between h-20 px-12 z-50" data-tauri-drag-region="true">
<nav className="flex flex-row items-center justify-between h-20 px-12 z-40" data-tauri-drag-region="true">
<div className="flex flex-1 pointer-events-none">
<LauncherLogo height={47} width={230} />
</div>
@ -21,18 +27,28 @@ export function Navbar() {
<div className="flex flex-1 items-center justify-center pointer-events-none gap-6">
<NavbarLink to=".">Home</NavbarLink>
<NavbarLink to="./clusters">Versions</NavbarLink>
<NavbarLink to=".">Accounts</NavbarLink>
<NavbarLink to="./accounts">Accounts</NavbarLink>
</div>
<div className="flex flex-1 items-center justify-end gap-2 pointer-events-none">
<Popup.Trigger>
<NavbarButton>
<AccountAvatar className="w-full h-full rounded-lg" uuid={currentAccount?.id} />
</NavbarButton>
<AccountPopup />
</Popup.Trigger>
<NavbarButton
children={<MinusIcon />}
onClick={onMinimize}
/>
<NavbarButton
children={<SquareIcon />}
onClick={onMaximize}
/>
<NavbarButton
children={(
<XCloseIcon
@ -87,7 +103,7 @@ function NavbarButton({
}: ButtonProps) {
return (
<Button
className={twMerge('flex items-center justify-center w-12 h-12 pointer-events-auto', className)}
className={twMerge('flex items-center justify-center w-10 h-10 pointer-events-auto', className)}
color={color}
onClick={onClick}
size={size}

View file

@ -70,7 +70,7 @@ export function SheetPage({
</div>
<OverlayScrollbarsComponent className="flex flex-col flex-1 mask-t-from-97% pt-4 relative" ref={ref => setScrollContainerRef(ref?.osInstance()?.elements().content ?? null)}>
<div className="flex flex-col mx-12 gap-4 flex-1 min-h-full">
<div className="flex flex-col mx-12 gap-4 flex-1 h-full min-h-full">
<motion.div className="w-full" ref={headerLargeRef} style={{ opacity: largeOpacity }}>
{headerLarge}
</motion.div>

View file

@ -0,0 +1,81 @@
import { getSkinUrl } from '@/utils/minecraft';
import { useEffect, useRef } from 'react';
import * as skinviewer from 'skinview3d';
import { twMerge } from 'tailwind-merge';
export interface SkinViewerProps {
skinUrl?: string | undefined | null;
capeUrl?: string | undefined | null;
width?: number;
height?: number;
className?: string | undefined;
}
export function SkinViewer({
skinUrl,
capeUrl,
width = 260,
height = 300,
className,
}: SkinViewerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const viewerRef = useRef<skinviewer.SkinViewer | null>(null);
useEffect(() => {
if (!canvasRef.current)
return;
const viewer = new skinviewer.SkinViewer({
canvas: canvasRef.current,
});
viewer.autoRotate = true;
viewer.autoRotateSpeed = 0.25;
viewerRef.current = viewer;
return () => {
viewer.dispose();
};
}, []);
useEffect(() => {
if (!viewerRef.current)
return;
viewerRef.current.loadSkin(getSkinUrl(skinUrl));
}, [skinUrl]);
useEffect(() => {
}, [capeUrl]);
useEffect(() => {
if (!viewerRef.current)
return;
if (capeUrl)
viewerRef.current.loadCape(capeUrl);
else
viewerRef.current.resetCape();
}, [capeUrl]);
useEffect(() => {
if (!viewerRef.current)
return;
viewerRef.current.setSize(width, height);
}, [width, height]);
return (
<div className={twMerge('flex flex-col justify-center items-center', className)} style={{ minWidth: `${width}px`, minHeight: `${height}px` }}>
<canvas
height={height}
ref={canvasRef}
width={width}
/>
<span className="text-fg-secondary text-xs">Hold to drag. Scroll to zoom in/out.</span>
</div>
);
}

View file

@ -1,7 +1,9 @@
export * from './AccountAvatar';
export * from './GameBackground';
export * from './Loader';
export * from './LogViewer';
export * from './Navbar';
export * from './SheetPage';
export * from './SkinViewer';
export * from './Spinner';
export * from './Tabs';

View file

@ -0,0 +1,76 @@
import type { MinecraftCredentials } from '@/bindings.gen';
import { AccountAvatar } from '@/components/AccountAvatar';
import { bindings } from '@/main';
import { useCommandMut, useCommandSuspense } from '@onelauncher/common';
import { useQueryClient } from '@tanstack/react-query';
import { Button as AriaButton } from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
import { Popup } from './Popup';
export function AccountPopup() {
const { data: currentAccount } = useCommandSuspense(['getDefaultUser'], () => bindings.core.getDefaultUser(true));
const { data: accounts } = useCommandSuspense(['getUsers', 'accountPopup'], bindings.core.getUsers, {
select(data) {
data.sort((a, b) => {
if (currentAccount?.id === a.id)
return -1;
else if (currentAccount?.id === b.id)
return 1;
return a.username.localeCompare(b.username);
});
return data;
},
});
const queryClient = useQueryClient();
const { mutate: setDefaultUser } = useCommandMut(bindings.core.setDefaultUser, {
onSuccess() {
queryClient.invalidateQueries({
queryKey: ['getDefaultUser'],
});
},
});
return (
<Popup placement="top left">
<div className="flex flex-col gap-1 min-w-40">
{accounts.map(profile => (
<AccountRow
key={profile.id}
onPress={() => setDefaultUser(profile.id)}
profile={profile}
selected={profile.id === currentAccount?.id}
/>
))}
</div>
</Popup>
);
}
function AccountRow({
selected = false,
profile,
onPress,
}: {
selected?: boolean;
profile: MinecraftCredentials;
onPress: () => void;
}) {
return (
<AriaButton
className={twMerge(
'flex flex-row items-center justify-start p-1 gap-2 hover:bg-component-bg-hover pressed:bg-component-bg-pressed rounded-lg',
selected && 'bg-brand/80 pointer-events-none',
)}
onPress={onPress}
slot="close"
>
<AccountAvatar className="aspect-square h-8 rounded-md" uuid={profile.id} />
<div className="text-left flex flex-col">
<p className="flex items-center gap-1 text-fg-primary font-medium">{profile.username}</p>
</div>
</AriaButton>
);
}

View file

@ -0,0 +1,79 @@
import type { MinecraftCredentials } from '@/bindings.gen';
import { AccountAvatar } from '@/components/AccountAvatar';
import { bindings } from '@/main';
import { useCommandMut } from '@onelauncher/common';
import { Button } from '@onelauncher/common/components';
import { useQueryClient } from '@tanstack/react-query';
import { Overlay } from './Overlay';
export function AddAccountModal() {
const queryClient = useQueryClient();
const { data: profile, isPending, mutate: login } = useCommandMut(bindings.core.openMsaLogin, {
onSuccess() {
queryClient.invalidateQueries({
queryKey: ['getUsers'],
});
queryClient.invalidateQueries({
queryKey: ['getDefaultUser'],
});
},
});
const onClick = () => {
login();
};
return (
<Overlay.Dialog>
<Overlay.Title>Add Account</Overlay.Title>
<p className="max-w-sm text-fg-secondary">
Lorem ipsum dolor sit amet consectetur, adipisicing elit. Fugiat aut unde, rem esse natus iusto impedit doloribus laborum laboriosam amet? Totam, commodi sed ducimus dicta praesentium sunt? A, soluta iusto.
</p>
{profile
? (
<>
<AccountRow profile={profile} />
<Button
className="w-full"
color="primary"
size="large"
slot="close"
>
Close
</Button>
</>
)
: (
<Button
className="w-full"
color="primary"
isPending={isPending}
onClick={onClick}
size="large"
>
Add Account
</Button>
)}
</Overlay.Dialog>
);
}
function AccountRow({
profile,
}: {
profile: MinecraftCredentials;
}) {
return (
<div className="flex flex-row items-center justify-start gap-2">
<AccountAvatar className="aspect-square h-12 rounded-sm " uuid={profile.id} />
<div className="text-left flex flex-col">
<p className="flex items-center gap-1 text-fg-primary font-semibold">{profile.username}</p>
<p className="text-fg-secondary text-sm">{profile.id}</p>
</div>
</div>
);
}

View file

@ -0,0 +1,78 @@
import { Button } from '@onelauncher/common/components';
import { XCloseIcon } from '@untitled-theme/icons-react';
import { Dialog, Modal, ModalOverlay } from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
import { tv } from 'tailwind-variants';
const modalVariants = tv({
slots: {
overlay: [
'fixed inset-0 z-40',
'flex items-center justify-center',
'bg-black/50 p-4',
'entering:animate-duration-150 entering:animate-fade',
'exiting:animate-duration-100 exiting:animate-fade exiting:animate-reverse',
],
modal: [
'entering:animate-duration-150 entering:animate-ease-in-out entering:animate-zoom',
'exiting:animate-duration-100 exiting:animate-ease-in-out exiting:animate-zoom exiting:animate-reverse',
],
dialog: [
'flex flex-col items-center min-w-sm min-h-32 p-4 gap-4 relative',
'border border-component-border bg-page-elevated rounded-2xl',
],
},
});
export type OverlayProps = React.ComponentProps<typeof ModalOverlay> & {
className?: string | undefined;
children?: React.ReactNode | undefined;
};
export function Overlay({
className,
children,
...rest
}: OverlayProps) {
const { overlay, modal } = modalVariants();
return (
<ModalOverlay
className={twMerge(overlay(), className)}
isDismissable
{...rest}
>
<Modal className={modal()}>
{children}
</Modal>
<p className="fixed bottom-0 left-1/2 -translate-x-1/2 text-xs text-fg-secondary text-center py-2 pointer-events-none">Click outside to dismiss</p>
</ModalOverlay>
);
}
Overlay.Dialog = function OverlayDialog({
className,
children,
}: {
className?: string | undefined;
children?: React.ReactNode | undefined;
}) {
const { dialog } = modalVariants();
return (
<Dialog className={twMerge(dialog(), className)} role="dialog">
<div className="absolute top-5.5 right-5.5">
<Button color="ghost" size="icon" slot="close">
<XCloseIcon />
</Button>
</div>
{children}
</Dialog>
);
};
Overlay.Title = function OverlayTitle({ children }: { children: React.ReactNode }) {
return <h2 className="text-2xl font-semibold">{children}</h2>;
};

View file

@ -0,0 +1,42 @@
import type { PopoverProps } from 'react-aria-components';
import { Dialog, DialogTrigger, Popover } from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
import { tv } from 'tailwind-variants';
export interface PopupProps extends Omit<PopoverProps, 'children'> {
children: React.ReactNode;
className?: string | undefined;
}
const popupVariants = tv({
slots: {
popover: [
'flex flex-col gap-0.5 rounded-lg',
'entering:animate-fade entering:animate-duration-75',
'exiting:animate-fade exiting:animate-duration-75 exiting:animate-reverse',
],
dialog: [
'flex flex-col items-center p-1 gap-2 relative',
'border border-component-border-hover bg-page-elevated shadow-md rounded-lg',
],
},
});
export function Popup({
children,
className,
...props
}: PopupProps) {
const { popover, dialog } = popupVariants();
return (
<Popover {...props} className={twMerge(popover(), className)}>
<Dialog className={dialog()} role="dialog">
{children}
</Dialog>
</Popover>
);
}
Popup.Trigger = DialogTrigger;

View file

@ -0,0 +1,4 @@
export * from './AccountPopup';
export * from './AddAccountModal';
export * from './Overlay';
export * from './Popup';

View file

@ -0,0 +1,38 @@
import type { MojangPlayerProfile } from '@/bindings.gen';
import type { QueryKey } from '@tanstack/react-query';
import { bindings } from '@/main';
import { useCommand } from '@onelauncher/common';
import { useQueryClient } from '@tanstack/react-query';
type PartialUuid = string | undefined | null;
export function usePlayerProfile(uuid: (() => PartialUuid) | PartialUuid) {
const id = typeof uuid === 'function' ? uuid() : uuid;
const queryKey: QueryKey = ['fetchMinecraftProfile', id];
const queryClient = useQueryClient();
const state = queryClient.getQueryState(queryKey);
const shouldFetch = (state?.fetchFailureCount ?? 0) < 1;
return useCommand<MojangPlayerProfile>(
queryKey,
() => {
if (!id)
throw new Error('No player ID provided');
return bindings.core.fetchMinecraftProfile(id);
},
{
placeholderData: {
username: 'Player',
uuid: '00000000-0000-0000-0000-000000000000',
is_slim: false,
cape_url: null,
skin_url: null,
},
enabled: shouldFetch,
staleTime: Infinity,
},
);
}

View file

@ -19,6 +19,7 @@ const queryClient = new QueryClient({
queries: {
retry: false,
enabled: false,
staleTime: 1000 * 60 * 1, // 1 minute
},
},
});

View file

@ -0,0 +1,161 @@
import type { MinecraftCredentials } from '@/bindings.gen';
import type { ButtonProps } from '@onelauncher/common/components';
import { AccountAvatar, SheetPage, SkinViewer } from '@/components';
import { AddAccountModal } from '@/components/overlay';
import { Overlay } from '@/components/overlay/Overlay';
import { usePlayerProfile } from '@/hooks/usePlayerProfile';
import { bindings } from '@/main';
import { useCommandMut, useCommandSuspense } from '@onelauncher/common';
import { Button } from '@onelauncher/common/components';
import { useQueryClient } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { Button as AriaButton, DialogTrigger } from 'react-aria-components';
import { twMerge } from 'tailwind-merge';
export const Route = createFileRoute('/app/accounts')({
component: RouteComponent,
});
function RouteComponent() {
return (
<SheetPage
headerLarge={<HeaderLarge />}
headerSmall={<HeaderSmall />}
>
<SheetPage.Content>
<div className="flex-1 flex flex-row gap-8">
<Viewer />
<div className="min-h-full w-px bg-component-border"></div>
<AccountList />
</div>
</SheetPage.Content>
</SheetPage>
);
}
function HeaderLarge() {
return (
<div className="flex flex-row justify-between items-end gap-16">
<div className="flex-1 flex flex-col">
<h1 className="text-3xl font-semibold">Accounts</h1>
<p className="text-md font-medium text-fg-secondary">Something something in corporate style fashion about picking your preferred gamemodes and versions and optionally loader so that oneclient can pick something for them</p>
</div>
<AddAccountButton size="large" />
</div>
);
}
function HeaderSmall() {
return (
<div className="flex flex-row justify-between items-center h-full">
<h1 className="text-2lg h-full font-medium">Accounts</h1>
<AddAccountButton size="normal" />
</div>
);
}
function Viewer() {
const { data: currentAccount } = useCommandSuspense(['getDefaultUser'], () => bindings.core.getDefaultUser(true));
const { data: profile } = usePlayerProfile(currentAccount?.id);
return (
<SkinViewer
capeUrl={profile?.cape_url}
className="h-full w-full max-w-1/4"
height={400}
skinUrl={profile?.skin_url}
width={250}
/>
);
}
function AccountList() {
const { data: accounts } = useCommandSuspense(['getUsers'], () => bindings.core.getUsers());
const { data: currentAccount } = useCommandSuspense(['getDefaultUser'], () => bindings.core.getDefaultUser(true));
const queryClient = useQueryClient();
const { mutate: setDefaultUser } = useCommandMut(bindings.core.setDefaultUser, {
onSuccess() {
queryClient.invalidateQueries({
queryKey: ['getDefaultUser'],
});
},
});
return (
<div className="flex-1 flex flex-col gap-2">
{accounts.map(account => (
<AccountRow
key={account.id}
onPress={() => setDefaultUser(account.id)}
profile={account}
selected={currentAccount?.id === account.id}
/>
))}
{accounts.length === 0 && (
<p className="h-full flex justify-center items-center text-fg-secondary text-sm">No accounts found.</p>
)}
</div>
);
}
function AccountRow({
selected = false,
profile,
onPress,
}: {
selected?: boolean;
profile: MinecraftCredentials;
onPress: () => void;
}) {
const { isError } = usePlayerProfile(profile.id);
return (
<AriaButton
className={twMerge(
'flex flex-row items-center justify-start p-2 gap-2 bg-component-bg hover:bg-component-bg-hover pressed:bg-component-bg-pressed outline rounded-lg',
selected
? 'outline-brand hover:outline-brand-hover pressed:outline-brand-pressed'
: 'outline-component-border hover:outline-component-border-hover pressed:outline-component-border-pressed',
)}
onPress={onPress}
>
<AccountAvatar className="aspect-square h-12 rounded-sm " uuid={profile.id} />
<div className="text-left flex flex-col">
<p className="flex items-center gap-1 text-fg-primary font-semibold">
{profile.username}
{isError && <span className="text-danger text-xs font-medium"> (Error fetching online profile)</span>}
</p>
<p className="text-fg-secondary text-sm">{profile.id}</p>
</div>
{/* <Button className="w-8 h-8" color="ghost" size="icon">
<Trash01Icon />
</Button> */}
</AriaButton>
);
}
function AddAccountButton({
size,
}: {
size: ButtonProps['size'];
}) {
return (
<DialogTrigger>
<Button color="secondary" size={size}>
Add Account
</Button>
<Overlay>
<AddAccountModal />
</Overlay>
</DialogTrigger>
);
}

View file

@ -69,7 +69,7 @@ function RouteComponent() {
<Tab from={Route.id} search={search} to="/app/cluster/logs">Logs</Tab>
</TabList>
<div className="relative">
<div className="relative pb-8">
<LoaderSuspense spinner={{ size: 'large' }}>
<Outlet />
</LoaderSuspense>

View file

@ -122,7 +122,7 @@ function RouteComponent() {
</div>
</div>
<SheetPage.Content className="sticky top-8 w-86 h-full flex flex-col p-2 gap-2 outline outline-ghost-overlay">
<SheetPage.Content className="sticky top-8 w-86 h-min flex flex-col p-2 gap-2 outline outline-ghost-overlay">
<GameBackground className="aspect-video w-full rounded-xl outline-2 outline-ghost-overlay" name={versionInfo.backgroundName} />
<div className="flex flex-col px-4 pt-2 pb-4 gap-2">
@ -212,9 +212,9 @@ function RouteComponent() {
);
}
function HeaderLarge({ ref }: { ref?: React.Ref<HTMLDivElement> }) {
function HeaderLarge() {
return (
<div className="flex flex-row justify-between items-end gap-8" ref={ref}>
<div className="flex flex-row justify-between items-end gap-8">
<div className="flex flex-col">
<h1 className="text-3xl font-semibold">Clusters</h1>
<p className="text-md font-medium text-fg-secondary">Something something in corporate style fashion about picking your preferred gamemodes and versions and optionally loader so that oneclient can pick something for them</p>

View file

@ -9,6 +9,15 @@
opacity: 0;
}
}
@keyframes custom-anim-zoom {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
}
@utility animate-fade-scale {
@ -16,3 +25,8 @@
opacity: 1;
animation: custom-anim-fade-scale var(--default-animation-duration, 1s) var(--default-animation-timing-function, cubic-bezier(0, 0, 0.2, 1)) var(--default-animation-delay, 0s) forwards;
}
@utility animate-zoom {
transform: scale(0);
animation: custom-anim-zoom var(--default-animation-duration, 1s) var(--default-animation-timing-function, cubic-bezier(0, 0, 0.2, 1)) var(--default-animation-delay, 0s) forwards;
}

View file

@ -0,0 +1,5 @@
import SteveSkin from '@onelauncher/common/assets/skin/steve.png';
export function getSkinUrl(skinUrl: string | undefined | null) {
return skinUrl || SteveSkin;
}

View file

@ -25,5 +25,5 @@
"skipLibCheck": true,
"noUncheckedSideEffectImports": true
},
"include": ["**/*.ts", "**/*.tsx", "vite.config.ts", "../../../packages/web_common/src/utils/error.ts"]
"include": ["**/*.ts", "**/*.tsx", "vite.config.ts", "../../../packages/web_common/src/utils/error.ts", "src/components/AccountAvatar.tsx"]
}

View file

@ -24,7 +24,7 @@ use onelauncher_entity::prelude::model::*;
#[taurpc::procedures(path = "core")]
pub trait TauriLauncherApi {
// Clusters
// MARK: API: clusters
#[taurpc(alias = "getClusters")]
async fn get_clusters() -> LauncherResult<Vec<Cluster>>;
@ -55,7 +55,7 @@ pub trait TauriLauncherApi {
#[taurpc(alias = "getLogByName")]
async fn get_log_by_name(id: ClusterId, name: String) -> LauncherResult<Option<String>>;
// Setting Profiles
// MARK: API: profiles
#[taurpc(alias = "getProfileOrDefault")]
async fn get_profile_or_default(name: Option<String>) -> LauncherResult<SettingsProfile>;
@ -68,14 +68,14 @@ pub trait TauriLauncherApi {
profile: ProfileUpdate,
) -> LauncherResult<SettingsProfile>;
// Game Metadata
// MARK: API: metadata
#[taurpc(alias = "getGameVersions")]
async fn get_game_versions() -> LauncherResult<Vec<Version>>;
#[taurpc(alias = "getLoadersForVersion")]
async fn get_loaders_for_version(mc_version: String) -> LauncherResult<Vec<GameLoader>>;
// Users
// MARK: API: users
#[taurpc(alias = "getUsers")]
async fn get_users() -> LauncherResult<Vec<MinecraftCredentials>>;
@ -98,14 +98,14 @@ pub trait TauriLauncherApi {
app_handle: AppHandle<R>,
) -> LauncherResult<Option<MinecraftCredentials>>;
// Settings
// MARK: API: settings
#[taurpc(alias = "readSettings")]
async fn read_settings() -> LauncherResult<Settings>;
#[taurpc(alias = "writeSettings")]
async fn write_settings(setting: Settings) -> LauncherResult<()>;
// Packages
// MARK: API: packages
#[taurpc(alias = "searchPackages")]
async fn search_packages(
provider: Provider,
@ -154,6 +154,12 @@ pub trait TauriLauncherApi {
provider: Provider,
author: PackageAuthor,
) -> LauncherResult<Vec<ManagedUser>>;
// MARK: API: minecraft
#[taurpc(alias = "fetchMinecraftProfile")]
async fn fetch_player_profile(
uuid: String,
) -> LauncherResult<crate::utils::minecraft::MojangPlayerProfile>;
}
#[derive(serde::Serialize, serde::Deserialize, specta::Type, Clone)]
@ -188,7 +194,7 @@ pub struct TauriLauncherApiImpl;
#[taurpc::resolvers]
impl TauriLauncherApi for TauriLauncherApiImpl {
// Clusters
// MARK: Impl: clusters
async fn get_clusters(self) -> LauncherResult<Vec<Cluster>> {
api::cluster::dao::get_all_clusters().await
}
@ -316,7 +322,7 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
api::cluster::content::get_log_by_name(&cluster, &name).await
}
// Setting Profiles
// MARK: Impl: profiles
async fn get_global_profile(self) -> SettingsProfile {
api::setting_profiles::get_global_profile().await
}
@ -374,7 +380,7 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
Ok(profile)
}
// Game Metadata
// MARK: Impl: metadata
async fn get_loaders_for_version(self, mc_version: String) -> LauncherResult<Vec<GameLoader>> {
api::game::metadata::get_loaders_for_version(&mc_version).await
}
@ -383,7 +389,7 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
api::game::metadata::get_game_versions().await
}
// Users
// MARK: Impl: users
async fn get_users(self) -> LauncherResult<Vec<MinecraftCredentials>> {
api::credentials::get_users().await
}
@ -471,6 +477,7 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
Ok(None)
}
// MARK: Impl: settings
async fn read_settings(self) -> LauncherResult<Settings> {
let state = State::get().await?;
let settings = state.settings.read().await;
@ -489,7 +496,7 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
Ok(())
}
// Packages
// MARK: Impl: packages
async fn search_packages(
self,
provider: Provider,
@ -566,4 +573,12 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
) -> LauncherResult<Vec<ManagedUser>> {
provider.get_users_from_author(author).await
}
// MARK: Impl: minecraft
async fn fetch_player_profile(
self,
uuid: String,
) -> LauncherResult<crate::utils::minecraft::MojangPlayerProfile> {
crate::utils::minecraft::fetch_player_profile(&uuid).await
}
}

View file

@ -0,0 +1,100 @@
use base64::Engine;
use reqwest::Method;
use crate::error::LauncherResult;
use crate::utils::http;
#[onelauncher_macro::specta]
#[derive(Debug, serde::Serialize, Clone)]
pub struct MojangPlayerProfile {
pub uuid: String,
pub username: String,
pub is_slim: bool,
pub skin_url: Option<String>,
pub cape_url: Option<String>,
}
pub async fn fetch_player_profile(uuid: &str) -> LauncherResult<MojangPlayerProfile> {
#[derive(serde::Deserialize)]
struct Properties {
name: String,
value: String,
}
#[derive(serde::Deserialize)]
struct Response {
id: String,
name: String,
properties: Vec<Properties>,
}
let response = http::fetch_json::<Response>(
Method::GET,
&format!("https://sessionserver.mojang.com/session/minecraft/profile/{uuid}"),
None,
None,
)
.await?;
#[derive(Default, serde::Deserialize)]
#[serde(default)]
struct TextureMetadata {
model: Option<String>,
}
#[derive(serde::Deserialize)]
struct Texture {
url: String,
#[serde(default)]
metadata: TextureMetadata,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "UPPERCASE")]
struct Textures {
skin: Option<Texture>,
cape: Option<Texture>,
}
#[derive(serde::Deserialize)]
struct DecodedProperties {
textures: Textures,
}
let texture_property = response
.properties
.iter()
.find(|p| p.name == "textures")
.ok_or_else(|| anyhow::anyhow!("no textures found in player profile"))?;
let decoded = base64::prelude::BASE64_STANDARD
.decode(texture_property.value.as_bytes())
.map_err(|_| {
anyhow::anyhow!(
"failed to decode base64 texture property: {}",
texture_property.value
)
})?;
let decoded = serde_json::from_slice::<DecodedProperties>(&decoded)
.map_err(|_| {
anyhow::anyhow!(
"failed to parse JSON from decoded texture property: {}",
texture_property.value
)
})?
.textures;
let is_slim = decoded
.skin
.as_ref()
.map_or(false, |s| s.metadata.model.as_deref() == Some("slim"));
Ok(MojangPlayerProfile {
uuid: response.id,
username: response.name,
is_slim,
skin_url: decoded.skin.as_ref().map(|s| s.url.clone()),
cape_url: decoded.cape.as_ref().map(|c| c.url.clone()),
})
}

View file

@ -2,6 +2,7 @@ pub mod crypto;
pub mod http;
pub mod icon;
pub mod io;
pub mod minecraft;
pub mod os_ext;
pub mod pagination;

View file

@ -12,7 +12,8 @@
"./components": {
"types": "./dist/components.d.ts",
"import": "./dist/components.js"
}
},
"./assets/*": "./dist/assets/*"
},
"main": "./dist/index.js",
"typings": "./dist/src/index.d.ts",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -11,20 +11,20 @@ export const buttonVariants = tv({
variants: {
color: {
primary: [
'text-fg-primary hover:text-fg-primary-hover pressed:text-fg-primary-pressed disabled:text-fg-primary-disabled',
'bg-brand hover:bg-brand-hover pressed:bg-brand-pressed disabled:bg-brand-disabled',
'text-fg-primary hover:text-fg-primary-hover pressed:text-fg-primary-pressed disabled:text-fg-primary-disabled pending:text-fg-primary-disabled',
'bg-brand hover:bg-brand-hover pressed:bg-brand-pressed disabled:bg-brand-disabled pending:bg-brand-disabled',
],
secondary: [
'border box-border border-component-border hover:border-component-border-hover pressed:border-component-border-pressed',
'text-fg-primary hover:text-fg-primary-hover pressed:text-fg-primary-pressed disabled:text-fg-primary-disabled',
'bg-component-bg hover:bg-component-bg-hover active:bg-component-bg-pressed disabled:bg-component-bg-disabled',
'text-fg-primary hover:text-fg-primary-hover pressed:text-fg-primary-pressed disabled:text-fg-primary-disabled pending:text-fg-primary-disabled',
'bg-component-bg hover:bg-component-bg-hover active:bg-component-bg-pressed disabled:bg-component-bg-disabled pending:bg-component-bg-disabled',
],
danger: [
'text-fg-primary hover:text-fg-primary-hover pressed:text-fg-primary-pressed disabled:text-fg-primary-disabled',
'bg-danger hover:bg-danger-hover pressed:bg-danger-pressed disabled:bg-danger-disabled',
'text-fg-primary hover:text-fg-primary-hover pressed:text-fg-primary-pressed disabled:text-fg-primary-disabled pending:text-fg-primary-disabled',
'bg-danger hover:bg-danger-hover pressed:bg-danger-pressed disabled:bg-danger-disabled pending:bg-danger-disabled',
],
ghost: [
'hover:bg-ghost-overlay-hover pressed:bg-ghost-overlay-pressed checked:selected:bg-ghost-overlay-pressed',
'hover:bg-ghost-overlay-hover pressed:bg-ghost-overlay-pressed checked:selected:bg-ghost-overlay-pressed pending:bg-ghost-overlay-pressed',
],
},
size: {

View file

@ -15,8 +15,4 @@ export function Popup({ children, ...props }: MyPopoverProps) {
);
}
function Trigger({ children, ...props }: any) {
return <DialogTrigger children={children} {...props} />;
}
Popup.Trigger = Trigger;
Popup.Trigger = DialogTrigger;

View file

@ -1,7 +1,7 @@
/* eslint-disable query/exhaustive-deps -- False */
import type { UndefinedInitialDataOptions, UseMutationOptions, UseMutationResult, UseQueryResult, UseSuspenseQueryOptions, UseSuspenseQueryResult } from '@tanstack/react-query';
import type { DefinedInitialDataOptions, DefinedUseQueryResult, FetchQueryOptions, UndefinedInitialDataOptions, UseMutationOptions, UseMutationResult, UseQueryResult, UseSuspenseQueryOptions, UseSuspenseQueryResult } from '@tanstack/react-query';
import type { AppError } from '../utils/error';
import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { useMutation, usePrefetchQuery, useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { isLauncherError } from '../utils/error';
// this gets overwritten by the consumer project
@ -11,14 +11,14 @@ export interface Register {
}
type CommandKeys = Register extends {
commands: infer Keys;
} ? Keys : never;
commands: infer Keys extends ReadonlyArray<string>;
} ? Keys[number] : string;
type DefaultError = Register extends {
defaultError: infer Error;
} ? Error : never;
type QueryKey = [CommandKeys[number], ...ReadonlyArray<unknown>];
type QueryKey = [CommandKeys, ...ReadonlyArray<unknown>];
declare module '@tanstack/react-query' {
interface Register {
@ -42,14 +42,27 @@ async function fetchCommand<T>(command: () => Promise<T>): Promise<T> {
}
}
type OmittedOptions<T> = Omit<T, 'queryKey' | 'queryFn'>;
export function useCommand<TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey>(
cacheKey: TQueryKey,
command: () => Promise<TQueryFnData>,
options?: Omit<
UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>,
'queryKey' | 'queryFn'
>,
): UseQueryResult<TData, TError> {
options: OmittedOptions<DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>>,
): DefinedUseQueryResult<TData, TError>;
export function useCommand<TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey>(
cacheKey: TQueryKey,
command: () => Promise<TQueryFnData>,
options?: OmittedOptions<UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>>,
): UseQueryResult<TData, TError>;
export function useCommand<TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey>(
cacheKey: TQueryKey,
command: () => Promise<TQueryFnData>,
options?:
| OmittedOptions<DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>>
| OmittedOptions<UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>>,
) {
return useQuery<TQueryFnData, TError, TData, TQueryKey>({
queryKey: cacheKey,
queryFn: () => fetchCommand(command),
@ -58,13 +71,22 @@ export function useCommand<TQueryFnData = unknown, TError = DefaultError, TData
});
}
export function usePrefetchedCommand<TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey>(
cacheKey: TQueryKey,
command: () => Promise<TQueryFnData>,
options?: OmittedOptions<FetchQueryOptions<TQueryFnData, TError, TData, TQueryKey>>,
) {
return usePrefetchQuery<TQueryFnData, TError, TData, TQueryKey>({
queryKey: cacheKey,
queryFn: () => fetchCommand(command),
...options,
});
}
export function useCommandSuspense<TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey>(
cacheKey: TQueryKey,
command: () => Promise<TQueryFnData>,
options?: Omit<
UseSuspenseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
'queryKey' | 'queryFn'
>,
options?: OmittedOptions<UseSuspenseQueryOptions<TQueryFnData, TError, TData, TQueryKey>>,
): UseSuspenseQueryResult<TData, TError> {
return useSuspenseQuery<TQueryFnData, TError, TData, TQueryKey>({
queryKey: cacheKey,

View file

@ -1,3 +1,4 @@
export * from './dev';
export * from './error';
export * from './minecraft';
export * from './nativeExperience';

View file

@ -0,0 +1,29 @@
export async function renderHeadToDataUrl(image: HTMLImageElement | string): Promise<string> {
return new Promise((resolve) => {
let img: CanvasImageSource;
if (typeof image === 'string') {
img = new Image();
img.crossOrigin = 'anonymous';
img.src = image;
}
else {
img = image;
}
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
canvas.width = 8;
canvas.height = 8;
// Base head (8x8 from (8,8))
ctx.drawImage(img, 8, 8, 8, 8, 0, 0, 8, 8);
// Overlay (8x8 from (40,8))
ctx.drawImage(img, 40, 8, 8, 8, 0, 0, 8, 8);
resolve(canvas.toDataURL('image/png'));
};
});
}

1
packages/web_common/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -11,6 +11,10 @@ export default defineConfig({
tailwindcss(),
viteStaticCopy({
targets: [
{
src: normalizePath(path.resolve(__dirname, 'src/assets/*')),
dest: './assets',
},
{
src: normalizePath(path.resolve(__dirname, 'src/theme.css')),
dest: './',
@ -22,7 +26,7 @@ export default defineConfig({
}),
],
build: {
minify: false,
minify: true,
lib: {
entry: {
index: path.resolve(__dirname, 'src/index.ts'),

72
pnpm-lock.yaml generated
View file

@ -108,6 +108,9 @@ catalogs:
rimraf:
specifier: ^6.0.1
version: 6.0.1
skinview3d:
specifier: ^3.4.1
version: 3.4.1
tailwind-merge:
specifier: ^3.2.0
version: 3.2.0
@ -292,6 +295,9 @@ importers:
react-just-parallax:
specifier: 'catalog:'
version: 3.1.16(react@19.1.0)
skinview3d:
specifier: 'catalog:'
version: 3.4.1
tailwind-merge:
specifier: 'catalog:'
version: 3.2.0
@ -2989,12 +2995,21 @@ packages:
'@types/semver@7.5.8':
resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==}
'@types/stats.js@0.17.4':
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
'@types/three@0.156.0':
resolution: {integrity: sha512-733bXDSRdlrxqOmQuOmfC1UBRuJ2pREPk8sWnx9MtIJEVDQMx8U0NQO5MVVaOrjzDPyLI+cFPim2X/ss9v0+LQ==}
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/webxr@0.5.22':
resolution: {integrity: sha512-Vr6Stjv5jPRqH690f5I5GLjVk8GSsoQSYJ2FVd/3jJF7KaqfwPi3ehfBS96mlQ2kPCwZaX6U0rG2+NGHBKkA/A==}
'@typescript-eslint/eslint-plugin@8.31.1':
resolution: {integrity: sha512-oUlH4h1ABavI4F0Xnl8/fOtML/eu8nI2A1nYd+f+55XI0BLu+RIqKoCiZKNo6DtqZBEQm5aNKA20G3Z5w3R6GQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -4068,6 +4083,9 @@ packages:
picomatch:
optional: true
fflate@0.6.10:
resolution: {integrity: sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==}
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
@ -4708,6 +4726,9 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
meshoptimizer@0.18.1:
resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==}
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
@ -5374,6 +5395,12 @@ packages:
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
skinview-utils@0.7.1:
resolution: {integrity: sha512-4eLrMqR526ehlZbsd8SuZ/CHpS9GiH0xUMoV+PYlJVi95ZFz5HJu7Spt5XYa72DRS7wgt5qquvHZf0XZJgmu9Q==}
skinview3d@3.4.1:
resolution: {integrity: sha512-WVN1selfDSAoQB7msLs3ueJjW/pge3nsmbqxJeXPnN/qIJ1GJKpMZO8mavSvMojaMrmpSgOJWfYUkK9B34ts2g==}
slice-ansi@5.0.0:
resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==}
engines: {node: '>=12'}
@ -5559,6 +5586,9 @@ packages:
text-decoder@1.1.1:
resolution: {integrity: sha512-8zll7REEv4GDD3x4/0pW+ppIxSNs7H1J10IKFZsuOMscumCdM2a+toDGLPA3T+1+fLBql4zbt5z83GEQGGV5VA==}
three@0.156.1:
resolution: {integrity: sha512-kP7H0FK9d/k6t/XvQ9FO6i+QrePoDcNhwl0I02+wmUJRNSLCUIDMcfObnzQvxb37/0Uc9TDT0T1HgsRRrO6SYQ==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@ -6003,18 +6033,6 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
ws@8.18.0:
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
ws@8.18.3:
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
engines: {node: '>=10.0.0'}
@ -8922,10 +8940,21 @@ snapshots:
'@types/semver@7.5.8': {}
'@types/stats.js@0.17.4': {}
'@types/three@0.156.0':
dependencies:
'@types/stats.js': 0.17.4
'@types/webxr': 0.5.22
fflate: 0.6.10
meshoptimizer: 0.18.1
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
'@types/webxr@0.5.22': {}
'@typescript-eslint/eslint-plugin@8.31.1(@typescript-eslint/parser@8.31.1(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3)':
dependencies:
'@eslint-community/regexpp': 4.11.1
@ -10213,6 +10242,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.2
fflate@0.6.10: {}
fflate@0.8.2:
optional: true
@ -10629,7 +10660,7 @@ snapshots:
whatwg-encoding: 3.1.1
whatwg-mimetype: 4.0.0
whatwg-url: 14.0.0
ws: 8.18.0
ws: 8.18.3
xml-name-validator: 5.0.0
transitivePeerDependencies:
- bufferutil
@ -10992,6 +11023,8 @@ snapshots:
merge2@1.4.1: {}
meshoptimizer@0.18.1: {}
micromark-core-commonmark@2.0.3:
dependencies:
decode-named-character-reference: 1.1.0
@ -11889,6 +11922,14 @@ snapshots:
sisteransi@1.0.5: {}
skinview-utils@0.7.1: {}
skinview3d@3.4.1:
dependencies:
'@types/three': 0.156.0
skinview-utils: 0.7.1
three: 0.156.1
slice-ansi@5.0.0:
dependencies:
ansi-styles: 6.2.1
@ -12090,6 +12131,8 @@ snapshots:
dependencies:
b4a: 1.6.6
three@0.156.1: {}
tiny-invariant@1.3.3: {}
tiny-warning@1.0.3: {}
@ -12560,9 +12603,6 @@ snapshots:
wrappy@1.0.2: {}
ws@8.18.0:
optional: true
ws@8.18.3: {}
xml-name-validator@5.0.0:

View file

@ -78,3 +78,4 @@ catalog:
'@formatjs/intl-durationformat': ^0.7.4
zustand: ^5.0.5
valtio: ^2.1.5
skinview3d: ^3.4.1