229 lines
6.5 KiB
Vue
229 lines
6.5 KiB
Vue
<template>
|
|
<div class="flex w-full flex-col gap-3">
|
|
<p class="text-sm text-muted-foreground sm:text-base">
|
|
Drop anywhere, paste or choose files. Max {{ maxQueueSize }} images, up to {{ maxImageSizeLabel }} each.
|
|
</p>
|
|
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<label
|
|
class="inline-flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 sm:w-auto"
|
|
:class="disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
|
|
>
|
|
Choose images
|
|
<input
|
|
ref="fileInputRef"
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
class="hidden"
|
|
:disabled="disabled"
|
|
@change="onFileInputChange"
|
|
/>
|
|
</label>
|
|
|
|
<button
|
|
v-if="!uploadConfirmed"
|
|
type="button"
|
|
class="inline-flex w-full shrink-0 items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60 sm:w-auto"
|
|
:disabled="!canOpenConfigure"
|
|
@click="configureModalOpen = true"
|
|
>
|
|
Configure & upload
|
|
</button>
|
|
</div>
|
|
|
|
<UploadConfigureModal
|
|
v-model:open="configureModalOpen"
|
|
v-model:settings="settings"
|
|
:metadata-error-count="metadataErrorCount"
|
|
@upload="onUploadFromModal"
|
|
/>
|
|
|
|
<Teleport to="body">
|
|
<div v-if="showFileDragOverlay" class="pointer-events-none fixed inset-0 z-70 bg-background/70">
|
|
<div
|
|
class="absolute inset-[5%] flex items-center justify-center rounded-lg border-2 border-dashed border-primary"
|
|
>
|
|
<p class="text-2xl font-medium text-foreground sm:text-3xl">Drop files</p>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
|
import { config } from '@/config';
|
|
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
|
|
import prettyBytes from 'pretty-bytes';
|
|
|
|
const { maxQueueSize, maxImageBytes } = config.validation.upload;
|
|
|
|
const props = defineProps({
|
|
disabled: {
|
|
type: Boolean,
|
|
required: true
|
|
},
|
|
canOpenConfigure: {
|
|
type: Boolean,
|
|
required: true
|
|
},
|
|
uploadConfirmed: {
|
|
type: Boolean,
|
|
required: true
|
|
},
|
|
metadataErrorCount: {
|
|
type: Number,
|
|
required: true
|
|
}
|
|
});
|
|
|
|
const settings = defineModel<UploadBatchSettingsForm>('settings', { required: true });
|
|
|
|
const emit = defineEmits<{
|
|
pick: [files: File[]];
|
|
upload: [uploadDespiteMetadataFailure?: boolean];
|
|
}>();
|
|
|
|
const fileInputRef = ref<HTMLInputElement | null>(null);
|
|
const configureModalOpen = ref(false);
|
|
const fileDragDepth = ref(0);
|
|
|
|
onMounted(() => {
|
|
window.addEventListener('dragenter', onWindowDragEnter);
|
|
window.addEventListener('dragleave', onWindowDragLeave);
|
|
window.addEventListener('dragover', onWindowDragOver);
|
|
window.addEventListener('drop', onWindowDrop);
|
|
window.addEventListener('dragend', onWindowDragEnd);
|
|
window.addEventListener('paste', onWindowPaste);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
window.removeEventListener('dragenter', onWindowDragEnter);
|
|
window.removeEventListener('dragleave', onWindowDragLeave);
|
|
window.removeEventListener('dragover', onWindowDragOver);
|
|
window.removeEventListener('drop', onWindowDrop);
|
|
window.removeEventListener('dragend', onWindowDragEnd);
|
|
window.removeEventListener('paste', onWindowPaste);
|
|
});
|
|
|
|
const showFileDragOverlay = computed(() => fileDragDepth.value > 0 && !props.disabled);
|
|
|
|
const maxImageSizeLabel = computed(() => prettyBytes(maxImageBytes, { binary: true, maximumFractionDigits: 1 }));
|
|
|
|
const emitPicked = (picked: File[]): void => {
|
|
if (picked.length === 0) {
|
|
return;
|
|
}
|
|
|
|
emit('pick', picked);
|
|
};
|
|
|
|
const onFileInputChange = (event: Event): void => {
|
|
const input = event.target as HTMLInputElement;
|
|
const picked = input.files ? [...input.files] : [];
|
|
|
|
emitPicked(picked);
|
|
};
|
|
|
|
const onUploadFromModal = (uploadDespiteMetadataFailure?: boolean): void => {
|
|
configureModalOpen.value = false;
|
|
|
|
emit('upload', uploadDespiteMetadataFailure);
|
|
};
|
|
|
|
const isFileDrag = (event: DragEvent): boolean => {
|
|
return Boolean(event.dataTransfer?.types.includes('Files'));
|
|
};
|
|
|
|
const onWindowDragEnter = (event: DragEvent): void => {
|
|
if (props.disabled || !isFileDrag(event)) {
|
|
return;
|
|
}
|
|
|
|
fileDragDepth.value += 1;
|
|
};
|
|
|
|
const onWindowDragLeave = (event: DragEvent): void => {
|
|
if (!isFileDrag(event)) {
|
|
return;
|
|
}
|
|
|
|
fileDragDepth.value = Math.max(0, fileDragDepth.value - 1);
|
|
};
|
|
|
|
const onWindowDragOver = (event: DragEvent): void => {
|
|
if (props.disabled || !isFileDrag(event)) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
if (fileDragDepth.value === 0) {
|
|
fileDragDepth.value = 1;
|
|
}
|
|
};
|
|
|
|
const onWindowDrop = (event: DragEvent): void => {
|
|
resetFileDragDepth();
|
|
|
|
if (props.disabled) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
const picked = event.dataTransfer?.files ? [...event.dataTransfer.files] : [];
|
|
|
|
emitPicked(picked);
|
|
};
|
|
|
|
const onWindowDragEnd = (): void => {
|
|
resetFileDragDepth();
|
|
};
|
|
|
|
const isPasswordInputTarget = (target: EventTarget | null): boolean => {
|
|
if (!(target instanceof HTMLElement)) {
|
|
return false;
|
|
}
|
|
|
|
return target.closest('input[type="password"]') !== null;
|
|
};
|
|
|
|
const onWindowPaste = (event: ClipboardEvent): void => {
|
|
if (props.disabled) {
|
|
return;
|
|
}
|
|
|
|
const picked = event.clipboardData?.files ? [...event.clipboardData.files] : [];
|
|
|
|
if (picked.length === 0) {
|
|
return;
|
|
}
|
|
|
|
if (isPasswordInputTarget(event.target)) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
emitPicked(picked);
|
|
};
|
|
|
|
const clearInput = (): void => {
|
|
if (fileInputRef.value) {
|
|
fileInputRef.value.value = '';
|
|
}
|
|
};
|
|
|
|
const closeConfigureModal = (): void => {
|
|
configureModalOpen.value = false;
|
|
};
|
|
|
|
const resetFileDragDepth = (): void => {
|
|
fileDragDepth.value = 0;
|
|
};
|
|
|
|
defineExpose({ clearInput, closeConfigureModal });
|
|
</script>
|