Refine admin panel workflows

This commit is contained in:
AzuTear
2026-06-18 14:29:40 +02:00
parent f4512eba2e
commit d015fcbe6f
14 changed files with 481 additions and 598 deletions
+85 -20
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ExternalLink, Film, PlayCircle, Search, Tags, Trash2, TriangleAlert, Users } from '@lucide/vue'
import { ExternalLink, Film, Layers3, PlayCircle, Search, Trash2, TriangleAlert, Users } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
@@ -12,6 +12,9 @@ import type { AdminClipSubmissionItem } from '../../types/awards'
const store = useAwardsStore()
const query = ref('')
const statusFilter = ref<'all' | 'pending' | 'reviewed'>('all')
const platformFilter = ref<'all' | string>('all')
const categoryFilter = ref('all')
const deleting = ref(false)
const adminMessage = ref('')
const adminError = ref('')
@@ -19,22 +22,60 @@ const clipToDelete = ref<AdminClipSubmissionItem | null>(null)
const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const submissions = computed(() => seasonDetail.value.clipSubmissions ?? [])
const categories = computed(() => seasonDetail.value.categories ?? [])
const categoryName = computed(() =>
Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category.name])),
Object.fromEntries(categories.value.map((category) => [category.id, category.name])),
)
const clips = computed(() => {
const search = query.value.trim().toLowerCase()
if (!search) return seasonDetail.value.clipSubmissions
return seasonDetail.value.clipSubmissions.filter((clip) =>
[clip.title, clip.creator, clip.platform, clip.submittedByTwitchId, clip.clipUrl].join(' ').toLowerCase().includes(search),
return submissions.value.filter((clip) =>
(statusFilter.value === 'all' || clip.status === statusFilter.value || (statusFilter.value === 'reviewed' && clip.status !== 'pending')) &&
(platformFilter.value === 'all' || clip.platform === platformFilter.value) &&
(categoryFilter.value === 'all' || String(clip.categoryId) === categoryFilter.value) &&
(!search || [clip.title, clip.creator, clip.platform, clip.submittedByTwitchId, clip.clipUrl].join(' ').toLowerCase().includes(search)),
)
})
const duplicateUrls = computed(() => {
const counts = new Map<string, number>()
for (const clip of submissions.value) {
const key = clip.clipUrl.trim().toLowerCase()
if (!key) continue
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return counts
})
const stats = computed(() => [
{ label: 'Einreichungen', value: seasonDetail.value.clipSubmissions.length, icon: Film },
{ label: 'Offen', value: seasonDetail.value.clipSubmissions.filter((clip) => clip.status === 'pending').length, icon: PlayCircle },
{ label: 'Clip-Kategorien', value: seasonDetail.value.categories.filter((category) => `${category.groupName} ${category.name}`.toLowerCase().includes('clip')).length, icon: Tags },
{ label: 'Einreichungen', value: submissions.value.length, icon: Film },
{ label: 'Offen', value: submissions.value.filter((clip) => clip.status === 'pending').length, icon: PlayCircle },
{ label: 'Duplikate', value: [...duplicateUrls.value.values()].filter((count) => count > 1).length, icon: Layers3 },
])
const statusFilters = computed(() => [
{ key: 'all' as const, label: 'Alle', count: submissions.value.length },
{ key: 'pending' as const, label: 'Offen', count: submissions.value.filter((clip) => clip.status === 'pending').length },
{ key: 'reviewed' as const, label: 'Geprüft', count: submissions.value.filter((clip) => clip.status !== 'pending').length },
])
const platformFilters = computed(() => [
{ key: 'all', label: 'Alle Plattformen', count: submissions.value.length },
...[...new Set(submissions.value.map((clip) => clip.platform).filter(Boolean))]
.sort()
.map((platform) => ({
key: platform,
label: platform,
count: submissions.value.filter((clip) => clip.platform === platform).length,
})),
])
const categoryFilters = computed(() => [
{ id: 'all' as const, label: 'Alle Kategorien', count: submissions.value.length },
...categories.value
.filter((category) => submissions.value.some((clip) => clip.categoryId === category.id))
.map((category) => ({
id: String(category.id),
label: category.name,
count: submissions.value.filter((clip) => clip.categoryId === category.id).length,
})),
])
function platformClass(platform: string) {
@@ -63,8 +104,8 @@ async function confirmDelete() {
<div class="space-y-6">
<AdminPageHeader
eyebrow="Clips"
title="Clip-Einreichungen moderieren"
description="Alle von der Community eingereichten Clips laufen hier auf. Sieh sie dir an, prüfe die Links und entferne Spam oder Duplikate."
title="Clip-Einreichungen triagieren"
description="Clips sind eine eigene Award-Arbeitsfläche: nach Kategorie, Plattform und Status filtern, Duplikate erkennen, Links prüfen und Spam oder falsche Einreichungen entfernen."
:icon="Film"
/>
@@ -85,15 +126,35 @@ async function confirmDelete() {
</section>
<Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5">
<label class="relative block">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input
v-model="query"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Titel, Creator, Plattform oder User suchen …"
/>
</label>
<div class="space-y-4 border-b border-violet-100 p-5">
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_220px_220px]">
<label class="relative block">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input
v-model="query"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Titel, Creator, Plattform, URL oder User suchen …"
/>
</label>
<select v-model="platformFilter" class="h-12 rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100">
<option v-for="filter in platformFilters" :key="filter.key" :value="filter.key">{{ filter.label }} · {{ filter.count }}</option>
</select>
<select v-model="categoryFilter" class="h-12 rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100">
<option v-for="filter in categoryFilters" :key="filter.id" :value="filter.id">{{ filter.label }} · {{ filter.count }}</option>
</select>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="filter in statusFilters"
:key="filter.key"
type="button"
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="statusFilter === filter.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="statusFilter = filter.key"
>
{{ filter.label }} · {{ filter.count }}
</button>
</div>
</div>
<p v-if="adminMessage" class="border-b border-emerald-100 bg-emerald-50 px-5 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
@@ -111,8 +172,12 @@ async function confirmDelete() {
<span v-if="clip.categoryId"> · {{ categoryName[clip.categoryId] }}</span>
· von {{ clip.submittedByTwitchId }}
</p>
<p v-if="duplicateUrls.get(clip.clipUrl.trim().toLowerCase()) && duplicateUrls.get(clip.clipUrl.trim().toLowerCase())! > 1" class="mt-1 text-xs font-semibold text-amber-700">
Mögliches Duplikat: diese URL wurde {{ duplicateUrls.get(clip.clipUrl.trim().toLowerCase()) }}x eingereicht.
</p>
</div>
<span :class="['shrink-0 rounded-full border px-3 py-1 text-xs font-semibold', platformClass(clip.platform)]">{{ clip.platform }}</span>
<span class="shrink-0 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-slate-600">{{ clip.status }}</span>
<a
:href="clip.clipUrl"
target="_blank"
@@ -133,7 +198,7 @@ async function confirmDelete() {
<div v-if="clips.length === 0" class="px-5 py-12 text-center">
<Users class="mx-auto h-6 w-6 text-violet-300" />
<p class="mt-2 text-sm text-slate-500">
{{ seasonDetail.clipSubmissions.length === 0 ? 'Noch keine Clip-Einreichungen in diesem Jahr.' : 'Keine Treffer für den aktuellen Filter.' }}
{{ submissions.length === 0 ? 'Noch keine Clip-Einreichungen in diesem Jahr.' : 'Keine Treffer für den aktuellen Filter.' }}
</p>
</div>
</div>