Improve admin candidate modal UX and add clip menu visibility toggle

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AzuTear
2026-06-27 18:39:35 +02:00
parent 494eba5edd
commit 18b61bed52
119 changed files with 15638 additions and 367 deletions
@@ -61,7 +61,7 @@ const logoutLabel = computed(() => !isTeamSession.value ? 'Von Twitch abmelden'
<div class="flex shrink-0 items-center justify-between gap-4 border-b border-[#f1ecfb] px-8 py-5">
<div>
<p class="text-[10px] font-bold uppercase tracking-[0.2em] text-[#8b6cdb]">Rechtliches</p>
<h2 class="font-['Cormorant_Garamond'] text-2xl font-bold text-[#3f3556]">Datenschutzerklärung</h2>
<h2 class="font-['Fredoka'] text-2xl font-bold text-[#3f3556]">Datenschutzerklärung</h2>
</div>
<button
type="button"
@@ -101,7 +101,7 @@ const logoutLabel = computed(() => !isTeamSession.value ? 'Von Twitch abmelden'
</div>
<div class="min-w-0">
<p class="text-[10px] font-bold uppercase tracking-[0.2em] text-[#8b6cdb]">Mein Profil</p>
<h2 class="truncate font-['Cormorant_Garamond'] text-2xl font-bold leading-tight text-[#3f3556]">
<h2 class="truncate font-['Fredoka'] text-2xl font-bold leading-tight text-[#3f3556]">
@{{ profileHandle }}
</h2>
</div>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import HomeStarField from './HomeStarField.vue'
import type { HomeArchiveYearItem, HomeSelectedArchive } from './homeModalTypes'
const props = defineProps<{
@@ -13,19 +14,25 @@ const props = defineProps<{
winnerPlatformKey: (url: string) => string
winnerPlatformLabel: (url: string) => string
}>()
function initialsFor(value: string) {
const parts = value.trim().split(/\s+/).filter(Boolean)
const initials = parts.length > 1
? `${parts[0][0] ?? ''}${parts[1][0] ?? ''}`
: value.slice(0, 2)
return initials.toUpperCase()
}
</script>
<template>
<template v-if="props.archiveModalOpen">
<div class="home-modal-overlay" @click="props.onCloseArchive" style="position:fixed;inset:0;z-index:260;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(34,18,58,.46);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);">
<div class="home-modal" @click="props.archiveModalStop" style="position:relative;width:100%;max-width:1020px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:radial-gradient(62% 80% at 82% 14%,rgba(255,210,236,.58),transparent 55%),radial-gradient(70% 90% at 10% 84%,rgba(206,196,247,.48),transparent 58%),linear-gradient(180deg,#fcf7ff 0%,#f3ebfc 100%);border-radius:30px;box-shadow:0 40px 90px rgba(20,8,40,.28);border:1px solid rgba(255,255,255,.62);">
<div class="home-modal" @click="props.archiveModalStop" style="position:relative;width:100%;max-width:1020px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:radial-gradient(62% 80% at 82% 14%,rgba(255,210,236,.58),transparent 55%),radial-gradient(70% 90% at 10% 84%,rgba(206,196,247,.48),transparent 58%),linear-gradient(180deg,#fcf7ff 0%,#f3ebfc 100%);border-radius:30px;box-shadow:0 40px 90px rgba(20,8,40,.28);border:none;">
<button @click="props.onCloseArchive" aria-label="Archiv schliessen" style="position:absolute;top:18px;right:18px;z-index:5;display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;border:none;background:rgba(139,108,219,.12);color:#8b6cdb;font-size:20px;cursor:pointer;" style-hover="background:rgba(139,108,219,.2);"></button>
<div style="position:relative;padding:30px 34px 22px;border-bottom:1px solid rgba(255,210,122,.14);background:linear-gradient(135deg,#2a1842,#3a2168);overflow:hidden;">
<span style="position:absolute;top:16px;left:24px;font-size:14px;color:#ffd27a;animation:twinkle 3s ease-in-out infinite;"></span>
<span style="position:absolute;top:26px;right:84px;font-size:12px;color:#ffb9d4;animation:twinkle 2.5s ease-in-out .4s infinite;"></span>
<span style="position:absolute;bottom:18px;left:280px;font-size:13px;color:#c9b1ff;animation:twinkle 3.2s ease-in-out .9s infinite;"></span>
<HomeStarField :count="13" variant="header" />
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#c9b1ff;margin-bottom:8px;position:relative;z-index:1;">Archiv</div>
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:38px;line-height:1;margin:0;color:#fff6fb;position:relative;z-index:1;">Gewinner vergangener Jahre</h3>
<h3 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(30px,3vw,36px);line-height:1.08;margin:0;color:#fff6fb;position:relative;z-index:1;">Gewinner vergangener Jahre</h3>
</div>
<div class="home-archive-modal__body" style="display:grid;grid-template-columns:220px minmax(0,1fr);min-height:0;flex:1;">
<aside class="home-archive-modal__years" style="padding:24px 18px;border-right:1px solid rgba(139,108,219,.12);background:rgba(255,255,255,.34);overflow-y:auto;">
@@ -53,34 +60,41 @@ const props = defineProps<{
<article
v-for="winner in props.selectedArchive.winners"
:key="`${props.selectedArchive.year}-${winner.category}`"
style="display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:16px 18px;border-radius:18px;background:rgba(255,255,255,.7);border:1px solid rgba(139,108,219,.12);box-shadow:0 12px 28px rgba(139,108,219,.08);"
style="display:grid;grid-template-columns:76px minmax(0,1fr);gap:15px;padding:16px 18px;border-radius:20px;background:rgba(255,255,255,.74);border:1px solid rgba(139,108,219,.12);box-shadow:0 12px 28px rgba(139,108,219,.08);"
>
<div style="width:76px;height:76px;border-radius:22px;background:linear-gradient(135deg,#ffe1a3,#e7b13e);display:grid;place-items:center;color:#2f1b00;font-family:'Fredoka',sans-serif;font-size:24px;font-weight:800;box-shadow:0 12px 24px rgba(139,108,219,.16);">
{{ initialsFor(winner.name) }}
</div>
<div style="min-width:0;">
<div style="font-size:10px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;color:#d9942a;margin-bottom:7px;">{{ winner.category }}</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:18px;line-height:1.25;color:#3f3556;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ winner.name }}</div>
<div style="font-size:13px;color:#8a8398;margin-top:5px;">{{ winner.handle }}</div>
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:9px;margin-top:12px;">
<a :href="winner.url" target="_blank" rel="noopener" :style="props.winnerPlatformStyle(winner.url)" style-hover="transform:translateY(-1px);opacity:.88;">
{{ props.winnerPlatformLabel(winner.url) }}
</a>
<a
v-if="winner.clipUrl"
:href="winner.clipUrl"
target="_blank"
rel="noopener noreferrer"
referrerpolicy="no-referrer"
style="display:inline-flex;align-items:center;gap:6px;color:#b7791f;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:800;font-size:12px;"
>
{{ winner.clipTitle }}
</a>
</div>
<iframe
v-if="winner.clipEmbedUrl"
:src="winner.clipEmbedUrl"
:title="winner.clipEmbedTitle"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture; web-share"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"
style="width:100%;aspect-ratio:16/9;border:0;border-radius:14px;background:#1b0d2d;margin-top:12px;"
/>
</div>
<a :href="winner.url" target="_blank" rel="noopener" :style="props.winnerPlatformStyle(winner.url)" style-hover="transform:translateY(-1px);opacity:.88;">
<template v-if="props.winnerPlatformKey(winner.url) === 'twitch'">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/><path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z"/></svg>
</template>
<template v-else-if="props.winnerPlatformKey(winner.url) === 'youtube'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.4.6A3 3 0 0 0 .5 6.2 31 31 0 0 0 0 12a31 31 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.6 9.4.6 9.4.6s7.5 0 9.4-.6a3 3 0 0 0 2.1-2.1A31 31 0 0 0 24 12a31 31 0 0 0-.5-5.8zM9.5 15.5v-7l6.5 3.5z"/></svg>
</template>
<template v-else-if="props.winnerPlatformKey(winner.url) === 'x'">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M18.9 1.6h3.5l-7.6 8.7L23.7 22h-7l-5.5-7.2L4.9 22H1.4l8.1-9.3L1 1.6h7.2l4.9 6.5 5.8-6.5zm-1.2 18.3h1.9L7.1 3.6H5z"/></svg>
</template>
<template v-else-if="props.winnerPlatformKey(winner.url) === 'instagram'">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4"/><circle cx="17" cy="7" r="1.1" fill="currentColor" stroke="none"/></svg>
</template>
<template v-else-if="props.winnerPlatformKey(winner.url) === 'cake'">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11h16"/><path d="M6 11V8a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v3"/><path d="M5 11h14l-1 7a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2z"/><path d="M9 6a2 2 0 1 1 4 0"/></svg>
</template>
<template v-else>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1.5 1.5"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1.5-1.5"/></svg>
</template>
{{ props.winnerPlatformLabel(winner.url) }}
</a>
</article>
</div>
</div>
@@ -2,7 +2,7 @@
<section id="kategorien" class="home-section" style="max-width:1200px;margin:0 auto;padding:90px 24px;">
<div style="text-align:center;margin-bottom:52px;">
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent,#ff5fa2);margin-bottom:12px;"> Die Awards</div>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:-.4px;">{{ displayCategories.length }} Kategorien · 1 Sternenhimmel</h2>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:0;">{{ displayCategories.length }} Kategorien · 1 Sternenhimmel</h2>
<p style="font-size:17px;color:var(--muted,#c9b8da);max-width:560px;margin:0 auto;">Von Newcomer bis VTuber des Jahres für jede Art von Magie gibt es einen Stern zu gewinnen.</p>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:18px;" data-cat-grid>
@@ -0,0 +1,37 @@
<script setup lang="ts">
export interface HomeCategoryProgressRailItem {
id: string | number
name: string
icon: string
status: string
tone: 'empty' | 'active' | 'done' | 'error'
}
const props = defineProps<{
items: HomeCategoryProgressRailItem[]
activeId: string | number
label: string
onSelect: (id: string | number) => void
}>()
</script>
<template>
<aside class="home-wizard-rail">
<div class="home-wizard-rail__label">{{ props.label }}</div>
<button
v-for="item in props.items"
:key="item.id"
type="button"
class="home-wizard-rail__button"
:class="[
`home-wizard-rail__button--${item.tone}`,
{ 'home-wizard-rail__button--selected': item.id === props.activeId },
]"
@click="props.onSelect(item.id)"
>
<span class="home-wizard-rail__icon">{{ item.icon }}</span>
<span class="home-wizard-rail__name">{{ item.name }}</span>
<span class="home-wizard-rail__status">{{ item.status }}</span>
</button>
</aside>
</template>
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { CheckCircle2, Handshake, Mic2, Send } from '@lucide/vue'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const submitting = ref(false)
const submitMessage = ref('')
const submitError = ref('')
const form = reactive({
artistName: '',
contactEmail: '',
contactDiscord: '',
platformUrl: '',
performanceType: '',
description: '',
technicalNotes: '',
referenceUrl: '',
})
const featureFlags = computed(() => store.overview.featureFlags)
const sponsors = computed(() =>
featureFlags.value.sponsorsVisible ? store.publicSponsors.items.filter((item) => item.isVisible) : [],
)
const showSection = computed(() => sponsors.value.length > 0 || featureFlags.value.showactApplicationsEnabled)
function resetForm() {
form.artistName = ''
form.contactEmail = ''
form.contactDiscord = ''
form.platformUrl = ''
form.performanceType = ''
form.description = ''
form.technicalNotes = ''
form.referenceUrl = ''
}
async function submitShowact() {
submitting.value = true
submitMessage.value = ''
submitError.value = ''
try {
await store.submitShowactApplication(form)
submitMessage.value = 'Bewerbung ist angekommen. Das Team meldet sich, wenn es passt.'
resetForm()
} catch (error) {
submitError.value = error instanceof Error ? error.message : 'Bewerbung konnte nicht gesendet werden.'
} finally {
submitting.value = false
}
}
</script>
<template>
<section v-if="showSection" class="mx-auto grid w-full max-w-6xl gap-5 px-5 py-10 lg:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]">
<div v-if="sponsors.length > 0" class="rounded-[24px] border border-white/70 bg-white/78 p-5 shadow-[0_18px_46px_rgba(105,78,160,0.13)] backdrop-blur">
<div class="flex items-center gap-3">
<span class="grid h-11 w-11 place-items-center rounded-2xl bg-sky-100 text-sky-700">
<Handshake class="h-5 w-5" />
</span>
<div>
<p class="text-xs font-bold uppercase tracking-[0.2em] text-sky-600">Partner</p>
<h2 class="text-xl font-black text-[#3f3556]">Sponsoren</h2>
</div>
</div>
<div class="mt-5 grid gap-3 sm:grid-cols-2">
<a
v-for="sponsor in sponsors"
:key="sponsor.id"
:href="sponsor.websiteUrl || undefined"
:target="sponsor.websiteUrl ? '_blank' : undefined"
rel="noreferrer"
class="group min-h-[136px] rounded-[20px] border border-violet-100 bg-white p-4 transition hover:-translate-y-0.5 hover:border-violet-200 hover:shadow-[0_18px_36px_rgba(105,78,160,0.12)]"
>
<div class="flex items-start gap-3">
<div class="grid h-12 w-12 shrink-0 place-items-center overflow-hidden rounded-2xl border border-violet-100 bg-violet-50 text-sm font-black text-violet-700">
<img v-if="sponsor.logoUrl" :src="sponsor.logoUrl" :alt="sponsor.name" class="h-full w-full object-contain p-1" loading="lazy" />
<span v-else>{{ sponsor.name.slice(0, 2).toUpperCase() }}</span>
</div>
<div class="min-w-0">
<p class="truncate text-sm font-black text-[#3f3556]">{{ sponsor.name }}</p>
<p class="mt-1 text-xs font-bold uppercase tracking-[0.14em] text-sky-600">{{ sponsor.tier }}</p>
</div>
</div>
<p v-if="sponsor.description" class="mt-3 line-clamp-3 text-sm leading-6 text-[#746b86]">{{ sponsor.description }}</p>
</a>
</div>
</div>
<div v-if="featureFlags.showactApplicationsEnabled" class="rounded-[24px] border border-white/70 bg-white/78 p-5 shadow-[0_18px_46px_rgba(105,78,160,0.13)] backdrop-blur">
<div class="flex items-center gap-3">
<span class="grid h-11 w-11 place-items-center rounded-2xl bg-fuchsia-100 text-fuchsia-700">
<Mic2 class="h-5 w-5" />
</span>
<div>
<p class="text-xs font-bold uppercase tracking-[0.2em] text-fuchsia-600">Showacts</p>
<h2 class="text-xl font-black text-[#3f3556]">Bewerben</h2>
</div>
</div>
<form class="mt-5 space-y-3" @submit.prevent="submitShowact">
<div class="grid gap-3 sm:grid-cols-2">
<input v-model="form.artistName" required maxlength="120" placeholder="Kuenstlername" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<input v-model="form.performanceType" required maxlength="80" placeholder="Showact-Art" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
</div>
<div class="grid gap-3 sm:grid-cols-2">
<input v-model="form.contactEmail" maxlength="180" placeholder="E-Mail" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<input v-model="form.contactDiscord" maxlength="120" placeholder="Discord" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
</div>
<div class="grid gap-3 sm:grid-cols-2">
<input v-model="form.platformUrl" maxlength="500" placeholder="Kanal/Profil URL" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<input v-model="form.referenceUrl" maxlength="500" placeholder="Referenz URL" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
</div>
<textarea v-model="form.description" required rows="3" maxlength="1000" placeholder="Kurz beschreiben, was du zeigen moechtest" class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<textarea v-model="form.technicalNotes" rows="2" maxlength="1000" placeholder="Technische Hinweise, Setup, Timing" class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
<p v-if="submitError" class="rounded-2xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ submitError }}</p>
<p v-if="submitMessage" class="flex items-center gap-2 rounded-2xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
<CheckCircle2 class="h-4 w-4" />
{{ submitMessage }}
</p>
<button
type="submit"
:disabled="submitting"
class="inline-flex h-11 items-center justify-center gap-2 rounded-2xl border border-fuchsia-500 bg-fuchsia-600 px-5 text-sm font-black text-white shadow-[0_14px_28px_rgba(192,38,211,0.2)] transition hover:bg-fuchsia-500 disabled:opacity-60"
>
<Send class="h-4 w-4" />
{{ submitting ? 'Sendet ...' : 'Bewerbung senden' }}
</button>
</form>
</div>
</section>
</template>
@@ -1,8 +1,6 @@
<template>
<section class="home-stream-band" style="position:relative;overflow:hidden;background:linear-gradient(115deg,#241640 0%,#3a2168 48%,#5b3aa0 100%);">
<span style="position:absolute;top:18px;left:7%;font-size:16px;color:rgba(255,255,255,.35);animation:twinkle 3s ease-in-out infinite;"></span>
<span style="position:absolute;bottom:16px;left:46%;font-size:12px;color:rgba(255,255,255,.3);animation:twinkle 2.6s ease-in-out .5s infinite;"></span>
<span style="position:absolute;top:24px;right:38%;font-size:13px;color:rgba(255,255,255,.3);animation:twinkle 3.3s ease-in-out 1s infinite;"></span>
<HomeStarField :count="13" variant="light" />
<div class="home-stream-band__inner" style="max-width:1200px;margin:0 auto;padding:26px 24px;display:flex;align-items:center;justify-content:space-between;gap:26px;flex-wrap:wrap;">
<div class="home-stream-band__lead" style="display:flex;align-items:center;gap:18px;">
<div style="flex:none;display:inline-flex;align-items:center;justify-content:center;width:54px;height:54px;border-radius:15px;background:rgba(145,70,255,.22);border:1px solid rgba(255,255,255,.18);">
@@ -10,7 +8,7 @@
</div>
<div>
<div style="font-size:11px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#c9b1ff;margin-bottom:5px;">{{ streamEyebrow }}</div>
<div style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:26px;color:#fff;line-height:1.1;">{{ streamTitle }}</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:26px;color:#fff;line-height:1.1;">{{ streamTitle }}</div>
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:8px 14px;font-size:14px;color:rgba(255,255,255,.78);margin-top:6px;">
<span style="display:inline-flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9b1ff" stroke-width="2"><rect x="3" y="4.5" width="18" height="17" rx="2.5"/><path d="M3 9h18M8 2.5v4M16 2.5v4" stroke-linecap="round"/></svg>{{ streamMeta }}</span>
<span style="width:4px;height:4px;border-radius:50%;background:rgba(255,255,255,.4);"></span>
@@ -46,6 +44,8 @@
</template>
<script setup lang="ts">
import HomeStarField from './HomeStarField.vue'
defineProps<{
publicStreamUrl: string
streamEyebrow: string
@@ -1,9 +1,15 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import HomeCategoryProgressRail from './HomeCategoryProgressRail.vue'
import HomeNominationReviewPane from './HomeNominationReviewPane.vue'
import HomeNominationWizardPane from './HomeNominationWizardPane.vue'
import HomeSelectDropdown from './HomeSelectDropdown.vue'
import HomeStarField from './HomeStarField.vue'
import HomeVotingPickerPane from './HomeVotingPickerPane.vue'
import HomeWizardFooter from './HomeWizardFooter.vue'
import type { HomeCategoryListItem, HomeNomineeListItem, HomeSelectionOption } from './homeModalTypes'
import type { HomeNominationSubmitContext } from './homeLandingTypes'
const props = defineProps<{
modalOpen: boolean
@@ -30,8 +36,10 @@ const props = defineProps<{
voteCount: number
totalCats: number
canSubmitVote: boolean
savedVoteActive: boolean
activeCatIndex: number
submitVote: () => Promise<void> | void
submitNomination: () => Promise<void> | void
submitNomination: (context: HomeNominationSubmitContext) => Promise<void> | void
submitting: boolean
onClipCatChange: (event: Event) => void
catOptions: HomeSelectionOption[]
@@ -47,10 +55,14 @@ const props = defineProps<{
const nominationCatValue = ref(0)
const clipCatValue = ref(0)
const clipNomQuery = ref('')
const nominationDrafts = ref<Record<number, string[]>>({})
const nominationVisibleLinkCounts = ref<Record<number, number>>({})
const nominationReviewMode = ref(false)
watch(
() => props.catOptions,
(options) => {
syncNominationDrafts(options)
nominationCatValue.value = resolveOptionValue(nominationCatValue.value, options)
const nextClipCat = resolveOptionValue(clipCatValue.value, options)
if (nextClipCat !== clipCatValue.value) {
@@ -61,6 +73,16 @@ watch(
{ immediate: true },
)
watch(
() => props.modalOpen,
(isOpen) => {
if (!isOpen) {
nominationReviewMode.value = false
nominationCatValue.value = 0
}
},
)
function resolveOptionValue(value: number, options: HomeSelectionOption[]) {
return options.some((option) => option.id === value) ? value : options[0]?.id ?? 0
}
@@ -74,20 +96,213 @@ function handleClipCategoryChange(value: number) {
function notifyClipCategoryChange(value: number) {
props.onClipCatChange({ target: { value: String(value) } } as unknown as Event)
}
function syncNominationDrafts(options: HomeSelectionOption[]) {
const nextDrafts: Record<number, string[]> = {}
const nextVisibleLinkCounts: Record<number, number> = {}
for (const option of options) {
const existing = nominationDrafts.value[option.id] ?? []
nextDrafts[option.id] = [existing[0] ?? '', existing[1] ?? '', existing[2] ?? '']
const filledCount = nextDrafts[option.id].reduce((count, link, index) => link.trim() ? index + 1 : count, 0)
nextVisibleLinkCounts[option.id] = Math.min(3, Math.max(1, nominationVisibleLinkCounts.value[option.id] ?? filledCount))
}
nominationDrafts.value = nextDrafts
nominationVisibleLinkCounts.value = nextVisibleLinkCounts
}
function nominationLinksFor(categoryIndex: number) {
return nominationDrafts.value[categoryIndex] ?? ['', '', '']
}
function nominationVisibleLinkCountFor(categoryIndex: number) {
return nominationVisibleLinkCounts.value[categoryIndex] ?? 1
}
function compactNominationCategory(categoryIndex: number) {
const links = nominationLinksFor(categoryIndex)
const compactedLinks = links.map((link) => link.trim()).filter(Boolean).slice(0, 3)
const nextLinks = [compactedLinks[0] ?? '', compactedLinks[1] ?? '', compactedLinks[2] ?? '']
const nextVisibleCount = Math.max(1, compactedLinks.length)
nominationDrafts.value = {
...nominationDrafts.value,
[categoryIndex]: nextLinks,
}
nominationVisibleLinkCounts.value = {
...nominationVisibleLinkCounts.value,
[categoryIndex]: nextVisibleCount,
}
}
function onNominationLinkInput(index: number, value: string) {
const links = [...nominationLinksFor(nominationCatValue.value)]
links[index] = value
nominationDrafts.value = {
...nominationDrafts.value,
[nominationCatValue.value]: links,
}
}
function nominationFieldErrors(categoryIndex: number) {
const links = nominationLinksFor(categoryIndex)
return links.map((link, index) => {
const value = link.trim()
if (!value) return ''
if (!isHttpUrl(value)) return 'Bitte gib einen gueltigen http(s)-Link ein.'
const normalized = normalizeUrlForCompare(value)
const duplicateIndex = links.findIndex((candidate, candidateIndex) =>
candidateIndex !== index && normalizeUrlForCompare(candidate) === normalized,
)
return duplicateIndex >= 0 ? 'Dieser Link ist in dieser Kategorie schon eingetragen.' : ''
})
}
const activeNominationCategory = computed(() =>
props.catOptions.find((option) => option.id === nominationCatValue.value) ?? props.catOptions[0] ?? null,
)
const activeNominationLinks = computed(() => nominationLinksFor(nominationCatValue.value))
const activeNominationErrors = computed(() => nominationFieldErrors(nominationCatValue.value))
const activeNominationVisibleLinkCount = computed(() => nominationVisibleLinkCountFor(nominationCatValue.value))
const activeNominationRemainingLinks = computed(() => Math.max(0, 3 - activeNominationVisibleLinkCount.value))
const nominationReviewItems = computed(() =>
props.catOptions
.map((option) => ({
categoryIndex: option.id,
categoryName: stripCategoryIcon(option.label),
links: nominationLinksFor(option.id).map((link) => link.trim()).filter(Boolean),
}))
.filter((item) => item.links.length > 0),
)
const nominationTotalLinks = computed(() =>
nominationReviewItems.value.reduce((sum, item) => sum + item.links.length, 0),
)
const nominationHasErrors = computed(() =>
props.catOptions.some((option) => nominationFieldErrors(option.id).some(Boolean)),
)
const nominationRailItems = computed(() => props.catOptions.map((option) => {
const errors = nominationFieldErrors(option.id)
const linkCount = nominationLinksFor(option.id).map((link) => link.trim()).filter(Boolean).length
const isActive = option.id === nominationCatValue.value
return {
id: option.id,
name: stripCategoryIcon(option.label),
icon: option.label.trim().split(' ')[0] ?? '✦',
status: errors.some(Boolean) ? 'Fehler' : linkCount === 0 ? 'Leer' : `${linkCount} Link${linkCount === 1 ? '' : 's'}`,
tone: errors.some(Boolean) ? 'error' as const : linkCount > 0 ? 'done' as const : isActive ? 'active' as const : 'empty' as const,
}
}))
const nominationProgressText = computed(() =>
`${nominationReviewItems.value.length} / ${props.catOptions.length} Kategorien ausgefuellt`,
)
const nominationHelperText = computed(() =>
nominationTotalLinks.value > 0
? `${nominationTotalLinks.value} Link${nominationTotalLinks.value === 1 ? '' : 's'} bereit. Leere Kategorien werden uebersprungen.`
: 'Leere Kategorien kannst du einfach ueberspringen.',
)
const isLastNominationCategory = computed(() => {
const activeIndex = props.catOptions.findIndex((option) => option.id === nominationCatValue.value)
return activeIndex >= props.catOptions.length - 1
})
function selectNominationCategory(id: string | number) {
compactNominationCategory(nominationCatValue.value)
nominationCatValue.value = Number(id)
nominationReviewMode.value = false
}
function goToPreviousNominationCategory() {
const activeIndex = props.catOptions.findIndex((option) => option.id === nominationCatValue.value)
const previous = props.catOptions[Math.max(0, activeIndex - 1)]
if (previous) selectNominationCategory(previous.id)
}
function goToNextNominationCategory() {
const activeIndex = props.catOptions.findIndex((option) => option.id === nominationCatValue.value)
const next = props.catOptions[Math.min(props.catOptions.length - 1, activeIndex + 1)]
if (next) selectNominationCategory(next.id)
}
function openNominationReview() {
compactNominationCategory(nominationCatValue.value)
nominationReviewMode.value = true
}
function closeNominationReview() {
nominationReviewMode.value = false
}
function editNominationCategory(categoryIndex: number) {
nominationCatValue.value = categoryIndex
nominationReviewMode.value = false
}
function addNominationLinkField() {
const currentCount = nominationVisibleLinkCountFor(nominationCatValue.value)
nominationVisibleLinkCounts.value = {
...nominationVisibleLinkCounts.value,
[nominationCatValue.value]: Math.min(3, currentCount + 1),
}
}
function removeNominationLinkField(index: number) {
if (index <= 0) return
const compactedLinks = nominationLinksFor(nominationCatValue.value)
.filter((_, linkIndex) => linkIndex !== index)
.map((link) => link.trim())
.filter(Boolean)
.slice(0, 3)
const nextLinks = [compactedLinks[0] ?? '', compactedLinks[1] ?? '', compactedLinks[2] ?? '']
const previousVisibleCount = nominationVisibleLinkCountFor(nominationCatValue.value)
const nextVisibleCount = Math.max(1, Math.min(3, Math.max(previousVisibleCount - 1, compactedLinks.length)))
nominationDrafts.value = {
...nominationDrafts.value,
[nominationCatValue.value]: nextLinks,
}
nominationVisibleLinkCounts.value = {
...nominationVisibleLinkCounts.value,
[nominationCatValue.value]: nextVisibleCount,
}
}
async function submitNominationDrafts() {
if (nominationHasErrors.value || nominationTotalLinks.value === 0 || props.submitting) return
await props.submitNomination({
entries: nominationReviewItems.value.map((item) => ({
categoryIndex: item.categoryIndex,
streamUrls: item.links,
})),
})
}
function isHttpUrl(value: string) {
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
function normalizeUrlForCompare(value: string) {
return value.trim().replace(/\/+$/, '').toLowerCase()
}
function stripCategoryIcon(value: string) {
return value.replace(/^\S+\s+/, '')
}
</script>
<template>
<template v-if="props.modalOpen">
<div class="home-modal-overlay" @click="props.closeModal" style="position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.5);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);">
<div class="home-modal" :class="{ 'home-modal--wide': props.isPicker && (props.isVote || (props.nominationPhase && !props.isVote)) }" @click="props.stop" style="position:relative;width:100%;max-width:680px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);">
<button @click="props.closeModal" aria-label="Schliessen" style="position:absolute;top:16px;right:16px;z-index:5;display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;" style-hover="background:#e6dcf6;"></button>
<div class="home-modal" :class="{ 'home-modal--wide': props.isPicker, 'home-modal--voting': props.isPicker && props.isVote && props.notSubmitted }" @click="props.stop" style="position:relative;width:100%;max-width:680px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);">
<button @click="props.closeModal" aria-label="Schliessen" style="position:absolute;top:18px;right:18px;z-index:5;display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;border:none;background:rgba(139,108,219,.12);color:#8b6cdb;font-size:20px;cursor:pointer;" style-hover="background:rgba(139,108,219,.2);"></button>
<template v-if="props.submitted">
<div class="home-modal__success" style="padding:56px 40px;text-align:center;">
<div style="width:72px;height:72px;margin:0 auto 22px;border-radius:50%;background:linear-gradient(135deg,#2bbd6e,#1f9d5a);display:flex;align-items:center;justify-content:center;box-shadow:0 14px 32px rgba(31,157,90,.32);">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:30px;margin:0 0 12px;color:#3f3556;">{{ props.successTitle }}</h3>
<h3 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:28px;line-height:1.14;margin:0 0 12px;color:#3f3556;">{{ props.successTitle }}</h3>
<p style="font-size:16px;line-height:1.6;color:#7d7491;max-width:420px;margin:0 auto 28px;">{{ props.successText }}</p>
<button @click="props.closeModal" style="padding:14px 32px;border-radius:13px;border:none;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:pointer;box-shadow:0 10px 24px rgba(124,86,196,.3);" style-hover="transform:translateY(-2px);">Schliessen</button>
</div>
@@ -104,7 +319,7 @@ function notifyClipCategoryChange(value: number) {
<template v-if="props.isShow">
<div class="home-modal__show" style="padding:38px 40px 40px;">
<div style="display:inline-flex;align-items:center;gap:8px;padding:5px 14px;border-radius:999px;background:#f3eefb;color:#a98ddb;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:18px;">Award-Show</div>
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:30px;margin:0 0 10px;color:#3f3556;">Sei live dabei </h3>
<h3 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:28px;line-height:1.14;margin:0 0 10px;color:#3f3556;">Sei live dabei </h3>
<p style="font-size:15.5px;line-height:1.6;color:#7d7491;margin:0 0 24px;">Die grosse Live-Show findet am <strong style="color:#5f44ad;">{{ props.formatShowDate() }}</strong> statt. Aktiviere eine Erinnerung, damit du nichts verpasst.</p>
<div class="home-modal__reminder-form" style="display:flex;gap:10px;margin-bottom:14px;">
<input data-dc-ref="emailRef" type="email" placeholder="deine@email.de" style="flex:1;padding:14px 16px;border-radius:12px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:'Outfit',sans-serif;font-size:15px;color:#3f3556;outline:none;" style-focus="border-color:#8b6cdb;" />
@@ -116,104 +331,80 @@ function notifyClipCategoryChange(value: number) {
<template v-if="props.isPicker">
<template v-if="props.nominationPhase && !props.isVote">
<div class="home-modal__nomination-submit" style="display:flex;flex-direction:column;max-height:calc(88vh - 80px);">
<div class="home-modal__picker-header" style="padding:30px 36px 18px;border-bottom:1px solid #f1ecfb;flex:none;">
<div style="display:inline-flex;align-items:center;gap:7px;padding:4px 12px;border-radius:999px;background:rgba(247,108,173,.12);color:#c7508a;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:14px;"> Nominierungsphase</div>
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:28px;margin:0 0 6px;color:#3f3556;">{{ props.pickerTitle }}</h3>
<p style="font-size:14px;line-height:1.5;color:#8a8398;margin:0;">{{ props.pickerSubtitle }}</p>
<div class="home-modal__nomination-submit home-wizard-shell">
<div class="home-modal__picker-header" style="padding:30px 36px 22px;border-bottom:1px solid #f1ecfb;flex:none;">
<HomeStarField :count="13" variant="header" />
<div style="display:inline-flex;align-items:center;gap:7px;padding:6px 14px;border-radius:999px;background:rgba(201,177,255,.12);border:1px solid rgba(201,177,255,.34);color:#c9b1ff;font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;margin-bottom:12px;"> Nominierungsphase</div>
<div class="home-wizard-header-row">
<div>
<h3 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(30px,3vw,36px);line-height:1.08;margin:0 0 14px;color:#3f3556;">Streamer nominieren</h3>
<p style="font-size:16px;line-height:1.5;color:#8a8398;margin:0;">Reiche pro Kategorie bis zu drei Stream- oder Kanal-Links ein.</p>
</div>
</div>
</div>
<div class="home-modal__nomination-grid" style="display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px;padding:24px 36px 32px;overflow-y:auto;">
<section style="display:flex;flex-direction:column;gap:15px;padding:18px;border-radius:18px;background:#fcfaff;border:1px solid #efe7fb;">
<div>
<h4 style="font-family:'Outfit',sans-serif;font-size:16px;font-weight:800;color:#3f3556;margin:0 0 5px;">VTuber oder Streamer nominieren</h4>
<p style="font-size:13px;line-height:1.45;color:#8a8398;margin:0;">Der Stream-Link geht direkt in den Admin-Review; den Anzeigenamen vergibt das Team.</p>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Kategorie</label>
<HomeSelectDropdown
v-model="nominationCatValue"
data-ref="nominationCatRef"
label="Kategorie fuer Nominierung auswaehlen"
:options="props.catOptions"
/>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Stream-Link <span style="color:#e11d48;">*</span></label>
<input data-dc-ref="nominationStreamUrlRef" type="url" placeholder="https://plattform.de/dein-kanal" maxlength="300" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
<p style="font-size:12.5px;line-height:1.45;color:#8a8398;margin:6px 0 0;">Offizieller Kanal- oder Stream-Link der Person.</p>
</div>
<button @click="props.submitNomination" :disabled="props.submitting" :style="props.submitting ? 'width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:#d1c4e9;color:#9e8cc5;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:15px;cursor:not-allowed;' : 'width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:15px;cursor:pointer;box-shadow:0 10px 24px rgba(124,86,196,.3);'" style-hover="transform:translateY(-2px);">
{{ props.submitting ? 'Speichert ...' : 'Nominierung einreichen' }}
</button>
</section>
<section style="display:flex;flex-direction:column;gap:15px;padding:18px;border-radius:18px;background:#fffafd;border:1px solid #f4d8e9;">
<div>
<h4 style="font-family:'Outfit',sans-serif;font-size:16px;font-weight:800;color:#3f3556;margin:0 0 5px;">Clip einreichen</h4>
<p style="font-size:13px;line-height:1.45;color:#8a8398;margin:0;">Highlight-Clips können separat zur Show-Prüfung eingereicht werden.</p>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Clip-Link <span style="color:#e11d48;">*</span></label>
<input data-dc-ref="clipUrlRef" type="url" placeholder="https://plattform.de/dein-clip" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
</div>
<div class="home-modal__clip-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Kategorie</label>
<HomeSelectDropdown
v-model="clipCatValue"
label="Kategorie fuer Clip auswaehlen"
:options="props.catOptions"
@change="handleClipCategoryChange"
/>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Für welche:n VTuber?</label>
<input
v-model="clipNomQuery"
data-dc-ref="clipNomSearchRef"
type="text"
list="clip-nominee-options"
placeholder="Name suchen"
autocomplete="off"
style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;"
style-focus="border-color:#8b6cdb;"
/>
<datalist id="clip-nominee-options">
<option v-for="option in props.clipNomOptions" :key="option.id" :value="option.label" />
</datalist>
</div>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Beschreibung <span style="color:#a99fc0;font-weight:400;">(optional)</span></label>
<textarea data-dc-ref="clipDescRef" placeholder="Warum ist dieser Moment so besonders?" rows="3" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;resize:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;"></textarea>
</div>
<label style="display:flex;align-items:flex-start;gap:11px;cursor:pointer;padding:13px;border-radius:11px;background:#fff;border:1px solid #ede4fb;">
<input type="checkbox" :checked="props.clipDsgvo" @change="props.clipDsgvoChange" style="width:17px;height:17px;flex:none;margin-top:2px;accent-color:#8b6cdb;cursor:pointer;" />
<span style="font-size:13px;line-height:1.6;color:#6f6685;">Ich stimme der Verarbeitung meiner Daten gemäß der <button @click="props.onOpenPrivacy" style="background:none;border:none;padding:0;color:#8b6cdb;font-weight:600;cursor:pointer;font-size:inherit;font-family:inherit;">Datenschutzerklärung</button> zu.</span>
</label>
<button @click="props.submitClip" :disabled="props.submitting || !props.canSubmitClip" :style="props.clipSubmitStyle">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
{{ props.submitting ? 'Speichert ...' : 'Clip einreichen' }}
</button>
</section>
<div class="home-wizard-layout">
<HomeCategoryProgressRail
label="Kategorien"
:items="nominationRailItems"
:active-id="nominationCatValue"
:on-select="selectNominationCategory"
/>
<main class="home-wizard-content">
<HomeNominationReviewPane
v-if="nominationReviewMode"
:items="nominationReviewItems"
:total-links="nominationTotalLinks"
:total-categories="props.catOptions.length"
:on-edit-category="editNominationCategory"
/>
<HomeNominationWizardPane
v-else
:category-name="activeNominationCategory ? stripCategoryIcon(activeNominationCategory.label) : 'Kategorie'"
:links="activeNominationLinks"
:errors="activeNominationErrors"
:visible-link-count="activeNominationVisibleLinkCount"
:remaining-link-count="activeNominationRemainingLinks"
:on-link-input="onNominationLinkInput"
:on-add-link-field="addNominationLinkField"
:on-remove-link-field="removeNominationLinkField"
/>
</main>
</div>
<HomeWizardFooter
:progress-text="nominationProgressText"
:helper-text="nominationHelperText"
:actions="nominationReviewMode
? [
{ label: 'Zurueck', tone: 'secondary', onClick: closeNominationReview },
{ label: props.submitting ? 'Speichert ...' : 'Nominierungen einreichen', disabled: props.submitting || nominationHasErrors || nominationTotalLinks === 0, onClick: submitNominationDrafts },
]
: [
{ label: 'Zurueck', tone: 'secondary', disabled: nominationCatValue === props.catOptions[0]?.id, onClick: goToPreviousNominationCategory },
{ label: isLastNominationCategory ? 'Nominierungen pruefen' : 'Weiter', disabled: nominationHasErrors, onClick: isLastNominationCategory ? openNominationReview : goToNextNominationCategory },
]"
/>
</div>
</template>
<template v-else>
<div style="display:flex;flex-direction:column;min-height:0;">
<div class="home-modal__picker-header" style="padding:30px 36px 18px;border-bottom:1px solid #f1ecfb;">
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:27px;margin:0 0 6px;color:#3f3556;">{{ props.pickerTitle }}</h3>
<p style="font-size:14px;line-height:1.5;color:#8a8398;margin:0;">{{ props.pickerSubtitle }}</p>
<div class="home-vote-shell">
<div class="home-modal__picker-header" style="padding:30px 36px 22px;border-bottom:1px solid #f1ecfb;">
<HomeStarField :count="13" variant="header" />
<div style="position:relative;z-index:1;display:inline-flex;align-items:center;gap:7px;padding:6px 14px;border-radius:999px;background:rgba(201,177,255,.12);border:1px solid rgba(201,177,255,.34);color:#c9b1ff;font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;margin-bottom:12px;">{{ props.isVote ? '★ Votingphase' : '✦ Nominierungen' }}</div>
<h3 style="position:relative;z-index:1;font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(30px,3vw,36px);line-height:1.08;margin:0 0 14px;color:#3f3556;">{{ props.pickerTitle }}</h3>
<p style="position:relative;z-index:1;font-size:16px;line-height:1.5;color:#8a8398;margin:0;">{{ props.pickerSubtitle }}</p>
</div>
<HomeVotingPickerPane
:cat-list="props.catList"
:active-cat-index="props.activeCatIndex"
:active-cat-name="props.activeCatName"
:noms="props.noms"
:vote-count="props.voteCount"
:total-cats="props.totalCats"
:can-submit-vote="props.canSubmitVote"
:saved-vote-active="props.savedVoteActive"
:submit-vote="props.submitVote"
:submitting="props.submitting"
:readonly-mode="!props.isVote"
/>
</div>
</template>
@@ -223,7 +414,7 @@ function notifyClipCategoryChange(value: number) {
<div class="home-modal__clip" style="display:flex;flex-direction:column;max-height:calc(88vh - 80px);">
<div class="home-modal__clip-header" style="padding:30px 40px 24px;border-bottom:1px solid #f1ecfb;flex:none;">
<div style="display:inline-flex;align-items:center;gap:7px;padding:4px 12px;border-radius:999px;background:rgba(247,108,173,.12);color:#c7508a;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:14px;"> Nominierungsphase</div>
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:28px;margin:0 0 6px;color:#3f3556;">Clip einreichen</h3>
<h3 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:26px;line-height:1.14;margin:0 0 6px;color:#3f3556;">Clip einreichen</h3>
<p style="font-size:14px;line-height:1.5;color:#8a8398;margin:0;">Die besten Clips werden in der Award-Show präsentiert.</p>
</div>
<div class="home-modal__clip-body" style="padding:24px 40px 32px;overflow-y:auto;display:flex;flex-direction:column;gap:16px;">
@@ -3,8 +3,10 @@ import { useRouter } from 'vue-router'
import CinematicStarLoader from '../CinematicStarLoader.vue'
import HomeCategoriesSection from './HomeCategoriesSection.vue'
import HomeHeroShell from './HomeHeroShell.vue'
import HomeExtrasSection from './HomeExtrasSection.vue'
import HomeLandingModals from './HomeLandingModals.vue'
import HomeParticipationSection from './HomeParticipationSection.vue'
import HomeStickyCountdownPill from './HomeStickyCountdownPill.vue'
import HomeSupportFooterSection from './HomeSupportFooterSection.vue'
import HomeTimelineSection from './HomeTimelineSection.vue'
import HomeWinnerShowcaseSection from './HomeWinnerShowcaseSection.vue'
@@ -50,6 +52,7 @@ const {
voteCount,
totalCats,
canSubmitVote,
savedVoteActive,
activeCatName,
phaseCardTitle,
phaseCardDescription,
@@ -89,6 +92,7 @@ const {
archiveYears,
selectedArchive,
winnerShowcase,
activeCat,
submitted,
submitting,
formError,
@@ -143,13 +147,13 @@ const {
} = useHomeLandingState()
const previewPhaseButtons = [
{ key: 'nomination', label: 'Nominierung', hint: 'Einreichen & Clips' },
{ key: 'nomination', label: 'Nominierung', hint: 'Links einreichen' },
{ key: 'voting', label: 'Voting', hint: 'Community stimmt ab' },
{ key: 'preparation', label: 'Aufbereitung', hint: 'Pause vor Show' },
{ key: 'show', label: 'Show', hint: 'Live-Finale' },
] as const
const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmit } = useHomeLandingViewEffects({
const { setRootEl, landingLoaderVisible, stickyCountdownVisible, handleClipSubmit } = useHomeLandingViewEffects({
router,
store,
authStore,
@@ -165,7 +169,6 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
preparationPhase,
completedPhase,
initializeHomeInteractions,
submitNomination,
submitClip,
})
</script>
@@ -186,6 +189,13 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
</transition>
<div :style="rootStyle" class="home-landing">
<HomeStickyCountdownPill
:show-countdown="showCountdown"
:visible="stickyCountdownVisible"
:show-phase="showPhase"
:public-stream-url="publicStreamUrl"
/>
<div v-if="isAdmin" class="home-demo-preview" aria-label="Demo Phasen-Vorschau">
<div class="home-demo-preview__label">
<span></span>
@@ -282,6 +292,8 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
:on-section-action="onSectionAction"
/>
<HomeExtrasSection />
<HomeSupportFooterSection
:community-social-links="communitySocialLinks"
:site-content="siteContent"
@@ -314,13 +326,15 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
:picker-title="pickerTitle"
:picker-subtitle="pickerSubtitle"
:cat-list="catList"
:active-cat-index="activeCat"
:active-cat-name="activeCatName"
:noms="noms"
:vote-count="voteCount"
:total-cats="totalCats"
:can-submit-vote="canSubmitVote"
:saved-vote-active="savedVoteActive"
:submit-vote="submitVote"
:submit-nomination="handleNominationSubmit"
:submit-nomination="submitNomination"
:submitting="submitting"
:on-clip-cat-change="onClipCatChange"
:cat-options="catOptions"
@@ -9,6 +9,7 @@ import type {
HomeSelectedArchive,
HomeSelectionOption,
} from './homeModalTypes'
import type { HomeNominationSubmitContext } from './homeLandingTypes'
import type { AuthSession } from '../../types/awards'
defineProps<{
@@ -31,13 +32,15 @@ defineProps<{
pickerTitle: string
pickerSubtitle: string
catList: HomeCategoryListItem[]
activeCatIndex: number
activeCatName: string
noms: HomeNomineeListItem[]
voteCount: number
totalCats: number
canSubmitVote: boolean
savedVoteActive: boolean
submitVote: () => Promise<void> | void
submitNomination: () => Promise<void> | void
submitNomination: (context: HomeNominationSubmitContext) => Promise<void> | void
submitting: boolean
onClipCatChange: (event: Event) => void
catOptions: HomeSelectionOption[]
@@ -100,11 +103,13 @@ defineProps<{
:picker-title="pickerTitle"
:picker-subtitle="pickerSubtitle"
:cat-list="catList"
:active-cat-index="activeCatIndex"
:active-cat-name="activeCatName"
:noms="noms"
:vote-count="voteCount"
:total-cats="totalCats"
:can-submit-vote="canSubmitVote"
:saved-vote-active="savedVoteActive"
:submit-vote="submitVote"
:submit-nomination="submitNomination"
:submitting="submitting"
@@ -0,0 +1,37 @@
<script setup lang="ts">
const props = defineProps<{
missingCategories: string[]
onBack: () => void
onConfirm: () => Promise<void> | void
submitting: boolean
submitLabel: string
hideActions?: boolean
}>()
</script>
<template>
<section class="home-missing-votes">
<p class="home-vote-picker__eyebrow">Pruefen</p>
<h4>Nicht alle Kategorien gewaehlt</h4>
<p>
In {{ props.missingCategories.length }} Kategorien fehlt noch eine Stimme. Du kannst trotzdem speichern
oder zurueckgehen und weitere Kategorien auswaehlen.
</p>
<div class="home-missing-votes__chips">
<span v-for="category in props.missingCategories" :key="category">{{ category }}</span>
</div>
<div v-if="!props.hideActions" class="home-missing-votes__actions">
<button type="button" class="home-wizard-footer__button home-wizard-footer__button--secondary" @click="props.onBack">
Zurueck zum Voting
</button>
<button
type="button"
class="home-wizard-footer__button home-wizard-footer__button--primary"
:disabled="props.submitting"
@click="props.onConfirm"
>
{{ props.submitting ? 'Speichert ...' : props.submitLabel }}
</button>
</div>
</section>
</template>
@@ -0,0 +1,46 @@
<script setup lang="ts">
export interface HomeNominationReviewItem {
categoryIndex: number
categoryName: string
links: string[]
}
const props = defineProps<{
items: HomeNominationReviewItem[]
totalLinks: number
totalCategories: number
onEditCategory: (index: number) => void
}>()
</script>
<template>
<section class="home-nomination-review">
<div class="home-nomination-review__hero">
<p class="home-vote-picker__eyebrow">Pruefen</p>
<h4>Nominierungen einreichen?</h4>
<p>
Du reichst {{ props.totalLinks }} Links in {{ props.items.length }} von
{{ props.totalCategories }} Kategorien ein. Leere Kategorien werden uebersprungen.
</p>
</div>
<div v-if="props.items.length" class="home-nomination-review__list">
<article v-for="item in props.items" :key="item.categoryName" class="home-nomination-review__item">
<div>
<h5>{{ item.categoryName }}</h5>
<span>{{ item.links.length }} Link{{ item.links.length === 1 ? '' : 's' }}</span>
</div>
<ul>
<li v-for="link in item.links" :key="link">{{ link }}</li>
</ul>
<button type="button" @click="props.onEditCategory(item.categoryIndex)">
Bearbeiten
</button>
</article>
</div>
<div v-else class="home-vote-picker__empty">
Noch keine Links eingetragen.
</div>
</section>
</template>
@@ -0,0 +1,59 @@
<script setup lang="ts">
const props = defineProps<{
categoryName: string
links: string[]
errors: string[]
visibleLinkCount: number
remainingLinkCount: number
onLinkInput: (index: number, value: string) => void
onAddLinkField: () => void
onRemoveLinkField: (index: number) => void
}>()
</script>
<template>
<section class="home-nomination-pane">
<header class="home-vote-picker__category-header">
<div>
<h4>{{ props.categoryName }}</h4>
</div>
</header>
<div class="home-nomination-pane__fields">
<label v-for="(_, index) in props.links.slice(0, props.visibleLinkCount)" :key="index" class="home-nomination-field">
<span class="home-nomination-field__header">
<span>{{ index === 0 ? 'Link 1' : `Link ${index + 1} optional` }}</span>
<button
v-if="index > 0"
type="button"
class="home-nomination-field__remove"
@click.prevent="props.onRemoveLinkField(index)"
>
Entfernen
</button>
</span>
<input
:value="props.links[index]"
type="url"
maxlength="300"
placeholder="https://plattform.de/dein-kanal"
:aria-invalid="Boolean(props.errors[index])"
@input="props.onLinkInput(index, ($event.target as HTMLInputElement).value)"
/>
<small v-if="props.errors[index]" class="home-nomination-field__error">{{ props.errors[index] }}</small>
<small v-else>Offizieller Kanal- oder Stream-Link.</small>
</label>
</div>
<button
v-if="props.remainingLinkCount > 0"
type="button"
class="home-nomination-pane__add-link"
@click="props.onAddLinkField"
>
<span>+ Weiteren Link hinzufuegen</span>
<small>Noch {{ props.remainingLinkCount }} moeglich</small>
</button>
</section>
</template>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import HomeStarField from './HomeStarField.vue'
const props = defineProps<{
sectionTitle: string
sectionText: string
@@ -27,7 +29,7 @@ const props = defineProps<{
<span style="flex:none;display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;border:2px solid #c9b6f0;font-family:'Outfit',sans-serif;font-weight:700;font-size:16px;color:#7c5fc8;">1</span>
<h3 style="margin:0;font-family:'Outfit',sans-serif;font-weight:700;font-size:19px;letter-spacing:1.5px;text-transform:uppercase;color:#5f44ad;">Nominieren</h3>
</div>
<p style="margin:0;font-size:15.5px;line-height:1.6;color:#7d7491;">Nominiere deine Favoriten in jeder Kategorie — pro Kategorie 3 Nominierungen. Du kannst auch Clips deiner Lieblingsmomente einsenden.</p>
<p style="margin:0;font-size:15.5px;line-height:1.6;color:#7d7491;">Nominiere deine Favoriten in jeder Kategorie — pro Kategorie bis zu 3 Stream- oder Kanal-Links.</p>
</div>
<svg data-step-arrow width="58" height="44" viewBox="0 0 58 44" fill="none" style="flex:none;margin-top:64px;"><path d="M3 12 C 22 4, 40 8, 50 26" stroke="#c2aef0" stroke-width="3" stroke-linecap="round"/><path d="M50 26 L 39 25 M50 26 L 49 14" stroke="#c2aef0" stroke-width="3" stroke-linecap="round"/></svg>
<div style="flex:1;">
@@ -51,10 +53,8 @@ const props = defineProps<{
</div>
<div class="home-cta-card" style="position:relative;overflow:hidden;border-radius:30px;padding:56px 40px;text-align:center;background:linear-gradient(135deg,#8b6cdb,#b78bff);box-shadow:0 30px 70px rgba(124,86,196,.4);">
<span style="position:absolute;top:24px;left:8%;font-size:22px;color:#fff;opacity:.6;animation:twinkle 3s ease-in-out infinite;">✦</span>
<span style="position:absolute;bottom:30px;right:12%;font-size:18px;color:#fff;opacity:.6;animation:twinkle 2.6s ease-in-out .5s infinite;">✧</span>
<span style="position:absolute;top:40%;right:6%;font-size:14px;color:#fff;opacity:.5;animation:twinkle 3.2s ease-in-out 1s infinite;">✦</span>
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(30px,3.8vw,44px);color:#fff;margin:0 0 14px;">{{ props.sectionTitle }}</h2>
<HomeStarField :count="13" variant="light" />
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(30px,3.8vw,44px);color:#fff;margin:0 0 14px;">{{ props.sectionTitle }}</h2>
<p style="font-size:18px;color:rgba(255,255,255,.92);max-width:540px;margin:0 auto 28px;">{{ props.sectionText }}</p>
<button
v-if="props.sectionActionDisabled"
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { ref } from 'vue'
type StarVariant = 'pastel' | 'light' | 'header'
const props = withDefaults(defineProps<{
count?: number
variant?: StarVariant
}>(), {
count: 12,
variant: 'light',
})
const palettes: Record<StarVariant, string[]> = {
pastel: ['#e7b13e', '#b79be8', '#f3a9cb', '#cdb6f0', '#ffb9d4'],
light: ['rgba(255,255,255,.62)', 'rgba(255,246,251,.54)', 'rgba(255,210,122,.5)', 'rgba(201,177,255,.58)'],
header: ['#ffd27a', '#ffb9d4', '#c9b1ff', '#fff6fb'],
}
function createStarPosition() {
return {
'--star-x': `${Math.round(4 + Math.random() * 92)}%`,
'--star-y': `${Math.round(8 + Math.random() * 84)}%`,
'--star-size': `${9 + Math.round(Math.random() * 12)}px`,
'--star-rotate': `${Math.round(Math.random() * 80 - 40)}deg`,
}
}
function createStarStyle(index: number) {
const palette = palettes[props.variant]
const alpha = props.variant === 'pastel' ? 0.85 : 0.62
return {
...createStarPosition(),
'--star-color': palette[index % palette.length],
'--star-alpha': String(alpha),
'--star-delay': `${(Math.random() * 8).toFixed(2)}s`,
'--star-duration': `${(8 + Math.random() * 5).toFixed(2)}s`,
}
}
const stars = ref(Array.from({ length: props.count }, (_, index) => {
const symbol = index % 3 === 1 ? '✧' : '✦'
return {
id: index,
symbol,
style: createStarStyle(index),
}
}))
function shuffleStar(index: number) {
stars.value[index].style = {
...stars.value[index].style,
...createStarPosition(),
}
}
</script>
<template>
<span class="home-star-field" aria-hidden="true">
<span
v-for="star in stars"
:key="star.id"
class="home-star-field__star"
:style="star.style"
@animationiteration="shuffleStar(star.id)"
>
{{ star.symbol }}
</span>
</span>
</template>
@@ -0,0 +1,37 @@
<template>
<div
v-if="showCountdown"
class="home-sticky-countdown"
:class="{ 'home-sticky-countdown--visible': visible }"
aria-live="polite"
>
<div class="home-sticky-countdown__inner">
<span class="home-sticky-countdown__dot" aria-hidden="true"></span>
<span data-dc-ref="stickyCountdownLabelRef" class="home-sticky-countdown__label">Finale startet in</span>
<span class="home-sticky-countdown__time" aria-label="Countdown bis zum Finale">
<span data-dc-ref="sbdRef">00</span><span class="home-sticky-countdown__unit">T</span>
<span data-dc-ref="sbhRef">00</span><span class="home-sticky-countdown__sep">:</span>
<span data-dc-ref="sbmRef">00</span><span class="home-sticky-countdown__sep">:</span>
<span data-dc-ref="sbsRef">00</span>
</span>
<a
v-if="showPhase"
:href="publicStreamUrl"
target="_blank"
rel="noopener noreferrer"
class="home-sticky-countdown__cta"
>
Zur Show
</a>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
showCountdown: boolean
visible: boolean
showPhase: boolean
publicStreamUrl: string
}>()
</script>
@@ -78,7 +78,7 @@ function isSafePublicUrl(value: string | null | undefined) {
<div style="position:relative;display:grid;grid-template-columns:1.62fr 1fr;gap:26px;align-items:stretch;" data-community-grid>
<div class="home-community-card" style="position:relative;overflow:visible;background:#f5f0fc;border:1px solid #e9e0f8;border-radius:28px;padding:46px 46px 42px;min-height:360px;">
<div class="home-community-card__content" style="position:relative;z-index:2;max-width:62%;">
<h2 style="margin:0 0 14px;font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(26px,3vw,36px);letter-spacing:.5px;color:#5f44ad;">COMMUNITY &amp; UPDATES</h2>
<h2 style="margin:0 0 14px;font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(26px,3vw,36px);letter-spacing:.5px;color:#5f44ad;">COMMUNITY &amp; UPDATES</h2>
<p style="font-size:16px;line-height:1.6;color:#7d7491;margin:0 0 26px;">Tritt unserer Community bei und verpasse keine News, Updates und Behind-the-Scenes!</p>
<div style="display:flex;flex-wrap:wrap;gap:12px;margin-bottom:26px;">
<a
@@ -117,7 +117,7 @@ function isSafePublicUrl(value: string | null | undefined) {
<img class="home-community-card__image" src="/assets/jayu-hero.png" alt="Jayuhime" style="position:absolute;z-index:1;right:-26px;bottom:0;height:430px;width:auto;pointer-events:none;filter:drop-shadow(0 18px 36px rgba(120,80,180,.22));" />
</div>
<div class="home-share-card" style="background:linear-gradient(160deg,#fdf6f6,#faf3fb);border:1px solid #f0e7f2;border-radius:28px;padding:46px 38px;display:flex;flex-direction:column;">
<h2 style="margin:0 0 14px;font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(24px,2.6vw,32px);letter-spacing:.5px;color:#5f44ad;">TEILE DIE AWARDS</h2>
<h2 style="margin:0 0 14px;font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(24px,2.6vw,32px);letter-spacing:.5px;color:#5f44ad;">TEILE DIE AWARDS</h2>
<p style="font-size:16px;line-height:1.6;color:#7d7491;margin:0 0 28px;">Supporte deine Favoriten und teile die Awards mit deinen Freunden!</p>
<div style="display:flex;flex-direction:column;gap:14px;margin-top:auto;">
<a href="#" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:15px 22px;border-radius:14px;background:#15131c;color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 10px 24px rgba(20,18,28,.2);" style-hover="transform:translateY(-2px);"><svg width="17" height="17" viewBox="0 0 24 24" fill="#fff"><path d="M18.9 1.6h3.5l-7.6 8.7L23.7 22h-7l-5.5-7.2L4.9 22H1.4l8.1-9.3L1 1.6h7.2l4.9 6.5 5.8-6.5zm-1.2 18.3h1.9L7.1 3.6H5z"/></svg>Auf X teilen</a>
@@ -132,7 +132,7 @@ function isSafePublicUrl(value: string | null | undefined) {
<section id="faq" class="home-section" style="max-width:880px;margin:0 auto;padding:90px 24px;">
<div style="text-align:center;margin-bottom:46px;">
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent,#8b6cdb);margin-bottom:12px;"> Häufige Fragen</div>
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:-.4px;color:var(--ink,#3f3556);">FAQ</h2>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:0;color:var(--ink,#3f3556);">FAQ</h2>
<p style="font-size:17px;color:var(--muted,#8a8398);max-width:520px;margin:0 auto;">Alles, was du über Nominierung, Voting und die Show wissen musst.</p>
</div>
<div style="display:flex;flex-direction:column;gap:14px;">
@@ -184,7 +184,7 @@ function isSafePublicUrl(value: string | null | undefined) {
<div style="display:flex;align-items:center;justify-content:space-between;gap:18px;padding:24px 32px 18px;border-bottom:1px solid #f1ecfb;flex:none;">
<div>
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#8b6cdb;margin-bottom:4px;">Footer Seite</div>
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:26px;margin:0;color:#3f3556;">{{ activeFooterLink.label }}</h2>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:26px;margin:0;color:#3f3556;">{{ activeFooterLink.label }}</h2>
</div>
<button @click="closeFooterLink" style="width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;" style-hover="background:#e6dcf6;"></button>
</div>
@@ -2,7 +2,7 @@
<section id="ablauf" class="home-section" style="max-width:1200px;margin:0 auto;padding:90px 24px 70px;">
<div style="text-align:center;margin-bottom:56px;">
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent,#8b6cdb);margin-bottom:12px;"> Der Ablauf</div>
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:-.4px;color:#3f3556;">In vier Schritten auf die Bühne</h2>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:0;color:#3f3556;">In vier Schritten auf die Bühne</h2>
<p style="font-size:17px;color:var(--muted,#8a8398);max-width:620px;margin:0 auto;">Von Nominierung und Voting über die Aufbereitung bis ganz zum Schluss zur grossen Show.</p>
</div>
@@ -23,7 +23,7 @@
<h3 style="font-family:'Outfit',sans-serif;font-weight:700;font-size:21px;margin:0 0 6px;color:#3f3556;">Nominierung</h3>
<div style="font-size:13px;font-weight:600;color:#a99fc0;margin-bottom:12px;">{{ formatTimelineRange('nomination') }}</div>
<p style="font-size:14.5px;line-height:1.6;color:#7d7491;margin:0 0 16px;">Die Community reicht ihre Favoriten ein pro Kategorie bis zu drei Nominierungen.</p>
<button type="button" @click="openNominate" style="display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#f1ecfb;color:#7355c8;border:none;cursor:pointer;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;" style-hover="background:#e8e0f9;">{{ nominationPhase ? 'Jetzt nominieren und Clips einsenden' : 'Nominierungen ansehen' }}</button>
<button type="button" @click="openNominate" style="display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#f1ecfb;color:#7355c8;border:none;cursor:pointer;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;" style-hover="background:#e8e0f9;">{{ nominationPhase ? 'Jetzt nominieren' : 'Nominierungen ansehen' }}</button>
</div>
</div>
@@ -1,16 +1,115 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import HomeMissingVotesConfirm from './HomeMissingVotesConfirm.vue'
import HomeWizardFooter from './HomeWizardFooter.vue'
import type { HomeCategoryListItem, HomeNomineeListItem } from './homeModalTypes'
const props = defineProps<{
catList: HomeCategoryListItem[]
activeCatIndex: number
activeCatName: string
noms: HomeNomineeListItem[]
voteCount: number
totalCats: number
canSubmitVote: boolean
savedVoteActive: boolean
submitVote: () => Promise<void> | void
submitting: boolean
readonlyMode?: boolean
}>()
const confirmMissingVotes = ref(false)
const contentRef = ref<HTMLElement | null>(null)
const activeCategoryRef = ref<HTMLElement | null>(null)
const missingVoteCategoryNames = computed(() => props.catList.filter((category) => !category.done).map((category) => category.name))
const submitLabel = computed(() => props.savedVoteActive ? 'Änderungen speichern' : 'Stimmen absenden')
const isLastCategory = computed(() => props.activeCatIndex >= props.catList.length - 1)
const footerPrimaryLabel = computed(() => {
if (!isLastCategory.value) return 'Weiter'
if (!props.canSubmitVote) return 'Erst Favorit:in wählen'
return 'Stimmen prüfen'
})
const footerHelperText = computed(() =>
confirmMissingVotes.value
? 'Pruefe die offenen Kategorien oder speichere deine aktuelle Auswahl.'
: props.readonlyMode
? 'Hier siehst du die freigegebenen Kandidat:innen der abgeschlossenen Nominierungsphase.'
: props.savedVoteActive
? 'Du bearbeitest dein gespeichertes Voting. Speichern ersetzt deine bisherige Auswahl.'
: 'Du kannst Kategorien ueberspringen und vor dem Speichern pruefen.',
)
const footerActions = computed(() =>
props.readonlyMode
? [
{
label: 'Zurueck',
tone: 'secondary' as const,
disabled: props.activeCatIndex === 0,
onClick: () => props.catList[Math.max(0, props.activeCatIndex - 1)]?.onClick(),
},
{
label: 'Weiter',
disabled: props.activeCatIndex >= props.catList.length - 1,
onClick: () => props.catList[Math.min(props.catList.length - 1, props.activeCatIndex + 1)]?.onClick(),
},
]
:
confirmMissingVotes.value
? [
{ label: 'Zurueck zum Voting', tone: 'secondary' as const, disabled: props.submitting, onClick: hideMissingVoteConfirm },
{ label: props.submitting ? 'Speichert ...' : submitLabel.value, disabled: props.submitting, onClick: submitConfirmedVote },
]
: [
{
label: 'Zurueck',
tone: 'secondary' as const,
disabled: props.activeCatIndex === 0,
onClick: () => props.catList[Math.max(0, props.activeCatIndex - 1)]?.onClick(),
},
{
label: props.submitting ? 'Speichert ...' : footerPrimaryLabel.value,
disabled: props.submitting || (isLastCategory.value && !props.canSubmitVote),
onClick: goToNextCategory,
},
],
)
function hideMissingVoteConfirm() {
confirmMissingVotes.value = false
}
watch(
() => props.activeCatIndex,
() => {
confirmMissingVotes.value = false
contentRef.value?.scrollTo({ top: 0 })
activeCategoryRef.value?.scrollIntoView({ block: 'nearest', inline: 'center' })
},
)
function setCategoryButtonRef(element: unknown, categoryIndex: number) {
if (categoryIndex === props.activeCatIndex) {
activeCategoryRef.value = element instanceof HTMLElement ? element : null
}
}
function goToNextCategory() {
if (isLastCategory.value) {
if (!props.canSubmitVote) return
confirmMissingVotes.value = missingVoteCategoryNames.value.length > 0
if (!confirmMissingVotes.value) {
void props.submitVote()
}
return
}
props.catList[props.activeCatIndex + 1]?.onClick()
}
async function submitConfirmedVote() {
await props.submitVote()
}
</script>
<template>
@@ -18,35 +117,94 @@ const props = defineProps<{
<aside class="home-vote-picker__rail">
<div class="home-vote-picker__rail-label">Kategorien</div>
<template v-for="(cat, __idx) in props.catList" :key="cat.idx ?? __idx">
<button class="home-vote-picker__category-button" @click="cat.onClick" :style="cat.rowStyle">
<button
:ref="(element) => setCategoryButtonRef(element, __idx)"
class="home-vote-picker__category-button"
@click="cat.onClick"
:style="cat.rowStyle"
>
<span :style="cat.iconStyle">{{ cat.icon }}</span>
<span>{{ cat.name }}</span>
<span :style="cat.checkStyle"></span>
<span v-if="!props.readonlyMode" :style="cat.checkStyle"></span>
</button>
</template>
</aside>
<section class="home-vote-picker__content">
<section ref="contentRef" class="home-vote-picker__content">
<HomeMissingVotesConfirm
v-if="confirmMissingVotes"
:missing-categories="missingVoteCategoryNames"
:on-back="hideMissingVoteConfirm"
:on-confirm="submitConfirmedVote"
:submitting="props.submitting"
:submit-label="submitLabel"
hide-actions
/>
<template v-else>
<header class="home-vote-picker__category-header">
<div>
<p class="home-vote-picker__eyebrow">Kategorie</p>
<h4>{{ props.activeCatName }}</h4>
</div>
<span class="home-vote-picker__hint">Nominee ansehen, Clip prüfen, Favorit:in wählen.</span>
</header>
<p v-if="props.savedVoteActive && !props.readonlyMode" class="home-vote-picker__edit-banner">
Du bearbeitest dein gespeichertes Voting. Speichern ersetzt deine bisherige Auswahl.
</p>
<div class="home-vote-picker__cards">
<template v-for="(nom, __idx) in props.noms" :key="nom.idx ?? __idx">
<article class="home-vote-card" :class="{ 'home-vote-card--selected': nom.selected, 'home-vote-card--missing-clip': !nom.hasClip }">
<div class="home-vote-card__identity">
<div class="home-vote-card__avatar">{{ nom.initials || '✦' }}</div>
<div class="home-vote-card__name-block">
<div class="home-vote-card__name-row">
<h5>{{ nom.name }}</h5>
<span>{{ nom.platform }}</span>
<a
v-if="props.readonlyMode && nom.url"
class="home-nomination-summary-card home-nomination-summary-card--clickable"
:href="nom.url"
target="_blank"
rel="noopener noreferrer"
referrerpolicy="no-referrer"
>
<div class="home-nomination-summary-card__avatar">{{ nom.initials || '✦' }}</div>
<div class="home-nomination-summary-card__body">
<h5>{{ nom.name }}</h5>
<span>{{ nom.handle || nom.platform }}</span>
</div>
</a>
<article v-else-if="props.readonlyMode" class="home-nomination-summary-card">
<div class="home-nomination-summary-card__avatar">{{ nom.initials || '✦' }}</div>
<div class="home-nomination-summary-card__body">
<h5>{{ nom.name }}</h5>
<span>{{ nom.handle || nom.platform }}</span>
</div>
</article>
<article v-else class="home-vote-card" :class="{ 'home-vote-card--selected': nom.selected, 'home-vote-card--missing-clip': !nom.hasClip }">
<div v-if="nom.selected" class="home-vote-card__burst" aria-hidden="true">
<span class="home-vote-card__burst-flare"></span>
<span class="home-vote-card__burst-ring"></span>
<span class="home-vote-card__burst-trail home-vote-card__burst-trail--left"></span>
<span class="home-vote-card__burst-trail home-vote-card__burst-trail--right"></span>
<span class="home-vote-card__burst-trail home-vote-card__burst-trail--up"></span>
<span class="home-vote-card__burst-star home-vote-card__burst-star--up"></span>
<span class="home-vote-card__burst-star home-vote-card__burst-star--left"></span>
<span class="home-vote-card__burst-star home-vote-card__burst-star--right"></span>
<span class="home-vote-card__burst-star home-vote-card__burst-star--left-small"></span>
<span class="home-vote-card__burst-star home-vote-card__burst-star--right-small"></span>
<span class="home-vote-card__burst-star home-vote-card__burst-star--top-small"></span>
</div>
<div class="home-vote-card__top">
<div class="home-vote-card__identity">
<div class="home-vote-card__avatar">{{ nom.initials || '✦' }}</div>
<div class="home-vote-card__name-block">
<div class="home-vote-card__name-row">
<h5>{{ nom.name }}</h5>
<span>{{ nom.platform }}</span>
</div>
<p>{{ nom.handle }}</p>
</div>
<p>{{ nom.handle }}</p>
</div>
<template v-if="nom.showPick">
<button class="home-vote-card__pick" :class="{ 'home-vote-card__pick--selected': nom.selected }" @click="nom.onPick">
{{ nom.btnLabel }}
</button>
</template>
</div>
<div class="home-vote-card__clip" @click.stop>
@@ -80,11 +238,6 @@ const props = defineProps<{
</p>
</div>
<template v-if="nom.showPick">
<button class="home-vote-card__pick" :class="{ 'home-vote-card__pick--selected': nom.selected }" @click="nom.onPick">
{{ nom.btnLabel }}
</button>
</template>
</article>
</template>
@@ -92,17 +245,13 @@ const props = defineProps<{
Für diese Kategorie sind noch keine Kandidat:innen freigegeben.
</div>
</div>
</template>
</section>
</div>
<div class="home-modal__vote-footer home-vote-picker__footer">
<div><span>{{ props.voteCount }}</span> / {{ props.totalCats }} Kategorien gewählt</div>
<button
@click="props.submitVote"
:disabled="props.submitting || !props.canSubmitVote"
:class="{ 'home-vote-picker__submit--disabled': !props.canSubmitVote }"
>
{{ props.submitting ? 'Speichert ...' : props.canSubmitVote ? 'Stimmen absenden ✩' : 'Erst Favorit:in wählen' }}
</button>
</div>
<HomeWizardFooter
:progress-text="props.readonlyMode ? `${props.totalCats} Kategorien` : `${props.voteCount} / ${props.totalCats} Kategorien gewählt`"
:helper-text="footerHelperText"
:actions="footerActions"
/>
</template>
@@ -1,26 +1,20 @@
<template>
<section id="nominierte" style="position:relative;overflow:hidden;background:linear-gradient(115deg,#241640 0%,#3a2168 48%,#5b3aa0 100%);border-top:1px solid rgba(255,255,255,.12);border-bottom:1px solid rgba(255,255,255,.12);">
<span style="position:absolute;top:22px;left:6%;font-size:16px;color:rgba(255,255,255,.35);animation:twinkle 3s ease-in-out infinite;"></span>
<span style="position:absolute;top:18px;left:38%;font-size:12px;color:rgba(255,255,255,.28);animation:twinkle 2.6s ease-in-out .4s infinite;"></span>
<span style="position:absolute;top:30px;right:22%;font-size:13px;color:rgba(255,255,255,.3);animation:twinkle 3.3s ease-in-out 1s infinite;"></span>
<span style="position:absolute;top:16px;right:7%;font-size:10px;color:rgba(255,255,255,.25);animation:twinkle 2.8s ease-in-out .7s infinite;"></span>
<span style="position:absolute;bottom:24px;left:14%;font-size:14px;color:rgba(255,255,255,.3);animation:twinkle 3.1s ease-in-out .3s infinite;"></span>
<span style="position:absolute;bottom:20px;right:34%;font-size:11px;color:rgba(255,255,255,.25);animation:twinkle 2.5s ease-in-out .9s infinite;"></span>
<span style="position:absolute;bottom:28px;right:8%;font-size:15px;color:rgba(255,255,255,.3);animation:twinkle 3.4s ease-in-out 1.2s infinite;"></span>
<HomeStarField :count="17" variant="light" />
<div class="home-section" style="max-width:1200px;margin:0 auto;padding:90px 24px;">
<div style="display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:46px;">
<div>
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#ff5fa2;margin-bottom:12px;">&#9733; Die Stars</div>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0;letter-spacing:-.4px;color:#fff6fb;">Gewinner {{ selectedArchive.year }}</h2>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0;letter-spacing:0;color:#fff6fb;">Gewinner {{ selectedArchive.year }}</h2>
</div>
<button @click="onOpenArchive" style="display:inline-flex;align-items:center;gap:8px;padding:12px 22px;border-radius:999px;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.12);color:#fff6fb;font-weight:600;font-size:15px;cursor:pointer;" style-hover="transform:translateY(-2px);background:rgba(255,255,255,.09);">Archiv ansehen &#8594;</button>
</div>
<div style="overflow-x:auto;overflow-y:hidden;padding:0 18px 12px 0;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.34) rgba(255,255,255,.06);">
<div style="display:flex;gap:22px;width:max-content;">
<article v-for="winner in winnerShowcase" :key="`${selectedArchive.year}-${winner.category}`" class="home-winner-card" style="flex:none;width:360px;border-radius:24px;overflow:hidden;background:#22123a;border:1px solid rgba(255,255,255,.12);">
<div class="home-winner-rail" style="overflow-x:auto;overflow-y:hidden;padding:0 18px 12px 0;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.34) rgba(255,255,255,.06);scroll-snap-type:x mandatory;scroll-padding-left:0;">
<div class="home-winner-track" style="display:flex;gap:22px;width:max-content;">
<article v-for="winner in winnerShowcase" :key="`${selectedArchive.year}-${winner.category}`" class="home-winner-card" style="flex:none;width:390px;border-radius:24px;overflow:hidden;background:#22123a;border:1px solid rgba(255,255,255,.12);">
<div style="position:relative;padding:24px 24px 20px;background:radial-gradient(circle at 28% 24%,rgba(255,95,162,.22),transparent 28%),radial-gradient(circle at 74% 78%,rgba(160,107,255,.2),transparent 32%),linear-gradient(180deg,#26133d 0%,#201132 100%);">
<div style="display:flex;align-items:center;gap:16px;margin-top:28px;margin-bottom:18px;min-height:108px;">
<div style="flex:none;width:84px;height:84px;border-radius:24px;background:linear-gradient(135deg,#ffd27a,#e7b13e);display:flex;align-items:center;justify-content:center;color:#2f1b00;font-family:'Fredoka',sans-serif;font-size:28px;font-weight:700;">{{ initialsFor(winner.name) }}</div>
<div style="display:flex;align-items:center;gap:18px;margin-top:28px;margin-bottom:20px;min-height:120px;">
<div style="flex:none;width:104px;height:104px;border-radius:28px;background:linear-gradient(135deg,#ffe1a3,#e7b13e);display:flex;align-items:center;justify-content:center;color:#2f1b00;font-family:'Fredoka',sans-serif;font-size:34px;font-weight:700;box-shadow:0 18px 42px rgba(0,0,0,.22);">{{ initialsFor(winner.name) }}</div>
<div style="min-width:0;">
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#ffd27a;margin-bottom:8px;">{{ winner.category }}</div>
<div style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:24px;line-height:1.15;color:#fff6fb;">{{ winner.name }}</div>
@@ -29,9 +23,29 @@
</div>
<a :href="winner.url" target="_blank" rel="noopener" style="display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:13px 18px;border-radius:14px;background:linear-gradient(135deg,#ff5fa2,#a06bff);color:#fff;font-family:'Fredoka',sans-serif;font-weight:600;font-size:14px;text-decoration:none;box-sizing:border-box;">{{ winner.platform }}</a>
</div>
<div style="padding:0 24px 24px;background:#1b0d2d;">
<div style="font-size:11px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;color:#ffb9d4;margin-bottom:10px;">Archivierter Gewinner</div>
<div style="border-radius:18px;overflow:hidden;background:#12091d;border:1px solid rgba(255,255,255,.08);aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;color:#c9b8da;font-family:'Fredoka',sans-serif;font-size:18px;">{{ winner.platform }} · {{ winner.handle }}</div>
<div style="padding:12px 24px 24px;background:#1b0d2d;">
<div style="font-size:11px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;color:#ffb9d4;margin-bottom:10px;">{{ winner.hasClip ? winner.clipPlatform : 'Archivierter Gewinner' }}</div>
<iframe
v-if="winner.clipEmbedUrl"
:src="winner.clipEmbedUrl"
:title="winner.clipEmbedTitle"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture; web-share"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"
style="width:100%;aspect-ratio:16/9;border:0;border-radius:18px;background:#12091d;box-shadow:0 16px 34px rgba(0,0,0,.22);"
/>
<a
v-else-if="winner.clipUrl"
:href="winner.clipUrl"
target="_blank"
rel="noopener noreferrer"
referrerpolicy="no-referrer"
style="border-radius:18px;background:#12091d;border:1px solid rgba(255,255,255,.08);aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;color:#fff6fb;font-family:'Fredoka',sans-serif;font-size:17px;text-decoration:none;text-align:center;padding:18px;"
>
{{ winner.clipTitle }}
</a>
<div v-else style="border-radius:18px;overflow:hidden;background:#12091d;border:1px solid rgba(255,255,255,.08);aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;color:#c9b8da;font-family:'Fredoka',sans-serif;font-size:18px;">{{ winner.platform }} · {{ winner.handle }}</div>
</div>
</article>
</div>
@@ -41,12 +55,20 @@
</template>
<script setup lang="ts">
import HomeStarField from './HomeStarField.vue'
type WinnerCard = {
category: string
name: string
handle: string
platform: string
url: string
clipUrl: string | null
clipTitle: string
clipPlatform: string
clipEmbedUrl: string | null
clipEmbedTitle: string
hasClip: boolean
}
defineProps<{
@@ -0,0 +1,36 @@
<script setup lang="ts">
export interface HomeWizardFooterAction {
label: string
disabled?: boolean
tone?: 'primary' | 'secondary' | 'danger'
onClick: () => Promise<void> | void
}
const props = defineProps<{
progressText: string
helperText: string
actions: HomeWizardFooterAction[]
}>()
</script>
<template>
<footer class="home-wizard-footer">
<div class="home-wizard-footer__copy">
<strong>{{ props.progressText }}</strong>
<span>{{ props.helperText }}</span>
</div>
<div class="home-wizard-footer__actions">
<button
v-for="action in props.actions"
:key="action.label"
type="button"
class="home-wizard-footer__button"
:class="`home-wizard-footer__button--${action.tone ?? 'primary'}`"
:disabled="action.disabled"
@click="action.onClick"
>
{{ action.label }}
</button>
</div>
</footer>
</template>
File diff suppressed because it is too large Load Diff
@@ -13,8 +13,10 @@ export interface HomeClipSubmitContext {
}
export interface HomeNominationSubmitContext {
categoryIndex: number
streamUrl: string
entries: Array<{
categoryIndex: number
streamUrls: string[]
}>
}
export interface HomeDisplayCategory {
@@ -12,6 +12,7 @@ export interface HomeCategoryListItem {
export interface HomeNomineeListItem {
name: string
handle: string
url: string | null
platform: string
initials: string
clipUrl: string | null
@@ -48,6 +49,12 @@ export interface HomeArchiveWinnerItem {
handle: string
platform: string
url: string
clipUrl: string | null
clipTitle: string
clipPlatform: string
clipEmbedUrl: string | null
clipEmbedTitle: string
hasClip: boolean
}
export interface HomeSelectedArchive {
@@ -1,5 +1,6 @@
import { computed, type Ref } from 'vue'
import { buildClipEmbed } from '../../lib/clipEmbeds'
import { useAwardsStore } from '../../stores/awards'
type AwardsStore = ReturnType<typeof useAwardsStore>
@@ -37,13 +38,7 @@ export function useHomeArchivePresentation(store: AwardsStore, archiveYear: Ref<
const selectedArchive = computed(() => ({
year: store.archive.year,
winners: store.archive.items.map((winner) => ({
category: winner.category,
name: winner.winnerName,
handle: winner.winnerSlug,
platform: winner.winnerPlatform,
url: winner.winnerUrl,
})),
winners: store.archive.items.map((winner) => toArchiveWinnerItem(winner)),
}))
const winnerShowcase = computed(() => selectedArchive.value.winners.slice(0, 4))
@@ -54,6 +49,36 @@ export function useHomeArchivePresentation(store: AwardsStore, archiveYear: Ref<
: "display:flex;align-items:center;justify-content:space-between;gap:10px;padding:14px 16px;border-radius:16px;border:1px solid rgba(255,210,122,.16);background:linear-gradient(135deg,#2a1842,#3a2168);color:#fff6fb;font-family:'Outfit',sans-serif;font-size:15px;font-weight:700;cursor:pointer;text-align:left;box-shadow:0 10px 24px rgba(20,8,40,.14);opacity:.9;"
}
function toArchiveWinnerItem(winner: {
category: string
winnerName: string
winnerSlug: string
winnerPlatform: string
winnerUrl: string
clipUrl?: string | null
clipTitle?: string | null
clipPlatform?: string | null
clipEmbedStatus?: string | null
}) {
const clipUrl = winner.clipUrl?.trim() || null
const clipTitle = winner.clipTitle?.trim() || 'Gewinner-Clip ansehen'
const clipPlatform = clipUrl ? winner.clipPlatform?.trim() || winnerPlatformLabel(clipUrl) : 'Kein Clip'
const clipEmbed = clipUrl && winner.clipEmbedStatus !== 'link_only' ? buildClipEmbed(clipUrl) : null
return {
category: winner.category,
name: winner.winnerName,
handle: winner.winnerSlug,
platform: winner.winnerPlatform,
url: winner.winnerUrl,
clipUrl,
clipTitle,
clipPlatform,
clipEmbedUrl: clipEmbed?.src ?? null,
clipEmbedTitle: clipEmbed?.title ?? clipTitle,
hasClip: Boolean(clipUrl),
}
}
function winnerPlatformKey(url: string) {
const normalized = url.toLowerCase()
if (normalized.includes('twitch.tv')) return 'twitch'
@@ -120,10 +120,11 @@ export function useHomeModalCandidatePresentation(params: {
const clipUrl = candidate.clipUrl?.trim() || null
const clipTitle = candidate.clipTitle?.trim() || 'Highlight-Clip ansehen'
const clipPlatform = clipUrl ? candidate.clipPlatform?.trim() || candidate.platform : 'Clip fehlt'
const clipEmbed = clipUrl ? buildClipEmbed(clipUrl) : null
const clipEmbed = clipUrl && candidate.clipEmbedStatus !== 'link_only' ? buildClipEmbed(clipUrl) : null
return {
name: candidate.displayName,
handle: candidate.channelSlug,
url: resolveCandidateUrl(candidate.channelUrl, candidate.channelSlug),
platform: candidate.platform,
initials: initialsFor(candidate.displayName),
clipUrl,
@@ -138,7 +139,7 @@ export function useHomeModalCandidatePresentation(params: {
onPick: () => cat && pickNominee(cat.id, idx),
cardStyle: `display:flex;align-items:center;justify-content:space-between;gap:12px;padding:13px 15px;border-radius:13px;transition:all .15s;border:1.5px solid ${selected ? '#8b6cdb;background:#f6f1fd;' : '#ece4f6;background:#fff;'}`,
btnStyle: "flex:none;white-space:nowrap;padding:8px 16px;border-radius:9px;border:none;cursor:pointer;font-family:'Outfit',sans-serif;font-weight:600;font-size:13px;transition:all .15s;" + (selected ? 'background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;' : 'background:#f1ecfb;color:#8b6cdb;'),
btnLabel: selected ? '✓ Gewählt' : 'Auswählen',
btnLabel: selected ? 'Auswahl entfernen' : 'Für Clip voten',
}
})
})
@@ -149,6 +150,21 @@ export function useHomeModalCandidatePresentation(params: {
}
}
function resolveCandidateUrl(channelUrl: string | null | undefined, channelSlug: string) {
const url = channelUrl?.trim() || channelSlug.trim()
if (!isHttpUrl(url) || url === '#') return null
return url
}
function isHttpUrl(value: string) {
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
function formatDateLabel(value: string) {
if (!value) return 'Noch nicht terminiert'
const date = new Date(`${value}T00:00:00`)
@@ -1,4 +1,4 @@
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
@@ -21,6 +21,8 @@ export function useHomeLandingState() {
const activeCat = ref(0)
const previewPhase = ref<HomePreviewPhase>('voting')
const clipSubmissionsEnabled = computed(() => store.overview.featureFlags.clipSubmissionsEnabled)
const clipSubmissionDisabledMessage = computed(() => store.overview.featureFlags.clipSubmissionDisabledMessage)
const {
role,
@@ -155,6 +157,7 @@ export function useHomeLandingState() {
voteCount,
totalCats,
canSubmitVote,
savedVoteActive,
activeCategory,
activeCatName,
pickerTitle,
@@ -230,7 +233,7 @@ export function useHomeLandingState() {
}
function openClipForPhase(event?: Event) {
if (!nominationPhase.value) {
if (!nominationPhase.value || !clipSubmissionsEnabled.value) {
event?.preventDefault()
return
}
@@ -238,8 +241,8 @@ export function useHomeLandingState() {
openClip(event)
}
watch(previewPhase, (phase) => {
if ((phase !== 'voting' && modal.value === 'vote') || (phase !== 'nomination' && modal.value === 'clip')) {
watch([previewPhase, clipSubmissionsEnabled], ([phase, clipsEnabled]) => {
if ((phase !== 'voting' && modal.value === 'vote') || ((phase !== 'nomination' || !clipsEnabled) && modal.value === 'clip')) {
closeModal()
}
})
@@ -282,6 +285,7 @@ export function useHomeLandingState() {
voteCount,
totalCats,
canSubmitVote,
savedVoteActive,
activeCatName,
phaseCardTitle,
phaseCardDescription,
@@ -318,6 +322,8 @@ export function useHomeLandingState() {
catOptions,
canSubmitClip,
clipSubmitStyle,
clipSubmissionsEnabled,
clipSubmissionDisabledMessage,
archiveYears,
selectedArchive,
winnerShowcase,
@@ -3,7 +3,6 @@ import type { Router } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { useAwardsStore } from '../../stores/awards'
import type { HomeNominationSubmitContext } from './homeLandingTypes'
type AuthStore = ReturnType<typeof useAuthStore>
type AwardsStore = ReturnType<typeof useAwardsStore>
@@ -38,7 +37,6 @@ interface UseHomeLandingViewEffectsParams {
preparationPhase: Readonly<Ref<boolean>>
completedPhase: Readonly<Ref<boolean>>
initializeHomeInteractions: () => Promise<void>
submitNomination: (nominationContext: HomeNominationSubmitContext) => Promise<void>
submitClip: (clipContext: { clipUrl: string; selectedNomineeQuery: string; description: string }) => Promise<void>
}
@@ -59,14 +57,12 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
preparationPhase,
completedPhase,
initializeHomeInteractions,
submitNomination,
submitClip,
} = params
const rootEl = ref<HTMLElement | null>(null)
const landingLoaderVisible = ref(true)
const nominationCatEl = ref<HTMLSelectElement | null>(null)
const nominationStreamUrlEl = ref<HTMLInputElement | null>(null)
const stickyCountdownVisible = ref(false)
const clipUrlEl = ref<HTMLInputElement | null>(null)
const clipNomSearchEl = ref<HTMLInputElement | null>(null)
const clipDescEl = ref<HTMLTextAreaElement | null>(null)
@@ -81,6 +77,11 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
bhEl: ref<HTMLElement | null>(null),
bmEl: ref<HTMLElement | null>(null),
bsEl: ref<HTMLElement | null>(null),
stickyLabelEl: ref<HTMLElement | null>(null),
sbdEl: ref<HTMLElement | null>(null),
sbhEl: ref<HTMLElement | null>(null),
sbmEl: ref<HTMLElement | null>(null),
sbsEl: ref<HTMLElement | null>(null),
}
let timer: ReturnType<typeof setInterval> | null = null
let landingLoaderTimer: ReturnType<typeof setTimeout> | null = null
@@ -97,13 +98,6 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
})
}
function handleNominationSubmit() {
return submitNomination({
categoryIndex: Number.parseInt(nominationCatEl.value?.value || '0', 10) || 0,
streamUrl: nominationStreamUrlEl.value?.value.trim() ?? '',
})
}
function assignDomRefs() {
const root = rootEl.value
if (!root) return
@@ -117,8 +111,11 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
countdownRefs.bhEl.value = root.querySelector('[data-dc-ref="bhRef"]')
countdownRefs.bmEl.value = root.querySelector('[data-dc-ref="bmRef"]')
countdownRefs.bsEl.value = root.querySelector('[data-dc-ref="bsRef"]')
nominationCatEl.value = root.querySelector('[data-dc-ref="nominationCatRef"]')
nominationStreamUrlEl.value = root.querySelector('[data-dc-ref="nominationStreamUrlRef"]')
countdownRefs.stickyLabelEl.value = root.querySelector('[data-dc-ref="stickyCountdownLabelRef"]')
countdownRefs.sbdEl.value = root.querySelector('[data-dc-ref="sbdRef"]')
countdownRefs.sbhEl.value = root.querySelector('[data-dc-ref="sbhRef"]')
countdownRefs.sbmEl.value = root.querySelector('[data-dc-ref="sbmRef"]')
countdownRefs.sbsEl.value = root.querySelector('[data-dc-ref="sbsRef"]')
clipUrlEl.value = root.querySelector('[data-dc-ref="clipUrlRef"]')
clipNomSearchEl.value = root.querySelector('[data-dc-ref="clipNomSearchRef"]')
clipDescEl.value = root.querySelector('[data-dc-ref="clipDescRef"]')
@@ -130,18 +127,30 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
root.querySelectorAll<HTMLElement>('[style-hover]').forEach((element) => {
if (element.dataset.hoverBound === '1') return
element.dataset.hoverBound = '1'
const base = element.getAttribute('style') ?? ''
const hover = element.getAttribute('style-hover') ?? ''
element.addEventListener('mouseenter', () => { element.setAttribute('style', `${base}${hover}`) })
element.addEventListener('mouseleave', () => { element.setAttribute('style', base) })
element.addEventListener('mouseenter', () => {
const base = element.getAttribute('style') ?? ''
element.dataset.hoverBaseStyle = base
element.setAttribute('style', `${base}${hover}`)
})
element.addEventListener('mouseleave', () => {
element.setAttribute('style', element.dataset.hoverBaseStyle ?? '')
delete element.dataset.hoverBaseStyle
})
})
root.querySelectorAll<HTMLElement>('[style-focus]').forEach((element) => {
if (element.dataset.focusBound === '1') return
element.dataset.focusBound = '1'
const base = element.getAttribute('style') ?? ''
const focus = element.getAttribute('style-focus') ?? ''
element.addEventListener('focus', () => { element.setAttribute('style', `${base}${focus}`) })
element.addEventListener('blur', () => { element.setAttribute('style', base) })
element.addEventListener('focus', () => {
const base = element.getAttribute('style') ?? ''
element.dataset.focusBaseStyle = base
element.setAttribute('style', `${base}${focus}`)
})
element.addEventListener('blur', () => {
element.setAttribute('style', element.dataset.focusBaseStyle ?? '')
delete element.dataset.focusBaseStyle
})
})
}
@@ -170,6 +179,26 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
setText(countdownRefs.bhEl.value, pad(showCountdown.h))
setText(countdownRefs.bmEl.value, pad(showCountdown.m))
setText(countdownRefs.bsEl.value, pad(showCountdown.s))
setText(countdownRefs.stickyLabelEl.value, showCompleted ? 'Award abgeschlossen' : showStarted ? 'Stream läuft seit' : 'Finale startet in')
setText(countdownRefs.sbdEl.value, pad(showCountdown.d))
setText(countdownRefs.sbhEl.value, pad(showCountdown.h))
setText(countdownRefs.sbmEl.value, pad(showCountdown.m))
setText(countdownRefs.sbsEl.value, pad(showCountdown.s))
}
function updateStickyCountdownVisibility() {
if (modalOpen.value || accountModalOpen.value || privacyModalOpen.value || archiveModalOpen.value) {
stickyCountdownVisible.value = false
return
}
const hero = rootEl.value?.querySelector('.home-hero')
if (!(hero instanceof HTMLElement)) {
stickyCountdownVisible.value = false
return
}
stickyCountdownVisible.value = hero.getBoundingClientRect().bottom <= 0
}
function resolveSelectedPhaseKey(): HomeTimelineKey {
@@ -183,6 +212,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
watch([modalOpen, accountModalOpen, privacyModalOpen, archiveModalOpen], () => {
document.body.style.overflow = modalOpen.value || accountModalOpen.value || privacyModalOpen.value || archiveModalOpen.value ? 'hidden' : ''
nextTick(setupDom)
updateStickyCountdownVisibility()
})
watch([submitted, streamLive, archiveYear], () => {
@@ -204,6 +234,9 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
await initializeHomeInteractions()
setupDom()
tick()
updateStickyCountdownVisibility()
window.addEventListener('scroll', updateStickyCountdownVisibility, { passive: true })
window.addEventListener('resize', updateStickyCountdownVisibility)
timer = setInterval(tick, 1000)
landingLoaderTimer = setTimeout(() => {
landingLoaderVisible.value = false
@@ -212,6 +245,8 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
onBeforeUnmount(() => {
document.body.style.overflow = ''
window.removeEventListener('scroll', updateStickyCountdownVisibility)
window.removeEventListener('resize', updateStickyCountdownVisibility)
if (timer) clearInterval(timer)
if (landingLoaderTimer) clearTimeout(landingLoaderTimer)
})
@@ -219,7 +254,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
return {
setRootEl,
landingLoaderVisible,
handleNominationSubmit,
stickyCountdownVisible,
handleClipSubmit,
}
}
@@ -21,12 +21,15 @@ export function useHomeParticipationActions(params: {
openModal: (kind: HomeInteractionModalKind) => void
closeAccountModal: () => void
votes: Ref<Record<string, number>>
savedVoteCount: Ref<number>
submitted: Ref<boolean>
submitting: Ref<boolean>
formError: Ref<string>
successKind: Ref<null | HomeSuccessKind>
clipCatIdx: Ref<number>
clipDsgvo: Ref<boolean>
clipSubmissionsEnabled: ComputedRef<boolean>
clipSubmissionDisabledMessage: ComputedRef<string>
}) {
const {
store,
@@ -43,12 +46,15 @@ export function useHomeParticipationActions(params: {
openModal,
closeAccountModal,
votes,
savedVoteCount,
submitted,
submitting,
formError,
successKind,
clipCatIdx,
clipDsgvo,
clipSubmissionsEnabled,
clipSubmissionDisabledMessage,
} = params
const {
@@ -64,6 +70,7 @@ export function useHomeParticipationActions(params: {
routerPush,
displayCategories,
votes,
savedVoteCount,
formError,
deleteConfirm,
accountActionError,
@@ -85,6 +92,7 @@ export function useHomeParticipationActions(params: {
activeCat,
previewPhase,
votes,
savedVoteCount,
submitted,
submitting,
formError,
@@ -94,6 +102,8 @@ export function useHomeParticipationActions(params: {
ensureViewerSession,
loadMyParticipation,
fallbackCreatorName: twitchUser,
clipSubmissionsEnabled,
clipSubmissionDisabledMessage,
})
async function initializeHomeInteractions() {
@@ -10,10 +10,12 @@ export function useHomeParticipationPresentation(params: {
modal: Ref<null | HomeInteractionModalKind>
previewPhase: Ref<HomePreviewPhase>
votes: Ref<Record<string, number>>
savedVoteCount: Ref<number>
submitted: Ref<boolean>
successKind: Ref<null | HomeSuccessKind>
clipCatIdx: Ref<number>
clipDsgvo: Ref<boolean>
clipSubmissionsEnabled: ComputedRef<boolean>
}) {
const {
store,
@@ -22,25 +24,28 @@ export function useHomeParticipationPresentation(params: {
modal,
previewPhase,
votes,
savedVoteCount,
submitted,
successKind,
clipCatIdx,
clipDsgvo,
clipSubmissionsEnabled,
} = params
const notSubmitted = computed(() => !submitted.value)
const voteCount = computed(() => Object.keys(votes.value).length)
const totalCats = computed(() => displayCategories.value.length)
const canSubmitVote = computed(() => previewPhase.value === 'voting' && voteCount.value > 0)
const savedVoteActive = computed(() => savedVoteCount.value > 0)
const activeCategory = computed(() => displayCategories.value[activeCat.value] ?? displayCategories.value[0] ?? null)
const activeCatName = computed(() => activeCategory.value?.name ?? '')
const pickerTitle = computed(() => (modal.value === 'nominate' && previewPhase.value === 'nomination' ? 'Streamer nominieren' : modal.value === 'nominate' ? 'Eingegangene Nominierungen' : 'Deine Stimme zählt'))
const pickerSubtitle = computed(() => (modal.value === 'nominate' && previewPhase.value === 'nomination' ? 'Reiche den offiziellen Stream- oder Kanal-Link ein. Den Anzeigenamen vergibt das Team im Review.' : modal.value === 'nominate' ? 'Die Nominierungsphase ist abgeschlossen — hier sind alle eingereichten Kandidat:innen.' : 'Wähle pro Kategorie deine:n Favorit:in. Eine Stimme pro Kategorie.'))
const successTitle = computed(() => successKind.value === 'nomination' ? 'Nominierung eingereicht ✦' : successKind.value === 'clip' ? 'Clip eingereicht ✦' : successKind.value === 'show' ? 'Erinnerung aktiviert ✦' : 'Stimme gespeichert ✩')
const successText = computed(() => successKind.value === 'nomination' ? 'Danke! Der Stream-Link wurde gespeichert und landet im Admin-Review.' : successKind.value === 'clip' ? 'Danke! Dein Clip wurde im Backend gespeichert und wird vom Team geprüft.' : successKind.value === 'show' ? `Wir erinnern dich rechtzeitig vor der Award-Show am ${formatShowDate(store)}.` : 'Danke fürs Abstimmen! Deine Auswahl wurde im Backend gespeichert.')
const successText = computed(() => successKind.value === 'nomination' ? 'Danke! Deine Links wurden gespeichert und landen im Admin-Review.' : successKind.value === 'clip' ? 'Danke! Dein Clip wurde im Backend gespeichert und wird vom Team geprüft.' : successKind.value === 'show' ? `Wir erinnern dich rechtzeitig vor der Award-Show am ${formatShowDate(store)}.` : savedVoteActive.value ? 'Danke! Deine geänderte Auswahl wurde gespeichert.' : 'Danke fürs Abstimmen! Deine Auswahl wurde im Backend gespeichert.')
const clipNomOptions = computed(() => (displayCategories.value[clipCatIdx.value]?.candidates ?? []).map((candidate, index) => ({ id: index, label: `${candidate.displayName}` })))
const catOptions = computed(() => displayCategories.value.map((category, index) => ({ id: index, label: `${category.icon} ${category.name}` })))
const canSubmitClip = computed(() => previewPhase.value === 'nomination' && clipDsgvo.value)
const canSubmitClip = computed(() => clipSubmissionsEnabled.value && previewPhase.value === 'nomination' && clipDsgvo.value)
const clipSubmitStyle = computed(() => canSubmitClip.value ? "width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:pointer;box-shadow:0 10px 24px rgba(124,86,196,.3);" : "width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:#d1c4e9;color:#9e8cc5;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:not-allowed;")
return {
@@ -48,6 +53,7 @@ export function useHomeParticipationPresentation(params: {
voteCount,
totalCats,
canSubmitVote,
savedVoteActive,
activeCategory,
activeCatName,
pickerTitle,
@@ -12,6 +12,7 @@ export function useHomeParticipationSessionActions(params: {
routerPush: (path: string) => Promise<unknown>
displayCategories: ComputedRef<HomeDisplayCategory[]>
votes: Ref<Record<string, number>>
savedVoteCount: Ref<number>
formError: Ref<string>
deleteConfirm: Ref<boolean>
accountActionError: Ref<string>
@@ -24,6 +25,7 @@ export function useHomeParticipationSessionActions(params: {
routerPush,
displayCategories,
votes,
savedVoteCount,
formError,
deleteConfirm,
accountActionError,
@@ -60,8 +62,10 @@ export function useHomeParticipationSessionActions(params: {
}
}
votes.value = nextVotes
savedVoteCount.value = Object.keys(nextVotes).length
} catch {
votes.value = {}
savedVoteCount.value = 0
}
}
@@ -78,6 +82,7 @@ export function useHomeParticipationSessionActions(params: {
accountActionError.value = ''
clipDsgvo.value = false
votes.value = {}
savedVoteCount.value = 0
await routerPush('/login')
}
@@ -89,6 +94,7 @@ export function useHomeParticipationSessionActions(params: {
deleteConfirm.value = false
clipDsgvo.value = false
votes.value = {}
savedVoteCount.value = 0
await store.loadHomeData()
await routerPush('/login')
} catch (error) {
@@ -1,4 +1,4 @@
import { ref, type ComputedRef, type Ref } from 'vue'
import { computed, ref, type ComputedRef, type Ref } from 'vue'
import { useAuthStore } from '../../stores/auth'
import { useAwardsStore } from '../../stores/awards'
@@ -43,17 +43,21 @@ export function useHomeParticipationState(params: {
} = params
const votes = ref<Record<string, number>>({})
const savedVoteCount = ref(0)
const submitted = ref(false)
const submitting = ref(false)
const formError = ref('')
const successKind = ref<null | HomeSuccessKind>(null)
const clipCatIdx = ref(0)
const clipDsgvo = ref(false)
const clipSubmissionsEnabled = computed(() => store.overview.featureFlags.clipSubmissionsEnabled)
const clipSubmissionDisabledMessage = computed(() => store.overview.featureFlags.clipSubmissionDisabledMessage)
const {
notSubmitted,
voteCount,
totalCats,
canSubmitVote,
savedVoteActive,
activeCategory,
activeCatName,
pickerTitle,
@@ -71,10 +75,12 @@ export function useHomeParticipationState(params: {
modal,
previewPhase,
votes,
savedVoteCount,
submitted,
successKind,
clipCatIdx,
clipDsgvo,
clipSubmissionsEnabled,
})
const {
initializeHomeInteractions,
@@ -109,26 +115,33 @@ export function useHomeParticipationState(params: {
openModal,
closeAccountModal,
votes,
savedVoteCount,
submitted,
submitting,
formError,
successKind,
clipCatIdx,
clipDsgvo,
clipSubmissionsEnabled,
clipSubmissionDisabledMessage,
})
return {
votes,
savedVoteCount,
submitted,
submitting,
formError,
successKind,
clipCatIdx,
clipDsgvo,
clipSubmissionsEnabled,
clipSubmissionDisabledMessage,
notSubmitted,
voteCount,
totalCats,
canSubmitVote,
savedVoteActive,
activeCategory,
activeCatName,
pickerTitle,
@@ -10,6 +10,7 @@ export function useHomeParticipationSubmitActions(params: {
activeCat: Ref<number>
previewPhase: Ref<HomePreviewPhase>
votes: Ref<Record<string, number>>
savedVoteCount: Ref<number>
submitted: Ref<boolean>
submitting: Ref<boolean>
formError: Ref<string>
@@ -19,6 +20,8 @@ export function useHomeParticipationSubmitActions(params: {
ensureViewerSession: () => Promise<AuthSession>
loadMyParticipation: () => Promise<void>
fallbackCreatorName: ComputedRef<string>
clipSubmissionsEnabled: ComputedRef<boolean>
clipSubmissionDisabledMessage: ComputedRef<string>
}) {
const {
store,
@@ -26,6 +29,7 @@ export function useHomeParticipationSubmitActions(params: {
activeCat,
previewPhase,
votes,
savedVoteCount,
submitted,
submitting,
formError,
@@ -35,6 +39,8 @@ export function useHomeParticipationSubmitActions(params: {
ensureViewerSession,
loadMyParticipation,
fallbackCreatorName,
clipSubmissionsEnabled,
clipSubmissionDisabledMessage,
} = params
function setCat(index: number) {
@@ -42,6 +48,13 @@ export function useHomeParticipationSubmitActions(params: {
}
function pickNominee(categoryId: string, index: number) {
if (votes.value[categoryId] === index) {
const nextVotes = { ...votes.value }
delete nextVotes[categoryId]
votes.value = nextVotes
return
}
votes.value = { ...votes.value, [categoryId]: index }
}
@@ -82,6 +95,7 @@ export function useHomeParticipationSubmitActions(params: {
entries,
})
await loadMyParticipation()
savedVoteCount.value = entries.length
submitted.value = true
successKind.value = 'vote'
} catch (error) {
@@ -113,19 +127,39 @@ export function useHomeParticipationSubmitActions(params: {
return
}
const streamUrl = nominationContext.streamUrl.trim()
if (!streamUrl) {
formError.value = 'Bitte füge einen Stream- oder Kanal-Link hinzu.'
const entries = nominationContext.entries
.map((entry) => ({
categoryIndex: entry.categoryIndex,
streamUrls: entry.streamUrls.map((url) => url.trim()).filter(Boolean),
}))
.filter((entry) => entry.streamUrls.length > 0)
if (entries.length === 0) {
formError.value = 'Bitte füge mindestens einen Stream- oder Kanal-Link hinzu.'
return
}
if (!isHttpUrl(streamUrl)) {
if (entries.some((entry) => entry.streamUrls.length > 3)) {
formError.value = 'Pro Kategorie sind maximal drei Links erlaubt.'
return
}
const invalidUrl = entries.flatMap((entry) => entry.streamUrls).find((url) => !isHttpUrl(url))
if (invalidUrl) {
formError.value = 'Bitte gib einen gültigen http(s)-Link ein.'
return
}
const category = displayCategories.value[nominationContext.categoryIndex]
if (!category) {
const hasDuplicateInCategory = entries.some((entry) => {
const normalizedUrls = entry.streamUrls.map((url) => normalizeUrlForCompare(url))
return new Set(normalizedUrls).size !== normalizedUrls.length
})
if (hasDuplicateInCategory) {
formError.value = 'Doppelte Links innerhalb derselben Kategorie sind nicht erlaubt.'
return
}
if (entries.some((entry) => !displayCategories.value[entry.categoryIndex])) {
formError.value = 'Bitte wähle eine gültige Kategorie aus.'
return
}
@@ -133,12 +167,15 @@ export function useHomeParticipationSubmitActions(params: {
submitting.value = true
try {
const session = await ensureViewerSession()
await store.submitNomination({
year: store.overview.year,
categoryId: Number(category.id),
twitchUserId: session.twitchUserId,
nominations: [{ streamUrl }],
})
await Promise.all(entries.map((entry) => {
const category = displayCategories.value[entry.categoryIndex]
return store.submitNomination({
year: store.overview.year,
categoryId: Number(category.id),
twitchUserId: session.twitchUserId,
nominations: entry.streamUrls.map((streamUrl) => ({ streamUrl })),
})
}))
await loadMyParticipation()
submitted.value = true
successKind.value = 'nomination'
@@ -152,6 +189,10 @@ export function useHomeParticipationSubmitActions(params: {
async function submitClip(clipContext: HomeClipSubmitContext) {
formError.value = ''
if (!clipDsgvo.value || submitting.value) return
if (!clipSubmissionsEnabled.value) {
formError.value = clipSubmissionDisabledMessage.value || 'Clip-Einreichungen sind aktuell geschlossen.'
return
}
if (previewPhase.value !== 'nomination') {
formError.value = 'Clip-Einreichungen sind nur während der Nominierungsphase möglich.'
return
@@ -223,6 +264,10 @@ function normalizeSearchValue(value: string) {
return value.trim().toLowerCase()
}
function normalizeUrlForCompare(value: string) {
return value.trim().replace(/\/+$/, '').toLowerCase()
}
function isHttpUrl(value: string) {
try {
const url = new URL(value)
@@ -40,11 +40,11 @@ export function useHomePhasePresentation(params: {
const streamLive = computed(() => showPhase.value && !Number.isNaN(showStartMs.value) && currentTimestamp.value >= showStartMs.value)
const streamLocked = computed(() => !streamLive.value)
const phaseCardTitle = computed(() => completedPhase.value ? 'Award-Jahr abgeschlossen' : nominationPhase.value ? 'Community Nominierung' : votingPhase.value ? 'Community Voting' : preparationPhase.value ? 'Aufbereitung bis zur Show' : 'Award Show Live')
const phaseCardDescription = computed(() => completedPhase.value ? 'Die grosse Award-Show ist beendet. Das Jahr ist abgeschlossen und alle Teilnahme-Aktionen sind gesperrt.' : nominationPhase.value ? 'Die Nominierungsphase läuft gerade. Reiche deine Favoriten und Highlight-Clips ein.' : votingPhase.value ? 'Die Nominierungsphase ist abgeschlossen.\nJetzt liegt es an dir: Stimme für deine Favoriten!' : preparationPhase.value ? 'Das Voting ist abgeschlossen. Das Team bereitet Clips, Ablauf und Gewinner-Momente für die Show vor.' : 'Die Award-Show läuft jetzt live. Zeit für Bühne, Gewinner:innen und ganz viel Glitzer.')
const phaseCardDescription = computed(() => completedPhase.value ? 'Die grosse Award-Show ist beendet. Das Jahr ist abgeschlossen und alle Teilnahme-Aktionen sind gesperrt.' : nominationPhase.value ? 'Die Nominierungsphase läuft gerade. Reiche pro Kategorie bis zu drei Stream- oder Kanal-Links ein.' : votingPhase.value ? 'Die Nominierungsphase ist abgeschlossen.\nJetzt liegt es an dir: Stimme für deine Favoriten!' : preparationPhase.value ? 'Das Voting ist abgeschlossen. Das Team bereitet Clips, Ablauf und Gewinner-Momente für die Show vor.' : 'Die Award-Show läuft jetzt live. Zeit für Bühne, Gewinner:innen und ganz viel Glitzer.')
const phaseCardRange = computed(() => completedPhase.value ? `Finale abgeschlossen · ${formatShowDate()}` : nominationPhase.value ? `Nominierungszeitraum · ${formatRange('nomination')}` : votingPhase.value ? `Voting-Zeitraum · ${formatRange('voting')}` : preparationPhase.value ? `Aufbereitungszeit · ${formatRange('preparation')}` : `Live · ${formatShowDate()} · ${formatTimeLabel(showStartsAt.value)} Uhr`)
const phaseStatusLabel = computed(() => completedPhase.value ? 'ABGESCHLOSSEN' : streamLive.value ? 'LIVE' : showPhase.value ? 'STARTET BALD' : preparationPhase.value ? 'IN AUFBEREITUNG' : 'AKTIV')
const phaseStatusStyle = computed(() => completedPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#f1ecfb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:.5px;' : showPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#ffe5ec;color:#ec3b5a;font-size:11px;font-weight:700;letter-spacing:.5px;' : preparationPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#fff1d6;color:#b7791f;font-size:11px;font-weight:700;letter-spacing:.5px;' : 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#e3f7ec;color:#1f9d5a;font-size:11px;font-weight:700;letter-spacing:.5px;')
const phasePrimaryLabel = computed(() => completedPhase.value ? '✦ Award beendet' : nominationPhase.value ? '✦ Nominieren & Clip' : votingPhase.value ? '★ Jetzt voten' : preparationPhase.value ? '✦ Show wird vorbereitet' : '● Zum Live-Stream')
const phasePrimaryLabel = computed(() => completedPhase.value ? '✦ Award beendet' : nominationPhase.value ? '✦ Jetzt nominieren' : votingPhase.value ? '★ Jetzt voten' : preparationPhase.value ? '✦ Show wird vorbereitet' : '● Zum Live-Stream')
const phasePrimaryDisabled = computed(() => preparationPhase.value || completedPhase.value)
const phasePrimaryActionStyle = computed(() => phasePrimaryDisabled.value
? 'display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:15px 18px;border-radius:13px;background:#eee7f8;color:#9b8abf;border:none;text-decoration:none;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:15px;box-shadow:none;cursor:not-allowed;'
@@ -72,8 +72,8 @@ export function useHomePhasePresentation(params: {
? 'position:absolute;top:27px;left:10%;right:10%;height:3px;background:linear-gradient(90deg,#8b6cdb 0%,#8b6cdb 66.66%,#e2d6f4 66.66%,#e2d6f4 100%);border-radius:3px;z-index:0;'
: 'position:absolute;top:27px;left:10%;right:10%;height:3px;background:linear-gradient(90deg,#8b6cdb 0%,#8b6cdb 100%,#e2d6f4 100%,#e2d6f4 100%);border-radius:3px;z-index:0;')
const sectionTitle = computed(() => completedPhase.value ? 'Das Award-Jahr ist abgeschlossen ✦' : nominationPhase.value ? 'Jetzt Highlights und Favoriten einreichen ✦' : votingPhase.value ? 'Meine Favoriten unterstützen ⭐' : preparationPhase.value ? 'Die Show wird vorbereitet ✦' : 'Die Gewinner werden jetzt live gekürt ✦')
const sectionText = computed(() => completedPhase.value ? 'Danke an alle, die nominiert, abgestimmt und live mitgefiebert haben. Die nächsten Aktionen sind gesperrt, bis ein neues Award-Jahr startet.' : nominationPhase.value ? 'Reiche deine Lieblingsmomente ein und hilf mit, die stärksten Clips und spannendsten Namen in die Show zu bringen.' : votingPhase.value ? 'Jede Stimme erzählt eine Geschichte. Unterstütze die Creator, die dich zum Lachen, Staunen und Mitfiebern bringen.' : preparationPhase.value ? 'Die Community hat abgestimmt. Jetzt bereitet das Team Clips, Ablauf und Show-Momente für die Award-Nacht vor.' : 'Die Bühne ist offen. Schau live zu, wie die Stars der Szene ausgezeichnet werden und die besten Momente gezeigt werden.')
const sectionActionLabel = computed(() => completedPhase.value ? 'Award-Jahr abgeschlossen' : nominationPhase.value ? 'Nominieren & Clip einreichen' : votingPhase.value ? 'Mit Twitch anmelden & voten' : preparationPhase.value ? 'Aufbereitung läuft' : 'Zum Live-Stream')
const sectionText = computed(() => completedPhase.value ? 'Danke an alle, die nominiert, abgestimmt und live mitgefiebert haben. Die nächsten Aktionen sind gesperrt, bis ein neues Award-Jahr startet.' : nominationPhase.value ? 'Reiche deine Favoriten als Stream- oder Kanal-Link ein und hilf dem Team, die stärksten Namen für das Voting vorzubereiten.' : votingPhase.value ? 'Jede Stimme erzählt eine Geschichte. Unterstütze die Creator, die dich zum Lachen, Staunen und Mitfiebern bringen.' : preparationPhase.value ? 'Die Community hat abgestimmt. Jetzt bereitet das Team Clips, Ablauf und Show-Momente für die Award-Nacht vor.' : 'Die Bühne ist offen. Schau live zu, wie die Stars der Szene ausgezeichnet werden und die besten Momente gezeigt werden.')
const sectionActionLabel = computed(() => completedPhase.value ? 'Award-Jahr abgeschlossen' : nominationPhase.value ? 'Nominierungen einreichen' : votingPhase.value ? 'Mit Twitch anmelden & voten' : preparationPhase.value ? 'Aufbereitung läuft' : 'Zum Live-Stream')
const sectionActionHref = computed(() => showPhase.value ? publicStreamUrl.value : '#')
const sectionActionDisabled = computed(() => preparationPhase.value || completedPhase.value)
const sectionActionStyle = computed(() => sectionActionDisabled.value