Files
BRETT/app/components/FileDropzone.vue
2026-10-03 23:45:04 +02:00

110 lines
3.3 KiB
Vue

<script setup lang="ts">
import { useDropZone, useEventListener } from '@vueuse/core'
import { toast } from 'vue-sonner'
import { isMediaFile, isMediaItemType } from '~~/shared/utils/media'
import { Button } from '@/components/ui/button'
const model = defineModel<File | null>()
const props = withDefaults(defineProps<{
disabled?: boolean
accept?: string
hint?: string
}>(), {
accept: 'image/*,video/*,audio/*,.mp3,.flac',
})
const { t } = useI18n()
const hintText = computed(() => props.hint ?? t('upload.dropzoneHint'))
const dropzoneRef = ref<HTMLElement>()
const inputRef = ref<HTMLInputElement>()
const previewUrl = ref<string | null>(null)
const { isOverDropZone } = useDropZone(dropzoneRef, {
dataTypes: types => types.some(isMediaItemType),
onDrop(files) {
if (props.disabled) return
const file = files?.find(isMediaFile)
if (file) select(file)
else if (files?.length) toast.error(t('upload.onlyMedia'))
},
})
// Drag-Events nicht bis zur seitenweiten Dropzone (document.body) durchreichen —
// sonst würde ein Drop hier zusätzlich den globalen Upload auslösen
useEventListener(dropzoneRef, ['dragenter', 'dragover', 'dragleave', 'drop'], e => e.stopPropagation())
function select(file: File | null) {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = file?.type.startsWith('image/') ? URL.createObjectURL(file) : null
model.value = file
if (inputRef.value) inputRef.value.value = ''
}
function openPicker() {
if (!props.disabled) inputRef.value?.click()
}
function formatSize(bytes: number) {
return bytes >= 1024 * 1024
? `${(bytes / 1024 / 1024).toFixed(1)} MB`
: `${Math.max(1, Math.round(bytes / 1024))} KB`
}
onUnmounted(() => {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
})
</script>
<template>
<div
ref="dropzoneRef"
role="button"
tabindex="0"
:aria-disabled="disabled"
class="flex min-h-32 cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed p-4 text-center text-sm transition-colors"
:class="[
isOverDropZone ? 'border-primary bg-accent' : 'border-primary/40 hover:border-primary',
disabled && 'pointer-events-none opacity-50',
]"
@click="openPicker"
@keydown.enter.prevent="openPicker"
@keydown.space.prevent="openPicker"
>
<input
ref="inputRef"
type="file"
:accept="accept"
class="hidden"
@change="select(($event.target as HTMLInputElement).files?.[0] ?? null)"
>
<template v-if="model">
<img
v-if="previewUrl"
:src="previewUrl"
:alt="model.name"
class="max-h-32 rounded-md"
>
<span class="max-w-full truncate font-medium">{{ model.name }}</span>
<span class="text-xs text-muted-foreground">{{ formatSize(model.size) }}</span>
<Button
type="button"
variant="ghost"
size="sm"
@click.stop="select(null)"
>
<Icon name="ph:x-fill" class="size-4" />
{{ $t('common.remove') }}
</Button>
</template>
<template v-else>
<Icon name="ph:cloud-arrow-up-fill" class="size-8 text-muted-foreground" />
<span>{{ $t('upload.dropzoneText') }}</span>
<span class="text-xs text-muted-foreground">{{ hintText }}</span>
</template>
</div>
</template>