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:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user