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
@@ -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
}
}