forked from BRETT/BRETT
Layout additions: breadcrumbs, global drop zone, sticky MOTD
- Route-derived SiteBreadcrumb below the MOTD on all non-index pages; separator icon switched to bxs:chevron-right-square - GlobalDropZone moved to the layout so it works on every page; on thread routes it only applies to the upper half of the viewport (comment attachments keep their own zone) - MOTD banner is now sticky below the site header Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
<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('/thread/'))
|
||||
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>
|
||||
Reference in New Issue
Block a user