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