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
+23 -2
View File
@@ -14,7 +14,11 @@ interface TileUpload {
commentCount?: number
}
const props = defineProps<{ upload: TileUpload }>()
const props = withDefaults(defineProps<{ upload: TileUpload, selectable?: boolean, selected?: boolean }>(), {
selectable: false,
selected: false,
})
const emit = defineEmits<{ select: [] }>()
const { t } = useI18n()
const { unreadUploadIds } = useNotifications()
@@ -47,7 +51,17 @@ const spanClass = computed(() => {
// 1. Ladeindikator oben sofort starten
// 2. Thread-Daten + Bild/Video vorladen (min. 600 ms, damit der Indikator sichtbar läuft)
// 3. Wenn geladen: viewTransitionName setzen und via startViewTransition navigieren
// Capture-Phase: läuft vor NuxtLinks eigenem Klick-Handler — preventDefault +
// stopImmediatePropagation verhindert die Navigation sicher in allen Browsern
function captureClick(e: MouseEvent) {
if (!props.selectable) return
e.preventDefault()
e.stopImmediatePropagation()
emit('select')
}
async function openUpload(e: MouseEvent) {
if (props.selectable) return
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
e.preventDefault()
if (navigating.value) return
@@ -116,10 +130,11 @@ async function openUpload(e: MouseEvent) {
:to="`/thread/${upload.id}`"
class="upload-tile group block"
:class="[spanClass, navigating ? 'opacity-80' : '']"
@click.capture="captureClick"
@click="openUpload"
>
<div
:class="cn('upload-tile-media relative h-full w-full overflow-hidden rounded-md', hasNotification && 'ring-4 ring-primary')"
:class="cn('upload-tile-media relative h-full w-full overflow-hidden rounded-md', selected ? 'ring-4 ring-accent' : hasNotification && 'ring-4 ring-primary')"
:style="lastViewedId === upload.id ? 'view-transition-name: upload-media' : undefined"
>
<NuxtImg
@@ -145,6 +160,12 @@ async function openUpload(e: MouseEvent) {
<Icon name="ph:music-note-fill" v-if="upload.type === 'audio'" class="size-8 text-muted-foreground" />
<Icon name="ph:link-fill" v-else class="size-8 text-muted-foreground" />
</div>
<span
v-if="selectable"
:class="cn('absolute top-1.5 left-1.5 z-10 flex size-6 items-center justify-center rounded-full border shadow', selected ? 'border-accent bg-accent text-accent-foreground' : 'border-muted-foreground/40 bg-background/60')"
>
<Icon v-if="selected" name="ph:check-bold" class="size-3.5" />
</span>
<span
v-if="hasNotification"
class="absolute top-1.5 right-1.5 z-10 flex size-6 items-center justify-center rounded-full bg-accent text-accent-foreground shadow"