forked from BRETT/BRETT
Initial Commit
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import { useDropZone, useEventListener } from '@vueuse/core'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { isMediaFile, isMediaItemType } from '~~/shared/utils/media'
|
||||
import UploadFilters from '@/components/UploadFilters.vue'
|
||||
import UploadTile from '@/components/UploadTile.vue'
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/pagination'
|
||||
|
||||
interface UploadItem {
|
||||
id: string
|
||||
type: 'image' | 'video' | 'audio' | 'youtube' | 'twitch' | 'link'
|
||||
url: string
|
||||
userId: number
|
||||
userName: string
|
||||
userColor: string | null
|
||||
userAvatarUrl: string | null
|
||||
commentCount: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface UploadMeta {
|
||||
id: string
|
||||
thumbUrl: string | null
|
||||
}
|
||||
|
||||
interface UploadList {
|
||||
items: UploadItem[]
|
||||
total: number
|
||||
pageCount: number
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const FILTER_KEYS = ['tag', 'type', 'uploader', 'days'] as const
|
||||
const hasFilters = computed(() => FILTER_KEYS.some(k => typeof route.query[k] === 'string' && route.query[k]))
|
||||
|
||||
const filterRowEl = ref<HTMLElement>()
|
||||
const { cells, cols } = usePageSize(filterRowEl)
|
||||
const { uniform } = useGridMode()
|
||||
|
||||
const page = computed<number>({
|
||||
get: () => Math.max(1, Math.floor(Number(route.query.page)) || 1),
|
||||
set: (v) => {
|
||||
const query = { ...route.query }
|
||||
if (v > 1) query.page = String(v)
|
||||
else delete query.page
|
||||
router.replace({ query })
|
||||
},
|
||||
})
|
||||
|
||||
// Filter-Wechsel setzt zurück auf Seite 1
|
||||
watch(() => FILTER_KEYS.map(k => route.query[k]).join('|'), () => {
|
||||
if (page.value !== 1) page.value = 1
|
||||
})
|
||||
|
||||
const fetchUrl = computed(() => {
|
||||
const params = new URLSearchParams()
|
||||
for (const key of FILTER_KEYS) {
|
||||
const value = route.query[key]
|
||||
if (typeof value === 'string' && value) params.set(key, value)
|
||||
}
|
||||
params.set('page', String(page.value))
|
||||
params.set('cells', String(cells.value))
|
||||
params.set('cols', String(cols.value))
|
||||
params.set('mode', uniform.value ? 'uniform' : 'bento')
|
||||
return `/api/uploads?${params.toString()}`
|
||||
})
|
||||
|
||||
// server: false — die Seitengröße hängt vom Viewport ab, den SSR nicht kennt.
|
||||
// Ein SSR-Fetch würde mit Fallback-Werten laden und müsste ohnehin ersetzt
|
||||
// werden; der Client-Fetch läuft einmalig mit den gemessenen Parametern.
|
||||
const { data: result, pending } = await useFetch<UploadList>(fetchUrl, {
|
||||
key: 'uploads',
|
||||
server: false,
|
||||
watch: [fetchUrl],
|
||||
})
|
||||
const uploads = computed(() => result.value?.items ?? [])
|
||||
const total = computed(() => result.value?.total ?? 0)
|
||||
const pageCount = computed(() => result.value?.pageCount ?? 0)
|
||||
|
||||
// Items können durch neue Uploads/Löschungen/Live-Updates weniger Seiten werden
|
||||
watch(pageCount, (count) => {
|
||||
if (count > 0 && page.value > count) page.value = count
|
||||
})
|
||||
|
||||
// Thumbnails + Kommentarzahlen asynchron nachladen — das Grid rendert sofort
|
||||
const meta = ref<Record<string, UploadMeta>>({})
|
||||
watch(uploads, async (list) => {
|
||||
if (!import.meta.client) return
|
||||
if (!list?.length) {
|
||||
meta.value = {}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await $fetch<UploadMeta[]>('/api/uploads/meta', {
|
||||
query: { ids: list.map(u => u.id).join(',') },
|
||||
})
|
||||
meta.value = Object.fromEntries(res.map(m => [m.id, m]))
|
||||
}
|
||||
catch {
|
||||
// Meta bleibt leer — Tiles zeigen Platzhalter
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
useHead({
|
||||
link: [
|
||||
{ rel: 'preconnect', href: 'https://www.youtube.com' },
|
||||
{ rel: 'preconnect', href: 'https://i.ytimg.com' },
|
||||
],
|
||||
})
|
||||
|
||||
// Originale vorladen, damit die View-Transition das echte Bild morpht statt eines leeren Platzhalters
|
||||
watch(uploads, (list) => {
|
||||
if (!import.meta.client) return
|
||||
for (const u of list ?? []) {
|
||||
if (u.type === 'image' && u.url) {
|
||||
const img = new Image()
|
||||
img.src = u.url
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const { user } = useAuth()
|
||||
const dropTarget = shallowRef<HTMLElement>()
|
||||
const uploading = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
dropTarget.value = document.body
|
||||
})
|
||||
|
||||
const { isOverDropZone } = useDropZone(dropTarget, {
|
||||
dataTypes: types => types.some(isMediaItemType),
|
||||
onDrop(files) {
|
||||
const valid = (files ?? []).filter(isMediaFile)
|
||||
if (valid.length && !uploading.value) uploadFiles(valid)
|
||||
else if (files?.length) toast.error('Nur Bilder, Videos und Audiodateien sind erlaubt')
|
||||
},
|
||||
})
|
||||
|
||||
// Verhindert, dass der Browser gedroppte Dateien außerhalb der Zone als Tab öffnet
|
||||
useEventListener('dragover', (e: DragEvent) => {
|
||||
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('Melde dich an, um hochzuladen.')
|
||||
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(`${failed} von ${files.length} Dateien fehlgeschlagen`)
|
||||
}
|
||||
await refreshNuxtData('uploads')
|
||||
await navigateTo('/')
|
||||
}
|
||||
finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-1 flex-col px-4 lg:px-6">
|
||||
<div
|
||||
v-if="isOverDropZone || 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 ? 'Lädt hoch…' : 'Zum Hochladen loslassen' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="filterRowEl" class="mb-2 flex items-center gap-2">
|
||||
<UploadFilters />
|
||||
<span v-if="hasFilters" class="text-sm text-muted-foreground">{{ total }} Uploads</span>
|
||||
</div>
|
||||
<p v-if="pending" class="text-muted-foreground">
|
||||
Lade Uploads…
|
||||
</p>
|
||||
<p v-else-if="!uploads.length" class="text-muted-foreground">
|
||||
{{ hasFilters ? 'Keine Uploads für die aktiven Filter.' : 'Noch keine Uploads. Sei der Erste!' }}
|
||||
</p>
|
||||
|
||||
<div v-else class="grid grid-flow-dense grid-cols-4 gap-2 [grid-auto-rows:6.5rem] sm:grid-cols-6 sm:[grid-auto-rows:7rem] md:grid-cols-8 lg:grid-cols-12 lg:[grid-auto-rows:7.5rem]">
|
||||
<UploadTile v-for="upload in uploads" :key="upload.id" :upload="{ ...upload, ...meta[upload.id] }" />
|
||||
</div>
|
||||
|
||||
<!-- Seiten haben variable Item-Anzahl (Zell-Budget) — Pagination arbeitet
|
||||
daher mit items-per-page=cells und total=pageCount*cells -->
|
||||
<Pagination
|
||||
v-if="pageCount > 1"
|
||||
v-model:page="page"
|
||||
:items-per-page="cells"
|
||||
:total="pageCount * cells"
|
||||
:sibling-count="1"
|
||||
show-edges
|
||||
class="mt-auto pt-4"
|
||||
>
|
||||
<PaginationContent v-slot="{ items }">
|
||||
<PaginationPrevious />
|
||||
<template v-for="(item, i) in items" :key="i">
|
||||
<PaginationItem
|
||||
v-if="item.type === 'page'"
|
||||
:value="item.value"
|
||||
:is-active="item.value === page"
|
||||
>
|
||||
{{ item.value }}
|
||||
</PaginationItem>
|
||||
<PaginationEllipsis v-else />
|
||||
</template>
|
||||
<PaginationNext />
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user