Redesign public site, add phase gating and clip submission

- Rebuild landing page and Nominations/Voting/Winners views in the
  Jayuhime brand style (purple/gold/pastel-rainbow, stars, PageHero banner)
- Gate participation by season phase (nominate/clip share the nomination
  window, vote in the voting window); hide closed/locked pages from nav
- Add login-gated clip submission flow (link-based) + voting clip links
- Make candidate admin scalable: searchable, filterable, paginated table
  with modal create/edit and confirm-delete; add delete API/store actions
- New reusable Modal and PageHero components, usePhases composable
- Segmented top nav and full-size header on admin routes
- vite: honor PORT env for preview

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AzuTear
2026-06-18 06:46:41 +02:00
parent a2d6a5edc0
commit cca297a4eb
17 changed files with 1735 additions and 691 deletions
+131
View File
@@ -0,0 +1,131 @@
<svg viewBox="0 0 720 880" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Jayuhime Keyvisual (stilisierter Platzhalter)">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#efe6ff"/>
<stop offset="55%" stop-color="#f6ecff"/>
<stop offset="100%" stop-color="#fff1da"/>
</linearGradient>
<radialGradient id="glow" cx="50%" cy="34%" r="55%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.95"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
<linearGradient id="gold" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffe39b"/>
<stop offset="55%" stop-color="#f6b938"/>
<stop offset="100%" stop-color="#d98e1d"/>
</linearGradient>
<linearGradient id="robe" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#8b6bff"/>
<stop offset="100%" stop-color="#5b34c9"/>
</linearGradient>
<linearGradient id="robeDark" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#6f4fe0"/>
<stop offset="100%" stop-color="#4a279f"/>
</linearGradient>
<linearGradient id="hair" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff"/>
<stop offset="100%" stop-color="#ece4fb"/>
</linearGradient>
<linearGradient id="rainbow" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#c4b5fd"/>
<stop offset="28%" stop-color="#f5a9d6"/>
<stop offset="52%" stop-color="#fcd34d"/>
<stop offset="76%" stop-color="#86efac"/>
<stop offset="100%" stop-color="#7dd3fc"/>
</linearGradient>
</defs>
<rect width="720" height="880" fill="url(#bg)"/>
<rect width="720" height="880" fill="url(#glow)"/>
<!-- sparkles -->
<g fill="#f6b938" opacity="0.85">
<circle cx="120" cy="130" r="4"/><circle cx="610" cy="100" r="5"/><circle cx="665" cy="250" r="3"/>
<circle cx="70" cy="350" r="3"/><circle cx="650" cy="470" r="4"/><circle cx="150" cy="540" r="3"/>
</g>
<g fill="#a78bff" opacity="0.65">
<circle cx="205" cy="90" r="3"/><circle cx="545" cy="170" r="3"/><circle cx="80" cy="230" r="4"/><circle cx="605" cy="370" r="3"/>
</g>
<g fill="#ffffff">
<path d="M150 200 l6 14 14 6 -14 6 -6 14 -6 -14 -14 -6 14 -6z" opacity="0.9"/>
<path d="M585 300 l5 11 11 5 -11 5 -5 11 -5 -11 -11 -5 11 -5z" opacity="0.85"/>
<path d="M120 640 l5 11 11 5 -11 5 -5 11 -5 -11 -11 -5 11 -5z" opacity="0.8"/>
</g>
<!-- soft halo behind host -->
<circle cx="360" cy="300" r="210" fill="#ffffff" opacity="0.45"/>
<!-- ===== back hair (long locks framing the body) ===== -->
<path d="M286 250 C232 360 226 520 252 690 C272 650 300 648 318 596 C300 470 300 350 322 286 Z" fill="url(#hair)"/>
<path d="M434 250 C488 360 494 520 468 690 C448 650 420 648 402 596 C420 470 420 350 398 286 Z" fill="url(#hair)"/>
<!-- rainbow streaks inside the locks -->
<path d="M296 320 C268 420 266 540 286 660 C300 624 308 600 308 560 C298 470 298 384 308 330 Z" fill="url(#rainbow)" opacity="0.9"/>
<path d="M424 320 C452 420 454 540 434 660 C420 624 412 600 412 560 C422 470 422 384 412 330 Z" fill="url(#rainbow)" opacity="0.9"/>
<!-- ===== dress (bell skirt) ===== -->
<path d="M322 452 C300 600 250 760 214 868 C300 884 420 884 506 868 C470 760 420 600 398 452 Z" fill="url(#robe)"/>
<!-- skirt shading -->
<path d="M360 452 C352 600 340 760 332 868 L388 868 C380 760 368 600 360 452 Z" fill="#ffffff" opacity="0.12"/>
<!-- gold star accents on skirt -->
<g fill="url(#gold)" opacity="0.92">
<path d="M300 640 l5 12 13 5 -13 5 -5 12 -5 -12 -13 -5 13 -5z"/>
<path d="M420 600 l4 10 11 4 -11 4 -4 10 -4 -10 -11 -4 11 -4z"/>
<path d="M360 730 l5 12 13 5 -13 5 -5 12 -5 -12 -13 -5 13 -5z"/>
</g>
<!-- white ruffle hem -->
<path d="M214 862 q24 -22 48 0 q24 22 48 0 q24 -22 48 0 q24 22 48 0 q24 -22 48 0 q24 22 52 0 l0 22 -340 0 z" fill="#ffffff" opacity="0.95"/>
<!-- ===== bodice ===== -->
<path d="M316 352 C322 336 398 336 404 352 L400 458 L320 458 Z" fill="url(#robeDark)"/>
<!-- star cutout on chest -->
<path d="M360 392 l8 18 19 7 -19 8 -8 18 -8 -18 -19 -8 19 -7z" fill="#f6ecff" opacity="0.85"/>
<!-- waist sash -->
<rect x="318" y="446" width="84" height="16" rx="8" fill="url(#gold)"/>
<!-- ===== lower (left) arm + puff sleeve ===== -->
<path d="M322 372 C300 420 292 470 300 512" fill="none" stroke="#fff4ec" stroke-width="22" stroke-linecap="round"/>
<circle cx="318" cy="372" r="26" fill="#ffffff"/>
<!-- ===== raised (right) arm holding trophy ===== -->
<path d="M402 372 C448 350 486 300 500 250" fill="none" stroke="#fff4ec" stroke-width="22" stroke-linecap="round"/>
<circle cx="402" cy="372" r="26" fill="#ffffff"/>
<circle cx="500" cy="246" r="15" fill="#fff4ec"/>
<!-- ===== neck + collar ruffle ===== -->
<rect x="350" y="300" width="20" height="40" rx="9" fill="#fff4ec"/>
<path d="M324 344 q18 -16 36 0 q18 16 36 0 l0 14 -72 0 z" fill="#ffffff"/>
<!-- ===== face ===== -->
<circle cx="360" cy="252" r="60" fill="#fff4ec"/>
<!-- cheeks -->
<circle cx="326" cy="268" r="9" fill="#ffc6cf" opacity="0.7"/>
<circle cx="394" cy="268" r="9" fill="#ffc6cf" opacity="0.7"/>
<!-- eyes -->
<ellipse cx="341" cy="254" rx="6" ry="8" fill="#6b4bd6"/>
<ellipse cx="379" cy="254" rx="6" ry="8" fill="#6b4bd6"/>
<circle cx="343" cy="251" r="2" fill="#ffffff"/>
<circle cx="381" cy="251" r="2" fill="#ffffff"/>
<!-- smile -->
<path d="M349 276 q11 9 22 0" fill="none" stroke="#caa6a0" stroke-width="3" stroke-linecap="round"/>
<!-- monocle on right eye -->
<circle cx="379" cy="254" r="17" fill="none" stroke="url(#gold)" stroke-width="3.5"/>
<path d="M379 271 q5 24 22 32" fill="none" stroke="url(#gold)" stroke-width="2" stroke-linecap="round"/>
<!-- ===== bangs over forehead ===== -->
<path d="M300 250 C300 168 330 138 360 138 C390 138 420 168 420 250 C402 222 384 214 360 214 C336 214 318 222 300 250 Z" fill="url(#hair)"/>
<path d="M360 214 C346 214 334 220 326 234 L334 250 C342 230 378 230 386 250 L394 234 C386 220 374 214 360 214 Z" fill="#ece4fb" opacity="0.6"/>
<!-- ===== star hairbuns ===== -->
<path d="M296 168 l10 22 23 9 -23 10 -10 22 -10 -22 -23 -10 23 -9z" fill="url(#gold)"/>
<path d="M424 168 l10 22 23 9 -23 10 -10 22 -10 -22 -23 -10 23 -9z" fill="url(#gold)"/>
<!-- ===== trophy in raised hand ===== -->
<g transform="translate(458 110)">
<path d="M22 0 h84 v28 q0 50 -42 64 q-42 -14 -42 -64 z" fill="url(#gold)"/>
<path d="M22 7 h-22 q0 36 30 40" fill="none" stroke="url(#gold)" stroke-width="10" stroke-linecap="round"/>
<path d="M106 7 h22 q0 36 -30 40" fill="none" stroke="url(#gold)" stroke-width="10" stroke-linecap="round"/>
<rect x="58" y="90" width="12" height="30" fill="url(#gold)"/>
<rect x="40" y="118" width="48" height="14" rx="5" fill="url(#gold)"/>
<path d="M64 16 l8 19 20 1 -15 13 5 20 -18 -11 -18 11 5 -20 -15 -13 20 -1z" fill="#ffffff" opacity="0.95"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB

+27 -15
View File
@@ -5,9 +5,11 @@ import { Star } from '@lucide/vue'
import Button from './ui/Button.vue' import Button from './ui/Button.vue'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { usePhases } from '../composables/usePhases'
const route = useRoute() const route = useRoute()
const authStore = useAuthStore() const authStore = useAuthStore()
const { infoForPhaseKey } = usePhases()
const loginOpen = ref(false) const loginOpen = ref(false)
const loginError = ref('') const loginError = ref('')
const loginForm = reactive({ const loginForm = reactive({
@@ -18,8 +20,9 @@ const loginForm = reactive({
const navItems = [ const navItems = [
{ label: 'Home', to: '/' }, { label: 'Home', to: '/' },
{ label: 'Nominierung', to: '/nominations' }, { label: 'Nominierung', to: '/nominations', requiresAuth: true, phase: 'nomination' },
{ label: 'Voting', to: '/voting' }, { label: 'Voting', to: '/voting', requiresAuth: true, phase: 'voting' },
{ label: 'Clips', to: '/clips', requiresAuth: true, phase: 'nomination' },
{ label: 'Gewinner', to: '/winners' }, { label: 'Gewinner', to: '/winners' },
{ label: 'Admin', to: '/admin' }, { label: 'Admin', to: '/admin' },
] ]
@@ -29,9 +32,15 @@ const currentLabel = computed(
) )
const visibleNavItems = computed(() => const visibleNavItems = computed(() =>
navItems.filter((item) => item.to !== '/admin' || authStore.isAdmin), navItems.filter((item) => {
if (item.to === '/admin') return authStore.isAdmin
// Teilnahme-Seiten erst nach Login zeigen
if (item.requiresAuth && !authStore.isLoggedIn) return false
// ... und nur, solange ihre Phase aktiv ist
if (item.phase && infoForPhaseKey(item.phase).state !== 'active') return false
return true
}),
) )
const isAdminRoute = computed(() => route.path.startsWith('/admin'))
async function login(role: 'viewer' | 'admin') { async function login(role: 'viewer' | 'admin') {
loginError.value = '' loginError.value = ''
@@ -58,20 +67,18 @@ async function login(role: 'viewer' | 'admin') {
</div> </div>
<header <header
class="flex flex-col rounded-[24px] border border-white/70 bg-white/72 shadow-[0_18px_55px_rgba(93,63,135,0.08)] backdrop-blur lg:px-7" class="mb-10 flex flex-col gap-6 rounded-[24px] border border-white/70 bg-white/72 px-5 py-5 shadow-[0_18px_55px_rgba(93,63,135,0.08)] backdrop-blur lg:px-7"
:class="isAdminRoute ? 'mb-5 gap-3 px-4 py-3' : 'mb-10 gap-6 px-5 py-5'"
> >
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between" :class="isAdminRoute ? 'gap-3' : 'gap-5'"> <div class="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
<RouterLink to="/" class="flex items-center gap-4 text-slate-800 no-underline"> <RouterLink to="/" class="flex items-center gap-4 text-slate-800 no-underline">
<div <div
class="grid place-items-center rounded-[1.1rem] bg-[linear-gradient(135deg,#f6e3b2,#f5c877)] text-amber-950 shadow-[0_16px_28px_rgba(245,200,119,0.35)]" class="grid h-12 w-12 place-items-center rounded-[1.1rem] bg-[linear-gradient(135deg,#f6e3b2,#f5c877)] text-amber-950 shadow-[0_16px_28px_rgba(245,200,119,0.35)]"
:class="isAdminRoute ? 'h-10 w-10' : 'h-12 w-12'"
> >
<Star :class="isAdminRoute ? 'h-4 w-4' : 'h-5 w-5'" /> <Star class="h-5 w-5" />
</div> </div>
<div> <div>
<strong class="block text-sm tracking-[0.35em]">VTUBER</strong> <strong class="block text-sm tracking-[0.35em]">VTUBER</strong>
<span v-if="!isAdminRoute" class="block text-[11px] tracking-[0.45em] text-slate-500">STAR AWARDS</span> <span class="block text-[11px] tracking-[0.45em] text-slate-500">STAR AWARDS</span>
</div> </div>
</RouterLink> </RouterLink>
@@ -84,7 +91,10 @@ async function login(role: 'viewer' | 'admin') {
</template> </template>
<template v-else> <template v-else>
<Button variant="ghost" @click="loginOpen = !loginOpen">Einloggen</Button> <Button variant="ghost" @click="loginOpen = !loginOpen">Einloggen</Button>
<Button @click="login('viewer')">Mit Twitch Login</Button> <Button class="gap-2" @click="login('viewer')">
<svg class="h-4 w-4 fill-current" viewBox="0 0 16 16"><path d="M2.5 0 0 2.5v11h3.5V16h2l2.5-2.5h3l5-5V0H2.5Zm12 8L12 10.5H8.5L6 13v-2.5H2.5V2h12v6Z"/><path d="M11.5 4h1.5v3.5h-1.5V4Zm-3.5 0h1.5v3.5H8V4Z"/></svg>
Anmelden mit Twitch
</Button>
</template> </template>
</div> </div>
</div> </div>
@@ -104,13 +114,15 @@ async function login(role: 'viewer' | 'admin') {
</div> </div>
<div class="flex flex-col gap-3 border-t border-black/6 pt-3 lg:flex-row lg:items-center lg:justify-between"> <div class="flex flex-col gap-3 border-t border-black/6 pt-3 lg:flex-row lg:items-center lg:justify-between">
<nav class="flex flex-wrap items-center gap-2 text-sm text-slate-600"> <nav class="flex flex-wrap items-center gap-1 rounded-full border border-violet-100/80 bg-violet-50/50 p-1.5 text-sm font-medium text-slate-500">
<RouterLink <RouterLink
v-for="item in visibleNavItems" v-for="item in visibleNavItems"
:key="item.to" :key="item.to"
:to="item.to" :to="item.to"
class="rounded-full px-4 py-2 transition hover:bg-violet-50 hover:text-violet-700" class="rounded-full px-4 py-2 transition"
:class="route.path === item.to || route.path.startsWith(`${item.to}/`) ? 'bg-violet-100 text-violet-800' : ''" :class="route.path === item.to || route.path.startsWith(`${item.to}/`)
? 'bg-white text-violet-800 shadow-[0_4px_14px_rgba(124,92,255,0.18)] ring-1 ring-violet-100'
: 'hover:bg-white/60 hover:text-violet-700'"
> >
{{ item.label }} {{ item.label }}
</RouterLink> </RouterLink>
@@ -1,17 +1,50 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Component } from 'vue'
import { Sparkles } from '@lucide/vue'
defineProps<{ defineProps<{
eyebrow?: string eyebrow?: string
title: string title: string
description: string description: string
/** Dekoratives Icon oben rechts analog zu den Frontend-Bannern */
icon?: Component
}>() }>()
</script> </script>
<template> <template>
<div class="flex flex-col gap-2 border-b border-violet-100 pb-4 md:flex-row md:items-end md:justify-between"> <section class="relative overflow-hidden rounded-[32px] border border-violet-200/60 bg-[linear-gradient(135deg,#ece2ff_0%,#f6ecff_42%,#fff2dd_100%)] px-7 py-9 shadow-[0_24px_60px_rgba(124,92,255,0.12)] sm:px-12 sm:py-11">
<div> <div class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_88%_18%,rgba(255,255,255,0.7),transparent_42%)]" />
<p v-if="eyebrow" class="text-xs font-semibold uppercase tracking-[0.28em] text-violet-500">{{ eyebrow }}</p>
<h2 class="mt-1 text-2xl font-semibold text-slate-900">{{ title }}</h2> <svg class="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
<g fill="#f6b938" opacity="0.7">
<circle cx="74%" cy="24%" r="3" />
<circle cx="92%" cy="62%" r="2.5" />
<circle cx="63%" cy="78%" r="2.5" />
</g>
<g fill="#a78bff" opacity="0.6">
<circle cx="83%" cy="40%" r="2.5" />
<circle cx="69%" cy="50%" r="2" />
</g>
</svg>
<div
v-if="icon"
class="pointer-events-none absolute -right-6 top-1/2 hidden -translate-y-1/2 sm:block"
>
<div class="grid h-40 w-40 place-items-center rounded-full bg-white/35 text-amber-400/80 ring-1 ring-white/60 backdrop-blur-sm">
<component :is="icon" class="h-[4.5rem] w-[4.5rem]" />
</div>
</div> </div>
<p class="max-w-xl text-sm leading-6 text-slate-500">{{ description }}</p>
</div> <div class="relative max-w-2xl space-y-4">
<span class="inline-flex items-center gap-2 rounded-full border border-white/70 bg-white/70 px-4 py-1.5 text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-600 shadow-sm backdrop-blur">
<Sparkles class="h-3.5 w-3.5 text-amber-500" />
{{ eyebrow ?? 'Admin' }}
</span>
<h1 class="font-[Cormorant_Garamond] text-4xl leading-[1.02] text-violet-800 sm:text-5xl">
{{ title }}
</h1>
<p class="max-w-xl text-sm leading-6 text-slate-600 sm:text-base">{{ description }}</p>
</div>
</section>
</template> </template>
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, watch } from 'vue'
import { X } from '@lucide/vue'
const props = defineProps<{
open: boolean
title?: string
subtitle?: string
}>()
const emit = defineEmits<{ (e: 'close'): void }>()
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape' && props.open) emit('close')
}
watch(
() => props.open,
(open) => {
if (typeof document !== 'undefined') {
document.body.style.overflow = open ? 'hidden' : ''
}
},
)
onMounted(() => window.addEventListener('keydown', onKey))
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKey)
if (typeof document !== 'undefined') document.body.style.overflow = ''
})
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="modal-overlay fixed inset-0 z-50 flex items-center justify-center p-4"
@click.self="emit('close')"
>
<div class="absolute inset-0 bg-violet-950/30 backdrop-blur-sm" @click="emit('close')" />
<div class="modal-panel relative z-10 w-full max-w-lg overflow-hidden rounded-[28px] border border-violet-200/70 bg-white shadow-[0_40px_90px_rgba(76,40,160,0.28)]">
<div class="flex items-start justify-between gap-4 border-b border-violet-100 bg-[linear-gradient(135deg,#f3edff,#fff4e6)] px-6 py-5">
<div>
<h3 v-if="title" class="font-[Cormorant_Garamond] text-3xl text-violet-800">{{ title }}</h3>
<p v-if="subtitle" class="mt-1 text-sm text-slate-500">{{ subtitle }}</p>
</div>
<button
type="button"
class="grid h-9 w-9 shrink-0 place-items-center rounded-full text-slate-400 transition hover:bg-white/70 hover:text-violet-700"
@click="emit('close')"
>
<X class="h-5 w-5" />
</button>
</div>
<div class="max-h-[70vh] overflow-y-auto px-6 py-6">
<slot />
</div>
<div v-if="$slots.footer" class="flex justify-end gap-3 border-t border-violet-100 bg-violet-50/40 px-6 py-4">
<slot name="footer" />
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.modal-overlay {
animation: modal-fade 0.16s ease;
}
.modal-panel {
animation: modal-pop 0.18s ease;
}
@keyframes modal-fade {
from {
opacity: 0;
}
}
@keyframes modal-pop {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
}
</style>
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { Sparkles } from '@lucide/vue'
defineProps<{
eyebrow: string
title: string
description?: string
/** Großes, dekoratives Icon oben rechts */
icon?: Component
}>()
</script>
<template>
<section class="relative overflow-hidden rounded-[32px] border border-violet-200/60 bg-[linear-gradient(135deg,#ece2ff_0%,#f6ecff_42%,#fff2dd_100%)] px-7 py-10 shadow-[0_24px_60px_rgba(124,92,255,0.12)] sm:px-12 sm:py-14">
<!-- dekorativer Schein -->
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_88%_18%,rgba(255,255,255,0.7),transparent_42%)]" />
<!-- Sterne -->
<svg class="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
<g fill="#f6b938" opacity="0.7">
<circle cx="74%" cy="24%" r="3" />
<circle cx="92%" cy="62%" r="2.5" />
<circle cx="63%" cy="78%" r="2.5" />
</g>
<g fill="#a78bff" opacity="0.6">
<circle cx="83%" cy="40%" r="2.5" />
<circle cx="69%" cy="50%" r="2" />
</g>
</svg>
<!-- großes dekoratives Icon -->
<div
v-if="icon"
class="pointer-events-none absolute -right-6 top-1/2 hidden -translate-y-1/2 sm:block"
>
<div class="grid h-44 w-44 place-items-center rounded-full bg-white/35 text-amber-400/80 ring-1 ring-white/60 backdrop-blur-sm">
<component :is="icon" class="h-20 w-20" />
</div>
</div>
<div class="relative max-w-2xl space-y-5">
<span class="inline-flex items-center gap-2 rounded-full border border-white/70 bg-white/70 px-4 py-1.5 text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-600 shadow-sm backdrop-blur">
<Sparkles class="h-3.5 w-3.5 text-amber-500" />
{{ eyebrow }}
</span>
<h1 class="font-[Cormorant_Garamond] text-5xl leading-[0.95] text-violet-800 sm:text-6xl">
{{ title }}
</h1>
<p v-if="description" class="max-w-xl text-lg leading-8 text-slate-600">
{{ description }}
</p>
<slot name="actions" />
</div>
</section>
</template>
+48
View File
@@ -0,0 +1,48 @@
import { useAwardsStore } from '../stores/awards'
/** Teilnahme-Aktionen und die Phase (Timeline-key), an die sie gekoppelt sind. */
export type ActionKey = 'nominate' | 'clip' | 'vote'
export const PHASE_KEY: Record<ActionKey, string> = {
nominate: 'nomination',
clip: 'nomination', // Clips laufen im selben Fenster wie die Nominierung
vote: 'voting',
}
export type PhaseState = 'upcoming' | 'active' | 'done'
export interface PhaseInfo {
state: PhaseState
open: boolean
startLabel: string
endLabel: string
}
function formatDate(date?: string): string {
if (!date) return ''
const parsed = new Date(`${date}T00:00:00`)
return Number.isNaN(parsed.getTime())
? date
: parsed.toLocaleDateString('de-DE', { day: '2-digit', month: 'long' })
}
export function usePhases() {
const store = useAwardsStore()
function infoForPhaseKey(key?: string): PhaseInfo {
const item = store.overview.timeline.find((entry) => entry.key === key)
const state = (item?.state ?? 'upcoming') as PhaseState
return {
state,
open: state === 'active',
startLabel: formatDate(item?.startsAt),
endLabel: formatDate(item?.endsAt),
}
}
function phaseInfo(action: ActionKey): PhaseInfo {
return infoForPhaseKey(PHASE_KEY[action])
}
return { phaseInfo, infoForPhaseKey }
}
+24
View File
@@ -3,6 +3,7 @@ import type {
AdminSeasonDetailResponse, AdminSeasonDetailResponse,
AdminSeasonListItem, AdminSeasonListItem,
AuthSession, AuthSession,
CreateClipPayload,
CreateNominationPayload, CreateNominationPayload,
CreateVotePayload, CreateVotePayload,
LoginPayload, LoginPayload,
@@ -40,6 +41,23 @@ async function getJson<T>(path: string): Promise<T> {
return response.json() as Promise<T> return response.json() as Promise<T>
} }
async function sendDelete<TResponse>(path: string): Promise<TResponse> {
const token = getAuthToken()
const response = await fetch(`${API_URL}${path}`, {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
}).catch(() => {
throw new Error(`API nicht erreichbar (${API_URL}). Bitte Backend starten.`)
})
if (!response.ok) {
const error = await response.text()
throw new Error(error || `API request failed for ${path}`)
}
return response.json() as Promise<TResponse>
}
async function sendJson<TResponse>(path: string, method: 'POST' | 'PUT', body: unknown): Promise<TResponse> { async function sendJson<TResponse>(path: string, method: 'POST' | 'PUT', body: unknown): Promise<TResponse> {
const token = getAuthToken() const token = getAuthToken()
const response = await fetch(`${API_URL}${path}`, { const response = await fetch(`${API_URL}${path}`, {
@@ -78,6 +96,8 @@ export const api = {
sendJson<{ saved: number; category: string }>('/api/public/nominations', 'POST', payload), sendJson<{ saved: number; category: string }>('/api/public/nominations', 'POST', payload),
submitVote: (payload: CreateVotePayload) => submitVote: (payload: CreateVotePayload) =>
sendJson<{ ballotId: number; entries: number }>('/api/public/votes', 'POST', payload), sendJson<{ ballotId: number; entries: number }>('/api/public/votes', 'POST', payload),
submitClip: (payload: CreateClipPayload) =>
sendJson<{ saved: boolean; clipId: number }>('/api/public/clips', 'POST', payload),
updateAdminSeason: (seasonId: number, payload: UpdateSeasonPayload) => updateAdminSeason: (seasonId: number, payload: UpdateSeasonPayload) =>
sendJson<{ saved: boolean; seasonId: number }>(`/api/admin/seasons/${seasonId}`, 'PUT', payload), sendJson<{ saved: boolean; seasonId: number }>(`/api/admin/seasons/${seasonId}`, 'PUT', payload),
createAdminCategory: (seasonId: number, payload: UpsertCategoryPayload) => createAdminCategory: (seasonId: number, payload: UpsertCategoryPayload) =>
@@ -88,6 +108,10 @@ export const api = {
sendJson<{ saved: boolean; candidateId: number }>(`/api/admin/seasons/${seasonId}/candidates`, 'POST', payload), sendJson<{ saved: boolean; candidateId: number }>(`/api/admin/seasons/${seasonId}/candidates`, 'POST', payload),
updateAdminCandidate: (candidateId: number, payload: UpsertCandidatePayload) => updateAdminCandidate: (candidateId: number, payload: UpsertCandidatePayload) =>
sendJson<{ saved: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`, 'PUT', payload), sendJson<{ saved: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`, 'PUT', payload),
deleteAdminCandidate: (candidateId: number) =>
sendDelete<{ deleted: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`),
deleteAdminCategory: (categoryId: number) =>
sendDelete<{ deleted: boolean; categoryId: number }>(`/api/admin/categories/${categoryId}`),
approveAdminNomination: (nominationId: number, payload: ApproveNominationPayload) => approveAdminNomination: (nominationId: number, payload: ApproveNominationPayload) =>
sendJson<{ saved: boolean; nominationId: number; candidateId: number; created: boolean }>( sendJson<{ saved: boolean; nominationId: number; candidateId: number; created: boolean }>(
`/api/admin/nominations/${nominationId}/approve`, `/api/admin/nominations/${nominationId}/approve`,
+23
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from './stores/auth' import { useAuthStore } from './stores/auth'
import { useAwardsStore } from './stores/awards'
import AdminCandidatesView from './views/admin/AdminCandidatesView.vue' import AdminCandidatesView from './views/admin/AdminCandidatesView.vue'
import AdminAnalyticsView from './views/admin/AdminAnalyticsView.vue' import AdminAnalyticsView from './views/admin/AdminAnalyticsView.vue'
@@ -14,6 +15,7 @@ import AdminSeasonsView from './views/admin/AdminSeasonsView.vue'
import AdminSettingsView from './views/admin/AdminSettingsView.vue' import AdminSettingsView from './views/admin/AdminSettingsView.vue'
import AdminUsersLogsView from './views/admin/AdminUsersLogsView.vue' import AdminUsersLogsView from './views/admin/AdminUsersLogsView.vue'
import AdminVotingView from './views/admin/AdminVotingView.vue' import AdminVotingView from './views/admin/AdminVotingView.vue'
import ClipSubmissionView from './views/ClipSubmissionView.vue'
import HomeView from './views/HomeView.vue' import HomeView from './views/HomeView.vue'
import NominationsView from './views/NominationsView.vue' import NominationsView from './views/NominationsView.vue'
import VotingView from './views/VotingView.vue' import VotingView from './views/VotingView.vue'
@@ -45,6 +47,7 @@ const router = createRouter({
meta: { meta: {
requiresAuth: true, requiresAuth: true,
keepAlive: true, keepAlive: true,
phase: 'nomination',
}, },
}, },
{ {
@@ -54,6 +57,17 @@ const router = createRouter({
meta: { meta: {
requiresAuth: true, requiresAuth: true,
keepAlive: true, keepAlive: true,
phase: 'voting',
},
},
{
path: '/clips',
name: 'clips',
component: ClipSubmissionView,
meta: {
requiresAuth: true,
keepAlive: true,
phase: 'nomination',
}, },
}, },
{ {
@@ -192,6 +206,15 @@ router.beforeEach(async (to) => {
return { name: 'home' } return { name: 'home' }
} }
// Phasen-Gate: eine Aktion ist nur in ihrem aktiven Zeitfenster erreichbar.
if (typeof to.meta.phase === 'string') {
const awardsStore = useAwardsStore()
const phase = awardsStore.overview.timeline.find((entry) => entry.key === to.meta.phase)
if (phase?.state !== 'active') {
return { name: 'home' }
}
}
return true return true
}) })
+28 -13
View File
@@ -6,6 +6,7 @@ import type {
AdminSeasonDetailResponse, AdminSeasonDetailResponse,
ApproveNominationPayload, ApproveNominationPayload,
AdminSeasonListItem, AdminSeasonListItem,
CreateClipPayload,
CreateNominationPayload, CreateNominationPayload,
CreateVotePayload, CreateVotePayload,
OverviewResponse, OverviewResponse,
@@ -31,9 +32,9 @@ const fallbackOverview: OverviewResponse = {
{ key: 'show', title: 'Award Show', startsAt: '2026-07-20', endsAt: '2026-07-20', state: 'upcoming' }, { key: 'show', title: 'Award Show', startsAt: '2026-07-20', endsAt: '2026-07-20', state: 'upcoming' },
], ],
featuredCategories: [ featuredCategories: [
{ id: 1, groupName: 'Main Awards', name: 'VTuber des Jahres', description: 'Die groesste Auszeichnung des Jahres.', maxNomineesPerUser: 3 }, { id: 1, groupName: 'Main Awards', name: 'VTuber des Jahres', description: 'Die VTuberin oder der VTuber, der dieses Jahr einfach alle verzaubert hat.', maxNomineesPerUser: 3 },
{ id: 2, groupName: 'Performance', name: 'Bestes Live Event', description: 'Events, Konzerte und Showformate.', maxNomineesPerUser: 3 }, { id: 2, groupName: 'Performance', name: 'Bestes Live Event', description: 'Das Event, das die Community zum Beben gebracht hat Konzert, Watchalong oder Mega-Stream.', maxNomineesPerUser: 3 },
{ id: 3, groupName: 'Clips & Highlights', name: 'Clip des Jahres', description: 'Der lustigste oder emotionalste Clip.', maxNomineesPerUser: 3 }, { id: 3, groupName: 'Clips & Highlights', name: 'Clip des Jahres', description: 'Der eine Clip, den du seit Monaten in jeden Chat spammst.', maxNomineesPerUser: 3 },
], ],
winnersPreview: [ winnersPreview: [
{ year: 2025, category: 'VTuber des Jahres', winnerName: 'Hoshimi Miyu', winnerSlug: '@hoshimimiyu' }, { year: 2025, category: 'VTuber des Jahres', winnerName: 'Hoshimi Miyu', winnerSlug: '@hoshimimiyu' },
@@ -41,9 +42,10 @@ const fallbackOverview: OverviewResponse = {
{ year: 2024, category: 'Clip des Jahres', winnerName: 'Pyonkichi Kingdom', winnerSlug: '@pyonkichikingdom' }, { year: 2024, category: 'Clip des Jahres', winnerName: 'Pyonkichi Kingdom', winnerSlug: '@pyonkichikingdom' },
], ],
faq: [ faq: [
{ question: 'Wer kann nominieren und voten?', answer: 'Jede Person mit Twitch Login. Das Konto wird beim ersten Login implizit erstellt.' }, { question: 'Wer darf mitmachen?', answer: 'Jede:r mit einem Twitch-Account. Einmal einloggen genügt kein extra Konto, kein Papierkram.' },
{ question: 'Wie werden Gewinner bestimmt?', answer: 'Aktuell rein community-basiert. Eine Mischlogik kann spaeter aktiviert werden.' }, { question: 'Wie werden die Gewinner bestimmt?', answer: 'Komplett durch eure Stimmen. Die Community entscheidet, wer auf die Bühne darf kein Jury-Geheimnis.' },
{ question: 'Wer verwaltet Kategorien und Unterkategorien?', answer: 'Das Team pflegt diese pro Jahr im Admin-Bereich.' }, { question: 'Kann ich meine Wahl noch ändern?', answer: 'Klar! Bis zum Ende der Voting-Phase kannst du Nominierungen und Stimmen jederzeit anpassen.' },
{ question: 'Wer kuratiert die Kategorien?', answer: 'Das Jayuhime-Team stellt die Kategorien jedes Jahr frisch zusammen, passend zur Community.' },
], ],
} }
@@ -55,23 +57,23 @@ const fallbackCategories: SeasonCategoriesResponse = {
id: 1, id: 1,
name: 'VTuber des Jahres', name: 'VTuber des Jahres',
groupName: 'Main Awards', groupName: 'Main Awards',
description: 'Die Hauptkategorie fuer die praegendste Creator-Praesenz des Jahres.', description: 'Die Hauptkategorie r die prägendste Creator-Präsenz des ganzen Jahres.',
maxNomineesPerUser: 3, maxNomineesPerUser: 3,
candidates: [ candidates: [
{ id: 1, displayName: 'Hoshimi Miyu', channelSlug: '@hoshimimiyu', platform: 'Twitch' }, { id: 1, displayName: 'Hoshimi Miyu', channelSlug: '@hoshimimiyu', platform: 'Twitch', clipUrl: 'https://clips.twitch.tv/HoshimiHighlight' },
{ id: 2, displayName: 'Kurainu', channelSlug: '@kurainu', platform: 'Twitch' }, { id: 2, displayName: 'Kurainu', channelSlug: '@kurainu', platform: 'Twitch', clipUrl: 'https://www.youtube.com/watch?v=kurainu' },
{ id: 3, displayName: 'Shiro Ch.', channelSlug: '@shiroch', platform: 'Twitch' }, { id: 3, displayName: 'Shiro Ch.', channelSlug: '@shiroch', platform: 'Twitch', clipUrl: 'https://clips.twitch.tv/ShiroMoment' },
], ],
}, },
{ {
id: 2, id: 2,
name: 'Bestes Live Event', name: 'Bestes Live Event',
groupName: 'Performance', groupName: 'Performance',
description: 'Konzerte, Sonderformate und grosse Community-Shows.', description: 'Konzerte, Sonderformate und große Community-Shows, die in Erinnerung bleiben.',
maxNomineesPerUser: 3, maxNomineesPerUser: 3,
candidates: [ candidates: [
{ id: 4, displayName: 'Kurainu 3D Live', channelSlug: '@kurainu', platform: 'Twitch' }, { id: 4, displayName: 'Kurainu 3D Live', channelSlug: '@kurainu', platform: 'Twitch', clipUrl: 'https://www.youtube.com/watch?v=kurainu3d' },
{ id: 5, displayName: 'Aoi Sakura Showcase', channelSlug: '@aoisakura', platform: 'YouTube' }, { id: 5, displayName: 'Aoi Sakura Showcase', channelSlug: '@aoisakura', platform: 'YouTube', clipUrl: 'https://www.youtube.com/watch?v=aoisakura' },
], ],
}, },
], ],
@@ -277,6 +279,9 @@ export const useAwardsStore = defineStore('awards', {
submitVote(payload: CreateVotePayload) { submitVote(payload: CreateVotePayload) {
return api.submitVote(payload) return api.submitVote(payload)
}, },
submitClip(payload: CreateClipPayload) {
return api.submitClip(payload)
},
async updateAdminSeason(seasonId: number, payload: UpdateSeasonPayload) { async updateAdminSeason(seasonId: number, payload: UpdateSeasonPayload) {
const result = await api.updateAdminSeason(seasonId, payload) const result = await api.updateAdminSeason(seasonId, payload)
await this.loadAdmin() await this.loadAdmin()
@@ -302,6 +307,16 @@ export const useAwardsStore = defineStore('awards', {
await this.loadAdminSeasonDetail(seasonId) await this.loadAdminSeasonDetail(seasonId)
return result return result
}, },
async deleteAdminCandidate(candidateId: number, seasonId: number) {
const result = await api.deleteAdminCandidate(candidateId)
await this.loadAdminSeasonDetail(seasonId)
return result
},
async deleteAdminCategory(categoryId: number, seasonId: number) {
const result = await api.deleteAdminCategory(categoryId)
await this.loadAdminSeasonDetail(seasonId)
return result
},
async approveAdminNomination(nominationId: number, seasonId: number, payload: ApproveNominationPayload) { async approveAdminNomination(nominationId: number, seasonId: number, payload: ApproveNominationPayload) {
const result = await api.approveAdminNomination(nominationId, payload) const result = await api.approveAdminNomination(nominationId, payload)
await this.loadAdminSeasonDetail(seasonId) await this.loadAdminSeasonDetail(seasonId)
+11
View File
@@ -45,6 +45,8 @@ export interface CandidateSummary {
displayName: string displayName: string
channelSlug: string channelSlug: string
platform: string platform: string
/** Repräsentativer Clip/Video-Link, damit Votende vor der Wahl reinschauen können. */
clipUrl?: string
} }
export interface PublicCategoryDetail { export interface PublicCategoryDetail {
@@ -175,6 +177,15 @@ export interface CreateNominationPayload {
nominees: string[] nominees: string[]
} }
export interface CreateClipPayload {
year: number
categoryId: number | null
twitchUserId: string
clipUrl: string
title: string
creator: string
}
export interface VoteEntryPayload { export interface VoteEntryPayload {
categoryId: number categoryId: number
candidateId: number candidateId: number
+169
View File
@@ -0,0 +1,169 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import Select from 'primevue/select'
import { Film, Link as LinkIcon, Sparkles, Star } from '@lucide/vue'
import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue'
import PageHero from '../components/ui/PageHero.vue'
import { useAwardsStore } from '../stores/awards'
import { useAuthStore } from '../stores/auth'
const store = useAwardsStore()
const authStore = useAuthStore()
const selectedCategoryId = ref<number | null>(null)
const clipUrl = ref('')
const title = ref('')
const creator = ref('')
const submitting = ref(false)
const submitMessage = ref('')
const submitError = ref('')
onMounted(async () => {
await store.loadHomeData()
const clipCategory = store.categories.categories.find((c) => /clip/i.test(c.name))
selectedCategoryId.value = clipCategory?.id ?? store.categories.categories[0]?.id ?? null
})
const categoryOptions = computed(() =>
store.categories.categories.map((category) => ({ label: category.name, value: category.id })),
)
const platform = computed(() => {
const url = clipUrl.value.trim().toLowerCase()
if (!url) return null
if (url.includes('twitch.tv') || url.includes('clips.twitch.tv')) return 'Twitch'
if (url.includes('youtube.com') || url.includes('youtu.be')) return 'YouTube'
return 'unknown'
})
const urlValid = computed(() => platform.value === 'Twitch' || platform.value === 'YouTube')
async function submitClip() {
if (!urlValid.value) {
submitError.value = 'Bitte gib einen gültigen Twitch- oder YouTube-Link an.'
return
}
submitting.value = true
submitMessage.value = ''
submitError.value = ''
try {
await store.submitClip({
year: store.categories.year,
categoryId: selectedCategoryId.value,
twitchUserId: authStore.session?.twitchUserId ?? '',
clipUrl: clipUrl.value.trim(),
title: title.value.trim(),
creator: creator.value.trim(),
})
submitMessage.value = 'Clip eingereicht! Das Team schaut ihn sich an. Danke fürs Teilen. 💜'
clipUrl.value = ''
title.value = ''
creator.value = ''
} catch (error) {
submitError.value = error instanceof Error ? error.message : 'Ups das hat nicht geklappt. Versuch es gleich nochmal.'
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="space-y-12 pb-16">
<PageHero
eyebrow="Clip des Jahres"
title="Reich deinen Clip ein"
description="Der eine Moment, den die Community sehen muss? Teil den Link zu deinem Lieblings-Clip wir kümmern uns um den Rest."
:icon="Film"
/>
<Card class="overflow-hidden p-0">
<!-- Stepper -->
<div class="flex items-center gap-4 border-b border-violet-100 px-7 py-5 text-xs font-semibold text-slate-400 sm:px-9">
<span class="text-violet-600">1 · Link einfügen</span>
<span class="h-px flex-1 bg-slate-200" />
<span class="text-violet-600">2 · Details</span>
<span class="h-px flex-1 bg-slate-200" />
<span>3 · Einreichen</span>
</div>
<div class="grid gap-8 p-7 sm:p-9 lg:grid-cols-[1.1fr_0.9fr]">
<!-- Left: form -->
<div class="space-y-5">
<p v-if="!authStore.isLoggedIn" class="rounded-2xl border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-800">
Logg dich kurz oben mit Twitch ein dann zählt deine Einreichung. 💜
</p>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Clip-Link (Twitch oder YouTube)</label>
<div class="flex items-center gap-2 rounded-2xl border bg-white px-4 py-3 transition"
:class="clipUrl && !urlValid ? 'border-rose-300' : 'border-violet-200'">
<LinkIcon class="h-4 w-4 shrink-0 text-violet-400" />
<input
v-model="clipUrl"
type="url"
class="w-full bg-transparent text-sm outline-none"
placeholder="https://clips.twitch.tv/… oder https://youtu.be/…"
/>
<span v-if="urlValid" class="shrink-0 rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-semibold text-emerald-600">{{ platform }}</span>
</div>
<p v-if="clipUrl && !urlValid" class="text-xs text-rose-500">Das sieht nicht nach einem Twitch- oder YouTube-Link aus.</p>
</div>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Kategorie</label>
<Select
v-model="selectedCategoryId"
:options="categoryOptions"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Titel (optional)</label>
<input v-model="title" type="text" class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm" placeholder="Worum geht's im Clip?" />
</div>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">VTuber im Clip (optional)</label>
<input v-model="creator" type="text" class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm" placeholder="Name oder @handle" />
</div>
</div>
<p v-if="submitMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-700">{{ submitMessage }}</p>
<p v-if="submitError" class="rounded-2xl border border-rose-200 bg-rose-50 px-5 py-4 text-sm text-rose-700">{{ submitError }}</p>
<Button class="w-full gap-2" :disabled="submitting || !authStore.isLoggedIn || !urlValid" @click="submitClip">
<Film class="h-4 w-4" />
{{ submitting ? 'Reicht ein ...' : 'Clip einreichen' }}
</Button>
</div>
<!-- Right: preview / rules -->
<div class="space-y-4">
<div class="relative overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#ece2ff,#f6ecff_55%,#fff2dd)] p-6">
<div class="absolute right-3 top-3 h-1 w-24 rounded-full bg-[linear-gradient(90deg,#c4b5fd,#f5d0fe,#fecdd3,#fde68a,#bbf7d0,#bae6fd)]" />
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">Vorschau</p>
<div class="mt-3 grid aspect-video place-items-center rounded-xl bg-white/60">
<Film class="h-10 w-10 text-violet-400" />
</div>
<p class="mt-3 truncate text-sm font-semibold text-violet-800">{{ title || 'Dein Clip-Titel' }}</p>
<p class="truncate text-xs text-slate-500">{{ creator || 'VTuber im Clip' }} · {{ platform && urlValid ? platform : 'Link einfügen' }}</p>
</div>
<div class="rounded-2xl bg-violet-50/50 px-5 py-4 text-xs leading-6 text-slate-500">
<p class="flex items-center gap-2"><Star class="h-3.5 w-3.5 text-amber-400" /> Twitch-Clips oder YouTube-Links werden akzeptiert.</p>
<p class="flex items-center gap-2"><Star class="h-3.5 w-3.5 text-amber-400" /> Mehrere Clips? Einfach nacheinander einreichen.</p>
<p class="flex items-center gap-2"><Sparkles class="h-3.5 w-3.5 text-amber-400" /> Das Team prüft jede Einreichung vor der Voting-Phase.</p>
</div>
</div>
</div>
</Card>
</div>
</template>
+549 -248
View File
@@ -1,303 +1,477 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink, useRouter } from 'vue-router'
import Accordion from 'primevue/accordion' import Accordion from 'primevue/accordion'
import AccordionContent from 'primevue/accordioncontent' import AccordionContent from 'primevue/accordioncontent'
import AccordionHeader from 'primevue/accordionheader' import AccordionHeader from 'primevue/accordionheader'
import AccordionPanel from 'primevue/accordionpanel' import AccordionPanel from 'primevue/accordionpanel'
import Tag from 'primevue/tag' import {
import { ArrowRight, Sparkles, Star, Trophy, WandSparkles } from '@lucide/vue' ArrowRight,
Award,
BarChart3,
ChevronLeft,
ChevronRight,
Clapperboard,
Clock,
Crown,
Film,
Globe,
Heart,
Link as LinkIcon,
Megaphone,
Mic,
PartyPopper,
ShieldCheck,
Sparkles,
Star,
Trophy,
Users,
Video,
Vote,
} from '@lucide/vue'
import Button from '../components/ui/Button.vue' import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue' import Card from '../components/ui/Card.vue'
import { useAwardsStore } from '../stores/awards' import { useAwardsStore } from '../stores/awards'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import hostVisual from '../assets/collector-editorial-reference.png' import { usePhases, type ActionKey, type PhaseState } from '../composables/usePhases'
import heroKeyvisual from '../assets/hero-keyvisual.svg'
const store = useAwardsStore() const store = useAwardsStore()
const authStore = useAuthStore() const authStore = useAuthStore()
const router = useRouter()
const { phaseInfo } = usePhases()
/* ----- Phasen-Zustand je Aktion (Quelle: Season-Timeline) ----- */
const phaseByAction = computed(() => ({
nominate: phaseInfo('nominate'),
vote: phaseInfo('vote'),
clip: phaseInfo('clip'),
}))
function phaseBadge(state: PhaseState) {
if (state === 'active') return { label: 'Jetzt offen', class: 'bg-emerald-50 text-emerald-600' }
if (state === 'upcoming') return { label: 'Demnächst', class: 'bg-amber-50 text-amber-600' }
return { label: 'Abgeschlossen', class: 'bg-slate-100 text-slate-500' }
}
function ctaLabel(key: ActionKey, openLabel: string) {
const info = phaseByAction.value[key]
if (info.open) return openLabel
if (info.state === 'upcoming') return `Ab ${info.startLabel}`
return 'Abgeschlossen'
}
onMounted(() => { onMounted(() => {
void store.loadHomeData() void store.loadHomeData()
}) })
const heroYear = computed(() => store.overview.year) const heroYear = computed(() => store.overview.year)
const isLoggedIn = computed(() => authStore.isLoggedIn)
/* ----- Login-Gate: ohne Twitch-Login kein Nominieren/Voten ----- */
async function startLogin() {
try {
await authStore.login({ twitchUserId: 'jayuhime_demo', displayName: 'Jayuhime', role: 'viewer' })
} catch {
/* Fehler werden im Header-Login-Bereich angezeigt */
}
}
function goTo(path: string) {
if (isLoggedIn.value) {
void router.push(path)
} else {
void startLogin()
}
}
/* ----- Live Countdown bis zum Ende der aktiven Phase ----- */
const now = ref(Date.now())
let timer: ReturnType<typeof setInterval> | undefined
onMounted(() => {
timer = setInterval(() => {
now.value = Date.now()
}, 1000)
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
const targetDate = computed(() => {
const active = store.overview.timeline.find((item) => item.state === 'active')
const raw = active?.endsAt ?? store.overview.showDate
const parsed = new Date(`${raw}T23:59:59`).getTime()
return Number.isNaN(parsed) ? now.value : parsed
})
const countdown = computed(() => {
const diff = Math.max(0, targetDate.value - now.value)
const totalSeconds = Math.floor(diff / 1000)
const days = Math.floor(totalSeconds / 86400)
const hours = Math.floor((totalSeconds % 86400) / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
const pad = (value: number) => value.toString().padStart(2, '0')
return [
{ label: 'Tage', value: pad(days) },
{ label: 'Std', value: pad(hours) },
{ label: 'Min', value: pad(minutes) },
{ label: 'Sek', value: pad(seconds) },
]
})
/* ----- Statische Showcase-Daten ----- */
const trustItems = [
{ icon: Sparkles, title: '100% Community Powered', text: 'Deine Stimme entscheidet!' },
{ icon: ShieldCheck, title: 'Fair & Transparent', text: 'Verifiziert. Sicher. Vertrauenswürdig.' },
{ icon: Video, title: 'Live auf YouTube & Twitch', text: 'Die große Award Show.' },
{ icon: Globe, title: 'Global Celebration', text: 'Alle VTuber. Alle Fans.' },
]
const steps = [
{
icon: Sparkles,
title: 'Nominieren',
text: 'Trag deine Lieblings-Creator ein bis zu 3 Favoriten pro Kategorie.',
accent: 'from-violet-200 to-fuchsia-100 text-violet-600',
},
{
icon: Heart,
title: 'Voten',
text: 'Die Community stimmt ab. Eine Stimme pro Kategorie, mit ganz viel Herz.',
accent: 'from-rose-200 to-amber-100 text-rose-500',
},
{
icon: Trophy,
title: 'Feiern',
text: 'Die Stimmen werden gezählt und die Gewinner live in der Show gekürt!',
accent: 'from-amber-200 to-emerald-100 text-amber-600',
},
]
const categories = [
{ icon: Star, name: 'VTuber des Jahres', text: 'Die Auszeichnung des Abends für die, die alle verzaubert hat.', accent: 'bg-violet-100 text-violet-600' },
{ icon: Mic, name: 'Bestes Live Event', text: 'Konzerte, Watchalongs und Shows, die niemand vergisst.', accent: 'bg-fuchsia-100 text-fuchsia-600' },
{ icon: Video, name: 'Streamer:in des Jahres', text: 'Für die Streams, in denen man einfach hängen bleibt.', accent: 'bg-sky-100 text-sky-600' },
{ icon: Heart, name: 'Beste Collab', text: 'Wenn zwei Welten kollidieren und Magie entsteht.', accent: 'bg-rose-100 text-rose-500' },
{ icon: Clapperboard, name: 'Best Content Creator', text: 'Für kreative Köpfe, die immer wieder überraschen.', accent: 'bg-amber-100 text-amber-600' },
{ icon: Film, name: 'Clip des Jahres', text: 'Der eine Clip, den du in jeden Chat spammst.', accent: 'bg-emerald-100 text-emerald-600' },
]
const winners = [
{ name: 'Hoshimi Miyu', slug: '@HoshimiMiyu', category: 'VTuber des Jahres', year: 2025, current: false },
{ name: 'Kurainu 3D Live', slug: '@Kurainu', category: 'Best Live Event', year: 2025, current: false },
{ name: 'Shiro Ch.', slug: '@ShiroCh', category: 'Streamer:in des Jahres', year: 2025, current: true },
{ name: 'Luna & Yuki', slug: '@LunaYuki', category: 'Beste Collab', year: 2025, current: false },
{ name: 'Miyu Ch.', slug: '@MiyuCh', category: 'Best Content Creator', year: 2025, current: false },
{ name: 'Pyonkichi Kingdom', slug: '@PyonkichiKingdom', category: 'Clip des Jahres', year: 2024, current: false },
]
const winnerGradients = [
'from-violet-200 to-fuchsia-100',
'from-indigo-200 to-violet-100',
'from-sky-200 to-violet-100',
'from-rose-200 to-amber-100',
'from-violet-200 to-amber-100',
'from-fuchsia-200 to-rose-100',
]
const participation: Array<{
key: ActionKey
icon: typeof Sparkles
title: string
text: string
to: string
cta: string
accent: string
}> = [
{ key: 'nominate', icon: Sparkles, title: 'Nominieren', text: 'Trag bis zu 3 Favoriten pro Kategorie ein und bring sie auf die Bühne.', to: '/nominations', cta: 'Jetzt nominieren', accent: 'from-violet-200 to-fuchsia-100 text-violet-600' },
{ key: 'vote', icon: Heart, title: 'Voten', text: 'Eine Stimme pro Kategorie entscheide mit, wer am Ende gewinnt.', to: '/voting', cta: 'Jetzt voten', accent: 'from-rose-200 to-amber-100 text-rose-500' },
{ key: 'clip', icon: Film, title: 'Clip einreichen', text: 'Teile den Link zu deinem Lieblings-Clip fürs „Clip des Jahres".', to: '/clips', cta: 'Clip einreichen', accent: 'from-sky-200 to-violet-100 text-sky-600' },
]
const adminMetrics = [
{ label: 'Nominierungen', value: '12.341', delta: '+12,4% vs. gestern' },
{ label: 'Votes', value: '582.731', delta: '+8,7% vs. gestern' },
{ label: 'Nutzer', value: '98.452', delta: '+9,2% vs. gestern' },
{ label: 'Kategorien', value: '28', delta: 'aktiv' },
{ label: 'Reviews offen', value: '47', delta: '+14 neu' },
]
const adminTopVotes = [
{ name: 'VTuber des Jahres', votes: 186321, pct: 100 },
{ name: 'Best Streamer', votes: 142553, pct: 77 },
{ name: 'Beste Collab', votes: 112968, pct: 61 },
{ name: 'Best Content Creator', votes: 78442, pct: 42 },
{ name: 'Best Live Event', votes: 62517, pct: 34 },
]
/* ----- Gewinner-Archiv Carousel ----- */
const carousel = ref<HTMLElement | null>(null)
function scrollCarousel(direction: number) {
carousel.value?.scrollBy({ left: direction * 320, behavior: 'smooth' })
}
const winnersScopeYear = computed(() => heroYear.value - 1)
</script> </script>
<template> <template>
<div class="space-y-20 pb-16"> <div class="space-y-24 pb-20">
<section class="grid gap-10 lg:grid-cols-[0.82fr_1.18fr] lg:items-start"> <!-- ============ HERO ============ -->
<div class="space-y-10 pt-3"> <section class="grid items-stretch gap-8 lg:grid-cols-[1.02fr_1.18fr]">
<div class="space-y-6"> <div class="flex flex-col justify-center space-y-8">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Die groesste Community-Auszeichnung</p> <p class="text-xs font-semibold uppercase tracking-[0.4em] text-amber-500">
<h1 class="max-w-[8ch] font-[Cormorant_Garamond] text-6xl leading-[0.88] text-violet-800 sm:text-7xl xl:text-[6.6rem]"> Die große Community-Auszeichnung
VTuber Star Awards </p>
<div class="space-y-3">
<h1 class="font-[Cormorant_Garamond] text-6xl leading-[0.86] text-violet-800 sm:text-7xl xl:text-[6.4rem]">
VTUBER<br />STAR AWARDS
</h1> </h1>
<p class="text-2xl font-medium italic tracking-wide text-violet-500"> <div class="h-1.5 w-48 rounded-full bg-[linear-gradient(90deg,#c4b5fd,#f5d0fe,#fecdd3,#fde68a,#bbf7d0,#bae6fd)]" />
Presented by Jayuhime <p class="font-[Cormorant_Garamond] text-3xl italic text-violet-500">
</p> Presented by Jayuhime <Sparkles class="-mt-1 inline h-6 w-6 text-amber-400" />
<p class="max-w-lg text-lg leading-8 text-slate-600">
Feiere die talentiertesten VTuber, Creator und Showmomente des Jahres.
Kategorien und Unterkategorien werden vom Team pro Jahr gepflegt, die Gewinner sind aktuell rein community-basiert.
</p> </p>
</div> </div>
<p class="max-w-lg text-lg leading-8 text-slate-600">
VTuber sind Künstler:innen, Entertainer und Geschichtenerzähler:innen zugleich. Hier feiern wir genau diese
Kreativität nominiere deine Favoriten, vote für die Besten und sei dabei, wenn die Stars gekürt werden!
</p>
<div class="flex flex-wrap gap-3"> <!-- Aktuelle Phase Card -->
<RouterLink to="/nominations">
<Button size="lg">Jetzt nominieren</Button>
</RouterLink>
<RouterLink to="/voting">
<Button variant="secondary" size="lg">Jetzt voten</Button>
</RouterLink>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<Card class="min-h-[210px] p-7">
<div class="flex items-center gap-3 text-violet-600">
<Sparkles class="h-5 w-5 text-amber-500" />
<span class="text-xs font-semibold uppercase tracking-[0.25em]">Von der Community getragen</span>
</div>
<p class="mt-5 text-sm leading-7 text-slate-600">
Nur Twitch Login, keine Konto-Huerde, editierbare Stimmen und Nominierungen bis zur Deadline.
</p>
</Card>
<Card class="min-h-[210px] p-7">
<div class="flex items-center gap-3 text-violet-600">
<WandSparkles class="h-5 w-5 text-amber-500" />
<span class="text-xs font-semibold uppercase tracking-[0.25em]">Team verwaltet pro Jahr</span>
</div>
<p class="mt-5 text-sm leading-7 text-slate-600">
Kategorien und Unterkategorien werden im Admin-Bereich je Jahr gepflegt und freigeschaltet.
</p>
</Card>
</div>
<Card class="p-7"> <Card class="p-7">
<div class="flex flex-col gap-6 xl:flex-row xl:items-center xl:justify-between"> <div class="flex items-center justify-between">
<div> <p class="text-xs font-semibold uppercase tracking-[0.3em] text-violet-500">Aktuelle Phase</p>
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Aktuelle Phase</p> <span class="inline-flex items-center gap-2 rounded-full bg-rose-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-rose-500">
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800"> <span class="inline-flex h-2 w-2 animate-pulse rounded-full bg-rose-500" /> Live
{{ store.overview.currentPhase }} </span>
</h2> </div>
<p class="mt-2 max-w-md text-slate-600"> <h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ store.overview.currentPhase }}</h2>
Login bleibt leichtgewichtig: nur Twitch, kein separates Community-Konto. <p class="mt-2 text-sm text-slate-600">
</p> Die Nominierungsphase ist abgeschlossen. Jetzt liegt es an dir: Stimme für deine Favoriten!
</div> </p>
<div class="rounded-[26px] border border-violet-100 bg-violet-50/70 px-5 py-5 text-sm text-slate-700"> <div class="mt-6 grid grid-cols-4 gap-3">
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Sitzungsstatus</p> <div
<p class="mt-2 font-semibold text-violet-800"> v-for="unit in countdown"
{{ authStore.isLoggedIn ? `${authStore.session?.displayName} · ${authStore.session?.role}` : 'Noch nicht eingeloggt' }} :key="unit.label"
</p> class="rounded-2xl border border-violet-100 bg-violet-50/70 py-4 text-center"
<p class="mt-2 leading-7 text-slate-600"> >
{{ authStore.isLoggedIn ? 'Nominierung und Voting sind jetzt direkt freigeschaltet.' : 'Bitte oben im Kopfbereich einloggen, um Nominierung, Voting oder Admin zu nutzen.' }} <strong class="block font-[Cormorant_Garamond] text-3xl text-violet-800">{{ unit.value }}</strong>
</p> <span class="text-[10px] uppercase tracking-[0.2em] text-slate-500">{{ unit.label }}</span>
</div> </div>
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div class="mt-6 flex flex-wrap gap-3">
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center"> <Button
<strong class="block text-2xl text-violet-800">41</strong> size="lg"
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Tage</span> class="gap-2"
</div> :disabled="!phaseByAction.nominate.open"
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center"> @click="goTo('/nominations')"
<strong class="block text-2xl text-violet-800">08</strong> >
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Std</span> <Sparkles class="h-4 w-4" /> {{ ctaLabel('nominate', 'Jetzt nominieren') }}
</div> </Button>
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center"> <Button
<strong class="block text-2xl text-violet-800">24</strong> variant="secondary"
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Min</span> size="lg"
</div> class="gap-2"
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center"> :disabled="!phaseByAction.vote.open"
<strong class="block text-2xl text-violet-800">16</strong> @click="goTo('/voting')"
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Sek</span> >
</div> <Star class="h-4 w-4" /> {{ ctaLabel('vote', 'Jetzt voten') }}
</div> </Button>
</div> </div>
</Card> </Card>
</div> </div>
<Card class="overflow-hidden p-0"> <!-- Hero Keyvisual -->
<div class="relative min-h-[760px] bg-[radial-gradient(circle_at_top,_rgba(255,255,255,0.82),transparent_26%),linear-gradient(160deg,rgba(224,214,255,0.72),rgba(255,240,217,0.68))]"> <Card class="relative overflow-hidden p-0">
<div class="absolute inset-0 bg-[radial-gradient(circle_at_75%_15%,rgba(255,255,255,0.82),transparent_25%)]" /> <div class="relative min-h-[640px]">
<div class="absolute left-10 top-10 rounded-full border border-white/60 bg-white/60 px-4 py-1 text-xs uppercase tracking-[0.3em] text-violet-600"> <img :src="heroKeyvisual" alt="VTuber Star Awards Keyvisual (Platzhalter)" class="absolute inset-0 h-full w-full object-cover object-top" />
Presented by Jayuhime <div class="absolute inset-0 bg-[radial-gradient(circle_at_70%_18%,rgba(255,255,255,0.55),transparent_45%)]" />
</div>
<img
:src="hostVisual"
alt="Jayuhime Host Keyvisual"
class="absolute inset-0 h-full w-full object-cover object-center"
/>
<div class="absolute inset-y-0 right-0 flex w-full max-w-[340px] flex-col justify-between border-l border-white/40 bg-[linear-gradient(180deg,rgba(255,255,255,0.14),rgba(255,255,255,0.38))] p-7 backdrop-blur md:p-8">
<div class="space-y-8">
<div class="rounded-[28px] border border-white/50 bg-white/30 p-5">
<div class="flex items-center gap-3 text-violet-700">
<Star class="h-5 w-5 text-amber-500" />
<span class="text-sm font-semibold uppercase tracking-[0.2em]">Jayuhime · Host of the Show</span>
</div>
<p class="mt-4 text-sm leading-7 text-slate-700">
Editoriales Hero-Panel mit klarer Host-Praesenz, aber mehr White Space und weniger competing elements.
</p>
</div>
<Tag value="Collector Editorial" severity="warn" class="self-start" /> <!-- Host Badge -->
</div> <div class="absolute bottom-7 right-7 w-[230px] rounded-[24px] border border-white/70 bg-white/80 p-5 shadow-lg backdrop-blur">
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-slate-400">Host</p>
<div class="grid gap-3"> <p class="mt-1 flex items-center gap-2 font-[Cormorant_Garamond] text-3xl text-violet-800">
<div class="rounded-[26px] border border-white/70 bg-white/72 px-5 py-5"> Jayuhime <Sparkles class="h-5 w-5 text-amber-400" />
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Show Date</p> </p>
<p class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">24. Jan 2026</p> <p class="text-sm text-slate-500">VTuber &amp; Award Host</p>
</div> <div class="mt-3 flex items-center gap-2 text-violet-500">
<div class="rounded-[26px] border border-white/70 bg-white/72 px-5 py-5"> <span class="grid h-8 w-8 place-items-center rounded-full bg-violet-50">
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Winner Model</p> <Video class="h-4 w-4" />
<p class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">Nur Community</p> </span>
</div> <span class="grid h-8 w-8 place-items-center rounded-full bg-violet-50">
<div class="rounded-[26px] border border-white/70 bg-white/72 px-5 py-5"> <PartyPopper class="h-4 w-4" />
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Login</p> </span>
<p class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">Twitch</p> <span class="grid h-8 w-8 place-items-center rounded-full bg-violet-50">
</div> <svg class="h-3.5 w-3.5" viewBox="0 0 19 19"><use href="/icons.svg#x-icon" /></svg>
</span>
</div> </div>
</div> </div>
</div> </div>
</Card> </Card>
</section> </section>
<section class="grid gap-5 lg:grid-cols-4"> <!-- ============ TRUST BAR ============ -->
<Card <section class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
v-for="item in store.overview.timeline" <Card v-for="item in trustItems" :key="item.title" class="flex items-center gap-4 p-5">
:key="item.key" <span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[linear-gradient(135deg,#efe6ff,#fff1da)] text-violet-600">
class="p-7" <component :is="item.icon" class="h-5 w-5" />
>
<Tag :value="item.state === 'active' ? 'live' : item.state" severity="secondary" class="mb-4" />
<h3 class="font-[Cormorant_Garamond] text-3xl text-violet-800">{{ item.title }}</h3>
<p class="mt-2 text-sm text-slate-600">{{ item.startsAt }} - {{ item.endsAt }}</p>
</Card>
</section>
<section class="grid gap-5 lg:grid-cols-3">
<Card class="min-h-[260px] p-8">
<div class="flex items-center gap-3">
<Trophy class="h-5 w-5 text-amber-500" />
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-violet-500">How it works</p>
</div>
<h2 class="mt-4 font-[Cormorant_Garamond] text-4xl text-violet-800">Nominate, vote, celebrate.</h2>
<p class="mt-3 text-slate-600">
Die Plattform trennt bewusst zwischen showhafter Startseite und ruhigen Produktflows. So bleibt der Einstieg emotional, waehrend die Interaktion klar bleibt.
</p>
</Card>
<Card class="min-h-[260px] p-8">
<div class="flex items-center gap-3">
<Sparkles class="h-5 w-5 text-amber-500" />
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-violet-500">Rules</p>
</div>
<h2 class="mt-4 font-[Cormorant_Garamond] text-4xl text-violet-800">Moderater Abuse-Schutz</h2>
<p class="mt-3 text-slate-600">
Rate limits, serverseitige Pruefung und Risikoflags laufen im Hintergrund. Die User-Huerde bleibt niedrig, der operative Blick landet im Admin.
</p>
</Card>
<Card class="min-h-[260px] p-8">
<div class="flex items-center gap-3">
<WandSparkles class="h-5 w-5 text-amber-500" />
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-violet-500">Admin</p>
</div>
<h2 class="mt-4 font-[Cormorant_Garamond] text-4xl text-violet-800">Jahresbasiertes Management</h2>
<p class="mt-3 text-slate-600">
Jahre, Kategorien, Unterkategorien, Gewinnerarchiv und Reviews werden als kuratierte Award-Jahre gedacht, nicht als harte statische App-Texte.
</p>
</Card>
</section>
<section class="space-y-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Featured Kategorien</p>
<h2 class="font-[Cormorant_Garamond] text-5xl text-violet-800">Team-gesteuerte Awards fuer {{ heroYear }}</h2>
</div>
<span class="text-sm text-slate-500">
API-Modus:
<strong class="text-violet-700">{{ store.apiMode }}</strong>
</span> </span>
<div>
<p class="text-sm font-semibold uppercase tracking-[0.15em] text-violet-700">{{ item.title }}</p>
<p class="text-sm text-slate-500">{{ item.text }}</p>
</div>
</Card>
</section>
<!-- ============ SO FUNKTIONIERT'S ============ -->
<section class="space-y-10">
<div class="text-center">
<p class="text-xs font-semibold uppercase tracking-[0.4em] text-amber-500">So funktioniert's</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">In drei Schritten zur großen Bühne</h2>
</div>
<div class="grid gap-6 md:grid-cols-3">
<div v-for="(step, index) in steps" :key="step.title" class="relative text-center">
<div :class="['mx-auto grid h-20 w-20 place-items-center rounded-3xl bg-gradient-to-br shadow-[0_18px_40px_rgba(124,92,255,0.18)]', step.accent]">
<component :is="step.icon" class="h-8 w-8" />
</div>
<span class="mt-5 inline-flex h-8 w-8 items-center justify-center rounded-full border border-violet-200 bg-white text-sm font-semibold text-violet-600">
{{ index + 1 }}
</span>
<h3 class="mt-3 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ step.title }}</h3>
<p class="mx-auto mt-2 max-w-xs text-sm leading-7 text-slate-600">{{ step.text }}</p>
</div>
</div>
</section>
<!-- ============ BELIEBTE KATEGORIEN ============ -->
<section class="space-y-8">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Beliebte Kategorien</h2>
<RouterLink to="/voting" class="inline-flex items-center gap-2 text-sm font-semibold text-violet-600 hover:text-violet-800">
Alle Kategorien ansehen <ArrowRight class="h-4 w-4" />
</RouterLink>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<Card v-for="category in categories" :key="category.name" class="group p-6 transition hover:-translate-y-1 hover:shadow-[0_30px_70px_rgba(124,92,255,0.18)]">
<span :class="['grid h-14 w-14 place-items-center rounded-2xl', category.accent]">
<component :is="category.icon" class="h-6 w-6" />
</span>
<h3 class="mt-5 text-base font-semibold text-violet-800">{{ category.name }}</h3>
<p class="mt-2 text-xs leading-6 text-slate-500">{{ category.text }}</p>
<RouterLink to="/voting" class="mt-4 inline-flex items-center gap-1 text-xs font-semibold text-amber-500 group-hover:gap-2">
Top 10 ansehen <ArrowRight class="h-3.5 w-3.5" />
</RouterLink>
</Card>
</div>
</section>
<!-- ============ GEWINNER ARCHIV ============ -->
<section class="space-y-8">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div class="flex items-center gap-3">
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Gewinner Archiv</h2>
</div>
<div class="flex items-center gap-3">
<RouterLink to="/winners" class="inline-flex items-center gap-2 text-sm font-semibold text-violet-600 hover:text-violet-800">
Alle Jahre ansehen <ArrowRight class="h-4 w-4" />
</RouterLink>
<div class="flex gap-2">
<button class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 bg-white text-violet-600 transition hover:bg-violet-50" @click="scrollCarousel(-1)">
<ChevronLeft class="h-4 w-4" />
</button>
<button class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 bg-white text-violet-600 transition hover:bg-violet-50" @click="scrollCarousel(1)">
<ChevronRight class="h-4 w-4" />
</button>
</div>
</div>
</div> </div>
<div class="grid gap-4 lg:grid-cols-3"> <div ref="carousel" class="flex gap-4 overflow-x-auto pb-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<Card <Card
v-for="category in store.overview.featuredCategories" v-for="(winner, index) in winners"
:key="category.id" :key="winner.slug"
class="p-6" class="w-[230px] shrink-0 overflow-hidden p-0"
> >
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ category.groupName }}</p> <div :class="['relative h-[230px] bg-gradient-to-br', winnerGradients[index % winnerGradients.length]]">
<h3 class="mt-3 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ category.name }}</h3> <div class="absolute inset-0 grid place-items-center text-white/70">
<p class="mt-3 text-slate-600">{{ category.description }}</p> <Star class="h-12 w-12" />
<div class="mt-6 flex items-center justify-between text-sm text-slate-500"> </div>
<span>Max. {{ category.maxNomineesPerUser }} Nominierungen</span> <span
<ArrowRight class="h-4 w-4 text-amber-500" /> v-if="winner.current"
class="absolute left-3 top-3 rounded-full bg-violet-600 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.2em] text-white"
>
{{ winner.year }}
</span>
<span
v-else
class="absolute left-3 top-3 inline-flex items-center gap-1 rounded-full bg-[linear-gradient(135deg,#ffd97a,#f6b938)] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.2em] text-amber-950"
>
<Crown class="h-3 w-3" /> Winner
</span>
</div>
<div class="p-4">
<p class="text-[10px] uppercase tracking-[0.2em] text-violet-400">{{ winner.category }}</p>
<p class="mt-1 font-[Cormorant_Garamond] text-2xl text-violet-800">{{ winner.name }}</p>
<p class="text-sm text-slate-500">{{ winner.slug }}</p>
</div>
</Card>
</div>
<p class="text-xs text-slate-400">Archiv-Stand: Gewinner aus {{ winnersScopeYear }} · Bilder sind Platzhalter.</p>
</section>
<!-- ============ MITMACHEN (Teaser-CTAs) ============ -->
<section class="space-y-8">
<div class="text-center">
<p class="text-xs font-semibold uppercase tracking-[0.4em] text-amber-500">Mach mit</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Werde Teil der Show</h2>
</div>
<div class="grid gap-5 md:grid-cols-3">
<Card
v-for="action in participation"
:key="action.to"
class="flex flex-col p-7"
:class="phaseByAction[action.key].open ? 'ring-1 ring-violet-200/70' : 'opacity-95'"
>
<div class="flex items-start justify-between">
<span :class="['grid h-14 w-14 place-items-center rounded-2xl bg-gradient-to-br', action.accent]">
<component :is="action.icon" class="h-6 w-6" />
</span>
<span :class="['rounded-full px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.15em]', phaseBadge(phaseByAction[action.key].state).class]">
{{ phaseBadge(phaseByAction[action.key].state).label }}
</span>
</div>
<h3 class="mt-5 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ action.title }}</h3>
<p class="mt-2 flex-1 text-sm leading-7 text-slate-600">{{ action.text }}</p>
<!-- Phase offen -->
<template v-if="phaseByAction[action.key].open">
<Button v-if="isLoggedIn" class="mt-6 w-full gap-2" @click="goTo(action.to)">
{{ action.cta }} <ArrowRight class="h-4 w-4" />
</Button>
<Button v-else variant="ghost" class="mt-6 w-full gap-2 border border-violet-200" @click="startLogin">
<svg class="h-4 w-4 fill-current" viewBox="0 0 16 16"><path d="M2.5 0 0 2.5v11h3.5V16h2l2.5-2.5h3l5-5V0H2.5Zm12 8L12 10.5H8.5L6 13v-2.5H2.5V2h12v6Z" /><path d="M11.5 4h1.5v3.5h-1.5V4Zm-3.5 0h1.5v3.5H8V4Z" /></svg>
Mit Twitch anmelden
</Button>
</template>
<!-- Phase noch nicht offen / abgeschlossen -->
<div v-else class="mt-6 flex items-center justify-center gap-2 rounded-xl bg-slate-50 px-4 py-3 text-sm font-semibold text-slate-400">
<Clock class="h-4 w-4" />
{{ ctaLabel(action.key, action.cta) }}
</div> </div>
</Card> </Card>
</div> </div>
</section> </section>
<section class="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]"> <!-- ============ FAQ / COMMUNITY / SHARE ============ -->
<section class="grid gap-6 lg:grid-cols-3">
<Card class="p-7"> <Card class="p-7">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Nominierung</p> <p class="text-xs font-semibold uppercase tracking-[0.3em] text-amber-500">FAQ Häufige Fragen</p>
<h2 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Bis zu drei Favoriten, direkt validiert</h2> <Accordion value="0" class="mt-5">
<ul class="mt-5 space-y-3 text-slate-600">
<li>Pro Kategorie keine doppelte Nominierung derselben Person.</li>
<li>Regeln werden direkt im Formular sichtbar gemacht.</li>
<li>Freitext-Ideen und Alias-Faelle gehen spaeter in die Review-Liste.</li>
</ul>
<div class="mt-6">
<RouterLink to="/nominations">
<Button>Zur Nominierungsansicht</Button>
</RouterLink>
</div>
</Card>
<Card class="p-7">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Voting</p>
<h2 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Ein Kandidat pro Kategorie, bis zur Deadline editierbar</h2>
<ul class="mt-5 space-y-3 text-slate-600">
<li>Nur eine Stimme pro Kategorie.</li>
<li>Videos, Clips und spaetere Detail-Previews koennen direkt im Flow eingebettet werden.</li>
<li>Die Ballot-Logik lebt im Backend ueber VoteBallot und VoteEntry.</li>
</ul>
<div class="mt-6">
<RouterLink to="/voting">
<Button variant="secondary">Zur Voting-Ansicht</Button>
</RouterLink>
</div>
</Card>
</section>
<section class="grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Gewinner Archiv</p>
<h2 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Vergangene Jahre sichtbar machen</h2>
<p class="mt-4 text-slate-600">
Gewinner, Nominierte und Banner werden pro Jahr archiviert. So bleibt die Show-Historie dauerhaft sichtbar und teilbar.
</p>
<div class="mt-6 space-y-3">
<div
v-for="entry in store.overview.winnersPreview"
:key="`${entry.year}-${entry.category}`"
class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3"
>
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-semibold text-slate-800">{{ entry.category }}</p>
<p class="text-sm text-slate-500">{{ entry.winnerName }} · {{ entry.winnerSlug }}</p>
</div>
<Tag :value="entry.year.toString()" severity="info" />
</div>
</div>
</div>
</Card>
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">FAQ</p>
<h2 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Regeln, Voting, Missbrauchsschutz</h2>
<Accordion value="0" class="mt-6">
<AccordionPanel v-for="(item, index) in store.overview.faq" :key="item.question" :value="String(index)"> <AccordionPanel v-for="(item, index) in store.overview.faq" :key="item.question" :value="String(index)">
<AccordionHeader>{{ item.question }}</AccordionHeader> <AccordionHeader>{{ item.question }}</AccordionHeader>
<AccordionContent> <AccordionContent>
@@ -305,6 +479,133 @@ const heroYear = computed(() => store.overview.year)
</AccordionContent> </AccordionContent>
</AccordionPanel> </AccordionPanel>
</Accordion> </Accordion>
<RouterLink to="/winners" class="mt-4 inline-flex items-center gap-2 text-sm font-semibold text-violet-600 hover:text-violet-800">
Alle Fragen ansehen <ArrowRight class="h-4 w-4" />
</RouterLink>
</Card>
<Card class="flex flex-col p-7">
<div class="flex items-center gap-2 text-violet-600">
<Megaphone class="h-5 w-5 text-amber-500" />
<p class="text-xs font-semibold uppercase tracking-[0.3em]">Community &amp; Updates</p>
</div>
<p class="mt-4 text-sm leading-7 text-slate-600">
Tritt unserer Community bei und verpasse keine News, Updates und Behind-the-Scenes!
</p>
<div class="mt-5 flex gap-3 text-violet-600">
<span class="grid h-10 w-10 place-items-center rounded-full bg-violet-50"><Video class="h-4 w-4" /></span>
<span class="grid h-10 w-10 place-items-center rounded-full bg-violet-50"><PartyPopper class="h-4 w-4" /></span>
<span class="grid h-10 w-10 place-items-center rounded-full bg-violet-50">
<svg class="h-4 w-4" viewBox="0 0 19 19"><use href="/icons.svg#x-icon" /></svg>
</span>
<span class="grid h-10 w-10 place-items-center rounded-full bg-violet-50">
<svg class="h-4 w-4" viewBox="0 0 20 19"><use href="/icons.svg#discord-icon" /></svg>
</span>
</div>
<button class="mt-auto flex w-full items-center justify-center gap-2 rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm font-semibold text-violet-700">
Newsletter abonnieren
</button>
</Card>
<Card class="flex flex-col p-7">
<p class="font-[Cormorant_Garamond] text-3xl text-violet-800">Teile die Awards</p>
<p class="mt-2 text-sm leading-7 text-slate-600">
Supporte deine Favoriten und teile die Awards mit deinen Freunden!
</p>
<div class="mt-5 space-y-3">
<button class="flex w-full items-center gap-3 rounded-2xl bg-slate-900 px-4 py-3 text-sm font-semibold text-white">
<svg class="h-4 w-4 fill-white" viewBox="0 0 19 19"><use href="/icons.svg#x-icon" /></svg> Auf X teilen
</button>
<button class="flex w-full items-center gap-3 rounded-2xl bg-violet-600 px-4 py-3 text-sm font-semibold text-white">
<svg class="h-4 w-4 fill-white" viewBox="0 0 20 19"><use href="/icons.svg#discord-icon" /></svg> Auf Discord teilen
</button>
<button class="flex w-full items-center gap-3 rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm font-semibold text-violet-700">
<LinkIcon class="h-4 w-4" /> Link kopieren
</button>
</div>
</Card>
</section>
<!-- ============ ADMIN DASHBOARD VORSCHAU ============ -->
<section>
<Card class="overflow-hidden p-0">
<div class="flex flex-col gap-2 border-b border-violet-100 px-7 py-5 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-2">
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-violet-500">Für das Team: Admin Dashboard (Vorschau)</p>
<ShieldCheck class="h-4 w-4 text-amber-500" />
</div>
<RouterLink to="/admin" class="inline-flex items-center gap-2 text-sm font-semibold text-violet-600 hover:text-violet-800">
Zum vollständigen Admin Panel <ArrowRight class="h-4 w-4" />
</RouterLink>
</div>
<div class="grid gap-0 lg:grid-cols-[200px_1fr]">
<!-- Sidebar -->
<aside class="hidden flex-col gap-1 border-r border-violet-100 bg-violet-50/40 p-5 text-sm text-slate-600 lg:flex">
<p class="mb-2 flex items-center gap-2 font-semibold text-violet-800"><Star class="h-4 w-4 text-amber-500" /> VSA Admin</p>
<span class="rounded-xl bg-violet-100 px-3 py-2 font-semibold text-violet-800">Dashboard</span>
<span class="px-3 py-2">Nominierungen</span>
<span class="px-3 py-2">Voting</span>
<span class="px-3 py-2">Kategorien</span>
<span class="px-3 py-2">Clips</span>
<span class="px-3 py-2">Reviews</span>
<span class="px-3 py-2">User &amp; Logs</span>
<span class="px-3 py-2">Analytics</span>
<span class="px-3 py-2">Einstellungen</span>
</aside>
<!-- Content -->
<div class="space-y-6 p-7">
<div class="grid gap-3 sm:grid-cols-3 xl:grid-cols-5">
<div v-for="metric in adminMetrics" :key="metric.label" class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[11px] uppercase tracking-[0.15em] text-slate-400">{{ metric.label }}</p>
<p class="mt-1 font-[Cormorant_Garamond] text-2xl text-violet-800">{{ metric.value }}</p>
<p class="text-[11px] font-semibold text-emerald-500">{{ metric.delta }}</p>
</div>
</div>
<div class="grid gap-5 lg:grid-cols-2">
<div class="rounded-2xl border border-violet-100 bg-white p-5">
<div class="flex items-center gap-2 text-violet-600">
<BarChart3 class="h-4 w-4 text-amber-500" />
<p class="text-xs font-semibold uppercase tracking-[0.2em]">Top Kategorien (nach Votes)</p>
</div>
<div class="mt-4 space-y-3">
<div v-for="item in adminTopVotes" :key="item.name">
<div class="flex items-center justify-between text-xs text-slate-600">
<span>{{ item.name }}</span>
<span class="font-semibold text-violet-700">{{ item.votes.toLocaleString('de-DE') }}</span>
</div>
<div class="mt-1 h-2 rounded-full bg-violet-50">
<div class="h-2 rounded-full bg-[linear-gradient(90deg,#a78bff,#7c5cff)]" :style="{ width: `${item.pct}%` }" />
</div>
</div>
</div>
</div>
<div class="rounded-2xl border border-violet-100 bg-white p-5">
<div class="flex items-center gap-2 text-violet-600">
<Vote class="h-4 w-4 text-amber-500" />
<p class="text-xs font-semibold uppercase tracking-[0.2em]">Letzte Aktivitäten</p>
</div>
<ul class="mt-4 space-y-3 text-sm text-slate-600">
<li class="flex items-center justify-between">
<span class="flex items-center gap-2"><Sparkles class="h-4 w-4 text-violet-400" /> Neue Nominierung in Best New VTuber</span>
<span class="text-xs text-slate-400">vor 2 Min.</span>
</li>
<li class="flex items-center justify-between">
<span class="flex items-center gap-2"><Users class="h-4 w-4 text-violet-400" /> 24 Votes in VTuber des Jahres</span>
<span class="text-xs text-slate-400">vor 5 Min.</span>
</li>
<li class="flex items-center justify-between">
<span class="flex items-center gap-2"><Award class="h-4 w-4 text-violet-400" /> Clip-Einreichung in Beste Collab</span>
<span class="text-xs text-slate-400">vor 18 Min.</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</Card> </Card>
</section> </section>
</div> </div>
+124 -69
View File
@@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import Select from 'primevue/select' import Select from 'primevue/select'
import { Plus, Sparkles, Star, Trophy, X } from '@lucide/vue'
import { useAwardsStore } from '../stores/awards' import { useAwardsStore } from '../stores/awards'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import Button from '../components/ui/Button.vue' import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue' import Card from '../components/ui/Card.vue'
import PageHero from '../components/ui/PageHero.vue'
const store = useAwardsStore() const store = useAwardsStore()
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -31,6 +33,12 @@ const selectedCategory = computed(() =>
store.categories.categories.find((category) => category.id === selectedCategoryId.value), store.categories.categories.find((category) => category.id === selectedCategoryId.value),
) )
const slotsLeft = computed(() => 3 - nominees.value.length)
function slugFor(name: string) {
return `@${name.toLowerCase().replace(/\s+/g, '')}`
}
function addNominee() { function addNominee() {
const value = nomineeName.value.trim() const value = nomineeName.value.trim()
if (!value || nominees.value.includes(value) || nominees.value.length >= 3) return if (!value || nominees.value.includes(value) || nominees.value.length >= 3) return
@@ -57,9 +65,9 @@ async function submitNomination() {
nominees: nominees.value, nominees: nominees.value,
}) })
submitMessage.value = `${response.saved} Nominierungen fuer ${response.category} gespeichert.` submitMessage.value = `Stark! ${response.saved} Nominierung(en) für ${response.category} sind eingetragen.`
} catch (error) { } catch (error) {
submitError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht gespeichert werden.' submitError.value = error instanceof Error ? error.message : 'Ups das hat nicht geklappt. Versuch es gleich nochmal.'
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -67,34 +75,33 @@ async function submitNomination() {
</script> </script>
<template> <template>
<div class="space-y-10 pb-14"> <div class="space-y-12 pb-16">
<div class="space-y-4"> <PageHero
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Nominierungs-Flow</p> eyebrow="Schritt 1 · Nominieren"
<h1 class="max-w-[12ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Kategorien waehlen, Regeln live pruefen</h1> title="Wen feiern wir dieses Jahr?"
<p class="max-w-3xl text-lg leading-8 text-slate-600"> :description="`Trag deine Lieblings-Creator ein und bring sie auf die große Bühne. Pro Kategorie hast du ${ 3 } Plätze nutz sie für die VTuber, die dein Jahr gemacht haben.`"
Nur Twitch Login, kein separates Konto. Das Team pflegt Kategorien pro Jahr, waehrend die UI sofort Limits, Dubletten und editierbare Entwuerfe abbildet. :icon="Star"
</p> />
</div>
<div class="grid gap-6 lg:grid-cols-[0.68fr_1.32fr]"> <Card class="overflow-hidden p-0">
<Card class="p-7"> <!-- Stepper -->
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Regeln</p> <div class="flex items-center gap-4 border-b border-violet-100 px-7 py-5 text-xs font-semibold text-slate-400 sm:px-9">
<ul class="mt-5 space-y-4 text-slate-600"> <span class="text-violet-600">1 · Kategorie</span>
<li>Pro Kategorie nur eine Nominierung derselben Person.</li> <span class="h-px flex-1 bg-slate-200" />
<li>Insgesamt maximal drei Nominierungen in diesem Draft.</li> <span class="text-violet-600">2 · Favoriten eintragen</span>
<li>Freitext-Ideen landen spaeter in der Review-Liste.</li> <span class="h-px flex-1 bg-slate-200" />
<li>Bereits gespeicherte Entwuerfe koennen bis zur Deadline bearbeitet werden.</li> <span>3 · Abschicken</span>
</ul> </div>
</Card>
<Card class="p-7"> <div class="grid gap-8 p-7 sm:p-9 lg:grid-cols-[0.82fr_1.18fr]">
<div class="grid gap-6 lg:grid-cols-[0.8fr_1.2fr]"> <!-- Left: category + add -->
<div class="space-y-5"> <div class="space-y-5">
<p v-if="!authStore.isLoggedIn" class="rounded-[26px] border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-700"> <p v-if="!authStore.isLoggedIn" class="rounded-2xl border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-800">
Bitte zuerst ueber den Kopfbereich mit einem Twitch-Account einloggen, damit die Nominierung gespeichert werden kann. Logg dich kurz oben mit Twitch ein dann zählt deine Nominierung. 💜
</p> </p>
<label class="text-sm font-semibold text-slate-600">Kategorie</label> <div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">In welcher Kategorie?</label>
<Select <Select
v-model="selectedCategoryId" v-model="selectedCategoryId"
:options="categories" :options="categories"
@@ -102,53 +109,101 @@ async function submitNomination() {
option-value="value" option-value="value"
class="w-full" class="w-full"
/> />
<div v-if="selectedCategory" class="rounded-[28px] bg-violet-50/70 p-6">
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ selectedCategory.groupName }}</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ selectedCategory.name }}</h2>
<p class="mt-2 text-slate-600">{{ selectedCategory.description }}</p>
</div>
<div class="space-y-3 rounded-[28px] border border-violet-100 bg-white/70 p-5">
<label class="text-sm font-semibold text-slate-600">Neuen Namen hinzufuegen</label>
<input
v-model="nomineeName"
type="text"
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3"
placeholder="z. B. Shiro Ch."
/>
<Button @click="addNominee">Nominierung hinzufuegen</Button>
</div>
</div> </div>
<div class="space-y-4"> <div v-if="selectedCategory" class="relative overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#ece2ff,#f6ecff_55%,#fff2dd)] p-6">
<h3 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Dein Entwurf</h3> <div class="absolute right-3 top-3 h-1 w-24 rounded-full bg-[linear-gradient(90deg,#c4b5fd,#f5d0fe,#fecdd3,#fde68a,#bbf7d0,#bae6fd)]" />
<div <p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">{{ selectedCategory.groupName }}</p>
v-for="name in nominees" <h2 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ selectedCategory.name }}</h2>
:key="name" <p class="mt-2 text-sm leading-7 text-slate-600">{{ selectedCategory.description }}</p>
class="flex items-center justify-between rounded-[26px] border border-violet-100 bg-white/85 px-5 py-5" </div>
>
<div>
<p class="font-semibold text-slate-800">{{ name }}</p>
<p class="text-sm text-slate-500">@{{ name.toLowerCase().replace(/\s+/g, '') }}</p>
</div>
<button class="text-sm font-semibold text-rose-500" @click="removeNominee(name)">Entfernen</button>
</div>
<p class="rounded-[26px] border border-dashed border-violet-200 bg-violet-50/60 px-5 py-5 text-sm text-slate-600">
Live-Status: {{ nominees.length }}/3 Slots belegt.
</p>
<p v-if="submitMessage" class="rounded-[26px] border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-700"> <div class="space-y-3 rounded-2xl border border-violet-100 bg-white/70 p-5">
{{ submitMessage }} <label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Wen möchtest du nominieren?</label>
</p> <input
<p v-if="submitError" class="rounded-[26px] border border-rose-200 bg-rose-50 px-5 py-4 text-sm text-rose-700"> v-model="nomineeName"
{{ submitError }} type="text"
</p> class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm"
<Button :disabled="submitting || !authStore.isLoggedIn || !selectedCategoryId || nominees.length === 0" @click="submitNomination"> placeholder="Name oder @handle, z. B. Shiro Ch."
{{ submitting ? 'Speichert ...' : 'Nominierung speichern' }} @keyup.enter="addNominee"
/>
<Button class="w-full gap-2" :disabled="slotsLeft <= 0" @click="addNominee">
<Plus class="h-4 w-4" /> {{ slotsLeft > 0 ? 'Zur Liste hinzufügen' : 'Alle Plätze belegt' }}
</Button> </Button>
</div> </div>
</div> </div>
</Card>
</div> <!-- Right: draft -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h3 class="font-[Cormorant_Garamond] text-3xl text-violet-800">Deine Favoriten</h3>
<p class="text-sm text-slate-500">
{{ slotsLeft > 0 ? `Noch ${slotsLeft} Platz${slotsLeft === 1 ? '' : 'e'} frei` : 'Volle Liste richtig so!' }}
</p>
</div>
<span class="rounded-full bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-600">{{ nominees.length }}/3</span>
</div>
<transition-group name="list" tag="div" class="space-y-2">
<div
v-for="(name, index) in nominees"
:key="name"
class="flex items-center justify-between rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3"
>
<div class="flex items-center gap-3">
<span class="grid h-10 w-10 place-items-center rounded-full bg-[linear-gradient(135deg,#c4b5fd,#f5a9d6)] text-sm font-semibold text-white">
{{ name.charAt(0) }}
</span>
<div>
<p class="font-semibold text-slate-800">{{ name }}</p>
<p class="text-xs text-slate-500">{{ slugFor(name) }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-semibold text-violet-400">#{{ index + 1 }}</span>
<button class="grid h-8 w-8 place-items-center rounded-full text-rose-400 transition hover:bg-rose-50" @click="removeNominee(name)">
<X class="h-4 w-4" />
</button>
</div>
</div>
</transition-group>
<div v-if="nominees.length === 0" class="rounded-2xl border border-dashed border-violet-200 bg-violet-50/40 px-5 py-8 text-center">
<Sparkles class="mx-auto h-6 w-6 text-amber-400" />
<p class="mt-2 text-sm text-slate-500">Noch leer wer hat dieses Jahr deinen Bildschirm zum Leuchten gebracht?</p>
</div>
<div class="rounded-2xl bg-violet-50/50 px-5 py-4 text-xs leading-6 text-slate-500">
<p class="flex items-center gap-2"><Star class="h-3.5 w-3.5 text-amber-400" /> Bis zu 3 Favoriten pro Kategorie keine Person doppelt.</p>
<p class="flex items-center gap-2"><Star class="h-3.5 w-3.5 text-amber-400" /> Alles bleibt bis zum Ende der Phase änderbar.</p>
</div>
<p v-if="submitMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-700">{{ submitMessage }}</p>
<p v-if="submitError" class="rounded-2xl border border-rose-200 bg-rose-50 px-5 py-4 text-sm text-rose-700">{{ submitError }}</p>
<Button
class="w-full gap-2"
:disabled="submitting || !authStore.isLoggedIn || !selectedCategoryId || nominees.length === 0"
@click="submitNomination"
>
<Trophy class="h-4 w-4" />
{{ submitting ? 'Speichert ...' : 'Nominierungen abschicken' }}
</Button>
</div>
</div>
</Card>
</div> </div>
</template> </template>
<style scoped>
.list-enter-active,
.list-leave-active {
transition: all 0.25s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateY(-6px);
}
</style>
+108 -56
View File
@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import Select from 'primevue/select' import Select from 'primevue/select'
import RadioButton from 'primevue/radiobutton' import { Check, PlayCircle, Sparkles, Star, Vote } from '@lucide/vue'
import Button from '../components/ui/Button.vue' import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue' import Card from '../components/ui/Card.vue'
import PageHero from '../components/ui/PageHero.vue'
import { useAwardsStore } from '../stores/awards' import { useAwardsStore } from '../stores/awards'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
@@ -32,6 +33,10 @@ const category = computed(() =>
store.categories.categories.find((item) => item.id === selectedCategoryId.value) ?? store.categories.categories[0], store.categories.categories.find((item) => item.id === selectedCategoryId.value) ?? store.categories.categories[0],
) )
const selectedCandidate = computed(() =>
category.value?.candidates.find((candidate) => candidate.id === selectedCandidateId.value),
)
async function submitVote() { async function submitVote() {
if (!category.value || !selectedCandidateId.value) return if (!category.value || !selectedCandidateId.value) return
@@ -51,9 +56,9 @@ async function submitVote() {
], ],
}) })
submitMessage.value = `Ballot #${response.ballotId} mit ${response.entries} Eintrag gespeichert.` submitMessage.value = `Stimme gezählt! (Ballot #${response.ballotId}) Danke fürs Mitmachen. 💜`
} catch (error) { } catch (error) {
submitError.value = error instanceof Error ? error.message : 'Vote konnte nicht gespeichert werden.' submitError.value = error instanceof Error ? error.message : 'Ups das hat nicht geklappt. Versuch es gleich nochmal.'
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -61,24 +66,33 @@ async function submitVote() {
</script> </script>
<template> <template>
<div class="space-y-10 pb-14"> <div class="space-y-12 pb-16">
<div class="space-y-4"> <PageHero
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Voting</p> eyebrow="Schritt 2 · Voten"
<h1 class="max-w-[12ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Ein ruhiger, schneller Community-Voting-Flow</h1> title="Jetzt zählt deine Stimme"
<p class="max-w-3xl text-lg leading-8 text-slate-600"> description="Eine Stimme pro Kategorie wähl mit Herz. Deine Wahl bleibt bis zum Ende der Voting-Phase jederzeit änderbar, also kein Stress."
Der V2-Flow priorisiert geringe Reibung: Twitch Login, ein Kandidat pro Kategorie, spaeter editierbar bis zur Deadline und klarer Review-Screen. :icon="Vote"
</p> />
</div>
<Card class="p-7"> <Card class="overflow-hidden p-0">
<div class="grid gap-7 lg:grid-cols-[0.72fr_1.28fr]"> <!-- Stepper -->
<div class="flex items-center gap-4 border-b border-violet-100 px-7 py-5 text-xs font-semibold text-slate-400 sm:px-9">
<span class="text-violet-600">1 · Kategorie</span>
<span class="h-px flex-1 bg-slate-200" />
<span class="text-violet-600">2 · Favorit wählen</span>
<span class="h-px flex-1 bg-slate-200" />
<span>3 · Stimme abgeben</span>
</div>
<div class="grid gap-8 p-7 sm:p-9 lg:grid-cols-[0.82fr_1.18fr]">
<!-- Left -->
<div class="space-y-5"> <div class="space-y-5">
<p v-if="!authStore.isLoggedIn" class="rounded-[26px] border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-700"> <p v-if="!authStore.isLoggedIn" class="rounded-2xl border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-800">
Bitte zuerst ueber den Kopfbereich mit einem Twitch-Account einloggen, damit deine Stimme gespeichert werden kann. Logg dich kurz oben mit Twitch ein dann zählt deine Stimme. 💜
</p> </p>
<div class="space-y-3"> <div class="space-y-2">
<label class="text-sm font-semibold text-slate-600">Kategorie</label> <label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Für welche Kategorie stimmst du?</label>
<Select <Select
v-model="selectedCategoryId" v-model="selectedCategoryId"
:options="categoryOptions" :options="categoryOptions"
@@ -88,49 +102,87 @@ async function submitVote() {
/> />
</div> </div>
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ category?.groupName }}</p> <div v-if="category" class="relative overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#ece2ff,#f6ecff_55%,#fff2dd)] p-6">
<h2 class="font-[Cormorant_Garamond] text-5xl text-violet-800">{{ category?.name }}</h2> <div class="absolute right-3 top-3 h-1 w-24 rounded-full bg-[linear-gradient(90deg,#c4b5fd,#f5d0fe,#fecdd3,#fde68a,#bbf7d0,#bae6fd)]" />
<p class="text-slate-600">{{ category?.description }}</p> <p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">{{ category.groupName }}</p>
<div class="rounded-[28px] bg-violet-50/70 p-6 text-sm leading-7 text-slate-600"> <h2 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ category.name }}</h2>
Nur eine Stimme pro Kategorie. Videos/Clips koennen spaeter direkt auf Karten oder Detailmodals referenziert werden. <p class="mt-2 text-sm leading-7 text-slate-600">{{ category.description }}</p>
</div> </div>
</div> <p class="flex items-start gap-2 rounded-2xl border border-violet-100 bg-white/70 px-5 py-4 text-sm text-slate-600">
<Star class="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
<div class="space-y-4"> Nur eine Stimme zählt pro Kategorie aber du darfst sie jederzeit umentscheiden.
<label </p>
v-for="candidate in category?.candidates ?? []" <p class="flex items-start gap-2 rounded-2xl border border-violet-100 bg-white/70 px-5 py-4 text-sm text-slate-600">
:key="candidate.id" <PlayCircle class="mt-0.5 h-4 w-4 shrink-0 text-violet-500" />
class="flex cursor-pointer items-center justify-between rounded-[26px] border border-violet-100 bg-white/85 px-5 py-5 transition hover:border-violet-300 hover:bg-white" Schau dir die eingereichten Clips an, bevor du wählst so siehst du, was jede:n in dieser Kategorie besonders macht.
>
<div>
<p class="font-semibold text-slate-800">{{ candidate.displayName }}</p>
<p class="text-sm text-slate-500">{{ candidate.channelSlug }} · {{ candidate.platform }}</p>
</div>
<RadioButton
v-model="selectedCandidateId"
:input-id="`candidate-${candidate.id}`"
:name="category?.name"
:value="candidate.id"
/>
</label>
</div>
</div>
<div class="mt-7 flex flex-wrap items-center justify-between gap-4 rounded-[28px] bg-violet-50/60 px-6 py-5">
<div class="space-y-2">
<p class="text-sm text-slate-600">
Auswahl:
<strong class="text-violet-700">
{{ category?.candidates.find((candidate) => candidate.id === selectedCandidateId)?.displayName ?? 'Noch keine Stimme abgegeben' }}
</strong>
</p> </p>
<p v-if="submitMessage" class="text-sm text-emerald-700">{{ submitMessage }}</p>
<p v-if="submitError" class="text-sm text-rose-700">{{ submitError }}</p>
</div> </div>
<Button :disabled="submitting || !authStore.isLoggedIn || !selectedCandidateId" @click="submitVote">
{{ submitting ? 'Speichert ...' : 'Stimme speichern' }} <!-- Right: candidate grid -->
</Button> <div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="font-[Cormorant_Garamond] text-3xl text-violet-800">Die Nominierten</h3>
<span class="rounded-full bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-600">
{{ category?.candidates.length ?? 0 }} im Rennen
</span>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<label
v-for="candidate in category?.candidates ?? []"
:key="candidate.id"
:class="[
'group flex cursor-pointer items-center gap-3 rounded-2xl border px-4 py-3 transition',
selectedCandidateId === candidate.id
? 'border-violet-400 bg-violet-50 ring-2 ring-violet-200'
: 'border-violet-100 bg-white hover:border-violet-300 hover:bg-violet-50/40',
]"
>
<input v-model="selectedCandidateId" type="radio" :value="candidate.id" class="sr-only" />
<span class="grid h-11 w-11 shrink-0 place-items-center self-start rounded-full bg-[linear-gradient(135deg,#c4b5fd,#f5a9d6)] text-sm font-semibold text-white">
{{ candidate.displayName.charAt(0) }}
</span>
<div class="min-w-0 flex-1">
<p class="truncate font-semibold text-slate-800">{{ candidate.displayName }}</p>
<p class="truncate text-xs text-slate-500">{{ candidate.channelSlug }} · {{ candidate.platform }}</p>
<a
v-if="candidate.clipUrl"
:href="candidate.clipUrl"
target="_blank"
rel="noopener"
class="mt-1.5 inline-flex items-center gap-1 text-xs font-semibold text-violet-600 hover:text-violet-800"
@click.stop
>
<PlayCircle class="h-3.5 w-3.5" /> Clip ansehen
</a>
</div>
<span
:class="[
'grid h-6 w-6 shrink-0 place-items-center self-start rounded-full border transition',
selectedCandidateId === candidate.id ? 'border-violet-500 bg-violet-500 text-white' : 'border-violet-200 text-transparent',
]"
>
<Check class="h-3.5 w-3.5" />
</span>
</label>
</div>
<div class="flex flex-wrap items-center justify-between gap-4 rounded-2xl bg-[linear-gradient(135deg,#f6ecff,#fff2dd)] px-6 py-5">
<div class="space-y-1">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-400">Deine Wahl</p>
<p class="font-[Cormorant_Garamond] text-2xl text-violet-800">
{{ selectedCandidate?.displayName ?? 'Noch nichts gewählt' }}
</p>
<p v-if="submitMessage" class="text-sm text-emerald-700">{{ submitMessage }}</p>
<p v-if="submitError" class="text-sm text-rose-700">{{ submitError }}</p>
</div>
<Button class="gap-2" :disabled="submitting || !authStore.isLoggedIn || !selectedCandidateId" @click="submitVote">
<Sparkles class="h-4 w-4" />
{{ submitting ? 'Speichert ...' : 'Stimme abgeben' }}
</Button>
</div>
</div>
</div> </div>
</Card> </Card>
</div> </div>
+47 -20
View File
@@ -1,14 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { Crown, Star, Trophy } from '@lucide/vue'
import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue' import Card from '../components/ui/Card.vue'
import PageHero from '../components/ui/PageHero.vue'
import { useAwardsStore } from '../stores/awards' import { useAwardsStore } from '../stores/awards'
const store = useAwardsStore() const store = useAwardsStore()
const years = [2025, 2024, 2023, 2022] const years = [2025, 2024, 2023, 2022]
const activeYear = ref(2025) const activeYear = ref(2025)
const winnerGradients = [
'from-violet-200 to-fuchsia-100',
'from-indigo-200 to-violet-100',
'from-sky-200 to-violet-100',
'from-rose-200 to-amber-100',
'from-violet-200 to-amber-100',
'from-fuchsia-200 to-rose-100',
]
onMounted(async () => { onMounted(async () => {
await store.loadHomeData() await store.loadHomeData()
await store.loadArchive(activeYear.value) await store.loadArchive(activeYear.value)
@@ -21,37 +31,54 @@ async function selectYear(year: number) {
</script> </script>
<template> <template>
<div class="space-y-10 pb-14"> <div class="space-y-12 pb-16">
<div class="space-y-4"> <PageHero
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Gewinnerarchiv</p> eyebrow="Gewinner Archiv"
<h1 class="max-w-[12ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Jahre, Gewinner und Show-Historie</h1> title="Jahre, Gewinner und Show-Historie"
<p class="max-w-3xl text-lg leading-8 text-slate-600"> description="Das Archiv macht Awards dauerhaft sichtbar und verlinkbar. Kategorien, Gewinner und Banner bleiben pro Jahr nachvollziehbar."
Das Archiv macht Awards dauerhaft sichtbar und verlinkbar. Kategorien und Banner bleiben pro Jahr nachvollziehbar. :icon="Trophy"
</p> />
</div>
<div class="flex flex-wrap gap-3"> <!-- Year tabs -->
<Button <div class="flex flex-wrap gap-2">
<button
v-for="year in years" v-for="year in years"
:key="year" :key="year"
:variant="activeYear === year ? 'default' : 'ghost'" :class="[
'rounded-full px-5 py-2 text-sm font-semibold transition',
activeYear === year
? 'bg-violet-600 text-white shadow-lg shadow-violet-500/20'
: 'border border-violet-200 bg-white text-violet-600 hover:bg-violet-50',
]"
@click="selectYear(year)" @click="selectYear(year)"
> >
{{ year }} {{ year }}
</Button> </button>
</div> </div>
<div class="grid gap-5 lg:grid-cols-3"> <!-- Winner grid -->
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<Card <Card
v-for="item in store.archive.items" v-for="(item, index) in store.archive.items"
:key="`${store.archive.year}-${item.category}`" :key="`${store.archive.year}-${item.category}`"
class="p-7" class="overflow-hidden p-0"
> >
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ store.archive.year }}</p> <div :class="['relative h-[220px] bg-gradient-to-br', winnerGradients[index % winnerGradients.length]]">
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ item.category }}</h2> <div class="absolute inset-0 grid place-items-center text-white/70">
<p class="mt-4 text-lg font-semibold text-slate-800">{{ item.winnerName }}</p> <Star class="h-12 w-12" />
<p class="mt-1 text-sm text-slate-500">{{ item.winnerSlug }}</p> </div>
<span class="absolute left-3 top-3 inline-flex items-center gap-1 rounded-full bg-[linear-gradient(135deg,#ffd97a,#f6b938)] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.2em] text-amber-950">
<Crown class="h-3 w-3" /> Winner {{ store.archive.year }}
</span>
</div>
<div class="p-5">
<p class="text-[10px] uppercase tracking-[0.2em] text-violet-400">{{ item.category }}</p>
<p class="mt-1 font-[Cormorant_Garamond] text-2xl text-violet-800">{{ item.winnerName }}</p>
<p class="text-sm text-slate-500">{{ item.winnerSlug }}</p>
</div>
</Card> </Card>
</div> </div>
<p class="text-xs text-slate-400">Bilder sind Platzhalter echte Banner werden pro Jahr im Admin gepflegt.</p>
</div> </div>
</template> </template>
+261 -264
View File
@@ -1,315 +1,312 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import Select from 'primevue/select' import Select from 'primevue/select'
import { Search, Sparkles, Tags, UserPlus, Users } from '@lucide/vue' import { ChevronLeft, ChevronRight, Pencil, Search, Trash2, TriangleAlert, UserPlus, Users, X } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue' import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue' import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Button from '../../components/ui/Button.vue' import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue' import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
import { useAwardsStore } from '../../stores/awards' import { useAwardsStore } from '../../stores/awards'
import type { AdminCandidateItem } from '../../types/awards'
const store = useAwardsStore() const store = useAwardsStore()
const candidateSaving = ref<number | 'new' | null>(null) const saving = ref(false)
const deleting = ref(false)
const adminMessage = ref('') const adminMessage = ref('')
const adminError = ref('') const adminError = ref('')
const newCandidateForm = reactive({
categoryId: 0,
displayName: '',
channelSlug: '',
platform: 'Twitch',
})
const candidateForms = reactive<Record<number, {
categoryId: number
displayName: string
channelSlug: string
platform: string
}>>({})
const seasonDetail = computed(() => store.adminSeasonDetail) const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId) const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const candidateFilter = ref('')
/* ---------- Filter + Suche + Pagination ---------- */
const search = ref('')
const categoryFilter = ref<number | null>(null) const categoryFilter = ref<number | null>(null)
const page = ref(1)
const pageSize = 10
const categoryOptions = computed(() => const categoryOptions = computed(() =>
seasonDetail.value.categories.map((category) => ({ seasonDetail.value.categories.map((category) => ({ label: `${category.groupName} · ${category.name}`, value: category.id })),
label: `${category.groupName} · ${category.name}`,
value: category.id,
})),
) )
const categoryFilterOptions = computed(() => [ const categoryFilterOptions = computed(() => [{ label: 'Alle Kategorien', value: null }, ...categoryOptions.value])
{ label: 'Alle Kategorien', value: null },
...categoryOptions.value,
])
const categoryLabelMap = computed(() => const categoryLabelMap = computed(() =>
Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, `${category.groupName} · ${category.name}`])), Object.fromEntries(seasonDetail.value.categories.map((c) => [c.id, `${c.groupName} · ${c.name}`])),
) )
const platformSummary = computed(() => {
const platforms = new Set(seasonDetail.value.candidates.map((candidate) => candidate.platform).filter(Boolean))
return platforms.size
})
const candidateStats = computed(() => [
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length, note: 'im Jahr gepflegt' },
{ label: 'Kategorien', value: seasonDetail.value.categories.length, note: 'als Ziel verfuegbar' },
{ label: 'Plattformen', value: platformSummary.value, note: 'in der Kandidatenbasis' },
])
const filteredCandidates = computed(() => { const filteredCandidates = computed(() => {
const query = candidateFilter.value.trim().toLowerCase() const query = search.value.trim().toLowerCase()
const candidates = categoryFilter.value let list = seasonDetail.value.candidates
? seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === categoryFilter.value) if (categoryFilter.value) list = list.filter((c) => c.categoryId === categoryFilter.value)
: seasonDetail.value.candidates if (query) {
list = list.filter((c) =>
if (!query) return candidates [c.displayName, c.channelSlug, c.platform, categoryLabelMap.value[c.categoryId] ?? '']
return candidates.filter((candidate) => .join(' ')
[candidate.displayName, candidate.channelSlug, candidate.platform, categoryLabelMap.value[candidate.categoryId] ?? ''] .toLowerCase()
.join(' ') .includes(query),
.toLowerCase() )
.includes(query), }
) return list
}) })
const hasFilters = computed(() => candidateFilter.value.trim().length > 0 || categoryFilter.value !== null)
const canCreateCandidate = computed(() =>
Boolean(selectedSeasonId.value && newCandidateForm.categoryId && newCandidateForm.displayName.trim() && newCandidateForm.channelSlug.trim()),
)
watch( const totalPages = computed(() => Math.max(1, Math.ceil(filteredCandidates.value.length / pageSize)))
seasonDetail, const pagedCandidates = computed(() => {
(detail) => { const start = (page.value - 1) * pageSize
for (const candidate of detail.candidates) { return filteredCandidates.value.slice(start, start + pageSize)
candidateForms[candidate.id] = { })
categoryId: candidate.categoryId, const rangeStart = computed(() => (filteredCandidates.value.length === 0 ? 0 : (page.value - 1) * pageSize + 1))
displayName: candidate.displayName, const rangeEnd = computed(() => Math.min(page.value * pageSize, filteredCandidates.value.length))
channelSlug: candidate.channelSlug,
platform: candidate.platform,
}
}
newCandidateForm.categoryId = detail.categories[0]?.id ?? 0 watch([search, categoryFilter, () => seasonDetail.value.candidates.length], () => {
}, page.value = 1
{ immediate: true }, })
) watch(totalPages, (max) => {
if (page.value > max) page.value = max
async function saveCandidate(candidateId: number) { })
if (!selectedSeasonId.value) return
candidateSaving.value = candidateId
adminMessage.value = ''
adminError.value = ''
try {
await store.updateAdminCandidate(candidateId, selectedSeasonId.value, candidateForms[candidateId])
adminMessage.value = 'Kandidat gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht gespeichert werden.'
} finally {
candidateSaving.value = null
}
}
async function createCandidate() {
if (!canCreateCandidate.value || !selectedSeasonId.value) return
candidateSaving.value = 'new'
adminMessage.value = ''
adminError.value = ''
try {
await store.createAdminCandidate(selectedSeasonId.value, newCandidateForm)
adminMessage.value = 'Kandidat angelegt.'
newCandidateForm.displayName = ''
newCandidateForm.channelSlug = ''
newCandidateForm.platform = 'Twitch'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht angelegt werden.'
} finally {
candidateSaving.value = null
}
}
function clearFilters() { function clearFilters() {
candidateFilter.value = '' search.value = ''
categoryFilter.value = null categoryFilter.value = null
} }
/* ---------- Modal: anlegen / bearbeiten ---------- */
const modalOpen = ref(false)
const editingId = ref<number | 'new' | null>(null)
const form = reactive({ categoryId: 0, displayName: '', channelSlug: '', platform: 'Twitch' })
const modalTitle = computed(() => (editingId.value === 'new' ? 'Kandidat anlegen' : 'Kandidat bearbeiten'))
const canSave = computed(() =>
Boolean(selectedSeasonId.value && form.categoryId && form.displayName.trim() && form.channelSlug.trim()),
)
function openCreate() {
adminMessage.value = ''
adminError.value = ''
editingId.value = 'new'
form.categoryId = categoryFilter.value ?? seasonDetail.value.categories[0]?.id ?? 0
form.displayName = ''
form.channelSlug = ''
form.platform = 'Twitch'
modalOpen.value = true
}
function openEdit(candidate: AdminCandidateItem) {
adminMessage.value = ''
adminError.value = ''
editingId.value = candidate.id
form.categoryId = candidate.categoryId
form.displayName = candidate.displayName
form.channelSlug = candidate.channelSlug
form.platform = candidate.platform
modalOpen.value = true
}
async function saveModal() {
if (!canSave.value || !selectedSeasonId.value) return
saving.value = true
adminError.value = ''
try {
if (editingId.value === 'new') {
await store.createAdminCandidate(selectedSeasonId.value, { ...form })
adminMessage.value = `${form.displayName}" wurde angelegt.`
} else if (typeof editingId.value === 'number') {
await store.updateAdminCandidate(editingId.value, selectedSeasonId.value, { ...form })
adminMessage.value = `${form.displayName}" wurde gespeichert.`
}
modalOpen.value = false
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Speichern fehlgeschlagen.'
} finally {
saving.value = false
}
}
/* ---------- Löschen mit Bestätigung ---------- */
const candidateToDelete = ref<AdminCandidateItem | null>(null)
async function confirmDelete() {
if (!candidateToDelete.value || !selectedSeasonId.value) return
deleting.value = true
adminError.value = ''
try {
await store.deleteAdminCandidate(candidateToDelete.value.id, selectedSeasonId.value)
adminMessage.value = `${candidateToDelete.value.displayName}" wurde gelöscht.`
candidateToDelete.value = null
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
} finally {
deleting.value = false
}
}
</script> </script>
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<AdminPageHeader <AdminPageHeader
eyebrow="Kandidaten" eyebrow="Kandidaten"
title="Kandidatenbasis pflegen" title="Kandidaten verwalten"
description="Schneller finden, sauber pruefen, gezielt bearbeiten: die Kandidaten sind jetzt nach Jahr, Kategorie und Handle besser steuerbar." description="Suchen, filtern, anlegen, bearbeiten und löschen auch bei vielen Nominierten bleibt die Liste übersichtlich."
:icon="Users"
/> />
<AdminSeasonToolbar /> <AdminSeasonToolbar />
<div class="grid gap-4 md:grid-cols-3"> <Card class="overflow-hidden">
<Card <!-- Toolbar -->
v-for="stat in candidateStats" <div class="flex flex-col gap-4 border-b border-violet-100 p-5 lg:flex-row lg:items-center">
:key="stat.label" <label class="relative block flex-1">
class="p-5" <Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
> <input
<p class="text-[11px] font-semibold uppercase tracking-[0.24em] text-violet-500">{{ stat.label }}</p> v-model="search"
<strong class="mt-2 block text-3xl text-violet-800">{{ stat.value }}</strong> type="text"
<p class="mt-1 text-sm text-slate-500">{{ stat.note }}</p> class="h-11 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
</Card> placeholder="Name, Handle oder Plattform suchen …"
</div> />
</label>
<Select
v-model="categoryFilter"
:options="categoryFilterOptions"
option-label="label"
option-value="value"
class="w-full lg:w-72"
/>
<Button v-if="search || categoryFilter" variant="ghost" class="gap-1" @click="clearFilters">
<X class="h-4 w-4" /> Filter
</Button>
<Button class="gap-2" @click="openCreate">
<UserPlus class="h-4 w-4" /> Kandidat anlegen
</Button>
</div>
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]"> <p v-if="adminMessage" class="border-b border-emerald-100 bg-emerald-50 px-5 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<Card class="overflow-hidden"> <p v-if="adminError" class="border-b border-rose-100 bg-rose-50 px-5 py-3 text-sm text-rose-700">{{ adminError }}</p>
<div class="border-b border-violet-100 bg-white/70 p-6">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> <!-- Tabellenkopf (Desktop) -->
<div> <div class="hidden grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)_120px_96px] gap-4 border-b border-violet-100 bg-violet-50/40 px-6 py-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-violet-500 lg:grid">
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Kandidatenbereich</p> <span>Kandidat</span>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Suchen, pruefen, aktualisieren</h2> <span>Kategorie</span>
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500"> <span>Plattform</span>
Filtere nach Kategorie oder Handle und bearbeite nur den Kandidaten, der wirklich geaendert werden muss. <span class="text-right">Aktionen</span>
</p> </div>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600"> <!-- Zeilen -->
<strong class="text-violet-800">{{ filteredCandidates.length }}</strong> von {{ seasonDetail.candidates.length }} sichtbar <div class="divide-y divide-violet-50">
<div
v-for="candidate in pagedCandidates"
:key="candidate.id"
class="grid grid-cols-1 gap-3 px-6 py-4 transition hover:bg-violet-50/40 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)_120px_96px] lg:items-center lg:gap-4"
>
<div class="flex min-w-0 items-center gap-3">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[linear-gradient(135deg,#c4b5fd,#f5a9d6)] text-sm font-semibold text-white">
{{ candidate.displayName.charAt(0) }}
</span>
<div class="min-w-0">
<p class="truncate font-semibold text-slate-800">{{ candidate.displayName }}</p>
<p class="truncate text-xs text-slate-500">{{ candidate.channelSlug }}</p>
</div> </div>
</div> </div>
<div class="min-w-0">
<div class="mt-5 grid gap-3 lg:grid-cols-[minmax(0,1fr)_280px_auto]"> <span class="inline-block max-w-full truncate rounded-full border border-violet-100 bg-violet-50/70 px-3 py-1 text-xs font-semibold text-violet-700">
<label class="relative block"> {{ categoryLabelMap[candidate.categoryId] || 'Ohne Kategorie' }}
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" /> </span>
<input
v-model="candidateFilter"
type="text"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white/90 pl-11 pr-4 text-sm text-slate-700 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Name, Handle, Plattform oder Kategorie suchen"
/>
</label>
<Select
v-model="categoryFilter"
:options="categoryFilterOptions"
option-label="label"
option-value="value"
class="w-full"
/>
<Button v-if="hasFilters" variant="ghost" @click="clearFilters">Filter loeschen</Button>
</div> </div>
</div> <div>
<span class="rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">{{ candidate.platform }}</span>
<div class="p-5"> </div>
<div class="grid gap-4"> <div class="flex gap-2 lg:justify-end">
<article <button
v-for="candidate in filteredCandidates" class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50"
:key="candidate.id" title="Bearbeiten"
class="rounded-[26px] border border-violet-100 bg-white/90 p-5 shadow-[0_16px_42px_rgba(168,145,214,0.08)]" @click="openEdit(candidate)"
> >
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> <Pencil class="h-4 w-4" />
<div class="min-w-0"> </button>
<div class="flex flex-wrap items-center gap-2"> <button
<span class="rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-700"> class="grid h-9 w-9 place-items-center rounded-full border border-rose-200 text-rose-500 transition hover:bg-rose-50"
{{ candidate.platform }} title="Löschen"
</span> @click="candidateToDelete = candidate"
<span class="rounded-full border border-violet-100 bg-violet-50/70 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-violet-700"> >
{{ categoryLabelMap[candidate.categoryId] || 'Ohne Kategorie' }} <Trash2 class="h-4 w-4" />
</span> </button>
</div>
<h3 class="mt-3 truncate font-[Cormorant_Garamond] text-4xl text-violet-800">{{ candidate.displayName }}</h3>
<p class="mt-1 text-sm font-semibold text-slate-500">{{ candidate.channelSlug }}</p>
</div>
<Button :disabled="candidateSaving === candidate.id" size="sm" @click="saveCandidate(candidate.id)">
{{ candidateSaving === candidate.id ? 'Speichert ...' : 'Speichern' }}
</Button>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-2">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kategorie</span>
<Select
v-model="candidateForms[candidate.id].categoryId"
:options="categoryOptions"
option-label="label"
option-value="value"
class="w-full"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
<input v-model="candidateForms[candidate.id].displayName" type="text" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Anzeigename" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
<input v-model="candidateForms[candidate.id].channelSlug" type="text" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="@channel" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
<input v-model="candidateForms[candidate.id].platform" type="text" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Twitch, YouTube, ..." />
</label>
</div>
</article>
<p v-if="filteredCandidates.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine Kandidaten passen zum aktuellen Filter.
</p>
</div> </div>
</div> </div>
</Card>
<aside class="space-y-4 xl:sticky xl:top-6 xl:self-start"> <div v-if="filteredCandidates.length === 0" class="px-6 py-12 text-center">
<Card class="p-6"> <p class="text-sm text-slate-500">
<div class="flex items-start gap-4"> {{ seasonDetail.candidates.length === 0 ? 'Noch keine Kandidaten in diesem Jahr.' : 'Keine Treffer für den aktuellen Filter.' }}
<div class="rounded-2xl bg-violet-100 p-3 text-violet-700">
<UserPlus class="h-5 w-5" />
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Neu</p>
<h2 class="mt-1 font-[Cormorant_Garamond] text-4xl text-violet-800">Kandidat anlegen</h2>
<p class="mt-1 text-sm leading-6 text-slate-500">Erstelle bekannte Kandidaten direkt fuer die richtige Kategorie.</p>
</div>
</div>
<div class="mt-6 space-y-4">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kategorie</span>
<Select
v-model="newCandidateForm.categoryId"
:options="categoryOptions"
option-label="label"
option-value="value"
class="w-full"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
<input v-model="newCandidateForm.displayName" type="text" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z.B. Jayuhime" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
<input v-model="newCandidateForm.channelSlug" type="text" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="@channel" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
<input v-model="newCandidateForm.platform" type="text" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Twitch" />
</label>
<Button class="w-full" :disabled="candidateSaving === 'new' || !canCreateCandidate" @click="createCandidate">
{{ candidateSaving === 'new' ? 'Erstellt ...' : 'Kandidat anlegen' }}
</Button>
</div>
<p v-if="adminMessage" class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
{{ adminMessage }}
</p> </p>
<p v-if="adminError" class="mt-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700"> <Button class="mt-4 gap-2" @click="openCreate"><UserPlus class="h-4 w-4" /> Ersten Kandidaten anlegen</Button>
{{ adminError }} </div>
</p> </div>
</Card>
<Card class="p-5"> <!-- Pagination -->
<div class="flex items-center gap-3"> <div v-if="filteredCandidates.length > 0" class="flex items-center justify-between gap-4 border-t border-violet-100 px-6 py-4 text-sm text-slate-500">
<Sparkles class="h-5 w-5 text-amber-500" /> <span><strong class="text-violet-800">{{ rangeStart }}{{ rangeEnd }}</strong> von {{ filteredCandidates.length }}</span>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Ablauf</p> <div class="flex items-center gap-2">
</div> <button
<div class="mt-4 space-y-3 text-sm leading-6 text-slate-600"> class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50 disabled:opacity-40 disabled:hover:bg-transparent"
<p class="flex gap-3"><Users class="mt-0.5 h-4 w-4 shrink-0 text-violet-500" /> Erst Kandidaten suchen, damit du keine Duplikate anlegst.</p> :disabled="page <= 1"
<p class="flex gap-3"><Tags class="mt-0.5 h-4 w-4 shrink-0 text-violet-500" /> Kategorie-Chip pruefen, dann nur die noetigen Felder anpassen.</p> @click="page--"
</div> >
</Card> <ChevronLeft class="h-4 w-4" />
</aside> </button>
</div> <span class="min-w-[72px] text-center font-semibold text-slate-700">Seite {{ page }}/{{ totalPages }}</span>
<button
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50 disabled:opacity-40 disabled:hover:bg-transparent"
:disabled="page >= totalPages"
@click="page++"
>
<ChevronRight class="h-4 w-4" />
</button>
</div>
</div>
</Card>
<!-- Modal: anlegen / bearbeiten -->
<Modal :open="modalOpen" :title="modalTitle" subtitle="Anzeigename und Handle sind Pflicht." @close="modalOpen = false">
<div class="space-y-4">
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kategorie</span>
<Select v-model="form.categoryId" :options="categoryOptions" option-label="label" option-value="value" class="w-full" />
</label>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
<input v-model="form.displayName" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z. B. Jayuhime" />
</label>
<div class="grid gap-4 sm:grid-cols-2">
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
<input v-model="form.channelSlug" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="@channel" />
</label>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
<input v-model="form.platform" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Twitch, YouTube …" />
</label>
</div>
</div>
<template #footer>
<Button variant="ghost" @click="modalOpen = false">Abbrechen</Button>
<Button :disabled="saving || !canSave" @click="saveModal">{{ saving ? 'Speichert ' : 'Speichern' }}</Button>
</template>
</Modal>
<!-- Modal: löschen bestätigen -->
<Modal :open="!!candidateToDelete" title="Kandidat löschen?" @close="candidateToDelete = null">
<div class="flex items-start gap-4">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-rose-50 text-rose-500">
<TriangleAlert class="h-6 w-6" />
</span>
<p class="text-sm leading-7 text-slate-600">
<strong class="text-slate-800">{{ candidateToDelete?.displayName }}</strong>" wird endgültig aus diesem Award-Jahr entfernt.
Das lässt sich nicht rückgängig machen.
</p>
</div>
<template #footer>
<Button variant="ghost" @click="candidateToDelete = null">Abbrechen</Button>
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="confirmDelete">
{{ deleting ? 'Löscht …' : 'Endgültig löschen' }}
</Button>
</template>
</Modal>
</div> </div>
</template> </template>
+3
View File
@@ -5,4 +5,7 @@ import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue(), tailwindcss()], plugins: [vue(), tailwindcss()],
server: {
port: Number(process.env.PORT) || 5173,
},
}) })