Initial VTuber Awards implementation

This commit is contained in:
AzuTear
2026-06-17 11:35:45 +02:00
commit 670259a983
74 changed files with 15797 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
VITE_API_URL=http://localhost:5084
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+7825
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@lucide/vue": "^1.20.0",
"@primeuix/themes": "^2.0.3",
"@tailwindcss/vite": "^4.3.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"pinia": "^3.0.4",
"primeicons": "^7.0.0",
"primevue": "^4.5.5",
"shadcn-vue": "^2.7.4",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1",
"vue": "^3.5.34",
"vue-router": "^5.1.0"
},
"devDependencies": {
"@types/node": "^24.12.3",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"vite": "^8.0.12",
"vue-tsc": "^3.2.8"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+11
View File
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import AppShell from './components/AppShell.vue'
</script>
<template>
<AppShell>
<RouterView />
</AppShell>
</template>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+122
View File
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { Star } from '@lucide/vue'
import Button from './ui/Button.vue'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const authStore = useAuthStore()
const loginOpen = ref(false)
const loginError = ref('')
const loginForm = reactive({
twitchUserId: 'jayuhime_demo',
displayName: 'Jayuhime',
role: 'viewer' as 'viewer' | 'admin',
})
const navItems = [
{ label: 'Home', to: '/' },
{ label: 'Nominierung', to: '/nominations' },
{ label: 'Voting', to: '/voting' },
{ label: 'Gewinner', to: '/winners' },
{ label: 'Admin', to: '/admin' },
]
const currentLabel = computed(
() => navItems.find((item) => item.to === route.path)?.label ?? 'Awards',
)
const visibleNavItems = computed(() =>
navItems.filter((item) => item.to !== '/admin' || authStore.isAdmin),
)
async function login(role: 'viewer' | 'admin') {
loginError.value = ''
loginForm.role = role
try {
await authStore.login(loginForm)
loginOpen.value = false
} catch (error) {
loginError.value = error instanceof Error ? error.message : 'Login fehlgeschlagen.'
}
}
</script>
<template>
<div class="min-h-screen overflow-x-hidden bg-[linear-gradient(180deg,#fffdf9_0%,#fff6ee_38%,#fff9f4_100%)] text-slate-800">
<div class="pointer-events-none absolute inset-x-0 top-0 h-[520px] bg-[radial-gradient(circle_at_top_left,_rgba(237,214,167,0.38),transparent_34%),radial-gradient(circle_at_top_right,_rgba(210,195,255,0.32),transparent_28%)]" />
<div class="pointer-events-none absolute left-1/2 top-36 h-[560px] w-[560px] -translate-x-1/2 rounded-full border border-white/70 opacity-70 blur-3xl" />
<div class="relative mx-auto w-full max-w-[1460px] px-4 py-4 sm:px-6 lg:px-10">
<div class="mb-5 flex items-center justify-between border-b border-black/8 px-2 pb-3 text-[11px] uppercase tracking-[0.34em] text-slate-500">
<span>VTuber Star Awards 2026</span>
<span>{{ currentLabel }}</span>
</div>
<header class="mb-10 flex flex-col gap-6 rounded-[34px] border border-white/70 bg-white/72 px-5 py-5 shadow-[0_24px_80px_rgba(93,63,135,0.08)] backdrop-blur lg:px-7">
<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 h-12 w-12 place-items-center rounded-[1.4rem] bg-[linear-gradient(135deg,#f6e3b2,#f5c877)] text-amber-950 shadow-[0_16px_28px_rgba(245,200,119,0.35)]">
<Star class="h-5 w-5" />
</div>
<div>
<strong class="block text-sm tracking-[0.35em]">VTUBER</strong>
<span class="block text-[11px] tracking-[0.45em] text-slate-500">STAR AWARDS</span>
</div>
</RouterLink>
<div class="flex flex-wrap items-center gap-3">
<template v-if="authStore.session">
<div class="rounded-full border border-violet-100 bg-violet-50/70 px-4 py-2 text-sm text-violet-800">
{{ authStore.session.displayName }} · {{ authStore.session.role }}
</div>
<Button variant="ghost" @click="authStore.logout()">Logout</Button>
</template>
<template v-else>
<Button variant="ghost" @click="loginOpen = !loginOpen">Sign in</Button>
<Button @click="login('viewer')">Mit Twitch Login</Button>
</template>
</div>
</div>
<div v-if="loginOpen && !authStore.session" class="rounded-[28px] border border-violet-100 bg-white/80 p-5">
<div class="grid gap-4 md:grid-cols-3">
<input v-model="loginForm.displayName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" />
<input v-model="loginForm.twitchUserId" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Twitch User ID" />
<div class="flex flex-wrap gap-3">
<Button :disabled="authStore.loading" @click="login('viewer')">
{{ authStore.loading ? 'Loggt ein ...' : 'Viewer Login' }}
</Button>
<Button variant="secondary" :disabled="authStore.loading" @click="login('admin')">Admin Login</Button>
</div>
</div>
<p v-if="loginError" class="mt-3 text-sm text-rose-700">{{ loginError }}</p>
</div>
<div class="flex flex-col gap-4 border-t border-black/6 pt-4 lg:flex-row lg:items-center lg:justify-between">
<nav class="flex flex-wrap items-center gap-2 text-sm text-slate-600">
<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 ? 'bg-violet-100 text-violet-800' : ''"
>
{{ item.label }}
</RouterLink>
</nav>
<div class="flex items-center gap-3 text-xs uppercase tracking-[0.24em] text-slate-500">
<span class="inline-flex h-2 w-2 rounded-full bg-emerald-500" />
Community Voting live
</div>
</div>
</header>
<slot />
</div>
</div>
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-xl text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-violet-600 text-white shadow-lg shadow-violet-500/20 hover:bg-violet-500',
secondary: 'border border-amber-300/60 bg-white text-amber-600 hover:bg-amber-50',
ghost: 'bg-white/70 text-slate-700 hover:bg-white',
},
size: {
default: 'h-11 px-5',
lg: 'h-12 px-6',
sm: 'h-9 px-4',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
const props = defineProps<{
variant?: VariantProps<typeof buttonVariants>['variant']
size?: VariantProps<typeof buttonVariants>['size']
class?: string
}>()
const classes = computed(() =>
cn(buttonVariants({ variant: props.variant, size: props.size }), props.class),
)
</script>
<template>
<button :class="classes">
<slot />
</button>
</template>
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { cn } from '../../lib/utils'
defineProps<{
class?: string
}>()
</script>
<template>
<div
:class="
cn(
'rounded-[28px] border border-violet-200/70 bg-white/80 shadow-[0_24px_60px_rgba(168,145,214,0.12)] backdrop-blur',
$props.class,
)
"
>
<slot />
</div>
</template>
+101
View File
@@ -0,0 +1,101 @@
import type {
AdminDashboardResponse,
AdminSeasonDetailResponse,
AdminSeasonListItem,
AuthSession,
CreateNominationPayload,
CreateVotePayload,
LoginPayload,
OverviewResponse,
SeasonCategoriesResponse,
UpdateSeasonPayload,
ApproveNominationPayload,
UpsertCandidatePayload,
UpsertCategoryPayload,
WinnerArchiveResponse,
} from '../types/awards'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:5084'
const AUTH_TOKEN_KEY = 'vtsa-session-token'
function getAuthToken() {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(AUTH_TOKEN_KEY)
}
async function getJson<T>(path: string): Promise<T> {
const token = getAuthToken()
const response = await fetch(`${API_URL}${path}`, {
headers: token
? {
Authorization: `Bearer ${token}`,
}
: undefined,
})
if (!response.ok) {
throw new Error(`API request failed for ${path}`)
}
return response.json() as Promise<T>
}
async function sendJson<TResponse>(path: string, method: 'POST' | 'PUT', body: unknown): Promise<TResponse> {
const token = getAuthToken()
const response = await fetch(`${API_URL}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
})
if (!response.ok) {
const error = await response.text()
throw new Error(error || `API request failed for ${path}`)
}
return response.json() as Promise<TResponse>
}
export const api = {
getOverview: () => getJson<OverviewResponse>('/api/public/overview'),
getSeasonCategories: (year: number) =>
getJson<SeasonCategoriesResponse>(`/api/public/seasons/${year}/categories`),
getWinnerArchive: (year: number) =>
getJson<WinnerArchiveResponse>(`/api/public/seasons/${year}/winners`),
getAdminDashboard: () => getJson<AdminDashboardResponse>('/api/admin/dashboard'),
getAdminSeasons: () => getJson<AdminSeasonListItem[]>('/api/admin/seasons'),
getAdminSeasonDetail: (seasonId: number) =>
getJson<AdminSeasonDetailResponse>(`/api/admin/seasons/${seasonId}`),
getSession: () => getJson<AuthSession>('/api/auth/session'),
login: (payload: LoginPayload) => sendJson<AuthSession>('/api/auth/dev-login', 'POST', payload),
logout: () => sendJson<{ loggedOut: boolean }>('/api/auth/logout', 'POST', {}),
submitNomination: (payload: CreateNominationPayload) =>
sendJson<{ saved: number; category: string }>('/api/public/nominations', 'POST', payload),
submitVote: (payload: CreateVotePayload) =>
sendJson<{ ballotId: number; entries: number }>('/api/public/votes', 'POST', payload),
updateAdminSeason: (seasonId: number, payload: UpdateSeasonPayload) =>
sendJson<{ saved: boolean; seasonId: number }>(`/api/admin/seasons/${seasonId}`, 'PUT', payload),
createAdminCategory: (seasonId: number, payload: UpsertCategoryPayload) =>
sendJson<{ saved: boolean; categoryId: number }>(`/api/admin/seasons/${seasonId}/categories`, 'POST', payload),
updateAdminCategory: (categoryId: number, payload: UpsertCategoryPayload) =>
sendJson<{ saved: boolean; categoryId: number }>(`/api/admin/categories/${categoryId}`, 'PUT', payload),
createAdminCandidate: (seasonId: number, payload: UpsertCandidatePayload) =>
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),
approveAdminNomination: (nominationId: number, payload: ApproveNominationPayload) =>
sendJson<{ saved: boolean; nominationId: number; candidateId: number; created: boolean }>(
`/api/admin/nominations/${nominationId}/approve`,
'POST',
payload,
),
rejectAdminNomination: (nominationId: number) =>
sendJson<{ saved: boolean; nominationId: number; rejected: boolean }>(
`/api/admin/nominations/${nominationId}/reject`,
'POST',
{},
),
}
export { AUTH_TOKEN_KEY }
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+29
View File
@@ -0,0 +1,29 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Aura from '@primeuix/themes/aura'
import App from './App.vue'
import router from './router'
import { useAuthStore } from './stores/auth'
import './style.css'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(PrimeVue, {
theme: {
preset: Aura,
options: {
darkModeSelector: false,
},
},
})
const authStore = useAuthStore(pinia)
void authStore.hydrate()
app.mount('#app')
+71
View File
@@ -0,0 +1,71 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from './stores/auth'
import AdminView from './views/AdminView.vue'
import HomeView from './views/HomeView.vue'
import NominationsView from './views/NominationsView.vue'
import VotingView from './views/VotingView.vue'
import WinnersView from './views/WinnersView.vue'
const router = createRouter({
history: createWebHistory(),
scrollBehavior() {
return { top: 0 }
},
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path: '/nominations',
name: 'nominations',
component: NominationsView,
meta: {
requiresAuth: true,
},
},
{
path: '/voting',
name: 'voting',
component: VotingView,
meta: {
requiresAuth: true,
},
},
{
path: '/winners',
name: 'winners',
component: WinnersView,
},
{
path: '/admin',
name: 'admin',
component: AdminView,
meta: {
requiresAdmin: true,
},
},
],
})
router.beforeEach(async (to) => {
const authStore = useAuthStore()
if (!authStore.hydrated) {
await authStore.hydrate()
}
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
return { name: 'home' }
}
if (to.meta.requiresAdmin && !authStore.isAdmin) {
return { name: 'home' }
}
return true
})
export default router
+68
View File
@@ -0,0 +1,68 @@
import { defineStore } from 'pinia'
import { AUTH_TOKEN_KEY, api } from '../lib/api'
import type { AuthSession, LoginPayload } from '../types/awards'
function readStoredToken() {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(AUTH_TOKEN_KEY)
}
function writeStoredToken(token: string | null) {
if (typeof window === 'undefined') return
if (token) {
window.localStorage.setItem(AUTH_TOKEN_KEY, token)
} else {
window.localStorage.removeItem(AUTH_TOKEN_KEY)
}
}
export const useAuthStore = defineStore('auth', {
state: () => ({
session: null as AuthSession | null,
hydrated: false,
loading: false,
}),
getters: {
isLoggedIn: (state) => Boolean(state.session),
isAdmin: (state) => state.session?.role === 'admin',
},
actions: {
async hydrate() {
if (!readStoredToken()) {
this.hydrated = true
return
}
try {
this.session = await api.getSession()
} catch {
this.session = null
writeStoredToken(null)
} finally {
this.hydrated = true
}
},
async login(payload: LoginPayload) {
this.loading = true
try {
const session = await api.login(payload)
this.session = session
writeStoredToken(session.sessionToken)
} finally {
this.loading = false
}
},
async logout() {
this.loading = true
try {
await api.logout()
} finally {
this.session = null
writeStoredToken(null)
this.loading = false
}
},
},
})
+273
View File
@@ -0,0 +1,273 @@
import { defineStore } from 'pinia'
import { api } from '../lib/api'
import type {
AdminDashboardResponse,
AdminSeasonDetailResponse,
ApproveNominationPayload,
AdminSeasonListItem,
CreateNominationPayload,
CreateVotePayload,
OverviewResponse,
SeasonCategoriesResponse,
UpdateSeasonPayload,
UpsertCandidatePayload,
UpsertCategoryPayload,
WinnerArchiveResponse,
} from '../types/awards'
const fallbackOverview: OverviewResponse = {
seasonId: 1,
year: 2026,
title: 'VTuber Star Awards 2026',
showDate: '2026-01-24',
currentPhase: 'Community Voting',
isCommunityOnly: true,
loginProvider: 'Twitch',
timeline: [
{ key: 'nomination', title: 'Nominierung', startsAt: '2026-05-01', endsAt: '2026-05-31', state: 'done' },
{ key: 'voting', title: 'Voting', startsAt: '2026-06-01', endsAt: '2026-06-30', state: 'active' },
{ key: 'review', title: 'Auswertung', startsAt: '2026-07-01', endsAt: '2026-07-10', state: 'upcoming' },
{ 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 },
],
winnersPreview: [
{ year: 2025, category: 'VTuber des Jahres', winnerName: 'Hoshimi Miyu', winnerSlug: '@hoshimimiyu' },
{ year: 2025, category: 'Bestes Live Event', winnerName: 'Kurainu 3D Live', winnerSlug: '@kurainu' },
{ 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.' },
],
}
const fallbackCategories: SeasonCategoriesResponse = {
seasonId: 1,
year: 2026,
categories: [
{
id: 1,
name: 'VTuber des Jahres',
groupName: 'Main Awards',
description: 'Die Hauptkategorie fuer die praegendste Creator-Praesenz des 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: 2,
name: 'Bestes Live Event',
groupName: 'Performance',
description: 'Konzerte, Sonderformate und grosse Community-Shows.',
maxNomineesPerUser: 3,
candidates: [
{ id: 4, displayName: 'Kurainu 3D Live', channelSlug: '@kurainu', platform: 'Twitch' },
{ id: 5, displayName: 'Aoi Sakura Showcase', channelSlug: '@aoisakura', platform: 'YouTube' },
],
},
],
}
const fallbackArchive: WinnerArchiveResponse = {
year: 2025,
items: [
{ category: 'VTuber des Jahres', winnerName: 'Hoshimi Miyu', winnerSlug: '@hoshimimiyu' },
{ category: 'Bestes Live Event', winnerName: 'Kurainu 3D Live', winnerSlug: '@kurainu' },
{ category: 'Clip des Jahres', winnerName: 'Pyonkichi Kingdom', winnerSlug: '@pyonkichikingdom' },
],
}
const fallbackAdmin: AdminDashboardResponse = {
metrics: [
{ label: 'Nominierungen', value: 12341, note: '+12.4% vs. gestern' },
{ label: 'Votes', value: 587231, note: '+8.7% vs. gestern' },
{ label: 'Kategorien', value: 28, note: 'aktiv im Jahr 2026' },
{ label: 'Reviews offen', value: 47, note: '14 neu' },
],
activities: [
{ label: 'Neue Nominierung in Best New VTuber', age: 'vor 2 Min.' },
{ label: 'Clip-Dublette erkannt in Clip des Jahres', age: 'vor 7 Min.' },
{ label: 'Alias-Merge fuer Hoshimi Miyu reviewt', age: 'vor 18 Min.' },
],
topCategories: [
{ category: 'VTuber des Jahres', votes: 186321 },
{ category: 'Bestes Live Event', votes: 132550 },
{ category: 'Clip des Jahres', votes: 98210 },
],
}
const fallbackAdminSeasons: AdminSeasonListItem[] = [
{ id: 1, year: 2026, name: 'VTuber Star Awards 2026', currentPhase: 'Community Voting', isCurrent: true, categoryCount: 4 },
{ id: 2, year: 2025, name: 'VTuber Star Awards 2025', currentPhase: 'Archived', isCurrent: false, categoryCount: 3 },
]
const fallbackAdminSeasonDetail: AdminSeasonDetailResponse = {
id: 1,
year: 2026,
name: 'VTuber Star Awards 2026',
currentPhase: 'Community Voting',
isCurrent: true,
categories: [
{
id: 1,
groupName: 'Main Awards',
name: 'VTuber des Jahres',
slug: 'vtuber-des-jahres',
description: 'Die groesste Auszeichnung des Jahres.',
sortOrder: 1,
maxNomineesPerUser: 3,
candidateCount: 3,
},
{
id: 2,
groupName: 'Performance',
name: 'Bestes Live Event',
slug: 'bestes-live-event',
description: 'Events, Konzerte und 3D-Shows.',
sortOrder: 2,
maxNomineesPerUser: 3,
candidateCount: 2,
},
],
candidates: [
{ id: 1, categoryId: 1, displayName: 'Hoshimi Miyu', channelSlug: '@hoshimimiyu', platform: 'Twitch' },
{ id: 2, categoryId: 1, displayName: 'Kurainu', channelSlug: '@kurainu', platform: 'Twitch' },
],
pendingNominations: [
{
id: 1,
categoryId: 1,
categoryName: 'VTuber des Jahres',
submittedByTwitchId: 'demo_user',
candidateText: 'Session Nominee',
createdAt: '2026-06-17T08:00:00Z',
},
],
}
const emptyAdmin: AdminDashboardResponse = {
metrics: [],
activities: [],
topCategories: [],
}
const emptyAdminSeasons: AdminSeasonListItem[] = []
const emptyAdminSeasonDetail: AdminSeasonDetailResponse = {
id: 0,
year: 0,
name: '',
currentPhase: '',
isCurrent: false,
categories: [],
candidates: [],
pendingNominations: [],
}
export const useAwardsStore = defineStore('awards', {
state: () => ({
overview: fallbackOverview as OverviewResponse,
categories: fallbackCategories as SeasonCategoriesResponse,
archive: fallbackArchive as WinnerArchiveResponse,
admin: fallbackAdmin as AdminDashboardResponse,
adminSeasons: fallbackAdminSeasons as AdminSeasonListItem[],
adminSeasonDetail: fallbackAdminSeasonDetail as AdminSeasonDetailResponse,
loading: false,
apiMode: 'fallback' as 'api' | 'fallback',
}),
actions: {
async loadHomeData() {
this.loading = true
try {
this.overview = await api.getOverview()
this.categories = await api.getSeasonCategories(this.overview.year)
this.archive = await api.getWinnerArchive(this.overview.winnersPreview[0]?.year ?? this.overview.year - 1)
this.apiMode = 'api'
} catch {
this.apiMode = 'fallback'
} finally {
this.loading = false
}
},
async loadArchive(year: number) {
try {
this.archive = await api.getWinnerArchive(year)
this.apiMode = 'api'
} catch {
this.archive = { ...fallbackArchive, year }
}
},
async loadAdmin() {
try {
this.admin = await api.getAdminDashboard()
this.adminSeasons = await api.getAdminSeasons()
this.adminSeasonDetail = await api.getAdminSeasonDetail(this.adminSeasons[0]?.id ?? 1)
this.apiMode = 'api'
} catch {
this.admin = emptyAdmin
this.adminSeasons = emptyAdminSeasons
this.adminSeasonDetail = emptyAdminSeasonDetail
}
},
async loadAdminSeasonDetail(seasonId: number) {
try {
this.adminSeasonDetail = await api.getAdminSeasonDetail(seasonId)
this.apiMode = 'api'
} catch {
this.adminSeasonDetail = emptyAdminSeasonDetail
}
},
submitNomination(payload: CreateNominationPayload) {
return api.submitNomination(payload)
},
submitVote(payload: CreateVotePayload) {
return api.submitVote(payload)
},
async updateAdminSeason(seasonId: number, payload: UpdateSeasonPayload) {
const result = await api.updateAdminSeason(seasonId, payload)
await this.loadAdmin()
return result
},
async createAdminCategory(seasonId: number, payload: UpsertCategoryPayload) {
const result = await api.createAdminCategory(seasonId, payload)
await this.loadAdminSeasonDetail(seasonId)
return result
},
async updateAdminCategory(categoryId: number, seasonId: number, payload: UpsertCategoryPayload) {
const result = await api.updateAdminCategory(categoryId, payload)
await this.loadAdminSeasonDetail(seasonId)
return result
},
async createAdminCandidate(seasonId: number, payload: UpsertCandidatePayload) {
const result = await api.createAdminCandidate(seasonId, payload)
await this.loadAdminSeasonDetail(seasonId)
return result
},
async updateAdminCandidate(candidateId: number, seasonId: number, payload: UpsertCandidatePayload) {
const result = await api.updateAdminCandidate(candidateId, payload)
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)
await this.loadAdmin()
return result
},
async rejectAdminNomination(nominationId: number, seasonId: number) {
const result = await api.rejectAdminNomination(nominationId)
await this.loadAdminSeasonDetail(seasonId)
await this.loadAdmin()
return result
},
},
})
+29
View File
@@ -0,0 +1,29 @@
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Manrope:wght@400;500;600;700;800&display=swap");
@import "tailwindcss";
@import "primeicons/primeicons.css";
@theme {
--font-display: "Cormorant Garamond", serif;
--font-sans: "Manrope", sans-serif;
}
html,
body,
#app {
min-height: 100%;
}
body {
font-family: var(--font-sans);
color: #1f2430;
background-color: #fffaf4;
}
::selection {
background: rgba(137, 92, 246, 0.18);
color: #2e1065;
}
a {
color: inherit;
}
+202
View File
@@ -0,0 +1,202 @@
export interface TimelineItem {
key: string
title: string
startsAt: string
endsAt: string
state: 'done' | 'active' | 'upcoming'
}
export interface FeaturedCategory {
id: number
groupName: string
name: string
description: string
maxNomineesPerUser: number
}
export interface WinnerPreview {
year: number
category: string
winnerName: string
winnerSlug: string
}
export interface FaqItem {
question: string
answer: string
}
export interface OverviewResponse {
seasonId: number
year: number
title: string
showDate: string
currentPhase: string
isCommunityOnly: boolean
loginProvider: string
timeline: TimelineItem[]
featuredCategories: FeaturedCategory[]
winnersPreview: WinnerPreview[]
faq: FaqItem[]
}
export interface CandidateSummary {
id: number
displayName: string
channelSlug: string
platform: string
}
export interface PublicCategoryDetail {
id: number
name: string
groupName: string
description: string
maxNomineesPerUser: number
candidates: CandidateSummary[]
}
export interface SeasonCategoriesResponse {
seasonId: number
year: number
categories: PublicCategoryDetail[]
}
export interface WinnerArchiveItem {
category: string
winnerName: string
winnerSlug: string
}
export interface WinnerArchiveResponse {
year: number
items: WinnerArchiveItem[]
}
export interface AdminMetric {
label: string
value: number
note: string
}
export interface AdminActivity {
label: string
age: string
}
export interface AdminTopCategory {
category: string
votes: number
}
export interface AdminDashboardResponse {
metrics: AdminMetric[]
activities: AdminActivity[]
topCategories: AdminTopCategory[]
}
export interface AdminSeasonListItem {
id: number
year: number
name: string
currentPhase: string
isCurrent: boolean
categoryCount: number
}
export interface AdminCategoryItem {
id: number
groupName: string
name: string
slug: string
description: string
sortOrder: number
maxNomineesPerUser: number
candidateCount: number
}
export interface AdminCandidateItem {
id: number
categoryId: number
displayName: string
channelSlug: string
platform: string
}
export interface AdminNominationReviewItem {
id: number
categoryId: number
categoryName: string
submittedByTwitchId: string
candidateText: string
createdAt: string
}
export interface AdminSeasonDetailResponse {
id: number
year: number
name: string
currentPhase: string
isCurrent: boolean
categories: AdminCategoryItem[]
candidates: AdminCandidateItem[]
pendingNominations: AdminNominationReviewItem[]
}
export interface CreateNominationPayload {
year: number
categoryId: number
twitchUserId: string
nominees: string[]
}
export interface VoteEntryPayload {
categoryId: number
candidateId: number
}
export interface CreateVotePayload {
seasonId: number
twitchUserId: string
entries: VoteEntryPayload[]
}
export interface UpdateSeasonPayload {
currentPhase: string
isCurrent: boolean
}
export interface UpsertCategoryPayload {
groupName: string
name: string
slug: string
description: string
sortOrder: number
maxNomineesPerUser: number
}
export interface UpsertCandidatePayload {
categoryId: number
displayName: string
channelSlug: string
platform: string
}
export interface ApproveNominationPayload {
displayName: string
channelSlug: string
platform: string
}
export interface AuthSession {
sessionToken: string
twitchUserId: string
displayName: string
role: 'viewer' | 'admin'
}
export interface LoginPayload {
twitchUserId: string
displayName: string
role: 'viewer' | 'admin'
}
+560
View File
@@ -0,0 +1,560 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Select from 'primevue/select'
import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue'
import { useAwardsStore } from '../stores/awards'
import { useAuthStore } from '../stores/auth'
const store = useAwardsStore()
const authStore = useAuthStore()
const selectedSeasonId = ref<number | null>(null)
const seasonSaving = ref(false)
const categorySaving = ref<number | 'new' | null>(null)
const candidateSaving = ref<number | 'new' | null>(null)
const reviewSaving = ref<number | null>(null)
const adminMessage = ref('')
const adminError = ref('')
const seasonForm = reactive({
currentPhase: '',
isCurrent: false,
})
const newCategoryForm = reactive({
groupName: '',
name: '',
slug: '',
description: '',
sortOrder: 1,
maxNomineesPerUser: 3,
})
const newCandidateForm = reactive({
categoryId: 0,
displayName: '',
channelSlug: '',
platform: 'Twitch',
})
const editForms = reactive<Record<number, {
groupName: string
name: string
slug: string
description: string
sortOrder: number
maxNomineesPerUser: number
}>>({})
const candidateForms = reactive<Record<number, {
categoryId: number
displayName: string
channelSlug: string
platform: string
}>>({})
const reviewForms = reactive<Record<number, {
displayName: string
channelSlug: string
platform: string
}>>({})
onMounted(async () => {
if (!authStore.isAdmin) return
await store.loadAdmin()
selectedSeasonId.value = store.adminSeasons[0]?.id ?? null
})
const metrics = computed(() => store.admin.metrics)
const activities = computed(() => store.admin.activities)
const topCategories = computed(() => store.admin.topCategories)
const seasons = computed(() => store.adminSeasons)
const seasonDetail = computed(() => store.adminSeasonDetail)
const categoryOptions = computed(() =>
seasonDetail.value.categories.map((category) => ({
label: `${category.groupName} · ${category.name}`,
value: category.id,
})),
)
watch(selectedSeasonId, async (seasonId) => {
if (!seasonId) return
await store.loadAdminSeasonDetail(seasonId)
})
watch(
seasonDetail,
(detail) => {
seasonForm.currentPhase = detail.currentPhase
seasonForm.isCurrent = detail.isCurrent
for (const category of detail.categories) {
editForms[category.id] = {
groupName: category.groupName,
name: category.name,
slug: category.slug,
description: category.description,
sortOrder: category.sortOrder,
maxNomineesPerUser: category.maxNomineesPerUser,
}
}
for (const candidate of detail.candidates) {
candidateForms[candidate.id] = {
categoryId: candidate.categoryId,
displayName: candidate.displayName,
channelSlug: candidate.channelSlug,
platform: candidate.platform,
}
}
for (const nomination of detail.pendingNominations) {
reviewForms[nomination.id] = {
displayName: nomination.candidateText,
channelSlug: '',
platform: 'Twitch',
}
}
newCandidateForm.categoryId = detail.categories[0]?.id ?? 0
},
{ immediate: true },
)
const seasonOptions = computed(() =>
seasons.value.map((season) => ({
label: `${season.year} · ${season.name}`,
value: season.id,
})),
)
async function saveSeason() {
if (!selectedSeasonId.value) return
seasonSaving.value = true
adminMessage.value = ''
adminError.value = ''
try {
await store.updateAdminSeason(selectedSeasonId.value, {
currentPhase: seasonForm.currentPhase,
isCurrent: seasonForm.isCurrent,
})
await store.loadAdminSeasonDetail(selectedSeasonId.value)
adminMessage.value = 'Season-Einstellungen gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Season konnte nicht gespeichert werden.'
} finally {
seasonSaving.value = false
}
}
async function saveCategory(categoryId: number) {
if (!selectedSeasonId.value) return
categorySaving.value = categoryId
adminMessage.value = ''
adminError.value = ''
try {
await store.updateAdminCategory(categoryId, selectedSeasonId.value, editForms[categoryId])
adminMessage.value = 'Kategorie gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht gespeichert werden.'
} finally {
categorySaving.value = null
}
}
async function createCategory() {
if (!selectedSeasonId.value) return
categorySaving.value = 'new'
adminMessage.value = ''
adminError.value = ''
try {
await store.createAdminCategory(selectedSeasonId.value, newCategoryForm)
adminMessage.value = 'Neue Kategorie angelegt.'
newCategoryForm.groupName = ''
newCategoryForm.name = ''
newCategoryForm.slug = ''
newCategoryForm.description = ''
newCategoryForm.sortOrder = seasonDetail.value.categories.length + 1
newCategoryForm.maxNomineesPerUser = 3
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht angelegt werden.'
} finally {
categorySaving.value = null
}
}
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 (!selectedSeasonId.value || !newCandidateForm.categoryId) 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
}
}
async function approveNomination(nominationId: number) {
if (!selectedSeasonId.value) return
reviewSaving.value = nominationId
adminMessage.value = ''
adminError.value = ''
try {
await store.approveAdminNomination(nominationId, selectedSeasonId.value, reviewForms[nominationId])
adminMessage.value = 'Nominierung wurde in die Kandidatenliste uebernommen.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht uebernommen werden.'
} finally {
reviewSaving.value = null
}
}
async function rejectNomination(nominationId: number) {
if (!selectedSeasonId.value) return
reviewSaving.value = nominationId
adminMessage.value = ''
adminError.value = ''
try {
await store.rejectAdminNomination(nominationId, selectedSeasonId.value)
adminMessage.value = 'Nominierung wurde aus der Review Queue entfernt.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht verworfen werden.'
} finally {
reviewSaving.value = null
}
}
</script>
<template>
<div class="space-y-10 pb-14">
<Card v-if="!authStore.isAdmin" class="p-8">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Admin Access</p>
<h1 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Admin Login erforderlich</h1>
<p class="mt-4 max-w-2xl text-lg leading-8 text-slate-600">
Bitte melde dich ueber den Header mit einem Admin-Login an, damit Season-, Category-, Candidate- und Review-Management verfuegbar werden.
</p>
</Card>
<template v-else>
<div class="space-y-4">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Admin</p>
<h1 class="max-w-[13ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Betriebswerkzeug fuer Seasons, Kategorien, Kandidaten und Review-Flows</h1>
<p class="max-w-3xl text-lg leading-8 text-slate-600">
Das Team pflegt das Jahres-Setup und die operativen Awards-Inhalte direkt aus einer zusammenhaengenden Admin-Oberflaeche.
</p>
</div>
<div class="grid gap-5 lg:grid-cols-4">
<Card
v-for="metric in metrics"
:key="metric.label"
class="p-7"
>
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ metric.label }}</p>
<strong class="mt-4 block text-4xl text-violet-800">{{ metric.value.toLocaleString('de-DE') }}</strong>
<p class="mt-2 text-sm text-slate-500">{{ metric.note }}</p>
</Card>
</div>
<div class="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
<Card class="p-7">
<div class="flex flex-col gap-6">
<div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Season Setup</h2>
<p class="mt-2 text-sm text-slate-500">Aktive Season auswaehlen, Phase anpassen und bei Bedarf zum aktuellen Jahr machen.</p>
</div>
<div class="space-y-3">
<label class="text-sm font-semibold text-slate-600">Season</label>
<Select
v-model="selectedSeasonId"
:options="seasonOptions"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div class="space-y-3">
<label class="text-sm font-semibold text-slate-600">Phase</label>
<input
v-model="seasonForm.currentPhase"
type="text"
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3"
/>
</div>
<label class="flex items-center gap-3 rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-4 text-sm text-slate-700">
<input v-model="seasonForm.isCurrent" type="checkbox" class="h-4 w-4 accent-violet-600" />
Diese Season ist die aktuelle Public Season
</label>
<div class="flex flex-wrap items-center gap-4">
<Button :disabled="seasonSaving || !selectedSeasonId" @click="saveSeason">
{{ seasonSaving ? 'Speichert ...' : 'Season speichern' }}
</Button>
<span class="text-sm text-slate-500">
{{ seasonDetail.year }} · {{ seasonDetail.name }}
</span>
</div>
<p v-if="adminMessage" class="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="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{{ adminError }}
</p>
</div>
</Card>
<Card class="p-7">
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Top Kategorien nach Votes</h2>
<DataTable :value="topCategories" class="mt-6" striped-rows>
<Column field="category" header="Kategorie" />
<Column field="votes" header="Votes">
<template #body="{ data }">
{{ Number(data.votes).toLocaleString('de-DE') }}
</template>
</Column>
</DataTable>
</Card>
</div>
<div class="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
<Card class="p-7">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorien der Season</h2>
<p class="mt-2 text-sm text-slate-500">Sortierung, Slugs und Limits werden hier pro Jahr gepflegt.</p>
</div>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
{{ seasonDetail.categories.length }} Kategorien
</span>
</div>
<div class="mt-6 space-y-4">
<div
v-for="category in seasonDetail.categories"
:key="category.id"
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
>
<div class="grid gap-4 md:grid-cols-2">
<input v-model="editForms[category.id].groupName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Group" />
<input v-model="editForms[category.id].name" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Name" />
<input v-model="editForms[category.id].slug" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Slug" />
<input v-model="editForms[category.id].sortOrder" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Sortierung" />
<input v-model="editForms[category.id].maxNomineesPerUser" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Limit" />
<div class="flex items-center rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
{{ category.candidateCount }} Kandidaten in dieser Kategorie
</div>
</div>
<textarea
v-model="editForms[category.id].description"
class="mt-4 min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3"
placeholder="Beschreibung"
/>
<div class="mt-4 flex justify-end">
<Button :disabled="categorySaving === category.id" @click="saveCategory(category.id)">
{{ categorySaving === category.id ? 'Speichert ...' : 'Kategorie speichern' }}
</Button>
</div>
</div>
</div>
</Card>
<Card class="p-7">
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Neue Kategorie</h2>
<div class="mt-6 space-y-4">
<input v-model="newCategoryForm.groupName" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Group Name" />
<input v-model="newCategoryForm.name" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Kategorie-Name" />
<input v-model="newCategoryForm.slug" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Slug" />
<textarea v-model="newCategoryForm.description" class="min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Beschreibung" />
<div class="grid gap-4 sm:grid-cols-2">
<input v-model="newCategoryForm.sortOrder" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Sortierung" />
<input v-model="newCategoryForm.maxNomineesPerUser" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Max Nominees" />
</div>
<Button :disabled="categorySaving === 'new' || !selectedSeasonId" @click="createCategory">
{{ categorySaving === 'new' ? 'Erstellt ...' : 'Kategorie anlegen' }}
</Button>
</div>
</Card>
</div>
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<Card class="p-7">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Kandidatenpflege</h2>
<p class="mt-2 text-sm text-slate-500">Bekannte Kandidaten koennen pro Kategorie gepflegt und fuer Voting/Archiv genutzt werden.</p>
</div>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
{{ seasonDetail.candidates.length }} Kandidaten
</span>
</div>
<div class="mt-6 space-y-4">
<div
v-for="candidate in seasonDetail.candidates"
:key="candidate.id"
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
>
<div class="grid gap-4 md:grid-cols-2">
<Select
v-model="candidateForms[candidate.id].categoryId"
:options="categoryOptions"
option-label="label"
option-value="value"
class="w-full"
/>
<input v-model="candidateForms[candidate.id].displayName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" />
<input v-model="candidateForms[candidate.id].channelSlug" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="@channel" />
<input v-model="candidateForms[candidate.id].platform" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Platform" />
</div>
<div class="mt-4 flex justify-end">
<Button :disabled="candidateSaving === candidate.id" @click="saveCandidate(candidate.id)">
{{ candidateSaving === candidate.id ? 'Speichert ...' : 'Kandidat speichern' }}
</Button>
</div>
</div>
</div>
</Card>
<Card class="p-7">
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Neuer Kandidat</h2>
<div class="mt-6 space-y-4">
<Select
v-model="newCandidateForm.categoryId"
:options="categoryOptions"
option-label="label"
option-value="value"
class="w-full"
/>
<input v-model="newCandidateForm.displayName" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" />
<input v-model="newCandidateForm.channelSlug" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="@channel" />
<input v-model="newCandidateForm.platform" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Platform" />
<Button :disabled="candidateSaving === 'new' || !selectedSeasonId || !newCandidateForm.categoryId" @click="createCandidate">
{{ candidateSaving === 'new' ? 'Erstellt ...' : 'Kandidat anlegen' }}
</Button>
</div>
<h3 class="mt-10 font-[Cormorant_Garamond] text-3xl text-violet-800">Letzte Aktivitaeten</h3>
<div class="mt-4 space-y-4">
<div
v-for="activity in activities"
:key="activity.label"
class="rounded-[26px] border border-violet-100 bg-violet-50/60 px-5 py-5"
>
<p class="font-semibold text-slate-800">{{ activity.label }}</p>
<p class="mt-1 text-sm text-slate-500">{{ activity.age }}</p>
</div>
</div>
</Card>
</div>
<Card class="p-7">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Review Queue</h2>
<p class="mt-2 text-sm text-slate-500">Freitext-Nominierungen und Alias-Faelle, die das Team direkt in Kandidaten ueberfuehren oder verwerfen kann.</p>
</div>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
{{ seasonDetail.pendingNominations.length }} offen
</span>
</div>
<div class="mt-6 space-y-4">
<div
v-for="nomination in seasonDetail.pendingNominations"
:key="nomination.id"
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
>
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">{{ nomination.categoryName }}</p>
<h3 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ nomination.candidateText }}</h3>
<p class="mt-2 text-sm text-slate-500">
Von {{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
</p>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
ID {{ nomination.id }}
</div>
</div>
<div class="mt-5 grid gap-4 md:grid-cols-3">
<input
v-model="reviewForms[nomination.id].displayName"
type="text"
class="rounded-2xl border border-violet-200 px-4 py-3"
placeholder="Display Name"
/>
<input
v-model="reviewForms[nomination.id].channelSlug"
type="text"
class="rounded-2xl border border-violet-200 px-4 py-3"
placeholder="@channel"
/>
<input
v-model="reviewForms[nomination.id].platform"
type="text"
class="rounded-2xl border border-violet-200 px-4 py-3"
placeholder="Platform"
/>
</div>
<div class="mt-4 flex flex-wrap justify-end gap-3">
<Button :disabled="reviewSaving === nomination.id" variant="secondary" @click="rejectNomination(nomination.id)">
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Verwerfen' }}
</Button>
<Button :disabled="reviewSaving === nomination.id" @click="approveNomination(nomination.id)">
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Als Kandidat uebernehmen' }}
</Button>
</div>
</div>
</div>
</Card>
</template>
</div>
</template>
+311
View File
@@ -0,0 +1,311 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { RouterLink } 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 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'
const store = useAwardsStore()
const authStore = useAuthStore()
onMounted(() => {
void store.loadHomeData()
})
const heroYear = computed(() => store.overview.year)
</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
</h1>
<p class="text-2xl font-medium italic tracking-wide text-violet-500">
Presented by Jayuhime
</p>
<p class="max-w-lg text-lg leading-8 text-slate-600">
Feiere die talentiertesten VTuber, Creator und Showmomente des Jahres.
Kategorien und Unterkategorien werden vom Team pro Jahr gepflegt, die Gewinner sind aktuell rein community-basiert.
</p>
</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]">Community powered</span>
</div>
<p class="mt-5 text-sm leading-7 text-slate-600">
Twitch Login only, keine Konto-Huerde, editierbare Votes 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 Season gepflegt und freigeschaltet.
</p>
</Card>
</div>
<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: Twitch only, 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">Session Status</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 Header 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">Community only</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">Season-first Management</h2>
<p class="mt-3 text-slate-600">
Jahre, Kategorien, Unterkategorien, Gewinnerarchiv und Reviews werden als saisonale Inhalte gedacht, nicht als harte statische App-Texte.
</p>
</Card>
</section>
<section class="space-y-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Featured Kategorien</p>
<h2 class="font-[Cormorant_Garamond] text-5xl text-violet-800">Team-gesteuerte Awards fuer {{ heroYear }}</h2>
</div>
<span class="text-sm text-slate-500">
API-Modus:
<strong class="text-violet-700">{{ store.apiMode }}</strong>
</span>
</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 Queue.</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 Seasons sichtbar machen</h2>
<p class="mt-4 text-slate-600">
Gewinner, Nominierte und Banner werden pro Jahr archiviert. So bleibt die Show-Historie dauerhaft sichtbar und teilbar.
</p>
<div class="mt-6 space-y-3">
<div
v-for="entry in store.overview.winnersPreview"
:key="`${entry.year}-${entry.category}`"
class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3"
>
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-semibold text-slate-800">{{ entry.category }}</p>
<p class="text-sm text-slate-500">{{ entry.winnerName }} · {{ entry.winnerSlug }}</p>
</div>
<Tag :value="entry.year.toString()" severity="info" />
</div>
</div>
</div>
</Card>
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">FAQ</p>
<h2 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Regeln, Voting, Missbrauchsschutz</h2>
<Accordion value="0" class="mt-6">
<AccordionPanel v-for="(item, index) in store.overview.faq" :key="item.question" :value="String(index)">
<AccordionHeader>{{ item.question }}</AccordionHeader>
<AccordionContent>
<p class="leading-7 text-slate-600">{{ item.answer }}</p>
</AccordionContent>
</AccordionPanel>
</Accordion>
</Card>
</section>
</div>
</template>
+154
View File
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import Select from 'primevue/select'
import { useAwardsStore } from '../stores/awards'
import { useAuthStore } from '../stores/auth'
import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue'
const store = useAwardsStore()
const authStore = useAuthStore()
const selectedCategoryId = ref<number | null>(null)
const nomineeName = ref('')
const nominees = ref<string[]>(['Hoshimi Miyu', 'Kurainu'])
const submitting = ref(false)
const submitMessage = ref('')
const submitError = ref('')
onMounted(async () => {
await store.loadHomeData()
selectedCategoryId.value = store.categories.categories[0]?.id ?? null
})
const categories = computed(() =>
store.categories.categories.map((category) => ({
label: category.name,
value: category.id,
})),
)
const selectedCategory = computed(() =>
store.categories.categories.find((category) => category.id === selectedCategoryId.value),
)
function addNominee() {
const value = nomineeName.value.trim()
if (!value || nominees.value.includes(value) || nominees.value.length >= 3) return
nominees.value = [...nominees.value, value]
nomineeName.value = ''
}
function removeNominee(name: string) {
nominees.value = nominees.value.filter((entry) => entry !== name)
}
async function submitNomination() {
if (!selectedCategoryId.value || nominees.value.length === 0) return
submitting.value = true
submitMessage.value = ''
submitError.value = ''
try {
const response = await store.submitNomination({
year: store.categories.year,
categoryId: selectedCategoryId.value,
twitchUserId: authStore.session?.twitchUserId ?? '',
nominees: nominees.value,
})
submitMessage.value = `${response.saved} Nominierungen fuer ${response.category} gespeichert.`
} catch (error) {
submitError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht gespeichert werden.'
} finally {
submitting.value = false
}
}
</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>
<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 Queue.</li>
<li>Bereits gespeicherte Entwuerfe koennen bis zur Deadline bearbeitet werden.</li>
</ul>
</Card>
<Card class="p-7">
<div class="grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
<div class="space-y-5">
<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 Header mit einem Twitch-Account einloggen, damit die Nominierung gespeichert werden kann.
</p>
<label class="text-sm font-semibold text-slate-600">Kategorie</label>
<Select
v-model="selectedCategoryId"
:options="categories"
option-label="label"
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>
<input
v-model="nomineeName"
type="text"
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3"
placeholder="z. B. Shiro Ch."
/>
<Button @click="addNominee">Nominierung hinzufuegen</Button>
</div>
</div>
<div class="space-y-4">
<h3 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Dein Entwurf</h3>
<div
v-for="name in nominees"
:key="name"
class="flex items-center justify-between rounded-[26px] border border-violet-100 bg-white/85 px-5 py-5"
>
<div>
<p class="font-semibold text-slate-800">{{ name }}</p>
<p class="text-sm text-slate-500">@{{ name.toLowerCase().replace(/\s+/g, '') }}</p>
</div>
<button class="text-sm font-semibold text-rose-500" @click="removeNominee(name)">Entfernen</button>
</div>
<p class="rounded-[26px] border border-dashed border-violet-200 bg-violet-50/60 px-5 py-5 text-sm text-slate-600">
Live-Status: {{ nominees.length }}/3 Slots belegt.
</p>
<p v-if="submitMessage" class="rounded-[26px] border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-700">
{{ 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' }}
</Button>
</div>
</div>
</Card>
</div>
</div>
</template>
+137
View File
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import Select from 'primevue/select'
import RadioButton from 'primevue/radiobutton'
import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue'
import { useAwardsStore } from '../stores/awards'
import { useAuthStore } from '../stores/auth'
const store = useAwardsStore()
const authStore = useAuthStore()
const selectedCategoryId = ref<number | null>(null)
const selectedCandidateId = ref<number | null>(null)
const submitting = ref(false)
const submitMessage = ref('')
const submitError = ref('')
onMounted(async () => {
await store.loadHomeData()
selectedCategoryId.value = store.categories.categories[0]?.id ?? null
})
const categoryOptions = computed(() =>
store.categories.categories.map((category) => ({
label: category.name,
value: category.id,
})),
)
const category = computed(() =>
store.categories.categories.find((item) => item.id === selectedCategoryId.value) ?? store.categories.categories[0],
)
async function submitVote() {
if (!category.value || !selectedCandidateId.value) return
submitting.value = true
submitMessage.value = ''
submitError.value = ''
try {
const response = await store.submitVote({
seasonId: store.categories.seasonId,
twitchUserId: authStore.session?.twitchUserId ?? '',
entries: [
{
categoryId: category.value.id,
candidateId: selectedCandidateId.value,
},
],
})
submitMessage.value = `Ballot #${response.ballotId} mit ${response.entries} Eintrag gespeichert.`
} catch (error) {
submitError.value = error instanceof Error ? error.message : 'Vote konnte nicht gespeichert werden.'
} finally {
submitting.value = false
}
}
</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>
<Card class="p-7">
<div class="grid gap-7 lg:grid-cols-[0.72fr_1.28fr]">
<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 Header mit einem Twitch-Account einloggen, damit deine Stimme gespeichert werden kann.
</p>
<div class="space-y-3">
<label class="text-sm font-semibold text-slate-600">Kategorie</label>
<Select
v-model="selectedCategoryId"
:options="categoryOptions"
option-label="label"
option-value="value"
class="w-full"
/>
</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>
</div>
<div class="space-y-4">
<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"
>
<div>
<p class="font-semibold text-slate-800">{{ candidate.displayName }}</p>
<p class="text-sm text-slate-500">{{ candidate.channelSlug }} · {{ candidate.platform }}</p>
</div>
<RadioButton
v-model="selectedCandidateId"
:input-id="`candidate-${candidate.id}`"
:name="category?.name"
:value="candidate.id"
/>
</label>
</div>
</div>
<div class="mt-7 flex flex-wrap items-center justify-between gap-4 rounded-[28px] bg-violet-50/60 px-6 py-5">
<div class="space-y-2">
<p class="text-sm text-slate-600">
Auswahl:
<strong class="text-violet-700">
{{ category?.candidates.find((candidate) => candidate.id === selectedCandidateId)?.displayName ?? 'Noch keine Stimme abgegeben' }}
</strong>
</p>
<p 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>
</div>
</Card>
</div>
</template>
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue'
import { useAwardsStore } from '../stores/awards'
const store = useAwardsStore()
const years = [2025, 2024, 2023, 2022]
const activeYear = ref(2025)
onMounted(async () => {
await store.loadHomeData()
await store.loadArchive(activeYear.value)
})
async function selectYear(year: number) {
activeYear.value = year
await store.loadArchive(year)
}
</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">Seasons, 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="flex flex-wrap gap-3">
<Button
v-for="year in years"
:key="year"
:variant="activeYear === year ? 'default' : 'ghost'"
@click="selectYear(year)"
>
{{ year }}
</Button>
</div>
<div class="grid gap-5 lg:grid-cols-3">
<Card
v-for="item in store.archive.items"
:key="`${store.archive.year}-${item.category}`"
class="p-7"
>
<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>
</Card>
</div>
</div>
</template>
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), tailwindcss()],
})