Improve admin candidate modal UX and add clip menu visibility toggle

- Widen AdminCandidateEditorModal to size lg for better readability
- Rename "Clip-Compilation" section to "Clip / Compilation", update copy to reflect single clips too, drop upload hint and Clip-Plattform field, rename label to "Link"
- Fix NativeSelect dropdown clipping inside overflow-y-auto modals by teleporting the menu to body with fixed positioning, flip-up logic, and dynamic maxHeight capped to viewport
- Add ClipAdminMenuVisible setting (backend domain, contracts, endpoint, migration) with matching frontend types, defaults, form wiring, and toggle in the Clip-Workflow modal — hides the Clips nav item from the admin sidebar when disabled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AzuTear
2026-06-27 18:39:35 +02:00
parent 494eba5edd
commit 18b61bed52
119 changed files with 15638 additions and 367 deletions
+3
View File
@@ -3,6 +3,7 @@ import { computed, reactive, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import AppShellAccountModals from './AppShellAccountModals.vue'
import { useBodyScrollLock } from '../composables/useBodyScrollLock'
import { privacyContentToHtml } from '../lib/privacyContent'
import { useAuthStore } from '../stores/auth'
import { useAwardsStore } from '../stores/awards'
@@ -13,6 +14,8 @@ const router = useRouter()
const authStore = useAuthStore()
const awardsStore = useAwardsStore()
useBodyScrollLock(() => awardsStore.loading)
const loginForm = reactive({
twitchUserId: 'jayuhime_viewer',
displayName: 'Jayuhime',
@@ -1,5 +1,5 @@
<template>
<Modal :open="open" :title="title" subtitle="Anzeigename und Handle sind Pflicht." @close="$emit('close')">
<Modal :open="open" size="lg" :title="title" subtitle="Anzeigename und Handle sind Pflicht. Annahme und Clip bereiten das Voting vor." @close="$emit('close')">
<div class="space-y-4">
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kategorie</span>
@@ -30,6 +30,70 @@
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Eigene Plattform</span>
<input :value="form.platform" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z. B. Cake, Booth, neue Plattform" @input="$emit('update:platform', ($event.target as HTMLInputElement).value)" />
</label>
<section class="rounded-2xl border border-violet-100 bg-violet-50/40 p-4">
<div class="mb-3">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Annahmestatus</p>
<p class="mt-1 text-sm text-slate-500">Kontakt und Zusage werden hier gepflegt, nicht im Review-Text versteckt.</p>
</div>
<div class="grid gap-2 sm:grid-cols-4">
<button
v-for="status in acceptanceStatusOptions"
:key="status.value"
type="button"
class="rounded-2xl border px-3 py-2 text-left text-sm transition"
:class="form.acceptanceStatus === status.value ? 'border-violet-400 bg-white text-violet-800 shadow-sm' : 'border-violet-100 bg-white/70 text-slate-600 hover:border-violet-200'"
@click="$emit('update:acceptanceStatus', status.value)"
>
<strong class="block text-sm">{{ status.label }}</strong>
<span class="text-xs">{{ status.description }}</span>
</button>
</div>
<label class="mt-4 block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Teamnotiz</span>
<textarea
:value="form.acceptanceNote"
rows="3"
maxlength="500"
class="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="z. B. Discord kontaktiert, wartet auf Zusage."
@input="$emit('update:acceptanceNote', ($event.target as HTMLTextAreaElement).value)"
/>
</label>
<p v-if="form.acceptanceStatus === 'declined'" class="mt-3 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
Diese Person ist nicht voting-bereit. Bitte Ersatz prüfen oder Kandidat entfernen.
</p>
</section>
<section class="rounded-2xl border border-violet-100 bg-white p-4">
<div class="mb-3 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Clip / Compilation</p>
<p class="mt-1 text-sm text-slate-500">Einzelner Clip oder Compilation YouTube- oder Twitch-Link.</p>
</div>
<a
v-if="form.clipCompilationUrl"
:href="form.clipCompilationUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center justify-center rounded-full border border-violet-200 px-3 py-1.5 text-xs font-semibold text-violet-700 hover:bg-violet-50"
>
Link öffnen
</a>
</div>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Link</span>
<input :value="form.clipCompilationUrl" type="url" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="https://youtu.be/..." @input="$emit('update:clipCompilationUrl', ($event.target as HTMLInputElement).value)" />
</label>
<label class="mt-4 block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Titel im Voting</span>
<input :value="form.clipCompilationTitle" type="text" maxlength="200" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Best-of Clip für das Voting" @input="$emit('update:clipCompilationTitle', ($event.target as HTMLInputElement).value)" />
</label>
<label class="mt-4 block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Embed-Status</span>
<NativeSelect :model-value="form.clipEmbedStatus" :options="clipEmbedStatusOptions" @update:model-value="$emit('update:clipEmbedStatus', String($event))" />
</label>
</section>
</div>
<template #footer>
<Button variant="ghost" @click="$emit('close')">Abbrechen</Button>
@@ -52,9 +116,17 @@ defineProps<{
displayName: string
channelSlug: string
platform: string
acceptanceStatus: string
acceptanceNote: string
clipCompilationUrl: string
clipCompilationTitle: string
clipCompilationPlatform: string
clipEmbedStatus: string
}
categoryOptions: Array<{ label: string; value: number }>
candidatePlatformOptions: SocialIconOption[]
acceptanceStatusOptions: Array<{ label: string; value: string; description: string }>
clipEmbedStatusOptions: Array<{ label: string; value: string }>
selectedPlatformValue: string
canSave: boolean
saving: boolean
@@ -67,6 +139,12 @@ defineEmits<{
'update:displayName': [value: string]
'update:channelSlug': [value: string]
'update:platform': [value: string]
'update:acceptanceStatus': [value: string]
'update:acceptanceNote': [value: string]
'update:clipCompilationUrl': [value: string]
'update:clipCompilationTitle': [value: string]
'update:clipCompilationPlatform': [value: string]
'update:clipEmbedStatus': [value: string]
'platform-selection': [value: string]
}>()
</script>
@@ -15,7 +15,12 @@
:options="categoryFilterOptions"
@update:model-value="$emit('update:categoryFilter', $event === null ? null : Number($event))"
/>
<Button v-if="search || categoryFilter" variant="ghost" class="gap-1" @click="$emit('clear-filters')">
<NativeSelect
:model-value="readinessFilter"
:options="readinessFilterOptions"
@update:model-value="$emit('update:readinessFilter', String($event))"
/>
<Button v-if="search || categoryFilter || readinessFilter !== 'all'" variant="ghost" class="gap-1" @click="$emit('clear-filters')">
<X class="h-4 w-4" /> Filter
</Button>
<Button class="gap-2" @click="$emit('open-create')">
@@ -33,12 +38,15 @@ import NativeSelect from '../ui/NativeSelect.vue'
defineProps<{
search: string
categoryFilter: number | null
readinessFilter: string
categoryFilterOptions: Array<{ label: string; value: number | null }>
readinessFilterOptions: Array<{ label: string; value: string }>
}>()
defineEmits<{
'update:search': [value: string]
'update:categoryFilter': [value: number | null]
'update:readinessFilter': [value: string]
'clear-filters': []
'open-create': []
}>()
@@ -1,8 +1,10 @@
<template>
<div>
<div class="hidden grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)_120px_96px] gap-4 border-b border-violet-100 bg-violet-50/40 px-6 py-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-violet-500 lg:grid">
<div class="hidden grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)_130px_minmax(0,1.2fr)_110px_96px] gap-4 border-b border-violet-100 bg-violet-50/40 px-6 py-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-violet-500 xl:grid">
<span>Kandidat</span>
<span>Kategorie</span>
<span>Annahme</span>
<span>Clip-Compilation</span>
<span>Plattform</span>
<span class="text-right">Aktionen</span>
</div>
@@ -11,7 +13,7 @@
<div
v-for="candidate in pagedCandidates"
:key="candidate.id"
class="grid grid-cols-1 gap-3 px-6 py-4 transition hover:bg-violet-50/40 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)_120px_96px] lg:items-center lg:gap-4"
class="grid grid-cols-1 gap-3 px-6 py-4 transition hover:bg-violet-50/40 xl:grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)_130px_minmax(0,1.2fr)_110px_96px] xl:items-center xl:gap-4"
>
<div class="flex min-w-0 items-center gap-3">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[linear-gradient(135deg,#c4b5fd,#f5a9d6)] text-sm font-semibold text-white">
@@ -23,6 +25,14 @@
<p v-if="isDuplicate(candidate)" class="mt-1 text-xs font-semibold text-amber-700">
Mögliches Duplikat in dieser Kategorie
</p>
<p
v-for="notice in candidateWorkflowNotices[candidate.id] ?? []"
:key="notice.message"
class="mt-1 text-xs font-semibold"
:class="notice.mode === 'block' ? 'text-rose-700' : 'text-amber-700'"
>
{{ notice.mode === 'block' ? 'Regel blockiert' : 'Regelhinweis' }}: {{ notice.message }}
</p>
</div>
</div>
<div class="min-w-0">
@@ -30,6 +40,31 @@
{{ categoryLabelMap[candidate.categoryId] || 'Ohne Kategorie' }}
</span>
</div>
<div>
<span class="inline-flex rounded-full border px-3 py-1 text-xs font-semibold" :class="acceptanceClass(candidate.acceptanceStatus)">
{{ acceptanceLabel(candidate.acceptanceStatus) }}
</span>
<p v-if="candidate.acceptanceNote" class="mt-1 line-clamp-2 text-xs text-slate-500">{{ candidate.acceptanceNote }}</p>
</div>
<div class="min-w-0">
<template v-if="candidate.clipCompilationUrl && candidate.clipEmbedStatus !== 'blocked'">
<a
:href="candidate.clipCompilationUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex max-w-full items-center gap-1 truncate text-sm font-semibold text-violet-700 hover:text-violet-900"
>
<ExternalLink class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ candidate.clipCompilationTitle || 'Compilation öffnen' }}</span>
</a>
<p class="mt-1 text-xs text-slate-500">
{{ candidate.clipCompilationPlatform || 'Plattform offen' }} · {{ embedLabel(candidate.clipEmbedStatus) }}
</p>
</template>
<p v-else class="text-xs font-semibold text-amber-700">
{{ candidate.clipEmbedStatus === 'blocked' ? 'Clip nicht nutzbar' : 'Clip fehlt' }}
</p>
</div>
<div>
<span class="rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">{{ candidate.platform }}</span>
</div>
@@ -71,7 +106,7 @@
</template>
<script setup lang="ts">
import { Pencil, Trash2, UserPlus } from '@lucide/vue'
import { ExternalLink, Pencil, Trash2, UserPlus } from '@lucide/vue'
import type { AdminCandidateItem } from '../../types/awards'
import Button from '../ui/Button.vue'
@@ -87,6 +122,8 @@ const props = defineProps<{
rangeEnd: number
categoryLabelMap: Record<number, string>
duplicateCandidateKeys: Map<string, number>
candidateWorkflowNotices: Record<number, Array<{ mode: 'warn' | 'block'; message: string }>>
acceptanceStatusOptions: Array<{ label: string; value: string; description: string }>
}>()
defineEmits<{
@@ -100,4 +137,22 @@ function isDuplicate(candidate: AdminCandidateItem) {
return (props.duplicateCandidateKeys.get(`${candidate.categoryId}:name:${candidate.displayName.trim().toLowerCase()}`) ?? 0) > 1
|| (props.duplicateCandidateKeys.get(`${candidate.categoryId}:slug:${candidate.channelSlug.trim().toLowerCase()}`) ?? 0) > 1
}
function acceptanceLabel(value: string) {
return props.acceptanceStatusOptions.find((option) => option.value === value)?.label ?? 'Offen'
}
function acceptanceClass(value: string) {
if (value === 'accepted') return 'border-emerald-200 bg-emerald-50 text-emerald-700'
if (value === 'contacted') return 'border-sky-200 bg-sky-50 text-sky-700'
if (value === 'declined') return 'border-rose-200 bg-rose-50 text-rose-700'
return 'border-slate-200 bg-slate-50 text-slate-600'
}
function embedLabel(value: string) {
if (value === 'embeddable') return 'Einbettbar'
if (value === 'link_only') return 'Nur Link'
if (value === 'blocked') return 'Nicht nutzbar'
return 'Embed prüfen'
}
</script>
@@ -0,0 +1,393 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Handshake, Mic2, Plus, Save, Settings2, Trash2 } from '@lucide/vue'
import { useAwardsStore } from '../../stores/awards'
import type { AdminSponsorItem, UpsertSponsorPayload } from '../../types/awards'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import AdminSettingsToggle from './AdminSettingsToggle.vue'
const store = useAwardsStore()
const loading = ref(false)
const saving = ref(false)
const statusMessage = ref('')
const statusError = ref('')
const editingSponsorId = ref<number | null>(null)
const showactNotes = reactive<Record<number, string>>({})
const settingsForm = reactive({
showactApplicationsEnabled: false,
showactApplicationDisabledMessage: 'Showact-Bewerbungen sind aktuell geschlossen.',
sponsorsVisible: true,
})
const sponsorForm = reactive<UpsertSponsorPayload>({
name: '',
websiteUrl: '',
logoUrl: '',
description: '',
tier: 'Partner',
sortOrder: 0,
isVisible: true,
})
const seasonId = computed(() => store.adminSelectedSeasonId ?? (store.adminSeasonDetail.id || store.overview.seasonId))
const pendingShowacts = computed(() => store.adminShowactApplications.filter((item) => item.status === 'pending').length)
const visibleSponsors = computed(() => store.adminSponsors.filter((item) => item.isVisible).length)
watch(
() => store.adminOptionalFeatureSettings,
(settings) => {
settingsForm.showactApplicationsEnabled = settings.showactApplicationsEnabled
settingsForm.showactApplicationDisabledMessage = settings.showactApplicationDisabledMessage || 'Showact-Bewerbungen sind aktuell geschlossen.'
settingsForm.sponsorsVisible = settings.sponsorsVisible
},
{ immediate: true, deep: true },
)
function resetSponsorForm() {
editingSponsorId.value = null
sponsorForm.name = ''
sponsorForm.websiteUrl = ''
sponsorForm.logoUrl = ''
sponsorForm.description = ''
sponsorForm.tier = 'Partner'
sponsorForm.sortOrder = store.adminSponsors.length + 1
sponsorForm.isVisible = true
}
function editSponsor(sponsor: AdminSponsorItem) {
editingSponsorId.value = sponsor.id
sponsorForm.name = sponsor.name
sponsorForm.websiteUrl = sponsor.websiteUrl
sponsorForm.logoUrl = sponsor.logoUrl
sponsorForm.description = sponsor.description
sponsorForm.tier = sponsor.tier
sponsorForm.sortOrder = sponsor.sortOrder
sponsorForm.isVisible = sponsor.isVisible
}
async function ensureSeasonId() {
if (seasonId.value) return seasonId.value
await store.loadHomeData()
return store.overview.seasonId
}
async function loadLandingExtras() {
loading.value = true
statusError.value = ''
try {
const resolvedSeasonId = await ensureSeasonId()
await Promise.all([
store.loadAdminOptionalFeatureSettings(),
resolvedSeasonId ? store.loadAdminExtras(resolvedSeasonId) : Promise.resolve(),
])
for (const application of store.adminShowactApplications) {
showactNotes[application.id] = application.reviewNote ?? ''
}
resetSponsorForm()
} catch (error) {
statusError.value = error instanceof Error ? error.message : 'Landingpage-Extras konnten nicht geladen werden.'
} finally {
loading.value = false
}
}
async function saveLandingExtrasSettings() {
saving.value = true
statusMessage.value = ''
statusError.value = ''
try {
await store.updateAdminOptionalFeatureSettings({
clipSubmissionsEnabled: store.adminOptionalFeatureSettings.clipSubmissionsEnabled,
clipReviewEnabled: store.adminOptionalFeatureSettings.clipReviewEnabled,
clipSubmissionDisabledMessage: store.adminOptionalFeatureSettings.clipSubmissionDisabledMessage,
showactApplicationsEnabled: settingsForm.showactApplicationsEnabled,
showactApplicationDisabledMessage: settingsForm.showactApplicationDisabledMessage,
sponsorsVisible: settingsForm.sponsorsVisible,
})
statusMessage.value = 'Landingpage-Extras wurden gespeichert.'
} catch (error) {
statusError.value = error instanceof Error ? error.message : 'Landingpage-Extras konnten nicht gespeichert werden.'
} finally {
saving.value = false
}
}
async function saveSponsor() {
const resolvedSeasonId = await ensureSeasonId()
if (!resolvedSeasonId) return
saving.value = true
statusMessage.value = ''
statusError.value = ''
try {
if (editingSponsorId.value) {
await store.updateAdminSponsor(editingSponsorId.value, resolvedSeasonId, sponsorForm)
statusMessage.value = 'Sponsor wurde aktualisiert.'
} else {
await store.createAdminSponsor(resolvedSeasonId, sponsorForm)
statusMessage.value = 'Sponsor wurde angelegt.'
}
resetSponsorForm()
} catch (error) {
statusError.value = error instanceof Error ? error.message : 'Sponsor konnte nicht gespeichert werden.'
} finally {
saving.value = false
}
}
async function deleteSponsor(sponsor: AdminSponsorItem) {
const resolvedSeasonId = await ensureSeasonId()
if (!resolvedSeasonId || !window.confirm(`Sponsor "${sponsor.name}" loeschen?`)) return
saving.value = true
statusMessage.value = ''
statusError.value = ''
try {
await store.deleteAdminSponsor(sponsor.id, resolvedSeasonId)
if (editingSponsorId.value === sponsor.id) resetSponsorForm()
statusMessage.value = 'Sponsor wurde geloescht.'
} catch (error) {
statusError.value = error instanceof Error ? error.message : 'Sponsor konnte nicht geloescht werden.'
} finally {
saving.value = false
}
}
async function updateShowactStatus(applicationId: number, status: string) {
const resolvedSeasonId = await ensureSeasonId()
if (!resolvedSeasonId) return
saving.value = true
statusMessage.value = ''
statusError.value = ''
try {
await store.updateAdminShowactStatus(applicationId, resolvedSeasonId, {
status,
reviewNote: showactNotes[applicationId] ?? '',
})
statusMessage.value = 'Showact-Status wurde gespeichert.'
} catch (error) {
statusError.value = error instanceof Error ? error.message : 'Showact-Status konnte nicht gespeichert werden.'
} finally {
saving.value = false
}
}
async function deleteShowact(applicationId: number, artistName: string) {
const resolvedSeasonId = await ensureSeasonId()
if (!resolvedSeasonId || !window.confirm(`Showact-Bewerbung von "${artistName}" loeschen?`)) return
saving.value = true
statusMessage.value = ''
statusError.value = ''
try {
await store.deleteAdminShowactApplication(applicationId, resolvedSeasonId)
delete showactNotes[applicationId]
statusMessage.value = 'Showact-Bewerbung wurde geloescht.'
} catch (error) {
statusError.value = error instanceof Error ? error.message : 'Showact-Bewerbung konnte nicht geloescht werden.'
} finally {
saving.value = false
}
}
function statusLabel(status: string) {
return {
pending: 'Offen',
shortlisted: 'Shortlist',
accepted: 'Angenommen',
rejected: 'Abgelehnt',
}[status] ?? status
}
onMounted(loadLandingExtras)
</script>
<template>
<Card id="content-landing-extras" class="p-6">
<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">Landingpage Extras</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Showacts und Sponsoren</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">
Module, Bewerbungen und Sponsoren direkt dort steuern, wo sie public erscheinen.
</p>
</div>
<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" :disabled="loading" @click="loadLandingExtras">
<Settings2 class="h-4 w-4" />
{{ loading ? 'Laedt ...' : 'Neu laden' }}
</Button>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-3">
<div class="rounded-2xl border border-fuchsia-100 bg-fuchsia-50/55 p-4">
<p class="text-xs font-bold uppercase tracking-[0.18em] text-fuchsia-600">Showacts</p>
<p class="mt-2 text-2xl font-black text-slate-950">{{ store.adminShowactApplications.length }}</p>
<p class="text-sm text-slate-500">{{ pendingShowacts }} offen</p>
</div>
<div class="rounded-2xl border border-sky-100 bg-sky-50/55 p-4">
<p class="text-xs font-bold uppercase tracking-[0.18em] text-sky-600">Sponsoren</p>
<p class="mt-2 text-2xl font-black text-slate-950">{{ store.adminSponsors.length }}</p>
<p class="text-sm text-slate-500">{{ visibleSponsors }} sichtbar</p>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/55 p-4">
<p class="text-xs font-bold uppercase tracking-[0.18em] text-violet-600">Saison</p>
<p class="mt-2 text-2xl font-black text-slate-950">{{ store.overview.year || store.adminSeasonDetail.year || '' }}</p>
<p class="text-sm text-slate-500">Landingpage-Jahr</p>
</div>
</div>
<section class="mt-6 grid gap-3 lg:grid-cols-2">
<div class="rounded-2xl border border-violet-100 bg-white p-4">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-base font-bold text-slate-900">Showact-Bewerbung</h3>
<p class="mt-1 text-sm leading-6 text-slate-500">Public-Formular auf der Landingpage anzeigen.</p>
</div>
<AdminSettingsToggle
:model-value="settingsForm.showactApplicationsEnabled"
label="Showact-Bewerbungen aktivieren"
active-label="Offen"
inactive-label="Zu"
@update:model-value="settingsForm.showactApplicationsEnabled = $event"
/>
</div>
</div>
<div class="rounded-2xl border border-violet-100 bg-white p-4">
<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>
</div>
<AdminSettingsToggle
:model-value="settingsForm.sponsorsVisible"
label="Sponsoren anzeigen"
active-label="Sichtbar"
inactive-label="Aus"
@update:model-value="settingsForm.sponsorsVisible = $event"
/>
</div>
</div>
</section>
<label class="mt-4 block space-y-2">
<span class="text-sm font-bold text-slate-900">Hinweis bei geschlossenen Showact-Bewerbungen</span>
<textarea
v-model="settingsForm.showactApplicationDisabledMessage"
rows="3"
maxlength="240"
class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm leading-6 text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
/>
</label>
<div class="mt-4 flex flex-wrap items-center gap-3">
<Button class="gap-2" :disabled="saving" @click="saveLandingExtrasSettings">
<Save class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Landingpage Extras speichern' }}
</Button>
<p v-if="statusMessage" class="text-sm font-semibold text-emerald-700">{{ statusMessage }}</p>
<p v-if="statusError" class="text-sm font-semibold text-rose-700">{{ statusError }}</p>
</div>
<section class="mt-7 grid gap-5 xl:grid-cols-[minmax(0,1.1fr)_minmax(340px,0.9fr)]">
<div class="overflow-hidden rounded-2xl border border-violet-100 bg-white">
<div class="border-b border-violet-100 p-4">
<div class="flex items-center gap-2">
<Mic2 class="h-5 w-5 text-fuchsia-700" />
<h3 class="text-base font-bold text-slate-900">Showact-Bewerbungen</h3>
</div>
</div>
<div v-if="store.adminShowactApplications.length === 0" class="p-4 text-sm text-slate-500">
Noch keine Bewerbungen fuer dieses Jahr.
</div>
<div v-else class="divide-y divide-violet-100">
<article v-for="application in store.adminShowactApplications" :key="application.id" class="space-y-3 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="flex flex-wrap items-center gap-2">
<h4 class="font-bold text-slate-950">{{ application.artistName }}</h4>
<span class="rounded-full bg-fuchsia-100 px-3 py-1 text-xs font-bold text-fuchsia-700">{{ statusLabel(application.status) }}</span>
</div>
<p class="mt-1 text-sm text-slate-500">{{ application.performanceType }}</p>
</div>
<div class="flex flex-wrap gap-2">
<Button size="sm" variant="ghost" :disabled="saving" @click="updateShowactStatus(application.id, 'shortlisted')">Shortlist</Button>
<Button size="sm" :disabled="saving" @click="updateShowactStatus(application.id, 'accepted')">Annehmen</Button>
<Button size="sm" variant="secondary" :disabled="saving" @click="updateShowactStatus(application.id, 'rejected')">Ablehnen</Button>
<Button size="sm" variant="ghost" class="text-rose-700" :disabled="saving" @click="deleteShowact(application.id, application.artistName)">
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
<p class="text-sm leading-6 text-slate-600">{{ application.description }}</p>
<div class="grid gap-2 text-sm text-slate-500 md:grid-cols-2">
<a v-if="application.platformUrl" class="font-semibold text-violet-700 hover:text-violet-900" :href="application.platformUrl" target="_blank" rel="noreferrer">Kanal/Profil</a>
<a v-if="application.referenceUrl" class="font-semibold text-violet-700 hover:text-violet-900" :href="application.referenceUrl" target="_blank" rel="noreferrer">Referenz</a>
<span v-if="application.contactEmail">Mail: {{ application.contactEmail }}</span>
<span v-if="application.contactDiscord">Discord: {{ application.contactDiscord }}</span>
</div>
<textarea
v-model="showactNotes[application.id]"
rows="2"
maxlength="500"
placeholder="Interne Review-Notiz"
class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
/>
</article>
</div>
</div>
<div class="overflow-hidden rounded-2xl border border-violet-100 bg-white">
<div class="border-b border-violet-100 p-4">
<div class="flex items-center gap-2">
<Handshake class="h-5 w-5 text-sky-700" />
<h3 class="text-base font-bold text-slate-900">Sponsoren</h3>
</div>
</div>
<form class="space-y-3 p-4" @submit.prevent="saveSponsor">
<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" />
<input v-model="sponsorForm.logoUrl" maxlength="500" placeholder="Logo 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" />
</div>
<div class="grid gap-3 sm:grid-cols-[1fr_120px]">
<input v-model="sponsorForm.tier" maxlength="80" placeholder="Tier" 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" />
<input v-model.number="sponsorForm.sortOrder" type="number" min="0" max="9999" 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" />
</div>
<textarea v-model="sponsorForm.description" rows="3" maxlength="500" placeholder="Beschreibung" 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" />
<label class="flex items-center gap-3 rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3 text-sm font-semibold text-slate-700">
<input v-model="sponsorForm.isVisible" type="checkbox" class="h-4 w-4 rounded border-violet-200 text-violet-600" />
Public anzeigen
</label>
<div class="flex flex-wrap gap-2">
<Button class="gap-2" :disabled="saving">
<Save class="h-4 w-4" />
{{ editingSponsorId ? 'Sponsor speichern' : 'Sponsor anlegen' }}
</Button>
<Button type="button" variant="ghost" :disabled="saving" @click="resetSponsorForm">
<Plus class="h-4 w-4" />
Neu
</Button>
</div>
</form>
<div class="divide-y divide-violet-100 border-t border-violet-100">
<article v-for="sponsor in store.adminSponsors" :key="sponsor.id" class="flex items-start justify-between gap-3 p-4">
<div class="min-w-0">
<p class="truncate text-sm font-bold text-slate-900">{{ sponsor.name }}</p>
<p class="mt-1 text-xs font-semibold text-slate-500">{{ sponsor.tier }} · Reihenfolge {{ sponsor.sortOrder }}</p>
<p class="mt-1 text-xs" :class="sponsor.isVisible ? 'text-emerald-700' : 'text-slate-500'">
{{ sponsor.isVisible ? 'Public sichtbar' : 'Verborgen' }}
</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)">
<Trash2 class="h-4 w-4" />
</Button>
</div>
</article>
</div>
</div>
</section>
</Card>
</template>
@@ -31,6 +31,10 @@
<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">
@@ -75,6 +79,20 @@
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>
@@ -87,7 +105,7 @@ import Card from '../ui/Card.vue'
import AdminRichTextEditor from './AdminRichTextEditor.vue'
import type { AdminContentForm } from './adminContentTypes'
export type FooterPreviewKey = 'imprint' | 'contact' | 'sponsors'
export type FooterPreviewKey = 'imprint' | 'contact' | 'sponsors' | 'showacts'
const props = defineProps<{
form: AdminContentForm
@@ -0,0 +1,160 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Loader2, Plus, Save, Trash2, X } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Modal from '../ui/Modal.vue'
import { watchAdminToast } from '../../composables/useAdminToast'
import { useAwardsStore } from '../../stores/awards'
const props = defineProps<{
open: boolean
}>()
const emit = defineEmits<{
close: []
}>()
const store = useAwardsStore()
const loading = ref(false)
const saving = ref(false)
const entries = ref<string[]>([])
const newUrl = ref('')
const success = ref('')
const error = ref('')
const normalizedEntries = computed(() =>
entries.value
.map((entry) => entry.trim())
.filter(Boolean),
)
const canSave = computed(() => !loading.value && !saving.value && normalizedEntries.value.length > 0)
watchAdminToast(success, error)
watch(
() => props.open,
(open) => {
if (open) {
void loadBlacklist()
}
},
{ immediate: true },
)
async function loadBlacklist() {
loading.value = true
error.value = ''
try {
const response = await store.loadAdminNominationLinkBlacklist()
entries.value = response.entries.map((entry) => entry.url)
} catch (loadError) {
error.value = loadError instanceof Error ? loadError.message : 'Blacklist konnte nicht geladen werden.'
} finally {
loading.value = false
}
}
function addEntry() {
const url = newUrl.value.trim()
if (!url) return
entries.value = [...entries.value, url]
newUrl.value = ''
}
function removeEntry(index: number) {
entries.value = entries.value.filter((_, entryIndex) => entryIndex !== index)
}
async function saveBlacklist() {
if (!canSave.value) return
saving.value = true
success.value = ''
error.value = ''
try {
const response = await store.updateAdminNominationLinkBlacklist({ urls: normalizedEntries.value })
entries.value = response.entries.map((entry) => entry.url)
success.value = 'Link-Blacklist wurde gespeichert.'
} catch (saveError) {
error.value = saveError instanceof Error ? saveError.message : 'Blacklist konnte nicht gespeichert werden.'
} finally {
saving.value = false
}
}
</script>
<template>
<Modal
:open="open"
title="Link-Blacklist"
subtitle="Blockiert generische oder unerwuenschte Stream-Links direkt bei der oeffentlichen Nominierung."
size="lg"
@close="emit('close')"
>
<div class="space-y-5">
<div class="rounded-2xl border border-amber-100 bg-amber-50/75 px-4 py-3 text-sm leading-6 text-amber-900">
Die Standardseiten von Twitch, Kick und YouTube sind bereits hinterlegt. User sollen direkte Kanal- oder Profil-Links einreichen, nicht die Startseite einer Plattform.
</div>
<div v-if="loading" class="flex items-center gap-3 rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-5 text-sm font-semibold text-violet-700">
<Loader2 class="h-4 w-4 animate-spin" />
Lade Blacklist ...
</div>
<div v-else class="space-y-3">
<div
v-for="(entry, index) in entries"
:key="`${entry}-${index}`"
class="grid gap-3 rounded-2xl border border-violet-100 bg-white px-4 py-3 md:grid-cols-[minmax(0,1fr)_auto]"
>
<label class="block min-w-0">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Blockierter Link</span>
<input
v-model="entries[index]"
type="url"
class="mt-2 h-11 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="https://example.com/"
/>
</label>
<Button type="button" variant="ghost" size="sm" class="self-end gap-2 rounded-2xl text-rose-700 hover:text-rose-800" @click="removeEntry(index)">
<Trash2 class="h-4 w-4" />
Entfernen
</Button>
</div>
<div class="grid gap-3 rounded-2xl border border-dashed border-violet-200 bg-violet-50/45 p-4 md:grid-cols-[minmax(0,1fr)_auto]">
<label class="block min-w-0">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-violet-600">Neuer Link</span>
<input
v-model="newUrl"
type="url"
class="mt-2 h-11 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="https://www.twitch.tv/"
@keydown.enter.prevent="addEntry"
/>
</label>
<Button type="button" variant="secondary" class="self-end gap-2 rounded-2xl" :disabled="!newUrl.trim()" @click="addEntry">
<Plus class="h-4 w-4" />
Hinzufügen
</Button>
</div>
</div>
<p v-if="entries.length === 0 && !loading" class="rounded-2xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
Die Blacklist darf nicht leer sein, damit die generischen Plattform-Links weiter geblockt bleiben.
</p>
</div>
<template #footer>
<Button type="button" variant="ghost" class="gap-2" :disabled="saving" @click="emit('close')">
<X class="h-4 w-4" />
Schließen
</Button>
<Button type="button" class="gap-2" :disabled="!canSave" @click="saveBlacklist">
<Loader2 v-if="saving" class="h-4 w-4 animate-spin" />
<Save v-else class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Blacklist speichern' }}
</Button>
</template>
</Modal>
</template>
@@ -20,6 +20,7 @@ const emit = defineEmits<{
const {
reviewSaving,
blacklistSaving,
adminMessage,
adminError,
reviewForms,
@@ -39,6 +40,7 @@ const {
canApproveSelected,
approveNomination,
rejectNomination,
addStreamUrlToBlacklist,
selectedPlatformValue,
handlePlatformSelection,
extractNominationStreamUrl,
@@ -153,10 +155,12 @@ onBeforeUnmount(() => {
:signal-summary="selectedNominationSignalSummary"
:stream-url="selectedNomination ? extractNominationStreamUrl(selectedNomination) : ''"
:can-approve-selected="canApproveSelected"
:blacklist-saving="blacklistSaving"
:selected-platform-value="selectedPlatformValue"
@platform-change="handlePlatformSelection"
@approve="approveNomination"
@reject="rejectNomination"
@blacklist-link="addStreamUrlToBlacklist"
/>
</div>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { Film, Settings2 } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
defineProps<{
loading: boolean
saving: boolean
canManage: boolean
summary: {
clipSubmissionsEnabled: boolean
clipReviewEnabled: boolean
pendingClipCount: number
totalClipCount: number
}
disabledMessage: string
}>()
const emit = defineEmits<{
configure: []
}>()
</script>
<template>
<Card class="overflow-hidden">
<section class="p-5">
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-start">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Optionale Workflows</p>
<div class="mt-2 flex flex-wrap items-center gap-2">
<Film class="h-5 w-5 text-violet-700" />
<h2 class="text-lg font-bold text-slate-900">Clip-Einreichungen steuern</h2>
</div>
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
Clip-Einreichung und Clip-Review sind optionale Betriebsfunktionen. Wenn deaktiviert, bleibt der Award-Workflow ohne Clip-Pflicht nutzbar.
</p>
</div>
<Button class="gap-2" :disabled="loading || saving || !canManage" @click="emit('configure')">
<Settings2 class="h-4 w-4" />
Clip-Workflow konfigurieren
</Button>
</div>
<div class="mt-5 flex flex-wrap gap-2">
<span
class="rounded-full px-3 py-1.5 text-xs font-semibold"
:class="summary.clipSubmissionsEnabled ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'"
>
{{ summary.clipSubmissionsEnabled ? 'Clips aktiv' : 'Clips deaktiviert' }}
</span>
<span
class="rounded-full px-3 py-1.5 text-xs font-semibold"
:class="summary.clipSubmissionsEnabled ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-600'"
>
{{ summary.clipSubmissionsEnabled ? 'Public sichtbar' : 'Public ausgeblendet' }}
</span>
<span
class="rounded-full px-3 py-1.5 text-xs font-semibold"
:class="summary.clipReviewEnabled ? 'bg-violet-100 text-violet-700' : 'bg-slate-100 text-slate-600'"
>
{{ summary.clipReviewEnabled ? 'Admin-Review sichtbar' : 'Admin-Review aus' }}
</span>
<span class="rounded-full bg-amber-100 px-3 py-1.5 text-xs font-semibold text-amber-700">
Review offen: {{ summary.pendingClipCount }}
</span>
<span class="rounded-full bg-sky-100 px-3 py-1.5 text-xs font-semibold text-sky-700">
Bestand: {{ summary.totalClipCount }}
</span>
</div>
<p v-if="!summary.clipSubmissionsEnabled" class="mt-4 rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm leading-6 text-slate-600">
{{ disabledMessage || 'Neue Clip-Einreichungen sind geschlossen. Bestehende Clips und Review-Daten bleiben erhalten.' }}
</p>
</section>
</Card>
</template>
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { Save } from '@lucide/vue'
import AdminSettingsToggle from './AdminSettingsToggle.vue'
import Button from '../ui/Button.vue'
import Modal from '../ui/Modal.vue'
defineProps<{
open: boolean
form: {
clipSubmissionsEnabled: boolean
clipReviewEnabled: boolean
clipAdminMenuVisible: boolean
clipSubmissionDisabledMessage: string
}
saving: boolean
dirty: boolean
canManage: boolean
}>()
const emit = defineEmits<{
close: []
save: []
'update:clipSubmissionsEnabled': [value: boolean]
'update:clipReviewEnabled': [value: boolean]
'update:clipAdminMenuVisible': [value: boolean]
'update:clipSubmissionDisabledMessage': [value: string]
}>()
</script>
<template>
<Modal
:open="open"
title="Clip-Workflow"
subtitle="Steuert, ob Besucher Clips einreichen können und ob der Clip-Bereich im Admin als aktiver Workflow erscheint."
size="lg"
@close="emit('close')"
>
<div class="space-y-5">
<section v-if="!canManage" class="rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-800">
Du kannst den Clip-Workflow ansehen, aber nicht bearbeiten.
</section>
<section class="grid gap-3 md:grid-cols-2">
<div class="rounded-2xl border border-violet-100 bg-violet-50/45 p-4">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="text-base font-bold text-slate-900">Public-Einreichung</h4>
<p class="mt-1 text-sm leading-6 text-slate-500">
Besucher können während der passenden Phase Clips einreichen.
</p>
</div>
<AdminSettingsToggle
:model-value="form.clipSubmissionsEnabled"
label="Clip-Einreichung aktivieren"
:disabled="!canManage"
active-label="Aktiv"
inactive-label="Inaktiv"
@update:model-value="emit('update:clipSubmissionsEnabled', $event)"
/>
</div>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/45 p-4">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="text-base font-bold text-slate-900">Admin-Review</h4>
<p class="mt-1 text-sm leading-6 text-slate-500">
Clip-Bestand und Alt-Einreichungen bleiben als optionale Inbox sichtbar.
</p>
</div>
<AdminSettingsToggle
:model-value="form.clipReviewEnabled"
label="Admin-Review anzeigen"
:disabled="!canManage"
active-label="Sichtbar"
inactive-label="Aus"
@update:model-value="emit('update:clipReviewEnabled', $event)"
/>
</div>
</div>
</section>
<div class="rounded-2xl border border-violet-100 bg-violet-50/45 p-4">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="text-base font-bold text-slate-900">Menüpunkt Clips" im Admin</h4>
<p class="mt-1 text-sm leading-6 text-slate-500">
Blendet den Clips-Menüpunkt im Admin-Panel ein oder aus unabhängig von Einreichung und Review.
</p>
</div>
<AdminSettingsToggle
:model-value="form.clipAdminMenuVisible"
label="Clips-Menüpunkt anzeigen"
:disabled="!canManage"
active-label="Sichtbar"
inactive-label="Aus"
@update:model-value="emit('update:clipAdminMenuVisible', $event)"
/>
</div>
</div>
<label class="block space-y-2">
<span class="text-sm font-bold text-slate-900">Hinweis bei deaktivierter Einreichung</span>
<textarea
:value="form.clipSubmissionDisabledMessage"
rows="3"
maxlength="240"
:disabled="!canManage"
class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm leading-6 text-slate-700 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('update:clipSubmissionDisabledMessage', ($event.target as HTMLTextAreaElement).value)"
/>
<span class="text-xs font-semibold text-slate-500">
{{ form.clipSubmissionDisabledMessage.length }}/240 Zeichen
</span>
</label>
<section class="rounded-2xl border border-sky-100 bg-sky-50 px-4 py-3 text-sm leading-6 text-sky-800">
Nominierung, Voting und Gewinnerpflege laufen unabhängig davon weiter. Bestehende Clips werden nicht gelöscht.
</section>
</div>
<template #footer>
<Button variant="ghost" :disabled="saving" @click="emit('close')">Schließen</Button>
<Button class="gap-2" :disabled="saving || !dirty || !canManage" @click="emit('save')">
<Save class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Clip-Workflow speichern' }}
</Button>
</template>
</Modal>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { CheckCircle2, Trash2 } from '@lucide/vue'
import { Ban, CheckCircle2, Loader2, Trash2 } from '@lucide/vue'
import Button from '../ui/Button.vue'
import NativeSelect from '../ui/NativeSelect.vue'
@@ -24,6 +24,7 @@ defineProps<{
} | null
streamUrl?: string
canApproveSelected: boolean
blacklistSaving: boolean
selectedPlatformValue: (platform: string) => string
}>()
@@ -31,6 +32,7 @@ const emit = defineEmits<{
'platform-change': [value: string]
approve: [nominationId: number]
reject: [nominationId: number]
'blacklist-link': [streamUrl: string]
}>()
</script>
@@ -51,14 +53,28 @@ const emit = defineEmits<{
<div v-if="streamUrl" class="mt-5 rounded-2xl border border-sky-100 bg-sky-50/70 p-4">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700">Eingereichter Stream-Link</p>
<a
:href="streamUrl"
target="_blank"
rel="noopener"
class="mt-2 inline-flex max-w-full items-center rounded-full border border-sky-200 bg-white px-3 py-1.5 text-sm font-semibold text-sky-800 underline-offset-4 hover:underline"
>
<span class="truncate">{{ streamUrl }}</span>
</a>
<div class="mt-2 flex flex-wrap items-center gap-2">
<a
:href="streamUrl"
target="_blank"
rel="noopener"
class="inline-flex max-w-full items-center rounded-full border border-sky-200 bg-white px-3 py-1.5 text-sm font-semibold text-sky-800 underline-offset-4 hover:underline"
>
<span class="truncate">{{ streamUrl }}</span>
</a>
<Button
type="button"
variant="ghost"
size="sm"
class="gap-2 rounded-full border-rose-200 bg-white px-3 text-rose-700 hover:bg-rose-50 hover:text-rose-800"
:disabled="blacklistSaving"
@click="emit('blacklist-link', streamUrl)"
>
<Loader2 v-if="blacklistSaving" class="h-3.5 w-3.5 animate-spin" />
<Ban v-else class="h-3.5 w-3.5" />
{{ blacklistSaving ? 'Blockiert ...' : 'Zur Blacklist' }}
</Button>
</div>
</div>
<div v-if="signalSummary" class="mt-4 grid gap-3 sm:grid-cols-3">
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { Save, ShieldCheck } from '@lucide/vue'
import type { AdminWorkflowRule } from '../../types/awards'
import AdminSettingsToggle from './AdminSettingsToggle.vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import NativeSelect from '../ui/NativeSelect.vue'
defineProps<{
rules: AdminWorkflowRule[]
loading: boolean
saving: boolean
dirty: boolean
canManage: boolean
summary: {
active: number
blocking: number
total: number
}
}>()
const emit = defineEmits<{
updateRule: [ruleKey: string, patch: Partial<AdminWorkflowRule>]
save: []
}>()
</script>
<template>
<Card class="overflow-visible">
<section class="border-b border-violet-100 p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0">
<div class="flex items-center gap-2">
<ShieldCheck class="h-5 w-5 text-violet-700" />
<h2 class="text-lg font-bold text-slate-900">Award-Workflow-Regeln</h2>
</div>
<p class="mt-1 max-w-3xl text-sm leading-6 text-slate-500">
Diese Regeln steuern Vorbereitung, finale Kandidat:innen und Gewinnervergabe der ausgewählten Saison.
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full bg-violet-50 px-3 py-1.5 text-xs font-semibold text-violet-700">
{{ summary.active }}/{{ summary.total }} aktiv
</span>
<span class="rounded-full bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700">
{{ summary.blocking }} blockierend
</span>
<Button class="gap-2" :disabled="loading || saving || !dirty || !canManage" @click="emit('save')">
<Save class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Speichern' }}
</Button>
</div>
</div>
</section>
<section v-if="!canManage" class="border-b border-amber-100 bg-amber-50 px-5 py-3 text-sm font-semibold text-amber-800">
Du kannst die Workflow-Regeln ansehen, aber nicht bearbeiten.
</section>
<section v-if="loading" class="p-5">
<p class="rounded-2xl border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Workflow-Regeln werden geladen.
</p>
</section>
<section v-else class="grid gap-3 p-5">
<article
v-for="rule in rules"
:key="rule.key"
class="rounded-2xl border border-violet-100 bg-violet-50/35 p-4"
>
<div class="grid gap-4 xl:grid-cols-[minmax(220px,1fr)_120px_170px_170px] xl:items-end">
<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>
<NativeSelect
: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>
</article>
<p v-if="rules.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine Workflow-Regeln verfügbar.
</p>
</section>
</Card>
</template>
@@ -24,6 +24,8 @@ export type AdminContentForm = {
contactContent: string
sponsorsUrl: string
sponsorsContent: string
showactsUrl: string
showactsContent: string
socialLinks: SocialLinkForm[]
faq: FaqFormItem[]
}
@@ -2,16 +2,45 @@ import { computed, reactive, ref, watch } from 'vue'
import { SOCIAL_ICON_OPTIONS, socialIconOptionForValue } from '../../lib/socialIcons'
import { useAwardsStore } from '../../stores/awards'
import type { AdminCandidateItem } from '../../types/awards'
import type { AdminCandidateItem, AdminWorkflowRule } from '../../types/awards'
interface CandidateForm {
categoryId: number
displayName: string
channelSlug: string
platform: string
acceptanceStatus: string
acceptanceNote: string
clipCompilationUrl: string
clipCompilationTitle: string
clipCompilationPlatform: string
clipEmbedStatus: string
}
const pageSize = 10
const maxFinalistsRuleKey = 'max_finalists_per_category'
const maxCandidateAppearancesRuleKey = 'max_candidate_appearances'
const acceptanceStatusOptions = [
{ label: 'Offen', value: 'open', description: 'Noch nicht kontaktiert' },
{ label: 'Angefragt', value: 'contacted', description: 'Kontakt läuft' },
{ label: 'Angenommen', value: 'accepted', description: 'nimmt teil' },
{ label: 'Abgesagt', value: 'declined', description: 'nicht voting-bereit' },
]
const clipEmbedStatusOptions = [
{ label: 'Noch nicht geprüft', value: 'unchecked' },
{ label: 'Einbettbar', value: 'embeddable' },
{ label: 'Nur Link', value: 'link_only' },
{ label: 'Nicht nutzbar', value: 'blocked' },
]
const readinessFilterOptions = [
{ label: 'Alle', value: 'all' },
{ label: 'Offen', value: 'open' },
{ label: 'Angefragt', value: 'contacted' },
{ label: 'Angenommen', value: 'accepted' },
{ label: 'Abgesagt', value: 'declined' },
{ label: 'Clip fehlt', value: 'missing_clip' },
{ label: 'Embed prüfen', value: 'embed_review' },
]
export function useAdminCandidateManager() {
const store = useAwardsStore()
@@ -21,13 +50,26 @@ export function useAdminCandidateManager() {
const adminError = ref('')
const search = ref('')
const categoryFilter = ref<number | null>(null)
const readinessFilter = ref('all')
const page = ref(1)
const modalOpen = ref(false)
const editingId = ref<number | 'new' | null>(null)
const candidateToDelete = ref<AdminCandidateItem | null>(null)
const form = reactive<CandidateForm>({ categoryId: 0, displayName: '', channelSlug: '', platform: 'Twitch' })
const form = reactive<CandidateForm>({
categoryId: 0,
displayName: '',
channelSlug: '',
platform: 'Twitch',
acceptanceStatus: 'open',
acceptanceNote: '',
clipCompilationUrl: '',
clipCompilationTitle: '',
clipCompilationPlatform: '',
clipEmbedStatus: 'unchecked',
})
const seasonDetail = computed(() => store.adminSeasonDetail)
const workflowRules = computed(() => store.adminWorkflowRules.rules)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const categoryOptions = computed(() =>
seasonDetail.value.categories.map((category) => ({ label: `${category.groupName} · ${category.name}`, value: category.id })),
@@ -51,6 +93,23 @@ export function useAdminCandidateManager() {
const duplicateCandidateCount = computed(() =>
seasonDetail.value.candidates.filter((candidate) => hasDuplicateCandidateKey(candidate, duplicateCandidateKeys.value)).length,
)
const candidateWorkflowNotices = computed(() => buildCandidateWorkflowNotices(
seasonDetail.value.candidates,
workflowRules.value,
))
const acceptedCandidateCount = computed(() =>
seasonDetail.value.candidates.filter((candidate) => candidate.acceptanceStatus === 'accepted').length,
)
const clipReadyCount = computed(() =>
seasonDetail.value.candidates.filter((candidate) => hasUsableCompilation(candidate)).length,
)
const actionNeededCount = computed(() =>
seasonDetail.value.candidates.filter((candidate) =>
candidate.acceptanceStatus !== 'accepted'
|| !hasUsableCompilation(candidate)
|| candidate.clipEmbedStatus === 'unchecked',
).length,
)
const filteredCandidates = computed(() => {
const query = search.value.trim().toLowerCase()
let list = seasonDetail.value.candidates
@@ -59,6 +118,20 @@ export function useAdminCandidateManager() {
list = list.filter((candidate) => candidate.categoryId === categoryFilter.value)
}
if (readinessFilter.value !== 'all') {
list = list.filter((candidate) => {
if (readinessFilter.value === 'missing_clip') {
return !hasUsableCompilation(candidate)
}
if (readinessFilter.value === 'embed_review') {
return Boolean(candidate.clipCompilationUrl?.trim()) && candidate.clipEmbedStatus === 'unchecked'
}
return candidate.acceptanceStatus === readinessFilter.value
})
}
if (query) {
list = list.filter((candidate) =>
[candidate.displayName, candidate.channelSlug, candidate.platform, categoryLabelMap.value[candidate.categoryId] ?? '']
@@ -79,12 +152,19 @@ export function useAdminCandidateManager() {
const rangeEnd = computed(() => Math.min(page.value * pageSize, filteredCandidates.value.length))
const modalTitle = computed(() => (editingId.value === 'new' ? 'Kandidat anlegen' : 'Kandidat bearbeiten'))
const canSave = computed(() =>
Boolean(selectedSeasonId.value && form.categoryId && form.displayName.trim() && form.channelSlug.trim() && form.platform.trim()),
Boolean(
selectedSeasonId.value
&& form.categoryId
&& form.displayName.trim()
&& form.channelSlug.trim()
&& form.platform.trim()
&& isValidOptionalUrl(form.clipCompilationUrl),
),
)
const candidatePlatformOptions = computed(() => SOCIAL_ICON_OPTIONS.filter((option) => option.key !== 'website'))
const selectedPlatformValue = computed(() => socialIconOptionForValue(form.platform)?.key ?? 'custom')
watch([search, categoryFilter, () => seasonDetail.value.candidates.length], () => {
watch([search, categoryFilter, readinessFilter, () => seasonDetail.value.candidates.length], () => {
page.value = 1
})
@@ -97,6 +177,16 @@ export function useAdminCandidateManager() {
function clearFilters() {
search.value = ''
categoryFilter.value = null
readinessFilter.value = 'all'
}
function resetPreparationForm() {
form.acceptanceStatus = 'open'
form.acceptanceNote = ''
form.clipCompilationUrl = ''
form.clipCompilationTitle = ''
form.clipCompilationPlatform = ''
form.clipEmbedStatus = 'unchecked'
}
function openCreate() {
@@ -107,6 +197,7 @@ export function useAdminCandidateManager() {
form.displayName = ''
form.channelSlug = ''
form.platform = 'Twitch'
resetPreparationForm()
modalOpen.value = true
}
@@ -118,6 +209,12 @@ export function useAdminCandidateManager() {
form.displayName = candidate.displayName
form.channelSlug = candidate.channelSlug
form.platform = candidate.platform
form.acceptanceStatus = candidate.acceptanceStatus || 'open'
form.acceptanceNote = candidate.acceptanceNote ?? ''
form.clipCompilationUrl = candidate.clipCompilationUrl ?? ''
form.clipCompilationTitle = candidate.clipCompilationTitle ?? ''
form.clipCompilationPlatform = candidate.clipCompilationPlatform ?? ''
form.clipEmbedStatus = candidate.clipEmbedStatus || 'unchecked'
modalOpen.value = true
}
@@ -143,10 +240,10 @@ export function useAdminCandidateManager() {
try {
if (editingId.value === 'new') {
await store.createAdminCandidate(selectedSeasonId.value, { ...form })
await store.createAdminCandidate(selectedSeasonId.value, buildCandidatePayload())
adminMessage.value = `${form.displayName}" wurde angelegt.`
} else if (typeof editingId.value === 'number') {
await store.updateAdminCandidate(editingId.value, selectedSeasonId.value, { ...form })
await store.updateAdminCandidate(editingId.value, selectedSeasonId.value, buildCandidatePayload())
adminMessage.value = `${form.displayName}" wurde gespeichert.`
}
modalOpen.value = false
@@ -157,6 +254,22 @@ export function useAdminCandidateManager() {
}
}
function buildCandidatePayload() {
const clipUrl = form.clipCompilationUrl.trim()
return {
categoryId: form.categoryId,
displayName: form.displayName,
channelSlug: form.channelSlug,
platform: form.platform,
acceptanceStatus: form.acceptanceStatus,
acceptanceNote: form.acceptanceNote.trim() || null,
clipCompilationUrl: clipUrl || null,
clipCompilationTitle: clipUrl ? form.clipCompilationTitle.trim() || null : null,
clipCompilationPlatform: clipUrl ? form.clipCompilationPlatform.trim() || null : null,
clipEmbedStatus: clipUrl ? form.clipEmbedStatus : 'unchecked',
}
}
async function confirmDelete() {
if (!candidateToDelete.value || !selectedSeasonId.value) {
return
@@ -184,12 +297,17 @@ export function useAdminCandidateManager() {
adminError,
search,
categoryFilter,
readinessFilter,
page,
categoryOptions,
categoryFilterOptions,
categoryLabelMap,
duplicateCandidateKeys,
candidateWorkflowNotices,
duplicateCandidateCount,
acceptedCandidateCount,
clipReadyCount,
actionNeededCount,
filteredCandidates,
pagedCandidates,
totalPages,
@@ -201,6 +319,9 @@ export function useAdminCandidateManager() {
canSave,
candidatePlatformOptions,
selectedPlatformValue,
acceptanceStatusOptions,
clipEmbedStatusOptions,
readinessFilterOptions,
candidateToDelete,
clearFilters,
openCreate,
@@ -211,6 +332,59 @@ export function useAdminCandidateManager() {
}
}
function buildCandidateWorkflowNotices(candidates: AdminCandidateItem[], rules: AdminWorkflowRule[]) {
const notices: Record<number, Array<{ mode: 'warn' | 'block'; message: string }>> = {}
const activeCandidates = candidates.filter((candidate) => candidate.acceptanceStatus !== 'declined')
const finalistsRule = rules.find((rule) => rule.key === maxFinalistsRuleKey)
const appearancesRule = rules.find((rule) => rule.key === maxCandidateAppearancesRuleKey)
if (finalistsRule?.enabled) {
const categoryCounts = new Map<number, number>()
for (const candidate of activeCandidates) {
categoryCounts.set(candidate.categoryId, (categoryCounts.get(candidate.categoryId) ?? 0) + 1)
}
for (const candidate of activeCandidates) {
const count = categoryCounts.get(candidate.categoryId) ?? 0
if (count > finalistsRule.limit) {
addCandidateNotice(notices, candidate.id, finalistsRule, `${count}/${finalistsRule.limit} finale Kandidat:innen in dieser Kategorie.`)
}
}
}
if (appearancesRule?.enabled) {
const identityCounts = new Map<string, number>()
for (const candidate of activeCandidates) {
const key = candidateIdentityKey(candidate)
identityCounts.set(key, (identityCounts.get(key) ?? 0) + 1)
}
for (const candidate of activeCandidates) {
const count = identityCounts.get(candidateIdentityKey(candidate)) ?? 0
if (count > appearancesRule.limit) {
addCandidateNotice(notices, candidate.id, appearancesRule, `${count}/${appearancesRule.limit} Kandidaturen für diese Person.`)
}
}
}
return notices
}
function addCandidateNotice(
notices: Record<number, Array<{ mode: 'warn' | 'block'; message: string }>>,
candidateId: number,
rule: AdminWorkflowRule,
message: string,
) {
const mode = rule.mode === 'warn' ? 'warn' : 'block'
notices[candidateId] = [...(notices[candidateId] ?? []), { mode, message }]
}
function candidateIdentityKey(candidate: Pick<AdminCandidateItem, 'displayName' | 'channelSlug'>) {
const channel = candidate.channelSlug.trim().replace(/^@+/, '').toLowerCase()
return channel ? `slug:${channel}` : `name:${candidate.displayName.trim().toLowerCase()}`
}
function createCandidateDuplicateKey(candidate: AdminCandidateItem, field: 'name' | 'slug') {
const value = field === 'name' ? candidate.displayName : candidate.channelSlug
return `${candidate.categoryId}:${field}:${value.trim().toLowerCase()}`
@@ -220,3 +394,19 @@ function hasDuplicateCandidateKey(candidate: AdminCandidateItem, candidateKeys:
return (candidateKeys.get(createCandidateDuplicateKey(candidate, 'name')) ?? 0) > 1
|| (candidateKeys.get(createCandidateDuplicateKey(candidate, 'slug')) ?? 0) > 1
}
function hasUsableCompilation(candidate: AdminCandidateItem) {
return Boolean(candidate.clipCompilationUrl?.trim()) && candidate.clipEmbedStatus !== 'blocked'
}
function isValidOptionalUrl(value: string) {
const trimmed = value.trim()
if (!trimmed) return true
try {
const url = new URL(trimmed)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
@@ -25,6 +25,23 @@ export function useAdminClipManager() {
const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const optionalFeatures = computed(() => store.adminOptionalFeatureSettings)
const clipSubmissionsEnabled = computed(() => optionalFeatures.value.clipSubmissionsEnabled)
const clipReviewEnabled = computed(() => optionalFeatures.value.clipReviewEnabled)
const clipDisabledMessage = computed(() => optionalFeatures.value.clipSubmissionDisabledMessage)
const clipWorkflowStatusLabel = computed(() => {
if (clipSubmissionsEnabled.value && clipReviewEnabled.value) return 'Public-Einreichung und Review aktiv'
if (clipSubmissionsEnabled.value) return 'Public-Einreichung aktiv, Review ausgeblendet'
if (clipReviewEnabled.value) return 'Public aus, Review bleibt sichtbar'
return 'Clip-Workflow deaktiviert'
})
const clipWorkflowStatusClass = computed(() =>
clipSubmissionsEnabled.value
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: clipReviewEnabled.value || submissions.value.length > 0
? 'border-amber-200 bg-amber-50 text-amber-700'
: 'border-slate-200 bg-slate-50 text-slate-600',
)
const submissions = computed(() => seasonDetail.value.clipSubmissions ?? [])
const categories = computed(() => seasonDetail.value.categories ?? [])
const categoryName = computed(() =>
@@ -188,6 +205,11 @@ export function useAdminClipManager() {
adminError,
clipToDelete,
reviewNotes,
clipSubmissionsEnabled,
clipReviewEnabled,
clipDisabledMessage,
clipWorkflowStatusLabel,
clipWorkflowStatusClass,
submissions,
categoryName,
clips,
@@ -22,6 +22,8 @@ function createEmptyForm(): AdminContentForm {
contactContent: '',
sponsorsUrl: '',
sponsorsContent: '',
showactsUrl: '',
showactsContent: '',
socialLinks: [],
faq: [],
}
@@ -83,6 +85,8 @@ export function useAdminContentManager() {
form.contactContent = settings.contactContent
form.sponsorsUrl = settings.sponsorsUrl
form.sponsorsContent = settings.sponsorsContent
form.showactsUrl = settings.showactsUrl
form.showactsContent = settings.showactsContent
form.socialLinks = settings.socialLinks.map((item) => ({
label: item.label ?? '',
platform: item.platform ?? '',
@@ -249,6 +253,8 @@ export function useAdminContentManager() {
contactContent: privacyContentForStorage(form.contactContent),
sponsorsUrl: form.sponsorsUrl,
sponsorsContent: privacyContentForStorage(form.sponsorsContent),
showactsUrl: form.showactsUrl,
showactsContent: privacyContentForStorage(form.showactsContent),
socialLinks: form.socialLinks
.map((item) => ({
label: item.label.trim(),
@@ -0,0 +1,123 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { useAwardsStore } from '../../stores/awards'
const fallbackDisabledMessage = 'Clip-Einreichungen sind aktuell geschlossen.'
export function useAdminOptionalFeatures() {
const store = useAwardsStore()
const optionalFeaturesLoading = ref(false)
const optionalFeaturesSaving = ref(false)
const optionalFeaturesError = ref('')
const optionalFeaturesSuccess = ref('')
const optionalFeaturesModalOpen = ref(false)
const originalSnapshot = ref('')
const optionalFeaturesForm = reactive({
clipSubmissionsEnabled: false,
clipReviewEnabled: true,
clipAdminMenuVisible: true,
clipSubmissionDisabledMessage: fallbackDisabledMessage,
})
const pendingClipCount = computed(() =>
store.adminSeasonDetail.clipSubmissions.filter((clip) => clip.status === 'pending').length,
)
const totalClipCount = computed(() => store.adminSeasonDetail.clipSubmissions.length)
const optionalFeaturesSummary = computed(() => ({
clipSubmissionsEnabled: optionalFeaturesForm.clipSubmissionsEnabled,
clipReviewEnabled: optionalFeaturesForm.clipReviewEnabled,
pendingClipCount: pendingClipCount.value,
totalClipCount: totalClipCount.value,
}))
const hasUnsavedOptionalFeatureChanges = computed(() =>
JSON.stringify(normalizedForm()) !== originalSnapshot.value,
)
function normalizedForm() {
return {
clipSubmissionsEnabled: optionalFeaturesForm.clipSubmissionsEnabled,
clipReviewEnabled: optionalFeaturesForm.clipReviewEnabled,
clipAdminMenuVisible: optionalFeaturesForm.clipAdminMenuVisible,
clipSubmissionDisabledMessage: normalizeDisabledMessage(optionalFeaturesForm.clipSubmissionDisabledMessage),
showactApplicationsEnabled: store.adminOptionalFeatureSettings.showactApplicationsEnabled,
showactApplicationDisabledMessage: store.adminOptionalFeatureSettings.showactApplicationDisabledMessage,
sponsorsVisible: store.adminOptionalFeatureSettings.sponsorsVisible,
}
}
function applyResponse(response = store.adminOptionalFeatureSettings) {
optionalFeaturesForm.clipSubmissionsEnabled = response.clipSubmissionsEnabled
optionalFeaturesForm.clipReviewEnabled = response.clipReviewEnabled
optionalFeaturesForm.clipAdminMenuVisible = response.clipAdminMenuVisible
optionalFeaturesForm.clipSubmissionDisabledMessage = normalizeDisabledMessage(response.clipSubmissionDisabledMessage)
originalSnapshot.value = JSON.stringify(normalizedForm())
}
async function loadOptionalFeatureSettings() {
optionalFeaturesLoading.value = true
optionalFeaturesError.value = ''
optionalFeaturesSuccess.value = ''
try {
const response = await store.loadAdminOptionalFeatureSettings()
applyResponse(response)
} catch (error) {
optionalFeaturesError.value = error instanceof Error ? error.message : 'Optionale Workflows konnten nicht geladen werden.'
applyResponse()
} finally {
optionalFeaturesLoading.value = false
}
}
async function saveOptionalFeatureSettings() {
optionalFeaturesSaving.value = true
optionalFeaturesError.value = ''
optionalFeaturesSuccess.value = ''
try {
const response = await store.updateAdminOptionalFeatureSettings(normalizedForm())
applyResponse(response)
optionalFeaturesSuccess.value = 'Clip-Workflow wurde gespeichert.'
optionalFeaturesModalOpen.value = false
} catch (error) {
optionalFeaturesError.value = error instanceof Error ? error.message : 'Clip-Workflow konnte nicht gespeichert werden.'
} finally {
optionalFeaturesSaving.value = false
}
}
function openOptionalFeaturesModal() {
optionalFeaturesError.value = ''
optionalFeaturesSuccess.value = ''
optionalFeaturesModalOpen.value = true
}
function closeOptionalFeaturesModal() {
if (hasUnsavedOptionalFeatureChanges.value && !window.confirm('Ungespeicherte Clip-Workflow-Änderungen verwerfen?')) {
return
}
applyResponse()
optionalFeaturesModalOpen.value = false
}
onMounted(loadOptionalFeatureSettings)
return {
hasUnsavedOptionalFeatureChanges,
optionalFeaturesError,
optionalFeaturesForm,
optionalFeaturesLoading,
optionalFeaturesModalOpen,
optionalFeaturesSaving,
optionalFeaturesSuccess,
optionalFeaturesSummary,
closeOptionalFeaturesModal,
loadOptionalFeatureSettings,
openOptionalFeaturesModal,
saveOptionalFeatureSettings,
}
}
function normalizeDisabledMessage(value: string) {
const trimmed = value.trim()
return trimmed ? trimmed.slice(0, 240) : fallbackDisabledMessage
}
@@ -8,6 +8,7 @@ export function useAdminReviewsManager() {
const store = useAwardsStore()
const route = useRoute()
const reviewSaving = ref<number | null>(null)
const blacklistSaving = ref(false)
const adminMessage = ref('')
const adminError = ref('')
const reviewFilter = ref('')
@@ -207,6 +208,24 @@ export function useAdminReviewsManager() {
}
}
async function addStreamUrlToBlacklist(streamUrl: string) {
const url = streamUrl.trim()
if (!url || blacklistSaving.value) return
blacklistSaving.value = true
adminMessage.value = ''
adminError.value = ''
try {
await store.addAdminNominationLinkBlacklistEntry({ url })
adminMessage.value = 'Link wurde zur Blacklist hinzugefügt.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Link konnte nicht zur Blacklist hinzugefügt werden.'
} finally {
blacklistSaving.value = false
}
}
function handleKeydown(event: KeyboardEvent) {
const target = event.target as Element
if (
@@ -280,6 +299,7 @@ export function useAdminReviewsManager() {
return {
reviewSaving,
blacklistSaving,
adminMessage,
adminError,
reviewForms,
@@ -299,6 +319,7 @@ export function useAdminReviewsManager() {
canApproveSelected,
approveNomination,
rejectNomination,
addStreamUrlToBlacklist,
selectedPlatformValue,
handlePlatformSelection,
extractNominationStreamUrl,
@@ -19,6 +19,7 @@ export function useAdminSettingsOverview() {
),
)
const pendingClips = computed(() => seasonDetail.value.clipSubmissions.filter((clip) => clip.status === 'pending').length)
const optionalFeatures = computed(() => store.adminOptionalFeatureSettings)
const pendingMigrationCount = computed(() => databaseHealth.value.pendingMigrations.length)
const openRiskCount = computed(() => getRiskMetricValue(store.admin.metrics))
const healthLoadedLabel = computed(() =>
@@ -29,12 +30,14 @@ export function useAdminSettingsOverview() {
siteSettings.value.imprintUrl,
siteSettings.value.contactUrl,
siteSettings.value.sponsorsUrl,
siteSettings.value.showactsUrl,
siteSettings.value.newsletterUrl,
].filter((url) => url.trim()).length)
const configuredFooterPages = computed(() => [
siteSettings.value.imprintContent,
siteSettings.value.contactContent,
siteSettings.value.sponsorsContent,
siteSettings.value.showactsContent,
].filter((content) => content.trim()).length)
const contentChecks = computed<AdminSettingsCheckItem[]>(() => [
{
@@ -62,8 +65,8 @@ export function useAdminSettingsOverview() {
},
{
label: 'Footer Links',
value: configuredFooterLinks.value === 4 && configuredFooterPages.value === 3,
note: `${configuredFooterLinks.value} von 4 Link-Zielen, ${configuredFooterPages.value} von 3 Footer-Seiten gepflegt`,
value: configuredFooterLinks.value === 5 && configuredFooterPages.value === 4,
note: `${configuredFooterLinks.value} von 5 Link-Zielen, ${configuredFooterPages.value} von 4 Footer-Seiten gepflegt`,
icon: Link2,
to: '/admin/content',
},
@@ -143,7 +146,15 @@ export function useAdminSettingsOverview() {
to: '/admin/categories',
},
{
label: 'Clip-Reviews offen',
label: 'Clip-Workflow',
state: optionalFeatures.value.clipSubmissionsEnabled,
note: optionalFeatures.value.clipSubmissionsEnabled
? 'Public-Clip-Einreichung ist aktiv.'
: 'Public-Clip-Einreichung ist deaktiviert.',
to: '/admin/settings',
},
{
label: 'Clip-Inbox',
state: pendingClips.value > 0,
note: pendingClips.value > 0 ? `${pendingClips.value} Clip-Einreichungen offen.` : 'Keine offenen Clip-Einreichungen.',
to: '/admin/clips',
@@ -2,8 +2,16 @@ import { computed, reactive, ref, watch } from 'vue'
import { CheckCircle2, Clock3, ListChecks, Trophy } from '@lucide/vue'
import { useAwardsStore } from '../../stores/awards'
import type { AdminCandidateItem, AdminWorkflowRule } from '../../types/awards'
const allFilter = 'all'
const maxWinnerPlacementsRuleKey = 'max_winner_placements'
const winnerRequiresClipRuleKey = 'winner_requires_clip'
type WinnerRuleNotice = {
mode: 'warn' | 'block'
message: string
}
export function useAdminWinnersManager() {
const store = useAwardsStore()
@@ -16,7 +24,53 @@ export function useAdminWinnersManager() {
const winnerSelections = reactive<Record<number, string>>({})
const seasonDetail = computed(() => store.adminSeasonDetail)
const workflowRules = computed(() => store.adminWorkflowRules.rules)
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])))
function findWorkflowRule(ruleKey: string): AdminWorkflowRule | null {
return workflowRules.value.find((rule) => rule.key === ruleKey) ?? null
}
function candidateIdentityKey(candidate: Pick<AdminCandidateItem, 'displayName' | 'channelSlug'>) {
const channel = candidate.channelSlug.trim().replace(/^@+/, '').toLowerCase()
return channel ? `slug:${channel}` : `name:${candidate.displayName.trim().toLowerCase()}`
}
function winnerRuleNoticeFor(categoryId: number): WinnerRuleNotice | null {
const candidateId = Number(winnerSelections[categoryId])
const selectedCandidate = selectedCandidateMap.value.get(candidateId)
if (!selectedCandidate) return null
const clipRule = findWorkflowRule(winnerRequiresClipRuleKey)
if (clipRule?.enabled && !selectedCandidate.clipCompilationUrl?.trim()) {
const mode = clipRule.mode === 'warn' ? 'warn' : 'block'
return {
mode,
message: 'Dieser Kandidat hat noch keinen Clip-Link. Bitte in der Kandidatenvorbereitung eine YouTube-/Twitch-Compilation pflegen.',
}
}
const rule = findWorkflowRule(maxWinnerPlacementsRuleKey)
if (!rule?.enabled) return null
const identityKey = candidateIdentityKey(selectedCandidate)
const existingWinnerCount = seasonDetail.value.results.filter((result) => {
if (result.categoryId === categoryId) return false
return candidateIdentityKey({
displayName: result.candidateDisplayName,
channelSlug: result.candidateChannelSlug,
}) === identityKey
}).length
if (existingWinnerCount < rule.limit) return null
const mode = rule.mode === 'warn' ? 'warn' : 'block'
return {
mode,
message: `Diese Person hat bereits ${existingWinnerCount} Gewinnerplatz(e). Limit: ${rule.limit}.`,
}
}
const resultRows = computed(() =>
seasonDetail.value.categories
@@ -125,6 +179,13 @@ export function useAdminWinnersManager() {
async function saveWinner(categoryId: number) {
const candidateId = Number(winnerSelections[categoryId])
if (!candidateId || !seasonDetail.value.id) return
const ruleNotice = winnerRuleNoticeFor(categoryId)
if (ruleNotice?.mode === 'block') {
adminMessage.value = ''
adminError.value = `Workflow-Regel blockiert: ${ruleNotice.message}`
return
}
savingResultForCategory.value = categoryId
adminMessage.value = ''
adminError.value = ''
@@ -167,5 +228,6 @@ export function useAdminWinnersManager() {
winnerSelections,
clearWinner,
saveWinner,
winnerRuleNoticeFor,
}
}
@@ -0,0 +1,97 @@
import { computed, onMounted, ref } from 'vue'
import { useAwardsStore } from '../../stores/awards'
import type { AdminWorkflowRule } from '../../types/awards'
function cloneRules(rules: AdminWorkflowRule[]) {
return rules.map((rule) => ({ ...rule }))
}
function normalizeRule(rule: AdminWorkflowRule): AdminWorkflowRule {
return {
...rule,
enabled: Boolean(rule.enabled),
limit: Math.min(50, Math.max(1, Number(rule.limit) || 1)),
mode: rule.mode === 'warn' ? 'warn' : 'block',
}
}
export function useAdminWorkflowRules() {
const store = useAwardsStore()
const workflowLoading = ref(false)
const workflowSaving = ref(false)
const workflowError = ref('')
const workflowSuccess = ref('')
const workflowRules = ref<AdminWorkflowRule[]>([])
const originalSnapshot = ref('')
const workflowRuleSummary = computed(() => {
const active = workflowRules.value.filter((rule) => rule.enabled).length
const blocking = workflowRules.value.filter((rule) => rule.enabled && rule.mode === 'block').length
return {
active,
blocking,
total: workflowRules.value.length,
}
})
const hasUnsavedWorkflowRuleChanges = computed(() =>
JSON.stringify(workflowRules.value.map(normalizeRule)) !== originalSnapshot.value,
)
async function loadWorkflowRules() {
workflowLoading.value = true
workflowError.value = ''
workflowSuccess.value = ''
try {
const response = await store.loadAdminWorkflowRules()
workflowRules.value = cloneRules(response.rules)
originalSnapshot.value = JSON.stringify(workflowRules.value.map(normalizeRule))
} catch (error) {
workflowError.value = error instanceof Error ? error.message : 'Workflow-Regeln konnten nicht geladen werden.'
workflowRules.value = []
originalSnapshot.value = '[]'
} finally {
workflowLoading.value = false
}
}
function updateWorkflowRule(ruleKey: string, patch: Partial<AdminWorkflowRule>) {
workflowRules.value = workflowRules.value.map((rule) =>
rule.key === ruleKey ? normalizeRule({ ...rule, ...patch }) : rule,
)
}
async function saveWorkflowRules() {
workflowSaving.value = true
workflowError.value = ''
workflowSuccess.value = ''
try {
const payloadRules = workflowRules.value.map(normalizeRule)
const response = await store.updateAdminWorkflowRules({ rules: payloadRules })
workflowRules.value = cloneRules(response.rules)
originalSnapshot.value = JSON.stringify(workflowRules.value.map(normalizeRule))
workflowSuccess.value = 'Workflow-Regeln wurden gespeichert.'
} catch (error) {
workflowError.value = error instanceof Error ? error.message : 'Workflow-Regeln konnten nicht gespeichert werden.'
} finally {
workflowSaving.value = false
}
}
onMounted(loadWorkflowRules)
return {
hasUnsavedWorkflowRuleChanges,
workflowError,
workflowLoading,
workflowRules,
workflowRuleSummary,
workflowSaving,
workflowSuccess,
loadWorkflowRules,
saveWorkflowRules,
updateWorkflowRule,
}
}
@@ -368,9 +368,9 @@
margin: -4px auto 12px;
padding: 6px 0 10px;
color: #fff8fd;
font-family: 'Cormorant Garamond', serif;
font-size: clamp(42px, 5.4vw, 66px);
line-height: 1.08;
font-family: 'Fredoka', sans-serif;
font-size: clamp(36px, 4.8vw, 58px);
line-height: 1.12;
font-weight: 700;
overflow: visible;
text-wrap: balance;
@@ -61,7 +61,7 @@ const logoutLabel = computed(() => !isTeamSession.value ? 'Von Twitch abmelden'
<div class="flex shrink-0 items-center justify-between gap-4 border-b border-[#f1ecfb] px-8 py-5">
<div>
<p class="text-[10px] font-bold uppercase tracking-[0.2em] text-[#8b6cdb]">Rechtliches</p>
<h2 class="font-['Cormorant_Garamond'] text-2xl font-bold text-[#3f3556]">Datenschutzerklärung</h2>
<h2 class="font-['Fredoka'] text-2xl font-bold text-[#3f3556]">Datenschutzerklärung</h2>
</div>
<button
type="button"
@@ -101,7 +101,7 @@ const logoutLabel = computed(() => !isTeamSession.value ? 'Von Twitch abmelden'
</div>
<div class="min-w-0">
<p class="text-[10px] font-bold uppercase tracking-[0.2em] text-[#8b6cdb]">Mein Profil</p>
<h2 class="truncate font-['Cormorant_Garamond'] text-2xl font-bold leading-tight text-[#3f3556]">
<h2 class="truncate font-['Fredoka'] text-2xl font-bold leading-tight text-[#3f3556]">
@{{ profileHandle }}
</h2>
</div>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import HomeStarField from './HomeStarField.vue'
import type { HomeArchiveYearItem, HomeSelectedArchive } from './homeModalTypes'
const props = defineProps<{
@@ -13,19 +14,25 @@ const props = defineProps<{
winnerPlatformKey: (url: string) => string
winnerPlatformLabel: (url: string) => string
}>()
function initialsFor(value: string) {
const parts = value.trim().split(/\s+/).filter(Boolean)
const initials = parts.length > 1
? `${parts[0][0] ?? ''}${parts[1][0] ?? ''}`
: value.slice(0, 2)
return initials.toUpperCase()
}
</script>
<template>
<template v-if="props.archiveModalOpen">
<div class="home-modal-overlay" @click="props.onCloseArchive" style="position:fixed;inset:0;z-index:260;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(34,18,58,.46);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);">
<div class="home-modal" @click="props.archiveModalStop" style="position:relative;width:100%;max-width:1020px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:radial-gradient(62% 80% at 82% 14%,rgba(255,210,236,.58),transparent 55%),radial-gradient(70% 90% at 10% 84%,rgba(206,196,247,.48),transparent 58%),linear-gradient(180deg,#fcf7ff 0%,#f3ebfc 100%);border-radius:30px;box-shadow:0 40px 90px rgba(20,8,40,.28);border:1px solid rgba(255,255,255,.62);">
<div class="home-modal" @click="props.archiveModalStop" style="position:relative;width:100%;max-width:1020px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:radial-gradient(62% 80% at 82% 14%,rgba(255,210,236,.58),transparent 55%),radial-gradient(70% 90% at 10% 84%,rgba(206,196,247,.48),transparent 58%),linear-gradient(180deg,#fcf7ff 0%,#f3ebfc 100%);border-radius:30px;box-shadow:0 40px 90px rgba(20,8,40,.28);border:none;">
<button @click="props.onCloseArchive" aria-label="Archiv schliessen" style="position:absolute;top:18px;right:18px;z-index:5;display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;border:none;background:rgba(139,108,219,.12);color:#8b6cdb;font-size:20px;cursor:pointer;" style-hover="background:rgba(139,108,219,.2);"></button>
<div style="position:relative;padding:30px 34px 22px;border-bottom:1px solid rgba(255,210,122,.14);background:linear-gradient(135deg,#2a1842,#3a2168);overflow:hidden;">
<span style="position:absolute;top:16px;left:24px;font-size:14px;color:#ffd27a;animation:twinkle 3s ease-in-out infinite;"></span>
<span style="position:absolute;top:26px;right:84px;font-size:12px;color:#ffb9d4;animation:twinkle 2.5s ease-in-out .4s infinite;"></span>
<span style="position:absolute;bottom:18px;left:280px;font-size:13px;color:#c9b1ff;animation:twinkle 3.2s ease-in-out .9s infinite;"></span>
<HomeStarField :count="13" variant="header" />
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#c9b1ff;margin-bottom:8px;position:relative;z-index:1;">Archiv</div>
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:38px;line-height:1;margin:0;color:#fff6fb;position:relative;z-index:1;">Gewinner vergangener Jahre</h3>
<h3 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(30px,3vw,36px);line-height:1.08;margin:0;color:#fff6fb;position:relative;z-index:1;">Gewinner vergangener Jahre</h3>
</div>
<div class="home-archive-modal__body" style="display:grid;grid-template-columns:220px minmax(0,1fr);min-height:0;flex:1;">
<aside class="home-archive-modal__years" style="padding:24px 18px;border-right:1px solid rgba(139,108,219,.12);background:rgba(255,255,255,.34);overflow-y:auto;">
@@ -53,34 +60,41 @@ const props = defineProps<{
<article
v-for="winner in props.selectedArchive.winners"
:key="`${props.selectedArchive.year}-${winner.category}`"
style="display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:16px 18px;border-radius:18px;background:rgba(255,255,255,.7);border:1px solid rgba(139,108,219,.12);box-shadow:0 12px 28px rgba(139,108,219,.08);"
style="display:grid;grid-template-columns:76px minmax(0,1fr);gap:15px;padding:16px 18px;border-radius:20px;background:rgba(255,255,255,.74);border:1px solid rgba(139,108,219,.12);box-shadow:0 12px 28px rgba(139,108,219,.08);"
>
<div style="width:76px;height:76px;border-radius:22px;background:linear-gradient(135deg,#ffe1a3,#e7b13e);display:grid;place-items:center;color:#2f1b00;font-family:'Fredoka',sans-serif;font-size:24px;font-weight:800;box-shadow:0 12px 24px rgba(139,108,219,.16);">
{{ initialsFor(winner.name) }}
</div>
<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:18px;line-height:1.25;color:#3f3556;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ winner.name }}</div>
<div style="font-size:13px;color:#8a8398;margin-top:5px;">{{ winner.handle }}</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;">
{{ 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>
<a :href="winner.url" target="_blank" rel="noopener" :style="props.winnerPlatformStyle(winner.url)" style-hover="transform:translateY(-1px);opacity:.88;">
<template v-if="props.winnerPlatformKey(winner.url) === 'twitch'">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><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>
</template>
<template v-else-if="props.winnerPlatformKey(winner.url) === 'youtube'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.4.6A3 3 0 0 0 .5 6.2 31 31 0 0 0 0 12a31 31 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.6 9.4.6 9.4.6s7.5 0 9.4-.6a3 3 0 0 0 2.1-2.1A31 31 0 0 0 24 12a31 31 0 0 0-.5-5.8zM9.5 15.5v-7l6.5 3.5z"/></svg>
</template>
<template v-else-if="props.winnerPlatformKey(winner.url) === 'x'">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M18.9 1.6h3.5l-7.6 8.7L23.7 22h-7l-5.5-7.2L4.9 22H1.4l8.1-9.3L1 1.6h7.2l4.9 6.5 5.8-6.5zm-1.2 18.3h1.9L7.1 3.6H5z"/></svg>
</template>
<template v-else-if="props.winnerPlatformKey(winner.url) === 'instagram'">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4"/><circle cx="17" cy="7" r="1.1" fill="currentColor" stroke="none"/></svg>
</template>
<template v-else-if="props.winnerPlatformKey(winner.url) === 'cake'">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11h16"/><path d="M6 11V8a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v3"/><path d="M5 11h14l-1 7a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2z"/><path d="M9 6a2 2 0 1 1 4 0"/></svg>
</template>
<template v-else>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1.5 1.5"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1.5-1.5"/></svg>
</template>
{{ props.winnerPlatformLabel(winner.url) }}
</a>
</article>
</div>
</div>
@@ -2,7 +2,7 @@
<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:-.4px;">{{ displayCategories.length }} Kategorien · 1 Sternenhimmel</h2>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:0;">{{ displayCategories.length }} Kategorien · 1 Sternenhimmel</h2>
<p style="font-size:17px;color:var(--muted,#c9b8da);max-width:560px;margin:0 auto;">Von Newcomer bis VTuber des Jahres für jede Art von Magie gibt es einen Stern zu gewinnen.</p>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:18px;" data-cat-grid>
@@ -0,0 +1,37 @@
<script setup lang="ts">
export interface HomeCategoryProgressRailItem {
id: string | number
name: string
icon: string
status: string
tone: 'empty' | 'active' | 'done' | 'error'
}
const props = defineProps<{
items: HomeCategoryProgressRailItem[]
activeId: string | number
label: string
onSelect: (id: string | number) => void
}>()
</script>
<template>
<aside class="home-wizard-rail">
<div class="home-wizard-rail__label">{{ props.label }}</div>
<button
v-for="item in props.items"
:key="item.id"
type="button"
class="home-wizard-rail__button"
:class="[
`home-wizard-rail__button--${item.tone}`,
{ 'home-wizard-rail__button--selected': item.id === props.activeId },
]"
@click="props.onSelect(item.id)"
>
<span class="home-wizard-rail__icon">{{ item.icon }}</span>
<span class="home-wizard-rail__name">{{ item.name }}</span>
<span class="home-wizard-rail__status">{{ item.status }}</span>
</button>
</aside>
</template>
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { CheckCircle2, Handshake, Mic2, Send } from '@lucide/vue'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const submitting = ref(false)
const submitMessage = ref('')
const submitError = ref('')
const form = reactive({
artistName: '',
contactEmail: '',
contactDiscord: '',
platformUrl: '',
performanceType: '',
description: '',
technicalNotes: '',
referenceUrl: '',
})
const featureFlags = computed(() => store.overview.featureFlags)
const sponsors = computed(() =>
featureFlags.value.sponsorsVisible ? store.publicSponsors.items.filter((item) => item.isVisible) : [],
)
const showSection = computed(() => sponsors.value.length > 0 || featureFlags.value.showactApplicationsEnabled)
function resetForm() {
form.artistName = ''
form.contactEmail = ''
form.contactDiscord = ''
form.platformUrl = ''
form.performanceType = ''
form.description = ''
form.technicalNotes = ''
form.referenceUrl = ''
}
async function submitShowact() {
submitting.value = true
submitMessage.value = ''
submitError.value = ''
try {
await store.submitShowactApplication(form)
submitMessage.value = 'Bewerbung ist angekommen. Das Team meldet sich, wenn es passt.'
resetForm()
} catch (error) {
submitError.value = error instanceof Error ? error.message : 'Bewerbung konnte nicht gesendet werden.'
} finally {
submitting.value = false
}
}
</script>
<template>
<section v-if="showSection" class="mx-auto grid w-full max-w-6xl gap-5 px-5 py-10 lg:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]">
<div v-if="sponsors.length > 0" class="rounded-[24px] border border-white/70 bg-white/78 p-5 shadow-[0_18px_46px_rgba(105,78,160,0.13)] backdrop-blur">
<div class="flex items-center gap-3">
<span class="grid h-11 w-11 place-items-center rounded-2xl bg-sky-100 text-sky-700">
<Handshake class="h-5 w-5" />
</span>
<div>
<p class="text-xs font-bold uppercase tracking-[0.2em] text-sky-600">Partner</p>
<h2 class="text-xl font-black text-[#3f3556]">Sponsoren</h2>
</div>
</div>
<div class="mt-5 grid gap-3 sm:grid-cols-2">
<a
v-for="sponsor in sponsors"
:key="sponsor.id"
:href="sponsor.websiteUrl || undefined"
:target="sponsor.websiteUrl ? '_blank' : undefined"
rel="noreferrer"
class="group min-h-[136px] rounded-[20px] border border-violet-100 bg-white p-4 transition hover:-translate-y-0.5 hover:border-violet-200 hover:shadow-[0_18px_36px_rgba(105,78,160,0.12)]"
>
<div class="flex items-start gap-3">
<div class="grid h-12 w-12 shrink-0 place-items-center overflow-hidden rounded-2xl border border-violet-100 bg-violet-50 text-sm font-black text-violet-700">
<img v-if="sponsor.logoUrl" :src="sponsor.logoUrl" :alt="sponsor.name" class="h-full w-full object-contain p-1" loading="lazy" />
<span v-else>{{ sponsor.name.slice(0, 2).toUpperCase() }}</span>
</div>
<div class="min-w-0">
<p class="truncate text-sm font-black text-[#3f3556]">{{ sponsor.name }}</p>
<p class="mt-1 text-xs font-bold uppercase tracking-[0.14em] text-sky-600">{{ sponsor.tier }}</p>
</div>
</div>
<p v-if="sponsor.description" class="mt-3 line-clamp-3 text-sm leading-6 text-[#746b86]">{{ sponsor.description }}</p>
</a>
</div>
</div>
<div v-if="featureFlags.showactApplicationsEnabled" class="rounded-[24px] border border-white/70 bg-white/78 p-5 shadow-[0_18px_46px_rgba(105,78,160,0.13)] backdrop-blur">
<div class="flex items-center gap-3">
<span class="grid h-11 w-11 place-items-center rounded-2xl bg-fuchsia-100 text-fuchsia-700">
<Mic2 class="h-5 w-5" />
</span>
<div>
<p class="text-xs font-bold uppercase tracking-[0.2em] text-fuchsia-600">Showacts</p>
<h2 class="text-xl font-black text-[#3f3556]">Bewerben</h2>
</div>
</div>
<form class="mt-5 space-y-3" @submit.prevent="submitShowact">
<div class="grid gap-3 sm:grid-cols-2">
<input v-model="form.artistName" required maxlength="120" placeholder="Kuenstlername" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<input v-model="form.performanceType" required maxlength="80" placeholder="Showact-Art" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
</div>
<div class="grid gap-3 sm:grid-cols-2">
<input v-model="form.contactEmail" maxlength="180" placeholder="E-Mail" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<input v-model="form.contactDiscord" maxlength="120" placeholder="Discord" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
</div>
<div class="grid gap-3 sm:grid-cols-2">
<input v-model="form.platformUrl" maxlength="500" placeholder="Kanal/Profil URL" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<input v-model="form.referenceUrl" maxlength="500" placeholder="Referenz URL" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
</div>
<textarea v-model="form.description" required rows="3" maxlength="1000" placeholder="Kurz beschreiben, was du zeigen moechtest" class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<textarea v-model="form.technicalNotes" rows="2" maxlength="1000" placeholder="Technische Hinweise, Setup, Timing" class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<p v-if="submitError" class="rounded-2xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ submitError }}</p>
<p v-if="submitMessage" class="flex items-center gap-2 rounded-2xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
<CheckCircle2 class="h-4 w-4" />
{{ submitMessage }}
</p>
<button
type="submit"
:disabled="submitting"
class="inline-flex h-11 items-center justify-center gap-2 rounded-2xl border border-fuchsia-500 bg-fuchsia-600 px-5 text-sm font-black text-white shadow-[0_14px_28px_rgba(192,38,211,0.2)] transition hover:bg-fuchsia-500 disabled:opacity-60"
>
<Send class="h-4 w-4" />
{{ submitting ? 'Sendet ...' : 'Bewerbung senden' }}
</button>
</form>
</div>
</section>
</template>
@@ -1,8 +1,6 @@
<template>
<section class="home-stream-band" style="position:relative;overflow:hidden;background:linear-gradient(115deg,#241640 0%,#3a2168 48%,#5b3aa0 100%);">
<span style="position:absolute;top:18px;left:7%;font-size:16px;color:rgba(255,255,255,.35);animation:twinkle 3s ease-in-out infinite;"></span>
<span style="position:absolute;bottom:16px;left:46%;font-size:12px;color:rgba(255,255,255,.3);animation:twinkle 2.6s ease-in-out .5s infinite;"></span>
<span style="position:absolute;top:24px;right:38%;font-size:13px;color:rgba(255,255,255,.3);animation:twinkle 3.3s ease-in-out 1s infinite;"></span>
<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 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);">
@@ -10,7 +8,7 @@
</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:'Cormorant Garamond',serif;font-weight:700;font-size:26px;color:#fff;line-height:1.1;">{{ streamTitle }}</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;">
<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>
@@ -46,6 +44,8 @@
</template>
<script setup lang="ts">
import HomeStarField from './HomeStarField.vue'
defineProps<{
publicStreamUrl: string
streamEyebrow: string
@@ -1,9 +1,15 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import HomeCategoryProgressRail from './HomeCategoryProgressRail.vue'
import HomeNominationReviewPane from './HomeNominationReviewPane.vue'
import HomeNominationWizardPane from './HomeNominationWizardPane.vue'
import HomeSelectDropdown from './HomeSelectDropdown.vue'
import HomeStarField from './HomeStarField.vue'
import HomeVotingPickerPane from './HomeVotingPickerPane.vue'
import HomeWizardFooter from './HomeWizardFooter.vue'
import type { HomeCategoryListItem, HomeNomineeListItem, HomeSelectionOption } from './homeModalTypes'
import type { HomeNominationSubmitContext } from './homeLandingTypes'
const props = defineProps<{
modalOpen: boolean
@@ -30,8 +36,10 @@ const props = defineProps<{
voteCount: number
totalCats: number
canSubmitVote: boolean
savedVoteActive: boolean
activeCatIndex: number
submitVote: () => Promise<void> | void
submitNomination: () => Promise<void> | void
submitNomination: (context: HomeNominationSubmitContext) => Promise<void> | void
submitting: boolean
onClipCatChange: (event: Event) => void
catOptions: HomeSelectionOption[]
@@ -47,10 +55,14 @@ const props = defineProps<{
const nominationCatValue = ref(0)
const clipCatValue = ref(0)
const clipNomQuery = ref('')
const nominationDrafts = ref<Record<number, string[]>>({})
const nominationVisibleLinkCounts = ref<Record<number, number>>({})
const nominationReviewMode = ref(false)
watch(
() => props.catOptions,
(options) => {
syncNominationDrafts(options)
nominationCatValue.value = resolveOptionValue(nominationCatValue.value, options)
const nextClipCat = resolveOptionValue(clipCatValue.value, options)
if (nextClipCat !== clipCatValue.value) {
@@ -61,6 +73,16 @@ watch(
{ immediate: true },
)
watch(
() => props.modalOpen,
(isOpen) => {
if (!isOpen) {
nominationReviewMode.value = false
nominationCatValue.value = 0
}
},
)
function resolveOptionValue(value: number, options: HomeSelectionOption[]) {
return options.some((option) => option.id === value) ? value : options[0]?.id ?? 0
}
@@ -74,20 +96,213 @@ function handleClipCategoryChange(value: number) {
function notifyClipCategoryChange(value: number) {
props.onClipCatChange({ target: { value: String(value) } } as unknown as Event)
}
function syncNominationDrafts(options: HomeSelectionOption[]) {
const nextDrafts: Record<number, string[]> = {}
const nextVisibleLinkCounts: Record<number, number> = {}
for (const option of options) {
const existing = nominationDrafts.value[option.id] ?? []
nextDrafts[option.id] = [existing[0] ?? '', existing[1] ?? '', existing[2] ?? '']
const filledCount = nextDrafts[option.id].reduce((count, link, index) => link.trim() ? index + 1 : count, 0)
nextVisibleLinkCounts[option.id] = Math.min(3, Math.max(1, nominationVisibleLinkCounts.value[option.id] ?? filledCount))
}
nominationDrafts.value = nextDrafts
nominationVisibleLinkCounts.value = nextVisibleLinkCounts
}
function nominationLinksFor(categoryIndex: number) {
return nominationDrafts.value[categoryIndex] ?? ['', '', '']
}
function nominationVisibleLinkCountFor(categoryIndex: number) {
return nominationVisibleLinkCounts.value[categoryIndex] ?? 1
}
function compactNominationCategory(categoryIndex: number) {
const links = nominationLinksFor(categoryIndex)
const compactedLinks = links.map((link) => link.trim()).filter(Boolean).slice(0, 3)
const nextLinks = [compactedLinks[0] ?? '', compactedLinks[1] ?? '', compactedLinks[2] ?? '']
const nextVisibleCount = Math.max(1, compactedLinks.length)
nominationDrafts.value = {
...nominationDrafts.value,
[categoryIndex]: nextLinks,
}
nominationVisibleLinkCounts.value = {
...nominationVisibleLinkCounts.value,
[categoryIndex]: nextVisibleCount,
}
}
function onNominationLinkInput(index: number, value: string) {
const links = [...nominationLinksFor(nominationCatValue.value)]
links[index] = value
nominationDrafts.value = {
...nominationDrafts.value,
[nominationCatValue.value]: links,
}
}
function nominationFieldErrors(categoryIndex: number) {
const links = nominationLinksFor(categoryIndex)
return links.map((link, index) => {
const value = link.trim()
if (!value) return ''
if (!isHttpUrl(value)) return 'Bitte gib einen gueltigen http(s)-Link ein.'
const normalized = normalizeUrlForCompare(value)
const duplicateIndex = links.findIndex((candidate, candidateIndex) =>
candidateIndex !== index && normalizeUrlForCompare(candidate) === normalized,
)
return duplicateIndex >= 0 ? 'Dieser Link ist in dieser Kategorie schon eingetragen.' : ''
})
}
const activeNominationCategory = computed(() =>
props.catOptions.find((option) => option.id === nominationCatValue.value) ?? props.catOptions[0] ?? null,
)
const activeNominationLinks = computed(() => nominationLinksFor(nominationCatValue.value))
const activeNominationErrors = computed(() => nominationFieldErrors(nominationCatValue.value))
const activeNominationVisibleLinkCount = computed(() => nominationVisibleLinkCountFor(nominationCatValue.value))
const activeNominationRemainingLinks = computed(() => Math.max(0, 3 - activeNominationVisibleLinkCount.value))
const nominationReviewItems = computed(() =>
props.catOptions
.map((option) => ({
categoryIndex: option.id,
categoryName: stripCategoryIcon(option.label),
links: nominationLinksFor(option.id).map((link) => link.trim()).filter(Boolean),
}))
.filter((item) => item.links.length > 0),
)
const nominationTotalLinks = computed(() =>
nominationReviewItems.value.reduce((sum, item) => sum + item.links.length, 0),
)
const nominationHasErrors = computed(() =>
props.catOptions.some((option) => nominationFieldErrors(option.id).some(Boolean)),
)
const nominationRailItems = computed(() => props.catOptions.map((option) => {
const errors = nominationFieldErrors(option.id)
const linkCount = nominationLinksFor(option.id).map((link) => link.trim()).filter(Boolean).length
const isActive = option.id === nominationCatValue.value
return {
id: option.id,
name: stripCategoryIcon(option.label),
icon: option.label.trim().split(' ')[0] ?? '✦',
status: errors.some(Boolean) ? 'Fehler' : linkCount === 0 ? 'Leer' : `${linkCount} Link${linkCount === 1 ? '' : 's'}`,
tone: errors.some(Boolean) ? 'error' as const : linkCount > 0 ? 'done' as const : isActive ? 'active' as const : 'empty' as const,
}
}))
const nominationProgressText = computed(() =>
`${nominationReviewItems.value.length} / ${props.catOptions.length} Kategorien ausgefuellt`,
)
const nominationHelperText = computed(() =>
nominationTotalLinks.value > 0
? `${nominationTotalLinks.value} Link${nominationTotalLinks.value === 1 ? '' : 's'} bereit. Leere Kategorien werden uebersprungen.`
: 'Leere Kategorien kannst du einfach ueberspringen.',
)
const isLastNominationCategory = computed(() => {
const activeIndex = props.catOptions.findIndex((option) => option.id === nominationCatValue.value)
return activeIndex >= props.catOptions.length - 1
})
function selectNominationCategory(id: string | number) {
compactNominationCategory(nominationCatValue.value)
nominationCatValue.value = Number(id)
nominationReviewMode.value = false
}
function goToPreviousNominationCategory() {
const activeIndex = props.catOptions.findIndex((option) => option.id === nominationCatValue.value)
const previous = props.catOptions[Math.max(0, activeIndex - 1)]
if (previous) selectNominationCategory(previous.id)
}
function goToNextNominationCategory() {
const activeIndex = props.catOptions.findIndex((option) => option.id === nominationCatValue.value)
const next = props.catOptions[Math.min(props.catOptions.length - 1, activeIndex + 1)]
if (next) selectNominationCategory(next.id)
}
function openNominationReview() {
compactNominationCategory(nominationCatValue.value)
nominationReviewMode.value = true
}
function closeNominationReview() {
nominationReviewMode.value = false
}
function editNominationCategory(categoryIndex: number) {
nominationCatValue.value = categoryIndex
nominationReviewMode.value = false
}
function addNominationLinkField() {
const currentCount = nominationVisibleLinkCountFor(nominationCatValue.value)
nominationVisibleLinkCounts.value = {
...nominationVisibleLinkCounts.value,
[nominationCatValue.value]: Math.min(3, currentCount + 1),
}
}
function removeNominationLinkField(index: number) {
if (index <= 0) return
const compactedLinks = nominationLinksFor(nominationCatValue.value)
.filter((_, linkIndex) => linkIndex !== index)
.map((link) => link.trim())
.filter(Boolean)
.slice(0, 3)
const nextLinks = [compactedLinks[0] ?? '', compactedLinks[1] ?? '', compactedLinks[2] ?? '']
const previousVisibleCount = nominationVisibleLinkCountFor(nominationCatValue.value)
const nextVisibleCount = Math.max(1, Math.min(3, Math.max(previousVisibleCount - 1, compactedLinks.length)))
nominationDrafts.value = {
...nominationDrafts.value,
[nominationCatValue.value]: nextLinks,
}
nominationVisibleLinkCounts.value = {
...nominationVisibleLinkCounts.value,
[nominationCatValue.value]: nextVisibleCount,
}
}
async function submitNominationDrafts() {
if (nominationHasErrors.value || nominationTotalLinks.value === 0 || props.submitting) return
await props.submitNomination({
entries: nominationReviewItems.value.map((item) => ({
categoryIndex: item.categoryIndex,
streamUrls: item.links,
})),
})
}
function isHttpUrl(value: string) {
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
function normalizeUrlForCompare(value: string) {
return value.trim().replace(/\/+$/, '').toLowerCase()
}
function stripCategoryIcon(value: string) {
return value.replace(/^\S+\s+/, '')
}
</script>
<template>
<template v-if="props.modalOpen">
<div class="home-modal-overlay" @click="props.closeModal" style="position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.5);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);">
<div class="home-modal" :class="{ 'home-modal--wide': props.isPicker && (props.isVote || (props.nominationPhase && !props.isVote)) }" @click="props.stop" style="position:relative;width:100%;max-width:680px;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);">
<button @click="props.closeModal" aria-label="Schliessen" style="position:absolute;top:16px;right:16px;z-index:5;display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;" style-hover="background:#e6dcf6;"></button>
<div class="home-modal" :class="{ 'home-modal--wide': props.isPicker, 'home-modal--voting': props.isPicker && props.isVote && props.notSubmitted }" @click="props.stop" style="position:relative;width:100%;max-width:680px;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);">
<button @click="props.closeModal" aria-label="Schliessen" style="position:absolute;top:18px;right:18px;z-index:5;display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;border:none;background:rgba(139,108,219,.12);color:#8b6cdb;font-size:20px;cursor:pointer;" style-hover="background:rgba(139,108,219,.2);"></button>
<template v-if="props.submitted">
<div class="home-modal__success" style="padding:56px 40px;text-align:center;">
<div style="width:72px;height:72px;margin:0 auto 22px;border-radius:50%;background:linear-gradient(135deg,#2bbd6e,#1f9d5a);display:flex;align-items:center;justify-content:center;box-shadow:0 14px 32px rgba(31,157,90,.32);">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:30px;margin:0 0 12px;color:#3f3556;">{{ props.successTitle }}</h3>
<h3 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:28px;line-height:1.14;margin:0 0 12px;color:#3f3556;">{{ props.successTitle }}</h3>
<p style="font-size:16px;line-height:1.6;color:#7d7491;max-width:420px;margin:0 auto 28px;">{{ props.successText }}</p>
<button @click="props.closeModal" style="padding:14px 32px;border-radius:13px;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 24px rgba(124,86,196,.3);" style-hover="transform:translateY(-2px);">Schliessen</button>
</div>
@@ -104,7 +319,7 @@ function notifyClipCategoryChange(value: number) {
<template v-if="props.isShow">
<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:'Cormorant Garamond',serif;font-weight:700;font-size:30px;margin:0 0 10px;color:#3f3556;">Sei live dabei </h3>
<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>
<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;" />
@@ -116,104 +331,80 @@ function notifyClipCategoryChange(value: number) {
<template v-if="props.isPicker">
<template v-if="props.nominationPhase && !props.isVote">
<div class="home-modal__nomination-submit" style="display:flex;flex-direction:column;max-height:calc(88vh - 80px);">
<div class="home-modal__picker-header" style="padding:30px 36px 18px;border-bottom:1px solid #f1ecfb;flex:none;">
<div style="display:inline-flex;align-items:center;gap:7px;padding:4px 12px;border-radius:999px;background:rgba(247,108,173,.12);color:#c7508a;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:14px;"> Nominierungsphase</div>
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:28px;margin:0 0 6px;color:#3f3556;">{{ props.pickerTitle }}</h3>
<p style="font-size:14px;line-height:1.5;color:#8a8398;margin:0;">{{ props.pickerSubtitle }}</p>
<div class="home-modal__nomination-submit home-wizard-shell">
<div class="home-modal__picker-header" style="padding:30px 36px 22px;border-bottom:1px solid #f1ecfb;flex:none;">
<HomeStarField :count="13" variant="header" />
<div style="display:inline-flex;align-items:center;gap:7px;padding:6px 14px;border-radius:999px;background:rgba(201,177,255,.12);border:1px solid rgba(201,177,255,.34);color:#c9b1ff;font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;margin-bottom:12px;"> Nominierungsphase</div>
<div class="home-wizard-header-row">
<div>
<h3 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(30px,3vw,36px);line-height:1.08;margin:0 0 14px;color:#3f3556;">Streamer nominieren</h3>
<p style="font-size:16px;line-height:1.5;color:#8a8398;margin:0;">Reiche pro Kategorie bis zu drei Stream- oder Kanal-Links ein.</p>
</div>
</div>
</div>
<div class="home-modal__nomination-grid" style="display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px;padding:24px 36px 32px;overflow-y:auto;">
<section style="display:flex;flex-direction:column;gap:15px;padding:18px;border-radius:18px;background:#fcfaff;border:1px solid #efe7fb;">
<div>
<h4 style="font-family:'Outfit',sans-serif;font-size:16px;font-weight:800;color:#3f3556;margin:0 0 5px;">VTuber oder Streamer nominieren</h4>
<p style="font-size:13px;line-height:1.45;color:#8a8398;margin:0;">Der Stream-Link geht direkt in den Admin-Review; den Anzeigenamen vergibt das Team.</p>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Kategorie</label>
<HomeSelectDropdown
v-model="nominationCatValue"
data-ref="nominationCatRef"
label="Kategorie fuer Nominierung auswaehlen"
:options="props.catOptions"
/>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Stream-Link <span style="color:#e11d48;">*</span></label>
<input data-dc-ref="nominationStreamUrlRef" type="url" placeholder="https://plattform.de/dein-kanal" maxlength="300" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
<p style="font-size:12.5px;line-height:1.45;color:#8a8398;margin:6px 0 0;">Offizieller Kanal- oder Stream-Link der Person.</p>
</div>
<button @click="props.submitNomination" :disabled="props.submitting" :style="props.submitting ? 'width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:#d1c4e9;color:#9e8cc5;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:15px;cursor:not-allowed;' : 'width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;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 24px rgba(124,86,196,.3);'" style-hover="transform:translateY(-2px);">
{{ props.submitting ? 'Speichert ...' : 'Nominierung einreichen' }}
</button>
</section>
<section style="display:flex;flex-direction:column;gap:15px;padding:18px;border-radius:18px;background:#fffafd;border:1px solid #f4d8e9;">
<div>
<h4 style="font-family:'Outfit',sans-serif;font-size:16px;font-weight:800;color:#3f3556;margin:0 0 5px;">Clip einreichen</h4>
<p style="font-size:13px;line-height:1.45;color:#8a8398;margin:0;">Highlight-Clips können separat zur Show-Prüfung eingereicht werden.</p>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Clip-Link <span style="color:#e11d48;">*</span></label>
<input data-dc-ref="clipUrlRef" type="url" placeholder="https://plattform.de/dein-clip" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
</div>
<div class="home-modal__clip-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div>
<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"
:options="props.catOptions"
@change="handleClipCategoryChange"
/>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Für welche:n VTuber?</label>
<input
v-model="clipNomQuery"
data-dc-ref="clipNomSearchRef"
type="text"
list="clip-nominee-options"
placeholder="Name suchen"
autocomplete="off"
style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;"
style-focus="border-color:#8b6cdb;"
/>
<datalist id="clip-nominee-options">
<option v-for="option in props.clipNomOptions" :key="option.id" :value="option.label" />
</datalist>
</div>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Beschreibung <span style="color:#a99fc0;font-weight:400;">(optional)</span></label>
<textarea data-dc-ref="clipDescRef" placeholder="Warum ist dieser Moment so besonders?" rows="3" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;resize:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;"></textarea>
</div>
<label style="display:flex;align-items:flex-start;gap:11px;cursor:pointer;padding:13px;border-radius:11px;background:#fff;border:1px solid #ede4fb;">
<input type="checkbox" :checked="props.clipDsgvo" @change="props.clipDsgvoChange" style="width:17px;height:17px;flex:none;margin-top:2px;accent-color:#8b6cdb;cursor:pointer;" />
<span style="font-size:13px;line-height:1.6;color:#6f6685;">Ich stimme der Verarbeitung meiner Daten gemäß der <button @click="props.onOpenPrivacy" style="background:none;border:none;padding:0;color:#8b6cdb;font-weight:600;cursor:pointer;font-size:inherit;font-family:inherit;">Datenschutzerklärung</button> zu.</span>
</label>
<button @click="props.submitClip" :disabled="props.submitting || !props.canSubmitClip" :style="props.clipSubmitStyle">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
{{ props.submitting ? 'Speichert ...' : 'Clip einreichen' }}
</button>
</section>
<div class="home-wizard-layout">
<HomeCategoryProgressRail
label="Kategorien"
:items="nominationRailItems"
:active-id="nominationCatValue"
:on-select="selectNominationCategory"
/>
<main class="home-wizard-content">
<HomeNominationReviewPane
v-if="nominationReviewMode"
:items="nominationReviewItems"
:total-links="nominationTotalLinks"
:total-categories="props.catOptions.length"
:on-edit-category="editNominationCategory"
/>
<HomeNominationWizardPane
v-else
:category-name="activeNominationCategory ? stripCategoryIcon(activeNominationCategory.label) : 'Kategorie'"
:links="activeNominationLinks"
:errors="activeNominationErrors"
:visible-link-count="activeNominationVisibleLinkCount"
:remaining-link-count="activeNominationRemainingLinks"
:on-link-input="onNominationLinkInput"
:on-add-link-field="addNominationLinkField"
:on-remove-link-field="removeNominationLinkField"
/>
</main>
</div>
<HomeWizardFooter
:progress-text="nominationProgressText"
:helper-text="nominationHelperText"
:actions="nominationReviewMode
? [
{ label: 'Zurueck', 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 },
]"
/>
</div>
</template>
<template v-else>
<div style="display:flex;flex-direction:column;min-height:0;">
<div class="home-modal__picker-header" style="padding:30px 36px 18px;border-bottom:1px solid #f1ecfb;">
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:27px;margin:0 0 6px;color:#3f3556;">{{ props.pickerTitle }}</h3>
<p style="font-size:14px;line-height:1.5;color:#8a8398;margin:0;">{{ props.pickerSubtitle }}</p>
<div class="home-vote-shell">
<div class="home-modal__picker-header" style="padding:30px 36px 22px;border-bottom:1px solid #f1ecfb;">
<HomeStarField :count="13" variant="header" />
<div style="position:relative;z-index:1;display:inline-flex;align-items:center;gap:7px;padding:6px 14px;border-radius:999px;background:rgba(201,177,255,.12);border:1px solid rgba(201,177,255,.34);color:#c9b1ff;font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;margin-bottom:12px;">{{ props.isVote ? '★ Votingphase' : '✦ Nominierungen' }}</div>
<h3 style="position:relative;z-index:1;font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(30px,3vw,36px);line-height:1.08;margin:0 0 14px;color:#3f3556;">{{ props.pickerTitle }}</h3>
<p style="position:relative;z-index:1;font-size:16px;line-height:1.5;color:#8a8398;margin:0;">{{ props.pickerSubtitle }}</p>
</div>
<HomeVotingPickerPane
:cat-list="props.catList"
:active-cat-index="props.activeCatIndex"
:active-cat-name="props.activeCatName"
:noms="props.noms"
:vote-count="props.voteCount"
:total-cats="props.totalCats"
:can-submit-vote="props.canSubmitVote"
:saved-vote-active="props.savedVoteActive"
:submit-vote="props.submitVote"
:submitting="props.submitting"
:readonly-mode="!props.isVote"
/>
</div>
</template>
@@ -223,7 +414,7 @@ function notifyClipCategoryChange(value: number) {
<div class="home-modal__clip" style="display:flex;flex-direction:column;max-height:calc(88vh - 80px);">
<div class="home-modal__clip-header" style="padding:30px 40px 24px;border-bottom:1px solid #f1ecfb;flex:none;">
<div style="display:inline-flex;align-items:center;gap:7px;padding:4px 12px;border-radius:999px;background:rgba(247,108,173,.12);color:#c7508a;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:14px;"> Nominierungsphase</div>
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:28px;margin:0 0 6px;color:#3f3556;">Clip einreichen</h3>
<h3 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:26px;line-height:1.14;margin:0 0 6px;color:#3f3556;">Clip einreichen</h3>
<p style="font-size:14px;line-height:1.5;color:#8a8398;margin:0;">Die besten Clips werden in der Award-Show präsentiert.</p>
</div>
<div class="home-modal__clip-body" style="padding:24px 40px 32px;overflow-y:auto;display:flex;flex-direction:column;gap:16px;">
@@ -3,8 +3,10 @@ import { useRouter } from 'vue-router'
import CinematicStarLoader from '../CinematicStarLoader.vue'
import HomeCategoriesSection from './HomeCategoriesSection.vue'
import HomeHeroShell from './HomeHeroShell.vue'
import HomeExtrasSection from './HomeExtrasSection.vue'
import HomeLandingModals from './HomeLandingModals.vue'
import HomeParticipationSection from './HomeParticipationSection.vue'
import HomeStickyCountdownPill from './HomeStickyCountdownPill.vue'
import HomeSupportFooterSection from './HomeSupportFooterSection.vue'
import HomeTimelineSection from './HomeTimelineSection.vue'
import HomeWinnerShowcaseSection from './HomeWinnerShowcaseSection.vue'
@@ -50,6 +52,7 @@ const {
voteCount,
totalCats,
canSubmitVote,
savedVoteActive,
activeCatName,
phaseCardTitle,
phaseCardDescription,
@@ -89,6 +92,7 @@ const {
archiveYears,
selectedArchive,
winnerShowcase,
activeCat,
submitted,
submitting,
formError,
@@ -143,13 +147,13 @@ const {
} = useHomeLandingState()
const previewPhaseButtons = [
{ key: 'nomination', label: 'Nominierung', hint: 'Einreichen & Clips' },
{ key: 'nomination', label: 'Nominierung', hint: 'Links einreichen' },
{ key: 'voting', label: 'Voting', hint: 'Community stimmt ab' },
{ key: 'preparation', label: 'Aufbereitung', hint: 'Pause vor Show' },
{ key: 'show', label: 'Show', hint: 'Live-Finale' },
] as const
const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmit } = useHomeLandingViewEffects({
const { setRootEl, landingLoaderVisible, stickyCountdownVisible, handleClipSubmit } = useHomeLandingViewEffects({
router,
store,
authStore,
@@ -165,7 +169,6 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
preparationPhase,
completedPhase,
initializeHomeInteractions,
submitNomination,
submitClip,
})
</script>
@@ -186,6 +189,13 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
</transition>
<div :style="rootStyle" class="home-landing">
<HomeStickyCountdownPill
:show-countdown="showCountdown"
:visible="stickyCountdownVisible"
:show-phase="showPhase"
:public-stream-url="publicStreamUrl"
/>
<div v-if="isAdmin" class="home-demo-preview" aria-label="Demo Phasen-Vorschau">
<div class="home-demo-preview__label">
<span></span>
@@ -282,6 +292,8 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
:on-section-action="onSectionAction"
/>
<HomeExtrasSection />
<HomeSupportFooterSection
:community-social-links="communitySocialLinks"
:site-content="siteContent"
@@ -314,13 +326,15 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
:picker-title="pickerTitle"
:picker-subtitle="pickerSubtitle"
:cat-list="catList"
:active-cat-index="activeCat"
:active-cat-name="activeCatName"
:noms="noms"
:vote-count="voteCount"
:total-cats="totalCats"
:can-submit-vote="canSubmitVote"
:saved-vote-active="savedVoteActive"
:submit-vote="submitVote"
:submit-nomination="handleNominationSubmit"
:submit-nomination="submitNomination"
:submitting="submitting"
:on-clip-cat-change="onClipCatChange"
:cat-options="catOptions"
@@ -9,6 +9,7 @@ import type {
HomeSelectedArchive,
HomeSelectionOption,
} from './homeModalTypes'
import type { HomeNominationSubmitContext } from './homeLandingTypes'
import type { AuthSession } from '../../types/awards'
defineProps<{
@@ -31,13 +32,15 @@ defineProps<{
pickerTitle: string
pickerSubtitle: string
catList: HomeCategoryListItem[]
activeCatIndex: number
activeCatName: string
noms: HomeNomineeListItem[]
voteCount: number
totalCats: number
canSubmitVote: boolean
savedVoteActive: boolean
submitVote: () => Promise<void> | void
submitNomination: () => Promise<void> | void
submitNomination: (context: HomeNominationSubmitContext) => Promise<void> | void
submitting: boolean
onClipCatChange: (event: Event) => void
catOptions: HomeSelectionOption[]
@@ -100,11 +103,13 @@ defineProps<{
:picker-title="pickerTitle"
:picker-subtitle="pickerSubtitle"
:cat-list="catList"
:active-cat-index="activeCatIndex"
:active-cat-name="activeCatName"
:noms="noms"
:vote-count="voteCount"
:total-cats="totalCats"
:can-submit-vote="canSubmitVote"
:saved-vote-active="savedVoteActive"
:submit-vote="submitVote"
:submit-nomination="submitNomination"
:submitting="submitting"
@@ -0,0 +1,37 @@
<script setup lang="ts">
const props = defineProps<{
missingCategories: string[]
onBack: () => void
onConfirm: () => Promise<void> | void
submitting: boolean
submitLabel: string
hideActions?: boolean
}>()
</script>
<template>
<section class="home-missing-votes">
<p class="home-vote-picker__eyebrow">Pruefen</p>
<h4>Nicht alle Kategorien gewaehlt</h4>
<p>
In {{ props.missingCategories.length }} Kategorien fehlt noch eine Stimme. Du kannst trotzdem speichern
oder zurueckgehen und weitere Kategorien auswaehlen.
</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
</button>
<button
type="button"
class="home-wizard-footer__button home-wizard-footer__button--primary"
:disabled="props.submitting"
@click="props.onConfirm"
>
{{ props.submitting ? 'Speichert ...' : props.submitLabel }}
</button>
</div>
</section>
</template>
@@ -0,0 +1,46 @@
<script setup lang="ts">
export interface HomeNominationReviewItem {
categoryIndex: number
categoryName: string
links: string[]
}
const props = defineProps<{
items: HomeNominationReviewItem[]
totalLinks: number
totalCategories: number
onEditCategory: (index: number) => void
}>()
</script>
<template>
<section class="home-nomination-review">
<div class="home-nomination-review__hero">
<p class="home-vote-picker__eyebrow">Pruefen</p>
<h4>Nominierungen einreichen?</h4>
<p>
Du reichst {{ props.totalLinks }} Links in {{ props.items.length }} von
{{ props.totalCategories }} Kategorien ein. Leere Kategorien werden uebersprungen.
</p>
</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>
<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>
<button type="button" @click="props.onEditCategory(item.categoryIndex)">
Bearbeiten
</button>
</article>
</div>
<div v-else class="home-vote-picker__empty">
Noch keine Links eingetragen.
</div>
</section>
</template>
@@ -0,0 +1,59 @@
<script setup lang="ts">
const props = defineProps<{
categoryName: string
links: string[]
errors: string[]
visibleLinkCount: number
remainingLinkCount: number
onLinkInput: (index: number, value: string) => void
onAddLinkField: () => void
onRemoveLinkField: (index: number) => void
}>()
</script>
<template>
<section class="home-nomination-pane">
<header class="home-vote-picker__category-header">
<div>
<h4>{{ props.categoryName }}</h4>
</div>
</header>
<div class="home-nomination-pane__fields">
<label v-for="(_, index) in props.links.slice(0, props.visibleLinkCount)" :key="index" class="home-nomination-field">
<span class="home-nomination-field__header">
<span>{{ index === 0 ? 'Link 1' : `Link ${index + 1} optional` }}</span>
<button
v-if="index > 0"
type="button"
class="home-nomination-field__remove"
@click.prevent="props.onRemoveLinkField(index)"
>
Entfernen
</button>
</span>
<input
:value="props.links[index]"
type="url"
maxlength="300"
placeholder="https://plattform.de/dein-kanal"
:aria-invalid="Boolean(props.errors[index])"
@input="props.onLinkInput(index, ($event.target as HTMLInputElement).value)"
/>
<small v-if="props.errors[index]" class="home-nomination-field__error">{{ props.errors[index] }}</small>
<small v-else>Offizieller Kanal- oder Stream-Link.</small>
</label>
</div>
<button
v-if="props.remainingLinkCount > 0"
type="button"
class="home-nomination-pane__add-link"
@click="props.onAddLinkField"
>
<span>+ Weiteren Link hinzufuegen</span>
<small>Noch {{ props.remainingLinkCount }} moeglich</small>
</button>
</section>
</template>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import HomeStarField from './HomeStarField.vue'
const props = defineProps<{
sectionTitle: string
sectionText: string
@@ -27,7 +29,7 @@ const props = defineProps<{
<span style="flex:none;display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;border:2px solid #c9b6f0;font-family:'Outfit',sans-serif;font-weight:700;font-size:16px;color:#7c5fc8;">1</span>
<h3 style="margin:0;font-family:'Outfit',sans-serif;font-weight:700;font-size:19px;letter-spacing:1.5px;text-transform:uppercase;color:#5f44ad;">Nominieren</h3>
</div>
<p style="margin:0;font-size:15.5px;line-height:1.6;color:#7d7491;">Nominiere deine Favoriten in jeder Kategorie — pro Kategorie 3 Nominierungen. Du kannst auch Clips deiner Lieblingsmomente einsenden.</p>
<p style="margin:0;font-size:15.5px;line-height:1.6;color:#7d7491;">Nominiere deine Favoriten in jeder Kategorie — pro Kategorie bis zu 3 Stream- oder Kanal-Links.</p>
</div>
<svg data-step-arrow width="58" height="44" viewBox="0 0 58 44" fill="none" style="flex:none;margin-top:64px;"><path d="M3 12 C 22 4, 40 8, 50 26" stroke="#c2aef0" stroke-width="3" stroke-linecap="round"/><path d="M50 26 L 39 25 M50 26 L 49 14" stroke="#c2aef0" stroke-width="3" stroke-linecap="round"/></svg>
<div style="flex:1;">
@@ -51,10 +53,8 @@ const props = defineProps<{
</div>
<div class="home-cta-card" style="position:relative;overflow:hidden;border-radius:30px;padding:56px 40px;text-align:center;background:linear-gradient(135deg,#8b6cdb,#b78bff);box-shadow:0 30px 70px rgba(124,86,196,.4);">
<span style="position:absolute;top:24px;left:8%;font-size:22px;color:#fff;opacity:.6;animation:twinkle 3s ease-in-out infinite;">✦</span>
<span style="position:absolute;bottom:30px;right:12%;font-size:18px;color:#fff;opacity:.6;animation:twinkle 2.6s ease-in-out .5s infinite;">✧</span>
<span style="position:absolute;top:40%;right:6%;font-size:14px;color:#fff;opacity:.5;animation:twinkle 3.2s ease-in-out 1s infinite;">✦</span>
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(30px,3.8vw,44px);color:#fff;margin:0 0 14px;">{{ props.sectionTitle }}</h2>
<HomeStarField :count="13" variant="light" />
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(30px,3.8vw,44px);color:#fff;margin:0 0 14px;">{{ props.sectionTitle }}</h2>
<p style="font-size:18px;color:rgba(255,255,255,.92);max-width:540px;margin:0 auto 28px;">{{ props.sectionText }}</p>
<button
v-if="props.sectionActionDisabled"
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { ref } from 'vue'
type StarVariant = 'pastel' | 'light' | 'header'
const props = withDefaults(defineProps<{
count?: number
variant?: StarVariant
}>(), {
count: 12,
variant: 'light',
})
const palettes: Record<StarVariant, string[]> = {
pastel: ['#e7b13e', '#b79be8', '#f3a9cb', '#cdb6f0', '#ffb9d4'],
light: ['rgba(255,255,255,.62)', 'rgba(255,246,251,.54)', 'rgba(255,210,122,.5)', 'rgba(201,177,255,.58)'],
header: ['#ffd27a', '#ffb9d4', '#c9b1ff', '#fff6fb'],
}
function createStarPosition() {
return {
'--star-x': `${Math.round(4 + Math.random() * 92)}%`,
'--star-y': `${Math.round(8 + Math.random() * 84)}%`,
'--star-size': `${9 + Math.round(Math.random() * 12)}px`,
'--star-rotate': `${Math.round(Math.random() * 80 - 40)}deg`,
}
}
function createStarStyle(index: number) {
const palette = palettes[props.variant]
const alpha = props.variant === 'pastel' ? 0.85 : 0.62
return {
...createStarPosition(),
'--star-color': palette[index % palette.length],
'--star-alpha': String(alpha),
'--star-delay': `${(Math.random() * 8).toFixed(2)}s`,
'--star-duration': `${(8 + Math.random() * 5).toFixed(2)}s`,
}
}
const stars = ref(Array.from({ length: props.count }, (_, index) => {
const symbol = index % 3 === 1 ? '✧' : '✦'
return {
id: index,
symbol,
style: createStarStyle(index),
}
}))
function shuffleStar(index: number) {
stars.value[index].style = {
...stars.value[index].style,
...createStarPosition(),
}
}
</script>
<template>
<span class="home-star-field" aria-hidden="true">
<span
v-for="star in stars"
:key="star.id"
class="home-star-field__star"
:style="star.style"
@animationiteration="shuffleStar(star.id)"
>
{{ star.symbol }}
</span>
</span>
</template>
@@ -0,0 +1,37 @@
<template>
<div
v-if="showCountdown"
class="home-sticky-countdown"
:class="{ 'home-sticky-countdown--visible': visible }"
aria-live="polite"
>
<div class="home-sticky-countdown__inner">
<span class="home-sticky-countdown__dot" aria-hidden="true"></span>
<span data-dc-ref="stickyCountdownLabelRef" class="home-sticky-countdown__label">Finale startet in</span>
<span class="home-sticky-countdown__time" aria-label="Countdown bis zum Finale">
<span data-dc-ref="sbdRef">00</span><span class="home-sticky-countdown__unit">T</span>
<span data-dc-ref="sbhRef">00</span><span class="home-sticky-countdown__sep">:</span>
<span data-dc-ref="sbmRef">00</span><span class="home-sticky-countdown__sep">:</span>
<span data-dc-ref="sbsRef">00</span>
</span>
<a
v-if="showPhase"
:href="publicStreamUrl"
target="_blank"
rel="noopener noreferrer"
class="home-sticky-countdown__cta"
>
Zur Show
</a>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
showCountdown: boolean
visible: boolean
showPhase: boolean
publicStreamUrl: string
}>()
</script>
@@ -78,7 +78,7 @@ function isSafePublicUrl(value: string | null | undefined) {
<div style="position:relative;display:grid;grid-template-columns:1.62fr 1fr;gap:26px;align-items:stretch;" data-community-grid>
<div class="home-community-card" style="position:relative;overflow:visible;background:#f5f0fc;border:1px solid #e9e0f8;border-radius:28px;padding:46px 46px 42px;min-height:360px;">
<div class="home-community-card__content" style="position:relative;z-index:2;max-width:62%;">
<h2 style="margin:0 0 14px;font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(26px,3vw,36px);letter-spacing:.5px;color:#5f44ad;">COMMUNITY &amp; UPDATES</h2>
<h2 style="margin:0 0 14px;font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(26px,3vw,36px);letter-spacing:.5px;color:#5f44ad;">COMMUNITY &amp; UPDATES</h2>
<p style="font-size:16px;line-height:1.6;color:#7d7491;margin:0 0 26px;">Tritt unserer Community bei und verpasse keine News, Updates und Behind-the-Scenes!</p>
<div style="display:flex;flex-wrap:wrap;gap:12px;margin-bottom:26px;">
<a
@@ -117,7 +117,7 @@ function isSafePublicUrl(value: string | null | undefined) {
<img class="home-community-card__image" src="/assets/jayu-hero.png" alt="Jayuhime" style="position:absolute;z-index:1;right:-26px;bottom:0;height:430px;width:auto;pointer-events:none;filter:drop-shadow(0 18px 36px rgba(120,80,180,.22));" />
</div>
<div class="home-share-card" style="background:linear-gradient(160deg,#fdf6f6,#faf3fb);border:1px solid #f0e7f2;border-radius:28px;padding:46px 38px;display:flex;flex-direction:column;">
<h2 style="margin:0 0 14px;font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(24px,2.6vw,32px);letter-spacing:.5px;color:#5f44ad;">TEILE DIE AWARDS</h2>
<h2 style="margin:0 0 14px;font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(24px,2.6vw,32px);letter-spacing:.5px;color:#5f44ad;">TEILE DIE AWARDS</h2>
<p style="font-size:16px;line-height:1.6;color:#7d7491;margin:0 0 28px;">Supporte deine Favoriten und teile die Awards mit deinen Freunden!</p>
<div style="display:flex;flex-direction:column;gap:14px;margin-top:auto;">
<a href="#" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:15px 22px;border-radius:14px;background:#15131c;color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 10px 24px rgba(20,18,28,.2);" style-hover="transform:translateY(-2px);"><svg width="17" height="17" viewBox="0 0 24 24" fill="#fff"><path d="M18.9 1.6h3.5l-7.6 8.7L23.7 22h-7l-5.5-7.2L4.9 22H1.4l8.1-9.3L1 1.6h7.2l4.9 6.5 5.8-6.5zm-1.2 18.3h1.9L7.1 3.6H5z"/></svg>Auf X teilen</a>
@@ -132,7 +132,7 @@ function isSafePublicUrl(value: string | null | undefined) {
<section id="faq" class="home-section" style="max-width:880px;margin:0 auto;padding:90px 24px;">
<div style="text-align:center;margin-bottom:46px;">
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent,#8b6cdb);margin-bottom:12px;"> Häufige Fragen</div>
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:-.4px;color:var(--ink,#3f3556);">FAQ</h2>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:0;color:var(--ink,#3f3556);">FAQ</h2>
<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;">
@@ -184,7 +184,7 @@ function isSafePublicUrl(value: string | null | undefined) {
<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:'Cormorant Garamond',serif;font-weight:700;font-size:26px;margin:0;color:#3f3556;">{{ activeFooterLink.label }}</h2>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:26px;margin:0;color:#3f3556;">{{ activeFooterLink.label }}</h2>
</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>
</div>
@@ -2,7 +2,7 @@
<section id="ablauf" class="home-section" style="max-width:1200px;margin:0 auto;padding:90px 24px 70px;">
<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:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:-.4px;color:#3f3556;">In vier Schritten auf die Bühne</h2>
<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>
</div>
@@ -23,7 +23,7 @@
<h3 style="font-family:'Outfit',sans-serif;font-weight:700;font-size:21px;margin:0 0 6px;color:#3f3556;">Nominierung</h3>
<div style="font-size:13px;font-weight:600;color:#a99fc0;margin-bottom:12px;">{{ formatTimelineRange('nomination') }}</div>
<p style="font-size:14.5px;line-height:1.6;color:#7d7491;margin:0 0 16px;">Die Community reicht ihre Favoriten ein pro Kategorie bis zu drei Nominierungen.</p>
<button type="button" @click="openNominate" style="display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#f1ecfb;color:#7355c8;border:none;cursor:pointer;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;" style-hover="background:#e8e0f9;">{{ nominationPhase ? 'Jetzt nominieren und Clips einsenden' : 'Nominierungen ansehen' }}</button>
<button type="button" @click="openNominate" style="display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#f1ecfb;color:#7355c8;border:none;cursor:pointer;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;" style-hover="background:#e8e0f9;">{{ nominationPhase ? 'Jetzt nominieren' : 'Nominierungen ansehen' }}</button>
</div>
</div>
@@ -1,16 +1,115 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import HomeMissingVotesConfirm from './HomeMissingVotesConfirm.vue'
import HomeWizardFooter from './HomeWizardFooter.vue'
import type { HomeCategoryListItem, HomeNomineeListItem } from './homeModalTypes'
const props = defineProps<{
catList: HomeCategoryListItem[]
activeCatIndex: number
activeCatName: string
noms: HomeNomineeListItem[]
voteCount: number
totalCats: number
canSubmitVote: boolean
savedVoteActive: boolean
submitVote: () => Promise<void> | void
submitting: boolean
readonlyMode?: boolean
}>()
const confirmMissingVotes = ref(false)
const contentRef = ref<HTMLElement | null>(null)
const activeCategoryRef = ref<HTMLElement | null>(null)
const missingVoteCategoryNames = computed(() => props.catList.filter((category) => !category.done).map((category) => category.name))
const submitLabel = computed(() => props.savedVoteActive ? 'Änderungen speichern' : 'Stimmen absenden')
const isLastCategory = computed(() => props.activeCatIndex >= props.catList.length - 1)
const footerPrimaryLabel = computed(() => {
if (!isLastCategory.value) return 'Weiter'
if (!props.canSubmitVote) return 'Erst Favorit:in wählen'
return 'Stimmen prüfen'
})
const footerHelperText = computed(() =>
confirmMissingVotes.value
? 'Pruefe 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.',
)
const footerActions = computed(() =>
props.readonlyMode
? [
{
label: 'Zurueck',
tone: 'secondary' as const,
disabled: props.activeCatIndex === 0,
onClick: () => props.catList[Math.max(0, props.activeCatIndex - 1)]?.onClick(),
},
{
label: 'Weiter',
disabled: props.activeCatIndex >= props.catList.length - 1,
onClick: () => props.catList[Math.min(props.catList.length - 1, props.activeCatIndex + 1)]?.onClick(),
},
]
:
confirmMissingVotes.value
? [
{ label: 'Zurueck zum Voting', tone: 'secondary' as const, disabled: props.submitting, onClick: hideMissingVoteConfirm },
{ label: props.submitting ? 'Speichert ...' : submitLabel.value, disabled: props.submitting, onClick: submitConfirmedVote },
]
: [
{
label: 'Zurueck',
tone: 'secondary' as const,
disabled: props.activeCatIndex === 0,
onClick: () => props.catList[Math.max(0, props.activeCatIndex - 1)]?.onClick(),
},
{
label: props.submitting ? 'Speichert ...' : footerPrimaryLabel.value,
disabled: props.submitting || (isLastCategory.value && !props.canSubmitVote),
onClick: goToNextCategory,
},
],
)
function hideMissingVoteConfirm() {
confirmMissingVotes.value = false
}
watch(
() => props.activeCatIndex,
() => {
confirmMissingVotes.value = false
contentRef.value?.scrollTo({ top: 0 })
activeCategoryRef.value?.scrollIntoView({ block: 'nearest', inline: 'center' })
},
)
function setCategoryButtonRef(element: unknown, categoryIndex: number) {
if (categoryIndex === props.activeCatIndex) {
activeCategoryRef.value = element instanceof HTMLElement ? element : null
}
}
function goToNextCategory() {
if (isLastCategory.value) {
if (!props.canSubmitVote) return
confirmMissingVotes.value = missingVoteCategoryNames.value.length > 0
if (!confirmMissingVotes.value) {
void props.submitVote()
}
return
}
props.catList[props.activeCatIndex + 1]?.onClick()
}
async function submitConfirmedVote() {
await props.submitVote()
}
</script>
<template>
@@ -18,35 +117,94 @@ const props = defineProps<{
<aside class="home-vote-picker__rail">
<div class="home-vote-picker__rail-label">Kategorien</div>
<template v-for="(cat, __idx) in props.catList" :key="cat.idx ?? __idx">
<button class="home-vote-picker__category-button" @click="cat.onClick" :style="cat.rowStyle">
<button
:ref="(element) => setCategoryButtonRef(element, __idx)"
class="home-vote-picker__category-button"
@click="cat.onClick"
:style="cat.rowStyle"
>
<span :style="cat.iconStyle">{{ cat.icon }}</span>
<span>{{ cat.name }}</span>
<span :style="cat.checkStyle"></span>
<span v-if="!props.readonlyMode" :style="cat.checkStyle"></span>
</button>
</template>
</aside>
<section class="home-vote-picker__content">
<section ref="contentRef" class="home-vote-picker__content">
<HomeMissingVotesConfirm
v-if="confirmMissingVotes"
:missing-categories="missingVoteCategoryNames"
:on-back="hideMissingVoteConfirm"
:on-confirm="submitConfirmedVote"
:submitting="props.submitting"
:submit-label="submitLabel"
hide-actions
/>
<template v-else>
<header class="home-vote-picker__category-header">
<div>
<p class="home-vote-picker__eyebrow">Kategorie</p>
<h4>{{ props.activeCatName }}</h4>
</div>
<span class="home-vote-picker__hint">Nominee ansehen, Clip prüfen, Favorit:in wählen.</span>
</header>
<p v-if="props.savedVoteActive && !props.readonlyMode" class="home-vote-picker__edit-banner">
Du bearbeitest dein gespeichertes Voting. Speichern ersetzt deine bisherige Auswahl.
</p>
<div class="home-vote-picker__cards">
<template v-for="(nom, __idx) in props.noms" :key="nom.idx ?? __idx">
<article class="home-vote-card" :class="{ 'home-vote-card--selected': nom.selected, 'home-vote-card--missing-clip': !nom.hasClip }">
<div class="home-vote-card__identity">
<div class="home-vote-card__avatar">{{ nom.initials || '✦' }}</div>
<div class="home-vote-card__name-block">
<div class="home-vote-card__name-row">
<h5>{{ nom.name }}</h5>
<span>{{ nom.platform }}</span>
<a
v-if="props.readonlyMode && nom.url"
class="home-nomination-summary-card home-nomination-summary-card--clickable"
:href="nom.url"
target="_blank"
rel="noopener noreferrer"
referrerpolicy="no-referrer"
>
<div class="home-nomination-summary-card__avatar">{{ nom.initials || '✦' }}</div>
<div class="home-nomination-summary-card__body">
<h5>{{ nom.name }}</h5>
<span>{{ nom.handle || nom.platform }}</span>
</div>
</a>
<article v-else-if="props.readonlyMode" class="home-nomination-summary-card">
<div class="home-nomination-summary-card__avatar">{{ nom.initials || '✦' }}</div>
<div class="home-nomination-summary-card__body">
<h5>{{ nom.name }}</h5>
<span>{{ nom.handle || nom.platform }}</span>
</div>
</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>
</div>
<div class="home-vote-card__top">
<div class="home-vote-card__identity">
<div class="home-vote-card__avatar">{{ nom.initials || '✦' }}</div>
<div class="home-vote-card__name-block">
<div class="home-vote-card__name-row">
<h5>{{ nom.name }}</h5>
<span>{{ nom.platform }}</span>
</div>
<p>{{ nom.handle }}</p>
</div>
<p>{{ nom.handle }}</p>
</div>
<template v-if="nom.showPick">
<button class="home-vote-card__pick" :class="{ 'home-vote-card__pick--selected': nom.selected }" @click="nom.onPick">
{{ nom.btnLabel }}
</button>
</template>
</div>
<div class="home-vote-card__clip" @click.stop>
@@ -80,11 +238,6 @@ const props = defineProps<{
</p>
</div>
<template v-if="nom.showPick">
<button class="home-vote-card__pick" :class="{ 'home-vote-card__pick--selected': nom.selected }" @click="nom.onPick">
{{ nom.btnLabel }}
</button>
</template>
</article>
</template>
@@ -92,17 +245,13 @@ const props = defineProps<{
Für diese Kategorie sind noch keine Kandidat:innen freigegeben.
</div>
</div>
</template>
</section>
</div>
<div class="home-modal__vote-footer home-vote-picker__footer">
<div><span>{{ props.voteCount }}</span> / {{ props.totalCats }} Kategorien gewählt</div>
<button
@click="props.submitVote"
:disabled="props.submitting || !props.canSubmitVote"
:class="{ 'home-vote-picker__submit--disabled': !props.canSubmitVote }"
>
{{ props.submitting ? 'Speichert ...' : props.canSubmitVote ? 'Stimmen absenden ✩' : 'Erst Favorit:in wählen' }}
</button>
</div>
<HomeWizardFooter
:progress-text="props.readonlyMode ? `${props.totalCats} Kategorien` : `${props.voteCount} / ${props.totalCats} Kategorien gewählt`"
:helper-text="footerHelperText"
:actions="footerActions"
/>
</template>
@@ -1,26 +1,20 @@
<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);">
<span style="position:absolute;top:22px;left:6%;font-size:16px;color:rgba(255,255,255,.35);animation:twinkle 3s ease-in-out infinite;"></span>
<span style="position:absolute;top:18px;left:38%;font-size:12px;color:rgba(255,255,255,.28);animation:twinkle 2.6s ease-in-out .4s infinite;"></span>
<span style="position:absolute;top:30px;right:22%;font-size:13px;color:rgba(255,255,255,.3);animation:twinkle 3.3s ease-in-out 1s infinite;"></span>
<span style="position:absolute;top:16px;right:7%;font-size:10px;color:rgba(255,255,255,.25);animation:twinkle 2.8s ease-in-out .7s infinite;"></span>
<span style="position:absolute;bottom:24px;left:14%;font-size:14px;color:rgba(255,255,255,.3);animation:twinkle 3.1s ease-in-out .3s infinite;"></span>
<span style="position:absolute;bottom:20px;right:34%;font-size:11px;color:rgba(255,255,255,.25);animation:twinkle 2.5s ease-in-out .9s infinite;"></span>
<span style="position:absolute;bottom:28px;right:8%;font-size:15px;color:rgba(255,255,255,.3);animation:twinkle 3.4s ease-in-out 1.2s infinite;"></span>
<HomeStarField :count="17" variant="light" />
<div class="home-section" style="max-width:1200px;margin:0 auto;padding:90px 24px;">
<div style="display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:46px;">
<div>
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#ff5fa2;margin-bottom:12px;">&#9733; Die Stars</div>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0;letter-spacing:-.4px;color:#fff6fb;">Gewinner {{ selectedArchive.year }}</h2>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0;letter-spacing:0;color:#fff6fb;">Gewinner {{ selectedArchive.year }}</h2>
</div>
<button @click="onOpenArchive" style="display:inline-flex;align-items:center;gap:8px;padding:12px 22px;border-radius:999px;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.12);color:#fff6fb;font-weight:600;font-size:15px;cursor:pointer;" style-hover="transform:translateY(-2px);background:rgba(255,255,255,.09);">Archiv ansehen &#8594;</button>
</div>
<div 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);">
<div 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:360px;border-radius:24px;overflow:hidden;background:#22123a;border:1px solid rgba(255,255,255,.12);">
<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:16px;margin-top:28px;margin-bottom:18px;min-height:108px;">
<div style="flex:none;width:84px;height:84px;border-radius:24px;background:linear-gradient(135deg,#ffd27a,#e7b13e);display:flex;align-items:center;justify-content:center;color:#2f1b00;font-family:'Fredoka',sans-serif;font-size:28px;font-weight:700;">{{ initialsFor(winner.name) }}</div>
<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>
@@ -29,9 +23,29 @@
</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:0 24px 24px;background:#1b0d2d;">
<div style="font-size:11px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;color:#ffb9d4;margin-bottom:10px;">Archivierter Gewinner</div>
<div 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 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>
@@ -41,12 +55,20 @@
</template>
<script setup lang="ts">
import HomeStarField from './HomeStarField.vue'
type WinnerCard = {
category: string
name: string
handle: string
platform: string
url: string
clipUrl: string | null
clipTitle: string
clipPlatform: string
clipEmbedUrl: string | null
clipEmbedTitle: string
hasClip: boolean
}
defineProps<{
@@ -0,0 +1,36 @@
<script setup lang="ts">
export interface HomeWizardFooterAction {
label: string
disabled?: boolean
tone?: 'primary' | 'secondary' | 'danger'
onClick: () => Promise<void> | void
}
const props = defineProps<{
progressText: string
helperText: string
actions: HomeWizardFooterAction[]
}>()
</script>
<template>
<footer class="home-wizard-footer">
<div class="home-wizard-footer__copy">
<strong>{{ props.progressText }}</strong>
<span>{{ props.helperText }}</span>
</div>
<div class="home-wizard-footer__actions">
<button
v-for="action in props.actions"
:key="action.label"
type="button"
class="home-wizard-footer__button"
:class="`home-wizard-footer__button--${action.tone ?? 'primary'}`"
:disabled="action.disabled"
@click="action.onClick"
>
{{ action.label }}
</button>
</div>
</footer>
</template>
File diff suppressed because it is too large Load Diff
@@ -13,8 +13,10 @@ export interface HomeClipSubmitContext {
}
export interface HomeNominationSubmitContext {
categoryIndex: number
streamUrl: string
entries: Array<{
categoryIndex: number
streamUrls: string[]
}>
}
export interface HomeDisplayCategory {
@@ -12,6 +12,7 @@ export interface HomeCategoryListItem {
export interface HomeNomineeListItem {
name: string
handle: string
url: string | null
platform: string
initials: string
clipUrl: string | null
@@ -48,6 +49,12 @@ export interface HomeArchiveWinnerItem {
handle: string
platform: string
url: string
clipUrl: string | null
clipTitle: string
clipPlatform: string
clipEmbedUrl: string | null
clipEmbedTitle: string
hasClip: boolean
}
export interface HomeSelectedArchive {
@@ -1,5 +1,6 @@
import { computed, type Ref } from 'vue'
import { buildClipEmbed } from '../../lib/clipEmbeds'
import { useAwardsStore } from '../../stores/awards'
type AwardsStore = ReturnType<typeof useAwardsStore>
@@ -37,13 +38,7 @@ export function useHomeArchivePresentation(store: AwardsStore, archiveYear: Ref<
const selectedArchive = computed(() => ({
year: store.archive.year,
winners: store.archive.items.map((winner) => ({
category: winner.category,
name: winner.winnerName,
handle: winner.winnerSlug,
platform: winner.winnerPlatform,
url: winner.winnerUrl,
})),
winners: store.archive.items.map((winner) => toArchiveWinnerItem(winner)),
}))
const winnerShowcase = computed(() => selectedArchive.value.winners.slice(0, 4))
@@ -54,6 +49,36 @@ export function useHomeArchivePresentation(store: AwardsStore, archiveYear: Ref<
: "display:flex;align-items:center;justify-content:space-between;gap:10px;padding:14px 16px;border-radius:16px;border:1px solid rgba(255,210,122,.16);background:linear-gradient(135deg,#2a1842,#3a2168);color:#fff6fb;font-family:'Outfit',sans-serif;font-size:15px;font-weight:700;cursor:pointer;text-align:left;box-shadow:0 10px 24px rgba(20,8,40,.14);opacity:.9;"
}
function toArchiveWinnerItem(winner: {
category: string
winnerName: string
winnerSlug: string
winnerPlatform: string
winnerUrl: string
clipUrl?: string | null
clipTitle?: string | null
clipPlatform?: string | null
clipEmbedStatus?: string | null
}) {
const clipUrl = winner.clipUrl?.trim() || null
const clipTitle = winner.clipTitle?.trim() || 'Gewinner-Clip ansehen'
const clipPlatform = clipUrl ? winner.clipPlatform?.trim() || winnerPlatformLabel(clipUrl) : 'Kein Clip'
const clipEmbed = clipUrl && winner.clipEmbedStatus !== 'link_only' ? buildClipEmbed(clipUrl) : null
return {
category: winner.category,
name: winner.winnerName,
handle: winner.winnerSlug,
platform: winner.winnerPlatform,
url: winner.winnerUrl,
clipUrl,
clipTitle,
clipPlatform,
clipEmbedUrl: clipEmbed?.src ?? null,
clipEmbedTitle: clipEmbed?.title ?? clipTitle,
hasClip: Boolean(clipUrl),
}
}
function winnerPlatformKey(url: string) {
const normalized = url.toLowerCase()
if (normalized.includes('twitch.tv')) return 'twitch'
@@ -120,10 +120,11 @@ export function useHomeModalCandidatePresentation(params: {
const clipUrl = candidate.clipUrl?.trim() || null
const clipTitle = candidate.clipTitle?.trim() || 'Highlight-Clip ansehen'
const clipPlatform = clipUrl ? candidate.clipPlatform?.trim() || candidate.platform : 'Clip fehlt'
const clipEmbed = clipUrl ? buildClipEmbed(clipUrl) : null
const clipEmbed = clipUrl && candidate.clipEmbedStatus !== 'link_only' ? buildClipEmbed(clipUrl) : null
return {
name: candidate.displayName,
handle: candidate.channelSlug,
url: resolveCandidateUrl(candidate.channelUrl, candidate.channelSlug),
platform: candidate.platform,
initials: initialsFor(candidate.displayName),
clipUrl,
@@ -138,7 +139,7 @@ export function useHomeModalCandidatePresentation(params: {
onPick: () => cat && pickNominee(cat.id, idx),
cardStyle: `display:flex;align-items:center;justify-content:space-between;gap:12px;padding:13px 15px;border-radius:13px;transition:all .15s;border:1.5px solid ${selected ? '#8b6cdb;background:#f6f1fd;' : '#ece4f6;background:#fff;'}`,
btnStyle: "flex:none;white-space:nowrap;padding:8px 16px;border-radius:9px;border:none;cursor:pointer;font-family:'Outfit',sans-serif;font-weight:600;font-size:13px;transition:all .15s;" + (selected ? 'background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;' : 'background:#f1ecfb;color:#8b6cdb;'),
btnLabel: selected ? '✓ Gewählt' : 'Auswählen',
btnLabel: selected ? 'Auswahl entfernen' : 'Für Clip voten',
}
})
})
@@ -149,6 +150,21 @@ export function useHomeModalCandidatePresentation(params: {
}
}
function resolveCandidateUrl(channelUrl: string | null | undefined, channelSlug: string) {
const url = channelUrl?.trim() || channelSlug.trim()
if (!isHttpUrl(url) || url === '#') return null
return url
}
function isHttpUrl(value: string) {
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
function formatDateLabel(value: string) {
if (!value) return 'Noch nicht terminiert'
const date = new Date(`${value}T00:00:00`)
@@ -1,4 +1,4 @@
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
@@ -21,6 +21,8 @@ export function useHomeLandingState() {
const activeCat = ref(0)
const previewPhase = ref<HomePreviewPhase>('voting')
const clipSubmissionsEnabled = computed(() => store.overview.featureFlags.clipSubmissionsEnabled)
const clipSubmissionDisabledMessage = computed(() => store.overview.featureFlags.clipSubmissionDisabledMessage)
const {
role,
@@ -155,6 +157,7 @@ export function useHomeLandingState() {
voteCount,
totalCats,
canSubmitVote,
savedVoteActive,
activeCategory,
activeCatName,
pickerTitle,
@@ -230,7 +233,7 @@ export function useHomeLandingState() {
}
function openClipForPhase(event?: Event) {
if (!nominationPhase.value) {
if (!nominationPhase.value || !clipSubmissionsEnabled.value) {
event?.preventDefault()
return
}
@@ -238,8 +241,8 @@ export function useHomeLandingState() {
openClip(event)
}
watch(previewPhase, (phase) => {
if ((phase !== 'voting' && modal.value === 'vote') || (phase !== 'nomination' && modal.value === 'clip')) {
watch([previewPhase, clipSubmissionsEnabled], ([phase, clipsEnabled]) => {
if ((phase !== 'voting' && modal.value === 'vote') || ((phase !== 'nomination' || !clipsEnabled) && modal.value === 'clip')) {
closeModal()
}
})
@@ -282,6 +285,7 @@ export function useHomeLandingState() {
voteCount,
totalCats,
canSubmitVote,
savedVoteActive,
activeCatName,
phaseCardTitle,
phaseCardDescription,
@@ -318,6 +322,8 @@ export function useHomeLandingState() {
catOptions,
canSubmitClip,
clipSubmitStyle,
clipSubmissionsEnabled,
clipSubmissionDisabledMessage,
archiveYears,
selectedArchive,
winnerShowcase,
@@ -3,7 +3,6 @@ import type { Router } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { useAwardsStore } from '../../stores/awards'
import type { HomeNominationSubmitContext } from './homeLandingTypes'
type AuthStore = ReturnType<typeof useAuthStore>
type AwardsStore = ReturnType<typeof useAwardsStore>
@@ -38,7 +37,6 @@ interface UseHomeLandingViewEffectsParams {
preparationPhase: Readonly<Ref<boolean>>
completedPhase: Readonly<Ref<boolean>>
initializeHomeInteractions: () => Promise<void>
submitNomination: (nominationContext: HomeNominationSubmitContext) => Promise<void>
submitClip: (clipContext: { clipUrl: string; selectedNomineeQuery: string; description: string }) => Promise<void>
}
@@ -59,14 +57,12 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
preparationPhase,
completedPhase,
initializeHomeInteractions,
submitNomination,
submitClip,
} = params
const rootEl = ref<HTMLElement | null>(null)
const landingLoaderVisible = ref(true)
const nominationCatEl = ref<HTMLSelectElement | null>(null)
const nominationStreamUrlEl = ref<HTMLInputElement | null>(null)
const stickyCountdownVisible = ref(false)
const clipUrlEl = ref<HTMLInputElement | null>(null)
const clipNomSearchEl = ref<HTMLInputElement | null>(null)
const clipDescEl = ref<HTMLTextAreaElement | null>(null)
@@ -81,6 +77,11 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
bhEl: ref<HTMLElement | null>(null),
bmEl: ref<HTMLElement | null>(null),
bsEl: ref<HTMLElement | null>(null),
stickyLabelEl: ref<HTMLElement | null>(null),
sbdEl: ref<HTMLElement | null>(null),
sbhEl: ref<HTMLElement | null>(null),
sbmEl: ref<HTMLElement | null>(null),
sbsEl: ref<HTMLElement | null>(null),
}
let timer: ReturnType<typeof setInterval> | null = null
let landingLoaderTimer: ReturnType<typeof setTimeout> | null = null
@@ -97,13 +98,6 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
})
}
function handleNominationSubmit() {
return submitNomination({
categoryIndex: Number.parseInt(nominationCatEl.value?.value || '0', 10) || 0,
streamUrl: nominationStreamUrlEl.value?.value.trim() ?? '',
})
}
function assignDomRefs() {
const root = rootEl.value
if (!root) return
@@ -117,8 +111,11 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
countdownRefs.bhEl.value = root.querySelector('[data-dc-ref="bhRef"]')
countdownRefs.bmEl.value = root.querySelector('[data-dc-ref="bmRef"]')
countdownRefs.bsEl.value = root.querySelector('[data-dc-ref="bsRef"]')
nominationCatEl.value = root.querySelector('[data-dc-ref="nominationCatRef"]')
nominationStreamUrlEl.value = root.querySelector('[data-dc-ref="nominationStreamUrlRef"]')
countdownRefs.stickyLabelEl.value = root.querySelector('[data-dc-ref="stickyCountdownLabelRef"]')
countdownRefs.sbdEl.value = root.querySelector('[data-dc-ref="sbdRef"]')
countdownRefs.sbhEl.value = root.querySelector('[data-dc-ref="sbhRef"]')
countdownRefs.sbmEl.value = root.querySelector('[data-dc-ref="sbmRef"]')
countdownRefs.sbsEl.value = root.querySelector('[data-dc-ref="sbsRef"]')
clipUrlEl.value = root.querySelector('[data-dc-ref="clipUrlRef"]')
clipNomSearchEl.value = root.querySelector('[data-dc-ref="clipNomSearchRef"]')
clipDescEl.value = root.querySelector('[data-dc-ref="clipDescRef"]')
@@ -130,18 +127,30 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
root.querySelectorAll<HTMLElement>('[style-hover]').forEach((element) => {
if (element.dataset.hoverBound === '1') return
element.dataset.hoverBound = '1'
const base = element.getAttribute('style') ?? ''
const hover = element.getAttribute('style-hover') ?? ''
element.addEventListener('mouseenter', () => { element.setAttribute('style', `${base}${hover}`) })
element.addEventListener('mouseleave', () => { element.setAttribute('style', base) })
element.addEventListener('mouseenter', () => {
const base = element.getAttribute('style') ?? ''
element.dataset.hoverBaseStyle = base
element.setAttribute('style', `${base}${hover}`)
})
element.addEventListener('mouseleave', () => {
element.setAttribute('style', element.dataset.hoverBaseStyle ?? '')
delete element.dataset.hoverBaseStyle
})
})
root.querySelectorAll<HTMLElement>('[style-focus]').forEach((element) => {
if (element.dataset.focusBound === '1') return
element.dataset.focusBound = '1'
const base = element.getAttribute('style') ?? ''
const focus = element.getAttribute('style-focus') ?? ''
element.addEventListener('focus', () => { element.setAttribute('style', `${base}${focus}`) })
element.addEventListener('blur', () => { element.setAttribute('style', base) })
element.addEventListener('focus', () => {
const base = element.getAttribute('style') ?? ''
element.dataset.focusBaseStyle = base
element.setAttribute('style', `${base}${focus}`)
})
element.addEventListener('blur', () => {
element.setAttribute('style', element.dataset.focusBaseStyle ?? '')
delete element.dataset.focusBaseStyle
})
})
}
@@ -170,6 +179,26 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
setText(countdownRefs.bhEl.value, pad(showCountdown.h))
setText(countdownRefs.bmEl.value, pad(showCountdown.m))
setText(countdownRefs.bsEl.value, pad(showCountdown.s))
setText(countdownRefs.stickyLabelEl.value, showCompleted ? 'Award abgeschlossen' : showStarted ? 'Stream läuft seit' : 'Finale startet in')
setText(countdownRefs.sbdEl.value, pad(showCountdown.d))
setText(countdownRefs.sbhEl.value, pad(showCountdown.h))
setText(countdownRefs.sbmEl.value, pad(showCountdown.m))
setText(countdownRefs.sbsEl.value, pad(showCountdown.s))
}
function updateStickyCountdownVisibility() {
if (modalOpen.value || accountModalOpen.value || privacyModalOpen.value || archiveModalOpen.value) {
stickyCountdownVisible.value = false
return
}
const hero = rootEl.value?.querySelector('.home-hero')
if (!(hero instanceof HTMLElement)) {
stickyCountdownVisible.value = false
return
}
stickyCountdownVisible.value = hero.getBoundingClientRect().bottom <= 0
}
function resolveSelectedPhaseKey(): HomeTimelineKey {
@@ -183,6 +212,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
watch([modalOpen, accountModalOpen, privacyModalOpen, archiveModalOpen], () => {
document.body.style.overflow = modalOpen.value || accountModalOpen.value || privacyModalOpen.value || archiveModalOpen.value ? 'hidden' : ''
nextTick(setupDom)
updateStickyCountdownVisibility()
})
watch([submitted, streamLive, archiveYear], () => {
@@ -204,6 +234,9 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
await initializeHomeInteractions()
setupDom()
tick()
updateStickyCountdownVisibility()
window.addEventListener('scroll', updateStickyCountdownVisibility, { passive: true })
window.addEventListener('resize', updateStickyCountdownVisibility)
timer = setInterval(tick, 1000)
landingLoaderTimer = setTimeout(() => {
landingLoaderVisible.value = false
@@ -212,6 +245,8 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
onBeforeUnmount(() => {
document.body.style.overflow = ''
window.removeEventListener('scroll', updateStickyCountdownVisibility)
window.removeEventListener('resize', updateStickyCountdownVisibility)
if (timer) clearInterval(timer)
if (landingLoaderTimer) clearTimeout(landingLoaderTimer)
})
@@ -219,7 +254,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
return {
setRootEl,
landingLoaderVisible,
handleNominationSubmit,
stickyCountdownVisible,
handleClipSubmit,
}
}
@@ -21,12 +21,15 @@ export function useHomeParticipationActions(params: {
openModal: (kind: HomeInteractionModalKind) => void
closeAccountModal: () => void
votes: Ref<Record<string, number>>
savedVoteCount: Ref<number>
submitted: Ref<boolean>
submitting: Ref<boolean>
formError: Ref<string>
successKind: Ref<null | HomeSuccessKind>
clipCatIdx: Ref<number>
clipDsgvo: Ref<boolean>
clipSubmissionsEnabled: ComputedRef<boolean>
clipSubmissionDisabledMessage: ComputedRef<string>
}) {
const {
store,
@@ -43,12 +46,15 @@ export function useHomeParticipationActions(params: {
openModal,
closeAccountModal,
votes,
savedVoteCount,
submitted,
submitting,
formError,
successKind,
clipCatIdx,
clipDsgvo,
clipSubmissionsEnabled,
clipSubmissionDisabledMessage,
} = params
const {
@@ -64,6 +70,7 @@ export function useHomeParticipationActions(params: {
routerPush,
displayCategories,
votes,
savedVoteCount,
formError,
deleteConfirm,
accountActionError,
@@ -85,6 +92,7 @@ export function useHomeParticipationActions(params: {
activeCat,
previewPhase,
votes,
savedVoteCount,
submitted,
submitting,
formError,
@@ -94,6 +102,8 @@ export function useHomeParticipationActions(params: {
ensureViewerSession,
loadMyParticipation,
fallbackCreatorName: twitchUser,
clipSubmissionsEnabled,
clipSubmissionDisabledMessage,
})
async function initializeHomeInteractions() {
@@ -10,10 +10,12 @@ export function useHomeParticipationPresentation(params: {
modal: Ref<null | HomeInteractionModalKind>
previewPhase: Ref<HomePreviewPhase>
votes: Ref<Record<string, number>>
savedVoteCount: Ref<number>
submitted: Ref<boolean>
successKind: Ref<null | HomeSuccessKind>
clipCatIdx: Ref<number>
clipDsgvo: Ref<boolean>
clipSubmissionsEnabled: ComputedRef<boolean>
}) {
const {
store,
@@ -22,25 +24,28 @@ export function useHomeParticipationPresentation(params: {
modal,
previewPhase,
votes,
savedVoteCount,
submitted,
successKind,
clipCatIdx,
clipDsgvo,
clipSubmissionsEnabled,
} = params
const notSubmitted = computed(() => !submitted.value)
const voteCount = computed(() => Object.keys(votes.value).length)
const totalCats = computed(() => displayCategories.value.length)
const canSubmitVote = computed(() => previewPhase.value === 'voting' && voteCount.value > 0)
const savedVoteActive = computed(() => savedVoteCount.value > 0)
const activeCategory = computed(() => displayCategories.value[activeCat.value] ?? displayCategories.value[0] ?? null)
const activeCatName = computed(() => activeCategory.value?.name ?? '')
const pickerTitle = computed(() => (modal.value === 'nominate' && previewPhase.value === 'nomination' ? 'Streamer nominieren' : modal.value === 'nominate' ? 'Eingegangene Nominierungen' : 'Deine Stimme zählt'))
const pickerSubtitle = computed(() => (modal.value === 'nominate' && previewPhase.value === 'nomination' ? 'Reiche den offiziellen Stream- oder Kanal-Link ein. Den Anzeigenamen vergibt das Team im Review.' : modal.value === 'nominate' ? 'Die Nominierungsphase ist abgeschlossen — hier sind alle eingereichten Kandidat:innen.' : 'Wähle pro Kategorie deine:n Favorit:in. Eine Stimme pro Kategorie.'))
const successTitle = computed(() => successKind.value === 'nomination' ? 'Nominierung eingereicht ✦' : successKind.value === 'clip' ? 'Clip eingereicht ✦' : successKind.value === 'show' ? 'Erinnerung aktiviert ✦' : 'Stimme gespeichert ✩')
const successText = computed(() => successKind.value === 'nomination' ? 'Danke! Der Stream-Link wurde gespeichert und landet im Admin-Review.' : successKind.value === 'clip' ? 'Danke! Dein Clip wurde im Backend gespeichert und wird vom Team geprüft.' : successKind.value === 'show' ? `Wir erinnern dich rechtzeitig vor der Award-Show am ${formatShowDate(store)}.` : 'Danke fürs Abstimmen! Deine Auswahl wurde im Backend gespeichert.')
const successText = computed(() => successKind.value === 'nomination' ? 'Danke! Deine Links wurden gespeichert und landen im Admin-Review.' : successKind.value === 'clip' ? 'Danke! Dein Clip wurde im Backend gespeichert und wird vom Team geprüft.' : successKind.value === 'show' ? `Wir erinnern dich rechtzeitig vor der Award-Show am ${formatShowDate(store)}.` : savedVoteActive.value ? 'Danke! Deine geänderte Auswahl wurde gespeichert.' : 'Danke fürs Abstimmen! Deine Auswahl wurde im Backend gespeichert.')
const clipNomOptions = computed(() => (displayCategories.value[clipCatIdx.value]?.candidates ?? []).map((candidate, index) => ({ id: index, label: `${candidate.displayName}` })))
const catOptions = computed(() => displayCategories.value.map((category, index) => ({ id: index, label: `${category.icon} ${category.name}` })))
const canSubmitClip = computed(() => previewPhase.value === 'nomination' && clipDsgvo.value)
const canSubmitClip = computed(() => clipSubmissionsEnabled.value && previewPhase.value === 'nomination' && clipDsgvo.value)
const clipSubmitStyle = computed(() => canSubmitClip.value ? "width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;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 24px rgba(124,86,196,.3);" : "width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:#d1c4e9;color:#9e8cc5;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:not-allowed;")
return {
@@ -48,6 +53,7 @@ export function useHomeParticipationPresentation(params: {
voteCount,
totalCats,
canSubmitVote,
savedVoteActive,
activeCategory,
activeCatName,
pickerTitle,
@@ -12,6 +12,7 @@ export function useHomeParticipationSessionActions(params: {
routerPush: (path: string) => Promise<unknown>
displayCategories: ComputedRef<HomeDisplayCategory[]>
votes: Ref<Record<string, number>>
savedVoteCount: Ref<number>
formError: Ref<string>
deleteConfirm: Ref<boolean>
accountActionError: Ref<string>
@@ -24,6 +25,7 @@ export function useHomeParticipationSessionActions(params: {
routerPush,
displayCategories,
votes,
savedVoteCount,
formError,
deleteConfirm,
accountActionError,
@@ -60,8 +62,10 @@ export function useHomeParticipationSessionActions(params: {
}
}
votes.value = nextVotes
savedVoteCount.value = Object.keys(nextVotes).length
} catch {
votes.value = {}
savedVoteCount.value = 0
}
}
@@ -78,6 +82,7 @@ export function useHomeParticipationSessionActions(params: {
accountActionError.value = ''
clipDsgvo.value = false
votes.value = {}
savedVoteCount.value = 0
await routerPush('/login')
}
@@ -89,6 +94,7 @@ export function useHomeParticipationSessionActions(params: {
deleteConfirm.value = false
clipDsgvo.value = false
votes.value = {}
savedVoteCount.value = 0
await store.loadHomeData()
await routerPush('/login')
} catch (error) {
@@ -1,4 +1,4 @@
import { ref, type ComputedRef, type Ref } from 'vue'
import { computed, ref, type ComputedRef, type Ref } from 'vue'
import { useAuthStore } from '../../stores/auth'
import { useAwardsStore } from '../../stores/awards'
@@ -43,17 +43,21 @@ export function useHomeParticipationState(params: {
} = params
const votes = ref<Record<string, number>>({})
const savedVoteCount = ref(0)
const submitted = ref(false)
const submitting = ref(false)
const formError = ref('')
const successKind = ref<null | HomeSuccessKind>(null)
const clipCatIdx = ref(0)
const clipDsgvo = ref(false)
const clipSubmissionsEnabled = computed(() => store.overview.featureFlags.clipSubmissionsEnabled)
const clipSubmissionDisabledMessage = computed(() => store.overview.featureFlags.clipSubmissionDisabledMessage)
const {
notSubmitted,
voteCount,
totalCats,
canSubmitVote,
savedVoteActive,
activeCategory,
activeCatName,
pickerTitle,
@@ -71,10 +75,12 @@ export function useHomeParticipationState(params: {
modal,
previewPhase,
votes,
savedVoteCount,
submitted,
successKind,
clipCatIdx,
clipDsgvo,
clipSubmissionsEnabled,
})
const {
initializeHomeInteractions,
@@ -109,26 +115,33 @@ export function useHomeParticipationState(params: {
openModal,
closeAccountModal,
votes,
savedVoteCount,
submitted,
submitting,
formError,
successKind,
clipCatIdx,
clipDsgvo,
clipSubmissionsEnabled,
clipSubmissionDisabledMessage,
})
return {
votes,
savedVoteCount,
submitted,
submitting,
formError,
successKind,
clipCatIdx,
clipDsgvo,
clipSubmissionsEnabled,
clipSubmissionDisabledMessage,
notSubmitted,
voteCount,
totalCats,
canSubmitVote,
savedVoteActive,
activeCategory,
activeCatName,
pickerTitle,
@@ -10,6 +10,7 @@ export function useHomeParticipationSubmitActions(params: {
activeCat: Ref<number>
previewPhase: Ref<HomePreviewPhase>
votes: Ref<Record<string, number>>
savedVoteCount: Ref<number>
submitted: Ref<boolean>
submitting: Ref<boolean>
formError: Ref<string>
@@ -19,6 +20,8 @@ export function useHomeParticipationSubmitActions(params: {
ensureViewerSession: () => Promise<AuthSession>
loadMyParticipation: () => Promise<void>
fallbackCreatorName: ComputedRef<string>
clipSubmissionsEnabled: ComputedRef<boolean>
clipSubmissionDisabledMessage: ComputedRef<string>
}) {
const {
store,
@@ -26,6 +29,7 @@ export function useHomeParticipationSubmitActions(params: {
activeCat,
previewPhase,
votes,
savedVoteCount,
submitted,
submitting,
formError,
@@ -35,6 +39,8 @@ export function useHomeParticipationSubmitActions(params: {
ensureViewerSession,
loadMyParticipation,
fallbackCreatorName,
clipSubmissionsEnabled,
clipSubmissionDisabledMessage,
} = params
function setCat(index: number) {
@@ -42,6 +48,13 @@ export function useHomeParticipationSubmitActions(params: {
}
function pickNominee(categoryId: string, index: number) {
if (votes.value[categoryId] === index) {
const nextVotes = { ...votes.value }
delete nextVotes[categoryId]
votes.value = nextVotes
return
}
votes.value = { ...votes.value, [categoryId]: index }
}
@@ -82,6 +95,7 @@ export function useHomeParticipationSubmitActions(params: {
entries,
})
await loadMyParticipation()
savedVoteCount.value = entries.length
submitted.value = true
successKind.value = 'vote'
} catch (error) {
@@ -113,19 +127,39 @@ export function useHomeParticipationSubmitActions(params: {
return
}
const streamUrl = nominationContext.streamUrl.trim()
if (!streamUrl) {
formError.value = 'Bitte füge einen Stream- oder Kanal-Link hinzu.'
const entries = nominationContext.entries
.map((entry) => ({
categoryIndex: entry.categoryIndex,
streamUrls: entry.streamUrls.map((url) => url.trim()).filter(Boolean),
}))
.filter((entry) => entry.streamUrls.length > 0)
if (entries.length === 0) {
formError.value = 'Bitte füge mindestens einen Stream- oder Kanal-Link hinzu.'
return
}
if (!isHttpUrl(streamUrl)) {
if (entries.some((entry) => entry.streamUrls.length > 3)) {
formError.value = 'Pro Kategorie sind maximal drei Links erlaubt.'
return
}
const invalidUrl = entries.flatMap((entry) => entry.streamUrls).find((url) => !isHttpUrl(url))
if (invalidUrl) {
formError.value = 'Bitte gib einen gültigen http(s)-Link ein.'
return
}
const category = displayCategories.value[nominationContext.categoryIndex]
if (!category) {
const hasDuplicateInCategory = entries.some((entry) => {
const normalizedUrls = entry.streamUrls.map((url) => normalizeUrlForCompare(url))
return new Set(normalizedUrls).size !== normalizedUrls.length
})
if (hasDuplicateInCategory) {
formError.value = 'Doppelte Links innerhalb derselben Kategorie sind nicht erlaubt.'
return
}
if (entries.some((entry) => !displayCategories.value[entry.categoryIndex])) {
formError.value = 'Bitte wähle eine gültige Kategorie aus.'
return
}
@@ -133,12 +167,15 @@ export function useHomeParticipationSubmitActions(params: {
submitting.value = true
try {
const session = await ensureViewerSession()
await store.submitNomination({
year: store.overview.year,
categoryId: Number(category.id),
twitchUserId: session.twitchUserId,
nominations: [{ streamUrl }],
})
await Promise.all(entries.map((entry) => {
const category = displayCategories.value[entry.categoryIndex]
return store.submitNomination({
year: store.overview.year,
categoryId: Number(category.id),
twitchUserId: session.twitchUserId,
nominations: entry.streamUrls.map((streamUrl) => ({ streamUrl })),
})
}))
await loadMyParticipation()
submitted.value = true
successKind.value = 'nomination'
@@ -152,6 +189,10 @@ export function useHomeParticipationSubmitActions(params: {
async function submitClip(clipContext: HomeClipSubmitContext) {
formError.value = ''
if (!clipDsgvo.value || submitting.value) return
if (!clipSubmissionsEnabled.value) {
formError.value = clipSubmissionDisabledMessage.value || 'Clip-Einreichungen sind aktuell geschlossen.'
return
}
if (previewPhase.value !== 'nomination') {
formError.value = 'Clip-Einreichungen sind nur während der Nominierungsphase möglich.'
return
@@ -223,6 +264,10 @@ function normalizeSearchValue(value: string) {
return value.trim().toLowerCase()
}
function normalizeUrlForCompare(value: string) {
return value.trim().replace(/\/+$/, '').toLowerCase()
}
function isHttpUrl(value: string) {
try {
const url = new URL(value)
@@ -40,11 +40,11 @@ export function useHomePhasePresentation(params: {
const streamLive = computed(() => showPhase.value && !Number.isNaN(showStartMs.value) && currentTimestamp.value >= showStartMs.value)
const streamLocked = computed(() => !streamLive.value)
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 läuft gerade. Reiche deine Favoriten und Highlight-Clips 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 phaseCardDescription = computed(() => completedPhase.value ? 'Die grosse Award-Show ist beendet. Das Jahr ist abgeschlossen und alle Teilnahme-Aktionen sind gesperrt.' : nominationPhase.value ? 'Die Nominierungsphase läuft gerade. Reiche pro Kategorie bis zu drei 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;')
const phasePrimaryLabel = computed(() => completedPhase.value ? '✦ Award beendet' : nominationPhase.value ? '✦ Nominieren & Clip' : votingPhase.value ? '★ Jetzt voten' : preparationPhase.value ? '✦ Show wird vorbereitet' : '● Zum Live-Stream')
const phasePrimaryLabel = computed(() => completedPhase.value ? '✦ Award beendet' : nominationPhase.value ? '✦ Jetzt nominieren' : votingPhase.value ? '★ Jetzt voten' : preparationPhase.value ? '✦ Show wird vorbereitet' : '● Zum Live-Stream')
const phasePrimaryDisabled = computed(() => preparationPhase.value || completedPhase.value)
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;'
@@ -72,8 +72,8 @@ export function useHomePhasePresentation(params: {
? 'position:absolute;top:27px;left:10%;right:10%;height:3px;background:linear-gradient(90deg,#8b6cdb 0%,#8b6cdb 66.66%,#e2d6f4 66.66%,#e2d6f4 100%);border-radius:3px;z-index:0;'
: 'position:absolute;top:27px;left:10%;right:10%;height:3px;background:linear-gradient(90deg,#8b6cdb 0%,#8b6cdb 100%,#e2d6f4 100%,#e2d6f4 100%);border-radius:3px;z-index:0;')
const sectionTitle = computed(() => completedPhase.value ? 'Das Award-Jahr ist abgeschlossen ✦' : nominationPhase.value ? 'Jetzt Highlights und Favoriten einreichen ✦' : votingPhase.value ? 'Meine Favoriten unterstützen ⭐' : preparationPhase.value ? 'Die Show wird vorbereitet ✦' : 'Die Gewinner werden jetzt live gekürt ✦')
const sectionText = computed(() => completedPhase.value ? 'Danke an alle, die nominiert, abgestimmt und live mitgefiebert haben. Die nächsten Aktionen sind gesperrt, bis ein neues Award-Jahr startet.' : nominationPhase.value ? 'Reiche deine Lieblingsmomente ein und hilf mit, die stärksten Clips und spannendsten Namen in die Show zu bringen.' : votingPhase.value ? 'Jede Stimme erzählt eine Geschichte. Unterstütze die Creator, die dich zum Lachen, Staunen und Mitfiebern bringen.' : preparationPhase.value ? 'Die Community hat abgestimmt. Jetzt bereitet das Team Clips, Ablauf und Show-Momente für die Award-Nacht vor.' : 'Die Bühne ist offen. Schau live zu, wie die Stars der Szene ausgezeichnet werden und die besten Momente gezeigt werden.')
const sectionActionLabel = computed(() => completedPhase.value ? 'Award-Jahr abgeschlossen' : nominationPhase.value ? 'Nominieren & Clip einreichen' : votingPhase.value ? 'Mit Twitch anmelden & voten' : preparationPhase.value ? 'Aufbereitung läuft' : 'Zum Live-Stream')
const sectionText = computed(() => completedPhase.value ? 'Danke an alle, die nominiert, abgestimmt und live mitgefiebert haben. Die nächsten Aktionen sind gesperrt, bis ein neues Award-Jahr startet.' : nominationPhase.value ? 'Reiche deine Favoriten als Stream- oder Kanal-Link ein und hilf dem Team, die stärksten Namen für das Voting vorzubereiten.' : votingPhase.value ? 'Jede Stimme erzählt eine Geschichte. Unterstütze die Creator, die dich zum Lachen, Staunen und Mitfiebern bringen.' : preparationPhase.value ? 'Die Community hat abgestimmt. Jetzt bereitet das Team Clips, Ablauf und Show-Momente für die Award-Nacht vor.' : 'Die Bühne ist offen. Schau live zu, wie die Stars der Szene ausgezeichnet werden und die besten Momente gezeigt werden.')
const sectionActionLabel = computed(() => completedPhase.value ? 'Award-Jahr abgeschlossen' : nominationPhase.value ? 'Nominierungen einreichen' : votingPhase.value ? 'Mit Twitch anmelden & voten' : preparationPhase.value ? 'Aufbereitung läuft' : 'Zum Live-Stream')
const sectionActionHref = computed(() => showPhase.value ? publicStreamUrl.value : '#')
const sectionActionDisabled = computed(() => preparationPhase.value || completedPhase.value)
const sectionActionStyle = computed(() => sectionActionDisabled.value
+54 -9
View File
@@ -18,6 +18,7 @@
</select>
<button
ref="triggerEl"
type="button"
class="native-select__trigger"
:disabled="disabled"
@@ -33,7 +34,8 @@
</span>
</button>
<div v-if="open" :id="listboxId" class="native-select__menu" role="listbox">
<Teleport to="body">
<div v-if="open" :id="listboxId" class="native-select__menu" :style="menuStyle" role="listbox">
<template v-for="(option, index) in options" :key="optionKey(option.value)">
<p v-if="option.group && option.group !== options[index - 1]?.group" class="native-select__group">
{{ option.group }}
@@ -56,12 +58,13 @@
</button>
</template>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { Check, ChevronDown } from '@lucide/vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from 'vue'
type SelectOptionValue = string | number | null
@@ -88,9 +91,48 @@ const emit = defineEmits<{
}>()
const rootEl = ref<HTMLElement | null>(null)
const triggerEl = ref<HTMLButtonElement | null>(null)
const open = ref(false)
const activeIndex = ref(0)
const listboxId = `native-select-${Math.random().toString(36).slice(2)}`
const menuPos = ref({ top: 0, bottom: 'auto' as string | number, left: 0, width: 0, maxHeight: 286 })
const menuStyle = computed<CSSProperties>(() => ({
position: 'fixed',
top: menuPos.value.top !== 0 ? `${menuPos.value.top}px` : 'auto',
bottom: menuPos.value.bottom !== 'auto' ? `${menuPos.value.bottom}px` : 'auto',
left: `${menuPos.value.left}px`,
width: `${menuPos.value.width}px`,
maxHeight: `${menuPos.value.maxHeight}px`,
zIndex: 9999,
}))
function updateMenuRect() {
if (!triggerEl.value) return
const r = triggerEl.value.getBoundingClientRect()
const PADDING = 8
const MAX_H = 286
const spaceBelow = window.innerHeight - r.bottom - PADDING
const spaceAbove = r.top - PADDING
if (spaceBelow >= Math.min(MAX_H, 160) || spaceBelow >= spaceAbove) {
menuPos.value = {
top: r.bottom + PADDING,
bottom: 'auto',
left: r.left,
width: r.width,
maxHeight: Math.min(MAX_H, spaceBelow),
}
} else {
menuPos.value = {
top: 0,
bottom: window.innerHeight - r.top + PADDING,
left: r.left,
width: r.width,
maxHeight: Math.min(MAX_H, spaceAbove),
}
}
}
const selectedOption = computed(() =>
props.options.find((option) => isSameValue(option.value, props.modelValue)) ?? null,
@@ -132,8 +174,11 @@ function setActiveIndex(index: number) {
function toggleDropdown() {
if (props.disabled || props.options.length === 0) return
open.value = !open.value
if (open.value && props.options[activeIndex.value]?.disabled) {
activeIndex.value = firstEnabledIndex()
if (open.value) {
updateMenuRect()
if (props.options[activeIndex.value]?.disabled) {
activeIndex.value = firstEnabledIndex()
}
}
}
@@ -202,10 +247,14 @@ function onDocumentPointerDown(event: PointerEvent) {
onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown)
window.addEventListener('scroll', updateMenuRect, true)
window.addEventListener('resize', updateMenuRect)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocumentPointerDown)
window.removeEventListener('scroll', updateMenuRect, true)
window.removeEventListener('resize', updateMenuRect)
})
</script>
@@ -218,7 +267,7 @@ onBeforeUnmount(() => {
}
.native-select--open{
z-index:45;
z-index:80;
}
.native-select__native{
@@ -305,10 +354,6 @@ onBeforeUnmount(() => {
}
.native-select__menu{
position:absolute;
top:calc(100% + 8px);
left:0;
right:0;
display:grid;
gap:4px;
max-height:286px;
+41
View File
@@ -2,15 +2,21 @@ import type {
AdminAuditEntriesResponse,
AdminAuditQueryOptions,
AdminDashboardResponse,
AdminOptionalFeatureSettingsResponse,
AdminOperationalSettingsResponse,
AdminNominationLinkBlacklistResponse,
AdminRiskFlagsResponse,
AdminRiskQueryOptions,
AdminRiskRulesResponse,
AdminSeasonDetailResponse,
AdminSeasonListItem,
AdminShowactApplicationItem,
AdminSiteSettingsResponse,
AdminSponsorItem,
AdminTeamResponse,
AdminWorkflowRulesResponse,
ApproveNominationPayload,
AddNominationLinkBlacklistEntryPayload,
BulkResolveRiskFlagsPayload,
CreateTeamMemberPayload,
CreateSeasonPayload,
@@ -19,14 +25,19 @@ import type {
SetAwardResultPayload,
TeamMemberPasswordResponse,
UpdateClipStatusPayload,
UpdateOptionalFeatureSettingsPayload,
UpdateOperationalSettingsPayload,
UpdateRiskRulesPayload,
UpdateShowactStatusPayload,
UpdateNominationLinkBlacklistPayload,
UpdateSeasonPayload,
UpdateSiteSettingsPayload,
UpdateTeamMemberPayload,
UpdateTeamRolesPayload,
UpdateWorkflowRulesPayload,
UpsertCandidatePayload,
UpsertCategoryPayload,
UpsertSponsorPayload,
} from '../../types/awards'
import { requestJson } from '../http'
import { jsonRequest } from './requestOptions'
@@ -82,10 +93,34 @@ export const adminApi = {
getAdminRiskRules: () => requestJson<AdminRiskRulesResponse>('/api/admin/risk-rules'),
updateAdminRiskRules: (payload: UpdateRiskRulesPayload) =>
requestJson<AdminRiskRulesResponse>('/api/admin/risk-rules', jsonRequest('PUT', payload)),
getAdminWorkflowRules: () => requestJson<AdminWorkflowRulesResponse>('/api/admin/workflow-rules'),
updateAdminWorkflowRules: (payload: UpdateWorkflowRulesPayload) =>
requestJson<AdminWorkflowRulesResponse>('/api/admin/workflow-rules', jsonRequest('PUT', payload)),
getAdminSeasons: () => requestJson<AdminSeasonListItem[]>('/api/admin/seasons'),
getAdminSeasonDetail: (seasonId: number) =>
requestJson<AdminSeasonDetailResponse>(`/api/admin/seasons/${seasonId}`),
getAdminShowactApplications: (seasonId: number) =>
requestJson<AdminShowactApplicationItem[]>(`/api/admin/seasons/${seasonId}/showacts`),
updateAdminShowactStatus: (applicationId: number, payload: UpdateShowactStatusPayload) =>
requestJson<{ saved: boolean; application: AdminShowactApplicationItem }>(
`/api/admin/showacts/${applicationId}/status`,
jsonRequest('POST', payload),
),
deleteAdminShowactApplication: (applicationId: number) =>
requestJson<{ deleted: boolean; applicationId: number }>(`/api/admin/showacts/${applicationId}`, { method: 'DELETE' }),
getAdminSponsors: (seasonId: number) =>
requestJson<AdminSponsorItem[]>(`/api/admin/seasons/${seasonId}/sponsors`),
createAdminSponsor: (seasonId: number, payload: UpsertSponsorPayload) =>
requestJson<{ saved: boolean; sponsor: AdminSponsorItem }>(`/api/admin/seasons/${seasonId}/sponsors`, jsonRequest('POST', payload)),
updateAdminSponsor: (sponsorId: number, payload: UpsertSponsorPayload) =>
requestJson<{ saved: boolean; sponsor: AdminSponsorItem }>(`/api/admin/sponsors/${sponsorId}`, jsonRequest('PUT', payload)),
deleteAdminSponsor: (sponsorId: number) =>
requestJson<{ deleted: boolean; sponsorId: number }>(`/api/admin/sponsors/${sponsorId}`, { method: 'DELETE' }),
getAdminSiteSettings: () => requestJson<AdminSiteSettingsResponse>('/api/admin/site-settings'),
getAdminOptionalFeatureSettings: () =>
requestJson<AdminOptionalFeatureSettingsResponse>('/api/admin/optional-feature-settings'),
updateAdminOptionalFeatureSettings: (payload: UpdateOptionalFeatureSettingsPayload) =>
requestJson<AdminOptionalFeatureSettingsResponse>('/api/admin/optional-feature-settings', jsonRequest('PUT', payload)),
getAdminOperationalSettings: () =>
requestJson<AdminOperationalSettingsResponse>('/api/admin/operational-settings'),
getAdminTeam: () => requestJson<AdminTeamResponse>('/api/admin/team'),
@@ -155,6 +190,12 @@ export const adminApi = {
`/api/admin/nominations/${nominationId}/reject`,
jsonRequest('POST', payload),
),
getAdminNominationLinkBlacklist: () =>
requestJson<AdminNominationLinkBlacklistResponse>('/api/admin/nominations/link-blacklist'),
updateAdminNominationLinkBlacklist: (payload: UpdateNominationLinkBlacklistPayload) =>
requestJson<AdminNominationLinkBlacklistResponse>('/api/admin/nominations/link-blacklist', jsonRequest('PUT', payload)),
addAdminNominationLinkBlacklistEntry: (payload: AddNominationLinkBlacklistEntryPayload) =>
requestJson<AdminNominationLinkBlacklistResponse>('/api/admin/nominations/link-blacklist', jsonRequest('POST', payload)),
resolveRiskFlag: (riskFlagId: number, payload: ResolveRiskFlagPayload) =>
requestJson<{ saved: boolean; riskFlagId: number; status: string }>(
`/api/admin/risk-flags/${riskFlagId}/resolve`,
+6
View File
@@ -1,9 +1,11 @@
import type {
CreateClipPayload,
CreateShowactApplicationPayload,
CreateNominationPayload,
CreateVotePayload,
OverviewResponse,
PublicSiteStatusResponse,
PublicSponsorsResponse,
SeasonCategoriesResponse,
UserParticipationResponse,
WinnerArchiveResponse,
@@ -18,6 +20,8 @@ export const publicApi = {
requestJson<SeasonCategoriesResponse>(`/api/public/seasons/${year}/categories`),
getWinnerArchive: (year: number) =>
requestJson<WinnerArchiveResponse>(`/api/public/seasons/${year}/winners`),
getPublicSponsors: (year: number) =>
requestJson<PublicSponsorsResponse>(`/api/public/seasons/${year}/sponsors`),
getMyParticipation: (year: number) =>
requestJson<UserParticipationResponse>(`/api/public/seasons/${year}/me`),
submitNomination: (payload: CreateNominationPayload) =>
@@ -26,4 +30,6 @@ export const publicApi = {
requestJson<{ ballotId: number; entries: number }>('/api/public/votes', jsonRequest('POST', payload)),
submitClip: (payload: CreateClipPayload) =>
requestJson<{ saved: boolean; clipId: number }>('/api/public/clips', jsonRequest('POST', payload)),
submitShowactApplication: (payload: CreateShowactApplicationPayload) =>
requestJson<{ saved: boolean; applicationId: number }>('/api/public/showacts', jsonRequest('POST', payload)),
}
+119 -2
View File
@@ -7,16 +7,21 @@ import {
createEmptyAdminDashboard,
createEmptyAdminRiskFlagsResponse,
createEmptyAdminSeasonDetail,
createEmptyAdminOptionalFeatureSettings,
createEmptyAdminSiteSettings,
createEmptyAdminWorkflowRulesResponse,
createEmptyArchive,
createEmptyDatabaseHealth,
createEmptyPublicSponsors,
normalizeSeasonDetail,
} from './awards/defaults'
import type {
ApproveNominationPayload,
AddNominationLinkBlacklistEntryPayload,
CreateSeasonPayload,
CreateClipPayload,
CreateNominationPayload,
CreateShowactApplicationPayload,
CreateVotePayload,
RejectNominationPayload,
ResolveRiskFlagPayload,
@@ -25,9 +30,14 @@ import type {
AdminRiskQueryOptions,
UpdateSeasonPayload,
UpdateClipStatusPayload,
UpdateNominationLinkBlacklistPayload,
UpdateOptionalFeatureSettingsPayload,
UpdateShowactStatusPayload,
UpdateSiteSettingsPayload,
UpdateWorkflowRulesPayload,
UpsertCandidatePayload,
UpsertCategoryPayload,
UpsertSponsorPayload,
} from '../types/awards'
interface AdminSeasonRefreshOptions {
@@ -45,6 +55,7 @@ export const useAwardsStore = defineStore('awards', {
try {
this.overview = await api.getOverview()
this.categories = await api.getSeasonCategories(this.overview.year)
await this.loadPublicSponsors(this.overview.year)
const overviewArchiveYears = this.overview.archiveYears ?? []
const initialArchiveYear = overviewArchiveYears[0]?.year
?? this.overview.winnersPreview[0]?.year
@@ -70,18 +81,31 @@ export const useAwardsStore = defineStore('awards', {
this.archive = createEmptyArchive(year)
}
},
async loadPublicSponsors(year: number) {
try {
this.publicSponsors = await api.getPublicSponsors(year)
return this.publicSponsors
} catch {
this.publicSponsors = createEmptyPublicSponsors(year)
return this.publicSponsors
}
},
async loadAdmin() {
try {
const [admin, adminSeasons, adminSiteSettings, databaseHealth] = await Promise.all([
const [admin, adminSeasons, adminSiteSettings, adminOptionalFeatureSettings, adminWorkflowRules, databaseHealth] = await Promise.all([
api.getAdminDashboard(),
api.getAdminSeasons(),
api.getAdminSiteSettings(),
api.getAdminOptionalFeatureSettings(),
api.getAdminWorkflowRules(),
api.getDatabaseHealth(),
])
this.admin = admin
this.adminSeasons = adminSeasons
this.adminSiteSettings = adminSiteSettings
this.adminOptionalFeatureSettings = adminOptionalFeatureSettings
this.adminWorkflowRules = adminWorkflowRules
this.databaseHealth = databaseHealth
if (!this.adminSelectedSeasonId || !this.adminSeasons.some((season) => season.id === this.adminSelectedSeasonId)) {
@@ -90,8 +114,16 @@ export const useAwardsStore = defineStore('awards', {
if (this.adminSelectedSeasonId) {
this.adminSeasonDetail = normalizeSeasonDetail(await api.getAdminSeasonDetail(this.adminSelectedSeasonId))
const [sponsors, showacts] = await Promise.all([
api.getAdminSponsors(this.adminSelectedSeasonId),
api.getAdminShowactApplications(this.adminSelectedSeasonId),
])
this.adminSponsors = sponsors
this.adminShowactApplications = showacts
} else {
this.adminSeasonDetail = createEmptyAdminSeasonDetail()
this.adminSponsors = []
this.adminShowactApplications = []
}
this.apiMode = 'api'
} catch {
@@ -99,25 +131,32 @@ export const useAwardsStore = defineStore('awards', {
this.adminSeasons = []
this.adminSeasonDetail = createEmptyAdminSeasonDetail()
this.adminSiteSettings = createEmptyAdminSiteSettings()
this.adminOptionalFeatureSettings = createEmptyAdminOptionalFeatureSettings()
this.adminWorkflowRules = createEmptyAdminWorkflowRulesResponse()
this.adminRiskHistory = []
this.adminRiskFlagsPage = createEmptyAdminRiskFlagsResponse()
this.adminRiskHistoryPage = createEmptyAdminRiskFlagsResponse()
this.adminSponsors = []
this.adminShowactApplications = []
this.databaseHealth = createEmptyDatabaseHealth()
this.adminSelectedSeasonId = null
}
},
async loadAdminContentWorkspace() {
try {
const [adminSiteSettings, databaseHealth] = await Promise.all([
const [adminSiteSettings, adminOptionalFeatureSettings, databaseHealth] = await Promise.all([
api.getAdminSiteSettings(),
api.getAdminOptionalFeatureSettings(),
api.getDatabaseHealth(),
])
this.adminSiteSettings = adminSiteSettings
this.adminOptionalFeatureSettings = adminOptionalFeatureSettings
this.databaseHealth = databaseHealth
this.apiMode = 'api'
} catch {
this.adminSiteSettings = createEmptyAdminSiteSettings()
this.adminOptionalFeatureSettings = createEmptyAdminOptionalFeatureSettings()
this.databaseHealth = createEmptyDatabaseHealth()
}
},
@@ -129,9 +168,17 @@ export const useAwardsStore = defineStore('awards', {
try {
this.adminSelectedSeasonId = seasonId
this.adminSeasonDetail = normalizeSeasonDetail(await api.getAdminSeasonDetail(seasonId))
const [sponsors, showacts] = await Promise.all([
api.getAdminSponsors(seasonId),
api.getAdminShowactApplications(seasonId),
])
this.adminSponsors = sponsors
this.adminShowactApplications = showacts
this.apiMode = 'api'
} catch {
this.adminSeasonDetail = createEmptyAdminSeasonDetail()
this.adminSponsors = []
this.adminShowactApplications = []
}
},
async initializeAdminWorkspace() {
@@ -209,6 +256,50 @@ export const useAwardsStore = defineStore('awards', {
submitClip(payload: CreateClipPayload) {
return api.submitClip(payload)
},
submitShowactApplication(payload: CreateShowactApplicationPayload) {
return api.submitShowactApplication(payload)
},
async loadAdminExtras(seasonId?: number | null) {
const resolvedSeasonId = seasonId ?? this.adminSelectedSeasonId
if (!resolvedSeasonId) {
this.adminSponsors = []
this.adminShowactApplications = []
return { sponsors: this.adminSponsors, showacts: this.adminShowactApplications }
}
const [sponsors, showacts] = await Promise.all([
api.getAdminSponsors(resolvedSeasonId),
api.getAdminShowactApplications(resolvedSeasonId),
])
this.adminSponsors = sponsors
this.adminShowactApplications = showacts
return { sponsors, showacts }
},
async createAdminSponsor(seasonId: number, payload: UpsertSponsorPayload) {
const result = await api.createAdminSponsor(seasonId, payload)
await Promise.all([this.loadAdminExtras(seasonId), this.loadPublicSponsors(this.overview.year)])
return result
},
async updateAdminSponsor(sponsorId: number, seasonId: number, payload: UpsertSponsorPayload) {
const result = await api.updateAdminSponsor(sponsorId, payload)
await Promise.all([this.loadAdminExtras(seasonId), this.loadPublicSponsors(this.overview.year)])
return result
},
async deleteAdminSponsor(sponsorId: number, seasonId: number) {
const result = await api.deleteAdminSponsor(sponsorId)
await Promise.all([this.loadAdminExtras(seasonId), this.loadPublicSponsors(this.overview.year)])
return result
},
async updateAdminShowactStatus(applicationId: number, seasonId: number, payload: UpdateShowactStatusPayload) {
const result = await api.updateAdminShowactStatus(applicationId, payload)
await this.loadAdminExtras(seasonId)
return result
},
async deleteAdminShowactApplication(applicationId: number, seasonId: number) {
const result = await api.deleteAdminShowactApplication(applicationId)
await this.loadAdminExtras(seasonId)
return result
},
async updateAdminSeason(seasonId: number, payload: UpdateSeasonPayload) {
const result = await api.updateAdminSeason(seasonId, payload)
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true, reloadHome: true })
@@ -292,6 +383,15 @@ export const useAwardsStore = defineStore('awards', {
await Promise.allSettled(nominationIds.map((id) => api.rejectAdminNomination(id, { reviewNote })))
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true })
},
loadAdminNominationLinkBlacklist() {
return api.getAdminNominationLinkBlacklist()
},
updateAdminNominationLinkBlacklist(payload: UpdateNominationLinkBlacklistPayload) {
return api.updateAdminNominationLinkBlacklist(payload)
},
addAdminNominationLinkBlacklistEntry(payload: AddNominationLinkBlacklistEntryPayload) {
return api.addAdminNominationLinkBlacklistEntry(payload)
},
async resolveRiskFlag(riskFlagId: number, payload: ResolveRiskFlagPayload) {
const result = await api.resolveRiskFlag(riskFlagId, payload)
await Promise.all([
@@ -328,6 +428,23 @@ export const useAwardsStore = defineStore('awards', {
async updateAdminRiskRules(payload: import('../types/awards').UpdateRiskRulesPayload) {
return api.updateAdminRiskRules(payload)
},
async loadAdminOptionalFeatureSettings() {
this.adminOptionalFeatureSettings = await api.getAdminOptionalFeatureSettings()
return this.adminOptionalFeatureSettings
},
async updateAdminOptionalFeatureSettings(payload: UpdateOptionalFeatureSettingsPayload) {
this.adminOptionalFeatureSettings = await api.updateAdminOptionalFeatureSettings(payload)
await this.loadHomeData()
return this.adminOptionalFeatureSettings
},
async loadAdminWorkflowRules() {
this.adminWorkflowRules = await api.getAdminWorkflowRules()
return this.adminWorkflowRules
},
async updateAdminWorkflowRules(payload: UpdateWorkflowRulesPayload) {
this.adminWorkflowRules = await api.updateAdminWorkflowRules(payload)
return this.adminWorkflowRules
},
async updateAdminSiteSettings(payload: UpdateSiteSettingsPayload) {
const result = await api.updateAdminSiteSettings(payload)
this.adminSiteSettings = await api.getAdminSiteSettings()
+55 -1
View File
@@ -1,13 +1,18 @@
import { ApiRequestError } from '../../lib/http'
import type {
AdminDashboardResponse,
AdminOptionalFeatureSettingsResponse,
AdminRiskFlag,
AdminRiskFlagsResponse,
AdminSeasonDetailResponse,
AdminSeasonListItem,
AdminShowactApplicationItem,
AdminSiteSettingsResponse,
AdminSponsorItem,
AdminWorkflowRulesResponse,
DatabaseHealthResponse,
OverviewResponse,
PublicSponsorsResponse,
SeasonCategoriesResponse,
WinnerArchiveResponse,
} from '../../types/awards'
@@ -39,10 +44,26 @@ export function createEmptyOverview(): OverviewResponse {
socialLinks: [],
footerLinks: [],
},
featureFlags: {
clipSubmissionsEnabled: false,
clipReviewEnabled: true,
clipAdminMenuVisible: true,
clipSubmissionDisabledMessage: 'Clip-Einreichungen sind aktuell geschlossen.',
showactApplicationsEnabled: false,
showactApplicationDisabledMessage: 'Showact-Bewerbungen sind aktuell geschlossen.',
sponsorsVisible: true,
},
faq: [],
}
}
export function createEmptyPublicSponsors(year = new Date().getFullYear()): PublicSponsorsResponse {
return {
year,
items: [],
}
}
export function createEmptyCategories(): SeasonCategoriesResponse {
return {
seasonId: 0,
@@ -81,6 +102,12 @@ export function createEmptyAdminRiskFlagsResponse(): AdminRiskFlagsResponse {
}
}
export function createEmptyAdminWorkflowRulesResponse(): AdminWorkflowRulesResponse {
return {
rules: [],
}
}
export function createEmptyAdminSeasonDetail(): AdminSeasonDetailResponse {
return {
id: 0,
@@ -122,11 +149,25 @@ export function createEmptyAdminSiteSettings(): AdminSiteSettingsResponse {
contactContent: '',
sponsorsUrl: '',
sponsorsContent: '',
showactsUrl: '',
showactsContent: '',
socialLinks: [],
faq: [],
}
}
export function createEmptyAdminOptionalFeatureSettings(): AdminOptionalFeatureSettingsResponse {
return {
clipSubmissionsEnabled: false,
clipReviewEnabled: true,
clipAdminMenuVisible: true,
clipSubmissionDisabledMessage: 'Clip-Einreichungen sind aktuell geschlossen.',
showactApplicationsEnabled: false,
showactApplicationDisabledMessage: 'Showact-Bewerbungen sind aktuell geschlossen.',
sponsorsVisible: true,
}
}
export function createEmptyDatabaseHealth(): DatabaseHealthResponse {
return {
provider: 'postgres',
@@ -143,10 +184,15 @@ export function createAwardsState() {
overview: createEmptyOverview(),
categories: createEmptyCategories(),
archive: createEmptyArchive(),
publicSponsors: createEmptyPublicSponsors(),
admin: createEmptyAdminDashboard(),
adminSeasons: [] as AdminSeasonListItem[],
adminSeasonDetail: createEmptyAdminSeasonDetail(),
adminSiteSettings: createEmptyAdminSiteSettings(),
adminOptionalFeatureSettings: createEmptyAdminOptionalFeatureSettings(),
adminWorkflowRules: createEmptyAdminWorkflowRulesResponse(),
adminSponsors: [] as AdminSponsorItem[],
adminShowactApplications: [] as AdminShowactApplicationItem[],
adminRiskHistory: [] as AdminRiskFlag[],
adminRiskFlagsPage: createEmptyAdminRiskFlagsResponse(),
adminRiskHistoryPage: createEmptyAdminRiskFlagsResponse(),
@@ -167,7 +213,15 @@ export function normalizeSeasonDetail(detail: AdminSeasonDetailResponse): AdminS
return {
...detail,
categories: detail.categories ?? [],
candidates: detail.candidates ?? [],
candidates: (detail.candidates ?? []).map((candidate) => ({
...candidate,
acceptanceStatus: candidate.acceptanceStatus ?? 'open',
acceptanceNote: candidate.acceptanceNote ?? null,
clipCompilationUrl: candidate.clipCompilationUrl ?? null,
clipCompilationTitle: candidate.clipCompilationTitle ?? null,
clipCompilationPlatform: candidate.clipCompilationPlatform ?? null,
clipEmbedStatus: candidate.clipEmbedStatus ?? 'unchecked',
})),
pendingNominations: detail.pendingNominations ?? [],
reviewedNominations: detail.reviewedNominations ?? [],
results: detail.results ?? [],
+68
View File
@@ -79,6 +79,19 @@ export interface AdminRiskRulesResponse {
rules: AdminRiskRule[]
}
export interface AdminWorkflowRule {
key: string
label: string
enabled: boolean
limit: number
mode: 'warn' | 'block' | string
description: string
}
export interface AdminWorkflowRulesResponse {
rules: AdminWorkflowRule[]
}
export interface AdminAuditEntry {
id: number
adminTwitchUserId: string
@@ -155,6 +168,12 @@ export interface AdminCandidateItem {
displayName: string
channelSlug: string
platform: string
acceptanceStatus: string
acceptanceNote: string | null
clipCompilationUrl: string | null
clipCompilationTitle: string | null
clipCompilationPlatform: string | null
clipEmbedStatus: string
}
export interface AdminAwardResultItem {
@@ -183,6 +202,14 @@ export interface AdminNominationReviewItem {
reviewedAt: string | null
}
export interface AdminNominationLinkBlacklistEntry {
url: string
}
export interface AdminNominationLinkBlacklistResponse {
entries: AdminNominationLinkBlacklistEntry[]
}
export interface AdminClipSubmissionItem {
id: number
categoryId: number | null
@@ -237,6 +264,8 @@ export interface AdminSiteSettingsResponse {
contactContent: string
sponsorsUrl: string
sponsorsContent: string
showactsUrl: string
showactsContent: string
socialLinks: PublicSocialLink[]
faq: FaqItem[]
}
@@ -259,6 +288,45 @@ export interface AdminOperationalSettingsResponse {
maintenanceMessage: string
}
export interface AdminOptionalFeatureSettingsResponse {
clipSubmissionsEnabled: boolean
clipReviewEnabled: boolean
clipAdminMenuVisible: boolean
clipSubmissionDisabledMessage: string
showactApplicationsEnabled: boolean
showactApplicationDisabledMessage: string
sponsorsVisible: boolean
}
export interface AdminSponsorItem {
id: number
seasonId: number
name: string
websiteUrl: string
logoUrl: string
description: string
tier: string
sortOrder: number
isVisible: boolean
}
export interface AdminShowactApplicationItem {
id: number
seasonId: number
artistName: string
contactEmail: string
contactDiscord: string
platformUrl: string
performanceType: string
description: string
technicalNotes: string
referenceUrl: string
status: string
reviewNote: string | null
createdAt: string
reviewedAt: string | null
}
export interface AdminTeamPermission {
key: string
label: string
+65
View File
@@ -81,6 +81,12 @@ export interface UpsertCandidatePayload {
displayName: string
channelSlug: string
platform: string
acceptanceStatus?: string | null
acceptanceNote?: string | null
clipCompilationUrl?: string | null
clipCompilationTitle?: string | null
clipCompilationPlatform?: string | null
clipEmbedStatus?: string | null
}
export interface UpdateClipStatusPayload {
@@ -113,6 +119,19 @@ export interface UpdateRiskRulesPayload {
rules: UpdateRiskRulePayload[]
}
export interface UpdateWorkflowRulePayload {
key: string
label: string
enabled: boolean
limit: number
mode: string
description: string
}
export interface UpdateWorkflowRulesPayload {
rules: UpdateWorkflowRulePayload[]
}
export interface SetAwardResultPayload {
categoryId: number
candidateId: number
@@ -129,6 +148,14 @@ export interface RejectNominationPayload {
reviewNote?: string
}
export interface UpdateNominationLinkBlacklistPayload {
urls: string[]
}
export interface AddNominationLinkBlacklistEntryPayload {
url: string
}
export interface UpdateSiteSettingsPayload {
hostDisplayName: string
hostTagline: string
@@ -141,6 +168,8 @@ export interface UpdateSiteSettingsPayload {
contactContent: string
sponsorsUrl: string
sponsorsContent: string
showactsUrl: string
showactsContent: string
socialLinks: PublicSocialLink[]
faq: FaqItem[]
}
@@ -159,3 +188,39 @@ export interface UpdateOperationalSettingsPayload {
maintenanceTitle: string
maintenanceMessage: string
}
export interface UpdateOptionalFeatureSettingsPayload {
clipSubmissionsEnabled: boolean
clipReviewEnabled: boolean
clipAdminMenuVisible: boolean
clipSubmissionDisabledMessage: string
showactApplicationsEnabled: boolean
showactApplicationDisabledMessage: string
sponsorsVisible: boolean
}
export interface UpsertSponsorPayload {
name: string
websiteUrl: string
logoUrl: string
description: string
tier: string
sortOrder: number
isVisible: boolean
}
export interface UpdateShowactStatusPayload {
status: string
reviewNote?: string
}
export interface CreateShowactApplicationPayload {
artistName: string
contactEmail: string
contactDiscord: string
platformUrl: string
performanceType: string
description: string
technicalNotes: string
referenceUrl: string
}
+37
View File
@@ -21,6 +21,10 @@ export interface WinnerPreview {
winnerSlug: string
winnerPlatform: string
winnerUrl: string
clipUrl?: string | null
clipTitle?: string | null
clipPlatform?: string | null
clipEmbedStatus?: string | null
}
export interface ArchiveYearSummary {
@@ -66,6 +70,15 @@ export interface PublicSiteStatusResponse {
maintenanceMessage: string
}
export interface PublicFeatureFlags {
clipSubmissionsEnabled: boolean
clipReviewEnabled: boolean
clipSubmissionDisabledMessage: string
showactApplicationsEnabled: boolean
showactApplicationDisabledMessage: string
sponsorsVisible: boolean
}
export interface OverviewResponse {
seasonId: number
year: number
@@ -81,6 +94,7 @@ export interface OverviewResponse {
winnersPreview: WinnerPreview[]
archiveYears: ArchiveYearSummary[]
siteContent: PublicSiteContent
featureFlags: PublicFeatureFlags
faq: FaqItem[]
}
@@ -88,11 +102,13 @@ export interface CandidateSummary {
id: number
displayName: string
channelSlug: string
channelUrl?: string | null
platform: string
/** Repräsentativer Clip/Video-Link, damit Votende vor der Wahl reinschauen können. */
clipUrl?: string | null
clipTitle?: string | null
clipPlatform?: string | null
clipEmbedStatus?: string | null
}
export interface PublicCategoryDetail {
@@ -116,6 +132,10 @@ export interface WinnerArchiveItem {
winnerSlug: string
winnerPlatform: string
winnerUrl: string
clipUrl?: string | null
clipTitle?: string | null
clipPlatform?: string | null
clipEmbedStatus?: string | null
}
export interface WinnerArchiveResponse {
@@ -123,6 +143,23 @@ export interface WinnerArchiveResponse {
items: WinnerArchiveItem[]
}
export interface SponsorItem {
id: number
seasonId: number
name: string
websiteUrl: string
logoUrl: string
description: string
tier: string
sortOrder: number
isVisible: boolean
}
export interface PublicSponsorsResponse {
year: number
items: SponsorItem[]
}
export interface UserNominationState {
categoryId: number
nominees: string[]
@@ -19,12 +19,17 @@ const {
adminError,
search,
categoryFilter,
readinessFilter,
page,
categoryOptions,
categoryFilterOptions,
categoryLabelMap,
duplicateCandidateKeys,
candidateWorkflowNotices,
duplicateCandidateCount,
acceptedCandidateCount,
clipReadyCount,
actionNeededCount,
filteredCandidates,
pagedCandidates,
totalPages,
@@ -36,6 +41,9 @@ const {
canSave,
candidatePlatformOptions,
selectedPlatformValue,
acceptanceStatusOptions,
clipEmbedStatusOptions,
readinessFilterOptions,
candidateToDelete,
clearFilters,
openCreate,
@@ -76,6 +84,24 @@ watchAdminToast(adminMessage, adminError)
<UserPlus class="h-6 w-6 text-violet-500" />
</div>
</Card>
<Card class="p-5">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-emerald-600">Angenommen</p>
<strong class="mt-3 block text-3xl text-emerald-800">{{ acceptedCandidateCount }}</strong>
</div>
<UserPlus class="h-6 w-6 text-emerald-500" />
</div>
</Card>
<Card class="p-5">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-sky-600">Clip bereit</p>
<strong class="mt-3 block text-3xl text-sky-800">{{ clipReadyCount }}</strong>
</div>
<Layers3 class="h-6 w-6 text-sky-500" />
</div>
</Card>
<Card class="p-5" :class="duplicateCandidateCount > 0 ? 'border-amber-200 bg-amber-50/60' : ''">
<div class="flex items-start justify-between gap-4">
<div>
@@ -85,15 +111,27 @@ watchAdminToast(adminMessage, adminError)
<Layers3 class="h-6 w-6" :class="duplicateCandidateCount > 0 ? 'text-amber-500' : 'text-violet-500'" />
</div>
</Card>
<Card class="p-5" :class="actionNeededCount > 0 ? 'border-rose-200 bg-rose-50/60' : ''">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em]" :class="actionNeededCount > 0 ? 'text-rose-600' : 'text-violet-500'">Handlungsbedarf</p>
<strong class="mt-3 block text-3xl" :class="actionNeededCount > 0 ? 'text-rose-700' : 'text-violet-900'">{{ actionNeededCount }}</strong>
</div>
<UserPlus class="h-6 w-6" :class="actionNeededCount > 0 ? 'text-rose-500' : 'text-violet-500'" />
</div>
</Card>
</section>
<Card class="overflow-hidden">
<AdminCandidatesFiltersBar
:search="search"
:category-filter="categoryFilter"
:readiness-filter="readinessFilter"
:category-filter-options="categoryFilterOptions"
:readiness-filter-options="readinessFilterOptions"
@update:search="search = $event"
@update:category-filter="categoryFilter = $event"
@update:readiness-filter="readinessFilter = $event"
@clear-filters="clearFilters"
@open-create="openCreate"
/>
@@ -108,6 +146,8 @@ watchAdminToast(adminMessage, adminError)
:range-end="rangeEnd"
:category-label-map="categoryLabelMap"
:duplicate-candidate-keys="duplicateCandidateKeys"
:candidate-workflow-notices="candidateWorkflowNotices"
:acceptance-status-options="acceptanceStatusOptions"
@edit="openEdit"
@delete="candidateToDelete = $event"
@update:page="page = $event"
@@ -122,6 +162,8 @@ watchAdminToast(adminMessage, adminError)
:category-options="categoryOptions"
:candidate-platform-options="candidatePlatformOptions"
:selected-platform-value="selectedPlatformValue"
:acceptance-status-options="acceptanceStatusOptions"
:clip-embed-status-options="clipEmbedStatusOptions"
:can-save="canSave"
:saving="saving"
@close="modalOpen = false"
@@ -130,6 +172,12 @@ watchAdminToast(adminMessage, adminError)
@update:display-name="form.displayName = $event"
@update:channel-slug="form.channelSlug = $event"
@update:platform="form.platform = $event"
@update:acceptance-status="form.acceptanceStatus = $event"
@update:acceptance-note="form.acceptanceNote = $event"
@update:clip-compilation-url="form.clipCompilationUrl = $event"
@update:clip-compilation-title="form.clipCompilationTitle = $event"
@update:clip-compilation-platform="form.clipCompilationPlatform = $event"
@update:clip-embed-status="form.clipEmbedStatus = $event"
@platform-selection="handlePlatformSelection"
/>
+29 -1
View File
@@ -23,6 +23,11 @@ const {
adminError,
clipToDelete,
reviewNotes,
clipSubmissionsEnabled,
clipReviewEnabled,
clipDisabledMessage,
clipWorkflowStatusLabel,
clipWorkflowStatusClass,
submissions,
categoryName,
clips,
@@ -54,10 +59,33 @@ watchAdminToast(adminMessage, adminError)
<AdminPageHeader
eyebrow="Clips"
:icon="Film"
:description="clipWorkflowStatusLabel"
/>
<AdminSeasonToolbar />
<div
class="flex flex-wrap items-start justify-between gap-4 rounded-2xl border px-5 py-4 shadow-sm"
:class="clipSubmissionsEnabled ? 'border-emerald-100 bg-emerald-50/70' : 'border-amber-100 bg-amber-50/70'"
>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span :class="['rounded-full border px-3 py-1 text-xs font-semibold', clipWorkflowStatusClass]">
{{ clipWorkflowStatusLabel }}
</span>
<span :class="['rounded-full border px-3 py-1 text-xs font-semibold', clipReviewEnabled ? 'border-violet-200 bg-white text-violet-700' : 'border-slate-200 bg-white text-slate-600']">
Admin-Review {{ clipReviewEnabled ? 'sichtbar' : 'aus' }}
</span>
</div>
<p class="mt-2 text-sm leading-6 text-slate-600">
Clip-Einreichungen sind optional. Finale Voting-Clips werden am Kandidaten als externe YouTube-/Twitch-Links gepflegt.
</p>
<p v-if="!clipSubmissionsEnabled" class="mt-1 text-xs font-semibold text-amber-800">
Public-Hinweis: {{ clipDisabledMessage }}
</p>
</div>
</div>
<section class="grid gap-4 lg:grid-cols-4">
<Card v-for="stat in stats" :key="stat.label" class="p-5">
<div class="flex items-start justify-between gap-4">
@@ -220,7 +248,7 @@ watchAdminToast(adminMessage, adminError)
<div v-if="clips.length === 0" class="px-5 py-12 text-center">
<Users class="mx-auto h-6 w-6 text-violet-300" />
<p class="mt-2 text-sm text-slate-500">
{{ submissions.length === 0 ? 'Noch keine Clip-Einreichungen in diesem Jahr.' : 'Keine Treffer für den aktuellen Filter.' }}
{{ submissions.length === 0 && !clipSubmissionsEnabled ? 'Clip-Einreichungen sind deaktiviert. Bestehende Clips würden hier weiterhin prüfbar bleiben.' : submissions.length === 0 ? 'Noch keine Clip-Einreichungen in diesem Jahr.' : 'Keine Treffer für den aktuellen Filter.' }}
</p>
</div>
</div>
+24 -4
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { FileText, Link2, MessageCircleQuestion, Share2, ShieldCheck } from '@lucide/vue'
import { FileText, Link2, MessageCircleQuestion, Mic2, Share2, ShieldCheck } from '@lucide/vue'
import { computed, ref } from 'vue'
import AdminContentBasicsSection from '../../components/admin/AdminContentBasicsSection.vue'
import AdminContentFaqPreviewModal from '../../components/admin/AdminContentFaqPreviewModal.vue'
import AdminContentFaqSection from '../../components/admin/AdminContentFaqSection.vue'
import AdminContentFooterPreviewModal from '../../components/admin/AdminContentFooterPreviewModal.vue'
import AdminContentLandingExtrasSection from '../../components/admin/AdminContentLandingExtrasSection.vue'
import AdminContentLinksSection from '../../components/admin/AdminContentLinksSection.vue'
import type { FooterPreviewKey } from '../../components/admin/AdminContentLinksSection.vue'
import AdminContentPrivacyPreviewModal from '../../components/admin/AdminContentPrivacyPreviewModal.vue'
@@ -15,9 +16,12 @@ import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import Modal from '../../components/ui/Modal.vue'
import { watchAdminToast } from '../../composables/useAdminToast'
import { privacyContentToHtml } from '../../lib/privacyContent'
import { useAwardsStore } from '../../stores/awards'
import { useAdminContentManager } from '../../components/admin/useAdminContentManager'
type ContentEditorKey = 'links' | 'socials' | 'faq' | 'privacy'
type ContentEditorKey = 'links' | 'socials' | 'faq' | 'privacy' | 'extras'
const store = useAwardsStore()
const {
form,
@@ -68,6 +72,11 @@ const footerPreviewPages = computed<Record<FooterPreviewKey, { title: string, ur
url: form.sponsorsUrl,
content: form.sponsorsContent,
},
showacts: {
title: 'Showacts',
url: form.showactsUrl,
content: form.showactsContent,
},
}))
const activeFooterPreview = computed(() => footerPreviewPages.value[footerPreviewKey.value])
@@ -77,10 +86,18 @@ const contentEditors = computed(() => [
key: 'links' as const,
eyebrow: 'Footer & Kontakt',
title: 'Rechtliche Links',
description: 'Kontaktwege, Impressum und Sponsoren-Inhalte bearbeiten.',
metric: `${[form.contactUrl, form.imprintUrl, form.sponsorsUrl].filter(Boolean).length}/3 URLs`,
description: 'Kontaktwege, Impressum, Sponsoren- und Showact-Inhalte bearbeiten.',
metric: `${[form.contactUrl, form.imprintUrl, form.sponsorsUrl, form.showactsUrl].filter(Boolean).length}/4 URLs`,
icon: Link2,
},
{
key: 'extras' as const,
eyebrow: 'Show Module',
title: 'Showacts & Sponsoren',
description: 'Landingpage-Module, Bewerbungen und Sponsorenlisten verwalten.',
metric: `${store.adminShowactApplications.length + store.adminSponsors.length} Einträge`,
icon: Mic2,
},
{
key: 'socials' as const,
eyebrow: 'Community',
@@ -205,6 +222,9 @@ function openContentEditor(key: ContentEditorKey) {
:save-site-settings="saveSiteSettings"
@open-preview="privacyPreviewOpen = true"
/>
<AdminContentLandingExtrasSection
v-else-if="activeContentEditor === 'extras'"
/>
</Modal>
<AdminContentPrivacyPreviewModal
+15 -2
View File
@@ -30,7 +30,11 @@ const route = useRoute()
const store = useAwardsStore()
const authStore = useAuthStore()
const openRiskCount = computed(() => getRiskMetricValue(store.admin.metrics))
const clipWorkflowEnabled = computed(() => store.adminOptionalFeatureSettings.clipSubmissionsEnabled)
const clipReviewVisible = computed(() => store.adminOptionalFeatureSettings.clipReviewEnabled)
const clipAdminMenuVisible = computed(() => store.adminOptionalFeatureSettings.clipAdminMenuVisible)
const pendingClipCount = computed(() => store.adminSeasonDetail.clipSubmissions.filter((clip) => clip.status === 'pending').length)
const totalClipCount = computed(() => store.adminSeasonDetail.clipSubmissions.length)
const adminWorkspaceLoading = ref(false)
const adminWorkspaceLoaded = ref(false)
const mobileNavOpen = ref(false)
@@ -51,8 +55,17 @@ const fullNavGroups = computed(() => [
{ label: 'Jahre', to: '/admin/years', description: 'Jahresstatus und Setup', icon: CalendarCog, permission: 'years', badge: () => `${store.adminSeasons.length}` },
{ label: 'Kategorien', to: '/admin/categories', description: 'Struktur und Limits', icon: Tags, permission: 'categories', badge: () => `${store.adminSeasonDetail.categories.length}` },
{ label: 'Kandidaten', to: '/admin/candidates', description: 'Kandidatenbasis pflegen', icon: Users, permission: 'candidates', badge: () => `${store.adminSeasonDetail.candidates.length}` },
{ label: 'Clips', to: '/admin/clips', description: 'Clip-Einreichungen prüfen', icon: Film, permission: 'clips', badge: () => `${pendingClipCount.value}` },
{ label: 'Landingpage', to: '/admin/content', description: 'FAQ, Footer und Datenschutz', icon: FileText, permission: 'content', badge: () => null },
...(clipAdminMenuVisible.value ? [{
label: 'Clips',
to: '/admin/clips',
description: clipWorkflowEnabled.value
? 'Clip-Einreichungen prüfen'
: clipReviewVisible.value || totalClipCount.value > 0 ? 'Optionale Clip-Inbox' : 'Clip-Workflow deaktiviert',
icon: Film,
permission: 'clips',
badge: () => pendingClipCount.value > 0 ? `${pendingClipCount.value}` : clipWorkflowEnabled.value ? null : 'aus',
}] : []),
{ label: 'Landingpage', to: '/admin/content', description: 'Public-Inhalte pflegen', icon: FileText, permission: 'content', badge: () => null },
],
},
{
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ClipboardList, Search, Sparkles, Tags, Users, XCircle } from '@lucide/vue'
import { Ban, ClipboardList, Search, Sparkles, Tags, Users, XCircle } from '@lucide/vue'
import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router'
import AdminNominationReviewModal from '../../components/admin/AdminNominationReviewModal.vue'
import AdminNominationLinkBlacklistModal from '../../components/admin/AdminNominationLinkBlacklistModal.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Card from '../../components/ui/Card.vue'
@@ -17,6 +18,7 @@ const query = ref('')
const categoryFilter = ref<number | null>(null)
const statusFilter = ref<'all' | 'selected' | 'empty-category' | 'heavy'>('all')
const reviewModalOpen = ref(false)
const blacklistModalOpen = ref(false)
const seasonDetail = computed(() => store.adminSeasonDetail)
const categoryMap = computed(() => Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category])))
@@ -170,13 +172,23 @@ watch(
</span>
</div>
</div>
<button
type="button"
class="inline-flex h-12 items-center justify-center rounded-2xl bg-violet-600 px-5 text-sm font-semibold text-white shadow-lg shadow-violet-500/20 transition hover:bg-violet-500"
@click="openReviewModal()"
>
Review-Fokus öffnen
</button>
<div class="flex flex-wrap gap-3">
<button
type="button"
class="inline-flex h-12 items-center justify-center gap-2 rounded-2xl border border-rose-200 bg-white px-5 text-sm font-semibold text-rose-700 shadow-sm shadow-rose-100/60 transition hover:bg-rose-50"
@click="blacklistModalOpen = true"
>
<Ban class="h-4 w-4" />
Link-Blacklist
</button>
<button
type="button"
class="inline-flex h-12 items-center justify-center rounded-2xl bg-violet-600 px-5 text-sm font-semibold text-white shadow-lg shadow-violet-500/20 transition hover:bg-violet-500"
@click="openReviewModal()"
>
Review-Fokus öffnen
</button>
</div>
</div>
</Card>
@@ -297,5 +309,6 @@ watch(
</section>
<AdminNominationReviewModal :open="reviewModalOpen" @close="closeReviewModal" />
<AdminNominationLinkBlacklistModal :open="blacklistModalOpen" @close="blacklistModalOpen = false" />
</div>
</template>
@@ -13,6 +13,7 @@ import { watchAdminToast } from '../../composables/useAdminToast'
const {
reviewSaving,
blacklistSaving,
adminMessage,
adminError,
reviewForms,
@@ -30,6 +31,7 @@ const {
canApproveSelected,
approveNomination,
rejectNomination,
addStreamUrlToBlacklist,
selectedPlatformValue,
handlePlatformSelection,
} = useAdminReviewsManager()
@@ -73,10 +75,12 @@ watchAdminToast(adminMessage, adminError)
:candidate-platform-options="candidatePlatformOptions"
:selected-candidate-collision="selectedCandidateCollision"
:can-approve-selected="canApproveSelected"
:blacklist-saving="blacklistSaving"
:selected-platform-value="selectedPlatformValue"
@platform-change="handlePlatformSelection"
@approve="approveNomination"
@reject="rejectNomination"
@blacklist-link="addStreamUrlToBlacklist"
/>
</div>
+80 -5
View File
@@ -4,18 +4,25 @@ import { computed, onBeforeUnmount, onMounted } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import AdminOperationalSettingsCard from '../../components/admin/AdminOperationalSettingsCard.vue'
import AdminOptionalFeaturesCard from '../../components/admin/AdminOptionalFeaturesCard.vue'
import AdminOptionalFeaturesModal from '../../components/admin/AdminOptionalFeaturesModal.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminReleaseNotesCard from '../../components/admin/AdminReleaseNotesCard.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import AdminSettingsDatabaseCard from '../../components/admin/AdminSettingsDatabaseCard.vue'
import AdminSettingsOverviewBoard from '../../components/admin/AdminSettingsOverviewBoard.vue'
import AdminWorkflowRulesCard from '../../components/admin/AdminWorkflowRulesCard.vue'
import { watchAdminErrorToast, watchAdminToast } from '../../composables/useAdminToast'
import { useAuthStore } from '../../stores/auth'
import { useAdminOperationalSettings } from '../../components/admin/useAdminOperationalSettings'
import { useAdminOptionalFeatures } from '../../components/admin/useAdminOptionalFeatures'
import { useAdminSettingsOverview } from '../../components/admin/useAdminSettingsOverview'
import { useAdminWorkflowRules } from '../../components/admin/useAdminWorkflowRules'
const authStore = useAuthStore()
const canManageOperationalSettings = computed(() => authStore.canManageOperationalSettings)
const canManageWorkflowRules = computed(() => authStore.hasPermission('settings'))
const canManageOptionalFeatures = computed(() => authStore.hasPermission('settings'))
const {
healthLoading,
@@ -52,24 +59,58 @@ const {
saveOperationalSettings,
} = useAdminOperationalSettings()
const {
hasUnsavedOptionalFeatureChanges,
optionalFeaturesError,
optionalFeaturesForm,
optionalFeaturesLoading,
optionalFeaturesModalOpen,
optionalFeaturesSaving,
optionalFeaturesSuccess,
optionalFeaturesSummary,
closeOptionalFeaturesModal,
openOptionalFeaturesModal,
saveOptionalFeatureSettings,
} = useAdminOptionalFeatures()
const {
hasUnsavedWorkflowRuleChanges,
workflowError,
workflowLoading,
workflowRules,
workflowRuleSummary,
workflowSaving,
workflowSuccess,
saveWorkflowRules,
updateWorkflowRule,
} = useAdminWorkflowRules()
watchAdminToast(operationalSuccess, operationalError)
watchAdminToast(optionalFeaturesSuccess, optionalFeaturesError)
watchAdminToast(workflowSuccess, workflowError)
watchAdminErrorToast(healthError)
function confirmDiscardOperationalChanges() {
if (!hasUnsavedOperationalChanges.value) {
function hasUnsavedSettingsChanges() {
return hasUnsavedOperationalChanges.value
|| hasUnsavedOptionalFeatureChanges.value
|| hasUnsavedWorkflowRuleChanges.value
}
function confirmDiscardSettingsChanges() {
if (!hasUnsavedSettingsChanges()) {
return true
}
return window.confirm('Du hast ungespeicherte Änderungen in Demo & Wartung. Änderungen verwerfen?')
return window.confirm('Du hast ungespeicherte Änderungen in den Einstellungen. Änderungen verwerfen?')
}
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (!hasUnsavedOperationalChanges.value) return
if (!hasUnsavedSettingsChanges()) return
event.preventDefault()
event.returnValue = ''
}
onBeforeRouteLeave(() => confirmDiscardOperationalChanges())
onBeforeRouteLeave(() => confirmDiscardSettingsChanges())
onMounted(() => window.addEventListener('beforeunload', handleBeforeUnload))
onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnload))
</script>
@@ -91,8 +132,28 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
:content-completion="contentCompletion"
/>
<AdminOptionalFeaturesCard
:loading="optionalFeaturesLoading"
:saving="optionalFeaturesSaving"
:can-manage="canManageOptionalFeatures"
:summary="optionalFeaturesSummary"
:disabled-message="optionalFeaturesForm.clipSubmissionDisabledMessage"
@configure="openOptionalFeaturesModal"
/>
<AdminReleaseNotesCard />
<AdminWorkflowRulesCard
:rules="workflowRules"
:loading="workflowLoading"
:saving="workflowSaving"
:dirty="hasUnsavedWorkflowRuleChanges"
:can-manage="canManageWorkflowRules"
:summary="workflowRuleSummary"
@update-rule="updateWorkflowRule"
@save="saveWorkflowRules"
/>
<AdminOperationalSettingsCard
v-model:demo-password="demoPasswordInput"
v-model:twitch-client-secret="twitchClientSecretInput"
@@ -124,5 +185,19 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
:health-loaded-label="healthLoadedLabel"
:refresh-database-health="refreshDatabaseHealth"
/>
<AdminOptionalFeaturesModal
:open="optionalFeaturesModalOpen"
:form="optionalFeaturesForm"
:saving="optionalFeaturesSaving"
:dirty="hasUnsavedOptionalFeatureChanges"
:can-manage="canManageOptionalFeatures"
@close="closeOptionalFeaturesModal"
@save="saveOptionalFeatureSettings"
@update:clip-submissions-enabled="optionalFeaturesForm.clipSubmissionsEnabled = $event"
@update:clip-review-enabled="optionalFeaturesForm.clipReviewEnabled = $event"
@update:clip-admin-menu-visible="optionalFeaturesForm.clipAdminMenuVisible = $event"
@update:clip-submission-disabled-message="optionalFeaturesForm.clipSubmissionDisabledMessage = $event"
/>
</div>
</template>
+29 -12
View File
@@ -23,6 +23,7 @@ const {
winnerSelections,
clearWinner,
saveWinner,
winnerRuleNoticeFor,
} = useAdminWinnersManager()
watchAdminToast(adminMessage, adminError)
@@ -131,20 +132,36 @@ watchAdminToast(adminMessage, adminError)
</p>
</div>
<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}`,
value: `${candidate.id}`,
})),
]"
/>
<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>
<div class="flex flex-wrap justify-end gap-2">
<Button :disabled="savingResultForCategory === row.category.id || row.isEmpty || !winnerSelections[row.category.id]" @click="saveWinner(row.category.id)">
<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