Files
BRETT/app/components/GlobalDropZone.vue
T
FliesentischKingandDevin 456776ea68 Shorten thread route from /thread to /t
All internal links (tiles, prev/next nav, comments, notifications,
breadcrumb, dropzone route check) point to /t/<id>. A global route
middleware permanently redirects old /thread/<id> URLs including
query string and comment anchor.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-04 15:24:32 +02:00

81 lines
2.8 KiB
Vue

<script setup lang="ts">
import { useDropZone, useEventListener } from '@vueuse/core'
import { toast } from 'vue-sonner'
import { isMediaFile, isMediaItemType } from '~~/shared/utils/media'
const { t } = useI18n()
const { user } = useAuth()
const route = useRoute()
const dropTarget = shallowRef<HTMLElement>()
const uploading = ref(false)
// In Threads gilt die Zone nur für die obere Bildschirmhälfte —
// die untere Hälfte gehört den Kommentaren (dort Anhänge, sonst nichts)
const topHalfOnly = computed(() => route.path.startsWith('/t/'))
const pointerY = ref(0)
const inTopHalf = computed(() => !topHalfOnly.value || pointerY.value < (import.meta.client ? window.innerHeight : 0) / 2)
onMounted(() => {
dropTarget.value = document.body
})
// Zonen wie das Kommentarfeld stoppen die Events per stopPropagation —
// Drops dort landen als Anhang und nicht hier
const { isOverDropZone } = useDropZone(dropTarget, {
dataTypes: types => types.some(isMediaItemType),
onDrop(files, event) {
if (topHalfOnly.value && event.clientY >= window.innerHeight / 2) return
const valid = (files ?? []).filter(isMediaFile)
if (valid.length && !uploading.value) uploadFiles(valid)
else if (files?.length) toast.error(t('upload.onlyMedia'))
},
})
// Verhindert, dass der Browser gedroppte Dateien außerhalb der Zone als Tab öffnet
useEventListener('dragover', (e: DragEvent) => {
pointerY.value = e.clientY
if (e.dataTransfer?.types.includes('Files')) e.preventDefault()
})
useEventListener('drop', (e: DragEvent) => {
if (e.dataTransfer?.types.includes('Files')) e.preventDefault()
})
async function uploadFiles(files: File[]) {
if (!user.value) {
toast.error(t('upload.loginToUploadToast'))
return
}
uploading.value = true
try {
const results = await Promise.allSettled(files.map((file) => {
const formData = new FormData()
formData.append('file', file)
return $fetch('/api/uploads', { method: 'POST', body: formData })
}))
const failed = results.filter(r => r.status === 'rejected').length
if (failed) {
toast.error(t('upload.failedCount', { failed, total: files.length }))
}
await refreshNuxtData('uploads')
await navigateTo('/')
}
finally {
uploading.value = false
}
}
</script>
<template>
<div
v-if="(isOverDropZone && inTopHalf) || uploading"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm"
>
<div class="flex flex-col items-center gap-3 rounded-lg border-2 border-dashed border-primary bg-background p-10">
<Icon name="ph:cloud-arrow-up-fill" class="size-12 text-primary" />
<p class="text-lg font-medium">
{{ uploading ? $t('upload.uploading') : $t('upload.dropToUpload') }}
</p>
</div>
</div>
</template>