mirror of
https://github.com/modrinth/code.git
synced 2026-09-03 13:36:48 +00:00
feat: implement drag to reorder skins
This commit is contained in:
@@ -44,7 +44,8 @@
|
|||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-i18n": "^10.0.0",
|
"vue-i18n": "^10.0.0",
|
||||||
"vue-router": "^4.6.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": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.1.1",
|
"@eslint/compat": "^1.1.1",
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { DropdownIcon, EditIcon, PlusIcon, TrashIcon, UnknownIcon } from '@modrinth/assets'
|
import {
|
||||||
|
DropdownIcon,
|
||||||
|
EditIcon,
|
||||||
|
MoveIcon,
|
||||||
|
PlusIcon,
|
||||||
|
TrashIcon,
|
||||||
|
UnknownIcon,
|
||||||
|
} from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
@@ -13,6 +20,7 @@ import {
|
|||||||
import { useElementSize, useWindowSize } from '@vueuse/core'
|
import { useElementSize, useWindowSize } from '@vueuse/core'
|
||||||
import { Tooltip } from 'floating-vue'
|
import { Tooltip } from 'floating-vue'
|
||||||
import { computed, nextTick, onUnmounted, ref, useTemplateRef, watch } from '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 { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||||
import type { Skin } from '@/helpers/skins.ts'
|
import type { Skin } from '@/helpers/skins.ts'
|
||||||
@@ -73,6 +81,10 @@ const messages = defineMessages({
|
|||||||
id: 'app.skins.delete-button',
|
id: 'app.skins.delete-button',
|
||||||
defaultMessage: 'Delete skin',
|
defaultMessage: 'Delete skin',
|
||||||
},
|
},
|
||||||
|
reorderSkinButton: {
|
||||||
|
id: 'app.skins.reorder-button',
|
||||||
|
defaultMessage: 'Reorder skin',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -88,6 +100,7 @@ const emit = defineEmits<{
|
|||||||
select: [skin: Skin]
|
select: [skin: Skin]
|
||||||
edit: [skin: Skin, event: MouseEvent]
|
edit: [skin: Skin, event: MouseEvent]
|
||||||
delete: [skin: Skin]
|
delete: [skin: Skin]
|
||||||
|
'reorder-saved-skins': [skins: Skin[]]
|
||||||
'add-skin': []
|
'add-skin': []
|
||||||
'add-skin-dragenter': [event: DragEvent]
|
'add-skin-dragenter': [event: DragEvent]
|
||||||
'add-skin-dragover': [event: DragEvent]
|
'add-skin-dragover': [event: DragEvent]
|
||||||
@@ -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 sectionLayouts = computed(() => {
|
||||||
const layouts: Array<{ section: SkinSection; top: number; height: number; index: number }> = []
|
const layouts: Array<{ section: SkinSection; top: number; height: number; index: number }> = []
|
||||||
let top = 0
|
let top = 0
|
||||||
@@ -209,6 +226,18 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.savedSkins,
|
||||||
|
(nextSkins) => {
|
||||||
|
if (isDraggingSavedSkin.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
draggableSavedSkins.value = [...nextSkins]
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
listWidth,
|
listWidth,
|
||||||
(width) => {
|
(width) => {
|
||||||
@@ -257,6 +286,32 @@ function skinKey(skin: Skin, prefix: string) {
|
|||||||
return `${prefix}-${skin.source}-${skin.texture_key}-${skin.variant}-${skin.cape_id ?? 'no-cape'}`
|
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) {
|
function isSectionOpen(key: string) {
|
||||||
return openSectionKeys.value.has(key)
|
return openSectionKeys.value.has(key)
|
||||||
}
|
}
|
||||||
@@ -354,61 +409,93 @@ defineExpose({ getAddSkinButtonElement })
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div
|
<Draggable
|
||||||
v-if="section.kind === 'saved'"
|
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"
|
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
|
<template #header>
|
||||||
ref="addSkinButton"
|
<SkinLikeTextButton
|
||||||
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
ref="addSkinButton"
|
||||||
dropzone
|
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
||||||
:drag-active="isAddSkinButtonDragActive"
|
dropzone
|
||||||
@click="emit('add-skin')"
|
:drag-active="isAddSkinButtonDragActive"
|
||||||
@dragenter="emit('add-skin-dragenter', $event)"
|
@click="emit('add-skin')"
|
||||||
@dragover="emit('add-skin-dragover', $event)"
|
@dragenter="emit('add-skin-dragenter', $event)"
|
||||||
@dragleave="emit('add-skin-dragleave', $event)"
|
@dragover="emit('add-skin-dragover', $event)"
|
||||||
@drop="emit('add-skin-drop', $event)"
|
@dragleave="emit('add-skin-dragleave', $event)"
|
||||||
>
|
@drop="emit('add-skin-drop', $event)"
|
||||||
<template #icon>
|
>
|
||||||
<PlusIcon class="size-8" />
|
<template #icon>
|
||||||
</template>
|
<PlusIcon class="size-8" />
|
||||||
{{ formatMessage(messages.addSkinButton) }}
|
</template>
|
||||||
<template #subtitle>{{ formatMessage(messages.dragAndDropSubtitle) }}</template>
|
{{ formatMessage(messages.addSkinButton) }}
|
||||||
</SkinLikeTextButton>
|
<template #subtitle>{{ formatMessage(messages.dragAndDropSubtitle) }}</template>
|
||||||
|
</SkinLikeTextButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
<SkinButton
|
<template #item="{ element: skin }">
|
||||||
v-for="skin in section.skins"
|
<div
|
||||||
:key="skinKey(skin, 'saved-skin')"
|
:key="savedSkinKey(skin)"
|
||||||
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
class="relative aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
||||||
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
>
|
||||||
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
|
<SkinButton
|
||||||
:selected="isSkinSelected(skin)"
|
class="h-full w-full min-w-0 box-border rounded-[20px]"
|
||||||
:active="isSkinActive(skin)"
|
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
||||||
@select="emit('select', skin)"
|
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
|
||||||
>
|
:selected="isSkinSelected(skin)"
|
||||||
<template #overlay-buttons>
|
:active="isSkinActive(skin)"
|
||||||
<ButtonStyled color="brand">
|
@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
|
<button
|
||||||
:aria-label="formatMessage(messages.editSkinButton)"
|
v-tooltip="formatMessage(messages.reorderSkinButton)"
|
||||||
class="pointer-events-auto"
|
:aria-label="formatMessage(messages.reorderSkinButton)"
|
||||||
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
|
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>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled v-show="!skin.is_equipped" circular color="red">
|
</div>
|
||||||
<button
|
</template>
|
||||||
v-tooltip="formatMessage(messages.deleteSkinButton)"
|
</Draggable>
|
||||||
:aria-label="formatMessage(messages.deleteSkinButton)"
|
|
||||||
class="!rounded-[100%] pointer-events-auto"
|
|
||||||
@click.stop="emit('delete', skin)"
|
|
||||||
>
|
|
||||||
<TrashIcon />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</template>
|
|
||||||
</SkinButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
@@ -442,3 +529,18 @@ defineExpose({ getAddSkinButtonElement })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
@@ -142,6 +142,12 @@ export async function remove_custom_skin(skin: Skin): Promise<void> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reorder_custom_skins(skins: Skin[]): Promise<void> {
|
||||||
|
await invoke('plugin:minecraft-skins|reorder_custom_skins', {
|
||||||
|
skins,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function save_custom_skin(
|
export async function save_custom_skin(
|
||||||
skin: Skin,
|
skin: Skin,
|
||||||
textureBlob: Uint8Array,
|
textureBlob: Uint8Array,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
get_normalized_skin_texture,
|
get_normalized_skin_texture,
|
||||||
normalize_skin_texture,
|
normalize_skin_texture,
|
||||||
remove_custom_skin,
|
remove_custom_skin,
|
||||||
|
reorder_custom_skins,
|
||||||
} from '@/helpers/skins.ts'
|
} from '@/helpers/skins.ts'
|
||||||
import { hasPride26Badge } from '@/helpers/user-campaigns.ts'
|
import { hasPride26Badge } from '@/helpers/user-campaigns.ts'
|
||||||
import { handleSevereError } from '@/store/error'
|
import { handleSevereError } from '@/store/error'
|
||||||
@@ -129,6 +130,14 @@ const messages = defineMessages({
|
|||||||
id: 'app.skins.dropped-file-error.text',
|
id: 'app.skins.dropped-file-error.text',
|
||||||
defaultMessage: 'Failed to read the dropped file.',
|
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: {
|
deleteSkinTitle: {
|
||||||
id: 'app.skins.delete-modal.title',
|
id: 'app.skins.delete-modal.title',
|
||||||
defaultMessage: 'Are you sure you want to delete this skin?',
|
defaultMessage: 'Are you sure you want to delete this skin?',
|
||||||
@@ -423,6 +432,19 @@ function setLocallyEquippedSkin(skinToApply: Skin) {
|
|||||||
void accountsCard.value?.setEquippedSkin(originalSelectedSkin.value)
|
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) {
|
function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin) {
|
||||||
let foundSkin = false
|
let foundSkin = false
|
||||||
const replacesSelectedSkin =
|
const replacesSelectedSkin =
|
||||||
@@ -451,7 +473,7 @@ function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin)
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!foundSkin) {
|
if (!foundSkin) {
|
||||||
skins.value.unshift({
|
insertLocalSkin({
|
||||||
...savedSkin,
|
...savedSkin,
|
||||||
is_equipped: applied || savedSkin.is_equipped,
|
is_equipped: applied || savedSkin.is_equipped,
|
||||||
})
|
})
|
||||||
@@ -480,6 +502,33 @@ function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin)
|
|||||||
generateSkinPreviews(skins.value, capes.value)
|
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 reorder_custom_skins(nextSavedSkins)
|
||||||
|
} 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() {
|
function schedulePendingSkinRefresh() {
|
||||||
if (pendingSkinRefreshTimeout !== null) {
|
if (pendingSkinRefreshTimeout !== null) {
|
||||||
window.clearTimeout(pendingSkinRefreshTimeout)
|
window.clearTimeout(pendingSkinRefreshTimeout)
|
||||||
@@ -876,6 +925,7 @@ await loadSkins()
|
|||||||
@select="changeSkin"
|
@select="changeSkin"
|
||||||
@edit="(skin, event) => editSkinModal?.show(event, skin)"
|
@edit="(skin, event) => editSkinModal?.show(event, skin)"
|
||||||
@delete="confirmDeleteSkin"
|
@delete="confirmDeleteSkin"
|
||||||
|
@reorder-saved-skins="reorderSavedSkins"
|
||||||
@add-skin="openAddSkinFileBrowser"
|
@add-skin="openAddSkinFileBrowser"
|
||||||
@add-skin-dragenter="onAddSkinDragOver"
|
@add-skin-dragenter="onAddSkinDragOver"
|
||||||
@add-skin-dragover="onAddSkinDragOver"
|
@add-skin-dragover="onAddSkinDragOver"
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ fn main() {
|
|||||||
"add_and_equip_custom_skin",
|
"add_and_equip_custom_skin",
|
||||||
"equip_skin",
|
"equip_skin",
|
||||||
"remove_custom_skin",
|
"remove_custom_skin",
|
||||||
|
"reorder_custom_skins",
|
||||||
"save_custom_skin",
|
"save_custom_skin",
|
||||||
"unequip_skin",
|
"unequip_skin",
|
||||||
"flush_pending_skin_change",
|
"flush_pending_skin_change",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
|||||||
add_and_equip_custom_skin,
|
add_and_equip_custom_skin,
|
||||||
equip_skin,
|
equip_skin,
|
||||||
remove_custom_skin,
|
remove_custom_skin,
|
||||||
|
reorder_custom_skins,
|
||||||
save_custom_skin,
|
save_custom_skin,
|
||||||
unequip_skin,
|
unequip_skin,
|
||||||
flush_pending_skin_change,
|
flush_pending_skin_change,
|
||||||
@@ -70,6 +71,14 @@ pub async fn remove_custom_skin(skin: Skin) -> Result<()> {
|
|||||||
Ok(minecraft_skins::remove_custom_skin(skin).await?)
|
Ok(minecraft_skins::remove_custom_skin(skin).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `invoke('plugin:minecraft-skins|reorder_custom_skins', skins)`
|
||||||
|
///
|
||||||
|
/// See also: [minecraft_skins::reorder_custom_skins]
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn reorder_custom_skins(skins: Vec<Skin>) -> Result<()> {
|
||||||
|
Ok(minecraft_skins::reorder_custom_skins(skins).await?)
|
||||||
|
}
|
||||||
|
|
||||||
/// `invoke('plugin:minecraft-skins|save_custom_skin', skin, texture_blob, variant, cape, replace_texture)`
|
/// `invoke('plugin:minecraft-skins|save_custom_skin', skin, texture_blob, variant, cape, replace_texture)`
|
||||||
///
|
///
|
||||||
/// See also: [minecraft_skins::save_custom_skin]
|
/// See also: [minecraft_skins::save_custom_skin]
|
||||||
|
|||||||
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
@@ -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"
|
|
||||||
}
|
|
||||||
Generated
+12
@@ -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"
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -28,5 +28,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac"
|
"hash": "e00fdeb5d19f4d836dd62d8ef400205d5d9fd63e23994c8c48f0721b49a79594"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE custom_minecraft_skins
|
||||||
|
ADD COLUMN display_order INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
UPDATE custom_minecraft_skins
|
||||||
|
SET display_order = rowid;
|
||||||
@@ -445,7 +445,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);
|
available_skins.extend(custom_skins);
|
||||||
|
|
||||||
for default_skin in assets::DEFAULT_SKINS.iter() {
|
for default_skin in assets::DEFAULT_SKINS.iter() {
|
||||||
@@ -801,6 +800,55 @@ pub async fn remove_custom_skin(skin: Skin) -> crate::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reorders saved skins for the currently selected Minecraft profile.
|
||||||
|
#[tracing::instrument(skip(skins))]
|
||||||
|
pub async fn reorder_custom_skins(skins: Vec<Skin>) -> crate::Result<()> {
|
||||||
|
let state = State::get().await?;
|
||||||
|
|
||||||
|
let selected_credentials = Credentials::get_default_credential(&state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or(ErrorKind::NoCredentialsError)?;
|
||||||
|
let profile_id = selected_credentials.offline_profile.id;
|
||||||
|
|
||||||
|
for skin in skins.iter() {
|
||||||
|
if !matches!(skin.source, SkinSource::CustomExternal) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let texture_blob = png_util::url_to_data_stream(&skin.texture)
|
||||||
|
.await?
|
||||||
|
.try_fold(Vec::new(), |mut texture, chunk| async move {
|
||||||
|
texture.extend_from_slice(&chunk);
|
||||||
|
Ok(texture)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
CustomMinecraftSkin::add(
|
||||||
|
profile_id,
|
||||||
|
&skin.texture_key,
|
||||||
|
&texture_blob,
|
||||||
|
skin.variant,
|
||||||
|
skin.cape_id,
|
||||||
|
&state.pool,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let texture_keys = skins
|
||||||
|
.iter()
|
||||||
|
.map(|skin| skin.texture_key.to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
CustomMinecraftSkin::reorder(
|
||||||
|
profile_id,
|
||||||
|
&texture_keys,
|
||||||
|
&state.pool,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Adds or updates a saved skin locally without applying it to Mojang.
|
/// 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
|
/// This is used by the skin editor. If the edited skin is currently equipped, the caller should
|
||||||
|
|||||||
@@ -44,6 +44,23 @@ impl CustomMinecraftSkin {
|
|||||||
|
|
||||||
let mut transaction = db.begin().await?;
|
let mut transaction = db.begin().await?;
|
||||||
|
|
||||||
|
let display_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 display_order {
|
||||||
|
Some(display_order) => display_order,
|
||||||
|
None => 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?,
|
||||||
|
};
|
||||||
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
"DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||||
minecraft_user_id,
|
minecraft_user_id,
|
||||||
@@ -60,8 +77,8 @@ impl CustomMinecraftSkin {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)",
|
"INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id, display_order) VALUES (?, ?, ?, ?, ?)",
|
||||||
minecraft_user_id, texture_key, variant, cape_id
|
minecraft_user_id, texture_key, variant, cape_id, display_order
|
||||||
)
|
)
|
||||||
.execute(&mut *transaction)
|
.execute(&mut *transaction)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -106,13 +123,16 @@ impl CustomMinecraftSkin {
|
|||||||
) -> crate::Result<impl Stream<Item = Self>> {
|
) -> crate::Result<impl Stream<Item = Self>> {
|
||||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||||
|
|
||||||
Ok(stream::iter(sqlx::query!(
|
Ok(stream::iter(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' \
|
||||||
FROM custom_minecraft_skins \
|
FROM custom_minecraft_skins \
|
||||||
WHERE minecraft_user_uuid = ? \
|
WHERE minecraft_user_uuid = ? \
|
||||||
ORDER BY rowid ASC \
|
ORDER BY display_order ASC, rowid ASC \
|
||||||
LIMIT ? OFFSET ?",
|
LIMIT ? OFFSET ?",
|
||||||
minecraft_user_id, count, offset
|
minecraft_user_id,
|
||||||
|
count,
|
||||||
|
offset
|
||||||
)
|
)
|
||||||
.fetch_all(&mut *db.acquire().await?)
|
.fetch_all(&mut *db.acquire().await?)
|
||||||
.await?)
|
.await?)
|
||||||
@@ -161,4 +181,32 @@ impl CustomMinecraftSkin {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn reorder(
|
||||||
|
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?;
|
||||||
|
|
||||||
|
for (display_order, texture_key) in texture_keys.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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
|
|
||||||
import type { FunctionalComponent, SVGAttributes } from 'vue'
|
import type { FunctionalComponent, SVGAttributes } from 'vue'
|
||||||
|
|
||||||
export type IconComponent = FunctionalComponent<SVGAttributes>
|
|
||||||
|
|
||||||
import _AffiliateIcon from './icons/affiliate.svg?component'
|
import _AffiliateIcon from './icons/affiliate.svg?component'
|
||||||
import _AlignLeftIcon from './icons/align-left.svg?component'
|
import _AlignLeftIcon from './icons/align-left.svg?component'
|
||||||
import _ArchiveIcon from './icons/archive.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 _MoonIcon from './icons/moon.svg?component'
|
||||||
import _MoreHorizontalIcon from './icons/more-horizontal.svg?component'
|
import _MoreHorizontalIcon from './icons/more-horizontal.svg?component'
|
||||||
import _MoreVerticalIcon from './icons/more-vertical.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 _NewspaperIcon from './icons/newspaper.svg?component'
|
||||||
import _NoSignalIcon from './icons/no-signal.svg?component'
|
import _NoSignalIcon from './icons/no-signal.svg?component'
|
||||||
import _NotepadTextIcon from './icons/notepad-text.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 _ZoomInIcon from './icons/zoom-in.svg?component'
|
||||||
import _ZoomOutIcon from './icons/zoom-out.svg?component'
|
import _ZoomOutIcon from './icons/zoom-out.svg?component'
|
||||||
|
|
||||||
|
export type IconComponent = FunctionalComponent<SVGAttributes>
|
||||||
|
|
||||||
export const AffiliateIcon = _AffiliateIcon
|
export const AffiliateIcon = _AffiliateIcon
|
||||||
export const AlignLeftIcon = _AlignLeftIcon
|
export const AlignLeftIcon = _AlignLeftIcon
|
||||||
export const ArchiveIcon = _ArchiveIcon
|
export const ArchiveIcon = _ArchiveIcon
|
||||||
@@ -609,6 +610,7 @@ export const MonitorSmartphoneIcon = _MonitorSmartphoneIcon
|
|||||||
export const MoonIcon = _MoonIcon
|
export const MoonIcon = _MoonIcon
|
||||||
export const MoreHorizontalIcon = _MoreHorizontalIcon
|
export const MoreHorizontalIcon = _MoreHorizontalIcon
|
||||||
export const MoreVerticalIcon = _MoreVerticalIcon
|
export const MoreVerticalIcon = _MoreVerticalIcon
|
||||||
|
export const MoveIcon = _MoveIcon
|
||||||
export const NewspaperIcon = _NewspaperIcon
|
export const NewspaperIcon = _NewspaperIcon
|
||||||
export const NoSignalIcon = _NoSignalIcon
|
export const NoSignalIcon = _NoSignalIcon
|
||||||
export const NotepadTextIcon = _NotepadTextIcon
|
export const NotepadTextIcon = _NotepadTextIcon
|
||||||
|
|||||||
@@ -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 |
Generated
+18
@@ -170,6 +170,9 @@ importers:
|
|||||||
vue-virtual-scroller:
|
vue-virtual-scroller:
|
||||||
specifier: v2.0.0-beta.8
|
specifier: v2.0.0-beta.8
|
||||||
version: 2.0.0-beta.8(vue@3.5.27(typescript@5.9.3))
|
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:
|
devDependencies:
|
||||||
'@eslint/compat':
|
'@eslint/compat':
|
||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
@@ -8799,6 +8802,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==}
|
resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
|
sortablejs@1.14.0:
|
||||||
|
resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==}
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -9906,6 +9912,11 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
vuedraggable@4.1.0:
|
||||||
|
resolution: {integrity: sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.0.1
|
||||||
|
|
||||||
w3c-keyname@2.2.8:
|
w3c-keyname@2.2.8:
|
||||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||||
|
|
||||||
@@ -19299,6 +19310,8 @@ snapshots:
|
|||||||
|
|
||||||
smol-toml@1.6.0: {}
|
smol-toml@1.6.0: {}
|
||||||
|
|
||||||
|
sortablejs@1.14.0: {}
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
source-map-support@0.5.21:
|
source-map-support@0.5.21:
|
||||||
@@ -20415,6 +20428,11 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
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: {}
|
w3c-keyname@2.2.8: {}
|
||||||
|
|
||||||
web-namespaces@2.0.1: {}
|
web-namespaces@2.0.1: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user