You've already forked AstralRinth
feat: drag and drop skins to reorder (#6357)
* feat: drag and drop skins to reorder * feat: implement drag to reorder skins * fix: ci * remove: backend implementation * regenerate sqlx * fix: remove v-if selectable * feat: remove drag handle * refactor: pnpm prepr * cargo fmt * fix: dragging disable hover, wrong evt for edit skin + remove back of skin hover --------- Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -148,7 +148,6 @@ import { arrayBufferToBase64 } from '@modrinth/utils'
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import {
|
||||
add_and_equip_custom_skin,
|
||||
type Cape,
|
||||
determineModelType,
|
||||
equip_skin,
|
||||
@@ -440,9 +439,22 @@ async function save() {
|
||||
const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer())
|
||||
|
||||
if (mode.value === 'new') {
|
||||
const addedSkin = await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
|
||||
const addedSkin = await save_custom_skin(
|
||||
{
|
||||
texture_key: '',
|
||||
variant: variant.value,
|
||||
cape_id: selectedCape.value?.id,
|
||||
texture: textureUrl,
|
||||
source: 'custom',
|
||||
is_equipped: false,
|
||||
},
|
||||
bytes,
|
||||
variant.value,
|
||||
selectedCape.value,
|
||||
true,
|
||||
)
|
||||
emit('saved', {
|
||||
applied: true,
|
||||
applied: false,
|
||||
skin: addedSkin,
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -13,6 +13,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'
|
||||
@@ -89,6 +90,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 +156,13 @@ const sections = computed<SkinSection[]>(() => [
|
||||
})),
|
||||
])
|
||||
|
||||
const draggableSavedSkins = ref<Skin[]>([])
|
||||
const isDraggingSavedSkin = ref(false)
|
||||
const canReorderSavedSkins = computed(() => draggableSavedSkins.value.length > 1)
|
||||
const fixedSavedSkins = computed(() =>
|
||||
props.savedSkins.filter((skin) => !canPersistSkinOrder(skin)),
|
||||
)
|
||||
|
||||
const sectionLayouts = computed(() => {
|
||||
const layouts: Array<{ section: SkinSection; top: number; height: number; index: number }> = []
|
||||
let top = 0
|
||||
@@ -210,6 +219,18 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.savedSkins,
|
||||
(nextSkins) => {
|
||||
if (isDraggingSavedSkin.value) {
|
||||
return
|
||||
}
|
||||
|
||||
draggableSavedSkins.value = nextSkins.filter(canPersistSkinOrder)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
listWidth,
|
||||
(width) => {
|
||||
@@ -258,6 +279,40 @@ 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 canPersistSkinOrder(skin: Skin) {
|
||||
return skin.source === 'custom'
|
||||
}
|
||||
|
||||
function doSkinOrdersMatch(firstSkins: Skin[], secondSkins: Skin[]) {
|
||||
const persistedSecondSkins = secondSkins.filter(canPersistSkinOrder)
|
||||
|
||||
return (
|
||||
firstSkins.length === persistedSecondSkins.length &&
|
||||
firstSkins.every(
|
||||
(skin, index) => savedSkinKey(skin) === savedSkinKey(persistedSecondSkins[index]),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function onSavedSkinDragStart() {
|
||||
isDraggingSavedSkin.value = true
|
||||
}
|
||||
|
||||
function onSavedSkinDragEnd() {
|
||||
isDraggingSavedSkin.value = false
|
||||
|
||||
if (doSkinOrdersMatch(draggableSavedSkins.value, props.savedSkins)) {
|
||||
draggableSavedSkins.value = props.savedSkins.filter(canPersistSkinOrder)
|
||||
return
|
||||
}
|
||||
|
||||
emit('reorder-saved-skins', [...draggableSavedSkins.value])
|
||||
}
|
||||
|
||||
function isSectionOpen(key: string) {
|
||||
return openSectionKeys.value.has(key)
|
||||
}
|
||||
@@ -355,63 +410,125 @@ 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"
|
||||
:disabled="readOnly || !canReorderSavedSkins"
|
||||
: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">
|
||||
<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>
|
||||
</div>
|
||||
<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"
|
||||
:selected="isSkinSelected(skin)"
|
||||
:active="isSkinActive(skin)"
|
||||
:disabled="readOnly"
|
||||
:is-dragging="isDraggingSavedSkin"
|
||||
@select="emit('select', skin)"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div
|
||||
v-for="skin in fixedSavedSkins"
|
||||
: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"
|
||||
:selected="isSkinSelected(skin)"
|
||||
:active="isSkinActive(skin)"
|
||||
:disabled="readOnly"
|
||||
:is-dragging="isDraggingSavedSkin"
|
||||
@select="emit('select', skin)"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
|
||||
<div
|
||||
v-else
|
||||
@@ -422,11 +539,11 @@ defineExpose({ getAddSkinButtonElement })
|
||||
:key="skinKey(skin, section.key)"
|
||||
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)"
|
||||
:tooltip="skin.name"
|
||||
:disabled="readOnly"
|
||||
:is-dragging="isDraggingSavedSkin"
|
||||
@select="emit('select', skin)"
|
||||
>
|
||||
<template #overlay-buttons>
|
||||
@@ -446,3 +563,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>
|
||||
|
||||
@@ -15,12 +15,10 @@ import { skinPreviewStorage } from '../storage/skin-preview-storage'
|
||||
|
||||
export interface RenderResult {
|
||||
forwards: string
|
||||
backwards: string
|
||||
}
|
||||
|
||||
export interface RawRenderResult {
|
||||
forwards: Blob
|
||||
backwards: Blob
|
||||
}
|
||||
|
||||
class BatchSkinRenderer {
|
||||
@@ -92,12 +90,9 @@ class BatchSkinRenderer {
|
||||
}
|
||||
|
||||
const frontCameraPos: [number, number, number] = [-1.3, 1, 6.3]
|
||||
const backCameraPos: [number, number, number] = [-1.3, 1, -2.5]
|
||||
|
||||
const forwards = await this.renderView(frontCameraPos, lookAtTarget)
|
||||
const backwards = await this.renderView(backCameraPos, lookAtTarget)
|
||||
|
||||
return { forwards, backwards }
|
||||
return { forwards }
|
||||
}
|
||||
|
||||
private async renderView(
|
||||
@@ -407,7 +402,6 @@ async function generateSkinPreviewsForGeneration(
|
||||
if (rawCached && !skinBlobUrlMap.has(skinKey)) {
|
||||
const cached: RenderResult = {
|
||||
forwards: URL.createObjectURL(rawCached.forwards),
|
||||
backwards: URL.createObjectURL(rawCached.backwards),
|
||||
}
|
||||
skinBlobUrlMap.set(skinKey, cached)
|
||||
}
|
||||
@@ -427,7 +421,6 @@ async function generateSkinPreviewsForGeneration(
|
||||
if (DEBUG_MODE) {
|
||||
const result = skinBlobUrlMap.get(key)!
|
||||
URL.revokeObjectURL(result.forwards)
|
||||
URL.revokeObjectURL(result.backwards)
|
||||
skinBlobUrlMap.delete(key)
|
||||
} else continue
|
||||
}
|
||||
@@ -456,7 +449,6 @@ async function generateSkinPreviewsForGeneration(
|
||||
|
||||
const renderResult: RenderResult = {
|
||||
forwards: URL.createObjectURL(rawRenderResult.forwards),
|
||||
backwards: URL.createObjectURL(rawRenderResult.backwards),
|
||||
}
|
||||
|
||||
skinBlobUrlMap.set(key, renderResult)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { RawRenderResult } from '../rendering/batch-skin-renderer'
|
||||
|
||||
interface StoredPreview {
|
||||
forwards: Blob
|
||||
backwards: Blob
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
@@ -38,7 +37,6 @@ export class SkinPreviewStorage {
|
||||
|
||||
const storedPreview: StoredPreview = {
|
||||
forwards: result.forwards,
|
||||
backwards: result.backwards,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
@@ -67,7 +65,7 @@ export class SkinPreviewStorage {
|
||||
return
|
||||
}
|
||||
|
||||
resolve({ forwards: result.forwards, backwards: result.backwards })
|
||||
resolve({ forwards: result.forwards })
|
||||
}
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
@@ -95,7 +93,7 @@ export class SkinPreviewStorage {
|
||||
const result = request.result as StoredPreview | undefined
|
||||
|
||||
if (result) {
|
||||
results[key] = { forwards: result.forwards, backwards: result.backwards }
|
||||
results[key] = { forwards: result.forwards }
|
||||
} else {
|
||||
results[key] = null
|
||||
}
|
||||
@@ -173,7 +171,7 @@ export class SkinPreviewStorage {
|
||||
const key = cursor.primaryKey as string
|
||||
const value = cursor.value as StoredPreview
|
||||
|
||||
const entrySize = value.forwards.size + value.backwards.size
|
||||
const entrySize = value.forwards.size
|
||||
totalSize += entrySize
|
||||
count++
|
||||
|
||||
|
||||
@@ -437,6 +437,12 @@
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "Slow down!"
|
||||
},
|
||||
"app.skins.reorder-error.text": {
|
||||
"message": "Your skin order could not be saved."
|
||||
},
|
||||
"app.skins.reorder-error.title": {
|
||||
"message": "Failed to reorder skins"
|
||||
},
|
||||
"app.skins.section.builders-and-biomes": {
|
||||
"message": "Builders & Biomes"
|
||||
},
|
||||
|
||||
@@ -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,35 @@ 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.filter((skin) => skin.source === 'custom').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 +1036,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"
|
||||
|
||||
@@ -117,6 +117,7 @@ fn main() {
|
||||
"equip_skin",
|
||||
"remove_custom_skin",
|
||||
"save_custom_skin",
|
||||
"set_custom_skin_order",
|
||||
"unequip_skin",
|
||||
"flush_pending_skin_change",
|
||||
"flush_pending_skin_change_for_profile",
|
||||
|
||||
@@ -14,6 +14,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
equip_skin,
|
||||
remove_custom_skin,
|
||||
save_custom_skin,
|
||||
set_custom_skin_order,
|
||||
unequip_skin,
|
||||
flush_pending_skin_change,
|
||||
flush_pending_skin_change_for_profile,
|
||||
@@ -91,6 +92,14 @@ pub async fn save_custom_skin(
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|set_custom_skin_order', texture_keys)`
|
||||
///
|
||||
/// See also: [minecraft_skins::set_custom_skin_order]
|
||||
#[tauri::command]
|
||||
pub async fn set_custom_skin_order(texture_keys: Vec<String>) -> Result<()> {
|
||||
Ok(minecraft_skins::set_custom_skin_order(texture_keys).await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|unequip_skin')`
|
||||
///
|
||||
/// See also: [minecraft_skins::unequip_skin]
|
||||
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE custom_minecraft_skins SET display_order = display_order + 1 WHERE minecraft_user_uuid = ? AND display_order >= ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "11875ae76d8a61099dff08e9792fd6f231ecffd45315ed2b76253d02bbd531ff"
|
||||
}
|
||||
Generated
+20
@@ -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"
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE custom_minecraft_skins SET display_order = display_order + 1 WHERE minecraft_user_uuid = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3538a56e37f456ca57998bd4753da27bff30801646436358acf57eb4cdfa25ad"
|
||||
}
|
||||
Generated
-12
@@ -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"
|
||||
}
|
||||
+9
-3
@@ -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', display_order FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? ORDER BY display_order ASC, rowid ASC LIMIT ? OFFSET ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -17,6 +17,11 @@
|
||||
"name": "cape_id: Hyphenated",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "display_order",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -25,8 +30,9 @@
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac"
|
||||
"hash": "57df2984dd65b408473ed1ae86b0215f46946f9b9043d4b037e3a4d5b27e0373"
|
||||
}
|
||||
Generated
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT texture_key FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? ORDER BY display_order ASC, rowid ASC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "texture_key",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "69c5a93676809ab6480fc41a2e6e34b16057db8b0eab08a9d8b8dce961c6f81c"
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id, display_order) VALUES (?, ?, ?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9a9b8b5c0b646b841b73461980b37ad3b03ecf99d1484c402694799dc08271f5"
|
||||
}
|
||||
+9
-3
@@ -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 = ? AND texture_key = ?",
|
||||
"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 = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -17,6 +17,11 @@
|
||||
"name": "cape_id: Hyphenated",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "display_order",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -25,8 +30,9 @@
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a0b0ff0ae4b88d5df9d15d3427ab4e9a6ff21cffdc9c2f3d6860e245949d313d"
|
||||
"hash": "a1217622558d50ee18e7ba0d85e991032037aaccfeca64dc076f0dcc826c108a"
|
||||
}
|
||||
Generated
+20
@@ -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"
|
||||
}
|
||||
Generated
+12
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
ALTER TABLE custom_minecraft_skins
|
||||
ADD COLUMN display_order INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
UPDATE custom_minecraft_skins
|
||||
SET display_order = (
|
||||
SELECT COUNT(*)
|
||||
FROM custom_minecraft_skins AS previous
|
||||
WHERE previous.minecraft_user_uuid = custom_minecraft_skins.minecraft_user_uuid
|
||||
AND previous.rowid <= custom_minecraft_skins.rowid
|
||||
) - 1;
|
||||
|
||||
CREATE INDEX custom_minecraft_skins_user_display_order
|
||||
ON custom_minecraft_skins (minecraft_user_uuid, display_order);
|
||||
@@ -65,7 +65,9 @@ use crate::{
|
||||
ErrorKind, State,
|
||||
state::{
|
||||
MinecraftCharacterExpressionState, MinecraftProfile,
|
||||
minecraft_skins::{CustomMinecraftSkin, mojang_api},
|
||||
minecraft_skins::{
|
||||
CustomMinecraftSkin, CustomMinecraftSkinInsertPosition, mojang_api,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -408,6 +410,7 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
|
||||
&texture_blob,
|
||||
custom_skin.variant,
|
||||
custom_skin.cape_id,
|
||||
CustomMinecraftSkinInsertPosition::Bottom,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
@@ -453,7 +456,6 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
|
||||
});
|
||||
}
|
||||
|
||||
custom_skins.sort_by(|a, b| a.texture.as_str().cmp(b.texture.as_str()));
|
||||
available_skins.extend(custom_skins);
|
||||
|
||||
for default_skin in assets::DEFAULT_SKINS.iter() {
|
||||
@@ -534,6 +536,7 @@ pub async fn add_and_equip_custom_skin(
|
||||
&texture_blob,
|
||||
variant,
|
||||
cape_id,
|
||||
CustomMinecraftSkinInsertPosition::Top,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
@@ -606,6 +609,21 @@ async fn add_and_equip_custom_skin_now(
|
||||
let equipped_skin = profile.current_skin()?;
|
||||
let equipped_skin_texture_key = equipped_skin.texture_key();
|
||||
let equipped_skin_variant = equipped_skin.variant;
|
||||
let insert_position = if local_texture_key
|
||||
!= equipped_skin_texture_key.as_ref()
|
||||
{
|
||||
CustomMinecraftSkin::get_by_texture(
|
||||
profile.id,
|
||||
local_texture_key,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.map_or(CustomMinecraftSkinInsertPosition::Top, |skin| {
|
||||
CustomMinecraftSkinInsertPosition::At(skin.display_order)
|
||||
})
|
||||
} else {
|
||||
CustomMinecraftSkinInsertPosition::Top
|
||||
};
|
||||
|
||||
let persistence_result = if cape_id.is_none()
|
||||
&& is_bundled_skin(&equipped_skin_texture_key, equipped_skin_variant)
|
||||
@@ -614,6 +632,7 @@ async fn add_and_equip_custom_skin_now(
|
||||
texture_key: equipped_skin_texture_key.to_string(),
|
||||
variant: equipped_skin_variant,
|
||||
cape_id: None,
|
||||
display_order: 0,
|
||||
}
|
||||
.remove(profile.id, &state.pool)
|
||||
.await
|
||||
@@ -624,6 +643,7 @@ async fn add_and_equip_custom_skin_now(
|
||||
&texture_blob,
|
||||
variant,
|
||||
cape_id,
|
||||
insert_position,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
@@ -639,6 +659,7 @@ async fn add_and_equip_custom_skin_now(
|
||||
texture_key: local_texture_key.to_string(),
|
||||
variant,
|
||||
cape_id,
|
||||
display_order: 0,
|
||||
}
|
||||
.remove(profile.id, &state.pool)
|
||||
.await?;
|
||||
@@ -741,6 +762,22 @@ async fn persist_equipped_skin(
|
||||
let equipped_skin_variant = equipped_skin.variant;
|
||||
let texture_key_changed =
|
||||
skin.texture_key.as_ref() != equipped_skin_texture_key.as_ref();
|
||||
let insert_position = if texture_key_changed {
|
||||
CustomMinecraftSkin::get_by_texture(
|
||||
profile.id,
|
||||
&skin.texture_key,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.map_or(
|
||||
CustomMinecraftSkinInsertPosition::Bottom,
|
||||
|saved_skin| {
|
||||
CustomMinecraftSkinInsertPosition::At(saved_skin.display_order)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
CustomMinecraftSkinInsertPosition::Bottom
|
||||
};
|
||||
|
||||
if skin.cape_id.is_none()
|
||||
&& is_bundled_skin(&equipped_skin_texture_key, equipped_skin_variant)
|
||||
@@ -749,6 +786,7 @@ async fn persist_equipped_skin(
|
||||
texture_key: equipped_skin_texture_key.to_string(),
|
||||
variant: equipped_skin_variant,
|
||||
cape_id: None,
|
||||
display_order: 0,
|
||||
}
|
||||
.remove(profile.id, &state.pool)
|
||||
.await?;
|
||||
@@ -760,6 +798,7 @@ async fn persist_equipped_skin(
|
||||
texture_blob,
|
||||
equipped_skin_variant,
|
||||
skin.cape_id,
|
||||
insert_position,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
@@ -770,6 +809,7 @@ async fn persist_equipped_skin(
|
||||
texture_key: skin.texture_key.to_string(),
|
||||
variant: skin.variant,
|
||||
cape_id: skin.cape_id,
|
||||
display_order: 0,
|
||||
}
|
||||
.remove(profile.id, &state.pool)
|
||||
.await?;
|
||||
@@ -796,6 +836,7 @@ pub async fn remove_custom_skin(skin: Skin) -> crate::Result<()> {
|
||||
texture_key: skin.texture_key.to_string(),
|
||||
variant: skin.variant,
|
||||
cape_id: skin.cape_id,
|
||||
display_order: 0,
|
||||
}
|
||||
.remove(selected_credentials.offline_profile.id, &state.pool)
|
||||
.await?;
|
||||
@@ -809,6 +850,25 @@ pub async fn remove_custom_skin(skin: Skin) -> crate::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persists the custom saved skin order for the currently selected Minecraft profile.
|
||||
#[tracing::instrument]
|
||||
pub async fn set_custom_skin_order(
|
||||
texture_keys: Vec<String>,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
|
||||
let selected_credentials = Credentials::get_default_credential(&state.pool)
|
||||
.await?
|
||||
.ok_or(ErrorKind::NoCredentialsError)?;
|
||||
|
||||
CustomMinecraftSkin::set_order(
|
||||
selected_credentials.offline_profile.id,
|
||||
&texture_keys,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Adds or updates a saved skin locally without applying it to Mojang.
|
||||
///
|
||||
/// This is used by the skin editor. If the edited skin is currently equipped, the caller should
|
||||
@@ -839,12 +899,26 @@ pub async fn save_custom_skin(
|
||||
Arc::clone(&skin.texture_key)
|
||||
};
|
||||
let cape_id = cape.map(|cape| cape.id);
|
||||
let insert_position = if replace_texture && old_texture_key != texture_key {
|
||||
CustomMinecraftSkin::get_by_texture(
|
||||
selected_credentials.offline_profile.id,
|
||||
&old_texture_key,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.map_or(CustomMinecraftSkinInsertPosition::Bottom, |skin| {
|
||||
CustomMinecraftSkinInsertPosition::At(skin.display_order)
|
||||
})
|
||||
} else {
|
||||
CustomMinecraftSkinInsertPosition::Bottom
|
||||
};
|
||||
|
||||
if cape_id.is_none() && is_bundled_skin(&texture_key, variant) {
|
||||
CustomMinecraftSkin {
|
||||
texture_key: texture_key.to_string(),
|
||||
variant,
|
||||
cape_id: None,
|
||||
display_order: 0,
|
||||
}
|
||||
.remove(selected_credentials.offline_profile.id, &state.pool)
|
||||
.await?;
|
||||
@@ -855,6 +929,7 @@ pub async fn save_custom_skin(
|
||||
&texture_blob,
|
||||
variant,
|
||||
cape_id,
|
||||
insert_position,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
@@ -865,6 +940,7 @@ pub async fn save_custom_skin(
|
||||
texture_key: old_texture_key.to_string(),
|
||||
variant: skin.variant,
|
||||
cape_id: skin.cape_id,
|
||||
display_order: 0,
|
||||
}
|
||||
.remove(selected_credentials.offline_profile.id, &state.pool)
|
||||
.await?;
|
||||
@@ -1210,6 +1286,7 @@ async fn preserve_current_profile_skin(
|
||||
&texture,
|
||||
current_skin.variant,
|
||||
current_cape_id,
|
||||
CustomMinecraftSkinInsertPosition::Bottom,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
@@ -1231,6 +1308,7 @@ async fn preserve_current_profile_skin(
|
||||
&texture,
|
||||
current_skin.variant,
|
||||
current_cape_id,
|
||||
CustomMinecraftSkinInsertPosition::Bottom,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use futures::{Stream, StreamExt, stream};
|
||||
use uuid::{Uuid, fmt::Hyphenated};
|
||||
|
||||
@@ -22,12 +24,22 @@ pub struct CustomMinecraftSkin {
|
||||
///
|
||||
/// If `None`, the skin is saved without a cape.
|
||||
pub cape_id: Option<Uuid>,
|
||||
/// The saved skin display order within this player's saved skins.
|
||||
pub display_order: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum CustomMinecraftSkinInsertPosition {
|
||||
Top,
|
||||
Bottom,
|
||||
At(i64),
|
||||
}
|
||||
|
||||
struct CustomMinecraftSkinRow {
|
||||
texture_key: String,
|
||||
variant: MinecraftSkinVariant,
|
||||
cape_id: Option<Hyphenated>,
|
||||
display_order: i64,
|
||||
}
|
||||
|
||||
impl CustomMinecraftSkin {
|
||||
@@ -37,6 +49,7 @@ impl CustomMinecraftSkin {
|
||||
texture: &[u8],
|
||||
variant: MinecraftSkinVariant,
|
||||
cape_id: Option<Uuid>,
|
||||
insert_position: CustomMinecraftSkinInsertPosition,
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
@@ -44,6 +57,51 @@ impl CustomMinecraftSkin {
|
||||
|
||||
let mut transaction = db.begin().await?;
|
||||
|
||||
let existing_order = sqlx::query_scalar!(
|
||||
"SELECT display_order FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
minecraft_user_id,
|
||||
texture_key
|
||||
)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
let display_order = match existing_order {
|
||||
Some(display_order) => display_order,
|
||||
None => match insert_position {
|
||||
CustomMinecraftSkinInsertPosition::Top => {
|
||||
sqlx::query!(
|
||||
"UPDATE custom_minecraft_skins SET display_order = display_order + 1 WHERE minecraft_user_uuid = ?",
|
||||
minecraft_user_id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
0
|
||||
}
|
||||
CustomMinecraftSkinInsertPosition::Bottom => {
|
||||
sqlx::query_scalar!(
|
||||
"SELECT COALESCE(MAX(display_order) + 1, 0) AS 'display_order!: i64' \
|
||||
FROM custom_minecraft_skins WHERE minecraft_user_uuid = ?",
|
||||
minecraft_user_id
|
||||
)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await?
|
||||
}
|
||||
CustomMinecraftSkinInsertPosition::At(display_order) => {
|
||||
sqlx::query!(
|
||||
"UPDATE custom_minecraft_skins SET display_order = display_order + 1 \
|
||||
WHERE minecraft_user_uuid = ? AND display_order >= ?",
|
||||
minecraft_user_id,
|
||||
display_order
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
display_order
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
minecraft_user_id,
|
||||
@@ -57,11 +115,11 @@ impl CustomMinecraftSkin {
|
||||
texture_key, texture
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)",
|
||||
minecraft_user_id, texture_key, variant, cape_id
|
||||
"INSERT INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id, display_order) VALUES (?, ?, ?, ?, ?)",
|
||||
minecraft_user_id, texture_key, variant, cape_id, display_order
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
@@ -80,7 +138,7 @@ impl CustomMinecraftSkin {
|
||||
|
||||
sqlx::query_as!(
|
||||
CustomMinecraftSkinRow,
|
||||
"SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' \
|
||||
"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 = ?",
|
||||
minecraft_user_id,
|
||||
@@ -93,6 +151,7 @@ impl CustomMinecraftSkin {
|
||||
texture_key: row.texture_key,
|
||||
variant: row.variant,
|
||||
cape_id: row.cape_id.map(Uuid::from),
|
||||
display_order: row.display_order,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
@@ -107,10 +166,10 @@ impl CustomMinecraftSkin {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
|
||||
Ok(stream::iter(sqlx::query!(
|
||||
"SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' \
|
||||
"SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated', display_order \
|
||||
FROM custom_minecraft_skins \
|
||||
WHERE minecraft_user_uuid = ? \
|
||||
ORDER BY rowid ASC \
|
||||
ORDER BY display_order ASC, rowid ASC \
|
||||
LIMIT ? OFFSET ?",
|
||||
minecraft_user_id, count, offset
|
||||
)
|
||||
@@ -120,6 +179,7 @@ impl CustomMinecraftSkin {
|
||||
texture_key: row.texture_key,
|
||||
variant: row.variant,
|
||||
cape_id: row.cape_id.map(Uuid::from),
|
||||
display_order: row.display_order,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -161,4 +221,62 @@ impl CustomMinecraftSkin {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_order(
|
||||
minecraft_user_id: Uuid,
|
||||
texture_keys: &[String],
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
let mut transaction = db.begin().await?;
|
||||
|
||||
let existing_rows = sqlx::query!(
|
||||
"SELECT texture_key FROM custom_minecraft_skins \
|
||||
WHERE minecraft_user_uuid = ? \
|
||||
ORDER BY display_order ASC, rowid ASC",
|
||||
minecraft_user_id
|
||||
)
|
||||
.fetch_all(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
let existing_keys = existing_rows
|
||||
.iter()
|
||||
.map(|row| row.texture_key.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
let mut seen_keys = HashSet::new();
|
||||
let mut ordered_keys = Vec::with_capacity(existing_rows.len());
|
||||
|
||||
for texture_key in texture_keys {
|
||||
if seen_keys.insert(texture_key.as_str())
|
||||
&& existing_keys.contains(texture_key.as_str())
|
||||
{
|
||||
ordered_keys.push(texture_key.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
for row in &existing_rows {
|
||||
if seen_keys.insert(row.texture_key.as_str()) {
|
||||
ordered_keys.push(row.texture_key.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
for (display_order, texture_key) in ordered_keys.into_iter().enumerate()
|
||||
{
|
||||
let display_order = display_order as i64;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE custom_minecraft_skins SET display_order = ? \
|
||||
WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
display_order,
|
||||
minecraft_user_id,
|
||||
texture_key
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ import _GitGraphIcon from './icons/git-graph.svg?component'
|
||||
import _GlassesIcon from './icons/glasses.svg?component'
|
||||
import _GlobeIcon from './icons/globe.svg?component'
|
||||
import _GridIcon from './icons/grid.svg?component'
|
||||
import _GripVerticalIcon from './icons/grip-vertical.svg?component'
|
||||
import _HamburgerIcon from './icons/hamburger.svg?component'
|
||||
import _HammerIcon from './icons/hammer.svg?component'
|
||||
import _HandHelpingIcon from './icons/hand-helping.svg?component'
|
||||
@@ -189,6 +190,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'
|
||||
@@ -555,6 +557,7 @@ export const GitGraphIcon = _GitGraphIcon
|
||||
export const GlassesIcon = _GlassesIcon
|
||||
export const GlobeIcon = _GlobeIcon
|
||||
export const GridIcon = _GridIcon
|
||||
export const GripVerticalIcon = _GripVerticalIcon
|
||||
export const HamburgerIcon = _HamburgerIcon
|
||||
export const HammerIcon = _HammerIcon
|
||||
export const HandHelpingIcon = _HandHelpingIcon
|
||||
@@ -609,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
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-grip-vertical"
|
||||
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"
|
||||
>
|
||||
<circle cx="9" cy="12" r="1" />
|
||||
<circle cx="9" cy="5" r="1" />
|
||||
<circle cx="9" cy="19" r="1" />
|
||||
<circle cx="15" cy="12" r="1" />
|
||||
<circle cx="15" cy="5" r="1" />
|
||||
<circle cx="15" cy="19" r="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 498 B |
@@ -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 |
@@ -9,28 +9,27 @@ const emit = defineEmits<{
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
forwardImageSrc?: string
|
||||
backwardImageSrc?: string
|
||||
selected: boolean
|
||||
active?: boolean
|
||||
tooltip?: string
|
||||
disabled?: boolean
|
||||
isDragging?: boolean
|
||||
}>(),
|
||||
{
|
||||
forwardImageSrc: undefined,
|
||||
backwardImageSrc: undefined,
|
||||
active: false,
|
||||
tooltip: undefined,
|
||||
disabled: false,
|
||||
isDragging: false,
|
||||
},
|
||||
)
|
||||
|
||||
const imagesLoaded = ref({
|
||||
forward: false,
|
||||
backward: false,
|
||||
})
|
||||
|
||||
function onImageLoad(type: 'forward' | 'backward') {
|
||||
imagesLoaded.value[type] = true
|
||||
function onImageLoad() {
|
||||
imagesLoaded.value.forward = true
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -39,13 +38,6 @@ watch(
|
||||
imagesLoaded.value.forward = false
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.backwardImageSrc,
|
||||
() => {
|
||||
imagesLoaded.value.backward = false
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -58,9 +50,17 @@ watch(
|
||||
{
|
||||
'skin-button--with-actions': $slots['overlay-buttons'] && !disabled,
|
||||
'skin-button--disabled': disabled,
|
||||
'skin-button--dragging': isDragging,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<span
|
||||
v-if="$slots['top-buttons']"
|
||||
class="pointer-events-none absolute right-3 top-3 z-30 flex items-center gap-1"
|
||||
>
|
||||
<slot name="top-buttons" />
|
||||
</span>
|
||||
|
||||
<button
|
||||
class="absolute inset-0 z-10 cursor-pointer border-none bg-transparent p-0 focus-visible:outline-none"
|
||||
:aria-label="tooltip ? `Select ${tooltip}` : 'Select skin'"
|
||||
@@ -70,19 +70,16 @@ watch(
|
||||
></button>
|
||||
|
||||
<span
|
||||
v-if="active && !selected"
|
||||
v-if="active && !selected && !$slots['top-buttons']"
|
||||
class="pointer-events-none absolute right-3 top-3 z-20 size-3 rounded-full border-2 border-solid border-surface-3 bg-green"
|
||||
></span>
|
||||
|
||||
<div
|
||||
v-if="!(imagesLoaded.forward && imagesLoaded.backward)"
|
||||
class="skeleton-loader h-full w-full"
|
||||
>
|
||||
<div v-if="!imagesLoaded.forward" class="skeleton-loader h-full w-full">
|
||||
<div class="skeleton absolute inset-0 aspect-[5/7]"></div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-show="imagesLoaded.forward && imagesLoaded.backward"
|
||||
v-show="imagesLoaded.forward"
|
||||
:key="`${selected}-${active}`"
|
||||
:class="[
|
||||
'skin-button__image-parent pointer-events-none relative z-0 mb-[1.5px] grid place-items-stretch with-shadow',
|
||||
@@ -93,14 +90,7 @@ watch(
|
||||
:src="forwardImageSrc"
|
||||
class="skin-button__image-facing col-start-1 row-start-1 h-full w-full object-contain"
|
||||
height="504"
|
||||
@load="onImageLoad('forward')"
|
||||
/>
|
||||
<img
|
||||
alt=""
|
||||
:src="backwardImageSrc"
|
||||
class="skin-button__image-away col-start-1 row-start-1 h-full w-full object-contain"
|
||||
height="504"
|
||||
@load="onImageLoad('backward')"
|
||||
@load="onImageLoad"
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -195,30 +185,17 @@ watch(
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.skin-button--dragging {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.skin-button__image-parent {
|
||||
width: 100%;
|
||||
height: 95%;
|
||||
transform: rotateY(0deg) translateZ(0);
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 500ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: transform;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.skin-button:not(.skin-button--disabled):hover .skin-button__image-parent {
|
||||
transform: rotateY(180deg) translateZ(0);
|
||||
}
|
||||
|
||||
.skin-button__image-facing,
|
||||
.skin-button__image-away {
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
transform: translateZ(0.1px);
|
||||
}
|
||||
|
||||
.skin-button__image-away {
|
||||
transform: rotateY(180deg) translateZ(0.1px);
|
||||
.skin-button__image-facing {
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.with-shadow img {
|
||||
|
||||
@@ -21,25 +21,11 @@ const frontImage = `data:image/svg+xml,${encodeURIComponent(`
|
||||
</svg>
|
||||
)}`)}`
|
||||
|
||||
const backImage = `data:image/svg+xml,${encodeURIComponent(`
|
||||
<svg width="114" height="176" viewBox="0 0 114 176" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="36" y="10" width="42" height="46" fill="#5A3828"/>
|
||||
<rect x="28" y="57" width="58" height="60" fill="#1E7FAC"/>
|
||||
<rect x="18" y="63" width="18" height="54" fill="#2693C7"/>
|
||||
<rect x="78" y="63" width="18" height="54" fill="#19759E"/>
|
||||
<rect x="22" y="117" width="14" height="43" fill="#A96A4D"/>
|
||||
<rect x="78" y="117" width="14" height="43" fill="#A96A4D"/>
|
||||
<rect x="36" y="117" width="18" height="55" fill="#1D334F"/>
|
||||
<rect x="60" y="117" width="18" height="55" fill="#162B45"/>
|
||||
</svg>
|
||||
)}`)}`
|
||||
|
||||
const meta = {
|
||||
title: 'Skin/SkinButton',
|
||||
component: SkinButton,
|
||||
args: {
|
||||
forwardImageSrc: frontImage,
|
||||
backwardImageSrc: backImage,
|
||||
selected: false,
|
||||
active: false,
|
||||
tooltip: 'Steve',
|
||||
|
||||
Generated
+22
-4
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user