b53c7fb736
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>
290 lines
13 KiB
Vue
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>
|