Refactor app architecture and clean local artifacts

This commit is contained in:
AzuTear
2026-06-24 23:43:14 +02:00
parent 17134b3b82
commit fef1d36fe8
274 changed files with 37724 additions and 6065 deletions
@@ -0,0 +1,101 @@
<script setup lang="ts">
const props = defineProps<{
privacyModalOpen: boolean
privacyContentBlocks: string[]
onClosePrivacy: () => void
privacyModalStop: (event: Event) => void
accountModalOpen: boolean
twitchUser: string
role: string
deleteNotConfirm: boolean
deleteConfirm: boolean
accountActionError: string
authLoading: boolean
onCloseAccount: () => void
accountModalStop: (event: Event) => void
onOpenPrivacy: () => void
onLogout: () => Promise<void> | void
onRequestDelete: () => void
onCancelDelete: () => void
onConfirmDelete: () => Promise<void> | void
}>()
</script>
<template>
<template v-if="props.privacyModalOpen">
<div class="home-modal-overlay" @click="props.onClosePrivacy" style="position:fixed;inset:0;z-index:400;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="props.privacyModalStop" 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);">
<div style="display:flex;align-items:center;justify-content:space-between;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;">Rechtliches</div>
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:26px;margin:0;color:#3f3556;">Datenschutzerklärung</h2>
</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>
</div>
</div>
</template>
<template v-if="props.accountModalOpen">
<div class="home-modal-overlay" @click="props.onCloseAccount" style="position:fixed;inset:0;z-index:300;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="props.accountModalStop" style="position:relative;width:100%;max-width:480px;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);overflow:hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:24px 28px 18px;border-bottom:1px solid #f1ecfb;">
<div>
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#8b6cdb;margin-bottom:4px;">Mein Profil</div>
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:24px;margin:0;color:#3f3556;">@{{ props.twitchUser }}</h2>
</div>
<button @click="props.onCloseAccount" 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="padding:24px 28px;display:flex;flex-direction:column;gap:16px;">
<div style="display:flex;align-items:center;gap:10px;padding:13px 16px;border-radius:14px;background:#f6f1fd;border:1px solid #ede4fb;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b6cdb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
<span style="font-size:14px;color:#5f44ad;font-weight:500;">Angemeldet über Twitch</span>
</div>
<div style="padding:16px;border-radius:14px;background:#fafafa;border:1px solid #f0eafc;">
<p style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:#a99fc0;margin:0 0 10px;">Gespeicherte Daten</p>
<div style="display:flex;flex-direction:column;gap:7px;">
<div class="home-account-data-row" style="display:flex;justify-content:space-between;font-size:13.5px;"><span style="color:#6f6685;">Twitch-ID</span><span style="font-weight:600;color:#3f3556;">@{{ props.twitchUser }}</span></div>
<div class="home-account-data-row" style="display:flex;justify-content:space-between;font-size:13.5px;"><span style="color:#6f6685;">Rolle</span><span style="font-weight:700;color:#8b6cdb;text-transform:uppercase;font-size:11px;letter-spacing:1px;">{{ props.role }}</span></div>
<div class="home-account-data-row" style="display:flex;justify-content:space-between;font-size:13.5px;"><span style="color:#6f6685;">Einreichungen</span><span style="font-weight:600;color:#3f3556;">Clips, Votes, Nominierungen</span></div>
<div class="home-account-data-row" style="display:flex;justify-content:space-between;font-size:13.5px;"><span style="color:#6f6685;">Löschfrist</span><span style="font-weight:600;color:#059669;">März 2027 (auto.)</span></div>
</div>
</div>
<button @click="props.onOpenPrivacy" style="display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:12px;border:1px solid #ede4fb;background:#f9f6ff;color:#6a4fb8;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:600;cursor:pointer;text-align:left;width:100%;" style-hover="background:#f1ecfb;">
<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="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
Datenschutzerklärung lesen
</button>
<button @click="props.onLogout" style="display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:12px;border:1px solid #e2e8f0;background:#f8fafc;color:#64748b;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:600;cursor:pointer;text-align:left;width:100%;" style-hover="background:#f1f5f9;">
<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="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
Abmelden
</button>
<template v-if="props.deleteNotConfirm">
<button @click="props.onRequestDelete" style="display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:12px;border:1px solid #fecdd3;background:#fff5f5;color:#e11d48;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:600;cursor:pointer;text-align:left;width:100%;" style-hover="background:#ffe4e6;">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/></svg>
Meine Daten löschen (Löschrecht Art. 17 DSGVO)
</button>
</template>
<template v-if="props.deleteConfirm">
<div style="padding:16px;border-radius:14px;background:#fff5f5;border:1.5px solid #fecdd3;">
<p style="font-size:13.5px;color:#9f1239;font-weight:600;margin:0 0 6px;">Alle deine Daten werden sofort gelöscht.</p>
<p style="font-size:12.5px;color:#e11d48;margin:0 0 14px;line-height:1.5;">Das umfasst deine Votes, Nominierungen und Clip-Einreichungen. Diese Aktion kann nicht rückgängig gemacht werden.</p>
<p v-if="props.accountActionError" style="font-size:12.5px;color:#be123c;margin:0 0 12px;line-height:1.5;font-weight:600;">{{ props.accountActionError }}</p>
<div class="home-account-confirm-actions" style="display:flex;gap:8px;">
<button :disabled="props.authLoading" @click="props.onCancelDelete" style="flex:1;padding:10px;border-radius:10px;border:1px solid #e6dcf6;background:white;color:#6f6685;font-family:'Outfit',sans-serif;font-weight:600;font-size:13px;cursor:pointer;" style-hover="background:#f9f6ff;">Abbrechen</button>
<button :disabled="props.authLoading" @click="props.onConfirmDelete" style="flex:1;padding:10px;border-radius:10px;border:none;background:#e11d48;color:white;font-family:'Outfit',sans-serif;font-weight:700;font-size:13px;cursor:pointer;" style-hover="opacity:0.9;">{{ props.authLoading ? 'Löscht …' : 'Jetzt löschen' }}</button>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
</template>
@@ -0,0 +1,91 @@
<script setup lang="ts">
import type { HomeArchiveYearItem, HomeSelectedArchive } from './homeModalTypes'
const props = defineProps<{
archiveModalOpen: boolean
archiveYears: HomeArchiveYearItem[]
selectedArchive: HomeSelectedArchive
onCloseArchive: () => void
archiveModalStop: (event: Event) => void
setArchiveYear: (year: number) => Promise<void>
archiveYearButtonStyle: (active: boolean) => string
winnerPlatformStyle: (url: string) => string
winnerPlatformKey: (url: string) => string
winnerPlatformLabel: (url: string) => string
}>()
</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);">
<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>
<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>
</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;">
<div style="display:flex;flex-direction:column;gap:10px;">
<button
v-for="year in props.archiveYears"
:key="year.year"
@click="props.setArchiveYear(year.year)"
: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>
</button>
</div>
</aside>
<div style="padding:24px 24px 28px;overflow-y:auto;">
<div style="display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin-bottom:18px;padding:18px 20px;border-radius:20px;background:linear-gradient(135deg,#2a1842,#3a2168);box-shadow:0 18px 40px rgba(34,18,58,.2);">
<div>
<div style="font-size:12px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#c9b1ff;margin-bottom:6px;">Award Year</div>
<h4 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:30px;line-height:1;margin:0;color:#fff6fb;">{{ props.selectedArchive.year }}</h4>
</div>
<div style="font-size:13px;color:rgba(255,246,251,.78);">{{ props.selectedArchive.winners.length }} Kategorien archiviert</div>
</div>
<div class="home-archive-modal__winners" style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;">
<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);"
>
<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>
<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>
</div>
</div>
</div>
</template>
</template>
@@ -0,0 +1,29 @@
<template>
<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>
<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>
<div v-for="category in displayCategories" :key="category.id" style="padding:26px 22px;border-radius:22px;background:var(--card,#22123a);border:1px solid var(--line,rgba(255,255,255,.12));transition:transform .2s,border-color .2s;" style-hover="transform:translateY(-5px);border-color:var(--accent,#ff5fa2);">
<div style="display:inline-flex;align-items:center;justify-content:center;width:50px;height:50px;border-radius:15px;background:linear-gradient(135deg,var(--accent,#ff5fa2),var(--accent2,#a06bff));color:#fff;font-size:24px;margin-bottom:16px;box-shadow:0 8px 20px var(--glow,rgba(255,95,162,.4));">{{ category.icon }}</div>
<h3 style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:19px;margin:0 0 6px;">{{ category.name }}</h3>
<p style="font-size:14px;color:var(--muted,#c9b8da);margin:0;line-height:1.5;">{{ category.candidates.length }} Kandidat:innen aktuell in dieser Award-Kategorie.</p>
</div>
</div>
</section>
</template>
<script setup lang="ts">
type DisplayCategory = {
id: string
name: string
icon: string
candidates: unknown[]
}
defineProps<{
displayCategories: DisplayCategory[]
}>()
</script>
@@ -0,0 +1,164 @@
<template>
<header class="home-hero" style="position:relative;overflow:hidden;min-height:780px;background:radial-gradient(62% 80% at 76% 26%,rgba(255,210,236,.55),transparent 60%),radial-gradient(70% 90% at 18% 84%,rgba(206,196,247,.5),transparent 62%),linear-gradient(180deg,#f8f3fe 0%,#f1ebfc 100%);">
<div style="position:absolute;top:-140px;right:6%;width:520px;height:520px;border-radius:50%;background:radial-gradient(circle,rgba(255,196,228,.55),transparent 68%);filter:blur(18px);pointer-events:none;animation:pulseGlow 7s ease-in-out infinite;"></div>
<div style="position:absolute;bottom:-160px;left:-60px;width:480px;height:480px;border-radius:50%;background:radial-gradient(circle,rgba(190,176,240,.5),transparent 70%);filter:blur(22px);pointer-events:none;animation:pulseGlow 9s ease-in-out infinite;"></div>
<div style="position:absolute;right:max(-120px,calc(50% - 640px));top:50%;transform:translateY(-50%);width:720px;height:720px;border-radius:50%;background:radial-gradient(circle,rgba(255,255,255,.85),rgba(244,236,255,.4) 55%,transparent 72%);pointer-events:none;"></div>
<span style="position:absolute;top:64px;left:9%;font-size:20px;color:#e7b13e;animation:twinkle 3s ease-in-out infinite;"></span>
<span style="position:absolute;top:150px;left:40%;font-size:13px;color:#b79be8;animation:twinkle 2.4s ease-in-out .4s infinite;"></span>
<span style="position:absolute;top:300px;left:33%;font-size:12px;color:#f3a9cb;animation:twinkle 2.8s ease-in-out .8s infinite;"></span>
<span style="position:absolute;top:90px;left:53%;font-size:15px;color:#cdb6f0;animation:twinkle 3.4s ease-in-out 1s infinite;"></span>
<span style="position:absolute;top:40px;left:30%;font-size:11px;color:#e7b13e;animation:twinkle 2.6s ease-in-out .2s infinite;"></span>
<span style="position:absolute;top:120px;left:88%;width:10px;height:10px;border-radius:50%;background:#f3a9cb;opacity:.7;"></span>
<span style="position:absolute;top:230px;left:62%;width:8px;height:8px;border-radius:50%;background:#c9b6f0;opacity:.7;"></span>
<span style="position:absolute;bottom:120px;left:84%;font-size:14px;color:#e7b13e;animation:twinkle 3.1s ease-in-out 1.4s infinite;"></span>
<img class="home-hero__character" src="/assets/jayu-hero.png" alt="Jayu mit Stern-Pokal" data-hero-char style="position:absolute;top:-10px;right:max(30px,calc(50% - 560px));height:1010px;width:auto;z-index:1;pointer-events:none;filter:drop-shadow(0 26px 50px rgba(120,80,180,.2));-webkit-mask-image:linear-gradient(to bottom,#000 50%,rgba(0,0,0,0) 80%);mask-image:linear-gradient(to bottom,#000 50%,rgba(0,0,0,0) 80%);" />
<div class="home-hero__veil" style="position:absolute;inset:0;z-index:2;pointer-events:none;background:linear-gradient(100deg,#f6f0fe 0%,rgba(246,240,254,.86) 26%,rgba(246,240,254,.3) 46%,transparent 62%);"></div>
<div class="home-hero__content" style="position:relative;z-index:3;max-width:1200px;margin:0 auto;padding:60px 24px 70px;">
<div class="home-hero__copy" style="max-width:560px;">
<div class="home-hero__eyebrow" style="display:flex;align-items:center;gap:10px;font-size:13px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#a98ddb;margin-bottom:22px;">
<span style="color:#e7b13e;animation:spinSlow 9s linear infinite;display:inline-block;"></span> Die grosse Community-Auszeichnung
</div>
<h1 style="margin:0 0 4px;font-family:'Cormorant Garamond',serif;font-weight:700;line-height:.92;">
<span style="display:block;font-size:clamp(56px,8.4vw,116px);letter-spacing:3px;color:#5f44ad;">VTUBER</span>
<span style="display:block;font-size:clamp(38px,5.6vw,78px);letter-spacing:5px;background:linear-gradient(95deg,#eeb24a,#d9942a);-webkit-background-clip:text;background-clip:text;color:transparent;">STAR AWARDS</span>
</h1>
<div class="home-hero__presented" style="font-family:'Sacramento',cursive;font-size:clamp(32px,3.8vw,48px);color:#8a6fd0;margin:4px 0 22px;transform:rotate(-3deg);transform-origin:left center;display:inline-block;white-space:nowrap;">Presented by {{ siteContent.hostDisplayName }} <span style="font-family:'Cormorant Garamond',serif;color:#c79be6;"></span></div>
<p class="home-hero__body" style="font-size:18px;line-height:1.62;color:#6f6685;max-width:430px;margin:0 0 30px;">
{{ categoryIntroText }}
</p>
<div class="home-hero__phase-card" style="max-width:430px;background:rgba(255,255,255,.46);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border:1px solid rgba(255,255,255,.6);border-radius:22px;padding:24px 24px 26px;box-shadow:0 24px 54px rgba(124,86,196,.16);">
<div style="font-size:12px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#d9942a;margin-bottom:8px;">Aktuelle Phase</div>
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin-bottom:8px;">
<div class="home-hero__phase-title" style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:28px;color:#3f3556;line-height:1.05;white-space:nowrap;">{{ phaseCardTitle }}</div>
<span :style="phaseStatusStyle"><span style="width:6px;height:6px;border-radius:50%;background:currentColor;display:inline-block;"></span>{{ phaseStatusLabel }}</span>
</div>
<p style="font-size:14px;line-height:1.55;color:#8a8398;margin:0 0 8px;white-space:pre-line;">{{ phaseCardDescription }}</p>
<div style="font-size:12.5px;font-weight:600;color:#a99fc0;margin:0 0 18px;">{{ phaseCardRange }}</div>
<template v-if="showCountdown">
<div
data-dc-ref="phaseCountdownLabelRef"
style="font-size:11px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;color:#a98ddb;margin:0 0 8px;"
aria-live="polite"
>
Phase endet in
</div>
<div style="display:flex;border:1px solid #eee4fb;border-radius:14px;overflow:hidden;margin-bottom:22px;background:#fbf8ff;">
<div style="flex:1;text-align:center;padding:14px 4px;border-right:1px solid #eee4fb;">
<div data-dc-ref="dRef" style="font-family:'Outfit',sans-serif;font-weight:700;font-size:28px;line-height:1;color:#5a4a8a;">00</div>
<div style="font-size:10px;letter-spacing:1.5px;text-transform:uppercase;color:#a99fc0;margin-top:5px;">Tage</div>
</div>
<div style="flex:1;text-align:center;padding:14px 4px;border-right:1px solid #eee4fb;">
<div data-dc-ref="hRef" style="font-family:'Outfit',sans-serif;font-weight:700;font-size:28px;line-height:1;color:#5a4a8a;">00</div>
<div style="font-size:10px;letter-spacing:1.5px;text-transform:uppercase;color:#a99fc0;margin-top:5px;">Std</div>
</div>
<div style="flex:1;text-align:center;padding:14px 4px;border-right:1px solid #eee4fb;">
<div data-dc-ref="mRef" style="font-family:'Outfit',sans-serif;font-weight:700;font-size:28px;line-height:1;color:#5a4a8a;">00</div>
<div style="font-size:10px;letter-spacing:1.5px;text-transform:uppercase;color:#a99fc0;margin-top:5px;">Min</div>
</div>
<div style="flex:1;text-align:center;padding:14px 4px;">
<div data-dc-ref="sRef" style="font-family:'Outfit',sans-serif;font-weight:700;font-size:28px;line-height:1;color:#c77ab0;">00</div>
<div style="font-size:10px;letter-spacing:1.5px;text-transform:uppercase;color:#a99fc0;margin-top:5px;">Sek</div>
</div>
</div>
</template>
<div>
<a
v-if="showPhase"
:href="publicStreamUrl"
:style="phasePrimaryActionStyle"
style-hover="transform:translateY(-2px);"
>
{{ phasePrimaryLabel }}
</a>
<button
v-else
type="button"
:disabled="phasePrimaryDisabled"
:aria-disabled="phasePrimaryDisabled"
:style="phasePrimaryActionStyle"
@click="onPrimaryPhaseAction"
style-hover="transform:translateY(-2px);"
>
{{ phasePrimaryLabel }}
</button>
</div>
</div>
</div>
</div>
<div class="home-hero__host-card" style="position:absolute;z-index:61;right:64px;bottom:96px;background:rgba(255,255,255,.46);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border:1px solid rgba(255,255,255,.6);border-radius:18px;padding:18px 24px 20px;box-shadow:0 20px 48px rgba(124,86,196,.18);">
<div style="font-size:12px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#a98ddb;margin-bottom:5px;">Host</div>
<div class="home-hero__host-name" style="display:flex;align-items:center;gap:9px;font-family:'Outfit',sans-serif;font-weight:700;font-size:27px;letter-spacing:.5px;color:#5f44ad;line-height:1;margin-bottom:5px;">{{ siteContent.hostDisplayName.toUpperCase() }} <span style="font-size:19px;color:#e7b13e;"></span></div>
<div style="font-size:14px;color:#8a8398;margin-bottom:14px;">{{ siteContent.hostTagline }}</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<a
v-for="social in hostSocialLinks"
:key="`host-${social.platform}`"
:href="social.url"
:aria-label="social.label"
style="display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:11px;background:#f1ecfb;text-decoration:none;"
style-hover="transform:translateY(-2px);background:#e8e0f9;"
>
<template v-if="isUploadedSocialIcon(social.icon)">
<img :src="social.icon" :alt="social.label" style="width:22px;height:22px;object-fit:contain;" />
</template>
<template v-else-if="socialSimpleIconPath(social.platform)">
<svg width="18" height="18" viewBox="0 0 24 24" :fill="socialSimpleIconColor(social.platform)"><path :d="socialSimpleIconPath(social.platform)" /></svg>
</template>
<template v-else-if="platformKey(social.platform) === 'twitch'">
<svg width="18" height="18" viewBox="0 0 24 24" fill="#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>
</template>
<template v-else-if="platformKey(social.platform) === 'youtube'">
<svg width="18" height="18" viewBox="0 0 24 24" fill="#FF0000"><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>
<span style="font-size:16px;font-weight:800;color:#5f44ad;">{{ social.icon || '✦' }}</span>
</template>
</a>
</div>
</div>
<div style="position:absolute;top:0;left:0;right:0;height:4px;z-index:5;background:linear-gradient(90deg,#8b6cdb,#e7b13e,#ff8fc0);"></div>
</header>
</template>
<script setup lang="ts">
interface HomeSocialLink {
label: string
platform: string
icon: string
url: string
}
interface HomeSiteContent {
hostDisplayName: string
hostTagline: string
}
defineProps<{
siteContent: HomeSiteContent
hostSocialLinks: HomeSocialLink[]
categoryIntroText: string
phaseCardTitle: string
phaseStatusStyle: string
phaseStatusLabel: string
phaseCardDescription: string
phaseCardRange: string
showCountdown: boolean
publicStreamUrl: string
showPhase: boolean
phasePrimaryLabel: string
phasePrimaryDisabled: boolean
phasePrimaryActionStyle: string
onPrimaryPhaseAction: (event?: Event) => void
isUploadedSocialIcon: (icon: string | null | undefined) => boolean
socialSimpleIconPath: (platform: string | null | undefined) => string
socialSimpleIconColor: (platform: string | null | undefined) => string
platformKey: (platform: string | null | undefined) => string
}>()
</script>
@@ -0,0 +1,112 @@
<script setup lang="ts">
import HomeHeroMasthead from './HomeHeroMasthead.vue'
import HomeHeroStatsBand from './HomeHeroStatsBand.vue'
import HomeHeroStreamBand from './HomeHeroStreamBand.vue'
import HomeTopNav from './HomeTopNav.vue'
interface HomeSocialLink {
label: string
platform: string
icon: string
url: string
}
interface HomeSiteContent {
hostDisplayName: string
hostTagline: string
}
defineProps<{
isGuest: boolean
isUser: boolean
isAdmin: boolean
twitchUser: string
siteContent: HomeSiteContent
hostSocialLinks: HomeSocialLink[]
categoryIntroText: string
phaseCardTitle: string
phaseStatusStyle: string
phaseStatusLabel: string
phaseCardDescription: string
phaseCardRange: string
showCountdown: boolean
publicStreamUrl: string
showPhase: boolean
phasePrimaryLabel: string
phasePrimaryDisabled: boolean
phasePrimaryActionStyle: string
streamEyebrow: string
streamTitle: string
streamMeta: string
streamLive: boolean
streamLocked: boolean
streamLockedLabel: string
streamLockedTitle: string
statOneValue: string
statOneLabel: string
statTwoValue: string
statThreeValue: string
statThreeLabel: string
onLogin: () => void
onOpenAccount: () => void
openAdminPanel: (event?: Event) => void
onPrimaryPhaseAction: (event?: Event) => void
isUploadedSocialIcon: (icon: string | null | undefined) => boolean
socialSimpleIconPath: (platform: string | null | undefined) => string
socialSimpleIconColor: (platform: string | null | undefined) => string
platformKey: (platform: string | null | undefined) => string
}>()
</script>
<template>
<HomeTopNav
:is-guest="isGuest"
:is-user="isUser"
:is-admin="isAdmin"
:twitch-user="twitchUser"
:on-login="onLogin"
:on-open-account="onOpenAccount"
:open-admin-panel="openAdminPanel"
/>
<HomeHeroMasthead
:site-content="siteContent"
:host-social-links="hostSocialLinks"
:category-intro-text="categoryIntroText"
:phase-card-title="phaseCardTitle"
:phase-status-style="phaseStatusStyle"
:phase-status-label="phaseStatusLabel"
:phase-card-description="phaseCardDescription"
:phase-card-range="phaseCardRange"
:show-countdown="showCountdown"
:public-stream-url="publicStreamUrl"
:show-phase="showPhase"
:phase-primary-label="phasePrimaryLabel"
:phase-primary-disabled="phasePrimaryDisabled"
:phase-primary-action-style="phasePrimaryActionStyle"
:on-primary-phase-action="onPrimaryPhaseAction"
:is-uploaded-social-icon="isUploadedSocialIcon"
:social-simple-icon-path="socialSimpleIconPath"
:social-simple-icon-color="socialSimpleIconColor"
:platform-key="platformKey"
/>
<HomeHeroStreamBand
:public-stream-url="publicStreamUrl"
:stream-eyebrow="streamEyebrow"
:stream-title="streamTitle"
:stream-meta="streamMeta"
:stream-live="streamLive"
:stream-locked="streamLocked"
:stream-locked-label="streamLockedLabel"
:stream-locked-title="streamLockedTitle"
/>
<HomeHeroStatsBand
:stat-one-value="statOneValue"
:stat-one-label="statOneLabel"
:stat-two-value="statTwoValue"
:stat-three-value="statThreeValue"
:stat-three-label="statThreeLabel"
/>
</template>
@@ -0,0 +1,32 @@
<template>
<section style="border-top:1px solid var(--line,rgba(255,255,255,.12));border-bottom:1px solid var(--line,rgba(255,255,255,.12));background:var(--bg2,#1f0f33);">
<div style="max-width:1100px;margin:0 auto;padding:34px 24px;display:grid;grid-template-columns:repeat(4,1fr);gap:24px;text-align:center;" data-stats>
<div>
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:38px;line-height:1;background:linear-gradient(135deg,var(--accent,#ff5fa2),var(--accent2,#a06bff));-webkit-background-clip:text;background-clip:text;color:transparent;">{{ statOneValue }}</div>
<div style="font-size:14px;color:var(--muted,#c9b8da);margin-top:6px;">{{ statOneLabel }}</div>
</div>
<div>
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:38px;line-height:1;background:linear-gradient(135deg,var(--accent,#ff5fa2),var(--accent2,#a06bff));-webkit-background-clip:text;background-clip:text;color:transparent;">{{ statTwoValue }}</div>
<div style="font-size:14px;color:var(--muted,#c9b8da);margin-top:6px;">Kategorien</div>
</div>
<div>
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:38px;line-height:1;background:linear-gradient(135deg,var(--accent,#ff5fa2),var(--accent2,#a06bff));-webkit-background-clip:text;background-clip:text;color:transparent;">{{ statThreeValue }}</div>
<div style="font-size:14px;color:var(--muted,#c9b8da);margin-top:6px;">{{ statThreeLabel }}</div>
</div>
<div>
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:38px;line-height:1;background:linear-gradient(135deg,var(--accent,#ff5fa2),var(--accent2,#a06bff));-webkit-background-clip:text;background-clip:text;color:transparent;"></div>
<div style="font-size:14px;color:var(--muted,#c9b8da);margin-top:6px;">Chaos &amp; Glitzer</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
defineProps<{
statOneValue: string
statOneLabel: string
statTwoValue: string
statThreeValue: string
statThreeLabel: string
}>()
</script>
@@ -0,0 +1,59 @@
<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>
<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);">
<svg width="26" height="26" viewBox="0 0 24 24" fill="#c9b1ff"><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>
</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="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>
<span>Countdown bis zur grossen Live-Show</span>
</div>
</div>
</div>
<template v-if="streamLive || streamLocked">
<div class="home-stream-band__actions" :class="{ 'home-stream-band__actions--locked': streamLocked }" style="display:flex;flex-direction:column;align-items:center;gap:9px;">
<div style="display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;letter-spacing:.5px;color:rgba(255,255,255,.7);">
<span data-dc-ref="streamCountdownLabelRef">Finale startet in</span>
<span style="display:inline-flex;align-items:center;gap:4px;font-family:'Outfit',sans-serif;font-weight:700;color:#fff;">
<span data-dc-ref="bdRef">00</span><span style="opacity:.6;">T</span>
<span data-dc-ref="bhRef">00</span><span style="opacity:.6;">:</span>
<span data-dc-ref="bmRef">00</span><span style="opacity:.6;">:</span>
<span data-dc-ref="bsRef">00</span>
</span>
</div>
<a v-if="streamLive" :href="publicStreamUrl" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:11px;padding:16px 28px;border-radius:14px;background:#ec3b5a;color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:700;font-size:16px;box-shadow:0 12px 30px rgba(236,59,90,.45);" style-hover="transform:translateY(-2px);">
<span style="width:9px;height:9px;border-radius:50%;background:#fff;display:inline-block;animation:pulseGlow 1.4s ease-in-out infinite;"></span>
Jetzt live · Zum Stream
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
</a>
<div v-else :title="streamLockedTitle" style="display:inline-flex;align-items:center;gap:9px;padding:14px 24px;border-radius:14px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.16);color:rgba(255,255,255,.62);font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:not-allowed;">
<svg width="15" height="15" viewBox="0 0 24 24" fill="rgba(255,255,255,.62)"><path d="M17 9V7a5 5 0 0 0-10 0v2H5v13h14V9h-2zM9 7a3 3 0 0 1 6 0v2H9V7zm3 11a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"/></svg>
{{ streamLockedLabel }}
</div>
</div>
</template>
</div>
</section>
</template>
<script setup lang="ts">
defineProps<{
publicStreamUrl: string
streamEyebrow: string
streamTitle: string
streamMeta: string
streamLive: boolean
streamLocked: boolean
streamLockedLabel: string
streamLockedTitle: string
}>()
</script>
@@ -0,0 +1,280 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import HomeSelectDropdown from './HomeSelectDropdown.vue'
import HomeVotingPickerPane from './HomeVotingPickerPane.vue'
import type { HomeCategoryListItem, HomeNomineeListItem, HomeSelectionOption } from './homeModalTypes'
const props = defineProps<{
modalOpen: boolean
submitted: boolean
notSubmitted: boolean
formError: string
successTitle: string
successText: string
closeModal: () => void
stop: (event: Event) => void
isShow: boolean
isPicker: boolean
isVote: boolean
isClip: boolean
nominationPhase: boolean
publicStreamUrl: string
formatShowDate: () => string
submitReminder: () => void
pickerTitle: string
pickerSubtitle: string
catList: HomeCategoryListItem[]
activeCatName: string
noms: HomeNomineeListItem[]
voteCount: number
totalCats: number
canSubmitVote: boolean
submitVote: () => Promise<void> | void
submitNomination: () => Promise<void> | void
submitting: boolean
onClipCatChange: (event: Event) => void
catOptions: HomeSelectionOption[]
clipNomOptions: HomeSelectionOption[]
clipDsgvo: boolean
clipDsgvoChange: () => void
onOpenPrivacy: () => void
canSubmitClip: boolean
clipSubmitStyle: string
submitClip: () => Promise<void> | void
}>()
const nominationCatValue = ref(0)
const clipCatValue = ref(0)
const clipNomValue = ref(0)
watch(
() => props.catOptions,
(options) => {
nominationCatValue.value = resolveOptionValue(nominationCatValue.value, options)
const nextClipCat = resolveOptionValue(clipCatValue.value, options)
if (nextClipCat !== clipCatValue.value) {
clipCatValue.value = nextClipCat
notifyClipCategoryChange(nextClipCat)
}
},
{ 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
notifyClipCategoryChange(value)
}
function notifyClipCategoryChange(value: number) {
props.onClipCatChange({ target: { value: String(value) } } as unknown as Event)
}
</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>
<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>
<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>
</template>
<template v-if="props.notSubmitted">
<p
v-if="props.formError"
style="margin:18px 36px 0;padding:12px 14px;border-radius:12px;border:1px solid #fecdd3;background:#fff1f2;color:#be123c;font-size:13.5px;font-weight:600;line-height:1.45;"
>
{{ props.formError }}
</p>
<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>
<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;" />
<button @click="props.submitReminder" style="flex:none;padding:14px 24px;border-radius:12px;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 22px rgba(124,86,196,.3);" style-hover="transform:translateY(-2px);">Erinnern</button>
</div>
<a :href="props.publicStreamUrl" target="_blank" rel="noopener" style="display:flex;align-items:center;justify-content:center;gap:9px;padding:14px;border-radius:12px;background:#f1ecfb;color:#7a3fd0;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;" style-hover="background:#e8e0f9;"><svg width="17" height="17" viewBox="0 0 24 24" fill="#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>Zum Award-Stream</a>
</div>
</template>
<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>
<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;">Name und Stream-Link gehen direkt in den Admin-Review.</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;">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>
</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://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;" />
</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>
<HomeSelectDropdown
v-model="clipNomValue"
data-ref="clipNomRef"
label="VTuber fuer Clip auswaehlen"
placeholder="Noch keine Kandidat:innen"
:options="props.clipNomOptions"
/>
</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>
</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>
<HomeVotingPickerPane
:cat-list="props.catList"
:active-cat-name="props.activeCatName"
:noms="props.noms"
:vote-count="props.voteCount"
:total-cats="props.totalCats"
:can-submit-vote="props.canSubmitVote"
:submit-vote="props.submitVote"
:submitting="props.submitting"
/>
</div>
</template>
</template>
<template v-if="props.isClip">
<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>
<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;">
<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;" />
</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>
<HomeSelectDropdown
v-model="clipNomValue"
data-ref="clipNomRef"
label="VTuber fuer Clip auswaehlen"
placeholder="Noch keine Kandidat:innen"
:options="props.clipNomOptions"
/>
</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:#fbf9ff;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:#f9f6ff;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>
</div>
</div>
</template>
</template>
</div>
</div>
</template>
</template>
@@ -0,0 +1,369 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import CinematicStarLoader from '../CinematicStarLoader.vue'
import HomeCategoriesSection from './HomeCategoriesSection.vue'
import HomeHeroShell from './HomeHeroShell.vue'
import HomeLandingModals from './HomeLandingModals.vue'
import HomeParticipationSection from './HomeParticipationSection.vue'
import HomeSupportFooterSection from './HomeSupportFooterSection.vue'
import HomeTimelineSection from './HomeTimelineSection.vue'
import HomeWinnerShowcaseSection from './HomeWinnerShowcaseSection.vue'
import { useHomeLandingState } from './useHomeLandingState'
import { useHomeLandingViewEffects } from './useHomeLandingViewEffects'
const router = useRouter()
const rootStyle = "font-family:'Outfit',sans-serif;min-height:100vh;overflow-x:hidden;line-height:1.5;position:relative;background:#f4eefb;color:#3f3556;--bg:#f4eefb;--bg2:#ebe2f8;--surface:#ffffff;--card:#ffffff;--ink:#3f3556;--muted:#8a8398;--accent:#8b6cdb;--accent2:#b78bff;--gold:#d9942a;--line:rgba(124,86,196,0.16);--glow:rgba(139,108,219,0.3)"
const {
store,
authStore,
role,
isGuest,
isUser,
isAdmin,
twitchUser,
siteContent,
hostSocialLinks,
communitySocialLinks,
footerLinks,
faqItems,
privacyContentBlocks,
publicStreamUrl,
displayCategories,
nominationPhase,
votingPhase,
reviewPhase,
showPhase,
completedPhase,
showCountdown,
streamLive,
streamLocked,
privacyModalOpen,
accountModalOpen,
archiveModalOpen,
modalOpen,
isShow,
isVote,
isPicker,
isClip,
notSubmitted,
deleteNotConfirm,
voteCount,
totalCats,
canSubmitVote,
activeCatName,
phaseCardTitle,
phaseCardDescription,
phaseCardRange,
phaseStatusLabel,
phaseStatusStyle,
phasePrimaryLabel,
phasePrimaryDisabled,
phasePrimaryActionStyle,
streamEyebrow,
streamTitle,
streamMeta,
streamLockedLabel,
streamLockedTitle,
statOneValue,
statOneLabel,
statTwoValue,
statThreeValue,
statThreeLabel,
categoryIntroText,
timelineLineStyle,
sectionTitle,
sectionText,
sectionActionLabel,
sectionActionHref,
sectionActionDisabled,
sectionActionStyle,
previewPhase,
pickerTitle,
pickerSubtitle,
successTitle,
successText,
clipNomOptions,
catOptions,
canSubmitClip,
clipSubmitStyle,
archiveYears,
selectedArchive,
winnerShowcase,
submitted,
submitting,
formError,
clipDsgvo,
archiveYear,
catList,
noms,
formatTimelineRange,
formatShowDate,
initialsFor,
openVote,
openNominate,
onLogin,
onOpenAccount,
openAdminPanel,
onPrimaryPhaseAction,
isUploadedSocialIcon,
socialSimpleIconPath,
socialSimpleIconColor,
platformKey,
onSectionAction,
closeModal,
stop,
submitReminder,
submitVote,
submitNomination,
onClipCatChange,
clipDsgvoChange,
onOpenPrivacy,
submitClip,
onCloseArchive,
archiveYearButtonStyle,
winnerPlatformStyle,
winnerPlatformKey,
winnerPlatformLabel,
privacyModalStop,
accountModalStop,
archiveModalStop,
onClosePrivacy,
onCloseAccount,
deleteConfirm,
accountActionError,
onLogout,
onRequestDelete,
onCancelDelete,
onConfirmDelete,
onOpenArchive,
setArchiveYear,
onTimelineFinalAction,
setPreviewPhase,
initializeHomeInteractions,
} = useHomeLandingState()
const previewPhaseButtons = [
{ key: 'nomination', label: 'Nominierung', hint: 'Einreichen & Clips' },
{ key: 'voting', label: 'Voting', hint: 'Community stimmt ab' },
{ key: 'review', label: 'Review', hint: 'Auswertung' },
{ key: 'show', label: 'Show', hint: 'Live-Finale' },
] as const
const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmit } = useHomeLandingViewEffects({
router,
store,
authStore,
modalOpen,
accountModalOpen,
privacyModalOpen,
archiveModalOpen,
submitted,
streamLive,
archiveYear,
nominationPhase,
votingPhase,
reviewPhase,
completedPhase,
initializeHomeInteractions,
submitNomination,
submitClip,
})
</script>
<template>
<div :ref="setRootEl">
<transition name="cinematic-loader">
<div
v-if="landingLoaderVisible"
style="position:fixed;inset:0;z-index:1200;"
>
<CinematicStarLoader
eyebrow="VTuber Star Award"
title="Suche nach Sternen im Himmel"
outro-text="Sterne am Himmel gefunden und Erfolgreich verknüpft."
/>
</div>
</transition>
<div :style="rootStyle" class="home-landing">
<div v-if="isAdmin" class="home-demo-preview" aria-label="Demo Phasen-Vorschau">
<div class="home-demo-preview__label">
<span></span>
<strong>Vorschau</strong>
</div>
<div class="home-demo-preview__buttons">
<button
v-for="phaseButton in previewPhaseButtons"
:key="phaseButton.key"
type="button"
class="home-demo-preview__button"
:class="{ 'home-demo-preview__button--active': previewPhase === phaseButton.key }"
@click="setPreviewPhase(phaseButton.key)"
>
<span>{{ phaseButton.label }}</span>
<small>{{ phaseButton.hint }}</small>
</button>
</div>
</div>
<HomeHeroShell
:is-guest="isGuest"
:is-user="isUser"
:is-admin="isAdmin"
:twitch-user="twitchUser"
:site-content="siteContent"
:host-social-links="hostSocialLinks"
:category-intro-text="categoryIntroText"
:phase-card-title="phaseCardTitle"
:phase-status-style="phaseStatusStyle"
:phase-status-label="phaseStatusLabel"
:phase-card-description="phaseCardDescription"
:phase-card-range="phaseCardRange"
:show-countdown="showCountdown"
:public-stream-url="publicStreamUrl"
:show-phase="showPhase"
:phase-primary-label="phasePrimaryLabel"
:phase-primary-disabled="phasePrimaryDisabled"
:phase-primary-action-style="phasePrimaryActionStyle"
:stream-eyebrow="streamEyebrow"
:stream-title="streamTitle"
:stream-meta="streamMeta"
:stream-live="streamLive"
:stream-locked="streamLocked"
:stream-locked-label="streamLockedLabel"
:stream-locked-title="streamLockedTitle"
:stat-one-value="statOneValue"
:stat-one-label="statOneLabel"
:stat-two-value="statTwoValue"
:stat-three-value="statThreeValue"
:stat-three-label="statThreeLabel"
:on-login="onLogin"
:on-open-account="onOpenAccount"
:open-admin-panel="openAdminPanel"
:on-primary-phase-action="onPrimaryPhaseAction"
:is-uploaded-social-icon="isUploadedSocialIcon"
:social-simple-icon-path="socialSimpleIconPath"
:social-simple-icon-color="socialSimpleIconColor"
:platform-key="platformKey"
/>
<HomeTimelineSection
:nomination-phase="nominationPhase"
:voting-phase="votingPhase"
:review-phase="reviewPhase"
:show-phase="showPhase"
:completed-phase="completedPhase"
:timeline-line-style="timelineLineStyle"
:public-stream-url="publicStreamUrl"
:format-timeline-range="formatTimelineRange"
:open-nominate="openNominate"
:open-vote="openVote"
:on-timeline-final-action="onTimelineFinalAction"
/>
<HomeCategoriesSection :display-categories="displayCategories" />
<HomeWinnerShowcaseSection
:selected-archive="selectedArchive"
:winner-showcase="winnerShowcase"
:initials-for="initialsFor"
:on-open-archive="onOpenArchive"
/>
<HomeParticipationSection
:section-title="sectionTitle"
:section-text="sectionText"
:section-action-label="sectionActionLabel"
:section-action-href="sectionActionHref"
:section-action-disabled="sectionActionDisabled"
:section-action-style="sectionActionStyle"
:nomination-phase="nominationPhase"
:review-phase="reviewPhase"
:on-section-action="onSectionAction"
/>
<HomeSupportFooterSection
:community-social-links="communitySocialLinks"
:site-content="siteContent"
:faq-items="faqItems"
:footer-links="footerLinks"
:on-open-privacy="onOpenPrivacy"
:is-uploaded-social-icon="isUploadedSocialIcon"
:social-simple-icon-path="socialSimpleIconPath"
:social-simple-icon-color="socialSimpleIconColor"
:platform-key="platformKey"
/>
<HomeLandingModals
:modal-open="modalOpen"
:submitted="submitted"
:not-submitted="notSubmitted"
:form-error="formError"
:success-title="successTitle"
:success-text="successText"
:close-modal="closeModal"
:stop="stop"
:is-show="isShow"
:is-picker="isPicker"
:is-vote="isVote"
:is-clip="isClip"
:nomination-phase="nominationPhase"
:public-stream-url="publicStreamUrl"
:format-show-date="formatShowDate"
:submit-reminder="submitReminder"
:picker-title="pickerTitle"
:picker-subtitle="pickerSubtitle"
:cat-list="catList"
:active-cat-name="activeCatName"
:noms="noms"
:vote-count="voteCount"
:total-cats="totalCats"
:can-submit-vote="canSubmitVote"
:submit-vote="submitVote"
:submit-nomination="handleNominationSubmit"
:submitting="submitting"
:on-clip-cat-change="onClipCatChange"
:cat-options="catOptions"
:clip-nom-options="clipNomOptions"
:clip-dsgvo="clipDsgvo"
:clip-dsgvo-change="clipDsgvoChange"
:on-open-privacy="onOpenPrivacy"
:can-submit-clip="canSubmitClip"
:clip-submit-style="clipSubmitStyle"
:submit-clip="handleClipSubmit"
:archive-modal-open="archiveModalOpen"
:archive-years="archiveYears"
:selected-archive="selectedArchive"
:on-close-archive="onCloseArchive"
:archive-modal-stop="archiveModalStop"
:set-archive-year="setArchiveYear"
:archive-year-button-style="archiveYearButtonStyle"
:winner-platform-style="winnerPlatformStyle"
:winner-platform-key="winnerPlatformKey"
:winner-platform-label="winnerPlatformLabel"
:privacy-modal-open="privacyModalOpen"
:privacy-content-blocks="privacyContentBlocks"
:on-close-privacy="onClosePrivacy"
:privacy-modal-stop="privacyModalStop"
:account-modal-open="accountModalOpen"
:twitch-user="twitchUser"
:role="role"
:delete-not-confirm="deleteNotConfirm"
:delete-confirm="deleteConfirm"
:account-action-error="accountActionError"
:auth-loading="authStore.loading"
:on-close-account="onCloseAccount"
:account-modal-stop="accountModalStop"
:on-logout="onLogout"
:on-request-delete="onRequestDelete"
:on-cancel-delete="onCancelDelete"
:on-confirm-delete="onConfirmDelete"
/>
</div>
</div>
</template>
<style>
@import './homeLandingExperience.css';
</style>
@@ -0,0 +1,153 @@
<script setup lang="ts">
import HomeAccountAndPrivacyModals from './HomeAccountAndPrivacyModals.vue'
import HomeArchiveModal from './HomeArchiveModal.vue'
import HomeInteractionModal from './HomeInteractionModal.vue'
import type {
HomeArchiveYearItem,
HomeCategoryListItem,
HomeNomineeListItem,
HomeSelectedArchive,
HomeSelectionOption,
} from './homeModalTypes'
defineProps<{
modalOpen: boolean
submitted: boolean
notSubmitted: boolean
formError: string
successTitle: string
successText: string
closeModal: () => void
stop: (event: Event) => void
isShow: boolean
isPicker: boolean
isVote: boolean
isClip: boolean
nominationPhase: boolean
publicStreamUrl: string
formatShowDate: () => string
submitReminder: () => void
pickerTitle: string
pickerSubtitle: string
catList: HomeCategoryListItem[]
activeCatName: string
noms: HomeNomineeListItem[]
voteCount: number
totalCats: number
canSubmitVote: boolean
submitVote: () => Promise<void> | void
submitNomination: () => Promise<void> | void
submitting: boolean
onClipCatChange: (event: Event) => void
catOptions: HomeSelectionOption[]
clipNomOptions: HomeSelectionOption[]
clipDsgvo: boolean
clipDsgvoChange: () => void
onOpenPrivacy: () => void
canSubmitClip: boolean
clipSubmitStyle: string
submitClip: () => Promise<void> | void
archiveModalOpen: boolean
archiveYears: HomeArchiveYearItem[]
selectedArchive: HomeSelectedArchive
onCloseArchive: () => void
archiveModalStop: (event: Event) => void
setArchiveYear: (year: number) => Promise<void>
archiveYearButtonStyle: (active: boolean) => string
winnerPlatformStyle: (url: string) => string
winnerPlatformKey: (url: string) => string
winnerPlatformLabel: (url: string) => string
privacyModalOpen: boolean
privacyContentBlocks: string[]
onClosePrivacy: () => void
privacyModalStop: (event: Event) => void
accountModalOpen: boolean
twitchUser: string
role: string
deleteNotConfirm: boolean
deleteConfirm: boolean
accountActionError: string
authLoading: boolean
onCloseAccount: () => void
accountModalStop: (event: Event) => void
onLogout: () => Promise<void> | void
onRequestDelete: () => void
onCancelDelete: () => void
onConfirmDelete: () => Promise<void> | void
}>()
</script>
<template>
<HomeInteractionModal
:modal-open="modalOpen"
:submitted="submitted"
:not-submitted="notSubmitted"
:form-error="formError"
:success-title="successTitle"
:success-text="successText"
:close-modal="closeModal"
:stop="stop"
:is-show="isShow"
:is-picker="isPicker"
:is-vote="isVote"
:is-clip="isClip"
:nomination-phase="nominationPhase"
:public-stream-url="publicStreamUrl"
:format-show-date="formatShowDate"
:submit-reminder="submitReminder"
:picker-title="pickerTitle"
:picker-subtitle="pickerSubtitle"
:cat-list="catList"
:active-cat-name="activeCatName"
:noms="noms"
:vote-count="voteCount"
:total-cats="totalCats"
:can-submit-vote="canSubmitVote"
:submit-vote="submitVote"
:submit-nomination="submitNomination"
:submitting="submitting"
:on-clip-cat-change="onClipCatChange"
:cat-options="catOptions"
:clip-nom-options="clipNomOptions"
:clip-dsgvo="clipDsgvo"
:clip-dsgvo-change="clipDsgvoChange"
:on-open-privacy="onOpenPrivacy"
:can-submit-clip="canSubmitClip"
:clip-submit-style="clipSubmitStyle"
:submit-clip="submitClip"
/>
<HomeArchiveModal
:archive-modal-open="archiveModalOpen"
:archive-years="archiveYears"
:selected-archive="selectedArchive"
:on-close-archive="onCloseArchive"
:archive-modal-stop="archiveModalStop"
:set-archive-year="setArchiveYear"
:archive-year-button-style="archiveYearButtonStyle"
:winner-platform-style="winnerPlatformStyle"
:winner-platform-key="winnerPlatformKey"
:winner-platform-label="winnerPlatformLabel"
/>
<HomeAccountAndPrivacyModals
:privacy-modal-open="privacyModalOpen"
:privacy-content-blocks="privacyContentBlocks"
:on-close-privacy="onClosePrivacy"
:privacy-modal-stop="privacyModalStop"
:account-modal-open="accountModalOpen"
:twitch-user="twitchUser"
:role="role"
:delete-not-confirm="deleteNotConfirm"
:delete-confirm="deleteConfirm"
:account-action-error="accountActionError"
:auth-loading="authLoading"
:on-close-account="onCloseAccount"
:account-modal-stop="accountModalStop"
:on-open-privacy="onOpenPrivacy"
:on-logout="onLogout"
:on-request-delete="onRequestDelete"
:on-cancel-delete="onCancelDelete"
:on-confirm-delete="onConfirmDelete"
/>
</template>
@@ -0,0 +1,79 @@
<script setup lang="ts">
const props = defineProps<{
sectionTitle: string
sectionText: string
sectionActionLabel: string
sectionActionHref: string
sectionActionDisabled: boolean
sectionActionStyle: string
nominationPhase: boolean
reviewPhase: boolean
onSectionAction: (event?: Event) => void
}>()
</script>
<template>
<section id="voting" class="home-section" style="max-width:1200px;margin:0 auto;padding:90px 24px;">
<div class="home-steps-card" style="position:relative;background:#fdfcff;border:1px solid #efe7fb;border-radius:32px;padding:52px 44px;box-shadow:0 22px 60px rgba(124,86,196,.09);margin-bottom:50px;">
<div style="display:flex;align-items:center;justify-content:center;gap:16px;margin-bottom:50px;">
<span style="font-size:13px;color:#e7b13e;"></span>
<h2 class="home-steps-heading" style="margin:0;font-family:'Outfit',sans-serif;font-weight:700;font-size:clamp(20px,2.4vw,26px);letter-spacing:3px;text-transform:uppercase;color:#6a4fb8;white-space:nowrap;">So funktioniert's</h2>
<span style="font-size:13px;color:#b9a3e8;">✦</span>
</div>
<div style="display:flex;align-items:flex-start;gap:10px;" data-steps>
<div style="flex:1;">
<div style="height:170px;border-radius:18px;background:#f6f1fd;border:1px dashed #d8c9f2;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:#a98ddb;font-family:monospace;font-size:12px;text-align:center;padding:14px;margin-bottom:22px;"><span style="font-size:30px;">✎</span>Illustration<br>Nominieren</div>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
<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>
</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;">
<div style="height:170px;border-radius:18px;background:#f6f1fd;border:1px dashed #d8c9f2;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:#a98ddb;font-family:monospace;font-size:12px;text-align:center;padding:14px;margin-bottom:22px;"><span style="font-size:30px;">♡</span>Illustration<br>Voten</div>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
<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;">2</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;">Voten</h3>
</div>
<p style="margin:0;font-size:15.5px;line-height:1.6;color:#7d7491;">Die Community stimmt über ihre Top-Favoriten ab. Nur 1 Stimme pro Kategorie.</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;">
<div style="height:170px;border-radius:18px;background:#f6f1fd;border:1px dashed #d8c9f2;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:#a98ddb;font-family:monospace;font-size:12px;text-align:center;padding:14px;margin-bottom:22px;"><span style="font-size:30px;">★</span>Illustration<br>Die Besten</div>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
<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;">3</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;">Die Besten</h3>
</div>
<p style="margin:0;font-size:15.5px;line-height:1.6;color:#7d7491;">Die Stimmen werden gezählt und die Gewinner live in der Show gekürt!</p>
</div>
</div>
</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>
<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"
type="button"
disabled
aria-disabled="true"
:style="props.sectionActionStyle"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="#9B8ABF"><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 }}
</button>
<a
v-else
:href="props.sectionActionHref"
@click="props.onSectionAction"
: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 }}
</a>
</div>
</section>
</template>
@@ -0,0 +1,331 @@
<script setup lang="ts">
import { Check, ChevronDown } from '@lucide/vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { HomeSelectionOption } from './homeModalTypes'
const props = withDefaults(defineProps<{
modelValue: number
options: HomeSelectionOption[]
dataRef?: string
label: string
placeholder?: string
}>(), {
dataRef: undefined,
placeholder: 'Auswählen',
})
const emit = defineEmits<{
'update:modelValue': [value: number]
change: [value: number]
}>()
const rootEl = ref<HTMLElement | null>(null)
const open = ref(false)
const activeIndex = ref(0)
const selectedOption = computed(() =>
props.options.find((option) => option.id === props.modelValue) ?? props.options[0] ?? null,
)
const selectedValue = computed(() => selectedOption.value?.id ?? 0)
const listboxId = computed(() =>
`home-select-${props.label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
)
watch(
() => props.options,
(options) => {
const currentIndex = options.findIndex((option) => option.id === props.modelValue)
if (currentIndex >= 0) {
activeIndex.value = currentIndex
return
}
activeIndex.value = 0
if (options[0]) {
emit('update:modelValue', options[0].id)
emit('change', options[0].id)
}
},
{ immediate: true },
)
function toggleDropdown() {
if (!props.options.length) return
open.value = !open.value
if (open.value) {
activeIndex.value = Math.max(0, props.options.findIndex((option) => option.id === selectedValue.value))
}
}
function closeDropdown() {
open.value = false
}
function selectOption(option: HomeSelectionOption) {
emit('update:modelValue', option.id)
emit('change', option.id)
closeDropdown()
}
function selectActiveOption() {
const option = props.options[activeIndex.value]
if (option) selectOption(option)
}
function moveActive(delta: number) {
if (!props.options.length) return
if (!open.value) {
open.value = true
return
}
activeIndex.value = (activeIndex.value + delta + props.options.length) % props.options.length
}
function onButtonKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown') {
event.preventDefault()
moveActive(1)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
moveActive(-1)
return
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
open.value ? selectActiveOption() : toggleDropdown()
return
}
if (event.key === 'Escape') {
event.preventDefault()
closeDropdown()
}
}
function onDocumentPointerDown(event: PointerEvent) {
if (!rootEl.value?.contains(event.target as Node)) {
closeDropdown()
}
}
onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocumentPointerDown)
})
</script>
<template>
<div ref="rootEl" class="home-select" :class="{ 'home-select--open': open }">
<select
v-if="props.dataRef"
class="home-select__native"
:data-dc-ref="props.dataRef"
:value="selectedValue"
aria-hidden="true"
tabindex="-1"
>
<option v-for="option in props.options" :key="option.id" :value="option.id">
{{ option.label }}
</option>
</select>
<button
type="button"
class="home-select__trigger"
:aria-label="props.label"
:aria-expanded="open"
aria-haspopup="listbox"
:aria-controls="listboxId"
@click="toggleDropdown"
@keydown="onButtonKeydown"
>
<span class="home-select__value">{{ selectedOption?.label ?? props.placeholder }}</span>
<span class="home-select__chevron" aria-hidden="true">
<ChevronDown :size="20" :stroke-width="2.8" />
</span>
</button>
<div v-if="open" :id="listboxId" class="home-select__menu" role="listbox">
<button
v-for="(option, optionIndex) in props.options"
:key="option.id"
type="button"
class="home-select__option"
:class="{
'home-select__option--active': optionIndex === activeIndex,
'home-select__option--selected': option.id === selectedValue,
}"
role="option"
:aria-selected="option.id === selectedValue"
@mouseenter="activeIndex = optionIndex"
@click="selectOption(option)"
>
<span class="home-select__option-label">{{ option.label }}</span>
<Check v-if="option.id === selectedValue" class="home-select__check" :size="18" :stroke-width="3" />
</button>
</div>
</div>
</template>
<style scoped>
.home-select{
position:relative;
width:100%;
z-index:1;
}
.home-select--open{
z-index:30;
}
.home-select__native{
position:absolute;
width:1px;
height:1px;
opacity:0;
pointer-events:none;
}
.home-select__trigger{
display:flex;
align-items:center;
justify-content:space-between;
gap:14px;
width:100%;
min-height:56px;
padding:11px 11px 11px 17px;
border:2px solid #e8def8;
border-radius:16px;
background:linear-gradient(180deg,#fff 0%,#fbf8ff 100%);
box-shadow:0 8px 22px rgba(139,108,219,.08), inset 0 1px 0 rgba(255,255,255,.9);
color:#3f3556;
cursor:pointer;
font-family:'Outfit',sans-serif;
font-size:15px;
font-weight:700;
line-height:1.2;
outline:none;
text-align:left;
transition:border-color .18s ease, box-shadow .18s ease, transform .18s ease, background .18s ease;
}
.home-select__trigger:hover,
.home-select__trigger:focus-visible,
.home-select--open .home-select__trigger{
border-color:#9b7ce4;
background:#fff;
box-shadow:0 12px 30px rgba(139,108,219,.16), 0 0 0 4px rgba(139,108,219,.12);
}
.home-select__value{
min-width:0;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.home-select__chevron{
display:grid;
place-items:center;
flex:none;
width:34px;
height:34px;
border-radius:12px;
background:#f1eafd;
color:#7c5bd2;
box-shadow:inset 0 0 0 1px rgba(139,108,219,.09);
transition:transform .18s ease, background .18s ease, color .18s ease;
}
.home-select--open .home-select__chevron{
transform:rotate(180deg);
background:#835fd8;
color:#fff;
}
.home-select__menu{
position:absolute;
top:calc(100% + 8px);
left:0;
right:0;
display:grid;
gap:4px;
max-height:286px;
padding:8px;
overflow:auto;
border:1px solid rgba(139,108,219,.18);
border-radius:18px;
background:rgba(255,255,255,.98);
box-shadow:0 24px 54px rgba(63,53,86,.22);
backdrop-filter:blur(14px);
-webkit-backdrop-filter:blur(14px);
}
.home-select__option{
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
width:100%;
min-height:46px;
padding:10px 12px;
border:0;
border-radius:13px;
background:transparent;
color:#514765;
cursor:pointer;
font-family:'Outfit',sans-serif;
font-size:15px;
font-weight:650;
line-height:1.2;
text-align:left;
transition:background .16s ease, color .16s ease, transform .16s ease;
}
.home-select__option:hover,
.home-select__option--active{
background:#f6f0ff;
color:#4f3a8a;
}
.home-select__option--selected{
background:linear-gradient(135deg,#efe6ff,#e7dcfb);
color:#3f2f70;
font-weight:800;
}
.home-select__option-label{
min-width:0;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.home-select__check{
flex:none;
color:#7c5bd2;
}
@media (max-width:760px){
.home-select__trigger{
min-height:52px;
padding-left:14px;
font-size:14px;
}
.home-select__menu{
max-height:232px;
}
}
</style>
@@ -0,0 +1,120 @@
<script setup lang="ts">
interface HomeSocialLink {
label: string
platform: string
icon: string
url: string
}
interface FooterLink {
label: string
url: string
}
interface SiteContent {
newsletterUrl: string
}
interface FaqItem {
question: string
answer: string
}
const props = defineProps<{
communitySocialLinks: HomeSocialLink[]
siteContent: SiteContent
faqItems: FaqItem[]
footerLinks: FooterLink[]
onOpenPrivacy: () => void
isUploadedSocialIcon: (icon: string | null | undefined) => boolean
socialSimpleIconPath: (platform: string | null | undefined) => string
socialSimpleIconColor: (platform: string | null | undefined) => string
platformKey: (platform: string | null | undefined) => string
}>()
</script>
<template>
<section id="host" style="background:linear-gradient(180deg,#ece3fa 0%,#e6dcf6 100%);border-top:1px solid #ddd0f2;border-bottom:1px solid #ddd0f2;padding:84px 0;">
<div style="max-width:1200px;margin:0 auto;padding:0 24px;">
<div style="position:relative;display:grid;grid-template-columns:1.62fr 1fr;gap:26px;align-items:stretch;" data-community-grid>
<div class="home-community-card" style="position:relative;overflow:visible;background:#f5f0fc;border:1px solid #e9e0f8;border-radius:28px;padding:46px 46px 42px;min-height:360px;">
<div class="home-community-card__content" style="position:relative;z-index:2;max-width:62%;">
<h2 style="margin:0 0 14px;font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(26px,3vw,36px);letter-spacing:.5px;color:#5f44ad;">COMMUNITY &amp; UPDATES</h2>
<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
v-for="social in props.communitySocialLinks"
:key="`community-${social.platform}`"
:href="social.url"
:aria-label="social.label"
style="display:inline-flex;align-items:center;justify-content:center;width:46px;height:46px;border-radius:13px;background:#fff;border:1px solid #e9e0f8;box-shadow:0 4px 12px rgba(124,86,196,.08);text-decoration:none;"
style-hover="transform:translateY(-2px);"
>
<template v-if="props.isUploadedSocialIcon(social.icon)">
<img :src="social.icon" :alt="social.label" style="width:24px;height:24px;object-fit:contain;" />
</template>
<template v-else-if="props.socialSimpleIconPath(social.platform)">
<svg width="20" height="20" viewBox="0 0 24 24" :fill="props.socialSimpleIconColor(social.platform)"><path :d="props.socialSimpleIconPath(social.platform)" /></svg>
</template>
<template v-else-if="props.platformKey(social.platform) === 'twitch'">
<svg width="20" height="20" viewBox="0 0 24 24" fill="#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>
</template>
<template v-else-if="props.platformKey(social.platform) === 'youtube'">
<svg width="20" height="20" viewBox="0 0 24 24" fill="#FF0000"><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.platformKey(social.platform) === 'instagram'">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#E1306C" 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="#E1306C" stroke="none"/></svg>
</template>
<template v-else-if="props.platformKey(social.platform) === 'discord'">
<svg width="21" height="21" viewBox="0 0 24 24" fill="#5865F2"><path d="M19.5 5.3A16 16 0 0 0 15.5 4l-.25.5a14.6 14.6 0 0 1 3.3 1.05 13 13 0 0 0-11.1 0A14.6 14.6 0 0 1 10.75 4.5L10.5 4A16 16 0 0 0 6.5 5.3 16.6 16.6 0 0 0 3.7 16.5a16.1 16.1 0 0 0 4.9 2.5l.6-.85a10.5 10.5 0 0 1-1.65-.8l.4-.3a11.5 11.5 0 0 0 9.9 0l.4.3a10.5 10.5 0 0 1-1.65.8l.6.85a16.1 16.1 0 0 0 4.9-2.5 16.6 16.6 0 0 0-2.8-11.2zM9.4 14.2c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.76 1.95-1.7 1.95zm5.2 0c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.75 1.95-1.7 1.95z"/></svg>
</template>
<template v-else>
<span style="font-size:18px;font-weight:800;color:#5f44ad;">{{ social.icon || '✦' }}</span>
</template>
</a>
</div>
<a :href="props.siteContent.newsletterUrl" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:11px;padding:15px 26px;border-radius:14px;background:#fff;border:1px solid #e3d8f5;color:#5f44ad;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 8px 22px rgba(124,86,196,.1);" style-hover="transform:translateY(-2px);border-color:#c9b6f0;"><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="#8b6cdb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/></svg>Newsletter abonnieren</a>
</div>
<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>
<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>
<a href="#" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:15px 22px;border-radius:14px;background:linear-gradient(135deg,#7c5fd0,#6d4fd0);color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 10px 24px rgba(124,86,196,.3);" style-hover="transform:translateY(-2px);"><svg width="19" height="19" viewBox="0 0 24 24" fill="#fff"><path d="M19.5 5.3A16 16 0 0 0 15.5 4l-.25.5a14.6 14.6 0 0 1 3.3 1.05 13 13 0 0 0-11.1 0A14.6 14.6 0 0 1 10.75 4.5L10.5 4A16 16 0 0 0 6.5 5.3 16.6 16.6 0 0 0 3.7 16.5a16.1 16.1 0 0 0 4.9 2.5l.6-.85a10.5 10.5 0 0 1-1.65-.8l.4-.3a11.5 11.5 0 0 0 9.9 0l.4.3a10.5 10.5 0 0 1-1.65.8l.6.85a16.1 16.1 0 0 0 4.9-2.5 16.6 16.6 0 0 0-2.8-11.2zM9.4 14.2c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.76 1.95-1.7 1.95zm5.2 0c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.75 1.95-1.7 1.95z"/></svg>Auf Discord teilen</a>
<a href="#" style="display:flex;align-items:center;justify-content:center;gap:11px;padding:15px 22px;border-radius:14px;background:#fff;border:1px solid #e9e0f8;color:#5f44ad;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 6px 16px rgba(124,86,196,.08);" style-hover="transform:translateY(-2px);border-color:#c9b6f0;"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b6cdb" 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>Link kopieren</a>
</div>
</div>
</div>
</div>
</section>
<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>
<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;">
<details v-for="item in props.faqItems" :key="item.question" style="background:#ffffff;border:1px solid var(--line,rgba(124,86,196,.16));border-radius:16px;padding:4px 22px;box-shadow:0 8px 24px rgba(124,86,196,.07);">
<summary style="display:flex;align-items:center;justify-content:space-between;gap:16px;cursor:pointer;list-style:none;padding:18px 0;font-family:'Outfit',sans-serif;font-weight:600;font-size:18px;color:var(--ink,#3f3556);">{{ item.question }}<span style="flex:none;display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;background:#f1ecfb;color:#8b6cdb;font-size:16px;transition:transform .2s;">+</span></summary>
<p style="margin:0 0 18px;font-size:15px;line-height:1.65;color:var(--muted,#8a8398);">{{ item.answer }}</p>
</details>
</div>
</section>
<footer style="border-top:1px solid var(--line,rgba(255,255,255,.12));">
<div style="max-width:1200px;margin:0 auto;padding:50px 24px;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:20px;">
<div style="display:flex;align-items:center;gap:10px;font-family:'Fredoka',sans-serif;font-weight:700;font-size:17px;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:10px;background:linear-gradient(135deg,var(--accent,#ff5fa2),var(--accent2,#a06bff));color:#fff;font-size:15px;"></span>
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>
<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>
@@ -0,0 +1,182 @@
<template>
<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>
</div>
<div style="position:relative;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:20px;align-items:start;" data-timeline>
<div :style="timelineLineStyle"></div>
<div style="position:relative;z-index:1;text-align:center;">
<div :style="nominationPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#2bbd6e,#1f9d5a);display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 6px rgba(43,189,110,.18),0 8px 20px rgba(31,157,90,.35);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">
<svg width="22" height="22" viewBox="0 0 24 24" fill="#fff"><path d="M12 17.3l-6.2 3.3 1.2-7-5.1-5 7.1-1 3-6.4 3 6.4 7.1 1-5.1 5 1.2 7z"/></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="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;">
<div :style="nominationPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#e3f7ec;color:#1f9d5a;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:#f1ecfb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;'">{{ nominationPhase ? 'JETZT AKTIV' : 'ABGESCHLOSSEN' }}</div>
<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>
</div>
</div>
<div style="position:relative;z-index:1;text-align:center;">
<div :style="votingPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#2bbd6e,#1f9d5a);display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 6px rgba(43,189,110,.18),0 8px 20px rgba(31,157,90,.35);border:4px solid #f4eefb;animation:pulseGlow 2.2s ease-in-out infinite;' : nominationPhase ? '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);' : '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">
<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="M12 2l2.9 6.3 6.9.7-5.1 4.6 1.4 6.8L12 17.8 5.9 20.4l1.4-6.8L2.2 9l6.9-.7z"/></svg>
</template>
<template v-else-if="votingPhase">
<svg width="22" height="22" viewBox="0 0 24 24" fill="#fff"><path d="M12 2l2.9 6.3 6.9.7-5.1 4.6 1.4 6.8L12 17.8 5.9 20.4l1.4-6.8L2.2 9l6.9-.7z"/></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="votingPhase ? 'background:#fff;border:1px solid #c9efd8;border-radius:20px;padding:24px 20px;box-shadow:0 16px 38px rgba(31,157,90,.14);min-height:320px;' : nominationPhase ? '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;' : '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;'">
<div :style="votingPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#e3f7ec;color:#1f9d5a;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : nominationPhase ? '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;' : '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;'"><span v-if="votingPhase" style="width:6px;height:6px;border-radius:50%;background:#1f9d5a;display:inline-block;"></span>{{ votingPhase ? 'JETZT AKTIV' : nominationPhase ? 'ALS NÄCHSTES' : 'ABGESCHLOSSEN' }}</div>
<h3 style="font-family:'Outfit',sans-serif;font-weight:700;font-size:21px;margin:0 0 6px;color:#3f3556;">Voting</h3>
<div :style="votingDateStyle(votingPhase, nominationPhase)">{{ formatTimelineRange('voting') }}</div>
<p style="font-size:14.5px;line-height:1.6;color:#7d7491;margin:0 0 16px;">Die ganze Szene stimmt ab eine Stimme pro Kategorie, Login über Twitch.</p>
<button
type="button"
@click="openVote"
:disabled="!votingPhase"
:aria-disabled="!votingPhase"
:style="voteButtonStyle(votingPhase)"
style-hover="transform:translateY(-2px);"
>
{{ votingPhase ? 'Jetzt voten' : nominationPhase ? 'Voting folgt' : 'Voting beendet' }}
</button>
</div>
</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;'">
<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">
<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>
<button
type="button"
disabled
aria-disabled="true"
:style="reviewButtonStyle(reviewPhase, showPhase, completedPhase)"
>
{{ reviewPhase ? 'Auswertung läuft' : showPhase || completedPhase ? 'Auswertung abgeschlossen' : 'Review folgt' }}
</button>
</div>
</div>
<div style="position:relative;z-index:1;text-align:center;">
<div :style="showPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#ec3b5a,#b91c43);display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 6px rgba(236,59,90,.18),0 8px 20px rgba(185,28,67,.28);border:4px solid #f4eefb;animation:pulseGlow 2.2s ease-in-out infinite;' : completedPhase ? '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;' : '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);'">
<template v-if="showPhase">
<svg width="18" height="18" viewBox="0 0 24 24" fill="#fff"><path d="M8 5v14l11-7z"/></svg>
</template>
<template v-else-if="completedPhase">
<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>
<template v-else>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#b9a9dd" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M8 5v14l11-7z"/></svg>
</template>
</div>
<div :style="showPhase ? 'background:#fff5f7;border:1px solid #ffc7d2;border-radius:20px;padding:24px 20px;box-shadow:0 16px 38px rgba(236,59,90,.14);min-height:320px;' : 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="showPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#ffe5ec;color:#ec3b5a;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : 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;'">{{ showPhase ? 'JETZT LIVE' : completedPhase ? 'ABGESCHLOSSEN' : 'FINALE' }}</div>
<h3 style="font-family:'Outfit',sans-serif;font-weight:700;font-size:21px;margin:0 0 6px;color:#3f3556;">Show</h3>
<div :style="showPhase ? 'font-size:13px;font-weight:600;color:#ec3b5a;margin-bottom:12px;' : 'font-size:13px;font-weight:600;color:#a99fc0;margin-bottom:12px;'">{{ formatTimelineRange('show') }}</div>
<p style="font-size:14.5px;line-height:1.6;color:#7d7491;margin:0 0 16px;">Die grosse Live-Show auf Twitch: Gewinner werden gekrönt, Clips gezeigt und die Szene feiert gemeinsam.</p>
<a
v-if="showPhase"
:href="publicStreamUrl"
:style="finalActionStyle(showPhase)"
style-hover="background:#ece2fa;"
>
Zum Stream
</a>
<button
v-else
type="button"
:disabled="completedPhase"
:aria-disabled="completedPhase"
@click="onTimelineFinalAction"
:style="finalActionStyle(showPhase, completedPhase)"
style-hover="background:#ece2fa;"
>
{{ completedPhase ? 'Award beendet' : 'Erinnerung aktivieren' }}
</button>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
defineProps<{
nominationPhase: boolean
votingPhase: boolean
reviewPhase: boolean
showPhase: boolean
completedPhase: boolean
timelineLineStyle: string
publicStreamUrl: string
formatTimelineRange: (key: 'nomination' | 'voting' | 'review' | 'show') => string
openNominate: (event?: Event) => void
openVote: (event?: Event) => void
onTimelineFinalAction: (event?: Event) => void
}>()
function votingDateStyle(votingPhase: boolean, nominationPhase: boolean) {
if (votingPhase) {
return 'font-size:13px;font-weight:600;color:#1f9d5a;margin-bottom:12px;'
}
return nominationPhase
? 'font-size:13px;font-weight:600;color:#a99fc0;margin-bottom:12px;'
: 'font-size:13px;font-weight:600;color:#8b6cdb;margin-bottom:12px;'
}
function voteButtonStyle(votingPhase: boolean) {
return votingPhase
? "display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;border:none;cursor:pointer;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;box-shadow:0 8px 18px rgba(124,86,196,.3);"
: "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) {
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;"
}
if (showPhase || completedPhase) {
return "display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#f1ecfb;color:#7355c8;border:none;cursor:not-allowed;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;"
}
return "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;"
}
function finalActionStyle(showPhase: boolean, completedPhase = false) {
if (completedPhase) {
return "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;text-decoration:none;"
}
return showPhase
? "display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#ec3b5a;color:#fff;border:none;cursor:pointer;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;box-shadow:0 8px 18px rgba(236,59,90,.26);text-decoration:none;"
: "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:pointer;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;text-decoration:none;"
}
</script>
@@ -0,0 +1,53 @@
<template>
<nav class="home-nav" style="position:sticky;top:0;z-index:50;display:flex;align-items:center;justify-content:space-between;gap:20px;padding:14px 28px;background:rgba(248,243,254,.86);border-bottom:1px solid #ece2fa;backdrop-filter:blur(10px);">
<div class="home-nav__brand" style="display:flex;align-items:center;gap:10px;font-family:'Fredoka',sans-serif;font-weight:700;font-size:19px;letter-spacing:.3px;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:11px;background:linear-gradient(135deg,#8b6cdb,#e7b13e);color:#fff;font-size:18px;box-shadow:0 6px 18px rgba(124,86,196,.35);"></span>
<span style="color:#3f3556;">VTuber <span style="color:#8b6cdb;">Star</span> Award</span>
</div>
<div class="home-nav__links" style="display:flex;align-items:center;gap:26px;font-size:15px;font-weight:500;color:#6f6685;" data-nav-links>
<a href="#kategorien" style="color:inherit;text-decoration:none;">Kategorien</a>
<a href="#ablauf" style="color:inherit;text-decoration:none;">Ablauf</a>
<a href="#nominierte" style="color:inherit;text-decoration:none;">Nominierte</a>
<a href="#voting" style="color:inherit;text-decoration:none;">Voting</a>
<a href="#host" style="color:inherit;text-decoration:none;">Host</a>
<a href="#faq" style="color:inherit;text-decoration:none;">FAQ</a>
</div>
<div class="home-nav__actions" style="display:flex;align-items:center;gap:10px;">
<template v-if="isGuest">
<button @click="onLogin" style="display:inline-flex;align-items:center;gap:8px;padding:10px 20px;border-radius:999px;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:'Fredoka',sans-serif;font-weight:600;font-size:15px;box-shadow:0 8px 22px rgba(124,86,196,.32);border:none;cursor:pointer;" style-hover="transform:translateY(-2px);">
<svg width="16" height="16" viewBox="0 0 24 24" fill="#fff"><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>
Anmelden mit Twitch
</button>
</template>
<template v-if="isUser">
<button @click="onOpenAccount" style="display:inline-flex;align-items:center;gap:8px;padding:8px 16px;border-radius:999px;background:rgba(139,108,219,0.1);border:1.5px solid rgba(139,108,219,0.25);color:#5f44ad;font-family:'Fredoka',sans-serif;font-weight:600;font-size:14px;cursor:pointer;" style-hover="background:rgba(139,108,219,0.18);">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
@{{ twitchUser }}
</button>
</template>
<template v-if="isAdmin">
<button @click="onOpenAccount" style="display:inline-flex;align-items:center;gap:6px;padding:7px 14px;border-radius:999px;background:rgba(231,177,62,0.1);border:1.5px solid rgba(231,177,62,0.4);color:#8a5a00;font-family:'Fredoka',sans-serif;font-weight:700;font-size:14px;cursor:pointer;" style-hover="background:rgba(231,177,62,0.18);">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
@{{ twitchUser }}
<span style="padding:1px 6px;border-radius:999px;background:rgba(231,177,62,0.25);color:#8a5a00;font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;">Admin</span>
</button>
</template>
<button @click="openAdminPanel" style="display:inline-flex;align-items:center;gap:6px;padding:8px 15px;border-radius:999px;background:rgba(231,177,62,0.12);border:1.5px solid rgba(231,177,62,0.3);color:#8a5a00;text-decoration:none;font-family:'Fredoka',sans-serif;font-weight:700;font-size:13px;cursor:pointer;" style-hover="background:rgba(231,177,62,0.22);">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
Admin Panel
</button>
</div>
</nav>
</template>
<script setup lang="ts">
defineProps<{
isGuest: boolean
isUser: boolean
isAdmin: boolean
twitchUser: string
onLogin: () => void
onOpenAccount: () => void
openAdminPanel: (event?: Event) => void
}>()
</script>
@@ -0,0 +1,92 @@
<script setup lang="ts">
import type { HomeCategoryListItem, HomeNomineeListItem } from './homeModalTypes'
const props = defineProps<{
catList: HomeCategoryListItem[]
activeCatName: string
noms: HomeNomineeListItem[]
voteCount: number
totalCats: number
canSubmitVote: boolean
submitVote: () => Promise<void> | void
submitting: boolean
}>()
</script>
<template>
<div class="home-vote-picker">
<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">
<span :style="cat.iconStyle">{{ cat.icon }}</span>
<span>{{ cat.name }}</span>
<span :style="cat.checkStyle"></span>
</button>
</template>
</aside>
<section class="home-vote-picker__content">
<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>
<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>
</div>
<p>{{ nom.handle }}</p>
</div>
</div>
<div class="home-vote-card__clip">
<span class="home-vote-card__clip-platform">{{ nom.clipPlatform }}</span>
<a
v-if="nom.clipUrl"
:href="nom.clipUrl"
target="_blank"
rel="noopener noreferrer"
referrerpolicy="no-referrer"
@click.stop
>
{{ nom.clipTitle }}
</a>
<span v-else>Kein geprüfter Clip für diese Kategorie hinterlegt.</span>
</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>
<div v-if="props.noms.length === 0" class="home-vote-picker__empty">
Für diese Kategorie sind noch keine Kandidat:innen freigegeben.
</div>
</div>
</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>
</template>
@@ -0,0 +1,61 @@
<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>
<div class="home-section" style="max-width:1200px;margin:0 auto;padding:90px 24px;">
<div style="display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:46px;">
<div>
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#ff5fa2;margin-bottom:12px;">&#9733; Die Stars</div>
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0;letter-spacing:-.4px;color:#fff6fb;">Gewinner {{ selectedArchive.year }}</h2>
</div>
<button @click="onOpenArchive" style="display:inline-flex;align-items:center;gap:8px;padding:12px 22px;border-radius:999px;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.12);color:#fff6fb;font-weight:600;font-size:15px;cursor:pointer;" style-hover="transform:translateY(-2px);background:rgba(255,255,255,.09);">Archiv ansehen &#8594;</button>
</div>
<div style="overflow-x:auto;overflow-y:hidden;padding:0 18px 12px 0;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.34) rgba(255,255,255,.06);">
<div style="display:flex;gap:22px;width:max-content;">
<article v-for="winner in winnerShowcase" :key="`${selectedArchive.year}-${winner.category}`" class="home-winner-card" style="flex:none;width:360px;border-radius:24px;overflow:hidden;background:#22123a;border:1px solid rgba(255,255,255,.12);">
<div 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="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>
<div style="font-size:13px;color:#c9b8da;margin-top:6px;">{{ winner.handle }}</div>
</div>
</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>
</article>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
type WinnerCard = {
category: string
name: string
handle: string
platform: string
url: string
}
defineProps<{
selectedArchive: {
year: number
winners: WinnerCard[]
}
winnerShowcase: WinnerCard[]
initialsFor: (value: string) => string
onOpenArchive: () => Promise<void>
}>()
</script>
@@ -0,0 +1,898 @@
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=Fredoka:wght@400;500;600;700&family=Great+Vibes&family=Outfit:wght@300;400;500;600;700&family=Sacramento&display=swap");
*{box-sizing:border-box;}
html,body{margin:0;padding:0;background:#160a26;}
@keyframes twinkle{0%,100%{opacity:.25;transform:scale(.7);}50%{opacity:1;transform:scale(1.15);}}
@keyframes floaty{0%,100%{transform:translateY(0);}50%{transform:translateY(-16px);}}
@keyframes floaty2{0%,100%{transform:translateY(0) rotate(-4deg);}50%{transform:translateY(-22px) rotate(4deg);}}
@keyframes pulseGlow{0%,100%{opacity:.55;}50%{opacity:1;}}
@keyframes spinSlow{from{transform:rotate(0);}to{transform:rotate(360deg);}}
@keyframes shimmer{0%{background-position:0% 50%;}100%{background-position:200% 50%;}}
#faq summary::-webkit-details-marker{display:none;}
#faq details[open] summary span{transform:rotate(45deg);}
.home-landing{
overflow-x:clip;
}
.home-demo-preview{
position:fixed;
left:50%;
bottom:22px;
z-index:95;
display:flex;
align-items:center;
gap:12px;
max-width:calc(100vw - 28px);
padding:10px 12px;
border:1px solid rgba(139,108,219,.22);
border-radius:999px;
background:rgba(255,255,255,.78);
box-shadow:0 18px 52px rgba(63,53,86,.18);
backdrop-filter:blur(18px);
-webkit-backdrop-filter:blur(18px);
transform:translateX(-50%);
}
.home-demo-preview__label{
display:inline-flex;
align-items:center;
gap:7px;
padding:0 7px 0 4px;
color:#5f44ad;
font-family:'Fredoka',sans-serif;
font-size:13px;
font-weight:800;
letter-spacing:.12em;
text-transform:uppercase;
white-space:nowrap;
}
.home-demo-preview__label span{
display:inline-grid;
place-items:center;
width:28px;
height:28px;
border-radius:50%;
color:#fff;
background:linear-gradient(135deg,#8b6cdb,#e7b13e);
box-shadow:0 10px 24px rgba(139,108,219,.26);
}
.home-demo-preview__buttons{
display:flex;
align-items:center;
gap:7px;
}
.home-demo-preview__button{
display:grid;
gap:1px;
min-width:112px;
padding:9px 14px;
border:1px solid rgba(139,108,219,.18);
border-radius:999px;
background:rgba(246,240,254,.78);
color:#6f6685;
cursor:pointer;
font-family:'Outfit',sans-serif;
text-align:left;
transition:transform .18s ease, border-color .18s ease, background .18s ease, box-shadow .18s ease, color .18s ease;
}
.home-demo-preview__button span{
font-size:13px;
font-weight:800;
line-height:1.1;
}
.home-demo-preview__button small{
color:inherit;
font-size:10px;
font-weight:700;
line-height:1.2;
opacity:.72;
}
.home-demo-preview__button:hover,
.home-demo-preview__button--active{
border-color:rgba(231,177,62,.5);
background:linear-gradient(135deg,rgba(139,108,219,.95),rgba(231,177,62,.9));
color:#fff;
box-shadow:0 12px 30px rgba(139,108,219,.24);
transform:translateY(-1px);
}
.home-landing img,
.home-landing svg{
max-width:100%;
}
.home-modal--wide{
max-width:1080px!important;
}
.home-vote-picker{
display:grid;
grid-template-columns:280px minmax(0,1fr);
min-height:0;
flex:1;
background:
radial-gradient(circle at 82% 8%,rgba(139,108,219,.13),transparent 32%),
#fff;
}
.home-vote-picker__rail{
border-right:1px solid #efe7fb;
padding:18px 16px;
overflow-y:auto;
display:flex;
flex-direction:column;
gap:8px;
background:linear-gradient(180deg,#fcfaff 0%,#f7f2ff 100%);
}
.home-vote-picker__rail-label{
margin:0 4px 6px;
font-size:11px;
font-weight:800;
letter-spacing:1.7px;
text-transform:uppercase;
color:#a98ddb;
}
.home-vote-picker__category-button{
min-height:54px;
}
.home-vote-picker__content{
min-width:0;
padding:22px 26px 24px;
overflow-y:auto;
}
.home-vote-picker__category-header{
display:flex;
align-items:flex-start;
justify-content:space-between;
gap:16px;
margin-bottom:18px;
}
.home-vote-picker__eyebrow{
margin:0 0 5px;
font-size:12px;
font-weight:800;
letter-spacing:1.8px;
text-transform:uppercase;
color:#a98ddb;
}
.home-vote-picker__category-header h4{
margin:0;
font-family:'Cormorant Garamond',serif;
font-size:30px;
line-height:1.05;
color:#3f3556;
}
.home-vote-picker__hint{
max-width:230px;
padding:8px 12px;
border-radius:999px;
background:#f4eefd;
color:#7d68bd;
font-size:12px;
font-weight:700;
line-height:1.35;
}
.home-vote-picker__cards{
display:grid;
gap:12px;
}
.home-vote-card{
display:grid;
grid-template-columns:minmax(210px,1.1fr) minmax(230px,1fr) auto;
align-items:center;
gap:16px;
padding:16px 18px;
border:1.5px solid #eadff8;
border-radius:20px;
background:rgba(255,255,255,.88);
box-shadow:0 14px 34px rgba(82,61,128,.08);
transition:border-color .16s ease,box-shadow .16s ease,transform .16s ease,background .16s ease;
}
.home-vote-card:hover{
border-color:#d8c7f2;
box-shadow:0 18px 40px rgba(82,61,128,.13);
transform:translateY(-1px);
}
.home-vote-card--selected{
border-color:#8b6cdb;
background:linear-gradient(135deg,#fbf8ff 0%,#f2ebff 100%);
box-shadow:0 18px 46px rgba(139,108,219,.18);
}
.home-vote-card--missing-clip{
background:#fff;
}
.home-vote-card__identity{
display:flex;
align-items:center;
gap:14px;
min-width:0;
}
.home-vote-card__avatar{
flex:none;
width:56px;
height:56px;
border-radius:18px;
display:grid;
place-items:center;
background:
linear-gradient(135deg,rgba(139,108,219,.96),rgba(231,177,62,.9)),
repeating-linear-gradient(45deg,#f3eefb 0 8px,#ece2fa 8px 16px);
color:#fff;
font-family:'Outfit',sans-serif;
font-size:18px;
font-weight:800;
box-shadow:0 12px 24px rgba(139,108,219,.22);
}
.home-vote-card__name-block{
min-width:0;
}
.home-vote-card__name-row{
display:flex;
align-items:center;
gap:9px;
min-width:0;
}
.home-vote-card__name-row h5{
margin:0;
min-width:0;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
font-family:'Outfit',sans-serif;
font-size:18px;
font-weight:800;
color:#332b49;
}
.home-vote-card__name-row span,
.home-vote-card__clip-platform{
flex:none;
padding:4px 8px;
border-radius:999px;
background:#f0e8fb;
color:#7d60c6;
font-size:11px;
font-weight:800;
line-height:1;
}
.home-vote-card__name-block p{
margin:5px 0 0;
color:#8e86a0;
font-size:14px;
font-weight:600;
}
.home-vote-card__clip{
min-width:0;
display:flex;
align-items:center;
gap:10px;
padding:10px 12px;
border-radius:16px;
background:#fbf8ff;
border:1px solid #efe5fb;
color:#7d7491;
font-size:13px;
font-weight:700;
}
.home-vote-card__clip a{
min-width:0;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
color:#6d4bc4;
text-decoration:none;
}
.home-vote-card__clip a:hover{
color:#4f32a2;
text-decoration:underline;
}
.home-vote-card--missing-clip .home-vote-card__clip{
background:#fffaf0;
border-color:#f2dfb6;
color:#9b6a16;
}
.home-vote-card__pick{
flex:none;
min-width:116px;
padding:12px 18px;
border:none;
border-radius:14px;
background:#f1ecfb;
color:#8b6cdb;
cursor:pointer;
font-family:'Outfit',sans-serif;
font-size:14px;
font-weight:800;
transition:transform .16s ease,box-shadow .16s ease,background .16s ease;
}
.home-vote-card__pick:hover{
transform:translateY(-1px);
box-shadow:0 10px 22px rgba(139,108,219,.18);
}
.home-vote-card__pick--selected{
background:linear-gradient(135deg,#8b6cdb,#7355c8);
color:#fff;
box-shadow:0 12px 26px rgba(139,108,219,.26);
}
.home-vote-picker__empty{
padding:32px 18px;
border:1px dashed #d8c9f2;
border-radius:18px;
background:#fcfaff;
color:#7d7491;
text-align:center;
font-size:14px;
font-weight:700;
}
.home-vote-picker__footer{
display:flex;
align-items:center;
justify-content:space-between;
gap:16px;
padding:18px 36px;
border-top:1px solid #f1ecfb;
background:#fcfaff;
}
.home-vote-picker__footer div{
color:#7d7491;
font-size:14px;
font-weight:700;
}
.home-vote-picker__footer span{
color:#8b6cdb;
font-weight:900;
}
.home-vote-picker__footer button{
padding:13px 28px;
border:none;
border-radius:14px;
background:linear-gradient(135deg,#8b6cdb,#7355c8);
color:#fff;
cursor:pointer;
font-family:'Outfit',sans-serif;
font-size:15px;
font-weight:800;
box-shadow:0 10px 22px rgba(124,86,196,.3);
}
.home-vote-picker__footer button:disabled,
.home-vote-picker__submit--disabled{
background:#d1c4e9!important;
color:#9e8cc5!important;
cursor:not-allowed!important;
box-shadow:none!important;
}
@media (max-width:1080px){
.home-nav{
flex-wrap:wrap!important;
align-items:flex-start!important;
gap:12px!important;
padding:12px 18px!important;
}
.home-nav__brand{
flex:1 1 260px!important;
min-width:0!important;
}
.home-nav__links{
order:3!important;
width:100%!important;
gap:16px!important;
overflow-x:auto!important;
overflow-y:hidden!important;
justify-content:flex-start!important;
padding:2px 2px 7px!important;
scrollbar-width:none;
-webkit-overflow-scrolling:touch;
}
.home-nav__links::-webkit-scrollbar{
display:none;
}
.home-nav__links a{
flex:0 0 auto!important;
}
.home-nav__actions{
flex:0 1 auto!important;
min-width:0!important;
}
.home-demo-preview{
align-items:flex-start;
border-radius:28px;
}
.home-demo-preview__buttons{
max-width:70vw;
overflow-x:auto;
padding-bottom:2px;
scrollbar-width:none;
}
.home-demo-preview__buttons::-webkit-scrollbar{
display:none;
}
}
@media (max-width:960px){
.home-hero{
min-height:auto!important;
}
.home-hero__content{
padding:52px 20px 32px!important;
}
.home-hero__copy{
max-width:620px!important;
}
.home-hero__character{
right:-170px!important;
top:76px!important;
height:780px!important;
opacity:.34!important;
}
.home-hero__veil{
background:linear-gradient(100deg,#f6f0fe 0%,rgba(246,240,254,.94) 44%,rgba(246,240,254,.55) 76%,transparent 100%)!important;
}
.home-hero__host-card{
position:relative!important;
right:auto!important;
bottom:auto!important;
margin:0 20px 30px!important;
max-width:620px!important;
z-index:4!important;
}
.home-stream-band__inner{
align-items:flex-start!important;
}
[data-timeline]{
grid-template-columns:repeat(2,minmax(0,1fr))!important;
}
[data-cat-grid]{
grid-template-columns:repeat(2,minmax(0,1fr))!important;
}
[data-community-grid]{
grid-template-columns:1fr!important;
}
}
@media (max-width:760px){
.home-nav{
padding:10px 14px!important;
}
.home-nav__brand{
flex-basis:100%!important;
font-size:17px!important;
}
.home-nav__actions{
order:2!important;
width:100%!important;
justify-content:flex-start!important;
overflow-x:auto!important;
padding-bottom:2px!important;
scrollbar-width:none;
}
.home-nav__actions::-webkit-scrollbar{
display:none;
}
.home-nav__actions > button{
flex:1 0 auto!important;
justify-content:center!important;
padding:10px 12px!important;
font-size:13px!important;
white-space:nowrap!important;
}
.home-demo-preview{
left:12px;
right:12px;
bottom:12px;
max-width:none;
transform:none;
flex-direction:column;
align-items:stretch;
border-radius:24px;
}
.home-demo-preview__label{
justify-content:center;
padding:0;
}
.home-demo-preview__buttons{
max-width:none;
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
}
.home-demo-preview__button{
min-width:0;
text-align:center;
}
.home-hero__content{
padding:40px 16px 24px!important;
}
.home-hero__eyebrow{
font-size:11px!important;
letter-spacing:1.7px!important;
margin-bottom:16px!important;
}
.home-hero__presented{
white-space:normal!important;
font-size:clamp(28px,8vw,38px)!important;
line-height:1.05!important;
margin-bottom:18px!important;
}
.home-hero__body{
font-size:16px!important;
max-width:none!important;
}
.home-hero__phase-card{
max-width:none!important;
border-radius:18px!important;
padding:20px!important;
}
.home-hero__phase-title{
white-space:normal!important;
font-size:24px!important;
}
.home-hero__host-card{
margin:0 16px 24px!important;
padding:16px 18px!important;
border-radius:16px!important;
}
.home-hero__host-name{
font-size:22px!important;
flex-wrap:wrap!important;
}
.home-stream-band__inner{
padding:22px 16px!important;
flex-direction:column!important;
gap:18px!important;
}
.home-stream-band__lead{
align-items:flex-start!important;
gap:14px!important;
}
.home-stream-band__actions{
width:100%!important;
align-items:stretch!important;
justify-content:center!important;
}
.home-stream-band__actions > a,
.home-stream-band__actions > div{
width:100%!important;
justify-content:center!important;
}
[data-stats]{
grid-template-columns:repeat(2,minmax(0,1fr))!important;
gap:20px 14px!important;
padding:28px 16px!important;
}
.home-section{
padding-left:16px!important;
padding-right:16px!important;
}
[data-timeline]{
grid-template-columns:1fr!important;
gap:18px!important;
}
[data-timeline] > div:first-child{
display:none!important;
}
[data-timeline] > div:not(:first-child){
text-align:left!important;
}
[data-timeline] > div:not(:first-child) > div:first-child{
margin:0 0 14px!important;
}
[data-timeline] > div:not(:first-child) > div:nth-child(2){
min-height:0!important;
padding:20px!important;
border-radius:18px!important;
}
[data-timeline] button,
[data-timeline] a{
width:100%!important;
}
[data-cat-grid]{
grid-template-columns:1fr!important;
}
[data-cat-grid] > div{
border-radius:18px!important;
padding:22px 20px!important;
}
.home-winner-card{
width:min(84vw,360px)!important;
}
.home-steps-card{
border-radius:22px!important;
padding:32px 20px!important;
}
.home-steps-heading{
white-space:normal!important;
letter-spacing:1.8px!important;
text-align:center!important;
}
[data-steps]{
flex-direction:column!important;
gap:24px!important;
}
[data-step-arrow]{
display:none!important;
}
.home-cta-card{
border-radius:22px!important;
padding:38px 20px!important;
}
.home-community-card{
padding:32px 22px!important;
min-height:0!important;
overflow:hidden!important;
}
.home-community-card__content{
max-width:100%!important;
}
.home-community-card__image{
right:-74px!important;
height:280px!important;
opacity:.18!important;
}
.home-share-card{
padding:32px 22px!important;
}
.home-modal-overlay{
align-items:flex-start!important;
padding:10px!important;
}
.home-modal{
max-height:calc(100dvh - 20px)!important;
border-radius:18px!important;
}
.home-modal__success,
.home-modal__show,
.home-modal__picker-header,
.home-modal__clip-header{
padding-left:20px!important;
padding-right:20px!important;
}
.home-modal__reminder-form{
flex-direction:column!important;
}
.home-modal__reminder-form button,
.home-modal__reminder-form input{
width:100%!important;
}
.home-modal__picker-grid{
grid-template-columns:1fr!important;
}
.home-vote-picker{
grid-template-columns:1fr!important;
}
.home-modal__nomination-grid{
grid-template-columns:1fr!important;
padding:20px!important;
}
.home-modal__category-rail{
border-right:none!important;
border-bottom:1px solid #f1ecfb!important;
max-height:210px!important;
}
.home-vote-picker__rail{
border-right:none!important;
border-bottom:1px solid #f1ecfb!important;
max-height:210px!important;
}
.home-modal__nominee-pane{
max-height:none!important;
}
.home-vote-picker__content{
padding:20px!important;
}
.home-vote-picker__category-header{
flex-direction:column!important;
}
.home-vote-picker__hint{
max-width:none!important;
}
.home-vote-card{
grid-template-columns:1fr!important;
align-items:stretch!important;
}
.home-vote-card__clip{
align-items:flex-start!important;
flex-direction:column!important;
}
.home-vote-card__pick{
width:100%!important;
}
.home-modal__vote-footer{
flex-direction:column!important;
align-items:stretch!important;
padding:16px 20px!important;
}
.home-modal__vote-footer button{
width:100%!important;
}
.home-modal__clip{
max-height:calc(100dvh - 20px)!important;
}
.home-modal__clip-body{
padding:20px!important;
}
.home-modal__clip-grid{
grid-template-columns:1fr!important;
}
.home-archive-modal__body{
grid-template-columns:1fr!important;
}
.home-archive-modal__years{
border-right:none!important;
border-bottom:1px solid rgba(139,108,219,.12)!important;
overflow-x:auto!important;
overflow-y:hidden!important;
}
.home-archive-modal__years > div{
flex-direction:row!important;
width:max-content!important;
}
.home-archive-modal__winners{
grid-template-columns:1fr!important;
}
}
@media (max-width:480px){
.home-hero__character{
right:-220px!important;
top:112px!important;
height:620px!important;
opacity:.22!important;
}
.home-hero__phase-card [data-dc-ref]{
font-size:23px!important;
}
[data-stats]{
grid-template-columns:repeat(2,minmax(0,1fr))!important;
}
.home-modal__success{
padding-top:48px!important;
padding-bottom:34px!important;
}
.home-vote-card__avatar{
width:48px!important;
height:48px!important;
border-radius:15px!important;
font-size:16px!important;
}
.home-vote-card__name-row{
align-items:flex-start!important;
flex-direction:column!important;
gap:6px!important;
}
.home-account-data-row{
align-items:flex-start!important;
flex-direction:column!important;
gap:3px!important;
}
.home-account-confirm-actions{
flex-direction:column!important;
}
}
@@ -0,0 +1,26 @@
import type { CandidateSummary } from '../../types/awards'
export type HomeInteractionModalKind = 'show' | 'vote' | 'nominate' | 'clip'
export type HomePreviewPhase = 'nomination' | 'voting' | 'review' | 'show' | 'completed'
export type HomeSuccessKind = 'vote' | 'show' | 'clip' | 'nomination'
export interface HomeClipSubmitContext {
clipUrl: string
selectedNomineeIndex: number
description: string
}
export interface HomeNominationSubmitContext {
categoryIndex: number
name: string
streamUrl: string
}
export interface HomeDisplayCategory {
id: string
name: string
icon: string
candidates: CandidateSummary[]
}
@@ -0,0 +1,53 @@
export interface HomeCategoryListItem {
name: string
icon: string
idx: number
done: boolean
onClick: () => void
rowStyle: string
iconStyle: string
checkStyle: string
}
export interface HomeNomineeListItem {
name: string
handle: string
platform: string
initials: string
clipUrl: string | null
clipTitle: string
clipPlatform: string
idx: number
selected: boolean
hasClip: boolean
showPick: boolean
onPick: () => void
cardStyle: string
btnStyle: string
btnLabel: string
}
export interface HomeSelectionOption {
id: number
label: string
}
export interface HomeArchiveYearItem {
year: number
label: string
winners: Array<unknown>
active: boolean
}
export interface HomeArchiveWinnerItem {
category: string
name: string
handle: string
platform: string
url: string
}
export interface HomeSelectedArchive {
year: number
winners: HomeArchiveWinnerItem[]
}
@@ -0,0 +1,82 @@
import { computed, type Ref } from 'vue'
import { useAwardsStore } from '../../stores/awards'
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)
}
return [...knownYears]
.sort((left, right) => right - left)
.map((year) => ({
year,
label: String(year),
winners: year === store.archive.year ? store.archive.items : store.overview.winnersPreview.filter((entry) => entry.year === year),
active: archiveYear.value === year,
}))
})
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,
})),
}))
const winnerShowcase = computed(() => selectedArchive.value.winners.slice(0, 4))
function archiveYearButtonStyle(active: boolean) {
return active
? "display:flex;align-items:center;justify-content:space-between;gap:10px;padding:14px 16px;border-radius:16px;border:1px solid rgba(255,210,122,.26);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 14px 28px rgba(20,8,40,.24);"
: "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 winnerPlatformKey(url: string) {
const normalized = url.toLowerCase()
if (normalized.includes('twitch.tv')) return 'twitch'
if (normalized.includes('youtube.com') || normalized.includes('youtu.be')) return 'youtube'
if (normalized.includes('x.com') || normalized.includes('twitter.com')) return 'x'
if (normalized.includes('instagram.com')) return 'instagram'
if (normalized.includes('cake.gg') || normalized.includes('cake.')) return 'cake'
return 'link'
}
function winnerPlatformLabel(url: string) {
const key = winnerPlatformKey(url)
if (key === 'twitch') return 'Twitch'
if (key === 'youtube') return 'YouTube'
if (key === 'x') return 'X'
if (key === 'instagram') return 'Instagram'
if (key === 'cake') return 'Cake'
return 'Profil'
}
function winnerPlatformStyle(url: string) {
const key = winnerPlatformKey(url)
if (key === 'twitch') return "flex:none;display:inline-flex;align-items:center;gap:7px;color:#c9b1ff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:700;font-size:12px;"
if (key === 'youtube') return "flex:none;display:inline-flex;align-items:center;gap:7px;color:#ffb3c1;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:700;font-size:12px;"
if (key === 'x') return "flex:none;display:inline-flex;align-items:center;gap:7px;color:#d8d3e6;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:700;font-size:12px;"
if (key === 'instagram') return "flex:none;display:inline-flex;align-items:center;gap:7px;color:#ffb5db;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:700;font-size:12px;"
if (key === 'cake') return "flex:none;display:inline-flex;align-items:center;gap:7px;color:#8b6cdb;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:700;font-size:12px;"
return "flex:none;display:inline-flex;align-items:center;gap:7px;color:#ffd27a;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:700;font-size:12px;"
}
return {
archiveYears,
selectedArchive,
winnerShowcase,
archiveYearButtonStyle,
winnerPlatformKey,
winnerPlatformLabel,
winnerPlatformStyle,
}
}
@@ -0,0 +1,143 @@
import { computed, ref, type ComputedRef } from 'vue'
import { useAwardsStore } from '../../stores/awards'
type AwardsStore = ReturnType<typeof useAwardsStore>
export function useHomeLandingModalState(
store: AwardsStore,
archiveYears: ComputedRef<Array<{ year: number }>>,
resetInteractionState: () => void,
) {
const modal = ref<null | 'show' | 'vote' | 'nominate' | 'clip'>(null)
const accountModal = ref(false)
const deleteConfirm = ref(false)
const accountActionError = ref('')
const privacyModal = ref(false)
const archiveModal = ref(false)
const archiveYear = ref(2025)
const privacyModalOpen = computed(() => privacyModal.value)
const accountModalOpen = computed(() => accountModal.value)
const archiveModalOpen = computed(() => archiveModal.value)
const modalOpen = computed(() => modal.value !== null)
const isShow = computed(() => modal.value === 'show')
const isVote = computed(() => modal.value === 'vote')
const isPicker = computed(() => modal.value === 'vote' || modal.value === 'nominate')
const isClip = computed(() => modal.value === 'clip')
const deleteNotConfirm = computed(() => !deleteConfirm.value)
function openModal(kind: 'show' | 'vote' | 'nominate' | 'clip') {
modal.value = kind
resetInteractionState()
}
function closeModal() {
modal.value = null
resetInteractionState()
}
function openVote(event?: Event) {
event?.preventDefault()
openModal('vote')
}
function openShow(event?: Event) {
event?.preventDefault()
openModal('show')
}
function openNominate(event?: Event) {
event?.preventDefault()
openModal('nominate')
}
function openClip(event?: Event) {
event?.preventDefault()
openModal('clip')
}
function stop(event: Event) {
event.stopPropagation()
}
function onOpenAccount() {
accountModal.value = true
deleteConfirm.value = false
accountActionError.value = ''
}
function onCloseAccount() {
accountModal.value = false
deleteConfirm.value = false
accountActionError.value = ''
}
function onOpenPrivacy() {
privacyModal.value = true
}
function onClosePrivacy() {
privacyModal.value = false
}
async function onOpenArchive() {
archiveModal.value = true
const latestArchiveYear = archiveYears.value[0]?.year ?? store.overview.year - 1
archiveYear.value = latestArchiveYear
await store.loadArchive(latestArchiveYear)
}
function onCloseArchive() {
archiveModal.value = false
}
async function setArchiveYear(year: number) {
archiveYear.value = year
await store.loadArchive(year)
}
function onRequestDelete() {
deleteConfirm.value = true
}
function onCancelDelete() {
deleteConfirm.value = false
accountActionError.value = ''
}
return {
modal,
accountModal,
deleteConfirm,
accountActionError,
privacyModal,
archiveModal,
archiveYear,
privacyModalOpen,
accountModalOpen,
archiveModalOpen,
modalOpen,
isShow,
isVote,
isPicker,
isClip,
deleteNotConfirm,
openModal,
closeModal,
openVote,
openShow,
openNominate,
openClip,
stop,
onOpenAccount,
onCloseAccount,
onOpenPrivacy,
onClosePrivacy,
onOpenArchive,
onCloseArchive,
setArchiveYear,
onRequestDelete,
onCancelDelete,
}
}
@@ -0,0 +1,184 @@
import { computed, type ComputedRef, type Ref } from 'vue'
import { useAuthStore } from '../../stores/auth'
import { useAwardsStore } from '../../stores/awards'
import type { HomeDisplayCategory, HomeInteractionModalKind } from './homeLandingTypes'
type AwardsStore = ReturnType<typeof useAwardsStore>
type AuthStore = ReturnType<typeof useAuthStore>
type HomeTimelineKey = 'nomination' | 'voting' | 'review' | 'show'
const CATEGORY_ICONS = ['✦', '★', '✧', '♬', '⚔', '☻', '♡', '✶'] as const
export function useHomeLandingOverviewPresentation(store: AwardsStore, authStore: AuthStore) {
const role = computed<'guest' | 'user' | 'admin'>(() => {
if (!authStore.session) return 'guest'
return authStore.isAdmin ? 'admin' : 'user'
})
const isGuest = computed(() => role.value === 'guest')
const isUser = computed(() => role.value === 'user')
const isAdmin = computed(() => role.value === 'admin')
const twitchUser = computed(() => authStore.session?.twitchUserId ?? 'local_user')
const siteContent = computed(() => store.overview.siteContent)
const faqItems = computed(() =>
(store.overview.faq ?? []).filter((item) => item?.question && item.answer),
)
const publicStreamUrl = computed(() => store.overview.showStreamUrl || 'https://twitch.tv/jayuhime')
const showDate = computed(() => store.overview.showDate)
const showStartsAt = computed(() => store.overview.showStartsAt || '20:00:00')
const currentYear = computed(() => store.overview.year ? String(store.overview.year) : '')
const displayCategories = computed<HomeDisplayCategory[]>(() =>
store.categories.categories.map((category, index) => ({
id: String(category.id),
name: category.name,
icon: CATEGORY_ICONS[index % CATEGORY_ICONS.length] ?? '✦',
candidates: category.candidates,
})),
)
const candidateCount = computed(() =>
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 }))
: [{ year: store.overview.year - 1 }],
)
function timelineItem(key: HomeTimelineKey) {
return store.overview.timeline.find((entry) => entry.key === key)
}
function formatRange(key: Exclude<HomeTimelineKey, 'show'>) {
const item = timelineItem(key)
if (!item) return ''
return `${formatDateLabel(item.startsAt)} ${formatDateLabel(item.endsAt)}`
}
function formatTimelineRange(key: HomeTimelineKey) {
const item = timelineItem(key)
if (!item) return 'Noch offen'
return item.startsAt === item.endsAt
? formatDateLabel(item.startsAt)
: `${formatDateLabel(item.startsAt)} ${formatDateLabel(item.endsAt)}`
}
function formatShowDate() {
return formatDateLabel(store.overview.showDate)
}
return {
role,
isGuest,
isUser,
isAdmin,
twitchUser,
siteContent,
faqItems,
publicStreamUrl,
showDate,
showStartsAt,
currentYear,
displayCategories,
candidateCount,
bootstrapArchiveYears,
formatRange,
formatTimelineRange,
formatShowDate,
initialsFor,
}
}
export function useHomeModalCandidatePresentation(params: {
displayCategories: ComputedRef<HomeDisplayCategory[]>
activeCat: Ref<number>
modal: Ref<null | HomeInteractionModalKind>
votes: Ref<Record<string, number>>
activeCategory: ComputedRef<HomeDisplayCategory | null>
setCat: (index: number) => void
pickNominee: (categoryId: string, index: number) => void
}) {
const {
displayCategories,
activeCat,
modal,
votes,
activeCategory,
setCat,
pickNominee,
} = params
const catList = computed(() => buildCatList(displayCategories, activeCat, votes, setCat))
const noms = computed(() => {
const cat = activeCategory.value
const list = cat?.candidates ?? []
const voteMode = modal.value === 'vote'
return list.map((candidate, idx) => {
const selected = cat ? votes.value[cat.id] === idx : false
const clipUrl = candidate.clipUrl?.trim() || null
const clipTitle = candidate.clipTitle?.trim() || 'Highlight-Clip ansehen'
const clipPlatform = clipUrl ? candidate.clipPlatform?.trim() || candidate.platform : 'Clip fehlt'
return {
name: candidate.displayName,
handle: candidate.channelSlug,
platform: candidate.platform,
initials: initialsFor(candidate.displayName),
clipUrl,
clipTitle,
clipPlatform,
idx,
selected,
hasClip: Boolean(clipUrl),
showPick: voteMode,
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',
}
})
})
return {
catList,
noms,
}
}
function formatDateLabel(value: string) {
if (!value) return 'Noch nicht terminiert'
const date = new Date(`${value}T00:00:00`)
return Number.isNaN(date.getTime())
? value
: date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' })
}
function initialsFor(value: string) {
return value
.split(/[\s&.-]+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('')
}
function buildCatList(
displayCategories: ComputedRef<HomeDisplayCategory[]>,
activeCat: Ref<number>,
votes: Ref<Record<string, number>>,
setCat: (index: number) => void,
) {
const baseRow = "display:flex;align-items:center;gap:10px;padding:11px 13px;border-radius:11px;cursor:pointer;font-size:14px;font-weight:600;transition:all .15s;border:1px solid transparent;outline:none;-webkit-tap-highlight-color:transparent;text-align:left;width:100%;background:transparent;font-family:'Outfit',sans-serif;"
return displayCategories.value.map((category, index) => {
const active = index === activeCat.value
const done = votes.value[category.id] != null
return {
name: category.name,
icon: category.icon,
idx: index,
done,
onClick: () => setCat(index),
rowStyle: baseRow + (active ? 'background:#f1ecfb;border-color:#d8c9f2;color:#5f44ad;' : 'border-color:transparent;color:#6f6685;'),
iconStyle: "flex:none;display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:8px;font-size:14px;" + (active ? 'background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;' : 'background:#efe7fb;color:#9a7fce;'),
checkStyle: `flex:none;margin-left:auto;color:#1f9d5a;font-size:14px;font-weight:700;display:${done ? 'inline' : 'none'};`,
}
})
}
@@ -0,0 +1,380 @@
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { useAwardsStore } from '../../stores/awards'
import { useHomeArchivePresentation } from './useHomeArchivePresentation'
import type { HomePreviewPhase } from './homeLandingTypes'
import { useHomeLandingModalState } from './useHomeLandingModalState'
import {
useHomeLandingOverviewPresentation,
useHomeModalCandidatePresentation,
} from './useHomeLandingPresentation'
import { useHomePhasePresentation } from './useHomePhasePresentation'
import { useHomeParticipationState } from './useHomeParticipationState'
import { useHomeSocialPresentation } from './useHomeSocialPresentation'
export function useHomeLandingState() {
const store = useAwardsStore()
const authStore = useAuthStore()
const router = useRouter()
const activeCat = ref(0)
const previewPhase = ref<HomePreviewPhase>('voting')
const {
role,
isGuest,
isUser,
isAdmin,
twitchUser,
siteContent,
faqItems,
publicStreamUrl,
showDate,
showStartsAt,
currentYear,
displayCategories,
candidateCount,
bootstrapArchiveYears,
formatRange,
formatTimelineRange,
formatShowDate,
initialsFor,
} = useHomeLandingOverviewPresentation(store, authStore)
const {
hostSocialLinks,
communitySocialLinks,
footerLinks,
privacyContentBlocks,
platformKey,
isUploadedSocialIcon,
socialSimpleIconPath,
socialSimpleIconColor,
} = useHomeSocialPresentation(siteContent)
const {
modal,
deleteConfirm,
accountActionError,
archiveYear,
privacyModalOpen,
accountModalOpen,
archiveModalOpen,
modalOpen,
isShow,
isVote,
isPicker,
isClip,
deleteNotConfirm,
openModal,
closeModal,
openVote,
openNominate,
openClip,
stop,
onOpenAccount,
onCloseAccount,
onOpenPrivacy,
onClosePrivacy,
onOpenArchive,
onCloseArchive,
setArchiveYear,
onRequestDelete,
onCancelDelete,
} = useHomeLandingModalState(store, bootstrapArchiveYears, () => {
submitted.value = false
formError.value = ''
successKind.value = null
if (modal.value === 'clip') {
clipDsgvo.value = false
}
})
const {
archiveYears,
selectedArchive,
winnerShowcase,
archiveYearButtonStyle,
winnerPlatformKey,
winnerPlatformLabel,
winnerPlatformStyle,
} = useHomeArchivePresentation(store, archiveYear)
const {
nominationPhase,
votingPhase,
reviewPhase,
showPhase,
completedPhase,
showCountdown,
streamLive,
streamLocked,
phaseCardTitle,
phaseCardDescription,
phaseCardRange,
phaseStatusLabel,
phaseStatusStyle,
phasePrimaryLabel,
phasePrimaryDisabled,
phasePrimaryActionStyle,
streamEyebrow,
streamTitle,
streamMeta,
streamLockedLabel,
streamLockedTitle,
statOneValue,
statOneLabel,
statTwoValue,
statThreeValue,
statThreeLabel,
categoryIntroText,
timelineLineStyle,
sectionTitle,
sectionText,
sectionActionLabel,
sectionActionHref,
sectionActionDisabled,
sectionActionStyle,
} = useHomePhasePresentation({
previewPhase,
displayCategories,
candidateCount,
publicStreamUrl,
showDate,
showStartsAt,
currentYear,
formatRange,
formatShowDate,
})
const {
votes,
submitted,
submitting,
formError,
successKind,
clipDsgvo,
notSubmitted,
voteCount,
totalCats,
canSubmitVote,
activeCategory,
activeCatName,
pickerTitle,
pickerSubtitle,
successTitle,
successText,
clipNomOptions,
catOptions,
canSubmitClip,
clipSubmitStyle,
initializeHomeInteractions,
onLogin,
onLogout,
onConfirmDelete,
openAdminPanel,
setCat,
pickNominee,
submitVote,
submitReminder,
submitNomination,
clipDsgvoChange,
onClipCatChange,
submitClip,
onPrimaryPhaseAction,
onSectionAction,
onTimelineFinalAction,
syncPreviewPhaseFromOverview,
} = useHomeParticipationState({
store,
authStore,
routerPush: async (path) => {
await router.push(path)
},
twitchUser,
displayCategories,
archiveYears,
archiveYear,
modal,
previewPhase,
activeCat,
deleteConfirm,
accountActionError,
openModal,
closeAccountModal: onCloseAccount,
})
const { catList, noms } = useHomeModalCandidatePresentation({
displayCategories,
activeCat,
modal,
votes,
activeCategory,
setCat,
pickNominee,
})
function setPreviewPhase(phase: HomePreviewPhase) {
previewPhase.value = phase
closeModal()
activeCat.value = 0
submitted.value = false
formError.value = ''
successKind.value = null
clipDsgvo.value = false
}
function openVoteForPhase(event?: Event) {
if (!votingPhase.value) {
event?.preventDefault()
return
}
openVote(event)
}
function openClipForPhase(event?: Event) {
if (!nominationPhase.value) {
event?.preventDefault()
return
}
openClip(event)
}
watch(previewPhase, (phase) => {
if ((phase !== 'voting' && modal.value === 'vote') || (phase !== 'nomination' && modal.value === 'clip')) {
closeModal()
}
})
return {
store,
authStore,
role,
isGuest,
isUser,
isAdmin,
twitchUser,
siteContent,
hostSocialLinks,
communitySocialLinks,
footerLinks,
faqItems,
privacyContentBlocks,
publicStreamUrl,
displayCategories,
candidateCount,
nominationPhase,
votingPhase,
reviewPhase,
showPhase,
completedPhase,
showCountdown,
streamLive,
streamLocked,
privacyModalOpen,
accountModalOpen,
archiveModalOpen,
modalOpen,
isShow,
isVote,
isPicker,
isClip,
notSubmitted,
deleteNotConfirm,
voteCount,
totalCats,
canSubmitVote,
activeCatName,
phaseCardTitle,
phaseCardDescription,
phaseCardRange,
phaseStatusLabel,
phaseStatusStyle,
phasePrimaryLabel,
phasePrimaryDisabled,
phasePrimaryActionStyle,
streamEyebrow,
streamTitle,
streamMeta,
streamLockedLabel,
streamLockedTitle,
statOneValue,
statOneLabel,
statTwoValue,
statThreeValue,
statThreeLabel,
categoryIntroText,
timelineLineStyle,
sectionTitle,
sectionText,
sectionActionLabel,
sectionActionHref,
sectionActionDisabled,
sectionActionStyle,
previewPhase,
pickerTitle,
pickerSubtitle,
successTitle,
successText,
clipNomOptions,
catOptions,
canSubmitClip,
clipSubmitStyle,
archiveYears,
selectedArchive,
winnerShowcase,
activeCat,
submitted,
submitting,
formError,
clipDsgvo,
archiveYear,
modal,
catList,
noms,
formatTimelineRange,
formatShowDate,
initialsFor,
openClip: openClipForPhase,
openVote: openVoteForPhase,
openNominate,
onLogin,
onOpenAccount,
openAdminPanel,
onPrimaryPhaseAction,
isUploadedSocialIcon,
socialSimpleIconPath,
socialSimpleIconColor,
platformKey,
onSectionAction,
closeModal,
stop,
submitReminder,
submitVote,
submitNomination,
onClipCatChange,
clipDsgvoChange,
onOpenPrivacy,
submitClip,
onCloseArchive,
archiveYearButtonStyle,
winnerPlatformStyle,
winnerPlatformKey,
winnerPlatformLabel,
privacyModalStop: stop,
accountModalStop: stop,
archiveModalStop: stop,
onClosePrivacy,
onCloseAccount,
deleteConfirm,
accountActionError,
onLogout,
onRequestDelete,
onCancelDelete,
onConfirmDelete,
onOpenArchive,
setArchiveYear,
onTimelineFinalAction,
setPreviewPhase,
syncPreviewPhaseFromOverview,
initializeHomeInteractions,
}
}
@@ -0,0 +1,380 @@
import { nextTick, onBeforeUnmount, onMounted, ref, watch, type ComponentPublicInstance, type Ref } from 'vue'
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>
type HomeTimelineKey = 'nomination' | 'voting' | 'review' | 'show' | 'completed'
interface PhaseCountdownTarget {
label: string
target: number
direction?: 'until' | 'since'
}
interface TimelineScheduleItem {
key: HomeTimelineKey
title: string
startMs: number
endMs: number
}
interface UseHomeLandingViewEffectsParams {
router: Router
store: AwardsStore
authStore: AuthStore
modalOpen: Readonly<Ref<boolean>>
accountModalOpen: Readonly<Ref<boolean>>
privacyModalOpen: Readonly<Ref<boolean>>
archiveModalOpen: Readonly<Ref<boolean>>
submitted: Readonly<Ref<boolean>>
streamLive: Readonly<Ref<boolean>>
archiveYear: Readonly<Ref<number>>
nominationPhase: Readonly<Ref<boolean>>
votingPhase: Readonly<Ref<boolean>>
reviewPhase: 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>
}
export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParams) {
const {
router,
store,
authStore,
modalOpen,
accountModalOpen,
privacyModalOpen,
archiveModalOpen,
submitted,
streamLive,
archiveYear,
nominationPhase,
votingPhase,
reviewPhase,
completedPhase,
initializeHomeInteractions,
submitNomination,
submitClip,
} = params
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 clipDescEl = ref<HTMLTextAreaElement | null>(null)
const countdownRefs = {
labelEl: ref<HTMLElement | null>(null),
streamLabelEl: ref<HTMLElement | null>(null),
dEl: ref<HTMLElement | null>(null),
hEl: ref<HTMLElement | null>(null),
mEl: ref<HTMLElement | null>(null),
sEl: ref<HTMLElement | null>(null),
bdEl: ref<HTMLElement | null>(null),
bhEl: ref<HTMLElement | null>(null),
bmEl: ref<HTMLElement | null>(null),
bsEl: ref<HTMLElement | null>(null),
}
let timer: ReturnType<typeof setInterval> | null = null
let landingLoaderTimer: ReturnType<typeof setTimeout> | null = null
function setRootEl(element: Element | ComponentPublicInstance | null) {
rootEl.value = element instanceof HTMLElement ? element : null
}
function handleClipSubmit() {
return submitClip({
clipUrl: clipUrlEl.value?.value.trim() ?? '',
selectedNomineeIndex: Number.parseInt(clipNomEl.value?.value || '0', 10) || 0,
description: clipDescEl.value?.value.trim() ?? '',
})
}
function handleNominationSubmit() {
return submitNomination({
categoryIndex: Number.parseInt(nominationCatEl.value?.value || '0', 10) || 0,
name: nominationNameEl.value?.value.trim() ?? '',
streamUrl: nominationStreamUrlEl.value?.value.trim() ?? '',
})
}
function assignDomRefs() {
const root = rootEl.value
if (!root) return
countdownRefs.labelEl.value = root.querySelector('[data-dc-ref="phaseCountdownLabelRef"]')
countdownRefs.streamLabelEl.value = root.querySelector('[data-dc-ref="streamCountdownLabelRef"]')
countdownRefs.dEl.value = root.querySelector('[data-dc-ref="dRef"]')
countdownRefs.hEl.value = root.querySelector('[data-dc-ref="hRef"]')
countdownRefs.mEl.value = root.querySelector('[data-dc-ref="mRef"]')
countdownRefs.sEl.value = root.querySelector('[data-dc-ref="sRef"]')
countdownRefs.bdEl.value = root.querySelector('[data-dc-ref="bdRef"]')
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"]')
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"]')
clipDescEl.value = root.querySelector('[data-dc-ref="clipDescRef"]')
}
function wireInteractiveStyles() {
const root = rootEl.value
if (!root) return
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) })
})
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) })
})
}
function setupDom() {
assignDomRefs()
wireInteractiveStyles()
}
function tick() {
const now = Date.now()
const activeTarget = resolvePhaseCountdownTarget(store, resolveSelectedPhaseKey(), now)
const showTarget = parseShowStartMs(store)
const activeTargetMs = Number.isNaN(activeTarget.target) ? showTarget : activeTarget.target
const activeCountdown = split(activeTarget.direction === 'since' ? now - activeTargetMs : activeTargetMs - now)
const showCompleted = resolveIsCompletedPhase(store.overview.currentPhase)
const showStarted = !Number.isNaN(showTarget) && now >= showTarget
const showCountdown = split(showCompleted ? 0 : showStarted ? now - showTarget : showTarget - now)
setText(countdownRefs.labelEl.value, activeTarget.label)
setText(countdownRefs.streamLabelEl.value, showCompleted ? 'Award abgeschlossen' : showStarted ? 'Stream läuft seit' : 'Finale startet in')
setText(countdownRefs.dEl.value, pad(activeCountdown.d))
setText(countdownRefs.hEl.value, pad(activeCountdown.h))
setText(countdownRefs.mEl.value, pad(activeCountdown.m))
setText(countdownRefs.sEl.value, pad(activeCountdown.s))
setText(countdownRefs.bdEl.value, pad(showCountdown.d))
setText(countdownRefs.bhEl.value, pad(showCountdown.h))
setText(countdownRefs.bmEl.value, pad(showCountdown.m))
setText(countdownRefs.bsEl.value, pad(showCountdown.s))
}
function resolveSelectedPhaseKey(): HomeTimelineKey {
if (nominationPhase.value) return 'nomination'
if (votingPhase.value) return 'voting'
if (reviewPhase.value) return 'review'
if (completedPhase.value) return 'completed'
return 'show'
}
watch([modalOpen, accountModalOpen, privacyModalOpen, archiveModalOpen], () => {
document.body.style.overflow = modalOpen.value || accountModalOpen.value || privacyModalOpen.value || archiveModalOpen.value ? 'hidden' : ''
nextTick(setupDom)
})
watch([submitted, streamLive, archiveYear], () => {
nextTick(setupDom)
})
onMounted(async () => {
if (!authStore.hydrated) {
await authStore.hydrate()
}
await store.loadHomeData()
if (store.lastPublicErrorKind) {
await router.replace({
name: 'network-error',
query: { from: '/' },
})
return
}
await initializeHomeInteractions()
setupDom()
tick()
timer = setInterval(tick, 1000)
landingLoaderTimer = setTimeout(() => {
landingLoaderVisible.value = false
}, 1500)
})
onBeforeUnmount(() => {
document.body.style.overflow = ''
if (timer) clearInterval(timer)
if (landingLoaderTimer) clearTimeout(landingLoaderTimer)
})
return {
setRootEl,
landingLoaderVisible,
handleNominationSubmit,
handleClipSubmit,
}
}
function pad(value: number) {
return String(value).padStart(2, '0')
}
function parseBackendDateMs(value: string, endOfDay = false) {
if (!value) return Number.NaN
const date = new Date(`${value}T${endOfDay ? '23:59:59' : '00:00:00'}`)
return date.getTime()
}
function parseShowStartMs(store: AwardsStore) {
const date = store.overview.showDate
if (!date) return Number.NaN
const time = normalizeBackendTime(store.overview.showStartsAt)
return new Date(`${date}T${time}`).getTime()
}
function normalizeBackendTime(value: string) {
if (!value) return '20:00:00'
return value.length === 5 ? `${value}:00` : value
}
function resolvePhaseCountdownTarget(
store: AwardsStore,
selectedPhaseKey: HomeTimelineKey,
now: number,
): PhaseCountdownTarget {
if (selectedPhaseKey === 'completed' || resolveIsCompletedPhase(store.overview.currentPhase)) {
return {
label: 'Award-Jahr abgeschlossen',
target: now,
}
}
const schedule = buildTimelineSchedule(store)
const selectedIndex = schedule.findIndex((item) => item.key === selectedPhaseKey)
const selectedItem = selectedIndex >= 0 ? schedule[selectedIndex] : null
if (selectedItem) {
const selectedTarget = resolveItemCountdownTarget(selectedItem, now)
if (selectedTarget) {
return selectedTarget
}
const nextTarget = schedule
.slice(selectedIndex + 1)
.map((item) => resolveItemCountdownTarget(item, now))
.find((target): target is PhaseCountdownTarget => Boolean(target))
if (nextTarget) {
return nextTarget
}
}
const globalTarget = schedule
.map((item) => resolveItemCountdownTarget(item, now))
.find((target): target is PhaseCountdownTarget => Boolean(target))
if (globalTarget) {
return globalTarget
}
const showTarget = parseBackendDateMs(store.overview.showDate, true)
return {
label: 'Phase abgeschlossen',
target: Number.isNaN(showTarget) ? now : showTarget,
}
}
function buildTimelineSchedule(store: AwardsStore): TimelineScheduleItem[] {
const phaseOrder: Exclude<HomeTimelineKey, 'completed'>[] = ['nomination', 'voting', 'review', 'show']
return phaseOrder
.map((key): TimelineScheduleItem | null => {
const entry = store.overview.timeline.find((item) => item.key === key)
const fallbackDate = key === 'show' ? store.overview.showDate : ''
const startsAt = entry?.startsAt || fallbackDate
const endsAt = entry?.endsAt || fallbackDate
const startMs = key === 'show' ? parseShowStartMs(store) : parseBackendDateMs(startsAt)
const endMs = parseBackendDateMs(endsAt, true)
if (Number.isNaN(startMs) || Number.isNaN(endMs)) {
return null
}
return {
key,
title: entry?.title || phaseTitle(key),
startMs,
endMs,
}
})
.filter((item): item is TimelineScheduleItem => item !== null)
}
function resolveItemCountdownTarget(item: TimelineScheduleItem, now: number): PhaseCountdownTarget | null {
if (now < item.startMs) {
return {
label: `${item.title} startet in`,
target: item.startMs,
}
}
if (now <= item.endMs) {
if (item.key === 'show') {
return {
label: 'Award-Show läuft seit',
target: item.startMs,
direction: 'since',
}
}
return {
label: `${item.title} noch offen`,
target: item.endMs,
}
}
return null
}
function resolveIsCompletedPhase(currentPhase: string) {
const value = currentPhase.trim().toLowerCase()
return value.includes('abgeschlossen') || value.includes('archiv') || value.includes('complete') || value.includes('ended')
}
function phaseTitle(key: HomeTimelineKey) {
return key === 'nomination'
? 'Nominierung'
: key === 'voting'
? 'Voting'
: key === 'review'
? 'Review & Auswertung'
: 'Award Show'
}
function split(milliseconds: number) {
const seconds = Math.floor(Math.max(0, milliseconds) / 1000)
return {
d: Math.floor(seconds / 86400),
h: Math.floor((seconds % 86400) / 3600),
m: Math.floor((seconds % 3600) / 60),
s: seconds % 60,
}
}
function setText(element: HTMLElement | null, value: string) {
if (element) {
element.textContent = value
}
}
@@ -0,0 +1,189 @@
import { watch, type ComputedRef, type Ref } from 'vue'
import type { AuthStore } from '../../stores/auth'
import type { AwardsStore } from '../../stores/awards'
import type { HomeDisplayCategory, HomeInteractionModalKind, HomePreviewPhase, HomeSuccessKind } from './homeLandingTypes'
import { useHomeParticipationSessionActions } from './useHomeParticipationSessionActions'
import { useHomeParticipationSubmitActions } from './useHomeParticipationSubmitActions'
export function useHomeParticipationActions(params: {
store: AwardsStore
authStore: AuthStore
routerPush: (path: string) => Promise<unknown>
twitchUser: ComputedRef<string>
displayCategories: ComputedRef<HomeDisplayCategory[]>
archiveYears: ComputedRef<Array<{ year: number }>>
archiveYear: Ref<number>
previewPhase: Ref<HomePreviewPhase>
activeCat: Ref<number>
deleteConfirm: Ref<boolean>
accountActionError: Ref<string>
openModal: (kind: HomeInteractionModalKind) => void
closeAccountModal: () => void
votes: Ref<Record<string, number>>
submitted: Ref<boolean>
submitting: Ref<boolean>
formError: Ref<string>
successKind: Ref<null | HomeSuccessKind>
clipCatIdx: Ref<number>
clipDsgvo: Ref<boolean>
}) {
const {
store,
authStore,
routerPush,
twitchUser,
displayCategories,
archiveYears,
archiveYear,
previewPhase,
activeCat,
deleteConfirm,
accountActionError,
openModal,
closeAccountModal,
votes,
submitted,
submitting,
formError,
successKind,
clipCatIdx,
clipDsgvo,
} = params
const {
ensureViewerSession,
loadMyParticipation,
onLogin,
onLogout,
onConfirmDelete,
openAdminPanel,
} = useHomeParticipationSessionActions({
store,
authStore,
routerPush,
displayCategories,
votes,
formError,
deleteConfirm,
accountActionError,
clipDsgvo,
closeAccountModal,
})
const {
setCat,
pickNominee,
submitVote,
submitReminder,
submitNomination,
clipDsgvoChange,
onClipCatChange,
submitClip,
} = useHomeParticipationSubmitActions({
store,
displayCategories,
activeCat,
previewPhase,
votes,
submitted,
submitting,
formError,
successKind,
clipCatIdx,
clipDsgvo,
ensureViewerSession,
loadMyParticipation,
fallbackCreatorName: twitchUser,
})
async function initializeHomeInteractions() {
syncPreviewPhaseFromOverview()
await loadMyParticipation()
archiveYear.value = archiveYears.value[0]?.year ?? store.overview.year - 1
}
function onPrimaryPhaseAction(event?: Event) {
if (previewPhase.value === 'nomination') {
event?.preventDefault()
openModal('nominate')
return
}
if (previewPhase.value === 'voting') {
event?.preventDefault()
openModal('vote')
return
}
if (previewPhase.value === 'review') {
event?.preventDefault()
return
}
}
function onSectionAction(event?: Event) {
onPrimaryPhaseAction(event)
}
function onTimelineFinalAction(event?: Event) {
if (previewPhase.value === 'show') return
event?.preventDefault()
openModal('show')
}
function syncPreviewPhaseFromOverview() {
const activeTimelineItem = store.overview.timeline.find((entry) => entry.state === 'active')
if (isHomePreviewPhaseKey(activeTimelineItem?.key)) {
previewPhase.value = activeTimelineItem.key
return
}
if (isCompletedPhase(store.overview.currentPhase)) {
previewPhase.value = 'completed'
}
}
watch(displayCategories, (categories) => {
if (activeCat.value >= categories.length) {
activeCat.value = 0
}
if (clipCatIdx.value >= categories.length) {
clipCatIdx.value = 0
}
})
watch(() => store.overview.currentPhase, () => {
syncPreviewPhaseFromOverview()
})
watch(() => authStore.session?.twitchUserId, () => {
void loadMyParticipation()
})
return {
initializeHomeInteractions,
onLogin,
onLogout,
onConfirmDelete,
openAdminPanel,
setCat,
pickNominee,
submitVote,
submitReminder,
submitNomination,
clipDsgvoChange,
onClipCatChange,
submitClip,
onPrimaryPhaseAction,
onSectionAction,
onTimelineFinalAction,
syncPreviewPhaseFromOverview,
}
}
function isHomePreviewPhaseKey(value: string | undefined): value is HomePreviewPhase {
return value === 'nomination' || value === 'voting' || value === 'review' || value === 'show'
}
function isCompletedPhase(value: string) {
const normalized = value.trim().toLowerCase()
return normalized.includes('abgeschlossen') || normalized.includes('archiv') || normalized.includes('complete') || normalized.includes('ended')
}
@@ -0,0 +1,71 @@
import { computed, type ComputedRef, type Ref } from 'vue'
import type { AwardsStore } from '../../stores/awards'
import type { HomeDisplayCategory, HomeInteractionModalKind, HomePreviewPhase, HomeSuccessKind } from './homeLandingTypes'
export function useHomeParticipationPresentation(params: {
store: AwardsStore
displayCategories: ComputedRef<HomeDisplayCategory[]>
activeCat: Ref<number>
modal: Ref<null | HomeInteractionModalKind>
previewPhase: Ref<HomePreviewPhase>
votes: Ref<Record<string, number>>
submitted: Ref<boolean>
successKind: Ref<null | HomeSuccessKind>
clipCatIdx: Ref<number>
clipDsgvo: Ref<boolean>
}) {
const {
store,
displayCategories,
activeCat,
modal,
previewPhase,
votes,
submitted,
successKind,
clipCatIdx,
clipDsgvo,
} = 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 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 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 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 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 {
notSubmitted,
voteCount,
totalCats,
canSubmitVote,
activeCategory,
activeCatName,
pickerTitle,
pickerSubtitle,
successTitle,
successText,
clipNomOptions,
catOptions,
canSubmitClip,
clipSubmitStyle,
}
}
function formatShowDate(store: AwardsStore) {
const value = store.overview.showDate
if (!value) return 'Noch nicht terminiert'
const date = new Date(`${value}T00:00:00`)
return Number.isNaN(date.getTime())
? value
: date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' })
}
@@ -0,0 +1,112 @@
import type { ComputedRef, Ref } from 'vue'
import { api } from '../../lib/api'
import type { AuthSession } from '../../types/awards'
import type { AuthStore } from '../../stores/auth'
import type { AwardsStore } from '../../stores/awards'
import type { HomeDisplayCategory } from './homeLandingTypes'
export function useHomeParticipationSessionActions(params: {
store: AwardsStore
authStore: AuthStore
routerPush: (path: string) => Promise<unknown>
displayCategories: ComputedRef<HomeDisplayCategory[]>
votes: Ref<Record<string, number>>
formError: Ref<string>
deleteConfirm: Ref<boolean>
accountActionError: Ref<string>
clipDsgvo: Ref<boolean>
closeAccountModal: () => void
}) {
const {
store,
authStore,
routerPush,
displayCategories,
votes,
formError,
deleteConfirm,
accountActionError,
clipDsgvo,
closeAccountModal,
} = params
async function ensureViewerSession(): Promise<AuthSession> {
if (authStore.session) return authStore.session
await authStore.login({
twitchUserId: 'local_user',
displayName: 'Local User',
role: 'viewer',
})
if (!authStore.session) {
throw new Error('Eine aktive Session konnte nicht erstellt werden.')
}
return authStore.session
}
async function loadMyParticipation() {
if (!authStore.session || !store.overview.year) return
try {
const participation = await api.getMyParticipation(store.overview.year)
const nextVotes: Record<string, number> = {}
for (const vote of participation.votes) {
const category = displayCategories.value.find((item) => Number(item.id) === vote.categoryId)
const candidateIndex = category?.candidates.findIndex((candidate) => candidate.id === vote.candidateId) ?? -1
if (category && candidateIndex >= 0) {
nextVotes[category.id] = candidateIndex
}
}
votes.value = nextVotes
} catch {
votes.value = {}
}
}
async function onLogin() {
formError.value = ''
await ensureViewerSession()
await loadMyParticipation()
}
async function onLogout() {
await authStore.logout()
closeAccountModal()
deleteConfirm.value = false
accountActionError.value = ''
clipDsgvo.value = false
votes.value = {}
await routerPush('/login')
}
async function onConfirmDelete() {
accountActionError.value = ''
try {
await authStore.deleteMyData()
closeAccountModal()
deleteConfirm.value = false
clipDsgvo.value = false
votes.value = {}
await store.loadHomeData()
await routerPush('/login')
} catch (error) {
accountActionError.value = error instanceof Error ? error.message : 'Deine Daten konnten gerade nicht gelöscht werden.'
}
}
async function openAdminPanel(event?: Event) {
event?.preventDefault()
await routerPush(authStore.isAdmin ? '/admin' : '/login?redirect=/admin')
}
return {
ensureViewerSession,
loadMyParticipation,
onLogin,
onLogout,
onConfirmDelete,
openAdminPanel,
}
}
@@ -0,0 +1,160 @@
import { ref, type ComputedRef, type Ref } from 'vue'
import { useAuthStore } from '../../stores/auth'
import { useAwardsStore } from '../../stores/awards'
import type { HomeDisplayCategory, HomeInteractionModalKind, HomePreviewPhase, HomeSuccessKind } from './homeLandingTypes'
import { useHomeParticipationActions } from './useHomeParticipationActions'
import { useHomeParticipationPresentation } from './useHomeParticipationPresentation'
type AuthStore = ReturnType<typeof useAuthStore>
type AwardsStore = ReturnType<typeof useAwardsStore>
export function useHomeParticipationState(params: {
store: AwardsStore
authStore: AuthStore
routerPush: (path: string) => Promise<unknown>
twitchUser: ComputedRef<string>
displayCategories: ComputedRef<HomeDisplayCategory[]>
archiveYears: ComputedRef<Array<{ year: number }>>
archiveYear: Ref<number>
modal: Ref<null | HomeInteractionModalKind>
previewPhase: Ref<HomePreviewPhase>
activeCat: Ref<number>
deleteConfirm: Ref<boolean>
accountActionError: Ref<string>
openModal: (kind: HomeInteractionModalKind) => void
closeAccountModal: () => void
}) {
const {
store,
authStore,
routerPush,
twitchUser,
displayCategories,
archiveYears,
archiveYear,
modal,
previewPhase,
activeCat,
deleteConfirm,
accountActionError,
openModal,
closeAccountModal,
} = params
const votes = ref<Record<string, number>>({})
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 {
notSubmitted,
voteCount,
totalCats,
canSubmitVote,
activeCategory,
activeCatName,
pickerTitle,
pickerSubtitle,
successTitle,
successText,
clipNomOptions,
catOptions,
canSubmitClip,
clipSubmitStyle,
} = useHomeParticipationPresentation({
store,
displayCategories,
activeCat,
modal,
previewPhase,
votes,
submitted,
successKind,
clipCatIdx,
clipDsgvo,
})
const {
initializeHomeInteractions,
onLogin,
onLogout,
onConfirmDelete,
openAdminPanel,
setCat,
pickNominee,
submitVote,
submitReminder,
submitNomination,
clipDsgvoChange,
onClipCatChange,
submitClip,
onPrimaryPhaseAction,
onSectionAction,
onTimelineFinalAction,
syncPreviewPhaseFromOverview,
} = useHomeParticipationActions({
store,
authStore,
routerPush,
twitchUser,
displayCategories,
archiveYears,
archiveYear,
previewPhase,
activeCat,
deleteConfirm,
accountActionError,
openModal,
closeAccountModal,
votes,
submitted,
submitting,
formError,
successKind,
clipCatIdx,
clipDsgvo,
})
return {
votes,
submitted,
submitting,
formError,
successKind,
clipCatIdx,
clipDsgvo,
notSubmitted,
voteCount,
totalCats,
canSubmitVote,
activeCategory,
activeCatName,
pickerTitle,
pickerSubtitle,
successTitle,
successText,
clipNomOptions,
catOptions,
canSubmitClip,
clipSubmitStyle,
initializeHomeInteractions,
onLogin,
onLogout,
onConfirmDelete,
openAdminPanel,
setCat,
pickNominee,
submitVote,
submitReminder,
submitNomination,
clipDsgvoChange,
onClipCatChange,
submitClip,
onPrimaryPhaseAction,
onSectionAction,
onTimelineFinalAction,
syncPreviewPhaseFromOverview,
}
}
@@ -0,0 +1,214 @@
import type { ComputedRef, Ref } from 'vue'
import type { AuthSession } from '../../types/awards'
import type { AwardsStore } from '../../stores/awards'
import type { HomeClipSubmitContext, HomeDisplayCategory, HomeNominationSubmitContext, HomePreviewPhase, HomeSuccessKind } from './homeLandingTypes'
export function useHomeParticipationSubmitActions(params: {
store: AwardsStore
displayCategories: ComputedRef<HomeDisplayCategory[]>
activeCat: Ref<number>
previewPhase: Ref<HomePreviewPhase>
votes: Ref<Record<string, number>>
submitted: Ref<boolean>
submitting: Ref<boolean>
formError: Ref<string>
successKind: Ref<null | HomeSuccessKind>
clipCatIdx: Ref<number>
clipDsgvo: Ref<boolean>
ensureViewerSession: () => Promise<AuthSession>
loadMyParticipation: () => Promise<void>
fallbackCreatorName: ComputedRef<string>
}) {
const {
store,
displayCategories,
activeCat,
previewPhase,
votes,
submitted,
submitting,
formError,
successKind,
clipCatIdx,
clipDsgvo,
ensureViewerSession,
loadMyParticipation,
fallbackCreatorName,
} = params
function setCat(index: number) {
activeCat.value = index
}
function pickNominee(categoryId: string, index: number) {
votes.value = { ...votes.value, [categoryId]: index }
}
async function submitVote() {
formError.value = ''
if (submitting.value) return
if (previewPhase.value !== 'voting') {
formError.value = 'Das Voting ist in dieser Phase nicht verfügbar.'
return
}
if (Object.keys(votes.value).length === 0) {
formError.value = 'Bitte wähle mindestens eine Kategorie aus.'
return
}
submitting.value = true
try {
const session = await ensureViewerSession()
const entries = Object.entries(votes.value)
.map(([categoryId, candidateIndex]) => {
const category = displayCategories.value.find((item) => item.id === categoryId)
const candidate = category?.candidates[candidateIndex]
return category && candidate
? { categoryId: Number(category.id), candidateId: candidate.id }
: null
})
.filter((entry: { categoryId: number; candidateId: number } | null): entry is { categoryId: number; candidateId: number } => entry !== null)
if (entries.length === 0) {
formError.value = 'Bitte wähle mindestens eine gültige Kategorie aus.'
return
}
await store.submitVote({
seasonId: store.overview.seasonId,
twitchUserId: session.twitchUserId,
entries,
})
await loadMyParticipation()
submitted.value = true
successKind.value = 'vote'
} catch (error) {
formError.value = error instanceof Error ? error.message : 'Deine Stimme konnte nicht gespeichert werden.'
} finally {
submitting.value = false
}
}
function submitReminder() {
submitted.value = true
successKind.value = 'show'
}
function clipDsgvoChange() {
clipDsgvo.value = !clipDsgvo.value
}
function onClipCatChange(event: Event) {
const target = event.target as HTMLSelectElement
clipCatIdx.value = Number.parseInt(target.value || '0', 10) || 0
}
async function submitNomination(nominationContext: HomeNominationSubmitContext) {
formError.value = ''
if (submitting.value) return
if (previewPhase.value !== 'nomination') {
formError.value = 'Nominierungen sind nur während der Nominierungsphase möglich.'
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
}
if (!isHttpUrl(streamUrl)) {
formError.value = 'Bitte gib einen gültigen http(s)-Link ein.'
return
}
const category = displayCategories.value[nominationContext.categoryIndex]
if (!category) {
formError.value = 'Bitte wähle eine gültige Kategorie aus.'
return
}
submitting.value = true
try {
const session = await ensureViewerSession()
await store.submitNomination({
year: store.overview.year,
categoryId: Number(category.id),
twitchUserId: session.twitchUserId,
nominations: [{ name, streamUrl }],
})
await loadMyParticipation()
submitted.value = true
successKind.value = 'nomination'
} catch (error) {
formError.value = error instanceof Error ? error.message : 'Deine Nominierung konnte nicht gespeichert werden.'
} finally {
submitting.value = false
}
}
async function submitClip(clipContext: HomeClipSubmitContext) {
formError.value = ''
if (!clipDsgvo.value || submitting.value) return
if (previewPhase.value !== 'nomination') {
formError.value = 'Clip-Einreichungen sind nur während der Nominierungsphase möglich.'
return
}
const url = clipContext.clipUrl.trim()
if (!url) {
formError.value = 'Bitte gib einen Twitch- oder YouTube-Clip-Link ein.'
return
}
submitting.value = true
try {
const session = await ensureViewerSession()
const category = displayCategories.value[clipCatIdx.value]
const selectedCreator = category?.candidates[clipContext.selectedNomineeIndex]
await store.submitClip({
year: store.overview.year,
categoryId: category ? Number(category.id) : null,
candidateId: selectedCreator?.id ?? null,
twitchUserId: session.twitchUserId,
clipUrl: url,
title: clipContext.description || `Clip fuer ${selectedCreator?.displayName ?? 'die Award-Show'}`,
creator: selectedCreator?.displayName ?? fallbackCreatorName.value,
})
submitted.value = true
successKind.value = 'clip'
} catch (error) {
formError.value = error instanceof Error ? error.message : 'Der Clip konnte nicht gespeichert werden.'
} finally {
submitting.value = false
}
}
return {
setCat,
pickNominee,
submitVote,
submitReminder,
clipDsgvoChange,
onClipCatChange,
submitNomination,
submitClip,
}
}
function isHttpUrl(value: string) {
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
@@ -0,0 +1,136 @@
import { computed, onBeforeUnmount, ref, type ComputedRef, type Ref } from 'vue'
import type { HomeDisplayCategory, HomePreviewPhase } from './homeLandingTypes'
export function useHomePhasePresentation(params: {
previewPhase: Ref<HomePreviewPhase>
displayCategories: ComputedRef<HomeDisplayCategory[]>
candidateCount: ComputedRef<number>
publicStreamUrl: ComputedRef<string>
showDate: ComputedRef<string>
showStartsAt: ComputedRef<string>
currentYear: ComputedRef<string>
formatRange: (key: 'nomination' | 'voting' | 'review') => string
formatShowDate: () => string
}) {
const {
previewPhase,
displayCategories,
candidateCount,
publicStreamUrl,
showDate,
showStartsAt,
currentYear,
formatRange,
formatShowDate,
} = params
const nominationPhase = computed(() => previewPhase.value === 'nomination')
const votingPhase = computed(() => previewPhase.value === 'voting')
const reviewPhase = computed(() => previewPhase.value === 'review')
const showPhase = computed(() => previewPhase.value === 'show')
const completedPhase = computed(() => previewPhase.value === 'completed')
const currentTimestamp = ref(Date.now())
const ticker = setInterval(() => {
currentTimestamp.value = Date.now()
}, 1000)
onBeforeUnmount(() => clearInterval(ticker))
const showCountdown = computed(() => true)
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 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;')
const streamEyebrow = computed(() => 'Das grosse Finale')
const streamTitle = computed(() => 'Award-Show Countdown')
const streamMeta = computed(() => `Finale am ${formatShowDate()} · ${formatTimeLabel(showStartsAt.value)} Uhr`)
const streamLockedLabel = computed(() => completedPhase.value ? 'Award abgeschlossen' : 'Stream noch gesperrt')
const streamLockedTitle = computed(() => completedPhase.value ? 'Die Award-Show ist beendet' : 'Verfügbar, sobald die Show startet')
const statOneValue = computed(() => completedPhase.value ? 'DONE' : streamLive.value ? 'LIVE' : String(candidateCount.value))
const statOneLabel = computed(() => completedPhase.value ? 'Jahr-Status' : showPhase.value ? 'Show-Status' : 'Kandidat:innen')
const statTwoValue = computed(() => String(displayCategories.value.length))
const statThreeValue = computed(() => currentYear.value || '—')
const statThreeLabel = computed(() => 'Award-Jahr')
const categoryIntroText = computed(() => {
const count = displayCategories.value.length
const countLabel = count === 1 ? 'Eine Kategorie' : `${count || 'Alle'} Kategorien`
return `${countLabel}, eine Show, eine Szene. Stimm für deine Lieblings-VTuber ab und sei live dabei, wenn die Gewinner feststehen.`
})
const timelineLineStyle = computed(() => nominationPhase.value
? '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
? '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 sectionActionHref = computed(() => showPhase.value ? publicStreamUrl.value : '#')
const sectionActionDisabled = computed(() => reviewPhase.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
? 'display:inline-flex;align-items:center;gap:11px;padding:17px 38px;border-radius:14px;background:#fff;color:#e855a5;text-decoration:none;font-family:\'Outfit\',sans-serif;font-weight:700;font-size:18px;box-shadow:0 14px 34px rgba(0,0,0,.22);'
: 'display:inline-flex;align-items:center;gap:11px;padding:17px 38px;border-radius:14px;background:#fff;color:#7a3fd0;text-decoration:none;font-family:\'Outfit\',sans-serif;font-weight:700;font-size:18px;box-shadow:0 14px 34px rgba(0,0,0,.22);')
return {
nominationPhase,
votingPhase,
reviewPhase,
showPhase,
completedPhase,
showCountdown,
streamLive,
streamLocked,
phaseCardTitle,
phaseCardDescription,
phaseCardRange,
phaseStatusLabel,
phaseStatusStyle,
phasePrimaryLabel,
phasePrimaryDisabled,
phasePrimaryActionStyle,
streamEyebrow,
streamTitle,
streamMeta,
streamLockedLabel,
streamLockedTitle,
statOneValue,
statOneLabel,
statTwoValue,
statThreeValue,
statThreeLabel,
categoryIntroText,
timelineLineStyle,
sectionTitle,
sectionText,
sectionActionLabel,
sectionActionHref,
sectionActionDisabled,
sectionActionStyle,
}
}
function parseShowStartMs(showDate: string, showStartsAt: string) {
if (!showDate) return Number.NaN
const time = normalizeTime(showStartsAt)
return new Date(`${showDate}T${time}`).getTime()
}
function normalizeTime(value: string) {
if (!value) return '20:00:00'
return value.length === 5 ? `${value}:00` : value
}
function formatTimeLabel(value: string) {
return normalizeTime(value).slice(0, 5)
}
@@ -0,0 +1,65 @@
import { computed, type ComputedRef } from 'vue'
import { simpleIconForKey } from '../../lib/socialIcons'
import type { OverviewResponse } from '../../types/awards'
export function useHomeSocialPresentation(siteContent: ComputedRef<OverviewResponse['siteContent']>) {
const siteSocialLinks = computed(() =>
(siteContent.value.socialLinks ?? [])
.filter((social) => social?.url && social.platform)
.map((social) => ({
label: social.label || social.platform || 'Social Link',
platform: social.platform || 'link',
icon: social.icon || '',
url: social.url,
showOnHost: social.showOnHost ?? true,
showOnCommunity: social.showOnCommunity ?? true,
})),
)
const hostSocialLinks = computed(() =>
siteSocialLinks.value.filter((social) => social.showOnHost).slice(0, 3),
)
const communitySocialLinks = computed(() =>
siteSocialLinks.value.filter((social) => social.showOnCommunity),
)
const footerLinks = computed(() =>
(siteContent.value.footerLinks ?? []).filter((link) => link?.label && link.url),
)
const privacyContentBlocks = computed(() =>
(siteContent.value.privacyPolicyContent || '')
.split(/\n{2,}/)
.map((block) => block.trim())
.filter(Boolean),
)
function platformKey(value: string | null | undefined) {
return (value ?? 'link').trim().toLowerCase()
}
function isUploadedSocialIcon(icon: string | null | undefined) {
return (icon ?? '').startsWith('data:image/')
}
function socialSimpleIconPath(platform: string | null | undefined) {
return simpleIconForKey(platform)?.path ?? ''
}
function socialSimpleIconColor(platform: string | null | undefined) {
return `#${simpleIconForKey(platform)?.hex ?? '5f44ad'}`
}
return {
hostSocialLinks,
communitySocialLinks,
footerLinks,
privacyContentBlocks,
platformKey,
isUploadedSocialIcon,
socialSimpleIconPath,
socialSimpleIconColor,
}
}