Refactor app architecture and clean local artifacts
This commit is contained in:
@@ -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 & 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 & 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 ♡ & 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 & 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 & 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;">★ 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 →</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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user