diff --git a/Cargo.lock b/Cargo.lock index b2ab04fd..af19ed27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3411,6 +3411,7 @@ dependencies = [ "tauri-plugin-clipboard-manager", "tauri-plugin-deep-link", "tauri-plugin-dialog", + "tauri-plugin-fs", "tauri-plugin-single-instance", "tauri-plugin-updater", "tauri-plugin-window-state", @@ -6061,9 +6062,9 @@ dependencies = [ [[package]] name = "tauri-plugin-fs" -version = "2.3.0" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ead0daec5d305adcefe05af9d970fc437bcc7996052d564e7393eb291252da" +checksum = "88371e340ad2f07409a3b68294abe73f20bc9c1bc1b631a31dc37a3d0161f682" dependencies = [ "anyhow", "dunce", @@ -6079,6 +6080,7 @@ dependencies = [ "thiserror 2.0.12", "toml", "url", + "uuid 1.16.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 81dc2a18..fc08dc38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ tauri-build = { version = "=2.2.0" } tauri-plugin-clipboard-manager = { version = "=2.2.2" } tauri-plugin-deep-link = { version = "=2.2.1" } tauri-plugin-dialog = { version = "=2.2.1" } +tauri-plugin-fs = { version = "=2.2.1" } tauri-plugin-single-instance = { version = "=2.2.3" } tauri-plugin-updater = { version = "=2.7.1" } tauri-plugin-window-state = { version = "=2.2.2" } diff --git a/apps/oneclient/desktop/Cargo.toml b/apps/oneclient/desktop/Cargo.toml index 8e2ce0c1..572627e8 100644 --- a/apps/oneclient/desktop/Cargo.toml +++ b/apps/oneclient/desktop/Cargo.toml @@ -36,6 +36,7 @@ tauri-plugin-single-instance = { workspace = true } tauri-plugin-updater = { workspace = true } tauri-plugin-clipboard-manager = { workspace = true } tauri-plugin-dialog = { workspace = true } +tauri-plugin-fs = { workspace = true } tauri-plugin-deep-link = { workspace = true } # code gen diff --git a/apps/oneclient/desktop/capabilities/default.json b/apps/oneclient/desktop/capabilities/default.json index 40d2165e..341ba87a 100644 --- a/apps/oneclient/desktop/capabilities/default.json +++ b/apps/oneclient/desktop/capabilities/default.json @@ -31,6 +31,11 @@ "dialog:allow-save", "dialog:allow-confirm", "deep-link:default", - "updater:default" + "updater:default", + + "fs:allow-download-read", + "fs:allow-download-write", + "fs:allow-data-read-recursive", + "fs:allow-data-write-recursive" ] } diff --git a/apps/oneclient/desktop/src/lib.rs b/apps/oneclient/desktop/src/lib.rs index 2bc41045..bbda4a49 100644 --- a/apps/oneclient/desktop/src/lib.rs +++ b/apps/oneclient/desktop/src/lib.rs @@ -71,6 +71,7 @@ async fn initialize_tauri(builder: tauri::Builder) -> LauncherResult // .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_deep_link::init()) .menu(tauri::menu::Menu::new) .invoke_handler(router.into_handler()) diff --git a/apps/oneclient/frontend/package.json b/apps/oneclient/frontend/package.json index e4e50039..ed92b5b4 100644 --- a/apps/oneclient/frontend/package.json +++ b/apps/oneclient/frontend/package.json @@ -28,6 +28,7 @@ "@tauri-apps/api": "catalog:", "@tauri-apps/plugin-clipboard-manager": "catalog:", "@tauri-apps/plugin-dialog": "catalog:", + "@tauri-apps/plugin-fs": "catalog:", "@untitled-theme/icons-react": "catalog:", "motion": "catalog:", "overlayscrollbars": "catalog:", diff --git a/apps/oneclient/frontend/src/bindings.gen.ts b/apps/oneclient/frontend/src/bindings.gen.ts index 0c593aaf..1f362a5c 100644 --- a/apps/oneclient/frontend/src/bindings.gen.ts +++ b/apps/oneclient/frontend/src/bindings.gen.ts @@ -108,12 +108,14 @@ export type ModpackManifest = { name: string; version: string; loader: GameLoade export type MojangCape = { id: string; state: string; url: string; alias: string } -export type MojangFullPlayerProfile = { uuid: string; username: string; skins: MojangSkin[]; capes: MojangCape[] } +export type MojangFullPlayerProfile = { id: string; username: string; skins: MojangSkin[]; capes: MojangCape[] } export type MojangPlayerProfile = { uuid: string; username: string; is_slim: boolean; skin_url: string | null; cape_url: string | null } export type MojangSkin = { id: string; state: string; url: string; variant: SkinVariant } +export type MowojangProfile = { id: string; username: string } + 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[] } @@ -252,15 +254,15 @@ export type VersionType = */ "old_beta" -const ARGS_MAP = { 'core':'{"getScreenshots":["id"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"fetchLoggedInProfile":["access_token"],"getPackageBody":["provider","body"],"getLogs":["id"],"installModpack":["modpack","cluster_id"],"fetchMinecraftProfile":["uuid"],"updateClusterById":["id","request"],"getMultiplePackages":["provider","slugs"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"getUsersFromAuthor":["provider","author"],"updateClusterProfile":["name","profile"],"getLogByName":["id","name"],"removeUser":["uuid"],"getUser":["uuid"],"launchCluster":["id","uuid"],"readSettings":[],"getProfileOrDefault":["name"],"getGameVersions":[],"removeCluster":["id"],"getRunningProcessesByClusterId":["cluster_id"],"open":["input"],"createSettingsProfile":["name"],"getClusterById":["id"],"openMsaLogin":[],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getRunningProcesses":[],"getClusters":[],"getWorlds":["id"],"changeSkin":["access_token","skin_url","skin_variant"],"killProcess":["pid"],"getLoadersForVersion":["mc_version"],"createCluster":["options"],"getUsers":[],"getGlobalProfile":[],"searchPackages":["provider","query"],"writeSettings":["setting"],"isClusterRunning":["cluster_id"],"getPackage":["provider","slug"],"getDefaultUser":["fallback"],"setDefaultUser":["uuid"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'events':'{"message":["event"],"ingress":["event"],"process":["event"]}', 'oneclient':'{"openDevTools":[],"getClustersGroupedByMajor":[],"getBundlesFor":["cluster_id"]}' } -export type Router = { 'folders': { fromCluster: (folderName: string) => Promise, -openCluster: (folderName: string) => Promise }, -'oneclient': { openDevTools: () => Promise, +const ARGS_MAP = { 'events':'{"ingress":["event"],"message":["event"],"process":["event"]}', 'oneclient':'{"getClustersGroupedByMajor":[],"openDevTools":[],"getBundlesFor":["cluster_id"]}', 'core':'{"updateClusterProfile":["name","profile"],"updateClusterById":["id","request"],"getLogByName":["id","name"],"killProcess":["pid"],"getUsers":[],"getPackage":["provider","slug"],"removeCluster":["id"],"launchCluster":["id","uuid"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getProfileOrDefault":["name"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"changeCape":["access_token","cape_uuid"],"changeSkin":["access_token","skin_url","skin_variant"],"getPackageBody":["provider","body"],"installModpack":["modpack","cluster_id"],"getWorlds":["id"],"removeUser":["uuid"],"convertUsernameUUID":["username_uuid"],"writeSettings":["setting"],"getGameVersions":[],"getClusterById":["id"],"getLoadersForVersion":["mc_version"],"getScreenshots":["id"],"getMultiplePackages":["provider","slugs"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getUsersFromAuthor":["provider","author"],"openMsaLogin":[],"getDefaultUser":["fallback"],"getUser":["uuid"],"getRunningProcessesByClusterId":["cluster_id"],"getGlobalProfile":[],"createSettingsProfile":["name"],"setDefaultUser":["uuid"],"getClusters":[],"removeCape":["access_token"],"searchPackages":["provider","query"],"isClusterRunning":["cluster_id"],"getLogs":["id"],"fetchLoggedInProfile":["access_token"],"open":["input"],"fetchMinecraftProfile":["uuid"],"readSettings":[],"getRunningProcesses":[],"createCluster":["options"]}', 'folders':'{"openCluster":["folder_name"],"fromCluster":["folder_name"]}' } +export type Router = { 'oneclient': { openDevTools: () => Promise, getClustersGroupedByMajor: () => Promise>, getBundlesFor: (clusterId: number) => Promise }, 'events': { ingress: (event: IngressPayload) => Promise, message: (event: MessagePayload) => Promise, process: (event: ProcessPayload) => Promise }, +'folders': { fromCluster: (folderName: string) => Promise, +openCluster: (folderName: string) => Promise }, 'core': { getClusters: () => Promise, getClusterById: (id: number) => Promise, removeCluster: (id: number) => Promise, @@ -301,6 +303,9 @@ fetchMinecraftProfile: (uuid: string) => Promise, fetchLoggedInProfile: (accessToken: string) => Promise, uploadSkinBytes: (accessToken: string, skinData: number[], imageFormat: string, skinVariant: SkinVariant) => Promise, changeSkin: (accessToken: string, skinUrl: string, skinVariant: SkinVariant) => Promise, +changeCape: (accessToken: string, capeUuid: string) => Promise, +removeCape: (accessToken: string) => Promise, +convertUsernameUUID: (usernameUuid: string) => Promise, open: (input: string) => Promise } }; diff --git a/apps/oneclient/frontend/src/components/SkinViewer.tsx b/apps/oneclient/frontend/src/components/SkinViewer.tsx index cebc77f7..c86b3dad 100644 --- a/apps/oneclient/frontend/src/components/SkinViewer.tsx +++ b/apps/oneclient/frontend/src/components/SkinViewer.tsx @@ -9,14 +9,48 @@ export interface SkinViewerProps { width?: number; height?: number; className?: string | undefined; + autoRotate?: boolean; + autoRotateSpeed?: number; + showText?: boolean; + playerRotatePhi?: number; + playerRotateTheta?: number; + translateRotateX?: number; + translateRotateY?: number; + translateRotateZ?: number; + zoom?: number; + animate?: boolean; + animation?: skinviewer.PlayerAnimation; + enableDamping?: boolean; + enableZoom?: boolean; + enableRotate?: boolean; + enablePan?: boolean; + elytra?: boolean; } +const defaultIdleAnimation = new skinviewer.IdleAnimation(); + export function SkinViewer({ skinUrl, capeUrl, width = 260, height = 300, className, + autoRotate = true, + autoRotateSpeed = 0.25, + showText = true, + playerRotatePhi = Math.PI / 3, + playerRotateTheta = -Math.PI / 6, + translateRotateX = 0, + translateRotateY = 0, + translateRotateZ = 0, + zoom = 0.9, + animate = false, + animation = defaultIdleAnimation, + enableDamping = true, + enableZoom = true, + enableRotate = true, + enablePan = true, + elytra = false }: SkinViewerProps) { const canvasRef = useRef(null); const viewerRef = useRef(null); @@ -29,8 +63,32 @@ export function SkinViewer({ canvas: canvasRef.current, }); - viewer.autoRotate = true; - viewer.autoRotateSpeed = 0.25; + viewer.controls.enableDamping = enableDamping; + viewer.controls.enableZoom = enableZoom; + viewer.controls.enableRotate = enableRotate; + viewer.controls.enablePan = enablePan; + + viewer.zoom = zoom; + + const setAngle = (phi: number, theta: number) => { + const r = viewer.controls.object.position.distanceTo(viewer.controls.target); + const x = r * Math.cos(phi - Math.PI / 2) * Math.sin(theta) + viewer.controls.target.x; + const y = r * Math.sin(phi + Math.PI / 2) + viewer.controls.target.y; + const z = r * Math.cos(phi - Math.PI / 2) * Math.cos(theta) + viewer.controls.target.z; + viewer.controls.object.position.set(x, y, z); + viewer.controls.object.lookAt(viewer.controls.target); + }; + setAngle(playerRotatePhi, playerRotateTheta) + + viewer.playerWrapper.translateX(translateRotateX); + viewer.playerWrapper.translateY(translateRotateY); + viewer.playerWrapper.translateZ(translateRotateZ); + + + viewer.animation = animation; + + viewer.autoRotate = autoRotate; + viewer.autoRotateSpeed = autoRotateSpeed; viewerRef.current = viewer; @@ -46,19 +104,15 @@ export function SkinViewer({ viewerRef.current.loadSkin(getSkinUrl(skinUrl)); }, [skinUrl]); - useEffect(() => { - - }, [capeUrl]); - useEffect(() => { if (!viewerRef.current) return; if (capeUrl) - viewerRef.current.loadCape(capeUrl); + viewerRef.current.loadCape(capeUrl, { backEquipment: elytra ? 'elytra' : 'cape' }); else viewerRef.current.resetCape(); - }, [capeUrl]); + }, [capeUrl, elytra]); useEffect(() => { if (!viewerRef.current) @@ -67,6 +121,20 @@ export function SkinViewer({ viewerRef.current.setSize(width, height); }, [width, height]); + useEffect(() => { + if (!viewerRef.current) + return; + + viewerRef.current.animation = animation; + }, [animation]); + + useEffect(() => { + if (!viewerRef.current || !viewerRef.current.animation) + return; + + viewerRef.current.animation.paused = !animate; + }, [animate]); + return (
- Hold to drag. Scroll to zoom in/out. + {showText ? Hold to drag. Scroll to zoom in/out. : <>}
); } diff --git a/apps/oneclient/frontend/src/components/overlay/AccountPopup.tsx b/apps/oneclient/frontend/src/components/overlay/AccountPopup.tsx index c7a9cd9e..f02f2f0b 100644 --- a/apps/oneclient/frontend/src/components/overlay/AccountPopup.tsx +++ b/apps/oneclient/frontend/src/components/overlay/AccountPopup.tsx @@ -3,7 +3,7 @@ import { bindings } from '@/main'; import { useCommand, useCommandMut } from '@onelauncher/common'; import { Button } from '@onelauncher/common/components'; import { Link } from '@tanstack/react-router'; -import { PlusIcon, Settings01Icon, Trash01Icon } from '@untitled-theme/icons-react'; +import { Pencil01Icon, PlusIcon, Settings01Icon, Trash01Icon } from '@untitled-theme/icons-react'; import { DialogTrigger } from 'react-aria-components'; import { twMerge } from 'tailwind-merge'; import { AccountAvatar } from '../AccountAvatar'; @@ -28,8 +28,9 @@ export function AccountPopup() { defaultUser.refetch(); if (defaultUser.data && defaultUser.data.id === user.id && users.data && users.data.length > 1) { - const filtered = users.data.filter((userData) => userData.id !== user.id) - if (filtered.length > 0) setDefaultUser(filtered[0]); + const filtered = users.data.filter(userData => userData.id !== user.id); + if (filtered.length > 0) + setDefaultUser(filtered[0]); } }; @@ -99,8 +100,9 @@ function AccountEntry({ loggedIn?: boolean; }) { return ( -
@@ -114,16 +116,29 @@ function AccountEntry({
- - +
+ + + - - - - + + + + + + + +
- + ); } diff --git a/apps/oneclient/frontend/src/components/overlay/ImportSkinModal.tsx b/apps/oneclient/frontend/src/components/overlay/ImportSkinModal.tsx new file mode 100644 index 00000000..aa55a0dd --- /dev/null +++ b/apps/oneclient/frontend/src/components/overlay/ImportSkinModal.tsx @@ -0,0 +1,34 @@ +import { Button, TextField } from '@onelauncher/common/components'; +import { useState } from 'react'; +import { Overlay } from './Overlay'; + +export function ImportSkinModal({ importFromURL, importFromUsername }: { importFromURL: (url: string) => void; importFromUsername: (username: string) => void }) { + const [input, setInput] = useState(''); + return ( + + Import + setInput(e.target.value)} /> + +
+ + +
+
+ ); +} diff --git a/apps/oneclient/frontend/src/components/overlay/RemoveSkinCapeModal.tsx b/apps/oneclient/frontend/src/components/overlay/RemoveSkinCapeModal.tsx new file mode 100644 index 00000000..7221c5af --- /dev/null +++ b/apps/oneclient/frontend/src/components/overlay/RemoveSkinCapeModal.tsx @@ -0,0 +1,22 @@ +import { Button } from '@onelauncher/common/components'; +import { Overlay } from './Overlay'; + +export function RemoveSkinCapeModal({ onPress }: { onPress: () => void; }) { + return ( + + Are you sure? + +

This cannot be undone

+ + +
+ ); +} diff --git a/apps/oneclient/frontend/src/components/overlay/index.ts b/apps/oneclient/frontend/src/components/overlay/index.ts index 913737fa..c1f1c7e2 100644 --- a/apps/oneclient/frontend/src/components/overlay/index.ts +++ b/apps/oneclient/frontend/src/components/overlay/index.ts @@ -1,6 +1,8 @@ export * from './AccountPopup'; export * from './AddAccountModal'; +export * from './ImportSkinModal'; export * from './Overlay'; export * from './Popup'; export * from './RemoveAccountModal'; +export * from './RemoveSkinCapeModal'; export * from './Toasts'; diff --git a/apps/oneclient/frontend/src/routes/app/accountSkin.tsx b/apps/oneclient/frontend/src/routes/app/accountSkin.tsx new file mode 100644 index 00000000..1ac2594d --- /dev/null +++ b/apps/oneclient/frontend/src/routes/app/accountSkin.tsx @@ -0,0 +1,483 @@ +import type { MinecraftCredentials } from '@/bindings.gen'; +import type { PlayerAnimation } from 'skinview3d'; +import { SheetPage, SkinViewer } from '@/components'; +import { ImportSkinModal, RemoveSkinCapeModal } from '@/components/overlay'; +import { Overlay } from '@/components/overlay/Overlay'; +import { usePlayerProfile } from '@/hooks/usePlayerProfile'; +import { bindings } from '@/main'; +import { getSkinUrl } from '@/utils/minecraft'; +import { useCommandSuspense } from '@onelauncher/common'; +import { Button } from '@onelauncher/common/components'; +import { useQueryClient } from '@tanstack/react-query'; +import { createFileRoute } from '@tanstack/react-router'; +import { dataDir, downloadDir, join } from '@tauri-apps/api/path'; +import { save } from '@tauri-apps/plugin-dialog'; +import { exists, mkdir, readTextFile, writeFile, writeTextFile } from '@tauri-apps/plugin-fs'; +import { Download01Icon, PlusIcon, Trash01Icon } from '@untitled-theme/icons-react'; +import { useEffect, useState } from 'react'; +import { DialogTrigger } from 'react-aria-components'; +import { CrouchAnimation, FlyingAnimation, HitAnimation, IdleAnimation, WalkingAnimation } from 'skinview3d'; +import { toast } from '@/utils/toast'; + +interface Skin { + is_slim: boolean; + skin_url: string; +} + +interface Cape { + url: string; + id: string; +} + +export const Route = createFileRoute('/app/accountSkin')({ + component: RouteComponent, +}); + +const animations = [ + { name: 'Idle', animation: new IdleAnimation(), speed: 0.1 }, + { name: 'Walking', animation: new WalkingAnimation(), speed: 0.1 }, + { name: 'Flying', animation: new FlyingAnimation(), speed: 0.2 }, + { name: 'Crouch', animation: new CrouchAnimation(), speed: 0.025 }, + { name: 'Hit', animation: new HitAnimation(), speed: 0.1 }, +]; + +async function getSkinHistory(): Promise> { + const parentDir = await join(await dataDir(), 'OneClient', 'metadata', 'history'); + const skinsPath = await join(parentDir, 'skins.json'); + try { + const dirExists = await exists(parentDir); + if (!dirExists) + await mkdir(parentDir, { recursive: true }); + + const fileExists = await exists(skinsPath); + if (!fileExists) { + await writeTextFile(skinsPath, JSON.stringify([])); + return []; + } + + const contents = await readTextFile(skinsPath); + return JSON.parse(contents) as Array; + } + catch (error) { + console.error(error); + await writeTextFile(skinsPath, JSON.stringify([])); + return []; + } +} + +async function saveSkinHistory(skins: Array): Promise { + const parentDir = await join(await dataDir(), 'OneClient', 'metadata', 'history'); + const skinsPath = await join(parentDir, 'skins.json'); + try { + const dirExists = await exists(parentDir); + if (!dirExists) + await mkdir(parentDir, { recursive: true }); + + await writeTextFile(skinsPath, JSON.stringify(skins)); + } + catch (error) { + console.error(error); + } +} + +function RouteComponent() { + const queryClient = useQueryClient(); + const [skins, setSkinsSTATE] = useState>([]); + const setSkins = (updater: Array | ((prev: Array) => Array)) => { + const newSkins = typeof updater === 'function' ? updater(skins) : updater; + + const seen: Set = new Set(); + const filteredDupes = newSkins.filter((skin) => { + const key = skin.skin_url || ''; + if (seen.has(key)) + return false; + seen.add(key); + return true; + }); + + saveSkinHistory(filteredDupes); + setSkinsSTATE(filteredDupes); + }; + const [capes, setCapes] = useState>([]); + const { data: currentAccount } = useCommandSuspense(['getDefaultUser'], () => bindings.core.getDefaultUser(true)); + const { data: loggedInUser } = useCommandSuspense(['fetchLoggedInProfile'], () => bindings.core.fetchLoggedInProfile((currentAccount as MinecraftCredentials).access_token)); + useEffect(() => { + async function fetchSkins() { + const skins: Array = loggedInUser.skins.map(skin => ({ + is_slim: skin.variant === 'slim', + skin_url: skin.url, + })); + (await getSkinHistory()).forEach(skin => skins.push(skin)); + setSkins(skins); + } + + fetchSkins(); + + setCapes([{ url: "", id: "" }, ...loggedInUser.capes.map(cape => ({ url: cape.url, id: cape.id }))]); + }, [loggedInUser]); + const { data: profile } = usePlayerProfile(currentAccount?.id); + const [animation, setAnimation] = useState(animations[0].animation); + const [animationName, setAnimationName] = useState(animations[0].name); + const skinData: Skin = { + is_slim: profile?.is_slim ?? false, + skin_url: getSkinUrl(profile?.skin_url), + }; + const [selectedSkin, setSelectedSkin] = useState(skinData); + useEffect(() => { + if (!skinData.skin_url) + return; + setSkins((prev) => { + const filtered = prev.filter(skin => skin.skin_url !== skinData.skin_url); + return [skinData, ...filtered]; + }); + setSelectedSkin(skinData); + }, [skinData.skin_url, profile]); + + const [selectedCape, setSelectedCape] = useState(''); + + const importFromURL = (url: string) => { + setSkins([...skins, { is_slim: false, skin_url: url }]); + }; + + const importFromUsername = async (username: string) => { + toast({ + type: 'info', + title: 'Import Skin', + message: `Importing skin from ${username}`, + }); + const { id } = await bindings.core.convertUsernameUUID(username) ?? { id: '', username: '' } + if (id === '') + return toast({ + type: 'error', + title: 'Import Skin', + message: `${username} doesn't exist`, + }); + const playerProfile = await bindings.core.fetchMinecraftProfile(id) + if (playerProfile.skin_url) + setSkins([...skins, { is_slim: playerProfile.is_slim, skin_url: playerProfile.skin_url }]); + toast({ + type: 'success', + title: 'Import Skin', + message: `Imported skin from ${username}`, + }); + } + + const [shouldShowElytra, setShouldShowElytra] = useState(false); + + const saveSkinToAccount = async () => { + try { + if (!currentAccount) + return; + await bindings.core.changeSkin(currentAccount.access_token, selectedSkin.skin_url, selectedSkin.is_slim ? 'slim' : 'classic'); + if (selectedCape === '') { + await bindings.core.removeCape(currentAccount.access_token) + } else { + const capeData = capes.find((cape) => cape.url === selectedCape) + if (!capeData) return + await bindings.core.changeCape(currentAccount.access_token, capeData.id) + } + queryClient.invalidateQueries({ + queryKey: ['getDefaultUser'], + }); + queryClient.invalidateQueries({ + queryKey: ['fetchLoggedInProfile'], + }); + queryClient.invalidateQueries({ + queryKey: ['fetchMinecraftProfile'], + }); + } + catch (error) { + console.error(error); + } + }; + + if (currentAccount === null) + return ( + } headerSmall={<>}> + +

No accounts added

+
+
+ ); + + const getNextAnimationData = () => { + const animationIndex = animations.findIndex(animationData => animationData.name === animationName); + if (animationIndex === -1 || animationIndex === animations.length - 1) + return animations[0]; + else + return animations[animationIndex + 1]; + }; + + const changeSelectedAnimation = () => { + const data = getNextAnimationData(); + data.animation.speed = data.speed; + if (data.name === 'Walking') + (data.animation as WalkingAnimation).headBobbing = false; + setAnimation(data.animation); + setAnimationName(data.name); + }; + + if (!selectedSkin.skin_url) + return <>; + + return ( + + )} + headerSmall={} + > + +
+
+

Current Skin

+ + + +
+ +
+ +
+ + + +
+ + setShouldShowElytra(!shouldShowElytra)} + shouldShowElytra={shouldShowElytra} + skinURL={selectedSkin.skin_url} + /> + +
+ +
+
+
+ ); +} + +function SkinHistoryRow({ selected, animation, setSelectedSkin, skins, setSkins, importFromURL, importFromUsername, capeURL, shouldShowElytra }: { selected: Skin; animation: PlayerAnimation; setSelectedSkin: (skin: Skin) => void; skins: Array; setSkins: React.Dispatch>>; importFromURL: (url: string) => void; importFromUsername: (username: string) => void; capeURL: string; shouldShowElytra: boolean }) { + return ( +
+
+

Skin History

+
+ +
+ + + + + + + {skins.map(skinData => ( + + ))} +
+
+ ); +} + +function RenderSkin({ skin, selected, animation, setSelectedSkin, setSkins, capeURL, shouldShowElytra }: { skin: Skin; selected: Skin; animation: PlayerAnimation; setSelectedSkin: (skin: Skin) => void; setSkins: React.Dispatch>>; capeURL: string; shouldShowElytra: boolean }) { + const exportSkin = async () => { + try { + if (!skin.skin_url) + return; + const filePath = await save({ + title: 'Skin Export Location', + filters: [ + { + name: 'Images', + extensions: ['png'], + }, + ], + defaultPath: await join(await downloadDir(), `${skin.skin_url.split('/').reverse()[0]}.png`), + }); + + if (!filePath) + return; + + const response = await fetch(skin.skin_url); + const buffer = await response.arrayBuffer(); + + await writeFile(filePath, new Uint8Array(buffer)); + } + catch (error) { + console.error(error); + } + }; + if (!skin.skin_url) + return <>; + return ( + + + + setSkins(prev => prev.filter(skinData => skinData.skin_url !== skin.skin_url))} /> + +
+ )} + + + ); +} + +function CapeRow({ selected, animation, setSelectedCape, capes, shouldShowElytra, setShouldShowElytra, skinURL }: { selected: string | null; animation: PlayerAnimation; setSelectedCape: (cape: string) => void; capes: Array; shouldShowElytra: boolean; setShouldShowElytra: () => void; skinURL: string }) { + return ( +
+
+ {capes.map(cape => ( + + ))} + +
+ +
+

Cape History

+ +
+
+ ); +} + +function RenderCape({ selected, animation, setSelectedCape, cape, shouldShowElytra, skinURL }: { selected: string | null; animation: PlayerAnimation; setSelectedCape: (cape: string) => void; cape: string; shouldShowElytra: boolean; skinURL: string }) { + return ( + + ); +} + +function HeaderLarge({ username, save }: { username: string; save: () => void }) { + return ( +
+
+

{`${username}'s Skins`}

+ +
+ +
+
+
+ ); +} + +function HeaderSmall() { + return ( +
+

Accounts

+
+ ); +} + +function Viewer({ skinURL, capeURL, height = 400, width = 250, showText = true, animation, enableControls = false, flip = false, shouldShowElytra }: { skinURL: string; capeURL: string; height?: number; width?: number; showText?: boolean; animation?: PlayerAnimation; enableControls?: boolean; flip?: boolean; shouldShowElytra: boolean }) { + return ( + + ); +} diff --git a/apps/oneclient/frontend/src/routes/app/accounts.tsx b/apps/oneclient/frontend/src/routes/app/accounts.tsx index b61503ab..c850eafd 100644 --- a/apps/oneclient/frontend/src/routes/app/accounts.tsx +++ b/apps/oneclient/frontend/src/routes/app/accounts.tsx @@ -8,8 +8,8 @@ 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 { Trash01Icon } from '@untitled-theme/icons-react'; +import { createFileRoute, Link } from '@tanstack/react-router'; +import { Pencil01Icon, Trash01Icon } from '@untitled-theme/icons-react'; import { Button as AriaButton, DialogTrigger } from 'react-aria-components'; import { twMerge } from 'tailwind-merge'; @@ -153,6 +153,17 @@ function AccountRow({
+ + {/* TODO: Find a better way to handle handle a user that isn't just changing the default user */} + +