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:
@@ -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 |
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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 – fü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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 & 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 & 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 & 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user