diff --git a/apps/oneclient/frontend/src/bindings.gen.ts b/apps/oneclient/frontend/src/bindings.gen.ts index d105374f..da8e80f8 100644 --- a/apps/oneclient/frontend/src/bindings.gen.ts +++ b/apps/oneclient/frontend/src/bindings.gen.ts @@ -254,8 +254,10 @@ export type VersionType = */ "old_beta" -const ARGS_MAP = { 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'events':'{"process":["event"],"ingress":["event"],"message":["event"]}', 'oneclient':'{"openDevTools":[],"getClustersGroupedByMajor":[],"getBundlesFor":["cluster_id"]}', 'core':'{"updateClusterById":["id","request"],"getRunningProcesses":[],"readSettings":[],"changeSkin":["access_token","skin_url","skin_variant"],"fetchMinecraftProfile":["uuid"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"searchPackages":["provider","query"],"getClusters":[],"getPackageBody":["provider","body"],"killProcess":["pid"],"createSettingsProfile":["name"],"installModpack":["modpack","cluster_id"],"getMultiplePackages":["provider","slugs"],"getUsers":[],"open":["input"],"getUsersFromAuthor":["provider","author"],"getUser":["uuid"],"launchCluster":["id","uuid"],"removeUser":["uuid"],"getLogByName":["id","name"],"getPackage":["provider","slug"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"removeCape":["access_token"],"writeSettings":["setting"],"getClusterById":["id"],"getLoadersForVersion":["mc_version"],"openMsaLogin":[],"updateClusterProfile":["name","profile"],"isClusterRunning":["cluster_id"],"getScreenshots":["id"],"getGlobalProfile":[],"createCluster":["options"],"getLogs":["id"],"getRunningProcessesByClusterId":["cluster_id"],"getProfileOrDefault":["name"],"getGameVersions":[],"getWorlds":["id"],"getDefaultUser":["fallback"],"setDefaultUser":["uuid"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"fetchLoggedInProfile":["access_token"],"changeCape":["access_token","cape_uuid"],"removeCluster":["id"],"convertUsernameUUID":["username_uuid"]}' } -export type Router = { 'core': { getClusters: () => Promise, +const ARGS_MAP = { 'oneclient':'{"openDevTools":[],"getClustersGroupedByMajor":[],"getBundlesFor":["cluster_id"]}', 'events':'{"process":["event"],"ingress":["event"],"message":["event"]}', 'core':'{"updateClusterById":["id","request"],"getRunningProcesses":[],"readSettings":[],"changeSkin":["access_token","skin_url","skin_variant"],"fetchMinecraftProfile":["uuid"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"searchPackages":["provider","query"],"getClusters":[],"getPackageBody":["provider","body"],"killProcess":["pid"],"createSettingsProfile":["name"],"installModpack":["modpack","cluster_id"],"getMultiplePackages":["provider","slugs"],"getUsers":[],"open":["input"],"getUsersFromAuthor":["provider","author"],"getUser":["uuid"],"launchCluster":["id","uuid"],"removeUser":["uuid"],"getLogByName":["id","name"],"getPackage":["provider","slug"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"removeCape":["access_token"],"writeSettings":["setting"],"getClusterById":["id"],"getLoadersForVersion":["mc_version"],"openMsaLogin":[],"updateClusterProfile":["name","profile"],"isClusterRunning":["cluster_id"],"getScreenshots":["id"],"getGlobalProfile":[],"createCluster":["options"],"getLogs":["id"],"getRunningProcessesByClusterId":["cluster_id"],"getProfileOrDefault":["name"],"getGameVersions":[],"getWorlds":["id"],"getDefaultUser":["fallback"],"setDefaultUser":["uuid"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"fetchLoggedInProfile":["access_token"],"changeCape":["access_token","cape_uuid"],"removeCluster":["id"],"convertUsernameUUID":["username_uuid"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}' } +export type Router = { 'folders': { fromCluster: (folderName: string) => Promise, +openCluster: (folderName: string) => Promise }, +'core': { getClusters: () => Promise, getClusterById: (id: number) => Promise, removeCluster: (id: number) => Promise, createCluster: (options: CreateCluster) => Promise, @@ -304,9 +306,7 @@ getClustersGroupedByMajor: () => Promise Promise }, 'events': { ingress: (event: IngressPayload) => Promise, message: (event: MessagePayload) => Promise, -process: (event: ProcessPayload) => Promise }, -'folders': { fromCluster: (folderName: string) => Promise, -openCluster: (folderName: string) => Promise } }; +process: (event: ProcessPayload) => Promise } }; export type { InferCommandOutput } diff --git a/apps/oneclient/frontend/src/routes/app/account/route.tsx b/apps/oneclient/frontend/src/routes/app/account/route.tsx new file mode 100644 index 00000000..6ce3ec92 --- /dev/null +++ b/apps/oneclient/frontend/src/routes/app/account/route.tsx @@ -0,0 +1,40 @@ +import type { MinecraftCredentials } from '@/bindings.gen'; +import { LoaderSuspense } from '@/components'; +import { bindings } from '@/main'; +import { createFileRoute, Outlet } from '@tanstack/react-router'; + +export interface AccountsRouteSearchParams { + profile: MinecraftCredentials; +} + +export const Route = createFileRoute('/app/account')({ + component: RouteComponent, + validateSearch: (search): AccountsRouteSearchParams => { + return { + profile: search.profile as MinecraftCredentials, + }; + }, + async beforeLoad({ context, search }) { + const query = context.queryClient.ensureQueryData({ + queryKey: ['fetchLoggedInProfile', search.profile.access_token], + queryFn: () => bindings.core.fetchLoggedInProfile(search.profile.access_token), + }); + + const profileData = await query; + return { + profileData, + profile: search.profile, + }; + }, +}); + +function RouteComponent() { + return ( + <> + + + + + + ); +} diff --git a/apps/oneclient/frontend/src/routes/app/accountSkin.tsx b/apps/oneclient/frontend/src/routes/app/account/skins.tsx similarity index 79% rename from apps/oneclient/frontend/src/routes/app/accountSkin.tsx rename to apps/oneclient/frontend/src/routes/app/account/skins.tsx index b7b72c23..07871e65 100644 --- a/apps/oneclient/frontend/src/routes/app/accountSkin.tsx +++ b/apps/oneclient/frontend/src/routes/app/account/skins.tsx @@ -1,15 +1,11 @@ -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 { toast } from '@/utils/toast'; -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'; @@ -29,18 +25,10 @@ interface Cape { id: string; } -export const Route = createFileRoute('/app/accountSkin')({ +export const Route = createFileRoute('/app/account/skins')({ 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'); @@ -48,13 +36,11 @@ async function getSkinHistory(): Promise> { 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; } @@ -72,7 +58,6 @@ async function saveSkinHistory(skins: Array): Promise { const dirExists = await exists(parentDir); if (!dirExists) await mkdir(parentDir, { recursive: true }); - await writeTextFile(skinsPath, JSON.stringify(skins)); } catch (error) { @@ -80,63 +65,68 @@ async function saveSkinHistory(skins: Array): Promise { } } -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; - }); +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 }, +]; - saveSkinHistory(filteredDupes); - setSkinsSTATE(filteredDupes); +function useSkinHistory() { + const [skins, setSkinsState] = useState>([]); + const [loaded, setLoaded] = useState(false); + useEffect(() => { + (async () => { + const history = await getSkinHistory(); + setSkinsState(history); + setLoaded(true); + })(); + }, []); + const setSkins = (updater: (prev: Array) => Array) => { + setSkinsState((prev) => { + const newSkins = updater(prev); + const dedupedSkins: Array = []; + const seen: Set = new Set(); + for (const skin of newSkins) + if (!seen.has(skin.skin_url)) { + seen.add(skin.skin_url); + dedupedSkins.push(skin); + } + + saveSkinHistory(dedupedSkins); + return dedupedSkins; + }); }; + + return [skins, setSkins, loaded] as const; +} + +function RouteComponent() { + const { profileData, profile, queryClient } = Route.useRouteContext(); + 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)); + const [selectedCape, setSelectedCape] = useState(''); + const [shouldShowElytra, setShouldShowElytra] = useState(false); + 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); - } + setCapes([{ url: '', id: '' }, ...profileData.capes.map(cape => ({ url: cape.url, id: cape.id }))]); + }, []); - fetchSkins(); + const [skins, setSkins, loaded] = useSkinHistory(); + const [selectedSkin, setSelectedSkin] = useState({ skin_url: getSkinUrl(null), is_slim: false }); + const skinData: Skin = { is_slim: profileData.skins[0].variant === 'slim', skin_url: getSkinUrl(profileData.skins[0].url) }; - 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) + if (!loaded) 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(''); + setSkins(prev => [...prev, skinData]); // ✅ always merges with latest + setSelectedSkin(skinData); + }, [loaded]); const importFromURL = (url: string) => { - setSkins([...skins, { is_slim: false, skin_url: url }]); + setSkins(prev => [...prev, { is_slim: false, skin_url: url }]); }; const importFromUsername = async (username: string) => { @@ -153,30 +143,27 @@ function RouteComponent() { 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}`, - }); + if (playerProfile.skin_url) { + setSkins(prev => [...prev, { is_slim: playerProfile.is_slim, skin_url: getSkinUrl(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'); + await bindings.core.changeSkin(profile.access_token, selectedSkin.skin_url, selectedSkin.is_slim ? 'slim' : 'classic'); if (selectedCape === '') { - await bindings.core.removeCape(currentAccount.access_token); + await bindings.core.removeCape(profile.access_token); } else { const capeData = capes.find(cape => cape.url === selectedCape); if (!capeData) return; - await bindings.core.changeCape(currentAccount.access_token, capeData.id); + await bindings.core.changeCape(profile.access_token, capeData.id); } queryClient.invalidateQueries({ queryKey: ['getDefaultUser'], @@ -193,14 +180,8 @@ function RouteComponent() { } }; - if (currentAccount === null) - return ( - } headerSmall={<>}> - -

No accounts added

-
-
- ); + const [animation, setAnimation] = useState(animations[0].animation); + const [animationName, setAnimationName] = useState(animations[0].name); const getNextAnimationData = () => { const animationIndex = animations.findIndex(animationData => animationData.name === animationName); @@ -219,13 +200,10 @@ function RouteComponent() { setAnimationName(data.name); }; - if (!selectedSkin.skin_url) - return <>; - return ( + )} headerSmall={} > @@ -282,7 +260,7 @@ function RouteComponent() { ); } -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 }) { +function SkinHistoryRow({ selected, animation, setSelectedSkin, skins, setSkins, importFromURL, importFromUsername, capeURL, shouldShowElytra }: { selected: Skin; animation: PlayerAnimation; setSelectedSkin: (skin: Skin) => void; skins: Array; setSkins: (updater: (prev: Array) => Array) => void; importFromURL: (url: string) => void; importFromUsername: (username: string) => void; capeURL: string; shouldShowElytra: boolean }) { return (
@@ -317,7 +295,7 @@ function SkinHistoryRow({ selected, animation, setSelectedSkin, skins, setSkins, ); } -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 }) { +function RenderSkin({ skin, selected, animation, setSelectedSkin, setSkins, capeURL, shouldShowElytra }: { skin: Skin; selected: Skin; animation: PlayerAnimation; setSelectedSkin: (skin: Skin) => void; setSkins: (updater: (prev: Array) => Array) => void; capeURL: string; shouldShowElytra: boolean }) { const exportSkin = async () => { try { if (!skin.skin_url) diff --git a/apps/oneclient/frontend/src/routes/app/accounts.tsx b/apps/oneclient/frontend/src/routes/app/accounts.tsx index c850eafd..18d6e2f0 100644 --- a/apps/oneclient/frontend/src/routes/app/accounts.tsx +++ b/apps/oneclient/frontend/src/routes/app/accounts.tsx @@ -10,6 +10,7 @@ import { Button } from '@onelauncher/common/components'; import { useQueryClient } from '@tanstack/react-query'; import { createFileRoute, Link } from '@tanstack/react-router'; import { Pencil01Icon, Trash01Icon } from '@untitled-theme/icons-react'; +import { useCallback } from 'react'; import { Button as AriaButton, DialogTrigger } from 'react-aria-components'; import { twMerge } from 'tailwind-merge'; @@ -129,6 +130,16 @@ function AccountRow({ }) { const { isError } = usePlayerProfile(profile.id); + const navigate = Route.useNavigate(); + const manageSkin = useCallback(() => { + navigate({ + to: `/app/account/skins`, + search: { + profile, + }, + }); + }, [profile, navigate]); + return (
- - {/* TODO: Find a better way to handle handle a user that isn't just changing the default user */} - - + +