polish: drag and drop file flow
This commit is contained in:
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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user