Merge branch 'oneclient/main' of https://github.com/Polyfrost/OneLauncher into oneclient/main

This commit is contained in:
Szedann 2025-08-20 13:05:05 +02:00
commit e6a865657c
49 changed files with 1399 additions and 70 deletions

6
Cargo.lock generated
View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 MiB

View file

@ -0,0 +1,9 @@
<svg width="565" height="116" viewBox="0 0 565 116" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M82.9609 92V26.9865H93.0904L97.4185 39.9707V92H82.9609ZM127.807 92L89.499 42.9175L93.0904 26.9865L131.399 76.0689L127.807 92ZM127.807 92L123.939 79.0157V26.9865H138.397V92H127.807ZM156.472 92V26.9865H170.93V92H156.472ZM167.523 92V79.4761H203.16V92H167.523ZM167.523 64.8343V52.7709H199.937V64.8343H167.523ZM167.523 39.4182V26.9865H202.7V39.4182H167.523Z" fill="#0A5BE8"/>
<path d="M248.333 93.013C243.483 93.013 238.97 92.1842 234.796 90.5266C230.683 88.8076 227.061 86.4134 223.93 83.3438C220.86 80.2742 218.466 76.7135 216.747 72.6617C215.028 68.5485 214.168 64.1283 214.168 59.4011C214.168 54.674 215.028 50.2845 216.747 46.2327C218.466 42.1194 220.86 38.5587 223.93 35.5506C226.999 32.5424 230.591 30.2095 234.704 28.5519C238.878 26.833 243.391 25.9735 248.241 25.9735C253.397 25.9735 257.971 26.8023 261.962 28.4598C266.013 30.1174 269.543 32.3889 272.552 35.2743L262.882 45.0355C261.163 43.1938 259.076 41.7511 256.621 40.7074C254.226 39.6638 251.433 39.142 248.241 39.142C245.417 39.142 242.807 39.6331 240.413 40.6154C238.08 41.5362 236.054 42.8868 234.335 44.6672C232.678 46.4475 231.358 48.5962 230.376 51.1133C229.455 53.6303 228.994 56.393 228.994 59.4011C228.994 62.4707 229.455 65.264 230.376 67.7811C231.358 70.2981 232.678 72.4468 234.335 74.2272C236.054 76.0075 238.08 77.3888 240.413 78.3711C242.807 79.3534 245.417 79.8445 248.241 79.8445C251.556 79.8445 254.441 79.3227 256.897 78.279C259.352 77.2354 261.47 75.7927 263.251 73.9509L273.012 83.7122C269.881 86.5976 266.29 88.869 262.238 90.5266C258.247 92.1842 253.612 93.013 248.333 93.013ZM286.499 92V26.9865H300.956V92H286.499ZM297.641 92V79.292H329.779V92H297.641ZM343.643 92V26.9865H358.101V92H343.643ZM376.147 92V26.9865H390.604V92H376.147ZM387.197 92V79.4761H422.835V92H387.197ZM387.197 64.8343V52.7709H419.612V64.8343H387.197ZM387.197 39.4182V26.9865H422.375V39.4182H387.197ZM438.327 92V26.9865H448.457L452.785 39.9707V92H438.327ZM483.174 92L444.865 42.9175L448.457 26.9865L486.765 76.0689L483.174 92ZM483.174 92L479.306 79.0157V26.9865H493.764V92H483.174ZM527.77 92V27.9073H542.227V92H527.77ZM507.418 39.6024V26.9865H562.579V39.6024H507.418Z" fill="#0048C5"/>
<path d="M29.1968 26H24.5066C19.1242 26 14.1506 28.8493 11.4594 33.4745L0.956285 51.5255C0.596616 52.1437 0.285016 52.781 0.0214844 53.4323L14.9434 50.7047C20.9687 49.6033 25.6832 44.9253 26.7932 38.9467L29.1968 26Z" fill="#0048C5"/>
<path d="M0 64.5149C0.267976 65.1846 0.586648 65.8397 0.956016 66.4745L11.4591 84.5255C14.1503 89.1507 19.1239 92 24.5063 92H29.2056L26.7929 79.0045C25.6829 73.0259 20.9684 68.3479 14.9431 67.2465L0 64.5149Z" fill="#0048C5"/>
<path d="M41.0508 92H45.5124C50.8948 92 55.8683 89.1507 58.5595 84.5255L69.0627 66.4745C69.4234 65.8545 69.7358 65.2152 69.9998 64.5618L55.3133 67.2465C49.288 68.3479 44.5735 73.0259 43.4635 79.0045L41.0508 92Z" fill="#0048C5"/>
<path d="M69.9782 53.3854C69.7187 52.7504 69.4135 52.1288 69.0625 51.5255L58.5593 33.4745C55.8681 28.8492 50.8946 26 45.5122 26H41.0596L43.4633 38.9467C44.5733 44.9253 49.2878 49.6033 55.3131 50.7047L69.9782 53.3854Z" fill="#0048C5"/>
<path d="M34.9819 27.295C35.0016 27.1215 35.2558 27.1215 35.2755 27.295L36.4373 37.5459C37.6214 47.9936 45.9187 56.2463 56.445 57.4461L68.5861 58.83C68.7606 58.8499 68.7606 59.1013 68.5861 59.1212L56.445 60.5051C45.9187 61.7049 37.6214 69.9576 36.4373 80.4053L35.2755 90.6561C35.2558 90.8297 35.0016 90.8297 34.9819 90.6561L33.822 80.4214C32.637 69.9663 24.3292 61.7104 13.7943 60.5189L1.43566 59.1212C1.26103 59.1014 1.26103 58.8497 1.43566 58.83L13.7943 57.4322C24.3292 56.2407 32.637 47.9848 33.822 37.5298L34.9819 27.295Z" fill="#0A5BE8"/>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

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

@ -4,10 +4,12 @@ import { twMerge } from 'tailwind-merge';
import CavesAndCliffs from '@/assets/backgrounds/CavesAndCliffs.jpg'; import CavesAndCliffs from '@/assets/backgrounds/CavesAndCliffs.jpg';
import HypixelSkyblockHub from '@/assets/backgrounds/HypixelSkyblockHub.png'; import HypixelSkyblockHub from '@/assets/backgrounds/HypixelSkyblockHub.png';
import MinecraftBuilding from '@/assets/backgrounds/MinecraftBuilding.png';
const GameBackgrounds = { const GameBackgrounds = {
HypixelSkyblockHub, HypixelSkyblockHub,
CavesAndCliffs, CavesAndCliffs,
MinecraftBuilding,
}; };
export type GameBackgroundName = keyof typeof GameBackgrounds; export type GameBackgroundName = keyof typeof GameBackgrounds;

View file

@ -1,19 +1,25 @@
import type { ButtonProps } from '@onelauncher/common/components'; import type { ButtonProps } from '@onelauncher/common/components';
import type { LinkProps, RegisteredRouter } from '@tanstack/react-router'; import type { LinkProps, RegisteredRouter } from '@tanstack/react-router';
import LauncherLogo from '@/assets/logos/oneclient.svg?react'; 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 { Link } from '@tanstack/react-router';
import { Window } from '@tauri-apps/api/window'; import { Window } from '@tauri-apps/api/window';
import { MinusIcon, SquareIcon, XCloseIcon } from '@untitled-theme/icons-react'; import { MinusIcon, SquareIcon, XCloseIcon } from '@untitled-theme/icons-react';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { AccountAvatar } from './AccountAvatar';
import { AccountPopup } from './overlay/AccountPopup';
export function Navbar() { export function Navbar() {
const { data: currentAccount } = useCommand(['getDefaultUser'], () => bindings.core.getDefaultUser(true));
const onMinimize = () => Window.getCurrent().minimize(); const onMinimize = () => Window.getCurrent().minimize();
const onMaximize = () => Window.getCurrent().toggleMaximize(); const onMaximize = () => Window.getCurrent().toggleMaximize();
const onClose = () => Window.getCurrent().close(); const onClose = () => Window.getCurrent().close();
return ( 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"> <div className="flex flex-1 pointer-events-none">
<LauncherLogo height={47} width={230} /> <LauncherLogo height={47} width={230} />
</div> </div>
@ -21,18 +27,28 @@ export function Navbar() {
<div className="flex flex-1 items-center justify-center pointer-events-none gap-6"> <div className="flex flex-1 items-center justify-center pointer-events-none gap-6">
<NavbarLink to=".">Home</NavbarLink> <NavbarLink to=".">Home</NavbarLink>
<NavbarLink to="./clusters">Versions</NavbarLink> <NavbarLink to="./clusters">Versions</NavbarLink>
<NavbarLink to=".">Accounts</NavbarLink> <NavbarLink to="./accounts">Accounts</NavbarLink>
</div> </div>
<div className="flex flex-1 items-center justify-end gap-2 pointer-events-none"> <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 <NavbarButton
children={<MinusIcon />} children={<MinusIcon />}
onClick={onMinimize} onClick={onMinimize}
/> />
<NavbarButton <NavbarButton
children={<SquareIcon />} children={<SquareIcon />}
onClick={onMaximize} onClick={onMaximize}
/> />
<NavbarButton <NavbarButton
children={( children={(
<XCloseIcon <XCloseIcon
@ -77,7 +93,7 @@ function NavbarLink({
); );
} }
function NavbarButton({ export function NavbarButton({
children, children,
color = 'ghost', color = 'ghost',
size = 'iconLarge', size = 'iconLarge',
@ -87,7 +103,7 @@ function NavbarButton({
}: ButtonProps) { }: ButtonProps) {
return ( return (
<Button <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} color={color}
onClick={onClick} onClick={onClick}
size={size} size={size}

View file

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

@ -0,0 +1,73 @@
import type { OnboardingStep } from '@/routes/onboarding/route';
import { useMatchRoute } from '@tanstack/react-router';
interface VerticalStepperProps {
steps: Array<OnboardingStep>;
linearSteps: Array<Omit<OnboardingStep, 'subSteps'>>;
};
export function Stepper({ steps, linearSteps }: VerticalStepperProps) {
const matchRoute = useMatchRoute();
const currentLinearIndex = linearSteps.findIndex(step =>
matchRoute({ to: step.path }));
const progressHeight = currentLinearIndex >= 0 ? `${(currentLinearIndex / (linearSteps.length - 1)) * 100}%` : '0%';
// const nextStep = () => {
// if (currentStep < steps.length - 1)
// setCurrentStep(currentStep + 1);
// };
// const prevStep = () => {
// if (currentStep > 0)
// setCurrentStep(currentStep - 1);
// };
// const goToStep = (stepIndex: number) => {
// setCurrentStep(stepIndex);
// };
return (
<div>
<div className="max-w-md mx-auto">
<div className="relative">
<div className="absolute left-4 top-0 w-0.5 h-full bg-gray-600"></div>
<div
className="absolute left-4 top-0 w-0.5 bg-brand transition-all duration-500 ease-in-out"
style={{
height: `${progressHeight}`,
}}
>
</div>
<ul className="relative space-y-4">
{steps.map((step, index) => (
<li
className="relative flex items-center cursor-pointer group"
key={index}
// onClick={() => goToStep(index)}
>
<div className="ml-8">
<span className={`
transition-all duration-300 font-medium
${index === currentLinearIndex
? 'text-white text-lg'
: index < currentLinearIndex
? ' text-base'
: 'text-gray-400 text-base'
}
`}
>
{step.title}
</span>
</div>
</li>
))}
</ul>
</div>
</div>
</div>
);
}

View file

@ -1,7 +1,9 @@
export * from './AccountAvatar';
export * from './GameBackground'; export * from './GameBackground';
export * from './Loader'; export * from './Loader';
export * from './LogViewer'; export * from './LogViewer';
export * from './Navbar'; export * from './Navbar';
export * from './SheetPage'; export * from './SheetPage';
export * from './SkinViewer';
export * from './Spinner'; export * from './Spinner';
export * from './Tabs'; 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: { queries: {
retry: false, retry: false,
enabled: 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> <Tab from={Route.id} search={search} to="/app/cluster/logs">Logs</Tab>
</TabList> </TabList>
<div className="relative"> <div className="relative pb-8">
<LoaderSuspense spinner={{ size: 'large' }}> <LoaderSuspense spinner={{ size: 'large' }}>
<Outlet /> <Outlet />
</LoaderSuspense> </LoaderSuspense>

View file

@ -122,7 +122,7 @@ function RouteComponent() {
</div> </div>
</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} /> <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"> <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 ( 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"> <div className="flex flex-col">
<h1 className="text-3xl font-semibold">Clusters</h1> <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> <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

@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/onboarding/account')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/onboarding/account"!</div>
}

View file

@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/onboarding/finished')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/onboarding/finished"!</div>
}

View file

@ -0,0 +1,15 @@
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/onboarding/')({
component: RouteComponent,
});
function RouteComponent() {
return (
<>
<div className="absolute top-56 left-32">
<h2 className="text-6xl font-bold">OneClient</h2>
</div>
</>
);
}

View file

@ -0,0 +1,13 @@
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/onboarding/language')({
component: RouteComponent,
});
function RouteComponent() {
return (
<div>
Hello "/onboarding/language"!
</div>
);
}

View file

@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/onboarding/preferences/')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/onboarding/preferences/"!</div>
}

View file

@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/onboarding/preferences/mods')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/onboarding/preferences/mods"!</div>
}

View file

@ -0,0 +1,88 @@
import CavesAndCliffs from '@/assets/backgrounds/CavesAndCliffs.jpg';
import { Button } from '@onelauncher/common/components';
import { createFileRoute } from '@tanstack/react-router';
import { DotsVerticalIcon, PlusIcon } from '@untitled-theme/icons-react';
export const Route = createFileRoute('/onboarding/preferences/versions')({
component: RouteComponent,
});
// placeholder
const versions = [
{
version: '1.8.9',
tags: ['PvP', 'Skyblock', 'Bedwars', 'UHC'],
},
{
version: '1.21.4',
tags: ['Nostalgia', 'Survival', 'UHC'],
},
{
version: '1.21.4',
tags: ['PvP', 'Minigames', 'Survival', 'UHC'],
},
];
function RouteComponent() {
return (
<div className="min-h-screen p-8">
<div className="max-w-6xl mx-auto">
<h1 className="text-4xl font-semibold mb-2">Starting Versions</h1>
<p className="text-slate-400 text-lg mb-2">
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 className="bg-page-elevated h-full p-4 rounded-xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{versions.map(version => (
<VersionCard
key={version.version}
tags={version.tags}
version={version.version}
/>
))}
<div className="border border-component-bg-hover rounded-xl">
<div className="flex items-center justify-center h-46">
<PlusIcon className="text-component-bg-hover size-12" />
</div>
</div>
</div>
</div>
</div>
);
}
interface VersionCardProps {
version: string;
tags: Array<string>;
}
export function VersionCard({ version, tags }: VersionCardProps) {
return (
<div className="overflow-hidden transition-colors cursor-pointer">
<div className="relative">
<img
alt={`Minecraft ${version} landscape`}
className="w-full rounded-xl h-48 object-cover"
src={CavesAndCliffs}
/>
<div className="absolute top-3 left-3 flex flex-wrap gap-1">
{tags.map(tag => (
<div className="bg-[#D0D7F3] rounded-xl text-brand text-sm px-2 py-1" key={tag}>
{tag}
</div>
))}
</div>
<Button className="absolute bottom-3 right-3 p-1 transition-colors" color="ghost" size="icon">
<DotsVerticalIcon className="w-4 h-4 text-white" />
</Button>
<div className="absolute bottom-3 left-3">
<span className="text-white font-bold px-3 py-1">{version}</span>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,230 @@
import type { PropsWithChildren } from 'react';
import Logo from '@/assets/logos/oneclient2.svg';
import { LoaderSuspense, NavbarButton } from '@/components';
import { GameBackground } from '@/components/GameBackground';
import { Stepper } from '@/components/Stepper';
import useAppShellStore from '@/stores/appShellStore';
import { Button } from '@onelauncher/common/components';
import { createFileRoute, Link, Outlet } from '@tanstack/react-router';
import { Window } from '@tauri-apps/api/window';
import { MinusIcon, SquareIcon, XCloseIcon } from '@untitled-theme/icons-react';
import { MouseParallax } from 'react-just-parallax';
export const Route = createFileRoute('/onboarding')({
component: RouteComponent,
loader: ({ location }) => {
const currentStepIndex = LINEAR_ONBOARDING_STEPS.findIndex(
step => step.path === location.pathname,
);
const isFirstStep = currentStepIndex === 0;
const isLastStep = currentStepIndex === LINEAR_ONBOARDING_STEPS.length - 1;
return {
isFirstStep,
isLastStep,
previousPath:
currentStepIndex > 0
? LINEAR_ONBOARDING_STEPS[currentStepIndex - 1]?.path
: undefined,
nextPath:
currentStepIndex < LINEAR_ONBOARDING_STEPS.length - 1
? LINEAR_ONBOARDING_STEPS[currentStepIndex + 1]?.path
: undefined,
currentStepIndex,
};
},
});
export interface OnboardingStep {
path: string;
title: string;
subSteps?: Array<OnboardingStep>;
};
const ONBOARDING_STEPS: Array<OnboardingStep> = [
{
path: '/onboarding',
title: 'Welcome',
},
{
path: '/onboarding/language',
title: 'Set Language',
},
{
path: '/onboarding/account',
title: 'Account',
},
{
path: '/onboarding/preferences/',
title: 'Preferences',
subSteps: [
{
path: '/onboarding/preferences/versions',
title: 'Versions',
},
{
path: '/onboarding/preferences/mods',
title: 'Mods',
},
],
},
{
path: '/onboarding/finished',
title: 'Finished',
},
];
function getLinearSteps(steps: Array<OnboardingStep>): Array<Omit<OnboardingStep, 'subSteps'>> {
return steps.flatMap(step => (step.subSteps ? step.subSteps : step));
}
const LINEAR_ONBOARDING_STEPS = getLinearSteps(ONBOARDING_STEPS);
function RouteComponent() {
return (
<LoaderSuspense spinner={{ size: 'large' }}>
<AppShell>
<div className="h-full w-full">
<Navbar />
<Outlet />
<OnboardingNavigation />
</div>
</AppShell>
</LoaderSuspense>
);
}
function AppShell({
children,
}: PropsWithChildren) {
const { isFirstStep } = Route.useLoaderData();
return (
<div className="flex flex-col h-full w-full">
<BackgroundGradient />
<div className="h-screen flex overflow-hidden">
<div className={`min-w-64 ${isFirstStep ? 'bg-page' : ''} border-r border-component-border flex flex-col`}>
<div className="p-6">
<div className="flex items-center gap-2">
<img alt="logo" className="w-52 h-12" src={Logo} />
</div>
</div>
<nav className="flex-1 p-4">
<Stepper linearSteps={LINEAR_ONBOARDING_STEPS} steps={ONBOARDING_STEPS} />
</nav>
<div className="p-4 text-xs text-fg-secondary">
<p>version info</p>
</div>
</div>
<div className={`flex-1 flex ${isFirstStep ? '' : 'bg-page'} flex-col relative`}>
{children}
</div>
</div>
</div>
);
}
function BackgroundGradient() {
const background = useAppShellStore(state => state.background);
if (background === 'none')
return undefined;
return (
<div className="relative">
{/* Linear black gradient: left -> right */}
{/* <div
className="absolute top-0 left-0 w-screen h-screen -z-10"
style={{
background: 'linear-gradient(270deg, rgba(0, 0, 0, 0.00) 35%, rgba(0, 0, 0, 0.60) 87.5%)',
}}
>
</div> */}
{/* Radial black gradient */}
<div
className="absolute top-0 left-0 w-screen h-screen -z-10" style={{
background: 'radial-gradient(48.29% 48.29% at 77.29% 50%, rgba(0, 0, 0, 0.00) 0%, rgba(0, 0, 0, 0.64) 100%)',
}}
>
</div>
{/* Linear black gradient: bottom -> 200 px up */}
<div
className="absolute bottom-0 left-0 w-screen h-50 -z-10" style={{
background: 'linear-gradient(180deg, rgba(17, 23, 28, 0.00) 0%, rgba(0, 0, 0, 0.68) 60%)',
}}
>
</div>
<MouseParallax isAbsolutelyPositioned strength={0.01} zIndex={-50}>
<GameBackground
className="absolute left-0 top-0 w-screen h-screen scale-110"
name="MinecraftBuilding"
/>
</MouseParallax>
</div>
);
}
export function OnboardingNavigation() {
const { isFirstStep, isLastStep, previousPath, nextPath } = Route.useLoaderData();
return (
<div className="absolute bottom-2 right-2 flex flex-row gap-2">
<div>
{!isFirstStep && previousPath && (
<Link to={previousPath}>
<Button className="w-32" color="ghost">Back</Button>
</Link>
)}
</div>
<div>
{nextPath && (
<Link to={nextPath}>
<Button className="w-32">{isLastStep ? 'Finish' : 'Next'}</Button>
</Link>
)}
</div>
</div>
);
}
export function Navbar() {
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">
<div className="flex flex-1 items-center justify-end gap-2 pointer-events-none">
<NavbarButton
children={<MinusIcon />}
onClick={onMinimize}
/>
<NavbarButton
children={<SquareIcon />}
onClick={onMaximize}
/>
<NavbarButton
children={(
<XCloseIcon
height={28}
strokeWidth={1.5}
width={28}
/>
)}
className="bg-transparent"
color="danger"
onClick={onClose}
/>
</div>
</nav>
);
}

View file

@ -9,6 +9,15 @@
opacity: 0; opacity: 0;
} }
} }
@keyframes custom-anim-zoom {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
} }
@utility animate-fade-scale { @utility animate-fade-scale {
@ -16,3 +25,8 @@
opacity: 1; 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; 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, "skipLibCheck": true,
"noUncheckedSideEffectImports": 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

@ -118,7 +118,7 @@ pub async fn launch_minecraft(
arguments::java_arguments( arguments::java_arguments(
args.get(&interpulse::api::minecraft::ArgumentType::Jvm) args.get(&interpulse::api::minecraft::ArgumentType::Jvm)
.map(Vec::as_slice), .map(Vec::as_slice),
&dirs.natives_dir().join(&version.id), &dirs.natives_dir().join(&version_name),
&dirs.libraries_dir(), &dirs.libraries_dir(),
&arguments::classpaths( &arguments::classpaths(
&dirs.libraries_dir(), &dirs.libraries_dir(),

View file

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

View file

@ -12,7 +12,8 @@
"./components": { "./components": {
"types": "./dist/components.d.ts", "types": "./dist/components.d.ts",
"import": "./dist/components.js" "import": "./dist/components.js"
} },
"./assets/*": "./dist/assets/*"
}, },
"main": "./dist/index.js", "main": "./dist/index.js",
"typings": "./dist/src/index.d.ts", "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: { variants: {
color: { color: {
primary: [ primary: [
'text-fg-primary hover:text-fg-primary-hover pressed:text-fg-primary-pressed disabled:text-fg-primary-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', 'bg-brand hover:bg-brand-hover pressed:bg-brand-pressed disabled:bg-brand-disabled pending:bg-brand-disabled',
], ],
secondary: [ secondary: [
'border box-border border-component-border hover:border-component-border-hover pressed:border-component-border-pressed', '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', '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', 'bg-component-bg hover:bg-component-bg-hover active:bg-component-bg-pressed disabled:bg-component-bg-disabled pending:bg-component-bg-disabled',
], ],
danger: [ danger: [
'text-fg-primary hover:text-fg-primary-hover pressed:text-fg-primary-pressed disabled:text-fg-primary-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', 'bg-danger hover:bg-danger-hover pressed:bg-danger-pressed disabled:bg-danger-disabled pending:bg-danger-disabled',
], ],
ghost: [ 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: { size: {

View file

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

View file

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

View file

@ -1,3 +1,4 @@
export * from './dev'; export * from './dev';
export * from './error'; export * from './error';
export * from './minecraft';
export * from './nativeExperience'; 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(), tailwindcss(),
viteStaticCopy({ viteStaticCopy({
targets: [ targets: [
{
src: normalizePath(path.resolve(__dirname, 'src/assets/*')),
dest: './assets',
},
{ {
src: normalizePath(path.resolve(__dirname, 'src/theme.css')), src: normalizePath(path.resolve(__dirname, 'src/theme.css')),
dest: './', dest: './',
@ -22,7 +26,7 @@ export default defineConfig({
}), }),
], ],
build: { build: {
minify: false, minify: true,
lib: { lib: {
entry: { entry: {
index: path.resolve(__dirname, 'src/index.ts'), index: path.resolve(__dirname, 'src/index.ts'),

72
pnpm-lock.yaml generated
View file

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

View file

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