forked from BRETT/BRETT
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:
@@ -14,7 +14,11 @@ interface TileUpload {
|
|||||||
commentCount?: number
|
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 { t } = useI18n()
|
||||||
|
|
||||||
const { unreadUploadIds } = useNotifications()
|
const { unreadUploadIds } = useNotifications()
|
||||||
@@ -47,7 +51,17 @@ const spanClass = computed(() => {
|
|||||||
// 1. Ladeindikator oben sofort starten
|
// 1. Ladeindikator oben sofort starten
|
||||||
// 2. Thread-Daten + Bild/Video vorladen (min. 600 ms, damit der Indikator sichtbar läuft)
|
// 2. Thread-Daten + Bild/Video vorladen (min. 600 ms, damit der Indikator sichtbar läuft)
|
||||||
// 3. Wenn geladen: viewTransitionName setzen und via startViewTransition navigieren
|
// 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) {
|
async function openUpload(e: MouseEvent) {
|
||||||
|
if (props.selectable) return
|
||||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (navigating.value) return
|
if (navigating.value) return
|
||||||
@@ -116,10 +130,11 @@ async function openUpload(e: MouseEvent) {
|
|||||||
:to="`/thread/${upload.id}`"
|
:to="`/thread/${upload.id}`"
|
||||||
class="upload-tile group block"
|
class="upload-tile group block"
|
||||||
:class="[spanClass, navigating ? 'opacity-80' : '']"
|
:class="[spanClass, navigating ? 'opacity-80' : '']"
|
||||||
|
@click.capture="captureClick"
|
||||||
@click="openUpload"
|
@click="openUpload"
|
||||||
>
|
>
|
||||||
<div
|
<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"
|
:style="lastViewedId === upload.id ? 'view-transition-name: upload-media' : undefined"
|
||||||
>
|
>
|
||||||
<NuxtImg
|
<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: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" />
|
<Icon name="ph:link-fill" v-else class="size-8 text-muted-foreground" />
|
||||||
</div>
|
</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
|
<span
|
||||||
v-if="hasNotification"
|
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"
|
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"
|
||||||
|
|||||||
@@ -49,10 +49,17 @@ export function useNotifications() {
|
|||||||
await $fetch(`/api/notifications/${id}/read`, { method: 'POST' })
|
await $fetch(`/api/notifications/${id}/read`, { method: 'POST' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeByUploads(uploadIds: string[]) {
|
||||||
|
const removed = notifications.value.filter(n => uploadIds.includes(n.uploadId))
|
||||||
|
if (!removed.length) return
|
||||||
|
notifications.value = notifications.value.filter(n => !uploadIds.includes(n.uploadId))
|
||||||
|
unreadCount.value = Math.max(0, unreadCount.value - removed.filter(n => !n.isRead).length)
|
||||||
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
notifications.value = []
|
notifications.value = []
|
||||||
unreadCount.value = 0
|
unreadCount.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return { notifications, unreadCount, unreadUploadIds, fetchAll, addLive, markAllRead, markRead, reset }
|
return { notifications, unreadCount, unreadUploadIds, fetchAll, addLive, markAllRead, markRead, removeByUploads, reset }
|
||||||
}
|
}
|
||||||
|
|||||||
+99
-58
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useDropZone, useEventListener } from '@vueuse/core'
|
import { useEventListener } from '@vueuse/core'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { isMediaFile, isMediaItemType } from '~~/shared/utils/media'
|
|
||||||
import UploadFilters from '@/components/UploadFilters.vue'
|
import UploadFilters from '@/components/UploadFilters.vue'
|
||||||
import UploadTile from '@/components/UploadTile.vue'
|
import UploadTile from '@/components/UploadTile.vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
import {
|
import {
|
||||||
Pagination,
|
Pagination,
|
||||||
PaginationContent,
|
PaginationContent,
|
||||||
@@ -12,6 +13,8 @@ import {
|
|||||||
PaginationNext,
|
PaginationNext,
|
||||||
PaginationPrevious,
|
PaginationPrevious,
|
||||||
} from '@/components/ui/pagination'
|
} from '@/components/ui/pagination'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Spinner } from '@/components/ui/spinner'
|
||||||
|
|
||||||
interface UploadItem {
|
interface UploadItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -113,7 +116,6 @@ watch(uploads, async (list) => {
|
|||||||
useHead({
|
useHead({
|
||||||
link: [
|
link: [
|
||||||
{ rel: 'preconnect', href: 'https://www.youtube.com' },
|
{ rel: 'preconnect', href: 'https://www.youtube.com' },
|
||||||
{ rel: 'preconnect', href: 'https://i.ytimg.com' },
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -128,82 +130,95 @@ watch(uploads, (list) => {
|
|||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
const { user } = useAuth()
|
const { can } = useAuth()
|
||||||
const dropTarget = shallowRef<HTMLElement>()
|
// Mass-Select für Moderatoren (upload.delete): Kacheln anklicken statt öffnen,
|
||||||
const uploading = ref(false)
|
// 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(() => {
|
function toggleSelect(id: string) {
|
||||||
dropTarget.value = document.body
|
const next = new Set(selectedIds.value)
|
||||||
})
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
const { isOverDropZone } = useDropZone(dropTarget, {
|
selectedIds.value = next
|
||||||
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
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function deleteSelected() {
|
||||||
|
if (!selectedIds.value.size || bulkDeleting.value) return
|
||||||
|
bulkDeleting.value = true
|
||||||
try {
|
try {
|
||||||
const results = await Promise.allSettled(files.map((file) => {
|
const res = await $fetch<{ deleted: number }>('/api/uploads/bulk-delete', {
|
||||||
const formData = new FormData()
|
method: 'POST',
|
||||||
formData.append('file', file)
|
body: { ids: [...selectedIds.value] },
|
||||||
return $fetch('/api/uploads', { method: 'POST', body: formData })
|
})
|
||||||
}))
|
toast.success(t('grid.deletedCount', { n: res.deleted }))
|
||||||
const failed = results.filter(r => r.status === 'rejected').length
|
exitSelectMode()
|
||||||
if (failed) {
|
|
||||||
toast.error(t('upload.failedCount', { failed, total: files.length }))
|
|
||||||
}
|
|
||||||
await refreshNuxtData('uploads')
|
await refreshNuxtData('uploads')
|
||||||
await navigateTo('/')
|
}
|
||||||
|
catch {
|
||||||
|
toast.error(t('grid.deleteFailed'))
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
uploading.value = false
|
bulkDeleting.value = false
|
||||||
|
bulkConfirmOpen.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-1 flex-col px-4 lg:px-6">
|
<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">
|
<div ref="filterRowEl" class="mb-2 flex items-center gap-2">
|
||||||
<UploadFilters />
|
<UploadFilters />
|
||||||
<span v-if="hasFilters" class="text-sm text-muted-foreground">{{ $t('grid.count', { n: total }) }}</span>
|
<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>
|
</div>
|
||||||
<p v-if="pending" class="text-muted-foreground">
|
|
||||||
{{ $t('grid.loading') }}
|
|
||||||
</p>
|
|
||||||
<p v-else-if="!uploads.length" class="text-muted-foreground">
|
<p v-else-if="!uploads.length" class="text-muted-foreground">
|
||||||
{{ hasFilters ? $t('grid.emptyFiltered') : $t('grid.empty') }}
|
{{ hasFilters ? $t('grid.emptyFiltered') : $t('grid.empty') }}
|
||||||
</p>
|
</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]">
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Seiten haben variable Item-Anzahl (Zell-Budget) — Pagination arbeitet
|
<!-- Seiten haben variable Item-Anzahl (Zell-Budget) — Pagination arbeitet
|
||||||
@@ -232,5 +247,31 @@ async function uploadFiles(files: File[]) {
|
|||||||
<PaginationNext />
|
<PaginationNext />
|
||||||
</PaginationContent>
|
</PaginationContent>
|
||||||
</Pagination>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { AppNotification } from '@/composables/useNotifications'
|
|||||||
export default defineNuxtPlugin(() => {
|
export default defineNuxtPlugin(() => {
|
||||||
const { $i18n } = useNuxtApp()
|
const { $i18n } = useNuxtApp()
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const { fetchAll, addLive, reset } = useNotifications()
|
const { fetchAll, addLive, reset, removeByUploads } = useNotifications()
|
||||||
const { pushLive } = useLiveComments()
|
const { pushLive } = useLiveComments()
|
||||||
const motd = useMotd()
|
const motd = useMotd()
|
||||||
const { applyEvent } = useCommentReactions()
|
const { applyEvent } = useCommentReactions()
|
||||||
@@ -34,6 +34,15 @@ export default defineNuxtPlugin(() => {
|
|||||||
else if (message.type === 'upload-reaction') {
|
else if (message.type === 'upload-reaction') {
|
||||||
applyUploadEvent(message as UploadReactionEventPayload)
|
applyUploadEvent(message as UploadReactionEventPayload)
|
||||||
}
|
}
|
||||||
|
else if (message.type === 'upload-deleted') {
|
||||||
|
const ids = Array.isArray(message.ids)
|
||||||
|
? message.ids.filter((i: unknown) => typeof i === 'string') as string[]
|
||||||
|
: []
|
||||||
|
if (ids.length) {
|
||||||
|
removeByUploads(ids)
|
||||||
|
refreshNuxtData(['uploads', 'latest-comments'])
|
||||||
|
}
|
||||||
|
}
|
||||||
else if (message.type === 'motd') {
|
else if (message.type === 'motd') {
|
||||||
motd.value = typeof message.message === 'string' ? message.message : ''
|
motd.value = typeof message.message === 'string' ? message.message : ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { unlink } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { eq, inArray } from 'drizzle-orm'
|
||||||
|
import { commentAttachments, comments, uploads } from '../../db/schema'
|
||||||
|
|
||||||
|
const ID_RE = /^[A-Za-z0-9]{1,16}$/
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requirePermission(event, 'upload.delete')
|
||||||
|
const body = await readBody<{ ids?: unknown }>(event)
|
||||||
|
const raw = Array.isArray(body?.ids) ? body.ids : []
|
||||||
|
const ids = [...new Set(raw.filter((x): x is string => typeof x === 'string' && ID_RE.test(x)))].slice(0, 200)
|
||||||
|
if (!ids.length) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Keine Uploads ausgewählt' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = useDb()
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: uploads.id, fileName: uploads.fileName, previewImage: uploads.previewImage })
|
||||||
|
.from(uploads)
|
||||||
|
.where(inArray(uploads.id, ids))
|
||||||
|
if (!rows.length) return { ok: true, deleted: 0 }
|
||||||
|
|
||||||
|
const found = rows.map(r => r.id)
|
||||||
|
const attachmentFiles = await db
|
||||||
|
.select({ fileName: commentAttachments.fileName, cover: commentAttachments.cover })
|
||||||
|
.from(commentAttachments)
|
||||||
|
.innerJoin(comments, eq(commentAttachments.commentId, comments.id))
|
||||||
|
.where(inArray(comments.uploadId, found))
|
||||||
|
|
||||||
|
await db.delete(uploads).where(inArray(uploads.id, found))
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.fileName) {
|
||||||
|
await unlink(join(uploadDir(), r.fileName)).catch(() => {})
|
||||||
|
}
|
||||||
|
if (r.previewImage?.startsWith('/uploads/')) {
|
||||||
|
await unlink(join(process.cwd(), 'public', r.previewImage)).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const f of attachmentFiles) {
|
||||||
|
await unlink(join(uploadDir(), 'attachments', f.fileName)).catch(() => {})
|
||||||
|
if (f.cover) {
|
||||||
|
await unlink(join(process.cwd(), 'public', f.cover)).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
broadcastToAll({ type: 'upload-deleted', ids: found })
|
||||||
|
return { ok: true, deleted: found.length }
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user