feat: accounts
This commit is contained in:
parent
fdf74258a0
commit
80ebf6e057
36 changed files with 926 additions and 78 deletions
6
Cargo.lock
generated
6
Cargo.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ tokio = { workspace = true }
|
|||
# utils
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
[dependencies.tauri]
|
||||
workspace = true
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
35
apps/oneclient/frontend/src/components/AccountAvatar.tsx
Normal file
35
apps/oneclient/frontend/src/components/AccountAvatar.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
81
apps/oneclient/frontend/src/components/SkinViewer.tsx
Normal file
81
apps/oneclient/frontend/src/components/SkinViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
78
apps/oneclient/frontend/src/components/overlay/Overlay.tsx
Normal file
78
apps/oneclient/frontend/src/components/overlay/Overlay.tsx
Normal 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>;
|
||||
};
|
||||
42
apps/oneclient/frontend/src/components/overlay/Popup.tsx
Normal file
42
apps/oneclient/frontend/src/components/overlay/Popup.tsx
Normal 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;
|
||||
4
apps/oneclient/frontend/src/components/overlay/index.ts
Normal file
4
apps/oneclient/frontend/src/components/overlay/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './AccountPopup';
|
||||
export * from './AddAccountModal';
|
||||
export * from './Overlay';
|
||||
export * from './Popup';
|
||||
38
apps/oneclient/frontend/src/hooks/usePlayerProfile.ts
Normal file
38
apps/oneclient/frontend/src/hooks/usePlayerProfile.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ const queryClient = new QueryClient({
|
|||
queries: {
|
||||
retry: false,
|
||||
enabled: false,
|
||||
staleTime: 1000 * 60 * 1, // 1 minute
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
161
apps/oneclient/frontend/src/routes/app/accounts.tsx
Normal file
161
apps/oneclient/frontend/src/routes/app/accounts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
5
apps/oneclient/frontend/src/utils/minecraft.ts
Normal file
5
apps/oneclient/frontend/src/utils/minecraft.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import SteveSkin from '@onelauncher/common/assets/skin/steve.png';
|
||||
|
||||
export function getSkinUrl(skinUrl: string | undefined | null) {
|
||||
return skinUrl || SteveSkin;
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
100
packages/core/src/utils/minecraft.rs
Normal file
100
packages/core/src/utils/minecraft.rs
Normal 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()),
|
||||
})
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
BIN
packages/web_common/src/assets/skin/steve.png
Normal file
BIN
packages/web_common/src/assets/skin/steve.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './dev';
|
||||
export * from './error';
|
||||
export * from './minecraft';
|
||||
export * from './nativeExperience';
|
||||
|
|
|
|||
29
packages/web_common/src/utils/minecraft.ts
Normal file
29
packages/web_common/src/utils/minecraft.ts
Normal 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
1
packages/web_common/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -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
72
pnpm-lock.yaml
generated
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -78,3 +78,4 @@ catalog:
|
|||
'@formatjs/intl-durationformat': ^0.7.4
|
||||
zustand: ^5.0.5
|
||||
valtio: ^2.1.5
|
||||
skinview3d: ^3.4.1
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue