diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index 9df74bb686..18ff367db0 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -44,7 +44,8 @@ "vue": "^3.5.13", "vue-i18n": "^10.0.0", "vue-router": "^4.6.0", - "vue-virtual-scroller": "v2.0.0-beta.8" + "vue-virtual-scroller": "v2.0.0-beta.8", + "vuedraggable": "^4.1.0" }, "devDependencies": { "@eslint/compat": "^1.1.1", diff --git a/apps/app-frontend/src/components/ui/skin/VirtualSkinSectionList.vue b/apps/app-frontend/src/components/ui/skin/VirtualSkinSectionList.vue index 43430cfa0a..fc6054ef3f 100644 --- a/apps/app-frontend/src/components/ui/skin/VirtualSkinSectionList.vue +++ b/apps/app-frontend/src/components/ui/skin/VirtualSkinSectionList.vue @@ -1,5 +1,12 @@ @@ -316,8 +346,24 @@ defineExpose({ getAddSkinButtonElement }) ]" :style="{ transform: `translateY(${top}px)` }" > +
+ + + +
+ -
- - - {{ formatMessage(messages.addSkinButton) }} - - + - - - -
+ +
+ + diff --git a/apps/app-frontend/src/helpers/skins.ts b/apps/app-frontend/src/helpers/skins.ts index 9a05df55de..e0d17fe0e2 100644 --- a/apps/app-frontend/src/helpers/skins.ts +++ b/apps/app-frontend/src/helpers/skins.ts @@ -142,6 +142,12 @@ export async function remove_custom_skin(skin: Skin): Promise { }) } +export async function set_custom_skin_order(textureKeys: string[]): Promise { + await invoke('plugin:minecraft-skins|set_custom_skin_order', { + textureKeys, + }) +} + export async function save_custom_skin( skin: Skin, textureBlob: Uint8Array, diff --git a/apps/app-frontend/src/pages/Skins.vue b/apps/app-frontend/src/pages/Skins.vue index 58bb228f50..f48d068a51 100644 --- a/apps/app-frontend/src/pages/Skins.vue +++ b/apps/app-frontend/src/pages/Skins.vue @@ -46,6 +46,7 @@ import { get_normalized_skin_texture, normalize_skin_texture, remove_custom_skin, + set_custom_skin_order, } from '@/helpers/skins.ts' import { hasPride26Badge } from '@/helpers/user-campaigns.ts' import { handleSevereError } from '@/store/error' @@ -269,6 +270,7 @@ let isUnmounted = false const isDraggingSkinFile = ref(false) const isAddSkinButtonDragActive = ref(false) +const isSavedSkinReorderMode = ref(false) const deleteSkinModal = ref() const skinToDelete = ref(null) @@ -480,6 +482,44 @@ function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin) generateSkinPreviews(skins.value, capes.value) } +function reorderLocalSavedSkins(textureKeys: string[]) { + const requestedKeys = new Set(textureKeys) + const savedSkinList = skins.value.filter((skin) => skin.source !== 'default') + const savedSkinByKey = new Map(savedSkinList.map((skin) => [skin.texture_key, skin])) + const orderedSavedSkins = [ + ...textureKeys.map((key) => savedSkinByKey.get(key)).filter((skin): skin is Skin => !!skin), + ...savedSkinList.filter((skin) => !requestedKeys.has(skin.texture_key)), + ] + let savedSkinIndex = 0 + + skins.value = skins.value.flatMap((skin) => { + if (skin.source === 'default') { + return [skin] + } + + const nextSkin = orderedSavedSkins[savedSkinIndex++] + return nextSkin ? [nextSkin] : [] + }) +} + +async function onSavedSkinsReordered(textureKeys: string[]) { + const previousSkins = skins.value + + reorderLocalSavedSkins(textureKeys) + + try { + await set_custom_skin_order(textureKeys) + } catch (error) { + skins.value = previousSkins + handleError(error as Error) + await loadSkins() + } +} + +function toggleSavedSkinReorderMode() { + isSavedSkinReorderMode.value = !isSavedSkinReorderMode.value +} + function schedulePendingSkinRefresh() { if (pendingSkinRefreshTimeout !== null) { window.clearTimeout(pendingSkinRefreshTimeout) @@ -853,7 +893,7 @@ await loadSkins() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65d8a252d1..e5551e7046 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,6 +170,9 @@ importers: vue-virtual-scroller: specifier: v2.0.0-beta.8 version: 2.0.0-beta.8(vue@3.5.27(typescript@5.9.3)) + vuedraggable: + specifier: ^4.1.0 + version: 4.1.0(vue@3.5.27(typescript@5.9.3)) devDependencies: '@eslint/compat': specifier: ^1.1.1 @@ -8799,6 +8802,9 @@ packages: resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==} engines: {node: '>= 18'} + sortablejs@1.14.0: + resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -9798,8 +9804,8 @@ packages: vue-component-type-helpers@3.2.4: resolution: {integrity: sha512-05lR16HeZDcDpB23ku5b5f1fBOoHqFnMiKRr2CiEvbG5Ux4Yi0McmQBOET0dR0nxDXosxyVqv67q6CzS3AK8rw==} - vue-component-type-helpers@3.3.2: - resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==} + vue-component-type-helpers@3.3.3: + resolution: {integrity: sha512-x4nsFpy5Pe8fqPzp/5vkTPeTTDBpAx4WVtV47Ejt0+2FQrq4pRRsJs7JmYRqMFzTu/LW+pCWEjQ3YVCkPV7f9g==} vue-confetti-explosion@1.0.2: resolution: {integrity: sha512-80OboM3/6BItIoZ6DpNcZFqGpF607kjIVc5af56oKgtFmt5yWehvJeoYhkzYlqxrqdBe0Ko4Ie3bWrmLau+dJw==} @@ -9906,6 +9912,11 @@ packages: typescript: optional: true + vuedraggable@4.1.0: + resolution: {integrity: sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==} + peerDependencies: + vue: ^3.0.1 + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -13232,7 +13243,7 @@ snapshots: storybook: 10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) type-fest: 2.19.0 vue: 3.5.27(typescript@5.9.3) - vue-component-type-helpers: 3.3.2 + vue-component-type-helpers: 3.3.3 '@stripe/stripe-js@7.9.0': {} @@ -19299,6 +19310,8 @@ snapshots: smol-toml@1.6.0: {} + sortablejs@1.14.0: {} + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -20279,7 +20292,7 @@ snapshots: vue-component-type-helpers@3.2.4: {} - vue-component-type-helpers@3.3.2: {} + vue-component-type-helpers@3.3.3: {} vue-confetti-explosion@1.0.2(vue@3.5.27(typescript@5.9.3)): dependencies: @@ -20415,6 +20428,11 @@ snapshots: optionalDependencies: typescript: 5.9.3 + vuedraggable@4.1.0(vue@3.5.27(typescript@5.9.3)): + dependencies: + sortablejs: 1.14.0 + vue: 3.5.27(typescript@5.9.3) + w3c-keyname@2.2.8: {} web-namespaces@2.0.1: {}