Files
frontend/app/components/ui/dropzone/Dropzone.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>