Mass-select grid and bulk delete for moderators

- Select mode in the filter row (upload.delete permission): tiles
  toggle selection in the capture phase, a floating action bar
  offers select-all and delete with confirmation popover
- New POST /api/uploads/bulk-delete endpoint (validated, deduped,
  max 200 ids) removing rows, files and attachment data
- upload-deleted broadcast on bulk and single delete; other clients
  refresh the grid and drop matching notifications
- Also: skeleton tile grid as loading state, dropzone logic moved
  to the layout-level GlobalDropZone, obsolete ytimg preconnect
  removed

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:38 +02:00
co-authored by Devin
parent f57fffb414
commit de0cd6d34f
5 changed files with 188 additions and 61 deletions
+98 -57
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { useDropZone, useEventListener } from '@vueuse/core'
import { 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 { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
Pagination,
PaginationContent,
@@ -12,6 +13,8 @@ import {
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination'
import { Skeleton } from '@/components/ui/skeleton'
import { Spinner } from '@/components/ui/spinner'
interface UploadItem {
id: string
@@ -113,7 +116,6 @@ watch(uploads, async (list) => {
useHead({
link: [
{ rel: 'preconnect', href: 'https://www.youtube.com' },
{ rel: 'preconnect', href: 'https://i.ytimg.com' },
],
})
@@ -128,82 +130,95 @@ watch(uploads, (list) => {
}
}, { immediate: true })
const { user } = useAuth()
const dropTarget = shallowRef<HTMLElement>()
const uploading = ref(false)
const { can } = useAuth()
// Mass-Select für Moderatoren (upload.delete): Kacheln anklicken statt öffnen,
// gemeinsam über POST /api/uploads/bulk-delete löschen
const selectMode = ref(false)
const selectedIds = ref<Set<string>>(new Set())
const bulkDeleting = ref(false)
const bulkConfirmOpen = ref(false)
onMounted(() => {
dropTarget.value = document.body
function toggleSelect(id: string) {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedIds.value = next
}
function exitSelectMode() {
selectMode.value = false
selectedIds.value = new Set()
bulkConfirmOpen.value = false
}
function selectAllVisible() {
selectedIds.value = new Set(uploads.value.map(u => u.id))
}
useEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape' && selectMode.value) exitSelectMode()
})
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(t('upload.onlyMedia'))
},
})
// 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(t('upload.loginToUploadToast'))
return
}
uploading.value = true
async function deleteSelected() {
if (!selectedIds.value.size || bulkDeleting.value) return
bulkDeleting.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 }))
}
const res = await $fetch<{ deleted: number }>('/api/uploads/bulk-delete', {
method: 'POST',
body: { ids: [...selectedIds.value] },
})
toast.success(t('grid.deletedCount', { n: res.deleted }))
exitSelectMode()
await refreshNuxtData('uploads')
await navigateTo('/')
}
catch {
toast.error(t('grid.deleteFailed'))
}
finally {
uploading.value = false
bulkDeleting.value = false
bulkConfirmOpen.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 ? $t('upload.uploading') : $t('upload.dropToUpload') }}
</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">{{ $t('grid.count', { n: total }) }}</span>
<Button
v-if="can('upload.delete')"
variant="outline"
size="sm"
class="ml-auto"
@click="selectMode ? exitSelectMode() : (selectMode = true)"
>
<Icon :name="selectMode ? 'ph:x-bold' : 'ph:list-checks-bold'" class="size-4" />
{{ selectMode ? $t('common.cancel') : $t('grid.select') }}
</Button>
</div>
<div
v-if="pending"
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]"
aria-hidden="true"
>
<Skeleton
v-for="i in cells || 24"
:key="i"
class="rounded-md"
/>
</div>
<p v-if="pending" class="text-muted-foreground">
{{ $t('grid.loading') }}
</p>
<p v-else-if="!uploads.length" class="text-muted-foreground">
{{ hasFilters ? $t('grid.emptyFiltered') : $t('grid.empty') }}
</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] }" />
<UploadTile
v-for="upload in uploads"
:key="upload.id"
:upload="{ ...upload, ...meta[upload.id] }"
:selectable="selectMode"
:selected="selectedIds.has(upload.id)"
@select="toggleSelect(upload.id)"
/>
</div>
<!-- Seiten haben variable Item-Anzahl (Zell-Budget) — Pagination arbeitet
@@ -232,5 +247,31 @@ async function uploadFiles(files: File[]) {
<PaginationNext />
</PaginationContent>
</Pagination>
<div
v-if="selectMode"
class="fixed bottom-4 left-4 z-40 flex items-center gap-2 rounded-lg border bg-card px-3 py-2 shadow-xl"
>
<span class="text-sm text-muted-foreground">{{ $t('grid.selected', { n: selectedIds.size }) }}</span>
<Button variant="outline" size="sm" @click="selectAllVisible">
{{ $t('grid.selectAll') }}
</Button>
<Popover v-model:open="bulkConfirmOpen">
<PopoverTrigger as-child>
<Button variant="destructive" size="sm" :disabled="!selectedIds.size || bulkDeleting">
<Icon name="ph:trash-fill" class="size-4" />
{{ $t('common.delete') }}
</Button>
</PopoverTrigger>
<PopoverContent class="w-64">
<p class="text-sm font-medium">{{ $t('grid.deleteConfirmTitle') }}</p>
<p class="mt-1 text-sm text-muted-foreground">{{ $t('grid.deleteConfirmDesc', { n: selectedIds.size }) }}</p>
<Button variant="destructive" size="sm" class="mt-3 w-full" @click="deleteSelected">
<Spinner v-if="bulkDeleting" />
{{ bulkDeleting ? $t('common.deleting') : $t('common.delete') }}
</Button>
</PopoverContent>
</Popover>
</div>
</div>
</template>