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