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:
@@ -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 & 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 & 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;">★ 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 →</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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ?? [],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user