Add team roles and content management updates

This commit is contained in:
AzuTear
2026-06-25 19:52:46 +02:00
parent 54293f4c45
commit 693769e5a8
126 changed files with 6966 additions and 738 deletions
@@ -1,7 +1,7 @@
<script setup lang="ts">
const props = defineProps<{
privacyModalOpen: boolean
privacyContentBlocks: string[]
privacyContentHtml: string
onClosePrivacy: () => void
privacyModalStop: (event: Event) => void
accountModalOpen: boolean
@@ -32,14 +32,11 @@ const props = defineProps<{
</div>
<button @click="props.onClosePrivacy" 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>
<div style="overflow-y:auto;padding:28px 32px;flex:1;display:flex;flex-direction:column;gap:14px;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.7;color:#6f6685;">
<p
v-for="(block, index) in props.privacyContentBlocks"
:key="`privacy-block-${index}`"
style="margin:0;padding:14px 16px;border-radius:16px;border:1px solid #f1ecfb;background:#fcfbff;white-space:pre-wrap;"
>
{{ block }}
</p>
<div style="overflow-y:auto;padding:28px 32px;flex:1;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.7;color:#6f6685;">
<div v-if="props.privacyContentHtml" class="home-privacy-content" v-html="props.privacyContentHtml" />
<div v-else style="margin:0;padding:14px 16px;border-radius:16px;border:1px solid #f1ecfb;background:#fcfbff;">
Datenschutzerklärung wird geladen.
</div>
</div>
</div>
</div>
@@ -99,3 +96,25 @@ const props = defineProps<{
</div>
</template>
</template>
<style scoped>
.home-privacy-content :deep(p) {
margin: 0 0 14px;
}
.home-privacy-content :deep(p:last-child),
.home-privacy-content :deep(ul:last-child),
.home-privacy-content :deep(ol:last-child) {
margin-bottom: 0;
}
.home-privacy-content :deep(ul),
.home-privacy-content :deep(ol) {
margin: 0 0 14px 20px;
padding-left: 16px;
}
.home-privacy-content :deep(li) {
margin: 3px 0;
}
</style>
@@ -37,7 +37,7 @@ const props = defineProps<{
:style="props.archiveYearButtonStyle(year.active)"
>
<span>{{ year.label }}</span>
<span :style="year.active ? 'display:inline-flex;align-items:center;justify-content:center;min-width:28px;height:28px;border-radius:999px;background:#fff;color:#8b6cdb;font-size:11px;font-weight:800;box-shadow:0 6px 14px rgba(139,108,219,.12);' : 'display:inline-flex;align-items:center;justify-content:center;min-width:28px;height:28px;border-radius:999px;background:#f1ecfb;color:#7355c8;font-size:11px;font-weight:800;'">{{ year.winners.length }}</span>
<span :style="year.active ? 'display:inline-flex;align-items:center;justify-content:center;min-width:28px;height:28px;border-radius:999px;background:#fff;color:#8b6cdb;font-size:11px;font-weight:800;box-shadow:0 6px 14px rgba(139,108,219,.12);' : 'display:inline-flex;align-items:center;justify-content:center;min-width:28px;height:28px;border-radius:999px;background:#f1ecfb;color:#7355c8;font-size:11px;font-weight:800;'">{{ year.winnerCount }}</span>
</button>
</div>
</aside>
@@ -46,7 +46,7 @@ const props = defineProps<{
const nominationCatValue = ref(0)
const clipCatValue = ref(0)
const clipNomValue = ref(0)
const clipNomQuery = ref('')
watch(
() => props.catOptions,
@@ -61,21 +61,13 @@ watch(
{ immediate: true },
)
watch(
() => props.clipNomOptions,
(options) => {
clipNomValue.value = resolveOptionValue(clipNomValue.value, options)
},
{ immediate: true },
)
function resolveOptionValue(value: number, options: HomeSelectionOption[]) {
return options.some((option) => option.id === value) ? value : options[0]?.id ?? 0
}
function handleClipCategoryChange(value: number) {
clipCatValue.value = value
clipNomValue.value = 0
clipNomQuery.value = ''
notifyClipCategoryChange(value)
}
@@ -134,7 +126,7 @@ function notifyClipCategoryChange(value: number) {
<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;">Name und Stream-Link gehen direkt in den Admin-Review.</p>
<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>
@@ -145,14 +137,10 @@ function notifyClipCategoryChange(value: number) {
:options="props.catOptions"
/>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Name <span style="color:#e11d48;">*</span></label>
<input data-dc-ref="nominationNameRef" type="text" placeholder="Kanalname oder Anzeigename" maxlength="120" 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>
<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://twitch.tv/kanal oder https://kick.com/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;">Twitch, Kick, YouTube oder ein anderer offizieller Kanal-Link.</p>
<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' }}
@@ -166,7 +154,7 @@ function notifyClipCategoryChange(value: number) {
</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://clips.twitch.tv/... oder YouTube · TikTok" 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;" />
<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>
@@ -180,13 +168,19 @@ function notifyClipCategoryChange(value: number) {
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Für welche:n VTuber?</label>
<HomeSelectDropdown
v-model="clipNomValue"
data-ref="clipNomRef"
label="VTuber fuer Clip auswaehlen"
placeholder="Noch keine Kandidat:innen"
:options="props.clipNomOptions"
<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>
@@ -235,7 +229,7 @@ function notifyClipCategoryChange(value: number) {
<div class="home-modal__clip-body" style="padding:24px 40px 32px;overflow-y:auto;display:flex;flex-direction:column;gap:16px;">
<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://clips.twitch.tv/... oder YouTube · TikTok" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
<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:#fbf9ff;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>
@@ -249,13 +243,19 @@ function notifyClipCategoryChange(value: number) {
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Für welche:n VTuber?</label>
<HomeSelectDropdown
v-model="clipNomValue"
data-ref="clipNomRef"
label="VTuber fuer Clip auswaehlen"
placeholder="Noch keine Kandidat:innen"
:options="props.clipNomOptions"
<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:#fbf9ff;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>
@@ -26,12 +26,12 @@ const {
communitySocialLinks,
footerLinks,
faqItems,
privacyContentBlocks,
privacyContentHtml,
publicStreamUrl,
displayCategories,
nominationPhase,
votingPhase,
reviewPhase,
preparationPhase,
showPhase,
completedPhase,
showCountdown,
@@ -145,7 +145,7 @@ const {
const previewPhaseButtons = [
{ key: 'nomination', label: 'Nominierung', hint: 'Einreichen & Clips' },
{ key: 'voting', label: 'Voting', hint: 'Community stimmt ab' },
{ key: 'review', label: 'Review', hint: 'Auswertung' },
{ key: 'preparation', label: 'Aufbereitung', hint: 'Pause vor Show' },
{ key: 'show', label: 'Show', hint: 'Live-Finale' },
] as const
@@ -162,7 +162,7 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
archiveYear,
nominationPhase,
votingPhase,
reviewPhase,
preparationPhase,
completedPhase,
initializeHomeInteractions,
submitNomination,
@@ -250,7 +250,7 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
<HomeTimelineSection
:nomination-phase="nominationPhase"
:voting-phase="votingPhase"
:review-phase="reviewPhase"
:preparation-phase="preparationPhase"
:show-phase="showPhase"
:completed-phase="completedPhase"
:timeline-line-style="timelineLineStyle"
@@ -278,7 +278,7 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
:section-action-disabled="sectionActionDisabled"
:section-action-style="sectionActionStyle"
:nomination-phase="nominationPhase"
:review-phase="reviewPhase"
:preparation-phase="preparationPhase"
:on-section-action="onSectionAction"
/>
@@ -342,7 +342,7 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
:winner-platform-key="winnerPlatformKey"
:winner-platform-label="winnerPlatformLabel"
:privacy-modal-open="privacyModalOpen"
:privacy-content-blocks="privacyContentBlocks"
:privacy-content-html="privacyContentHtml"
:on-close-privacy="onClosePrivacy"
:privacy-modal-stop="privacyModalStop"
:account-modal-open="accountModalOpen"
@@ -58,7 +58,7 @@ defineProps<{
winnerPlatformKey: (url: string) => string
winnerPlatformLabel: (url: string) => string
privacyModalOpen: boolean
privacyContentBlocks: string[]
privacyContentHtml: string
onClosePrivacy: () => void
privacyModalStop: (event: Event) => void
accountModalOpen: boolean
@@ -132,7 +132,7 @@ defineProps<{
<HomeAccountAndPrivacyModals
:privacy-modal-open="privacyModalOpen"
:privacy-content-blocks="privacyContentBlocks"
:privacy-content-html="privacyContentHtml"
:on-close-privacy="onClosePrivacy"
:privacy-modal-stop="privacyModalStop"
:account-modal-open="accountModalOpen"
@@ -7,7 +7,7 @@ const props = defineProps<{
sectionActionDisabled: boolean
sectionActionStyle: string
nominationPhase: boolean
reviewPhase: boolean
preparationPhase: boolean
onSectionAction: (event?: Event) => void
}>()
</script>
@@ -72,7 +72,7 @@ const props = defineProps<{
:style="props.sectionActionStyle"
style-hover="transform:translateY(-2px);"
>
<svg width="22" height="22" viewBox="0 0 24 24" :fill="props.nominationPhase ? '#E855A5' : props.reviewPhase ? '#B7791F' : '#9146FF'"><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>{{ props.sectionActionLabel }}
<svg width="22" height="22" viewBox="0 0 24 24" :fill="props.nominationPhase ? '#E855A5' : props.preparationPhase ? '#B7791F' : '#9146FF'"><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>{{ props.sectionActionLabel }}
</a>
</div>
</section>
@@ -1,4 +1,8 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { privacyContentToHtml } from '../../lib/privacyContent'
interface HomeSocialLink {
label: string
platform: string
@@ -7,8 +11,10 @@ interface HomeSocialLink {
}
interface FooterLink {
key: string
label: string
url: string
content: string
}
interface SiteContent {
@@ -31,6 +37,25 @@ const props = defineProps<{
socialSimpleIconColor: (platform: string | null | undefined) => string
platformKey: (platform: string | null | undefined) => string
}>()
const activeFooterLink = ref<FooterLink | null>(null)
const activeFooterHtml = computed(() => privacyContentToHtml(activeFooterLink.value?.content || ''))
function openFooterLink(event: Event, link: FooterLink) {
if (!link.content.trim()) {
if (!link.url.trim()) {
event.preventDefault()
}
return
}
event.preventDefault()
activeFooterLink.value = link
}
function closeFooterLink() {
activeFooterLink.value = null
}
</script>
<template>
@@ -111,10 +136,83 @@ const props = defineProps<{
VTuber Star Award 2026
</div>
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:8px 24px;font-size:14px;font-weight:500;">
<a v-for="link in props.footerLinks" :key="link.label" :href="link.url" target="_blank" rel="noopener" style="color:var(--muted,#8a8398);text-decoration:none;" style-hover="color:var(--accent,#8b6cdb);">{{ link.label }}</a>
<template v-for="link in props.footerLinks" :key="link.key || link.label">
<button
v-if="link.content"
type="button"
style="background:none;border:none;padding:0;color:var(--muted,#8a8398);text-decoration:none;cursor:pointer;font-size:inherit;font-family:inherit;font-weight:inherit;"
style-hover="color:var(--accent,#8b6cdb);"
@click="openFooterLink($event, link)"
>
{{ link.label }}
</button>
<a
v-else
:href="link.url || '#'"
target="_blank"
rel="noopener"
style="color:var(--muted,#8a8398);text-decoration:none;"
style-hover="color:var(--accent,#8b6cdb);"
@click="openFooterLink($event, link)"
>
{{ link.label }}
</a>
</template>
<button @click="props.onOpenPrivacy" style="background:none;border:none;padding:0;color:var(--muted,#8a8398);text-decoration:none;cursor:pointer;font-size:inherit;" style-hover="color:var(--accent,#8b6cdb);">Datenschutz</button>
</div>
<div style="font-size:13px;color:var(--muted,#c9b8da);">© 2026 · Made with &amp; Chaos</div>
</div>
</footer>
<template v-if="activeFooterLink">
<div class="home-modal-overlay" @click="closeFooterLink" style="position:fixed;inset:0;z-index:420;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.55);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);">
<div class="home-modal" @click.stop style="position:relative;width:100%;max-width:720px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);">
<div style="display:flex;align-items:center;justify-content:space-between;gap:18px;padding:24px 32px 18px;border-bottom:1px solid #f1ecfb;flex:none;">
<div>
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#8b6cdb;margin-bottom:4px;">Footer Seite</div>
<h2 style="font-family:'Cormorant Garamond',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>
<div style="overflow-y:auto;padding:28px 32px;flex:1;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.7;color:#6f6685;">
<div v-if="activeFooterHtml" class="home-footer-page-content" v-html="activeFooterHtml" />
<p v-else style="margin:0;padding:14px 16px;border-radius:16px;border:1px solid #fde68a;background:#fffbeb;color:#92400e;font-weight:600;">
Für diese Footer-Seite ist noch kein Inhalt hinterlegt.
</p>
<a
v-if="activeFooterLink.url"
:href="activeFooterLink.url"
target="_blank"
rel="noopener"
style="display:inline-flex;align-items:center;gap:9px;margin-top:18px;padding:12px 16px;border-radius:14px;background:#f6f1fd;border:1px solid #e6dcf6;color:#6a4fb8;text-decoration:none;font-weight:700;"
style-hover="background:#f1ecfb;"
>
Externe Seite öffnen
</a>
</div>
</div>
</div>
</template>
</template>
<style scoped>
.home-footer-page-content :deep(p) {
margin: 0 0 14px;
}
.home-footer-page-content :deep(p:last-child),
.home-footer-page-content :deep(ul:last-child),
.home-footer-page-content :deep(ol:last-child) {
margin-bottom: 0;
}
.home-footer-page-content :deep(ul),
.home-footer-page-content :deep(ol) {
margin: 0 0 14px 20px;
padding-left: 16px;
}
.home-footer-page-content :deep(li) {
margin: 3px 0;
}
</style>
@@ -2,8 +2,8 @@
<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 Phasen 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 Review &amp; Auswertung bis ganz zum Schluss zur grossen Show.</p>
<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>
<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>
<div style="position:relative;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:20px;align-items:start;" data-timeline>
@@ -58,29 +58,29 @@
</div>
<div style="position:relative;z-index:1;text-align:center;">
<div :style="nominationPhase || votingPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:#fff;border:4px solid #e2d6f4;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 12px rgba(124,86,196,.1);' : reviewPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#f7c76a,#b7791f);display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 6px rgba(247,199,106,.18),0 8px 20px rgba(183,121,31,.28);border:4px solid #f4eefb;animation:pulseGlow 2.2s ease-in-out infinite;' : 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#8b6cdb,#7355c8);display:flex;align-items:center;justify-content:center;box-shadow:0 8px 20px rgba(124,86,196,.32);border:4px solid #f4eefb;'">
<div :style="nominationPhase || votingPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:#fff;border:4px solid #e2d6f4;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 12px rgba(124,86,196,.1);' : preparationPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#f7c76a,#b7791f);display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 6px rgba(247,199,106,.18),0 8px 20px rgba(183,121,31,.28);border:4px solid #f4eefb;animation:pulseGlow 2.2s ease-in-out infinite;' : 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#8b6cdb,#7355c8);display:flex;align-items:center;justify-content:center;box-shadow:0 8px 20px rgba(124,86,196,.32);border:4px solid #f4eefb;'">
<template v-if="nominationPhase || votingPhase">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#b9a9dd" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
</template>
<template v-else-if="reviewPhase">
<template v-else-if="preparationPhase">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
</template>
<template v-else>
<svg width="24" height="24" 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>
</template>
</div>
<div :style="reviewPhase ? 'background:#fffdf8;border:1px solid #f3ddae;border-radius:20px;padding:24px 20px;box-shadow:0 16px 38px rgba(183,121,31,.14);min-height:320px;' : showPhase || completedPhase ? 'background:#fff;border:1px solid #efe7fb;border-radius:20px;padding:24px 20px;box-shadow:0 12px 30px rgba(124,86,196,.08);min-height:320px;' : 'background:#fbf9ff;border:1px solid #ede5fa;border-radius:20px;padding:24px 20px;box-shadow:0 12px 30px rgba(124,86,196,.06);min-height:320px;'">
<div :style="reviewPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#fff1d6;color:#b7791f;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : showPhase || completedPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#f1ecfb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#f3eefb;color:#a98ddb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;'">{{ reviewPhase ? 'IN PRÜFUNG' : showPhase || completedPhase ? 'ABGESCHLOSSEN' : 'BEVORSTEHEND' }}</div>
<h3 style="font-family:'Outfit',sans-serif;font-weight:700;font-size:21px;margin:0 0 6px;color:#3f3556;">Review &amp; Auswertung</h3>
<div :style="reviewPhase ? 'font-size:13px;font-weight:600;color:#b7791f;margin-bottom:12px;' : showPhase || completedPhase ? 'font-size:13px;font-weight:600;color:#8b6cdb;margin-bottom:12px;' : 'font-size:13px;font-weight:600;color:#a99fc0;margin-bottom:12px;'">{{ formatTimelineRange('review') }}</div>
<p style="font-size:14.5px;line-height:1.6;color:#7d7491;margin:0 0 16px;">Das Team prüft Fairness, Stimmen und Clips, wertet die Ergebnisse aus und bereitet die Show final vor.</p>
<div :style="preparationPhase ? 'background:#fffdf8;border:1px solid #f3ddae;border-radius:20px;padding:24px 20px;box-shadow:0 16px 38px rgba(183,121,31,.14);min-height:320px;' : showPhase || completedPhase ? 'background:#fff;border:1px solid #efe7fb;border-radius:20px;padding:24px 20px;box-shadow:0 12px 30px rgba(124,86,196,.08);min-height:320px;' : 'background:#fbf9ff;border:1px solid #ede5fa;border-radius:20px;padding:24px 20px;box-shadow:0 12px 30px rgba(124,86,196,.06);min-height:320px;'">
<div :style="preparationPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#fff1d6;color:#b7791f;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : showPhase || completedPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#f1ecfb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#f3eefb;color:#a98ddb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;'">{{ preparationPhase ? 'AUFBEREITUNG' : showPhase || completedPhase ? 'ABGESCHLOSSEN' : 'BEVORSTEHEND' }}</div>
<h3 style="font-family:'Outfit',sans-serif;font-weight:700;font-size:21px;margin:0 0 6px;color:#3f3556;">Aufbereitung</h3>
<div :style="preparationPhase ? 'font-size:13px;font-weight:600;color:#b7791f;margin-bottom:12px;' : showPhase || completedPhase ? 'font-size:13px;font-weight:600;color:#8b6cdb;margin-bottom:12px;' : 'font-size:13px;font-weight:600;color:#a99fc0;margin-bottom:12px;'">{{ formatTimelineRange('preparation') }}</div>
<p style="font-size:14.5px;line-height:1.6;color:#7d7491;margin:0 0 16px;">Das Team bereitet Clips, Ablauf und Gewinner-Momente für die Show vor.</p>
<button
type="button"
disabled
aria-disabled="true"
:style="reviewButtonStyle(reviewPhase, showPhase, completedPhase)"
:style="preparationButtonStyle(preparationPhase, showPhase, completedPhase)"
>
{{ reviewPhase ? 'Auswertung läuft' : showPhase || completedPhase ? 'Auswertung abgeschlossen' : 'Review folgt' }}
{{ preparationPhase ? 'Aufbereitung läuft' : showPhase || completedPhase ? 'Aufbereitung abgeschlossen' : 'Aufbereitung folgt' }}
</button>
</div>
</div>
@@ -131,12 +131,12 @@
defineProps<{
nominationPhase: boolean
votingPhase: boolean
reviewPhase: boolean
preparationPhase: boolean
showPhase: boolean
completedPhase: boolean
timelineLineStyle: string
publicStreamUrl: string
formatTimelineRange: (key: 'nomination' | 'voting' | 'review' | 'show') => string
formatTimelineRange: (key: 'nomination' | 'voting' | 'preparation' | 'show') => string
openNominate: (event?: Event) => void
openVote: (event?: Event) => void
onTimelineFinalAction: (event?: Event) => void
@@ -158,8 +158,8 @@ function voteButtonStyle(votingPhase: boolean) {
: "display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#f3eefb;color:#a06bd8;border:1px solid #e4d8f6;cursor:not-allowed;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;box-shadow:none;"
}
function reviewButtonStyle(reviewPhase: boolean, showPhase: boolean, completedPhase: boolean) {
if (reviewPhase) {
function preparationButtonStyle(preparationPhase: boolean, showPhase: boolean, completedPhase: boolean) {
if (preparationPhase) {
return "display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#fff1d6;color:#8a5a00;border:1px solid #f3ddae;cursor:not-allowed;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;"
}
@@ -2,19 +2,18 @@ import type { CandidateSummary } from '../../types/awards'
export type HomeInteractionModalKind = 'show' | 'vote' | 'nominate' | 'clip'
export type HomePreviewPhase = 'nomination' | 'voting' | 'review' | 'show' | 'completed'
export type HomePreviewPhase = 'nomination' | 'voting' | 'preparation' | 'show' | 'completed'
export type HomeSuccessKind = 'vote' | 'show' | 'clip' | 'nomination'
export interface HomeClipSubmitContext {
clipUrl: string
selectedNomineeIndex: number
selectedNomineeQuery: string
description: string
}
export interface HomeNominationSubmitContext {
categoryIndex: number
name: string
streamUrl: string
}
@@ -37,6 +37,7 @@ export interface HomeSelectionOption {
export interface HomeArchiveYearItem {
year: number
label: string
winnerCount: number
winners: Array<unknown>
active: boolean
}
@@ -6,16 +6,30 @@ type AwardsStore = ReturnType<typeof useAwardsStore>
export function useHomeArchivePresentation(store: AwardsStore, archiveYear: Ref<number>) {
const archiveYears = computed(() => {
const knownYears = new Set<number>(store.overview.winnersPreview.map((entry) => entry.year))
if (store.archive.items.length > 0) {
knownYears.add(store.archive.year)
const knownYears = new Map<number, number>()
for (const entry of store.overview.archiveYears ?? []) {
knownYears.set(entry.year, entry.winnerCount)
}
return [...knownYears]
.sort((left, right) => right - left)
.map((year) => ({
for (const entry of store.overview.winnersPreview) {
if (!knownYears.has(entry.year)) {
knownYears.set(
entry.year,
store.overview.winnersPreview.filter((winner) => winner.year === entry.year).length,
)
}
}
if (store.archive.items.length > 0) {
knownYears.set(store.archive.year, store.archive.items.length)
}
return [...knownYears.entries()]
.sort(([left], [right]) => right - left)
.map(([year, winnerCount]) => ({
year,
label: String(year),
winnerCount: year === store.archive.year ? store.archive.items.length : winnerCount,
winners: year === store.archive.year ? store.archive.items : store.overview.winnersPreview.filter((entry) => entry.year === year),
active: archiveYear.value === year,
}))
@@ -7,7 +7,7 @@ import type { HomeDisplayCategory, HomeInteractionModalKind } from './homeLandin
type AwardsStore = ReturnType<typeof useAwardsStore>
type AuthStore = ReturnType<typeof useAuthStore>
type HomeTimelineKey = 'nomination' | 'voting' | 'review' | 'show'
type HomeTimelineKey = 'nomination' | 'voting' | 'preparation' | 'show'
const CATEGORY_ICONS = ['✦', '★', '✧', '♬', '⚔', '☻', '♡', '✶'] as const
@@ -40,8 +40,10 @@ export function useHomeLandingOverviewPresentation(store: AwardsStore, authStore
displayCategories.value.reduce((sum, category) => sum + category.candidates.length, 0),
)
const bootstrapArchiveYears = computed<Array<{ year: number }>>(() =>
store.overview.winnersPreview.length > 0
? [...new Set(store.overview.winnersPreview.map((winner) => winner.year))].map((year) => ({ year }))
(store.overview.archiveYears ?? []).length > 0
? (store.overview.archiveYears ?? []).map((entry) => ({ year: entry.year }))
: store.overview.winnersPreview.length > 0
? [...new Set(store.overview.winnersPreview.map((winner) => winner.year))].map((year) => ({ year }))
: [{ year: store.overview.year - 1 }],
)
@@ -46,7 +46,7 @@ export function useHomeLandingState() {
hostSocialLinks,
communitySocialLinks,
footerLinks,
privacyContentBlocks,
privacyContentHtml,
platformKey,
isUploadedSocialIcon,
socialSimpleIconPath,
@@ -101,7 +101,7 @@ export function useHomeLandingState() {
const {
nominationPhase,
votingPhase,
reviewPhase,
preparationPhase,
showPhase,
completedPhase,
showCountdown,
@@ -257,13 +257,13 @@ export function useHomeLandingState() {
communitySocialLinks,
footerLinks,
faqItems,
privacyContentBlocks,
privacyContentHtml,
publicStreamUrl,
displayCategories,
candidateCount,
nominationPhase,
votingPhase,
reviewPhase,
preparationPhase,
showPhase,
completedPhase,
showCountdown,
@@ -7,7 +7,7 @@ import type { HomeNominationSubmitContext } from './homeLandingTypes'
type AuthStore = ReturnType<typeof useAuthStore>
type AwardsStore = ReturnType<typeof useAwardsStore>
type HomeTimelineKey = 'nomination' | 'voting' | 'review' | 'show' | 'completed'
type HomeTimelineKey = 'nomination' | 'voting' | 'preparation' | 'show' | 'completed'
interface PhaseCountdownTarget {
label: string
@@ -35,11 +35,11 @@ interface UseHomeLandingViewEffectsParams {
archiveYear: Readonly<Ref<number>>
nominationPhase: Readonly<Ref<boolean>>
votingPhase: Readonly<Ref<boolean>>
reviewPhase: Readonly<Ref<boolean>>
preparationPhase: Readonly<Ref<boolean>>
completedPhase: Readonly<Ref<boolean>>
initializeHomeInteractions: () => Promise<void>
submitNomination: (nominationContext: HomeNominationSubmitContext) => Promise<void>
submitClip: (clipContext: { clipUrl: string; selectedNomineeIndex: number; description: string }) => Promise<void>
submitClip: (clipContext: { clipUrl: string; selectedNomineeQuery: string; description: string }) => Promise<void>
}
export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParams) {
@@ -56,7 +56,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
archiveYear,
nominationPhase,
votingPhase,
reviewPhase,
preparationPhase,
completedPhase,
initializeHomeInteractions,
submitNomination,
@@ -66,10 +66,9 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
const rootEl = ref<HTMLElement | null>(null)
const landingLoaderVisible = ref(true)
const nominationCatEl = ref<HTMLSelectElement | null>(null)
const nominationNameEl = ref<HTMLInputElement | null>(null)
const nominationStreamUrlEl = ref<HTMLInputElement | null>(null)
const clipUrlEl = ref<HTMLInputElement | null>(null)
const clipNomEl = ref<HTMLSelectElement | null>(null)
const clipNomSearchEl = ref<HTMLInputElement | null>(null)
const clipDescEl = ref<HTMLTextAreaElement | null>(null)
const countdownRefs = {
labelEl: ref<HTMLElement | null>(null),
@@ -93,7 +92,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
function handleClipSubmit() {
return submitClip({
clipUrl: clipUrlEl.value?.value.trim() ?? '',
selectedNomineeIndex: Number.parseInt(clipNomEl.value?.value || '0', 10) || 0,
selectedNomineeQuery: clipNomSearchEl.value?.value.trim() ?? '',
description: clipDescEl.value?.value.trim() ?? '',
})
}
@@ -101,7 +100,6 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
function handleNominationSubmit() {
return submitNomination({
categoryIndex: Number.parseInt(nominationCatEl.value?.value || '0', 10) || 0,
name: nominationNameEl.value?.value.trim() ?? '',
streamUrl: nominationStreamUrlEl.value?.value.trim() ?? '',
})
}
@@ -120,10 +118,9 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
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"]')
nominationNameEl.value = root.querySelector('[data-dc-ref="nominationNameRef"]')
nominationStreamUrlEl.value = root.querySelector('[data-dc-ref="nominationStreamUrlRef"]')
clipUrlEl.value = root.querySelector('[data-dc-ref="clipUrlRef"]')
clipNomEl.value = root.querySelector('[data-dc-ref="clipNomRef"]')
clipNomSearchEl.value = root.querySelector('[data-dc-ref="clipNomSearchRef"]')
clipDescEl.value = root.querySelector('[data-dc-ref="clipDescRef"]')
}
@@ -178,7 +175,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
function resolveSelectedPhaseKey(): HomeTimelineKey {
if (nominationPhase.value) return 'nomination'
if (votingPhase.value) return 'voting'
if (reviewPhase.value) return 'review'
if (preparationPhase.value) return 'preparation'
if (completedPhase.value) return 'completed'
return 'show'
}
@@ -297,7 +294,7 @@ function resolvePhaseCountdownTarget(
}
function buildTimelineSchedule(store: AwardsStore): TimelineScheduleItem[] {
const phaseOrder: Exclude<HomeTimelineKey, 'completed'>[] = ['nomination', 'voting', 'review', 'show']
const phaseOrder: Exclude<HomeTimelineKey, 'completed'>[] = ['nomination', 'voting', 'preparation', 'show']
return phaseOrder
.map((key): TimelineScheduleItem | null => {
@@ -358,8 +355,8 @@ function phaseTitle(key: HomeTimelineKey) {
? 'Nominierung'
: key === 'voting'
? 'Voting'
: key === 'review'
? 'Review & Auswertung'
: key === 'preparation'
? 'Aufbereitung'
: 'Award Show'
}
@@ -113,7 +113,7 @@ export function useHomeParticipationActions(params: {
openModal('vote')
return
}
if (previewPhase.value === 'review') {
if (previewPhase.value === 'preparation') {
event?.preventDefault()
return
}
@@ -180,7 +180,7 @@ export function useHomeParticipationActions(params: {
}
function isHomePreviewPhaseKey(value: string | undefined): value is HomePreviewPhase {
return value === 'nomination' || value === 'voting' || value === 'review' || value === 'show'
return value === 'nomination' || value === 'voting' || value === 'preparation' || value === 'show'
}
function isCompletedPhase(value: string) {
@@ -35,9 +35,9 @@ export function useHomeParticipationPresentation(params: {
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 Name und Stream-Link ein. Optional kannst du direkt einen Clip mitschicken.' : 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 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! Name und Stream-Link 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)}.` : 'Danke fürs Abstimmen! Deine Auswahl wurde im Backend 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 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)
@@ -113,13 +113,7 @@ export function useHomeParticipationSubmitActions(params: {
return
}
const name = nominationContext.name.trim()
const streamUrl = nominationContext.streamUrl.trim()
if (!name) {
formError.value = 'Bitte gib den Namen des VTubers oder Streamers ein.'
return
}
if (!streamUrl) {
formError.value = 'Bitte füge einen Stream- oder Kanal-Link hinzu.'
return
@@ -143,7 +137,7 @@ export function useHomeParticipationSubmitActions(params: {
year: store.overview.year,
categoryId: Number(category.id),
twitchUserId: session.twitchUserId,
nominations: [{ name, streamUrl }],
nominations: [{ streamUrl }],
})
await loadMyParticipation()
submitted.value = true
@@ -165,7 +159,12 @@ export function useHomeParticipationSubmitActions(params: {
const url = clipContext.clipUrl.trim()
if (!url) {
formError.value = 'Bitte gib einen Twitch- oder YouTube-Clip-Link ein.'
formError.value = 'Bitte gib einen Clip-Link ein.'
return
}
if (!isHttpUrl(url)) {
formError.value = 'Bitte gib einen gültigen http(s)-Link ein.'
return
}
@@ -173,7 +172,12 @@ export function useHomeParticipationSubmitActions(params: {
try {
const session = await ensureViewerSession()
const category = displayCategories.value[clipCatIdx.value]
const selectedCreator = category?.candidates[clipContext.selectedNomineeIndex]
const selectedCreator = resolveSelectedCreator(category, clipContext.selectedNomineeQuery)
if (category?.candidates.length && !selectedCreator) {
formError.value = 'Bitte wähle einen VTuber aus den Vorschlägen aus.'
return
}
await store.submitClip({
year: store.overview.year,
categoryId: category ? Number(category.id) : null,
@@ -204,6 +208,21 @@ export function useHomeParticipationSubmitActions(params: {
}
}
function resolveSelectedCreator(category: HomeDisplayCategory | undefined, query: string) {
const candidates = category?.candidates ?? []
const normalizedQuery = normalizeSearchValue(query)
if (!normalizedQuery) return null
return candidates.find((candidate) => normalizeSearchValue(candidate.displayName) === normalizedQuery)
?? candidates.find((candidate) => normalizeSearchValue(candidate.channelSlug) === normalizedQuery)
?? candidates.find((candidate) => normalizeSearchValue(candidate.displayName).includes(normalizedQuery))
?? null
}
function normalizeSearchValue(value: string) {
return value.trim().toLowerCase()
}
function isHttpUrl(value: string) {
try {
const url = new URL(value)
@@ -10,7 +10,7 @@ export function useHomePhasePresentation(params: {
showDate: ComputedRef<string>
showStartsAt: ComputedRef<string>
currentYear: ComputedRef<string>
formatRange: (key: 'nomination' | 'voting' | 'review') => string
formatRange: (key: 'nomination' | 'voting' | 'preparation') => string
formatShowDate: () => string
}) {
const {
@@ -27,7 +27,7 @@ export function useHomePhasePresentation(params: {
const nominationPhase = computed(() => previewPhase.value === 'nomination')
const votingPhase = computed(() => previewPhase.value === 'voting')
const reviewPhase = computed(() => previewPhase.value === 'review')
const preparationPhase = computed(() => previewPhase.value === 'preparation')
const showPhase = computed(() => previewPhase.value === 'show')
const completedPhase = computed(() => previewPhase.value === 'completed')
const currentTimestamp = ref(Date.now())
@@ -39,13 +39,13 @@ export function useHomePhasePresentation(params: {
const showStartMs = computed(() => parseShowStartMs(showDate.value, showStartsAt.value))
const streamLive = computed(() => showPhase.value && !Number.isNaN(showStartMs.value) && currentTimestamp.value >= showStartMs.value)
const streamLocked = computed(() => !streamLive.value)
const phaseCardTitle = computed(() => completedPhase.value ? 'Award-Jahr abgeschlossen' : nominationPhase.value ? 'Community Nominierung' : votingPhase.value ? 'Community Voting' : reviewPhase.value ? 'Review & Auswertung' : '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!' : reviewPhase.value ? 'Das Voting ist abgeschlossen. Das Team prüft Ergebnisse, Clips und finale Show-Momente.' : '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')}` : reviewPhase.value ? `Review-Zeitraum · ${formatRange('review')}` : `Live · ${formatShowDate()} · ${formatTimeLabel(showStartsAt.value)} Uhr`)
const phaseStatusLabel = computed(() => completedPhase.value ? 'ABGESCHLOSSEN' : streamLive.value ? 'LIVE' : showPhase.value ? 'STARTET BALD' : reviewPhase.value ? 'IN PRÜFUNG' : '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;' : reviewPhase.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' : reviewPhase.value ? '✦ Auswertung läuft' : '● Zum Live-Stream')
const phasePrimaryDisabled = computed(() => reviewPhase.value || completedPhase.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 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 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;'
: 'display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:15px 18px;border-radius:13px;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;border:none;text-decoration:none;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:15px;box-shadow:0 10px 22px rgba(124,86,196,.32);cursor:pointer;')
@@ -68,14 +68,14 @@ export function useHomePhasePresentation(params: {
? 'position:absolute;top:27px;left:10%;right:10%;height:3px;background:linear-gradient(90deg,#e2d6f4 0%,#e2d6f4 100%);border-radius:3px;z-index:0;'
: votingPhase.value
? 'position:absolute;top:27px;left:10%;right:10%;height:3px;background:linear-gradient(90deg,#8b6cdb 0%,#8b6cdb 33.33%,#e2d6f4 33.33%,#e2d6f4 100%);border-radius:3px;z-index:0;'
: reviewPhase.value
: preparationPhase.value
? '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 ⭐' : reviewPhase.value ? 'Das Voting wird gerade ausgewertet ✦' : '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.' : reviewPhase.value ? 'Die Community hat abgestimmt. Jetzt prüft das Team Ergebnisse, Clips und finale Showeinspieler für die Award-Nacht.' : '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' : reviewPhase.value ? 'Auswertung läuft' : 'Zum Live-Stream')
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 sectionActionHref = computed(() => showPhase.value ? publicStreamUrl.value : '#')
const sectionActionDisabled = computed(() => reviewPhase.value || completedPhase.value)
const sectionActionDisabled = computed(() => preparationPhase.value || completedPhase.value)
const sectionActionStyle = computed(() => sectionActionDisabled.value
? 'display:inline-flex;align-items:center;gap:11px;padding:17px 38px;border-radius:14px;background:rgba(255,255,255,.72);color:#9b8abf;text-decoration:none;font-family:\'Outfit\',sans-serif;font-weight:700;font-size:18px;box-shadow:none;cursor:not-allowed;border:none;'
: nominationPhase.value
@@ -85,7 +85,7 @@ export function useHomePhasePresentation(params: {
return {
nominationPhase,
votingPhase,
reviewPhase,
preparationPhase,
showPhase,
completedPhase,
showCountdown,
@@ -1,5 +1,6 @@
import { computed, type ComputedRef } from 'vue'
import { privacyContentToHtml } from '../../lib/privacyContent'
import { simpleIconForKey } from '../../lib/socialIcons'
import type { OverviewResponse } from '../../types/awards'
@@ -26,15 +27,10 @@ export function useHomeSocialPresentation(siteContent: ComputedRef<OverviewRespo
)
const footerLinks = computed(() =>
(siteContent.value.footerLinks ?? []).filter((link) => link?.label && link.url),
(siteContent.value.footerLinks ?? []).filter((link) => link?.label && (link.url || link.content)),
)
const privacyContentBlocks = computed(() =>
(siteContent.value.privacyPolicyContent || '')
.split(/\n{2,}/)
.map((block) => block.trim())
.filter(Boolean),
)
const privacyContentHtml = computed(() => privacyContentToHtml(siteContent.value.privacyPolicyContent || ''))
function platformKey(value: string | null | undefined) {
return (value ?? 'link').trim().toLowerCase()
@@ -56,7 +52,7 @@ export function useHomeSocialPresentation(siteContent: ComputedRef<OverviewRespo
hostSocialLinks,
communitySocialLinks,
footerLinks,
privacyContentBlocks,
privacyContentHtml,
platformKey,
isUploadedSocialIcon,
socialSimpleIconPath,