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 { useAuthStore } from '../stores/auth'
|
||||
import { usePhases } from '../composables/usePhases'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const { infoForPhaseKey } = usePhases()
|
||||
const loginOpen = ref(false)
|
||||
const loginError = ref('')
|
||||
const loginForm = reactive({
|
||||
@@ -18,8 +20,9 @@ const loginForm = reactive({
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Home', to: '/' },
|
||||
{ label: 'Nominierung', to: '/nominations' },
|
||||
{ label: 'Voting', to: '/voting' },
|
||||
{ label: 'Nominierung', to: '/nominations', requiresAuth: true, phase: 'nomination' },
|
||||
{ label: 'Voting', to: '/voting', requiresAuth: true, phase: 'voting' },
|
||||
{ label: 'Clips', to: '/clips', requiresAuth: true, phase: 'nomination' },
|
||||
{ label: 'Gewinner', to: '/winners' },
|
||||
{ label: 'Admin', to: '/admin' },
|
||||
]
|
||||
@@ -29,9 +32,15 @@ const currentLabel = 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') {
|
||||
loginError.value = ''
|
||||
@@ -58,20 +67,18 @@ async function login(role: 'viewer' | 'admin') {
|
||||
</div>
|
||||
|
||||
<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="isAdminRoute ? 'mb-5 gap-3 px-4 py-3' : 'mb-10 gap-6 px-5 py-5'"
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<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="isAdminRoute ? 'h-10 w-10' : 'h-12 w-12'"
|
||||
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)]"
|
||||
>
|
||||
<Star :class="isAdminRoute ? 'h-4 w-4' : 'h-5 w-5'" />
|
||||
<Star class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</RouterLink>
|
||||
|
||||
@@ -84,7 +91,10 @@ async function login(role: 'viewer' | 'admin') {
|
||||
</template>
|
||||
<template v-else>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,13 +114,15 @@ async function login(role: 'viewer' | 'admin') {
|
||||
</div>
|
||||
|
||||
<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
|
||||
v-for="item in visibleNavItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="rounded-full px-4 py-2 transition hover:bg-violet-50 hover:text-violet-700"
|
||||
:class="route.path === item.to || route.path.startsWith(`${item.to}/`) ? 'bg-violet-100 text-violet-800' : ''"
|
||||
class="rounded-full px-4 py-2 transition"
|
||||
: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 }}
|
||||
</RouterLink>
|
||||
|
||||
@@ -1,17 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { Sparkles } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
eyebrow?: string
|
||||
title: string
|
||||
description: string
|
||||
/** Dekoratives Icon oben rechts – analog zu den Frontend-Bannern */
|
||||
icon?: Component
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 border-b border-violet-100 pb-4 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<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>
|
||||
<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 class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_88%_18%,rgba(255,255,255,0.7),transparent_42%)]" />
|
||||
|
||||
<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>
|
||||
<p class="max-w-xl text-sm leading-6 text-slate-500">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="relative max-w-2xl space-y-4">
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-white/70 bg-white/70 px-4 py-1.5 text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-600 shadow-sm backdrop-blur">
|
||||
<Sparkles class="h-3.5 w-3.5 text-amber-500" />
|
||||
{{ eyebrow ?? 'Admin' }}
|
||||
</span>
|
||||
<h1 class="font-[Cormorant_Garamond] text-4xl leading-[1.02] text-violet-800 sm:text-5xl">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p class="max-w-xl text-sm leading-6 text-slate-600 sm:text-base">{{ description }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -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,
|
||||
AdminSeasonListItem,
|
||||
AuthSession,
|
||||
CreateClipPayload,
|
||||
CreateNominationPayload,
|
||||
CreateVotePayload,
|
||||
LoginPayload,
|
||||
@@ -40,6 +41,23 @@ async function getJson<T>(path: string): 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> {
|
||||
const token = getAuthToken()
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
@@ -78,6 +96,8 @@ export const api = {
|
||||
sendJson<{ saved: number; category: string }>('/api/public/nominations', 'POST', payload),
|
||||
submitVote: (payload: CreateVotePayload) =>
|
||||
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) =>
|
||||
sendJson<{ saved: boolean; seasonId: number }>(`/api/admin/seasons/${seasonId}`, 'PUT', payload),
|
||||
createAdminCategory: (seasonId: number, payload: UpsertCategoryPayload) =>
|
||||
@@ -88,6 +108,10 @@ export const api = {
|
||||
sendJson<{ saved: boolean; candidateId: number }>(`/api/admin/seasons/${seasonId}/candidates`, 'POST', payload),
|
||||
updateAdminCandidate: (candidateId: number, payload: UpsertCandidatePayload) =>
|
||||
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) =>
|
||||
sendJson<{ saved: boolean; nominationId: number; candidateId: number; created: boolean }>(
|
||||
`/api/admin/nominations/${nominationId}/approve`,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { useAwardsStore } from './stores/awards'
|
||||
|
||||
import AdminCandidatesView from './views/admin/AdminCandidatesView.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 AdminUsersLogsView from './views/admin/AdminUsersLogsView.vue'
|
||||
import AdminVotingView from './views/admin/AdminVotingView.vue'
|
||||
import ClipSubmissionView from './views/ClipSubmissionView.vue'
|
||||
import HomeView from './views/HomeView.vue'
|
||||
import NominationsView from './views/NominationsView.vue'
|
||||
import VotingView from './views/VotingView.vue'
|
||||
@@ -45,6 +47,7 @@ const router = createRouter({
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
keepAlive: true,
|
||||
phase: 'nomination',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -54,6 +57,17 @@ const router = createRouter({
|
||||
meta: {
|
||||
requiresAuth: 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' }
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
AdminSeasonDetailResponse,
|
||||
ApproveNominationPayload,
|
||||
AdminSeasonListItem,
|
||||
CreateClipPayload,
|
||||
CreateNominationPayload,
|
||||
CreateVotePayload,
|
||||
OverviewResponse,
|
||||
@@ -31,9 +32,9 @@ const fallbackOverview: OverviewResponse = {
|
||||
{ key: 'show', title: 'Award Show', startsAt: '2026-07-20', endsAt: '2026-07-20', state: 'upcoming' },
|
||||
],
|
||||
featuredCategories: [
|
||||
{ id: 1, groupName: 'Main Awards', name: 'VTuber des Jahres', description: 'Die groesste Auszeichnung des Jahres.', maxNomineesPerUser: 3 },
|
||||
{ id: 2, groupName: 'Performance', name: 'Bestes Live Event', description: 'Events, Konzerte und Showformate.', maxNomineesPerUser: 3 },
|
||||
{ id: 3, groupName: 'Clips & Highlights', name: 'Clip des Jahres', description: 'Der lustigste oder emotionalste Clip.', 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: '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 eine Clip, den du seit Monaten in jeden Chat spammst.', maxNomineesPerUser: 3 },
|
||||
],
|
||||
winnersPreview: [
|
||||
{ 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' },
|
||||
],
|
||||
faq: [
|
||||
{ question: 'Wer kann nominieren und voten?', answer: 'Jede Person mit Twitch Login. Das Konto wird beim ersten Login implizit erstellt.' },
|
||||
{ question: 'Wie werden Gewinner bestimmt?', answer: 'Aktuell rein community-basiert. Eine Mischlogik kann spaeter aktiviert werden.' },
|
||||
{ question: 'Wer verwaltet Kategorien und Unterkategorien?', answer: 'Das Team pflegt diese pro Jahr im Admin-Bereich.' },
|
||||
{ question: 'Wer darf mitmachen?', answer: 'Jede:r mit einem Twitch-Account. Einmal einloggen genügt – kein extra Konto, kein Papierkram.' },
|
||||
{ question: 'Wie werden die Gewinner bestimmt?', answer: 'Komplett durch eure Stimmen. Die Community entscheidet, wer auf die Bühne darf – kein Jury-Geheimnis.' },
|
||||
{ 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,
|
||||
name: 'VTuber des Jahres',
|
||||
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,
|
||||
candidates: [
|
||||
{ id: 1, displayName: 'Hoshimi Miyu', channelSlug: '@hoshimimiyu', platform: 'Twitch' },
|
||||
{ id: 2, displayName: 'Kurainu', channelSlug: '@kurainu', platform: 'Twitch' },
|
||||
{ id: 3, displayName: 'Shiro Ch.', channelSlug: '@shiroch', 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', clipUrl: 'https://www.youtube.com/watch?v=kurainu' },
|
||||
{ id: 3, displayName: 'Shiro Ch.', channelSlug: '@shiroch', platform: 'Twitch', clipUrl: 'https://clips.twitch.tv/ShiroMoment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Bestes Live Event',
|
||||
groupName: 'Performance',
|
||||
description: 'Konzerte, Sonderformate und grosse Community-Shows.',
|
||||
description: 'Konzerte, Sonderformate und große Community-Shows, die in Erinnerung bleiben.',
|
||||
maxNomineesPerUser: 3,
|
||||
candidates: [
|
||||
{ id: 4, displayName: 'Kurainu 3D Live', channelSlug: '@kurainu', platform: 'Twitch' },
|
||||
{ id: 5, displayName: 'Aoi Sakura Showcase', channelSlug: '@aoisakura', platform: 'YouTube' },
|
||||
{ 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', clipUrl: 'https://www.youtube.com/watch?v=aoisakura' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -277,6 +279,9 @@ export const useAwardsStore = defineStore('awards', {
|
||||
submitVote(payload: CreateVotePayload) {
|
||||
return api.submitVote(payload)
|
||||
},
|
||||
submitClip(payload: CreateClipPayload) {
|
||||
return api.submitClip(payload)
|
||||
},
|
||||
async updateAdminSeason(seasonId: number, payload: UpdateSeasonPayload) {
|
||||
const result = await api.updateAdminSeason(seasonId, payload)
|
||||
await this.loadAdmin()
|
||||
@@ -302,6 +307,16 @@ export const useAwardsStore = defineStore('awards', {
|
||||
await this.loadAdminSeasonDetail(seasonId)
|
||||
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) {
|
||||
const result = await api.approveAdminNomination(nominationId, payload)
|
||||
await this.loadAdminSeasonDetail(seasonId)
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface CandidateSummary {
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
/** Repräsentativer Clip/Video-Link, damit Votende vor der Wahl reinschauen können. */
|
||||
clipUrl?: string
|
||||
}
|
||||
|
||||
export interface PublicCategoryDetail {
|
||||
@@ -175,6 +177,15 @@ export interface CreateNominationPayload {
|
||||
nominees: string[]
|
||||
}
|
||||
|
||||
export interface CreateClipPayload {
|
||||
year: number
|
||||
categoryId: number | null
|
||||
twitchUserId: string
|
||||
clipUrl: string
|
||||
title: string
|
||||
creator: string
|
||||
}
|
||||
|
||||
export interface VoteEntryPayload {
|
||||
categoryId: 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">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import Accordion from 'primevue/accordion'
|
||||
import AccordionContent from 'primevue/accordioncontent'
|
||||
import AccordionHeader from 'primevue/accordionheader'
|
||||
import AccordionPanel from 'primevue/accordionpanel'
|
||||
import Tag from 'primevue/tag'
|
||||
import { ArrowRight, Sparkles, Star, Trophy, WandSparkles } from '@lucide/vue'
|
||||
import {
|
||||
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 Card from '../components/ui/Card.vue'
|
||||
import { useAwardsStore } from '../stores/awards'
|
||||
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 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(() => {
|
||||
void store.loadHomeData()
|
||||
})
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="space-y-20 pb-16">
|
||||
<section class="grid gap-10 lg:grid-cols-[0.82fr_1.18fr] lg:items-start">
|
||||
<div class="space-y-10 pt-3">
|
||||
<div class="space-y-6">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Die groesste Community-Auszeichnung</p>
|
||||
<h1 class="max-w-[8ch] font-[Cormorant_Garamond] text-6xl leading-[0.88] text-violet-800 sm:text-7xl xl:text-[6.6rem]">
|
||||
VTuber Star Awards
|
||||
<div class="space-y-24 pb-20">
|
||||
<!-- ============ HERO ============ -->
|
||||
<section class="grid items-stretch gap-8 lg:grid-cols-[1.02fr_1.18fr]">
|
||||
<div class="flex flex-col justify-center space-y-8">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.4em] text-amber-500">
|
||||
Die große Community-Auszeichnung
|
||||
</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>
|
||||
<p class="text-2xl font-medium italic tracking-wide text-violet-500">
|
||||
Presented by Jayuhime
|
||||
<div class="h-1.5 w-48 rounded-full bg-[linear-gradient(90deg,#c4b5fd,#f5d0fe,#fecdd3,#fde68a,#bbf7d0,#bae6fd)]" />
|
||||
<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>
|
||||
</div>
|
||||
<p class="max-w-lg text-lg leading-8 text-slate-600">
|
||||
Feiere die talentiertesten VTuber, Creator und Showmomente des Jahres.
|
||||
Kategorien und Unterkategorien werden vom Team pro Jahr gepflegt, die Gewinner sind aktuell rein community-basiert.
|
||||
VTuber sind Künstler:innen, Entertainer und Geschichtenerzähler:innen zugleich. Hier feiern wir genau diese
|
||||
Kreativität – nominiere deine Favoriten, vote für die Besten und sei dabei, wenn die Stars gekürt werden!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div class="flex flex-col gap-6 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Aktuelle Phase</p>
|
||||
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">
|
||||
{{ 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>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.3em] 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">
|
||||
<span class="inline-flex h-2 w-2 animate-pulse rounded-full bg-rose-500" /> Live
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<Card
|
||||
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.
|
||||
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ store.overview.currentPhase }}</h2>
|
||||
<p class="mt-2 text-sm text-slate-600">
|
||||
Die Nominierungsphase ist abgeschlossen. Jetzt liegt es an dir: Stimme für deine Favoriten!
|
||||
</p>
|
||||
<div class="mt-6 space-y-3">
|
||||
|
||||
<div class="mt-6 grid grid-cols-4 gap-3">
|
||||
<div
|
||||
v-for="entry in store.overview.winnersPreview"
|
||||
:key="`${entry.year}-${entry.category}`"
|
||||
class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3"
|
||||
v-for="unit in countdown"
|
||||
:key="unit.label"
|
||||
class="rounded-2xl border border-violet-100 bg-violet-50/70 py-4 text-center"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-semibold text-slate-800">{{ entry.category }}</p>
|
||||
<p class="text-sm text-slate-500">{{ entry.winnerName }} · {{ entry.winnerSlug }}</p>
|
||||
<strong class="block font-[Cormorant_Garamond] text-3xl text-violet-800">{{ unit.value }}</strong>
|
||||
<span class="text-[10px] uppercase tracking-[0.2em] text-slate-500">{{ unit.label }}</span>
|
||||
</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>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card class="p-6">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">FAQ</p>
|
||||
<h2 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Regeln, Voting, Missbrauchsschutz</h2>
|
||||
<Accordion value="0" class="mt-6">
|
||||
<!-- ============ TRUST BAR ============ -->
|
||||
<section class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card v-for="item in trustItems" :key="item.title" class="flex items-center gap-4 p-5">
|
||||
<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)">
|
||||
<AccordionHeader>{{ item.question }}</AccordionHeader>
|
||||
<AccordionContent>
|
||||
@@ -305,6 +479,133 @@ const heroYear = computed(() => store.overview.year)
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
</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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import Select from 'primevue/select'
|
||||
import { Plus, Sparkles, Star, Trophy, X } from '@lucide/vue'
|
||||
import { useAwardsStore } from '../stores/awards'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import Button from '../components/ui/Button.vue'
|
||||
import Card from '../components/ui/Card.vue'
|
||||
import PageHero from '../components/ui/PageHero.vue'
|
||||
|
||||
const store = useAwardsStore()
|
||||
const authStore = useAuthStore()
|
||||
@@ -31,6 +33,12 @@ const selectedCategory = computed(() =>
|
||||
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() {
|
||||
const value = nomineeName.value.trim()
|
||||
if (!value || nominees.value.includes(value) || nominees.value.length >= 3) return
|
||||
@@ -57,9 +65,9 @@ async function submitNomination() {
|
||||
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) {
|
||||
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 {
|
||||
submitting.value = false
|
||||
}
|
||||
@@ -67,34 +75,33 @@ async function submitNomination() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-10 pb-14">
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Nominierungs-Flow</p>
|
||||
<h1 class="max-w-[12ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Kategorien waehlen, Regeln live pruefen</h1>
|
||||
<p class="max-w-3xl text-lg leading-8 text-slate-600">
|
||||
Nur Twitch Login, kein separates Konto. Das Team pflegt Kategorien pro Jahr, waehrend die UI sofort Limits, Dubletten und editierbare Entwuerfe abbildet.
|
||||
</p>
|
||||
<div class="space-y-12 pb-16">
|
||||
<PageHero
|
||||
eyebrow="Schritt 1 · Nominieren"
|
||||
title="Wen feiern wir dieses Jahr?"
|
||||
: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.`"
|
||||
:icon="Star"
|
||||
/>
|
||||
|
||||
<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 class="grid gap-6 lg:grid-cols-[0.68fr_1.32fr]">
|
||||
<Card class="p-7">
|
||||
<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="grid gap-8 p-7 sm:p-9 lg:grid-cols-[0.82fr_1.18fr]">
|
||||
<!-- Left: category + add -->
|
||||
<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">
|
||||
Bitte zuerst ueber den Kopfbereich mit einem Twitch-Account einloggen, damit die Nominierung gespeichert werden kann.
|
||||
<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 Nominierung. 💜
|
||||
</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
|
||||
v-model="selectedCategoryId"
|
||||
:options="categories"
|
||||
@@ -102,53 +109,101 @@ async function submitNomination() {
|
||||
option-value="value"
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
<div v-if="selectedCategory" class="rounded-[28px] bg-violet-50/70 p-6">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ selectedCategory.groupName }}</p>
|
||||
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ selectedCategory.name }}</h2>
|
||||
<p class="mt-2 text-slate-600">{{ selectedCategory.description }}</p>
|
||||
</div>
|
||||
<div class="space-y-3 rounded-[28px] border border-violet-100 bg-white/70 p-5">
|
||||
<label class="text-sm font-semibold text-slate-600">Neuen Namen hinzufuegen</label>
|
||||
|
||||
<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
|
||||
v-model="nomineeName"
|
||||
type="text"
|
||||
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3"
|
||||
placeholder="z. B. Shiro Ch."
|
||||
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm"
|
||||
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>
|
||||
|
||||
<!-- Right: draft -->
|
||||
<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
|
||||
v-for="name in nominees"
|
||||
v-for="(name, index) in nominees"
|
||||
: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>
|
||||
<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>
|
||||
<button class="text-sm font-semibold text-rose-500" @click="removeNominee(name)">Entfernen</button>
|
||||
</div>
|
||||
<p class="rounded-[26px] border border-dashed border-violet-200 bg-violet-50/60 px-5 py-5 text-sm text-slate-600">
|
||||
Live-Status: {{ nominees.length }}/3 Slots belegt.
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-semibold text-violet-400">#{{ index + 1 }}</span>
|
||||
<button class="grid h-8 w-8 place-items-center rounded-full text-rose-400 transition hover:bg-rose-50" @click="removeNominee(name)">
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
|
||||
<p v-if="submitMessage" class="rounded-[26px] border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-700">
|
||||
{{ submitMessage }}
|
||||
</p>
|
||||
<p v-if="submitError" class="rounded-[26px] border border-rose-200 bg-rose-50 px-5 py-4 text-sm text-rose-700">
|
||||
{{ submitError }}
|
||||
</p>
|
||||
<Button :disabled="submitting || !authStore.isLoggedIn || !selectedCategoryId || nominees.length === 0" @click="submitNomination">
|
||||
{{ submitting ? 'Speichert ...' : 'Nominierung speichern' }}
|
||||
<div v-if="nominees.length === 0" class="rounded-2xl border border-dashed border-violet-200 bg-violet-50/40 px-5 py-8 text-center">
|
||||
<Sparkles class="mx-auto h-6 w-6 text-amber-400" />
|
||||
<p class="mt-2 text-sm text-slate-500">Noch leer – wer hat dieses Jahr deinen Bildschirm zum Leuchten gebracht?</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-violet-50/50 px-5 py-4 text-xs leading-6 text-slate-500">
|
||||
<p class="flex items-center gap-2"><Star class="h-3.5 w-3.5 text-amber-400" /> Bis zu 3 Favoriten pro Kategorie – keine Person doppelt.</p>
|
||||
<p class="flex items-center gap-2"><Star class="h-3.5 w-3.5 text-amber-400" /> Alles bleibt bis zum Ende der Phase änderbar.</p>
|
||||
</div>
|
||||
|
||||
<p v-if="submitMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-700">{{ submitMessage }}</p>
|
||||
<p v-if="submitError" class="rounded-2xl border border-rose-200 bg-rose-50 px-5 py-4 text-sm text-rose-700">{{ submitError }}</p>
|
||||
|
||||
<Button
|
||||
class="w-full gap-2"
|
||||
:disabled="submitting || !authStore.isLoggedIn || !selectedCategoryId || nominees.length === 0"
|
||||
@click="submitNomination"
|
||||
>
|
||||
<Trophy class="h-4 w-4" />
|
||||
{{ submitting ? 'Speichert ...' : 'Nominierungen abschicken' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
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 Card from '../components/ui/Card.vue'
|
||||
import PageHero from '../components/ui/PageHero.vue'
|
||||
import { useAwardsStore } from '../stores/awards'
|
||||
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],
|
||||
)
|
||||
|
||||
const selectedCandidate = computed(() =>
|
||||
category.value?.candidates.find((candidate) => candidate.id === selectedCandidateId.value),
|
||||
)
|
||||
|
||||
async function submitVote() {
|
||||
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) {
|
||||
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 {
|
||||
submitting.value = false
|
||||
}
|
||||
@@ -61,24 +66,33 @@ async function submitVote() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-10 pb-14">
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Voting</p>
|
||||
<h1 class="max-w-[12ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Ein ruhiger, schneller Community-Voting-Flow</h1>
|
||||
<p class="max-w-3xl text-lg leading-8 text-slate-600">
|
||||
Der V2-Flow priorisiert geringe Reibung: Twitch Login, ein Kandidat pro Kategorie, spaeter editierbar bis zur Deadline und klarer Review-Screen.
|
||||
</p>
|
||||
<div class="space-y-12 pb-16">
|
||||
<PageHero
|
||||
eyebrow="Schritt 2 · Voten"
|
||||
title="Jetzt zählt deine Stimme"
|
||||
description="Eine Stimme pro Kategorie – wähl mit Herz. Deine Wahl bleibt bis zum Ende der Voting-Phase jederzeit änderbar, also kein Stress."
|
||||
:icon="Vote"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<Card class="p-7">
|
||||
<div class="grid gap-7 lg:grid-cols-[0.72fr_1.28fr]">
|
||||
<div class="grid gap-8 p-7 sm:p-9 lg:grid-cols-[0.82fr_1.18fr]">
|
||||
<!-- Left -->
|
||||
<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">
|
||||
Bitte zuerst ueber den Kopfbereich mit einem Twitch-Account einloggen, damit deine Stimme gespeichert werden kann.
|
||||
<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 Stimme. 💜
|
||||
</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<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">Für welche Kategorie stimmst du?</label>
|
||||
<Select
|
||||
v-model="selectedCategoryId"
|
||||
:options="categoryOptions"
|
||||
@@ -88,50 +102,88 @@ async function submitVote() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ category?.groupName }}</p>
|
||||
<h2 class="font-[Cormorant_Garamond] text-5xl text-violet-800">{{ category?.name }}</h2>
|
||||
<p class="text-slate-600">{{ category?.description }}</p>
|
||||
<div class="rounded-[28px] bg-violet-50/70 p-6 text-sm leading-7 text-slate-600">
|
||||
Nur eine Stimme pro Kategorie. Videos/Clips koennen spaeter direkt auf Karten oder Detailmodals referenziert werden.
|
||||
<div v-if="category" 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">{{ category.groupName }}</p>
|
||||
<h2 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ category.name }}</h2>
|
||||
<p class="mt-2 text-sm leading-7 text-slate-600">{{ category.description }}</p>
|
||||
</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>
|
||||
|
||||
<!-- Right: candidate grid -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-[Cormorant_Garamond] text-3xl text-violet-800">Die Nominierten</h3>
|
||||
<span class="rounded-full bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-600">
|
||||
{{ category?.candidates.length ?? 0 }} im Rennen
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<label
|
||||
v-for="candidate in category?.candidates ?? []"
|
||||
:key="candidate.id"
|
||||
class="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>
|
||||
<p class="font-semibold text-slate-800">{{ candidate.displayName }}</p>
|
||||
<p class="text-sm text-slate-500">{{ candidate.channelSlug }} · {{ candidate.platform }}</p>
|
||||
<input v-model="selectedCandidateId" type="radio" :value="candidate.id" class="sr-only" />
|
||||
<span class="grid h-11 w-11 shrink-0 place-items-center self-start rounded-full bg-[linear-gradient(135deg,#c4b5fd,#f5a9d6)] text-sm font-semibold text-white">
|
||||
{{ candidate.displayName.charAt(0) }}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-semibold text-slate-800">{{ candidate.displayName }}</p>
|
||||
<p class="truncate text-xs text-slate-500">{{ candidate.channelSlug }} · {{ candidate.platform }}</p>
|
||||
<a
|
||||
v-if="candidate.clipUrl"
|
||||
:href="candidate.clipUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="mt-1.5 inline-flex items-center gap-1 text-xs font-semibold text-violet-600 hover:text-violet-800"
|
||||
@click.stop
|
||||
>
|
||||
<PlayCircle class="h-3.5 w-3.5" /> Clip ansehen
|
||||
</a>
|
||||
</div>
|
||||
<RadioButton
|
||||
v-model="selectedCandidateId"
|
||||
:input-id="`candidate-${candidate.id}`"
|
||||
:name="category?.name"
|
||||
:value="candidate.id"
|
||||
/>
|
||||
<span
|
||||
:class="[
|
||||
'grid h-6 w-6 shrink-0 place-items-center self-start rounded-full border transition',
|
||||
selectedCandidateId === candidate.id ? 'border-violet-500 bg-violet-500 text-white' : 'border-violet-200 text-transparent',
|
||||
]"
|
||||
>
|
||||
<Check class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-7 flex flex-wrap items-center justify-between gap-4 rounded-[28px] bg-violet-50/60 px-6 py-5">
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-slate-600">
|
||||
Auswahl:
|
||||
<strong class="text-violet-700">
|
||||
{{ category?.candidates.find((candidate) => candidate.id === selectedCandidateId)?.displayName ?? 'Noch keine Stimme abgegeben' }}
|
||||
</strong>
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 rounded-2xl bg-[linear-gradient(135deg,#f6ecff,#fff2dd)] px-6 py-5">
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-400">Deine Wahl</p>
|
||||
<p class="font-[Cormorant_Garamond] text-2xl text-violet-800">
|
||||
{{ selectedCandidate?.displayName ?? 'Noch nichts gewählt' }}
|
||||
</p>
|
||||
<p v-if="submitMessage" class="text-sm text-emerald-700">{{ submitMessage }}</p>
|
||||
<p v-if="submitError" class="text-sm text-rose-700">{{ submitError }}</p>
|
||||
</div>
|
||||
<Button :disabled="submitting || !authStore.isLoggedIn || !selectedCandidateId" @click="submitVote">
|
||||
{{ submitting ? 'Speichert ...' : 'Stimme speichern' }}
|
||||
<Button class="gap-2" :disabled="submitting || !authStore.isLoggedIn || !selectedCandidateId" @click="submitVote">
|
||||
<Sparkles class="h-4 w-4" />
|
||||
{{ submitting ? 'Speichert ...' : 'Stimme abgeben' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
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 PageHero from '../components/ui/PageHero.vue'
|
||||
import { useAwardsStore } from '../stores/awards'
|
||||
|
||||
const store = useAwardsStore()
|
||||
const years = [2025, 2024, 2023, 2022]
|
||||
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 () => {
|
||||
await store.loadHomeData()
|
||||
await store.loadArchive(activeYear.value)
|
||||
@@ -21,37 +31,54 @@ async function selectYear(year: number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-10 pb-14">
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Gewinnerarchiv</p>
|
||||
<h1 class="max-w-[12ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Jahre, Gewinner und Show-Historie</h1>
|
||||
<p class="max-w-3xl text-lg leading-8 text-slate-600">
|
||||
Das Archiv macht Awards dauerhaft sichtbar und verlinkbar. Kategorien und Banner bleiben pro Jahr nachvollziehbar.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-12 pb-16">
|
||||
<PageHero
|
||||
eyebrow="Gewinner Archiv"
|
||||
title="Jahre, Gewinner und Show-Historie"
|
||||
description="Das Archiv macht Awards dauerhaft sichtbar und verlinkbar. Kategorien, Gewinner und Banner bleiben pro Jahr nachvollziehbar."
|
||||
:icon="Trophy"
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button
|
||||
<!-- Year tabs -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="year in years"
|
||||
: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)"
|
||||
>
|
||||
{{ year }}
|
||||
</Button>
|
||||
</button>
|
||||
</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
|
||||
v-for="item in store.archive.items"
|
||||
v-for="(item, index) in store.archive.items"
|
||||
: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>
|
||||
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ item.category }}</h2>
|
||||
<p class="mt-4 text-lg font-semibold text-slate-800">{{ item.winnerName }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ item.winnerSlug }}</p>
|
||||
<div :class="['relative h-[220px] 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 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>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-slate-400">Bilder sind Platzhalter – echte Banner werden pro Jahr im Admin gepflegt.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,184 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
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 AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||
import Button from '../../components/ui/Button.vue'
|
||||
import Card from '../../components/ui/Card.vue'
|
||||
import Modal from '../../components/ui/Modal.vue'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminCandidateItem } from '../../types/awards'
|
||||
|
||||
const store = useAwardsStore()
|
||||
const candidateSaving = ref<number | 'new' | null>(null)
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
const adminMessage = 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 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
|
||||
return candidates.filter((candidate) =>
|
||||
[candidate.displayName, candidate.channelSlug, candidate.platform, categoryLabelMap.value[candidate.categoryId] ?? '']
|
||||
/* ---------- Filter + Suche + Pagination ---------- */
|
||||
const search = ref('')
|
||||
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(' ')
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
)
|
||||
}
|
||||
return list
|
||||
})
|
||||
const hasFilters = computed(() => candidateFilter.value.trim().length > 0 || categoryFilter.value !== null)
|
||||
const canCreateCandidate = computed(() =>
|
||||
Boolean(selectedSeasonId.value && newCandidateForm.categoryId && newCandidateForm.displayName.trim() && newCandidateForm.channelSlug.trim()),
|
||||
)
|
||||
|
||||
watch(
|
||||
seasonDetail,
|
||||
(detail) => {
|
||||
for (const candidate of detail.candidates) {
|
||||
candidateForms[candidate.id] = {
|
||||
categoryId: candidate.categoryId,
|
||||
displayName: candidate.displayName,
|
||||
channelSlug: candidate.channelSlug,
|
||||
platform: candidate.platform,
|
||||
}
|
||||
}
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredCandidates.value.length / pageSize)))
|
||||
const pagedCandidates = computed(() => {
|
||||
const start = (page.value - 1) * pageSize
|
||||
return filteredCandidates.value.slice(start, start + pageSize)
|
||||
})
|
||||
const rangeStart = computed(() => (filteredCandidates.value.length === 0 ? 0 : (page.value - 1) * pageSize + 1))
|
||||
const rangeEnd = computed(() => Math.min(page.value * pageSize, filteredCandidates.value.length))
|
||||
|
||||
newCandidateForm.categoryId = detail.categories[0]?.id ?? 0
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
watch([search, categoryFilter, () => seasonDetail.value.candidates.length], () => {
|
||||
page.value = 1
|
||||
})
|
||||
watch(totalPages, (max) => {
|
||||
if (page.value > max) page.value = max
|
||||
})
|
||||
|
||||
function clearFilters() {
|
||||
candidateFilter.value = ''
|
||||
search.value = ''
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<AdminPageHeader
|
||||
eyebrow="Kandidaten"
|
||||
title="Kandidatenbasis pflegen"
|
||||
description="Schneller finden, sauber pruefen, gezielt bearbeiten: die Kandidaten sind jetzt nach Jahr, Kategorie und Handle besser steuerbar."
|
||||
title="Kandidaten verwalten"
|
||||
description="Suchen, filtern, anlegen, bearbeiten und löschen – auch bei vielen Nominierten bleibt die Liste übersichtlich."
|
||||
:icon="Users"
|
||||
/>
|
||||
|
||||
<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">
|
||||
<div class="border-b border-violet-100 bg-white/70 p-6">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<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">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-col gap-4 border-b border-violet-100 p-5 lg:flex-row lg:items-center">
|
||||
<label class="relative block flex-1">
|
||||
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||
<input
|
||||
v-model="candidateFilter"
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white/90 pl-11 pr-4 text-sm text-slate-700 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Name, Handle, Plattform oder Kategorie suchen"
|
||||
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 oder Plattform suchen …"
|
||||
/>
|
||||
</label>
|
||||
<Select
|
||||
@@ -186,130 +168,145 @@ function clearFilters() {
|
||||
:options="categoryFilterOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-full"
|
||||
class="w-full lg:w-72"
|
||||
/>
|
||||
<Button v-if="hasFilters" variant="ghost" @click="clearFilters">Filter loeschen</Button>
|
||||
</div>
|
||||
<Button v-if="search || categoryFilter" variant="ghost" class="gap-1" @click="clearFilters">
|
||||
<X class="h-4 w-4" /> Filter
|
||||
</Button>
|
||||
<Button class="gap-2" @click="openCreate">
|
||||
<UserPlus class="h-4 w-4" /> Kandidat anlegen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<div class="grid gap-4">
|
||||
<article
|
||||
v-for="candidate in filteredCandidates"
|
||||
<p v-if="adminMessage" class="border-b border-emerald-100 bg-emerald-50 px-5 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
|
||||
<p v-if="adminError" class="border-b border-rose-100 bg-rose-50 px-5 py-3 text-sm text-rose-700">{{ adminError }}</p>
|
||||
|
||||
<!-- 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"
|
||||
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="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<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 }}
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[linear-gradient(135deg,#c4b5fd,#f5a9d6)] text-sm font-semibold text-white">
|
||||
{{ candidate.displayName.charAt(0) }}
|
||||
</span>
|
||||
<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' }}
|
||||
</span>
|
||||
</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>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Neu</p>
|
||||
<h2 class="mt-1 font-[Cormorant_Garamond] text-4xl text-violet-800">Kandidat anlegen</h2>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">Erstelle bekannte Kandidaten direkt fuer die richtige Kategorie.</p>
|
||||
<span class="rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">{{ candidate.platform }}</span>
|
||||
</div>
|
||||
<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 class="mt-6 space-y-4">
|
||||
<label class="space-y-2">
|
||||
<div v-if="filteredCandidates.length === 0" class="px-6 py-12 text-center">
|
||||
<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>
|
||||
<Select
|
||||
v-model="newCandidateForm.categoryId"
|
||||
:options="categoryOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-full"
|
||||
/>
|
||||
<Select v-model="form.categoryId" :options="categoryOptions" option-label="label" option-value="value" class="w-full" />
|
||||
</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>
|
||||
<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 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>
|
||||
<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 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>
|
||||
<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>
|
||||
<Button class="w-full" :disabled="candidateSaving === 'new' || !canCreateCandidate" @click="createCandidate">
|
||||
{{ candidateSaving === 'new' ? 'Erstellt ...' : 'Kandidat anlegen' }}
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="modalOpen = false">Abbrechen</Button>
|
||||
<Button :disabled="saving || !canSave" @click="saveModal">{{ saving ? 'Speichert …' : 'Speichern' }}</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Modal: löschen bestätigen -->
|
||||
<Modal :open="!!candidateToDelete" title="Kandidat löschen?" @close="candidateToDelete = null">
|
||||
<div class="flex items-start gap-4">
|
||||
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-rose-50 text-rose-500">
|
||||
<TriangleAlert class="h-6 w-6" />
|
||||
</span>
|
||||
<p class="text-sm leading-7 text-slate-600">
|
||||
„<strong class="text-slate-800">{{ candidateToDelete?.displayName }}</strong>" wird endgültig aus diesem Award-Jahr entfernt.
|
||||
Das lässt sich nicht rückgängig machen.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="candidateToDelete = null">Abbrechen</Button>
|
||||
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="confirmDelete">
|
||||
{{ deleting ? 'Löscht …' : 'Endgültig löschen' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p v-if="adminMessage" class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
|
||||
{{ adminMessage }}
|
||||
</p>
|
||||
<p 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>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -5,4 +5,7 @@ import tailwindcss from '@tailwindcss/vite'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
server: {
|
||||
port: Number(process.env.PORT) || 5173,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user