Files
vtuber-awards/frontend/src/views/admin/AdminClipsView.vue
T
AzuTear b53c7fb736 Add viewer-range categories, nomination tracking, dynamic showact form, session timeout, share URLs, and workflow-per-season
Features:
- Category viewer ranges + subcategory templates (admin group modal, tree workspace)
- Nomination enrichment via TwitchTracker API (NominationEnrichmentService,
  TwitchTrackerViewerStatsProvider) with admin tracking rules editor
- Nomination group tracker: CategoryGroupName as primary identifier,
  CategoryId stays as nullable legacy field; StreamerIdentity table
- Dynamic showact application form builder (AdminShowactFormBuilder,
  ShowactApplicationSchedule)
- Session idle timeout setting (AdminSessionTimeoutCard)
- Share URLs for X and Discord (SiteSettings, public extras)
- Workflow rules now stored per season (falls back to global SiteSettings)
- New admin routes: settings/access, settings/workflows, tracking-rules
- New admin review workspace with subcategory tabs
- AdminCategoriesView rebuilt with group/subcategory modals

Migrations (all additive):
- AddShareUrls, AddShowactDynamicForm, AddCategoryViewerRanges,
  AddSessionIdleTimeoutSettings, AddSeasonSubcategoryTemplates,
  AddNominationGroupTrackerIdentity, AddShowactApplicationSchedule,
  AddSeasonWorkflowRulesJson

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 23:32:21 +02:00

290 lines
13 KiB
Vue

<script setup lang="ts">
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 { watchAdminToast } from '../../composables/useAdminToast'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
import NativeSelect from '../../components/ui/NativeSelect.vue'
import PaginationFooter from '../../components/ui/PaginationFooter.vue'
const {
query,
statusFilter,
platformFilter,
categoryFilter,
deleting,
statusSaving,
bulkSaving,
adminMessage,
adminError,
clipToDelete,
reviewNotes,
clipSubmissionsEnabled,
clipReviewEnabled,
clipDisabledMessage,
clipWorkflowStatusLabel,
clipWorkflowStatusClass,
submissions,
categoryName,
clips,
page,
totalPages,
pagedClips,
rangeStart,
rangeEnd,
clipEmbeds,
stats,
statusFilters,
platformFilters,
categoryFilters,
platformClass,
statusClass,
statusLabel,
duplicateUrlCount,
creatorClipCount,
updateClipStatus,
bulkUpdateStatus,
confirmDelete,
} = useAdminClipManager()
watchAdminToast(adminMessage, adminError)
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Clips"
:icon="Film"
:description="clipWorkflowStatusLabel"
/>
<AdminSeasonToolbar />
<div
class="flex flex-wrap items-start justify-between gap-4 rounded-2xl border px-5 py-4 shadow-sm"
:class="clipSubmissionsEnabled ? 'border-emerald-100 bg-emerald-50/70' : 'border-amber-100 bg-amber-50/70'"
>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span :class="['rounded-full border px-3 py-1 text-xs font-semibold', clipWorkflowStatusClass]">
{{ clipWorkflowStatusLabel }}
</span>
<span :class="['rounded-full border px-3 py-1 text-xs font-semibold', clipReviewEnabled ? 'border-violet-200 bg-white text-violet-700' : 'border-slate-200 bg-white text-slate-600']">
Admin-Review {{ clipReviewEnabled ? 'sichtbar' : 'aus' }}
</span>
</div>
<p class="mt-2 text-sm leading-6 text-slate-600">
Clip-Einreichungen sind optional. Finale Voting-Clips werden am Kandidaten als externe YouTube-/Twitch-Links gepflegt.
</p>
<p v-if="!clipSubmissionsEnabled" class="mt-1 text-xs font-semibold text-amber-800">
Public-Hinweis: {{ clipDisabledMessage }}
</p>
</div>
</div>
<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>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">{{ stat.label }}</p>
<strong class="mt-3 block text-3xl text-violet-900">{{ stat.value }}</strong>
</div>
<div class="grid h-10 w-10 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<component :is="stat.icon" class="h-5 w-5" />
</div>
</div>
</Card>
</section>
<Card class="overflow-hidden">
<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>
<NativeSelect
v-model="platformFilter"
:options="platformFilters.map((filter) => ({ label: `${filter.label} · ${filter.count}`, value: filter.key }))"
/>
<NativeSelect
v-model="categoryFilter"
:options="categoryFilters.map((filter) => ({ label: `${filter.label} · ${filter.count}`, value: filter.id }))"
/>
</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>
<div v-if="clips.length > 0" class="flex flex-wrap items-center justify-between gap-3 border-b border-violet-100 bg-violet-50/40 px-5 py-3">
<p class="text-sm text-slate-600">
<strong class="text-violet-800">{{ clips.length }}</strong>
{{ clips.length === 1 ? 'Clip' : 'Clips' }} in dieser Ansicht
</p>
<div class="flex flex-wrap gap-2">
<Button
class="gap-1.5 !bg-emerald-600 hover:!bg-emerald-500"
size="sm"
:disabled="bulkSaving || statusSaving !== null"
@click="bulkUpdateStatus('approved')"
>
<CheckCircle2 class="h-3.5 w-3.5" />
{{ bulkSaving ? 'Verarbeite …' : 'Alle freigeben' }}
</Button>
<Button
class="gap-1.5 !bg-rose-600 hover:!bg-rose-500"
size="sm"
:disabled="bulkSaving || statusSaving !== null"
@click="bulkUpdateStatus('rejected')"
>
<XCircle class="h-3.5 w-3.5" />
Alle ablehnen
</Button>
</div>
</div>
<div class="divide-y divide-violet-50">
<div v-for="clip in pagedClips" :key="clip.id" class="grid gap-4 px-5 py-5 xl:grid-cols-[minmax(0,1fr)_minmax(320px,420px)_320px] xl:items-start">
<div class="flex min-w-0 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>
<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 class="mt-3 flex flex-wrap gap-2">
<span :class="['rounded-full border px-3 py-1 text-xs font-semibold', platformClass(clip.platform)]">{{ clip.platform }}</span>
<span :class="['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 items-center gap-1.5 rounded-full border border-violet-200 px-3 py-1 text-xs font-semibold text-violet-700 transition hover:bg-violet-50"
>
<ExternalLink class="h-3.5 w-3.5" /> Clip öffnen
</a>
</div>
<p v-if="duplicateUrlCount(clip.clipUrl) > 1" class="mt-3 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>
</div>
<div class="min-w-0">
<div v-if="clipEmbeds[clip.id]">
<div class="overflow-hidden rounded-2xl border border-violet-100 bg-slate-950 shadow-sm">
<iframe
class="aspect-video w-full"
:src="clipEmbeds[clip.id]?.src"
:title="clip.title || `${clip.platform} Clip ${clip.id}`"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture; web-share"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"
/>
</div>
<p class="mt-2 text-xs text-slate-400">
Falls der Clip nicht lädt oder nicht mehr verfügbar ist, öffne ihn direkt über den Clip-Link.
</p>
</div>
<div v-else class="rounded-2xl border border-dashed border-amber-200 bg-amber-50 px-4 py-5 text-sm font-medium leading-6 text-amber-800">
Dieser Clip kann nicht eingebettet werden. Öffne ihn über den Clip-Link zur manuellen Prüfung.
</div>
</div>
<div class="w-full">
<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">
<Users class="mx-auto h-6 w-6 text-violet-300" />
<p class="mt-2 text-sm text-slate-500">
{{ submissions.length === 0 && !clipSubmissionsEnabled ? 'Clip-Einreichungen sind deaktiviert. Bestehende Clips würden hier weiterhin prüfbar bleiben.' : submissions.length === 0 ? 'Noch keine Clip-Einreichungen in diesem Jahr.' : 'Keine Treffer für den aktuellen Filter.' }}
</p>
</div>
</div>
<PaginationFooter
:page="page"
:total-pages="totalPages"
:range-start="rangeStart"
:range-end="rangeEnd"
:filtered-count="clips.length"
@update:page="page = $event"
/>
</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>