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