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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user