Improve admin candidate modal UX and add clip menu visibility toggle

- Widen AdminCandidateEditorModal to size lg for better readability
- Rename "Clip-Compilation" section to "Clip / Compilation", update copy to reflect single clips too, drop upload hint and Clip-Plattform field, rename label to "Link"
- Fix NativeSelect dropdown clipping inside overflow-y-auto modals by teleporting the menu to body with fixed positioning, flip-up logic, and dynamic maxHeight capped to viewport
- Add ClipAdminMenuVisible setting (backend domain, contracts, endpoint, migration) with matching frontend types, defaults, form wiring, and toggle in the Clip-Workflow modal — hides the Clips nav item from the admin sidebar when disabled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AzuTear
2026-06-27 18:39:35 +02:00
parent 494eba5edd
commit 18b61bed52
119 changed files with 15638 additions and 367 deletions
@@ -1,5 +1,5 @@
<template>
<Modal :open="open" :title="title" subtitle="Anzeigename und Handle sind Pflicht." @close="$emit('close')">
<Modal :open="open" size="lg" :title="title" subtitle="Anzeigename und Handle sind Pflicht. Annahme und Clip bereiten das Voting vor." @close="$emit('close')">
<div class="space-y-4">
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kategorie</span>
@@ -30,6 +30,70 @@
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Eigene Plattform</span>
<input :value="form.platform" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z. B. Cake, Booth, neue Plattform" @input="$emit('update:platform', ($event.target as HTMLInputElement).value)" />
</label>
<section class="rounded-2xl border border-violet-100 bg-violet-50/40 p-4">
<div class="mb-3">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Annahmestatus</p>
<p class="mt-1 text-sm text-slate-500">Kontakt und Zusage werden hier gepflegt, nicht im Review-Text versteckt.</p>
</div>
<div class="grid gap-2 sm:grid-cols-4">
<button
v-for="status in acceptanceStatusOptions"
:key="status.value"
type="button"
class="rounded-2xl border px-3 py-2 text-left text-sm transition"
:class="form.acceptanceStatus === status.value ? 'border-violet-400 bg-white text-violet-800 shadow-sm' : 'border-violet-100 bg-white/70 text-slate-600 hover:border-violet-200'"
@click="$emit('update:acceptanceStatus', status.value)"
>
<strong class="block text-sm">{{ status.label }}</strong>
<span class="text-xs">{{ status.description }}</span>
</button>
</div>
<label class="mt-4 block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Teamnotiz</span>
<textarea
:value="form.acceptanceNote"
rows="3"
maxlength="500"
class="w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="z. B. Discord kontaktiert, wartet auf Zusage."
@input="$emit('update:acceptanceNote', ($event.target as HTMLTextAreaElement).value)"
/>
</label>
<p v-if="form.acceptanceStatus === 'declined'" class="mt-3 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
Diese Person ist nicht voting-bereit. Bitte Ersatz prüfen oder Kandidat entfernen.
</p>
</section>
<section class="rounded-2xl border border-violet-100 bg-white p-4">
<div class="mb-3 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Clip / Compilation</p>
<p class="mt-1 text-sm text-slate-500">Einzelner Clip oder Compilation YouTube- oder Twitch-Link.</p>
</div>
<a
v-if="form.clipCompilationUrl"
:href="form.clipCompilationUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center justify-center rounded-full border border-violet-200 px-3 py-1.5 text-xs font-semibold text-violet-700 hover:bg-violet-50"
>
Link öffnen
</a>
</div>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Link</span>
<input :value="form.clipCompilationUrl" type="url" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="https://youtu.be/..." @input="$emit('update:clipCompilationUrl', ($event.target as HTMLInputElement).value)" />
</label>
<label class="mt-4 block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Titel im Voting</span>
<input :value="form.clipCompilationTitle" type="text" maxlength="200" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Best-of Clip für das Voting" @input="$emit('update:clipCompilationTitle', ($event.target as HTMLInputElement).value)" />
</label>
<label class="mt-4 block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Embed-Status</span>
<NativeSelect :model-value="form.clipEmbedStatus" :options="clipEmbedStatusOptions" @update:model-value="$emit('update:clipEmbedStatus', String($event))" />
</label>
</section>
</div>
<template #footer>
<Button variant="ghost" @click="$emit('close')">Abbrechen</Button>
@@ -52,9 +116,17 @@ defineProps<{
displayName: string
channelSlug: string
platform: string
acceptanceStatus: string
acceptanceNote: string
clipCompilationUrl: string
clipCompilationTitle: string
clipCompilationPlatform: string
clipEmbedStatus: string
}
categoryOptions: Array<{ label: string; value: number }>
candidatePlatformOptions: SocialIconOption[]
acceptanceStatusOptions: Array<{ label: string; value: string; description: string }>
clipEmbedStatusOptions: Array<{ label: string; value: string }>
selectedPlatformValue: string
canSave: boolean
saving: boolean
@@ -67,6 +139,12 @@ defineEmits<{
'update:displayName': [value: string]
'update:channelSlug': [value: string]
'update:platform': [value: string]
'update:acceptanceStatus': [value: string]
'update:acceptanceNote': [value: string]
'update:clipCompilationUrl': [value: string]
'update:clipCompilationTitle': [value: string]
'update:clipCompilationPlatform': [value: string]
'update:clipEmbedStatus': [value: string]
'platform-selection': [value: string]
}>()
</script>
@@ -15,7 +15,12 @@
:options="categoryFilterOptions"
@update:model-value="$emit('update:categoryFilter', $event === null ? null : Number($event))"
/>
<Button v-if="search || categoryFilter" variant="ghost" class="gap-1" @click="$emit('clear-filters')">
<NativeSelect
:model-value="readinessFilter"
:options="readinessFilterOptions"
@update:model-value="$emit('update:readinessFilter', String($event))"
/>
<Button v-if="search || categoryFilter || readinessFilter !== 'all'" variant="ghost" class="gap-1" @click="$emit('clear-filters')">
<X class="h-4 w-4" /> Filter
</Button>
<Button class="gap-2" @click="$emit('open-create')">
@@ -33,12 +38,15 @@ import NativeSelect from '../ui/NativeSelect.vue'
defineProps<{
search: string
categoryFilter: number | null
readinessFilter: string
categoryFilterOptions: Array<{ label: string; value: number | null }>
readinessFilterOptions: Array<{ label: string; value: string }>
}>()
defineEmits<{
'update:search': [value: string]
'update:categoryFilter': [value: number | null]
'update:readinessFilter': [value: string]
'clear-filters': []
'open-create': []
}>()
@@ -1,8 +1,10 @@
<template>
<div>
<div class="hidden grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)_120px_96px] gap-4 border-b border-violet-100 bg-violet-50/40 px-6 py-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-violet-500 lg:grid">
<div class="hidden grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)_130px_minmax(0,1.2fr)_110px_96px] gap-4 border-b border-violet-100 bg-violet-50/40 px-6 py-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-violet-500 xl:grid">
<span>Kandidat</span>
<span>Kategorie</span>
<span>Annahme</span>
<span>Clip-Compilation</span>
<span>Plattform</span>
<span class="text-right">Aktionen</span>
</div>
@@ -11,7 +13,7 @@
<div
v-for="candidate in pagedCandidates"
:key="candidate.id"
class="grid grid-cols-1 gap-3 px-6 py-4 transition hover:bg-violet-50/40 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)_120px_96px] lg:items-center lg:gap-4"
class="grid grid-cols-1 gap-3 px-6 py-4 transition hover:bg-violet-50/40 xl:grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)_130px_minmax(0,1.2fr)_110px_96px] xl:items-center xl:gap-4"
>
<div class="flex min-w-0 items-center gap-3">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[linear-gradient(135deg,#c4b5fd,#f5a9d6)] text-sm font-semibold text-white">
@@ -23,6 +25,14 @@
<p v-if="isDuplicate(candidate)" class="mt-1 text-xs font-semibold text-amber-700">
Mögliches Duplikat in dieser Kategorie
</p>
<p
v-for="notice in candidateWorkflowNotices[candidate.id] ?? []"
:key="notice.message"
class="mt-1 text-xs font-semibold"
:class="notice.mode === 'block' ? 'text-rose-700' : 'text-amber-700'"
>
{{ notice.mode === 'block' ? 'Regel blockiert' : 'Regelhinweis' }}: {{ notice.message }}
</p>
</div>
</div>
<div class="min-w-0">
@@ -30,6 +40,31 @@
{{ categoryLabelMap[candidate.categoryId] || 'Ohne Kategorie' }}
</span>
</div>
<div>
<span class="inline-flex rounded-full border px-3 py-1 text-xs font-semibold" :class="acceptanceClass(candidate.acceptanceStatus)">
{{ acceptanceLabel(candidate.acceptanceStatus) }}
</span>
<p v-if="candidate.acceptanceNote" class="mt-1 line-clamp-2 text-xs text-slate-500">{{ candidate.acceptanceNote }}</p>
</div>
<div class="min-w-0">
<template v-if="candidate.clipCompilationUrl && candidate.clipEmbedStatus !== 'blocked'">
<a
:href="candidate.clipCompilationUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex max-w-full items-center gap-1 truncate text-sm font-semibold text-violet-700 hover:text-violet-900"
>
<ExternalLink class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ candidate.clipCompilationTitle || 'Compilation öffnen' }}</span>
</a>
<p class="mt-1 text-xs text-slate-500">
{{ candidate.clipCompilationPlatform || 'Plattform offen' }} · {{ embedLabel(candidate.clipEmbedStatus) }}
</p>
</template>
<p v-else class="text-xs font-semibold text-amber-700">
{{ candidate.clipEmbedStatus === 'blocked' ? 'Clip nicht nutzbar' : 'Clip fehlt' }}
</p>
</div>
<div>
<span class="rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">{{ candidate.platform }}</span>
</div>
@@ -71,7 +106,7 @@
</template>
<script setup lang="ts">
import { Pencil, Trash2, UserPlus } from '@lucide/vue'
import { ExternalLink, Pencil, Trash2, UserPlus } from '@lucide/vue'
import type { AdminCandidateItem } from '../../types/awards'
import Button from '../ui/Button.vue'
@@ -87,6 +122,8 @@ const props = defineProps<{
rangeEnd: number
categoryLabelMap: Record<number, string>
duplicateCandidateKeys: Map<string, number>
candidateWorkflowNotices: Record<number, Array<{ mode: 'warn' | 'block'; message: string }>>
acceptanceStatusOptions: Array<{ label: string; value: string; description: string }>
}>()
defineEmits<{
@@ -100,4 +137,22 @@ function isDuplicate(candidate: AdminCandidateItem) {
return (props.duplicateCandidateKeys.get(`${candidate.categoryId}:name:${candidate.displayName.trim().toLowerCase()}`) ?? 0) > 1
|| (props.duplicateCandidateKeys.get(`${candidate.categoryId}:slug:${candidate.channelSlug.trim().toLowerCase()}`) ?? 0) > 1
}
function acceptanceLabel(value: string) {
return props.acceptanceStatusOptions.find((option) => option.value === value)?.label ?? 'Offen'
}
function acceptanceClass(value: string) {
if (value === 'accepted') return 'border-emerald-200 bg-emerald-50 text-emerald-700'
if (value === 'contacted') return 'border-sky-200 bg-sky-50 text-sky-700'
if (value === 'declined') return 'border-rose-200 bg-rose-50 text-rose-700'
return 'border-slate-200 bg-slate-50 text-slate-600'
}
function embedLabel(value: string) {
if (value === 'embeddable') return 'Einbettbar'
if (value === 'link_only') return 'Nur Link'
if (value === 'blocked') return 'Nicht nutzbar'
return 'Embed prüfen'
}
</script>
@@ -0,0 +1,393 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Handshake, Mic2, Plus, Save, Settings2, Trash2 } from '@lucide/vue'
import { useAwardsStore } from '../../stores/awards'
import type { AdminSponsorItem, UpsertSponsorPayload } from '../../types/awards'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import AdminSettingsToggle from './AdminSettingsToggle.vue'
const store = useAwardsStore()
const loading = ref(false)
const saving = ref(false)
const statusMessage = ref('')
const statusError = ref('')
const editingSponsorId = ref<number | null>(null)
const showactNotes = reactive<Record<number, string>>({})
const settingsForm = reactive({
showactApplicationsEnabled: false,
showactApplicationDisabledMessage: 'Showact-Bewerbungen sind aktuell geschlossen.',
sponsorsVisible: true,
})
const sponsorForm = reactive<UpsertSponsorPayload>({
name: '',
websiteUrl: '',
logoUrl: '',
description: '',
tier: 'Partner',
sortOrder: 0,
isVisible: true,
})
const seasonId = computed(() => store.adminSelectedSeasonId ?? (store.adminSeasonDetail.id || store.overview.seasonId))
const pendingShowacts = computed(() => store.adminShowactApplications.filter((item) => item.status === 'pending').length)
const visibleSponsors = computed(() => store.adminSponsors.filter((item) => item.isVisible).length)
watch(
() => store.adminOptionalFeatureSettings,
(settings) => {
settingsForm.showactApplicationsEnabled = settings.showactApplicationsEnabled
settingsForm.showactApplicationDisabledMessage = settings.showactApplicationDisabledMessage || 'Showact-Bewerbungen sind aktuell geschlossen.'
settingsForm.sponsorsVisible = settings.sponsorsVisible
},
{ immediate: true, deep: true },
)
function resetSponsorForm() {
editingSponsorId.value = null
sponsorForm.name = ''
sponsorForm.websiteUrl = ''
sponsorForm.logoUrl = ''
sponsorForm.description = ''
sponsorForm.tier = 'Partner'
sponsorForm.sortOrder = store.adminSponsors.length + 1
sponsorForm.isVisible = true
}
function editSponsor(sponsor: AdminSponsorItem) {
editingSponsorId.value = sponsor.id
sponsorForm.name = sponsor.name
sponsorForm.websiteUrl = sponsor.websiteUrl
sponsorForm.logoUrl = sponsor.logoUrl
sponsorForm.description = sponsor.description
sponsorForm.tier = sponsor.tier
sponsorForm.sortOrder = sponsor.sortOrder
sponsorForm.isVisible = sponsor.isVisible
}
async function ensureSeasonId() {
if (seasonId.value) return seasonId.value
await store.loadHomeData()
return store.overview.seasonId
}
async function loadLandingExtras() {
loading.value = true
statusError.value = ''
try {
const resolvedSeasonId = await ensureSeasonId()
await Promise.all([
store.loadAdminOptionalFeatureSettings(),
resolvedSeasonId ? store.loadAdminExtras(resolvedSeasonId) : Promise.resolve(),
])
for (const application of store.adminShowactApplications) {
showactNotes[application.id] = application.reviewNote ?? ''
}
resetSponsorForm()
} catch (error) {
statusError.value = error instanceof Error ? error.message : 'Landingpage-Extras konnten nicht geladen werden.'
} finally {
loading.value = false
}
}
async function saveLandingExtrasSettings() {
saving.value = true
statusMessage.value = ''
statusError.value = ''
try {
await store.updateAdminOptionalFeatureSettings({
clipSubmissionsEnabled: store.adminOptionalFeatureSettings.clipSubmissionsEnabled,
clipReviewEnabled: store.adminOptionalFeatureSettings.clipReviewEnabled,
clipSubmissionDisabledMessage: store.adminOptionalFeatureSettings.clipSubmissionDisabledMessage,
showactApplicationsEnabled: settingsForm.showactApplicationsEnabled,
showactApplicationDisabledMessage: settingsForm.showactApplicationDisabledMessage,
sponsorsVisible: settingsForm.sponsorsVisible,
})
statusMessage.value = 'Landingpage-Extras wurden gespeichert.'
} catch (error) {
statusError.value = error instanceof Error ? error.message : 'Landingpage-Extras konnten nicht gespeichert werden.'
} finally {
saving.value = false
}
}
async function saveSponsor() {
const resolvedSeasonId = await ensureSeasonId()
if (!resolvedSeasonId) return
saving.value = true
statusMessage.value = ''
statusError.value = ''
try {
if (editingSponsorId.value) {
await store.updateAdminSponsor(editingSponsorId.value, resolvedSeasonId, sponsorForm)
statusMessage.value = 'Sponsor wurde aktualisiert.'
} else {
await store.createAdminSponsor(resolvedSeasonId, sponsorForm)
statusMessage.value = 'Sponsor wurde angelegt.'
}
resetSponsorForm()
} catch (error) {
statusError.value = error instanceof Error ? error.message : 'Sponsor konnte nicht gespeichert werden.'
} finally {
saving.value = false
}
}
async function deleteSponsor(sponsor: AdminSponsorItem) {
const resolvedSeasonId = await ensureSeasonId()
if (!resolvedSeasonId || !window.confirm(`Sponsor "${sponsor.name}" loeschen?`)) return
saving.value = true
statusMessage.value = ''
statusError.value = ''
try {
await store.deleteAdminSponsor(sponsor.id, resolvedSeasonId)
if (editingSponsorId.value === sponsor.id) resetSponsorForm()
statusMessage.value = 'Sponsor wurde geloescht.'
} catch (error) {
statusError.value = error instanceof Error ? error.message : 'Sponsor konnte nicht geloescht werden.'
} finally {
saving.value = false
}
}
async function updateShowactStatus(applicationId: number, status: string) {
const resolvedSeasonId = await ensureSeasonId()
if (!resolvedSeasonId) return
saving.value = true
statusMessage.value = ''
statusError.value = ''
try {
await store.updateAdminShowactStatus(applicationId, resolvedSeasonId, {
status,
reviewNote: showactNotes[applicationId] ?? '',
})
statusMessage.value = 'Showact-Status wurde gespeichert.'
} catch (error) {
statusError.value = error instanceof Error ? error.message : 'Showact-Status konnte nicht gespeichert werden.'
} finally {
saving.value = false
}
}
async function deleteShowact(applicationId: number, artistName: string) {
const resolvedSeasonId = await ensureSeasonId()
if (!resolvedSeasonId || !window.confirm(`Showact-Bewerbung von "${artistName}" loeschen?`)) return
saving.value = true
statusMessage.value = ''
statusError.value = ''
try {
await store.deleteAdminShowactApplication(applicationId, resolvedSeasonId)
delete showactNotes[applicationId]
statusMessage.value = 'Showact-Bewerbung wurde geloescht.'
} catch (error) {
statusError.value = error instanceof Error ? error.message : 'Showact-Bewerbung konnte nicht geloescht werden.'
} finally {
saving.value = false
}
}
function statusLabel(status: string) {
return {
pending: 'Offen',
shortlisted: 'Shortlist',
accepted: 'Angenommen',
rejected: 'Abgelehnt',
}[status] ?? status
}
onMounted(loadLandingExtras)
</script>
<template>
<Card id="content-landing-extras" class="p-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Landingpage Extras</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Showacts und Sponsoren</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">
Module, Bewerbungen und Sponsoren direkt dort steuern, wo sie public erscheinen.
</p>
</div>
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-violet-700 shadow-none hover:bg-violet-50" :disabled="loading" @click="loadLandingExtras">
<Settings2 class="h-4 w-4" />
{{ loading ? 'Laedt ...' : 'Neu laden' }}
</Button>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-3">
<div class="rounded-2xl border border-fuchsia-100 bg-fuchsia-50/55 p-4">
<p class="text-xs font-bold uppercase tracking-[0.18em] text-fuchsia-600">Showacts</p>
<p class="mt-2 text-2xl font-black text-slate-950">{{ store.adminShowactApplications.length }}</p>
<p class="text-sm text-slate-500">{{ pendingShowacts }} offen</p>
</div>
<div class="rounded-2xl border border-sky-100 bg-sky-50/55 p-4">
<p class="text-xs font-bold uppercase tracking-[0.18em] text-sky-600">Sponsoren</p>
<p class="mt-2 text-2xl font-black text-slate-950">{{ store.adminSponsors.length }}</p>
<p class="text-sm text-slate-500">{{ visibleSponsors }} sichtbar</p>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/55 p-4">
<p class="text-xs font-bold uppercase tracking-[0.18em] text-violet-600">Saison</p>
<p class="mt-2 text-2xl font-black text-slate-950">{{ store.overview.year || store.adminSeasonDetail.year || '' }}</p>
<p class="text-sm text-slate-500">Landingpage-Jahr</p>
</div>
</div>
<section class="mt-6 grid gap-3 lg:grid-cols-2">
<div class="rounded-2xl border border-violet-100 bg-white p-4">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-base font-bold text-slate-900">Showact-Bewerbung</h3>
<p class="mt-1 text-sm leading-6 text-slate-500">Public-Formular auf der Landingpage anzeigen.</p>
</div>
<AdminSettingsToggle
:model-value="settingsForm.showactApplicationsEnabled"
label="Showact-Bewerbungen aktivieren"
active-label="Offen"
inactive-label="Zu"
@update:model-value="settingsForm.showactApplicationsEnabled = $event"
/>
</div>
</div>
<div class="rounded-2xl border border-violet-100 bg-white p-4">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-base font-bold text-slate-900">Sponsoren-Modul</h3>
<p class="mt-1 text-sm leading-6 text-slate-500">Sponsor-Kacheln auf der Landingpage anzeigen.</p>
</div>
<AdminSettingsToggle
:model-value="settingsForm.sponsorsVisible"
label="Sponsoren anzeigen"
active-label="Sichtbar"
inactive-label="Aus"
@update:model-value="settingsForm.sponsorsVisible = $event"
/>
</div>
</div>
</section>
<label class="mt-4 block space-y-2">
<span class="text-sm font-bold text-slate-900">Hinweis bei geschlossenen Showact-Bewerbungen</span>
<textarea
v-model="settingsForm.showactApplicationDisabledMessage"
rows="3"
maxlength="240"
class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm leading-6 text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
/>
</label>
<div class="mt-4 flex flex-wrap items-center gap-3">
<Button class="gap-2" :disabled="saving" @click="saveLandingExtrasSettings">
<Save class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Landingpage Extras speichern' }}
</Button>
<p v-if="statusMessage" class="text-sm font-semibold text-emerald-700">{{ statusMessage }}</p>
<p v-if="statusError" class="text-sm font-semibold text-rose-700">{{ statusError }}</p>
</div>
<section class="mt-7 grid gap-5 xl:grid-cols-[minmax(0,1.1fr)_minmax(340px,0.9fr)]">
<div class="overflow-hidden rounded-2xl border border-violet-100 bg-white">
<div class="border-b border-violet-100 p-4">
<div class="flex items-center gap-2">
<Mic2 class="h-5 w-5 text-fuchsia-700" />
<h3 class="text-base font-bold text-slate-900">Showact-Bewerbungen</h3>
</div>
</div>
<div v-if="store.adminShowactApplications.length === 0" class="p-4 text-sm text-slate-500">
Noch keine Bewerbungen fuer dieses Jahr.
</div>
<div v-else class="divide-y divide-violet-100">
<article v-for="application in store.adminShowactApplications" :key="application.id" class="space-y-3 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="flex flex-wrap items-center gap-2">
<h4 class="font-bold text-slate-950">{{ application.artistName }}</h4>
<span class="rounded-full bg-fuchsia-100 px-3 py-1 text-xs font-bold text-fuchsia-700">{{ statusLabel(application.status) }}</span>
</div>
<p class="mt-1 text-sm text-slate-500">{{ application.performanceType }}</p>
</div>
<div class="flex flex-wrap gap-2">
<Button size="sm" variant="ghost" :disabled="saving" @click="updateShowactStatus(application.id, 'shortlisted')">Shortlist</Button>
<Button size="sm" :disabled="saving" @click="updateShowactStatus(application.id, 'accepted')">Annehmen</Button>
<Button size="sm" variant="secondary" :disabled="saving" @click="updateShowactStatus(application.id, 'rejected')">Ablehnen</Button>
<Button size="sm" variant="ghost" class="text-rose-700" :disabled="saving" @click="deleteShowact(application.id, application.artistName)">
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
<p class="text-sm leading-6 text-slate-600">{{ application.description }}</p>
<div class="grid gap-2 text-sm text-slate-500 md:grid-cols-2">
<a v-if="application.platformUrl" class="font-semibold text-violet-700 hover:text-violet-900" :href="application.platformUrl" target="_blank" rel="noreferrer">Kanal/Profil</a>
<a v-if="application.referenceUrl" class="font-semibold text-violet-700 hover:text-violet-900" :href="application.referenceUrl" target="_blank" rel="noreferrer">Referenz</a>
<span v-if="application.contactEmail">Mail: {{ application.contactEmail }}</span>
<span v-if="application.contactDiscord">Discord: {{ application.contactDiscord }}</span>
</div>
<textarea
v-model="showactNotes[application.id]"
rows="2"
maxlength="500"
placeholder="Interne Review-Notiz"
class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
/>
</article>
</div>
</div>
<div class="overflow-hidden rounded-2xl border border-violet-100 bg-white">
<div class="border-b border-violet-100 p-4">
<div class="flex items-center gap-2">
<Handshake class="h-5 w-5 text-sky-700" />
<h3 class="text-base font-bold text-slate-900">Sponsoren</h3>
</div>
</div>
<form class="space-y-3 p-4" @submit.prevent="saveSponsor">
<input v-model="sponsorForm.name" required maxlength="120" placeholder="Sponsor-Name" class="w-full rounded-2xl border border-violet-100 px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<div class="grid gap-3 sm:grid-cols-2">
<input v-model="sponsorForm.websiteUrl" maxlength="500" placeholder="Website URL" class="rounded-2xl border border-violet-100 px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<input v-model="sponsorForm.logoUrl" maxlength="500" placeholder="Logo URL" class="rounded-2xl border border-violet-100 px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
</div>
<div class="grid gap-3 sm:grid-cols-[1fr_120px]">
<input v-model="sponsorForm.tier" maxlength="80" placeholder="Tier" class="rounded-2xl border border-violet-100 px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<input v-model.number="sponsorForm.sortOrder" type="number" min="0" max="9999" class="rounded-2xl border border-violet-100 px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
</div>
<textarea v-model="sponsorForm.description" rows="3" maxlength="500" placeholder="Beschreibung" class="w-full rounded-2xl border border-violet-100 px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<label class="flex items-center gap-3 rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3 text-sm font-semibold text-slate-700">
<input v-model="sponsorForm.isVisible" type="checkbox" class="h-4 w-4 rounded border-violet-200 text-violet-600" />
Public anzeigen
</label>
<div class="flex flex-wrap gap-2">
<Button class="gap-2" :disabled="saving">
<Save class="h-4 w-4" />
{{ editingSponsorId ? 'Sponsor speichern' : 'Sponsor anlegen' }}
</Button>
<Button type="button" variant="ghost" :disabled="saving" @click="resetSponsorForm">
<Plus class="h-4 w-4" />
Neu
</Button>
</div>
</form>
<div class="divide-y divide-violet-100 border-t border-violet-100">
<article v-for="sponsor in store.adminSponsors" :key="sponsor.id" class="flex items-start justify-between gap-3 p-4">
<div class="min-w-0">
<p class="truncate text-sm font-bold text-slate-900">{{ sponsor.name }}</p>
<p class="mt-1 text-xs font-semibold text-slate-500">{{ sponsor.tier }} · Reihenfolge {{ sponsor.sortOrder }}</p>
<p class="mt-1 text-xs" :class="sponsor.isVisible ? 'text-emerald-700' : 'text-slate-500'">
{{ sponsor.isVisible ? 'Public sichtbar' : 'Verborgen' }}
</p>
</div>
<div class="flex shrink-0 gap-2">
<Button size="sm" variant="ghost" @click="editSponsor(sponsor)">Bearbeiten</Button>
<Button size="sm" variant="ghost" class="text-rose-700" :disabled="saving" @click="deleteSponsor(sponsor)">
<Trash2 class="h-4 w-4" />
</Button>
</div>
</article>
</div>
</div>
</section>
</Card>
</template>
@@ -31,6 +31,10 @@
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Sponsoren & Partner URL</span>
<input v-model="form.sponsorsUrl" type="url" class="h-12 w-full 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" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Showacts URL</span>
<input v-model="form.showactsUrl" type="url" class="h-12 w-full 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" />
</label>
</div>
<div class="mt-7 space-y-5">
<div class="space-y-3">
@@ -75,6 +79,20 @@
min-height-class="min-h-[260px]"
/>
</div>
<div class="space-y-3">
<div class="flex justify-end">
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-violet-700 shadow-none hover:bg-violet-50" @click="$emit('open-preview', 'showacts')">
<Eye class="h-4 w-4" />
Showacts Preview
</Button>
</div>
<AdminRichTextEditor
v-model="form.showactsContent"
label="Showacts Inhalt"
placeholder="Informationen zu Showact-Bewerbungen, Ablauf und Kontakt..."
min-height-class="min-h-[260px]"
/>
</div>
</div>
</Card>
</template>
@@ -87,7 +105,7 @@ import Card from '../ui/Card.vue'
import AdminRichTextEditor from './AdminRichTextEditor.vue'
import type { AdminContentForm } from './adminContentTypes'
export type FooterPreviewKey = 'imprint' | 'contact' | 'sponsors'
export type FooterPreviewKey = 'imprint' | 'contact' | 'sponsors' | 'showacts'
const props = defineProps<{
form: AdminContentForm
@@ -0,0 +1,160 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Loader2, Plus, Save, Trash2, X } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Modal from '../ui/Modal.vue'
import { watchAdminToast } from '../../composables/useAdminToast'
import { useAwardsStore } from '../../stores/awards'
const props = defineProps<{
open: boolean
}>()
const emit = defineEmits<{
close: []
}>()
const store = useAwardsStore()
const loading = ref(false)
const saving = ref(false)
const entries = ref<string[]>([])
const newUrl = ref('')
const success = ref('')
const error = ref('')
const normalizedEntries = computed(() =>
entries.value
.map((entry) => entry.trim())
.filter(Boolean),
)
const canSave = computed(() => !loading.value && !saving.value && normalizedEntries.value.length > 0)
watchAdminToast(success, error)
watch(
() => props.open,
(open) => {
if (open) {
void loadBlacklist()
}
},
{ immediate: true },
)
async function loadBlacklist() {
loading.value = true
error.value = ''
try {
const response = await store.loadAdminNominationLinkBlacklist()
entries.value = response.entries.map((entry) => entry.url)
} catch (loadError) {
error.value = loadError instanceof Error ? loadError.message : 'Blacklist konnte nicht geladen werden.'
} finally {
loading.value = false
}
}
function addEntry() {
const url = newUrl.value.trim()
if (!url) return
entries.value = [...entries.value, url]
newUrl.value = ''
}
function removeEntry(index: number) {
entries.value = entries.value.filter((_, entryIndex) => entryIndex !== index)
}
async function saveBlacklist() {
if (!canSave.value) return
saving.value = true
success.value = ''
error.value = ''
try {
const response = await store.updateAdminNominationLinkBlacklist({ urls: normalizedEntries.value })
entries.value = response.entries.map((entry) => entry.url)
success.value = 'Link-Blacklist wurde gespeichert.'
} catch (saveError) {
error.value = saveError instanceof Error ? saveError.message : 'Blacklist konnte nicht gespeichert werden.'
} finally {
saving.value = false
}
}
</script>
<template>
<Modal
:open="open"
title="Link-Blacklist"
subtitle="Blockiert generische oder unerwuenschte Stream-Links direkt bei der oeffentlichen Nominierung."
size="lg"
@close="emit('close')"
>
<div class="space-y-5">
<div class="rounded-2xl border border-amber-100 bg-amber-50/75 px-4 py-3 text-sm leading-6 text-amber-900">
Die Standardseiten von Twitch, Kick und YouTube sind bereits hinterlegt. User sollen direkte Kanal- oder Profil-Links einreichen, nicht die Startseite einer Plattform.
</div>
<div v-if="loading" class="flex items-center gap-3 rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-5 text-sm font-semibold text-violet-700">
<Loader2 class="h-4 w-4 animate-spin" />
Lade Blacklist ...
</div>
<div v-else class="space-y-3">
<div
v-for="(entry, index) in entries"
:key="`${entry}-${index}`"
class="grid gap-3 rounded-2xl border border-violet-100 bg-white px-4 py-3 md:grid-cols-[minmax(0,1fr)_auto]"
>
<label class="block min-w-0">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Blockierter Link</span>
<input
v-model="entries[index]"
type="url"
class="mt-2 h-11 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="https://example.com/"
/>
</label>
<Button type="button" variant="ghost" size="sm" class="self-end gap-2 rounded-2xl text-rose-700 hover:text-rose-800" @click="removeEntry(index)">
<Trash2 class="h-4 w-4" />
Entfernen
</Button>
</div>
<div class="grid gap-3 rounded-2xl border border-dashed border-violet-200 bg-violet-50/45 p-4 md:grid-cols-[minmax(0,1fr)_auto]">
<label class="block min-w-0">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-violet-600">Neuer Link</span>
<input
v-model="newUrl"
type="url"
class="mt-2 h-11 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="https://www.twitch.tv/"
@keydown.enter.prevent="addEntry"
/>
</label>
<Button type="button" variant="secondary" class="self-end gap-2 rounded-2xl" :disabled="!newUrl.trim()" @click="addEntry">
<Plus class="h-4 w-4" />
Hinzufügen
</Button>
</div>
</div>
<p v-if="entries.length === 0 && !loading" class="rounded-2xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
Die Blacklist darf nicht leer sein, damit die generischen Plattform-Links weiter geblockt bleiben.
</p>
</div>
<template #footer>
<Button type="button" variant="ghost" class="gap-2" :disabled="saving" @click="emit('close')">
<X class="h-4 w-4" />
Schließen
</Button>
<Button type="button" class="gap-2" :disabled="!canSave" @click="saveBlacklist">
<Loader2 v-if="saving" class="h-4 w-4 animate-spin" />
<Save v-else class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Blacklist speichern' }}
</Button>
</template>
</Modal>
</template>
@@ -20,6 +20,7 @@ const emit = defineEmits<{
const {
reviewSaving,
blacklistSaving,
adminMessage,
adminError,
reviewForms,
@@ -39,6 +40,7 @@ const {
canApproveSelected,
approveNomination,
rejectNomination,
addStreamUrlToBlacklist,
selectedPlatformValue,
handlePlatformSelection,
extractNominationStreamUrl,
@@ -153,10 +155,12 @@ onBeforeUnmount(() => {
:signal-summary="selectedNominationSignalSummary"
:stream-url="selectedNomination ? extractNominationStreamUrl(selectedNomination) : ''"
:can-approve-selected="canApproveSelected"
:blacklist-saving="blacklistSaving"
:selected-platform-value="selectedPlatformValue"
@platform-change="handlePlatformSelection"
@approve="approveNomination"
@reject="rejectNomination"
@blacklist-link="addStreamUrlToBlacklist"
/>
</div>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { Film, Settings2 } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
defineProps<{
loading: boolean
saving: boolean
canManage: boolean
summary: {
clipSubmissionsEnabled: boolean
clipReviewEnabled: boolean
pendingClipCount: number
totalClipCount: number
}
disabledMessage: string
}>()
const emit = defineEmits<{
configure: []
}>()
</script>
<template>
<Card class="overflow-hidden">
<section class="p-5">
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-start">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Optionale Workflows</p>
<div class="mt-2 flex flex-wrap items-center gap-2">
<Film class="h-5 w-5 text-violet-700" />
<h2 class="text-lg font-bold text-slate-900">Clip-Einreichungen steuern</h2>
</div>
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
Clip-Einreichung und Clip-Review sind optionale Betriebsfunktionen. Wenn deaktiviert, bleibt der Award-Workflow ohne Clip-Pflicht nutzbar.
</p>
</div>
<Button class="gap-2" :disabled="loading || saving || !canManage" @click="emit('configure')">
<Settings2 class="h-4 w-4" />
Clip-Workflow konfigurieren
</Button>
</div>
<div class="mt-5 flex flex-wrap gap-2">
<span
class="rounded-full px-3 py-1.5 text-xs font-semibold"
:class="summary.clipSubmissionsEnabled ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'"
>
{{ summary.clipSubmissionsEnabled ? 'Clips aktiv' : 'Clips deaktiviert' }}
</span>
<span
class="rounded-full px-3 py-1.5 text-xs font-semibold"
:class="summary.clipSubmissionsEnabled ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-600'"
>
{{ summary.clipSubmissionsEnabled ? 'Public sichtbar' : 'Public ausgeblendet' }}
</span>
<span
class="rounded-full px-3 py-1.5 text-xs font-semibold"
:class="summary.clipReviewEnabled ? 'bg-violet-100 text-violet-700' : 'bg-slate-100 text-slate-600'"
>
{{ summary.clipReviewEnabled ? 'Admin-Review sichtbar' : 'Admin-Review aus' }}
</span>
<span class="rounded-full bg-amber-100 px-3 py-1.5 text-xs font-semibold text-amber-700">
Review offen: {{ summary.pendingClipCount }}
</span>
<span class="rounded-full bg-sky-100 px-3 py-1.5 text-xs font-semibold text-sky-700">
Bestand: {{ summary.totalClipCount }}
</span>
</div>
<p v-if="!summary.clipSubmissionsEnabled" class="mt-4 rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm leading-6 text-slate-600">
{{ disabledMessage || 'Neue Clip-Einreichungen sind geschlossen. Bestehende Clips und Review-Daten bleiben erhalten.' }}
</p>
</section>
</Card>
</template>
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { Save } from '@lucide/vue'
import AdminSettingsToggle from './AdminSettingsToggle.vue'
import Button from '../ui/Button.vue'
import Modal from '../ui/Modal.vue'
defineProps<{
open: boolean
form: {
clipSubmissionsEnabled: boolean
clipReviewEnabled: boolean
clipAdminMenuVisible: boolean
clipSubmissionDisabledMessage: string
}
saving: boolean
dirty: boolean
canManage: boolean
}>()
const emit = defineEmits<{
close: []
save: []
'update:clipSubmissionsEnabled': [value: boolean]
'update:clipReviewEnabled': [value: boolean]
'update:clipAdminMenuVisible': [value: boolean]
'update:clipSubmissionDisabledMessage': [value: string]
}>()
</script>
<template>
<Modal
:open="open"
title="Clip-Workflow"
subtitle="Steuert, ob Besucher Clips einreichen können und ob der Clip-Bereich im Admin als aktiver Workflow erscheint."
size="lg"
@close="emit('close')"
>
<div class="space-y-5">
<section v-if="!canManage" class="rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-800">
Du kannst den Clip-Workflow ansehen, aber nicht bearbeiten.
</section>
<section class="grid gap-3 md:grid-cols-2">
<div class="rounded-2xl border border-violet-100 bg-violet-50/45 p-4">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="text-base font-bold text-slate-900">Public-Einreichung</h4>
<p class="mt-1 text-sm leading-6 text-slate-500">
Besucher können während der passenden Phase Clips einreichen.
</p>
</div>
<AdminSettingsToggle
:model-value="form.clipSubmissionsEnabled"
label="Clip-Einreichung aktivieren"
:disabled="!canManage"
active-label="Aktiv"
inactive-label="Inaktiv"
@update:model-value="emit('update:clipSubmissionsEnabled', $event)"
/>
</div>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/45 p-4">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="text-base font-bold text-slate-900">Admin-Review</h4>
<p class="mt-1 text-sm leading-6 text-slate-500">
Clip-Bestand und Alt-Einreichungen bleiben als optionale Inbox sichtbar.
</p>
</div>
<AdminSettingsToggle
:model-value="form.clipReviewEnabled"
label="Admin-Review anzeigen"
:disabled="!canManage"
active-label="Sichtbar"
inactive-label="Aus"
@update:model-value="emit('update:clipReviewEnabled', $event)"
/>
</div>
</div>
</section>
<div class="rounded-2xl border border-violet-100 bg-violet-50/45 p-4">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="text-base font-bold text-slate-900">Menüpunkt Clips" im Admin</h4>
<p class="mt-1 text-sm leading-6 text-slate-500">
Blendet den Clips-Menüpunkt im Admin-Panel ein oder aus unabhängig von Einreichung und Review.
</p>
</div>
<AdminSettingsToggle
:model-value="form.clipAdminMenuVisible"
label="Clips-Menüpunkt anzeigen"
:disabled="!canManage"
active-label="Sichtbar"
inactive-label="Aus"
@update:model-value="emit('update:clipAdminMenuVisible', $event)"
/>
</div>
</div>
<label class="block space-y-2">
<span class="text-sm font-bold text-slate-900">Hinweis bei deaktivierter Einreichung</span>
<textarea
:value="form.clipSubmissionDisabledMessage"
rows="3"
maxlength="240"
:disabled="!canManage"
class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm leading-6 text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400"
@input="emit('update:clipSubmissionDisabledMessage', ($event.target as HTMLTextAreaElement).value)"
/>
<span class="text-xs font-semibold text-slate-500">
{{ form.clipSubmissionDisabledMessage.length }}/240 Zeichen
</span>
</label>
<section class="rounded-2xl border border-sky-100 bg-sky-50 px-4 py-3 text-sm leading-6 text-sky-800">
Nominierung, Voting und Gewinnerpflege laufen unabhängig davon weiter. Bestehende Clips werden nicht gelöscht.
</section>
</div>
<template #footer>
<Button variant="ghost" :disabled="saving" @click="emit('close')">Schließen</Button>
<Button class="gap-2" :disabled="saving || !dirty || !canManage" @click="emit('save')">
<Save class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Clip-Workflow speichern' }}
</Button>
</template>
</Modal>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { CheckCircle2, Trash2 } from '@lucide/vue'
import { Ban, CheckCircle2, Loader2, Trash2 } from '@lucide/vue'
import Button from '../ui/Button.vue'
import NativeSelect from '../ui/NativeSelect.vue'
@@ -24,6 +24,7 @@ defineProps<{
} | null
streamUrl?: string
canApproveSelected: boolean
blacklistSaving: boolean
selectedPlatformValue: (platform: string) => string
}>()
@@ -31,6 +32,7 @@ const emit = defineEmits<{
'platform-change': [value: string]
approve: [nominationId: number]
reject: [nominationId: number]
'blacklist-link': [streamUrl: string]
}>()
</script>
@@ -51,14 +53,28 @@ const emit = defineEmits<{
<div v-if="streamUrl" class="mt-5 rounded-2xl border border-sky-100 bg-sky-50/70 p-4">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700">Eingereichter Stream-Link</p>
<a
:href="streamUrl"
target="_blank"
rel="noopener"
class="mt-2 inline-flex max-w-full items-center rounded-full border border-sky-200 bg-white px-3 py-1.5 text-sm font-semibold text-sky-800 underline-offset-4 hover:underline"
>
<span class="truncate">{{ streamUrl }}</span>
</a>
<div class="mt-2 flex flex-wrap items-center gap-2">
<a
:href="streamUrl"
target="_blank"
rel="noopener"
class="inline-flex max-w-full items-center rounded-full border border-sky-200 bg-white px-3 py-1.5 text-sm font-semibold text-sky-800 underline-offset-4 hover:underline"
>
<span class="truncate">{{ streamUrl }}</span>
</a>
<Button
type="button"
variant="ghost"
size="sm"
class="gap-2 rounded-full border-rose-200 bg-white px-3 text-rose-700 hover:bg-rose-50 hover:text-rose-800"
:disabled="blacklistSaving"
@click="emit('blacklist-link', streamUrl)"
>
<Loader2 v-if="blacklistSaving" class="h-3.5 w-3.5 animate-spin" />
<Ban v-else class="h-3.5 w-3.5" />
{{ blacklistSaving ? 'Blockiert ...' : 'Zur Blacklist' }}
</Button>
</div>
</div>
<div v-if="signalSummary" class="mt-4 grid gap-3 sm:grid-cols-3">
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { Save, ShieldCheck } from '@lucide/vue'
import type { AdminWorkflowRule } from '../../types/awards'
import AdminSettingsToggle from './AdminSettingsToggle.vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import NativeSelect from '../ui/NativeSelect.vue'
defineProps<{
rules: AdminWorkflowRule[]
loading: boolean
saving: boolean
dirty: boolean
canManage: boolean
summary: {
active: number
blocking: number
total: number
}
}>()
const emit = defineEmits<{
updateRule: [ruleKey: string, patch: Partial<AdminWorkflowRule>]
save: []
}>()
</script>
<template>
<Card class="overflow-visible">
<section class="border-b border-violet-100 p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0">
<div class="flex items-center gap-2">
<ShieldCheck class="h-5 w-5 text-violet-700" />
<h2 class="text-lg font-bold text-slate-900">Award-Workflow-Regeln</h2>
</div>
<p class="mt-1 max-w-3xl text-sm leading-6 text-slate-500">
Diese Regeln steuern Vorbereitung, finale Kandidat:innen und Gewinnervergabe der ausgewählten Saison.
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full bg-violet-50 px-3 py-1.5 text-xs font-semibold text-violet-700">
{{ summary.active }}/{{ summary.total }} aktiv
</span>
<span class="rounded-full bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700">
{{ summary.blocking }} blockierend
</span>
<Button class="gap-2" :disabled="loading || saving || !dirty || !canManage" @click="emit('save')">
<Save class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Speichern' }}
</Button>
</div>
</div>
</section>
<section v-if="!canManage" class="border-b border-amber-100 bg-amber-50 px-5 py-3 text-sm font-semibold text-amber-800">
Du kannst die Workflow-Regeln ansehen, aber nicht bearbeiten.
</section>
<section v-if="loading" class="p-5">
<p class="rounded-2xl border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Workflow-Regeln werden geladen.
</p>
</section>
<section v-else class="grid gap-3 p-5">
<article
v-for="rule in rules"
:key="rule.key"
class="rounded-2xl border border-violet-100 bg-violet-50/35 p-4"
>
<div class="grid gap-4 xl:grid-cols-[minmax(220px,1fr)_120px_170px_170px] xl:items-end">
<div class="min-w-0">
<h3 class="text-base font-semibold text-slate-900">{{ rule.label }}</h3>
<p class="mt-1 text-sm leading-6 text-slate-500">{{ rule.description }}</p>
</div>
<label v-if="rule.key !== 'winner_requires_clip'" class="space-y-1">
<span class="text-xs font-semibold text-slate-500">Limit</span>
<input
:value="rule.limit"
type="number"
min="1"
max="50"
:disabled="!canManage"
class="h-10 w-full rounded-xl border border-violet-100 bg-white px-3 text-sm outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400"
@input="emit('updateRule', rule.key, { limit: Number(($event.target as HTMLInputElement).value) })"
>
</label>
<div v-else class="rounded-xl border border-violet-100 bg-white px-3 py-2 text-xs font-semibold leading-5 text-slate-500">
Ja/Nein-Regel<br>
<span class="font-normal">Limit wird hier nicht verwendet.</span>
</div>
<label class="space-y-1">
<span class="text-xs font-semibold text-slate-500">Modus</span>
<NativeSelect
:model-value="rule.mode"
:disabled="!canManage"
:options="[
{ label: 'Blockieren', value: 'block' },
{ label: 'Warnen', value: 'warn' },
]"
@update:model-value="emit('updateRule', rule.key, { mode: String($event) })"
/>
</label>
<AdminSettingsToggle
:model-value="rule.enabled"
:label="`${rule.label} aktivieren`"
:disabled="!canManage"
active-label="Aktiv"
inactive-label="Inaktiv"
@update:model-value="emit('updateRule', rule.key, { enabled: $event })"
/>
</div>
</article>
<p v-if="rules.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine Workflow-Regeln verfügbar.
</p>
</section>
</Card>
</template>
@@ -24,6 +24,8 @@ export type AdminContentForm = {
contactContent: string
sponsorsUrl: string
sponsorsContent: string
showactsUrl: string
showactsContent: string
socialLinks: SocialLinkForm[]
faq: FaqFormItem[]
}
@@ -2,16 +2,45 @@ import { computed, reactive, ref, watch } from 'vue'
import { SOCIAL_ICON_OPTIONS, socialIconOptionForValue } from '../../lib/socialIcons'
import { useAwardsStore } from '../../stores/awards'
import type { AdminCandidateItem } from '../../types/awards'
import type { AdminCandidateItem, AdminWorkflowRule } from '../../types/awards'
interface CandidateForm {
categoryId: number
displayName: string
channelSlug: string
platform: string
acceptanceStatus: string
acceptanceNote: string
clipCompilationUrl: string
clipCompilationTitle: string
clipCompilationPlatform: string
clipEmbedStatus: string
}
const pageSize = 10
const maxFinalistsRuleKey = 'max_finalists_per_category'
const maxCandidateAppearancesRuleKey = 'max_candidate_appearances'
const acceptanceStatusOptions = [
{ label: 'Offen', value: 'open', description: 'Noch nicht kontaktiert' },
{ label: 'Angefragt', value: 'contacted', description: 'Kontakt läuft' },
{ label: 'Angenommen', value: 'accepted', description: 'nimmt teil' },
{ label: 'Abgesagt', value: 'declined', description: 'nicht voting-bereit' },
]
const clipEmbedStatusOptions = [
{ label: 'Noch nicht geprüft', value: 'unchecked' },
{ label: 'Einbettbar', value: 'embeddable' },
{ label: 'Nur Link', value: 'link_only' },
{ label: 'Nicht nutzbar', value: 'blocked' },
]
const readinessFilterOptions = [
{ label: 'Alle', value: 'all' },
{ label: 'Offen', value: 'open' },
{ label: 'Angefragt', value: 'contacted' },
{ label: 'Angenommen', value: 'accepted' },
{ label: 'Abgesagt', value: 'declined' },
{ label: 'Clip fehlt', value: 'missing_clip' },
{ label: 'Embed prüfen', value: 'embed_review' },
]
export function useAdminCandidateManager() {
const store = useAwardsStore()
@@ -21,13 +50,26 @@ export function useAdminCandidateManager() {
const adminError = ref('')
const search = ref('')
const categoryFilter = ref<number | null>(null)
const readinessFilter = ref('all')
const page = ref(1)
const modalOpen = ref(false)
const editingId = ref<number | 'new' | null>(null)
const candidateToDelete = ref<AdminCandidateItem | null>(null)
const form = reactive<CandidateForm>({ categoryId: 0, displayName: '', channelSlug: '', platform: 'Twitch' })
const form = reactive<CandidateForm>({
categoryId: 0,
displayName: '',
channelSlug: '',
platform: 'Twitch',
acceptanceStatus: 'open',
acceptanceNote: '',
clipCompilationUrl: '',
clipCompilationTitle: '',
clipCompilationPlatform: '',
clipEmbedStatus: 'unchecked',
})
const seasonDetail = computed(() => store.adminSeasonDetail)
const workflowRules = computed(() => store.adminWorkflowRules.rules)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const categoryOptions = computed(() =>
seasonDetail.value.categories.map((category) => ({ label: `${category.groupName} · ${category.name}`, value: category.id })),
@@ -51,6 +93,23 @@ export function useAdminCandidateManager() {
const duplicateCandidateCount = computed(() =>
seasonDetail.value.candidates.filter((candidate) => hasDuplicateCandidateKey(candidate, duplicateCandidateKeys.value)).length,
)
const candidateWorkflowNotices = computed(() => buildCandidateWorkflowNotices(
seasonDetail.value.candidates,
workflowRules.value,
))
const acceptedCandidateCount = computed(() =>
seasonDetail.value.candidates.filter((candidate) => candidate.acceptanceStatus === 'accepted').length,
)
const clipReadyCount = computed(() =>
seasonDetail.value.candidates.filter((candidate) => hasUsableCompilation(candidate)).length,
)
const actionNeededCount = computed(() =>
seasonDetail.value.candidates.filter((candidate) =>
candidate.acceptanceStatus !== 'accepted'
|| !hasUsableCompilation(candidate)
|| candidate.clipEmbedStatus === 'unchecked',
).length,
)
const filteredCandidates = computed(() => {
const query = search.value.trim().toLowerCase()
let list = seasonDetail.value.candidates
@@ -59,6 +118,20 @@ export function useAdminCandidateManager() {
list = list.filter((candidate) => candidate.categoryId === categoryFilter.value)
}
if (readinessFilter.value !== 'all') {
list = list.filter((candidate) => {
if (readinessFilter.value === 'missing_clip') {
return !hasUsableCompilation(candidate)
}
if (readinessFilter.value === 'embed_review') {
return Boolean(candidate.clipCompilationUrl?.trim()) && candidate.clipEmbedStatus === 'unchecked'
}
return candidate.acceptanceStatus === readinessFilter.value
})
}
if (query) {
list = list.filter((candidate) =>
[candidate.displayName, candidate.channelSlug, candidate.platform, categoryLabelMap.value[candidate.categoryId] ?? '']
@@ -79,12 +152,19 @@ export function useAdminCandidateManager() {
const rangeEnd = computed(() => Math.min(page.value * pageSize, filteredCandidates.value.length))
const modalTitle = computed(() => (editingId.value === 'new' ? 'Kandidat anlegen' : 'Kandidat bearbeiten'))
const canSave = computed(() =>
Boolean(selectedSeasonId.value && form.categoryId && form.displayName.trim() && form.channelSlug.trim() && form.platform.trim()),
Boolean(
selectedSeasonId.value
&& form.categoryId
&& form.displayName.trim()
&& form.channelSlug.trim()
&& form.platform.trim()
&& isValidOptionalUrl(form.clipCompilationUrl),
),
)
const candidatePlatformOptions = computed(() => SOCIAL_ICON_OPTIONS.filter((option) => option.key !== 'website'))
const selectedPlatformValue = computed(() => socialIconOptionForValue(form.platform)?.key ?? 'custom')
watch([search, categoryFilter, () => seasonDetail.value.candidates.length], () => {
watch([search, categoryFilter, readinessFilter, () => seasonDetail.value.candidates.length], () => {
page.value = 1
})
@@ -97,6 +177,16 @@ export function useAdminCandidateManager() {
function clearFilters() {
search.value = ''
categoryFilter.value = null
readinessFilter.value = 'all'
}
function resetPreparationForm() {
form.acceptanceStatus = 'open'
form.acceptanceNote = ''
form.clipCompilationUrl = ''
form.clipCompilationTitle = ''
form.clipCompilationPlatform = ''
form.clipEmbedStatus = 'unchecked'
}
function openCreate() {
@@ -107,6 +197,7 @@ export function useAdminCandidateManager() {
form.displayName = ''
form.channelSlug = ''
form.platform = 'Twitch'
resetPreparationForm()
modalOpen.value = true
}
@@ -118,6 +209,12 @@ export function useAdminCandidateManager() {
form.displayName = candidate.displayName
form.channelSlug = candidate.channelSlug
form.platform = candidate.platform
form.acceptanceStatus = candidate.acceptanceStatus || 'open'
form.acceptanceNote = candidate.acceptanceNote ?? ''
form.clipCompilationUrl = candidate.clipCompilationUrl ?? ''
form.clipCompilationTitle = candidate.clipCompilationTitle ?? ''
form.clipCompilationPlatform = candidate.clipCompilationPlatform ?? ''
form.clipEmbedStatus = candidate.clipEmbedStatus || 'unchecked'
modalOpen.value = true
}
@@ -143,10 +240,10 @@ export function useAdminCandidateManager() {
try {
if (editingId.value === 'new') {
await store.createAdminCandidate(selectedSeasonId.value, { ...form })
await store.createAdminCandidate(selectedSeasonId.value, buildCandidatePayload())
adminMessage.value = `${form.displayName}" wurde angelegt.`
} else if (typeof editingId.value === 'number') {
await store.updateAdminCandidate(editingId.value, selectedSeasonId.value, { ...form })
await store.updateAdminCandidate(editingId.value, selectedSeasonId.value, buildCandidatePayload())
adminMessage.value = `${form.displayName}" wurde gespeichert.`
}
modalOpen.value = false
@@ -157,6 +254,22 @@ export function useAdminCandidateManager() {
}
}
function buildCandidatePayload() {
const clipUrl = form.clipCompilationUrl.trim()
return {
categoryId: form.categoryId,
displayName: form.displayName,
channelSlug: form.channelSlug,
platform: form.platform,
acceptanceStatus: form.acceptanceStatus,
acceptanceNote: form.acceptanceNote.trim() || null,
clipCompilationUrl: clipUrl || null,
clipCompilationTitle: clipUrl ? form.clipCompilationTitle.trim() || null : null,
clipCompilationPlatform: clipUrl ? form.clipCompilationPlatform.trim() || null : null,
clipEmbedStatus: clipUrl ? form.clipEmbedStatus : 'unchecked',
}
}
async function confirmDelete() {
if (!candidateToDelete.value || !selectedSeasonId.value) {
return
@@ -184,12 +297,17 @@ export function useAdminCandidateManager() {
adminError,
search,
categoryFilter,
readinessFilter,
page,
categoryOptions,
categoryFilterOptions,
categoryLabelMap,
duplicateCandidateKeys,
candidateWorkflowNotices,
duplicateCandidateCount,
acceptedCandidateCount,
clipReadyCount,
actionNeededCount,
filteredCandidates,
pagedCandidates,
totalPages,
@@ -201,6 +319,9 @@ export function useAdminCandidateManager() {
canSave,
candidatePlatformOptions,
selectedPlatformValue,
acceptanceStatusOptions,
clipEmbedStatusOptions,
readinessFilterOptions,
candidateToDelete,
clearFilters,
openCreate,
@@ -211,6 +332,59 @@ export function useAdminCandidateManager() {
}
}
function buildCandidateWorkflowNotices(candidates: AdminCandidateItem[], rules: AdminWorkflowRule[]) {
const notices: Record<number, Array<{ mode: 'warn' | 'block'; message: string }>> = {}
const activeCandidates = candidates.filter((candidate) => candidate.acceptanceStatus !== 'declined')
const finalistsRule = rules.find((rule) => rule.key === maxFinalistsRuleKey)
const appearancesRule = rules.find((rule) => rule.key === maxCandidateAppearancesRuleKey)
if (finalistsRule?.enabled) {
const categoryCounts = new Map<number, number>()
for (const candidate of activeCandidates) {
categoryCounts.set(candidate.categoryId, (categoryCounts.get(candidate.categoryId) ?? 0) + 1)
}
for (const candidate of activeCandidates) {
const count = categoryCounts.get(candidate.categoryId) ?? 0
if (count > finalistsRule.limit) {
addCandidateNotice(notices, candidate.id, finalistsRule, `${count}/${finalistsRule.limit} finale Kandidat:innen in dieser Kategorie.`)
}
}
}
if (appearancesRule?.enabled) {
const identityCounts = new Map<string, number>()
for (const candidate of activeCandidates) {
const key = candidateIdentityKey(candidate)
identityCounts.set(key, (identityCounts.get(key) ?? 0) + 1)
}
for (const candidate of activeCandidates) {
const count = identityCounts.get(candidateIdentityKey(candidate)) ?? 0
if (count > appearancesRule.limit) {
addCandidateNotice(notices, candidate.id, appearancesRule, `${count}/${appearancesRule.limit} Kandidaturen für diese Person.`)
}
}
}
return notices
}
function addCandidateNotice(
notices: Record<number, Array<{ mode: 'warn' | 'block'; message: string }>>,
candidateId: number,
rule: AdminWorkflowRule,
message: string,
) {
const mode = rule.mode === 'warn' ? 'warn' : 'block'
notices[candidateId] = [...(notices[candidateId] ?? []), { mode, message }]
}
function candidateIdentityKey(candidate: Pick<AdminCandidateItem, 'displayName' | 'channelSlug'>) {
const channel = candidate.channelSlug.trim().replace(/^@+/, '').toLowerCase()
return channel ? `slug:${channel}` : `name:${candidate.displayName.trim().toLowerCase()}`
}
function createCandidateDuplicateKey(candidate: AdminCandidateItem, field: 'name' | 'slug') {
const value = field === 'name' ? candidate.displayName : candidate.channelSlug
return `${candidate.categoryId}:${field}:${value.trim().toLowerCase()}`
@@ -220,3 +394,19 @@ function hasDuplicateCandidateKey(candidate: AdminCandidateItem, candidateKeys:
return (candidateKeys.get(createCandidateDuplicateKey(candidate, 'name')) ?? 0) > 1
|| (candidateKeys.get(createCandidateDuplicateKey(candidate, 'slug')) ?? 0) > 1
}
function hasUsableCompilation(candidate: AdminCandidateItem) {
return Boolean(candidate.clipCompilationUrl?.trim()) && candidate.clipEmbedStatus !== 'blocked'
}
function isValidOptionalUrl(value: string) {
const trimmed = value.trim()
if (!trimmed) return true
try {
const url = new URL(trimmed)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
@@ -25,6 +25,23 @@ export function useAdminClipManager() {
const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const optionalFeatures = computed(() => store.adminOptionalFeatureSettings)
const clipSubmissionsEnabled = computed(() => optionalFeatures.value.clipSubmissionsEnabled)
const clipReviewEnabled = computed(() => optionalFeatures.value.clipReviewEnabled)
const clipDisabledMessage = computed(() => optionalFeatures.value.clipSubmissionDisabledMessage)
const clipWorkflowStatusLabel = computed(() => {
if (clipSubmissionsEnabled.value && clipReviewEnabled.value) return 'Public-Einreichung und Review aktiv'
if (clipSubmissionsEnabled.value) return 'Public-Einreichung aktiv, Review ausgeblendet'
if (clipReviewEnabled.value) return 'Public aus, Review bleibt sichtbar'
return 'Clip-Workflow deaktiviert'
})
const clipWorkflowStatusClass = computed(() =>
clipSubmissionsEnabled.value
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: clipReviewEnabled.value || submissions.value.length > 0
? 'border-amber-200 bg-amber-50 text-amber-700'
: 'border-slate-200 bg-slate-50 text-slate-600',
)
const submissions = computed(() => seasonDetail.value.clipSubmissions ?? [])
const categories = computed(() => seasonDetail.value.categories ?? [])
const categoryName = computed(() =>
@@ -188,6 +205,11 @@ export function useAdminClipManager() {
adminError,
clipToDelete,
reviewNotes,
clipSubmissionsEnabled,
clipReviewEnabled,
clipDisabledMessage,
clipWorkflowStatusLabel,
clipWorkflowStatusClass,
submissions,
categoryName,
clips,
@@ -22,6 +22,8 @@ function createEmptyForm(): AdminContentForm {
contactContent: '',
sponsorsUrl: '',
sponsorsContent: '',
showactsUrl: '',
showactsContent: '',
socialLinks: [],
faq: [],
}
@@ -83,6 +85,8 @@ export function useAdminContentManager() {
form.contactContent = settings.contactContent
form.sponsorsUrl = settings.sponsorsUrl
form.sponsorsContent = settings.sponsorsContent
form.showactsUrl = settings.showactsUrl
form.showactsContent = settings.showactsContent
form.socialLinks = settings.socialLinks.map((item) => ({
label: item.label ?? '',
platform: item.platform ?? '',
@@ -249,6 +253,8 @@ export function useAdminContentManager() {
contactContent: privacyContentForStorage(form.contactContent),
sponsorsUrl: form.sponsorsUrl,
sponsorsContent: privacyContentForStorage(form.sponsorsContent),
showactsUrl: form.showactsUrl,
showactsContent: privacyContentForStorage(form.showactsContent),
socialLinks: form.socialLinks
.map((item) => ({
label: item.label.trim(),
@@ -0,0 +1,123 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { useAwardsStore } from '../../stores/awards'
const fallbackDisabledMessage = 'Clip-Einreichungen sind aktuell geschlossen.'
export function useAdminOptionalFeatures() {
const store = useAwardsStore()
const optionalFeaturesLoading = ref(false)
const optionalFeaturesSaving = ref(false)
const optionalFeaturesError = ref('')
const optionalFeaturesSuccess = ref('')
const optionalFeaturesModalOpen = ref(false)
const originalSnapshot = ref('')
const optionalFeaturesForm = reactive({
clipSubmissionsEnabled: false,
clipReviewEnabled: true,
clipAdminMenuVisible: true,
clipSubmissionDisabledMessage: fallbackDisabledMessage,
})
const pendingClipCount = computed(() =>
store.adminSeasonDetail.clipSubmissions.filter((clip) => clip.status === 'pending').length,
)
const totalClipCount = computed(() => store.adminSeasonDetail.clipSubmissions.length)
const optionalFeaturesSummary = computed(() => ({
clipSubmissionsEnabled: optionalFeaturesForm.clipSubmissionsEnabled,
clipReviewEnabled: optionalFeaturesForm.clipReviewEnabled,
pendingClipCount: pendingClipCount.value,
totalClipCount: totalClipCount.value,
}))
const hasUnsavedOptionalFeatureChanges = computed(() =>
JSON.stringify(normalizedForm()) !== originalSnapshot.value,
)
function normalizedForm() {
return {
clipSubmissionsEnabled: optionalFeaturesForm.clipSubmissionsEnabled,
clipReviewEnabled: optionalFeaturesForm.clipReviewEnabled,
clipAdminMenuVisible: optionalFeaturesForm.clipAdminMenuVisible,
clipSubmissionDisabledMessage: normalizeDisabledMessage(optionalFeaturesForm.clipSubmissionDisabledMessage),
showactApplicationsEnabled: store.adminOptionalFeatureSettings.showactApplicationsEnabled,
showactApplicationDisabledMessage: store.adminOptionalFeatureSettings.showactApplicationDisabledMessage,
sponsorsVisible: store.adminOptionalFeatureSettings.sponsorsVisible,
}
}
function applyResponse(response = store.adminOptionalFeatureSettings) {
optionalFeaturesForm.clipSubmissionsEnabled = response.clipSubmissionsEnabled
optionalFeaturesForm.clipReviewEnabled = response.clipReviewEnabled
optionalFeaturesForm.clipAdminMenuVisible = response.clipAdminMenuVisible
optionalFeaturesForm.clipSubmissionDisabledMessage = normalizeDisabledMessage(response.clipSubmissionDisabledMessage)
originalSnapshot.value = JSON.stringify(normalizedForm())
}
async function loadOptionalFeatureSettings() {
optionalFeaturesLoading.value = true
optionalFeaturesError.value = ''
optionalFeaturesSuccess.value = ''
try {
const response = await store.loadAdminOptionalFeatureSettings()
applyResponse(response)
} catch (error) {
optionalFeaturesError.value = error instanceof Error ? error.message : 'Optionale Workflows konnten nicht geladen werden.'
applyResponse()
} finally {
optionalFeaturesLoading.value = false
}
}
async function saveOptionalFeatureSettings() {
optionalFeaturesSaving.value = true
optionalFeaturesError.value = ''
optionalFeaturesSuccess.value = ''
try {
const response = await store.updateAdminOptionalFeatureSettings(normalizedForm())
applyResponse(response)
optionalFeaturesSuccess.value = 'Clip-Workflow wurde gespeichert.'
optionalFeaturesModalOpen.value = false
} catch (error) {
optionalFeaturesError.value = error instanceof Error ? error.message : 'Clip-Workflow konnte nicht gespeichert werden.'
} finally {
optionalFeaturesSaving.value = false
}
}
function openOptionalFeaturesModal() {
optionalFeaturesError.value = ''
optionalFeaturesSuccess.value = ''
optionalFeaturesModalOpen.value = true
}
function closeOptionalFeaturesModal() {
if (hasUnsavedOptionalFeatureChanges.value && !window.confirm('Ungespeicherte Clip-Workflow-Änderungen verwerfen?')) {
return
}
applyResponse()
optionalFeaturesModalOpen.value = false
}
onMounted(loadOptionalFeatureSettings)
return {
hasUnsavedOptionalFeatureChanges,
optionalFeaturesError,
optionalFeaturesForm,
optionalFeaturesLoading,
optionalFeaturesModalOpen,
optionalFeaturesSaving,
optionalFeaturesSuccess,
optionalFeaturesSummary,
closeOptionalFeaturesModal,
loadOptionalFeatureSettings,
openOptionalFeaturesModal,
saveOptionalFeatureSettings,
}
}
function normalizeDisabledMessage(value: string) {
const trimmed = value.trim()
return trimmed ? trimmed.slice(0, 240) : fallbackDisabledMessage
}
@@ -8,6 +8,7 @@ export function useAdminReviewsManager() {
const store = useAwardsStore()
const route = useRoute()
const reviewSaving = ref<number | null>(null)
const blacklistSaving = ref(false)
const adminMessage = ref('')
const adminError = ref('')
const reviewFilter = ref('')
@@ -207,6 +208,24 @@ export function useAdminReviewsManager() {
}
}
async function addStreamUrlToBlacklist(streamUrl: string) {
const url = streamUrl.trim()
if (!url || blacklistSaving.value) return
blacklistSaving.value = true
adminMessage.value = ''
adminError.value = ''
try {
await store.addAdminNominationLinkBlacklistEntry({ url })
adminMessage.value = 'Link wurde zur Blacklist hinzugefügt.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Link konnte nicht zur Blacklist hinzugefügt werden.'
} finally {
blacklistSaving.value = false
}
}
function handleKeydown(event: KeyboardEvent) {
const target = event.target as Element
if (
@@ -280,6 +299,7 @@ export function useAdminReviewsManager() {
return {
reviewSaving,
blacklistSaving,
adminMessage,
adminError,
reviewForms,
@@ -299,6 +319,7 @@ export function useAdminReviewsManager() {
canApproveSelected,
approveNomination,
rejectNomination,
addStreamUrlToBlacklist,
selectedPlatformValue,
handlePlatformSelection,
extractNominationStreamUrl,
@@ -19,6 +19,7 @@ export function useAdminSettingsOverview() {
),
)
const pendingClips = computed(() => seasonDetail.value.clipSubmissions.filter((clip) => clip.status === 'pending').length)
const optionalFeatures = computed(() => store.adminOptionalFeatureSettings)
const pendingMigrationCount = computed(() => databaseHealth.value.pendingMigrations.length)
const openRiskCount = computed(() => getRiskMetricValue(store.admin.metrics))
const healthLoadedLabel = computed(() =>
@@ -29,12 +30,14 @@ export function useAdminSettingsOverview() {
siteSettings.value.imprintUrl,
siteSettings.value.contactUrl,
siteSettings.value.sponsorsUrl,
siteSettings.value.showactsUrl,
siteSettings.value.newsletterUrl,
].filter((url) => url.trim()).length)
const configuredFooterPages = computed(() => [
siteSettings.value.imprintContent,
siteSettings.value.contactContent,
siteSettings.value.sponsorsContent,
siteSettings.value.showactsContent,
].filter((content) => content.trim()).length)
const contentChecks = computed<AdminSettingsCheckItem[]>(() => [
{
@@ -62,8 +65,8 @@ export function useAdminSettingsOverview() {
},
{
label: 'Footer Links',
value: configuredFooterLinks.value === 4 && configuredFooterPages.value === 3,
note: `${configuredFooterLinks.value} von 4 Link-Zielen, ${configuredFooterPages.value} von 3 Footer-Seiten gepflegt`,
value: configuredFooterLinks.value === 5 && configuredFooterPages.value === 4,
note: `${configuredFooterLinks.value} von 5 Link-Zielen, ${configuredFooterPages.value} von 4 Footer-Seiten gepflegt`,
icon: Link2,
to: '/admin/content',
},
@@ -143,7 +146,15 @@ export function useAdminSettingsOverview() {
to: '/admin/categories',
},
{
label: 'Clip-Reviews offen',
label: 'Clip-Workflow',
state: optionalFeatures.value.clipSubmissionsEnabled,
note: optionalFeatures.value.clipSubmissionsEnabled
? 'Public-Clip-Einreichung ist aktiv.'
: 'Public-Clip-Einreichung ist deaktiviert.',
to: '/admin/settings',
},
{
label: 'Clip-Inbox',
state: pendingClips.value > 0,
note: pendingClips.value > 0 ? `${pendingClips.value} Clip-Einreichungen offen.` : 'Keine offenen Clip-Einreichungen.',
to: '/admin/clips',
@@ -2,8 +2,16 @@ import { computed, reactive, ref, watch } from 'vue'
import { CheckCircle2, Clock3, ListChecks, Trophy } from '@lucide/vue'
import { useAwardsStore } from '../../stores/awards'
import type { AdminCandidateItem, AdminWorkflowRule } from '../../types/awards'
const allFilter = 'all'
const maxWinnerPlacementsRuleKey = 'max_winner_placements'
const winnerRequiresClipRuleKey = 'winner_requires_clip'
type WinnerRuleNotice = {
mode: 'warn' | 'block'
message: string
}
export function useAdminWinnersManager() {
const store = useAwardsStore()
@@ -16,7 +24,53 @@ export function useAdminWinnersManager() {
const winnerSelections = reactive<Record<number, string>>({})
const seasonDetail = computed(() => store.adminSeasonDetail)
const workflowRules = computed(() => store.adminWorkflowRules.rules)
const resultMap = computed(() => new Map(seasonDetail.value.results.map((result) => [result.categoryId, result])))
const selectedCandidateMap = computed(() => new Map(seasonDetail.value.candidates.map((candidate) => [candidate.id, candidate])))
function findWorkflowRule(ruleKey: string): AdminWorkflowRule | null {
return workflowRules.value.find((rule) => rule.key === ruleKey) ?? null
}
function candidateIdentityKey(candidate: Pick<AdminCandidateItem, 'displayName' | 'channelSlug'>) {
const channel = candidate.channelSlug.trim().replace(/^@+/, '').toLowerCase()
return channel ? `slug:${channel}` : `name:${candidate.displayName.trim().toLowerCase()}`
}
function winnerRuleNoticeFor(categoryId: number): WinnerRuleNotice | null {
const candidateId = Number(winnerSelections[categoryId])
const selectedCandidate = selectedCandidateMap.value.get(candidateId)
if (!selectedCandidate) return null
const clipRule = findWorkflowRule(winnerRequiresClipRuleKey)
if (clipRule?.enabled && !selectedCandidate.clipCompilationUrl?.trim()) {
const mode = clipRule.mode === 'warn' ? 'warn' : 'block'
return {
mode,
message: 'Dieser Kandidat hat noch keinen Clip-Link. Bitte in der Kandidatenvorbereitung eine YouTube-/Twitch-Compilation pflegen.',
}
}
const rule = findWorkflowRule(maxWinnerPlacementsRuleKey)
if (!rule?.enabled) return null
const identityKey = candidateIdentityKey(selectedCandidate)
const existingWinnerCount = seasonDetail.value.results.filter((result) => {
if (result.categoryId === categoryId) return false
return candidateIdentityKey({
displayName: result.candidateDisplayName,
channelSlug: result.candidateChannelSlug,
}) === identityKey
}).length
if (existingWinnerCount < rule.limit) return null
const mode = rule.mode === 'warn' ? 'warn' : 'block'
return {
mode,
message: `Diese Person hat bereits ${existingWinnerCount} Gewinnerplatz(e). Limit: ${rule.limit}.`,
}
}
const resultRows = computed(() =>
seasonDetail.value.categories
@@ -125,6 +179,13 @@ export function useAdminWinnersManager() {
async function saveWinner(categoryId: number) {
const candidateId = Number(winnerSelections[categoryId])
if (!candidateId || !seasonDetail.value.id) return
const ruleNotice = winnerRuleNoticeFor(categoryId)
if (ruleNotice?.mode === 'block') {
adminMessage.value = ''
adminError.value = `Workflow-Regel blockiert: ${ruleNotice.message}`
return
}
savingResultForCategory.value = categoryId
adminMessage.value = ''
adminError.value = ''
@@ -167,5 +228,6 @@ export function useAdminWinnersManager() {
winnerSelections,
clearWinner,
saveWinner,
winnerRuleNoticeFor,
}
}
@@ -0,0 +1,97 @@
import { computed, onMounted, ref } from 'vue'
import { useAwardsStore } from '../../stores/awards'
import type { AdminWorkflowRule } from '../../types/awards'
function cloneRules(rules: AdminWorkflowRule[]) {
return rules.map((rule) => ({ ...rule }))
}
function normalizeRule(rule: AdminWorkflowRule): AdminWorkflowRule {
return {
...rule,
enabled: Boolean(rule.enabled),
limit: Math.min(50, Math.max(1, Number(rule.limit) || 1)),
mode: rule.mode === 'warn' ? 'warn' : 'block',
}
}
export function useAdminWorkflowRules() {
const store = useAwardsStore()
const workflowLoading = ref(false)
const workflowSaving = ref(false)
const workflowError = ref('')
const workflowSuccess = ref('')
const workflowRules = ref<AdminWorkflowRule[]>([])
const originalSnapshot = ref('')
const workflowRuleSummary = computed(() => {
const active = workflowRules.value.filter((rule) => rule.enabled).length
const blocking = workflowRules.value.filter((rule) => rule.enabled && rule.mode === 'block').length
return {
active,
blocking,
total: workflowRules.value.length,
}
})
const hasUnsavedWorkflowRuleChanges = computed(() =>
JSON.stringify(workflowRules.value.map(normalizeRule)) !== originalSnapshot.value,
)
async function loadWorkflowRules() {
workflowLoading.value = true
workflowError.value = ''
workflowSuccess.value = ''
try {
const response = await store.loadAdminWorkflowRules()
workflowRules.value = cloneRules(response.rules)
originalSnapshot.value = JSON.stringify(workflowRules.value.map(normalizeRule))
} catch (error) {
workflowError.value = error instanceof Error ? error.message : 'Workflow-Regeln konnten nicht geladen werden.'
workflowRules.value = []
originalSnapshot.value = '[]'
} finally {
workflowLoading.value = false
}
}
function updateWorkflowRule(ruleKey: string, patch: Partial<AdminWorkflowRule>) {
workflowRules.value = workflowRules.value.map((rule) =>
rule.key === ruleKey ? normalizeRule({ ...rule, ...patch }) : rule,
)
}
async function saveWorkflowRules() {
workflowSaving.value = true
workflowError.value = ''
workflowSuccess.value = ''
try {
const payloadRules = workflowRules.value.map(normalizeRule)
const response = await store.updateAdminWorkflowRules({ rules: payloadRules })
workflowRules.value = cloneRules(response.rules)
originalSnapshot.value = JSON.stringify(workflowRules.value.map(normalizeRule))
workflowSuccess.value = 'Workflow-Regeln wurden gespeichert.'
} catch (error) {
workflowError.value = error instanceof Error ? error.message : 'Workflow-Regeln konnten nicht gespeichert werden.'
} finally {
workflowSaving.value = false
}
}
onMounted(loadWorkflowRules)
return {
hasUnsavedWorkflowRuleChanges,
workflowError,
workflowLoading,
workflowRules,
workflowRuleSummary,
workflowSaving,
workflowSuccess,
loadWorkflowRules,
saveWorkflowRules,
updateWorkflowRule,
}
}