Initial VTuber Awards implementation
This commit is contained in:
@@ -0,0 +1 @@
|
||||
VITE_API_URL=http://localhost:5084
|
||||
@@ -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?
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
Generated
+7825
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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()],
|
||||
})
|
||||
Reference in New Issue
Block a user