forked from BRETT/BRETT
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>
81 lines
2.8 KiB
Vue
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>
|