Merge branch 'truman/reorder-skins' into cal/drag-and-drop-to-move-skins

This commit is contained in:
tdgao
2026-06-10 09:33:25 -07:00
7 changed files with 293 additions and 53 deletions
@@ -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>
+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,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"
}
@@ -1,6 +1,10 @@
{
"db_name": "SQLite",
<<<<<<<< HEAD:packages/app-lib/.sqlx/query-a1217622558d50ee18e7ba0d85e991032037aaccfeca64dc076f0dcc826c108a.json
"query": "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated', display_order FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
========
"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 ?",
>>>>>>>> truman/reorder-skins:packages/app-lib/.sqlx/query-e00fdeb5d19f4d836dd62d8ef400205d5d9fd63e23994c8c48f0721b49a79594.json
"describe": {
"columns": [
{
@@ -34,5 +38,9 @@
false
]
},
<<<<<<<< HEAD:packages/app-lib/.sqlx/query-a1217622558d50ee18e7ba0d85e991032037aaccfeca64dc076f0dcc826c108a.json
"hash": "a1217622558d50ee18e7ba0d85e991032037aaccfeca64dc076f0dcc826c108a"
========
"hash": "e00fdeb5d19f4d836dd62d8ef400205d5d9fd63e23994c8c48f0721b49a79594"
>>>>>>>> truman/reorder-skins:packages/app-lib/.sqlx/query-e00fdeb5d19f4d836dd62d8ef400205d5d9fd63e23994c8c48f0721b49a79594.json
}
@@ -0,0 +1,46 @@
{
"db_name": "SQLite",
<<<<<<<< HEAD:packages/app-lib/.sqlx/query-a1217622558d50ee18e7ba0d85e991032037aaccfeca64dc076f0dcc826c108a.json
"query": "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated', display_order FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
========
"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 ?",
>>>>>>>> truman/reorder-skins:packages/app-lib/.sqlx/query-e00fdeb5d19f4d836dd62d8ef400205d5d9fd63e23994c8c48f0721b49a79594.json
"describe": {
"columns": [
{
"name": "texture_key",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "variant: MinecraftSkinVariant",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "cape_id: Hyphenated",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "display_order",
"ordinal": 3,
"type_info": "Integer"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
true,
false
]
},
<<<<<<<< HEAD:packages/app-lib/.sqlx/query-a1217622558d50ee18e7ba0d85e991032037aaccfeca64dc076f0dcc826c108a.json
"hash": "a1217622558d50ee18e7ba0d85e991032037aaccfeca64dc076f0dcc826c108a"
========
"hash": "e00fdeb5d19f4d836dd62d8ef400205d5d9fd63e23994c8c48f0721b49a79594"
>>>>>>>> truman/reorder-skins:packages/app-lib/.sqlx/query-e00fdeb5d19f4d836dd62d8ef400205d5d9fd63e23994c8c48f0721b49a79594.json
}
+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'
@@ -190,6 +188,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'
@@ -426,6 +425,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
@@ -611,6 +612,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