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