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:
2026-10-04 03:06:42 +02:00
co-authored by Devin
parent de0cd6d34f
commit 7689c3e9c0
4 changed files with 137 additions and 1 deletions
+80
View File
@@ -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>
+1 -1
View File
@@ -7,7 +7,7 @@ if (data.value.message) motd.value = data.value.message
</script> </script>
<template> <template>
<div v-if="motd" class="border-b px-4 py-2 lg:px-6"> <div v-if="motd" class="sticky top-(--header-height) z-10 border-b bg-background px-4 py-2 lg:px-6">
<p class="flex items-center gap-2 text-sm"> <p class="flex items-center gap-2 text-sm">
<Icon name="ph:warning-octagon-fill" class="size-4 shrink-0 text-muted-foreground" /> <Icon name="ph:warning-octagon-fill" class="size-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 whitespace-pre-wrap">{{ motd }}</span> <span class="min-w-0 whitespace-pre-wrap">{{ motd }}</span>
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
const { t } = useI18n()
const route = useRoute()
interface Crumb {
label: string
to?: string
}
const crumbs = computed<Crumb[]>(() => {
const list: Crumb[] = [{ label: t('palette.home'), to: '/' }]
const p = route.path
if (p === '/') return []
if (p.startsWith('/thread/')) list.push({ label: t('nav.thread') })
else if (p === '/users') list.push({ label: t('nav.users') })
else if (p.startsWith('/user/')) {
list.push({ label: t('nav.users'), to: '/users' }, { label: t('nav.profile') })
}
else if (p === '/settings') list.push({ label: t('nav.settings') })
else if (p === '/admin') list.push({ label: t('nav.admin') })
else if (p === '/login') list.push({ label: t('auth.login') })
else if (p === '/register') list.push({ label: t('auth.register') })
return list
})
</script>
<template>
<Breadcrumb v-if="crumbs.length > 1" class="px-4 py-2 lg:px-6">
<BreadcrumbList>
<template v-for="(crumb, i) in crumbs" :key="i">
<BreadcrumbItem>
<BreadcrumbLink v-if="crumb.to" as-child>
<NuxtLink :to="crumb.to">{{ crumb.label }}</NuxtLink>
</BreadcrumbLink>
<BreadcrumbPage v-else>
{{ crumb.label }}
</BreadcrumbPage>
</BreadcrumbItem>
<BreadcrumbSeparator v-if="i < crumbs.length - 1" />
</template>
</BreadcrumbList>
</Breadcrumb>
</template>
+4
View File
@@ -1,7 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import CommentsSidebar from '@/components/CommentsSidebar.vue' import CommentsSidebar from '@/components/CommentsSidebar.vue'
import CommandPalette from '@/components/CommandPalette.vue' import CommandPalette from '@/components/CommandPalette.vue'
import GlobalDropZone from '@/components/GlobalDropZone.vue'
import MotdBanner from '@/components/MotdBanner.vue' import MotdBanner from '@/components/MotdBanner.vue'
import SiteBreadcrumb from '@/components/SiteBreadcrumb.vue'
import SiteHeader from '@/components/SiteHeader.vue' import SiteHeader from '@/components/SiteHeader.vue'
import { import {
SidebarInset, SidebarInset,
@@ -20,6 +22,7 @@ import {
<SidebarInset> <SidebarInset>
<SiteHeader /> <SiteHeader />
<MotdBanner /> <MotdBanner />
<SiteBreadcrumb />
<div class="flex flex-1 flex-col"> <div class="flex flex-1 flex-col">
<div class="@container/main flex flex-1 flex-col gap-2"> <div class="@container/main flex flex-1 flex-col gap-2">
<div class="flex flex-1 flex-col gap-4 py-4 md:gap-6 md:py-6"> <div class="flex flex-1 flex-col gap-4 py-4 md:gap-6 md:py-6">
@@ -30,5 +33,6 @@ import {
</SidebarInset> </SidebarInset>
<CommentsSidebar /> <CommentsSidebar />
<CommandPalette /> <CommandPalette />
<GlobalDropZone />
</SidebarProvider> </SidebarProvider>
</template> </template>