Refactor app architecture and clean local artifacts

This commit is contained in:
AzuTear
2026-06-24 23:43:14 +02:00
parent 17134b3b82
commit fef1d36fe8
274 changed files with 37724 additions and 6065 deletions
+38 -4
View File
@@ -1,7 +1,11 @@
import { defineStore } from 'pinia'
import { AUTH_TOKEN_KEY, api } from '../lib/api'
import type { AuthSession, LoginPayload } from '../types/awards'
import { ApiRequestError } from '../lib/http'
import type { AuthSession, DemoLoginPayload, LoginPayload } from '../types/awards'
const adminAccessRoles = new Set(['content_admin', 'admin', 'owner'])
const adminWorkspaceRoles = new Set(['admin', 'owner'])
function readStoredToken() {
if (typeof window === 'undefined') return null
@@ -26,7 +30,12 @@ export const useAuthStore = defineStore('auth', {
}),
getters: {
isLoggedIn: (state) => Boolean(state.session),
isAdmin: (state) => state.session?.role === 'admin',
isAdmin: (state) => adminAccessRoles.has(state.session?.role ?? ''),
canAccessAdmin: (state) => adminAccessRoles.has(state.session?.role ?? ''),
canManageContent: (state) => adminAccessRoles.has(state.session?.role ?? ''),
canManageAdminWorkspace: (state) => adminWorkspaceRoles.has(state.session?.role ?? ''),
canManageOperationalSettings: (state) => state.session?.role === 'owner',
isOwner: (state) => state.session?.role === 'owner',
},
actions: {
async hydrate() {
@@ -37,9 +46,11 @@ export const useAuthStore = defineStore('auth', {
try {
this.session = await api.getSession()
} catch {
} catch (error) {
this.session = null
writeStoredToken(null)
if (error instanceof ApiRequestError && (error.status === 401 || error.status === 403)) {
writeStoredToken(null)
}
} finally {
this.hydrated = true
}
@@ -54,6 +65,16 @@ export const useAuthStore = defineStore('auth', {
this.loading = false
}
},
async demoLogin(payload: DemoLoginPayload) {
this.loading = true
try {
const session = await api.demoLogin(payload)
this.session = session
writeStoredToken(session.sessionToken)
} finally {
this.loading = false
}
},
async logout() {
this.loading = true
try {
@@ -64,5 +85,18 @@ export const useAuthStore = defineStore('auth', {
this.loading = false
}
},
async deleteMyData() {
this.loading = true
try {
const result = await api.deleteMyData()
this.session = null
writeStoredToken(null)
return result
} finally {
this.loading = false
}
},
},
})
export type AuthStore = ReturnType<typeof useAuthStore>
+210 -253
View File
@@ -1,257 +1,59 @@
import { defineStore } from 'pinia'
import { api } from '../lib/api'
import {
classifyPublicLoadError,
createAwardsState,
createEmptyAdminDashboard,
createEmptyAdminRiskFlagsResponse,
createEmptyAdminSeasonDetail,
createEmptyAdminSiteSettings,
createEmptyArchive,
createEmptyDatabaseHealth,
normalizeSeasonDetail,
} from './awards/defaults'
import type {
AdminDashboardResponse,
AdminSeasonDetailResponse,
ApproveNominationPayload,
AdminSeasonListItem,
CreateSeasonPayload,
CreateClipPayload,
CreateNominationPayload,
CreateVotePayload,
OverviewResponse,
SeasonCategoriesResponse,
RejectNominationPayload,
ResolveRiskFlagPayload,
SetAwardResultPayload,
AdminAuditQueryOptions,
AdminRiskQueryOptions,
UpdateSeasonPayload,
UpdateClipStatusPayload,
UpdateSiteSettingsPayload,
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 VTuberin oder der VTuber, der dieses Jahr einfach alle verzaubert hat.', maxNomineesPerUser: 3 },
{ id: 2, groupName: 'Performance', name: 'Bestes Live Event', description: 'Das Event, das die Community zum Beben gebracht hat Konzert, Watchalong oder Mega-Stream.', maxNomineesPerUser: 3 },
{ id: 3, groupName: 'Clips & Highlights', name: 'Clip des Jahres', description: 'Der eine Clip, den du seit Monaten in jeden Chat spammst.', maxNomineesPerUser: 3 },
],
winnersPreview: [
{ year: 2025, category: 'VTuber des Jahres', winnerName: 'Hoshimi Miyu', winnerSlug: '@hoshimimiyu' },
{ 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 darf mitmachen?', answer: 'Jede:r mit einem Twitch-Account. Einmal einloggen genügt kein extra Konto, kein Papierkram.' },
{ question: 'Wie werden die Gewinner bestimmt?', answer: 'Komplett durch eure Stimmen. Die Community entscheidet, wer auf die Bühne darf kein Jury-Geheimnis.' },
{ question: 'Kann ich meine Wahl noch ändern?', answer: 'Klar! Bis zum Ende der Voting-Phase kannst du Nominierungen und Stimmen jederzeit anpassen.' },
{ question: 'Wer kuratiert die Kategorien?', answer: 'Das Jayuhime-Team stellt die Kategorien jedes Jahr frisch zusammen, passend zur Community.' },
],
}
const fallbackCategories: SeasonCategoriesResponse = {
seasonId: 1,
year: 2026,
categories: [
{
id: 1,
name: 'VTuber des Jahres',
groupName: 'Main Awards',
description: 'Die Hauptkategorie für die prägendste Creator-Präsenz des ganzen Jahres.',
maxNomineesPerUser: 3,
candidates: [
{ id: 1, displayName: 'Hoshimi Miyu', channelSlug: '@hoshimimiyu', platform: 'Twitch', clipUrl: 'https://clips.twitch.tv/HoshimiHighlight' },
{ id: 2, displayName: 'Kurainu', channelSlug: '@kurainu', platform: 'Twitch', clipUrl: 'https://www.youtube.com/watch?v=kurainu' },
{ id: 3, displayName: 'Shiro Ch.', channelSlug: '@shiroch', platform: 'Twitch', clipUrl: 'https://clips.twitch.tv/ShiroMoment' },
],
},
{
id: 2,
name: 'Bestes Live Event',
groupName: 'Performance',
description: 'Konzerte, Sonderformate und große Community-Shows, die in Erinnerung bleiben.',
maxNomineesPerUser: 3,
candidates: [
{ id: 4, displayName: 'Kurainu 3D Live', channelSlug: '@kurainu', platform: 'Twitch', clipUrl: 'https://www.youtube.com/watch?v=kurainu3d' },
{ id: 5, displayName: 'Aoi Sakura Showcase', channelSlug: '@aoisakura', platform: 'YouTube', clipUrl: 'https://www.youtube.com/watch?v=aoisakura' },
],
},
],
}
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: 'Stimmen', 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 Bester neuer VTuber', age: 'vor 2 Min.' },
{ label: 'Clip-Dublette erkannt in Clip des Jahres', age: 'vor 7 Min.' },
{ label: 'Alias-Zusammenfuehrung fuer Hoshimi Miyu geprueft', age: 'vor 18 Min.' },
],
topCategories: [
{ category: 'VTuber des Jahres', votes: 186321 },
{ category: 'Bestes Live Event', votes: 132550 },
{ category: 'Clip des Jahres', votes: 98210 },
],
riskFlags: [
{
id: 1,
source: 'vote',
type: 'rapid_vote_updates',
severity: 'high',
status: 'open',
summary: 'Mehrere Voting-Aenderungen in kurzer Zeit erkannt.',
twitchUserId: 'demo_user',
createdFromIp: '127.0.0.1',
createdAt: '2026-06-17T08:40:00Z',
metadataJson: '{"recentVoteSubmissions":3}',
},
],
auditEntries: [
{
id: 1,
adminTwitchUserId: 'jayuhime_admin',
actionType: 'category.update',
entityType: 'category',
entityId: '1',
summary: 'Kategorie VTuber des Jahres wurde aktualisiert.',
createdAt: '2026-06-17T08:32:00Z',
},
],
}
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: 'Archiviert', 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: 'Hauptpreise',
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',
},
],
clipSubmissions: [
{
id: 1,
categoryId: 1,
submittedByTwitchId: 'demo_user',
clipUrl: 'https://clips.twitch.tv/DemoClip',
title: 'Epischer Clutch im Finale',
creator: 'Hoshimi Miyu',
platform: 'Twitch',
status: 'pending',
createdAt: '2026-06-17T09:10:00Z',
},
],
}
const emptyAdmin: AdminDashboardResponse = {
metrics: [],
activities: [],
topCategories: [],
riskFlags: [],
auditEntries: [],
}
const emptyAdminSeasons: AdminSeasonListItem[] = []
const emptyAdminSeasonDetail: AdminSeasonDetailResponse = {
id: 0,
year: 0,
name: '',
currentPhase: '',
isCurrent: false,
categories: [],
candidates: [],
pendingNominations: [],
clipSubmissions: [],
}
/**
* Guarantee the array fields exist even if a (possibly older) backend omits them,
* so views can safely read `.length`/`.filter` without crashing the render.
*/
function normalizeSeasonDetail(detail: AdminSeasonDetailResponse): AdminSeasonDetailResponse {
return {
...detail,
categories: detail.categories ?? [],
candidates: detail.candidates ?? [],
pendingNominations: detail.pendingNominations ?? [],
clipSubmissions: detail.clipSubmissions ?? [],
}
interface AdminSeasonRefreshOptions {
reloadAdmin?: boolean
reloadHome?: boolean
}
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,
adminSelectedSeasonId: fallbackAdminSeasonDetail.id as number | null,
loading: false,
apiMode: 'fallback' as 'api' | 'fallback',
}),
state: createAwardsState,
actions: {
async loadHomeData() {
this.loading = true
this.lastPublicError = null
this.lastPublicErrorKind = null
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 {
} catch (error) {
this.apiMode = 'fallback'
const { message, kind } = classifyPublicLoadError(error)
if (kind) {
this.lastPublicError = message
this.lastPublicErrorKind = kind
}
} finally {
this.loading = false
}
@@ -261,35 +63,71 @@ export const useAwardsStore = defineStore('awards', {
this.archive = await api.getWinnerArchive(year)
this.apiMode = 'api'
} catch {
this.archive = { ...fallbackArchive, year }
this.archive = createEmptyArchive(year)
}
},
async loadAdmin() {
try {
this.admin = await api.getAdminDashboard()
this.adminSeasons = await api.getAdminSeasons()
const [admin, adminSeasons, adminSiteSettings, databaseHealth] = await Promise.all([
api.getAdminDashboard(),
api.getAdminSeasons(),
api.getAdminSiteSettings(),
api.getDatabaseHealth(),
])
this.admin = admin
this.adminSeasons = adminSeasons
this.adminSiteSettings = adminSiteSettings
this.databaseHealth = databaseHealth
if (!this.adminSelectedSeasonId || !this.adminSeasons.some((season) => season.id === this.adminSelectedSeasonId)) {
this.adminSelectedSeasonId = this.adminSeasons[0]?.id ?? null
}
if (this.adminSelectedSeasonId) {
this.adminSeasonDetail = normalizeSeasonDetail(await api.getAdminSeasonDetail(this.adminSelectedSeasonId))
} else {
this.adminSeasonDetail = createEmptyAdminSeasonDetail()
}
this.apiMode = 'api'
} catch {
this.admin = emptyAdmin
this.adminSeasons = emptyAdminSeasons
this.adminSeasonDetail = emptyAdminSeasonDetail
this.admin = createEmptyAdminDashboard()
this.adminSeasons = []
this.adminSeasonDetail = createEmptyAdminSeasonDetail()
this.adminSiteSettings = createEmptyAdminSiteSettings()
this.adminRiskHistory = []
this.adminRiskFlagsPage = createEmptyAdminRiskFlagsResponse()
this.adminRiskHistoryPage = createEmptyAdminRiskFlagsResponse()
this.databaseHealth = createEmptyDatabaseHealth()
this.adminSelectedSeasonId = null
}
},
async loadAdminContentWorkspace() {
try {
const [adminSiteSettings, databaseHealth] = await Promise.all([
api.getAdminSiteSettings(),
api.getDatabaseHealth(),
])
this.adminSiteSettings = adminSiteSettings
this.databaseHealth = databaseHealth
this.apiMode = 'api'
} catch {
this.adminSiteSettings = createEmptyAdminSiteSettings()
this.databaseHealth = createEmptyDatabaseHealth()
}
},
async loadDatabaseHealth() {
this.databaseHealth = await api.getDatabaseHealth()
return this.databaseHealth
},
async loadAdminSeasonDetail(seasonId: number) {
try {
this.adminSelectedSeasonId = seasonId
this.adminSeasonDetail = normalizeSeasonDetail(await api.getAdminSeasonDetail(seasonId))
this.apiMode = 'api'
} catch {
this.adminSeasonDetail = emptyAdminSeasonDetail
this.adminSeasonDetail = createEmptyAdminSeasonDetail()
}
},
async initializeAdminWorkspace() {
@@ -298,6 +136,63 @@ export const useAwardsStore = defineStore('awards', {
await this.loadAdminSeasonDetail(this.adminSelectedSeasonId)
}
},
async refreshAdminSeasonWorkspace(seasonId: number, options: AdminSeasonRefreshOptions = {}) {
this.adminSelectedSeasonId = seasonId
if (options.reloadAdmin) {
await this.loadAdmin()
} else {
await this.loadAdminSeasonDetail(seasonId)
}
if (options.reloadHome) {
await this.loadHomeData()
}
},
async refreshAfterSeasonListMutation(options: Pick<AdminSeasonRefreshOptions, 'reloadHome'> = {}) {
await this.loadAdmin()
if (options.reloadHome) {
await this.loadHomeData()
}
},
async loadAdminAuditEntries(limit = 200, query = '') {
const auditEntries = await api.getAdminAuditEntries(limit, query)
this.admin = { ...this.admin, auditEntries }
return auditEntries
},
async loadAdminAuditEntriesPage(options: AdminAuditQueryOptions = {}, append = false) {
const response = await api.getAdminAuditEntriesPage(options)
this.admin = {
...this.admin,
auditEntries: append ? [...this.admin.auditEntries, ...response.items] : response.items,
}
return response
},
async loadAdminRiskFlags(limit = 200, status = 'open', query = '') {
const response = await this.loadAdminRiskFlagsPage({ limit, status, query })
return response.items
},
async loadAdminRiskFlagsPage(options: AdminRiskQueryOptions = {}) {
const response = await api.getAdminRiskFlagsPage(options)
this.adminRiskFlagsPage = response
if ((options.status ?? 'open') === 'open' && !options.reviewedOnly) {
this.admin = { ...this.admin, riskFlags: response.items }
}
return response
},
async loadAdminRiskHistory(limit = 80, query = '') {
const response = await this.loadAdminRiskHistoryPage({ limit, query })
return response.items
},
async loadAdminRiskHistoryPage(options: AdminRiskQueryOptions = {}) {
const response = await api.getAdminRiskFlagsPage({
...options,
status: options.status ?? 'all',
reviewedOnly: true,
})
this.adminRiskHistoryPage = response
this.adminRiskHistory = response.items
return response
},
setAdminSeason(seasonId: number) {
this.adminSelectedSeasonId = seasonId
},
@@ -312,60 +207,122 @@ export const useAwardsStore = defineStore('awards', {
},
async updateAdminSeason(seasonId: number, payload: UpdateSeasonPayload) {
const result = await api.updateAdminSeason(seasonId, payload)
await this.loadAdmin()
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true, reloadHome: true })
return result
},
async createAdminSeason(payload: CreateSeasonPayload) {
const result = await api.createAdminSeason(payload)
await this.refreshAdminSeasonWorkspace(result.seasonId, { reloadAdmin: true, reloadHome: true })
return result
},
async deleteAdminSeason(seasonId: number) {
const result = await api.deleteAdminSeason(seasonId)
await this.refreshAfterSeasonListMutation({ reloadHome: true })
return result
},
async createAdminCategory(seasonId: number, payload: UpsertCategoryPayload) {
const result = await api.createAdminCategory(seasonId, payload)
await this.loadAdminSeasonDetail(seasonId)
await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true })
return result
},
async updateAdminCategory(categoryId: number, seasonId: number, payload: UpsertCategoryPayload) {
const result = await api.updateAdminCategory(categoryId, payload)
await this.loadAdminSeasonDetail(seasonId)
await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true })
return result
},
async createAdminCandidate(seasonId: number, payload: UpsertCandidatePayload) {
const result = await api.createAdminCandidate(seasonId, payload)
await this.loadAdminSeasonDetail(seasonId)
await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true })
return result
},
async updateAdminCandidate(candidateId: number, seasonId: number, payload: UpsertCandidatePayload) {
const result = await api.updateAdminCandidate(candidateId, payload)
await this.loadAdminSeasonDetail(seasonId)
await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true })
return result
},
async deleteAdminCandidate(candidateId: number, seasonId: number) {
const result = await api.deleteAdminCandidate(candidateId)
await this.loadAdminSeasonDetail(seasonId)
await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true })
return result
},
async deleteAdminCategory(categoryId: number, seasonId: number) {
const result = await api.deleteAdminCategory(categoryId)
await this.loadAdminSeasonDetail(seasonId)
await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true })
return result
},
async deleteAdminClip(clipId: number, seasonId: number) {
const result = await api.deleteAdminClip(clipId)
await this.loadAdminSeasonDetail(seasonId)
await this.refreshAdminSeasonWorkspace(seasonId)
return result
},
async updateAdminClipStatus(clipId: number, seasonId: number, payload: UpdateClipStatusPayload) {
const result = await api.updateAdminClipStatus(clipId, payload)
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true })
return result
},
async setAdminResult(seasonId: number, payload: SetAwardResultPayload) {
const result = await api.setAdminResult(seasonId, payload)
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true, reloadHome: true })
return result
},
async deleteAdminResult(resultId: number, seasonId: number) {
const result = await api.deleteAdminResult(resultId)
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true, reloadHome: true })
return result
},
async approveAdminNomination(nominationId: number, seasonId: number, payload: ApproveNominationPayload) {
const result = await api.approveAdminNomination(nominationId, payload)
await this.loadAdminSeasonDetail(seasonId)
await this.loadAdmin()
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true, reloadHome: true })
return result
},
async rejectAdminNomination(nominationId: number, seasonId: number) {
const result = await api.rejectAdminNomination(nominationId)
await this.loadAdminSeasonDetail(seasonId)
await this.loadAdmin()
async rejectAdminNomination(nominationId: number, seasonId: number, payload: RejectNominationPayload) {
const result = await api.rejectAdminNomination(nominationId, payload)
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true })
return result
},
async resolveRiskFlag(riskFlagId: number, status = 'resolved') {
const result = await api.resolveRiskFlag(riskFlagId, status)
await this.loadAdmin()
async resolveRiskFlag(riskFlagId: number, payload: ResolveRiskFlagPayload) {
const result = await api.resolveRiskFlag(riskFlagId, payload)
await Promise.all([
this.loadAdminRiskFlagsPage({
limit: this.adminRiskFlagsPage.limit || 25,
offset: this.adminRiskFlagsPage.offset,
status: 'open',
}),
this.loadAdminRiskHistoryPage({
limit: this.adminRiskHistoryPage.limit || 25,
offset: this.adminRiskHistoryPage.offset,
}),
])
return result
},
async bulkResolveRiskFlags(payload: import('../types/awards').BulkResolveRiskFlagsPayload) {
const result = await api.bulkResolveRiskFlags(payload)
await Promise.all([
this.loadAdminRiskFlagsPage({
limit: this.adminRiskFlagsPage.limit || 25,
offset: this.adminRiskFlagsPage.offset,
status: 'open',
}),
this.loadAdminRiskHistoryPage({
limit: this.adminRiskHistoryPage.limit || 25,
offset: this.adminRiskHistoryPage.offset,
}),
])
return result
},
async loadAdminRiskRules() {
return api.getAdminRiskRules()
},
async updateAdminRiskRules(payload: import('../types/awards').UpdateRiskRulesPayload) {
return api.updateAdminRiskRules(payload)
},
async updateAdminSiteSettings(payload: UpdateSiteSettingsPayload) {
const result = await api.updateAdminSiteSettings(payload)
this.adminSiteSettings = await api.getAdminSiteSettings()
await this.loadHomeData()
return result
},
},
})
export type AwardsStore = ReturnType<typeof useAwardsStore>
+184
View File
@@ -0,0 +1,184 @@
import { ApiRequestError } from '../../lib/http'
import type {
AdminDashboardResponse,
AdminRiskFlag,
AdminRiskFlagsResponse,
AdminSeasonDetailResponse,
AdminSeasonListItem,
AdminSiteSettingsResponse,
DatabaseHealthResponse,
OverviewResponse,
SeasonCategoriesResponse,
WinnerArchiveResponse,
} from '../../types/awards'
export type ApiMode = 'api' | 'fallback'
export type PublicErrorKind = 'offline' | 'unreachable' | 'server' | null
export function createEmptyOverview(): OverviewResponse {
return {
seasonId: 0,
year: new Date().getFullYear(),
title: '',
showDate: '',
showStartsAt: '20:00:00',
showStreamUrl: '',
currentPhase: '',
isCommunityOnly: true,
loginProvider: 'Twitch',
timeline: [],
featuredCategories: [],
winnersPreview: [],
siteContent: {
hostDisplayName: '',
hostTagline: '',
newsletterUrl: '',
privacyEmail: '',
privacyPolicyContent: '',
socialLinks: [],
footerLinks: [],
},
faq: [],
}
}
export function createEmptyCategories(): SeasonCategoriesResponse {
return {
seasonId: 0,
year: new Date().getFullYear(),
categories: [],
}
}
export function createEmptyArchive(year = new Date().getFullYear() - 1): WinnerArchiveResponse {
return {
year,
items: [],
}
}
export function createEmptyAdminDashboard(): AdminDashboardResponse {
return {
metrics: [],
activities: [],
topCategories: [],
riskFlags: [],
auditEntries: [],
}
}
export function createEmptyAdminRiskFlagsResponse(): AdminRiskFlagsResponse {
return {
items: [],
totalCount: 0,
returnedCount: 0,
offset: 0,
limit: 25,
hasMore: false,
severityCounts: [],
statusCounts: [],
}
}
export function createEmptyAdminSeasonDetail(): AdminSeasonDetailResponse {
return {
id: 0,
year: 0,
name: '',
showStreamUrl: '',
currentPhase: '',
isCurrent: false,
isCommunityOnly: true,
nominationStartsAt: '',
nominationEndsAt: '',
votingStartsAt: '',
votingEndsAt: '',
reviewStartsAt: '',
reviewEndsAt: '',
showDate: '',
showStartsAt: '20:00:00',
categories: [],
candidates: [],
pendingNominations: [],
reviewedNominations: [],
results: [],
clipSubmissions: [],
}
}
export function createEmptyAdminSiteSettings(): AdminSiteSettingsResponse {
return {
hostDisplayName: '',
hostTagline: '',
newsletterUrl: '',
privacyEmail: '',
privacyPolicyContent: '',
privacyPolicyUpdatedBy: null,
privacyPolicyUpdatedAt: null,
imprintUrl: '',
contactUrl: '',
sponsorsUrl: '',
socialLinks: [],
faq: [],
}
}
export function createEmptyDatabaseHealth(): DatabaseHealthResponse {
return {
provider: 'postgres',
canConnect: false,
pendingMigrations: [],
configuredConnection: {
source: 'unknown',
},
}
}
export function createAwardsState() {
return {
overview: createEmptyOverview(),
categories: createEmptyCategories(),
archive: createEmptyArchive(),
admin: createEmptyAdminDashboard(),
adminSeasons: [] as AdminSeasonListItem[],
adminSeasonDetail: createEmptyAdminSeasonDetail(),
adminSiteSettings: createEmptyAdminSiteSettings(),
adminRiskHistory: [] as AdminRiskFlag[],
adminRiskFlagsPage: createEmptyAdminRiskFlagsResponse(),
adminRiskHistoryPage: createEmptyAdminRiskFlagsResponse(),
databaseHealth: createEmptyDatabaseHealth(),
adminSelectedSeasonId: null as number | null,
loading: false,
apiMode: 'fallback' as ApiMode,
lastPublicError: null as string | null,
lastPublicErrorKind: null as PublicErrorKind,
}
}
/**
* Guarantee the array fields exist even if a (possibly older) backend omits them,
* so views can safely read `.length`/`.filter` without crashing the render.
*/
export function normalizeSeasonDetail(detail: AdminSeasonDetailResponse): AdminSeasonDetailResponse {
return {
...detail,
categories: detail.categories ?? [],
candidates: detail.candidates ?? [],
pendingNominations: detail.pendingNominations ?? [],
reviewedNominations: detail.reviewedNominations ?? [],
results: detail.results ?? [],
clipSubmissions: detail.clipSubmissions ?? [],
}
}
export function classifyPublicLoadError(error: unknown) {
const message = error instanceof Error ? error.message : 'Die Landingpage konnte nicht geladen werden.'
const isOffline = typeof navigator !== 'undefined' && navigator.onLine === false
const isUnreachable = message.toLowerCase().includes('api nicht erreichbar')
const isServerError = error instanceof ApiRequestError && error.status !== null && error.status >= 500
return {
message,
kind: isOffline ? 'offline' : isServerError ? 'server' : isUnreachable ? 'unreachable' : null,
} satisfies { message: string; kind: PublicErrorKind }
}