Update release notes and deploy workspace
This commit is contained in:
@@ -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 & 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 Option 2 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;">★ 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 →</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">★ 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 →
|
||||
</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">★</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;
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'|'/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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user