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
@@ -19,12 +19,17 @@ const {
adminError,
search,
categoryFilter,
readinessFilter,
page,
categoryOptions,
categoryFilterOptions,
categoryLabelMap,
duplicateCandidateKeys,
candidateWorkflowNotices,
duplicateCandidateCount,
acceptedCandidateCount,
clipReadyCount,
actionNeededCount,
filteredCandidates,
pagedCandidates,
totalPages,
@@ -36,6 +41,9 @@ const {
canSave,
candidatePlatformOptions,
selectedPlatformValue,
acceptanceStatusOptions,
clipEmbedStatusOptions,
readinessFilterOptions,
candidateToDelete,
clearFilters,
openCreate,
@@ -76,6 +84,24 @@ watchAdminToast(adminMessage, adminError)
<UserPlus class="h-6 w-6 text-violet-500" />
</div>
</Card>
<Card class="p-5">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-emerald-600">Angenommen</p>
<strong class="mt-3 block text-3xl text-emerald-800">{{ acceptedCandidateCount }}</strong>
</div>
<UserPlus class="h-6 w-6 text-emerald-500" />
</div>
</Card>
<Card class="p-5">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-sky-600">Clip bereit</p>
<strong class="mt-3 block text-3xl text-sky-800">{{ clipReadyCount }}</strong>
</div>
<Layers3 class="h-6 w-6 text-sky-500" />
</div>
</Card>
<Card class="p-5" :class="duplicateCandidateCount > 0 ? 'border-amber-200 bg-amber-50/60' : ''">
<div class="flex items-start justify-between gap-4">
<div>
@@ -85,15 +111,27 @@ watchAdminToast(adminMessage, adminError)
<Layers3 class="h-6 w-6" :class="duplicateCandidateCount > 0 ? 'text-amber-500' : 'text-violet-500'" />
</div>
</Card>
<Card class="p-5" :class="actionNeededCount > 0 ? 'border-rose-200 bg-rose-50/60' : ''">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em]" :class="actionNeededCount > 0 ? 'text-rose-600' : 'text-violet-500'">Handlungsbedarf</p>
<strong class="mt-3 block text-3xl" :class="actionNeededCount > 0 ? 'text-rose-700' : 'text-violet-900'">{{ actionNeededCount }}</strong>
</div>
<UserPlus class="h-6 w-6" :class="actionNeededCount > 0 ? 'text-rose-500' : 'text-violet-500'" />
</div>
</Card>
</section>
<Card class="overflow-hidden">
<AdminCandidatesFiltersBar
:search="search"
:category-filter="categoryFilter"
:readiness-filter="readinessFilter"
:category-filter-options="categoryFilterOptions"
:readiness-filter-options="readinessFilterOptions"
@update:search="search = $event"
@update:category-filter="categoryFilter = $event"
@update:readiness-filter="readinessFilter = $event"
@clear-filters="clearFilters"
@open-create="openCreate"
/>
@@ -108,6 +146,8 @@ watchAdminToast(adminMessage, adminError)
:range-end="rangeEnd"
:category-label-map="categoryLabelMap"
:duplicate-candidate-keys="duplicateCandidateKeys"
:candidate-workflow-notices="candidateWorkflowNotices"
:acceptance-status-options="acceptanceStatusOptions"
@edit="openEdit"
@delete="candidateToDelete = $event"
@update:page="page = $event"
@@ -122,6 +162,8 @@ watchAdminToast(adminMessage, adminError)
:category-options="categoryOptions"
:candidate-platform-options="candidatePlatformOptions"
:selected-platform-value="selectedPlatformValue"
:acceptance-status-options="acceptanceStatusOptions"
:clip-embed-status-options="clipEmbedStatusOptions"
:can-save="canSave"
:saving="saving"
@close="modalOpen = false"
@@ -130,6 +172,12 @@ watchAdminToast(adminMessage, adminError)
@update:display-name="form.displayName = $event"
@update:channel-slug="form.channelSlug = $event"
@update:platform="form.platform = $event"
@update:acceptance-status="form.acceptanceStatus = $event"
@update:acceptance-note="form.acceptanceNote = $event"
@update:clip-compilation-url="form.clipCompilationUrl = $event"
@update:clip-compilation-title="form.clipCompilationTitle = $event"
@update:clip-compilation-platform="form.clipCompilationPlatform = $event"
@update:clip-embed-status="form.clipEmbedStatus = $event"
@platform-selection="handlePlatformSelection"
/>
+29 -1
View File
@@ -23,6 +23,11 @@ const {
adminError,
clipToDelete,
reviewNotes,
clipSubmissionsEnabled,
clipReviewEnabled,
clipDisabledMessage,
clipWorkflowStatusLabel,
clipWorkflowStatusClass,
submissions,
categoryName,
clips,
@@ -54,10 +59,33 @@ watchAdminToast(adminMessage, adminError)
<AdminPageHeader
eyebrow="Clips"
:icon="Film"
:description="clipWorkflowStatusLabel"
/>
<AdminSeasonToolbar />
<div
class="flex flex-wrap items-start justify-between gap-4 rounded-2xl border px-5 py-4 shadow-sm"
:class="clipSubmissionsEnabled ? 'border-emerald-100 bg-emerald-50/70' : 'border-amber-100 bg-amber-50/70'"
>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span :class="['rounded-full border px-3 py-1 text-xs font-semibold', clipWorkflowStatusClass]">
{{ clipWorkflowStatusLabel }}
</span>
<span :class="['rounded-full border px-3 py-1 text-xs font-semibold', clipReviewEnabled ? 'border-violet-200 bg-white text-violet-700' : 'border-slate-200 bg-white text-slate-600']">
Admin-Review {{ clipReviewEnabled ? 'sichtbar' : 'aus' }}
</span>
</div>
<p class="mt-2 text-sm leading-6 text-slate-600">
Clip-Einreichungen sind optional. Finale Voting-Clips werden am Kandidaten als externe YouTube-/Twitch-Links gepflegt.
</p>
<p v-if="!clipSubmissionsEnabled" class="mt-1 text-xs font-semibold text-amber-800">
Public-Hinweis: {{ clipDisabledMessage }}
</p>
</div>
</div>
<section class="grid gap-4 lg:grid-cols-4">
<Card v-for="stat in stats" :key="stat.label" class="p-5">
<div class="flex items-start justify-between gap-4">
@@ -220,7 +248,7 @@ watchAdminToast(adminMessage, adminError)
<div v-if="clips.length === 0" class="px-5 py-12 text-center">
<Users class="mx-auto h-6 w-6 text-violet-300" />
<p class="mt-2 text-sm text-slate-500">
{{ submissions.length === 0 ? 'Noch keine Clip-Einreichungen in diesem Jahr.' : 'Keine Treffer für den aktuellen Filter.' }}
{{ submissions.length === 0 && !clipSubmissionsEnabled ? 'Clip-Einreichungen sind deaktiviert. Bestehende Clips würden hier weiterhin prüfbar bleiben.' : submissions.length === 0 ? 'Noch keine Clip-Einreichungen in diesem Jahr.' : 'Keine Treffer für den aktuellen Filter.' }}
</p>
</div>
</div>
+24 -4
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { FileText, Link2, MessageCircleQuestion, Share2, ShieldCheck } from '@lucide/vue'
import { FileText, Link2, MessageCircleQuestion, Mic2, Share2, ShieldCheck } from '@lucide/vue'
import { computed, ref } from 'vue'
import AdminContentBasicsSection from '../../components/admin/AdminContentBasicsSection.vue'
import AdminContentFaqPreviewModal from '../../components/admin/AdminContentFaqPreviewModal.vue'
import AdminContentFaqSection from '../../components/admin/AdminContentFaqSection.vue'
import AdminContentFooterPreviewModal from '../../components/admin/AdminContentFooterPreviewModal.vue'
import AdminContentLandingExtrasSection from '../../components/admin/AdminContentLandingExtrasSection.vue'
import AdminContentLinksSection from '../../components/admin/AdminContentLinksSection.vue'
import type { FooterPreviewKey } from '../../components/admin/AdminContentLinksSection.vue'
import AdminContentPrivacyPreviewModal from '../../components/admin/AdminContentPrivacyPreviewModal.vue'
@@ -15,9 +16,12 @@ import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import Modal from '../../components/ui/Modal.vue'
import { watchAdminToast } from '../../composables/useAdminToast'
import { privacyContentToHtml } from '../../lib/privacyContent'
import { useAwardsStore } from '../../stores/awards'
import { useAdminContentManager } from '../../components/admin/useAdminContentManager'
type ContentEditorKey = 'links' | 'socials' | 'faq' | 'privacy'
type ContentEditorKey = 'links' | 'socials' | 'faq' | 'privacy' | 'extras'
const store = useAwardsStore()
const {
form,
@@ -68,6 +72,11 @@ const footerPreviewPages = computed<Record<FooterPreviewKey, { title: string, ur
url: form.sponsorsUrl,
content: form.sponsorsContent,
},
showacts: {
title: 'Showacts',
url: form.showactsUrl,
content: form.showactsContent,
},
}))
const activeFooterPreview = computed(() => footerPreviewPages.value[footerPreviewKey.value])
@@ -77,10 +86,18 @@ const contentEditors = computed(() => [
key: 'links' as const,
eyebrow: 'Footer & Kontakt',
title: 'Rechtliche Links',
description: 'Kontaktwege, Impressum und Sponsoren-Inhalte bearbeiten.',
metric: `${[form.contactUrl, form.imprintUrl, form.sponsorsUrl].filter(Boolean).length}/3 URLs`,
description: 'Kontaktwege, Impressum, Sponsoren- und Showact-Inhalte bearbeiten.',
metric: `${[form.contactUrl, form.imprintUrl, form.sponsorsUrl, form.showactsUrl].filter(Boolean).length}/4 URLs`,
icon: Link2,
},
{
key: 'extras' as const,
eyebrow: 'Show Module',
title: 'Showacts & Sponsoren',
description: 'Landingpage-Module, Bewerbungen und Sponsorenlisten verwalten.',
metric: `${store.adminShowactApplications.length + store.adminSponsors.length} Einträge`,
icon: Mic2,
},
{
key: 'socials' as const,
eyebrow: 'Community',
@@ -205,6 +222,9 @@ function openContentEditor(key: ContentEditorKey) {
:save-site-settings="saveSiteSettings"
@open-preview="privacyPreviewOpen = true"
/>
<AdminContentLandingExtrasSection
v-else-if="activeContentEditor === 'extras'"
/>
</Modal>
<AdminContentPrivacyPreviewModal
+15 -2
View File
@@ -30,7 +30,11 @@ const route = useRoute()
const store = useAwardsStore()
const authStore = useAuthStore()
const openRiskCount = computed(() => getRiskMetricValue(store.admin.metrics))
const clipWorkflowEnabled = computed(() => store.adminOptionalFeatureSettings.clipSubmissionsEnabled)
const clipReviewVisible = computed(() => store.adminOptionalFeatureSettings.clipReviewEnabled)
const clipAdminMenuVisible = computed(() => store.adminOptionalFeatureSettings.clipAdminMenuVisible)
const pendingClipCount = computed(() => store.adminSeasonDetail.clipSubmissions.filter((clip) => clip.status === 'pending').length)
const totalClipCount = computed(() => store.adminSeasonDetail.clipSubmissions.length)
const adminWorkspaceLoading = ref(false)
const adminWorkspaceLoaded = ref(false)
const mobileNavOpen = ref(false)
@@ -51,8 +55,17 @@ const fullNavGroups = computed(() => [
{ label: 'Jahre', to: '/admin/years', description: 'Jahresstatus und Setup', icon: CalendarCog, permission: 'years', badge: () => `${store.adminSeasons.length}` },
{ label: 'Kategorien', to: '/admin/categories', description: 'Struktur und Limits', icon: Tags, permission: 'categories', badge: () => `${store.adminSeasonDetail.categories.length}` },
{ label: 'Kandidaten', to: '/admin/candidates', description: 'Kandidatenbasis pflegen', icon: Users, permission: 'candidates', badge: () => `${store.adminSeasonDetail.candidates.length}` },
{ label: 'Clips', to: '/admin/clips', description: 'Clip-Einreichungen prüfen', icon: Film, permission: 'clips', badge: () => `${pendingClipCount.value}` },
{ label: 'Landingpage', to: '/admin/content', description: 'FAQ, Footer und Datenschutz', icon: FileText, permission: 'content', badge: () => null },
...(clipAdminMenuVisible.value ? [{
label: 'Clips',
to: '/admin/clips',
description: clipWorkflowEnabled.value
? 'Clip-Einreichungen prüfen'
: clipReviewVisible.value || totalClipCount.value > 0 ? 'Optionale Clip-Inbox' : 'Clip-Workflow deaktiviert',
icon: Film,
permission: 'clips',
badge: () => pendingClipCount.value > 0 ? `${pendingClipCount.value}` : clipWorkflowEnabled.value ? null : 'aus',
}] : []),
{ label: 'Landingpage', to: '/admin/content', description: 'Public-Inhalte pflegen', icon: FileText, permission: 'content', badge: () => null },
],
},
{
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ClipboardList, Search, Sparkles, Tags, Users, XCircle } from '@lucide/vue'
import { Ban, ClipboardList, Search, Sparkles, Tags, Users, XCircle } from '@lucide/vue'
import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router'
import AdminNominationReviewModal from '../../components/admin/AdminNominationReviewModal.vue'
import AdminNominationLinkBlacklistModal from '../../components/admin/AdminNominationLinkBlacklistModal.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Card from '../../components/ui/Card.vue'
@@ -17,6 +18,7 @@ const query = ref('')
const categoryFilter = ref<number | null>(null)
const statusFilter = ref<'all' | 'selected' | 'empty-category' | 'heavy'>('all')
const reviewModalOpen = ref(false)
const blacklistModalOpen = ref(false)
const seasonDetail = computed(() => store.adminSeasonDetail)
const categoryMap = computed(() => Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category])))
@@ -170,13 +172,23 @@ watch(
</span>
</div>
</div>
<button
type="button"
class="inline-flex h-12 items-center justify-center rounded-2xl bg-violet-600 px-5 text-sm font-semibold text-white shadow-lg shadow-violet-500/20 transition hover:bg-violet-500"
@click="openReviewModal()"
>
Review-Fokus öffnen
</button>
<div class="flex flex-wrap gap-3">
<button
type="button"
class="inline-flex h-12 items-center justify-center gap-2 rounded-2xl border border-rose-200 bg-white px-5 text-sm font-semibold text-rose-700 shadow-sm shadow-rose-100/60 transition hover:bg-rose-50"
@click="blacklistModalOpen = true"
>
<Ban class="h-4 w-4" />
Link-Blacklist
</button>
<button
type="button"
class="inline-flex h-12 items-center justify-center rounded-2xl bg-violet-600 px-5 text-sm font-semibold text-white shadow-lg shadow-violet-500/20 transition hover:bg-violet-500"
@click="openReviewModal()"
>
Review-Fokus öffnen
</button>
</div>
</div>
</Card>
@@ -297,5 +309,6 @@ watch(
</section>
<AdminNominationReviewModal :open="reviewModalOpen" @close="closeReviewModal" />
<AdminNominationLinkBlacklistModal :open="blacklistModalOpen" @close="blacklistModalOpen = false" />
</div>
</template>
@@ -13,6 +13,7 @@ import { watchAdminToast } from '../../composables/useAdminToast'
const {
reviewSaving,
blacklistSaving,
adminMessage,
adminError,
reviewForms,
@@ -30,6 +31,7 @@ const {
canApproveSelected,
approveNomination,
rejectNomination,
addStreamUrlToBlacklist,
selectedPlatformValue,
handlePlatformSelection,
} = useAdminReviewsManager()
@@ -73,10 +75,12 @@ watchAdminToast(adminMessage, adminError)
:candidate-platform-options="candidatePlatformOptions"
:selected-candidate-collision="selectedCandidateCollision"
:can-approve-selected="canApproveSelected"
:blacklist-saving="blacklistSaving"
:selected-platform-value="selectedPlatformValue"
@platform-change="handlePlatformSelection"
@approve="approveNomination"
@reject="rejectNomination"
@blacklist-link="addStreamUrlToBlacklist"
/>
</div>
+80 -5
View File
@@ -4,18 +4,25 @@ import { computed, onBeforeUnmount, onMounted } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import AdminOperationalSettingsCard from '../../components/admin/AdminOperationalSettingsCard.vue'
import AdminOptionalFeaturesCard from '../../components/admin/AdminOptionalFeaturesCard.vue'
import AdminOptionalFeaturesModal from '../../components/admin/AdminOptionalFeaturesModal.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminReleaseNotesCard from '../../components/admin/AdminReleaseNotesCard.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import AdminSettingsDatabaseCard from '../../components/admin/AdminSettingsDatabaseCard.vue'
import AdminSettingsOverviewBoard from '../../components/admin/AdminSettingsOverviewBoard.vue'
import AdminWorkflowRulesCard from '../../components/admin/AdminWorkflowRulesCard.vue'
import { watchAdminErrorToast, watchAdminToast } from '../../composables/useAdminToast'
import { useAuthStore } from '../../stores/auth'
import { useAdminOperationalSettings } from '../../components/admin/useAdminOperationalSettings'
import { useAdminOptionalFeatures } from '../../components/admin/useAdminOptionalFeatures'
import { useAdminSettingsOverview } from '../../components/admin/useAdminSettingsOverview'
import { useAdminWorkflowRules } from '../../components/admin/useAdminWorkflowRules'
const authStore = useAuthStore()
const canManageOperationalSettings = computed(() => authStore.canManageOperationalSettings)
const canManageWorkflowRules = computed(() => authStore.hasPermission('settings'))
const canManageOptionalFeatures = computed(() => authStore.hasPermission('settings'))
const {
healthLoading,
@@ -52,24 +59,58 @@ const {
saveOperationalSettings,
} = useAdminOperationalSettings()
const {
hasUnsavedOptionalFeatureChanges,
optionalFeaturesError,
optionalFeaturesForm,
optionalFeaturesLoading,
optionalFeaturesModalOpen,
optionalFeaturesSaving,
optionalFeaturesSuccess,
optionalFeaturesSummary,
closeOptionalFeaturesModal,
openOptionalFeaturesModal,
saveOptionalFeatureSettings,
} = useAdminOptionalFeatures()
const {
hasUnsavedWorkflowRuleChanges,
workflowError,
workflowLoading,
workflowRules,
workflowRuleSummary,
workflowSaving,
workflowSuccess,
saveWorkflowRules,
updateWorkflowRule,
} = useAdminWorkflowRules()
watchAdminToast(operationalSuccess, operationalError)
watchAdminToast(optionalFeaturesSuccess, optionalFeaturesError)
watchAdminToast(workflowSuccess, workflowError)
watchAdminErrorToast(healthError)
function confirmDiscardOperationalChanges() {
if (!hasUnsavedOperationalChanges.value) {
function hasUnsavedSettingsChanges() {
return hasUnsavedOperationalChanges.value
|| hasUnsavedOptionalFeatureChanges.value
|| hasUnsavedWorkflowRuleChanges.value
}
function confirmDiscardSettingsChanges() {
if (!hasUnsavedSettingsChanges()) {
return true
}
return window.confirm('Du hast ungespeicherte Änderungen in Demo & Wartung. Änderungen verwerfen?')
return window.confirm('Du hast ungespeicherte Änderungen in den Einstellungen. Änderungen verwerfen?')
}
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (!hasUnsavedOperationalChanges.value) return
if (!hasUnsavedSettingsChanges()) return
event.preventDefault()
event.returnValue = ''
}
onBeforeRouteLeave(() => confirmDiscardOperationalChanges())
onBeforeRouteLeave(() => confirmDiscardSettingsChanges())
onMounted(() => window.addEventListener('beforeunload', handleBeforeUnload))
onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnload))
</script>
@@ -91,8 +132,28 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
:content-completion="contentCompletion"
/>
<AdminOptionalFeaturesCard
:loading="optionalFeaturesLoading"
:saving="optionalFeaturesSaving"
:can-manage="canManageOptionalFeatures"
:summary="optionalFeaturesSummary"
:disabled-message="optionalFeaturesForm.clipSubmissionDisabledMessage"
@configure="openOptionalFeaturesModal"
/>
<AdminReleaseNotesCard />
<AdminWorkflowRulesCard
:rules="workflowRules"
:loading="workflowLoading"
:saving="workflowSaving"
:dirty="hasUnsavedWorkflowRuleChanges"
:can-manage="canManageWorkflowRules"
:summary="workflowRuleSummary"
@update-rule="updateWorkflowRule"
@save="saveWorkflowRules"
/>
<AdminOperationalSettingsCard
v-model:demo-password="demoPasswordInput"
v-model:twitch-client-secret="twitchClientSecretInput"
@@ -124,5 +185,19 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
:health-loaded-label="healthLoadedLabel"
:refresh-database-health="refreshDatabaseHealth"
/>
<AdminOptionalFeaturesModal
:open="optionalFeaturesModalOpen"
:form="optionalFeaturesForm"
:saving="optionalFeaturesSaving"
:dirty="hasUnsavedOptionalFeatureChanges"
:can-manage="canManageOptionalFeatures"
@close="closeOptionalFeaturesModal"
@save="saveOptionalFeatureSettings"
@update:clip-submissions-enabled="optionalFeaturesForm.clipSubmissionsEnabled = $event"
@update:clip-review-enabled="optionalFeaturesForm.clipReviewEnabled = $event"
@update:clip-admin-menu-visible="optionalFeaturesForm.clipAdminMenuVisible = $event"
@update:clip-submission-disabled-message="optionalFeaturesForm.clipSubmissionDisabledMessage = $event"
/>
</div>
</template>
+29 -12
View File
@@ -23,6 +23,7 @@ const {
winnerSelections,
clearWinner,
saveWinner,
winnerRuleNoticeFor,
} = useAdminWinnersManager()
watchAdminToast(adminMessage, adminError)
@@ -131,20 +132,36 @@ watchAdminToast(adminMessage, adminError)
</p>
</div>
<NativeSelect
v-model="winnerSelections[row.category.id]"
:disabled="row.isEmpty"
:options="[
{ label: 'Bitte Gewinner waehlen', value: '' },
...row.candidates.map((candidate) => ({
label: `${candidate.displayName} · ${candidate.channelSlug} · ${candidate.platform}`,
value: `${candidate.id}`,
})),
]"
/>
<div class="min-w-0 space-y-2">
<NativeSelect
v-model="winnerSelections[row.category.id]"
:disabled="row.isEmpty"
:options="[
{ label: 'Bitte Gewinner waehlen', value: '' },
...row.candidates.map((candidate) => ({
label: `${candidate.displayName} · ${candidate.channelSlug} · ${candidate.platform} · ${candidate.clipCompilationUrl ? 'Clip gepflegt' : 'kein Clip'}`,
value: `${candidate.id}`,
})),
]"
/>
<p
v-if="winnerRuleNoticeFor(row.category.id)"
class="rounded-2xl border px-3 py-2 text-sm font-semibold"
:class="winnerRuleNoticeFor(row.category.id)?.mode === 'block'
? 'border-rose-100 bg-rose-50 text-rose-700'
: 'border-amber-100 bg-amber-50 text-amber-700'"
>
{{ winnerRuleNoticeFor(row.category.id)?.mode === 'block' ? 'Blockiert' : 'Warnung' }}:
{{ winnerRuleNoticeFor(row.category.id)?.message }}
</p>
</div>
<div class="flex flex-wrap justify-end gap-2">
<Button :disabled="savingResultForCategory === row.category.id || row.isEmpty || !winnerSelections[row.category.id]" @click="saveWinner(row.category.id)">
<Button
:disabled="savingResultForCategory === row.category.id || row.isEmpty || !winnerSelections[row.category.id] || winnerRuleNoticeFor(row.category.id)?.mode === 'block'"
@click="saveWinner(row.category.id)"
>
{{ savingResultForCategory === row.category.id ? 'Speichert ...' : row.existing ? 'Aktualisieren' : 'Setzen' }}
</Button>
<Button