diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index e7737ed16..6804df966 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -24,6 +24,7 @@ "@tauri-apps/plugin-os": "^2.2.1", "@tauri-apps/plugin-updater": "^2.7.1", "@tauri-apps/plugin-window-state": "^2.2.2", + "@types/three": "^0.172.0", "@vintl/vintl": "^4.4.1", "@vueuse/core": "^11.1.0", "dayjs": "^1.11.10", diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 454ef9338..8a86df971 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -69,12 +69,7 @@ import { hide_ads_window, init_ads_window } from '@/helpers/ads.js' import FriendsList from '@/components/ui/friends/FriendsList.vue' import { openUrl } from '@tauri-apps/plugin-opener' import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue' -import { - get_available_capes, - get_available_skins, - get_normalized_skin_texture, - normalize_skin_texture, -} from './helpers/skins' +import { get_available_capes, get_available_skins } from './helpers/skins' import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer' const formatRelativeTime = useRelativeTime() diff --git a/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue b/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue index 7b60e8604..0a32f24d3 100644 --- a/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue +++ b/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue @@ -77,13 +77,15 @@
- - - +
@@ -117,14 +119,22 @@ import { get_normalized_skin_texture, } from '@/helpers/skins.ts' import { handleError } from '@/store/notifications' -import { UploadIcon, CheckIcon, SaveIcon, XIcon, ChevronRightIcon } from '@modrinth/assets' -import {computedAsync} from "@vueuse/core"; +import { + UploadIcon, + CheckIcon, + SaveIcon, + XIcon, + ChevronRightIcon, + SpinnerIcon, +} from '@modrinth/assets' +import { computedAsync } from '@vueuse/core' const modal = useTemplateRef('modal') const selectCapeModal = useTemplateRef('selectCapeModal') const mode = ref<'new' | 'edit'>('new') const currentSkin = ref(null) const shouldRestoreModal = ref(false) +const isSaving = ref(false) // Add loading state const uploadedTextureUrl = ref(null) @@ -192,6 +202,7 @@ const disableSave = computed( ) const saveTooltip = computed(() => { + if (isSaving.value) return 'Saving...' if (mode.value === 'new' && !uploadedTextureUrl.value) return 'Upload a skin first!' if (mode.value === 'edit' && !hasEdits.value) return 'Make an edit to the skin first!' return undefined @@ -205,6 +216,7 @@ function resetState() { selectedCape.value = undefined visibleCapeList.value = [] shouldRestoreModal.value = false + isSaving.value = false } function show(e: MouseEvent, skin?: Skin) { @@ -310,6 +322,8 @@ function openUploadSkinModal(e: MouseEvent) { } async function save() { + isSaving.value = true + try { let textureUrl: string @@ -335,6 +349,8 @@ async function save() { hide() } catch (err) { handleError(err) + } finally { + isSaving.value = false } } diff --git a/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts b/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts index c83e9ffdc..45a4cb231 100644 --- a/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts +++ b/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts @@ -1,6 +1,6 @@ import * as THREE from 'three' -import {Skin, Cape, normalize_skin_texture, get_normalized_skin_texture} from '../skins' -import { determineModelType } from '../skins' +import type { Skin, Cape } from '../skins' +import { get_normalized_skin_texture, determineModelType } from '../skins' import { reactive } from 'vue' import { setupSkinModel, disposeCaches } from '@modrinth/utils' import { skinPreviewStorage } from '../storage/skin-preview-storage' diff --git a/apps/app-frontend/src/helpers/skins.ts b/apps/app-frontend/src/helpers/skins.ts index 91c923441..71a466b86 100644 --- a/apps/app-frontend/src/helpers/skins.ts +++ b/apps/app-frontend/src/helpers/skins.ts @@ -1,6 +1,6 @@ import { invoke } from '@tauri-apps/api/core' import { handleError } from '@/store/notifications' -import {arrayBufferToBase64} from "@modrinth/utils"; +import { arrayBufferToBase64 } from '@modrinth/utils' export interface Cape { id: string @@ -144,9 +144,9 @@ export async function remove_custom_skin(skin: Skin): Promise { } export async function get_normalized_skin_texture(skin: Skin): Promise { - const data = await normalize_skin_texture(skin.texture); - const base64 = arrayBufferToBase64(data); - return `data:image/png;base64,${base64}`; + const data = await normalize_skin_texture(skin.texture) + const base64 = arrayBufferToBase64(data) + return `data:image/png;base64,${base64}` } export async function normalize_skin_texture(texture: Uint8Array | string): Promise { diff --git a/apps/app-frontend/src/pages/Skins.vue b/apps/app-frontend/src/pages/Skins.vue index ddb5a7cd7..5fec6bdfa 100644 --- a/apps/app-frontend/src/pages/Skins.vue +++ b/apps/app-frontend/src/pages/Skins.vue @@ -16,15 +16,16 @@ import { SkinLikeTextButton, SkinPreviewRenderer, } from '@modrinth/ui' -import {computedAsync} from '@vueuse/core' -import type {Ref} from 'vue' -import {computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch} from 'vue' +import { computedAsync } from '@vueuse/core' +import type { Ref } from 'vue' +import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue' import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue' import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue' import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue' -import {handleError} from '@/store/notifications' -import {Cape, normalize_skin_texture, Skin} from '@/helpers/skins.ts' +import { handleError } from '@/store/notifications' +import type { Cape, Skin } from '@/helpers/skins.ts' import { + normalize_skin_texture, equip_skin, filterDefaultSkins, filterSavedSkins, @@ -34,14 +35,14 @@ import { remove_custom_skin, set_default_cape, } from '@/helpers/skins.ts' -import {get as getSettings} from '@/helpers/settings.ts' -import {get_default_user, login as login_flow, users} from '@/helpers/auth' -import type {RenderResult} from '@/helpers/rendering/batch-skin-renderer.ts' -import {generateSkinPreviews, map} from '@/helpers/rendering/batch-skin-renderer.ts' -import {handleSevereError} from '@/store/error' -import {trackEvent} from '@/helpers/analytics' +import { get as getSettings } from '@/helpers/settings.ts' +import { get_default_user, login as login_flow, users } from '@/helpers/auth' +import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts' +import { generateSkinPreviews, map } from '@/helpers/rendering/batch-skin-renderer.ts' +import { handleSevereError } from '@/store/error' +import { trackEvent } from '@/helpers/analytics' import type AccountsCard from '@/components/ui/AccountsCard.vue' -import {arrayBufferToBase64} from "@modrinth/utils"; +import { arrayBufferToBase64 } from '@modrinth/utils' const editSkinModal = useTemplateRef('editSkinModal') const selectCapeModal = useTemplateRef('selectCapeModal') @@ -59,6 +60,9 @@ const username = computed(() => currentUser.value?.profile?.name ?? undefined) const selectedSkin = ref(null) const defaultCape = ref() +const originalSelectedSkin = ref(null) +const originalDefaultCape = ref() + const savedSkins = computed(() => filterSavedSkins(skins.value)) const defaultSkins = computed(() => filterDefaultSkins(skins.value)) @@ -74,9 +78,9 @@ const currentCape = computed(() => { const skinTexture = computedAsync(async () => { if (selectedSkin.value?.texture) { - return await get_normalized_skin_texture(selectedSkin.value); + return await get_normalized_skin_texture(selectedSkin.value) } else { - return ''; + return '' } }) const capeTexture = computed(() => currentCape.value?.texture) @@ -106,6 +110,7 @@ async function loadCapes() { try { capes.value = (await get_available_capes()) ?? [] defaultCape.value = capes.value.find((c) => c.is_equipped) + originalDefaultCape.value = defaultCape.value } catch (error) { if (currentUser.value) { handleError(error) @@ -118,6 +123,7 @@ async function loadSkins() { skins.value = (await get_available_skins()) ?? [] generateSkinPreviews(skins.value, capes.value) selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null + originalSelectedSkin.value = selectedSkin.value } catch (error) { if (currentUser.value) { handleError(error) @@ -126,14 +132,45 @@ async function loadSkins() { } async function changeSkin(newSkin: Skin) { - await equip_skin(newSkin).catch(handleError) - await loadSkins() + const previousSkin = selectedSkin.value + const previousSkinsList = [...skins.value] + + skins.value = skins.value.map((skin) => { + return { + ...skin, + is_equipped: skin.texture_key === newSkin.texture_key, + } + }) + + selectedSkin.value = skins.value.find((s) => s.texture_key === newSkin.texture_key) || null + + try { + await equip_skin(newSkin) + } catch (error) { + selectedSkin.value = previousSkin + skins.value = previousSkinsList + handleError(error) + } } async function handleCapeSelected(cape: Cape | undefined) { - await set_default_cape(cape).catch(handleError) - await loadSkins() - await loadCapes() + const previousDefaultCape = defaultCape.value + const previousCapesList = [...capes.value] + + capes.value = capes.value.map((c) => ({ + ...c, + is_equipped: cape ? c.id === cape.id : false, + })) + + defaultCape.value = cape ? capes.value.find((c) => c.id === cape.id) : undefined + + try { + await set_default_cape(cape) + } catch (error) { + defaultCape.value = previousDefaultCape + capes.value = previousCapesList + handleError(error) + } } async function onSkinSaved() { @@ -178,8 +215,10 @@ function openUploadSkinModal(e: MouseEvent) { function onSkinFileUploaded(file: File) { const fakeEvent = new MouseEvent('click') file.arrayBuffer().then(async (buf) => { - const skinTextureNormalized: Uint8Array = await normalize_skin_texture(`data:image/png;base64,` + arrayBufferToBase64(buf)) - const skinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized); + const skinTextureNormalized: Uint8Array = await normalize_skin_texture( + `data:image/png;base64,` + arrayBufferToBase64(buf), + ) + const skinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized) if (editSkinModal.value && editSkinModal.value.shouldRestoreModal) { editSkinModal.value.restoreWithNewTexture(skinTexUrl) diff --git a/packages/utils/utils.ts b/packages/utils/utils.ts index 5f8f5ea76..9482f2268 100644 --- a/packages/utils/utils.ts +++ b/packages/utils/utils.ts @@ -364,12 +364,6 @@ export function getPingLevel(ping: number) { } export function arrayBufferToBase64(buffer: Uint8Array | ArrayBuffer): string { - const bytes = buffer instanceof Uint8Array - ? buffer - : new Uint8Array(buffer); - let binary = ''; - for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary); + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer) + return btoa(String.fromCharCode(...bytes)) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b58cbb634..750b25065 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,15 +77,21 @@ importers: '@tauri-apps/plugin-window-state': specifier: ^2.2.2 version: 2.2.2 + '@types/three': + specifier: ^0.172.0 + version: 0.172.0 '@vintl/vintl': specifier: ^4.4.1 version: 4.4.1(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)) + '@vueuse/core': + specifier: ^11.1.0 + version: 11.1.0(vue@3.5.13(typescript@5.5.4)) dayjs: specifier: ^1.11.10 version: 1.11.11 floating-vue: specifier: ^5.2.2 - version: 5.2.2(@nuxt/kit@3.14.1592(magicast@0.3.5))(vue@3.5.13(typescript@5.5.4)) + version: 5.2.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.34.9))(vue@3.5.13(typescript@5.5.4)) ofetch: specifier: ^1.3.4 version: 1.4.1 @@ -9489,34 +9495,6 @@ snapshots: - supports-color - typescript - '@nuxt/kit@3.14.1592(magicast@0.3.5)': - dependencies: - '@nuxt/schema': 3.14.1592(magicast@0.3.5) - c12: 2.0.1(magicast@0.3.5) - consola: 3.2.3 - defu: 6.1.4 - destr: 2.0.3 - globby: 14.0.2 - hash-sum: 2.0.0 - ignore: 6.0.2 - jiti: 2.4.1 - klona: 2.0.6 - knitwork: 1.1.0 - mlly: 1.7.3 - pathe: 1.1.2 - pkg-types: 1.2.1 - scule: 1.3.0 - semver: 7.7.1 - ufo: 1.5.4 - unctx: 2.3.1 - unimport: 3.14.4 - untyped: 1.5.1 - transitivePeerDependencies: - - magicast - - rollup - - supports-color - optional: true - '@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@3.29.4)': dependencies: '@nuxt/schema': 3.14.1592(magicast@0.3.5)(rollup@3.29.4) @@ -9572,20 +9550,27 @@ snapshots: - rollup - supports-color - '@nuxt/schema@3.14.1592(magicast@0.3.5)': + '@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.34.9)': dependencies: + '@nuxt/schema': 3.14.1592(magicast@0.3.5)(rollup@4.34.9) c12: 2.0.1(magicast@0.3.5) - compatx: 0.1.8 consola: 3.2.3 defu: 6.1.4 - hookable: 5.5.3 + destr: 2.0.3 + globby: 14.0.2 + hash-sum: 2.0.0 + ignore: 6.0.2 + jiti: 2.4.1 + klona: 2.0.6 + knitwork: 1.1.0 + mlly: 1.7.3 pathe: 1.1.2 pkg-types: 1.2.1 scule: 1.3.0 - std-env: 3.8.0 + semver: 7.7.1 ufo: 1.5.4 - uncrypto: 0.1.3 - unimport: 3.14.4 + unctx: 2.3.1 + unimport: 3.14.4(rollup@4.34.9) untyped: 1.5.1 transitivePeerDependencies: - magicast @@ -9634,6 +9619,27 @@ snapshots: - rollup - supports-color + '@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.34.9)': + dependencies: + c12: 2.0.1(magicast@0.3.5) + compatx: 0.1.8 + consola: 3.2.3 + defu: 6.1.4 + hookable: 5.5.3 + pathe: 1.1.2 + pkg-types: 1.2.1 + scule: 1.3.0 + std-env: 3.8.0 + ufo: 1.5.4 + uncrypto: 0.1.3 + unimport: 3.14.4(rollup@4.34.9) + untyped: 1.5.1 + transitivePeerDependencies: + - magicast + - rollup + - supports-color + optional: true + '@nuxt/telemetry@2.6.0(magicast@0.3.5)(rollup@4.28.1)': dependencies: '@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.28.1) @@ -9976,6 +9982,15 @@ snapshots: optionalDependencies: rollup: 4.28.1 + '@rollup/pluginutils@5.1.3(rollup@4.34.9)': + dependencies: + '@types/estree': 1.0.6 + estree-walker: 2.0.2 + picomatch: 4.0.2 + optionalDependencies: + rollup: 4.34.9 + optional: true + '@rollup/pluginutils@5.1.4(rollup@4.34.9)': dependencies: '@types/estree': 1.0.6 @@ -13081,13 +13096,13 @@ snapshots: optionalDependencies: '@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.28.1) - floating-vue@5.2.2(@nuxt/kit@3.14.1592(magicast@0.3.5))(vue@3.5.13(typescript@5.5.4)): + floating-vue@5.2.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.34.9))(vue@3.5.13(typescript@5.5.4)): dependencies: '@floating-ui/dom': 1.1.1 vue: 3.5.13(typescript@5.5.4) vue-resize: 2.0.0-alpha.1(vue@3.5.13(typescript@5.5.4)) optionalDependencies: - '@nuxt/kit': 3.14.1592(magicast@0.3.5) + '@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.34.9) for-each@0.3.3: dependencies: @@ -16704,26 +16719,6 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 - unimport@3.14.4: - dependencies: - '@rollup/pluginutils': 5.1.3(rollup@4.28.1) - acorn: 8.14.0 - escape-string-regexp: 5.0.0 - estree-walker: 3.0.3 - local-pkg: 0.5.1 - magic-string: 0.30.14 - mlly: 1.7.3 - pathe: 1.1.2 - picomatch: 4.0.2 - pkg-types: 1.2.1 - scule: 1.3.0 - strip-literal: 2.1.1 - tinyglobby: 0.2.10 - unplugin: 1.16.0 - transitivePeerDependencies: - - rollup - optional: true - unimport@3.14.4(rollup@3.29.4): dependencies: '@rollup/pluginutils': 5.1.3(rollup@3.29.4) @@ -16763,6 +16758,26 @@ snapshots: transitivePeerDependencies: - rollup + unimport@3.14.4(rollup@4.34.9): + dependencies: + '@rollup/pluginutils': 5.1.3(rollup@4.34.9) + acorn: 8.14.0 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + local-pkg: 0.5.1 + magic-string: 0.30.14 + mlly: 1.7.3 + pathe: 1.1.2 + picomatch: 4.0.2 + pkg-types: 1.2.1 + scule: 1.3.0 + strip-literal: 2.1.1 + tinyglobby: 0.2.10 + unplugin: 1.16.0 + transitivePeerDependencies: + - rollup + optional: true + unist-util-find-after@5.0.0: dependencies: '@types/unist': 3.0.3