forked from BRETT/BRETT
- 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>
278 lines
8.8 KiB
Vue
278 lines
8.8 KiB
Vue
<script setup lang="ts">
|
|
import { useEventListener } from '@vueuse/core'
|
|
import { toast } from 'vue-sonner'
|
|
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,
|
|
PaginationEllipsis,
|
|
PaginationItem,
|
|
PaginationNext,
|
|
PaginationPrevious,
|
|
} from '@/components/ui/pagination'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { Spinner } from '@/components/ui/spinner'
|
|
|
|
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 { t } = useI18n()
|
|
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' },
|
|
],
|
|
})
|
|
|
|
// 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 { 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)
|
|
|
|
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()
|
|
})
|
|
|
|
async function deleteSelected() {
|
|
if (!selectedIds.value.size || bulkDeleting.value) return
|
|
bulkDeleting.value = true
|
|
try {
|
|
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')
|
|
}
|
|
catch {
|
|
toast.error(t('grid.deleteFailed'))
|
|
}
|
|
finally {
|
|
bulkDeleting.value = false
|
|
bulkConfirmOpen.value = false
|
|
}
|
|
}
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex flex-1 flex-col px-4 lg:px-6">
|
|
<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-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] }"
|
|
:selectable="selectMode"
|
|
:selected="selectedIds.has(upload.id)"
|
|
@select="toggleSelect(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
|
|
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>
|