Align backend with frontend, make clips end-to-end, polish admin
Backend - Add ClipSubmission entity + table (runtime bootstrapper) and POST /api/public/clips (server-derives platform from the link) - Surface clip submissions in the admin season detail - Add DELETE candidate/category/clip endpoints with audit entries Frontend - Clips admin: real moderation view (list, open link, delete) instead of placeholder; wired clipSubmissions through types/api/store - Categories admin: add delete with confirm modal (matches Candidates) - Voting ranking bar uses the unified purple gradient - Fix ASCII transliterations to proper German umlauts across admin - Remove orphan AdminView 2.vue Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,57 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ExternalLink, Film, Search, Tags, Users } from '@lucide/vue'
|
||||
import { ExternalLink, Film, PlayCircle, Search, Tags, Trash2, TriangleAlert, Users } from '@lucide/vue'
|
||||
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||
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 deleting = ref(false)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
const clipToDelete = ref<AdminClipSubmissionItem | null>(null)
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const clipCategories = computed(() =>
|
||||
seasonDetail.value.categories.filter((category) => `${category.groupName} ${category.name}`.toLowerCase().includes('clip')),
|
||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||
const categoryName = computed(() =>
|
||||
Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category.name])),
|
||||
)
|
||||
const clipCategoryIds = computed(() => new Set(clipCategories.value.map((category) => category.id)))
|
||||
const clipCandidates = computed(() => {
|
||||
|
||||
const clips = computed(() => {
|
||||
const search = query.value.trim().toLowerCase()
|
||||
return seasonDetail.value.candidates
|
||||
.filter((candidate) => clipCategoryIds.value.has(candidate.categoryId))
|
||||
.filter((candidate) => !search || [candidate.displayName, candidate.channelSlug, candidate.platform].join(' ').toLowerCase().includes(search))
|
||||
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),
|
||||
)
|
||||
})
|
||||
const candidateCategory = computed(() => Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category.name])))
|
||||
const clipReviewCount = computed(() => seasonDetail.value.pendingNominations.filter((nomination) => clipCategoryIds.value.has(nomination.categoryId)).length)
|
||||
const clipReadiness = computed(() => [
|
||||
{
|
||||
label: 'Clip-Kategorie existiert',
|
||||
done: clipCategories.value.length > 0,
|
||||
note: clipCategories.value.length > 0 ? `${clipCategories.value.length} Clip-Kategorien gefunden.` : 'Lege mindestens eine Clip-Kategorie an.',
|
||||
},
|
||||
{
|
||||
label: 'Kandidaten vorhanden',
|
||||
done: clipCandidates.value.length > 0,
|
||||
note: clipCandidates.value.length > 0 ? `${clipCandidates.value.length} Clip-Kandidaten gepflegt.` : 'Noch keine Clip-Kandidaten vorhanden.',
|
||||
},
|
||||
{
|
||||
label: 'Review-Restbestand',
|
||||
done: clipReviewCount.value === 0,
|
||||
note: clipReviewCount.value === 0 ? 'Keine offenen Clip-Reviews.' : `${clipReviewCount.value} Clip-Reviews offen.`,
|
||||
},
|
||||
])
|
||||
|
||||
const stats = computed(() => [
|
||||
{ label: 'Clip-Kategorien', value: clipCategories.value.length, icon: Tags },
|
||||
{ label: 'Clip-Kandidaten', value: clipCandidates.value.length, icon: Users },
|
||||
{ label: 'Offene Reviews', value: clipReviewCount.value, icon: Film },
|
||||
{ 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 },
|
||||
])
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<AdminPageHeader
|
||||
eyebrow="Clips"
|
||||
title="Clip-Kategorien im Blick behalten"
|
||||
description="Bis ein eigener Clip-Review-Endpunkt existiert, zeigt diese Seite die operativen Clip-Kategorien, Kandidaten und offenen Review-Faelle kompakt an."
|
||||
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."
|
||||
:icon="Film"
|
||||
/>
|
||||
|
||||
@@ -71,50 +84,77 @@ const stats = computed(() => [
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-6 xl:grid-cols-[0.86fr_1.14fr]">
|
||||
<Card class="p-6">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Workflow</p>
|
||||
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was hier geprueft wird</h2>
|
||||
<div class="mt-5 space-y-3">
|
||||
<div
|
||||
v-for="item in clipReadiness"
|
||||
:key="item.label"
|
||||
class="rounded-2xl border p-4"
|
||||
:class="item.done ? 'border-emerald-100 bg-emerald-50/40' : 'border-amber-100 bg-amber-50/60'"
|
||||
>
|
||||
<p class="font-semibold text-slate-900">{{ item.label }}</p>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">{{ item.note }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-amber-100 bg-amber-50/70 p-4">
|
||||
<p class="font-semibold text-amber-800">Naechster Backend-Ausbau</p>
|
||||
<p class="mt-1 text-sm leading-6 text-amber-700">Fuer echte Clip-Moderation brauchen wir spaeter eine ClipSubmission-Admin-API mit Status, Duplikaten und Entscheidung.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<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>
|
||||
|
||||
<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="Clip-Kandidat, Handle oder Plattform suchen" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="divide-y divide-violet-50">
|
||||
<div v-for="candidate in clipCandidates" :key="candidate.id" class="grid gap-3 px-5 py-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-slate-900">{{ candidate.displayName }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ candidate.channelSlug }} · {{ candidate.platform }} · {{ candidateCategory[candidate.categoryId] }}</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-700">
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
Clip-Link spaeter
|
||||
</span>
|
||||
<p v-if="adminMessage" class="border-b border-emerald-100 bg-emerald-50 px-5 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
|
||||
<p v-if="adminError" class="border-b border-rose-100 bg-rose-50 px-5 py-3 text-sm text-rose-700">{{ adminError }}</p>
|
||||
|
||||
<div class="divide-y divide-violet-50">
|
||||
<div v-for="clip in clips" :key="clip.id" class="flex flex-col gap-3 px-5 py-4 lg:flex-row lg:items-center lg:gap-4">
|
||||
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[linear-gradient(135deg,#ece2ff,#fff2dd)] text-violet-600">
|
||||
<Film class="h-5 w-5" />
|
||||
</div>
|
||||
<p v-if="clipCandidates.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
|
||||
Keine Clip-Kandidaten gefunden. Lege zuerst eine Clip-Kategorie und passende Kandidaten an.
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-semibold text-slate-900">{{ clip.title || 'Ohne Titel' }}</p>
|
||||
<p class="truncate text-sm text-slate-500">
|
||||
{{ clip.creator || 'Unbekannt' }}
|
||||
<span v-if="clip.categoryId"> · {{ categoryName[clip.categoryId] }}</span>
|
||||
· von {{ clip.submittedByTwitchId }}
|
||||
</p>
|
||||
</div>
|
||||
<span :class="['shrink-0 rounded-full border px-3 py-1 text-xs font-semibold', platformClass(clip.platform)]">{{ clip.platform }}</span>
|
||||
<a
|
||||
:href="clip.clipUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
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>
|
||||
|
||||
<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.' }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Modal :open="!!clipToDelete" title="Clip entfernen?" @close="clipToDelete = null">
|
||||
<div class="flex items-start gap-4">
|
||||
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-rose-50 text-rose-500">
|
||||
<TriangleAlert class="h-6 w-6" />
|
||||
</span>
|
||||
<p class="text-sm leading-7 text-slate-600">
|
||||
Die Einreichung „<strong class="text-slate-800">{{ clipToDelete?.title || clipToDelete?.clipUrl }}</strong>" wird entfernt.
|
||||
Das lässt sich nicht rückgängig machen.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="clipToDelete = null">Abbrechen</Button>
|
||||
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="confirmDelete">
|
||||
{{ deleting ? 'Entfernt …' : 'Entfernen' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user