Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/app-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
200 changes: 151 additions & 49 deletions apps/app-frontend/src/components/ui/skin/VirtualSkinSectionList.vue
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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'
Expand Down Expand Up @@ -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<{
Expand All @@ -88,6 +100,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]
Expand Down Expand Up @@ -153,6 +166,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
Expand Down Expand Up @@ -209,6 +226,18 @@ watch(
{ immediate: true },
)

watch(
() => props.savedSkins,
(nextSkins) => {
if (isDraggingSavedSkin.value) {
return
}

draggableSavedSkins.value = [...nextSkins]
},
{ immediate: true },
)

watch(
listWidth,
(width) => {
Expand Down Expand Up @@ -257,6 +286,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)
}
Expand Down Expand Up @@ -354,61 +409,93 @@ 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
:drag-active="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>

<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)"
@select="emit('select', skin)"
>
<template #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">
<template #header>
<SkinLikeTextButton
ref="addSkinButton"
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
dropzone
:drag-active="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>

<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)"
>
<template #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
v-tooltip="formatMessage(messages.deleteSkinButton)"
:aria-label="formatMessage(messages.deleteSkinButton)"
class="!rounded-[100%] pointer-events-auto"
@click.stop="emit('delete', skin)"
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
>
<TrashIcon />
<MoveIcon />
</button>
</ButtonStyled>
</template>
</SkinButton>
</div>
</div>
</template>
</Draggable>

<div
v-else
Expand Down Expand Up @@ -442,3 +529,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 changes: 6 additions & 0 deletions apps/app-frontend/src/helpers/skins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
52 changes: 51 additions & 1 deletion apps/app-frontend/src/pages/Skins.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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?',
Expand Down Expand Up @@ -423,6 +432,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 =
Expand Down Expand Up @@ -451,7 +473,7 @@ function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin)
})

if (!foundSkin) {
skins.value.unshift({
insertLocalSkin({
...savedSkin,
is_equipped: applied || savedSkin.is_equipped,
})
Expand Down Expand Up @@ -480,6 +502,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)
Expand Down Expand Up @@ -876,6 +925,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"
Expand Down
Loading