Add viewer-range categories, nomination tracking, dynamic showact form, session timeout, share URLs, and workflow-per-season
Features: - Category viewer ranges + subcategory templates (admin group modal, tree workspace) - Nomination enrichment via TwitchTracker API (NominationEnrichmentService, TwitchTrackerViewerStatsProvider) with admin tracking rules editor - Nomination group tracker: CategoryGroupName as primary identifier, CategoryId stays as nullable legacy field; StreamerIdentity table - Dynamic showact application form builder (AdminShowactFormBuilder, ShowactApplicationSchedule) - Session idle timeout setting (AdminSessionTimeoutCard) - Share URLs for X and Discord (SiteSettings, public extras) - Workflow rules now stored per season (falls back to global SiteSettings) - New admin routes: settings/access, settings/workflows, tracking-rules - New admin review workspace with subcategory tabs - AdminCategoriesView rebuilt with group/subcategory modals Migrations (all additive): - AddShareUrls, AddShowactDynamicForm, AddCategoryViewerRanges, AddSessionIdleTimeoutSettings, AddSeasonSubcategoryTemplates, AddNominationGroupTrackerIdentity, AddShowactApplicationSchedule, AddSeasonWorkflowRulesJson Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { KeyRound, Save, Wrench } from '@lucide/vue'
|
||||
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
|
||||
import AdminDemoPreviewCard from '../../components/admin/AdminDemoPreviewCard.vue'
|
||||
import AdminMaintenanceModeCard from '../../components/admin/AdminMaintenanceModeCard.vue'
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import { useAdminOperationalSettings } from '../../components/admin/useAdminOperationalSettings'
|
||||
import { watchAdminToast } from '../../composables/useAdminToast'
|
||||
import Button from '../../components/ui/Button.vue'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const canManageOperationalSettings = computed(() => authStore.canManageOperationalSettings)
|
||||
|
||||
const {
|
||||
operationalLoading,
|
||||
operationalSaving,
|
||||
operationalError,
|
||||
operationalSuccess,
|
||||
operationalForm,
|
||||
demoPasswordSet,
|
||||
demoManagedByDatabase,
|
||||
demoPasswordInput,
|
||||
demoPasswordHint,
|
||||
demoCredentialsComplete,
|
||||
hasUnsavedOperationalChanges,
|
||||
saveOperationalSettings,
|
||||
} = useAdminOperationalSettings()
|
||||
|
||||
watchAdminToast(operationalSuccess, operationalError)
|
||||
|
||||
function confirmDiscardSettingsChanges() {
|
||||
if (!hasUnsavedOperationalChanges.value) {
|
||||
return true
|
||||
}
|
||||
|
||||
return window.confirm('Du hast ungespeicherte Änderungen in Demo und Wartung. Änderungen verwerfen?')
|
||||
}
|
||||
|
||||
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
||||
if (!hasUnsavedOperationalChanges.value) return
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(() => confirmDiscardSettingsChanges())
|
||||
onMounted(() => window.addEventListener('beforeunload', handleBeforeUnload))
|
||||
onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnload))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<AdminPageHeader
|
||||
eyebrow="Demo & Wartung"
|
||||
description="Bündelt Demo-Schutz und Wartungsmodus an einem sinnvollen Betriebsort."
|
||||
:icon="Wrench"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button class="gap-2 rounded-2xl px-5" :disabled="operationalLoading || operationalSaving || !canManageOperationalSettings || !hasUnsavedOperationalChanges" @click="saveOperationalSettings">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ operationalSaving ? 'Speichert ...' : 'Betriebseinstellungen speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section class="grid gap-6 xl:grid-cols-2">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2 px-1 text-sm font-semibold text-violet-700">
|
||||
<KeyRound class="h-4 w-4" />
|
||||
Demo-Vorschau
|
||||
</div>
|
||||
<AdminDemoPreviewCard
|
||||
:form="operationalForm"
|
||||
:saving="operationalSaving"
|
||||
:can-manage="canManageOperationalSettings"
|
||||
:demo-password="demoPasswordInput"
|
||||
:demo-password-hint="demoPasswordHint"
|
||||
:demo-password-set="demoPasswordSet"
|
||||
:demo-managed-by-database="demoManagedByDatabase"
|
||||
:demo-credentials-complete="demoCredentialsComplete"
|
||||
@update:demo-password="demoPasswordInput = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2 px-1 text-sm font-semibold text-amber-700">
|
||||
<Wrench class="h-4 w-4" />
|
||||
Wartungsmodus
|
||||
</div>
|
||||
<AdminMaintenanceModeCard
|
||||
:form="operationalForm"
|
||||
:saving="operationalSaving"
|
||||
:can-manage="canManageOperationalSettings"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { BarChart3, CheckCircle2, ExternalLink, TrendingUp } from '@lucide/vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||
@@ -23,6 +23,8 @@ const {
|
||||
totalVotes,
|
||||
} = useAdminAnalyticsManager()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const healthFilter = ref<string>('Alle')
|
||||
const groupFilter = ref<string>('Alle')
|
||||
|
||||
@@ -42,6 +44,43 @@ const healthStatusOptions = [
|
||||
{ key: 'Bereit', label: 'Bereit', dot: 'bg-sky-400' },
|
||||
{ key: 'Gewinner gesetzt', label: 'Gewinner', dot: 'bg-emerald-400' },
|
||||
]
|
||||
|
||||
function applyRouteFilters() {
|
||||
const rawHealth = Array.isArray(route.query.health) ? route.query.health[0] : route.query.health
|
||||
const rawGroup = Array.isArray(route.query.group) ? route.query.group[0] : route.query.group
|
||||
|
||||
healthFilter.value = healthStatusOptions.some((option) => option.key === rawHealth) ? String(rawHealth) : 'Alle'
|
||||
groupFilter.value = groupOptions.value.includes(String(rawGroup)) ? String(rawGroup) : 'Alle'
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.query.health, route.query.group, groupOptions.value.join('|')] as const,
|
||||
() => {
|
||||
applyRouteFilters()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch([healthFilter, groupFilter], ([nextHealth, nextGroup]) => {
|
||||
const currentHealth = Array.isArray(route.query.health) ? route.query.health[0] : route.query.health
|
||||
const currentGroup = Array.isArray(route.query.group) ? route.query.group[0] : route.query.group
|
||||
|
||||
const normalizedHealth = nextHealth === 'Alle' ? undefined : nextHealth
|
||||
const normalizedGroup = nextGroup === 'Alle' ? undefined : nextGroup
|
||||
|
||||
if (currentHealth === normalizedHealth && currentGroup === normalizedGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextQuery = { ...route.query } as Record<string, string>
|
||||
if (normalizedHealth) nextQuery.health = normalizedHealth
|
||||
else delete nextQuery.health
|
||||
|
||||
if (normalizedGroup) nextQuery.group = normalizedGroup
|
||||
else delete nextQuery.group
|
||||
|
||||
void router.replace({ query: nextQuery })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -163,6 +202,14 @@ const healthStatusOptions = [
|
||||
<p class="mt-3 text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">{{ insight.label }}</p>
|
||||
<strong class="mt-1 block text-2xl text-slate-950">{{ insight.value }}</strong>
|
||||
<p class="mt-1 text-xs leading-5 text-slate-500">{{ insight.note }}</p>
|
||||
<RouterLink
|
||||
v-if="'to' in insight && insight.to"
|
||||
:to="insight.to"
|
||||
class="mt-3 inline-flex items-center gap-1 text-xs font-semibold text-violet-700 hover:text-violet-800 hover:underline"
|
||||
>
|
||||
Öffnen
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -261,7 +308,7 @@ const healthStatusOptions = [
|
||||
<RouterLink
|
||||
v-for="category in filteredHealth"
|
||||
:key="category.id"
|
||||
to="/admin/categories"
|
||||
:to="`/admin/categories?group=${encodeURIComponent(category.groupName || 'Ohne Gruppe')}`"
|
||||
class="grid items-center gap-3 px-5 py-3.5 transition hover:bg-violet-50/50 sm:grid-cols-[minmax(0,1fr)_80px_80px_100px_140px]"
|
||||
>
|
||||
<!-- Name + group -->
|
||||
|
||||
@@ -25,6 +25,7 @@ const {
|
||||
categoryFilterOptions,
|
||||
categoryLabelMap,
|
||||
duplicateCandidateKeys,
|
||||
candidateIdentitySummaries,
|
||||
candidateWorkflowNotices,
|
||||
duplicateCandidateCount,
|
||||
acceptedCandidateCount,
|
||||
@@ -41,6 +42,8 @@ const {
|
||||
canSave,
|
||||
candidatePlatformOptions,
|
||||
selectedPlatformValue,
|
||||
editorIdentitySummary,
|
||||
editorRuleNotices,
|
||||
acceptanceStatusOptions,
|
||||
clipEmbedStatusOptions,
|
||||
readinessFilterOptions,
|
||||
@@ -146,6 +149,7 @@ watchAdminToast(adminMessage, adminError)
|
||||
:range-end="rangeEnd"
|
||||
:category-label-map="categoryLabelMap"
|
||||
:duplicate-candidate-keys="duplicateCandidateKeys"
|
||||
:candidate-identity-summaries="candidateIdentitySummaries"
|
||||
:candidate-workflow-notices="candidateWorkflowNotices"
|
||||
:acceptance-status-options="acceptanceStatusOptions"
|
||||
@edit="openEdit"
|
||||
@@ -162,6 +166,8 @@ watchAdminToast(adminMessage, adminError)
|
||||
:category-options="categoryOptions"
|
||||
:candidate-platform-options="candidatePlatformOptions"
|
||||
:selected-platform-value="selectedPlatformValue"
|
||||
:identity-summary="editorIdentitySummary"
|
||||
:rule-notices="editorRuleNotices"
|
||||
:acceptance-status-options="acceptanceStatusOptions"
|
||||
:clip-embed-status-options="clipEmbedStatusOptions"
|
||||
:can-save="canSave"
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { Layers3, PlusCircle, Search, Tags, Trash2, TriangleAlert } from '@lucide/vue'
|
||||
|
||||
import AdminCategoryGroupModal from '../../components/admin/AdminCategoryGroupModal.vue'
|
||||
import AdminCategoryOverviewModal from '../../components/admin/AdminCategoryOverviewModal.vue'
|
||||
import AdminCategoryTemplatesModal from '../../components/admin/AdminCategoryTemplatesModal.vue'
|
||||
import AdminCategoryTreeWorkspace from '../../components/admin/AdminCategoryTreeWorkspace.vue'
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||
import { useAdminCategoryManager } from '../../components/admin/useAdminCategoryManager'
|
||||
@@ -13,22 +17,55 @@ const {
|
||||
selectedSeasonId,
|
||||
query,
|
||||
statusFilter,
|
||||
selectedCategoryId,
|
||||
selectedGroupName,
|
||||
saving,
|
||||
adminMessage,
|
||||
adminError,
|
||||
editForms,
|
||||
newCategoryForm,
|
||||
filteredCategories,
|
||||
selectedCategory,
|
||||
groupModalOpen,
|
||||
editingGroupName,
|
||||
groupDraft,
|
||||
overviewTarget,
|
||||
overviewTitle,
|
||||
overviewSubtitle,
|
||||
overviewCategories,
|
||||
overviewCandidates,
|
||||
overviewNominations,
|
||||
filteredGroups,
|
||||
categoryStats,
|
||||
statusFilters,
|
||||
categoryToDelete,
|
||||
activeSubcategoryTemplates,
|
||||
focusedSubcategoryTemplate,
|
||||
subcategoryConfigModalOpen,
|
||||
subcategoryModalOpen,
|
||||
subcategoryDraft,
|
||||
subcategoryDrafts,
|
||||
subcategoryModalTitle,
|
||||
canSaveSubcategory,
|
||||
canSaveSubcategoryConfig,
|
||||
canCreateGroup,
|
||||
canSaveGroup,
|
||||
groupModalTitle,
|
||||
groupToDelete,
|
||||
deleting,
|
||||
saveCategory,
|
||||
createCategory,
|
||||
fillNewSlug,
|
||||
confirmDeleteCategory,
|
||||
openCreateGroupModal,
|
||||
openEditGroupModal,
|
||||
closeGroupModal,
|
||||
openSubcategoryConfigModal,
|
||||
closeSubcategoryConfigModal,
|
||||
openCreateSubcategoryModal,
|
||||
openEditSubcategoryModal,
|
||||
closeSubcategoryModal,
|
||||
fillSubcategorySlug,
|
||||
saveSubcategoryDraft,
|
||||
removeDraftSubcategory,
|
||||
saveSubcategoryConfig,
|
||||
saveGroupModal,
|
||||
confirmDeleteGroup,
|
||||
openGroupOverview,
|
||||
openCategoryOverview,
|
||||
isFocusedSubcategory,
|
||||
formatViewerRange,
|
||||
recommendedNominatorTarget,
|
||||
} = useAdminCategoryManager()
|
||||
|
||||
watchAdminToast(adminMessage, adminError)
|
||||
@@ -39,167 +76,136 @@ watchAdminToast(adminMessage, adminError)
|
||||
<AdminPageHeader
|
||||
eyebrow="Kategorien"
|
||||
:icon="Tags"
|
||||
description="Hauptkategorien und globale Unterkategorien werden getrennt gepflegt. Die Tree-Ansicht zeigt, wie viele Kandidaten und Nominierungen darunter liegen."
|
||||
/>
|
||||
|
||||
<AdminSeasonToolbar />
|
||||
|
||||
<section class="grid gap-6 xl:grid-cols-[minmax(320px,0.82fr)_minmax(0,1.18fr)]">
|
||||
<Card class="overflow-hidden">
|
||||
<div class="border-b border-violet-100 p-5">
|
||||
<div class="grid gap-3">
|
||||
<label class="relative block">
|
||||
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||
<input v-model="query" class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Kategorie, Gruppe oder Slug suchen" />
|
||||
</label>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div v-for="stat in categoryStats" :key="stat.label" class="rounded-2xl border border-violet-50 bg-violet-50/50 px-3 py-2">
|
||||
<p class="truncate text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-500">{{ stat.label }}</p>
|
||||
<strong class="text-lg text-violet-800">{{ stat.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="filter in statusFilters"
|
||||
:key="filter.key"
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="statusFilter === filter.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
|
||||
@click="statusFilter = filter.key"
|
||||
>
|
||||
{{ filter.label }} · {{ filter.count }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Card class="p-5">
|
||||
<div class="grid gap-4 xl:grid-cols-[minmax(260px,0.9fr)_minmax(0,1.1fr)_auto] xl:items-start">
|
||||
<label class="relative block">
|
||||
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||
<input
|
||||
v-model="query"
|
||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Hauptkategorie oder Unterkategorie suchen"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="max-h-[700px] space-y-2 overflow-y-auto p-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="category in filteredCategories"
|
||||
:key="category.id"
|
||||
v-for="filter in statusFilters"
|
||||
:key="filter.key"
|
||||
type="button"
|
||||
class="w-full rounded-2xl border p-3 text-left transition"
|
||||
:class="selectedCategory?.id === category.id ? 'border-violet-200 bg-violet-50/80 shadow-[0_12px_30px_rgba(168,145,214,0.12)]' : 'border-violet-100 bg-white/85 hover:bg-violet-50/50'"
|
||||
@click="selectedCategoryId = category.id"
|
||||
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="statusFilter === filter.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
|
||||
@click="statusFilter = filter.key"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-semibold text-slate-900">{{ category.name }}</p>
|
||||
<p class="mt-1 truncate text-sm text-slate-500">{{ category.groupName }} · /{{ category.slug }}</p>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
<span class="rounded-full bg-emerald-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-emerald-700">{{ category.candidates }} Kandidaten</span>
|
||||
<span class="rounded-full bg-violet-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-violet-700">Limit {{ category.maxNomineesPerUser }}</span>
|
||||
<span v-if="category.pending" class="rounded-full bg-amber-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-amber-700">{{ category.pending }} Reviews</span>
|
||||
<span v-if="category.candidates === 0" class="rounded-full bg-rose-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-rose-700">leer</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="rounded-xl border border-violet-100 bg-white px-2.5 py-1 text-xs font-semibold text-violet-800">#{{ category.sortOrder }}</span>
|
||||
</div>
|
||||
{{ filter.label }} · {{ filter.count }}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div class="space-y-6">
|
||||
<Card v-if="selectedCategory" class="p-6">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategorie bearbeiten</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">{{ selectedCategory.name }}</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">{{ selectedCategory.description }}</p>
|
||||
</div>
|
||||
<div class="grid h-12 w-12 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||
<Layers3 class="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-4 md:grid-cols-2">
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Gruppe</span>
|
||||
<input v-model="editForms[selectedCategory.id].groupName" class="h-12 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" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
|
||||
<input v-model="editForms[selectedCategory.id].name" class="h-12 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" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Slug</span>
|
||||
<input v-model="editForms[selectedCategory.id].slug" class="h-12 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" />
|
||||
</label>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Reihenfolge</span>
|
||||
<input v-model="editForms[selectedCategory.id].sortOrder" type="number" class="h-12 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" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Limit</span>
|
||||
<input v-model="editForms[selectedCategory.id].maxNomineesPerUser" type="number" class="h-12 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" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="mt-4 block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Beschreibung</span>
|
||||
<textarea v-model="editForms[selectedCategory.id].description" class="min-h-24 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" />
|
||||
</label>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between gap-3">
|
||||
<Button variant="ghost" class="gap-2 border border-rose-200 text-rose-600 hover:bg-rose-50" @click="categoryToDelete = selectedCategory">
|
||||
<Trash2 class="h-4 w-4" /> Löschen
|
||||
</Button>
|
||||
<Button :disabled="saving === selectedCategory.id" @click="saveCategory(selectedCategory.id)">
|
||||
{{ saving === selectedCategory.id ? 'Speichert …' : 'Kategorie speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||
<PlusCircle class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Neu</p>
|
||||
<h2 class="mt-1 text-lg font-bold text-slate-900">Kategorie anlegen</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-4 md:grid-cols-2">
|
||||
<input v-model="newCategoryForm.groupName" class="h-12 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Gruppe" />
|
||||
<input v-model="newCategoryForm.name" class="h-12 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Kategorie" />
|
||||
<div class="flex gap-2">
|
||||
<input v-model="newCategoryForm.slug" class="h-12 min-w-0 flex-1 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="slug" />
|
||||
<button type="button" class="h-12 rounded-2xl border border-violet-100 bg-violet-50 px-4 text-xs font-semibold text-violet-700 transition hover:bg-violet-100" @click="fillNewSlug">
|
||||
Auto
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<input v-model="newCategoryForm.sortOrder" type="number" class="h-12 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Reihenfolge" />
|
||||
<input v-model="newCategoryForm.maxNomineesPerUser" type="number" class="h-12 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Limit" />
|
||||
</div>
|
||||
</div>
|
||||
<textarea v-model="newCategoryForm.description" class="mt-4 min-h-20 w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Beschreibung" />
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Button :disabled="saving === 'new' || !selectedSeasonId" @click="createCategory">
|
||||
{{ saving === 'new' ? 'Erstellt ...' : 'Kategorie anlegen' }}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<div class="flex flex-wrap gap-2 xl:justify-end">
|
||||
<Button variant="ghost" class="gap-2" @click="openSubcategoryConfigModal">
|
||||
<Layers3 class="h-4 w-4" />
|
||||
Unterkategorien konfigurieren
|
||||
</Button>
|
||||
<Button class="gap-2" :disabled="!selectedSeasonId || !canCreateGroup" @click="openCreateGroupModal">
|
||||
<PlusCircle class="h-4 w-4" />
|
||||
Hauptkategorie anlegen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Modal :open="!!categoryToDelete" title="Kategorie löschen?" @close="categoryToDelete = null">
|
||||
<div class="mt-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div v-for="stat in categoryStats" :key="stat.label" class="rounded-2xl border border-violet-50 bg-violet-50/50 px-3 py-2">
|
||||
<p class="truncate text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-500">{{ stat.label }}</p>
|
||||
<strong class="text-lg text-violet-800">{{ stat.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="focusedSubcategoryTemplate"
|
||||
class="mt-4 rounded-2xl border border-sky-200 bg-sky-50/80 px-4 py-3 text-sm text-sky-900"
|
||||
>
|
||||
Fokus auf Unterkategorie:
|
||||
<strong>{{ focusedSubcategoryTemplate.name }}</strong>
|
||||
<span class="text-sky-700">({{ formatViewerRange(focusedSubcategoryTemplate.viewerRangeMin, focusedSubcategoryTemplate.viewerRangeMax) }})</span>
|
||||
</div>
|
||||
|
||||
<p v-if="activeSubcategoryTemplates.length === 0" class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
Lege zuerst mindestens eine Unterkategorie an, bevor du Hauptkategorien erzeugst.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<AdminCategoryTreeWorkspace
|
||||
:groups="filteredGroups"
|
||||
:selected-group-name="selectedGroupName"
|
||||
:is-focused="isFocusedSubcategory"
|
||||
:recommended-nominator-target="recommendedNominatorTarget"
|
||||
@edit-group="(group) => { selectedGroupName = group.name; openEditGroupModal(group) }"
|
||||
@overview-group="openGroupOverview"
|
||||
@overview-category="openCategoryOverview"
|
||||
/>
|
||||
|
||||
<AdminCategoryGroupModal
|
||||
:open="groupModalOpen"
|
||||
:title="groupModalTitle"
|
||||
:form="groupDraft"
|
||||
:can-save="canSaveGroup"
|
||||
:can-create-group="canCreateGroup"
|
||||
:saving="saving === (editingGroupName ?? 'new')"
|
||||
:is-editing="editingGroupName !== null"
|
||||
@close="closeGroupModal"
|
||||
@save="saveGroupModal"
|
||||
@delete="groupToDelete = filteredGroups.find((group) => group.name === editingGroupName) ?? null; closeGroupModal()"
|
||||
/>
|
||||
|
||||
<AdminCategoryTemplatesModal
|
||||
:open="subcategoryConfigModalOpen"
|
||||
:subcategories="subcategoryDrafts"
|
||||
:can-save="canSaveSubcategoryConfig"
|
||||
:saving="saving === 'templates'"
|
||||
:editor-open="subcategoryModalOpen"
|
||||
:editor-title="subcategoryModalTitle"
|
||||
:form="subcategoryDraft"
|
||||
:can-save-subcategory="canSaveSubcategory"
|
||||
@close="closeSubcategoryConfigModal"
|
||||
@save="saveSubcategoryConfig"
|
||||
@add-subcategory="openCreateSubcategoryModal"
|
||||
@edit-subcategory="openEditSubcategoryModal"
|
||||
@remove-subcategory="removeDraftSubcategory"
|
||||
@close-editor="closeSubcategoryModal"
|
||||
@save-subcategory="saveSubcategoryDraft"
|
||||
@fill-slug="fillSubcategorySlug"
|
||||
/>
|
||||
|
||||
<AdminCategoryOverviewModal
|
||||
:open="!!overviewTarget"
|
||||
:title="overviewTitle"
|
||||
:subtitle="overviewSubtitle"
|
||||
:categories="overviewCategories"
|
||||
:candidates="overviewCandidates"
|
||||
:nominations="overviewNominations"
|
||||
:recommended-nominator-target="recommendedNominatorTarget"
|
||||
@close="overviewTarget = null"
|
||||
/>
|
||||
|
||||
<Modal :open="!!groupToDelete" title="Hauptkategorie loeschen?" @close="groupToDelete = null">
|
||||
<div class="flex items-start gap-4">
|
||||
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-rose-50 text-rose-500">
|
||||
<TriangleAlert class="h-6 w-6" />
|
||||
</span>
|
||||
<p class="text-sm leading-7 text-slate-600">
|
||||
„<strong class="text-slate-800">{{ categoryToDelete?.name }}</strong>" und alle zugehörigen Kandidaten werden aus diesem
|
||||
Award-Jahr entfernt. Das lässt sich nicht rückgängig machen.
|
||||
"<strong class="text-slate-800">{{ groupToDelete?.name }}</strong>" und alle automatisch darunter erzeugten Unterkategorien
|
||||
werden aus diesem Award-Jahr entfernt. Das laesst sich nicht rueckgaengig machen.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="categoryToDelete = null">Abbrechen</Button>
|
||||
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="confirmDeleteCategory">
|
||||
{{ deleting ? 'Löscht …' : 'Endgültig löschen' }}
|
||||
<Button variant="ghost" @click="groupToDelete = null">Abbrechen</Button>
|
||||
<Button class="gap-2 !bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="confirmDeleteGroup">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
{{ deleting ? 'Loescht ...' : 'Endgueltig loeschen' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
@@ -199,16 +199,21 @@ watchAdminToast(adminMessage, adminError)
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div v-if="clipEmbeds[clip.id]" class="overflow-hidden rounded-2xl border border-violet-100 bg-slate-950 shadow-sm">
|
||||
<iframe
|
||||
class="aspect-video w-full"
|
||||
:src="clipEmbeds[clip.id]?.src"
|
||||
:title="clip.title || `${clip.platform} Clip ${clip.id}`"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
<div v-if="clipEmbeds[clip.id]">
|
||||
<div class="overflow-hidden rounded-2xl border border-violet-100 bg-slate-950 shadow-sm">
|
||||
<iframe
|
||||
class="aspect-video w-full"
|
||||
:src="clipEmbeds[clip.id]?.src"
|
||||
:title="clip.title || `${clip.platform} Clip ${clip.id}`"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-slate-400">
|
||||
Falls der Clip nicht lädt oder nicht mehr verfügbar ist, öffne ihn direkt über den Clip-Link.
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="rounded-2xl border border-dashed border-amber-200 bg-amber-50 px-4 py-5 text-sm font-medium leading-6 text-amber-800">
|
||||
Dieser Clip kann nicht eingebettet werden. Öffne ihn über den Clip-Link zur manuellen Prüfung.
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { FileText, Link2, MessageCircleQuestion, Mic2, Share2, ShieldCheck } from '@lucide/vue'
|
||||
import { FileText, Handshake, MessageCircleQuestion, Mic2, ScrollText, Send, 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 AdminContentPageSection from '../../components/admin/AdminContentPageSection.vue'
|
||||
import AdminContentPrivacyPreviewModal from '../../components/admin/AdminContentPrivacyPreviewModal.vue'
|
||||
import AdminContentPrivacySection from '../../components/admin/AdminContentPrivacySection.vue'
|
||||
import AdminContentSocialLinksSection from '../../components/admin/AdminContentSocialLinksSection.vue'
|
||||
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' | 'extras'
|
||||
type ContentEditorKey = 'imprint' | 'contact' | 'socials' | 'faq' | 'privacy' | 'showacts' | 'sponsors'
|
||||
|
||||
const store = useAwardsStore()
|
||||
|
||||
@@ -51,53 +48,42 @@ const {
|
||||
|
||||
watchAdminToast(saveMessage, saveError)
|
||||
|
||||
const footerPreviewOpen = ref(false)
|
||||
const footerPreviewKey = ref<FooterPreviewKey>('imprint')
|
||||
const faqPreviewOpen = ref(false)
|
||||
const activeContentEditor = ref<ContentEditorKey | null>(null)
|
||||
|
||||
const footerPreviewPages = computed<Record<FooterPreviewKey, { title: string, url: string, content: string }>>(() => ({
|
||||
imprint: {
|
||||
title: 'Impressum',
|
||||
url: form.imprintUrl,
|
||||
content: form.imprintContent,
|
||||
},
|
||||
contact: {
|
||||
title: 'Kontakt',
|
||||
url: form.contactUrl,
|
||||
content: form.contactContent,
|
||||
},
|
||||
sponsors: {
|
||||
title: 'Sponsoren & Partner',
|
||||
url: form.sponsorsUrl,
|
||||
content: form.sponsorsContent,
|
||||
},
|
||||
showacts: {
|
||||
title: 'Showacts',
|
||||
url: form.showactsUrl,
|
||||
content: form.showactsContent,
|
||||
},
|
||||
}))
|
||||
|
||||
const activeFooterPreview = computed(() => footerPreviewPages.value[footerPreviewKey.value])
|
||||
const activeFooterPreviewHtml = computed(() => privacyContentToHtml(activeFooterPreview.value.content))
|
||||
const contentEditors = computed(() => [
|
||||
{
|
||||
key: 'links' as const,
|
||||
eyebrow: 'Footer & Kontakt',
|
||||
title: 'Rechtliche Links',
|
||||
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: 'imprint' as const,
|
||||
eyebrow: 'Rechtliches',
|
||||
title: 'Impressum',
|
||||
description: 'Impressumstext für das Footer-Modal bearbeiten.',
|
||||
metric: form.imprintContent ? 'Inhalt vorhanden' : 'Leer',
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
key: 'extras' as const,
|
||||
key: 'contact' as const,
|
||||
eyebrow: 'Rechtliches',
|
||||
title: 'Kontakt',
|
||||
description: 'Kontakttext für das Footer-Modal bearbeiten.',
|
||||
metric: form.contactContent ? 'Inhalt vorhanden' : 'Leer',
|
||||
icon: Send,
|
||||
},
|
||||
{
|
||||
key: 'showacts' as const,
|
||||
eyebrow: 'Show Module',
|
||||
title: 'Showacts & Sponsoren',
|
||||
description: 'Landingpage-Module, Bewerbungen und Sponsorenlisten verwalten.',
|
||||
metric: `${store.adminShowactApplications.length + store.adminSponsors.length} Einträge`,
|
||||
title: 'Showacts',
|
||||
description: 'Seiteninhalt, Banner-Toggle und Bewerbungen verwalten.',
|
||||
metric: `${store.adminShowactApplications.length} Bewerbungen`,
|
||||
icon: Mic2,
|
||||
},
|
||||
{
|
||||
key: 'sponsors' as const,
|
||||
eyebrow: 'Partner',
|
||||
title: 'Sponsoren',
|
||||
description: 'Seiteninhalt, Sichtbarkeit und Sponsor-Kacheln verwalten.',
|
||||
metric: `${store.adminSponsors.length} Einträge`,
|
||||
icon: Handshake,
|
||||
},
|
||||
{
|
||||
key: 'socials' as const,
|
||||
eyebrow: 'Community',
|
||||
@@ -118,17 +104,13 @@ const contentEditors = computed(() => [
|
||||
key: 'privacy' as const,
|
||||
eyebrow: 'Rechtstexte',
|
||||
title: 'Datenschutz',
|
||||
description: 'Datenschutzerklärung mit Preview bearbeiten.',
|
||||
description: 'Datenschutz-E-Mail und Datenschutzerklärung bearbeiten.',
|
||||
metric: privacyUpdatedLabel.value,
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
])
|
||||
const currentContentEditor = computed(() => contentEditors.value.find((item) => item.key === activeContentEditor.value) ?? null)
|
||||
|
||||
function openFooterPreview(key: FooterPreviewKey) {
|
||||
footerPreviewKey.value = key
|
||||
footerPreviewOpen.value = true
|
||||
}
|
||||
const currentContentEditor = computed(() => contentEditors.value.find((item) => item.key === activeContentEditor.value) ?? null)
|
||||
|
||||
function openContentEditor(key: ContentEditorKey) {
|
||||
activeContentEditor.value = key
|
||||
@@ -179,12 +161,23 @@ function openContentEditor(key: ContentEditorKey) {
|
||||
size="xl"
|
||||
@close="activeContentEditor = null"
|
||||
>
|
||||
<AdminContentLinksSection
|
||||
v-if="activeContentEditor === 'links'"
|
||||
<AdminContentPageSection
|
||||
v-if="activeContentEditor === 'imprint'"
|
||||
:form="form"
|
||||
:saving="saving"
|
||||
content-key="imprintContent"
|
||||
label="Impressum"
|
||||
placeholder="Impressumstext..."
|
||||
:save-site-settings="saveSiteSettings"
|
||||
/>
|
||||
<AdminContentPageSection
|
||||
v-else-if="activeContentEditor === 'contact'"
|
||||
:form="form"
|
||||
:saving="saving"
|
||||
content-key="contactContent"
|
||||
label="Kontakt"
|
||||
placeholder="Kontakttext..."
|
||||
:save-site-settings="saveSiteSettings"
|
||||
@open-preview="openFooterPreview"
|
||||
/>
|
||||
<AdminContentSocialLinksSection
|
||||
v-else-if="activeContentEditor === 'socials'"
|
||||
@@ -223,7 +216,18 @@ function openContentEditor(key: ContentEditorKey) {
|
||||
@open-preview="privacyPreviewOpen = true"
|
||||
/>
|
||||
<AdminContentLandingExtrasSection
|
||||
v-else-if="activeContentEditor === 'extras'"
|
||||
v-else-if="activeContentEditor === 'showacts'"
|
||||
section="showacts"
|
||||
:form="form"
|
||||
:saving="saving"
|
||||
:save-site-settings="saveSiteSettings"
|
||||
/>
|
||||
<AdminContentLandingExtrasSection
|
||||
v-else-if="activeContentEditor === 'sponsors'"
|
||||
section="sponsors"
|
||||
:form="form"
|
||||
:saving="saving"
|
||||
:save-site-settings="saveSiteSettings"
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@@ -233,13 +237,6 @@ function openContentEditor(key: ContentEditorKey) {
|
||||
:updated-label="privacyUpdatedLabel"
|
||||
@close="privacyPreviewOpen = false"
|
||||
/>
|
||||
<AdminContentFooterPreviewModal
|
||||
:open="footerPreviewOpen"
|
||||
:title="activeFooterPreview.title"
|
||||
:url="activeFooterPreview.url"
|
||||
:content-html="activeFooterPreviewHtml"
|
||||
@close="footerPreviewOpen = false"
|
||||
/>
|
||||
<AdminContentFaqPreviewModal
|
||||
:open="faqPreviewOpen"
|
||||
:faq="form.faq"
|
||||
|
||||
@@ -11,15 +11,17 @@ import {
|
||||
FileClock,
|
||||
Menu,
|
||||
Settings,
|
||||
Settings2,
|
||||
ShieldCheck,
|
||||
Tags,
|
||||
Trophy,
|
||||
UserCog,
|
||||
Users,
|
||||
Wrench,
|
||||
FileText,
|
||||
X,
|
||||
} from '@lucide/vue'
|
||||
|
||||
import AdminToastViewport from '../../components/admin/AdminToastViewport.vue'
|
||||
import Card from '../../components/ui/Card.vue'
|
||||
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
|
||||
import { getRiskMetricValue } from '../../lib/adminMetrics'
|
||||
@@ -50,7 +52,7 @@ const fullNavGroups = computed(() => [
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Inhalte',
|
||||
label: 'Awards',
|
||||
items: [
|
||||
{ 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}` },
|
||||
@@ -65,6 +67,11 @@ const fullNavGroups = computed(() => [
|
||||
permission: 'clips',
|
||||
badge: () => pendingClipCount.value > 0 ? `${pendingClipCount.value}` : clipWorkflowEnabled.value ? null : 'aus',
|
||||
}] : []),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Landingpage',
|
||||
items: [
|
||||
{ label: 'Landingpage', to: '/admin/content', description: 'Public-Inhalte pflegen', icon: FileText, permission: 'content', badge: () => null },
|
||||
],
|
||||
},
|
||||
@@ -72,7 +79,6 @@ const fullNavGroups = computed(() => [
|
||||
label: 'Kontrolle',
|
||||
items: [
|
||||
{ label: 'Risiko', to: '/admin/risk', description: 'Flags entscheiden', icon: AlertTriangle, permission: 'risk', badge: () => `${openRiskCount.value}` },
|
||||
{ label: 'Team', to: '/admin/team', description: 'Logins und Rollen', icon: UserCog, permission: 'team', badge: () => null },
|
||||
{ label: 'Audit-Log', to: '/admin/users-logs', description: 'Admin-Aktionen', icon: FileClock, permission: 'audit', badge: () => null },
|
||||
],
|
||||
},
|
||||
@@ -81,7 +87,16 @@ const fullNavGroups = computed(() => [
|
||||
items: [
|
||||
{ label: 'Analytics', to: '/admin/analytics', description: 'Metriken und Rankings', icon: BarChart3, permission: 'analytics', badge: () => null },
|
||||
{ label: 'Gewinner', to: '/admin/winners', description: 'Finale Ergebnisse freigeben', icon: Trophy, permission: 'winners', badge: () => `${store.adminSeasonDetail.results.length}` },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Einstellungen',
|
||||
items: [
|
||||
{ label: 'Einstellungen', to: '/admin/settings', description: 'Systemchecks und Status', icon: Settings, permission: 'settings', badge: () => null },
|
||||
{ label: 'Tracking Rules', to: '/admin/tracking-rules', description: 'Viewer-Tracking und Review-Regeln', icon: Settings2, permission: 'settings', badge: () => null },
|
||||
{ label: 'Demo & Wartung', to: '/admin/settings/access', description: 'Demo-Zugang und Sternenpause', icon: Wrench, permission: 'settings', badge: () => null },
|
||||
{ label: 'Workflow-Steuerung', to: '/admin/settings/workflows', description: 'Regeln und optionale Workflows', icon: ShieldCheck, permission: 'settings', badge: () => null },
|
||||
{ label: 'Team', to: '/admin/team', description: 'Logins und Rollen', icon: UserCog, permission: 'team', badge: () => null },
|
||||
],
|
||||
},
|
||||
])
|
||||
@@ -132,8 +147,6 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminToastViewport />
|
||||
|
||||
<!-- Mobile top bar -->
|
||||
<div class="sticky top-0 z-40 flex items-center gap-3 border-b border-violet-100 bg-white/90 px-4 py-3 backdrop-blur-sm xl:hidden">
|
||||
<button
|
||||
|
||||
@@ -1,128 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Ban, ClipboardList, Search, Sparkles, Tags, Users, XCircle } from '@lucide/vue'
|
||||
import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router'
|
||||
import { ClipboardList } from '@lucide/vue'
|
||||
import { useRoute } 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 AdminReviewWorkspace from '../../components/admin/AdminReviewWorkspace.vue'
|
||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||
import Card from '../../components/ui/Card.vue'
|
||||
import PaginationFooter from '../../components/ui/PaginationFooter.vue'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
|
||||
const store = useAwardsStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
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 selectedCategoryGroup = ref<string | null>(null)
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const categoryMap = computed(() => Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category])))
|
||||
const filteredNominations = computed(() => {
|
||||
const search = query.value.trim().toLowerCase()
|
||||
const heavyCategoryIds = new Set(categoryStats.value.filter((category) => category.pending >= 3).map((category) => category.id))
|
||||
return seasonDetail.value.pendingNominations.filter((nomination) => {
|
||||
const matchesCategory = !categoryFilter.value || nomination.categoryId === categoryFilter.value
|
||||
const category = categoryMap.value[nomination.categoryId]
|
||||
const candidateCount = seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === nomination.categoryId).length
|
||||
const matchesStatus =
|
||||
statusFilter.value === 'all' ||
|
||||
(statusFilter.value === 'selected' && !!categoryFilter.value) ||
|
||||
(statusFilter.value === 'empty-category' && candidateCount === 0) ||
|
||||
(statusFilter.value === 'heavy' && heavyCategoryIds.has(nomination.categoryId))
|
||||
const matchesSearch = !search || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId, nomination.streamUrl]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(search)
|
||||
return matchesCategory && matchesStatus && matchesSearch && !!category
|
||||
})
|
||||
})
|
||||
const categoryStats = computed(() =>
|
||||
seasonDetail.value.categories
|
||||
.map((category) => ({
|
||||
...category,
|
||||
pending: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length,
|
||||
candidates: seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length,
|
||||
}))
|
||||
.sort((a, b) => b.pending - a.pending || a.sortOrder - b.sortOrder),
|
||||
seasonDetail.value.categories.map((category) => ({
|
||||
...category,
|
||||
pending: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length,
|
||||
candidates: seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length,
|
||||
})),
|
||||
)
|
||||
const nominationStats = computed(() => [
|
||||
{ label: 'Offene Nominierungen', value: seasonDetail.value.pendingNominations.length, icon: Sparkles },
|
||||
{ label: 'Betroffene Kategorien', value: categoryStats.value.filter((category) => category.pending > 0).length, icon: Tags },
|
||||
{ label: 'Kandidatenbasis', value: seasonDetail.value.candidates.length, icon: Users },
|
||||
])
|
||||
const reviewFocusCategories = computed(() => categoryStats.value.filter((category) => category.pending > 0).slice(0, 3))
|
||||
const statusFilters = computed(() => [
|
||||
{ key: 'all' as const, label: 'Alle', count: seasonDetail.value.pendingNominations.length },
|
||||
{ key: 'empty-category' as const, label: 'Ohne Kandidatenbasis', count: seasonDetail.value.pendingNominations.filter((nomination) => !seasonDetail.value.candidates.some((candidate) => candidate.categoryId === nomination.categoryId)).length },
|
||||
{ key: 'heavy' as const, label: 'Hoher Druck', count: categoryStats.value.filter((category) => category.pending >= 3).reduce((sum, category) => sum + category.pending, 0) },
|
||||
const mainCategoryStats = computed(() =>
|
||||
[...new Set(categoryStats.value.map((category) => category.groupName))]
|
||||
.map((groupName) => {
|
||||
const subcategories = categoryStats.value.filter((category) => category.groupName === groupName)
|
||||
return {
|
||||
groupName,
|
||||
pending: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryGroupName === groupName).length,
|
||||
candidates: subcategories.reduce((sum, category) => sum + category.candidates, 0),
|
||||
subcategoryCount: subcategories.length,
|
||||
categoryIds: subcategories.map((category) => category.id),
|
||||
sortOrder: Math.min(...subcategories.map((category) => category.sortOrder)),
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.groupName.localeCompare(b.groupName)),
|
||||
)
|
||||
const selectedCategoryFilterIds = computed(() =>
|
||||
selectedCategoryGroup.value === null
|
||||
? null
|
||||
: mainCategoryStats.value.find((category) => category.groupName === selectedCategoryGroup.value)?.categoryIds ?? null,
|
||||
)
|
||||
const reviewOverviewStats = computed(() => [
|
||||
{ label: 'Hauptkategorien', value: new Set(seasonDetail.value.categories.map((category) => category.groupName)).size },
|
||||
{ label: 'Unterkategorien', value: seasonDetail.value.categories.length },
|
||||
{ label: 'Nominierungen', value: seasonDetail.value.pendingNominations.length + seasonDetail.value.reviewedNominations.length },
|
||||
{ label: 'Noch zu bearbeiten', value: seasonDetail.value.pendingNominationGroups.length },
|
||||
])
|
||||
|
||||
function openReviewModal(nominationId?: number) {
|
||||
const nextQuery: LocationQueryRaw = { ...route.query, review: '1' }
|
||||
function applyRouteFilters() {
|
||||
const rawCategoryGroup = Array.isArray(route.query.categoryGroup) ? route.query.categoryGroup[0] : route.query.categoryGroup
|
||||
const rawCategoryId = Array.isArray(route.query.categoryId) ? route.query.categoryId[0] : route.query.categoryId
|
||||
const fallbackGroupName = mainCategoryStats.value[0]?.groupName ?? null
|
||||
|
||||
if (nominationId) {
|
||||
nextQuery.nominationId = String(nominationId)
|
||||
} else {
|
||||
delete nextQuery.nominationId
|
||||
if (typeof rawCategoryGroup === 'string' && rawCategoryGroup.trim()) {
|
||||
selectedCategoryGroup.value = mainCategoryStats.value.some((category) => category.groupName === rawCategoryGroup)
|
||||
? rawCategoryGroup
|
||||
: fallbackGroupName
|
||||
return
|
||||
}
|
||||
|
||||
reviewModalOpen.value = true
|
||||
void router.replace({ name: 'admin-nominations', query: nextQuery })
|
||||
}
|
||||
|
||||
const bulkRejecting = ref<number | null>(null)
|
||||
|
||||
async function bulkRejectCategory(categoryId: number) {
|
||||
if (!store.adminSelectedSeasonId || bulkRejecting.value !== null) return
|
||||
const ids = seasonDetail.value.pendingNominations
|
||||
.filter((n) => n.categoryId === categoryId)
|
||||
.map((n) => n.id)
|
||||
if (ids.length === 0) return
|
||||
bulkRejecting.value = categoryId
|
||||
try {
|
||||
await store.bulkRejectAdminNominations(ids, store.adminSelectedSeasonId)
|
||||
} finally {
|
||||
bulkRejecting.value = null
|
||||
categoryFilter.value = null
|
||||
const parsedCategoryId = Number(rawCategoryId)
|
||||
if (!Number.isFinite(parsedCategoryId) || parsedCategoryId <= 0) {
|
||||
selectedCategoryGroup.value = fallbackGroupName
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const NOM_PAGE_SIZE = 20
|
||||
const page = ref(1)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredNominations.value.length / NOM_PAGE_SIZE)))
|
||||
const pagedNominations = computed(() =>
|
||||
filteredNominations.value.slice((page.value - 1) * NOM_PAGE_SIZE, page.value * NOM_PAGE_SIZE),
|
||||
)
|
||||
const rangeStart = computed(() =>
|
||||
filteredNominations.value.length === 0 ? 0 : (page.value - 1) * NOM_PAGE_SIZE + 1,
|
||||
)
|
||||
const rangeEnd = computed(() => Math.min(page.value * NOM_PAGE_SIZE, filteredNominations.value.length))
|
||||
|
||||
watch([query, categoryFilter, statusFilter], () => {
|
||||
page.value = 1
|
||||
})
|
||||
watch(totalPages, (max) => {
|
||||
if (page.value > max) page.value = max
|
||||
})
|
||||
|
||||
function closeReviewModal() {
|
||||
const restQuery: LocationQueryRaw = { ...route.query }
|
||||
delete restQuery.review
|
||||
delete restQuery.nominationId
|
||||
|
||||
reviewModalOpen.value = false
|
||||
void router.replace({ name: 'admin-nominations', query: restQuery })
|
||||
selectedCategoryGroup.value = seasonDetail.value.categories.find((category) => category.id === parsedCategoryId)?.groupName
|
||||
?? fallbackGroupName
|
||||
?? null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.query.review, route.query.nominationId] as const,
|
||||
([review, nominationId]) => {
|
||||
reviewModalOpen.value = Boolean(review || nominationId)
|
||||
() => [route.query.categoryId, route.query.categoryGroup, seasonDetail.value.categories.length] as const,
|
||||
() => {
|
||||
applyRouteFilters()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -138,177 +92,69 @@ watch(
|
||||
|
||||
<AdminSeasonToolbar />
|
||||
|
||||
<section class="grid gap-4 lg:grid-cols-3">
|
||||
<Card v-for="stat in nominationStats" :key="stat.label" class="p-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">{{ stat.label }}</p>
|
||||
<strong class="mt-3 block text-4xl text-violet-900">{{ stat.value.toLocaleString('de-DE') }}</strong>
|
||||
</div>
|
||||
<div class="grid h-11 w-11 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||
<component :is="stat.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Card
|
||||
v-for="item in reviewOverviewStats"
|
||||
:key="item.label"
|
||||
class="rounded-[22px] px-5 py-5 shadow-[0_10px_28px_rgba(168,145,214,0.08)]"
|
||||
>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{{ item.label }}</p>
|
||||
<p class="mt-3 text-4xl font-bold text-slate-900">{{ item.value }}</p>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Card class="overflow-hidden border-amber-100 bg-amber-50/55">
|
||||
<div class="grid gap-5 p-5 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-amber-700">Review-Fokus</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">
|
||||
{{ seasonDetail.pendingNominations.length }} offene Entscheidungen
|
||||
</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-600">
|
||||
Die Nominierungsseite bleibt eine Übersicht. Der Review-Fokus öffnet die Queue mit Entscheidungspanel, ohne die Liste dauerhaft aufzublähen.
|
||||
</p>
|
||||
<div v-if="reviewFocusCategories.length" class="mt-3 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="category in reviewFocusCategories"
|
||||
:key="category.id"
|
||||
class="rounded-full border border-amber-200 bg-white/75 px-3 py-1 text-xs font-semibold text-amber-800"
|
||||
>
|
||||
{{ category.name }} · {{ category.pending }}
|
||||
</span>
|
||||
<Card class="overflow-hidden">
|
||||
<div class="grid gap-0 xl:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<aside class="border-b border-violet-100 bg-[linear-gradient(180deg,#fcfaff_0%,#f8f4ff_100%)] xl:border-b-0 xl:border-r">
|
||||
<div class="border-b border-violet-100 px-5 py-5">
|
||||
<div>
|
||||
<p class="text-[11px] font-bold uppercase tracking-[0.22em] text-violet-500">Kategorien</p>
|
||||
<h2 class="mt-1 text-2xl font-bold text-slate-900">Hauptkategorien</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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 class="space-y-3 p-4">
|
||||
<button
|
||||
v-for="category in mainCategoryStats"
|
||||
:key="category.groupName"
|
||||
type="button"
|
||||
class="group w-full rounded-[24px] border px-4 py-4 text-left transition"
|
||||
:class="selectedCategoryGroup === category.groupName ? 'border-violet-300 bg-white shadow-[0_18px_38px_rgba(168,145,214,0.18)] ring-2 ring-violet-100' : 'border-violet-100 bg-white/80 hover:border-violet-200 hover:bg-white'"
|
||||
@click="selectedCategoryGroup = category.groupName"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="selectedCategoryGroup === category.groupName"
|
||||
class="inline-flex h-2.5 w-2.5 rounded-full bg-violet-500 shadow-[0_0_0_6px_rgba(139,108,219,0.12)]"
|
||||
/>
|
||||
<div class="truncate text-lg font-semibold text-slate-900">{{ category.groupName }}</div>
|
||||
</div>
|
||||
<div class="mt-1 text-sm" :class="selectedCategoryGroup === category.groupName ? 'text-violet-700' : 'text-slate-500'">{{ category.subcategoryCount }} Tiers · {{ category.candidates }} Kandidaten</div>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex min-h-11 min-w-11 items-center justify-center rounded-full border px-3 text-base font-bold"
|
||||
:class="selectedCategoryGroup === category.groupName ? 'border-violet-300 bg-violet-600 text-white' : 'border-violet-100 bg-violet-50 text-violet-700'"
|
||||
>
|
||||
{{ category.pending }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<AdminReviewWorkspace
|
||||
:category-ids-filter="selectedCategoryFilterIds"
|
||||
:active-main-category="selectedCategoryGroup"
|
||||
:show-category-filter-chips="false"
|
||||
show-blacklist-button
|
||||
history-collapsible
|
||||
@open-blacklist="blacklistModalOpen = true"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<section class="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
|
||||
<Card class="overflow-hidden">
|
||||
<div class="border-b border-violet-100 p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategorien</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Wo staut es sich?</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-violet-50">
|
||||
<div
|
||||
v-for="category in categoryStats"
|
||||
:key="category.id"
|
||||
class="group/cat grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-5 py-3 transition hover:bg-violet-50/50"
|
||||
:class="categoryFilter === category.id ? 'bg-violet-50/80' : ''"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 text-left"
|
||||
@click="categoryFilter = categoryFilter === category.id ? null : category.id"
|
||||
>
|
||||
<span class="block truncate font-semibold text-slate-900">{{ category.name }}</span>
|
||||
<span class="mt-0.5 block truncate text-sm text-slate-500">{{ category.groupName }} · {{ category.candidates }} Kandidaten</span>
|
||||
</button>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
v-if="category.pending > 0"
|
||||
type="button"
|
||||
class="hidden h-7 items-center gap-1 rounded-full border border-rose-200 bg-white px-2.5 text-[11px] font-semibold text-rose-600 transition hover:bg-rose-50 disabled:opacity-50 group-hover/cat:flex"
|
||||
:disabled="bulkRejecting === category.id"
|
||||
:title="`Alle ${category.pending} Nominierungen ablehnen`"
|
||||
@click.stop="bulkRejectCategory(category.id)"
|
||||
>
|
||||
<XCircle class="h-3 w-3" />
|
||||
{{ bulkRejecting === category.id ? '…' : 'Alle ablehnen' }}
|
||||
</button>
|
||||
<span class="rounded-full border border-violet-100 bg-white px-3 py-1 text-sm font-semibold text-violet-800">{{ category.pending }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="overflow-hidden">
|
||||
<div class="border-b border-violet-100 p-5">
|
||||
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<label class="relative block">
|
||||
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||
<input
|
||||
v-model="query"
|
||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Nach Link, User oder Kategorie suchen"
|
||||
/>
|
||||
</label>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="filter in statusFilters"
|
||||
:key="filter.key"
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="statusFilter === filter.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
|
||||
@click="statusFilter = filter.key"
|
||||
>
|
||||
{{ filter.label }} · {{ filter.count }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-violet-50">
|
||||
<div v-for="nomination in pagedNominations" :key="nomination.id" class="px-5 py-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">{{ nomination.categoryName }}</p>
|
||||
<h3 class="mt-1 truncate text-lg font-semibold text-slate-900">{{ nomination.candidateText || nomination.streamUrl || 'Name im Review festlegen' }}</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="rounded-full border border-slate-100 bg-slate-50 px-3 py-1 text-xs font-semibold text-slate-600">
|
||||
Limit {{ categoryMap[nomination.categoryId]?.maxNomineesPerUser ?? '-' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!seasonDetail.candidates.some((candidate) => candidate.categoryId === nomination.categoryId)"
|
||||
class="rounded-full border border-amber-100 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700"
|
||||
>
|
||||
erst Kandidatenbasis klären
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-9 items-center justify-center rounded-full bg-violet-600 px-4 text-xs font-semibold text-white shadow-sm shadow-violet-500/20 transition hover:bg-violet-500"
|
||||
@click="openReviewModal(nomination.id)"
|
||||
>
|
||||
Fall entscheiden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="filteredNominations.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
|
||||
Keine Nominierungen passen zum aktuellen Filter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PaginationFooter
|
||||
:page="page"
|
||||
:total-pages="totalPages"
|
||||
:range-start="rangeStart"
|
||||
:range-end="rangeEnd"
|
||||
:filtered-count="filteredNominations.length"
|
||||
@update:page="page = $event"
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<AdminNominationReviewModal :open="reviewModalOpen" @close="closeReviewModal" />
|
||||
<AdminNominationLinkBlacklistModal :open="blacklistModalOpen" @close="blacklistModalOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,42 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { Sparkles } from '@lucide/vue'
|
||||
|
||||
import AdminReviewDecisionPanel from '../../components/admin/AdminReviewDecisionPanel.vue'
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import AdminReviewsHistorySection from '../../components/admin/AdminReviewsHistorySection.vue'
|
||||
import AdminReviewsQueueHeader from '../../components/admin/AdminReviewsQueueHeader.vue'
|
||||
import AdminReviewsQueueList from '../../components/admin/AdminReviewsQueueList.vue'
|
||||
import AdminReviewWorkspace from '../../components/admin/AdminReviewWorkspace.vue'
|
||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||
import Card from '../../components/ui/Card.vue'
|
||||
import { useAdminReviewsManager } from '../../components/admin/useAdminReviewsManager'
|
||||
import { watchAdminToast } from '../../composables/useAdminToast'
|
||||
|
||||
const {
|
||||
reviewSaving,
|
||||
blacklistSaving,
|
||||
adminMessage,
|
||||
adminError,
|
||||
reviewForms,
|
||||
seasonDetail,
|
||||
reviewFilter,
|
||||
categoryFilter,
|
||||
selectedNominationId,
|
||||
candidatePlatformOptions,
|
||||
filteredNominations,
|
||||
selectedNomination,
|
||||
reviewStats,
|
||||
reviewedNominations,
|
||||
categoryOptions,
|
||||
selectedCandidateCollision,
|
||||
canApproveSelected,
|
||||
approveNomination,
|
||||
rejectNomination,
|
||||
addStreamUrlToBlacklist,
|
||||
selectedPlatformValue,
|
||||
handlePlatformSelection,
|
||||
} = useAdminReviewsManager()
|
||||
|
||||
watchAdminToast(adminMessage, adminError)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -50,45 +18,7 @@ watchAdminToast(adminMessage, adminError)
|
||||
<AdminSeasonToolbar />
|
||||
|
||||
<Card class="overflow-hidden">
|
||||
<AdminReviewsQueueHeader
|
||||
v-model:review-filter="reviewFilter"
|
||||
v-model:category-filter="categoryFilter"
|
||||
:total-pending="seasonDetail.pendingNominations.length"
|
||||
:visible-count="filteredNominations.length"
|
||||
:review-stats="reviewStats"
|
||||
:category-options="categoryOptions"
|
||||
/>
|
||||
|
||||
<div class="space-y-4 p-6">
|
||||
<div class="grid gap-5 xl:grid-cols-[minmax(320px,0.85fr)_minmax(0,1.15fr)]">
|
||||
<AdminReviewsQueueList
|
||||
:nominations="filteredNominations"
|
||||
:total-pending="seasonDetail.pendingNominations.length"
|
||||
:selected-nomination-id="selectedNominationId"
|
||||
@select="selectedNominationId = $event"
|
||||
/>
|
||||
|
||||
<AdminReviewDecisionPanel
|
||||
:nomination="selectedNomination"
|
||||
:review-saving="reviewSaving"
|
||||
:review-form="selectedNomination ? reviewForms[selectedNomination.id] : null"
|
||||
: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>
|
||||
|
||||
<AdminReviewsHistorySection
|
||||
:reviewed-nominations="reviewedNominations"
|
||||
:reviewed-total="seasonDetail.reviewedNominations.length"
|
||||
/>
|
||||
</div>
|
||||
<AdminReviewWorkspace />
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -22,7 +22,6 @@ import AdminSeasonDeleteModal from '../../components/admin/AdminSeasonDeleteModa
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import {
|
||||
createSeasonTimelineRows,
|
||||
hasShowDayPassed,
|
||||
normalizePhaseKey,
|
||||
resolveAutoPhase,
|
||||
SEASON_PHASES,
|
||||
@@ -61,8 +60,12 @@ const {
|
||||
latestSeasonAuditSummary,
|
||||
latestSeasonAuditMeta,
|
||||
canCreate,
|
||||
phaseGateway,
|
||||
activatePhase,
|
||||
activatePublicSeason,
|
||||
requestPhaseGateway,
|
||||
requestCompletionGateway,
|
||||
confirmPhaseGateway,
|
||||
openCreateModal,
|
||||
saveSeason,
|
||||
completeSeason,
|
||||
@@ -229,13 +232,13 @@ function requestPhaseActivation(row: (typeof timelineRows.value)[number]) {
|
||||
return
|
||||
}
|
||||
|
||||
void activatePhase(row.title)
|
||||
void requestPhaseGateway(row.title)
|
||||
}
|
||||
|
||||
function requestPhaseActivationByTitle(phase: string) {
|
||||
const row = timelineRows.value.find((item) => item.title === phase)
|
||||
if (!row) {
|
||||
void activatePhase(phase)
|
||||
void requestPhaseGateway(phase)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -244,21 +247,7 @@ function requestPhaseActivationByTitle(phase: string) {
|
||||
|
||||
function requestCompleteSeason() {
|
||||
if (completing.value || !canCompleteSelectedSeason.value) return
|
||||
|
||||
if (!hasShowDayPassed(form)) {
|
||||
phaseWarning.value = {
|
||||
action: 'complete',
|
||||
phase: 'Abgeschlossen',
|
||||
range: form.showDate ? `nach ${formatLocalDate(form.showDate)}` : 'nach der Award Show',
|
||||
reason: form.showDate
|
||||
? `Die Award Show ist laut Zeitplan erst am ${formatLocalDate(form.showDate)} bzw. noch nicht vorbei.`
|
||||
: 'Für die Award Show ist noch kein Datum hinterlegt.',
|
||||
detail: 'Wenn du jetzt beendest, werden Public-Aktionen gesperrt und das Jahr als abgeschlossen behandelt.',
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
void completeSeason()
|
||||
void requestCompletionGateway()
|
||||
}
|
||||
|
||||
async function confirmPhaseWarning() {
|
||||
@@ -789,4 +778,111 @@ const readinessScore = computed(() => {
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
:open="!!phaseGateway"
|
||||
:title="phaseGateway?.title || 'Check-Gateway'"
|
||||
:subtitle="phaseGateway?.subtitle || ''"
|
||||
@close="phaseGateway = null"
|
||||
>
|
||||
<div v-if="phaseGateway" class="space-y-4">
|
||||
<div v-if="phaseGateway.blockers.length" class="rounded-[24px] border border-rose-200 bg-rose-50 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0 text-rose-700" />
|
||||
<div class="min-w-0">
|
||||
<p class="font-bold text-rose-900">Diese Punkte blockieren den nächsten Schritt.</p>
|
||||
<div class="mt-3 space-y-3">
|
||||
<div
|
||||
v-for="item in phaseGateway.blockers"
|
||||
:key="`${item.label}-${item.to}`"
|
||||
class="rounded-2xl border border-rose-100 bg-white/90 p-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-rose-900">{{ item.label }}</p>
|
||||
<p class="mt-1 text-sm leading-6 text-rose-800">{{ item.detail }}</p>
|
||||
<ul v-if="item.entries?.length" class="mt-2 space-y-1 text-sm text-rose-800">
|
||||
<li v-for="entry in item.entries" :key="`${entry.label}-${entry.to}`" class="truncate">
|
||||
<RouterLink :to="entry.to" class="inline-flex max-w-full items-center gap-1 hover:underline">
|
||||
<span class="shrink-0">•</span>
|
||||
<span class="truncate">{{ entry.label }}</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<RouterLink :to="item.to" class="inline-flex shrink-0 items-center gap-1 rounded-full border border-rose-200 bg-white px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-50">
|
||||
Öffnen
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="phaseGateway.warnings.length" class="rounded-[24px] border border-amber-200 bg-amber-50 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0 text-amber-700" />
|
||||
<div class="min-w-0">
|
||||
<p class="font-bold text-amber-900">Diese Punkte solltest du bewusst prüfen.</p>
|
||||
<div class="mt-3 space-y-3">
|
||||
<div
|
||||
v-for="item in phaseGateway.warnings"
|
||||
:key="`${item.label}-${item.to}`"
|
||||
class="rounded-2xl border border-amber-100 bg-white/90 p-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-amber-900">{{ item.label }}</p>
|
||||
<p class="mt-1 text-sm leading-6 text-amber-800">{{ item.detail }}</p>
|
||||
<ul v-if="item.entries?.length" class="mt-2 space-y-1 text-sm text-amber-800">
|
||||
<li v-for="entry in item.entries" :key="`${entry.label}-${entry.to}`" class="truncate">
|
||||
<RouterLink :to="entry.to" class="inline-flex max-w-full items-center gap-1 hover:underline">
|
||||
<span class="shrink-0">•</span>
|
||||
<span class="truncate">{{ entry.label }}</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<RouterLink :to="item.to" class="inline-flex shrink-0 items-center gap-1 rounded-full border border-amber-200 bg-white px-3 py-1.5 text-xs font-semibold text-amber-700 hover:bg-amber-50">
|
||||
Öffnen
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 rounded-[22px] border border-violet-100 bg-white/80 p-4 text-sm">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<span class="font-semibold text-slate-500">Zielphase</span>
|
||||
<strong class="text-right text-slate-900">{{ phaseGateway.phase }}</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<span class="font-semibold text-slate-500">Blocker</span>
|
||||
<strong class="text-right text-slate-900">{{ phaseGateway.blockers.length }}</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<span class="font-semibold text-slate-500">Warnungen</span>
|
||||
<strong class="text-right text-slate-900">{{ phaseGateway.warnings.length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button type="button" variant="ghost" :disabled="saving || completing" @click="phaseGateway = null">
|
||||
Schließen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
:disabled="saving || completing || (phaseGateway?.blockers?.length ?? 0) > 0"
|
||||
@click="confirmPhaseGateway"
|
||||
>
|
||||
{{ phaseGateway?.confirmsLabel || 'Fortfahren' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { Settings } from '@lucide/vue'
|
||||
import { Save, Settings } from '@lucide/vue'
|
||||
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 AdminSessionTimeoutCard from '../../components/admin/AdminSessionTimeoutCard.vue'
|
||||
import AdminTwitchAuthCard from '../../components/admin/AdminTwitchAuthCard.vue'
|
||||
import { watchAdminErrorToast, watchAdminToast } from '../../composables/useAdminToast'
|
||||
import Button from '../../components/ui/Button.vue'
|
||||
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,
|
||||
@@ -38,62 +32,25 @@ const {
|
||||
} = useAdminSettingsOverview()
|
||||
|
||||
const {
|
||||
operationalLoading,
|
||||
operationalSaving,
|
||||
operationalError,
|
||||
operationalSuccess,
|
||||
operationalForm,
|
||||
demoPasswordSet,
|
||||
demoManagedByDatabase,
|
||||
demoPasswordInput,
|
||||
twitchClientSecretSet,
|
||||
twitchAuthConfigured,
|
||||
twitchAuthManagedByDatabase,
|
||||
twitchClientSecretInput,
|
||||
demoPasswordHint,
|
||||
twitchSecretHint,
|
||||
demoCredentialsComplete,
|
||||
twitchAuthComplete,
|
||||
operationalSummary,
|
||||
hasUnsavedOperationalChanges,
|
||||
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 hasUnsavedSettingsChanges() {
|
||||
return hasUnsavedOperationalChanges.value
|
||||
|| hasUnsavedOptionalFeatureChanges.value
|
||||
|| hasUnsavedWorkflowRuleChanges.value
|
||||
}
|
||||
|
||||
function confirmDiscardSettingsChanges() {
|
||||
@@ -119,11 +76,16 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
|
||||
<div class="space-y-6">
|
||||
<AdminPageHeader
|
||||
eyebrow="Einstellungen"
|
||||
description="Demo-Zugang, Wartungsmodus und Healthchecks."
|
||||
description="Übersicht, Twitch-Konfiguration, Session-Timeout und Systemstatus."
|
||||
:icon="Settings"
|
||||
/>
|
||||
|
||||
<AdminSeasonToolbar />
|
||||
<div class="flex justify-end">
|
||||
<Button class="gap-2 rounded-2xl px-5" :disabled="operationalSaving || !canManageOperationalSettings || !hasUnsavedOperationalChanges" @click="saveOperationalSettings">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ operationalSaving ? 'Speichert ...' : 'Kern-Einstellungen speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AdminSettingsOverviewBoard
|
||||
:checks="checks"
|
||||
@@ -132,49 +94,26 @@ 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"
|
||||
<AdminTwitchAuthCard
|
||||
:form="operationalForm"
|
||||
:loading="operationalLoading"
|
||||
:saving="operationalSaving"
|
||||
:error="operationalError"
|
||||
:success="operationalSuccess"
|
||||
:demo-password-hint="demoPasswordHint"
|
||||
:demo-password-set="demoPasswordSet"
|
||||
:demo-managed-by-database="demoManagedByDatabase"
|
||||
:demo-credentials-complete="demoCredentialsComplete"
|
||||
:can-manage="canManageOperationalSettings"
|
||||
:twitch-client-secret="twitchClientSecretInput"
|
||||
:twitch-secret-hint="twitchSecretHint"
|
||||
:twitch-client-secret-set="twitchClientSecretSet"
|
||||
:twitch-auth-configured="twitchAuthConfigured"
|
||||
:twitch-auth-managed-by-database="twitchAuthManagedByDatabase"
|
||||
:twitch-auth-complete="twitchAuthComplete"
|
||||
:summary="operationalSummary"
|
||||
:dirty="hasUnsavedOperationalChanges"
|
||||
:can-manage="canManageOperationalSettings"
|
||||
@save="saveOperationalSettings"
|
||||
@update:twitch-client-secret="twitchClientSecretInput = $event"
|
||||
/>
|
||||
|
||||
<AdminSessionTimeoutCard
|
||||
:form="operationalForm"
|
||||
:saving="operationalSaving"
|
||||
:can-manage="canManageOperationalSettings"
|
||||
/>
|
||||
|
||||
<AdminSettingsDatabaseCard
|
||||
@@ -185,19 +124,5 @@ 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>
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
<script setup lang="ts">
|
||||
import { Save, Settings2 } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import AdminSettingsToggle from '../../components/admin/AdminSettingsToggle.vue'
|
||||
import { useAdminTrackingRules } from '../../components/admin/useAdminTrackingRules'
|
||||
import { watchAdminToast } from '../../composables/useAdminToast'
|
||||
import Button from '../../components/ui/Button.vue'
|
||||
import Card from '../../components/ui/Card.vue'
|
||||
import NativeSelect from '../../components/ui/NativeSelect.vue'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import type { AdminTrackingMetricRule } from '../../types/awards'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const canManageTrackingRules = computed(() => authStore.hasPermission('settings'))
|
||||
const windowOptions = [
|
||||
{ label: '7 Tage', value: '7d' },
|
||||
{ label: '30 Tage', value: '30d' },
|
||||
{ label: '3 Monate', value: '90d' },
|
||||
{ label: 'All Time', value: 'all_time' },
|
||||
]
|
||||
|
||||
const {
|
||||
trackingLoading,
|
||||
rulesSaving,
|
||||
sourceSaving,
|
||||
notesSaving,
|
||||
trackingError,
|
||||
trackingSuccess,
|
||||
trackingSource,
|
||||
importantMetrics,
|
||||
optionalMetrics,
|
||||
trackingFlags,
|
||||
manualReviewNotes,
|
||||
trackingSummary,
|
||||
hasUnsavedTrackingSourceChanges,
|
||||
hasUnsavedTrackingRuleChanges,
|
||||
hasUnsavedTrackingNotesChanges,
|
||||
updateImportantMetric,
|
||||
updateOptionalMetric,
|
||||
updateTrackingFlag,
|
||||
saveTrackingSource,
|
||||
saveTrackingNotes,
|
||||
saveTrackingRules,
|
||||
} = useAdminTrackingRules()
|
||||
|
||||
watchAdminToast(trackingSuccess, trackingError)
|
||||
|
||||
const activeImportantMetrics = computed(() => importantMetrics.value.filter((rule) => rule.enabled))
|
||||
const disabledImportantMetrics = computed(() => importantMetrics.value.filter((rule) => !rule.enabled))
|
||||
const activeOptionalMetrics = computed(() => optionalMetrics.value.filter((rule) => rule.enabled))
|
||||
const disabledOptionalMetrics = computed(() => optionalMetrics.value.filter((rule) => !rule.enabled))
|
||||
|
||||
function sourceSupportLabel(sourceSupport: string) {
|
||||
return sourceSupport === 'auto' ? 'Auto' : sourceSupport === 'manual' ? 'Manuell' : 'Kontext'
|
||||
}
|
||||
|
||||
function sourceSupportClass(sourceSupport: string) {
|
||||
return sourceSupport === 'auto'
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||
: sourceSupport === 'manual'
|
||||
? 'bg-amber-50 text-amber-700 border-amber-200'
|
||||
: 'bg-slate-100 text-slate-700 border-slate-200'
|
||||
}
|
||||
|
||||
function windowSupportText(rule: AdminTrackingMetricRule) {
|
||||
if (rule.sourceSupport !== 'auto') {
|
||||
return 'Diese Metrik ist nur Review-Kontext und wird nicht automatisch von TwitchTracker befuellt.'
|
||||
}
|
||||
|
||||
if (rule.autoSupportedWindowKeys.includes(rule.windowKey)) {
|
||||
return 'Dieses Zeitfenster wird von der TwitchTracker API automatisch geliefert.'
|
||||
}
|
||||
|
||||
const labels = rule.autoSupportedWindowKeys
|
||||
.map((value) => windowOptions.find((item) => item.value === value)?.label ?? value)
|
||||
.join(', ')
|
||||
|
||||
return `Dieses Zeitfenster ist aktuell nicht automatisch verfuegbar. Auto geht nur fuer: ${labels || 'keine Zeitfenster'}.`
|
||||
}
|
||||
|
||||
function effectSummary(rule: AdminTrackingMetricRule, section: 'important' | 'optional') {
|
||||
const parts: string[] = []
|
||||
if (section === 'important' && rule.requiredForAutoClassification) {
|
||||
parts.push('Fehlt diese Metrik, kann die Nominierung automatisch geflaggt werden.')
|
||||
}
|
||||
|
||||
if (rule.showInReview) {
|
||||
parts.push('Die Metrik wird im Nominierungsreview sichtbar.')
|
||||
} else {
|
||||
parts.push('Die Metrik wird im Nominierungsreview nicht angezeigt.')
|
||||
}
|
||||
|
||||
if (rule.sourceSupport === 'auto') {
|
||||
parts.push('Werte kommen direkt aus TwitchTracker, sofern das Zeitfenster unterstuetzt wird.')
|
||||
} else if (rule.sourceSupport === 'manual') {
|
||||
parts.push('Diese Metrik ist fuer manuelle Pflege gedacht.')
|
||||
} else {
|
||||
parts.push('Diese Metrik dient nur als Review-Kontext.')
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
function isTopCategoriesRule(rule: AdminTrackingMetricRule) {
|
||||
return rule.key === 'top_categories_context'
|
||||
}
|
||||
|
||||
function ignoredCategoriesValue(rule: AdminTrackingMetricRule) {
|
||||
return (rule.ignoredCategories ?? []).join(', ')
|
||||
}
|
||||
|
||||
function updateIgnoredCategories(ruleKey: string, rawValue: string) {
|
||||
const ignoredCategories = rawValue
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
updateOptionalMetric(ruleKey, { ignoredCategories })
|
||||
}
|
||||
|
||||
function numericInputValue(event: Event) {
|
||||
const value = (event.target as HTMLInputElement | null)?.value?.trim() ?? ''
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function updateTopCount(ruleKey: string, event: Event) {
|
||||
updateOptionalMetric(ruleKey, { topCount: numericInputValue(event) })
|
||||
}
|
||||
|
||||
function updateMinPrimaryCategorySharePercent(ruleKey: string, event: Event) {
|
||||
updateOptionalMetric(ruleKey, { minPrimaryCategorySharePercent: numericInputValue(event) })
|
||||
}
|
||||
|
||||
function updateMinPrimaryCategoryHours(ruleKey: string, event: Event) {
|
||||
updateOptionalMetric(ruleKey, { minPrimaryCategoryHours: numericInputValue(event) })
|
||||
}
|
||||
|
||||
function updateMaxDistinctCategoriesBeforeFlag(ruleKey: string, event: Event) {
|
||||
updateOptionalMetric(ruleKey, { maxDistinctCategoriesBeforeFlag: numericInputValue(event) })
|
||||
}
|
||||
|
||||
function updateIgnoredCategoriesFromEvent(ruleKey: string, event: Event) {
|
||||
updateIgnoredCategories(ruleKey, (event.target as HTMLInputElement | null)?.value ?? '')
|
||||
}
|
||||
|
||||
function activateImportantMetric(ruleKey: string) {
|
||||
updateImportantMetric(ruleKey, { enabled: true })
|
||||
}
|
||||
|
||||
function activateOptionalMetric(ruleKey: string) {
|
||||
updateOptionalMetric(ruleKey, { enabled: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<AdminPageHeader
|
||||
eyebrow="Tracking Rules"
|
||||
description="Nur Regeln zeigen, die im Review oder in der Auto-Bewertung wirklich etwas bewirken. Deaktivierte Metriken bleiben aus dem Nominierungsreview komplett raus."
|
||||
:icon="Settings2"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Tracking Source</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">TwitchTracker API-Quelle</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
|
||||
Diese Base URL wird fuer automatische TwitchTracker-Lookups verwendet.
|
||||
</p>
|
||||
</div>
|
||||
<Button class="gap-2" :disabled="trackingLoading || sourceSaving || !hasUnsavedTrackingSourceChanges || !canManageTrackingRules" @click="saveTrackingSource">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ sourceSaving ? 'Speichert ...' : 'Source speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 p-5 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Provider</p>
|
||||
<strong class="mt-1 block text-lg text-violet-900">{{ trackingSource.providerLabel }}</strong>
|
||||
<p class="mt-1 text-sm text-slate-500">Key: {{ trackingSource.providerKey }}</p>
|
||||
</div>
|
||||
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">API Base URL</span>
|
||||
<input
|
||||
v-model="trackingSource.baseUrl"
|
||||
type="url"
|
||||
:disabled="!canManageTrackingRules"
|
||||
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 disabled:cursor-not-allowed disabled:bg-slate-50"
|
||||
placeholder="https://twitchtracker.com/api"
|
||||
>
|
||||
<p class="text-sm text-amber-700">Erwartet wird die API-Basis, nicht die normale Website-Startseite.</p>
|
||||
</label>
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Manual Review Notes</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Admin-Notizen fuer Fallback-Quellen</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
|
||||
Diese Notizen erscheinen im Nominierungsreview als Hilfe fuer den manuellen Check.
|
||||
</p>
|
||||
</div>
|
||||
<Button class="gap-2" :disabled="trackingLoading || notesSaving || !hasUnsavedTrackingNotesChanges || !canManageTrackingRules" @click="saveTrackingNotes">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ notesSaving ? 'Speichert ...' : 'Notizen speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 p-5">
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/45 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-slate-900">Notizen im Nominierungsreview anzeigen</p>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Wenn deaktiviert, bleiben die Admin-Notizen gespeichert, werden im Review aber komplett ausgeblendet.
|
||||
</p>
|
||||
</div>
|
||||
<AdminSettingsToggle
|
||||
:model-value="trackingSource.showManualReviewNotesInReview"
|
||||
label="Review-Notizen"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Sichtbar"
|
||||
inactive-label="Versteckt"
|
||||
@update:model-value="trackingSource.showManualReviewNotesInReview = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-model="manualReviewNotes"
|
||||
rows="10"
|
||||
:disabled="!canManageTrackingRules"
|
||||
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:cursor-not-allowed disabled:bg-slate-50"
|
||||
placeholder="- SullyGnome Channel Summary - manueller Check fuer Category Fit - Sonderregeln fuer kleine Kanaele"
|
||||
/>
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<section class="grid gap-4 md:grid-cols-4">
|
||||
<Card class="p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">Wichtige aktiv</p>
|
||||
<strong class="mt-3 block text-3xl text-violet-900">{{ trackingSummary.importantActive }}</strong>
|
||||
</Card>
|
||||
<Card class="p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">Optionale aktiv</p>
|
||||
<strong class="mt-3 block text-3xl text-violet-900">{{ trackingSummary.optionalActive }}</strong>
|
||||
</Card>
|
||||
<Card class="p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">Flags aktiv</p>
|
||||
<strong class="mt-3 block text-3xl text-violet-900">{{ trackingSummary.flagsActive }}</strong>
|
||||
</Card>
|
||||
<Card class="p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">Manual Review</p>
|
||||
<strong class="mt-3 block text-3xl text-violet-900">{{ trackingSummary.manualReviewFlags }}</strong>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<h2 class="text-xl font-bold text-slate-900">Wichtige Metriken</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">Diese Regeln wirken direkt auf automatische Flags oder auf die Sichtbarkeit im Nominierungsreview.</p>
|
||||
</div>
|
||||
<Button class="gap-2" :disabled="trackingLoading || rulesSaving || !hasUnsavedTrackingRuleChanges || !canManageTrackingRules" @click="saveTrackingRules">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ rulesSaving ? 'Speichert ...' : 'Regeln speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="trackingLoading" class="p-5 text-sm text-slate-500">Tracking Rules werden geladen.</section>
|
||||
<section v-else class="space-y-4 p-5">
|
||||
<article v-for="rule in activeImportantMetrics" :key="rule.key" class="rounded-2xl border border-violet-100 bg-violet-50/35 p-4">
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-base font-semibold text-slate-900">{{ rule.label }}</h3>
|
||||
<span class="rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em]" :class="sourceSupportClass(rule.sourceSupport)">
|
||||
{{ sourceSupportLabel(rule.sourceSupport) }}
|
||||
</span>
|
||||
<span v-if="rule.requiredForAutoClassification" class="rounded-full border border-rose-200 bg-rose-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-rose-700">
|
||||
Pflicht fuer Auto-Check
|
||||
</span>
|
||||
<span v-if="rule.showInReview" class="rounded-full border border-sky-200 bg-sky-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sky-700">
|
||||
Im Review sichtbar
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">{{ rule.description }}</p>
|
||||
<p class="mt-2 text-xs text-amber-700">{{ windowSupportText(rule) }}</p>
|
||||
</div>
|
||||
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.enabled"
|
||||
:label="`${rule.label} aktiv`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Aktiv"
|
||||
inactive-label="Inaktiv"
|
||||
@update:model-value="updateImportantMetric(rule.key, { enabled: $event })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-3">
|
||||
<label class="space-y-2">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Zeitraum</span>
|
||||
<NativeSelect
|
||||
:model-value="rule.windowKey"
|
||||
:disabled="!canManageTrackingRules"
|
||||
:options="windowOptions"
|
||||
@update:model-value="updateImportantMetric(rule.key, { windowKey: String($event) })"
|
||||
/>
|
||||
</label>
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.requiredForAutoClassification"
|
||||
:label="`${rule.label} fuer Auto-Check erzwingen`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Pflicht"
|
||||
inactive-label="Nicht Pflicht"
|
||||
@update:model-value="updateImportantMetric(rule.key, { requiredForAutoClassification: $event })"
|
||||
/>
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.showInReview"
|
||||
:label="`${rule.label} im Nominierungsreview anzeigen`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Sichtbar"
|
||||
inactive-label="Ausgeblendet"
|
||||
@update:model-value="updateImportantMetric(rule.key, { showInReview: $event })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Wirkung</p>
|
||||
<ul class="mt-2 space-y-1 text-sm text-slate-600">
|
||||
<li v-for="item in effectSummary(rule, 'important')" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-if="disabledImportantMetrics.length" class="rounded-2xl border border-dashed border-slate-200 bg-slate-50/70 p-4">
|
||||
<p class="text-sm font-semibold text-slate-700">Deaktivierte wichtige Metriken</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="rule in disabledImportantMetrics"
|
||||
:key="rule.key"
|
||||
type="button"
|
||||
class="rounded-full border border-slate-200 bg-white px-3 py-2 text-sm font-semibold text-slate-700 transition hover:border-violet-200 hover:text-violet-700"
|
||||
:disabled="!canManageTrackingRules"
|
||||
@click="activateImportantMetric(rule.key)"
|
||||
>
|
||||
{{ rule.label }} aktivieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<Card class="overflow-visible">
|
||||
<section class="border-b border-violet-100 p-5">
|
||||
<h2 class="text-xl font-bold text-slate-900">Optionale Metriken</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">Nur aktive optionale Metriken koennen im Nominierungsreview erscheinen. Deaktivierte Metriken werden dort komplett ausgeblendet.</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 p-5">
|
||||
<article v-for="rule in activeOptionalMetrics" :key="rule.key" class="rounded-2xl border border-violet-100 bg-violet-50/35 p-4">
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-base font-semibold text-slate-900">{{ rule.label }}</h3>
|
||||
<span class="rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em]" :class="sourceSupportClass(rule.sourceSupport)">
|
||||
{{ sourceSupportLabel(rule.sourceSupport) }}
|
||||
</span>
|
||||
<span v-if="rule.showInReview" class="rounded-full border border-sky-200 bg-sky-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sky-700">
|
||||
Im Review sichtbar
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">{{ rule.description }}</p>
|
||||
<p class="mt-2 text-xs text-amber-700">{{ windowSupportText(rule) }}</p>
|
||||
</div>
|
||||
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.enabled"
|
||||
:label="`${rule.label} aktiv`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Aktiv"
|
||||
inactive-label="Inaktiv"
|
||||
@update:model-value="updateOptionalMetric(rule.key, { enabled: $event })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<label class="space-y-2">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Zeitraum</span>
|
||||
<NativeSelect
|
||||
:model-value="rule.windowKey"
|
||||
:disabled="!canManageTrackingRules"
|
||||
:options="windowOptions"
|
||||
@update:model-value="updateOptionalMetric(rule.key, { windowKey: String($event) })"
|
||||
/>
|
||||
</label>
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.showInReview"
|
||||
:label="`${rule.label} im Nominierungsreview anzeigen`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Sichtbar"
|
||||
inactive-label="Ausgeblendet"
|
||||
@update:model-value="updateOptionalMetric(rule.key, { showInReview: $event })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isTopCategoriesRule(rule)" class="mt-4 space-y-3 rounded-2xl border border-violet-100 bg-white p-4">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Review-Kontext fuer Top Categories</p>
|
||||
<p class="mt-1 text-sm text-slate-600">Diese Einstellungen erzeugen aktuell keinen automatischen TwitchTracker-Abgleich, steuern aber den manuellen Review-Rahmen fuer Admins.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<label class="space-y-2">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Top X Kategorien</span>
|
||||
<input
|
||||
:value="rule.topCount ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
:disabled="!canManageTrackingRules"
|
||||
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 disabled:cursor-not-allowed disabled:bg-slate-50"
|
||||
@input="updateTopCount(rule.key, $event)"
|
||||
>
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Min Anteil Hauptkategorie %</span>
|
||||
<input
|
||||
:value="rule.minPrimaryCategorySharePercent ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
:disabled="!canManageTrackingRules"
|
||||
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 disabled:cursor-not-allowed disabled:bg-slate-50"
|
||||
@input="updateMinPrimaryCategorySharePercent(rule.key, $event)"
|
||||
>
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Min Stunden Hauptkategorie</span>
|
||||
<input
|
||||
:value="rule.minPrimaryCategoryHours ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
:disabled="!canManageTrackingRules"
|
||||
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 disabled:cursor-not-allowed disabled:bg-slate-50"
|
||||
@input="updateMinPrimaryCategoryHours(rule.key, $event)"
|
||||
>
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Max unterschiedliche Kategorien</span>
|
||||
<input
|
||||
:value="rule.maxDistinctCategoriesBeforeFlag ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
:disabled="!canManageTrackingRules"
|
||||
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 disabled:cursor-not-allowed disabled:bg-slate-50"
|
||||
@input="updateMaxDistinctCategoriesBeforeFlag(rule.key, $event)"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="space-y-2">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Ignorierte Kategorien</span>
|
||||
<input
|
||||
:value="ignoredCategoriesValue(rule)"
|
||||
type="text"
|
||||
:disabled="!canManageTrackingRules"
|
||||
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 disabled:cursor-not-allowed disabled:bg-slate-50"
|
||||
placeholder="Just Chatting, Special Events"
|
||||
@input="updateIgnoredCategoriesFromEvent(rule.key, $event)"
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.matchAwardCategoryAgainstTopCategories"
|
||||
:label="`${rule.label} mit Award-Kategorie abgleichen`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Abgleich aktiv"
|
||||
inactive-label="Nur Kontext"
|
||||
@update:model-value="updateOptionalMetric(rule.key, { matchAwardCategoryAgainstTopCategories: $event })"
|
||||
/>
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.flagIfAwardCategoryNotInTopX"
|
||||
:label="`${rule.label} Flag bei fehlendem Match`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Flag aktiv"
|
||||
inactive-label="Kein Flag"
|
||||
@update:model-value="updateOptionalMetric(rule.key, { flagIfAwardCategoryNotInTopX: $event })"
|
||||
/>
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.flagIfCategorySpreadTooWide"
|
||||
:label="`${rule.label} Flag bei zu breitem Mix`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Flag aktiv"
|
||||
inactive-label="Kein Flag"
|
||||
@update:model-value="updateOptionalMetric(rule.key, { flagIfCategorySpreadTooWide: $event })"
|
||||
/>
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.flagIfNoCategoryContextAvailable"
|
||||
:label="`${rule.label} Flag wenn kein Kontext da ist`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Flag aktiv"
|
||||
inactive-label="Kein Flag"
|
||||
@update:model-value="updateOptionalMetric(rule.key, { flagIfNoCategoryContextAvailable: $event })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Wirkung</p>
|
||||
<ul class="mt-2 space-y-1 text-sm text-slate-600">
|
||||
<li v-for="item in effectSummary(rule, 'optional')" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-if="disabledOptionalMetrics.length" class="rounded-2xl border border-dashed border-slate-200 bg-slate-50/70 p-4">
|
||||
<p class="text-sm font-semibold text-slate-700">Deaktivierte optionale Metriken</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="rule in disabledOptionalMetrics"
|
||||
:key="rule.key"
|
||||
type="button"
|
||||
class="rounded-full border border-slate-200 bg-white px-3 py-2 text-sm font-semibold text-slate-700 transition hover:border-violet-200 hover:text-violet-700"
|
||||
:disabled="!canManageTrackingRules"
|
||||
@click="activateOptionalMetric(rule.key)"
|
||||
>
|
||||
{{ rule.label }} aktivieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<Card class="overflow-visible">
|
||||
<section class="border-b border-violet-100 p-5">
|
||||
<h2 class="text-xl font-bold text-slate-900">Flags</h2>
|
||||
</section>
|
||||
<section class="grid gap-3 p-5">
|
||||
<article v-for="rule in trackingFlags" :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(0,1fr)_180px_170px] xl:items-start">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold text-slate-900">{{ rule.label }}</h3>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">{{ rule.description }}</p>
|
||||
</div>
|
||||
|
||||
<label class="space-y-2">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Severity</span>
|
||||
<NativeSelect
|
||||
:model-value="rule.severity"
|
||||
:disabled="!canManageTrackingRules"
|
||||
:options="[
|
||||
{ label: 'Hoch', value: 'high' },
|
||||
{ label: 'Mittel', value: 'medium' },
|
||||
{ label: 'Niedrig', value: 'low' },
|
||||
]"
|
||||
@update:model-value="updateTrackingFlag(rule.key, { severity: String($event) })"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.enabled"
|
||||
:label="`${rule.label} aktiv`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
@update:model-value="updateTrackingFlag(rule.key, { enabled: $event })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.autoTriggerEnabled"
|
||||
:label="`${rule.label} automatisch ausloesen`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Auto"
|
||||
inactive-label="Nur manuell"
|
||||
@update:model-value="updateTrackingFlag(rule.key, { autoTriggerEnabled: $event })"
|
||||
/>
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.requiresManualReview"
|
||||
:label="`${rule.label} braucht Review`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Review noetig"
|
||||
inactive-label="Nur Hinweis"
|
||||
@update:model-value="updateTrackingFlag(rule.key, { requiresManualReview: $event })"
|
||||
/>
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.blocksApproval"
|
||||
:label="`${rule.label} blockiert Approve`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Blockiert"
|
||||
inactive-label="Warnt nur"
|
||||
@update:model-value="updateTrackingFlag(rule.key, { blocksApproval: $event })"
|
||||
/>
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.adminNoteRequiredOnOverride"
|
||||
:label="`${rule.label} braucht Override-Notiz`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Notiz noetig"
|
||||
inactive-label="Optional"
|
||||
@update:model-value="updateTrackingFlag(rule.key, { adminNoteRequiredOnOverride: $event })"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -23,6 +23,9 @@ const {
|
||||
winnerSelections,
|
||||
clearWinner,
|
||||
saveWinner,
|
||||
workflowRuleSummary,
|
||||
selectedWinnerIdentitySummary,
|
||||
selectedWinnerClipPreview,
|
||||
winnerRuleNoticeFor,
|
||||
} = useAdminWinnersManager()
|
||||
|
||||
@@ -101,6 +104,25 @@ watchAdminToast(adminMessage, adminError)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<span
|
||||
class="rounded-full border px-3 py-1.5 text-xs font-semibold"
|
||||
:class="workflowRuleSummary.maxWinnerPlacements?.enabled ? 'border-violet-200 bg-violet-50 text-violet-800' : 'border-slate-200 bg-slate-50 text-slate-600'"
|
||||
>
|
||||
{{ workflowRuleSummary.maxWinnerPlacements?.enabled
|
||||
? `Gewinnerlimit aktiv: ${workflowRuleSummary.maxWinnerPlacements.limit}x pro Person`
|
||||
: 'Gewinnerlimit aus' }}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full border px-3 py-1.5 text-xs font-semibold"
|
||||
:class="workflowRuleSummary.winnerRequiresClip?.enabled ? 'border-amber-200 bg-amber-50 text-amber-800' : 'border-slate-200 bg-slate-50 text-slate-600'"
|
||||
>
|
||||
{{ workflowRuleSummary.winnerRequiresClip?.enabled
|
||||
? `Clip-Pflicht ${workflowRuleSummary.winnerRequiresClip.mode === 'warn' ? 'als Warnung' : 'als Blocker'}`
|
||||
: 'Clip-Pflicht aus' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="visibleResultRows.length" class="divide-y divide-violet-50">
|
||||
@@ -155,6 +177,69 @@ watchAdminToast(adminMessage, adminError)
|
||||
{{ winnerRuleNoticeFor(row.category.id)?.mode === 'block' ? 'Blockiert' : 'Warnung' }}:
|
||||
{{ winnerRuleNoticeFor(row.category.id)?.message }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="selectedWinnerIdentitySummary(row.category.id)"
|
||||
class="grid gap-2 sm:grid-cols-4"
|
||||
>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/40 px-3 py-2">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Kandidaturen</p>
|
||||
<strong class="mt-1 block text-sm text-violet-900">{{ selectedWinnerIdentitySummary(row.category.id)?.appearances }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/40 px-3 py-2">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Gewinner</p>
|
||||
<strong class="mt-1 block text-sm text-violet-900">{{ selectedWinnerIdentitySummary(row.category.id)?.winnerPlacements }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/40 px-3 py-2">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Viewer-Nominierungen</p>
|
||||
<strong class="mt-1 block text-sm text-violet-900">{{ selectedWinnerIdentitySummary(row.category.id)?.nominationTally }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/40 px-3 py-2">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Clip</p>
|
||||
<strong class="mt-1 block text-sm text-violet-900">{{ selectedWinnerIdentitySummary(row.category.id)?.hasClip ? 'vorhanden' : 'fehlt' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedWinnerClipPreview(row.category.id)"
|
||||
class="rounded-[22px] border border-violet-100 bg-violet-50/40 p-3"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Winner Preview</p>
|
||||
<span class="rounded-full border border-violet-100 bg-white px-2.5 py-1 text-[10px] font-semibold text-violet-700">
|
||||
{{ selectedWinnerClipPreview(row.category.id)?.clipPlatform }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
v-if="selectedWinnerClipPreview(row.category.id)?.clipEmbedUrl"
|
||||
:src="selectedWinnerClipPreview(row.category.id)?.clipEmbedUrl ?? undefined"
|
||||
:title="selectedWinnerClipPreview(row.category.id)?.clipEmbedTitle"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
class="w-full rounded-2xl border-0 bg-slate-950"
|
||||
style="aspect-ratio:16/9;"
|
||||
/>
|
||||
|
||||
<a
|
||||
v-else-if="selectedWinnerClipPreview(row.category.id)?.clipUrl"
|
||||
:href="selectedWinnerClipPreview(row.category.id)?.clipUrl ?? undefined"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex aspect-[16/9] items-center justify-center rounded-2xl border border-violet-100 bg-white px-4 text-center text-sm font-semibold text-violet-700 hover:bg-violet-100/60"
|
||||
>
|
||||
{{ selectedWinnerClipPreview(row.category.id)?.clipTitle }}
|
||||
</a>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex aspect-[16/9] items-center justify-center rounded-2xl border border-dashed border-amber-200 bg-amber-50 px-4 text-center text-sm font-semibold text-amber-700"
|
||||
>
|
||||
Noch kein Clip-Link gepflegt.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { Settings2, ShieldCheck } from '@lucide/vue'
|
||||
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
|
||||
import AdminOptionalFeaturesEditor from '../../components/admin/AdminOptionalFeaturesEditor.vue'
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||
import AdminWorkflowRulesCard from '../../components/admin/AdminWorkflowRulesCard.vue'
|
||||
import { useAdminOptionalFeatures } from '../../components/admin/useAdminOptionalFeatures'
|
||||
import { useAdminWorkflowRules } from '../../components/admin/useAdminWorkflowRules'
|
||||
import { watchAdminToast } from '../../composables/useAdminToast'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const canManageSettings = computed(() => authStore.hasPermission('settings'))
|
||||
|
||||
const {
|
||||
hasUnsavedWorkflowRuleChanges,
|
||||
workflowError,
|
||||
workflowLoading,
|
||||
workflowRules,
|
||||
workflowRuleSummary,
|
||||
workflowSaving,
|
||||
workflowSuccess,
|
||||
saveWorkflowRules,
|
||||
updateWorkflowRule,
|
||||
} = useAdminWorkflowRules()
|
||||
|
||||
const {
|
||||
hasUnsavedOptionalFeatureChanges,
|
||||
optionalFeaturesError,
|
||||
optionalFeaturesForm,
|
||||
optionalFeaturesSaving,
|
||||
optionalFeaturesSuccess,
|
||||
saveOptionalFeatureSettings,
|
||||
} = useAdminOptionalFeatures()
|
||||
|
||||
watchAdminToast(workflowSuccess, workflowError)
|
||||
watchAdminToast(optionalFeaturesSuccess, optionalFeaturesError)
|
||||
|
||||
function hasUnsavedChanges() {
|
||||
return hasUnsavedWorkflowRuleChanges.value || hasUnsavedOptionalFeatureChanges.value
|
||||
}
|
||||
|
||||
function confirmDiscardSettingsChanges() {
|
||||
if (!hasUnsavedChanges()) {
|
||||
return true
|
||||
}
|
||||
|
||||
return window.confirm('Du hast ungespeicherte Änderungen in der Workflow-Steuerung. Änderungen verwerfen?')
|
||||
}
|
||||
|
||||
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
||||
if (!hasUnsavedChanges()) return
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(() => confirmDiscardSettingsChanges())
|
||||
onMounted(() => window.addEventListener('beforeunload', handleBeforeUnload))
|
||||
onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnload))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<AdminPageHeader
|
||||
eyebrow="Workflow-Steuerung"
|
||||
description="Saisonregeln und optionale Clip-Workflows zusammengefasst, weil sie fachlich denselben Betriebsbereich betreffen."
|
||||
:icon="ShieldCheck"
|
||||
/>
|
||||
|
||||
<AdminSeasonToolbar />
|
||||
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-center gap-2 px-1 text-sm font-semibold text-violet-700">
|
||||
<ShieldCheck class="h-4 w-4" />
|
||||
Award-Workflow-Regeln
|
||||
</div>
|
||||
<AdminWorkflowRulesCard
|
||||
:rules="workflowRules"
|
||||
:loading="workflowLoading"
|
||||
:saving="workflowSaving"
|
||||
:dirty="hasUnsavedWorkflowRuleChanges"
|
||||
:can-manage="canManageSettings"
|
||||
:summary="workflowRuleSummary"
|
||||
@update-rule="updateWorkflowRule"
|
||||
@save="saveWorkflowRules"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-center gap-2 px-1 text-sm font-semibold text-violet-700">
|
||||
<Settings2 class="h-4 w-4" />
|
||||
Optionale Workflows
|
||||
</div>
|
||||
<AdminOptionalFeaturesEditor
|
||||
:form="optionalFeaturesForm"
|
||||
:saving="optionalFeaturesSaving"
|
||||
:dirty="hasUnsavedOptionalFeatureChanges"
|
||||
:can-manage="canManageSettings"
|
||||
@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"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user