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>
<p class="max-w-xl text-sm leading-6 text-slate-500">{{ description }}</p>
</div> </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>
+562 -261
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">
Presented by Jayuhime <Sparkles class="-mt-1 inline h-6 w-6 text-amber-400" />
</p> </p>
</div>
<p class="max-w-lg text-lg leading-8 text-slate-600"> <p class="max-w-lg text-lg leading-8 text-slate-600">
Feiere die talentiertesten VTuber, Creator und Showmomente des Jahres. VTuber sind Künstler:innen, Entertainer und Geschichtenerzähler:innen zugleich. Hier feiern wir genau diese
Kategorien und Unterkategorien werden vom Team pro Jahr gepflegt, die Gewinner sind aktuell rein community-basiert. Kreativität nominiere deine Favoriten, vote für die Besten und sei dabei, wenn die Stars gekürt werden!
</p> </p>
</div>
<div class="flex flex-wrap gap-3">
<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>
<!-- Aktuelle Phase Card -->
<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 }}
</h2>
<p class="mt-2 max-w-md text-slate-600">
Login bleibt leichtgewichtig: nur Twitch, kein separates Community-Konto.
</p>
</div>
<div class="rounded-[26px] border border-violet-100 bg-violet-50/70 px-5 py-5 text-sm text-slate-700">
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Sitzungsstatus</p>
<p class="mt-2 font-semibold text-violet-800">
{{ authStore.isLoggedIn ? `${authStore.session?.displayName} · ${authStore.session?.role}` : 'Noch nicht eingeloggt' }}
</p>
<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.' }}
</p>
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center">
<strong class="block text-2xl text-violet-800">41</strong>
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Tage</span>
</div>
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center">
<strong class="block text-2xl text-violet-800">08</strong>
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Std</span>
</div>
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center">
<strong class="block text-2xl text-violet-800">24</strong>
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Min</span>
</div>
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center">
<strong class="block text-2xl text-violet-800">16</strong>
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Sek</span>
</div>
</div>
</div>
</Card>
</div>
<Card class="overflow-hidden p-0">
<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))]">
<div class="absolute inset-0 bg-[radial-gradient(circle_at_75%_15%,rgba(255,255,255,0.82),transparent_25%)]" />
<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">
Presented by Jayuhime
</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" />
</div>
<div class="grid gap-3">
<div class="rounded-[26px] border border-white/70 bg-white/72 px-5 py-5">
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Show Date</p>
<p class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">24. Jan 2026</p>
</div>
<div class="rounded-[26px] border border-white/70 bg-white/72 px-5 py-5">
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Winner Model</p>
<p class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">Nur Community</p>
</div>
<div class="rounded-[26px] border border-white/70 bg-white/72 px-5 py-5">
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Login</p>
<p class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">Twitch</p>
</div>
</div>
</div>
</div>
</Card>
</section>
<section class="grid gap-5 lg:grid-cols-4">
<Card
v-for="item in store.overview.timeline"
:key="item.key"
class="p-7"
>
<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> </div>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ store.overview.currentPhase }}</h2>
<div class="grid gap-4 lg:grid-cols-3"> <p class="mt-2 text-sm text-slate-600">
<Card Die Nominierungsphase ist abgeschlossen. Jetzt liegt es an dir: Stimme für deine Favoriten!
v-for="category in store.overview.featuredCategories"
:key="category.id"
class="p-6"
>
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ category.groupName }}</p>
<h3 class="mt-3 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ category.name }}</h3>
<p class="mt-3 text-slate-600">{{ category.description }}</p>
<div class="mt-6 flex items-center justify-between text-sm text-slate-500">
<span>Max. {{ category.maxNomineesPerUser }} Nominierungen</span>
<ArrowRight class="h-4 w-4 text-amber-500" />
</div>
</Card>
</div>
</section>
<section class="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<Card class="p-7">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Nominierung</p>
<h2 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Bis zu drei Favoriten, direkt validiert</h2>
<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> </p>
<div class="mt-6 space-y-3">
<div class="mt-6 grid grid-cols-4 gap-3">
<div <div
v-for="entry in store.overview.winnersPreview" v-for="unit in countdown"
:key="`${entry.year}-${entry.category}`" :key="unit.label"
class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3" class="rounded-2xl border border-violet-100 bg-violet-50/70 py-4 text-center"
> >
<div class="flex items-center justify-between gap-3"> <strong class="block font-[Cormorant_Garamond] text-3xl text-violet-800">{{ unit.value }}</strong>
<div> <span class="text-[10px] uppercase tracking-[0.2em] text-slate-500">{{ unit.label }}</span>
<p class="font-semibold text-slate-800">{{ entry.category }}</p>
<p class="text-sm text-slate-500">{{ entry.winnerName }} · {{ entry.winnerSlug }}</p>
</div> </div>
<Tag :value="entry.year.toString()" severity="info" /> </div>
<div class="mt-6 flex flex-wrap gap-3">
<Button
size="lg"
class="gap-2"
:disabled="!phaseByAction.nominate.open"
@click="goTo('/nominations')"
>
<Sparkles class="h-4 w-4" /> {{ ctaLabel('nominate', 'Jetzt nominieren') }}
</Button>
<Button
variant="secondary"
size="lg"
class="gap-2"
:disabled="!phaseByAction.vote.open"
@click="goTo('/voting')"
>
<Star class="h-4 w-4" /> {{ ctaLabel('vote', 'Jetzt voten') }}
</Button>
</div>
</Card>
</div>
<!-- Hero Keyvisual -->
<Card class="relative overflow-hidden p-0">
<div class="relative min-h-[640px]">
<img :src="heroKeyvisual" alt="VTuber Star Awards Keyvisual (Platzhalter)" class="absolute inset-0 h-full w-full object-cover object-top" />
<div class="absolute inset-0 bg-[radial-gradient(circle_at_70%_18%,rgba(255,255,255,0.55),transparent_45%)]" />
<!-- Host Badge -->
<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>
<p class="mt-1 flex items-center gap-2 font-[Cormorant_Garamond] text-3xl text-violet-800">
Jayuhime <Sparkles class="h-5 w-5 text-amber-400" />
</p>
<p class="text-sm text-slate-500">VTuber &amp; Award Host</p>
<div class="mt-3 flex items-center gap-2 text-violet-500">
<span class="grid h-8 w-8 place-items-center rounded-full bg-violet-50">
<Video class="h-4 w-4" />
</span>
<span class="grid h-8 w-8 place-items-center rounded-full bg-violet-50">
<PartyPopper class="h-4 w-4" />
</span>
<span class="grid h-8 w-8 place-items-center rounded-full bg-violet-50">
<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>
<Card class="p-6"> <!-- ============ TRUST BAR ============ -->
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">FAQ</p> <section class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<h2 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Regeln, Voting, Missbrauchsschutz</h2> <Card v-for="item in trustItems" :key="item.title" class="flex items-center gap-4 p-5">
<Accordion value="0" class="mt-6"> <span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[linear-gradient(135deg,#efe6ff,#fff1da)] text-violet-600">
<component :is="item.icon" class="h-5 w-5" />
</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 ref="carousel" class="flex gap-4 overflow-x-auto pb-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<Card
v-for="(winner, index) in winners"
:key="winner.slug"
class="w-[230px] shrink-0 overflow-hidden p-0"
>
<div :class="['relative h-[230px] bg-gradient-to-br', winnerGradients[index % winnerGradients.length]]">
<div class="absolute inset-0 grid place-items-center text-white/70">
<Star class="h-12 w-12" />
</div>
<span
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>
</Card>
</div>
</section>
<!-- ============ FAQ / COMMUNITY / SHARE ============ -->
<section class="grid gap-6 lg:grid-cols-3">
<Card class="p-7">
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-amber-500">FAQ Häufige Fragen</p>
<Accordion value="0" class="mt-5">
<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>
+107 -52
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> />
<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 · Kategorie</span>
<span class="h-px flex-1 bg-slate-200" />
<span class="text-violet-600">2 · Favoriten eintragen</span>
<span class="h-px flex-1 bg-slate-200" />
<span>3 · Abschicken</span>
</div> </div>
<div class="grid gap-6 lg:grid-cols-[0.68fr_1.32fr]"> <div class="grid gap-8 p-7 sm:p-9 lg:grid-cols-[0.82fr_1.18fr]">
<Card class="p-7"> <!-- Left: category + add -->
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Regeln</p>
<ul class="mt-5 space-y-4 text-slate-600">
<li>Pro Kategorie nur eine Nominierung derselben Person.</li>
<li>Insgesamt maximal drei Nominierungen in diesem Draft.</li>
<li>Freitext-Ideen landen spaeter in der Review-Liste.</li>
<li>Bereits gespeicherte Entwuerfe koennen bis zur Deadline bearbeitet werden.</li>
</ul>
</Card>
<Card class="p-7">
<div class="grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
<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>
<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> <div v-if="selectedCategory" 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">{{ selectedCategory.groupName }}</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ selectedCategory.name }}</h2>
<p class="mt-2 text-sm leading-7 text-slate-600">{{ selectedCategory.description }}</p>
</div>
<div class="space-y-3 rounded-2xl border border-violet-100 bg-white/70 p-5">
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Wen möchtest du nominieren?</label>
<input <input
v-model="nomineeName" v-model="nomineeName"
type="text" type="text"
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3" class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm"
placeholder="z. B. Shiro Ch." placeholder="Name oder @handle, z. B. Shiro Ch."
@keyup.enter="addNominee"
/> />
<Button @click="addNominee">Nominierung hinzufuegen</Button> <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>
</div> </div>
</div> </div>
<!-- Right: draft -->
<div class="space-y-4"> <div class="space-y-4">
<h3 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Dein Entwurf</h3> <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 <div
v-for="name in nominees" v-for="(name, index) in nominees"
:key="name" :key="name"
class="flex items-center justify-between rounded-[26px] border border-violet-100 bg-white/85 px-5 py-5" 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> <div>
<p class="font-semibold text-slate-800">{{ name }}</p> <p class="font-semibold text-slate-800">{{ name }}</p>
<p class="text-sm text-slate-500">@{{ name.toLowerCase().replace(/\s+/g, '') }}</p> <p class="text-xs text-slate-500">{{ slugFor(name) }}</p>
</div> </div>
<button class="text-sm font-semibold text-rose-500" @click="removeNominee(name)">Entfernen</button>
</div> </div>
<p class="rounded-[26px] border border-dashed border-violet-200 bg-violet-50/60 px-5 py-5 text-sm text-slate-600"> <div class="flex items-center gap-2">
Live-Status: {{ nominees.length }}/3 Slots belegt. <span class="text-xs font-semibold text-violet-400">#{{ index + 1 }}</span>
</p> <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>
<p v-if="submitMessage" class="rounded-[26px] border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-700"> <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">
{{ submitMessage }} <Sparkles class="mx-auto h-6 w-6 text-amber-400" />
</p> <p class="mt-2 text-sm text-slate-500">Noch leer wer hat dieses Jahr deinen Bildschirm zum Leuchten gebracht?</p>
<p v-if="submitError" class="rounded-[26px] border border-rose-200 bg-rose-50 px-5 py-4 text-sm text-rose-700"> </div>
{{ submitError }}
</p> <div class="rounded-2xl bg-violet-50/50 px-5 py-4 text-xs leading-6 text-slate-500">
<Button :disabled="submitting || !authStore.isLoggedIn || !selectedCategoryId || nominees.length === 0" @click="submitNomination"> <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>
{{ submitting ? 'Speichert ...' : 'Nominierung speichern' }} <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> </Button>
</div> </div>
</div> </div>
</Card> </Card>
</div> </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>
+93 -41
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> />
<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 · 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>
<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-7 lg:grid-cols-[0.72fr_1.28fr]"> <!-- 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,50 +102,88 @@ 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>
<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" />
Nur eine Stimme zählt pro Kategorie aber du darfst sie jederzeit umentscheiden.
</p>
<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">
<PlayCircle class="mt-0.5 h-4 w-4 shrink-0 text-violet-500" />
Schau dir die eingereichten Clips an, bevor du wählst so siehst du, was jede:n in dieser Kategorie besonders macht.
</p>
</div> </div>
<!-- Right: candidate grid -->
<div class="space-y-4"> <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 <label
v-for="candidate in category?.candidates ?? []" v-for="candidate in category?.candidates ?? []"
:key="candidate.id" :key="candidate.id"
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" :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',
]"
> >
<div> <input v-model="selectedCandidateId" type="radio" :value="candidate.id" class="sr-only" />
<p class="font-semibold text-slate-800">{{ candidate.displayName }}</p> <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">
<p class="text-sm text-slate-500">{{ candidate.channelSlug }} · {{ candidate.platform }}</p> {{ 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> </div>
<RadioButton <span
v-model="selectedCandidateId" :class="[
:input-id="`candidate-${candidate.id}`" 'grid h-6 w-6 shrink-0 place-items-center self-start rounded-full border transition',
:name="category?.name" selectedCandidateId === candidate.id ? 'border-violet-500 bg-violet-500 text-white' : 'border-violet-200 text-transparent',
:value="candidate.id" ]"
/> >
<Check class="h-3.5 w-3.5" />
</span>
</label> </label>
</div> </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="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-2"> <div class="space-y-1">
<p class="text-sm text-slate-600"> <p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-400">Deine Wahl</p>
Auswahl: <p class="font-[Cormorant_Garamond] text-2xl text-violet-800">
<strong class="text-violet-700"> {{ selectedCandidate?.displayName ?? 'Noch nichts gewählt' }}
{{ 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="submitMessage" class="text-sm text-emerald-700">{{ submitMessage }}</p>
<p v-if="submitError" class="text-sm text-rose-700">{{ submitError }}</p> <p v-if="submitError" class="text-sm text-rose-700">{{ submitError }}</p>
</div> </div>
<Button :disabled="submitting || !authStore.isLoggedIn || !selectedCandidateId" @click="submitVote"> <Button class="gap-2" :disabled="submitting || !authStore.isLoggedIn || !selectedCandidateId" @click="submitVote">
{{ submitting ? 'Speichert ...' : 'Stimme speichern' }} <Sparkles class="h-4 w-4" />
{{ submitting ? 'Speichert ...' : 'Stimme abgeben' }}
</Button> </Button>
</div> </div>
</div>
</div>
</Card> </Card>
</div> </div>
</template> </template>
+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>
+239 -242
View File
@@ -1,184 +1,166 @@
<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('')
const categoryFilter = ref<number | null>(null)
const categoryOptions = computed(() =>
seasonDetail.value.categories.map((category) => ({
label: `${category.groupName} · ${category.name}`,
value: category.id,
})),
)
const categoryFilterOptions = computed(() => [
{ label: 'Alle Kategorien', value: null },
...categoryOptions.value,
])
const categoryLabelMap = computed(() =>
Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, `${category.groupName} · ${category.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 query = candidateFilter.value.trim().toLowerCase()
const candidates = categoryFilter.value
? seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === categoryFilter.value)
: seasonDetail.value.candidates
if (!query) return candidates /* ---------- Filter + Suche + Pagination ---------- */
return candidates.filter((candidate) => const search = ref('')
[candidate.displayName, candidate.channelSlug, candidate.platform, categoryLabelMap.value[candidate.categoryId] ?? ''] const categoryFilter = ref<number | null>(null)
const page = ref(1)
const pageSize = 10
const categoryOptions = computed(() =>
seasonDetail.value.categories.map((category) => ({ label: `${category.groupName} · ${category.name}`, value: category.id })),
)
const categoryFilterOptions = computed(() => [{ label: 'Alle Kategorien', value: null }, ...categoryOptions.value])
const categoryLabelMap = computed(() =>
Object.fromEntries(seasonDetail.value.categories.map((c) => [c.id, `${c.groupName} · ${c.name}`])),
)
const filteredCandidates = computed(() => {
const query = search.value.trim().toLowerCase()
let list = seasonDetail.value.candidates
if (categoryFilter.value) list = list.filter((c) => c.categoryId === categoryFilter.value)
if (query) {
list = list.filter((c) =>
[c.displayName, c.channelSlug, c.platform, categoryLabelMap.value[c.categoryId] ?? '']
.join(' ') .join(' ')
.toLowerCase() .toLowerCase()
.includes(query), .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
v-for="stat in candidateStats"
:key="stat.label"
class="p-5"
>
<p class="text-[11px] font-semibold uppercase tracking-[0.24em] text-violet-500">{{ stat.label }}</p>
<strong class="mt-2 block text-3xl text-violet-800">{{ stat.value }}</strong>
<p class="mt-1 text-sm text-slate-500">{{ stat.note }}</p>
</Card>
</div>
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<Card class="overflow-hidden"> <Card class="overflow-hidden">
<div class="border-b border-violet-100 bg-white/70 p-6"> <!-- Toolbar -->
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> <div class="flex flex-col gap-4 border-b border-violet-100 p-5 lg:flex-row lg:items-center">
<div> <label class="relative block flex-1">
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Kandidatenbereich</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Suchen, pruefen, aktualisieren</h2>
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
Filtere nach Kategorie oder Handle und bearbeite nur den Kandidaten, der wirklich geaendert werden muss.
</p>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
<strong class="text-violet-800">{{ filteredCandidates.length }}</strong> von {{ seasonDetail.candidates.length }} sichtbar
</div>
</div>
<div class="mt-5 grid gap-3 lg:grid-cols-[minmax(0,1fr)_280px_auto]">
<label class="relative block">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" /> <Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input <input
v-model="candidateFilter" v-model="search"
type="text" 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" 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"
placeholder="Name, Handle, Plattform oder Kategorie suchen" placeholder="Name, Handle oder Plattform suchen"
/> />
</label> </label>
<Select <Select
@@ -186,130 +168,145 @@ function clearFilters() {
:options="categoryFilterOptions" :options="categoryFilterOptions"
option-label="label" option-label="label"
option-value="value" option-value="value"
class="w-full" class="w-full lg:w-72"
/> />
<Button v-if="hasFilters" variant="ghost" @click="clearFilters">Filter loeschen</Button> <Button v-if="search || categoryFilter" variant="ghost" class="gap-1" @click="clearFilters">
</div> <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>
<div class="p-5"> <p v-if="adminMessage" class="border-b border-emerald-100 bg-emerald-50 px-5 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<div class="grid gap-4"> <p v-if="adminError" class="border-b border-rose-100 bg-rose-50 px-5 py-3 text-sm text-rose-700">{{ adminError }}</p>
<article
v-for="candidate in filteredCandidates" <!-- Tabellenkopf (Desktop) -->
<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">
<span>Kandidat</span>
<span>Kategorie</span>
<span>Plattform</span>
<span class="text-right">Aktionen</span>
</div>
<!-- Zeilen -->
<div class="divide-y divide-violet-50">
<div
v-for="candidate in pagedCandidates"
:key="candidate.id" :key="candidate.id"
class="rounded-[26px] border border-violet-100 bg-white/90 p-5 shadow-[0_16px_42px_rgba(168,145,214,0.08)]" 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 flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> <div class="flex min-w-0 items-center gap-3">
<div class="min-w-0"> <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">
<div class="flex flex-wrap items-center gap-2"> {{ candidate.displayName.charAt(0) }}
<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">
{{ candidate.platform }}
</span> </span>
<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"> <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 class="min-w-0">
<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">
{{ categoryLabelMap[candidate.categoryId] || 'Ohne Kategorie' }} {{ categoryLabelMap[candidate.categoryId] || 'Ohne Kategorie' }}
</span> </span>
</div> </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>
</Card>
<aside class="space-y-4 xl:sticky xl:top-6 xl:self-start">
<Card class="p-6">
<div class="flex items-start gap-4">
<div class="rounded-2xl bg-violet-100 p-3 text-violet-700">
<UserPlus class="h-5 w-5" />
</div>
<div> <div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Neu</p> <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>
<h2 class="mt-1 font-[Cormorant_Garamond] text-4xl text-violet-800">Kandidat anlegen</h2> </div>
<p class="mt-1 text-sm leading-6 text-slate-500">Erstelle bekannte Kandidaten direkt fuer die richtige Kategorie.</p> <div class="flex gap-2 lg:justify-end">
<button
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50"
title="Bearbeiten"
@click="openEdit(candidate)"
>
<Pencil class="h-4 w-4" />
</button>
<button
class="grid h-9 w-9 place-items-center rounded-full border border-rose-200 text-rose-500 transition hover:bg-rose-50"
title="Löschen"
@click="candidateToDelete = candidate"
>
<Trash2 class="h-4 w-4" />
</button>
</div> </div>
</div> </div>
<div class="mt-6 space-y-4"> <div v-if="filteredCandidates.length === 0" class="px-6 py-12 text-center">
<label class="space-y-2"> <p class="text-sm text-slate-500">
{{ seasonDetail.candidates.length === 0 ? 'Noch keine Kandidaten in diesem Jahr.' : 'Keine Treffer für den aktuellen Filter.' }}
</p>
<Button class="mt-4 gap-2" @click="openCreate"><UserPlus class="h-4 w-4" /> Ersten Kandidaten anlegen</Button>
</div>
</div>
<!-- Pagination -->
<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">
<span><strong class="text-violet-800">{{ rangeStart }}{{ rangeEnd }}</strong> von {{ filteredCandidates.length }}</span>
<div class="flex items-center gap-2">
<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 <= 1"
@click="page--"
>
<ChevronLeft class="h-4 w-4" />
</button>
<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> <span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kategorie</span>
<Select <Select v-model="form.categoryId" :options="categoryOptions" option-label="label" option-value="value" class="w-full" />
v-model="newCandidateForm.categoryId"
:options="categoryOptions"
option-label="label"
option-value="value"
class="w-full"
/>
</label> </label>
<label class="space-y-2"> <label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span> <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" /> <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> </label>
<label class="space-y-2"> <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> <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" /> <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>
<label class="space-y-2"> <label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span> <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" /> <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> </label>
<Button class="w-full" :disabled="candidateSaving === 'new' || !canCreateCandidate" @click="createCandidate"> </div>
{{ candidateSaving === 'new' ? 'Erstellt ...' : 'Kandidat anlegen' }} </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> </Button>
</div> </template>
</Modal>
<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 v-if="adminError" class="mt-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{{ adminError }}
</p>
</Card>
<Card class="p-5">
<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.24em] text-violet-500">Ablauf</p>
</div>
<div class="mt-4 space-y-3 text-sm leading-6 text-slate-600">
<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>
<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>
</div>
</Card>
</aside>
</div>
</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,
},
}) })