Compare commits

...
Author SHA1 Message Date
tdgao 281fca42aa Merge branch 'main' into truman/reorder-skins 2026-06-10 09:30:53 -07:00
tdgao 734da21448 remove: backend implementation 2026-06-10 09:21:07 -07:00
tdgao 67c2cf1b4a fix: ci 2026-06-09 17:43:43 -07:00
tdgao 2909deab1d feat: implement drag to reorder skins 2026-06-09 17:35:33 -07:00
13 changed files with 319 additions and 68 deletions
+2 -1
View File
@@ -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",
@@ -1,5 +1,12 @@
<script setup lang="ts">
import { DropdownIcon, EditIcon, PlusIcon, TrashIcon, UnknownIcon } from '@modrinth/assets'
import {
DropdownIcon,
EditIcon,
MoveIcon,
PlusIcon,
TrashIcon,
UnknownIcon,
} from '@modrinth/assets'
import {
Accordion,
ButtonStyled,
@@ -13,6 +20,7 @@ import {
import { useElementSize, useWindowSize } from '@vueuse/core'
import { Tooltip } from 'floating-vue'
import { computed, nextTick, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import Draggable from 'vuedraggable'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Skin } from '@/helpers/skins.ts'
@@ -73,6 +81,10 @@ const messages = defineMessages({
id: 'app.skins.delete-button',
defaultMessage: 'Delete skin',
},
reorderSkinButton: {
id: 'app.skins.reorder-button',
defaultMessage: 'Reorder skin',
},
})
const props = defineProps<{
@@ -89,6 +101,7 @@ const emit = defineEmits<{
select: [skin: Skin]
edit: [skin: Skin, event: MouseEvent]
delete: [skin: Skin]
'reorder-saved-skins': [skins: Skin[]]
'add-skin': []
'add-skin-dragenter': [event: DragEvent]
'add-skin-dragover': [event: DragEvent]
@@ -154,6 +167,10 @@ const sections = computed<SkinSection[]>(() => [
})),
])
const draggableSavedSkins = ref<Skin[]>([])
const isDraggingSavedSkin = ref(false)
const canReorderSavedSkins = computed(() => draggableSavedSkins.value.length > 1)
const sectionLayouts = computed(() => {
const layouts: Array<{ section: SkinSection; top: number; height: number; index: number }> = []
let top = 0
@@ -210,6 +227,18 @@ watch(
{ immediate: true },
)
watch(
() => props.savedSkins,
(nextSkins) => {
if (isDraggingSavedSkin.value) {
return
}
draggableSavedSkins.value = [...nextSkins]
},
{ immediate: true },
)
watch(
listWidth,
(width) => {
@@ -258,6 +287,32 @@ function skinKey(skin: Skin, prefix: string) {
return `${prefix}-${skin.source}-${skin.texture_key}-${skin.variant}-${skin.cape_id ?? 'no-cape'}`
}
function savedSkinKey(skin: Skin) {
return skinKey(skin, 'saved-skin')
}
function doSkinOrdersMatch(firstSkins: Skin[], secondSkins: Skin[]) {
return (
firstSkins.length === secondSkins.length &&
firstSkins.every((skin, index) => savedSkinKey(skin) === savedSkinKey(secondSkins[index]))
)
}
function onSavedSkinDragStart() {
isDraggingSavedSkin.value = true
}
function onSavedSkinDragEnd() {
isDraggingSavedSkin.value = false
if (doSkinOrdersMatch(draggableSavedSkins.value, props.savedSkins)) {
draggableSavedSkins.value = [...props.savedSkins]
return
}
emit('reorder-saved-skins', [...draggableSavedSkins.value])
}
function isSectionOpen(key: string) {
return openSectionKeys.value.has(key)
}
@@ -355,63 +410,95 @@ defineExpose({ getAddSkinButtonElement })
</Tooltip>
</template>
<div
<Draggable
v-if="section.kind === 'saved'"
:list="draggableSavedSkins"
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
:item-key="savedSkinKey"
handle=".skin-reorder-handle"
:animation="250"
:swap-threshold="1"
:invert-swap="false"
:force-fallback="true"
:fallback-on-body="true"
:fallback-tolerance="4"
ghost-class="skin-reorder-ghost"
chosen-class="skin-reorder-chosen"
drag-class="skin-reorder-drag"
fallback-class="skin-reorder-fallback"
@start="onSavedSkinDragStart"
@end="onSavedSkinDragEnd"
>
<SkinLikeTextButton
ref="addSkinButton"
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
dropzone
:disabled="readOnly"
:drag-active="!readOnly && isAddSkinButtonDragActive"
@click="emit('add-skin')"
@dragenter="emit('add-skin-dragenter', $event)"
@dragover="emit('add-skin-dragover', $event)"
@dragleave="emit('add-skin-dragleave', $event)"
@drop="emit('add-skin-drop', $event)"
>
<template #icon>
<PlusIcon class="size-8" />
</template>
{{ formatMessage(messages.addSkinButton) }}
<template #subtitle>{{ formatMessage(messages.dragAndDropSubtitle) }}</template>
</SkinLikeTextButton>
<template #header>
<SkinLikeTextButton
ref="addSkinButton"
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
dropzone
:disabled="readOnly"
:drag-active="!readOnly && isAddSkinButtonDragActive"
@click="emit('add-skin')"
@dragenter="emit('add-skin-dragenter', $event)"
@dragover="emit('add-skin-dragover', $event)"
@dragleave="emit('add-skin-dragleave', $event)"
@drop="emit('add-skin-drop', $event)"
>
<template #icon>
<PlusIcon class="size-8" />
</template>
{{ formatMessage(messages.addSkinButton) }}
<template #subtitle>{{ formatMessage(messages.dragAndDropSubtitle) }}</template>
</SkinLikeTextButton>
</template>
<SkinButton
v-for="skin in section.skins"
:key="skinKey(skin, 'saved-skin')"
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="isSkinSelected(skin)"
:active="isSkinActive(skin)"
:disabled="readOnly"
@select="emit('select', skin)"
>
<template v-if="!readOnly" #overlay-buttons>
<ButtonStyled color="brand">
<template #item="{ element: skin }">
<div
:key="savedSkinKey(skin)"
class="relative aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
>
<SkinButton
class="h-full w-full min-w-0 box-border rounded-[20px]"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="isSkinSelected(skin)"
:active="isSkinActive(skin)"
@select="emit('select', skin)"
:disabled="readOnly"
>
<template v-if="!readOnly" #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
class="pointer-events-auto"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
>
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-show="!skin.is_equipped" circular color="red">
<button
v-tooltip="formatMessage(messages.deleteSkinButton)"
:aria-label="formatMessage(messages.deleteSkinButton)"
class="!rounded-[100%] pointer-events-auto"
@click.stop="emit('delete', skin)"
>
<TrashIcon />
</button>
</ButtonStyled>
</template>
</SkinButton>
<ButtonStyled v-if="canReorderSavedSkins" circular>
<button
:aria-label="formatMessage(messages.editSkinButton)"
class="pointer-events-auto"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
v-tooltip="formatMessage(messages.reorderSkinButton)"
:aria-label="formatMessage(messages.reorderSkinButton)"
class="skin-reorder-handle absolute bottom-3 right-3 z-40 cursor-grab active:cursor-grabbing"
@click.stop.prevent
>
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
<MoveIcon />
</button>
</ButtonStyled>
<ButtonStyled v-show="!skin.is_equipped" circular color="red">
<button
v-tooltip="formatMessage(messages.deleteSkinButton)"
:aria-label="formatMessage(messages.deleteSkinButton)"
class="!rounded-[100%] pointer-events-auto"
@click.stop="emit('delete', skin)"
>
<TrashIcon />
</button>
</ButtonStyled>
</template>
</SkinButton>
</div>
</div>
</template>
</Draggable>
<div
v-else
@@ -446,3 +533,18 @@ defineExpose({ getAddSkinButtonElement })
</div>
</div>
</template>
<style scoped>
:global(.skin-reorder-ghost) {
opacity: 0.35;
}
:global(.skin-reorder-drag) {
cursor: grabbing;
}
:global(.skin-reorder-fallback) {
opacity: 0.9;
pointer-events: none;
}
</style>
+6
View File
@@ -142,6 +142,12 @@ export async function remove_custom_skin(skin: Skin): Promise<void> {
})
}
export async function set_custom_skin_order(textureKeys: string[]): Promise<void> {
await invoke('plugin:minecraft-skins|set_custom_skin_order', {
textureKeys,
})
}
export async function save_custom_skin(
skin: Skin,
textureBlob: Uint8Array,
+51 -1
View File
@@ -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'
@@ -129,6 +130,14 @@ const messages = defineMessages({
id: 'app.skins.dropped-file-error.text',
defaultMessage: 'Failed to read the dropped file.',
},
reorderSkinErrorTitle: {
id: 'app.skins.reorder-error.title',
defaultMessage: 'Failed to reorder skins',
},
reorderSkinErrorText: {
id: 'app.skins.reorder-error.text',
defaultMessage: 'Your skin order could not be saved.',
},
deleteSkinTitle: {
id: 'app.skins.delete-modal.title',
defaultMessage: 'Are you sure you want to delete this skin?',
@@ -491,6 +500,19 @@ function setLocallyEquippedSkin(skinToApply: Skin) {
void accountsCard.value?.setEquippedSkin(originalSelectedSkin.value)
}
function insertLocalSkin(savedSkin: Skin) {
const firstNonCustomSkinIndex = skins.value.findIndex((skin) => skin.source !== 'custom')
if (firstNonCustomSkinIndex === -1) {
skins.value = [...skins.value, savedSkin]
return
}
const nextSkins = [...skins.value]
nextSkins.splice(firstNonCustomSkinIndex, 0, savedSkin)
skins.value = nextSkins
}
function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin) {
let foundSkin = false
const replacesSelectedSkin =
@@ -519,7 +541,7 @@ function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin)
})
if (!foundSkin) {
skins.value.unshift({
insertLocalSkin({
...savedSkin,
is_equipped: applied || savedSkin.is_equipped,
})
@@ -548,6 +570,33 @@ function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin)
generateSkinPreviews(skins.value, capes.value)
}
async function reorderSavedSkins(orderedSkins: Skin[]) {
const previousSkins = skins.value
const orderedTextureKeys = orderedSkins.map((skin) => skin.texture_key)
const orderedTextureKeySet = new Set(orderedTextureKeys)
const remainingSavedSkins = previousSkins.filter(
(skin) => skin.source !== 'default' && !orderedTextureKeySet.has(skin.texture_key),
)
const defaultSkins = previousSkins.filter((skin) => skin.source === 'default')
const nextSavedSkins = [...orderedSkins, ...remainingSavedSkins]
skins.value = [...nextSavedSkins, ...defaultSkins]
generateSkinPreviews(skins.value, capes.value)
try {
await set_custom_skin_order(nextSavedSkins.map((skin) => skin.texture_key))
} catch (error) {
skins.value = previousSkins
generateSkinPreviews(skins.value, capes.value)
addNotification({
type: 'error',
title: formatMessage(messages.reorderSkinErrorTitle),
text: error instanceof Error ? error.message : formatMessage(messages.reorderSkinErrorText),
})
await loadSkins()
}
}
function schedulePendingSkinRefresh() {
if (pendingSkinRefreshTimeout !== null) {
window.clearTimeout(pendingSkinRefreshTimeout)
@@ -985,6 +1034,7 @@ await loadSkins()
@select="changeSkin"
@edit="(skin, event) => editSkinModal?.show(event, skin)"
@delete="confirmDeleteSkin"
@reorder-saved-skins="reorderSavedSkins"
@add-skin="openAddSkinFileBrowser"
@add-skin-dragenter="onAddSkinDragOver"
@add-skin-dragover="onAddSkinDragOver"
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT display_order FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
"describe": {
"columns": [
{
"name": "display_order",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "18f04a0f6c262995b5f1eee10c2c5a396443ead9a9e295f6eea0986e40d65449"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "4c8063f9ce2fd7deec9b69e0b2c1055fe47287d7f99be41215c25c1019d439b9"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id, display_order) VALUES (?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "766c24900e5b90577a8a3a4e97636d84eaefe6049e0455b4609ba5f95258aad1"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT COALESCE(MAX(display_order) + 1, 0) AS 'display_order!: i64' FROM custom_minecraft_skins WHERE minecraft_user_uuid = ?",
"describe": {
"columns": [
{
"name": "display_order!: i64",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "bab7f687a8397975747cfe194a0e2cbc2e701584d8b3394a1aa686e3ff4d47f5"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE custom_minecraft_skins SET display_order = ? WHERE minecraft_user_uuid = ? AND texture_key = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "d68c41fb2cb182aa8fc23422c55bd5b728ba93d9e3bb6f3db57ac1c83574d508"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? ORDER BY rowid ASC LIMIT ? OFFSET ?",
"query": "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? ORDER BY display_order ASC, rowid ASC LIMIT ? OFFSET ?",
"describe": {
"columns": [
{
@@ -28,5 +28,5 @@
true
]
},
"hash": "aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac"
"hash": "e00fdeb5d19f4d836dd62d8ef400205d5d9fd63e23994c8c48f0721b49a79594"
}
+4 -2
View File
@@ -3,8 +3,6 @@
import type { FunctionalComponent, SVGAttributes } from 'vue'
export type IconComponent = FunctionalComponent<SVGAttributes>
import _AffiliateIcon from './icons/affiliate.svg?component'
import _AlignLeftIcon from './icons/align-left.svg?component'
import _ArchiveIcon from './icons/archive.svg?component'
@@ -189,6 +187,7 @@ import _MonitorSmartphoneIcon from './icons/monitor-smartphone.svg?component'
import _MoonIcon from './icons/moon.svg?component'
import _MoreHorizontalIcon from './icons/more-horizontal.svg?component'
import _MoreVerticalIcon from './icons/more-vertical.svg?component'
import _MoveIcon from './icons/move.svg?component'
import _NewspaperIcon from './icons/newspaper.svg?component'
import _NoSignalIcon from './icons/no-signal.svg?component'
import _NotepadTextIcon from './icons/notepad-text.svg?component'
@@ -425,6 +424,8 @@ import _XCircleIcon from './icons/x-circle.svg?component'
import _ZoomInIcon from './icons/zoom-in.svg?component'
import _ZoomOutIcon from './icons/zoom-out.svg?component'
export type IconComponent = FunctionalComponent<SVGAttributes>
export const AffiliateIcon = _AffiliateIcon
export const AlignLeftIcon = _AlignLeftIcon
export const ArchiveIcon = _ArchiveIcon
@@ -609,6 +610,7 @@ export const MonitorSmartphoneIcon = _MonitorSmartphoneIcon
export const MoonIcon = _MoonIcon
export const MoreHorizontalIcon = _MoreHorizontalIcon
export const MoreVerticalIcon = _MoreVerticalIcon
export const MoveIcon = _MoveIcon
export const NewspaperIcon = _NewspaperIcon
export const NoSignalIcon = _NoSignalIcon
export const NotepadTextIcon = _NotepadTextIcon
+20
View File
@@ -0,0 +1,20 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-move"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 2v20" />
<path d="m15 19-3 3-3-3" />
<path d="m19 9 3 3-3 3" />
<path d="M2 12h20" />
<path d="m5 9-3 3 3 3" />
<path d="m9 5 3-3 3 3" />
</svg>

After

Width:  |  Height:  |  Size: 447 B

+18
View File
@@ -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'}
@@ -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==}
@@ -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:
@@ -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: {}