Refine admin panel workflows
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user