Add winner archive, host image upload and live demo data
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 1m1s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m30s

Deliver the demo-ready feature set and seed data so the live site can be
presented end to end:

- Winner archive: ArchivedWinner domain, admin CRUD endpoints/view/manager
  and public archive surface, backed by AddArchivedWinners migration.
- Host presentation: host image upload and artist name on SiteSettings with
  public image endpoint and supporting migrations.
- Clip submissions: idempotent table-ensure migration plus current-season
  demo clips for review workflows.
- Demo seed data: sponsors, share links and 2025 archived winners, with a
  guarded RemoveDemoSeasons cleanup; all seeds guard against real data.
- EnsureRuntimeSchemaParity migration to align runtime schema defensively.
- Admin/home UI refinements; remove unused team role permissions modal and
  dead share-quick-links code.

All seed and schema migrations are idempotent (IF NOT EXISTS / ON CONFLICT)
and skip when real season data is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AzuTear
2026-06-29 21:47:52 +02:00
parent f323e5a82c
commit 6b3b0360e7
92 changed files with 7412 additions and 583 deletions
@@ -0,0 +1,185 @@
<script setup lang="ts">
import { Archive, Link as LinkIcon, Plus, Save, Trash2 } from '@lucide/vue'
import { onMounted } from 'vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import { watchAdminToast } from '../../composables/useAdminToast'
import { useAdminArchiveManager } from '../../components/admin/useAdminArchiveManager'
const {
loading,
adminError,
adminMessage,
groupedDrafts,
stats,
loadArchivedWinners,
addDraft,
saveDraft,
deleteDraft,
} = useAdminArchiveManager()
watchAdminToast(adminMessage, adminError)
onMounted(() => {
void loadArchivedWinners()
})
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Archiv"
description="Historische Gewinner für die Landingpage unabhängig vom aktiven Award-Jahr pflegen. Änderungen landen direkt in der öffentlichen Archivquelle."
:icon="Archive"
/>
<section class="grid gap-3 sm:grid-cols-3">
<Card v-for="card in stats" :key="card.label" class="p-4">
<p class="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">{{ card.label }}</p>
<strong class="mt-2 block text-2xl text-slate-950">{{ card.value }}</strong>
<p class="mt-1 text-sm leading-5 text-slate-500">{{ card.note }}</p>
</Card>
</section>
<Card class="p-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-lg font-bold text-slate-950">Archiv-Gewinner verwalten</h2>
<p class="mt-1 text-sm leading-6 text-slate-500">
Pro Zeile kannst du Jahr, Gewinner, Link, Kategorie und Unterkategorie anlegen, anpassen oder löschen.
</p>
</div>
<Button class="gap-2" @click="addDraft">
<Plus class="h-4 w-4" />
Neue Zeile
</Button>
</div>
</Card>
<div v-if="loading" class="rounded-[26px] border border-dashed border-violet-200 bg-white/70 px-5 py-10 text-center text-sm text-slate-500">
Archiv wird geladen ...
</div>
<div v-else-if="groupedDrafts.length === 0" class="rounded-[26px] border border-dashed border-violet-200 bg-white/70 px-5 py-10 text-center text-sm text-slate-500">
Noch keine Archiv-Gewinner vorhanden.
</div>
<section v-else class="space-y-5">
<div
v-for="group in groupedDrafts"
:key="group.key"
class="space-y-3"
>
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-[11px] font-bold uppercase tracking-[0.18em] text-violet-600">Archiv</p>
<h2 class="text-2xl font-bold text-slate-950">{{ group.label }}</h2>
</div>
<span class="rounded-full border border-violet-100 bg-violet-50 px-3 py-1.5 text-xs font-semibold text-violet-700">
{{ group.items.length }} Einträge
</span>
</div>
<div class="grid gap-4 xl:grid-cols-2">
<Card
v-for="draft in group.items"
:key="draft.localKey"
class="p-5"
>
<div class="grid gap-4">
<div class="grid gap-4 sm:grid-cols-[120px_minmax(0,1fr)]">
<label class="grid min-w-0 gap-1.5">
<span class="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">Jahr</span>
<input
v-model="draft.year"
type="text"
inputmode="numeric"
pattern="[0-9]*"
maxlength="4"
class="h-11 rounded-2xl border border-violet-200 bg-white px-4 text-sm text-slate-900 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="2025"
>
</label>
<label class="grid gap-1.5">
<span class="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">Gewinner</span>
<input
v-model="draft.winnerName"
type="text"
class="h-11 rounded-2xl border border-violet-200 bg-white px-4 text-sm text-slate-900 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Name des Gewinners"
>
</label>
</div>
<label class="grid gap-1.5">
<span class="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">Link</span>
<div class="relative">
<LinkIcon class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input
v-model="draft.winnerUrl"
type="url"
class="h-11 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm text-slate-900 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="https://twitch.tv/..."
>
</div>
</label>
<div class="grid gap-4 sm:grid-cols-2">
<label class="grid gap-1.5">
<span class="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">Kategorie</span>
<input
v-model="draft.category"
type="text"
class="h-11 rounded-2xl border border-violet-200 bg-white px-4 text-sm text-slate-900 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="z. B. Rising Stars"
>
</label>
<label class="grid gap-1.5">
<span class="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">Unterkategorie</span>
<input
v-model="draft.subcategory"
type="text"
class="h-11 rounded-2xl border border-violet-200 bg-white px-4 text-sm text-slate-900 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="z. B. Aufbau"
>
</label>
</div>
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-violet-100 pt-4">
<p class="text-xs text-slate-400">
{{ draft.updatedAt ? `Zuletzt aktualisiert: ${new Date(draft.updatedAt).toLocaleString('de-DE')}` : draft.isNew ? 'Neue Archivzeile' : 'Noch nicht aktualisiert' }}
</p>
<div class="flex flex-wrap gap-2">
<Button
variant="ghost"
class="gap-2"
:disabled="draft.isDeleting || draft.isSaving"
@click="deleteDraft(draft.localKey)"
>
<Trash2 class="h-4 w-4" />
{{ draft.isDeleting ? 'Löscht ...' : 'Löschen' }}
</Button>
<Button
class="gap-2"
:disabled="draft.isDeleting || draft.isSaving"
@click="saveDraft(draft.localKey)"
>
<Save class="h-4 w-4" />
{{ draft.isSaving ? 'Speichert ...' : 'Speichern' }}
</Button>
</div>
</div>
</div>
</Card>
</div>
</div>
</section>
</div>
</template>
@@ -25,7 +25,6 @@ const {
categoryFilterOptions,
categoryLabelMap,
duplicateCandidateKeys,
candidateIdentitySummaries,
candidateWorkflowNotices,
duplicateCandidateCount,
acceptedCandidateCount,
@@ -48,9 +47,13 @@ const {
clipEmbedStatusOptions,
readinessFilterOptions,
candidateToDelete,
deletePreviewLoading,
candidateDeletePreview,
clearFilters,
openCreate,
openEdit,
openDelete,
closeDeleteModal,
handlePlatformSelection,
saveModal,
confirmDelete,
@@ -149,11 +152,10 @@ 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"
@delete="candidateToDelete = $event"
@delete="openDelete"
@update:page="page = $event"
@open-create="openCreate"
/>
@@ -189,8 +191,10 @@ watchAdminToast(adminMessage, adminError)
<AdminCandidateDeleteModal
:candidate="candidateToDelete"
:preview="candidateDeletePreview"
:loading="deletePreviewLoading"
:deleting="deleting"
@close="candidateToDelete = null"
@close="closeDeleteModal"
@confirm="confirmDelete"
/>
</div>
+11 -1
View File
@@ -32,6 +32,8 @@ const {
saveError,
privacyPreviewOpen,
iconUploadError,
hostImageUploadError,
hostImageUploading,
privacyPreviewHtml,
privacyUpdatedLabel,
addSocialLink,
@@ -47,6 +49,7 @@ const {
socialSimpleIconColor,
handleSocialIconUpload,
clearSocialIcon,
handleHostImageUpload,
saveSiteSettings,
adminSiteSettings,
} = useAdminContentManager()
@@ -164,7 +167,14 @@ function closeContentEditor() {
:icon="FileText"
/>
<AdminContentBasicsSection :form="form" :saving="saving" :save-site-settings="saveSiteSettings" />
<AdminContentBasicsSection
:form="form"
:saving="saving"
:host-image-uploading="hostImageUploading"
:host-image-upload-error="hostImageUploadError"
:handle-host-image-upload="handleHostImageUpload"
:save-site-settings="saveSiteSettings"
/>
<section aria-labelledby="content-editor-title" class="space-y-4">
<div>
@@ -66,6 +66,7 @@ const fullNavGroups = computed(() => [
{ label: 'Jahre', to: '/admin/years', description: 'Jahresstatus und Setup', icon: CalendarCog, permission: 'years', badge: () => `${store.adminSeasons.length}` },
{ label: 'Kategorien', to: '/admin/categories', description: 'Struktur und Limits', icon: Tags, permission: 'categories', badge: () => `${store.adminSeasonDetail.categories.length}` },
{ label: 'Kandidaten', to: '/admin/candidates', description: 'Kandidatenbasis pflegen', icon: Users, permission: 'candidates', badge: () => `${store.adminSeasonDetail.candidates.length}` },
{ label: 'Archiv', to: '/admin/archive', description: 'Historische Gewinner pflegen', icon: Trophy, permission: 'winners', badge: () => store.adminArchivedWinners.length > 0 ? `${store.adminArchivedWinners.length}` : null },
...(clipAdminMenuVisible.value ? [{
label: 'Clips',
to: '/admin/clips',
+83 -25
View File
@@ -1,9 +1,8 @@
<script setup lang="ts">
import { Check, KeyRound, LockKeyhole, Pencil, RefreshCw, RotateCcw, Save, ShieldCheck, Trash2, UserPlus, Users } from '@lucide/vue'
import { Check, KeyRound, LockKeyhole, Minus, Pencil, RefreshCw, RotateCcw, Save, ShieldCheck, Trash2, UserPlus, Users } from '@lucide/vue'
import { computed, reactive, ref } from 'vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminTeamRolePermissionsModal from '../../components/admin/AdminTeamRolePermissionsModal.vue'
import { useAdminTeamManager } from '../../components/admin/useAdminTeamManager'
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
import { watchAdminToast } from '../../composables/useAdminToast'
@@ -22,7 +21,6 @@ const canChangeOwnPassword = computed(() => authStore.isOwnerOrCreator && authSt
const passwordModalOpen = ref(false)
const createMemberModalOpen = ref(false)
const editMemberModalOpen = ref(false)
const rolePermissionsModalOpen = ref(false)
const profileError = ref('')
const profileSuccess = ref('')
const ownPasswordForm = reactive({
@@ -132,6 +130,10 @@ function canDeleteMember(member: AdminTeamMember) {
return true
}
function handlePermissionChange(roleKey: string, permissionKey: string, event: Event) {
setRolePermission(roleKey, permissionKey, event.target instanceof HTMLInputElement && event.target.checked)
}
function openCreateMemberModal() {
startCreateMember()
createMemberModalOpen.value = true
@@ -281,16 +283,6 @@ async function changeOwnPassword() {
<RefreshCw class="mr-2 h-4 w-4" :class="refreshingPresence ? 'animate-spin' : ''" />
Aktualisieren
</Button>
<Button
size="sm"
type="button"
variant="ghost"
class="border border-violet-200 bg-violet-50 text-violet-700 shadow-sm shadow-violet-200/40 hover:-translate-y-0.5 hover:border-violet-300 hover:bg-violet-100 hover:text-violet-800 hover:shadow-lg hover:shadow-violet-200/60"
@click="rolePermissionsModalOpen = true"
>
<ShieldCheck class="mr-2 h-4 w-4" />
Rollenrechte
</Button>
<Button size="sm" type="button" @click="openCreateMemberModal">
<UserPlus class="mr-2 h-4 w-4" />
Neuer Login
@@ -377,18 +369,84 @@ async function changeOwnPassword() {
</div>
</Card>
<AdminTeamRolePermissionsModal
:open="rolePermissionsModalOpen"
:saving="saving"
:has-role-changes="hasRoleChanges"
:roles="roles"
:permissions="permissions"
:permission-groups="permissionGroups"
:role-has-permission="roleHasPermission"
:set-role-permission="setRolePermission"
:save-role-permissions="saveRolePermissions"
@close="rolePermissionsModalOpen = false"
/>
<Card class="p-4 md:p-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex items-start gap-3">
<span class="grid h-9 w-9 place-items-center rounded-xl bg-violet-50 text-violet-600">
<ShieldCheck class="h-4 w-4" />
</span>
<div>
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Rollenrechte</p>
<h2 class="mt-1 text-lg font-bold text-slate-900">Berechtigungen einstellen</h2>
<p class="mt-1 text-xs font-semibold text-slate-500">
Alle Rollen bleiben direkt vergleichbar, ohne zwischen Einzelansichten zu wechseln.
</p>
</div>
</div>
<Button type="button" :disabled="saving || !hasRoleChanges" @click="saveRolePermissions">
<Save class="mr-2 h-4 w-4" />
{{ saving ? 'Speichert...' : 'Speichern' }}
</Button>
</div>
<div class="mt-4 overflow-x-auto rounded-xl border border-violet-100">
<table class="min-w-[920px] divide-y divide-violet-100 text-sm">
<thead class="bg-violet-50/70 text-left text-[10px] font-bold uppercase tracking-[0.16em] text-violet-500">
<tr>
<th class="sticky left-0 z-10 bg-violet-50/95 px-3 py-2">Menüpunkt</th>
<th v-for="role in roles" :key="role.key" class="px-3 py-2 text-center">
<div class="flex flex-col items-center gap-1">
<span>{{ role.label }}</span>
<span
v-if="role.key === 'owner' || role.key === 'creator'"
class="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.12em] text-slate-500"
>
fixiert
</span>
</div>
</th>
</tr>
</thead>
<tbody class="divide-y divide-violet-100 bg-white/70">
<tr v-for="permission in permissions" :key="permission.key">
<td class="sticky left-0 z-10 max-w-[280px] bg-white px-3 py-3 align-top">
<span class="block font-bold text-slate-900">{{ permission.label }}</span>
<span class="mt-1 block text-xs leading-5 text-slate-500">{{ permission.description }}</span>
<span class="mt-2 inline-flex rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[10px] font-bold text-slate-500">
{{ permission.menuPath }}
</span>
</td>
<td v-for="role in roles" :key="`${role.key}-${permission.key}`" class="px-3 py-3 text-center">
<label class="inline-flex cursor-pointer items-center justify-center" :class="role.key === 'owner' || role.key === 'creator' ? 'cursor-not-allowed' : ''">
<input
class="sr-only"
type="checkbox"
:checked="roleHasPermission(role.key, permission.key)"
:disabled="role.key === 'owner' || role.key === 'creator'"
:aria-label="`${role.label}: ${permission.label}`"
@change="handlePermissionChange(role.key, permission.key, $event)"
>
<span
class="inline-flex h-9 min-w-20 items-center justify-center gap-2 rounded-xl border px-3 text-sm font-bold transition"
:class="roleHasPermission(role.key, permission.key)
? role.key === 'owner' || role.key === 'creator'
? 'border-slate-200 bg-slate-100 text-slate-500'
: 'border-violet-500 bg-violet-600 text-white shadow-lg shadow-violet-500/20'
: role.key === 'owner' || role.key === 'creator'
? 'border-slate-200 bg-slate-50 text-slate-300'
: 'border-violet-200 bg-white text-violet-700 hover:bg-violet-50'"
>
<Check v-if="roleHasPermission(role.key, permission.key)" class="h-4 w-4" :stroke-width="3" />
<Minus v-else class="h-4 w-4" :stroke-width="3" />
{{ roleHasPermission(role.key, permission.key) ? 'An' : 'Aus' }}
</span>
</label>
</td>
</tr>
</tbody>
</table>
</div>
</Card>
<Teleport to="body">
<div
@@ -77,7 +77,7 @@ function windowSupportText(rule: AdminTrackingMetricRule) {
.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'}.`
return `Dieses Zeitfenster ist aktuell nicht automatisch verfügbar. Auto geht nur für: ${labels || 'keine Zeitfenster'}.`
}
function effectSummary(rule: AdminTrackingMetricRule, section: 'important' | 'optional') {
@@ -93,9 +93,9 @@ function effectSummary(rule: AdminTrackingMetricRule, section: 'important' | 'op
}
if (rule.sourceSupport === 'auto') {
parts.push('Werte kommen direkt aus TwitchTracker, sofern das Zeitfenster unterstuetzt wird.')
parts.push('Werte kommen direkt aus TwitchTracker, sofern das Zeitfenster unterstützt wird.')
} else if (rule.sourceSupport === 'manual') {
parts.push('Diese Metrik ist fuer manuelle Pflege gedacht.')
parts.push('Diese Metrik ist für manuelle Pflege gedacht.')
} else {
parts.push('Diese Metrik dient nur als Review-Kontext.')
}
@@ -174,7 +174,7 @@ function activateOptionalMetric(ruleKey: string) {
<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.
Diese Base URL wird für automatische TwitchTracker-Lookups verwendet.
</p>
</div>
<Button class="gap-2" :disabled="trackingLoading || sourceSaving || !hasUnsavedTrackingSourceChanges || !canManageTrackingRules" @click="saveTrackingSource">
@@ -210,9 +210,9 @@ function activateOptionalMetric(ruleKey: string) {
<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>
<h2 class="mt-1 text-xl font-bold text-slate-900">Admin-Notizen für 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.
Diese Notizen erscheinen im Nominierungsreview als Hilfe für den manuellen Check.
</p>
</div>
<Button class="gap-2" :disabled="trackingLoading || notesSaving || !hasUnsavedTrackingNotesChanges || !canManageTrackingRules" @click="saveTrackingNotes">
@@ -247,7 +247,7 @@ function activateOptionalMetric(ruleKey: string) {
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"
placeholder="- SullyGnome Channel Summary&#10;- manueller Check für Category Fit&#10;- Sonderregeln für kleine Kanäle"
/>
</section>
</Card>
@@ -296,7 +296,7 @@ function activateOptionalMetric(ruleKey: string) {
{{ 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
Pflicht für 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
@@ -328,7 +328,7 @@ function activateOptionalMetric(ruleKey: string) {
</label>
<AdminSettingsToggle
:model-value="rule.requiredForAutoClassification"
:label="`${rule.label} fuer Auto-Check erzwingen`"
:label="`${rule.label} für Auto-Check erzwingen`"
:disabled="!canManageTrackingRules"
active-label="Pflicht"
inactive-label="Nicht Pflicht"
@@ -373,7 +373,7 @@ function activateOptionalMetric(ruleKey: string) {
<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>
<p class="mt-2 text-sm text-slate-500">Nur aktive optionale Metriken können im Nominierungsreview erscheinen. Deaktivierte Metriken werden dort komplett ausgeblendet.</p>
</section>
<section class="space-y-4 p-5">
@@ -425,8 +425,8 @@ function activateOptionalMetric(ruleKey: string) {
<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>
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Review-Kontext für 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 für Admins.</p>
</div>
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
@@ -555,6 +555,7 @@ function activateOptionalMetric(ruleKey: string) {
<Card class="overflow-visible">
<section class="border-b border-violet-100 p-5">
<h2 class="text-xl font-bold text-slate-900">Flags</h2>
<p class="mt-2 text-sm text-slate-500">Tracking-Flags markieren Review-Faelle und Hinweise, blockieren aber keine Annahme oder Verwerfung mehr.</p>
</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">
@@ -599,23 +600,20 @@ function activateOptionalMetric(ruleKey: string) {
:model-value="rule.requiresManualReview"
:label="`${rule.label} braucht Review`"
:disabled="!canManageTrackingRules"
active-label="Review noetig"
active-label="Review nötig"
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 })"
/>
<div class="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">Approve-Verhalten</p>
<p class="mt-2 text-sm font-semibold text-slate-900">Warnt nur</p>
<p class="mt-1 text-xs leading-5 text-slate-500">Tracking-Flags sind reine Zusatzinfos für den Review-Kontext und sperren die Entscheidung nicht.</p>
</div>
<AdminSettingsToggle
:model-value="rule.adminNoteRequiredOnOverride"
:label="`${rule.label} braucht Override-Notiz`"
:disabled="!canManageTrackingRules"
active-label="Notiz noetig"
active-label="Notiz nötig"
inactive-label="Optional"
@update:model-value="updateTrackingFlag(rule.key, { adminNoteRequiredOnOverride: $event })"
/>
@@ -16,6 +16,7 @@ const {
statusFilter,
statusFilters,
summaryCards,
seasonCandidates,
votingGroups,
selectedGroupName,
focusedCategoryId,
@@ -117,6 +118,7 @@ function openLeaderboard(category: AdminVotingWorkspaceRow) {
<AdminVotingLeaderboardModal
:category="leaderboardCategory"
:candidates="seasonCandidates"
@close="leaderboardCategory = null"
/>
</div>
@@ -43,7 +43,7 @@ watchAdminToast(adminMessage, adminError)
<div class="space-y-6">
<AdminPageHeader
eyebrow="Gewinner"
description="Finale Gewinner pro Unterkategorie setzen, Guards pruefen und Landingpage-Freigabe vorbereiten."
description="Finale Gewinner pro Unterkategorie setzen, Guards prüfen und Landingpage-Freigabe vorbereiten."
:icon="Trophy"
/>