208 lines
6.3 KiB
Vue
208 lines
6.3 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref } from 'vue'
|
|
import { useDropZone } from '@vueuse/core'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Upload } from 'lucide-vue-next'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
interface DropzoneProps {
|
|
accept?: Record<string, string[]>
|
|
maxFiles?: number
|
|
maxSize?: number
|
|
minSize?: number
|
|
disabled?: boolean
|
|
src?: File[]
|
|
class?: string
|
|
}
|
|
|
|
const props = withDefaults(defineProps<DropzoneProps>(), {
|
|
maxFiles: 1,
|
|
accept: () => ({}),
|
|
src: () => []
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
drop: [acceptedFiles: File[], event: DragEvent]
|
|
error: [error: Error]
|
|
}>()
|
|
|
|
const files = ref<File[]>(props.src || [])
|
|
const error = ref<string>('')
|
|
|
|
const renderBytes = (bytes: number): string => {
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
|
let size = bytes
|
|
let unitIndex = 0
|
|
|
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
size /= 1024
|
|
unitIndex++
|
|
}
|
|
|
|
return `${size.toFixed(2)}${units[unitIndex]}`
|
|
}
|
|
|
|
const validateFile = (file: File): string => {
|
|
if (props.minSize && file.size < props.minSize) {
|
|
return `File must be at least ${renderBytes(props.minSize)}`
|
|
}
|
|
|
|
if (props.maxSize && file.size > props.maxSize) {
|
|
return `File must be less than ${renderBytes(props.maxSize)}`
|
|
}
|
|
|
|
if (props.accept && Object.keys(props.accept).length > 0) {
|
|
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase()
|
|
const mimeType = file.type
|
|
|
|
const isAccepted = Object.entries(props.accept).some(([mime, extensions]) => {
|
|
return (
|
|
mimeType === mime ||
|
|
extensions.some((ext) => fileExtension === ext.toLowerCase())
|
|
)
|
|
})
|
|
|
|
if (!isAccepted) {
|
|
const acceptedTypes = Object.keys(props.accept).join(', ')
|
|
return `File type not accepted. Accepted types: ${acceptedTypes}`
|
|
}
|
|
}
|
|
|
|
return ''
|
|
}
|
|
|
|
const onDrop = (droppedFiles: File[] | null, event: DragEvent) => {
|
|
if (!droppedFiles || props.disabled) return
|
|
|
|
error.value = ''
|
|
const validFiles: File[] = []
|
|
const newFiles: File[] = []
|
|
|
|
droppedFiles.slice(0, props.maxFiles).forEach((file) => {
|
|
const validationError = validateFile(file)
|
|
if (validationError) {
|
|
error.value = validationError
|
|
emit('error', new Error(validationError))
|
|
} else {
|
|
validFiles.push(file)
|
|
}
|
|
})
|
|
|
|
if (validFiles.length > 0) {
|
|
newFiles.push(...validFiles)
|
|
|
|
if (props.maxFiles === 1) {
|
|
files.value = newFiles
|
|
} else {
|
|
const remainingSlots = props.maxFiles - files.value.length
|
|
files.value = [...files.value, ...newFiles.slice(0, remainingSlots)]
|
|
}
|
|
|
|
emit('drop', newFiles, event)
|
|
}
|
|
}
|
|
|
|
const dropZoneRef = ref<HTMLElement>()
|
|
const { isOverDropZone } = useDropZone(dropZoneRef, {
|
|
onDrop,
|
|
dataTypes: props.accept ? Object.keys(props.accept) : []
|
|
})
|
|
|
|
const handleClick = () => {
|
|
if (props.disabled) return
|
|
|
|
const input = document.createElement('input')
|
|
input.type = 'file'
|
|
input.multiple = props.maxFiles > 1
|
|
input.accept = props.accept ? Object.keys(props.accept).join(',') : '*/*'
|
|
|
|
input.onchange = (e: Event) => {
|
|
const target = e.target as HTMLInputElement
|
|
if (target.files) {
|
|
onDrop(Array.from(target.files), e as unknown as DragEvent)
|
|
}
|
|
}
|
|
|
|
input.click()
|
|
}
|
|
|
|
const maxLabelItems = 3
|
|
const fileListLabel = computed(() => {
|
|
if (files.value.length === 0) return ''
|
|
|
|
if (files.value.length > maxLabelItems) {
|
|
const firstThree = files.value.slice(0, maxLabelItems).map(f => f.name)
|
|
const remaining = files.value.length - maxLabelItems
|
|
return `${new Intl.ListFormat('en').format(firstThree)} and ${remaining} more`
|
|
}
|
|
|
|
return new Intl.ListFormat('en').format(files.value.map(f => f.name))
|
|
})
|
|
|
|
const captionText = computed(() => {
|
|
let caption = ''
|
|
|
|
if (Object.keys(props.accept).length > 0) {
|
|
caption += 'Accepts '
|
|
caption += new Intl.ListFormat('en').format(Object.keys(props.accept))
|
|
}
|
|
|
|
if (props.minSize && props.maxSize) {
|
|
caption += ` between ${renderBytes(props.minSize)} and ${renderBytes(props.maxSize)}`
|
|
} else if (props.minSize) {
|
|
caption += ` at least ${renderBytes(props.minSize)}`
|
|
} else if (props.maxSize) {
|
|
caption += ` less than ${renderBytes(props.maxSize)}`
|
|
}
|
|
|
|
return caption
|
|
})
|
|
|
|
defineExpose({
|
|
files,
|
|
clearFiles: () => { files.value = [] },
|
|
addFiles: (newFiles: File[]) => {
|
|
const remainingSlots = props.maxFiles - files.value.length
|
|
files.value = [...files.value, ...newFiles.slice(0, remainingSlots)]
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<Button ref="dropZoneRef" :class="cn(
|
|
'relative h-auto w-full flex-col overflow-hidden p-8',
|
|
isOverDropZone && 'outline-none ring-1 ring-ring',
|
|
props.class
|
|
)
|
|
" :disabled="disabled" type="button" variant="outline" @click="handleClick">
|
|
<div v-if="files.length === 0" class="flex flex-col items-center justify-center">
|
|
<div class="flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
|
<Upload :size="16" />
|
|
</div>
|
|
<p class="my-2 w-full truncate text-wrap font-medium text-sm">
|
|
Upload {{ maxFiles === 1 ? 'a file' : 'files' }}
|
|
</p>
|
|
<p class="w-full truncate text-wrap text-muted-foreground text-xs">
|
|
Drag and drop or click to upload
|
|
</p>
|
|
<p v-if="captionText" class="text-wrap text-muted-foreground text-xs">
|
|
{{ captionText }}.
|
|
</p>
|
|
</div>
|
|
|
|
<div v-else class="flex flex-col items-center justify-center">
|
|
<div class="flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
|
<Upload :size="16" />
|
|
</div>
|
|
<p class="my-2 w-full truncate font-medium text-sm">
|
|
{{ fileListLabel }}
|
|
</p>
|
|
<p class="w-full text-wrap text-muted-foreground text-xs">
|
|
Drag and drop or click to replace
|
|
</p>
|
|
</div>
|
|
|
|
<p v-if="error" class="mt-2 text-sm text-destructive">{{ error }}</p>
|
|
</Button>
|
|
</template>
|