Format imports, partial implementation of import upload entries
This commit is contained in:
207
app/components/ui/dropzone/Dropzone.vue
Normal file
207
app/components/ui/dropzone/Dropzone.vue
Normal file
@ -0,0 +1,207 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user