Update release notes and deploy workspace
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 59s
CI - Build & Verify / Deploy to award.noveria.net (push) Failing after 53s

This commit is contained in:
AzuTear
2026-06-29 17:49:54 +02:00
parent c466348d18
commit 441ef2b850
196 changed files with 9279 additions and 39292 deletions
@@ -3,7 +3,7 @@
:open="open"
size="lg"
:title="title"
subtitle="Diese Hauptkategorie bekommt automatisch alle globalen Unterkategorien der ausgewaehlten Season."
subtitle="Diese Hauptkategorie bekommt automatisch alle globalen Unterkategorien der ausgewaehlten Season. Die Beschreibung erscheint auf der Landingpage in der Awards-Karte."
@close="$emit('close')"
>
<div class="grid gap-4 md:grid-cols-2">
@@ -43,11 +43,11 @@
</label>
<label class="space-y-2 md:col-span-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Beschreibung</span>
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Landingpage-Beschreibung</span>
<textarea
:value="form.description"
class="min-h-28 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"
placeholder="Kurze Orientierung fuer Admins und Public-Flows"
placeholder="Kurzer Beschreibungstext fuer die Awards-Sektion auf der Landingpage"
@input="form.description = ($event.target as HTMLTextAreaElement).value"
/>
</label>
@@ -0,0 +1,333 @@
<template>
<Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5">
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategoriegesundheit</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Status aller Kategorien</h2>
<p class="mt-1 text-sm text-slate-500">Kompakter Überblick pro Status und Gruppe.</p>
</div>
<div class="flex items-center gap-2">
<label class="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Gruppe</label>
<select
:value="props.groupFilter"
class="h-10 min-w-[180px] rounded-xl border border-violet-200 bg-white px-3 text-sm font-medium text-slate-700 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
@change="onGroupChange"
>
<option
v-for="group in props.groupOptions"
:key="group"
:value="group"
>
{{ group }}
</option>
</select>
</div>
</div>
<div class="mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
<button
v-for="status in summaryCards"
:key="status.key"
type="button"
class="rounded-[18px] border px-4 py-3 text-left transition hover:translate-y-[-1px]"
:class="props.healthFilter === status.key ? status.activeClass : status.cardClass"
@click="emit('update:healthFilter', status.key)"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.16em] opacity-80">{{ status.label }}</p>
<strong class="mt-1.5 block text-2xl leading-none">{{ status.value }}</strong>
</div>
<span class="mt-1 h-2.5 w-2.5 rounded-full" :class="status.dotClass" />
</div>
<p class="mt-2 text-xs leading-5 opacity-85">{{ status.note }}</p>
</button>
</div>
<div class="mt-3 flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap gap-1.5">
<button
v-for="opt in props.healthStatusOptions"
:key="opt.key"
type="button"
class="flex h-9 items-center gap-1.5 rounded-xl border px-3 text-xs font-semibold transition"
:class="props.healthFilter === opt.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="emit('update:healthFilter', opt.key)"
>
<span v-if="opt.dot" class="h-2 w-2 rounded-full" :class="opt.dot" />
{{ opt.label }}
</button>
</div>
<div class="flex flex-wrap gap-1.5">
<button
type="button"
class="flex h-9 items-center rounded-xl border border-violet-100 bg-white px-3 text-xs font-semibold text-slate-600 transition hover:bg-violet-50"
@click="openAllGroups"
>
Alle öffnen
</button>
<button
type="button"
class="flex h-9 items-center rounded-xl border border-violet-100 bg-white px-3 text-xs font-semibold text-slate-600 transition hover:bg-violet-50"
@click="openProblemGroups"
>
Nur Problemgruppen
</button>
</div>
</div>
</div>
<div v-if="groupedHealth.length > 0" class="space-y-4 bg-[linear-gradient(180deg,#fff,rgba(245,243,255,0.45))] p-4">
<section
v-for="group in groupedHealth"
:key="group.name"
class="rounded-[22px] border border-violet-100 bg-white/90 shadow-[0_10px_24px_rgba(139,108,219,0.07)]"
>
<button
type="button"
class="flex w-full flex-wrap items-start justify-between gap-3 px-4 py-3.5 text-left transition hover:bg-violet-50/40"
@click="toggleGroup(group.name)"
>
<div class="flex min-w-0 items-start gap-3">
<span class="mt-1 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-violet-200 bg-white text-violet-700">
<ChevronRight class="h-3.5 w-3.5 transition" :class="isGroupOpen(group.name) ? 'rotate-90' : ''" />
</span>
<div class="min-w-0">
<h3 class="text-base font-bold text-slate-900">{{ group.name }}</h3>
<p class="mt-1 text-xs text-slate-500">{{ group.categories.length }} Kategorien in diesem Bereich</p>
</div>
</div>
<div class="flex flex-wrap justify-end gap-2">
<span class="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-slate-600">{{ group.categories.length }} Gesamt</span>
<span v-if="group.emptyCount > 0" class="rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-xs font-semibold text-rose-700">{{ group.emptyCount }} leer</span>
<span v-if="group.reviewCount > 0" class="rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">{{ group.reviewCount }} Review offen</span>
<span v-if="group.winnerCount > 0" class="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">{{ group.winnerCount }} Gewinner</span>
</div>
</button>
<div
v-if="isGroupOpen(group.name)"
class="grid gap-2.5 border-t border-violet-100 p-3 lg:grid-cols-2"
>
<RouterLink
v-for="category in group.categories"
:key="category.id"
:to="`/admin/categories?group=${encodeURIComponent(category.groupName || 'Ohne Gruppe')}`"
class="rounded-[18px] border p-3 transition hover:border-violet-200 hover:bg-violet-50/40"
:class="category.cardClass"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="h-2.5 w-2.5 shrink-0 rounded-full" :class="category.statusDot" />
<strong class="truncate text-sm text-slate-900">{{ category.name }}</strong>
</div>
<p class="mt-1 text-xs text-slate-500">{{ category.groupName || 'Ohne Gruppe' }}</p>
</div>
<span class="inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-[11px] font-bold" :class="category.statusClass">
{{ category.status }}
</span>
</div>
<div class="mt-3 grid grid-cols-2 gap-2 text-sm">
<div class="rounded-2xl border border-slate-100 bg-white/70 px-3 py-2">
<p class="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Kandidaten</p>
<strong class="mt-1 block text-base text-slate-900">{{ category.candidates }}</strong>
</div>
<div class="rounded-2xl border border-slate-100 bg-white/70 px-3 py-2">
<p class="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Reviews</p>
<strong class="mt-1 block text-base" :class="category.reviews > 0 ? 'text-amber-700' : 'text-slate-900'">{{ category.reviews }}</strong>
</div>
<div class="rounded-2xl border border-slate-100 bg-white/70 px-3 py-2">
<p class="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Votes</p>
<strong class="mt-1 block text-base text-slate-900">{{ category.votes.toLocaleString('de-DE') }}</strong>
</div>
<div class="rounded-2xl border border-slate-100 bg-white/70 px-3 py-2">
<p class="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Anteil</p>
<strong class="mt-1 block text-base text-violet-700">{{ category.votePct }}%</strong>
</div>
</div>
</RouterLink>
</div>
</section>
</div>
<div v-else class="px-5 py-12 text-center text-sm text-slate-500">
Keine Kategorien für diesen Filter.
</div>
<div class="flex items-center gap-2 border-t border-violet-100 bg-emerald-50/60 px-5 py-3 text-sm text-emerald-800">
<CheckCircle2 class="h-4 w-4 shrink-0 text-emerald-500" />
Analytics nutzt dieselben Admin-Daten wie Dashboard, Jahre und Kategorien.
<RouterLink to="/admin/categories" class="ml-auto flex items-center gap-1 text-xs font-semibold text-violet-600 hover:text-violet-800">
Kategorien bearbeiten <ExternalLink class="h-3 w-3" />
</RouterLink>
</div>
</Card>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { CheckCircle2, ChevronRight, ExternalLink } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import Card from '../ui/Card.vue'
interface HealthOption {
key: string
label: string
dot?: string
}
interface HealthCategory {
id: number
name: string
groupName: string
candidates: number
reviews: number
hasWinner: boolean
votes: number
votePct: number
status: string
statusClass: string
statusDot: string
cardClass: string
}
const props = defineProps<{
filteredHealth: HealthCategory[]
healthSummary: {
leer: number
reviewOffen: number
bereit: number
gewinner: number
total: number
}
groupOptions: string[]
groupFilter: string
healthFilter: string
healthStatusOptions: HealthOption[]
candidateWarningsActive: boolean
reviewWarningsActive: boolean
}>()
const emit = defineEmits<{
'update:groupFilter': [value: string]
'update:healthFilter': [value: string]
}>()
const openGroups = ref<string[]>([])
function onGroupChange(event: Event) {
const target = event.target as HTMLSelectElement | null
if (target) {
emit('update:groupFilter', target.value)
}
}
const groupedHealth = computed(() => {
const groups = new Map<string, HealthCategory[]>()
for (const category of props.filteredHealth) {
const key = category.groupName || 'Ohne Gruppe'
if (!groups.has(key)) {
groups.set(key, [])
}
groups.get(key)!.push(category)
}
return [...groups.entries()].map(([name, categories]) => ({
name,
categories,
emptyCount: props.candidateWarningsActive ? categories.filter((item) => item.status === 'Leer').length : 0,
reviewCount: props.reviewWarningsActive ? categories.filter((item) => item.status === 'Review offen').length : 0,
winnerCount: categories.filter((item) => item.status === 'Gewinner gesetzt').length,
}))
})
watch(
groupedHealth,
(groups) => {
const availableNames = new Set(groups.map((group) => group.name))
const preserved = openGroups.value.filter((name) => availableNames.has(name))
const autoOpen = groups
.filter((group) => group.emptyCount > 0 || group.reviewCount > 0)
.map((group) => group.name)
openGroups.value = [...new Set([...(preserved.length > 0 ? preserved : autoOpen), ...(preserved.length === 0 && autoOpen.length === 0 && groups[0] ? [groups[0].name] : [])])]
},
{ immediate: true },
)
function isGroupOpen(name: string) {
return openGroups.value.includes(name)
}
function toggleGroup(name: string) {
openGroups.value = isGroupOpen(name)
? openGroups.value.filter((entry) => entry !== name)
: [...openGroups.value, name]
}
function openAllGroups() {
openGroups.value = groupedHealth.value.map((group) => group.name)
}
function openProblemGroups() {
const problemGroups = groupedHealth.value
.filter((group) => group.emptyCount > 0 || group.reviewCount > 0)
.map((group) => group.name)
openGroups.value = problemGroups.length > 0
? problemGroups
: groupedHealth.value.slice(0, 1).map((group) => group.name)
}
const summaryCards = computed(() => [
{
key: 'Leer',
label: 'Leer',
value: props.healthSummary.leer,
note: props.healthSummary.leer === 0
? 'Keine Lücken in der Kandidatenbasis.'
: props.candidateWarningsActive
? 'Diese Kategorien brauchen zuerst Kandidaten.'
: 'Noch normaler Vorbereitungsstand.',
dotClass: props.candidateWarningsActive ? 'bg-rose-400' : 'bg-violet-400',
cardClass: props.candidateWarningsActive ? 'border-rose-100 bg-rose-50/70 text-rose-800' : 'border-violet-100 bg-violet-50/70 text-violet-800',
activeClass: props.candidateWarningsActive ? 'border-rose-200 bg-rose-100 text-rose-900 shadow-[0_12px_28px_rgba(244,63,94,0.12)]' : 'border-violet-200 bg-violet-100 text-violet-900 shadow-[0_12px_28px_rgba(139,92,246,0.12)]',
},
{
key: 'Review offen',
label: 'Review offen',
value: props.healthSummary.reviewOffen,
note: props.healthSummary.reviewOffen === 0
? 'Keine offenen Review-Staus.'
: props.reviewWarningsActive
? 'Hier blockiert noch Nominationsarbeit.'
: 'Nominierungen laufen noch; Review folgt später.',
dotClass: props.reviewWarningsActive ? 'bg-amber-400' : 'bg-violet-400',
cardClass: props.reviewWarningsActive ? 'border-amber-100 bg-amber-50/70 text-amber-800' : 'border-violet-100 bg-violet-50/70 text-violet-800',
activeClass: props.reviewWarningsActive ? 'border-amber-200 bg-amber-100 text-amber-900 shadow-[0_12px_28px_rgba(245,158,11,0.12)]' : 'border-violet-200 bg-violet-100 text-violet-900 shadow-[0_12px_28px_rgba(139,92,246,0.12)]',
},
{
key: 'Bereit',
label: 'Bereit',
value: props.healthSummary.bereit,
note: 'Kandidatenbasis steht, aber finale Winner-Setzung fehlt noch.',
dotClass: 'bg-sky-400',
cardClass: 'border-sky-100 bg-sky-50/70 text-sky-800',
activeClass: 'border-sky-200 bg-sky-100 text-sky-900 shadow-[0_12px_28px_rgba(56,189,248,0.12)]',
},
{
key: 'Gewinner gesetzt',
label: 'Gewinner',
value: props.healthSummary.gewinner,
note: 'Diese Kategorien sind inhaltlich am weitesten abgeschlossen.',
dotClass: 'bg-emerald-400',
cardClass: 'border-emerald-100 bg-emerald-50/70 text-emerald-800',
activeClass: 'border-emerald-200 bg-emerald-100 text-emerald-900 shadow-[0_12px_28px_rgba(16,185,129,0.12)]',
},
])
</script>
@@ -23,6 +23,9 @@
<p class="mt-2 line-clamp-2 text-sm leading-6 text-slate-500">
{{ group.description || 'Keine Beschreibung hinterlegt.' }}
</p>
<p class="mt-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-violet-500">
Wird in der Landingpage-Awards-Karte verwendet
</p>
</button>
<div class="flex flex-wrap items-center gap-2 lg:justify-end">
@@ -50,28 +53,48 @@
<div
v-for="category in group.categories"
:key="category.id"
class="grid gap-3 px-4 py-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center"
:class="isFocused(category.slug) || isFocused(category.name) ? 'bg-sky-50/60' : 'bg-white'"
class="group grid gap-3 rounded-[22px] border border-transparent px-4 py-3 transition duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center"
:class="isFocused(category.slug) || isFocused(category.name)
? 'border-sky-200 bg-sky-50/70 shadow-[inset_0_1px_0_rgba(255,255,255,0.7)]'
: 'bg-white hover:border-violet-200 hover:bg-gradient-to-r hover:from-violet-50/75 hover:via-white hover:to-violet-50/35 focus-within:border-violet-200 focus-within:bg-gradient-to-r focus-within:from-violet-50/75 focus-within:via-white focus-within:to-violet-50/35'"
>
<button type="button" class="min-w-0 border-l-2 border-violet-100 pl-4 text-left" @click="$emit('overview-category', group, category)">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<span class="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-violet-50 text-violet-600">
<GitBranch class="h-3.5 w-3.5" />
</span>
<p class="truncate font-semibold text-slate-900">{{ category.name }}</p>
<button
type="button"
class="min-w-0 rounded-2xl text-left outline-none focus-visible:ring-2 focus-visible:ring-violet-200 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
@click="$emit('overview-category', group, category)"
>
<div class="flex min-w-0 gap-4">
<span
v-if="isFocused(category.slug) || isFocused(category.name)"
class="rounded-full bg-sky-100 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sky-700"
>
Fokus
</span>
<span class="rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em]" :class="statusClass(category)">
{{ statusLabel(category) }}
</span>
</div>
<div class="mt-2 flex flex-wrap gap-2 text-xs text-slate-500">
<span class="font-mono text-slate-400">/{{ category.slug }}</span>
<span>{{ formatViewerRange(category.viewerRangeMin, category.viewerRangeMax) }}</span>
class="mt-1 hidden h-14 w-1 shrink-0 rounded-full bg-violet-100 transition duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] sm:block"
:class="isFocused(category.slug) || isFocused(category.name) ? 'bg-sky-300' : 'group-hover:bg-violet-200 group-focus-within:bg-violet-200'"
/>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<span
class="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-violet-50 text-violet-600 transition duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]"
:class="isFocused(category.slug) || isFocused(category.name) ? 'bg-sky-100 text-sky-700' : 'group-hover:bg-white group-hover:text-violet-700 group-focus-within:bg-white group-focus-within:text-violet-700'"
>
<GitBranch class="h-3.5 w-3.5" />
</span>
<p class="truncate font-semibold text-slate-900 transition-colors duration-200 group-hover:text-violet-950 group-focus-within:text-violet-950">
{{ category.name }}
</p>
<span
v-if="isFocused(category.slug) || isFocused(category.name)"
class="rounded-full bg-sky-100 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sky-700"
>
Fokus
</span>
<span class="rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em]" :class="statusClass(category)">
{{ statusLabel(category) }}
</span>
</div>
<div class="mt-2 flex flex-wrap gap-2 text-xs text-slate-500">
<span class="font-mono text-slate-400 transition-colors duration-200 group-hover:text-violet-500 group-focus-within:text-violet-500">/{{ category.slug }}</span>
<span>{{ formatViewerRange(category.viewerRangeMin, category.viewerRangeMax) }}</span>
</div>
</div>
</div>
</button>
@@ -0,0 +1,84 @@
<template>
<div class="space-y-5">
<div class="rounded-[24px] border border-violet-100 bg-violet-50/50 p-4 text-sm leading-6 text-slate-600">
Diese Texte steuern direkt die beiden Landingpage-Bereiche <strong class="text-slate-900">Die Awards</strong> und
<strong class="text-slate-900">Unterkategorien</strong>.
</div>
<section class="rounded-[24px] border border-violet-100 bg-white/86 p-5">
<div class="mb-4">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">Landingpage</p>
<h3 class="mt-1 text-lg font-bold text-slate-900">Die Awards</h3>
</div>
<div class="grid gap-4">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Titel</span>
<input
v-model="form.awardsSectionTitle"
type="text"
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"
placeholder="Titel für den Awards-Bereich"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Beschreibung</span>
<textarea
v-model="form.awardsSectionDescription"
class="min-h-28 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"
placeholder="Einleitungstext für die Hauptkategorien"
/>
</label>
</div>
</section>
<section class="rounded-[24px] border border-violet-100 bg-white/86 p-5">
<div class="mb-4">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">Landingpage</p>
<h3 class="mt-1 text-lg font-bold text-slate-900">Unterkategorien</h3>
</div>
<div class="grid gap-4">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Titel</span>
<input
v-model="form.subcategoriesSectionTitle"
type="text"
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"
placeholder="Titel für den Unterkategorien-Bereich"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Beschreibung</span>
<textarea
v-model="form.subcategoriesSectionDescription"
class="min-h-28 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"
placeholder="Einleitungstext für die Unterkategorien"
/>
</label>
</div>
</section>
<div class="flex justify-end">
<Button :disabled="saving" size="sm" class="gap-2 rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-500 shadow-violet-500/20" @click="onSave">
<Save class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Awards-Bereich speichern' }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { Save } from '@lucide/vue'
import Button from '../ui/Button.vue'
import type { AdminContentForm } from './adminContentTypes'
const props = defineProps<{
form: AdminContentForm
saving: boolean
saveSiteSettings: (sectionLabel?: string) => Promise<void>
}>()
function onSave() {
return props.saveSiteSettings('Awards & Unterkategorien')
}
</script>
@@ -531,7 +531,7 @@ onMounted(loadLandingExtras)
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-base font-bold text-slate-900">Sponsoren-Modul</h3>
<p class="mt-1 text-sm leading-6 text-slate-500">Sponsor-Kacheln auf der Landingpage anzeigen.</p>
<p class="mt-1 text-sm leading-6 text-slate-500">Sponsor-Kacheln auf der Landingpage anzeigen. Der Footer-Link "Sponsoren &amp; Partner" öffnet den unten gepflegten Modal-Inhalt.</p>
</div>
<AdminSettingsToggle
:model-value="settingsForm.sponsorsVisible"
@@ -551,8 +551,8 @@ onMounted(loadLandingExtras)
</div>
<AdminRichTextEditor
v-model="props.form.sponsorsContent"
label="Sponsoren-Seiteninhalt"
placeholder="Texte für die Sponsoren-Seite (wird als Modal auf der Landingpage angezeigt)..."
label="Footer-Modal: Sponsoren & Partner"
placeholder="Texte für das Footer-Modal Sponsoren & Partner auf der Landingpage..."
min-height-class="min-h-[220px]"
/>
@@ -574,6 +574,12 @@ onMounted(loadLandingExtras)
</div>
<form class="space-y-3 p-4" @submit.prevent="saveSponsor">
<div
v-if="editingSponsorId"
class="rounded-2xl border border-sky-100 bg-sky-50 px-4 py-3 text-sm font-semibold text-sky-800"
>
Bearbeitungsmodus aktiv. Deine Anpassungen werden beim Speichern direkt auf diesen Sponsor uebernommen.
</div>
<input v-model="sponsorForm.name" required maxlength="120" placeholder="Sponsor-Name" class="w-full rounded-2xl border border-violet-100 px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<div class="grid gap-3 sm:grid-cols-2">
<input v-model="sponsorForm.websiteUrl" maxlength="500" placeholder="Website URL" class="rounded-2xl border border-violet-100 px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
@@ -589,11 +595,11 @@ onMounted(loadLandingExtras)
Public anzeigen
</label>
<div class="flex flex-wrap gap-2">
<Button class="gap-2" :disabled="saving">
<Button type="submit" class="gap-2" :disabled="saving || localSaving">
<Save class="h-4 w-4" />
{{ editingSponsorId ? 'Sponsor speichern' : 'Sponsor anlegen' }}
</Button>
<Button type="button" variant="ghost" :disabled="saving" @click="resetSponsorForm">
<Button type="button" variant="ghost" :disabled="saving || localSaving" @click="resetSponsorForm">
<Plus class="h-4 w-4" />
Neu
</Button>
@@ -610,8 +616,8 @@ onMounted(loadLandingExtras)
</p>
</div>
<div class="flex shrink-0 gap-2">
<Button size="sm" variant="ghost" @click="editSponsor(sponsor)">Bearbeiten</Button>
<Button size="sm" variant="ghost" class="text-rose-700" :disabled="saving" @click="deleteSponsor(sponsor)">
<Button type="button" size="sm" variant="ghost" @click="editSponsor(sponsor)">Bearbeiten</Button>
<Button type="button" size="sm" variant="ghost" class="text-rose-700" :disabled="saving || localSaving" @click="deleteSponsor(sponsor)">
<Trash2 class="h-4 w-4" />
</Button>
</div>
@@ -1,123 +0,0 @@
<template>
<Card id="content-links" class="p-6">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Footer & Kontakt</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Rechtliche Links und Kontaktwege</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">Diese URLs und Inhalte werden im Footer und in den öffentlichen Kontaktflächen ausgespielt.</p>
</div>
<div class="flex shrink-0 flex-col items-end gap-3">
<Link2 class="h-6 w-6 text-violet-500" />
<Button :disabled="saving" size="sm" class="gap-2 rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-500 shadow-violet-500/20" @click="onSave">
<Save class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Footer speichern' }}
</Button>
</div>
</div>
<div class="mt-6 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">Datenschutz E-Mail</span>
<input v-model="form.privacyEmail" type="email" 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" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kontakt URL</span>
<input v-model="form.contactUrl" type="url" 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" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Impressum URL</span>
<input v-model="form.imprintUrl" type="url" 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" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Sponsoren & Partner URL</span>
<input v-model="form.sponsorsUrl" type="url" 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" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Showacts URL</span>
<input v-model="form.showactsUrl" type="url" 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" />
</label>
</div>
<div class="mt-7 space-y-5">
<div class="space-y-3">
<div class="flex justify-end">
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-violet-700 shadow-none hover:bg-violet-50" @click="$emit('open-preview', 'imprint')">
<Eye class="h-4 w-4" />
Impressum Preview
</Button>
</div>
<AdminRichTextEditor
v-model="form.imprintContent"
label="Impressum Inhalt"
placeholder="Impressumstext..."
min-height-class="min-h-[300px]"
/>
</div>
<div class="space-y-3">
<div class="flex justify-end">
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-violet-700 shadow-none hover:bg-violet-50" @click="$emit('open-preview', 'contact')">
<Eye class="h-4 w-4" />
Kontakt Preview
</Button>
</div>
<AdminRichTextEditor
v-model="form.contactContent"
label="Kontakt Inhalt"
placeholder="Kontakttext..."
min-height-class="min-h-[260px]"
/>
</div>
<div class="space-y-3">
<div class="flex justify-end">
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-violet-700 shadow-none hover:bg-violet-50" @click="$emit('open-preview', 'sponsors')">
<Eye class="h-4 w-4" />
Sponsoren Preview
</Button>
</div>
<AdminRichTextEditor
v-model="form.sponsorsContent"
label="Sponsoren & Partner Inhalt"
placeholder="Sponsor:innen, Partner und Hinweise..."
min-height-class="min-h-[260px]"
/>
</div>
<div class="space-y-3">
<div class="flex justify-end">
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-violet-700 shadow-none hover:bg-violet-50" @click="$emit('open-preview', 'showacts')">
<Eye class="h-4 w-4" />
Showacts Preview
</Button>
</div>
<AdminRichTextEditor
v-model="form.showactsContent"
label="Showacts Inhalt"
placeholder="Informationen zu Showact-Bewerbungen, Ablauf und Kontakt..."
min-height-class="min-h-[260px]"
/>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { Eye, Link2, Save } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import AdminRichTextEditor from './AdminRichTextEditor.vue'
import type { AdminContentForm } from './adminContentTypes'
export type FooterPreviewKey = 'imprint' | 'contact' | 'sponsors' | 'showacts'
const props = defineProps<{
form: AdminContentForm
saving: boolean
saveSiteSettings: (sectionLabel?: string) => Promise<void>
}>()
defineEmits<{
'open-preview': [key: FooterPreviewKey]
}>()
function onSave() {
return props.saveSiteSettings('Footer & Kontakt')
}
</script>
@@ -4,7 +4,7 @@
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Community</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Social Links</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">Plattform, Label und URL werden auf der Landingpage als Social Icons angezeigt.</p>
<p class="mt-2 text-sm leading-6 text-slate-500">Plattform, Label und URL werden auf der Landingpage im Host- und Community-Bereich verwendet. Der Share-Block nutzt die X- und Discord-Links aus dem Basis-Bereich.</p>
</div>
<div class="flex flex-wrap gap-2 sm:justify-end">
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-4 text-violet-700 shadow-none hover:bg-violet-100" @click="addSocialLink">
@@ -0,0 +1,168 @@
<template>
<div class="space-y-5">
<div class="rounded-[24px] border border-violet-100 bg-violet-50/50 p-4 text-sm leading-6 text-slate-600">
Dieser Bereich steuert den Finale-Banner direkt unter dem Hero. Der Countdown selbst bleibt automatisch an das
Show-Datum des aktiven Award-Jahres gekoppelt.
</div>
<section class="rounded-[24px] border border-violet-100 bg-white/86 p-5">
<div class="mb-4">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">Finale-Banner</p>
<h3 class="mt-1 text-lg font-bold text-slate-900">Standard-Inhalt</h3>
</div>
<div class="grid gap-4">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Eyebrow</span>
<input
v-model="form.streamBannerEyebrow"
type="text"
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"
placeholder="Das grosse Finale"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Titel</span>
<input
v-model="form.streamBannerTitle"
type="text"
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"
placeholder="Award-Show Finale"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Text</span>
<textarea
v-model="form.streamBannerText"
class="min-h-24 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"
placeholder="Kurzer Text unter dem Titel"
/>
</label>
</div>
</section>
<section class="rounded-[24px] border border-violet-100 bg-white/86 p-5">
<div class="mb-4">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">Button</p>
<h3 class="mt-1 text-lg font-bold text-slate-900">Stream-Aktion</h3>
</div>
<div class="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">Live-Button Text</span>
<input
v-model="form.streamBannerLiveButtonLabel"
type="text"
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"
placeholder="Jetzt live · Zum Stream"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Gesperrt-Button Text</span>
<input
v-model="form.streamBannerLockedButtonLabel"
type="text"
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"
placeholder="Stream noch gesperrt"
/>
</label>
<label class="space-y-2 md:col-span-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Live-Button URL</span>
<input
v-model="form.streamBannerLiveButtonUrl"
type="url"
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"
placeholder="Leer lassen, um die Stream-URL des Award-Jahres zu nutzen"
/>
</label>
</div>
</section>
<section class="rounded-[24px] border border-violet-100 bg-white/86 p-5">
<div class="mb-4 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">Award vorbei</p>
<h3 class="mt-1 text-lg font-bold text-slate-900">Alternativer Abschluss-Inhalt</h3>
<p class="mt-2 text-sm leading-6 text-slate-500">
Wird nur verwendet, wenn das Award-Jahr abgeschlossen ist und der Schalter aktiv ist.
</p>
</div>
<label class="inline-flex cursor-pointer items-center gap-3 rounded-2xl border border-violet-100 bg-violet-50 px-4 py-3">
<input v-model="form.streamBannerUseCompletedContent" type="checkbox" class="h-4 w-4 rounded border-violet-300 text-violet-600 focus:ring-violet-300" />
<span class="text-sm font-bold text-violet-700">Abschluss-Inhalt aktiv</span>
</label>
</div>
<div class="grid gap-4">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Eyebrow</span>
<input
v-model="form.streamBannerCompletedEyebrow"
type="text"
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"
placeholder="Danke fürs Mitfiebern"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Titel</span>
<input
v-model="form.streamBannerCompletedTitle"
type="text"
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"
placeholder="Award-Show abgeschlossen"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Text</span>
<textarea
v-model="form.streamBannerCompletedText"
class="min-h-24 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"
placeholder="Text nach dem Finale"
/>
</label>
<div class="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">Button Text</span>
<input
v-model="form.streamBannerCompletedButtonLabel"
type="text"
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"
placeholder="Highlights ansehen"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Button URL</span>
<input
v-model="form.streamBannerCompletedButtonUrl"
type="url"
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"
placeholder="https://..."
/>
</label>
</div>
</div>
</section>
<div class="flex justify-end">
<Button :disabled="saving" size="sm" class="gap-2 rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-500 shadow-violet-500/20" @click="onSave">
<Save class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Finale-Banner speichern' }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { Save } from '@lucide/vue'
import Button from '../ui/Button.vue'
import type { AdminContentForm } from './adminContentTypes'
const props = defineProps<{
form: AdminContentForm
saving: boolean
saveSiteSettings: (sectionLabel?: string) => Promise<void>
}>()
function onSave() {
return props.saveSiteSettings('Finale-Banner')
}
</script>
@@ -1,8 +1,11 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import Card from '../ui/Card.vue'
defineProps<{
activities: Array<{ label: string; age: string }>
canViewAuditLog: boolean
}>()
</script>
@@ -13,10 +16,14 @@ defineProps<{
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Aktivitäten</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Was gerade passiert ist</h2>
</div>
<p class="text-sm text-slate-500">Audit-nahe Ereignisse, komprimiert für den schnellen Blick.</p>
<RouterLink v-if="canViewAuditLog" to="/admin/users-logs" class="inline-flex h-10 items-center justify-center rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-violet-700 transition hover:bg-violet-50">
Alles ansehen
</RouterLink>
</div>
<div class="mt-6 grid gap-4 md:grid-cols-3">
<p class="mt-2 text-sm text-slate-500">Globale Admin-Ereignisse, komprimiert für den schnellen Blick.</p>
<div class="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div
v-for="activity in activities"
:key="activity.label"
@@ -2,11 +2,10 @@
import Card from '../ui/Card.vue'
defineProps<{
metricCards: Array<{ label: string; value: number; note: string; source: string; icon: unknown }>
metricCards: Array<{ label: string; value: number; note: string; icon: unknown }>
liveSummary: string
liveStatusBadge: string
openRiskCount: number
openReviewCount: number
liveStatusTone: 'ok' | 'warn' | 'danger' | 'info'
}>()
</script>
@@ -22,7 +21,12 @@ defineProps<{
</div>
<div
class="inline-flex shrink-0 items-center rounded-xl border px-3 py-2 text-xs font-bold"
:class="openRiskCount > 0 ? 'border-rose-200 bg-rose-50 text-rose-700' : openReviewCount > 0 ? 'border-amber-200 bg-amber-50 text-amber-700' : 'border-emerald-200 bg-emerald-50 text-emerald-700'"
:class="{
'border-emerald-200 bg-emerald-50 text-emerald-700': liveStatusTone === 'ok',
'border-amber-200 bg-amber-50 text-amber-700': liveStatusTone === 'warn',
'border-rose-200 bg-rose-50 text-rose-700': liveStatusTone === 'danger',
'border-slate-200 bg-slate-50 text-slate-600': liveStatusTone === 'info',
}"
>
{{ liveStatusBadge }}
</div>
@@ -47,7 +51,6 @@ defineProps<{
<div class="mt-3 rounded-xl border border-violet-100 bg-violet-50/50 px-3 py-2">
<p class="text-xs font-semibold leading-5 text-slate-700">{{ metric.note }}</p>
<p class="mt-0.5 text-[11px] text-slate-500">Quelle: {{ metric.source }}</p>
</div>
</div>
</div>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import Card from '../ui/Card.vue'
defineProps<{
publicHealthItems: Array<{ label: string; state: 'ok' | 'warn' | 'danger' | 'info'; note: string; to: string; icon: unknown }>
}>()
</script>
<template>
<Card class="p-6">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Public Health</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Was die Community sieht</h2>
</div>
<div class="mt-5 grid gap-3 sm:grid-cols-2">
<RouterLink
v-for="item in publicHealthItems"
:key="item.label"
:to="item.to"
class="rounded-[22px] border bg-white/85 p-4 transition hover:-translate-y-0.5 hover:bg-violet-50/60"
:class="{
'border-emerald-100': item.state === 'ok',
'border-amber-100': item.state === 'warn',
'border-rose-100': item.state === 'danger',
'border-slate-100': item.state === 'info',
}"
>
<div class="flex items-start gap-3">
<div
class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl"
:class="{
'bg-emerald-100 text-emerald-700': item.state === 'ok',
'bg-amber-100 text-amber-700': item.state === 'warn',
'bg-rose-100 text-rose-700': item.state === 'danger',
'bg-slate-100 text-slate-600': item.state === 'info',
}"
>
<component :is="item.icon" class="h-5 w-5" />
</div>
<div class="min-w-0">
<p class="font-semibold text-slate-800">{{ item.label }}</p>
<p class="mt-1 text-sm leading-5 text-slate-500">{{ item.note }}</p>
</div>
</div>
</RouterLink>
</div>
<p v-if="publicHealthItems.length === 0" class="mt-5 rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Public-Health-Details sind für deine Rolle nicht sichtbar.
</p>
</Card>
</template>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import Card from '../ui/Card.vue'
defineProps<{
queueItems: Array<{ label: string; value: number; to: string; hint: string; icon: unknown; tone: string }>
}>()
</script>
<template>
<Card class="p-6">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Team-Queue</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Was offen ist</h2>
</div>
</div>
<div class="mt-5 space-y-3">
<RouterLink
v-for="item in queueItems"
:key="item.label"
:to="item.to"
class="group block rounded-[22px] border border-violet-100 bg-white/85 p-4 transition hover:-translate-y-0.5 hover:border-violet-200 hover:bg-violet-50/70"
>
<div class="flex items-center justify-between gap-4">
<div class="flex min-w-0 items-center gap-3">
<div
class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl"
:class="{
'bg-violet-100 text-violet-700': item.tone === 'violet',
'bg-rose-100 text-rose-700': item.tone === 'rose',
'bg-amber-100 text-amber-700': item.tone === 'amber',
'bg-emerald-100 text-emerald-700': item.tone === 'emerald',
}"
>
<component :is="item.icon" class="h-5 w-5" />
</div>
<div class="min-w-0">
<p class="font-semibold text-slate-800">{{ item.label }}</p>
<p class="text-sm leading-5 text-slate-500">{{ item.hint }}</p>
</div>
</div>
<strong class="grid h-9 min-w-9 shrink-0 place-items-center rounded-full border border-violet-100 bg-white px-3 text-violet-800">
{{ item.value.toLocaleString('de-DE') }}
</strong>
</div>
</RouterLink>
<p v-if="queueItems.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Für deine Rolle sind keine Dashboard-Queues sichtbar.
</p>
</div>
</Card>
</template>
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed } from 'vue'
import { CheckCircle2, CircleAlert } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import Card from '../ui/Card.vue'
const props = defineProps<{
readinessItems: Array<{ label: string; complete: boolean; note: string; to: string }>
readinessScore: { done: number; total: number }
}>()
const readinessPercent = computed(() =>
props.readinessScore.total === 0
? 0
: Math.round((props.readinessScore.done / props.readinessScore.total) * 100),
)
</script>
<template>
<Card class="p-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Readiness</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Show-Bereitschaft</h2>
<p class="mt-2 text-sm leading-5 text-slate-500">
{{ readinessScore.done }}/{{ readinessScore.total }} Checks bereit
</p>
</div>
<div class="grid h-16 w-16 shrink-0 place-items-center rounded-full border border-violet-100 bg-violet-50 text-lg font-black text-violet-800">
{{ readinessPercent }}%
</div>
</div>
<div class="mt-5 h-3 rounded-full bg-violet-50">
<div class="h-3 rounded-full bg-gradient-to-r from-emerald-300 to-violet-500" :style="{ width: `${readinessPercent}%` }"></div>
</div>
<div class="mt-5 space-y-3">
<RouterLink
v-for="item in readinessItems"
:key="item.label"
:to="item.to"
class="block rounded-[20px] border p-4 transition hover:-translate-y-0.5 hover:bg-violet-50/60"
:class="item.complete ? 'border-emerald-100 bg-emerald-50/50' : 'border-amber-100 bg-amber-50/50'"
>
<div class="flex items-start gap-3">
<CheckCircle2 v-if="item.complete" class="mt-0.5 h-5 w-5 shrink-0 text-emerald-600" />
<CircleAlert v-else class="mt-0.5 h-5 w-5 shrink-0 text-amber-600" />
<div class="min-w-0">
<p class="font-semibold text-slate-800">{{ item.label }}</p>
<p class="mt-1 text-sm leading-5 text-slate-600">{{ item.note }}</p>
</div>
</div>
</RouterLink>
</div>
</Card>
</template>
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { AlertTriangle, CalendarClock } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import Card from '../ui/Card.vue'
defineProps<{
timelinePhases: Array<{
key: string
title: string
description: string
dateRange: string
statusLabel: string
dotClass: string
statusClass: string
active: boolean
}>
activeCountdown: string
phaseMismatch: boolean
phaseMismatchLabel: string
canOpenYears: boolean
}>()
</script>
<template>
<Card class="p-6">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Timeline</p>
<div class="mt-2 flex flex-wrap items-center gap-3">
<h2 class="text-2xl font-bold text-slate-950">{{ activeCountdown }}</h2>
<span
v-if="phaseMismatch"
class="inline-flex items-center gap-2 rounded-full border border-amber-200 bg-amber-50 px-3 py-1.5 text-xs font-bold text-amber-700"
>
<AlertTriangle class="h-3.5 w-3.5" />
Zeitplan prüfen
</span>
</div>
<p v-if="phaseMismatchLabel" class="mt-2 text-sm leading-5 text-amber-700">{{ phaseMismatchLabel }}</p>
</div>
<RouterLink v-if="canOpenYears" to="/admin/years" class="inline-flex h-10 shrink-0 items-center justify-center gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-violet-700 transition hover:bg-violet-50">
<CalendarClock class="h-4 w-4" />
Jahr öffnen
</RouterLink>
</div>
<div class="mt-6 grid gap-3 lg:grid-cols-4">
<div
v-for="phase in timelinePhases"
:key="phase.key"
class="rounded-[22px] border p-4"
:class="phase.active ? 'border-violet-200 bg-violet-50/80 shadow-[0_14px_34px_rgba(124,92,255,0.12)]' : 'border-violet-100 bg-white/85'"
>
<div class="flex items-start justify-between gap-3">
<span class="mt-1 h-3 w-3 shrink-0 rounded-full" :class="phase.dotClass"></span>
<span class="rounded-full border px-2.5 py-1 text-[11px] font-bold uppercase" :class="phase.statusClass">
{{ phase.statusLabel }}
</span>
</div>
<h3 class="mt-4 text-base font-bold text-slate-900">{{ phase.title }}</h3>
<p class="mt-1 text-sm leading-5 text-slate-500">{{ phase.dateRange }}</p>
<p class="mt-3 text-xs leading-5 text-slate-500">{{ phase.description }}</p>
</div>
</div>
</Card>
</template>
@@ -2,9 +2,10 @@
import Card from '../ui/Card.vue'
defineProps<{
totalCategoryVotes: number
maxCategoryVotes: number
topCategories: Array<{ category: string; votes: number }>
maxCategoryValue: number
topCategoryTitle: string
topCategoryTotalLabel: string
topCategories: Array<{ category: string; value: number; basis: string }>
}>()
</script>
@@ -13,9 +14,9 @@ defineProps<{
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Kategorie-Performance</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Top Kategorien nach Stimmen</h2>
<h2 class="mt-1 text-xl font-bold text-slate-900">{{ topCategoryTitle }}</h2>
</div>
<p class="text-sm text-slate-500">{{ totalCategoryVotes.toLocaleString('de-DE') }} Stimmen in den Top-Kategorien</p>
<p class="text-sm text-slate-500">{{ topCategoryTotalLabel }}</p>
</div>
<div class="mt-6 space-y-4">
@@ -29,15 +30,18 @@ defineProps<{
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">#{{ index + 1 }}</p>
<h3 class="mt-1 font-semibold text-slate-800">{{ category.category }}</h3>
</div>
<strong class="text-lg text-violet-800">{{ Number(category.votes).toLocaleString('de-DE') }}</strong>
<strong class="text-lg text-violet-800">{{ Number(category.value).toLocaleString('de-DE') }}</strong>
</div>
<div class="mt-4 h-3 rounded-full bg-[#f7f2ff]">
<div
class="h-3 rounded-full bg-gradient-to-r from-[#c4b5fd] to-[#7c5cff]"
:style="{ width: `${(category.votes / maxCategoryVotes) * 100}%` }"
:style="{ width: `${(category.value / maxCategoryValue) * 100}%` }"
/>
</div>
</div>
<p v-if="topCategories.length === 0" class="rounded-[24px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Noch keine Kategorie-Signale für dieses Jahr.
</p>
</div>
</Card>
</template>
@@ -13,6 +13,7 @@ const expandedVersions = ref<string[]>([releaseVersions[0]?.version ?? semanticV
const featureCount = computed(() => releaseNotes.filter((note) => note.type === 'feature').length)
const fixCount = computed(() => releaseNotes.filter((note) => note.type === 'fix').length)
const versionCount = computed(() => releaseVersions.length)
const currentReleaseSummary = computed(() => releaseVersions[0]?.summary ?? 'Aktueller Feature-Release.')
function versionFeatureCount(version: typeof releaseVersions[number]) {
return version.notes.filter((note) => note.type === 'feature').length
@@ -78,7 +79,7 @@ function changeTypeClasses(type: ReleaseChangeType) {
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Build Version</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Version {{ buildVersion }}</h2>
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
Feature-Release vom {{ buildDateLabel }} mit Twitch Login, Team-Verknuepfung, Admin-UX-Polish und Backend-Haertung.
Release vom {{ buildDateLabel }}: {{ currentReleaseSummary }}
</p>
</div>
</div>
@@ -57,9 +57,9 @@ function avgViewerLabel(nomination: AdminNominationReviewGroup) {
v-for="nomination in nominations"
:key="nomination.id"
type="button"
class="w-full rounded-[24px] border p-4 text-left transition"
class="group w-full rounded-[24px] border p-4 text-left transition"
:class="selectedNominationId === nomination.id ? 'border-violet-300 bg-white shadow-[0_18px_34px_rgba(168,145,214,0.14)] ring-2 ring-violet-100' : 'border-violet-100 bg-white/85 hover:border-violet-200 hover:bg-violet-50/50'"
@click="emit('select', nomination.id)"
@click="emit('open', nomination.id)"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
@@ -116,13 +116,11 @@ function avgViewerLabel(nomination: AdminNominationReviewGroup) {
>
#{{ nomination.id }}
</div>
<button
type="button"
class="rounded-full border border-violet-200 bg-white px-3 py-1.5 text-xs font-semibold text-violet-700 transition hover:bg-violet-50"
@click.stop="emit('open', nomination.id)"
<span
class="rounded-full border border-violet-200 bg-white px-3 py-1.5 text-xs font-semibold text-violet-700 transition group-hover:bg-violet-50"
>
Prüfen
</button>
</span>
</div>
</div>
</button>
@@ -28,10 +28,6 @@ const props = defineProps<{
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
<input v-model="props.createForm.name" type="text" 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" />
</label>
<label class="space-y-2 sm:col-span-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Finaler Stream-Link</span>
<input v-model="props.createForm.showStreamUrl" type="url" 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" />
</label>
<label class="space-y-2 sm:col-span-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Vorjahr kopieren</span>
<NativeSelect v-model="props.createForm.copyStructureFromSeasonId" :options="props.copySourceOptions" />
@@ -39,6 +35,9 @@ const props = defineProps<{
Kopiert Kategorien, Gruppen, Slugs, Beschreibung, Sortierung und Nominierungs-Limits. Kandidaten und Gewinner bleiben leer.
</span>
</label>
<div class="rounded-[22px] border border-violet-100 bg-violet-50/60 p-4 text-sm leading-6 text-slate-600 sm:col-span-2">
Der Live-Link fuer das Finale kommt zentral aus dem Landingpage-Modul, damit Jahre und Banner nicht doppelt gepflegt werden.
</div>
<fieldset class="grid gap-2 sm:col-span-2 sm:grid-cols-2">
<legend class="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Startphase</legend>
<button
@@ -6,7 +6,7 @@
<strong class="text-5xl font-black leading-none text-slate-950">{{ props.form.year }}</strong>
<div class="mb-0.5">
<p class="text-lg font-bold leading-snug text-slate-800">{{ props.form.name || '' }}</p>
<p class="mt-1 text-sm text-slate-500">{{ props.form.showStreamUrl || 'Kein Stream-Link' }}</p>
<p class="mt-1 text-sm text-slate-500">Finale-Banner und Live-CTA werden im Content Hub gepflegt.</p>
</div>
</div>
<div class="flex items-center gap-2">
@@ -1,143 +0,0 @@
<template>
<Card class="overflow-hidden">
<div class="flex flex-col gap-4 border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-[#f7eef8] p-5 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Phasenwechsel</p>
<h2 class="mt-1 truncate text-xl font-bold leading-tight text-slate-900 lg:text-2xl">
{{ props.form.name || props.seasonName || 'Kein Award-Jahr gewählt' }}
</h2>
<p class="mt-2 max-w-2xl text-sm leading-5 text-slate-500">
Wechsle die öffentliche Award-Phase bewusst. Die Datumsanalyse hilft beim Abgleich, speichert aber nichts automatisch.
</p>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Aktiv</span>
<span class="rounded-full border border-violet-200 bg-violet-100 px-4 py-2 text-sm font-bold text-violet-800">
{{ props.form.currentPhase || 'Nicht gesetzt' }}
</span>
</div>
</div>
<div v-if="autoMismatch" class="flex flex-col gap-3 border-b border-amber-100 bg-amber-50/70 px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-start gap-3">
<span class="grid h-9 w-9 shrink-0 place-items-center rounded-2xl bg-amber-100 text-amber-700">
<AlertTriangle class="h-4 w-4" />
</span>
<div>
<p class="text-sm font-bold text-amber-900">Zeitplan empfiehlt {{ autoPhase }}</p>
<p class="mt-1 text-sm leading-6 text-amber-800/80">
Laut heutigen Daten liegt das Jahr in einer anderen Phase als gespeichert.
</p>
</div>
</div>
<Button
variant="ghost"
class="border border-amber-200 bg-white text-amber-700 hover:bg-amber-100"
:disabled="props.saving || !props.selectedSeasonId || !autoPhase || autoPhaseIsCompleted"
@click="autoPhase && !autoPhaseIsCompleted && props.activatePhase(autoPhase)"
>
{{ autoPhaseIsCompleted ? 'Über Beenden abschließen' : 'Automatisch aktivieren' }}
</Button>
</div>
<div class="p-4">
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<article
v-for="phase in phaseCards"
:key="phase.key"
class="flex min-h-[178px] flex-col rounded-[20px] border p-4 transition"
:class="phase.cardClass"
>
<div class="mb-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<span class="h-2.5 w-2.5 rounded-full" :class="phase.dotClass"></span>
<span class="text-xs font-bold text-slate-400">{{ phase.ordinal }}</span>
</div>
<span class="rounded-full border px-3 py-1 text-[11px] font-bold uppercase tracking-[0.08em]" :class="phase.badgeClass">
{{ phase.statusLabel }}
</span>
</div>
<h3 class="text-base font-bold leading-snug text-slate-900">{{ phase.title }}</h3>
<p class="mt-1 flex-1 text-xs leading-5 text-slate-500">{{ phase.dateRange }}</p>
<button
type="button"
class="mt-3 inline-flex w-full items-center justify-center gap-2 rounded-2xl px-4 py-2.5 text-sm font-bold transition disabled:cursor-not-allowed disabled:opacity-60"
:class="phase.active ? 'bg-violet-600 text-white' : phase.finalLocked ? 'border border-slate-200 bg-slate-50 text-slate-500' : 'border border-violet-100 bg-white text-violet-700 hover:bg-violet-50'"
:disabled="props.saving || !props.selectedSeasonId || phase.active || phase.finalLocked"
:title="phase.actionTitle"
@click="props.activatePhase(phase.title)"
>
<CheckCircle2 v-if="phase.active" class="h-4 w-4" />
<LockKeyhole v-else-if="phase.finalLocked" class="h-4 w-4" />
<WandSparkles v-else class="h-4 w-4" />
{{ phase.actionLabel }}
</button>
</article>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { AlertTriangle, CheckCircle2, LockKeyhole, WandSparkles } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import {
createSeasonTimelineRows,
hasShowDayPassed,
normalizePhaseKey,
resolveAutoPhase,
type SeasonTimelineForm,
} from './adminSeasonTimeline'
const props = defineProps<{
form: SeasonTimelineForm & { name?: string }
seasonName: string
saving: boolean
selectedSeasonId: number | null
activatePhase: (phase: string) => Promise<void> | void
}>()
const currentPhaseKey = computed(() => normalizePhaseKey(props.form.currentPhase))
const autoPhase = computed(() => resolveAutoPhase(props.form))
const autoPhaseIsCompleted = computed(() => normalizePhaseKey(autoPhase.value) === 'completed')
const autoMismatch = computed(() => Boolean(autoPhase.value && normalizePhaseKey(autoPhase.value) !== currentPhaseKey.value))
const phaseCards = computed(() =>
createSeasonTimelineRows(props.form, currentPhaseKey.value, autoPhase.value, null).map((phase, index) => {
const isAutoActive = autoPhase.value === phase.title && !phase.active
const isDone = phase.statusLabel === 'Erledigt'
const isCompletedPhase = phase.key === 'completed'
const finalLocked = isCompletedPhase && !phase.active
const showHasPassed = hasShowDayPassed(props.form)
return {
...phase,
ordinal: String(index + 1).padStart(2, '0'),
finalLocked,
actionLabel: phase.active ? 'Aktiv' : isCompletedPhase ? 'Beenden nutzen' : 'Phase aktivieren',
actionTitle: isCompletedPhase
? showHasPassed
? 'Abschluss erfolgt über den Beenden-Flow in den Grunddaten.'
: 'Abschluss ist erst nach der Award Show möglich.'
: `Phase ${phase.title} aktivieren`,
cardClass: phase.active
? 'border-violet-200 bg-violet-50 shadow-[0_18px_42px_rgba(139,108,219,0.14)]'
: isAutoActive
? 'border-amber-200 bg-amber-50/70'
: 'border-violet-100 bg-white/90 hover:border-violet-200 hover:bg-violet-50/40',
dotClass: phase.active ? 'bg-violet-600' : isAutoActive ? 'bg-amber-500' : isDone ? 'bg-emerald-500' : 'bg-slate-300',
badgeClass: phase.active
? 'border-violet-200 bg-violet-100 text-violet-800'
: isAutoActive
? 'border-amber-200 bg-white text-amber-700'
: isDone
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-slate-200 bg-slate-50 text-slate-500',
}
}),
)
</script>
@@ -1,163 +0,0 @@
<template>
<Card class="flex h-[684px] flex-col overflow-hidden">
<div class="shrink-0 border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-[#f7eef8] p-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Grunddaten</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">{{ seasonName || 'Kein Jahr gewählt' }}</h2>
<p class="mt-2 max-w-xl text-sm leading-6 text-slate-500">
Die wichtigsten Stammdaten, der Public-Kontext und der finale Stream-Link auf einen Blick.
</p>
</div>
<div class="rounded-2xl border px-4 py-3 text-sm font-semibold" :class="form.isCurrent ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-slate-100 bg-slate-50 text-slate-500'">
{{ form.isCurrent ? 'Öffentlich aktiv' : 'Intern vorbereitet' }}
</div>
</div>
</div>
<div class="grid min-h-0 flex-1 gap-5 overflow-y-auto p-5 min-[1400px]:grid-cols-[minmax(0,1fr)_minmax(300px,0.78fr)]">
<div class="space-y-4">
<div class="grid gap-4 sm:grid-cols-[140px_minmax(0,1fr)]">
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Jahr</span>
<input v-model.number="form.year" type="number" 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 disabled:text-slate-400" placeholder="2027" :disabled="!selectedSeasonId || saving" />
</label>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
<input v-model="form.name" type="text" 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 disabled:text-slate-400" placeholder="VTuber Star Awards 2027" :disabled="!selectedSeasonId || saving" />
</label>
<label class="block space-y-2 sm:col-span-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Finaler Stream-Link</span>
<input v-model="form.showStreamUrl" type="url" 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 disabled:text-slate-400" placeholder="https://twitch.tv/jayuhime" :disabled="!selectedSeasonId || saving" />
</label>
</div>
<div class="grid gap-3 md:grid-cols-2">
<label class="flex min-h-[108px] cursor-pointer gap-3 rounded-[22px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50 has-disabled:cursor-not-allowed has-disabled:opacity-70">
<input v-model="form.isCommunityOnly" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" :disabled="!selectedSeasonId || saving" />
<span>
<span class="block font-semibold text-slate-800">Community-only</span>
<span class="mt-1 block text-sm leading-5 text-slate-500">Voting und Teilnahme laufen über die Community.</span>
</span>
</label>
<label class="flex min-h-[108px] cursor-pointer gap-3 rounded-[22px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50 has-disabled:cursor-not-allowed has-disabled:opacity-70">
<input v-model="form.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" :disabled="!selectedSeasonId || saving || (!form.isCurrent && !canActivatePublic)" />
<span>
<span class="block font-semibold text-slate-800">Public-Kontext</span>
<span class="mt-1 block text-sm leading-5 text-slate-500">
{{ form.isCurrent ? 'Dieses Jahr ist auf der Landingpage sichtbar.' : canActivatePublic ? 'Mit Speichern oder Button als Landingpage-Jahr aktivieren.' : 'Erst die Public-Readiness-Blocker unten loesen.' }}
</span>
</span>
</label>
</div>
<div class="grid gap-3 border-t border-violet-100 pt-4 md:grid-cols-4">
<Button variant="ghost" class="w-full gap-2 border border-rose-100 bg-rose-50 text-rose-600 hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-50" :disabled="!selectedSeasonId || !canDeleteSelectedSeason" @click="openDeleteSeasonModal">
<Trash2 class="h-4 w-4" />
Löschen
</Button>
<Button variant="ghost" class="w-full gap-2 border border-emerald-100 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-50" :disabled="saving || !selectedSeasonId || form.isCurrent || !canActivatePublic" @click="activatePublicSeason">
<Globe2 class="h-4 w-4" />
{{ saving ? 'Aktiviert ...' : 'Public aktivieren' }}
</Button>
<Button variant="ghost" class="w-full gap-2 border border-amber-100 bg-amber-50 text-amber-700 hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50" :disabled="completing || !selectedSeasonId || !canCompleteSelectedSeason" @click="completeSeason">
<CheckCircle2 class="h-4 w-4" />
{{ completing ? 'Schliesst ...' : 'Beenden' }}
</Button>
<Button class="w-full" :disabled="saving || !selectedSeasonId" @click="saveSeason">
{{ saving ? 'Speichert ...' : 'Speichern' }}
</Button>
</div>
<p v-if="selectedSeasonIsCurrent" class="text-xs leading-5 text-slate-500">
Das öffentliche aktive Jahr kann nicht gelöscht werden. Schalte zuerst ein anderes Jahr öffentlich.
</p>
</div>
<aside class="min-h-0 space-y-4 lg:max-h-[432px] lg:overflow-y-auto lg:pr-1">
<div class="rounded-[22px] border border-violet-100 bg-white p-4">
<div class="flex items-start gap-3">
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<History class="h-5 w-5" />
</div>
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Letzte Admin-Aktion</p>
<p class="mt-1 text-sm font-semibold leading-5 text-slate-800">
{{ loadingSeasonAudit ? 'Laedt Audit ...' : latestSeasonAuditSummary }}
</p>
<p class="mt-1 text-xs leading-5 text-slate-500">{{ latestSeasonAuditMeta }}</p>
</div>
</div>
</div>
<div class="rounded-[22px] border border-violet-100 bg-white p-4">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Public-Readiness</p>
<p class="mt-1 text-sm leading-5 text-slate-500">Blocker für Public auf einen Blick.</p>
</div>
<span class="shrink-0 rounded-full border px-3 py-1 text-xs font-semibold" :class="canActivatePublic ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-rose-200 bg-rose-50 text-rose-700'">
{{ canActivatePublic ? 'Bereit' : 'Blockiert' }}
</span>
</div>
<div class="mt-4 grid gap-2">
<RouterLink
v-for="item in readinessItems"
:key="item.label"
:to="item.to"
class="flex min-h-[58px] items-start gap-3 rounded-2xl border px-3 py-2.5 transition hover:bg-violet-50/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400"
:class="item.complete ? 'border-emerald-100 bg-emerald-50/60' : item.blocking ? 'border-rose-200 bg-rose-50/70' : 'border-amber-100 bg-amber-50/60'"
>
<CheckCircle2 v-if="item.complete" class="mt-0.5 h-4 w-4 shrink-0 text-emerald-700" />
<AlertTriangle v-else class="mt-0.5 h-4 w-4 shrink-0" :class="item.blocking ? 'text-rose-700' : 'text-amber-700'" />
<span class="min-w-0">
<span class="flex flex-wrap items-center gap-2 text-sm font-semibold text-slate-800">
{{ item.label }}
<span v-if="item.blocking && !item.complete" class="rounded-full bg-rose-100 px-2 py-0.5 text-[11px] text-rose-700">Blocker</span>
</span>
<span class="mt-0.5 block text-xs leading-5 text-slate-500">{{ item.note }}</span>
</span>
</RouterLink>
</div>
</div>
<div v-if="archiveReadinessIssues.length > 0" class="rounded-[22px] border border-rose-200 bg-rose-50 px-4 py-3 text-sm leading-6 text-rose-700" role="alert">
<strong class="block text-rose-800">Abschluss noch blockiert.</strong>
{{ archiveReadinessIssues.join(' ') }}
</div>
</aside>
</div>
</Card>
</template>
<script setup lang="ts">
import { AlertTriangle, CheckCircle2, Globe2, History, Trash2 } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import type { AdminSeasonForm, AdminSeasonReadinessItem } from './adminSeasonTypes'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
defineProps<{
form: AdminSeasonForm
seasonName: string
saving: boolean
completing: boolean
adminMessage: string
adminError: string
readinessItems: AdminSeasonReadinessItem[]
archiveReadinessIssues: string[]
canActivatePublic: boolean
loadingSeasonAudit: boolean
latestSeasonAuditSummary: string
latestSeasonAuditMeta: string
selectedSeasonId: number | null
canDeleteSelectedSeason: boolean
canCompleteSelectedSeason: boolean
selectedSeasonIsCurrent: boolean
openDeleteSeasonModal: () => void
activatePublicSeason: () => Promise<boolean | void> | boolean | void
saveSeason: () => Promise<boolean | void> | boolean | void
completeSeason: () => Promise<void>
}>()
</script>
@@ -1,175 +0,0 @@
<template>
<Card class="overflow-hidden">
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-[#f7eef8] p-5">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">{{ props.form.year || '-' }} · Timeline</p>
<h2 class="mt-1 text-xl font-bold leading-tight text-slate-900">Phasen und Pausen bearbeiten</h2>
<p class="mt-2 max-w-2xl text-sm leading-5 text-slate-500">
Pflege die echten Zeitfenster für Landingpage, Public-API und Teilnahme-Gates. Die Aufbereitung ist die planbare Pause zwischen Voting und Show.
</p>
</div>
<span class="w-fit rounded-full border border-violet-200 bg-white px-4 py-2 text-sm font-bold text-violet-700">
{{ props.form.currentPhase || 'Keine Phase gesetzt' }}
</span>
</div>
</div>
<p v-if="timelineError" class="border-b border-rose-100 bg-rose-50 px-6 py-3 text-sm font-semibold text-rose-700" role="alert">{{ timelineError }}</p>
<div class="hidden grid-cols-[34px_minmax(0,1fr)_240px_128px_110px] gap-4 border-b border-violet-100 bg-violet-50/50 px-5 py-3 text-[11px] font-bold uppercase tracking-[0.16em] text-violet-500 lg:grid">
<span></span>
<span>Phase</span>
<span>Zeitraum</span>
<span>Status</span>
<span class="text-right">Bearbeiten</span>
</div>
<div class="max-h-[390px] overflow-y-auto divide-y divide-violet-50">
<div
v-for="row in timelineRows"
:key="row.key"
class="grid gap-4 px-5 py-3.5 lg:grid-cols-[34px_minmax(0,1fr)_240px_128px_110px] lg:items-center"
:class="row.isEditing ? 'bg-violet-50/50' : 'bg-white/70'"
>
<span class="mt-2 h-2.5 w-2.5 rounded-full lg:mx-auto lg:mt-0" :class="row.dotClass"></span>
<div class="min-w-0">
<p class="font-semibold text-slate-900">{{ row.title }}</p>
<p class="mt-1 text-sm leading-5 text-slate-500">{{ row.description }}</p>
</div>
<div v-if="row.isEditing" class="grid gap-2">
<template v-if="row.key === 'show'">
<input v-model="draft.start" type="date" class="h-10 rounded-xl border border-violet-200 bg-white px-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
<input v-model="draft.showStartsAt" type="time" class="h-10 rounded-xl border border-violet-200 bg-white px-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</template>
<template v-else>
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-2">
<input v-model="draft.start" type="date" class="h-10 min-w-0 rounded-xl border border-violet-200 bg-white px-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
<input v-model="draft.end" type="date" class="h-10 min-w-0 rounded-xl border border-violet-200 bg-white px-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</div>
</template>
</div>
<p v-else class="text-sm font-semibold text-slate-600">{{ row.dateRange }}</p>
<span class="w-fit rounded-full border px-3 py-1 text-xs font-bold" :class="row.statusClass">
{{ row.statusLabel }}
</span>
<div class="flex flex-wrap justify-start gap-2 lg:justify-end">
<template v-if="row.isEditing">
<Button size="sm" :disabled="props.saving" @click="saveTimelineEdit(row)">OK</Button>
<button
type="button"
class="grid h-9 w-9 place-items-center rounded-xl border border-violet-100 bg-white text-slate-500 transition hover:bg-violet-50"
:disabled="props.saving"
title="Bearbeitung abbrechen"
@click="cancelTimelineEdit"
>
<X class="h-4 w-4" />
</button>
</template>
<template v-else>
<button
v-if="row.editable"
type="button"
class="grid h-9 w-9 place-items-center rounded-xl border border-violet-100 bg-white text-violet-700 transition hover:bg-violet-50 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="props.saving || !props.selectedSeasonId"
title="Zeitraum bearbeiten"
@click="startTimelineEdit(row)"
>
<Pencil class="h-4 w-4" />
</button>
<span v-else class="text-xs font-semibold text-slate-400">fest</span>
</template>
</div>
</div>
</div>
<div class="border-t border-violet-100 bg-violet-50/30 px-5 py-3">
<p class="text-xs leading-5 text-slate-500">
Hinweis: Nominierung, Voting, Aufbereitung und Show sind als Systemfenster verdrahtet, damit Public-API, Vorschau und Teilnahme-Gates sauber zusammenlaufen.
</p>
</div>
</Card>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { Pencil, X } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import {
createSeasonTimelineRows,
normalizePhaseKey,
resolveAutoPhase,
type PhaseKey,
type PhaseRowConfig,
type SeasonTimelineForm,
} from './adminSeasonTimeline'
const props = defineProps<{
form: SeasonTimelineForm
saving: boolean
selectedSeasonId: number | null
saveSeason: () => Promise<boolean | void> | boolean | void
}>()
const editingPhase = ref<PhaseKey | null>(null)
const timelineError = ref('')
const draft = reactive({
start: '',
end: '',
showStartsAt: '20:00',
})
const currentPhaseKey = computed(() => normalizePhaseKey(props.form.currentPhase))
const autoPhase = computed(() => resolveAutoPhase(props.form))
const timelineRows = computed(() =>
createSeasonTimelineRows(props.form, currentPhaseKey.value, autoPhase.value, editingPhase.value),
)
function startTimelineEdit(row: PhaseRowConfig) {
if (!row.editable) return
timelineError.value = ''
editingPhase.value = row.key
draft.start = row.start ? props.form[row.start] : ''
draft.end = row.end ? props.form[row.end] : ''
draft.showStartsAt = props.form.showStartsAt || '20:00'
}
function cancelTimelineEdit() {
editingPhase.value = null
timelineError.value = ''
}
async function saveTimelineEdit(row: PhaseRowConfig) {
timelineError.value = ''
if (!row.start || !row.end) {
return
}
if (!draft.start || !draft.end) {
timelineError.value = 'Bitte fülle Start und Ende der Phase aus.'
return
}
if (row.key !== 'show' && draft.start > draft.end) {
timelineError.value = 'Das Startdatum darf nicht nach dem Enddatum liegen.'
return
}
props.form[row.start] = draft.start
props.form[row.end] = row.key === 'show' ? draft.start : draft.end
if (row.key === 'show') {
props.form.showStartsAt = draft.showStartsAt || '20:00'
}
const saved = await props.saveSeason()
if (saved !== false) {
editingPhase.value = null
}
}
</script>
@@ -5,6 +5,7 @@ import { Check, ChevronDown, ChevronUp, Eye, Plus, Save, Star, Trash2 } from '@l
import type { ShowactFormField, ShowactFieldType } from '../../types/awards/showactForm'
import Button from '../ui/Button.vue'
import Modal from '../ui/Modal.vue'
import NativeSelect from '../ui/NativeSelect.vue'
import type { AdminContentForm } from './adminContentTypes'
const props = defineProps<{
@@ -23,10 +24,10 @@ const fieldTypeOptions: { value: ShowactFieldType; label: string }[] = [
{ value: 'email', label: 'E-Mail' },
{ value: 'url', label: 'URL' },
{ value: 'textarea', label: 'Textbereich' },
{ value: 'select', label: 'Auswahl (Select)' },
{ value: 'radio', label: 'Einzelauswahl' },
{ value: 'multiselect', label: 'Mehrfachauswahl' },
{ value: 'checkbox', label: 'Checkbox' },
{ value: 'select', label: 'Einzelauswahl (Dropdown)' },
{ value: 'radio', label: 'Einzelauswahl (Liste)' },
{ value: 'multiselect', label: 'Mehrfachauswahl (Liste)' },
{ value: 'checkbox', label: 'Einzelne Checkboxen' },
]
function fieldTypeBadge(type: ShowactFieldType) {
@@ -35,10 +36,10 @@ function fieldTypeBadge(type: ShowactFieldType) {
email: 'E-Mail',
url: 'URL',
textarea: 'Textarea',
select: 'Select',
radio: 'Radio',
multiselect: 'Multi',
checkbox: 'Checkbox',
select: 'Dropdown',
radio: '1 aus Liste',
multiselect: 'Mehrfach',
checkbox: 'Checkboxen',
}[type] ?? type
}
@@ -114,7 +115,7 @@ function updateFieldType(field: ShowactFormField, type: ShowactFieldType) {
}
if (type === 'checkbox' && field.options.length === 0) {
field.options = ['Ja']
field.options = ['Option 1', 'Option 2']
} else if (type === 'radio' && field.options.length === 0) {
field.options = ['Ja', 'Nein']
} else if ((type === 'select' || type === 'multiselect') && field.options.length === 0) {
@@ -125,22 +126,13 @@ function updateFieldType(field: ShowactFormField, type: ShowactFieldType) {
field.maxLength = 1500
} else if ((type === 'email' || type === 'url') && (!field.maxLength || field.maxLength < 200)) {
field.maxLength = 500
} else if (type === 'checkbox' && (!field.maxLength || field.maxLength > 100)) {
field.maxLength = 20
} else if ((type === 'select' || type === 'radio') && (!field.maxLength || field.maxLength > 200)) {
field.maxLength = 120
} else if ((type === 'multiselect' || type === 'checkbox') && (!field.maxLength || field.maxLength < 120)) {
field.maxLength = 500
}
}
function getOptionsText(field: ShowactFormField): string {
return field.options.join('\n')
}
function setOptionsText(field: ShowactFormField, text: string) {
field.options = text
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
}
async function save() {
saveMessage.value = ''
saveError.value = ''
@@ -159,6 +151,35 @@ function previewOptionLabel(field: ShowactFormField) {
return field.placeholder || 'Antwort'
}
function isChoiceField(field: ShowactFormField) {
return field.type === 'select' || field.type === 'radio' || field.type === 'multiselect' || field.type === 'checkbox'
}
function isTextLikeField(field: ShowactFormField) {
return field.type === 'text' || field.type === 'email' || field.type === 'url' || field.type === 'textarea'
}
function usesPlaceholder(field: ShowactFormField) {
return isTextLikeField(field) || field.type === 'select'
}
function addOption(field: ShowactFormField) {
const nextIndex = field.options.length + 1
field.options.push(`Option ${nextIndex}`)
}
function removeOption(field: ShowactFormField, optionIndex: number) {
field.options.splice(optionIndex, 1)
}
function choiceFieldHint(field: ShowactFormField) {
if (field.type === 'select') return 'Wird auf der Landingpage als Dropdown mit genau einer Auswahl gezeigt.'
if (field.type === 'radio') return 'Wird als sichtbare Liste gezeigt, aus der genau ein Eintrag gewaehlt werden kann.'
if (field.type === 'multiselect') return 'Wird als Liste mit mehreren ankreuzbaren Optionen fuer ein gemeinsames Feld gerendert.'
if (field.type === 'checkbox') return 'Jede Option wird als eigene benennbare Checkbox auf der Landingpage angezeigt.'
return ''
}
</script>
<template>
@@ -256,15 +277,11 @@ function previewOptionLabel(field: ShowactFormField) {
<!-- Field type -->
<label class="flex flex-col gap-1.5">
<span class="text-xs font-bold text-slate-700">Feldtyp</span>
<select
:value="field.type"
class="min-h-12 w-full rounded-2xl border-2 border-violet-100 bg-white px-4 py-3 text-sm font-bold text-slate-800 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
@change="updateFieldType(field, (($event.target as HTMLSelectElement).value || 'text') as ShowactFieldType)"
>
<option v-for="option in fieldTypeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<NativeSelect
:model-value="field.type"
:options="fieldTypeOptions"
@update:model-value="updateFieldType(field, (($event ?? 'text') as ShowactFieldType))"
/>
</label>
<!-- Field ID -->
@@ -292,12 +309,12 @@ function previewOptionLabel(field: ShowactFormField) {
</label>
<!-- Placeholder -->
<label class="flex flex-col gap-1.5">
<label v-if="usesPlaceholder(field)" class="flex flex-col gap-1.5">
<span class="text-xs font-bold text-slate-700">Platzhaltertext</span>
<input
v-model="field.placeholder"
maxlength="500"
placeholder="Hinweistext im leeren Feld"
:placeholder="field.type === 'select' ? 'z.B. Bitte auswählen' : 'Hinweistext im leeren Feld'"
class="rounded-xl border border-violet-100 bg-white px-3 py-2 text-sm text-slate-700 outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
/>
</label>
@@ -316,7 +333,7 @@ function previewOptionLabel(field: ShowactFormField) {
</label>
<!-- MaxLength -->
<label class="flex flex-col gap-1.5">
<label v-if="isTextLikeField(field)" class="flex flex-col gap-1.5">
<span class="text-xs font-bold text-slate-700">Max. Zeichenanzahl</span>
<input
v-model.number="field.maxLength"
@@ -328,16 +345,62 @@ function previewOptionLabel(field: ShowactFormField) {
</label>
<!-- Options for choice fields -->
<label v-if="field.type === 'select' || field.type === 'radio' || field.type === 'multiselect' || field.type === 'checkbox'" class="flex flex-col gap-1.5">
<span class="text-xs font-bold text-slate-700">Optionen (eine pro Zeile)</span>
<textarea
:value="getOptionsText(field)"
rows="4"
placeholder="Option 1&#10;Option 2&#10;Option 3"
class="rounded-xl border border-violet-100 bg-white px-3 py-2 text-sm text-slate-700 outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
@input="setOptionsText(field, ($event.target as HTMLTextAreaElement).value)"
/>
</label>
<div v-if="isChoiceField(field)" class="space-y-3 rounded-2xl border border-violet-100 bg-white/80 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<span class="text-xs font-bold text-slate-700">Optionen</span>
<p class="mt-1 text-xs font-semibold leading-5 text-slate-500">
{{ choiceFieldHint(field) }}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
class="gap-2 rounded-xl border border-violet-200 bg-violet-50 text-violet-700 shadow-none hover:bg-violet-100"
@click="addOption(field)"
>
<Plus class="h-4 w-4" />
Option
</Button>
</div>
<div v-if="field.options.length > 0" class="space-y-2">
<div
v-for="(_, optionIndex) in field.options"
:key="`${field.id}-${optionIndex}`"
class="grid gap-2 rounded-2xl border border-violet-100 bg-violet-50/55 p-3 sm:grid-cols-[auto_minmax(0,1fr)_auto] sm:items-center"
>
<span
class="flex h-9 w-9 items-center justify-center rounded-xl border border-violet-200 bg-white text-violet-600"
:class="field.type === 'radio' ? 'rounded-full' : ''"
>
<span
class="block border-2 border-violet-300 bg-white"
:class="field.type === 'radio' ? 'h-3.5 w-3.5 rounded-full' : 'h-3.5 w-3.5 rounded-[4px]'"
/>
</span>
<input
v-model="field.options[optionIndex]"
maxlength="300"
:placeholder="field.type === 'checkbox' ? 'Checkbox-Beschriftung' : 'Optionslabel'"
class="rounded-xl border border-violet-100 bg-white px-3 py-2 text-sm font-semibold text-slate-700 outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
/>
<button
type="button"
class="inline-flex h-9 items-center justify-center rounded-xl border border-rose-200 bg-rose-50 px-3 text-sm font-bold text-rose-600 transition hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-40"
:disabled="field.options.length <= 1"
@click="removeOption(field, optionIndex)"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</div>
<p v-else class="rounded-xl border border-dashed border-violet-200 bg-violet-50/60 px-3 py-3 text-sm font-semibold text-slate-500">
Noch keine Optionen angelegt.
</p>
</div>
<!-- Toggles row -->
<div class="flex flex-wrap gap-3">
@@ -478,14 +541,26 @@ function previewOptionLabel(field: ShowactFormField) {
Andere:
<span class="min-w-28 flex-1 border-b border-violet-200 text-slate-400">Eigene Antwort</span>
</label>
<p v-if="field.options.length === 0" class="rounded-xl border border-dashed border-violet-200 bg-violet-50/60 px-3 py-3 text-sm font-semibold text-slate-500">
Keine Optionen konfiguriert.
</p>
</div>
<div v-else-if="field.type === 'checkbox'" class="mt-3 space-y-2">
<label
v-for="option in field.options"
:key="option"
class="flex items-center gap-3 rounded-xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold text-slate-600"
>
<span class="h-4 w-4 rounded border border-violet-300 bg-white" />
{{ option }}
</label>
<p
v-if="field.options.length === 0"
class="rounded-xl border border-dashed border-violet-200 bg-violet-50/60 px-3 py-3 text-sm font-semibold text-slate-500"
>
Keine Checkboxen konfiguriert.
</p>
</div>
<label
v-else-if="field.type === 'checkbox'"
class="mt-3 flex items-center gap-3 rounded-xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold text-slate-600"
>
<span class="h-4 w-4 rounded border border-violet-300 bg-white" />
{{ field.options[0] || 'Ja' }}
</label>
</div>
</div>
</Modal>
@@ -0,0 +1,225 @@
<script setup lang="ts">
import { Check, ChevronRight, LockKeyhole, Minus, Save } from '@lucide/vue'
import { computed, ref, watch } from 'vue'
import type { AdminTeamPermission, AdminTeamRole } from '../../types/awards'
import Button from '../ui/Button.vue'
import Modal from '../ui/Modal.vue'
interface PermissionGroup {
label: string
items: AdminTeamPermission[]
}
const props = defineProps<{
open: boolean
saving: boolean
hasRoleChanges: boolean
roles: AdminTeamRole[]
permissions: AdminTeamPermission[]
permissionGroups: PermissionGroup[]
roleHasPermission: (roleKey: string, permissionKey: string) => boolean
setRolePermission: (roleKey: string, permissionKey: string, checked: boolean) => void
saveRolePermissions: () => void | Promise<void>
}>()
const emit = defineEmits<{
close: []
}>()
const selectedRoleKey = ref<string | null>(null)
const selectedRole = computed(() =>
props.roles.find((role) => role.key === selectedRoleKey.value) ?? props.roles[0] ?? null,
)
const selectedPermissionGroups = computed(() => {
if (!selectedRole.value) return []
return props.permissionGroups.map((group) => ({
...group,
grantedCount: group.items.filter((permission) => props.roleHasPermission(selectedRole.value!.key, permission.key)).length,
}))
})
watch(
() => [props.open, props.roles.map((role) => role.key).join('|')] as const,
([open]) => {
if (!open) return
if (!selectedRoleKey.value || !props.roles.some((role) => role.key === selectedRoleKey.value)) {
selectedRoleKey.value = props.roles[0]?.key ?? null
}
},
{ immediate: true },
)
function rolePermissionLocked(roleKey: string) {
return roleKey === 'owner' || roleKey === 'creator'
}
function rolePermissionCount(roleKey: string) {
return props.permissions.filter((permission) => props.roleHasPermission(roleKey, permission.key)).length
}
function permissionModeLabel(permission: AdminTeamPermission) {
return permission.readOnlySupported ? 'Lesen oder Schreiben' : 'Schreiben'
}
async function save() {
await props.saveRolePermissions()
}
</script>
<template>
<Modal
:open="open"
title="Rollenrechte"
subtitle="Rolle im Navigator wählen und Berechtigungen im Detail pflegen."
size="xl"
@close="emit('close')"
>
<div class="grid gap-5 xl:grid-cols-[280px_minmax(0,1fr)]">
<aside class="space-y-3">
<div class="rounded-[24px] border border-violet-100 bg-violet-50/55 p-4">
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Navigator</p>
<p class="mt-2 text-sm leading-6 text-slate-600">
Jede Rolle zeigt ihre Rechte einzeln. Das ist kompakter als die alte Matrix und passt zum aktuellen Admin-Workspace.
</p>
</div>
<button
v-for="role in roles"
:key="role.key"
type="button"
class="w-full rounded-[24px] border p-4 text-left transition"
:class="selectedRole?.key === role.key
? 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-[#fff4e6] shadow-[0_18px_40px_rgba(139,108,219,0.14)]'
: 'border-violet-100 bg-white/90 hover:border-violet-200 hover:bg-violet-50/50'"
@click="selectedRoleKey = role.key"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-[11px] font-bold uppercase tracking-[0.16em] text-violet-500">{{ role.label }}</p>
<p class="mt-2 text-sm leading-6 text-slate-600">{{ role.description }}</p>
</div>
<span
class="grid h-9 w-9 shrink-0 place-items-center rounded-2xl border transition"
:class="selectedRole?.key === role.key ? 'border-violet-200 bg-white text-violet-700' : 'border-violet-100 bg-violet-50 text-violet-500'"
>
<LockKeyhole v-if="rolePermissionLocked(role.key)" class="h-4 w-4" />
<ChevronRight v-else class="h-4 w-4" />
</span>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="rounded-full border border-violet-100 bg-white px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.12em] text-violet-700">
{{ rolePermissionCount(role.key) }}/{{ permissions.length }} Rechte
</span>
<span
v-if="rolePermissionLocked(role.key)"
class="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.12em] text-slate-500"
>
fixiert
</span>
</div>
</button>
</aside>
<section v-if="selectedRole" class="space-y-4">
<div class="rounded-[28px] border border-violet-100 bg-white/95 p-5 shadow-[0_18px_40px_rgba(124,92,255,0.08)]">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Rolle</p>
<h3 class="mt-1 text-2xl font-bold text-slate-950">{{ selectedRole.label }}</h3>
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-600">{{ selectedRole.description }}</p>
</div>
<div class="flex flex-wrap gap-2">
<span class="rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-xs font-bold text-violet-700">
{{ rolePermissionCount(selectedRole.key) }}/{{ permissions.length }} Rechte aktiv
</span>
<span
v-if="rolePermissionLocked(selectedRole.key)"
class="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-bold text-slate-500"
>
Systemrolle
</span>
</div>
</div>
<div
v-if="rolePermissionLocked(selectedRole.key)"
class="mt-4 flex items-start gap-3 rounded-[22px] border border-slate-200 bg-slate-50/80 px-4 py-3 text-sm text-slate-600"
>
<LockKeyhole class="mt-0.5 h-4 w-4 shrink-0 text-slate-400" />
<p>Owner und Creator bleiben auf Vollzugriff fixiert und können hier nur eingesehen werden.</p>
</div>
</div>
<div
v-for="group in selectedPermissionGroups"
:key="group.label"
class="rounded-[28px] border border-violet-100 bg-white/92 p-5 shadow-[0_14px_34px_rgba(124,92,255,0.06)]"
>
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-violet-100 pb-4">
<div>
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Bereich</p>
<h4 class="mt-1 text-lg font-bold text-slate-950">{{ group.label }}</h4>
</div>
<span class="rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-xs font-bold text-violet-700">
{{ group.grantedCount }}/{{ group.items.length }} aktiv
</span>
</div>
<div class="mt-4 grid gap-3">
<div
v-for="permission in group.items"
:key="permission.key"
class="grid gap-3 rounded-[24px] border border-violet-100 bg-violet-50/35 p-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-center"
>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<p class="text-base font-bold text-slate-950">{{ permission.label }}</p>
<span class="rounded-full border border-violet-100 bg-white px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.12em] text-violet-700">
{{ permissionModeLabel(permission) }}
</span>
<span class="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[10px] font-bold text-slate-500">
{{ permission.menuPath }}
</span>
</div>
<p class="mt-2 text-sm leading-6 text-slate-600">{{ permission.description }}</p>
</div>
<button
type="button"
class="inline-flex items-center justify-center gap-2 rounded-2xl border px-4 py-3 text-sm font-bold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
:class="[
roleHasPermission(selectedRole.key, permission.key)
? rolePermissionLocked(selectedRole.key)
? 'border-slate-200 bg-slate-100 text-slate-500'
: 'border-violet-500 bg-violet-600 text-white shadow-lg shadow-violet-500/20 hover:bg-violet-500'
: rolePermissionLocked(selectedRole.key)
? 'border-slate-200 bg-slate-50 text-slate-300'
: 'border-violet-200 bg-white text-violet-700 hover:bg-violet-50',
]"
:disabled="rolePermissionLocked(selectedRole.key)"
:aria-label="`${selectedRole.label}: ${permission.label}`"
:aria-pressed="roleHasPermission(selectedRole.key, permission.key)"
@click="setRolePermission(selectedRole.key, permission.key, !roleHasPermission(selectedRole.key, permission.key))"
>
<Check v-if="roleHasPermission(selectedRole.key, permission.key)" class="h-4 w-4" :stroke-width="3" />
<Minus v-else class="h-4 w-4" :stroke-width="3" />
{{ roleHasPermission(selectedRole.key, permission.key) ? 'Aktiv' : 'Aus' }}
</button>
</div>
</div>
</div>
</section>
</div>
<template #footer>
<Button type="button" variant="ghost" @click="emit('close')">Schließen</Button>
<Button type="button" :disabled="saving || !hasRoleChanges" @click="save">
<Save class="mr-2 h-4 w-4" />
{{ saving ? 'Speichert...' : 'Speichern' }}
</Button>
</template>
</Modal>
</template>
@@ -0,0 +1,104 @@
<template>
<Modal
:open="category !== null"
:title="category ? `${category.categoryName} Leaderboard` : 'Leaderboard'"
:subtitle="category ? `${category.groupName} · ${viewerRangeLabel(category.viewerRangeMin, category.viewerRangeMax)}` : undefined"
size="xl"
@close="$emit('close')"
>
<template v-if="category">
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div class="rounded-2xl border border-violet-100 bg-violet-50/40 px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Votes</p>
<strong class="mt-1 block text-xl text-slate-950">{{ category.voteCount.toLocaleString('de-DE') }}</strong>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/40 px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Kandidaten</p>
<strong class="mt-1 block text-xl text-slate-950">{{ category.candidateCount }}</strong>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/40 px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Reviews</p>
<strong class="mt-1 block text-xl text-slate-950">{{ category.openReviewCount }}</strong>
</div>
</div>
<div class="mt-5 overflow-hidden rounded-2xl border border-violet-100">
<div class="hidden grid-cols-[72px_minmax(220px,1.5fr)_120px_120px_180px] gap-3 bg-violet-50/70 px-4 py-3 text-[10px] font-bold uppercase tracking-[0.14em] text-slate-500 lg:grid">
<span>Rang</span>
<span>Kandidat</span>
<span>Votes</span>
<span>Anteil</span>
<span>Status</span>
</div>
<div v-if="category.leaderboard.length > 0" class="divide-y divide-violet-100 bg-white">
<article
v-for="(candidate, index) in category.leaderboard"
:key="candidate.candidateId"
class="grid gap-3 px-4 py-4 lg:grid-cols-[72px_minmax(220px,1.5fr)_120px_120px_180px] lg:items-center"
>
<div class="flex items-center gap-3 lg:block">
<span class="grid h-9 w-9 place-items-center rounded-xl bg-violet-100 text-sm font-black text-violet-800">{{ index + 1 }}</span>
<span v-if="candidate.isTopTie" class="rounded-full bg-amber-50 px-2 py-1 text-[11px] font-semibold text-amber-700 lg:hidden">Tie</span>
</div>
<div class="min-w-0">
<p class="truncate text-base font-bold text-slate-900">{{ candidate.displayName }}</p>
<p class="mt-0.5 truncate text-sm text-slate-500">{{ candidate.channelSlug }} · {{ candidate.platform }}</p>
</div>
<p class="text-sm font-bold text-slate-900">
<span class="lg:hidden text-slate-500">Votes: </span>{{ candidate.votes.toLocaleString('de-DE') }}
</p>
<p class="text-sm font-bold text-violet-800">
<span class="lg:hidden text-slate-500">Anteil: </span>{{ candidate.voteSharePercent }}%
</p>
<div class="flex flex-wrap gap-2">
<span v-if="candidate.isCurrentWinner" class="rounded-full bg-emerald-50 px-3 py-1 text-[11px] font-semibold text-emerald-700">
Gewinner
</span>
<span v-if="candidate.isTopTie" class="hidden rounded-full bg-amber-50 px-3 py-1 text-[11px] font-semibold text-amber-700 lg:inline-flex">
Tie
</span>
<span class="rounded-full px-3 py-1 text-[11px] font-semibold" :class="candidate.hasClip ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
{{ candidate.hasClip ? 'Clip da' : 'Clip fehlt' }}
</span>
<span v-if="candidate.hasWinnerConflict" class="rounded-full bg-amber-50 px-3 py-1 text-[11px] font-semibold text-amber-700">
Regelkonflikt
</span>
<span v-if="!candidate.isAccepted" class="rounded-full bg-slate-100 px-3 py-1 text-[11px] font-semibold text-slate-600">
nicht final
</span>
</div>
</article>
</div>
<div v-else class="bg-white px-4 py-12 text-center text-sm text-slate-500">
Noch keine Votes oder keine Kandidaten in dieser Unterkategorie.
</div>
</div>
</template>
</Modal>
</template>
<script setup lang="ts">
import type { AdminVotingWorkspaceRow } from './useAdminVotingManager'
import Modal from '../ui/Modal.vue'
defineProps<{
category: AdminVotingWorkspaceRow | null
}>()
defineEmits<{
close: []
}>()
function viewerRangeLabel(min: number | null, max: number | null) {
if (min === null && max === null) return 'Offene Einordnung'
if (min !== null && max === null) return `Ab ${min} Viewer`
if (min === null && max !== null) return `Bis ${max} Viewer`
return `${min} bis ${max} Viewer`
}
</script>
@@ -0,0 +1,198 @@
<template>
<div class="space-y-3">
<article
v-for="group in groups"
:key="group.name"
class="overflow-hidden rounded-2xl border bg-white/90 shadow-[0_14px_36px_rgba(124,91,180,0.08)] transition"
:class="selectedGroupName === group.name ? 'border-violet-200 ring-2 ring-violet-100' : 'border-violet-100'"
>
<button
type="button"
class="grid w-full gap-3 border-b border-violet-100 bg-violet-50/45 px-4 py-4 text-left lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center"
@click="toggleGroup(group.name); $emit('select-group', group.name)"
>
<div class="min-w-0">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-white text-violet-700 shadow-sm">
<FolderTree class="h-4 w-4" />
</span>
<h2 class="truncate text-lg font-bold text-slate-900">{{ group.name }}</h2>
<span class="rounded-full bg-white px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-violet-700">
{{ group.totalCategories }} Unterkategorien
</span>
</div>
<p class="mt-2 text-sm leading-6 text-slate-500">
{{ group.totalVotes.toLocaleString('de-DE') }} Votes · {{ group.votedCategories }} mit Votes · {{ group.readyCount }} bereit
</p>
</div>
<div class="flex flex-wrap items-center gap-2 lg:justify-end">
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">
{{ group.winnerSetCount }} Gewinner
</span>
<span v-if="group.problemCount > 0" class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">
{{ group.problemCount }} pruefen
</span>
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-xl border border-violet-100 bg-white text-violet-700">
<ChevronDown class="h-4 w-4 transition" :class="isExpanded(group.name) ? 'rotate-180' : ''" />
</span>
</div>
</button>
<div v-if="isExpanded(group.name)" class="divide-y divide-violet-100 bg-white">
<article
v-for="row in group.categories"
:key="row.categoryId"
class="grid gap-4 px-4 py-4 transition hover:bg-violet-50/35 xl:grid-cols-[minmax(280px,1.1fr)_minmax(0,1fr)_auto] xl:items-center"
:class="focusedCategoryId === row.categoryId ? 'bg-violet-50/55' : ''"
@click="$emit('select-category', row.categoryId)"
>
<div class="min-w-0">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-violet-100/80 text-violet-700">
<GitBranch class="h-5 w-5" />
</span>
<div class="min-w-0">
<p class="truncate text-lg font-bold text-slate-900">{{ row.categoryName }}</p>
<div class="mt-1 flex flex-wrap items-center gap-2">
<span class="inline-flex rounded-full border border-violet-200 bg-violet-50 px-2.5 py-1 text-[11px] font-semibold text-violet-700">
{{ viewerRangeLabel(row.viewerRangeMin, row.viewerRangeMax) }}
</span>
<span class="inline-flex rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[11px] font-semibold text-slate-500">
{{ row.candidateCount > 0 ? `${row.candidateCount} Kandidaten im Rennen` : 'Noch kein Kandidatenfeld' }}
</span>
</div>
</div>
</div>
</div>
<div class="grid gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full border px-3 py-1 text-[11px] font-bold uppercase tracking-[0.14em]" :class="row.statusTone">
{{ row.statusLabel }}
</span>
<span v-if="row.showTopVoteTieWarning" class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">
Tie
</span>
<span v-if="row.showMissingClipWarning" class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">
Clip fehlt
</span>
<span v-if="row.showOpenReviewWarning" class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">
{{ row.openReviewCount }} Reviews
</span>
<span v-if="row.showRuleConflictWarning" class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">
Regelkonflikt
</span>
</div>
<p class="mt-2 text-sm leading-6 text-slate-500">
{{ summaryText(row) }}
</p>
</div>
<div class="grid gap-2 text-center sm:grid-cols-2">
<div class="rounded-2xl border border-violet-100 bg-violet-50/40 px-3 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-500">Votes</p>
<strong class="mt-1 block text-base text-slate-950">{{ row.voteCount.toLocaleString('de-DE') }}</strong>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/40 px-3 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-500">Kandidaten</p>
<strong class="mt-1 block text-base text-slate-950">{{ row.candidateCount }}</strong>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 xl:justify-end" @click.stop>
<button
type="button"
class="inline-flex items-center gap-2 rounded-full border border-violet-200 bg-white px-3 py-2 text-sm font-semibold text-violet-700 transition hover:bg-violet-50"
@click="$emit('open-leaderboard', row)"
>
<ListOrdered class="h-4 w-4" />
Leaderboard
</button>
<RouterLink
class="inline-flex items-center gap-2 rounded-full border border-violet-200 bg-white px-3 py-2 text-sm font-semibold text-violet-700 transition hover:bg-violet-50"
:to="`/admin/winners?categoryId=${row.categoryId}`"
>
Gewinner
<ArrowRight class="h-4 w-4" />
</RouterLink>
</div>
</article>
</div>
</article>
<div v-if="groups.length === 0" class="rounded-2xl border border-dashed border-violet-200 bg-violet-50/50 px-4 py-12 text-center text-sm text-slate-500">
Keine Hauptkategorien passen zu diesem Filter.
</div>
</div>
</template>
<script setup lang="ts">
import { ArrowRight, ChevronDown, FolderTree, GitBranch, ListOrdered } from '@lucide/vue'
import { ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import type { AdminVotingGroupSummary, AdminVotingWorkspaceRow } from './useAdminVotingManager'
const props = defineProps<{
groups: AdminVotingGroupSummary[]
selectedGroupName: string | null
focusedCategoryId: number | null
}>()
defineEmits<{
'select-group': [groupName: string]
'select-category': [categoryId: number]
'open-leaderboard': [row: AdminVotingWorkspaceRow]
}>()
const expandedGroup = ref<string | null>(null)
watch(
() => props.groups.map((group) => group.name),
(groupNames) => {
if (!expandedGroup.value || !groupNames.includes(expandedGroup.value)) {
expandedGroup.value = groupNames[0] ?? null
}
},
{ immediate: true },
)
watch(
() => props.selectedGroupName,
(groupName) => {
if (groupName) {
expandedGroup.value = groupName
}
},
)
function isExpanded(groupName: string) {
return expandedGroup.value === groupName
}
function toggleGroup(groupName: string) {
expandedGroup.value = groupName
}
function viewerRangeLabel(min: number | null, max: number | null) {
if (min === null && max === null) return 'Offene Einordnung'
if (min !== null && max === null) return `Ab ${min} Viewer`
if (min === null && max !== null) return `Bis ${max} Viewer`
return `${min} bis ${max} Viewer`
}
function summaryText(row: AdminVotingWorkspaceRow) {
if (row.topCandidate && row.voteCount > 0) {
return `${row.topCandidate.displayName} liegt aktuell vorne mit ${row.topCandidate.votes.toLocaleString('de-DE')} Votes.`
}
if (row.candidateCount > 0) {
return 'Die Unterkategorie ist vorbereitet, aber bisher wurden noch keine Votes abgegeben.'
}
return 'Diese Unterkategorie hat aktuell noch kein aufgebautes Kandidatenfeld.'
}
</script>
@@ -0,0 +1,293 @@
<template>
<div class="space-y-3">
<article
v-for="group in groups"
:key="group.name"
class="overflow-hidden rounded-2xl border bg-white/90 shadow-[0_14px_36px_rgba(124,91,180,0.08)] transition"
:class="selectedGroupName === group.name ? 'border-violet-200 ring-2 ring-violet-100' : 'border-violet-100'"
>
<button
type="button"
class="grid w-full gap-3 border-b border-violet-100 bg-violet-50/45 px-4 py-4 text-left lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center"
@click="toggleGroup(group.name); $emit('select-group', group.name)"
>
<div class="min-w-0">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-white text-violet-700 shadow-sm">
<FolderTree class="h-4 w-4" />
</span>
<h2 class="truncate text-lg font-bold text-slate-900">{{ group.name }}</h2>
<span class="rounded-full bg-white px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-violet-700">
{{ group.totalCategories }} Unterkategorien
</span>
</div>
<p class="mt-2 text-sm leading-6 text-slate-500">
{{ group.completedCount }} gesetzt · {{ group.missingCount }} offen · {{ group.approvedClips }} Clips freigegeben
</p>
</div>
<div class="flex flex-wrap items-center gap-2 lg:justify-end">
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">
{{ group.completedCount }} gesetzt
</span>
<span class="rounded-full bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-700">
{{ group.missingCount }} offen
</span>
<span v-if="group.pendingReviewCount > 0" class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">
{{ group.pendingReviewCount }} Reviews
</span>
<span v-if="group.emptyCount > 0" class="rounded-full bg-rose-50 px-3 py-1 text-xs font-semibold text-rose-700">
{{ group.emptyCount }} leer
</span>
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-xl border border-violet-100 bg-white text-violet-700">
<ChevronDown class="h-4 w-4 transition" :class="isExpanded(group.name) ? 'rotate-180' : ''" />
</span>
</div>
</button>
<div v-if="isExpanded(group.name)" class="divide-y divide-violet-100 bg-violet-50/20">
<article
v-for="row in group.categories"
:key="row.category.id"
:id="`winner-category-${row.category.id}`"
:data-category-id="row.category.id"
class="bg-[linear-gradient(180deg,rgba(244,239,255,0.72),rgba(255,255,255,0.98))] px-4 py-4 transition"
:class="focusedCategoryId === row.category.id ? 'bg-violet-50/80' : ''"
@click="$emit('select-category', row.category.id)"
>
<div class="grid gap-3 xl:grid-cols-[minmax(260px,1fr)_auto] xl:items-center">
<div class="min-w-0 flex-1">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-white text-violet-700 shadow-sm">
<GitBranch class="h-4 w-4" />
</span>
<p class="truncate text-lg font-bold text-slate-900">{{ row.category.name }}</p>
<span
class="rounded-full px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.12em]"
:class="statusClass(row)"
>
{{ statusLabel(row) }}
</span>
</div>
<p class="mt-2 text-sm leading-6 text-slate-500">
{{ row.existing?.candidateDisplayName || 'Noch kein Gewinner gesetzt' }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2 xl:justify-end">
<span class="rounded-full bg-violet-100/70 px-3 py-1 text-xs font-semibold text-violet-700">
{{ row.candidates.length }} Kandidaten
</span>
<span class="rounded-full bg-sky-100/70 px-3 py-1 text-xs font-semibold text-sky-700">
{{ row.approvedClips }} Clips
</span>
<span v-if="row.hasPendingReviews" class="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700">
{{ row.openReviews }} Reviews
</span>
</div>
</div>
<div class="mt-3 grid gap-3 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-start">
<div class="space-y-3" @click.stop>
<NativeSelect
:model-value="winnerSelections[row.category.id]"
:disabled="row.isEmpty"
:options="buildWinnerOptions(row)"
@update:model-value="$emit('update:winner-selection', row.category.id, String($event ?? ''))"
/>
<p
v-if="winnerRuleNoticeFor(row.category.id)"
class="flex flex-col gap-3 rounded-2xl border px-3 py-2 text-sm font-semibold sm:flex-row sm:items-center sm:justify-between"
:class="winnerRuleNoticeFor(row.category.id)?.mode === 'block'
? 'border-rose-100 bg-rose-50 text-rose-700'
: 'border-amber-100 bg-amber-50 text-amber-700'"
>
<span>
{{ winnerRuleNoticeFor(row.category.id)?.mode === 'block' ? 'Blockiert' : 'Warnung' }}:
{{ winnerRuleNoticeFor(row.category.id)?.message }}
</span>
<RouterLink
v-if="winnerSelections[row.category.id]"
class="inline-flex shrink-0 items-center justify-center rounded-full border border-current/20 bg-white/80 px-3 py-1.5 text-xs font-bold transition hover:bg-white"
:to="candidateDetailLink(row.category.id)"
>
Kandidat bearbeiten
</RouterLink>
</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-white/70 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-white/70 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-white/70 px-3 py-2">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">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-white/70 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>
<div class="flex flex-wrap gap-2 xl:justify-end" @click.stop>
<Button
:disabled="savingResultForCategory === row.category.id || row.isEmpty || !winnerSelections[row.category.id] || winnerRuleNoticeFor(row.category.id)?.mode === 'block'"
@click="$emit('save', row.category.id)"
>
{{ savingResultForCategory === row.category.id ? 'Speichert ...' : row.existing ? 'Aktualisieren' : 'Setzen' }}
</Button>
<Button
v-if="row.existing"
variant="secondary"
class="gap-1.5"
:disabled="deletingResultId === row.existing.id"
@click="$emit('clear', row.existing.id)"
>
<Trash2 class="h-4 w-4" />
{{ deletingResultId === row.existing.id ? 'Entfernt ...' : 'Entfernen' }}
</Button>
</div>
</div>
</article>
</div>
</article>
<div v-if="groups.length === 0" class="rounded-2xl border border-dashed border-violet-200 bg-violet-50/50 px-4 py-12 text-center text-sm text-slate-500">
Keine Hauptkategorien passen zu diesem Filter.
</div>
</div>
</template>
<script setup lang="ts">
import { ChevronDown, FolderTree, GitBranch, Trash2 } from '@lucide/vue'
import { nextTick, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import type {
AdminWinnerGroupSummary,
AdminWinnerResultRow,
WinnerIdentitySummary,
WinnerRuleNotice,
} from './useAdminWinnersManager'
import Button from '../ui/Button.vue'
import NativeSelect from '../ui/NativeSelect.vue'
const props = defineProps<{
groups: AdminWinnerGroupSummary[]
selectedGroupName: string | null
focusedCategoryId: number | null
winnerSelections: Record<number, string>
savingResultForCategory: number | null
deletingResultId: number | null
selectedWinnerIdentitySummary: (categoryId: number) => WinnerIdentitySummary | null
winnerRuleNoticeFor: (categoryId: number) => WinnerRuleNotice | null
}>()
defineEmits<{
'select-group': [groupName: string]
'select-category': [categoryId: number]
'update:winner-selection': [categoryId: number, value: string]
save: [categoryId: number]
clear: [resultId: number]
}>()
const expandedGroup = ref<string | null>(null)
watch(
() => props.groups.map((group) => group.name),
(groupNames) => {
if (!expandedGroup.value || !groupNames.includes(expandedGroup.value)) {
expandedGroup.value = groupNames[0] ?? null
}
},
{ immediate: true },
)
watch(
() => props.selectedGroupName,
(groupName) => {
if (groupName) {
expandedGroup.value = groupName
}
},
)
watch(
() => props.focusedCategoryId,
async (categoryId) => {
if (!categoryId) return
const matchingGroup = props.groups.find((group) =>
group.categories.some((row) => row.category.id === categoryId),
)
if (matchingGroup) {
expandedGroup.value = matchingGroup.name
}
await nextTick()
const target = document.querySelector<HTMLElement>(`[data-category-id="${categoryId}"]`)
target?.scrollIntoView({ behavior: 'smooth', block: 'center' })
},
{ immediate: true },
)
function isExpanded(groupName: string) {
return expandedGroup.value === groupName
}
function toggleGroup(groupName: string) {
expandedGroup.value = groupName
}
function buildWinnerOptions(row: AdminWinnerResultRow) {
const rankByCandidateId = new Map(row.topVotingRanks.map((rank) => [rank.candidateId, rank]))
const sortedCandidates = [...row.candidates].sort((left, right) => {
const leftRank = rankByCandidateId.get(left.id)?.rank ?? Number.POSITIVE_INFINITY
const rightRank = rankByCandidateId.get(right.id)?.rank ?? Number.POSITIVE_INFINITY
return leftRank - rightRank || left.displayName.localeCompare(right.displayName, 'de')
})
return [
{ label: 'Bitte Gewinner waehlen', value: '' },
...sortedCandidates.map((candidate) => {
const rank = rankByCandidateId.get(candidate.id)
return {
label: `${candidate.displayName} · ${candidate.channelSlug} · ${candidate.platform} · ${candidate.clipCompilationUrl ? 'Clip gepflegt' : 'kein Clip'}`,
value: `${candidate.id}`,
meta: rank ? `#${rank.rank} · ${rank.votes.toLocaleString('de-DE')} Votes` : undefined,
}
}),
]
}
function candidateDetailLink(categoryId: number) {
const candidateId = props.winnerSelections[categoryId]
return `/admin/candidates?candidateId=${candidateId}&categoryId=${categoryId}`
}
function statusLabel(row: AdminWinnerResultRow) {
if (row.isEmpty) return row.isEmptyWarning ? 'Leer' : 'Später'
if (row.hasPendingReviews) return row.isComplete ? 'Gesetzt + Review' : 'Review'
if (row.isComplete) return 'Gesetzt'
return 'Offen'
}
function statusClass(row: AdminWinnerResultRow) {
if (row.isEmptyWarning) return 'bg-rose-100 text-rose-700'
if (row.isEmpty) return 'bg-violet-100 text-violet-700'
if (row.hasPendingReviews) return 'bg-amber-100 text-amber-700'
if (row.isComplete) return 'bg-emerald-100 text-emerald-700'
return 'bg-sky-100 text-sky-700'
}
</script>
@@ -25,7 +25,12 @@ const emit = defineEmits<{
save: []
}>()
const winnerRequiresClipRuleKey = 'winner_requires_clip'
const advisoryOnlyRuleKey = 'recommended_nominators_per_subcategory'
const modeOptions = [
{ label: 'Blockieren', value: 'block' },
{ label: 'Warnen', value: 'warn' },
]
</script>
<template>
@@ -71,60 +76,68 @@ const advisoryOnlyRuleKey = 'recommended_nominators_per_subcategory'
<article
v-for="rule in rules"
:key="rule.key"
class="rounded-2xl border border-violet-100 bg-violet-50/35 p-4"
class="rounded-[26px] border border-violet-100 bg-violet-50/35 p-4 shadow-[0_16px_40px_rgba(139,108,219,0.08)]"
>
<div class="grid gap-4 xl:grid-cols-[minmax(220px,1fr)_120px_170px_170px] xl:items-end">
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(560px,660px)] xl:items-center">
<div class="min-w-0">
<h3 class="text-base font-semibold text-slate-900">{{ rule.label }}</h3>
<p class="mt-1 text-sm leading-6 text-slate-500">{{ rule.description }}</p>
</div>
<label v-if="rule.key !== 'winner_requires_clip'" class="space-y-1">
<span class="text-xs font-semibold text-slate-500">Limit</span>
<input
:value="rule.limit"
type="number"
min="1"
max="50"
:disabled="!canManage"
class="h-10 w-full rounded-xl border border-violet-100 bg-white px-3 text-sm outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400"
@input="emit('updateRule', rule.key, { limit: Number(($event.target as HTMLInputElement).value) })"
>
</label>
<div v-else class="rounded-xl border border-violet-100 bg-white px-3 py-2 text-xs font-semibold leading-5 text-slate-500">
Ja/Nein-Regel<br>
<span class="font-normal">Limit wird hier nicht verwendet.</span>
</div>
<label class="space-y-1">
<span class="text-xs font-semibold text-slate-500">Modus</span>
<div
v-if="rule.key === advisoryOnlyRuleKey"
class="rounded-xl border border-violet-100 bg-white px-3 py-2 text-xs font-semibold leading-5 text-slate-500"
>
Hinweis-Regel<br>
<span class="font-normal">Diese Regel bleibt immer unverbindlich und blockiert nichts.</span>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<div class="flex min-h-[122px] flex-col rounded-2xl border border-violet-100 bg-white/90 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)]">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Limit</span>
<template v-if="rule.key !== winnerRequiresClipRuleKey">
<input
:value="rule.limit"
type="number"
min="1"
max="50"
:disabled="!canManage"
class="mt-auto h-12 w-full rounded-2xl border border-violet-100 bg-white px-4 text-lg font-semibold text-slate-900 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400"
@input="emit('updateRule', rule.key, { limit: Number(($event.target as HTMLInputElement).value) })"
>
</template>
<div v-else class="mt-auto space-y-2 text-sm leading-6 text-slate-500">
<p class="font-semibold text-slate-700">Ja/Nein-Regel</p>
<p>Limit wird hier nicht verwendet.</p>
</div>
</div>
<NativeSelect
v-else
:model-value="rule.mode"
:disabled="!canManage"
:options="[
{ label: 'Blockieren', value: 'block' },
{ label: 'Warnen', value: 'warn' },
]"
@update:model-value="emit('updateRule', rule.key, { mode: String($event) })"
/>
</label>
<AdminSettingsToggle
:model-value="rule.enabled"
:label="`${rule.label} aktivieren`"
:disabled="!canManage"
active-label="Aktiv"
inactive-label="Inaktiv"
@update:model-value="emit('updateRule', rule.key, { enabled: $event })"
/>
<div class="flex min-h-[122px] flex-col rounded-2xl border border-violet-100 bg-white/90 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)]">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Modus</span>
<div
v-if="rule.key === advisoryOnlyRuleKey"
class="mt-auto space-y-2 text-sm leading-6 text-slate-500"
>
<p class="font-semibold text-slate-700">Hinweis-Regel</p>
<p>Diese Regel bleibt immer unverbindlich und blockiert nichts.</p>
</div>
<NativeSelect
v-else
class="mt-auto"
:model-value="rule.mode"
:disabled="!canManage"
:options="modeOptions"
@update:model-value="emit('updateRule', rule.key, { mode: String($event) })"
/>
</div>
<div class="flex min-h-[122px] flex-col rounded-2xl border border-violet-100 bg-white/90 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)]">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Status</span>
<div class="mt-auto">
<AdminSettingsToggle
class="w-full justify-between"
:model-value="rule.enabled"
:label="`${rule.label} aktivieren`"
:disabled="!canManage"
active-label="Aktiv"
inactive-label="Inaktiv"
@update:model-value="emit('updateRule', rule.key, { enabled: $event })"
/>
</div>
</div>
</div>
</div>
</article>
@@ -30,6 +30,22 @@ export type AdminContentForm = {
sponsorsContent: string
showactsUrl: string
showactsContent: string
streamBannerEyebrow: string
streamBannerTitle: string
streamBannerText: string
streamBannerLiveButtonLabel: string
streamBannerLiveButtonUrl: string
streamBannerLockedButtonLabel: string
streamBannerUseCompletedContent: boolean
streamBannerCompletedEyebrow: string
streamBannerCompletedTitle: string
streamBannerCompletedText: string
streamBannerCompletedButtonLabel: string
streamBannerCompletedButtonUrl: string
awardsSectionTitle: string
awardsSectionDescription: string
subcategoriesSectionTitle: string
subcategoriesSectionDescription: string
socialLinks: SocialLinkForm[]
faq: FaqFormItem[]
showactFormSchema: ShowactFormField[]
@@ -0,0 +1,52 @@
import type { AdminSeasonDetailResponse } from '../../types/awards'
export type AdminWorkflowPhaseKey = 'nomination' | 'voting' | 'preparation' | 'show' | 'completed'
export interface AdminReadinessScope {
phaseKey: AdminWorkflowPhaseKey
winnersPublished: boolean
candidateWarningsActive: boolean
reviewWarningsActive: boolean
voteWarningsActive: boolean
winnerWarningsActive: boolean
publicationWarningsActive: boolean
}
export function normalizeAdminWorkflowPhase(currentPhase: string): AdminWorkflowPhaseKey {
const value = currentPhase.trim().toLowerCase()
if (value.includes('abgeschlossen') || value.includes('archiv') || value.includes('complete') || value.includes('ended')) {
return 'completed'
}
if (value.includes('show')) return 'show'
if (value.includes('aufbereit') || value.includes('vorbereit') || value.includes('pause') || value.includes('review') || value.includes('auswert')) {
return 'preparation'
}
if (value.includes('vot')) return 'voting'
return 'nomination'
}
export function buildAdminReadinessScope(season: Pick<AdminSeasonDetailResponse, 'currentPhase' | 'winnersPublishedAt'>): AdminReadinessScope {
const phaseKey = normalizeAdminWorkflowPhase(season.currentPhase)
const winnersPublished = Boolean(season.winnersPublishedAt)
const afterNomination = phaseKey !== 'nomination'
const afterVotingStarted = phaseKey === 'preparation' || phaseKey === 'show' || phaseKey === 'completed'
const winnerWarningsActive = afterVotingStarted || winnersPublished
return {
phaseKey,
winnersPublished,
candidateWarningsActive: afterNomination,
reviewWarningsActive: afterNomination,
voteWarningsActive: afterVotingStarted,
winnerWarningsActive,
publicationWarningsActive: phaseKey === 'show' || phaseKey === 'completed' || winnersPublished,
}
}
export function phaseLabel(phaseKey: AdminWorkflowPhaseKey) {
if (phaseKey === 'nomination') return 'Nominierung'
if (phaseKey === 'voting') return 'Voting'
if (phaseKey === 'preparation') return 'Auswertung'
if (phaseKey === 'show') return 'Award-Show'
return 'Abgeschlossen'
}
@@ -1,4 +1,6 @@
export type PhaseKey = 'nomination' | 'voting' | 'preparation' | 'show' | 'completed'
import { normalizeAdminWorkflowPhase, type AdminWorkflowPhaseKey } from './adminReadinessPhase'
export type PhaseKey = AdminWorkflowPhaseKey
export type SeasonDateField =
| 'nominationStartsAt'
@@ -112,14 +114,8 @@ export function createSeasonTimelineRows(
})
}
export function normalizePhaseKey(value: string): PhaseKey | string {
const phase = value.trim().toLowerCase()
if (phase.includes('abgeschlossen') || phase.includes('archiv') || phase.includes('complete') || phase.includes('ended')) return 'completed'
if (phase.includes('show')) return 'show'
if (phase.includes('aufbereit') || phase.includes('vorbereit') || phase.includes('pause') || phase.includes('review') || phase.includes('auswert')) return 'preparation'
if (phase.includes('vot')) return 'voting'
if (phase.includes('nomin')) return 'nomination'
return phase
export function normalizePhaseKey(value: string): PhaseKey {
return normalizeAdminWorkflowPhase(value)
}
export function resolveAutoPhase(form: SeasonTimelineForm) {
@@ -1,7 +1,6 @@
export interface AdminSeasonForm {
year: number
name: string
showStreamUrl: string
currentPhase: string
isCurrent: boolean
isCommunityOnly: boolean
@@ -3,6 +3,7 @@ import { AlertTriangle, CheckCircle2, Clock3, Sparkles, Tags, Trophy, Users, Vot
import { getVoteMetricValue } from '../../lib/adminMetrics'
import { useAwardsStore } from '../../stores/awards'
import { buildAdminReadinessScope } from './adminReadinessPhase'
export function useAdminAnalyticsManager() {
const store = useAwardsStore()
@@ -11,9 +12,10 @@ export function useAdminAnalyticsManager() {
const topCategories = computed(() => store.admin.topCategories)
const totalVotes = computed(() => getVoteMetricValue(store.admin.metrics))
const totalNominations = computed(() => store.admin.metrics.find((metric) => metric.label === 'Nominierungen')?.value ?? 0)
const maxVotes = computed(() => Math.max(...topCategories.value.map((category) => category.votes), 1))
const maxVotes = computed(() => Math.max(...topCategories.value.map((category) => category.value), 1))
const resultMap = computed(() => new Map(seasonDetail.value.results.map((result) => [result.categoryId, result])))
const voteMap = computed(() => new Map(topCategories.value.map((category) => [category.category, category.votes])))
const voteMap = computed(() => new Map(topCategories.value.map((category) => [category.category, category.value])))
const readinessScope = computed(() => buildAdminReadinessScope(seasonDetail.value))
const categoryHealth = computed(() =>
seasonDetail.value.categories
@@ -23,6 +25,8 @@ export function useAdminAnalyticsManager() {
const hasWinner = resultMap.value.has(category.id)
const votes = voteMap.value.get(category.name) ?? 0
const status = candidates === 0 ? 'Leer' : reviews > 0 ? 'Review offen' : hasWinner ? 'Gewinner gesetzt' : 'Bereit'
const emptyIsWarning = candidates === 0 && readinessScope.value.candidateWarningsActive
const reviewIsWarning = reviews > 0 && readinessScope.value.reviewWarningsActive
return {
id: category.id,
@@ -34,14 +38,25 @@ export function useAdminAnalyticsManager() {
votes,
votePct: totalVotes.value > 0 ? Math.round((votes / totalVotes.value) * 100) : 0,
status,
statusClass: candidates === 0
statusClass: emptyIsWarning
? 'border-rose-100 bg-rose-50 text-rose-700'
: reviews > 0
: reviewIsWarning
? 'border-amber-100 bg-amber-50 text-amber-700'
: hasWinner
? 'border-emerald-100 bg-emerald-50 text-emerald-700'
: 'border-sky-100 bg-sky-50 text-sky-700',
statusDot: candidates === 0 ? 'bg-rose-400' : reviews > 0 ? 'bg-amber-400' : hasWinner ? 'bg-emerald-400' : 'bg-sky-400',
: candidates === 0 || reviews > 0
? 'border-violet-100 bg-violet-50 text-violet-700'
: 'border-sky-100 bg-sky-50 text-sky-700',
statusDot: emptyIsWarning ? 'bg-rose-400' : reviewIsWarning ? 'bg-amber-400' : hasWinner ? 'bg-emerald-400' : candidates === 0 || reviews > 0 ? 'bg-violet-400' : 'bg-sky-400',
cardClass: emptyIsWarning
? 'border-rose-100 bg-rose-50/55'
: reviewIsWarning
? 'border-amber-100 bg-amber-50/55'
: hasWinner
? 'border-emerald-100 bg-emerald-50/55'
: candidates === 0 || reviews > 0
? 'border-violet-100 bg-violet-50/55'
: 'border-sky-100 bg-sky-50/55',
}
})
.sort((a, b) => b.reviews - a.reviews || a.candidates - b.candidates || b.votes - a.votes),
@@ -93,16 +108,20 @@ export function useAdminAnalyticsManager() {
{
label: 'Gewinnerstatus',
value: `${winnerCoveragePct.value}%`,
note: `${seasonDetail.value.results.length} von ${seasonDetail.value.categories.length} Kategorien final gesetzt.`,
note: readinessScope.value.winnerWarningsActive
? `${seasonDetail.value.results.length} von ${seasonDetail.value.categories.length} Kategorien final gesetzt.`
: 'Gewinner werden erst in Auswertung oder Show relevant.',
icon: Trophy,
to: categoriesWithoutWinner.value.length > 0
? `/admin/winners?status=open&categoryId=${categoriesWithoutWinner.value[0]!.id}`
? `/admin/voting?status=issues&categoryId=${categoriesWithoutWinner.value[0]!.id}`
: '/admin/winners?status=set',
},
{
label: 'Freigabe-Risiko',
value: String(categoriesWithReviews.value.length + pendingClipCount.value),
note: `${categoriesWithReviews.value.length} Kategorien mit Reviews, ${pendingClipCount.value} Clips offen.`,
note: readinessScope.value.reviewWarningsActive
? `${categoriesWithReviews.value.length} Kategorien mit Reviews, ${pendingClipCount.value} Clips offen.`
: 'Noch Vorbereitungsstand, keine Freigabe-Warnung.',
icon: AlertTriangle,
to: categoriesWithReviews.value.length > 0
? `/admin/nominations?review=1&categoryId=${categoriesWithReviews.value[0]!.id}&status=heavy`
@@ -124,7 +143,7 @@ export function useAdminAnalyticsManager() {
},
{
label: 'Staerkste Kategorie',
value: strongestCategory?.votes.toLocaleString('de-DE') ?? '0',
value: strongestCategory?.value.toLocaleString('de-DE') ?? '0',
note: strongestCategory ? strongestCategory.category : 'Noch keine Vote-Verteilung vorhanden.',
icon: CheckCircle2,
},
@@ -155,21 +174,37 @@ export function useAdminAnalyticsManager() {
key: 'pending-reviews',
label: 'Offene Reviews',
value: seasonDetail.value.pendingNominations.length,
note: categoriesWithReviews.value.length === 0 ? 'Review-Queue ist leer.' : `${categoriesWithReviews.value.length} Kategorien betroffen.`,
note: categoriesWithReviews.value.length === 0
? 'Review-Queue ist leer.'
: readinessScope.value.reviewWarningsActive
? `${categoriesWithReviews.value.length} Kategorien betroffen.`
: 'Nominierungen laufen noch; Review ist eingeplant.',
to: categoriesWithReviews.value.length > 0
? `/admin/nominations?review=1&categoryId=${categoriesWithReviews.value[0]!.id}&status=heavy`
: '/admin/nominations?review=1',
tone: seasonDetail.value.pendingNominations.length > 0 ? 'border-amber-100 bg-amber-50 text-amber-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
tone: seasonDetail.value.pendingNominations.length > 0 && readinessScope.value.reviewWarningsActive
? 'border-amber-100 bg-amber-50 text-amber-800'
: seasonDetail.value.pendingNominations.length > 0
? 'border-violet-100 bg-violet-50 text-violet-800'
: 'border-emerald-100 bg-emerald-50 text-emerald-800',
},
{
key: 'missing-winners',
label: 'Gewinner fehlen',
label: readinessScope.value.winnerWarningsActive ? 'Gewinner fehlen' : 'Gewinner später',
value: categoriesWithoutWinner.value.length,
note: categoriesWithoutWinner.value.length === 0 ? 'Alle Gewinner sind gesetzt.' : 'Finalisierung auf der Gewinner-Seite abschliessen.',
note: categoriesWithoutWinner.value.length === 0
? 'Alle Gewinner sind gesetzt.'
: readinessScope.value.winnerWarningsActive
? 'Voting-Vorbereitung prüfen und danach final setzen.'
: 'Für diese Phase noch kein Problem.',
to: categoriesWithoutWinner.value.length > 0
? `/admin/winners?status=open&categoryId=${categoriesWithoutWinner.value[0]!.id}`
? `/admin/voting?status=issues&categoryId=${categoriesWithoutWinner.value[0]!.id}`
: '/admin/winners?status=set',
tone: categoriesWithoutWinner.value.length > 0 ? 'border-sky-100 bg-sky-50 text-sky-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
tone: categoriesWithoutWinner.value.length > 0 && readinessScope.value.winnerWarningsActive
? 'border-sky-100 bg-sky-50 text-sky-800'
: categoriesWithoutWinner.value.length > 0
? 'border-violet-100 bg-violet-50 text-violet-800'
: 'border-emerald-100 bg-emerald-50 text-emerald-800',
},
])
@@ -177,8 +212,9 @@ export function useAdminAnalyticsManager() {
const topCategoriesEnriched = computed(() =>
topCategories.value.map((cat) => ({
...cat,
pct: totalVotes.value > 0 ? Math.round((cat.votes / totalVotes.value) * 100) : 0,
barWidth: totalVotes.value > 0 ? Math.max(2, Math.round((cat.votes / maxVotes.value) * 100)) : 2,
votes: cat.value,
pct: totalVotes.value > 0 ? Math.round((cat.value / totalVotes.value) * 100) : 0,
barWidth: totalVotes.value > 0 ? Math.max(2, Math.round((cat.value / maxVotes.value) * 100)) : 2,
})),
)
@@ -196,6 +232,7 @@ export function useAdminAnalyticsManager() {
categoryGroups,
metricCards,
readinessCards,
readinessScope,
insightCards,
attentionItems,
topCategories,
@@ -62,6 +62,7 @@ export function useAdminCandidateManager() {
const page = ref(1)
const modalOpen = ref(false)
const editingId = ref<number | 'new' | null>(null)
const openedRouteCandidateId = ref<number | null>(null)
const candidateToDelete = ref<AdminCandidateItem | null>(null)
const form = reactive<CandidateForm>({
categoryId: 0,
@@ -239,6 +240,7 @@ export function useAdminCandidateManager() {
const rawQuery = Array.isArray(route.query.q) ? route.query.q[0] : route.query.q
const rawCategoryId = Array.isArray(route.query.categoryId) ? route.query.categoryId[0] : route.query.categoryId
const rawReadiness = Array.isArray(route.query.readiness) ? route.query.readiness[0] : route.query.readiness
const rawCandidateId = Array.isArray(route.query.candidateId) ? route.query.candidateId[0] : route.query.candidateId
search.value = typeof rawQuery === 'string' ? rawQuery : ''
@@ -247,6 +249,22 @@ export function useAdminCandidateManager() {
const nextReadiness = typeof rawReadiness === 'string' ? rawReadiness : 'all'
readinessFilter.value = readinessFilterOptions.some((option) => option.value === nextReadiness) ? nextReadiness : 'all'
const parsedCandidateId = Number(rawCandidateId)
if (!Number.isFinite(parsedCandidateId) || parsedCandidateId <= 0) {
openedRouteCandidateId.value = null
return
}
if (openedRouteCandidateId.value === parsedCandidateId) {
return
}
const candidate = seasonDetail.value.candidates.find((item) => item.id === parsedCandidateId)
if (candidate) {
openedRouteCandidateId.value = parsedCandidateId
openEdit(candidate)
}
}
watch([search, categoryFilter, readinessFilter, () => seasonDetail.value.candidates.length], () => {
@@ -260,7 +278,7 @@ export function useAdminCandidateManager() {
})
watch(
() => [route.query.q, route.query.categoryId, route.query.readiness] as const,
() => [route.query.q, route.query.categoryId, route.query.readiness, route.query.candidateId, seasonDetail.value.candidates.length] as const,
() => {
applyRouteFilters()
},
@@ -5,7 +5,12 @@ import {
simpleIconForKey,
socialIconOptionForKey,
} from '../../lib/socialIcons'
import { privacyContentForStorage, privacyContentToHtml } from '../../lib/privacyContent'
import {
plainTextContentForStorage,
plainTextContentFromStorage,
privacyContentForStorage,
privacyContentToHtml,
} from '../../lib/privacyContent'
import { useAwardsStore } from '../../stores/awards'
import type { ShowactFormField } from '../../types/awards/showactForm'
import { DEFAULT_SHOWACT_SCHEMA } from '../../types/awards/showactForm'
@@ -38,6 +43,22 @@ function createEmptyForm(): AdminContentForm {
sponsorsContent: '',
showactsUrl: '',
showactsContent: '',
streamBannerEyebrow: 'Das grosse Finale',
streamBannerTitle: 'Award-Show Finale',
streamBannerText: 'Finale, Countdown und Stream-Link an einem Ort. Sei live dabei, wenn die Community ihre Stars feiert.',
streamBannerLiveButtonLabel: 'Jetzt live · Zum Stream',
streamBannerLiveButtonUrl: '',
streamBannerLockedButtonLabel: 'Stream noch gesperrt',
streamBannerUseCompletedContent: false,
streamBannerCompletedEyebrow: 'Danke fürs Mitfiebern',
streamBannerCompletedTitle: 'Award-Show abgeschlossen',
streamBannerCompletedText: 'Die grosse Award-Show ist vorbei. Danke an alle, die nominiert, gevotet und live mitgefiebert haben.',
streamBannerCompletedButtonLabel: 'Highlights ansehen',
streamBannerCompletedButtonUrl: '',
awardsSectionTitle: '',
awardsSectionDescription: '',
subcategoriesSectionTitle: '',
subcategoriesSectionDescription: '',
socialLinks: [],
faq: [],
showactFormSchema: [],
@@ -104,6 +125,22 @@ export function useAdminContentManager() {
form.sponsorsContent = settings.sponsorsContent
form.showactsUrl = settings.showactsUrl
form.showactsContent = settings.showactsContent
form.streamBannerEyebrow = settings.streamBannerEyebrow
form.streamBannerTitle = settings.streamBannerTitle
form.streamBannerText = plainTextContentFromStorage(settings.streamBannerText)
form.streamBannerLiveButtonLabel = settings.streamBannerLiveButtonLabel
form.streamBannerLiveButtonUrl = settings.streamBannerLiveButtonUrl
form.streamBannerLockedButtonLabel = settings.streamBannerLockedButtonLabel
form.streamBannerUseCompletedContent = settings.streamBannerUseCompletedContent
form.streamBannerCompletedEyebrow = settings.streamBannerCompletedEyebrow
form.streamBannerCompletedTitle = settings.streamBannerCompletedTitle
form.streamBannerCompletedText = plainTextContentFromStorage(settings.streamBannerCompletedText)
form.streamBannerCompletedButtonLabel = settings.streamBannerCompletedButtonLabel
form.streamBannerCompletedButtonUrl = settings.streamBannerCompletedButtonUrl
form.awardsSectionTitle = settings.awardsSectionTitle
form.awardsSectionDescription = plainTextContentFromStorage(settings.awardsSectionDescription)
form.subcategoriesSectionTitle = settings.subcategoriesSectionTitle
form.subcategoriesSectionDescription = plainTextContentFromStorage(settings.subcategoriesSectionDescription)
form.socialLinks = settings.socialLinks.map((item) => ({
label: item.label ?? '',
platform: item.platform ?? '',
@@ -275,6 +312,22 @@ export function useAdminContentManager() {
sponsorsContent: privacyContentForStorage(form.sponsorsContent),
showactsUrl: form.showactsUrl,
showactsContent: privacyContentForStorage(form.showactsContent),
streamBannerEyebrow: form.streamBannerEyebrow,
streamBannerTitle: form.streamBannerTitle,
streamBannerText: plainTextContentForStorage(form.streamBannerText),
streamBannerLiveButtonLabel: form.streamBannerLiveButtonLabel,
streamBannerLiveButtonUrl: form.streamBannerLiveButtonUrl,
streamBannerLockedButtonLabel: form.streamBannerLockedButtonLabel,
streamBannerUseCompletedContent: form.streamBannerUseCompletedContent,
streamBannerCompletedEyebrow: form.streamBannerCompletedEyebrow,
streamBannerCompletedTitle: form.streamBannerCompletedTitle,
streamBannerCompletedText: plainTextContentForStorage(form.streamBannerCompletedText),
streamBannerCompletedButtonLabel: form.streamBannerCompletedButtonLabel,
streamBannerCompletedButtonUrl: form.streamBannerCompletedButtonUrl,
awardsSectionTitle: form.awardsSectionTitle,
awardsSectionDescription: plainTextContentForStorage(form.awardsSectionDescription),
subcategoriesSectionTitle: form.subcategoriesSectionTitle,
subcategoriesSectionDescription: plainTextContentForStorage(form.subcategoriesSectionDescription),
showactFormSchemaJson: JSON.stringify(form.showactFormSchema),
socialLinks: form.socialLinks
.map((item) => ({
@@ -1,168 +1,251 @@
import { computed } from 'vue'
import { BarChart3, Clock3, ShieldAlert, Sparkles, Tags, Users } from '@lucide/vue'
import {
BarChart3,
CheckCircle2,
Clock3,
ExternalLink,
Film,
Globe2,
ShieldAlert,
Sparkles,
Tags,
Trophy,
Users,
Vote,
} from '@lucide/vue'
import { getRiskMetricValue, getVoteMetricValue } from '../../lib/adminMetrics'
import { useAwardsStore } from '../../stores/awards'
import { useAuthStore } from '../../stores/auth'
import type { AdminSeasonDetailResponse } from '../../types/awards'
import { buildAdminReadinessScope, phaseLabel } from './adminReadinessPhase'
import {
createSeasonTimelineRows,
normalizePhaseKey,
resolveAutoPhase,
type PhaseKey,
type SeasonTimelineForm,
} from './adminSeasonTimeline'
type DashboardState = 'ok' | 'warn' | 'danger' | 'info'
type PublicHealthItem = { label: string; state: DashboardState; note: string; to: string; icon: unknown }
const metricToneMap = {
Nominierungen: {
icon: Sparkles,
source: 'Nomination-Tabelle',
},
Stimmen: {
icon: BarChart3,
source: 'VoteEntries-Tabelle',
},
Kategorien: {
icon: Tags,
source: 'Categories-Tabelle',
},
'Reviews offen': {
icon: Clock3,
source: 'Nominations-Review-Status',
},
Risikohinweise: {
icon: ShieldAlert,
source: 'RiskFlags-Tabelle',
},
} as const
const phaseEndFields: Record<PhaseKey, keyof Pick<
AdminSeasonDetailResponse,
'nominationEndsAt' | 'votingEndsAt' | 'reviewEndsAt' | 'showDate'
>> = {
nomination: 'nominationEndsAt',
voting: 'votingEndsAt',
preparation: 'reviewEndsAt',
show: 'showDate',
completed: 'showDate',
}
export function useAdminDashboardOverview() {
const store = useAwardsStore()
const authStore = useAuthStore()
const seasonDetail = computed(() => store.adminSeasonDetail)
const adminSiteSettings = computed(() => store.adminSiteSettings)
const metrics = computed(() => store.admin.metrics)
const activities = computed(() => store.admin.activities)
const topCategories = computed(() => store.admin.topCategories)
const readinessScope = computed(() => buildAdminReadinessScope(seasonDetail.value))
const phaseKey = computed(() => readinessScope.value.phaseKey)
const adminGreeting = computed(() => {
const displayName = authStore.session?.displayName?.trim()
return displayName ? `Hi ${displayName}` : 'Admin-Überblick'
})
const metricCards = computed(() =>
metrics.value.map((metric) => ({
...metric,
...(metricToneMap[metric.label as keyof typeof metricToneMap] ?? {
icon: BarChart3,
source: 'Backend-Metrik',
}),
})),
)
const openReviewCount = computed(() => store.adminSeasonDetail.pendingNominations.length)
const openReviewCount = computed(() => seasonDetail.value.pendingNominations.length)
const openRiskCount = computed(() => getRiskMetricValue(metrics.value))
const pendingClipCount = computed(() => seasonDetail.value.clipSubmissions.filter((clip) => clip.status === 'pending').length)
const votingSummary = computed(() => seasonDetail.value.votingWorkspace.summary)
const missingWinnerCount = computed(() =>
Math.max(votingSummary.value.totalSubcategories - votingSummary.value.winnerSetSubcategories, 0),
)
const streamBannerLinkConfigured = computed(() => Boolean(adminSiteSettings.value.streamBannerLiveButtonUrl.trim()))
const liveSummary = computed(() => {
const phase = store.adminSeasonDetail.currentPhase || 'nicht gesetzt'
const reviewText = openReviewCount.value === 1 ? '1 offene Review' : `${openReviewCount.value} offene Reviews`
const riskText = openRiskCount.value === 1 ? '1 Risikohinweis' : `${openRiskCount.value} Risikohinweise`
const categoryText = store.adminSeasonDetail.categories.length === 1
? '1 Kategorie'
: `${store.adminSeasonDetail.categories.length} Kategorien`
const candidateText = store.adminSeasonDetail.candidates.length === 1
? '1 Kandidat'
: `${store.adminSeasonDetail.candidates.length} Kandidaten`
const phase = seasonDetail.value.currentPhase || 'nicht gesetzt'
const yearLabel = seasonDetail.value.year ? `${seasonDetail.value.year}` : 'kein Jahr'
const voteText = getVoteMetricValue(metrics.value) === 1 ? '1 Stimme' : `${getVoteMetricValue(metrics.value)} Stimmen`
const nominationText = metrics.value.find((metric) => metric.label === 'Nominierungen')?.value ?? 0
return `Aktuelle Phase: ${phase}. ${categoryText}, ${candidateText}, ${reviewText} und ${riskText} kommen direkt aus der Admin-API.`
return `${yearLabel} · ${phase}. ${nominationText.toLocaleString('de-DE')} Nominierungen, ${voteText} und ${openReviewCount.value.toLocaleString('de-DE')} offene Reviews im gewählten Jahr.`
})
const liveStatusTone = computed<DashboardState>(() => {
if (openRiskCount.value > 0) return 'danger'
if (readinessScope.value.reviewWarningsActive && openReviewCount.value > 0) return 'warn'
return 'ok'
})
const liveStatusBadge = computed(() => {
if (openRiskCount.value > 0) {
return `${openRiskCount.value} Risiko offen`
}
if (openReviewCount.value > 0) {
return `${openReviewCount.value} Review offen`
}
return 'Betrieb wirkt sauber'
if (openRiskCount.value > 0) return `${openRiskCount.value} Risiko offen`
if (readinessScope.value.reviewWarningsActive && openReviewCount.value > 0) return `${openReviewCount.value} Review offen`
return `${phaseLabel(phaseKey.value)} im Blick`
})
const maxCategoryVotes = computed(() => Math.max(...topCategories.value.map((category) => category.votes), 1))
const totalCategoryVotes = computed(() => topCategories.value.reduce((sum, category) => sum + category.votes, 0))
const topCategoryBasis = computed(() =>
topCategories.value[0]?.basis || (phaseKey.value === 'nomination' ? 'Nominierungen' : 'Stimmen'),
)
const maxCategoryValue = computed(() => Math.max(...topCategories.value.map((category) => category.value), 1))
const totalCategoryValue = computed(() => topCategories.value.reduce((sum, category) => sum + category.value, 0))
const topCategoryTitle = computed(() =>
topCategoryBasis.value.toLowerCase().includes('nomin')
? 'Top Kategorien nach Nominierungen'
: 'Top Kategorien nach Stimmen',
)
const topCategoryTotalLabel = computed(() =>
`${totalCategoryValue.value.toLocaleString('de-DE')} ${topCategoryBasis.value.toLowerCase()} in den Top-Kategorien`,
)
const timelineForm = computed<SeasonTimelineForm>(() => ({
year: seasonDetail.value.year,
currentPhase: seasonDetail.value.currentPhase,
nominationStartsAt: seasonDetail.value.nominationStartsAt,
nominationEndsAt: seasonDetail.value.nominationEndsAt,
votingStartsAt: seasonDetail.value.votingStartsAt,
votingEndsAt: seasonDetail.value.votingEndsAt,
reviewStartsAt: seasonDetail.value.reviewStartsAt,
reviewEndsAt: seasonDetail.value.reviewEndsAt,
showDate: seasonDetail.value.showDate,
showStartsAt: seasonDetail.value.showStartsAt,
}))
const autoPhase = computed(() => resolveAutoPhase(timelineForm.value))
const timelinePhases = computed(() =>
createSeasonTimelineRows(timelineForm.value, normalizePhaseKey(seasonDetail.value.currentPhase), autoPhase.value, null)
.filter((phase) => phase.key !== 'completed'),
)
const activeCountdown = computed(() => buildCountdownLabel(phaseKey.value, seasonDetail.value[phaseEndFields[phaseKey.value]]))
const phaseMismatch = computed(() => {
const auto = autoPhase.value
if (!auto || phaseKey.value === 'completed') return false
const currentTitle = timelinePhases.value.find((phase) => phase.key === phaseKey.value)?.title
return Boolean(currentTitle && auto !== currentTitle)
})
const phaseMismatchLabel = computed(() =>
phaseMismatch.value ? `Zeitplan wirkt wie "${autoPhase.value}", gespeichert ist "${phaseLabel(phaseKey.value)}".` : '',
)
const yearTotals = computed(() => [
{
label: 'Nominierungen gesamt',
label: 'Nominierungen',
value: metrics.value.find((metric) => metric.label === 'Nominierungen')?.value ?? 0,
note: `im Award-Jahr ${store.adminSeasonDetail.year}`,
note: `im Award-Jahr ${seasonDetail.value.year}`,
icon: Sparkles,
},
{
label: 'Stimmen gesamt',
label: 'Stimmen',
value: getVoteMetricValue(metrics.value),
note: 'alle abgegebenen Votes',
icon: BarChart3,
},
{
label: 'Kandidaten',
value: store.adminSeasonDetail.candidates.length,
value: seasonDetail.value.candidates.length,
note: 'für Voting und Archiv gepflegt',
icon: Users,
},
{
label: 'Kategorien',
value: store.adminSeasonDetail.categories.length,
note: 'aktive Award-Kategorien',
icon: Tags,
},
{
label: 'Offene Reviews',
value: store.adminSeasonDetail.pendingNominations.length,
note: 'brauchen Team-Entscheidung',
icon: Clock3,
},
{
label: 'Risikohinweise',
value: openRiskCount.value,
note: 'aktuell offen',
icon: ShieldAlert,
label: 'Winner-ready',
value: votingSummary.value.winnerSetSubcategories,
note: `${votingSummary.value.totalSubcategories} Unterkategorien insgesamt`,
icon: Trophy,
},
])
function canOpenAdminPath(path: string) {
if (path === '/admin' || path.startsWith('/admin/dashboard')) return authStore.hasPermission('dashboard')
if (path.startsWith('/admin/years')) return authStore.hasPermission('years')
if (path.startsWith('/admin/nominations')) return authStore.hasPermission('nominations')
if (path.startsWith('/admin/risk')) return authStore.hasPermission('risk')
if (path.startsWith('/admin/categories')) return authStore.hasPermission('categories')
if (path.startsWith('/admin/candidates')) return authStore.hasPermission('candidates')
if (path.startsWith('/admin/clips')) return authStore.hasPermission('clips')
if (path.startsWith('/admin/users-logs')) return authStore.hasPermission('audit')
if (path.startsWith('/admin/winners')) return authStore.hasPermission('winners')
if (path.startsWith('/admin/voting')) return authStore.hasPermission('voting')
if (path.startsWith('/admin/analytics')) return authStore.hasPermission('analytics')
return true
if (path.startsWith('/admin/content')) return authStore.hasPermission('content')
if (path.startsWith('/admin/settings')) return authStore.hasPermission('settings')
if (path.startsWith('/admin/tracking-rules')) return authStore.hasPermission('settings')
if (path.startsWith('/admin/team')) return authStore.hasPermission('team')
return !path.startsWith('/admin')
}
const priorityActions = computed(() => [
const queueItems = computed(() => [
{
label: 'Reviews bearbeiten',
value: store.adminSeasonDetail.pendingNominations.length,
label: 'Reviews',
value: openReviewCount.value,
to: '/admin/nominations?review=1',
hint: 'Freitext-Nominierungen warten auf Entscheidung',
hint: readinessScope.value.reviewWarningsActive ? 'Freitext-Nominierungen entscheiden' : 'Sammelt sich für die spätere Prüfung',
icon: Sparkles,
tone: 'violet',
tone: openReviewCount.value > 0 && readinessScope.value.reviewWarningsActive ? 'amber' : 'violet',
},
{
label: 'Risiko prüfen',
label: 'Risiko',
value: openRiskCount.value,
to: '/admin/risk',
hint: 'Auffällige Muster brauchen Sichtung',
hint: 'Saisonale und globale offene Hinweise',
icon: ShieldAlert,
tone: 'rose',
tone: openRiskCount.value > 0 ? 'rose' : 'emerald',
},
{
label: 'Kategorien pflegen',
value: store.adminSeasonDetail.categories.length,
to: '/admin/categories',
hint: 'Texte, Limits und Reihenfolge aktuell halten',
icon: Tags,
tone: 'amber',
label: 'Clips',
value: pendingClipCount.value,
to: '/admin/clips?status=pending',
hint: 'Clip-Einreichungen in der Inbox',
icon: Film,
tone: pendingClipCount.value > 0 ? 'amber' : 'emerald',
},
{
label: 'Kandidatenbasis',
value: store.adminSeasonDetail.candidates.length,
to: store.adminSeasonDetail.candidates.length === 0
? '/admin/categories?status=empty'
: '/admin/candidates',
hint: 'Kandidaten und Plattformen schnell prüfen',
icon: Users,
tone: 'emerald',
label: 'Voting',
value: votingSummary.value.problemSubcategories,
to: '/admin/voting',
hint: readinessScope.value.voteWarningsActive ? 'Problem-Unterkategorien prüfen' : 'Voting-Readiness später relevant',
icon: Vote,
tone: votingSummary.value.problemSubcategories > 0 && readinessScope.value.voteWarningsActive ? 'amber' : 'violet',
},
{
label: 'Gewinner',
value: missingWinnerCount.value,
to: '/admin/winners',
hint: readinessScope.value.winnerWarningsActive ? 'Unterkategorien ohne Gewinner' : 'Finalisierung später relevant',
icon: Trophy,
tone: missingWinnerCount.value > 0 && readinessScope.value.winnerWarningsActive ? 'amber' : 'emerald',
},
].filter((item) => canOpenAdminPath(item.to)))
const operationChecks = computed(() => {
const categoriesWithoutCandidates = store.adminSeasonDetail.categories.filter((category) =>
!store.adminSeasonDetail.candidates.some((candidate) => candidate.categoryId === category.id),
const categoriesWithoutCandidates = seasonDetail.value.categories.filter((category) =>
!seasonDetail.value.candidates.some((candidate) => candidate.categoryId === category.id),
)
const categoriesWithReviews = store.adminSeasonDetail.categories.filter((category) =>
store.adminSeasonDetail.pendingNominations.some((nomination) => nomination.categoryId === category.id),
const categoriesWithReviews = seasonDetail.value.categories.filter((category) =>
seasonDetail.value.pendingNominations.some((nomination) => nomination.categoryId === category.id),
)
return [
@@ -172,8 +255,12 @@ export function useAdminDashboardOverview() {
to: categoriesWithoutCandidates.length > 0
? `/admin/categories?group=${encodeURIComponent(categoriesWithoutCandidates[0]!.groupName)}`
: '/admin/categories',
state: categoriesWithoutCandidates.length === 0 ? 'ok' : 'warn',
note: categoriesWithoutCandidates.length === 0 ? 'Alle Kategorien sind besetzt.' : 'Vor Voting-Endspurt prüfen.',
state: categoriesWithoutCandidates.length === 0 || !readinessScope.value.candidateWarningsActive ? 'ok' : 'warn',
note: categoriesWithoutCandidates.length === 0
? 'Alle Kategorien sind besetzt.'
: readinessScope.value.candidateWarningsActive
? 'Vor Voting-Endspurt prüfen.'
: 'In dieser Phase noch Vorbereitung, kein Blocker.',
},
{
label: 'Review-Backlog verteilt',
@@ -181,19 +268,148 @@ export function useAdminDashboardOverview() {
to: categoriesWithReviews.length > 0
? `/admin/nominations?review=1&categoryId=${categoriesWithReviews[0]!.id}&status=heavy`
: '/admin/nominations?review=1',
state: categoriesWithReviews.length <= 1 ? 'ok' : 'warn',
note: categoriesWithReviews.length <= 1 ? 'Backlog ist fokussiert.' : 'Mehrere Kategorien brauchen Sichtung.',
state: categoriesWithReviews.length <= 1 || !readinessScope.value.reviewWarningsActive ? 'ok' : 'warn',
note: categoriesWithReviews.length <= 1
? 'Backlog ist fokussiert.'
: readinessScope.value.reviewWarningsActive
? 'Mehrere Kategorien brauchen Sichtung.'
: 'Nominierungen werden gesammelt; Review wird später relevant.',
},
{
label: 'Risk Flags offen',
value: openRiskCount.value,
to: '/admin/risk',
state: openRiskCount.value === 0 ? 'ok' : 'danger',
note: openRiskCount.value === 0 ? 'Keine offenen Hinweise.' : 'Missbrauchsschutz zuerst prüfen.',
note: openRiskCount.value === 0 ? 'Keine offenen Hinweise.' : 'Saisonale und globale Hinweise zuerst prüfen.',
},
].filter((item) => canOpenAdminPath(item.to))
})
const readinessItems = computed(() => {
const datesComplete = [
seasonDetail.value.nominationStartsAt,
seasonDetail.value.nominationEndsAt,
seasonDetail.value.votingStartsAt,
seasonDetail.value.votingEndsAt,
seasonDetail.value.reviewStartsAt,
seasonDetail.value.reviewEndsAt,
seasonDetail.value.showDate,
].every((value) => value.trim())
const hasVotingBase = votingSummary.value.totalSubcategories > 0
const votesReady = !readinessScope.value.voteWarningsActive ||
(hasVotingBase && votingSummary.value.votedSubcategories === votingSummary.value.totalSubcategories)
const winnersReady = !readinessScope.value.winnerWarningsActive ||
(hasVotingBase && votingSummary.value.winnerSetSubcategories === votingSummary.value.totalSubcategories)
return [
{
label: 'Timeline',
complete: datesComplete,
note: datesComplete ? 'Alle Phasen sind terminiert.' : 'Mindestens ein Phasendatum fehlt.',
to: '/admin/years',
},
{
label: 'Public-Jahr',
complete: seasonDetail.value.isCurrent,
note: seasonDetail.value.isCurrent ? 'Dieses Jahr ist öffentlich sichtbar.' : 'Das Jahr ist intern ausgewählt.',
to: '/admin/years',
},
{
label: 'Finale-Link',
complete: streamBannerLinkConfigured.value,
note: streamBannerLinkConfigured.value ? 'Finale-Banner Button-Link ist gepflegt.' : 'Finale-Banner Button-Link fehlt.',
to: '/admin/content?editor=streamBanner',
},
{
label: 'Reviews',
complete: !readinessScope.value.reviewWarningsActive || openReviewCount.value === 0,
note: readinessScope.value.reviewWarningsActive
? `${openReviewCount.value} offene Reviews.`
: 'Review-Backlog ist für diese Phase noch kein Blocker.',
to: '/admin/nominations?review=1',
},
{
label: 'Voting',
complete: votesReady,
note: readinessScope.value.voteWarningsActive
? `${votingSummary.value.votedSubcategories}/${votingSummary.value.totalSubcategories} Unterkategorien haben Votes.`
: 'Voting-Readiness wird ab Aufbereitung kritisch.',
to: '/admin/voting',
},
{
label: 'Gewinner',
complete: winnersReady,
note: readinessScope.value.winnerWarningsActive
? `${votingSummary.value.winnerSetSubcategories}/${votingSummary.value.totalSubcategories} Gewinner gesetzt.`
: 'Gewinner werden später finalisiert.',
to: '/admin/winners',
},
].filter((item) => canOpenAdminPath(item.to))
})
const readinessScore = computed(() => ({
done: readinessItems.value.filter((item) => item.complete).length,
total: readinessItems.value.length,
}))
const publicHealthItems = computed<PublicHealthItem[]>(() => {
const siteSettings = store.adminSiteSettings
const operationalSettings = store.adminOperationalSettings
const configuredFooterLinks = [
siteSettings.imprintUrl,
siteSettings.contactUrl,
siteSettings.sponsorsUrl,
siteSettings.showactsUrl,
siteSettings.newsletterUrl,
].filter((url) => url.trim()).length
const configuredFooterPages = [
siteSettings.imprintContent,
siteSettings.contactContent,
siteSettings.sponsorsContent,
siteSettings.showactsContent,
].filter((content) => content.trim()).length
const canReadSettings = authStore.hasPermission('settings')
const contentReady = configuredFooterLinks === 5 &&
configuredFooterPages === 4 &&
Boolean(siteSettings.privacyPolicyContent.trim() && siteSettings.privacyEmail.trim())
return [
{
label: 'Saison öffentlich',
state: seasonDetail.value.isCurrent ? 'ok' as const : 'warn' as const,
note: seasonDetail.value.isCurrent ? `${seasonDetail.value.year} ist Public-Kontext.` : 'Gewähltes Jahr ist nicht öffentlich.',
to: '/admin/years',
icon: Globe2,
},
{
label: 'Wartungsmodus',
state: canReadSettings ? operationalSettings.maintenanceModeEnabled ? 'warn' as const : 'ok' as const : 'info' as const,
note: canReadSettings
? operationalSettings.maintenanceModeEnabled ? 'Landingpage zeigt Sternenpause.' : 'Public-Seite ist nicht im Wartungsmodus.'
: 'Nur mit Settings-Berechtigung prüfbar.',
to: '/admin/settings/access',
icon: ShieldAlert,
},
{
label: 'Finale-Link',
state: streamBannerLinkConfigured.value ? 'ok' as const : 'warn' as const,
note: streamBannerLinkConfigured.value ? 'Finale-Banner zeigt auf einen Live-Link.' : 'Im Finale-Banner fehlt noch der Live-Link.',
to: '/admin/content?editor=streamBanner',
icon: ExternalLink,
},
{
label: 'Pflicht-Content',
state: contentReady ? 'ok' as const : 'warn' as const,
note: contentReady
? 'Footer und Datenschutz sind gepflegt.'
: `${configuredFooterLinks}/5 Links, ${configuredFooterPages}/4 Seiten plus Datenschutz prüfen.`,
to: '/admin/content',
icon: CheckCircle2,
},
].filter((item) => canOpenAdminPath(item.to))
})
const canViewAuditLog = computed(() => authStore.hasPermission('audit'))
const canOpenYears = computed(() => canOpenAdminPath('/admin/years'))
return {
store,
activities,
@@ -203,10 +419,49 @@ export function useAdminDashboardOverview() {
openRiskCount,
liveSummary,
liveStatusBadge,
maxCategoryVotes,
totalCategoryVotes,
liveStatusTone,
maxCategoryValue,
totalCategoryValue,
topCategoryTitle,
topCategoryTotalLabel,
yearTotals,
priorityActions,
queueItems,
operationChecks,
timelinePhases,
activeCountdown,
phaseMismatch,
phaseMismatchLabel,
readinessItems,
readinessScore,
publicHealthItems,
adminGreeting,
canViewAuditLog,
canOpenYears,
}
}
function buildCountdownLabel(phaseKey: PhaseKey, endValue: string) {
if (phaseKey === 'completed') return 'Award-Jahr abgeschlossen'
const date = parseDate(endValue)
if (!date) return 'Nächster Termin offen'
const today = startOfDay(new Date())
const days = Math.ceil((date.getTime() - today.getTime()) / 86_400_000)
const target = phaseKey === 'show' ? 'bis zur Show' : 'bis Phasen-Ende'
if (days < 0) return `${Math.abs(days)} Tage über dem Zeitplan`
if (days === 0) return `Heute ${target}`
if (days === 1) return `Noch 1 Tag ${target}`
return `Noch ${days} Tage ${target}`
}
function parseDate(value: string) {
if (!value) return null
const date = new Date(`${value}T00:00:00`)
return Number.isNaN(date.getTime()) ? null : startOfDay(date)
}
function startOfDay(date: Date) {
const next = new Date(date)
next.setHours(0, 0, 0, 0)
return next
}
@@ -18,7 +18,6 @@ function createEmptySeasonForm(): AdminSeasonForm {
return {
year: new Date().getFullYear(),
name: '',
showStreamUrl: '',
currentPhase: '',
isCurrent: false,
isCommunityOnly: true,
@@ -37,7 +36,6 @@ function createEmptyCreateForm(): AdminSeasonCreateForm {
return {
year: new Date().getFullYear() + 1,
name: '',
showStreamUrl: 'https://twitch.tv/jayuhime',
currentPhase: 'Nominierung',
isCurrent: false,
isCommunityOnly: true,
@@ -107,7 +105,6 @@ export function useAdminSeasonManager() {
Boolean(
createForm.year &&
createForm.name.trim() &&
createForm.showStreamUrl.trim() &&
createForm.currentPhase.trim() &&
createForm.nominationStartsAt &&
createForm.nominationEndsAt &&
@@ -134,7 +131,6 @@ export function useAdminSeasonManager() {
(detail) => {
form.year = detail.year
form.name = detail.name
form.showStreamUrl = detail.showStreamUrl
form.currentPhase = detail.currentPhase
form.isCurrent = detail.isCurrent
form.isCommunityOnly = detail.isCommunityOnly
@@ -170,7 +166,6 @@ export function useAdminSeasonManager() {
function fillCreateDefaults(year = new Date().getFullYear() + 1) {
createForm.year = year
createForm.name = `VTuber Star Awards ${year}`
createForm.showStreamUrl = 'https://twitch.tv/jayuhime'
createForm.currentPhase = 'Nominierung'
createForm.isCurrent = false
createForm.isCommunityOnly = true
@@ -41,6 +41,20 @@ export function useAdminTeamManager() {
const activeMembers = computed(() => members.value.filter((member) => member.isActive).length)
const pendingPasswordChanges = computed(() => members.value.filter((member) => member.mustChangePassword).length)
const roleOptions = computed(() => roles.value.map((role) => ({ value: role.key, label: role.label })))
const permissionGroups = computed(() => {
const grouped = new Map<string, AdminTeamPermission[]>()
for (const permission of permissions.value) {
const key = permission.groupLabel || 'Sonstiges'
const current = grouped.get(key)
if (current) current.push(permission)
else grouped.set(key, [permission])
}
return [...grouped.entries()].map(([label, items]) => ({
label,
items,
}))
})
const selectedMember = computed(() => members.value.find((member) => member.id === editingMemberId.value) ?? null)
const assignableRoleOptions = computed(() => roleOptions.value.filter((role) =>
role.value !== 'creator' || selectedMember.value?.role === 'creator',
@@ -295,6 +309,7 @@ export function useAdminTeamManager() {
members,
roles,
permissions,
permissionGroups,
memberForm,
editingMemberId,
selectedMember,
@@ -0,0 +1,293 @@
import { computed, ref, watch } from 'vue'
import { AlertTriangle, CheckCircle2, Trophy, Vote } from '@lucide/vue'
import { useRoute } from 'vue-router'
import { useAwardsStore } from '../../stores/awards'
import type {
AdminVotingCandidateRank,
AdminVotingCategoryWorkspaceItem,
} from '../../types/awards'
import { buildAdminReadinessScope } from './adminReadinessPhase'
const allFilter = 'all'
export interface AdminVotingWorkspaceRow extends AdminVotingCategoryWorkspaceItem {
statusKey: 'winner' | 'issues' | 'ready' | 'empty' | 'idle'
statusLabel: string
statusTone: string
topCandidate: AdminVotingCandidateRank | null
topFour: AdminVotingCandidateRank[]
showMissingClipWarning: boolean
showOpenReviewWarning: boolean
showTopVoteTieWarning: boolean
showRuleConflictWarning: boolean
isProblem: boolean
}
export interface AdminVotingGroupSummary {
name: string
sortOrder: number
totalCategories: number
votedCategories: number
totalVotes: number
readyCount: number
problemCount: number
winnerSetCount: number
categories: AdminVotingWorkspaceRow[]
}
export function useAdminVotingManager() {
const store = useAwardsStore()
const route = useRoute()
const query = ref('')
const statusFilter = ref(allFilter)
const problemOnly = ref(false)
const selectedGroupName = ref<string | null>(null)
const focusedCategoryId = ref<number | null>(null)
const seasonDetail = computed(() => store.adminSeasonDetail)
const votingWorkspace = computed(() => seasonDetail.value.votingWorkspace)
const readinessScope = computed(() => buildAdminReadinessScope(seasonDetail.value))
const workspaceRows = computed<AdminVotingWorkspaceRow[]>(() =>
votingWorkspace.value.categories
.map((category) => {
const topCandidate = category.leaderboard[0] ?? null
const showMissingClipWarning = category.hasMissingClip && readinessScope.value.candidateWarningsActive
const showOpenReviewWarning = category.hasOpenReviews && readinessScope.value.reviewWarningsActive
const showTopVoteTieWarning = category.hasTopVoteTie && readinessScope.value.winnerWarningsActive
const showRuleConflictWarning = category.hasRuleConflict && readinessScope.value.winnerWarningsActive
const isProblem = showMissingClipWarning
|| showOpenReviewWarning
|| showTopVoteTieWarning
|| showRuleConflictWarning
let statusKey: AdminVotingWorkspaceRow['statusKey'] = 'idle'
let statusLabel = 'Leer'
let statusTone = 'border-slate-200 bg-slate-50 text-slate-600'
if (category.hasWinner) {
statusKey = 'winner'
statusLabel = 'Gewinner gesetzt'
statusTone = 'border-emerald-100 bg-emerald-50 text-emerald-700'
} else if (isProblem) {
statusKey = 'issues'
statusLabel = 'Pruefen'
statusTone = 'border-amber-100 bg-amber-50 text-amber-700'
} else if (category.winnerReady) {
statusKey = 'ready'
statusLabel = readinessScope.value.winnerWarningsActive ? 'Bereit' : 'Voting bereit'
statusTone = 'border-sky-100 bg-sky-50 text-sky-700'
} else if (category.candidateCount > 0) {
statusKey = 'empty'
statusLabel = readinessScope.value.phaseKey === 'nomination' ? 'Später' : 'Vorbereitung offen'
statusTone = readinessScope.value.phaseKey === 'nomination'
? 'border-slate-200 bg-slate-50 text-slate-600'
: 'border-violet-100 bg-violet-50 text-violet-700'
}
return {
...category,
statusKey,
statusLabel,
statusTone,
topCandidate,
topFour: category.leaderboard.slice(0, 4),
showMissingClipWarning,
showOpenReviewWarning,
showTopVoteTieWarning,
showRuleConflictWarning,
isProblem,
}
})
.sort((left, right) =>
left.sortOrder - right.sortOrder
|| left.groupName.localeCompare(right.groupName, 'de')
|| left.categoryName.localeCompare(right.categoryName, 'de')),
)
function rowMatchesFilters(row: AdminVotingWorkspaceRow) {
if (problemOnly.value && !row.isProblem) {
return false
}
if (statusFilter.value !== allFilter) {
if (statusFilter.value === 'winner' && !row.hasWinner) return false
if (statusFilter.value === 'ready' && !row.winnerReady) return false
if (statusFilter.value === 'issues' && !row.isProblem) return false
if (statusFilter.value === 'idle' && row.voteCount > 0) return false
}
const normalizedQuery = query.value.trim().toLowerCase()
if (!normalizedQuery) {
return true
}
return [
row.groupName,
row.categoryName,
row.topCandidate?.displayName ?? '',
...row.leaderboard.map((candidate) => `${candidate.displayName} ${candidate.channelSlug} ${candidate.platform}`),
].some((value) => value.toLowerCase().includes(normalizedQuery))
}
const votingGroups = computed<AdminVotingGroupSummary[]>(() => {
const grouped = new Map<string, AdminVotingWorkspaceRow[]>()
for (const row of workspaceRows.value) {
const key = row.groupName.trim() || 'Ohne Hauptkategorie'
const current = grouped.get(key)
if (current) current.push(row)
else grouped.set(key, [row])
}
return [...grouped.entries()]
.map(([name, rows]) => {
const categories = rows.filter(rowMatchesFilters)
if (categories.length === 0) return null
return {
name,
sortOrder: Math.min(...rows.map((row) => row.sortOrder)),
totalCategories: categories.length,
votedCategories: categories.filter((row) => row.voteCount > 0).length,
totalVotes: categories.reduce((sum, row) => sum + row.voteCount, 0),
readyCount: categories.filter((row) => row.winnerReady).length,
problemCount: categories.filter((row) => row.isProblem).length,
winnerSetCount: categories.filter((row) => row.hasWinner).length,
categories,
} satisfies AdminVotingGroupSummary
})
.filter((group): group is AdminVotingGroupSummary => group !== null)
.sort((left, right) => left.sortOrder - right.sortOrder || left.name.localeCompare(right.name, 'de'))
})
const selectedGroup = computed(() =>
votingGroups.value.find((group) => group.name === selectedGroupName.value)
?? votingGroups.value[0]
?? null,
)
const selectedCategory = computed(() =>
selectedGroup.value?.categories.find((row) => row.categoryId === focusedCategoryId.value)
?? selectedGroup.value?.categories[0]
?? null,
)
const summaryCards = computed(() => [
{
label: 'Gesamtvotes',
value: votingWorkspace.value.summary.totalVotes.toLocaleString('de-DE'),
note: 'Alle abgegebenen Vote-Eintraege der aktuellen Season.',
icon: Vote,
tone: 'border-violet-100 bg-violet-50 text-violet-800',
},
{
label: 'Abgedeckt',
value: `${votingWorkspace.value.summary.votedSubcategories}/${votingWorkspace.value.summary.totalSubcategories}`,
note: 'Unterkategorien mit Votes',
icon: CheckCircle2,
tone: 'border-sky-100 bg-sky-50 text-sky-800',
},
{
label: 'Vorbereitung',
value: String(votingWorkspace.value.summary.readySubcategories),
note: 'Unterkategorien sind fuer Gewinner vorbereitet',
icon: Trophy,
tone: 'border-emerald-100 bg-emerald-50 text-emerald-800',
},
{
label: 'Sonderfaelle',
value: String(workspaceRows.value.filter((row) => row.isProblem).length),
note: readinessScope.value.winnerWarningsActive
? 'Clip, Tie oder Review brauchen Aufmerksamkeit'
: readinessScope.value.phaseKey === 'voting'
? 'Reviews und Clip-Readiness, keine Gewinner-Warnung'
: 'Für diese Phase bewusst reduziert',
icon: AlertTriangle,
tone: workspaceRows.value.some((row) => row.isProblem)
? 'border-amber-100 bg-amber-50 text-amber-800'
: 'border-emerald-100 bg-emerald-50 text-emerald-800',
},
])
const statusFilters = computed(() => [
{ value: allFilter, label: 'Alle', count: workspaceRows.value.length },
{ value: 'issues', label: 'Pruefen', count: workspaceRows.value.filter((row) => row.isProblem).length },
{ value: 'ready', label: 'Bereit', count: workspaceRows.value.filter((row) => row.winnerReady).length },
{ value: 'winner', label: 'Gewinner gesetzt', count: workspaceRows.value.filter((row) => row.hasWinner).length },
{ value: 'idle', label: 'Noch ohne Votes', count: workspaceRows.value.filter((row) => row.voteCount === 0).length },
])
function applyRouteFilters() {
const rawQuery = Array.isArray(route.query.q) ? route.query.q[0] : route.query.q
const rawStatus = Array.isArray(route.query.status) ? route.query.status[0] : route.query.status
const rawGroup = Array.isArray(route.query.group) ? route.query.group[0] : route.query.group
const rawCategoryId = Array.isArray(route.query.categoryId) ? route.query.categoryId[0] : route.query.categoryId
const rawProblem = Array.isArray(route.query.problems) ? route.query.problems[0] : route.query.problems
query.value = typeof rawQuery === 'string' ? rawQuery : ''
statusFilter.value = statusFilters.value.some((option) => option.value === rawStatus) ? String(rawStatus) : allFilter
problemOnly.value = rawProblem === '1' || rawProblem === 'true'
selectedGroupName.value = typeof rawGroup === 'string' && rawGroup.trim() ? rawGroup : null
const parsedCategoryId = Number(rawCategoryId)
focusedCategoryId.value = Number.isFinite(parsedCategoryId) && parsedCategoryId > 0 ? parsedCategoryId : null
if (!selectedGroupName.value && focusedCategoryId.value) {
selectedGroupName.value = workspaceRows.value.find((row) => row.categoryId === focusedCategoryId.value)?.groupName ?? null
}
}
watch(
() => [route.query.q, route.query.status, route.query.group, route.query.categoryId, route.query.problems] as const,
applyRouteFilters,
{ immediate: true },
)
watch(votingGroups, (groups) => {
if (!groups.some((group) => group.name === selectedGroupName.value)) {
selectedGroupName.value = groups[0]?.name ?? null
}
}, { immediate: true })
watch(selectedGroup, (group) => {
if (!group?.categories.some((row) => row.categoryId === focusedCategoryId.value)) {
focusedCategoryId.value = group?.categories[0]?.categoryId ?? null
}
}, { immediate: true })
function clearFilters() {
query.value = ''
statusFilter.value = allFilter
problemOnly.value = false
}
function selectGroup(groupName: string) {
selectedGroupName.value = groupName
focusedCategoryId.value = votingGroups.value.find((group) => group.name === groupName)?.categories[0]?.categoryId ?? null
}
function focusCategory(categoryId: number) {
focusedCategoryId.value = categoryId
const match = workspaceRows.value.find((row) => row.categoryId === categoryId)
if (match) {
selectedGroupName.value = match.groupName
}
}
return {
problemOnly,
query,
statusFilter,
statusFilters,
summaryCards,
votingGroups,
selectedGroup,
selectedGroupName,
selectedCategory,
focusedCategoryId,
clearFilters,
selectGroup,
focusCategory,
}
}
@@ -4,18 +4,19 @@ import { useRoute } from 'vue-router'
import { buildClipEmbed } from '../../lib/clipEmbeds'
import { useAwardsStore } from '../../stores/awards'
import type { AdminCandidateItem, AdminWorkflowRule } from '../../types/awards'
import type { AdminAwardResultItem, AdminCandidateItem, AdminCategoryItem, AdminVotingCandidateRank, AdminWorkflowRule } from '../../types/awards'
import { buildAdminReadinessScope, phaseLabel } from './adminReadinessPhase'
const allFilter = 'all'
const maxWinnerPlacementsRuleKey = 'max_winner_placements'
const winnerRequiresClipRuleKey = 'winner_requires_clip'
type WinnerRuleNotice = {
export type WinnerRuleNotice = {
mode: 'warn' | 'block'
message: string
}
type WinnerIdentitySummary = {
export type WinnerIdentitySummary = {
appearances: number
acceptedAppearances: number
winnerPlacements: number
@@ -23,7 +24,7 @@ type WinnerIdentitySummary = {
hasClip: boolean
}
type WinnerClipPreview = {
export type WinnerClipPreview = {
clipUrl: string | null
clipTitle: string
clipPlatform: string
@@ -32,23 +33,79 @@ type WinnerClipPreview = {
clipEmbedStatus: string
}
export type WinnerVotingRank = Pick<AdminVotingCandidateRank, 'candidateId' | 'votes' | 'voteSharePercent' | 'isTopTie'> & {
rank: number
}
export interface AdminWinnerResultRow {
category: AdminCategoryItem
candidates: AdminCandidateItem[]
topVotingRanks: WinnerVotingRank[]
existing: AdminAwardResultItem | null
openReviews: number
approvedClips: number
hasPendingReviews: boolean
isEmptyWarning: boolean
isEmpty: boolean
isComplete: boolean
}
export interface AdminWinnerGroupSummary {
name: string
sortOrder: number
totalCategories: number
completedCount: number
missingCount: number
pendingReviewCount: number
emptyCount: number
approvedClips: number
categories: AdminWinnerResultRow[]
}
export interface WinnerWorkflowRuleSummary {
maxWinnerPlacements: AdminWorkflowRule | null
winnerRequiresClip: AdminWorkflowRule | null
}
export interface WinnerPublicationIssue {
key: string
label: string
note: string
isWarning: boolean
}
export interface WinnerPublicationState {
isPublished: boolean
publishedAtLabel: string
publishedByLabel: string
statusLabel: string
statusTone: string
phaseLabel: string
canPublish: boolean
issues: WinnerPublicationIssue[]
}
export function useAdminWinnersManager() {
const store = useAwardsStore()
const route = useRoute()
const savingResultForCategory = ref<number | null>(null)
const deletingResultId = ref<number | null>(null)
const publishingWinners = ref(false)
const unpublishingWinners = ref(false)
const adminMessage = ref('')
const adminError = ref('')
const query = ref('')
const statusFilter = ref(allFilter)
const categoryFilter = ref<number | null>(null)
const selectedGroupName = ref<string | null>(null)
const focusedCategoryId = ref<number | null>(null)
const winnerSelections = reactive<Record<number, string>>({})
const seasonDetail = computed(() => store.adminSeasonDetail)
const workflowRules = computed(() => store.adminWorkflowRules.rules)
const readinessScope = computed(() => buildAdminReadinessScope(seasonDetail.value))
const resultMap = computed(() => new Map(seasonDetail.value.results.map((result) => [result.categoryId, result])))
const selectedCandidateMap = computed(() => new Map(seasonDetail.value.candidates.map((candidate) => [candidate.id, candidate])))
const workflowRuleSummary = computed(() => {
const workflowRuleSummary = computed<WinnerWorkflowRuleSummary>(() => {
const maxWinnerRule = findWorkflowRule(maxWinnerPlacementsRuleKey)
const clipRule = findWorkflowRule(winnerRequiresClipRuleKey)
return {
@@ -69,6 +126,19 @@ export function useAdminWinnersManager() {
return channel ? `slug:${channel}` : `name:${candidate.displayName.trim().toLowerCase()}`
}
function formatDateTime(value?: string | null) {
if (!value) return 'Noch nicht veröffentlicht'
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return parsed.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function selectedWinnerIdentitySummary(categoryId: number): WinnerIdentitySummary | null {
const candidateId = Number(winnerSelections[categoryId])
const selectedCandidate = selectedCandidateMap.value.get(candidateId)
@@ -150,7 +220,7 @@ export function useAdminWinnersManager() {
}
}
const resultRows = computed(() =>
const resultRows = computed<AdminWinnerResultRow[]>(() =>
seasonDetail.value.categories
.map((category) => {
const candidates = seasonDetail.value.candidates
@@ -162,14 +232,27 @@ export function useAdminWinnersManager() {
|| nomination.suggestedCategoryId === category.id,
).length
const approvedClips = seasonDetail.value.clipSubmissions.filter((clip) => clip.categoryId === category.id && clip.status === 'approved').length
const topVotingRanks = seasonDetail.value.votingWorkspace.categories
.find((item) => item.categoryId === category.id)
?.leaderboard
.slice(0, 5)
.map((candidate, index) => ({
candidateId: candidate.candidateId,
votes: candidate.votes,
voteSharePercent: candidate.voteSharePercent,
isTopTie: candidate.isTopTie,
rank: index + 1,
})) ?? []
return {
category,
candidates,
topVotingRanks,
existing,
openReviews,
approvedClips,
hasPendingReviews: openReviews > 0,
hasPendingReviews: openReviews > 0 && readinessScope.value.reviewWarningsActive,
isEmptyWarning: candidates.length === 0 && readinessScope.value.candidateWarningsActive,
isEmpty: candidates.length === 0,
isComplete: Boolean(existing),
}
@@ -177,12 +260,181 @@ export function useAdminWinnersManager() {
.sort((a, b) => a.category.sortOrder - b.category.sortOrder),
)
function rowMatchesFilters(row: AdminWinnerResultRow) {
const matchesStatus = statusFilter.value === allFilter
|| (statusFilter.value === 'open' && !row.isComplete)
|| (statusFilter.value === 'set' && row.isComplete)
|| (statusFilter.value === 'review' && row.hasPendingReviews)
|| (statusFilter.value === 'empty' && row.isEmpty)
if (!matchesStatus) return false
const normalizedQuery = query.value.trim().toLowerCase()
if (!normalizedQuery) return true
return [
row.category.name,
row.category.groupName,
row.existing?.candidateDisplayName ?? '',
...row.candidates.map((candidate) => `${candidate.displayName} ${candidate.channelSlug} ${candidate.platform}`),
].some((value) => value.toLowerCase().includes(normalizedQuery))
}
const winnerGroups = computed<AdminWinnerGroupSummary[]>(() => {
const grouped = new Map<string, AdminWinnerResultRow[]>()
for (const row of resultRows.value) {
const key = row.category.groupName.trim() || 'Ohne Gruppe'
const current = grouped.get(key)
if (current) {
current.push(row)
} else {
grouped.set(key, [row])
}
}
return [...grouped.entries()]
.map(([name, rows]) => {
const categories = rows
.filter((row) => rowMatchesFilters(row))
.sort((left, right) => left.category.sortOrder - right.category.sortOrder || left.category.name.localeCompare(right.category.name, 'de'))
if (categories.length === 0) {
return null
}
return {
name,
sortOrder: Math.min(...rows.map((row) => row.category.sortOrder)),
totalCategories: categories.length,
completedCount: categories.filter((row) => row.isComplete).length,
missingCount: categories.filter((row) => !row.isComplete).length,
pendingReviewCount: categories.filter((row) => row.hasPendingReviews).length,
emptyCount: categories.filter((row) => row.isEmptyWarning).length,
approvedClips: categories.reduce((sum, row) => sum + row.approvedClips, 0),
categories,
} satisfies AdminWinnerGroupSummary
})
.filter((group): group is AdminWinnerGroupSummary => group !== null)
.sort((left, right) => left.sortOrder - right.sortOrder || left.name.localeCompare(right.name, 'de'))
})
const selectedGroup = computed(() =>
winnerGroups.value.find((group) => group.name === selectedGroupName.value)
?? winnerGroups.value[0]
?? null,
)
const selectedGroupRows = computed(() => selectedGroup.value?.categories ?? [])
const completedCount = computed(() => resultRows.value.filter((row) => row.isComplete).length)
const missingCount = computed(() => Math.max(0, resultRows.value.length - completedCount.value))
const reviewWarningCount = computed(() => resultRows.value.filter((row) => row.hasPendingReviews).length)
const totalOpenReviewCount = computed(() => resultRows.value.reduce((sum, row) => sum + row.openReviews, 0))
const emptyCount = computed(() => resultRows.value.filter((row) => row.isEmpty).length)
const completionPct = computed(() => resultRows.value.length === 0 ? 0 : Math.round((completedCount.value / resultRows.value.length) * 100))
const publicationIssues = computed<WinnerPublicationIssue[]>(() => {
const issues: WinnerPublicationIssue[] = []
if (missingCount.value > 0) {
const isWarning = readinessScope.value.winnerWarningsActive || readinessScope.value.publicationWarningsActive
issues.push({
key: 'missing-winners',
label: isWarning ? `${missingCount.value} Gewinner fehlen` : `${missingCount.value} Gewinner später nötig`,
note: isWarning
? 'Jede Unterkategorie braucht einen gesetzten Gewinner.'
: 'Für diese Phase ist das neutral; vor der Freigabe braucht jede Unterkategorie einen Gewinner.',
isWarning,
})
}
if (totalOpenReviewCount.value > 0) {
const isWarning = readinessScope.value.reviewWarningsActive || readinessScope.value.publicationWarningsActive
issues.push({
key: 'open-reviews',
label: isWarning ? `${totalOpenReviewCount.value} Reviews offen` : `${totalOpenReviewCount.value} Reviews gesammelt`,
note: isWarning
? 'Offene Nominierungs-Reviews müssen vor der Veröffentlichung geklärt sein.'
: 'In der Nominierung ist das Sammelstand; vor Voting/Freigabe wird es handlungsrelevant.',
isWarning,
})
}
const clipRule = workflowRuleSummary.value.winnerRequiresClip
if (clipRule?.enabled && clipRule.mode === 'block') {
const missingClipCount = seasonDetail.value.results.filter((result) => {
const candidate = selectedCandidateMap.value.get(result.candidateId)
return !candidate?.clipCompilationUrl?.trim()
}).length
if (missingClipCount > 0) {
const isWarning = readinessScope.value.phaseKey !== 'nomination' || readinessScope.value.publicationWarningsActive
issues.push({
key: 'missing-clips',
label: isWarning ? `${missingClipCount} Gewinner ohne Clip` : `${missingClipCount} Clips später nötig`,
note: isWarning
? 'Die Workflow-Regel verlangt gepflegte YouTube-/Twitch-Compilation-Links.'
: 'Clip-Readiness ist für Voting/Freigabe relevant, aber noch kein Nominierungsproblem.',
isWarning,
})
}
}
const winnerRule = workflowRuleSummary.value.maxWinnerPlacements
if (winnerRule?.enabled && winnerRule.mode === 'block') {
const winnerIdentityCounts = seasonDetail.value.results
.reduce((map, result) => {
const key = candidateIdentityKey({
streamerIdentityId: result.streamerIdentityId,
displayName: result.candidateDisplayName,
channelSlug: result.candidateChannelSlug,
})
const current = map.get(key)
if (current) {
current.count += 1
} else {
map.set(key, { count: 1, displayName: result.candidateDisplayName })
}
return map
}, new Map<string, { count: number; displayName: string }>())
const duplicateWinner = [...winnerIdentityCounts.values()].find((item) => item.count > winnerRule.limit)
if (duplicateWinner) {
const isWarning = readinessScope.value.winnerWarningsActive || readinessScope.value.publicationWarningsActive
issues.push({
key: 'winner-identity',
label: isWarning
? `${duplicateWinner.displayName} gewinnt ${duplicateWinner.count}x`
: `${duplicateWinner.displayName} Limit später prüfen`,
note: isWarning
? `Limit laut Workflow-Regel: ${winnerRule.limit}.`
: `Das Limit wird vor der Gewinnerfreigabe relevant: ${winnerRule.limit}.`,
isWarning,
})
}
}
return issues
})
const publicationState = computed<WinnerPublicationState>(() => {
const isPublished = Boolean(seasonDetail.value.winnersPublishedAt)
return {
isPublished,
publishedAtLabel: formatDateTime(seasonDetail.value.winnersPublishedAt),
publishedByLabel: seasonDetail.value.winnersPublishedByTwitchId ? `von ${seasonDetail.value.winnersPublishedByTwitchId}` : 'ohne Admin-ID',
statusLabel: isPublished ? 'Öffentlich sichtbar' : 'Intern gespeichert',
statusTone: isPublished
? 'border-emerald-100 bg-emerald-50 text-emerald-800'
: readinessScope.value.publicationWarningsActive && publicationIssues.value.length === 0
? 'border-sky-100 bg-sky-50 text-sky-800'
: readinessScope.value.publicationWarningsActive
? 'border-amber-100 bg-amber-50 text-amber-800'
: 'border-violet-100 bg-violet-50 text-violet-800',
phaseLabel: phaseLabel(readinessScope.value.phaseKey),
canPublish: publicationIssues.value.length === 0 && resultRows.value.length > 0,
issues: publicationIssues.value,
}
})
const statusFilters = computed(() => [
{ value: allFilter, label: 'Alle', count: resultRows.value.length },
{ value: 'open', label: 'Offen', count: missingCount.value },
@@ -191,29 +443,6 @@ export function useAdminWinnersManager() {
{ value: 'empty', label: 'Ohne Kandidaten', count: emptyCount.value },
])
const visibleResultRows = computed(() => {
const normalizedQuery = query.value.trim().toLowerCase()
return resultRows.value.filter((row) => {
const matchesStatus = statusFilter.value === allFilter
|| (statusFilter.value === 'open' && !row.isComplete)
|| (statusFilter.value === 'set' && row.isComplete)
|| (statusFilter.value === 'review' && row.hasPendingReviews)
|| (statusFilter.value === 'empty' && row.isEmpty)
if (!matchesStatus) return false
if (categoryFilter.value && row.category.id !== categoryFilter.value) return false
if (!normalizedQuery) return true
return [
row.category.name,
row.category.groupName,
row.existing?.candidateDisplayName ?? '',
...row.candidates.map((candidate) => `${candidate.displayName} ${candidate.channelSlug} ${candidate.platform}`),
].some((value) => value.toLowerCase().includes(normalizedQuery))
})
})
const summaryCards = computed(() => [
{
label: 'Fortschritt',
@@ -221,13 +450,23 @@ export function useAdminWinnersManager() {
note: `${completedCount.value} von ${resultRows.value.length} Kategorien final.`,
icon: Trophy,
tone: 'border-violet-100 bg-violet-50 text-violet-800',
progress: completionPct.value,
},
{
label: 'Offen',
value: String(missingCount.value),
note: missingCount.value === 0 ? 'Keine Gewinner fehlen.' : 'Diese Kategorien brauchen eine Auswahl.',
note: missingCount.value === 0
? 'Keine Gewinner fehlen.'
: readinessScope.value.winnerWarningsActive
? 'Diese Kategorien brauchen eine Auswahl.'
: 'Für diese Phase noch kein Blocker.',
icon: ListChecks,
tone: missingCount.value > 0 ? 'border-sky-100 bg-sky-50 text-sky-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
tone: missingCount.value > 0 && readinessScope.value.winnerWarningsActive
? 'border-sky-100 bg-sky-50 text-sky-800'
: missingCount.value > 0
? 'border-violet-100 bg-violet-50 text-violet-800'
: 'border-emerald-100 bg-emerald-50 text-emerald-800',
progress: null,
},
{
label: 'Reviews',
@@ -235,13 +474,15 @@ export function useAdminWinnersManager() {
note: reviewWarningCount.value === 0 ? 'Keine Review-Warnungen.' : 'Vor finaler Freigabe pruefen.',
icon: Clock3,
tone: reviewWarningCount.value > 0 ? 'border-amber-100 bg-amber-50 text-amber-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
progress: null,
},
{
label: 'Gesetzt',
value: String(completedCount.value),
note: 'Speist Archiv und Gewinneransicht.',
note: publicationState.value.isPublished ? 'Auf der Landingpage sichtbar.' : 'Intern gespeichert bis zur Veröffentlichung.',
icon: CheckCircle2,
tone: 'border-emerald-100 bg-emerald-50 text-emerald-800',
progress: null,
},
])
@@ -249,12 +490,24 @@ export function useAdminWinnersManager() {
const rawQuery = Array.isArray(route.query.q) ? route.query.q[0] : route.query.q
const rawStatus = Array.isArray(route.query.status) ? route.query.status[0] : route.query.status
const rawCategoryId = Array.isArray(route.query.categoryId) ? route.query.categoryId[0] : route.query.categoryId
const rawGroup = Array.isArray(route.query.group) ? route.query.group[0] : route.query.group
query.value = typeof rawQuery === 'string' ? rawQuery : ''
const nextStatus = typeof rawStatus === 'string' ? rawStatus : allFilter
statusFilter.value = statusFilters.value.some((option) => option.value === nextStatus) ? nextStatus : allFilter
const parsedCategoryId = Number(rawCategoryId)
categoryFilter.value = Number.isFinite(parsedCategoryId) && parsedCategoryId > 0 ? parsedCategoryId : null
focusedCategoryId.value = Number.isFinite(parsedCategoryId) && parsedCategoryId > 0 ? parsedCategoryId : null
if (typeof rawGroup === 'string' && rawGroup.trim()) {
selectedGroupName.value = rawGroup
return
}
if (focusedCategoryId.value) {
selectedGroupName.value = resultRows.value.find((row) => row.category.id === focusedCategoryId.value)?.category.groupName ?? null
return
}
selectedGroupName.value = null
}
watch(resultRows, (rows) => {
@@ -270,14 +523,44 @@ export function useAdminWinnersManager() {
}
}, { immediate: true })
watch(winnerGroups, (groups) => {
if (!groups.some((group) => group.name === selectedGroupName.value)) {
selectedGroupName.value = groups[0]?.name ?? null
}
}, { immediate: true })
watch(selectedGroupRows, (rows) => {
if (!rows.some((row) => row.category.id === focusedCategoryId.value)) {
focusedCategoryId.value = rows[0]?.category.id ?? null
}
}, { immediate: true })
watch(
() => [route.query.q, route.query.status, route.query.categoryId] as const,
() => [route.query.q, route.query.status, route.query.categoryId, route.query.group] as const,
() => {
applyRouteFilters()
},
{ immediate: true },
)
function clearFilters() {
query.value = ''
statusFilter.value = allFilter
}
function selectGroup(groupName: string) {
selectedGroupName.value = groupName
focusedCategoryId.value = winnerGroups.value.find((group) => group.name === groupName)?.categories[0]?.category.id ?? null
}
function focusCategory(categoryId: number) {
focusedCategoryId.value = categoryId
const matchingRow = resultRows.value.find((row) => row.category.id === categoryId)
if (matchingRow) {
selectedGroupName.value = matchingRow.category.groupName
}
}
async function saveWinner(categoryId: number) {
const candidateId = Number(winnerSelections[categoryId])
if (!candidateId || !seasonDetail.value.id) return
@@ -316,21 +599,63 @@ export function useAdminWinnersManager() {
}
}
async function publishWinners() {
if (!seasonDetail.value.id || !publicationState.value.canPublish) return
publishingWinners.value = true
adminMessage.value = ''
adminError.value = ''
try {
await store.publishAdminWinners(seasonDetail.value.id)
adminMessage.value = 'Gewinner wurden auf der Landingpage veröffentlicht.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Gewinner konnten nicht veröffentlicht werden.'
} finally {
publishingWinners.value = false
}
}
async function unpublishWinners() {
if (!seasonDetail.value.id) return
unpublishingWinners.value = true
adminMessage.value = ''
adminError.value = ''
try {
await store.unpublishAdminWinners(seasonDetail.value.id)
adminMessage.value = 'Gewinner wurden von der Landingpage zurückgenommen.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Veröffentlichung konnte nicht zurückgenommen werden.'
} finally {
unpublishingWinners.value = false
}
}
return {
adminError,
adminMessage,
completionPct,
clearFilters,
deletingResultId,
focusedCategoryId,
query,
publishingWinners,
savingResultForCategory,
unpublishingWinners,
selectedGroup,
selectedGroupName,
selectedGroupRows,
selectGroup,
focusCategory,
statusFilter,
statusFilters,
summaryCards,
visibleResultRows,
winnerGroups,
winnerSelections,
clearWinner,
saveWinner,
workflowRuleSummary,
publicationState,
publishWinners,
unpublishWinners,
selectedWinnerIdentitySummary,
selectedWinnerClipPreview,
winnerRuleNoticeFor,
@@ -503,10 +503,14 @@
width: 84px;
}
.loader__dissolve {
transform: scale(0.74);
transform-origin: top left;
}
.loader__dissolve-dot--one { left: 35px; top: 45px; }
.loader__dissolve-dot--two { left: 113px; top: 27px; }
.loader__dissolve-dot--three { left: 208px; top: 58px; }
.loader__dissolve-dot--four { left: 59px; top: 118px; }
.loader__dissolve-dot--five { left: 138px; top: 103px; }
.loader__dissolve-dot--six { left: 213px; top: 148px; }
.loader__dissolve-dot--seven { left: 159px; top: 164px; }
.loader__dissolve-dot--eight { left: 25px; top: 157px; }
.loader__text {
font-size: 15px;
@@ -11,7 +11,6 @@ const props = defineProps<{
setArchiveYear: (year: number) => Promise<void>
archiveYearButtonStyle: (active: boolean) => string
winnerPlatformStyle: (url: string) => string
winnerPlatformKey: (url: string) => string
winnerPlatformLabel: (url: string) => string
}>()
@@ -57,10 +56,17 @@ function initialsFor(value: string) {
<div style="font-size:13px;color:rgba(255,246,251,.78);">{{ props.selectedArchive.winners.length }} Kategorien archiviert</div>
</div>
<div class="home-archive-modal__winners" style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;">
<article
<component
:is="winner.url ? 'a' : 'article'"
v-for="winner in props.selectedArchive.winners"
:key="`${props.selectedArchive.year}-${winner.category}`"
style="display:grid;grid-template-columns:84px minmax(0,1fr);gap:16px;padding:18px;border-radius:24px;background:rgba(255,255,255,.78);border:1px solid rgba(139,108,219,.12);box-shadow:0 12px 28px rgba(139,108,219,.08);align-items:start;"
:href="winner.url || undefined"
:target="winner.url ? '_blank' : undefined"
:rel="winner.url ? 'noopener' : undefined"
:aria-label="winner.url ? `${winner.name} auf ${props.winnerPlatformLabel(winner.url)} öffnen` : undefined"
style="display:grid;grid-template-columns:84px minmax(0,1fr);gap:16px;padding:18px;border-radius:24px;background:rgba(255,255,255,.78);border:1px solid rgba(139,108,219,.12);box-shadow:0 12px 28px rgba(139,108,219,.08);align-items:start;color:inherit;text-decoration:none;cursor:pointer;transition:transform .16s ease,box-shadow .16s ease,border-color .16s ease;"
style-hover="transform:translateY(-2px);box-shadow:0 18px 38px rgba(139,108,219,.16);border-color:rgba(139,108,219,.26);"
style-focus="outline:3px solid rgba(139,108,219,.28);outline-offset:3px;"
>
<div style="width:84px;height:84px;border-radius:24px;background:linear-gradient(135deg,#ffe1a3,#e7b13e);display:grid;place-items:center;color:#2f1b00;font-family:'Fredoka',sans-serif;font-size:26px;font-weight:800;box-shadow:0 12px 24px rgba(139,108,219,.16);">
{{ initialsFor(winner.name) }}
@@ -68,54 +74,14 @@ function initialsFor(value: string) {
<div style="min-width:0;">
<div style="font-size:10px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;color:#d9942a;margin-bottom:7px;">{{ winner.category }}</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:20px;line-height:1.2;color:#3f3556;">{{ winner.name }}</div>
<div style="font-size:13px;color:#8a8398;margin-top:5px;">{{ winner.handle }}</div>
<div style="font-size:13px;color:#8a8398;margin-top:5px;">Unterkategorie · {{ winner.subcategory }}</div>
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:9px;margin-top:12px;">
<a :href="winner.url" target="_blank" rel="noopener" :style="props.winnerPlatformStyle(winner.url)" style-hover="transform:translateY(-1px);opacity:.88;">
<span :style="props.winnerPlatformStyle(winner.url)">
{{ props.winnerPlatformLabel(winner.url) }}
</a>
<a
v-if="winner.clipUrl"
:href="winner.clipUrl"
target="_blank"
rel="noopener noreferrer"
referrerpolicy="no-referrer"
style="display:inline-flex;align-items:center;gap:6px;color:#b7791f;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:800;font-size:12px;"
>
{{ winner.clipTitle }}
</a>
</div>
<iframe
v-if="winner.clipEmbedUrl"
:src="winner.clipEmbedUrl"
:title="winner.clipEmbedTitle"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture; web-share"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"
style="width:100%;aspect-ratio:16/9;border:0;border-radius:14px;background:#1b0d2d;margin-top:12px;"
/>
<div
v-else-if="winner.clipUrl"
style="display:flex;align-items:center;justify-content:center;aspect-ratio:16/9;border-radius:14px;background:rgba(255,255,255,.88);border:1px solid rgba(139,108,219,.12);margin-top:12px;padding:14px;text-align:center;"
>
<a
:href="winner.clipUrl"
target="_blank"
rel="noopener noreferrer"
referrerpolicy="no-referrer"
style="color:#7c5bb4;text-decoration:none;font-family:'Outfit',sans-serif;font-size:13px;font-weight:700;"
>
Clip extern ansehen
</a>
</div>
<div
v-else
style="display:flex;align-items:center;justify-content:center;aspect-ratio:16/9;border-radius:14px;background:rgba(255,248,232,.92);border:1px dashed rgba(231,177,62,.42);margin-top:12px;padding:14px;text-align:center;color:#b7791f;font-family:'Outfit',sans-serif;font-size:13px;font-weight:700;"
>
Fuer diesen Gewinner ist kein Clip hinterlegt.
</span>
</div>
</div>
</article>
</component>
</div>
</div>
</div>
@@ -2,21 +2,52 @@
<section id="kategorien" class="home-section" style="max-width:1200px;margin:0 auto;padding:90px 24px;">
<div style="text-align:center;margin-bottom:52px;">
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent,#ff5fa2);margin-bottom:12px;"> Die Awards</div>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:0;">{{ landingCategories.length }} Hauptkategorien · 1 Sternenhimmel</h2>
<p style="font-size:17px;color:var(--muted,#c9b8da);max-width:560px;margin:0 auto;">Von Gaming bis Creative - jede Hauptkategorie bringt ihre eigenen Viewer-Tiers und Favoriten mit.</p>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:0;">{{ awardsTitle }}</h2>
<p style="font-size:17px;color:var(--muted,#c9b8da);max-width:680px;margin:0 auto;line-height:1.65;">{{ awardsDescription }}</p>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:18px;" data-cat-grid>
<div v-for="category in landingCategories" :key="category.id" style="padding:26px 22px;border-radius:22px;background:var(--card,#22123a);border:1px solid var(--line,rgba(255,255,255,.12));transition:transform .2s,border-color .2s;" style-hover="transform:translateY(-5px);border-color:var(--accent,#ff5fa2);">
<div style="display:inline-flex;align-items:center;justify-content:center;width:50px;height:50px;border-radius:15px;background:linear-gradient(135deg,var(--accent,#ff5fa2),var(--accent2,#a06bff));color:#fff;font-size:24px;margin-bottom:16px;box-shadow:0 8px 20px var(--glow,rgba(255,95,162,.4));">{{ category.icon }}</div>
<h3 style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:19px;margin:0 0 6px;">{{ category.name }}</h3>
<p style="font-size:14px;color:var(--muted,#c9b8da);margin:0;line-height:1.5;">{{ category.subcategoryCount }} Viewer-Tiers · {{ category.candidateCount }} Kandidat:innen aktuell.</p>
<p v-if="category.description" style="font-size:13px;color:var(--muted,#c9b8da);margin:12px 0 0;line-height:1.5;">{{ category.description }}</p>
<div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:18px;" data-cat-grid>
<article v-for="category in landingCategories" :key="category.id" style="position:relative;display:flex;flex-direction:column;gap:18px;min-height:100%;padding:28px 22px 24px;border-radius:28px;background:radial-gradient(circle at 18% 16%,rgba(255,255,255,.98),transparent 26%),radial-gradient(circle at 84% 20%,rgba(255,214,120,.18),transparent 20%),linear-gradient(160deg,rgba(255,255,255,.97),rgba(247,239,255,.96) 52%,rgba(239,229,255,.94));border:1px solid rgba(178,150,232,.28);box-shadow:0 20px 54px rgba(43,20,74,.10), inset 0 1px 0 rgba(255,255,255,.92), inset 0 -1px 0 rgba(176,145,232,.10);transition:transform .2s,border-color .2s,box-shadow .2s;" style-hover="transform:translateY(-5px);border-color:rgba(255,95,162,.34);box-shadow:0 28px 68px rgba(43,20,74,.14), inset 0 1px 0 rgba(255,255,255,.96), inset 0 -1px 0 rgba(176,145,232,.14);">
<span aria-hidden="true" style="position:absolute;top:14px;right:16px;font-size:10px;color:rgba(231,177,62,.9);text-shadow:0 0 12px rgba(231,177,62,.42);"></span>
<span aria-hidden="true" style="position:absolute;top:42px;right:34px;font-size:7px;color:rgba(164,122,238,.72);text-shadow:0 0 10px rgba(164,122,238,.32);"></span>
<div style="display:inline-flex;align-items:center;justify-content:center;width:60px;height:60px;border-radius:20px;background:radial-gradient(circle at 30% 28%,rgba(255,255,255,.34),transparent 30%),linear-gradient(135deg,var(--accent2,#8b6cdb),#b490ff 58%,#ffd678 140%);color:#fff;font-size:26px;box-shadow:0 16px 36px rgba(139,108,219,.30), inset 0 1px 0 rgba(255,255,255,.32);">{{ category.icon }}</div>
<div>
<h3 style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:clamp(21px,2.2vw,27px);line-height:1.15;color:#3d2f58;margin:0 0 10px;">{{ category.name }}</h3>
<p v-if="category.description" style="font-size:15px;color:#7a6f93;margin:0;line-height:1.65;">{{ category.description }}</p>
</div>
</article>
</div>
<div style="margin-top:34px;padding:28px 24px;border-radius:30px;background:linear-gradient(135deg,rgba(255,255,255,.94),rgba(245,236,255,.92));border:1px solid rgba(139,108,219,.14);box-shadow:0 18px 54px rgba(43,20,74,.08);">
<div style="max-width:780px;margin:0 auto 28px;text-align:center;">
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#a05acb;margin-bottom:12px;"> Unterkategorien</div>
<h3 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(28px,3.2vw,40px);line-height:1.15;color:#3d2f58;margin:0 0 14px;">{{ subcategoriesTitle }}</h3>
<p style="font-size:16px;color:#7a6f93;line-height:1.7;margin:0;">{{ subcategoriesDescription }}</p>
</div>
<div v-if="allSubcategoryNames.length" style="display:flex;flex-wrap:wrap;justify-content:center;gap:12px 10px;">
<span v-for="subcategoryName in allSubcategoryNames" :key="subcategoryName" style="position:relative;display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:10px 18px;border-radius:999px;background:radial-gradient(circle at 22% 18%,rgba(255,255,255,.95),transparent 34%),linear-gradient(135deg,rgba(255,255,255,.94),rgba(241,232,255,.92) 52%,rgba(234,222,255,.96));border:1px solid rgba(178,150,232,.36);color:#4f3d70;font-size:14px;font-weight:700;line-height:1.2;box-shadow:0 10px 24px rgba(139,108,219,.14), inset 0 1px 0 rgba(255,255,255,.9), inset 0 -1px 0 rgba(176,145,232,.12);">
<span aria-hidden="true" style="display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:999px;background:linear-gradient(135deg,rgba(164,122,238,.18),rgba(255,214,120,.22));color:#9a62df;font-size:11px;box-shadow:0 4px 10px rgba(154,98,223,.16), inset 0 1px 0 rgba(255,255,255,.78);"></span>
<span>{{ subcategoryName }}</span>
<span aria-hidden="true" style="position:absolute;top:8px;right:12px;font-size:8px;color:rgba(231,177,62,.82);text-shadow:0 0 10px rgba(231,177,62,.38);"></span>
</span>
</div>
<p v-else style="font-size:13px;color:#8d83a0;margin:0;line-height:1.5;text-align:center;">Noch keine Unterkategorien veröffentlicht.</p>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { plainTextContentFromStorage } from '../../lib/privacyContent'
type LandingSiteContent = {
awardsSectionTitle?: string | null
awardsSectionDescription?: string | null
subcategoriesSectionTitle?: string | null
subcategoriesSectionDescription?: string | null
}
type LandingCategory = {
id: string
groupName: string
@@ -24,11 +55,47 @@ type LandingCategory = {
icon: string
description: string
maxNomineesPerUser: number
candidateCount: number
subcategoryCount: number
subcategoryNames: string[]
}
defineProps<{
const props = defineProps<{
siteContent: LandingSiteContent
landingCategories: LandingCategory[]
}>()
const allSubcategoryNames = computed(() => {
const seen = new Set<string>()
const names: string[] = []
for (const category of props.landingCategories) {
for (const subcategoryName of category.subcategoryNames) {
const normalized = subcategoryName.trim()
if (!normalized) continue
const key = normalized.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
names.push(normalized)
}
}
return names
})
const awardsTitle = computed(() =>
plainTextContentFromStorage(props.siteContent.awardsSectionTitle ?? '') || 'Die Kategorien, die diese Award-Season zum Funkeln bringen',
)
const awardsDescription = computed(() =>
plainTextContentFromStorage(props.siteContent.awardsSectionDescription ?? '') || 'Von großen Community-Favoriten bis zu liebgewonnenen Szenen-Highlights: Jede Hauptkategorie entfaltet ihre eigenen Unterkategorien und macht die Show noch spannender.',
)
const subcategoriesTitle = computed(() =>
plainTextContentFromStorage(props.siteContent.subcategoriesSectionTitle ?? '') || 'Hier bekommt jede Story ihre Chance auf den großen Moment',
)
const subcategoriesDescription = computed(() =>
plainTextContentFromStorage(props.siteContent.subcategoriesSectionDescription ?? '') || 'Zu jeder Kategorie gehören eigene Unterkategorien, damit wirklich jede Reise ihren Platz hat: ob ganz frisch entdeckt, gerade im großen Aufwind oder schon fest in der Szene etabliert. Am Ende kann jede Person den Spot bekommen, der zu ihrer Geschichte passt.',
)
</script>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { computed, onUnmounted, reactive, ref, watch } from 'vue'
import { Handshake, Mic2, Send, X } from '@lucide/vue'
import { privacyContentToHtml } from '../../lib/privacyContent'
@@ -126,12 +126,19 @@ function closeShowactInfoModal() {
emit('update:modalOpen', false)
}
// For multiselect: values are stored as comma-separated strings
function getMultiselectValues(fieldId: string): string[] {
function getChoiceValues(fieldId: string): string[] {
const raw = fieldValues[fieldId] ?? ''
return raw ? raw.split(',').map((v) => v.trim()).filter(Boolean) : []
}
function getMultiselectValues(fieldId: string): string[] {
return getChoiceValues(fieldId)
}
function getCheckboxValues(fieldId: string): string[] {
return getChoiceValues(fieldId)
}
function otherFieldId(fieldId: string) {
return `${fieldId}__other`
}
@@ -140,6 +147,11 @@ function getOtherValue(fieldId: string) {
return (fieldValues[otherFieldId(fieldId)] ?? '').trim()
}
function setChoiceValues(fieldId: string, values: string[]) {
fieldValues[fieldId] = values.join(', ')
delete fieldErrors[fieldId]
}
function setMultiselectValue(fieldId: string, option: string, checked: boolean) {
const current = getMultiselectValues(fieldId)
if (checked) {
@@ -148,18 +160,20 @@ function setMultiselectValue(fieldId: string, option: string, checked: boolean)
const idx = current.indexOf(option)
if (idx !== -1) current.splice(idx, 1)
}
fieldValues[fieldId] = current.join(', ')
delete fieldErrors[fieldId]
setChoiceValues(fieldId, current)
}
function setCheckboxValue(field: ShowactFormField, checked: boolean) {
const value = field.options[0] || 'Ja'
fieldValues[field.id] = checked ? value : ''
delete fieldErrors[field.id]
}
function setCheckboxOptionValue(field: ShowactFormField, option: string, checked: boolean) {
const current = getCheckboxValues(field.id)
if (checked) {
if (!current.includes(option)) current.push(option)
} else {
const next = current.filter((value) => value !== option)
setChoiceValues(field.id, next)
return
}
function isCheckboxChecked(field: ShowactFormField) {
return Boolean((fieldValues[field.id] ?? '').trim())
setChoiceValues(field.id, current)
}
function updateOtherValue(field: ShowactFormField, value: string) {
@@ -173,7 +187,7 @@ function validateField(field: ShowactFormField): string {
if (field.required && !value && !otherValue) {
if (field.type === 'checkbox') {
return `Bitte bestaetige${field.label}".`
return `Bitte waehle bei${field.label}" mindestens eine Checkbox aus.`
}
if (field.type === 'radio' || field.type === 'select' || field.type === 'multiselect') {
@@ -333,7 +347,6 @@ async function submitShowact() {
const inputStyle = 'padding:13px 16px;border-radius:12px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:\'Outfit\',sans-serif;font-size:15px;color:#3f3556;outline:none;transition:border-color .15s;'
const labelStyle = 'font-family:\'Outfit\',sans-serif;font-size:13px;font-weight:600;color:#5f44ad;'
const errorStyle = 'font-family:\'Outfit\',sans-serif;font-size:12px;font-weight:600;color:#d14b72;line-height:1.45;'
const checkboxStyle = 'display:flex;align-items:center;gap:9px;padding:10px 14px;border-radius:12px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:\'Outfit\',sans-serif;font-size:14px;color:#3f3556;cursor:pointer;'
function onFocus(event: FocusEvent) {
;(event.target as HTMLElement).style.borderColor = '#8b6cdb'
@@ -398,6 +411,14 @@ watch(
},
{ immediate: true },
)
watch([localModalOpen, showactInfoModalOpen], ([form, info]) => {
document.body.style.overflow = form || info ? 'hidden' : ''
})
onUnmounted(() => {
document.body.style.overflow = ''
})
</script>
<template>
@@ -450,7 +471,7 @@ watch(
<h2 class="mt-0.5 text-xl font-black text-[#3f3556]">Als Showact auftreten</h2>
<p class="mt-1 text-sm leading-6 text-[#746b86]">
<template v-if="featureFlags.showactApplicationsEnabled">
Hast du eine Idee fuer einen Beitrag? Bewirb dich jetzt und zeig der Community, was du kannst.
Hast du eine Idee für einen Beitrag? Bewirb dich jetzt und zeig der Community, was du kannst.
</template>
<template v-else>
Showacts bleiben ein Teil der Award-Show. Aktuell ist das Bewerbungsfenster geschlossen, aber alle Infos bleiben hier erreichbar.
@@ -601,52 +622,65 @@ watch(
<div v-else-if="field.type === 'multiselect'" style="display:flex;flex-direction:column;gap:6px;margin-bottom:14px;">
<span :style="labelStyle">{{ field.label }}{{ field.required ? ' *' : '' }}</span>
<span v-if="field.helpText" style="font-family:'Outfit',sans-serif;font-size:12px;line-height:1.45;color:#8a819d;">{{ field.helpText }}</span>
<div :style="'display:flex;flex-direction:column;gap:6px;padding:10px 14px;border-radius:12px;border:1.5px solid #e6dcf6;background:#fbf9ff;' + (fieldErrors[field.id] ? 'border-color:#d14b72;background:#fff7fa;' : '')">
<div
class="home-showact-choice-group"
:class="{ 'home-showact-choice-group--invalid': Boolean(fieldErrors[field.id]) }"
>
<label
v-for="opt in field.options"
:key="opt"
style="display:flex;align-items:center;gap:8px;font-family:'Outfit',sans-serif;font-size:14px;color:#3f3556;cursor:pointer;"
class="home-showact-choice-option"
>
<input
type="checkbox"
:checked="getMultiselectValues(field.id).includes(opt)"
style="width:16px;height:16px;"
class="home-showact-choice-input"
@change="setMultiselectValue(field.id, opt, ($event.target as HTMLInputElement).checked); updateFieldError(field)"
/>
{{ opt }}
<span>{{ opt }}</span>
</label>
<label
v-if="field.allowOther"
style="display:flex;align-items:center;gap:8px;font-family:'Outfit',sans-serif;font-size:14px;color:#3f3556;cursor:pointer;"
class="home-showact-choice-option home-showact-choice-option--other"
>
<span style="width:16px;height:16px;border-radius:4px;border:1.5px solid #d8c9eb;background:#fff;"></span>
Andere:
<span class="home-showact-choice-faux-input" aria-hidden="true"></span>
<span>Andere:</span>
<input
:value="getOtherValue(field.id)"
type="text"
placeholder="Eigene Antwort"
style="min-width:0;flex:1;border:0;border-bottom:1.5px solid #d8c9eb;background:transparent;padding:4px 2px;font-family:'Outfit',sans-serif;font-size:14px;color:#3f3556;outline:none;"
class="home-showact-choice-other-input"
@input="updateOtherValue(field, ($event.target as HTMLInputElement).value)"
/>
</label>
<span v-if="field.options.length === 0" style="font-size:13px;color:#9ca3af;">Keine Optionen konfiguriert.</span>
<span v-if="field.options.length === 0" class="home-showact-choice-empty">Keine Optionen konfiguriert.</span>
</div>
<span v-if="fieldErrors[field.id]" :style="errorStyle">{{ fieldErrors[field.id] }}</span>
</div>
<!-- single checkbox confirmation -->
<!-- checkbox group -->
<div v-else-if="field.type === 'checkbox'" style="display:flex;flex-direction:column;gap:6px;margin-bottom:14px;">
<span :style="labelStyle">{{ field.label }}{{ field.required ? ' *' : '' }}</span>
<span v-if="field.helpText" style="font-family:'Outfit',sans-serif;font-size:12px;line-height:1.45;color:#8a819d;">{{ field.helpText }}</span>
<label :style="checkboxStyle + (fieldErrors[field.id] ? 'border-color:#d14b72;background:#fff7fa;' : '')">
<input
type="checkbox"
:checked="isCheckboxChecked(field)"
style="width:17px;height:17px;accent-color:#7c56c4;"
@change="setCheckboxValue(field, ($event.target as HTMLInputElement).checked); updateFieldError(field)"
/>
{{ field.options[0] || 'Ja' }}
</label>
<div
class="home-showact-choice-group"
:class="{ 'home-showact-choice-group--invalid': Boolean(fieldErrors[field.id]) }"
>
<label
v-for="opt in field.options"
:key="opt"
class="home-showact-choice-option"
>
<input
type="checkbox"
:checked="getCheckboxValues(field.id).includes(opt)"
class="home-showact-choice-input"
@change="setCheckboxOptionValue(field, opt, ($event.target as HTMLInputElement).checked); updateFieldError(field)"
/>
<span>{{ opt }}</span>
</label>
<span v-if="field.options.length === 0" class="home-showact-choice-empty">Keine Checkboxen konfiguriert.</span>
</div>
<span v-if="fieldErrors[field.id]" :style="errorStyle">{{ fieldErrors[field.id] }}</span>
</div>
</template>
@@ -693,7 +727,7 @@ watch(
<div style="overflow-y:auto;padding:28px 36px 32px;flex:1;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.7;color:#6f6685;">
<div v-if="showactInfoHtml" class="home-showact-info-content" v-html="showactInfoHtml" />
<p v-else style="margin:0;padding:14px 16px;border-radius:16px;border:1px solid #fde68a;background:#fffbeb;color:#92400e;font-weight:600;">
Fuer Showacts ist aktuell noch kein Landingpage-Inhalt hinterlegt.
Für Showacts ist aktuell noch kein Landingpage-Inhalt hinterlegt.
</p>
</div>
</div>
@@ -721,12 +755,88 @@ watch(
.home-showact-info-content :deep(li) {
margin: 3px 0;
}
</style>
<style scoped>
.home-showact-choice-group {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
border: 1.5px solid #e6dcf6;
border-radius: 14px;
background: #fbf9ff;
transition: border-color .15s ease, background .15s ease;
}
.home-showact-choice-group--invalid {
border-color: #d14b72;
background: #fff7fa;
}
.home-showact-choice-option {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(139, 108, 219, 0.12);
background: rgba(255, 255, 255, 0.86);
color: #3f3556;
font-family: 'Outfit', sans-serif;
font-size: 14px;
font-weight: 600;
line-height: 1.5;
cursor: pointer;
}
.home-showact-choice-option--other {
align-items: center;
}
.home-showact-choice-input {
width: 16px;
height: 16px;
margin-top: 2px;
accent-color: #7c56c4;
flex: none;
}
.home-showact-choice-faux-input {
width: 16px;
height: 16px;
margin-top: 1px;
border-radius: 4px;
border: 1.5px solid #d8c9eb;
background: #fff;
flex: none;
}
.home-showact-choice-other-input {
min-width: 0;
flex: 1;
border: 0;
border-bottom: 1.5px solid #d8c9eb;
background: transparent;
padding: 4px 2px;
color: #3f3556;
font-family: 'Outfit', sans-serif;
font-size: 14px;
outline: none;
}
.home-showact-choice-empty {
color: #9ca3af;
font-size: 13px;
font-weight: 600;
}
@media (max-width: 560px) {
.home-showact-grid {
grid-template-columns: 1fr !important;
}
.home-showact-choice-option--other {
align-items: flex-start;
flex-direction: column;
}
}
</style>
@@ -18,7 +18,7 @@
<div class="home-hero__content" style="position:relative;z-index:3;max-width:1200px;margin:0 auto;padding:60px 24px 70px;">
<div class="home-hero__copy" style="max-width:560px;">
<div class="home-hero__eyebrow" style="display:flex;align-items:center;gap:10px;font-size:13px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#a98ddb;margin-bottom:22px;">
<span style="color:#e7b13e;animation:spinSlow 9s linear infinite;display:inline-block;"></span> Die grosse Community-Auszeichnung
<span style="color:#e7b13e;animation:spinSlow 9s linear infinite;display:inline-block;"></span> Die große Community-Auszeichnung
</div>
<h1 style="margin:0 0 4px;font-family:'Cormorant Garamond',serif;font-weight:700;line-height:.92;">
<span style="display:block;font-size:clamp(56px,8.4vw,116px);letter-spacing:3px;color:#5f44ad;">VTUBER</span>
@@ -37,10 +37,12 @@ defineProps<{
phasePrimaryActionStyle: string
streamEyebrow: string
streamTitle: string
streamText: string
streamMeta: string
streamLive: boolean
streamLocked: boolean
streamLockedLabel: string
streamButtonLabel: string
streamButtonUrl: string
streamLockedTitle: string
statOneValue: string
statOneLabel: string
@@ -95,10 +97,12 @@ defineProps<{
:public-stream-url="publicStreamUrl"
:stream-eyebrow="streamEyebrow"
:stream-title="streamTitle"
:stream-text="streamText"
:stream-meta="streamMeta"
:stream-live="streamLive"
:stream-locked="streamLocked"
:stream-locked-label="streamLockedLabel"
:stream-button-label="streamButtonLabel"
:stream-button-url="streamButtonUrl"
:stream-locked-title="streamLockedTitle"
/>
@@ -1,41 +1,50 @@
<template>
<section class="home-stream-band" style="position:relative;overflow:hidden;background:linear-gradient(115deg,#241640 0%,#3a2168 48%,#5b3aa0 100%);">
<HomeStarField :count="13" variant="light" />
<div class="home-stream-band__inner" style="max-width:1200px;margin:0 auto;padding:26px 24px;display:flex;align-items:center;justify-content:space-between;gap:26px;flex-wrap:wrap;">
<div class="home-stream-band__lead" style="display:flex;align-items:center;gap:18px;">
<div class="home-stream-band__inner" style="max-width:1200px;margin:0 auto;padding:26px 24px;display:flex;align-items:center;justify-content:space-between;gap:26px;">
<div class="home-stream-band__lead" style="display:flex;align-items:center;justify-content:center;gap:18px;">
<div style="flex:none;display:inline-flex;align-items:center;justify-content:center;width:54px;height:54px;border-radius:15px;background:rgba(145,70,255,.22);border:1px solid rgba(255,255,255,.18);">
<svg width="26" height="26" viewBox="0 0 24 24" fill="#c9b1ff"><path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/><path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z"/></svg>
</div>
<div>
<div style="font-size:11px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#c9b1ff;margin-bottom:5px;">{{ streamEyebrow }}</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:26px;color:#fff;line-height:1.1;">{{ streamTitle }}</div>
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:8px 14px;font-size:14px;color:rgba(255,255,255,.78);margin-top:6px;">
<div class="home-stream-band__copy">
<div class="home-stream-band__eyebrow" style="font-size:11px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#c9b1ff;margin-bottom:7px;">{{ streamEyebrow }}</div>
<div class="home-stream-band__headline-row" style="display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap;">
<span class="home-stream-band__title" style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(28px,3vw,36px);color:#fff;line-height:1.02;">
{{ streamTitle }}
</span>
<span class="home-stream-band__countdown-chip" aria-label="Countdown bis zum Finale" style="display:inline-flex;align-items:center;gap:10px;padding:10px 16px;border-radius:999px;background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.14);box-shadow:inset 0 1px 0 rgba(255,255,255,.08);">
<span
data-dc-ref="streamCountdownLabelRef"
class="home-stream-band__countdown-label"
style="font-family:'Outfit',sans-serif;font-weight:700;font-size:14px;color:#e9dcff;line-height:1;"
>
Finale in
</span>
<span class="home-stream-band__countdown-time" style="display:inline-flex;align-items:baseline;gap:4px;font-family:'Outfit',sans-serif;font-size:clamp(22px,2.4vw,28px);font-weight:800;line-height:1;color:#fff;">
<span data-dc-ref="bdRef">00</span><span class="home-stream-band__countdown-unit" style="opacity:.7;">T</span>
<span data-dc-ref="bhRef">00</span><span class="home-stream-band__countdown-unit" style="opacity:.45;">:</span>
<span data-dc-ref="bmRef">00</span><span class="home-stream-band__countdown-unit" style="opacity:.45;">:</span>
<span data-dc-ref="bsRef">00</span>
</span>
</span>
</div>
<div class="home-stream-band__meta" style="display:flex;align-items:center;justify-content:center;flex-wrap:wrap;gap:8px 14px;font-size:14px;color:rgba(255,255,255,.78);margin-top:8px;">
<span style="display:inline-flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9b1ff" stroke-width="2"><rect x="3" y="4.5" width="18" height="17" rx="2.5"/><path d="M3 9h18M8 2.5v4M16 2.5v4" stroke-linecap="round"/></svg>{{ streamMeta }}</span>
<span style="width:4px;height:4px;border-radius:50%;background:rgba(255,255,255,.4);"></span>
<span>Countdown bis zur grossen Live-Show</span>
<span>{{ streamText }}</span>
</div>
</div>
</div>
<template v-if="streamLive || streamLocked">
<div class="home-stream-band__actions" :class="{ 'home-stream-band__actions--locked': streamLocked }" style="display:flex;flex-direction:column;align-items:center;gap:9px;">
<div style="display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;letter-spacing:.5px;color:rgba(255,255,255,.7);">
<span data-dc-ref="streamCountdownLabelRef">Finale startet in</span>
<span style="display:inline-flex;align-items:center;gap:4px;font-family:'Outfit',sans-serif;font-weight:700;color:#fff;">
<span data-dc-ref="bdRef">00</span><span style="opacity:.6;">T</span>
<span data-dc-ref="bhRef">00</span><span style="opacity:.6;">:</span>
<span data-dc-ref="bmRef">00</span><span style="opacity:.6;">:</span>
<span data-dc-ref="bsRef">00</span>
</span>
</div>
<a v-if="streamLive" :href="publicStreamUrl" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:11px;padding:16px 28px;border-radius:14px;background:#ec3b5a;color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:700;font-size:16px;box-shadow:0 12px 30px rgba(236,59,90,.45);" style-hover="transform:translateY(-2px);">
<span style="width:9px;height:9px;border-radius:50%;background:#fff;display:inline-block;animation:pulseGlow 1.4s ease-in-out infinite;"></span>
Jetzt live · Zum Stream
<div class="home-stream-band__actions" :class="{ 'home-stream-band__actions--locked': streamLocked }" style="display:flex;flex-direction:column;align-items:flex-end;gap:9px;">
<a v-if="streamButtonHref" :href="streamButtonHref" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:11px;padding:16px 28px;border-radius:14px;background:#ec3b5a;color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:700;font-size:16px;box-shadow:0 12px 30px rgba(236,59,90,.45);" style-hover="transform:translateY(-2px);">
<span v-if="streamLive" style="width:9px;height:9px;border-radius:50%;background:#fff;display:inline-block;animation:pulseGlow 1.4s ease-in-out infinite;"></span>
{{ streamButtonLabel }}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
</a>
<div v-else :title="streamLockedTitle" style="display:inline-flex;align-items:center;gap:9px;padding:14px 24px;border-radius:14px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.16);color:rgba(255,255,255,.62);font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:not-allowed;">
<svg width="15" height="15" viewBox="0 0 24 24" fill="rgba(255,255,255,.62)"><path d="M17 9V7a5 5 0 0 0-10 0v2H5v13h14V9h-2zM9 7a3 3 0 0 1 6 0v2H9V7zm3 11a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"/></svg>
{{ streamLockedLabel }}
{{ streamButtonLabel }}
</div>
</div>
</template>
@@ -44,16 +53,22 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import HomeStarField from './HomeStarField.vue'
defineProps<{
const props = defineProps<{
publicStreamUrl: string
streamEyebrow: string
streamTitle: string
streamText: string
streamMeta: string
streamLive: boolean
streamLocked: boolean
streamLockedLabel: string
streamButtonLabel: string
streamButtonUrl: string
streamLockedTitle: string
}>()
const streamButtonHref = computed(() => props.streamButtonUrl || (props.streamLive ? props.publicStreamUrl : ''))
</script>
@@ -195,12 +195,12 @@ const nominationRailItems = computed(() => props.catOptions.map((option) => {
}
}))
const nominationProgressText = computed(() =>
`${nominationReviewItems.value.length} / ${props.catOptions.length} Kategorien ausgefuellt`,
`${nominationReviewItems.value.length} / ${props.catOptions.length} Kategorien ausgefüllt`,
)
const nominationHelperText = computed(() =>
nominationTotalLinks.value > 0
? `${nominationTotalLinks.value} Link${nominationTotalLinks.value === 1 ? '' : 's'} bereit. Leere Kategorien werden uebersprungen.`
: 'Leere Kategorien kannst du einfach ueberspringen.',
? `${nominationTotalLinks.value} Link${nominationTotalLinks.value === 1 ? '' : 's'} bereit. Leere Kategorien werden übersprungen.`
: 'Leere Kategorien kannst du einfach überspringen.',
)
const isLastNominationCategory = computed(() => {
const activeIndex = props.catOptions.findIndex((option) => option.id === nominationCatValue.value)
@@ -329,7 +329,7 @@ function stripCategoryIcon(value: string) {
<div class="home-modal__show" style="padding:38px 40px 40px;">
<div style="display:inline-flex;align-items:center;gap:8px;padding:5px 14px;border-radius:999px;background:#f3eefb;color:#a98ddb;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:18px;">Award-Show</div>
<h3 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:28px;line-height:1.14;margin:0 0 10px;color:#3f3556;">Sei live dabei </h3>
<p style="font-size:15.5px;line-height:1.6;color:#7d7491;margin:0 0 24px;">Die grosse Live-Show findet am <strong style="color:#5f44ad;">{{ props.formatShowDate() }}</strong> statt. Aktiviere eine Erinnerung, damit du nichts verpasst.</p>
<p style="font-size:15.5px;line-height:1.6;color:#7d7491;margin:0 0 24px;">Die große Live-Show findet am <strong style="color:#5f44ad;">{{ props.formatShowDate() }}</strong> statt. Aktiviere eine Erinnerung, damit du nichts verpasst.</p>
<div class="home-modal__reminder-form" style="display:flex;gap:10px;margin-bottom:14px;">
<input data-dc-ref="emailRef" type="email" placeholder="deine@email.de" style="flex:1;padding:14px 16px;border-radius:12px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:'Outfit',sans-serif;font-size:15px;color:#3f3556;outline:none;" style-focus="border-color:#8b6cdb;" />
<button @click="props.submitReminder" style="flex:none;padding:14px 24px;border-radius:12px;border:none;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:pointer;box-shadow:0 10px 22px rgba(124,86,196,.3);" style-hover="transform:translateY(-2px);">Erinnern</button>
@@ -384,12 +384,12 @@ function stripCategoryIcon(value: string) {
:helper-text="nominationHelperText"
:actions="nominationReviewMode
? [
{ label: 'Zurueck', tone: 'secondary', onClick: closeNominationReview },
{ label: 'Zurück', tone: 'secondary', onClick: closeNominationReview },
{ label: props.submitting ? 'Speichert ...' : 'Nominierungen einreichen', disabled: props.submitting || nominationHasErrors || nominationTotalLinks === 0, onClick: submitNominationDrafts },
]
: [
{ label: 'Zurueck', tone: 'secondary', disabled: nominationCatValue === props.catOptions[0]?.id, onClick: goToPreviousNominationCategory },
{ label: isLastNominationCategory ? 'Nominierungen pruefen' : 'Weiter', disabled: nominationHasErrors, onClick: isLastNominationCategory ? openNominationReview : goToNextNominationCategory },
{ label: 'Zurück', tone: 'secondary', disabled: nominationCatValue === props.catOptions[0]?.id, onClick: goToPreviousNominationCategory },
{ label: isLastNominationCategory ? 'Nominierungen prüfen' : 'Weiter', disabled: nominationHasErrors, onClick: isLastNominationCategory ? openNominationReview : goToNextNominationCategory },
]"
/>
</div>
@@ -436,7 +436,7 @@ function stripCategoryIcon(value: string) {
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Kategorie</label>
<HomeSelectDropdown
v-model="clipCatValue"
label="Kategorie fuer Clip auswaehlen"
label="Kategorie für Clip auswählen"
:options="props.catOptions"
@change="handleClipCategoryChange"
/>
@@ -66,8 +66,10 @@ const {
phasePrimaryActionStyle,
streamEyebrow,
streamTitle,
streamText,
streamMeta,
streamLockedLabel,
streamButtonLabel,
streamButtonUrl,
streamLockedTitle,
statOneValue,
statOneLabel,
@@ -93,6 +95,7 @@ const {
clipSubmitStyle,
archiveYears,
selectedArchive,
winnerShowcaseYear,
winnerShowcase,
activeCat,
submitted,
@@ -128,7 +131,6 @@ const {
onCloseArchive,
archiveYearButtonStyle,
winnerPlatformStyle,
winnerPlatformKey,
winnerPlatformLabel,
privacyModalStop,
accountModalStop,
@@ -239,10 +241,12 @@ const { setRootEl, landingLoaderVisible, stickyCountdownVisible, handleClipSubmi
:phase-primary-action-style="phasePrimaryActionStyle"
:stream-eyebrow="streamEyebrow"
:stream-title="streamTitle"
:stream-text="streamText"
:stream-meta="streamMeta"
:stream-live="streamLive"
:stream-locked="streamLocked"
:stream-locked-label="streamLockedLabel"
:stream-button-label="streamButtonLabel"
:stream-button-url="streamButtonUrl"
:stream-locked-title="streamLockedTitle"
:stat-one-value="statOneValue"
:stat-one-label="statOneLabel"
@@ -273,10 +277,10 @@ const { setRootEl, landingLoaderVisible, stickyCountdownVisible, handleClipSubmi
:on-timeline-final-action="onTimelineFinalAction"
/>
<HomeCategoriesSection :landing-categories="landingCategories" />
<HomeCategoriesSection :site-content="siteContent" :landing-categories="landingCategories" />
<HomeWinnerShowcaseSection
:selected-archive="selectedArchive"
:winner-showcase-year="winnerShowcaseYear"
:winner-showcase="winnerShowcase"
:initials-for="initialsFor"
:on-open-archive="onOpenArchive"
@@ -356,7 +360,6 @@ const { setRootEl, landingLoaderVisible, stickyCountdownVisible, handleClipSubmi
:set-archive-year="setArchiveYear"
:archive-year-button-style="archiveYearButtonStyle"
:winner-platform-style="winnerPlatformStyle"
:winner-platform-key="winnerPlatformKey"
:winner-platform-label="winnerPlatformLabel"
:privacy-modal-open="privacyModalOpen"
:privacy-content-html="privacyContentHtml"
@@ -59,7 +59,6 @@ defineProps<{
setArchiveYear: (year: number) => Promise<void>
archiveYearButtonStyle: (active: boolean) => string
winnerPlatformStyle: (url: string) => string
winnerPlatformKey: (url: string) => string
winnerPlatformLabel: (url: string) => string
privacyModalOpen: boolean
privacyContentHtml: string
@@ -133,7 +132,6 @@ defineProps<{
:set-archive-year="setArchiveYear"
:archive-year-button-style="archiveYearButtonStyle"
:winner-platform-style="winnerPlatformStyle"
:winner-platform-key="winnerPlatformKey"
:winner-platform-label="winnerPlatformLabel"
/>
@@ -11,18 +11,18 @@ const props = defineProps<{
<template>
<section class="home-missing-votes">
<p class="home-vote-picker__eyebrow">Pruefen</p>
<h4>Nicht alle Kategorien gewaehlt</h4>
<p class="home-vote-picker__eyebrow">Prüfen</p>
<h4>Nicht alle Kategorien gewählt</h4>
<p>
In {{ props.missingCategories.length }} Kategorien fehlt noch eine Stimme. Du kannst trotzdem speichern
oder zurueckgehen und weitere Kategorien auswaehlen.
oder zurückgehen und weitere Kategorien auswählen.
</p>
<div class="home-missing-votes__chips">
<span v-for="category in props.missingCategories" :key="category">{{ category }}</span>
</div>
<div v-if="!props.hideActions" class="home-missing-votes__actions">
<button type="button" class="home-wizard-footer__button home-wizard-footer__button--secondary" @click="props.onBack">
Zurueck zum Voting
Zurück zum Voting
</button>
<button
type="button"
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
export interface HomeNominationReviewItem {
categoryIndex: number
categoryName: string
@@ -11,36 +13,73 @@ const props = defineProps<{
totalCategories: number
onEditCategory: (index: number) => void
}>()
const skippedCategoryCount = computed(() =>
Math.max(0, props.totalCategories - props.items.length),
)
const reviewStats = computed(() => [
{
label: 'Links bereit',
value: `${props.totalLinks}`,
},
{
label: 'Kategorien gefuellt',
value: `${props.items.length}/${props.totalCategories}`,
},
{
label: 'Werden uebersprungen',
value: `${skippedCategoryCount.value}`,
},
])
</script>
<template>
<section class="home-nomination-review">
<div class="home-nomination-review__hero">
<p class="home-vote-picker__eyebrow">Pruefen</p>
<p class="home-vote-picker__eyebrow">Prüfen</p>
<h4>Nominierungen einreichen?</h4>
<p>
Du reichst {{ props.totalLinks }} Links in {{ props.items.length }} von
{{ props.totalCategories }} Kategorien ein. Leere Kategorien werden uebersprungen.
{{ props.totalCategories }} Kategorien ein. Leere Kategorien werden übersprungen.
</p>
<div class="home-nomination-review__stats">
<div v-for="stat in reviewStats" :key="stat.label" class="home-nomination-review__stat">
<span>{{ stat.label }}</span>
<strong>{{ stat.value }}</strong>
</div>
</div>
</div>
<div v-if="props.items.length" class="home-nomination-review__list">
<article v-for="item in props.items" :key="item.categoryName" class="home-nomination-review__item">
<div>
<h5>{{ item.categoryName }}</h5>
<div class="home-nomination-review__item-header">
<div class="home-nomination-review__item-copy">
<p class="home-nomination-review__item-eyebrow">Kategorie</p>
<h5>{{ item.categoryName }}</h5>
</div>
<span>{{ item.links.length }} Link{{ item.links.length === 1 ? '' : 's' }}</span>
</div>
<ul>
<li v-for="link in item.links" :key="link">{{ link }}</li>
<ul class="home-nomination-review__links">
<li v-for="link in item.links" :key="link">
<span class="home-nomination-review__link-dot" aria-hidden="true"></span>
<span>{{ link }}</span>
</li>
</ul>
<button type="button" @click="props.onEditCategory(item.categoryIndex)">
<button class="home-nomination-review__edit" type="button" @click="props.onEditCategory(item.categoryIndex)">
Bearbeiten
</button>
</article>
</div>
<div v-else class="home-vote-picker__empty">
Noch keine Links eingetragen.
<div v-else class="home-nomination-review__empty">
<div class="home-nomination-review__empty-icon" aria-hidden="true"></div>
<div class="home-nomination-review__empty-copy">
<p class="home-nomination-review__empty-eyebrow">Noch leer</p>
<h5>Noch keine Links eingetragen.</h5>
<p>Fülle links mindestens eine Kategorie aus, dann erscheint hier deine finale Einreichungsübersicht.</p>
</div>
</div>
</section>
</template>
@@ -51,8 +51,8 @@ const props = defineProps<{
class="home-nomination-pane__add-link"
@click="props.onAddLinkField"
>
<span>+ Weiteren Link hinzufuegen</span>
<small>Noch {{ props.remainingLinkCount }} moeglich</small>
<span>+ Weiteren Link hinzufügen</span>
<small>Noch {{ props.remainingLinkCount }} möglich</small>
</button>
</section>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import { privacyContentToHtml } from '../../lib/privacyContent'
@@ -41,12 +41,29 @@ const props = defineProps<{
platformKey: (platform: string | null | undefined) => string
}>()
const openFaqIndex = ref<number | null>(null)
function toggleFaq(index: number) {
openFaqIndex.value = openFaqIndex.value === index ? null : index
}
const activeFooterLink = ref<FooterLink | null>(null)
const linkCopied = ref(false)
const activeFooterHtml = computed(() => privacyContentToHtml(activeFooterLink.value?.content || ''))
const safeNewsletterUrl = computed(() =>
isSafePublicUrl(props.siteContent.newsletterUrl) ? props.siteContent.newsletterUrl : '',
)
const isSponsorFooterModal = computed(() => activeFooterLink.value?.key === 'sponsors')
const footerModalEyebrow = computed(() =>
isSponsorFooterModal.value ? 'Support the show' : 'Footer Seite',
)
const footerModalIntro = computed(() => {
if (isSponsorFooterModal.value) {
return 'Partner, Sponsoren und kreative Wegbegleiter machen diese Award-Nacht erst möglich.'
}
return 'Alle Infos kommen direkt aus dem Landingpage-Content und bleiben dadurch jederzeit aktuell.'
})
function openFooterLink(event: Event, link: FooterLink) {
if (link.key === 'showacts' && props.onShowactApply) {
@@ -89,6 +106,14 @@ function isSafePublicUrl(value: string | null | undefined) {
return false
}
}
watch(activeFooterLink, (link) => {
document.body.style.overflow = link ? 'hidden' : ''
})
onUnmounted(() => {
document.body.style.overflow = ''
})
</script>
<template>
@@ -155,10 +180,21 @@ function isSafePublicUrl(value: string | null | undefined) {
<p style="font-size:17px;color:var(--muted,#8a8398);max-width:520px;margin:0 auto;">Alles, was du über Nominierung, Voting und die Show wissen musst.</p>
</div>
<div style="display:flex;flex-direction:column;gap:14px;">
<details v-for="item in props.faqItems" :key="item.question" style="background:#ffffff;border:1px solid var(--line,rgba(124,86,196,.16));border-radius:16px;padding:4px 22px;box-shadow:0 8px 24px rgba(124,86,196,.07);">
<summary style="display:flex;align-items:center;justify-content:space-between;gap:16px;cursor:pointer;list-style:none;padding:18px 0;font-family:'Outfit',sans-serif;font-weight:600;font-size:18px;color:var(--ink,#3f3556);">{{ item.question }}<span style="flex:none;display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;background:#f1ecfb;color:#8b6cdb;font-size:16px;transition:transform .2s;">+</span></summary>
<p style="margin:0 0 18px;font-size:15px;line-height:1.65;color:var(--muted,#8a8398);">{{ item.answer }}</p>
</details>
<div
v-for="(item, index) in props.faqItems"
:key="item.question"
class="faq-item"
:class="{ 'faq-item--open': openFaqIndex === index }"
>
<button class="faq-trigger" @click="toggleFaq(index)" :aria-expanded="openFaqIndex === index">
<span class="faq-question">
<span class="faq-star" aria-hidden="true"></span>
{{ item.question }}
</span>
<span class="faq-toggle" aria-hidden="true">{{ openFaqIndex === index ? '×' : '+' }}</span>
</button>
<p v-show="openFaqIndex === index" class="faq-answer">{{ item.answer }}</p>
</div>
</div>
</section>
@@ -199,16 +235,35 @@ function isSafePublicUrl(value: string | null | undefined) {
<template v-if="activeFooterLink">
<div class="home-modal-overlay" @click="closeFooterLink" style="position:fixed;inset:0;z-index:420;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.55);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);">
<div class="home-modal" @click.stop style="position:relative;width:100%;max-width:720px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);">
<div style="display:flex;align-items:center;justify-content:space-between;gap:18px;padding:24px 32px 18px;border-bottom:1px solid #f1ecfb;flex:none;">
<div>
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#8b6cdb;margin-bottom:4px;">Footer Seite</div>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:26px;margin:0;color:#3f3556;">{{ activeFooterLink.label }}</h2>
<div class="home-modal" @click.stop style="position:relative;width:100%;max-width:780px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:linear-gradient(180deg,#fffaff 0%,#ffffff 42%,#faf7ff 100%);border:1px solid rgba(221,208,242,.9);border-radius:32px;box-shadow:0 40px 90px rgba(40,24,70,.4);">
<div style="position:relative;display:flex;align-items:flex-start;justify-content:space-between;gap:18px;padding:28px 32px 24px;border-bottom:1px solid #f1ecfb;flex:none;overflow:hidden;background:radial-gradient(circle at top left,rgba(193,163,255,.22),transparent 42%),radial-gradient(circle at top right,rgba(255,191,219,.2),transparent 36%),linear-gradient(135deg,#fbf7ff 0%,#ffffff 54%,#fff7fb 100%);">
<div style="position:absolute;right:88px;top:20px;font-size:14px;color:#d6c3fb;"> </div>
<div style="max-width:560px;position:relative;z-index:1;">
<div style="display:inline-flex;align-items:center;gap:8px;padding:8px 12px;border-radius:999px;background:rgba(255,255,255,.82);border:1px solid rgba(221,208,242,.95);box-shadow:0 10px 24px rgba(124,86,196,.08);font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#8b6cdb;margin-bottom:14px;">
<span style="font-size:12px;"></span>
{{ footerModalEyebrow }}
</div>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(28px,4vw,34px);line-height:1.05;margin:0;color:#3f3556;">{{ activeFooterLink.label }}</h2>
<p style="margin:14px 0 0;font-family:'Outfit',sans-serif;font-size:15px;line-height:1.7;color:#7d7491;">
{{ footerModalIntro }}
</p>
</div>
<button @click="closeFooterLink" style="width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;" style-hover="background:#e6dcf6;"></button>
<button type="button" @click="closeFooterLink" style="position:relative;z-index:1;width:42px;height:42px;border-radius:50%;border:1px solid rgba(221,208,242,.95);background:rgba(255,255,255,.92);color:#8b6cdb;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 12px 28px rgba(124,86,196,.12);" style-hover="background:#f3ebff;"></button>
</div>
<div style="overflow-y:auto;padding:28px 32px;flex:1;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.7;color:#6f6685;">
<div v-if="activeFooterHtml" class="home-footer-page-content" v-html="activeFooterHtml" />
<div style="overflow-y:auto;padding:28px 32px 32px;flex:1;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.7;color:#6f6685;">
<div
v-if="isSponsorFooterModal"
style="display:grid;gap:14px;margin-bottom:18px;"
>
<div style="display:flex;flex-wrap:wrap;gap:10px;">
<span class="home-sponsor-pill">Für Newcomer, Rising Stars und Legenden</span>
<span class="home-sponsor-pill">Community, Show und Sichtbarkeit</span>
<span class="home-sponsor-pill">Gemeinsam tragen wir die Bühne</span>
</div>
</div>
<div v-if="activeFooterHtml" class="home-footer-modal-surface">
<div class="home-footer-page-content" v-html="activeFooterHtml" />
</div>
<p v-else style="margin:0;padding:14px 16px;border-radius:16px;border:1px solid #fde68a;background:#fffbeb;color:#92400e;font-weight:600;">
Für diese Footer-Seite ist noch kein Inhalt hinterlegt.
</p>
@@ -219,6 +274,89 @@ function isSafePublicUrl(value: string | null | undefined) {
</template>
<style scoped>
/* FAQ accordion */
.faq-item {
background: #ffffff;
border: 1px solid rgba(124, 86, 196, 0.16);
border-radius: 16px;
padding: 4px 22px;
box-shadow: 0 8px 24px rgba(124, 86, 196, 0.07);
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
.faq-item--open {
border-color: rgba(139, 108, 219, 0.45);
box-shadow: 0 8px 28px rgba(124, 86, 196, 0.15);
background: linear-gradient(135deg, #fdfbff 0%, #f5f0ff 100%);
}
.faq-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
padding: 18px 0;
background: none;
border: none;
cursor: pointer;
font-family: 'Outfit', sans-serif;
font-weight: 600;
font-size: 18px;
color: #3f3556;
text-align: left;
transition: color 0.2s;
}
.faq-item--open .faq-trigger {
color: #6b44c0;
}
.faq-question {
display: flex;
align-items: center;
gap: 10px;
}
.faq-star {
flex: none;
font-size: 11px;
color: #c4aef7;
transition: color 0.2s;
}
.faq-item--open .faq-star {
color: #8b6cdb;
}
.faq-toggle {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 50%;
background: #f1ecfb;
color: #8b6cdb;
font-size: 18px;
line-height: 1;
transition: background 0.2s;
}
.faq-item--open .faq-toggle {
background: #e4d8ff;
}
.faq-answer {
margin: 0 0 18px;
font-size: 15px;
line-height: 1.65;
color: #8a8398;
padding-left: 21px;
border-left: 2px solid #d8c8ff;
}
.home-footer-page-content :deep(p) {
margin: 0 0 14px;
}
@@ -238,4 +376,37 @@ function isSafePublicUrl(value: string | null | undefined) {
.home-footer-page-content :deep(li) {
margin: 3px 0;
}
.home-footer-modal-surface {
border: 1px solid rgba(221, 208, 242, 0.9);
border-radius: 24px;
padding: 22px 22px 20px;
background:
radial-gradient(circle at top right, rgba(255, 206, 232, 0.16), transparent 26%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(249, 244, 255, 0.96) 100%);
box-shadow: 0 20px 44px rgba(124, 86, 196, 0.1);
}
.home-sponsor-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 999px;
border: 1px solid rgba(212, 194, 247, 0.95);
background:
radial-gradient(circle at top, rgba(255, 255, 255, 0.95), rgba(245, 238, 255, 0.98)),
linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(243, 235, 255, 0.96));
color: #6d55a8;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.03em;
box-shadow: 0 12px 26px rgba(124, 86, 196, 0.08);
}
.home-sponsor-pill::before {
content: '✦';
color: #b089ff;
font-size: 12px;
}
</style>
@@ -3,13 +3,13 @@
<div style="text-align:center;margin-bottom:56px;">
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent,#8b6cdb);margin-bottom:12px;"> Der Ablauf</div>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:0;color:#3f3556;">In vier Schritten auf die Bühne</h2>
<p style="font-size:17px;color:var(--muted,#8a8398);max-width:620px;margin:0 auto;">Von Nominierung und Voting über die Aufbereitung bis ganz zum Schluss zur grossen Show.</p>
<p style="font-size:17px;color:var(--muted,#8a8398);max-width:620px;margin:0 auto;">Von Nominierung und Voting über die Aufbereitung bis ganz zum Schluss zur großen Show.</p>
</div>
<div style="position:relative;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:20px;align-items:start;" data-timeline>
<div :style="timelineLineStyle"></div>
<div class="timeline-grid" style="position:relative;display:grid;gap:20px;align-items:start;" data-timeline>
<div :style="timelineLineStyle" class="timeline-connector-line"></div>
<div style="position:relative;z-index:1;text-align:center;">
<div class="timeline-phase" style="position:relative;z-index:1;text-align:center;">
<div :style="nominationPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#2bbd6e,#1f9d5a);display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 6px rgba(43,189,110,.18),0 8px 20px rgba(31,157,90,.35);border:4px solid #f4eefb;animation:pulseGlow 2.2s ease-in-out infinite;' : 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#8b6cdb,#7355c8);display:flex;align-items:center;justify-content:center;box-shadow:0 8px 20px rgba(124,86,196,.32);border:4px solid #f4eefb;'">
<template v-if="nominationPhase">
<svg width="22" height="22" viewBox="0 0 24 24" fill="#fff"><path d="M12 17.3l-6.2 3.3 1.2-7-5.1-5 7.1-1 3-6.4 3 6.4 7.1 1-5.1 5 1.2 7z"/></svg>
@@ -27,7 +27,7 @@
</div>
</div>
<div style="position:relative;z-index:1;text-align:center;">
<div class="timeline-phase" style="position:relative;z-index:1;text-align:center;">
<div :style="votingPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#2bbd6e,#1f9d5a);display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 6px rgba(43,189,110,.18),0 8px 20px rgba(31,157,90,.35);border:4px solid #f4eefb;animation:pulseGlow 2.2s ease-in-out infinite;' : nominationPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:#fff;border:4px solid #e2d6f4;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 12px rgba(124,86,196,.1);' : 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#8b6cdb,#7355c8);display:flex;align-items:center;justify-content:center;box-shadow:0 8px 20px rgba(124,86,196,.32);border:4px solid #f4eefb;'">
<template v-if="nominationPhase">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#b9a9dd" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l2.9 6.3 6.9.7-5.1 4.6 1.4 6.8L12 17.8 5.9 20.4l1.4-6.8L2.2 9l6.9-.7z"/></svg>
@@ -57,7 +57,7 @@
</div>
</div>
<div style="position:relative;z-index:1;text-align:center;">
<div class="timeline-phase" style="position:relative;z-index:1;text-align:center;">
<div :style="nominationPhase || votingPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:#fff;border:4px solid #e2d6f4;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 12px rgba(124,86,196,.1);' : preparationPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#f7c76a,#b7791f);display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 6px rgba(247,199,106,.18),0 8px 20px rgba(183,121,31,.28);border:4px solid #f4eefb;animation:pulseGlow 2.2s ease-in-out infinite;' : 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#8b6cdb,#7355c8);display:flex;align-items:center;justify-content:center;box-shadow:0 8px 20px rgba(124,86,196,.32);border:4px solid #f4eefb;'">
<template v-if="nominationPhase || votingPhase">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#b9a9dd" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
@@ -85,7 +85,7 @@
</div>
</div>
<div style="position:relative;z-index:1;text-align:center;">
<div class="timeline-phase" style="position:relative;z-index:1;text-align:center;">
<div :style="showPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#ec3b5a,#b91c43);display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 6px rgba(236,59,90,.18),0 8px 20px rgba(185,28,67,.28);border:4px solid #f4eefb;animation:pulseGlow 2.2s ease-in-out infinite;' : completedPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#8b6cdb,#7355c8);display:flex;align-items:center;justify-content:center;box-shadow:0 8px 20px rgba(124,86,196,.32);border:4px solid #f4eefb;' : 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:#fff;border:4px solid #e2d6f4;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 12px rgba(124,86,196,.1);'">
<template v-if="showPhase">
<svg width="18" height="18" viewBox="0 0 24 24" fill="#fff"><path d="M8 5v14l11-7z"/></svg>
@@ -101,7 +101,7 @@
<div :style="showPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#ffe5ec;color:#ec3b5a;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : completedPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#f1ecfb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#f3eefb;color:#a98ddb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;'">{{ showPhase ? 'JETZT LIVE' : completedPhase ? 'ABGESCHLOSSEN' : 'FINALE' }}</div>
<h3 style="font-family:'Outfit',sans-serif;font-weight:700;font-size:21px;margin:0 0 6px;color:#3f3556;">Show</h3>
<div :style="showPhase ? 'font-size:13px;font-weight:600;color:#ec3b5a;margin-bottom:12px;' : 'font-size:13px;font-weight:600;color:#a99fc0;margin-bottom:12px;'">{{ formatTimelineRange('show') }}</div>
<p style="font-size:14.5px;line-height:1.6;color:#7d7491;margin:0 0 16px;">Die grosse Live-Show auf Twitch: Gewinner werden gekrönt, Clips gezeigt und die Szene feiert gemeinsam.</p>
<p style="font-size:14.5px;line-height:1.6;color:#7d7491;margin:0 0 16px;">Die große Live-Show auf Twitch: Gewinner werden gekrönt, Clips gezeigt und die Szene feiert gemeinsam.</p>
<a
v-if="showPhase"
:href="publicStreamUrl"
@@ -180,3 +180,35 @@ function finalActionStyle(showPhase: boolean, completedPhase = false) {
: "display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#f3eefb;color:#a06bd8;border:1px solid #e4d8f6;cursor:pointer;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;text-decoration:none;"
}
</script>
<style scoped>
.timeline-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.timeline-phase {
display: flex;
flex-direction: column;
align-items: stretch;
}
.timeline-phase > :first-child {
align-self: center;
}
@media (max-width: 1024px) {
.timeline-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.timeline-connector-line {
display: none;
}
}
@media (max-width: 600px) {
.timeline-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -59,18 +59,18 @@ const footerPrimaryLabel = computed(() => {
})
const footerHelperText = computed(() =>
confirmMissingVotes.value
? 'Pruefe die offenen Kategorien oder speichere deine aktuelle Auswahl.'
? 'Prüfe die offenen Kategorien oder speichere deine aktuelle Auswahl.'
: props.readonlyMode
? 'Hier siehst du die freigegebenen Kandidat:innen der abgeschlossenen Nominierungsphase.'
: props.savedVoteActive
? 'Du bearbeitest dein gespeichertes Voting. Speichern ersetzt deine bisherige Auswahl.'
: 'Du kannst Kategorien ueberspringen und vor dem Speichern pruefen.',
: 'Du kannst Kategorien überspringen und vor dem Speichern prüfen.',
)
const footerActions = computed(() =>
props.readonlyMode
? [
{
label: 'Zurueck',
label: 'Zurück',
tone: 'secondary' as const,
disabled: props.activeCatIndex === 0,
onClick: () => props.catList[Math.max(0, props.activeCatIndex - 1)]?.onClick(),
@@ -84,12 +84,12 @@ const footerActions = computed(() =>
:
confirmMissingVotes.value
? [
{ label: 'Zurueck zum Voting', tone: 'secondary' as const, disabled: props.submitting, onClick: hideMissingVoteConfirm },
{ label: 'Zurück zum Voting', tone: 'secondary' as const, disabled: props.submitting, onClick: hideMissingVoteConfirm },
{ label: props.submitting ? 'Speichert ...' : submitLabel.value, disabled: props.submitting, onClick: submitConfirmedVote },
]
: [
{
label: 'Zurueck',
label: 'Zurück',
tone: 'secondary' as const,
disabled: props.activeCatIndex === 0,
onClick: () => props.catList[Math.max(0, props.activeCatIndex - 1)]?.onClick(),
@@ -230,17 +230,18 @@ async function submitConfirmedVote() {
</article>
<article v-else class="home-vote-card" :class="{ 'home-vote-card--selected': nom.selected, 'home-vote-card--missing-clip': !nom.hasClip }">
<div v-if="nom.selected" class="home-vote-card__burst" aria-hidden="true">
<span class="home-vote-card__burst-flare"></span>
<span class="home-vote-card__burst-ring"></span>
<span class="home-vote-card__burst-trail home-vote-card__burst-trail--left"></span>
<span class="home-vote-card__burst-trail home-vote-card__burst-trail--right"></span>
<span class="home-vote-card__burst-trail home-vote-card__burst-trail--up"></span>
<span class="home-vote-card__burst-star home-vote-card__burst-star--up"></span>
<span class="home-vote-card__burst-star home-vote-card__burst-star--left"></span>
<span class="home-vote-card__burst-star home-vote-card__burst-star--right"></span>
<span class="home-vote-card__burst-star home-vote-card__burst-star--left-small"></span>
<span class="home-vote-card__burst-star home-vote-card__burst-star--right-small"></span>
<span class="home-vote-card__burst-star home-vote-card__burst-star--top-small"></span>
<span class="home-vote-card__sky-glow"></span>
<span class="home-vote-card__sky-sweep"></span>
<span class="home-vote-card__sky-star home-vote-card__sky-star--one"></span>
<span class="home-vote-card__sky-star home-vote-card__sky-star--two"></span>
<span class="home-vote-card__sky-star home-vote-card__sky-star--three"></span>
<span class="home-vote-card__sky-star home-vote-card__sky-star--four"></span>
<span class="home-vote-card__sky-star home-vote-card__sky-star--five"></span>
<span class="home-vote-card__sky-star home-vote-card__sky-star--six"></span>
<span class="home-vote-card__sky-star home-vote-card__sky-star--seven"></span>
<span class="home-vote-card__sky-star home-vote-card__sky-star--eight"></span>
<span class="home-vote-card__sky-star home-vote-card__sky-star--nine"></span>
<span class="home-vote-card__sky-star home-vote-card__sky-star--ten"></span>
</div>
<div class="home-vote-card__top">
<div class="home-vote-card__identity">
@@ -1,63 +1,8 @@
<template>
<section id="nominierte" style="position:relative;overflow:hidden;background:linear-gradient(115deg,#241640 0%,#3a2168 48%,#5b3aa0 100%);border-top:1px solid rgba(255,255,255,.12);border-bottom:1px solid rgba(255,255,255,.12);">
<HomeStarField :count="17" variant="light" />
<div class="home-section" style="max-width:1200px;margin:0 auto;padding:90px 24px;">
<div style="display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:46px;">
<div>
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#ff5fa2;margin-bottom:12px;">&#9733; Die Stars</div>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0;letter-spacing:0;color:#fff6fb;">Gewinner {{ selectedArchive.year }}</h2>
</div>
<button @click="onOpenArchive" style="display:inline-flex;align-items:center;gap:8px;padding:12px 22px;border-radius:999px;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.12);color:#fff6fb;font-weight:600;font-size:15px;cursor:pointer;" style-hover="transform:translateY(-2px);background:rgba(255,255,255,.09);">Archiv ansehen &#8594;</button>
</div>
<div class="home-winner-rail" style="overflow-x:auto;overflow-y:hidden;padding:0 18px 12px 0;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.34) rgba(255,255,255,.06);scroll-snap-type:x mandatory;scroll-padding-left:0;">
<div class="home-winner-track" style="display:flex;gap:22px;width:max-content;">
<article v-for="winner in winnerShowcase" :key="`${selectedArchive.year}-${winner.category}`" class="home-winner-card" style="flex:none;width:390px;border-radius:24px;overflow:hidden;background:#22123a;border:1px solid rgba(255,255,255,.12);">
<div style="position:relative;padding:24px 24px 20px;background:radial-gradient(circle at 28% 24%,rgba(255,95,162,.22),transparent 28%),radial-gradient(circle at 74% 78%,rgba(160,107,255,.2),transparent 32%),linear-gradient(180deg,#26133d 0%,#201132 100%);">
<div style="display:flex;align-items:center;gap:18px;margin-top:28px;margin-bottom:20px;min-height:120px;">
<div style="flex:none;width:104px;height:104px;border-radius:28px;background:linear-gradient(135deg,#ffe1a3,#e7b13e);display:flex;align-items:center;justify-content:center;color:#2f1b00;font-family:'Fredoka',sans-serif;font-size:34px;font-weight:700;box-shadow:0 18px 42px rgba(0,0,0,.22);">{{ initialsFor(winner.name) }}</div>
<div style="min-width:0;">
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#ffd27a;margin-bottom:8px;">{{ winner.category }}</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:24px;line-height:1.15;color:#fff6fb;">{{ winner.name }}</div>
<div style="font-size:13px;color:#c9b8da;margin-top:6px;">{{ winner.handle }}</div>
</div>
</div>
<a :href="winner.url" target="_blank" rel="noopener" style="display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:13px 18px;border-radius:14px;background:linear-gradient(135deg,#ff5fa2,#a06bff);color:#fff;font-family:'Fredoka',sans-serif;font-weight:600;font-size:14px;text-decoration:none;box-sizing:border-box;">{{ winner.platform }}</a>
</div>
<div style="padding:12px 24px 24px;background:#1b0d2d;">
<div style="font-size:11px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;color:#ffb9d4;margin-bottom:10px;">{{ winner.hasClip ? winner.clipPlatform : 'Archivierter Gewinner' }}</div>
<iframe
v-if="winner.clipEmbedUrl"
:src="winner.clipEmbedUrl"
:title="winner.clipEmbedTitle"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture; web-share"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"
style="width:100%;aspect-ratio:16/9;border:0;border-radius:18px;background:#12091d;box-shadow:0 16px 34px rgba(0,0,0,.22);"
/>
<a
v-else-if="winner.clipUrl"
:href="winner.clipUrl"
target="_blank"
rel="noopener noreferrer"
referrerpolicy="no-referrer"
style="border-radius:18px;background:#12091d;border:1px solid rgba(255,255,255,.08);aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;color:#fff6fb;font-family:'Fredoka',sans-serif;font-size:17px;text-decoration:none;text-align:center;padding:18px;"
>
{{ winner.clipTitle }}
</a>
<div v-else style="border-radius:18px;overflow:hidden;background:#12091d;border:1px solid rgba(255,255,255,.08);aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;color:#c9b8da;font-family:'Fredoka',sans-serif;font-size:18px;">{{ winner.platform }} · {{ winner.handle }}</div>
</div>
</article>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import HomeStarField from './HomeStarField.vue'
type WinnerCard = {
categoryGroup: string
category: string
name: string
handle: string
@@ -72,12 +17,382 @@ type WinnerCard = {
}
defineProps<{
selectedArchive: {
year: number
winners: WinnerCard[]
}
winnerShowcaseYear: number
winnerShowcase: WinnerCard[]
initialsFor: (value: string) => string
onOpenArchive: () => Promise<void>
}>()
</script>
<template>
<section id="nominierte" class="home-winner-section">
<HomeStarField :count="17" variant="light" />
<div class="home-section home-winner-section__inner">
<div class="home-winner-section__header">
<div>
<div class="home-winner-section__eyebrow">&#9733; Die Stars</div>
<h2 class="home-winner-section__title">Gewinner {{ winnerShowcaseYear }}</h2>
</div>
<button
type="button"
class="home-winner-section__archive-button"
style-hover="transform:translateY(-2px);background:rgba(255,255,255,.09);"
@click="onOpenArchive"
>
Archiv ansehen &rarr;
</button>
</div>
<div v-if="winnerShowcase.length > 0" class="home-winner-rail">
<div class="home-winner-track">
<article
v-for="winner in winnerShowcase"
:key="`${winnerShowcaseYear}-${winner.category}-${winner.handle}`"
class="home-winner-card"
>
<div class="home-winner-card__spotlight">
<div class="home-winner-card__portrait" aria-hidden="true">
{{ initialsFor(winner.name) }}
</div>
<div class="home-winner-card__status">Gewinner</div>
<div class="home-winner-card__plaque">
<div class="home-winner-card__category">{{ winner.categoryGroup }}</div>
<h3 class="home-winner-card__name">{{ winner.name }}</h3>
<p class="home-winner-card__subcategory">{{ winner.category }}</p>
</div>
</div>
<div class="home-winner-card__actions">
<a
:href="winner.url"
target="_blank"
rel="noopener noreferrer"
class="home-winner-card__profile"
>
{{ winner.platform }}
</a>
</div>
</article>
</div>
</div>
<div v-else class="home-winner-empty">
<div class="home-winner-empty__badge">&#9733;</div>
<div>
<h3>Noch keine Gewinner veröffentlicht</h3>
<p>Sobald ein Award-Jahr im Adminbereich freigegeben ist, erscheinen die Gewinner hier.</p>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.home-winner-section{
position:relative;
overflow:hidden;
border-top:1px solid rgba(255,255,255,.12);
border-bottom:1px solid rgba(255,255,255,.12);
background:
radial-gradient(circle at 20% 12%,rgba(255,95,162,.16),transparent 30%),
radial-gradient(circle at 82% 24%,rgba(201,177,255,.18),transparent 34%),
linear-gradient(115deg,#241640 0%,#3a2168 48%,#5b3aa0 100%);
}
.home-winner-section__inner{
position:relative;
z-index:1;
max-width:1200px;
margin:0 auto;
padding:90px 24px;
}
.home-winner-section__header{
display:flex;
flex-wrap:wrap;
align-items:flex-end;
justify-content:space-between;
gap:20px;
margin-bottom:46px;
}
.home-winner-section__eyebrow{
margin-bottom:12px;
color:#ff5fa2;
font-size:13px;
font-weight:700;
letter-spacing:1.5px;
text-transform:uppercase;
}
.home-winner-section__title{
margin:0;
color:#fff6fb;
font-family:'Fredoka',sans-serif;
font-size:clamp(32px,4vw,48px);
font-weight:700;
letter-spacing:0;
}
.home-winner-section__archive-button{
display:inline-flex;
align-items:center;
justify-content:center;
gap:8px;
min-height:48px;
padding:12px 22px;
border:1px solid rgba(255,255,255,.12);
border-radius:999px;
background:rgba(255,255,255,.05);
color:#fff6fb;
cursor:pointer;
font-family:'Outfit',sans-serif;
font-size:15px;
font-weight:700;
transition:transform .18s ease, background .18s ease, border-color .18s ease;
}
.home-winner-rail{
overflow-x:auto;
overflow-y:hidden;
padding:0 18px 14px 0;
scroll-padding-left:0;
scrollbar-color:rgba(255,255,255,.34) rgba(255,255,255,.06);
scrollbar-width:thin;
}
.home-winner-track{
display:flex;
gap:24px;
width:max-content;
scroll-snap-type:x mandatory;
}
.home-winner-card{
flex:none;
width:374px;
min-height:478px;
display:flex;
flex-direction:column;
justify-content:space-between;
border:1px solid rgba(255,255,255,.13);
border-radius:28px;
background:
radial-gradient(circle at 22% 15%,rgba(255,95,162,.14),transparent 30%),
radial-gradient(circle at 82% 84%,rgba(160,107,255,.17),transparent 32%),
linear-gradient(180deg,rgba(47,29,68,.96),rgba(34,21,50,.98));
box-shadow:0 24px 62px rgba(16,7,32,.28);
overflow:hidden;
scroll-snap-align:start;
scroll-snap-stop:always;
}
.home-winner-card__spotlight{
position:relative;
display:grid;
min-height:372px;
place-items:center;
padding:28px 26px 34px;
}
.home-winner-card__portrait{
display:grid;
place-items:center;
width:min(248px,100%);
aspect-ratio:1;
border-radius:34%;
color:#3a2d30;
background:
radial-gradient(circle at 32% 28%,rgba(255,244,203,.78),transparent 24%),
linear-gradient(145deg,#ffe39a 0%,#f4bf4f 58%,#d6a342 100%);
box-shadow:
0 24px 58px rgba(0,0,0,.28),
inset 0 1px 0 rgba(255,255,255,.46),
inset 0 -12px 26px rgba(114,71,0,.12);
font-family:'Fredoka',sans-serif;
font-size:64px;
font-weight:800;
letter-spacing:0;
}
.home-winner-card__status{
position:absolute;
right:22px;
top:28px;
padding:7px 12px;
border:1px solid rgba(255,255,255,.14);
border-radius:999px;
color:#ffd27a;
background:rgba(23,12,36,.48);
font-size:11px;
font-weight:900;
letter-spacing:.14em;
text-transform:uppercase;
}
.home-winner-card__plaque{
position:absolute;
left:50%;
bottom:22px;
width:min(236px,calc(100% - 48px));
padding:10px 14px 11px;
border:1px solid rgba(255,210,122,.24);
border-radius:8px;
background:linear-gradient(180deg,rgba(58,28,92,.96),rgba(40,19,65,.98));
box-shadow:0 18px 36px rgba(14,6,27,.34);
text-align:center;
transform:translateX(-50%);
}
.home-winner-card__category{
color:#ffd27a;
font-size:10px;
font-weight:900;
letter-spacing:.13em;
line-height:1.15;
text-transform:uppercase;
}
.home-winner-card__name{
margin:4px 0 0;
color:#fff6fb;
font-family:'Fredoka',sans-serif;
font-size:22px;
font-weight:700;
letter-spacing:0;
line-height:1.05;
overflow-wrap:anywhere;
}
.home-winner-card__subcategory{
margin:4px 0 0;
color:#d9c9ee;
font-size:13px;
font-weight:800;
line-height:1.2;
overflow-wrap:anywhere;
}
.home-winner-card__actions{
display:grid;
gap:10px;
padding:0 26px 26px;
}
.home-winner-card__profile{
display:flex;
align-items:center;
justify-content:center;
min-height:48px;
padding:12px 18px;
border-radius:15px;
font-family:'Fredoka',sans-serif;
font-size:14px;
font-weight:700;
text-align:center;
text-decoration:none;
transition:transform .18s ease, opacity .18s ease, border-color .18s ease;
}
.home-winner-card__profile{
color:#fff;
background:linear-gradient(135deg,#d36fa7,#a06bff);
box-shadow:0 16px 34px rgba(123,76,196,.26);
}
.home-winner-card__profile:hover{
opacity:.92;
transform:translateY(-2px);
}
.home-winner-empty{
display:flex;
align-items:center;
gap:18px;
max-width:720px;
padding:24px;
border:1px solid rgba(255,255,255,.12);
border-radius:24px;
color:#fff6fb;
background:rgba(32,17,50,.62);
box-shadow:0 22px 54px rgba(16,7,32,.22);
}
.home-winner-empty__badge{
flex:none;
display:grid;
place-items:center;
width:58px;
height:58px;
border-radius:20px;
color:#3a2d30;
background:linear-gradient(145deg,#ffe39a,#e7b13e);
font-size:24px;
}
.home-winner-empty h3{
margin:0 0 5px;
font-family:'Fredoka',sans-serif;
font-size:24px;
}
.home-winner-empty p{
margin:0;
color:#d9c9ee;
font-size:15px;
line-height:1.5;
}
@media (max-width:720px){
.home-winner-section__inner{
padding:72px 16px;
}
.home-winner-section__header{
align-items:flex-start;
margin-bottom:34px;
}
.home-winner-section__archive-button{
width:100%;
}
.home-winner-rail{
margin-right:-16px;
padding-right:16px;
}
.home-winner-track{
gap:16px;
}
.home-winner-card{
min-height:430px;
border-radius:24px;
}
.home-winner-card__spotlight{
min-height:324px;
padding:24px 22px 30px;
}
.home-winner-card__portrait{
width:min(218px,100%);
font-size:54px;
}
.home-winner-card__status{
right:18px;
top:22px;
}
.home-winner-card__actions{
padding:0 22px 22px;
}
.home-winner-empty{
align-items:flex-start;
padding:20px;
}
}
</style>
@@ -4,13 +4,9 @@
html,body{margin:0;padding:0;background:#160a26;}
@keyframes twinkle{0%,100%{opacity:.25;transform:scale(.7);}50%{opacity:1;transform:scale(1.15);}}
@keyframes starAppearRandom{0%,16%,100%{opacity:0;transform:translate(-50%,-50%) scale(.55) rotate(var(--star-rotate));}30%,68%{opacity:var(--star-alpha);transform:translate(-50%,-50%) scale(1) rotate(var(--star-rotate));}84%{opacity:0;transform:translate(-50%,-50%) scale(1.16) rotate(var(--star-rotate));}}
@keyframes voteBurstFlare{0%{opacity:.42;transform:translate3d(-50%,-50%,0) scale(.26);}14%{opacity:.88;transform:translate3d(-50%,-50%,0) scale(.88);}64%{opacity:.34;transform:translate3d(-50%,-50%,0) scale(1.42);}100%{opacity:0;transform:translate3d(-50%,-50%,0) scale(1.72);}}
@keyframes voteBurstRing{0%{opacity:.36;transform:translate3d(-50%,-50%,0) scale(.36);}16%{opacity:.72;transform:translate3d(-50%,-50%,0) scale(.82);}100%{opacity:0;transform:translate3d(-50%,-50%,0) scale(2.22);}}
@keyframes voteTrail{0%{opacity:.3;transform:translate3d(0,-50%,0) rotate(var(--trail-rotate)) scaleX(.12);}18%{opacity:.72;transform:translate3d(0,-50%,0) rotate(var(--trail-rotate)) scaleX(.72);}100%{opacity:0;transform:translate3d(var(--trail-x),calc(-50% + var(--trail-y)),0) rotate(var(--trail-rotate)) scaleX(1.08);}}
@keyframes voteStarUp{0%{opacity:.38;transform:translate3d(-50%,-50%,0) scale(.28) rotate(-12deg);}18%{opacity:1;transform:translate3d(-50%,-70%,0) scale(.88) rotate(-2deg);}66%{opacity:1;transform:translate3d(-50%,-170%,0) scale(1.24) rotate(12deg);}100%{opacity:0;transform:translate3d(-50%,-226%,0) scale(.7) rotate(24deg);}}
@keyframes voteStarLeft{0%{opacity:.34;transform:translate3d(-50%,-50%,0) scale(.26) rotate(8deg);}18%{opacity:1;transform:translate3d(-70%,-66%,0) scale(.74) rotate(-4deg);}66%{opacity:.98;transform:translate3d(-176%,-128%,0) scale(1) rotate(-20deg);}100%{opacity:0;transform:translate3d(-236%,-82%,0) scale(.56) rotate(-36deg);}}
@keyframes voteStarRight{0%{opacity:.34;transform:translate3d(-50%,-50%,0) scale(.26) rotate(-8deg);}18%{opacity:1;transform:translate3d(-32%,-66%,0) scale(.68) rotate(4deg);}66%{opacity:.98;transform:translate3d(88%,-122%,0) scale(.88) rotate(18deg);}100%{opacity:0;transform:translate3d(150%,-76%,0) scale(.5) rotate(34deg);}}
@keyframes voteStarDriftSmall{0%{opacity:.28;transform:translate3d(-50%,-50%,0) scale(.22) rotate(0deg);}30%{opacity:.92;transform:translate3d(calc(-50% + var(--star-dx-start)),calc(-50% + var(--star-dy-start)),0) scale(.72) rotate(14deg);}100%{opacity:0;transform:translate3d(calc(-50% + var(--star-dx-end)),calc(-50% + var(--star-dy-end)),0) scale(.28) rotate(42deg);}}
@keyframes voteSkyGlow{0%{opacity:0;transform:scale(.98);}24%{opacity:.88;transform:scale(1);}76%{opacity:.34;transform:scale(1.015);}100%{opacity:0;transform:scale(1.03);}}
@keyframes voteSkySweep{0%{opacity:0;transform:translate3d(-46%,18%,0) rotate(-9deg) scaleX(.7);}22%{opacity:.62;}64%{opacity:.5;}100%{opacity:0;transform:translate3d(46%,-18%,0) rotate(-9deg) scaleX(1.22);}}
@keyframes voteSkyStar{0%,12%{opacity:0;transform:translate(-50%,-50%) scale(.58) rotate(var(--star-rotate));}34%{opacity:1;transform:translate(-50%,-52%) scale(1.12) rotate(var(--star-rotate));}72%{opacity:.72;transform:translate(-50%,-55%) scale(.94) rotate(var(--star-rotate));}100%{opacity:0;transform:translate(-50%,-58%) scale(.72) rotate(var(--star-rotate));}}
@keyframes floaty{0%,100%{transform:translateY(0);}50%{transform:translateY(-16px);}}
@keyframes floaty2{0%,100%{transform:translateY(0) rotate(-4deg);}50%{transform:translateY(-22px) rotate(4deg);}}
@keyframes pulseGlow{0%,100%{opacity:.55;}50%{opacity:1;}}
@@ -142,7 +138,9 @@ html,body{margin:0;padding:0;background:#160a26;}
.home-sticky-countdown,
.home-sticky-countdown__dot,
.home-vote-card__burst-star{
.home-vote-card__sky-star,
.home-vote-card__sky-glow,
.home-vote-card__sky-sweep{
animation:none;
transition:none;
}
@@ -254,6 +252,57 @@ html,body{margin:0;padding:0;background:#160a26;}
transform:translateY(-1px);
}
.home-stream-band__copy{
min-width:0;
flex:1 1 auto;
display:flex;
flex-direction:column;
align-items:center;
}
.home-stream-band__headline-row{
width:100%;
}
.home-stream-band__lead{
flex:1 1 auto;
}
.home-stream-band__eyebrow,
.home-stream-band__meta{
text-align:center;
}
.home-stream-band__headline{
min-width:0;
}
.home-stream-band__title{
white-space:nowrap;
letter-spacing:.01em;
}
.home-stream-band__countdown-label{
white-space:nowrap;
opacity:.92;
}
.home-stream-band__countdown-chip{
backdrop-filter:blur(10px);
-webkit-backdrop-filter:blur(10px);
}
.home-stream-band__countdown-time{
font-variant-numeric:tabular-nums;
white-space:nowrap;
text-shadow:0 4px 18px rgba(11,6,26,.18);
}
.home-stream-band__actions{
flex:none;
margin-left:auto;
}
.home-landing img,
.home-landing svg{
max-width:100%;
@@ -509,14 +558,17 @@ html,body{margin:0;padding:0;background:#160a26;}
}
.home-nomination-review__hero{
margin-bottom:18px;
padding:18px 20px;
position:relative;
overflow:hidden;
margin-bottom:20px;
padding:22px 24px;
border:1px solid rgba(139,108,219,.14);
border-radius:18px;
border-radius:24px;
background:
linear-gradient(135deg,rgba(255,255,255,.94),rgba(252,248,255,.94)),
radial-gradient(circle at 100% 0,rgba(231,177,62,.12),transparent 34%);
box-shadow:0 14px 34px rgba(82,61,128,.07);
radial-gradient(circle at top right,rgba(231,177,62,.16),transparent 34%),
radial-gradient(circle at 12% 0,rgba(201,177,255,.26),transparent 28%),
linear-gradient(145deg,rgba(255,255,255,.96),rgba(250,245,255,.96));
box-shadow:0 18px 40px rgba(82,61,128,.08);
}
.home-nomination-review__item h5{
@@ -534,6 +586,39 @@ html,body{margin:0;padding:0;background:#160a26;}
line-height:1.5;
}
.home-nomination-review__stats{
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
gap:12px;
margin-top:18px;
}
.home-nomination-review__stat{
display:grid;
gap:4px;
padding:14px 16px;
border:1px solid rgba(139,108,219,.1);
border-radius:18px;
background:rgba(255,255,255,.72);
box-shadow:inset 0 1px 0 rgba(255,255,255,.7);
}
.home-nomination-review__stat span{
color:#8f84a7;
font-size:11px;
font-weight:800;
letter-spacing:.08em;
text-transform:uppercase;
}
.home-nomination-review__stat strong{
color:#4a356d;
font-family:'Fredoka',sans-serif;
font-size:22px;
font-weight:700;
line-height:1;
}
.home-nomination-pane__fields{
display:grid;
gap:14px;
@@ -670,26 +755,39 @@ html,body{margin:0;padding:0;background:#160a26;}
.home-nomination-review__list{
display:grid;
gap:12px;
gap:14px;
}
.home-nomination-review__item{
display:grid;
gap:12px;
padding:16px;
border:1px solid #efe7fb;
border-radius:18px;
background:#fff;
box-shadow:0 12px 28px rgba(82,61,128,.07);
gap:14px;
padding:18px;
border:1px solid rgba(139,108,219,.12);
border-radius:24px;
background:linear-gradient(135deg,rgba(255,255,255,.96),rgba(249,244,255,.96));
box-shadow:0 16px 34px rgba(82,61,128,.08);
}
.home-nomination-review__item > div{
.home-nomination-review__item-header{
display:flex;
align-items:center;
align-items:flex-start;
justify-content:space-between;
gap:12px;
}
.home-nomination-review__item-copy{
min-width:0;
}
.home-nomination-review__item-eyebrow{
margin:0 0 6px;
color:#9d85d9;
font-size:11px;
font-weight:900;
letter-spacing:.12em;
text-transform:uppercase;
}
.home-nomination-review__item span{
padding:4px 8px;
border-radius:999px;
@@ -699,26 +797,117 @@ html,body{margin:0;padding:0;background:#160a26;}
font-weight:900;
}
.home-nomination-review__item ul{
.home-nomination-review__links{
display:grid;
gap:9px;
margin:0;
padding-left:18px;
padding:0;
list-style:none;
}
.home-nomination-review__links li{
display:flex;
align-items:flex-start;
gap:10px;
padding:12px 14px;
border:1px solid rgba(139,108,219,.1);
border-radius:16px;
background:rgba(255,255,255,.74);
color:#6f6685;
font-size:13px;
font-weight:700;
line-height:1.5;
overflow-wrap:anywhere;
}
.home-nomination-review__item button{
.home-nomination-review__link-dot{
flex:none;
width:10px;
height:10px;
margin-top:5px;
border-radius:50%;
background:linear-gradient(135deg,#8b6cdb,#e9a8c9);
box-shadow:0 0 0 5px rgba(139,108,219,.12);
}
.home-nomination-review__edit{
justify-self:start;
border:0;
border-radius:11px;
background:#f1ecfb;
border-radius:999px;
background:linear-gradient(135deg,#f4effd,#f0e8fb);
color:#7658c4;
cursor:pointer;
font-family:'Outfit',sans-serif;
font-size:13px;
font-weight:900;
padding:9px 13px;
padding:10px 14px;
box-shadow:0 10px 22px rgba(139,108,219,.1);
transition:transform .16s ease,box-shadow .16s ease,background .16s ease,color .16s ease;
}
.home-nomination-review__edit:hover{
background:linear-gradient(135deg,#8b6cdb,#7355c8);
color:#fff;
box-shadow:0 14px 28px rgba(139,108,219,.22);
transform:translateY(-1px);
}
.home-nomination-review__empty{
display:grid;
place-items:center;
gap:16px;
min-height:240px;
padding:34px 24px;
border:1px dashed rgba(139,108,219,.32);
border-radius:28px;
background:
radial-gradient(circle at top,rgba(231,177,62,.16),transparent 28%),
linear-gradient(145deg,rgba(255,255,255,.92),rgba(247,240,255,.9));
box-shadow:inset 0 1px 0 rgba(255,255,255,.78);
text-align:center;
}
.home-nomination-review__empty-icon{
display:grid;
place-items:center;
width:62px;
height:62px;
border-radius:22px;
background:linear-gradient(135deg,#8b6cdb,#7355c8);
color:#fff2bd;
font-size:24px;
box-shadow:0 18px 38px rgba(139,108,219,.22);
}
.home-nomination-review__empty-copy{
display:grid;
gap:6px;
max-width:420px;
}
.home-nomination-review__empty-eyebrow{
margin:0;
color:#9d85d9;
font-size:11px;
font-weight:900;
letter-spacing:.16em;
text-transform:uppercase;
}
.home-nomination-review__empty-copy h5{
margin:0;
color:#3f3556;
font-family:'Fredoka',sans-serif;
font-size:28px;
line-height:1.05;
}
.home-nomination-review__empty-copy p{
margin:0;
color:#8a8398;
font-size:14px;
font-weight:700;
line-height:1.6;
}
.home-wizard-footer{
@@ -1245,133 +1434,152 @@ html,body{margin:0;padding:0;background:#160a26;}
contain:paint;
}
.home-vote-card__burst-flare,
.home-vote-card__burst-ring{
.home-vote-card__sky-glow,
.home-vote-card__sky-sweep,
.home-vote-card__sky-star{
position:absolute;
left:50%;
top:36%;
opacity:0;
pointer-events:none;
transform:translate(-50%,-50%) scale(.2);
will-change:transform,opacity;
}
.home-vote-card__burst-flare{
width:124px;
height:124px;
border-radius:50%;
.home-vote-card__sky-glow{
inset:-1px;
border-radius:inherit;
opacity:0;
background:
radial-gradient(circle,rgba(255,255,255,.92) 0 9%,rgba(255,210,122,.55) 26%,rgba(233,168,201,.24) 48%,transparent 72%);
filter:blur(1.2px);
animation:voteBurstFlare .9s cubic-bezier(.2,.78,.22,1) forwards;
radial-gradient(circle at 24% 22%,rgba(255,242,189,.46),transparent 12%),
radial-gradient(circle at 72% 28%,rgba(201,177,255,.4),transparent 15%),
radial-gradient(circle at 44% 76%,rgba(233,168,201,.26),transparent 18%),
linear-gradient(135deg,rgba(37,20,72,.24),rgba(139,108,219,.18) 44%,rgba(255,210,122,.12));
box-shadow:inset 0 0 0 1px rgba(255,242,189,.28),0 18px 46px rgba(78,46,139,.18);
animation:voteSkyGlow 1.05s cubic-bezier(.22,.8,.24,1) forwards;
}
.home-vote-card__burst-ring{
width:82px;
height:82px;
border:1.5px solid rgba(255,242,189,.74);
border-radius:50%;
box-shadow:0 0 16px rgba(255,210,122,.34),0 0 28px rgba(201,177,255,.18);
animation:voteBurstRing .92s cubic-bezier(.2,.78,.22,1) .02s forwards;
}
.home-vote-card__burst-trail{
position:absolute;
left:50%;
top:36%;
width:86px;
height:5px;
.home-vote-card__sky-sweep{
left:-18%;
right:-18%;
top:4%;
height:92%;
border-radius:999px;
opacity:0;
pointer-events:none;
transform-origin:left center;
transform:translate3d(0,-50%,0) rotate(var(--trail-rotate)) scaleX(.12);
background:linear-gradient(90deg,rgba(255,255,255,0),rgba(255,242,189,.92),rgba(201,177,255,.42),rgba(255,255,255,0));
box-shadow:0 0 14px rgba(255,210,122,.42),0 0 22px rgba(139,108,219,.16);
will-change:transform,opacity;
background:
linear-gradient(90deg,transparent 0%,rgba(255,210,122,.16) 18%,rgba(255,143,192,.2) 34%,rgba(201,177,255,.24) 50%,rgba(122,215,255,.18) 66%,rgba(255,242,189,.2) 82%,transparent 100%),
radial-gradient(ellipse at center,rgba(255,255,255,.36),transparent 62%);
filter:blur(14px);
transform-origin:center;
animation:voteSkySweep 1.45s cubic-bezier(.18,.84,.22,1) .05s forwards;
}
.home-vote-card__burst-trail--left{
--trail-rotate:-150deg;
--trail-x:-20px;
--trail-y:-13px;
animation:voteTrail .58s cubic-bezier(.22,.76,.24,1) .06s forwards;
}
.home-vote-card__burst-trail--right{
--trail-rotate:-36deg;
--trail-x:16px;
--trail-y:-13px;
animation:voteTrail .56s cubic-bezier(.22,.76,.24,1) .08s forwards;
}
.home-vote-card__burst-trail--up{
--trail-rotate:-90deg;
--trail-x:0px;
--trail-y:-20px;
width:96px;
animation:voteTrail .62s cubic-bezier(.22,.76,.24,1) .04s forwards;
}
.home-vote-card__burst-star{
position:absolute;
.home-vote-card__sky-star{
left:50%;
top:36%;
color:#ffd27a;
top:50%;
color:#fff2bd;
font-family:'Fredoka',sans-serif;
line-height:1;
opacity:0;
text-shadow:0 0 8px rgba(255,255,255,.85),0 0 14px rgba(255,210,122,.78),0 0 24px rgba(139,108,219,.28);
transform:translate(-50%,-50%) scale(.2);
text-shadow:0 0 8px rgba(255,255,255,.86),0 0 18px rgba(255,210,122,.72),0 0 28px rgba(139,108,219,.22);
transform:translate(-50%,-50%) scale(.58) rotate(var(--star-rotate));
will-change:transform,opacity;
animation:voteSkyStar var(--star-duration) cubic-bezier(.2,.76,.2,1) var(--star-delay) forwards;
}
.home-vote-card__burst-star--up{
color:#fff2bd;
font-size:36px;
animation:voteStarUp .96s cubic-bezier(.2,.78,.22,1) .02s forwards;
.home-vote-card__sky-star--one{
--star-rotate:-12deg;
--star-duration:.92s;
--star-delay:.1s;
left:24%;
top:30%;
color:#ffd27a;
font-size:19px;
}
.home-vote-card__burst-star--left{
color:#e9a8c9;
font-size:24px;
animation:voteStarLeft .9s cubic-bezier(.2,.78,.22,1) .06s forwards;
}
.home-vote-card__burst-star--right{
color:#c9b1ff;
font-size:21px;
animation:voteStarRight .88s cubic-bezier(.2,.78,.22,1) .08s forwards;
}
.home-vote-card__burst-star--left-small{
--star-dx-start:-18px;
--star-dy-start:-14px;
--star-dx-end:-92px;
--star-dy-end:-2px;
color:#fff2bd;
.home-vote-card__sky-star--two{
--star-rotate:18deg;
--star-duration:.98s;
--star-delay:.18s;
left:42%;
top:22%;
color:#fff7d8;
font-size:13px;
animation:voteStarDriftSmall .68s cubic-bezier(.2,.78,.22,1) .12s forwards;
}
.home-vote-card__burst-star--right-small{
--star-dx-start:18px;
--star-dy-start:-12px;
--star-dx-end:88px;
--star-dy-end:-8px;
.home-vote-card__sky-star--three{
--star-rotate:0deg;
--star-duration:1.04s;
--star-delay:.06s;
left:58%;
top:38%;
color:#fff;
font-size:12px;
}
.home-vote-card__sky-star--four{
--star-rotate:22deg;
--star-duration:.96s;
--star-delay:.24s;
left:72%;
top:26%;
color:#c9b1ff;
font-size:17px;
}
.home-vote-card__sky-star--five{
--star-rotate:-18deg;
--star-duration:.88s;
--star-delay:.3s;
left:80%;
top:54%;
color:#e9a8c9;
font-size:12px;
animation:voteStarDriftSmall .66s cubic-bezier(.2,.78,.22,1) .15s forwards;
}
.home-vote-card__burst-star--top-small{
--star-dx-start:4px;
--star-dy-start:-18px;
--star-dx-end:34px;
--star-dy-end:-88px;
.home-vote-card__sky-star--six{
--star-rotate:12deg;
--star-duration:1.02s;
--star-delay:.2s;
left:18%;
top:58%;
color:#fff7d8;
font-size:11px;
}
.home-vote-card__sky-star--seven{
--star-rotate:-24deg;
--star-duration:1.08s;
--star-delay:.34s;
left:34%;
top:68%;
color:#c9b1ff;
font-size:14px;
animation:voteStarDriftSmall .72s cubic-bezier(.2,.78,.22,1) .18s forwards;
font-size:15px;
}
.home-vote-card__sky-star--eight{
--star-rotate:8deg;
--star-duration:.96s;
--star-delay:.16s;
left:51%;
top:64%;
color:#ffd27a;
font-size:10px;
}
.home-vote-card__sky-star--nine{
--star-rotate:-8deg;
--star-duration:1.1s;
--star-delay:.28s;
left:66%;
top:70%;
color:#fff;
font-size:13px;
}
.home-vote-card__sky-star--ten{
--star-rotate:26deg;
--star-duration:.94s;
--star-delay:.4s;
left:88%;
top:38%;
color:#ffb8d8;
font-size:11px;
}
.home-vote-picker__empty{
@@ -1763,13 +1971,43 @@ html,body{margin:0;padding:0;background:#160a26;}
.home-stream-band__lead{
align-items:flex-start!important;
justify-content:flex-start!important;
gap:14px!important;
width:100%!important;
}
.home-stream-band__copy{
align-items:flex-start!important;
}
.home-stream-band__eyebrow,
.home-stream-band__meta{
text-align:left!important;
}
.home-stream-band__actions{
width:100%!important;
align-items:stretch!important;
justify-content:center!important;
margin-left:0!important;
}
.home-stream-band__headline-row{
justify-content:flex-start!important;
gap:8px!important;
}
.home-stream-band__title{
white-space:normal!important;
}
.home-stream-band__countdown-chip{
flex-wrap:wrap!important;
row-gap:6px!important;
}
.home-stream-band__countdown-time{
font-size:clamp(22px,7vw,28px)!important;
}
.home-stream-band__actions > a,
@@ -1821,7 +2059,7 @@ html,body{margin:0;padding:0;background:#160a26;}
grid-template-columns:1fr!important;
}
[data-cat-grid] > div{
[data-cat-grid] > *{
border-radius:18px!important;
padding:22px 20px!important;
}
@@ -2016,6 +2254,15 @@ html,body{margin:0;padding:0;background:#160a26;}
flex-direction:column!important;
}
.home-nomination-review__stats{
grid-template-columns:1fr!important;
}
.home-nomination-review__item-header{
flex-direction:column!important;
align-items:flex-start!important;
}
.home-nomination-field__header{
align-items:flex-start!important;
flex-direction:column!important;
@@ -35,6 +35,5 @@ export interface HomeLandingCategory {
icon: string
description: string
maxNomineesPerUser: number
candidateCount: number
subcategoryCount: number
subcategoryNames: string[]
}
@@ -52,16 +52,9 @@ export interface HomeArchiveYearItem {
export interface HomeArchiveWinnerItem {
category: string
subcategory: string
name: string
handle: string
platform: string
url: string
clipUrl: string | null
clipTitle: string
clipPlatform: string
clipEmbedUrl: string | null
clipEmbedTitle: string
hasClip: boolean
}
export interface HomeSelectedArchive {
@@ -12,15 +12,6 @@ export function useHomeArchivePresentation(store: AwardsStore, archiveYear: Ref<
knownYears.set(entry.year, entry.winnerCount)
}
for (const entry of store.overview.winnersPreview) {
if (!knownYears.has(entry.year)) {
knownYears.set(
entry.year,
store.overview.winnersPreview.filter((winner) => winner.year === entry.year).length,
)
}
}
if (store.archive.items.length > 0) {
knownYears.set(store.archive.year, store.archive.items.length)
}
@@ -31,7 +22,7 @@ export function useHomeArchivePresentation(store: AwardsStore, archiveYear: Ref<
year,
label: String(year),
winnerCount: year === store.archive.year ? store.archive.items.length : winnerCount,
winners: year === store.archive.year ? store.archive.items : store.overview.winnersPreview.filter((entry) => entry.year === year),
winners: year === store.archive.year ? store.archive.items : [],
active: archiveYear.value === year,
}))
})
@@ -41,7 +32,15 @@ export function useHomeArchivePresentation(store: AwardsStore, archiveYear: Ref<
winners: store.archive.items.map((winner) => toArchiveWinnerItem(winner)),
}))
const winnerShowcase = computed(() => selectedArchive.value.winners.slice(0, 4))
const winnerShowcaseYear = computed(() =>
store.overview.winnersPreview[0]?.year ?? selectedArchive.value.year,
)
const winnerShowcase = computed(() => {
const publishedWinners = store.overview.winnersPreview.map((winner) => toWinnerShowcaseItem(winner))
const archiveFallback = store.archive.items.map((winner) => toWinnerShowcaseItem(winner))
return (publishedWinners.length > 0 ? publishedWinners : archiveFallback).slice(0, 8)
})
function archiveYearButtonStyle(active: boolean) {
return active
@@ -50,6 +49,21 @@ export function useHomeArchivePresentation(store: AwardsStore, archiveYear: Ref<
}
function toArchiveWinnerItem(winner: {
categoryGroup?: string | null
category: string
winnerName: string
winnerUrl: string
}) {
return {
category: winner.category,
subcategory: winner.categoryGroup?.trim() || winner.category,
name: winner.winnerName,
url: winner.winnerUrl,
}
}
function toWinnerShowcaseItem(winner: {
categoryGroup?: string | null
category: string
winnerName: string
winnerSlug: string
@@ -66,7 +80,9 @@ export function useHomeArchivePresentation(store: AwardsStore, archiveYear: Ref<
const clipEmbed = clipUrl && winner.clipEmbedStatus !== 'link_only' && winner.clipEmbedStatus !== 'blocked'
? buildClipEmbed(clipUrl)
: null
return {
categoryGroup: winner.categoryGroup?.trim() || winner.category,
category: winner.category,
name: winner.winnerName,
handle: winner.winnerSlug,
@@ -114,9 +130,9 @@ export function useHomeArchivePresentation(store: AwardsStore, archiveYear: Ref<
return {
archiveYears,
selectedArchive,
winnerShowcaseYear,
winnerShowcase,
archiveYearButtonStyle,
winnerPlatformKey,
winnerPlatformLabel,
winnerPlatformStyle,
}
@@ -24,7 +24,10 @@ export function useHomeLandingOverviewPresentation(store: AwardsStore, authStore
const faqItems = computed(() =>
(store.overview.faq ?? []).filter((item) => item?.question && item.answer),
)
const publicStreamUrl = computed(() => store.overview.showStreamUrl || 'https://twitch.tv/jayuhime')
const publicStreamUrl = computed(() =>
store.overview.siteContent.streamBanner.liveButtonUrl
|| 'https://twitch.tv/jayuhime',
)
const showDate = computed(() => store.overview.showDate)
const showStartsAt = computed(() => store.overview.showStartsAt || '20:00:00')
const currentYear = computed(() => store.overview.year ? String(store.overview.year) : '')
@@ -42,7 +45,9 @@ export function useHomeLandingOverviewPresentation(store: AwardsStore, authStore
if (store.overview.featuredCategories.length > 0) {
return store.overview.featuredCategories.map((category, index) => {
const matchingSubcategories = displayCategories.value.filter((item) => item.groupName === category.groupName)
const candidateCount = matchingSubcategories.reduce((sum, item) => sum + item.candidates.length, 0)
const subcategoryNames = matchingSubcategories
.map((item) => item.name.trim())
.filter(Boolean)
return {
id: String(category.id),
groupName: category.groupName,
@@ -50,8 +55,7 @@ export function useHomeLandingOverviewPresentation(store: AwardsStore, authStore
icon: CATEGORY_ICONS[index % CATEGORY_ICONS.length] ?? '✦',
description: category.description,
maxNomineesPerUser: category.maxNomineesPerUser,
candidateCount,
subcategoryCount: matchingSubcategories.length,
subcategoryNames,
}
})
}
@@ -65,6 +69,9 @@ export function useHomeLandingOverviewPresentation(store: AwardsStore, authStore
seenGroups.add(groupKey)
const matchingSubcategories = displayCategories.value.filter((item) => item.groupName.trim().toLowerCase() === groupKey)
const subcategoryNames = matchingSubcategories
.map((item) => item.name.trim())
.filter(Boolean)
return [{
id: category.id,
groupName: category.groupName,
@@ -72,8 +79,7 @@ export function useHomeLandingOverviewPresentation(store: AwardsStore, authStore
icon: CATEGORY_ICONS[index % CATEGORY_ICONS.length] ?? '✦',
description: '',
maxNomineesPerUser: category.maxNomineesPerUser,
candidateCount: matchingSubcategories.reduce((sum, item) => sum + item.candidates.length, 0),
subcategoryCount: matchingSubcategories.length,
subcategoryNames,
}]
})
})
@@ -83,8 +89,6 @@ export function useHomeLandingOverviewPresentation(store: AwardsStore, authStore
const bootstrapArchiveYears = computed<Array<{ year: number }>>(() =>
(store.overview.archiveYears ?? []).length > 0
? (store.overview.archiveYears ?? []).map((entry) => ({ year: entry.year }))
: store.overview.winnersPreview.length > 0
? [...new Set(store.overview.winnersPreview.map((winner) => winner.year))].map((year) => ({ year }))
: [{ year: store.overview.year - 1 }],
)
@@ -95,9 +95,9 @@ export function useHomeLandingState() {
const {
archiveYears,
selectedArchive,
winnerShowcaseYear,
winnerShowcase,
archiveYearButtonStyle,
winnerPlatformKey,
winnerPlatformLabel,
winnerPlatformStyle,
} = useHomeArchivePresentation(store, archiveYear)
@@ -120,8 +120,10 @@ export function useHomeLandingState() {
phasePrimaryActionStyle,
streamEyebrow,
streamTitle,
streamText,
streamMeta,
streamLockedLabel,
streamButtonLabel,
streamButtonUrl,
streamLockedTitle,
statOneValue,
statOneLabel,
@@ -140,6 +142,7 @@ export function useHomeLandingState() {
previewPhase,
landingCategories,
candidateCount,
siteContent,
publicStreamUrl,
showDate,
showStartsAt,
@@ -299,8 +302,10 @@ export function useHomeLandingState() {
phasePrimaryActionStyle,
streamEyebrow,
streamTitle,
streamText,
streamMeta,
streamLockedLabel,
streamButtonLabel,
streamButtonUrl,
streamLockedTitle,
statOneValue,
statOneLabel,
@@ -328,6 +333,7 @@ export function useHomeLandingState() {
clipSubmissionDisabledMessage,
archiveYears,
selectedArchive,
winnerShowcaseYear,
winnerShowcase,
activeCat,
submitted,
@@ -365,7 +371,6 @@ export function useHomeLandingState() {
onCloseArchive,
archiveYearButtonStyle,
winnerPlatformStyle,
winnerPlatformKey,
winnerPlatformLabel,
privacyModalStop: stop,
accountModalStop: stop,
@@ -86,6 +86,17 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
let timer: ReturnType<typeof setInterval> | null = null
let landingLoaderTimer: ReturnType<typeof setTimeout> | null = null
function syncScrollLock() {
const shouldLock = landingLoaderVisible.value
|| modalOpen.value
|| accountModalOpen.value
|| privacyModalOpen.value
|| archiveModalOpen.value
document.documentElement.style.overflow = shouldLock ? 'hidden' : ''
document.body.style.overflow = shouldLock ? 'hidden' : ''
}
function setRootEl(element: Element | ComponentPublicInstance | null) {
rootEl.value = element instanceof HTMLElement ? element : null
}
@@ -170,7 +181,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
const showCountdown = split(showCompleted ? 0 : showStarted ? now - showTarget : showTarget - now)
setText(countdownRefs.labelEl.value, activeTarget.label)
setText(countdownRefs.streamLabelEl.value, showCompleted ? 'Award abgeschlossen' : showStarted ? 'Stream läuft seit' : 'Finale startet in')
setText(countdownRefs.streamLabelEl.value, showCompleted ? 'Award abgeschlossen' : showStarted ? 'Live seit' : 'Finale in')
setText(countdownRefs.dEl.value, pad(activeCountdown.d))
setText(countdownRefs.hEl.value, pad(activeCountdown.h))
setText(countdownRefs.mEl.value, pad(activeCountdown.m))
@@ -209,8 +220,8 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
return 'show'
}
watch([modalOpen, accountModalOpen, privacyModalOpen, archiveModalOpen], () => {
document.body.style.overflow = modalOpen.value || accountModalOpen.value || privacyModalOpen.value || archiveModalOpen.value ? 'hidden' : ''
watch([landingLoaderVisible, modalOpen, accountModalOpen, privacyModalOpen, archiveModalOpen], () => {
syncScrollLock()
nextTick(setupDom)
updateStickyCountdownVisibility()
})
@@ -220,6 +231,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
})
onMounted(async () => {
syncScrollLock()
if (!authStore.hydrated) {
await authStore.hydrate()
}
@@ -244,6 +256,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
})
onBeforeUnmount(() => {
document.documentElement.style.overflow = ''
document.body.style.overflow = ''
window.removeEventListener('scroll', updateStickyCountdownVisibility)
window.removeEventListener('resize', updateStickyCountdownVisibility)
@@ -226,7 +226,7 @@ export function useHomeParticipationSubmitActions(params: {
candidateId: selectedCreator?.id ?? null,
twitchUserId: session.twitchUserId,
clipUrl: url,
title: clipContext.description || `Clip fuer ${selectedCreator?.displayName ?? 'die Award-Show'}`,
title: clipContext.description || `Clip für ${selectedCreator?.displayName ?? 'die Award-Show'}`,
creator: selectedCreator?.displayName ?? fallbackCreatorName.value,
})
submitted.value = true
@@ -1,11 +1,14 @@
import { computed, onBeforeUnmount, ref, type ComputedRef, type Ref } from 'vue'
import { plainTextContentFromStorage } from '../../lib/privacyContent'
import type { OverviewResponse } from '../../types/awards'
import type { HomeLandingCategory, HomePreviewPhase } from './homeLandingTypes'
export function useHomePhasePresentation(params: {
previewPhase: Ref<HomePreviewPhase>
landingCategories: ComputedRef<HomeLandingCategory[]>
candidateCount: ComputedRef<number>
siteContent: ComputedRef<OverviewResponse['siteContent']>
publicStreamUrl: ComputedRef<string>
showDate: ComputedRef<string>
showStartsAt: ComputedRef<string>
@@ -17,6 +20,7 @@ export function useHomePhasePresentation(params: {
previewPhase,
landingCategories,
candidateCount,
siteContent,
publicStreamUrl,
showDate,
showStartsAt,
@@ -39,8 +43,10 @@ export function useHomePhasePresentation(params: {
const showStartMs = computed(() => parseShowStartMs(showDate.value, showStartsAt.value))
const streamLive = computed(() => showPhase.value && !Number.isNaN(showStartMs.value) && currentTimestamp.value >= showStartMs.value)
const streamLocked = computed(() => !streamLive.value)
const streamBanner = computed(() => siteContent.value.streamBanner ?? defaultStreamBannerContent())
const useCompletedStreamBanner = computed(() => completedPhase.value && streamBanner.value.useCompletedContent)
const phaseCardTitle = computed(() => completedPhase.value ? 'Award-Jahr abgeschlossen' : nominationPhase.value ? 'Community Nominierung' : votingPhase.value ? 'Community Voting' : preparationPhase.value ? 'Aufbereitung bis zur Show' : 'Award Show Live')
const phaseCardDescription = computed(() => completedPhase.value ? 'Die grosse Award-Show ist beendet. Das Jahr ist abgeschlossen und alle Teilnahme-Aktionen sind gesperrt.' : nominationPhase.value ? 'Die Nominierungsphase laeuft gerade. Reiche pro Kategorie die erlaubte Anzahl an Stream- oder Kanal-Links ein.' : votingPhase.value ? 'Die Nominierungsphase ist abgeschlossen.\nJetzt liegt es an dir: Stimme fuer deine Favoriten!' : preparationPhase.value ? 'Das Voting ist abgeschlossen. Das Team bereitet Clips, Ablauf und Gewinner-Momente fuer die Show vor.' : 'Die Award-Show laeuft jetzt live. Zeit fuer Buehne, Gewinner:innen und ganz viel Glitzer.')
const phaseCardDescription = computed(() => completedPhase.value ? 'Die große Award-Show ist beendet. Das Jahr ist abgeschlossen und alle Teilnahme-Aktionen sind gesperrt.' : nominationPhase.value ? 'Die Nominierungsphase läuft gerade. Reiche pro Kategorie die erlaubte Anzahl an Stream- oder Kanal-Links ein.' : votingPhase.value ? 'Die Nominierungsphase ist abgeschlossen.\nJetzt liegt es an dir: Stimme für deine Favoriten!' : preparationPhase.value ? 'Das Voting ist abgeschlossen. Das Team bereitet Clips, Ablauf und Gewinner-Momente für die Show vor.' : 'Die Award-Show läuft jetzt live. Zeit für Bühne, Gewinner:innen und ganz viel Glitzer.')
const phaseCardRange = computed(() => completedPhase.value ? `Finale abgeschlossen · ${formatShowDate()}` : nominationPhase.value ? `Nominierungszeitraum · ${formatRange('nomination')}` : votingPhase.value ? `Voting-Zeitraum · ${formatRange('voting')}` : preparationPhase.value ? `Aufbereitungszeit · ${formatRange('preparation')}` : `Live · ${formatShowDate()} · ${formatTimeLabel(showStartsAt.value)} Uhr`)
const phaseStatusLabel = computed(() => completedPhase.value ? 'ABGESCHLOSSEN' : streamLive.value ? 'LIVE' : showPhase.value ? 'STARTET BALD' : preparationPhase.value ? 'IN AUFBEREITUNG' : 'AKTIV')
const phaseStatusStyle = computed(() => completedPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#f1ecfb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:.5px;' : showPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#ffe5ec;color:#ec3b5a;font-size:11px;font-weight:700;letter-spacing:.5px;' : preparationPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#fff1d6;color:#b7791f;font-size:11px;font-weight:700;letter-spacing:.5px;' : 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#e3f7ec;color:#1f9d5a;font-size:11px;font-weight:700;letter-spacing:.5px;')
@@ -49,10 +55,30 @@ export function useHomePhasePresentation(params: {
const phasePrimaryActionStyle = computed(() => phasePrimaryDisabled.value
? 'display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:15px 18px;border-radius:13px;background:#eee7f8;color:#9b8abf;border:none;text-decoration:none;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:15px;box-shadow:none;cursor:not-allowed;'
: 'display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:15px 18px;border-radius:13px;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;border:none;text-decoration:none;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:15px;box-shadow:0 10px 22px rgba(124,86,196,.32);cursor:pointer;')
const streamEyebrow = computed(() => 'Das grosse Finale')
const streamTitle = computed(() => 'Award-Show Countdown')
const streamMeta = computed(() => `Finale am ${formatShowDate()} · ${formatTimeLabel(showStartsAt.value)} Uhr`)
const streamLockedLabel = computed(() => completedPhase.value ? 'Award abgeschlossen' : 'Stream noch gesperrt')
const streamEyebrow = computed(() => useCompletedStreamBanner.value
? firstText(streamBanner.value.completedEyebrow, 'Danke fürs Mitfiebern')
: firstText(streamBanner.value.eyebrow, 'Das große Finale'))
const streamTitle = computed(() => useCompletedStreamBanner.value
? firstText(streamBanner.value.completedTitle, 'Award-Show abgeschlossen')
: firstText(streamBanner.value.title, 'Award-Show Finale'))
const streamText = computed(() => useCompletedStreamBanner.value
? firstText(streamBanner.value.completedText, 'Die große Award-Show ist vorbei. Danke an alle, die live dabei waren.')
: firstText(streamBanner.value.text, 'Countdown bis zur großen Live-Show'))
const streamMeta = computed(() => completedPhase.value ? `Finale abgeschlossen · ${formatShowDate()}` : `Finale am ${formatShowDate()} · ${formatTimeLabel(showStartsAt.value)} Uhr`)
const streamButtonLabel = computed(() => {
if (useCompletedStreamBanner.value) {
return firstText(streamBanner.value.completedButtonLabel, 'Highlights ansehen')
}
return streamLive.value
? firstText(streamBanner.value.liveButtonLabel, 'Jetzt live · Zum Stream')
: firstText(streamBanner.value.lockedButtonLabel, 'Stream noch gesperrt')
})
const streamButtonUrl = computed(() => useCompletedStreamBanner.value
? (streamBanner.value.completedButtonUrl ?? '').trim()
: streamLive.value
? (streamBanner.value.liveButtonUrl ?? '').trim()
: '')
const streamLockedTitle = computed(() => completedPhase.value ? 'Die Award-Show ist beendet' : 'Verfügbar, sobald die Show startet')
const statOneValue = computed(() => completedPhase.value ? 'DONE' : streamLive.value ? 'LIVE' : String(candidateCount.value))
const statOneLabel = computed(() => completedPhase.value ? 'Jahr-Status' : showPhase.value ? 'Show-Status' : 'Kandidat:innen')
@@ -101,8 +127,10 @@ export function useHomePhasePresentation(params: {
phasePrimaryActionStyle,
streamEyebrow,
streamTitle,
streamText,
streamMeta,
streamLockedLabel,
streamButtonLabel,
streamButtonUrl,
streamLockedTitle,
statOneValue,
statOneLabel,
@@ -134,3 +162,25 @@ function normalizeTime(value: string) {
function formatTimeLabel(value: string) {
return normalizeTime(value).slice(0, 5)
}
function firstText(value: string | null | undefined, fallback: string) {
const trimmed = plainTextContentFromStorage(value ?? '')
return trimmed || fallback
}
function defaultStreamBannerContent(): OverviewResponse['siteContent']['streamBanner'] {
return {
eyebrow: 'Das große Finale',
title: 'Award-Show Finale',
text: 'Countdown bis zur großen Live-Show',
liveButtonLabel: 'Jetzt live · Zum Stream',
liveButtonUrl: '',
lockedButtonLabel: 'Stream noch gesperrt',
useCompletedContent: false,
completedEyebrow: 'Danke fürs Mitfiebern',
completedTitle: 'Award-Show abgeschlossen',
completedText: 'Die große Award-Show ist vorbei. Danke an alle, die live dabei waren.',
completedButtonLabel: 'Highlights ansehen',
completedButtonUrl: '',
}
}
@@ -54,6 +54,7 @@
@click="selectOption(option)"
>
<span class="native-select__option-label">{{ option.label }}</span>
<span v-if="option.meta" class="native-select__option-meta">{{ option.meta }}</span>
<Check v-if="isSelected(option)" class="native-select__check" :size="18" :stroke-width="3" />
</button>
</template>
@@ -71,6 +72,7 @@ type SelectOptionValue = string | number | null
type SelectOption = {
label: string
value: SelectOptionValue
meta?: string
disabled?: boolean
group?: string
}
@@ -424,12 +426,31 @@ onBeforeUnmount(() => {
}
.native-select__option-label{
flex:1 1 auto;
min-width:0;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.native-select__option-meta{
flex:none;
margin-left:auto;
border-radius:999px;
background:#f1eafd;
color:#6d45cc;
padding:4px 9px;
font-size:11px;
font-weight:850;
line-height:1;
white-space:nowrap;
}
.native-select__option--selected .native-select__option-meta{
background:#fff;
color:#6d45cc;
}
.native-select__check{
flex:none;
color:#7c5bd2;
+19 -1
View File
@@ -69,7 +69,15 @@ function buildAdminAuditEntryParams(options: AdminAuditQueryOptions = {}) {
}
export const adminApi = {
getAdminDashboard: () => requestJson<AdminDashboardResponse>('/api/admin/dashboard'),
getAdminDashboard: (seasonId?: number | null) => {
const params = new URLSearchParams()
if (seasonId) {
params.set('seasonId', String(seasonId))
}
const query = params.toString()
return requestJson<AdminDashboardResponse>(`/api/admin/dashboard${query ? `?${query}` : ''}`)
},
getAdminAuditEntriesPage: (options: AdminAuditQueryOptions = {}) =>
requestJson<AdminAuditEntriesResponse>(`/api/admin/audit-entries?${buildAdminAuditEntryParams(options).toString()}`),
getAdminAuditEntries: (limit = 200, query = '') => {
@@ -215,6 +223,16 @@ export const adminApi = {
requestJson<{ deleted: boolean; resultId: number }>(`/api/admin/results/${resultId}`, {
method: 'DELETE',
}),
publishAdminWinners: (seasonId: number) =>
requestJson<{ saved: boolean; seasonId: number; winnersPublishedAt: string | null; winnersPublishedByTwitchId: string | null }>(
`/api/admin/seasons/${seasonId}/winners/publish`,
jsonRequest('POST', {}),
),
unpublishAdminWinners: (seasonId: number) =>
requestJson<{ saved: boolean; seasonId: number; winnersPublishedAt: string | null; winnersPublishedByTwitchId: string | null }>(
`/api/admin/seasons/${seasonId}/winners/unpublish`,
jsonRequest('POST', {}),
),
approveAdminNomination: (nominationId: number, payload: ApproveNominationPayload) =>
requestJson<{ saved: boolean; nominationId: number; candidateId: number; created: boolean }>(
`/api/admin/nominations/${nominationId}/approve`,
+50
View File
@@ -42,6 +42,43 @@ function hasHtmlMarkup(value: string) {
return /<\/?[a-z][\s\S]*>/i.test(value)
}
function normalizePlainTextLines(value: string) {
return value
.replace(/\r\n?/g, '\n')
.split('\n')
.map((line) => line.trim())
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function decodeBasicHtmlEntities(value: string) {
return value
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;|&#039;/gi, "'")
}
function htmlToPlainText(value: string) {
const withBreakHints = value
.replace(/<\s*br\s*\/?>/gi, '\n')
.replace(/<\s*li\b[^>]*>/gi, '- ')
.replace(/<\/\s*(p|div|li|ul|ol|h1|h2|h3|h4|h5|h6)\s*>/gi, '\n')
if (typeof document === 'undefined') {
return normalizePlainTextLines(
decodeBasicHtmlEntities(withBreakHints.replace(/<[^>]*>/g, ' ')),
)
}
const template = document.createElement('template')
template.innerHTML = withBreakHints
return normalizePlainTextLines(template.textContent ?? '')
}
function normalizePlainText(value: string) {
return value
.split(/\n{2,}/)
@@ -199,3 +236,16 @@ export function privacyContentToHtml(value: string) {
export function privacyContentForStorage(value: string) {
return privacyContentToHtml(value)
}
export function plainTextContentFromStorage(value: string) {
const trimmed = value.trim()
if (!trimmed) {
return ''
}
return hasHtmlMarkup(trimmed) ? htmlToPlainText(trimmed) : normalizePlainTextLines(trimmed)
}
export function plainTextContentForStorage(value: string) {
return plainTextContentFromStorage(value)
}
+78 -3
View File
@@ -23,7 +23,7 @@ export interface VersionRule {
export const semanticVersion = '0.1.0'
export const buildVersion = import.meta.env.VITE_BUILD_VERSION?.trim() || semanticVersion
export const buildDateLabel = import.meta.env.VITE_BUILD_DATE?.trim() || '26.06.2026'
export const buildDateLabel = import.meta.env.VITE_BUILD_DATE?.trim() || '29.06.2026'
export const versionRules: VersionRule[] = [
{
@@ -49,8 +49,58 @@ export const releaseVersions: ReleaseVersionEntry[] = [
buildVersion,
buildDateLabel,
releaseType: 'feature',
summary: 'Feature-Release mit Twitch Login, Team-Verknuepfung, Admin-UX-Polish und Backend-Haertung.',
summary: 'Feature-Release mit neuem Admin-Cockpit, Voting- und Gewinner-Workflows, Landingpage-Polish, Workflow-Regeln und robusterer Deploy-Basis.',
notes: [
{
type: 'feature',
title: 'Admin-Dashboard als Cockpit ausgebaut',
description: 'Das Dashboard zeigt jetzt Readiness, Public Health, Queue-Lage, Timeline und Top-Kategorien kompakter an einem Ort.',
},
{
type: 'feature',
title: 'Voting hat eine eigene Admin-Seite',
description: 'Der neue Voting-Bereich bringt Kategoriebaum, Vote-Status und Leaderboard-Modal in einen klareren Arbeitsfluss.',
},
{
type: 'feature',
title: 'Gewinnerverwaltung wurde neu strukturiert',
description: 'Gewinner lassen sich in einem Baum-Workspace setzen, pruefen und mit Workflow-Status besser fuer die Veroeffentlichung vorbereiten.',
},
{
type: 'feature',
title: 'Workflow-Regeln pro Saison sind sichtbarer',
description: 'Award-Workflow-Regeln fuer Kandidaturen, Gewinnerplaetze, Clip-Link-Pflicht und Hinweise werden einheitlicher dargestellt und gesteuert.',
},
{
type: 'feature',
title: 'Content Hub erweitert',
description: 'Awards, Stream-Banner, Social Links, Landing-Extras, Footer-Inhalte und Datenschutztexte sind klarer im Adminbereich gepflegt.',
},
{
type: 'feature',
title: 'Showact-Bewerbungen sind flexibler',
description: 'Der Showact-Flow unterstuetzt dynamische Formularfelder, Zeitfenster und bessere Admin-Konfiguration.',
},
{
type: 'feature',
title: 'Landingpage wirkt fertiger',
description: 'Hero, Kategorien, Timeline, Extras, Support-Footer und Gewinner-Showcase wurden visuell ueberarbeitet und staerker mit Backend-Daten verbunden.',
},
{
type: 'feature',
title: 'Voting-Animation fuehlt sich lebendiger an',
description: 'Beim Vote gleitet nun eine laengere Lichtwelle ueber die ganze Card, begleitet von mehr Sternen und weicheren Kurven.',
},
{
type: 'feature',
title: 'Archiv-Cards verlinken direkt weiter',
description: 'Gewinner im Archiv koennen direkt ueber die Card geoeffnet werden, waehrend die Plattform weiterhin sichtbar bleibt.',
},
{
type: 'feature',
title: 'Team-Rechte sind genauer steuerbar',
description: 'Teamrollen koennen ueber eine eigene Rechte-Ansicht besser nachvollzogen und verwaltet werden.',
},
{
type: 'feature',
title: 'Twitch Login ist live',
@@ -74,7 +124,17 @@ export const releaseVersions: ReleaseVersionEntry[] = [
{
type: 'feature',
title: 'Admin-Bereiche wurden aufgeraeumt',
description: 'Jahre, Landingpage, Risiko, Team und Analytics sind kompakter, klarer gruppiert und weniger erschlagend.',
description: 'Jahre, Landingpage, Risiko, Team, Analytics, Voting und Gewinner sind kompakter, klarer gruppiert und weniger erschlagend.',
},
{
type: 'feature',
title: 'Season-Setup wurde robuster',
description: 'Saison-Erstellung, Uebersicht, Phasen, Kategorie-Gesundheit und Workflow-Status sind enger miteinander verbunden.',
},
{
type: 'feature',
title: 'Tracking- und Review-Hinweise sind besser eingebunden',
description: 'Review-Notizen, Tracking-Status und optionale Hinweise laufen sichtbarer durch die Admin-Workflows.',
},
{
type: 'feature',
@@ -91,11 +151,26 @@ export const releaseVersions: ReleaseVersionEntry[] = [
title: 'Dropdowns sehen wieder korrekt aus',
description: 'Das Dropdown-Design wurde stabilisiert und passt nun besser zum restlichen Admin-UI.',
},
{
type: 'fix',
title: 'Admin-Workspaces wurden gesplittet',
description: 'Grosse Verwaltungsflaechen wurden in kleinere Komponenten und Composables zerlegt, damit sie wartbarer bleiben.',
},
{
type: 'fix',
title: 'Backend, Auth und Sicherheit wurden gehaertet',
description: 'Sessions, Team-Rechte, Audit-Logs, Public Writes, Vote-Eindeutigkeit und Runtime-Settings wurden robuster gemacht.',
},
{
type: 'fix',
title: 'Migrationen und Seed-Daten wurden bereinigt',
description: 'Die Datenbankbasis wurde konsolidiert, Demo-Szenarien klarer getrennt und alte Bootstrap-Pfade entfernt.',
},
{
type: 'fix',
title: 'Release- und Smoke-Dokumentation erweitert',
description: 'Projekt-, Release- und End-to-End-Smoke-Dokumente beschreiben den aktuellen Deploy- und Pruefpfad genauer.',
},
{
type: 'fix',
title: 'Datenbank- und Deploy-Sicherheit verbessert',
+16 -3
View File
@@ -16,6 +16,7 @@ const adminRoutePermissions: Record<string, string> = {
'admin-users-logs': 'audit',
'admin-team': 'team',
'admin-analytics': 'analytics',
'admin-voting': 'voting',
'admin-winners': 'winners',
'admin-content': 'content',
'admin-settings': 'settings',
@@ -24,6 +25,18 @@ const adminRoutePermissions: Record<string, string> = {
'admin-tracking-rules': 'settings',
}
function hasAdminPermission(permissionKeys: string[] = [], permissionKey: string) {
if (permissionKeys.includes(permissionKey)) {
return true
}
if (permissionKey === 'voting') {
return permissionKeys.includes('analytics') || permissionKeys.includes('winners')
}
return false
}
function firstAllowedAdminRoute(permissionKeys: string[] = []) {
const preferredRoutes = [
'admin-dashboard',
@@ -35,8 +48,8 @@ function firstAllowedAdminRoute(permissionKeys: string[] = []) {
'admin-team',
]
return preferredRoutes.find((routeName) => permissionKeys.includes(adminRoutePermissions[routeName]))
?? Object.entries(adminRoutePermissions).find(([, permission]) => permissionKeys.includes(permission))?.[0]
return preferredRoutes.find((routeName) => hasAdminPermission(permissionKeys, adminRoutePermissions[routeName]))
?? Object.entries(adminRoutePermissions).find(([, permission]) => hasAdminPermission(permissionKeys, permission))?.[0]
?? 'home'
}
@@ -114,7 +127,7 @@ router.beforeEach(async (to) => {
if (to.path.startsWith('/admin') && authStore.session) {
const routeName = String(to.name ?? '')
const neededPermission = adminRoutePermissions[routeName]
if (neededPermission && !authStore.session.permissionKeys.includes(neededPermission)) {
if (neededPermission && !hasAdminPermission(authStore.session.permissionKeys, neededPermission)) {
const fallback = firstAllowedAdminRoute(authStore.session.permissionKeys)
return fallback === 'home' ? { name: 'home' } : { name: fallback }
}
+6
View File
@@ -77,6 +77,12 @@ export const adminRoutes: RouteRecordRaw[] = [
component: () => import('../views/admin/AdminAnalyticsView.vue'),
meta: { keepAlive: true },
},
{
path: 'voting',
name: 'admin-voting',
component: () => import('../views/admin/AdminVotingView.vue'),
meta: { keepAlive: true },
},
{
path: 'winners',
name: 'admin-winners',
+14 -1
View File
@@ -24,9 +24,22 @@ const adminWorkspacePermissions = new Set([
'risk',
'audit',
'analytics',
'voting',
'winners',
])
function hasEffectivePermission(permissionKeys: string[] = [], permissionKey: string) {
if (permissionKeys.includes(permissionKey)) {
return true
}
if (permissionKey === 'voting') {
return permissionKeys.includes('analytics') || permissionKeys.includes('winners')
}
return false
}
function readStoredToken() {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(AUTH_TOKEN_KEY)
@@ -56,7 +69,7 @@ export const useAuthStore = defineStore('auth', {
canManageAdminWorkspace: (state) => adminWorkspaceRoles.has(state.session?.role ?? '') || (state.session?.permissionKeys ?? []).some((key) => adminWorkspacePermissions.has(key)),
canManageOperationalSettings: (state) => state.session?.role === 'owner' || state.session?.role === 'creator',
canManageTeam: (state) => (state.session?.permissionKeys ?? []).includes('team'),
hasPermission: (state) => (permissionKey: string) => (state.session?.permissionKeys ?? []).includes(permissionKey),
hasPermission: (state) => (permissionKey: string) => hasEffectivePermission(state.session?.permissionKeys ?? [], permissionKey),
isOwner: (state) => state.session?.role === 'owner',
isCreator: (state) => state.session?.role === 'creator',
isOwnerOrCreator: (state) => state.session?.role === 'owner' || state.session?.role === 'creator',
+43 -32
View File
@@ -5,6 +5,7 @@ import {
classifyPublicLoadError,
createAwardsState,
createEmptyAdminDashboard,
createEmptyAdminOperationalSettings,
createEmptyAdminRiskFlagsResponse,
createEmptyAdminSeasonDetail,
createEmptyAdminOptionalFeatureSettings,
@@ -65,12 +66,10 @@ export const useAwardsStore = defineStore('awards', {
this.categories = await api.getSeasonCategories(this.overview.year)
await this.loadPublicSponsors(this.overview.year)
const overviewArchiveYears = this.overview.archiveYears ?? []
const initialArchiveYear = canExposeCurrentSeasonWinners(this.overview.currentPhase)
? this.overview.year
: overviewArchiveYears[0]?.year
?? this.overview.winnersPreview[0]?.year
?? this.overview.year - 1
this.archive = await api.getWinnerArchive(initialArchiveYear)
const initialArchiveYear = overviewArchiveYears[0]?.year ?? this.overview.year - 1
this.archive = overviewArchiveYears.length > 0
? await api.getWinnerArchive(initialArchiveYear)
: createEmptyArchive(initialArchiveYear)
this.apiMode = 'api'
} catch (error) {
this.apiMode = 'fallback'
@@ -102,18 +101,18 @@ export const useAwardsStore = defineStore('awards', {
},
async loadAdmin() {
try {
const [admin, adminSeasons, adminSiteSettings, adminOptionalFeatureSettings, databaseHealth] = await Promise.all([
api.getAdminDashboard(),
const [adminSeasons, adminSiteSettings, adminOptionalFeatureSettings, adminOperationalSettings, databaseHealth] = await Promise.all([
api.getAdminSeasons(),
api.getAdminSiteSettings(),
api.getAdminOptionalFeatureSettings(),
api.getDatabaseHealth(),
api.getAdminSiteSettings().catch(() => createEmptyAdminSiteSettings()),
api.getAdminOptionalFeatureSettings().catch(() => createEmptyAdminOptionalFeatureSettings()),
api.getAdminOperationalSettings().catch(() => createEmptyAdminOperationalSettings()),
api.getDatabaseHealth().catch(() => createEmptyDatabaseHealth()),
])
this.admin = admin
this.adminSeasons = adminSeasons
this.adminSiteSettings = adminSiteSettings
this.adminOptionalFeatureSettings = adminOptionalFeatureSettings
this.adminOperationalSettings = adminOperationalSettings
this.databaseHealth = databaseHealth
if (!this.adminSelectedSeasonId || !this.adminSeasons.some((season) => season.id === this.adminSelectedSeasonId)) {
@@ -121,17 +120,20 @@ export const useAwardsStore = defineStore('awards', {
}
if (this.adminSelectedSeasonId) {
const [seasonDetail, sponsors, showacts, adminWorkflowRules] = await Promise.all([
const [admin, seasonDetail, sponsors, showacts, adminWorkflowRules] = await Promise.all([
api.getAdminDashboard(this.adminSelectedSeasonId),
api.getAdminSeasonDetail(this.adminSelectedSeasonId),
api.getAdminSponsors(this.adminSelectedSeasonId),
api.getAdminShowactApplications(this.adminSelectedSeasonId),
api.getAdminWorkflowRules(this.adminSelectedSeasonId),
api.getAdminSponsors(this.adminSelectedSeasonId).catch(() => []),
api.getAdminShowactApplications(this.adminSelectedSeasonId).catch(() => []),
api.getAdminWorkflowRules(this.adminSelectedSeasonId).catch(() => createEmptyAdminWorkflowRulesResponse()),
])
this.admin = admin
this.adminSeasonDetail = normalizeSeasonDetail(seasonDetail)
this.adminSponsors = sponsors
this.adminShowactApplications = showacts
this.adminWorkflowRules = adminWorkflowRules
} else {
this.admin = createEmptyAdminDashboard()
this.adminSeasonDetail = createEmptyAdminSeasonDetail()
this.adminSponsors = []
this.adminShowactApplications = []
@@ -144,6 +146,7 @@ export const useAwardsStore = defineStore('awards', {
this.adminSeasons = []
this.adminSeasonDetail = createEmptyAdminSeasonDetail()
this.adminSiteSettings = createEmptyAdminSiteSettings()
this.adminOperationalSettings = createEmptyAdminOperationalSettings()
this.adminOptionalFeatureSettings = createEmptyAdminOptionalFeatureSettings()
this.adminWorkflowRules = createEmptyAdminWorkflowRulesResponse()
this.adminTrackingRules = createEmptyAdminTrackingRulesResponse()
@@ -158,19 +161,22 @@ export const useAwardsStore = defineStore('awards', {
},
async loadAdminContentWorkspace() {
try {
const [adminSiteSettings, adminOptionalFeatureSettings, databaseHealth] = await Promise.all([
api.getAdminSiteSettings(),
api.getAdminOptionalFeatureSettings(),
api.getDatabaseHealth(),
const [adminSiteSettings, adminOptionalFeatureSettings, adminOperationalSettings, databaseHealth] = await Promise.all([
api.getAdminSiteSettings().catch(() => createEmptyAdminSiteSettings()),
api.getAdminOptionalFeatureSettings().catch(() => createEmptyAdminOptionalFeatureSettings()),
api.getAdminOperationalSettings().catch(() => createEmptyAdminOperationalSettings()),
api.getDatabaseHealth().catch(() => createEmptyDatabaseHealth()),
])
this.adminSiteSettings = adminSiteSettings
this.adminOptionalFeatureSettings = adminOptionalFeatureSettings
this.adminOperationalSettings = adminOperationalSettings
this.databaseHealth = databaseHealth
this.apiMode = 'api'
} catch {
this.apiMode = 'fallback'
this.adminSiteSettings = createEmptyAdminSiteSettings()
this.adminOperationalSettings = createEmptyAdminOperationalSettings()
this.adminOptionalFeatureSettings = createEmptyAdminOptionalFeatureSettings()
this.databaseHealth = createEmptyDatabaseHealth()
}
@@ -189,12 +195,14 @@ export const useAwardsStore = defineStore('awards', {
async loadAdminSeasonDetail(seasonId: number) {
try {
this.adminSelectedSeasonId = seasonId
const [seasonDetail, sponsors, showacts, adminWorkflowRules] = await Promise.all([
const [admin, seasonDetail, sponsors, showacts, adminWorkflowRules] = await Promise.all([
api.getAdminDashboard(seasonId),
api.getAdminSeasonDetail(seasonId),
api.getAdminSponsors(seasonId),
api.getAdminShowactApplications(seasonId),
api.getAdminWorkflowRules(seasonId),
api.getAdminSponsors(seasonId).catch(() => []),
api.getAdminShowactApplications(seasonId).catch(() => []),
api.getAdminWorkflowRules(seasonId).catch(() => createEmptyAdminWorkflowRulesResponse()),
])
this.admin = admin
this.adminSeasonDetail = normalizeSeasonDetail(seasonDetail)
this.adminSponsors = sponsors
this.adminShowactApplications = showacts
@@ -202,6 +210,7 @@ export const useAwardsStore = defineStore('awards', {
this.apiMode = 'api'
} catch {
this.apiMode = 'fallback'
this.admin = createEmptyAdminDashboard()
this.adminSeasonDetail = createEmptyAdminSeasonDetail()
this.adminSponsors = []
this.adminShowactApplications = []
@@ -416,6 +425,16 @@ export const useAwardsStore = defineStore('awards', {
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true, reloadHome: true })
return result
},
async publishAdminWinners(seasonId: number) {
const result = await api.publishAdminWinners(seasonId)
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true, reloadHome: true })
return result
},
async unpublishAdminWinners(seasonId: number) {
const result = await api.unpublishAdminWinners(seasonId)
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true, reloadHome: true })
return result
},
async approveAdminNomination(nominationId: number, seasonId: number, payload: ApproveNominationPayload) {
const result = await api.approveAdminNomination(nominationId, payload)
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true, reloadHome: true })
@@ -545,12 +564,4 @@ export const useAwardsStore = defineStore('awards', {
},
})
function canExposeCurrentSeasonWinners(currentPhase: string) {
const normalized = currentPhase.trim().toLowerCase()
return normalized.includes('abgeschlossen')
|| normalized.includes('archiv')
|| normalized.includes('complete')
|| normalized.includes('ended')
}
export type AwardsStore = ReturnType<typeof useAwardsStore>
+91 -2
View File
@@ -2,6 +2,7 @@ import { ApiRequestError } from '../../lib/http'
import type {
AdminDashboardResponse,
AdminOptionalFeatureSettingsResponse,
AdminOperationalSettingsResponse,
AdminRiskFlag,
AdminRiskFlagsResponse,
AdminSeasonDetailResponse,
@@ -28,7 +29,6 @@ export function createEmptyOverview(): OverviewResponse {
title: '',
showDate: '',
showStartsAt: '20:00:00',
showStreamUrl: '',
currentPhase: '',
isCommunityOnly: true,
loginProvider: 'Twitch',
@@ -44,6 +44,24 @@ export function createEmptyOverview(): OverviewResponse {
shareDiscordUrl: '',
privacyEmail: '',
privacyPolicyContent: '',
awardsSectionTitle: '',
awardsSectionDescription: '',
subcategoriesSectionTitle: '',
subcategoriesSectionDescription: '',
streamBanner: {
eyebrow: 'Das grosse Finale',
title: 'Award-Show Finale',
text: 'Finale, Countdown und Stream-Link an einem Ort. Sei live dabei, wenn die Community ihre Stars feiert.',
liveButtonLabel: 'Jetzt live · Zum Stream',
liveButtonUrl: '',
lockedButtonLabel: 'Stream noch gesperrt',
useCompletedContent: false,
completedEyebrow: 'Danke fürs Mitfiebern',
completedTitle: 'Award-Show abgeschlossen',
completedText: 'Die grosse Award-Show ist vorbei. Danke an alle, die nominiert, gevotet und live mitgefiebert haben.',
completedButtonLabel: 'Highlights ansehen',
completedButtonUrl: '',
},
socialLinks: [],
footerLinks: [],
},
@@ -87,6 +105,10 @@ export function createEmptyArchive(year = new Date().getFullYear() - 1): WinnerA
export function createEmptyAdminDashboard(): AdminDashboardResponse {
return {
seasonId: 0,
year: 0,
seasonName: '',
isCurrent: false,
metrics: [],
activities: [],
topCategories: [],
@@ -95,6 +117,27 @@ export function createEmptyAdminDashboard(): AdminDashboardResponse {
}
}
export function createEmptyAdminOperationalSettings(): AdminOperationalSettingsResponse {
return {
demoLoginManagedByDatabase: false,
demoLoginEnabled: false,
demoLoginEmail: '',
demoLoginPasswordSet: false,
demoLoginTwitchUserId: '',
demoLoginDisplayName: '',
twitchAuthManagedByDatabase: false,
twitchAuthConfigured: false,
twitchClientId: '',
twitchClientSecretSet: false,
twitchRedirectUri: '',
twitchScope: '',
sessionIdleTimeoutHours: 12,
maintenanceModeEnabled: false,
maintenanceTitle: 'Sternenpause',
maintenanceMessage: 'Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.',
}
}
export function createEmptyAdminRiskFlagsResponse(): AdminRiskFlagsResponse {
return {
items: [],
@@ -135,7 +178,7 @@ export function createEmptyAdminSeasonDetail(): AdminSeasonDetailResponse {
id: 0,
year: 0,
name: '',
showStreamUrl: '',
isDemo: false,
currentPhase: '',
isCurrent: false,
isCommunityOnly: true,
@@ -147,6 +190,8 @@ export function createEmptyAdminSeasonDetail(): AdminSeasonDetailResponse {
reviewEndsAt: '',
showDate: '',
showStartsAt: '20:00:00',
winnersPublishedAt: null,
winnersPublishedByTwitchId: null,
subcategoryTemplates: [],
categories: [],
candidates: [],
@@ -156,6 +201,17 @@ export function createEmptyAdminSeasonDetail(): AdminSeasonDetailResponse {
trackingReviewNotes: '',
showTrackingReviewNotes: true,
results: [],
votingWorkspace: {
summary: {
totalVotes: 0,
totalSubcategories: 0,
votedSubcategories: 0,
readySubcategories: 0,
problemSubcategories: 0,
winnerSetSubcategories: 0,
},
categories: [],
},
clipSubmissions: [],
}
}
@@ -179,6 +235,22 @@ export function createEmptyAdminSiteSettings(): AdminSiteSettingsResponse {
sponsorsContent: '',
showactsUrl: '',
showactsContent: '',
streamBannerEyebrow: 'Das grosse Finale',
streamBannerTitle: 'Award-Show Finale',
streamBannerText: 'Finale, Countdown und Stream-Link an einem Ort. Sei live dabei, wenn die Community ihre Stars feiert.',
streamBannerLiveButtonLabel: 'Jetzt live · Zum Stream',
streamBannerLiveButtonUrl: '',
streamBannerLockedButtonLabel: 'Stream noch gesperrt',
streamBannerUseCompletedContent: false,
streamBannerCompletedEyebrow: 'Danke fürs Mitfiebern',
streamBannerCompletedTitle: 'Award-Show abgeschlossen',
streamBannerCompletedText: 'Die grosse Award-Show ist vorbei. Danke an alle, die nominiert, gevotet und live mitgefiebert haben.',
streamBannerCompletedButtonLabel: 'Highlights ansehen',
streamBannerCompletedButtonUrl: '',
awardsSectionTitle: '',
awardsSectionDescription: '',
subcategoriesSectionTitle: '',
subcategoriesSectionDescription: '',
socialLinks: [],
faq: [],
showactFormSchemaJson: '[]',
@@ -221,6 +293,7 @@ export function createAwardsState() {
adminSeasons: [] as AdminSeasonListItem[],
adminSeasonDetail: createEmptyAdminSeasonDetail(),
adminSiteSettings: createEmptyAdminSiteSettings(),
adminOperationalSettings: createEmptyAdminOperationalSettings(),
adminOptionalFeatureSettings: createEmptyAdminOptionalFeatureSettings(),
adminWorkflowRules: createEmptyAdminWorkflowRulesResponse(),
adminTrackingRules: createEmptyAdminTrackingRulesResponse(),
@@ -245,6 +318,8 @@ export function createAwardsState() {
export function normalizeSeasonDetail(detail: AdminSeasonDetailResponse): AdminSeasonDetailResponse {
return {
...detail,
winnersPublishedAt: detail.winnersPublishedAt ?? null,
winnersPublishedByTwitchId: detail.winnersPublishedByTwitchId ?? null,
subcategoryTemplates: detail.subcategoryTemplates ?? [],
categories: detail.categories ?? [],
candidates: (detail.candidates ?? []).map((candidate) => ({
@@ -263,6 +338,20 @@ export function normalizeSeasonDetail(detail: AdminSeasonDetailResponse): AdminS
reviewedNominations: detail.reviewedNominations ?? [],
trackingReviewNotes: detail.trackingReviewNotes ?? '',
results: detail.results ?? [],
votingWorkspace: {
summary: {
totalVotes: detail.votingWorkspace?.summary?.totalVotes ?? 0,
totalSubcategories: detail.votingWorkspace?.summary?.totalSubcategories ?? 0,
votedSubcategories: detail.votingWorkspace?.summary?.votedSubcategories ?? 0,
readySubcategories: detail.votingWorkspace?.summary?.readySubcategories ?? 0,
problemSubcategories: detail.votingWorkspace?.summary?.problemSubcategories ?? 0,
winnerSetSubcategories: detail.votingWorkspace?.summary?.winnerSetSubcategories ?? 0,
},
categories: (detail.votingWorkspace?.categories ?? []).map((category) => ({
...category,
leaderboard: category.leaderboard ?? [],
})),
},
clipSubmissions: detail.clipSubmissions ?? [],
}
}
+82 -2
View File
@@ -13,7 +13,8 @@ export interface AdminActivity {
export interface AdminTopCategory {
category: string
votes: number
value: number
basis: string
}
export interface AdminRiskFlag {
@@ -202,6 +203,10 @@ export interface AdminAuditQueryOptions {
}
export interface AdminDashboardResponse {
seasonId: number
year: number
seasonName: string
isCurrent: boolean
metrics: AdminMetric[]
activities: AdminActivity[]
topCategories: AdminTopCategory[]
@@ -225,7 +230,10 @@ export interface AdminSeasonListItem {
name: string
currentPhase: string
isCurrent: boolean
isDemo: boolean
categoryCount: number
winnersPublishedAt: string | null
winnersPublishedByTwitchId: string | null
}
export interface AdminCategoryItem {
@@ -276,6 +284,58 @@ export interface AdminAwardResultItem {
candidatePlatform: string
}
export interface AdminVotingWorkspaceSummary {
totalVotes: number
totalSubcategories: number
votedSubcategories: number
readySubcategories: number
problemSubcategories: number
winnerSetSubcategories: number
}
export interface AdminVotingCandidateRank {
candidateId: number
displayName: string
channelSlug: string
platform: string
votes: number
voteSharePercent: number
nominationTally: number
hasClip: boolean
clipEmbedStatus: string
hasWinnerConflict: boolean
isCurrentWinner: boolean
isAccepted: boolean
isTopTie: boolean
}
export interface AdminVotingCategoryWorkspaceItem {
categoryId: number
groupName: string
categoryName: string
sortOrder: number
viewerRangeMin: number | null
viewerRangeMax: number | null
voteCount: number
candidateCount: number
readyCandidateCount: number
nominationCount: number
openReviewCount: number
hasWinner: boolean
winnerReady: boolean
hasTopVoteTie: boolean
hasMissingClip: boolean
hasRuleConflict: boolean
hasOpenReviews: boolean
hasSoftNominatorWarning: boolean
leaderboard: AdminVotingCandidateRank[]
}
export interface AdminVotingWorkspace {
summary: AdminVotingWorkspaceSummary
categories: AdminVotingCategoryWorkspaceItem[]
}
export interface AdminNominationReviewItem {
id: number
categoryId: number | null
@@ -363,7 +423,7 @@ export interface AdminSeasonDetailResponse {
id: number
year: number
name: string
showStreamUrl: string
isDemo: boolean
currentPhase: string
isCurrent: boolean
isCommunityOnly: boolean
@@ -375,6 +435,8 @@ export interface AdminSeasonDetailResponse {
reviewEndsAt: string
showDate: string
showStartsAt: string
winnersPublishedAt: string | null
winnersPublishedByTwitchId: string | null
subcategoryTemplates: AdminSubcategoryTemplate[]
categories: AdminCategoryItem[]
candidates: AdminCandidateItem[]
@@ -384,6 +446,7 @@ export interface AdminSeasonDetailResponse {
trackingReviewNotes: string
showTrackingReviewNotes: boolean
results: AdminAwardResultItem[]
votingWorkspace: AdminVotingWorkspace
clipSubmissions: AdminClipSubmissionItem[]
}
@@ -405,6 +468,22 @@ export interface AdminSiteSettingsResponse {
sponsorsContent: string
showactsUrl: string
showactsContent: string
streamBannerEyebrow: string
streamBannerTitle: string
streamBannerText: string
streamBannerLiveButtonLabel: string
streamBannerLiveButtonUrl: string
streamBannerLockedButtonLabel: string
streamBannerUseCompletedContent: boolean
streamBannerCompletedEyebrow: string
streamBannerCompletedTitle: string
streamBannerCompletedText: string
streamBannerCompletedButtonLabel: string
streamBannerCompletedButtonUrl: string
awardsSectionTitle: string
awardsSectionDescription: string
subcategoriesSectionTitle: string
subcategoriesSectionDescription: string
socialLinks: PublicSocialLink[]
faq: FaqItem[]
showactFormSchemaJson: string
@@ -476,6 +555,7 @@ export interface AdminTeamPermission {
key: string
label: string
description: string
groupLabel: string
menuPath: string
readOnlySupported: boolean
}
+16 -2
View File
@@ -36,7 +36,6 @@ export interface CreateVotePayload {
export interface CreateSeasonPayload {
year: number
name: string
showStreamUrl: string
currentPhase: string
isCurrent: boolean
isCommunityOnly: boolean
@@ -54,7 +53,6 @@ export interface CreateSeasonPayload {
export interface UpdateSeasonPayload {
year: number
name: string
showStreamUrl: string
currentPhase: string
isCurrent: boolean
isCommunityOnly: boolean
@@ -291,6 +289,22 @@ export interface UpdateSiteSettingsPayload {
sponsorsContent: string
showactsUrl: string
showactsContent: string
streamBannerEyebrow: string
streamBannerTitle: string
streamBannerText: string
streamBannerLiveButtonLabel: string
streamBannerLiveButtonUrl: string
streamBannerLockedButtonLabel: string
streamBannerUseCompletedContent: boolean
streamBannerCompletedEyebrow: string
streamBannerCompletedTitle: string
streamBannerCompletedText: string
streamBannerCompletedButtonLabel: string
streamBannerCompletedButtonUrl: string
awardsSectionTitle: string
awardsSectionDescription: string
subcategoriesSectionTitle: string
subcategoriesSectionDescription: string
socialLinks: PublicSocialLink[]
faq: FaqItem[]
showactFormSchemaJson: string
+22 -3
View File
@@ -16,6 +16,7 @@ export interface FeaturedCategory {
export interface WinnerPreview {
year: number
categoryGroup: string
category: string
winnerName: string
winnerSlug: string
@@ -53,6 +54,21 @@ export interface FooterLink {
content: string
}
export interface PublicStreamBannerContent {
eyebrow: string
title: string
text: string
liveButtonLabel: string
liveButtonUrl: string
lockedButtonLabel: string
useCompletedContent: boolean
completedEyebrow: string
completedTitle: string
completedText: string
completedButtonLabel: string
completedButtonUrl: string
}
export interface PublicSiteContent {
hostDisplayName: string
hostTagline: string
@@ -61,6 +77,11 @@ export interface PublicSiteContent {
shareDiscordUrl: string
privacyEmail: string
privacyPolicyContent: string
awardsSectionTitle: string
awardsSectionDescription: string
subcategoriesSectionTitle: string
subcategoriesSectionDescription: string
streamBanner: PublicStreamBannerContent
socialLinks: PublicSocialLink[]
footerLinks: FooterLink[]
}
@@ -91,7 +112,6 @@ export interface OverviewResponse {
title: string
showDate: string
showStartsAt: string
showStreamUrl: string
currentPhase: string
isCommunityOnly: boolean
loginProvider: string
@@ -123,8 +143,6 @@ export interface PublicCategoryDetail {
groupName: string
description: string
maxNomineesPerUser: number
viewerRangeMin: number | null
viewerRangeMax: number | null
candidates: CandidateSummary[]
}
@@ -135,6 +153,7 @@ export interface SeasonCategoriesResponse {
}
export interface WinnerArchiveItem {
categoryGroup: string
category: string
winnerName: string
winnerSlug: string
+15 -88
View File
@@ -1,19 +1,20 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { BarChart3, CheckCircle2, ExternalLink, TrendingUp } from '@lucide/vue'
import { BarChart3, ExternalLink, TrendingUp } from '@lucide/vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import AdminCategoryHealthCard from '../../components/admin/AdminCategoryHealthCard.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminAnalyticsManager } from '../../components/admin/useAdminAnalyticsManager'
import Card from '../../components/ui/Card.vue'
import NativeSelect from '../../components/ui/NativeSelect.vue'
const {
categoryHealth,
categoryGroups,
metricCards,
readinessCards,
readinessScope,
insightCards,
attentionItems,
topCategoriesEnriched,
@@ -272,91 +273,17 @@ watch([healthFilter, groupFilter], ([nextHealth, nextGroup]) => {
</div>
</Card>
<!-- Category health -->
<Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategoriegesundheit</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Status aller Kategorien</h2>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-2">
<NativeSelect
v-model="groupFilter"
class="w-44"
:options="groupOptions.map((group) => ({ label: group, value: group }))"
/>
<div class="flex flex-wrap gap-1.5">
<button
v-for="opt in healthStatusOptions"
:key="opt.key"
type="button"
class="flex h-9 items-center gap-1.5 rounded-xl border px-3 text-xs font-semibold transition"
:class="healthFilter === opt.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="healthFilter = opt.key"
>
<span v-if="opt.dot" class="h-2 w-2 rounded-full" :class="opt.dot" />
{{ opt.label }}
</button>
</div>
</div>
</div>
</div>
<div v-if="filteredHealth.length > 0" class="divide-y divide-violet-50">
<RouterLink
v-for="category in filteredHealth"
:key="category.id"
: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 -->
<span class="min-w-0">
<span class="flex items-center gap-2">
<span class="h-2 w-2 shrink-0 rounded-full" :class="category.statusDot" />
<strong class="truncate text-sm text-slate-900">{{ category.name }}</strong>
</span>
<span class="mt-0.5 block truncate text-xs text-slate-500 pl-4">{{ category.groupName || 'Ohne Gruppe' }}</span>
</span>
<!-- Candidates -->
<span class="text-sm text-slate-500">
<strong class="block text-slate-900">{{ category.candidates }}</strong>
Kandidaten
</span>
<!-- Reviews -->
<span class="text-sm" :class="category.reviews > 0 ? 'text-amber-600 font-semibold' : 'text-slate-500'">
<strong class="block" :class="category.reviews > 0 ? 'text-amber-700' : 'text-slate-900'">{{ category.reviews }}</strong>
Reviews
</span>
<!-- Votes + pct -->
<span class="text-sm text-slate-500">
<strong class="block text-slate-900">{{ category.votes.toLocaleString('de-DE') }}</strong>
<span class="text-violet-600 font-semibold">{{ category.votePct }}%</span>
</span>
<!-- Status badge -->
<span class="inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-bold" :class="category.statusClass">
<CheckCircle2 v-if="category.status === 'Gewinner gesetzt'" class="h-3 w-3" />
{{ category.status }}
</span>
</RouterLink>
</div>
<div v-else class="px-5 py-12 text-center text-sm text-slate-500">
Keine Kategorien für diesen Filter.
</div>
<div class="flex items-center gap-2 border-t border-violet-100 bg-emerald-50/60 px-5 py-3 text-sm text-emerald-800">
<CheckCircle2 class="h-4 w-4 shrink-0 text-emerald-500" />
Analytics nutzt dieselben Admin-Daten wie Dashboard, Jahre und Kategorien.
<RouterLink to="/admin/categories" class="ml-auto flex items-center gap-1 text-xs font-semibold text-violet-600 hover:text-violet-800">
Kategorien bearbeiten <ExternalLink class="h-3 w-3" />
</RouterLink>
</div>
</Card>
<AdminCategoryHealthCard
:filtered-health="filteredHealth"
:health-summary="healthSummary"
:group-options="groupOptions"
:group-filter="groupFilter"
:health-filter="healthFilter"
:health-status-options="healthStatusOptions"
:candidate-warnings-active="readinessScope.candidateWarningsActive"
:review-warnings-active="readinessScope.reviewWarningsActive"
@update:group-filter="groupFilter = $event"
@update:health-filter="healthFilter = $event"
/>
</div>
</template>
+57 -6
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { FileText, Handshake, MessageCircleQuestion, Mic2, ScrollText, Send, Share2, ShieldCheck } from '@lucide/vue'
import { computed, ref } from 'vue'
import { FileText, Handshake, MessageCircleQuestion, Mic2, RadioTower, ScrollText, Send, Share2, ShieldCheck, Sparkles } from '@lucide/vue'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AdminContentAwardsSection from '../../components/admin/AdminContentAwardsSection.vue'
import AdminContentBasicsSection from '../../components/admin/AdminContentBasicsSection.vue'
import AdminContentFaqPreviewModal from '../../components/admin/AdminContentFaqPreviewModal.vue'
import AdminContentFaqSection from '../../components/admin/AdminContentFaqSection.vue'
@@ -10,15 +12,18 @@ import AdminContentPageSection from '../../components/admin/AdminContentPageSect
import AdminContentPrivacyPreviewModal from '../../components/admin/AdminContentPrivacyPreviewModal.vue'
import AdminContentPrivacySection from '../../components/admin/AdminContentPrivacySection.vue'
import AdminContentSocialLinksSection from '../../components/admin/AdminContentSocialLinksSection.vue'
import AdminContentStreamBannerSection from '../../components/admin/AdminContentStreamBannerSection.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import Modal from '../../components/ui/Modal.vue'
import { watchAdminToast } from '../../composables/useAdminToast'
import { useAwardsStore } from '../../stores/awards'
import { useAdminContentManager } from '../../components/admin/useAdminContentManager'
type ContentEditorKey = 'imprint' | 'contact' | 'socials' | 'faq' | 'privacy' | 'showacts' | 'sponsors'
type ContentEditorKey = 'awards' | 'streamBanner' | 'imprint' | 'contact' | 'socials' | 'faq' | 'privacy' | 'showacts' | 'sponsors'
const store = useAwardsStore()
const route = useRoute()
const router = useRouter()
const {
form,
@@ -52,6 +57,22 @@ const faqPreviewOpen = ref(false)
const activeContentEditor = ref<ContentEditorKey | null>(null)
const contentEditors = computed(() => [
{
key: 'awards' as const,
eyebrow: 'Landingpage',
title: 'Awards-Bereich',
description: 'Titel und Beschreibung für Die Awards und Unterkategorien pflegen.',
metric: form.awardsSectionTitle.trim() && form.subcategoriesSectionTitle.trim() ? 'Texte gepflegt' : 'Texte prüfen',
icon: Sparkles,
},
{
key: 'streamBanner' as const,
eyebrow: 'Landingpage',
title: 'Finale-Banner',
description: 'Titel, Text, Button und Abschluss-Inhalt für den Countdown-Banner pflegen.',
metric: form.streamBannerUseCompletedContent ? 'Abschluss aktiv' : 'Countdown aktiv',
icon: RadioTower,
},
{
key: 'imprint' as const,
eyebrow: 'Rechtliches',
@@ -112,8 +133,26 @@ const contentEditors = computed(() => [
const currentContentEditor = computed(() => contentEditors.value.find((item) => item.key === activeContentEditor.value) ?? null)
watch(
() => route.query.editor,
(editor) => {
const resolved = Array.isArray(editor) ? editor[0] : editor
activeContentEditor.value = contentEditors.value.some((item) => item.key === resolved)
? resolved as ContentEditorKey
: null
},
{ immediate: true },
)
function openContentEditor(key: ContentEditorKey) {
activeContentEditor.value = key
const nextQuery = { ...route.query, editor: key } as Record<string, string>
void router.replace({ query: nextQuery })
}
function closeContentEditor() {
const nextQuery = { ...route.query } as Record<string, string>
delete nextQuery.editor
void router.replace({ query: nextQuery })
}
</script>
@@ -159,10 +198,22 @@ function openContentEditor(key: ContentEditorKey) {
:title="currentContentEditor?.title"
:subtitle="currentContentEditor?.description"
size="xl"
@close="activeContentEditor = null"
@close="closeContentEditor"
>
<AdminContentAwardsSection
v-if="activeContentEditor === 'awards'"
:form="form"
:saving="saving"
:save-site-settings="saveSiteSettings"
/>
<AdminContentStreamBannerSection
v-else-if="activeContentEditor === 'streamBanner'"
:form="form"
:saving="saving"
:save-site-settings="saveSiteSettings"
/>
<AdminContentPageSection
v-if="activeContentEditor === 'imprint'"
v-else-if="activeContentEditor === 'imprint'"
:form="form"
:saving="saving"
content-key="imprintContent"
+46 -12
View File
@@ -4,7 +4,10 @@ import { LayoutDashboard } from '@lucide/vue'
import AdminDashboardActivitySection from '../../components/admin/AdminDashboardActivitySection.vue'
import AdminDashboardChecksSection from '../../components/admin/AdminDashboardChecksSection.vue'
import AdminDashboardHeroSection from '../../components/admin/AdminDashboardHeroSection.vue'
import AdminDashboardPrioritySection from '../../components/admin/AdminDashboardPrioritySection.vue'
import AdminDashboardPublicHealthCard from '../../components/admin/AdminDashboardPublicHealthCard.vue'
import AdminDashboardQueueCard from '../../components/admin/AdminDashboardQueueCard.vue'
import AdminDashboardReadinessCard from '../../components/admin/AdminDashboardReadinessCard.vue'
import AdminDashboardTimelineStrip from '../../components/admin/AdminDashboardTimelineStrip.vue'
import AdminDashboardTopCategoriesSection from '../../components/admin/AdminDashboardTopCategoriesSection.vue'
import AdminDashboardYearTotalsSection from '../../components/admin/AdminDashboardYearTotalsSection.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
@@ -16,15 +19,25 @@ const {
activities,
topCategories,
metricCards,
openReviewCount,
openRiskCount,
liveSummary,
liveStatusBadge,
maxCategoryVotes,
totalCategoryVotes,
liveStatusTone,
maxCategoryValue,
topCategoryTitle,
topCategoryTotalLabel,
yearTotals,
priorityActions,
queueItems,
operationChecks,
timelinePhases,
activeCountdown,
phaseMismatch,
phaseMismatchLabel,
readinessItems,
readinessScore,
publicHealthItems,
adminGreeting,
canViewAuditLog,
canOpenYears,
} = useAdminDashboardOverview()
</script>
@@ -32,25 +45,42 @@ const {
<div class="space-y-6">
<AdminPageHeader
eyebrow="Dashboard"
:description="`${adminGreeting} · Saisonlage, Queues und Public-Readiness für das gewählte Jahr.`"
:icon="LayoutDashboard"
/>
<AdminSeasonToolbar />
<AdminDashboardTimelineStrip
:timeline-phases="timelinePhases"
:active-countdown="activeCountdown"
:phase-mismatch="phaseMismatch"
:phase-mismatch-label="phaseMismatchLabel"
:can-open-years="canOpenYears"
/>
<section class="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
<AdminDashboardHeroSection
:metric-cards="metricCards"
:live-summary="liveSummary"
:live-status-badge="liveStatusBadge"
:open-risk-count="openRiskCount"
:open-review-count="openReviewCount"
:live-status-tone="liveStatusTone"
/>
<AdminDashboardPrioritySection :priority-actions="priorityActions" />
<AdminDashboardQueueCard :queue-items="queueItems" />
</section>
<AdminDashboardChecksSection :operation-checks="operationChecks" />
<section class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<AdminDashboardReadinessCard
:readiness-items="readinessItems"
:readiness-score="readinessScore"
/>
<AdminDashboardPublicHealthCard :public-health-items="publicHealthItems" />
</section>
<section class="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
<AdminDashboardYearTotalsSection
:year="store.adminSeasonDetail.year"
@@ -58,12 +88,16 @@ const {
/>
<AdminDashboardTopCategoriesSection
:total-category-votes="totalCategoryVotes"
:max-category-votes="maxCategoryVotes"
:max-category-value="maxCategoryValue"
:top-category-title="topCategoryTitle"
:top-category-total-label="topCategoryTotalLabel"
:top-categories="topCategories"
/>
</section>
<AdminDashboardActivitySection :activities="activities" />
<AdminDashboardActivitySection
:activities="activities"
:can-view-audit-log="canViewAuditLog"
/>
</div>
</template>
+11 -3
View File
@@ -15,6 +15,7 @@ import {
ShieldCheck,
Tags,
Trophy,
Vote,
UserCog,
Users,
Wrench,
@@ -40,15 +41,23 @@ const totalClipCount = computed(() => store.adminSeasonDetail.clipSubmissions.le
const adminWorkspaceLoading = ref(false)
const adminWorkspaceLoaded = ref(false)
const mobileNavOpen = ref(false)
const seasonWorkspacePermissions = ['dashboard', 'years', 'nominations', 'categories', 'candidates', 'clips', 'analytics', 'voting', 'winners']
useBodyScrollLock(() => mobileNavOpen.value)
const canLoadSeasonWorkspace = computed(() =>
authStore.canManageAdminWorkspace
|| seasonWorkspacePermissions.some((permission) => authStore.hasPermission(permission)),
)
const fullNavGroups = computed(() => [
{
label: 'Betrieb',
items: [
{ label: 'Dashboard', to: '/admin/dashboard', description: 'Live-Lage und Aufgaben', icon: LayoutDashboard, permission: 'dashboard', badge: () => null },
{ label: 'Nominierungen', to: '/admin/nominations', description: 'Eingang und Backlog', icon: ClipboardList, permission: 'nominations', badge: () => `${store.adminSeasonDetail.pendingNominations.length}` },
{ label: 'Voting', to: '/admin/voting', description: 'Stimmenlage und Gewinner-Vorbereitung', icon: Vote, permission: 'voting', badge: () => `${store.adminSeasonDetail.votingWorkspace.summary.problemSubcategories}` },
{ label: 'Gewinner', to: '/admin/winners', description: 'Finale Ergebnisse freigeben', icon: Trophy, permission: 'winners', badge: () => `${store.adminSeasonDetail.results.length}` },
],
},
{
@@ -86,7 +95,6 @@ const fullNavGroups = computed(() => [
label: 'Auswertung',
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}` },
],
},
{
@@ -126,7 +134,7 @@ async function ensureAdminWorkspace() {
adminWorkspaceLoading.value = true
try {
if (authStore.canManageAdminWorkspace) {
if (canLoadSeasonWorkspace.value) {
await store.initializeAdminWorkspace()
} else if (authStore.hasPermission('content') || authStore.hasPermission('settings')) {
await store.loadAdminContentWorkspace()
@@ -138,7 +146,7 @@ async function ensureAdminWorkspace() {
}
watch(
() => [authStore.hydrated, authStore.canAccessAdmin, authStore.canManageAdminWorkspace, route.path] as const,
() => [authStore.hydrated, authStore.canAccessAdmin, canLoadSeasonWorkspace.value, route.path] as const,
() => {
void ensureAdminWorkspace()
},
+18 -10
View File
@@ -203,16 +203,24 @@ const readinessScore = computed(() => {
</label>
</div>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Finaler Stream-Link</span>
<input
v-model="form.showStreamUrl"
type="url"
placeholder="https://twitch.tv/jayuhime"
:disabled="saving"
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:bg-slate-50 disabled:text-slate-400"
/>
</label>
<div class="rounded-[22px] border border-violet-100 bg-violet-50/60 p-4">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Finale-Banner</p>
<h3 class="mt-1 text-base font-bold text-slate-900">Live-Button im Landingpage-Content pflegen</h3>
<p class="mt-1 text-sm leading-6 text-slate-500">
Der Link für den Finale-Button wird zentral im Landingpage-Modul gepflegt.
</p>
</div>
<RouterLink
to="/admin/content?editor=streamBanner"
class="inline-flex shrink-0 items-center justify-center gap-2 rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm font-semibold text-violet-700 transition hover:bg-violet-100"
>
Finale-Banner öffnen
<ExternalLink class="h-4 w-4" />
</RouterLink>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<label class="flex cursor-pointer gap-3 rounded-[22px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50 has-disabled:opacity-70">
+17 -91
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { Check, KeyRound, LockKeyhole, Minus, Pencil, RefreshCw, RotateCcw, Save, ShieldCheck, Trash2, UserPlus, Users } from '@lucide/vue'
import { Check, KeyRound, LockKeyhole, 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'
@@ -43,6 +44,7 @@ const {
members,
roles,
permissions,
permissionGroups,
memberForm,
selectedMember,
pendingPasswordChanges,
@@ -72,7 +74,6 @@ const anyTeamModalOpen = computed(() =>
passwordModalOpen.value
|| createMemberModalOpen.value
|| editMemberModalOpen.value
|| rolePermissionsModalOpen.value
|| Boolean(memberPendingDeletion.value),
)
@@ -81,6 +82,7 @@ useBodyScrollLock(() => anyTeamModalOpen.value)
const summaryItems = computed(() => [
{ label: 'Mitglieder', value: members.value.length, note: `${onlineMembers.value} online` },
{ label: 'Rollen', value: roles.value.length, note: 'Owner, Creator und Team' },
{ label: 'Berechtigungen', value: permissions.value.length, note: `${permissionGroups.value.length} Bereiche` },
{ label: 'PW-Wechsel', value: pendingPasswordChanges.value, note: 'temporäre Passwörter offen' },
])
@@ -112,14 +114,6 @@ function memberOnlineStatus(member: AdminTeamMember) {
return member.isOnline ? 'Online' : 'Offline'
}
function rolePermissionLocked(roleKey: string) {
return roleKey === 'owner' || roleKey === 'creator'
}
function rolePermissionCount(roleKey: string) {
return permissions.value.filter((permission) => roleHasPermission(roleKey, permission.key)).length
}
function isOwnTeamAccount(member: AdminTeamMember) {
const teamLogin = authStore.session?.teamLogin?.toLowerCase()
const boundTwitchUserId = authStore.session?.boundTwitchUserId?.toLowerCase()
@@ -219,7 +213,7 @@ async function changeOwnPassword() {
:icon="Users"
/>
<div class="grid gap-3 md:grid-cols-3">
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<Card
v-for="item in summaryItems"
:key="item.label"
@@ -383,86 +377,18 @@ async function changeOwnPassword() {
</div>
</Card>
<Teleport to="body">
<div
v-if="rolePermissionsModalOpen"
class="fixed inset-0 z-[305] flex items-center justify-center bg-slate-950/50 p-3 backdrop-blur-sm md:p-6"
@click.self="rolePermissionsModalOpen = false"
>
<Card class="flex max-h-[88vh] w-full max-w-6xl flex-col overflow-hidden rounded-[30px] bg-white p-0 shadow-[0_34px_100px_rgba(39,28,72,0.28)]">
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-violet-100 bg-white px-5 py-4 md:px-6">
<div class="flex min-w-0 items-center gap-3">
<span class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-violet-50 text-violet-600 ring-1 ring-violet-100">
<ShieldCheck class="h-5 w-5" />
</span>
<div class="min-w-0">
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Rollenrechte</p>
<h2 class="mt-1 text-xl font-bold leading-tight text-slate-950">Berechtigungen einstellen</h2>
</div>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<Button type="button" variant="ghost" @click="rolePermissionsModalOpen = false">Schließen</Button>
<Button type="button" :disabled="saving || !hasRoleChanges" @click="saveRolePermissions">
<Save class="mr-2 h-4 w-4" />
Speichern
</Button>
</div>
</div>
<div class="min-h-0 flex-1 overflow-auto bg-slate-50/70 p-3 md:p-5">
<table class="w-full min-w-[1040px] overflow-hidden rounded-2xl border border-violet-100 bg-white text-sm shadow-sm">
<thead class="sticky top-0 z-20 bg-violet-50 text-left text-[10px] font-bold uppercase tracking-[0.16em] text-violet-500 shadow-[0_1px_0_rgba(221,214,254,0.85)]">
<tr>
<th class="sticky left-0 z-30 w-[340px] bg-violet-50 px-5 py-4">Bereich</th>
<th v-for="role in roles" :key="role.key" class="min-w-[112px] px-3 py-4 text-center">
<span class="block text-[11px] leading-4 text-violet-600">{{ role.label }}</span>
<span class="mt-1 block text-[10px] font-extrabold tracking-normal text-slate-400">
{{ rolePermissionCount(role.key) }}/{{ permissions.length }}
</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-violet-100 bg-white">
<tr v-for="permission in permissions" :key="permission.key" class="transition hover:bg-violet-50/35">
<td class="sticky left-0 z-10 w-[340px] bg-white px-5 py-4 shadow-[1px_0_0_rgba(237,233,254,0.95)]">
<span class="block text-base font-bold leading-5 text-slate-950">{{ permission.label }}</span>
<span class="mt-1 block max-w-[300px] text-xs font-semibold leading-5 text-slate-500">{{ permission.description }}</span>
</td>
<td v-for="role in roles" :key="`${role.key}-${permission.key}`" class="px-3 py-4 text-center">
<button
type="button"
class="relative mx-auto grid h-9 w-9 place-items-center rounded-xl ring-1 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
:class="[
roleHasPermission(role.key, permission.key)
? rolePermissionLocked(role.key)
? 'bg-slate-100 text-slate-400 ring-slate-200'
: 'bg-violet-600 text-white shadow-lg shadow-violet-500/20 ring-violet-500 hover:bg-violet-500'
: rolePermissionLocked(role.key)
? 'bg-slate-50 text-slate-300 ring-slate-200'
: 'bg-white text-slate-300 ring-slate-300 hover:text-violet-500 hover:ring-violet-300',
]"
:disabled="rolePermissionLocked(role.key)"
:aria-label="`${role.label}: ${permission.label}`"
:aria-pressed="roleHasPermission(role.key, permission.key)"
@click="setRolePermission(role.key, permission.key, !roleHasPermission(role.key, permission.key))"
>
<Check v-if="roleHasPermission(role.key, permission.key)" class="h-5 w-5" :stroke-width="3" />
<Minus v-else class="h-4 w-4" :stroke-width="3" />
<span
v-if="rolePermissionLocked(role.key) && roleHasPermission(role.key, permission.key)"
class="absolute -right-1 -top-1 grid h-4 w-4 place-items-center rounded-full bg-white text-slate-400 ring-1 ring-slate-200"
>
<LockKeyhole class="h-2.5 w-2.5" />
</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</Card>
</div>
</Teleport>
<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"
/>
<Teleport to="body">
<div
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Filter, Vote } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import AdminVotingLeaderboardModal from '../../components/admin/AdminVotingLeaderboardModal.vue'
import AdminVotingTreeWorkspace from '../../components/admin/AdminVotingTreeWorkspace.vue'
import { type AdminVotingWorkspaceRow, useAdminVotingManager } from '../../components/admin/useAdminVotingManager'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
const {
problemOnly,
query,
statusFilter,
statusFilters,
summaryCards,
votingGroups,
selectedGroupName,
focusedCategoryId,
clearFilters,
selectGroup,
focusCategory,
} = useAdminVotingManager()
const leaderboardCategory = ref<AdminVotingWorkspaceRow | null>(null)
const compactSummaryCards = computed(() => summaryCards.value.slice(0, 4))
function openLeaderboard(category: AdminVotingWorkspaceRow) {
focusCategory(category.categoryId)
leaderboardCategory.value = category
}
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Voting"
description="Operative Stimmenlage, Unterkategorie-Status und Gewinner-Vorbereitung in einer Arbeitsflaeche."
:icon="Vote"
/>
<AdminSeasonToolbar />
<section class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Card v-for="card in compactSummaryCards" :key="card.label" class="p-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<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>
</div>
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-xl border" :class="card.tone">
<component :is="card.icon" class="h-5 w-5" />
</div>
</div>
</Card>
</section>
<Card class="p-5">
<div class="grid gap-4 xl:grid-cols-[minmax(260px,0.9fr)_minmax(0,1.1fr)_auto] xl:items-center">
<label class="relative block">
<Filter 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"
type="search"
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, Unterkategorie oder Kandidat suchen"
>
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="option in statusFilters"
:key="option.value"
type="button"
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="statusFilter === option.value ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="statusFilter = option.value"
>
{{ option.label }}
<span class="rounded-full bg-white/70 px-2 py-0.5 text-[11px]">{{ option.count }}</span>
</button>
</div>
<div class="flex flex-wrap items-center gap-2 xl:justify-end">
<Button
:variant="problemOnly ? 'default' : 'ghost'"
class="shrink-0"
@click="problemOnly = !problemOnly"
>
{{ problemOnly ? 'Nur Sonderfaelle aktiv' : 'Nur Sonderfaelle' }}
</Button>
<Button
v-if="query || statusFilter !== 'all' || problemOnly"
variant="ghost"
class="shrink-0"
@click="clearFilters"
>
Filter reset
</Button>
</div>
</div>
</Card>
<section>
<AdminVotingTreeWorkspace
:groups="votingGroups"
:selected-group-name="selectedGroupName"
:focused-category-id="focusedCategoryId"
@select-group="selectGroup"
@select-category="focusCategory"
@open-leaderboard="openLeaderboard"
/>
</section>
<AdminVotingLeaderboardModal
:category="leaderboardCategory"
@close="leaderboardCategory = null"
/>
</div>
</template>
+131 -214
View File
@@ -1,31 +1,38 @@
<script setup lang="ts">
import { Award, CheckCircle2, Filter, Trash2, Trophy } from '@lucide/vue'
import { AlertTriangle, Clock3, EyeOff, Filter, Trophy, UploadCloud } from '@lucide/vue'
import AdminWinnersTreeWorkspace from '../../components/admin/AdminWinnersTreeWorkspace.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminWinnersManager } from '../../components/admin/useAdminWinnersManager'
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'
const {
adminError,
adminMessage,
completionPct,
clearFilters,
deletingResultId,
focusedCategoryId,
publicationState,
publishingWinners,
query,
savingResultForCategory,
selectedGroupName,
selectGroup,
focusCategory,
statusFilter,
statusFilters,
summaryCards,
visibleResultRows,
winnerGroups,
winnerSelections,
unpublishingWinners,
clearWinner,
publishWinners,
saveWinner,
workflowRuleSummary,
selectedWinnerIdentitySummary,
selectedWinnerClipPreview,
unpublishWinners,
winnerRuleNoticeFor,
} = useAdminWinnersManager()
@@ -36,12 +43,76 @@ watchAdminToast(adminMessage, adminError)
<div class="space-y-6">
<AdminPageHeader
eyebrow="Gewinner"
description="Finale Gewinner je Kategorie setzen, aktualisieren und fuer Archiv sowie Public-Ansicht freigeben."
description="Finale Gewinner pro Unterkategorie setzen, Guards pruefen und Landingpage-Freigabe vorbereiten."
:icon="Trophy"
/>
<AdminSeasonToolbar />
<Card class="p-5">
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-start">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-bold uppercase tracking-[0.12em]" :class="publicationState.statusTone">
<UploadCloud v-if="!publicationState.isPublished" class="h-4 w-4" />
<Trophy v-else class="h-4 w-4" />
{{ publicationState.statusLabel }}
</span>
<span class="rounded-full border border-violet-100 bg-violet-50 px-3 py-1.5 text-xs font-semibold text-violet-700">
Phase: {{ publicationState.phaseLabel }}
</span>
</div>
<h2 class="mt-3 text-xl font-bold text-slate-900">Landingpage-Freigabe</h2>
<p class="mt-1 max-w-3xl text-sm leading-6 text-slate-500">
Gewinner bleiben intern, bis dieses Award-Jahr veröffentlicht wird. Nach der Freigabe erscheinen sie auf der Landingpage; der bisherige Landingpage-Jahrgang wandert ins Archiv.
</p>
<p class="mt-3 text-sm font-semibold text-slate-600">
{{ publicationState.publishedAtLabel }}
<span v-if="publicationState.isPublished" class="text-slate-400">· {{ publicationState.publishedByLabel }}</span>
</p>
<div v-if="publicationState.issues.length > 0" class="mt-4 grid gap-2 sm:grid-cols-2">
<div
v-for="issue in publicationState.issues"
:key="issue.key"
class="rounded-2xl border px-4 py-3"
:class="issue.isWarning ? 'border-amber-100 bg-amber-50 text-amber-800' : 'border-violet-100 bg-violet-50 text-violet-800'"
>
<div class="flex items-start gap-2">
<AlertTriangle v-if="issue.isWarning" class="mt-0.5 h-4 w-4 shrink-0" />
<Clock3 v-else class="mt-0.5 h-4 w-4 shrink-0" />
<div>
<p class="text-sm font-bold">{{ issue.label }}</p>
<p class="mt-1 text-xs leading-5 opacity-80">{{ issue.note }}</p>
</div>
</div>
</div>
</div>
</div>
<div class="flex flex-wrap gap-2 xl:justify-end">
<Button
:disabled="publicationState.isPublished || !publicationState.canPublish || publishingWinners"
class="gap-2"
@click="publishWinners"
>
<UploadCloud class="h-4 w-4" />
{{ publishingWinners ? 'Veröffentlicht ...' : publicationState.isPublished ? 'Veröffentlicht' : 'Veröffentlichen' }}
</Button>
<Button
v-if="publicationState.isPublished"
variant="secondary"
class="gap-2"
:disabled="unpublishingWinners"
@click="unpublishWinners"
>
<EyeOff class="h-4 w-4" />
{{ unpublishingWinners ? 'Nimmt zurück ...' : 'Zurücknehmen' }}
</Button>
</div>
</div>
</Card>
<section class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Card v-for="card in summaryCards" :key="card.label" class="p-4">
<div class="flex items-start justify-between gap-3">
@@ -54,220 +125,66 @@ watchAdminToast(adminMessage, adminError)
<component :is="card.icon" class="h-5 w-5" />
</div>
</div>
<div v-if="typeof card.progress === 'number'" class="mt-4">
<div class="h-3 overflow-hidden rounded-full bg-violet-100">
<div class="h-full rounded-full bg-[linear-gradient(90deg,#8b5cf6,#22c55e)] transition-all" :style="{ width: `${card.progress}%` }" />
</div>
</div>
</Card>
</section>
<Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5">
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-center">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Winner Lock-In</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Gewinner freigeben</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">
Pro Kategorie kann genau ein Gewinner gesetzt werden. Offene Reviews werden sichtbar markiert, blockieren die technische Freigabe aber nicht.
</p>
</div>
<div>
<div class="flex items-center justify-between text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">
<span>Fortschritt</span>
<span>{{ completionPct }}%</span>
</div>
<div class="mt-2 h-3 overflow-hidden rounded-full bg-violet-100">
<div class="h-full rounded-full bg-[linear-gradient(90deg,#8b5cf6,#22c55e)] transition-all" :style="{ width: `${completionPct}%` }" />
</div>
</div>
</div>
<div class="mt-5 grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<label class="relative block">
<Filter class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input
v-model="query"
type="search"
placeholder="Kategorie, Gewinner oder Kandidat suchen"
class="h-11 w-full rounded-2xl border border-violet-200 bg-white pl-10 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
>
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="option in statusFilters"
:key="option.value"
type="button"
class="inline-flex h-10 items-center gap-2 rounded-xl border px-3 text-sm font-semibold transition"
:class="statusFilter === option.value ? 'border-violet-200 bg-violet-600 text-white shadow-lg shadow-violet-500/20' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="statusFilter = option.value"
>
{{ option.label }}
<span class="rounded-full bg-white/25 px-2 py-0.5 text-xs">{{ option.count }}</span>
</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">
<article
v-for="row in visibleResultRows"
:key="row.category.id"
class="grid gap-4 px-5 py-4 2xl:grid-cols-[minmax(0,1fr)_420px_auto] 2xl:items-center"
<section class="grid gap-4 xl:grid-cols-[minmax(260px,0.9fr)_minmax(0,1.1fr)_auto] xl:items-center">
<label class="relative block">
<Filter 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"
type="search"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white/90 pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Hauptkategorie, Unterkategorie oder Gewinner suchen"
>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<p class="truncate font-semibold text-slate-900">{{ row.category.name }}</p>
<span v-if="row.existing" class="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700">
<CheckCircle2 class="h-3.5 w-3.5" />
gesetzt
</span>
<span v-if="row.hasPendingReviews" class="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700">
{{ row.openReviews }} Reviews offen
</span>
<span v-if="row.isEmpty" class="rounded-full bg-rose-100 px-3 py-1 text-xs font-semibold text-rose-700">
keine Kandidaten
</span>
</div>
<p class="mt-1 text-sm text-slate-500">
{{ row.category.groupName }} · {{ row.candidates.length }} Kandidaten · {{ row.approvedClips }} Clips freigegeben
</p>
<p v-if="row.existing" class="mt-2 inline-flex items-center gap-2 rounded-2xl border border-emerald-100 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-800">
<Award class="h-4 w-4" />
Aktuell: {{ row.existing.candidateDisplayName }} · {{ row.existing.candidatePlatform }}
</p>
</div>
</label>
<div class="min-w-0 space-y-2">
<NativeSelect
v-model="winnerSelections[row.category.id]"
:disabled="row.isEmpty"
:options="[
{ label: 'Bitte Gewinner waehlen', value: '' },
...row.candidates.map((candidate) => ({
label: `${candidate.displayName} · ${candidate.channelSlug} · ${candidate.platform} · ${candidate.clipCompilationUrl ? 'Clip gepflegt' : 'kein Clip'}`,
value: `${candidate.id}`,
})),
]"
/>
<p
v-if="winnerRuleNoticeFor(row.category.id)"
class="rounded-2xl border px-3 py-2 text-sm font-semibold"
:class="winnerRuleNoticeFor(row.category.id)?.mode === 'block'
? 'border-rose-100 bg-rose-50 text-rose-700'
: 'border-amber-100 bg-amber-50 text-amber-700'"
>
{{ winnerRuleNoticeFor(row.category.id)?.mode === 'block' ? 'Blockiert' : 'Warnung' }}:
{{ winnerRuleNoticeFor(row.category.id)?.message }}
</p>
<div
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">
<Button
:disabled="savingResultForCategory === row.category.id || row.isEmpty || !winnerSelections[row.category.id] || winnerRuleNoticeFor(row.category.id)?.mode === 'block'"
@click="saveWinner(row.category.id)"
>
{{ savingResultForCategory === row.category.id ? 'Speichert ...' : row.existing ? 'Aktualisieren' : 'Setzen' }}
</Button>
<Button
v-if="row.existing"
variant="secondary"
class="gap-1.5"
:disabled="deletingResultId === row.existing.id"
@click="clearWinner(row.existing.id)"
>
<Trash2 class="h-4 w-4" />
{{ deletingResultId === row.existing.id ? 'Entfernt ...' : 'Entfernen' }}
</Button>
</div>
</article>
<div class="flex flex-wrap gap-2">
<button
v-for="option in statusFilters"
:key="option.value"
type="button"
class="inline-flex items-center gap-2 rounded-full border bg-white/90 px-3 py-1.5 text-xs font-semibold transition"
:class="statusFilter === option.value ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 text-slate-600 hover:bg-violet-50'"
@click="statusFilter = option.value"
>
{{ option.label }}
<span class="rounded-full bg-white/70 px-2 py-0.5 text-[11px]">{{ option.count }}</span>
</button>
</div>
<div v-else class="p-8 text-center">
<Trophy class="mx-auto h-8 w-8 text-violet-300" />
<p class="mt-3 font-semibold text-slate-900">Keine Kategorien fuer diese Filter.</p>
<p class="mt-1 text-sm text-slate-500">Passe Suche oder Statusfilter an.</p>
<div class="flex items-center justify-end">
<Button
v-if="query || statusFilter !== 'all'"
variant="ghost"
class="shrink-0"
@click="clearFilters"
>
Filter reset
</Button>
</div>
</Card>
</section>
<AdminWinnersTreeWorkspace
:groups="winnerGroups"
:selected-group-name="selectedGroupName"
:focused-category-id="focusedCategoryId"
:winner-selections="winnerSelections"
:saving-result-for-category="savingResultForCategory"
:deleting-result-id="deletingResultId"
:selected-winner-identity-summary="selectedWinnerIdentitySummary"
:winner-rule-notice-for="winnerRuleNoticeFor"
@select-group="selectGroup"
@select-category="focusCategory"
@update:winner-selection="(categoryId: number, value: string) => { winnerSelections[categoryId] = value }"
@save="saveWinner"
@clear="clearWinner"
/>
</div>
</template>