polish: drag and drop file flow

This commit is contained in:
Calum H. authored and Alejandro González committed 2025-06-14 17:45:05 +02:00
1 parent 673b9733b3
commit beb2079854
4 files changed
+174 -30

No files matched your search

@@ -26,7 +26,7 @@
<div class="flex flex-col gap-4 w-full min-h-[20rem]">
<section>
<h2 class="text-base font-semibold mb-2">Texture</h2>
<Button>
<Button @click="openUploadSkinModal">
<UploadIcon /> Replace texture
</Button>
</section>
@@ -85,9 +85,6 @@
</Button>
</ButtonStyled>
<Button @click="hide"><XIcon/>Cancel</Button>
<ButtonStyled v-if="mode==='edit'" color="red">
<Button @click="handleDelete"><TrashIcon/>Delete</Button>
</ButtonStyled>
</div>
</NewModal>
@@ -231,19 +228,35 @@ function show(e: MouseEvent, skin?: Skin) {
modal.value?.show(e)
}
function showNew(e: MouseEvent, skinTexture: Uint8Array, filename: string) {
mode.value = 'new'
currentSkin.value = null
fileUploadTextureBlob.value = skinTexture
fileName.value = filename
variant.value = 'CLASSIC'
selectedCape.value = undefined
visibleCapeList.value = []
initVisibleCapeList()
modal.value?.show(e)
}
function restoreWithNewTexture(skinTexture: Uint8Array, filename: string) {
fileUploadTextureBlob.value = skinTexture
fileName.value = filename
if (shouldRestoreModal.value) {
setTimeout(() => {
modal.value?.show()
shouldRestoreModal.value = false
}, 0)
}
}
function hide() {
modal.value?.hide()
setTimeout(() => resetState(), 250)
}
async function onTextureSelected(files: FileList|null) {
if (!files?.length) return
const file = files[0]
fileName.value = file.name
const buf = await file.arrayBuffer()
fileUploadTextureBlob.value = new Uint8Array(buf)
}
function selectCape(cape: Cape|undefined) {
if (cape && selectedCape.value?.id !== cape.id) {
const isInVisibleList = visibleCapeList.value.some(c => c.id === cape.id)
@@ -298,17 +311,10 @@ function openSelectCapeModal(e: MouseEvent) {
}, 0)
}
async function handleDelete() {
try {
if (currentSkin.value?.is_equipped) {
await unequip_skin()
}
await remove_custom_skin(currentSkin.value!)
emit('deleted', currentSkin.value!)
hide()
} catch (err) {
handleError(err)
}
function openUploadSkinModal(e: MouseEvent) {
shouldRestoreModal.value = true
modal.value?.hide()
emit('open-upload-modal', e)
}
async function save() {
@@ -345,9 +351,16 @@ watch(() => props.capes, () => {
}, { immediate: true })
const emit = defineEmits<{
(e:'saved'):void
(e:'deleted', skin: Skin):void
(event: 'saved'): void
(event: 'deleted', skin: Skin): void
(event: 'open-upload-modal', mouseEvent: MouseEvent): void
}>()
defineExpose({ show, hide })
defineExpose({
show,
showNew,
restoreWithNewTexture,
hide,
shouldRestoreModal
})
</script>
@@ -0,0 +1,101 @@
<template>
<NewModal ref="modal" @on-hide="hide(true)">
<template #title>
<span class="text-lg font-extrabold text-contrast">
Upload skin texture
</span>
</template>
<div class="relative">
<div
class="border-2 border-dashed border-highlight-gray rounded-xl h-[173px] flex flex-col items-center justify-center p-8 cursor-pointer bg-button-bg hover:bg-button-hover transition-colors relative"
@click="triggerFileInput"
@drop.prevent="handleFileOperation"
@dragover.prevent
>
<p class="mx-auto mb-0 text-primary text-xl text-center flex items-center gap-2"><UploadIcon /> Select skin texture file</p>
<p class="mx-auto mt-0 text-secondary text-sm text-center">Drag and drop or click here to browse</p>
<p class="mx-auto mt-0 text-secondary text-xs text-center">Only 64x64 PNG files are accepted</p>
<input ref="fileInput" type="file" accept="image/png" class="hidden" @change="handleFileOperation" />
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { UploadIcon } from '@modrinth/assets'
import { useNotifications } from "@/store/state"
import { NewModal } from '@modrinth/ui'
const notifications = useNotifications()
const modal = ref()
const fileInput = ref<HTMLInputElement>()
const emit = defineEmits<{
(e: 'uploaded', file: File): void
(e: 'canceled'): void
}>()
function show(e?: MouseEvent) {
modal.value?.show(e)
}
function hide(emitCanceled = false) {
modal.value?.hide()
resetState()
if (emitCanceled) {
emit('canceled')
}
}
function resetState() {
if (fileInput.value) fileInput.value.value = ''
}
function triggerFileInput() {
fileInput.value?.click()
}
async function validateImageDimensions(file: File): Promise<boolean> {
return new Promise((resolve) => {
const img = new Image()
img.onload = () => {
URL.revokeObjectURL(img.src)
resolve(img.width === 64 && img.height === 64)
}
img.onerror = () => {
URL.revokeObjectURL(img.src)
resolve(false)
}
img.src = URL.createObjectURL(file)
})
}
async function handleFileOperation(e: Event | DragEvent) {
// Get files from either drag event or file input
const files = (e as DragEvent).dataTransfer?.files || (e.target as HTMLInputElement).files;
if (!files || files.length === 0) {
return;
}
const file = files[0];
if (file.type !== 'image/png') {
notifications.addNotification({
title: 'Invalid file type.',
text: 'Only PNG files are accepted.',
type: 'error',
});
return;
}
const isValidDimensions = await validateImageDimensions(file);
if (!isValidDimensions) {
notifications.addNotification({
title: 'Invalid dimensions.',
text: 'Only 64x64 PNG files are accepted.',
type: 'error',
});
return;
}
emit('uploaded', file);
hide();
}
defineExpose({ show, hide })
</script>
+33 -3
View File
@@ -9,9 +9,10 @@ import {
ConfirmModal
} from '@modrinth/ui'
import type { Ref } from 'vue';
import { ref, computed, useTemplateRef, watch, onMounted, onUnmounted, inject, nextTick } from 'vue'
import { ref, computed, useTemplateRef, watch, onMounted, onUnmounted, inject } from 'vue'
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
import { handleError } from '@/store/notifications'
import {
get_available_skins,
@@ -33,13 +34,13 @@ import type AccountsCard from "@/components/ui/AccountsCard.vue";
const editSkinModal = useTemplateRef('editSkinModal')
const selectCapeModal = useTemplateRef('selectCapeModal')
const uploadSkinModal = useTemplateRef('uploadSkinModal')
const settings = ref(await getSettings())
const skins = ref<Skin[]>([])
const capes = ref<Cape[]>([])
const accountsCard = inject('accountsCard') as Ref<typeof AccountsCard>
const loginInProgress = ref(false);
const currentUser = ref(undefined)
const currentUserId = ref<string | undefined>(undefined)
@@ -151,6 +152,33 @@ async function login() {
accountsCard.value.setLoginDisabled(false);
}
function openUploadSkinModal(e: MouseEvent) {
uploadSkinModal.value?.show(e)
}
function onSkinFileUploaded(file: File) {
const fakeEvent = new MouseEvent('click')
file.arrayBuffer().then(buf => {
const skinTexture = new Uint8Array(buf)
if (editSkinModal.value && editSkinModal.value.shouldRestoreModal) {
editSkinModal.value.restoreWithNewTexture(skinTexture, file.name)
} else {
editSkinModal.value?.showNew(fakeEvent, skinTexture, file.name)
}
})
}
function onUploadCanceled() {
if (editSkinModal.value && editSkinModal.value.shouldRestoreModal) {
setTimeout(() => {
const fakeEvent = new MouseEvent('click')
editSkinModal.value!.show(fakeEvent)
editSkinModal.value!.shouldRestoreModal = false
}, 0)
}
}
watch(
() => selectedSkin.value?.cape_id,
() => {}
@@ -190,8 +218,10 @@ await Promise.all([loadCapes(), loadSkins(), loadCurrentUser()])
:capes="capes"
@saved="onSkinSaved"
@deleted="() => loadSkins()"
@open-upload-modal="openUploadSkinModal"
/>
<SelectCapeModal ref="selectCapeModal" :capes="capes" @select="handleCapeSelected" />
<UploadSkinModal ref="uploadSkinModal" @uploaded="onSkinFileUploaded" @canceled="onUploadCanceled" />
<ConfirmModal
ref="deleteSkinModal"
title="Are you sure you want to delete this skin?"
@@ -248,7 +278,7 @@ await Promise.all([loadCapes(), loadSkins(), loadCurrentUser()])
<SkinLikeTextButton
class="skin-card"
tooltip="Add a skin"
@click="editSkinModal?.show"
@click="openUploadSkinModal"
>
<template #icon>
<PlusIcon class="w-5 h-5 stroke-2" />
@@ -56,7 +56,7 @@ withDefaults(
@supports (background-color: color-mix(in srgb, transparent, transparent)) {
.highlighted-glow::before {
box-shadow: inset 0 0 2px 4px color-mix(in srgb, var(--color-brand), transparent 10%);
box-shadow: inset 0 0 2px 2px color-mix(in srgb, var(--color-brand), transparent 10%);
}
}
</style>