Refactor app architecture and clean local artifacts
This commit is contained in:
@@ -1,117 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ExternalLink, Film, Layers3, PlayCircle, Search, Trash2, TriangleAlert, Users } from '@lucide/vue'
|
||||
import { CheckCircle2, ExternalLink, Film, Search, Trash2, TriangleAlert, Undo2, Users, XCircle } from '@lucide/vue'
|
||||
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||
import { useAdminClipManager } from '../../components/admin/useAdminClipManager'
|
||||
import Button from '../../components/ui/Button.vue'
|
||||
import Card from '../../components/ui/Card.vue'
|
||||
import Modal from '../../components/ui/Modal.vue'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
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('')
|
||||
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(categories.value.map((category) => [category.id, category.name])),
|
||||
)
|
||||
|
||||
const clips = computed(() => {
|
||||
const search = query.value.trim().toLowerCase()
|
||||
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: 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) {
|
||||
if (platform === 'Twitch') return 'border-violet-200 bg-violet-50 text-violet-700'
|
||||
if (platform === 'YouTube') return 'border-rose-200 bg-rose-50 text-rose-600'
|
||||
return 'border-slate-200 bg-slate-50 text-slate-600'
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!clipToDelete.value || !selectedSeasonId.value) return
|
||||
deleting.value = true
|
||||
adminError.value = ''
|
||||
try {
|
||||
await store.deleteAdminClip(clipToDelete.value.id, selectedSeasonId.value)
|
||||
adminMessage.value = 'Clip-Einreichung wurde entfernt.'
|
||||
clipToDelete.value = null
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
const {
|
||||
query,
|
||||
statusFilter,
|
||||
platformFilter,
|
||||
categoryFilter,
|
||||
deleting,
|
||||
statusSaving,
|
||||
adminMessage,
|
||||
adminError,
|
||||
clipToDelete,
|
||||
reviewNotes,
|
||||
submissions,
|
||||
categoryName,
|
||||
clips,
|
||||
stats,
|
||||
statusFilters,
|
||||
platformFilters,
|
||||
categoryFilters,
|
||||
platformClass,
|
||||
statusClass,
|
||||
statusLabel,
|
||||
duplicateUrlCount,
|
||||
creatorClipCount,
|
||||
updateClipStatus,
|
||||
confirmDelete,
|
||||
} = useAdminClipManager()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<AdminPageHeader
|
||||
eyebrow="Clips"
|
||||
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"
|
||||
/>
|
||||
|
||||
<AdminSeasonToolbar />
|
||||
|
||||
<section class="grid gap-4 lg:grid-cols-3">
|
||||
<section class="grid gap-4 lg:grid-cols-4">
|
||||
<Card v-for="stat in stats" :key="stat.label" class="p-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
@@ -172,27 +106,56 @@ 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 v-if="duplicateUrlCount(clip.clipUrl) > 1" class="mt-1 text-xs font-semibold text-amber-700">
|
||||
Mögliches Duplikat: diese URL wurde {{ duplicateUrlCount(clip.clipUrl) }}x eingereicht.
|
||||
</p>
|
||||
<p v-if="creatorClipCount(clip) > 1" class="mt-1 text-xs font-semibold text-violet-700">
|
||||
Sammelpunkt: {{ creatorClipCount(clip) }} Clips für diese Person oder diesen Kandidaten.
|
||||
</p>
|
||||
<p v-if="clip.reviewedAt" class="mt-1 text-xs text-slate-500">
|
||||
Zuletzt geprüft von {{ clip.reviewedByTwitchId || 'Admin' }} · {{ new Date(clip.reviewedAt).toLocaleString('de-DE') }}
|
||||
</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>
|
||||
<span :class="['shrink-0 rounded-full border px-3 py-1 text-xs font-semibold', statusClass(clip.status)]">{{ statusLabel(clip.status) }}</span>
|
||||
<a
|
||||
:href="clip.clipUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
referrerpolicy="no-referrer"
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-violet-200 px-3 py-1.5 text-xs font-semibold text-violet-700 transition hover:bg-violet-50"
|
||||
>
|
||||
<ExternalLink class="h-3.5 w-3.5" /> Clip öffnen
|
||||
</a>
|
||||
<button
|
||||
class="grid h-9 w-9 shrink-0 place-items-center rounded-full border border-rose-200 text-rose-500 transition hover:bg-rose-50"
|
||||
title="Einreichung entfernen"
|
||||
@click="clipToDelete = clip"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
<div class="w-full lg:w-[320px]">
|
||||
<textarea
|
||||
v-model="reviewNotes[clip.id]"
|
||||
rows="2"
|
||||
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Moderationsnotiz für Team oder spätere Rückfragen"
|
||||
/>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<Button variant="secondary" class="gap-1.5" :disabled="statusSaving === clip.id" @click="updateClipStatus(clip, 'pending')">
|
||||
<Undo2 class="h-4 w-4" />
|
||||
Zurück auf offen
|
||||
</Button>
|
||||
<Button class="gap-1.5 !bg-emerald-600 hover:!bg-emerald-500" :disabled="statusSaving === clip.id" @click="updateClipStatus(clip, 'approved')">
|
||||
<CheckCircle2 class="h-4 w-4" />
|
||||
Freigeben
|
||||
</Button>
|
||||
<Button class="gap-1.5 !bg-rose-600 hover:!bg-rose-500" :disabled="statusSaving === clip.id" @click="updateClipStatus(clip, 'rejected')">
|
||||
<XCircle class="h-4 w-4" />
|
||||
Ablehnen
|
||||
</Button>
|
||||
<button
|
||||
class="grid h-10 w-10 place-items-center rounded-full border border-rose-200 text-rose-500 transition hover:bg-rose-50"
|
||||
title="Einreichung entfernen"
|
||||
@click="clipToDelete = clip"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="clips.length === 0" class="px-5 py-12 text-center">
|
||||
|
||||
Reference in New Issue
Block a user