Initial VTuber Awards implementation
This commit is contained in:
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user