From 0f12b440c6d091b3e48b396f8dfad0a64985b29c Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Tue, 9 Jun 2026 13:46:47 +0100 Subject: [PATCH] feat: drag and drop skins to reorder --- apps/app-frontend/package.json | 3 +- .../ui/skin/VirtualSkinSectionList.vue | 200 +++++++++++----- apps/app-frontend/src/helpers/skins.ts | 6 + apps/app-frontend/src/pages/Skins.vue | 45 +++- apps/app/build.rs | 1 + apps/app/src/api/minecraft_skins.rs | 9 + ...fd6f231ecffd45315ed2b76253d02bbd531ff.json | 12 + ...c5a396443ead9a9e295f6eea0986e40d65449.json | 20 ++ ...da27bff30801646436358acf57eb4cdfa25ad.json | 12 + ...1055fe47287d7f99be41215c25c1019d439b9.json | 12 - ...215f46946f9b9043d4b037e3a4d5b27e0373.json} | 12 +- ...e34b16057db8b0eab08a9d8b8dce961c6f81c.json | 20 ++ ...37ad3b03ecf99d1484c402694799dc08271f5.json | 12 + ...91032037aaccfeca64dc076f0dcc826c108a.json} | 12 +- ...e2cbc2e701584d8b3394a1aa686e3ff4d47f5.json | 20 ++ ...bd5b728ba93d9e3bb6f3db57ac1c83574d508.json | 12 + .../20260609120000_add-saved-skin-order.sql | 13 ++ packages/app-lib/src/api/minecraft_skins.rs | 66 +++++- .../app-lib/src/state/minecraft_skins/mod.rs | 130 ++++++++++- packages/assets/generated-icons.ts | 219 +++++++++--------- packages/assets/icons/grip-vertical.svg | 20 ++ .../ui/src/components/skin/SkinButton.vue | 12 +- pnpm-lock.yaml | 26 ++- 23 files changed, 700 insertions(+), 194 deletions(-) create mode 100644 packages/app-lib/.sqlx/query-11875ae76d8a61099dff08e9792fd6f231ecffd45315ed2b76253d02bbd531ff.json create mode 100644 packages/app-lib/.sqlx/query-18f04a0f6c262995b5f1eee10c2c5a396443ead9a9e295f6eea0986e40d65449.json create mode 100644 packages/app-lib/.sqlx/query-3538a56e37f456ca57998bd4753da27bff30801646436358acf57eb4cdfa25ad.json delete mode 100644 packages/app-lib/.sqlx/query-4c8063f9ce2fd7deec9b69e0b2c1055fe47287d7f99be41215c25c1019d439b9.json rename packages/app-lib/.sqlx/{query-aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac.json => query-57df2984dd65b408473ed1ae86b0215f46946f9b9043d4b037e3a4d5b27e0373.json} (59%) create mode 100644 packages/app-lib/.sqlx/query-69c5a93676809ab6480fc41a2e6e34b16057db8b0eab08a9d8b8dce961c6f81c.json create mode 100644 packages/app-lib/.sqlx/query-9a9b8b5c0b646b841b73461980b37ad3b03ecf99d1484c402694799dc08271f5.json rename packages/app-lib/.sqlx/{query-a0b0ff0ae4b88d5df9d15d3427ab4e9a6ff21cffdc9c2f3d6860e245949d313d.json => query-a1217622558d50ee18e7ba0d85e991032037aaccfeca64dc076f0dcc826c108a.json} (61%) create mode 100644 packages/app-lib/.sqlx/query-bab7f687a8397975747cfe194a0e2cbc2e701584d8b3394a1aa686e3ff4d47f5.json create mode 100644 packages/app-lib/.sqlx/query-d68c41fb2cb182aa8fc23422c55bd5b728ba93d9e3bb6f3db57ac1c83574d508.json create mode 100644 packages/app-lib/migrations/20260609120000_add-saved-skin-order.sql create mode 100644 packages/assets/icons/grip-vertical.svg 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: {}