Refactor app architecture and clean local artifacts
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user