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:
AzuTear
2026-06-28 23:32:21 +02:00
parent 4b2c5fa15d
commit b53c7fb736
178 changed files with 28933 additions and 1908 deletions
@@ -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"
+156 -150
View File
@@ -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>
+15 -10
View File
@@ -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.
+57 -60
View File
@@ -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"
+18 -5
View File
@@ -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
+110 -264
View File
@@ -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>
+2 -72
View File
@@ -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>
+114 -18
View File
@@ -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>
+21 -96
View File
@@ -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&#10;- manueller Check fuer Category Fit&#10;- 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>