Refactor app architecture and clean local artifacts
This commit is contained in:
@@ -8,3 +8,7 @@ export function getMetricValue(metrics: AdminMetric[], labels: string[]) {
|
||||
export function getVoteMetricValue(metrics: AdminMetric[]) {
|
||||
return getMetricValue(metrics, ['Stimmen', 'Votes'])
|
||||
}
|
||||
|
||||
export function getRiskMetricValue(metrics: AdminMetric[]) {
|
||||
return getMetricValue(metrics, ['Risikohinweise', 'Risk Flags', 'Risiko'])
|
||||
}
|
||||
|
||||
+10
-133
@@ -1,137 +1,14 @@
|
||||
import type {
|
||||
AdminDashboardResponse,
|
||||
AdminSeasonDetailResponse,
|
||||
AdminSeasonListItem,
|
||||
AuthSession,
|
||||
CreateClipPayload,
|
||||
CreateNominationPayload,
|
||||
CreateVotePayload,
|
||||
LoginPayload,
|
||||
OverviewResponse,
|
||||
SeasonCategoriesResponse,
|
||||
UpdateSeasonPayload,
|
||||
ApproveNominationPayload,
|
||||
UpsertCandidatePayload,
|
||||
UpsertCategoryPayload,
|
||||
WinnerArchiveResponse,
|
||||
} from '../types/awards'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://127.0.0.1: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,
|
||||
}).catch(() => {
|
||||
throw new Error(`API nicht erreichbar (${API_URL}). Bitte Backend starten.`)
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed for ${path}`)
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
async function sendDelete<TResponse>(path: string): Promise<TResponse> {
|
||||
const token = getAuthToken()
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
}).catch(() => {
|
||||
throw new Error(`API nicht erreichbar (${API_URL}). Bitte Backend starten.`)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
throw new Error(error || `API request failed for ${path}`)
|
||||
}
|
||||
|
||||
return response.json() as Promise<TResponse>
|
||||
}
|
||||
|
||||
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),
|
||||
}).catch(() => {
|
||||
throw new Error(`API nicht erreichbar (${API_URL}). Bitte Backend starten.`)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
throw new Error(error || `API request failed for ${path}`)
|
||||
}
|
||||
|
||||
return response.json() as Promise<TResponse>
|
||||
}
|
||||
import { adminApi } from './api/adminApi'
|
||||
import { authApi } from './api/authApi'
|
||||
import { publicApi } from './api/publicApi'
|
||||
import { systemApi } from './api/systemApi'
|
||||
|
||||
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),
|
||||
submitClip: (payload: CreateClipPayload) =>
|
||||
sendJson<{ saved: boolean; clipId: number }>('/api/public/clips', '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),
|
||||
deleteAdminCandidate: (candidateId: number) =>
|
||||
sendDelete<{ deleted: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`),
|
||||
deleteAdminCategory: (categoryId: number) =>
|
||||
sendDelete<{ deleted: boolean; categoryId: number }>(`/api/admin/categories/${categoryId}`),
|
||||
deleteAdminClip: (clipId: number) =>
|
||||
sendDelete<{ deleted: boolean; clipId: number }>(`/api/admin/clips/${clipId}`),
|
||||
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',
|
||||
{},
|
||||
),
|
||||
resolveRiskFlag: (riskFlagId: number, status = 'resolved') =>
|
||||
sendJson<{ saved: boolean; riskFlagId: number; status: string }>(
|
||||
`/api/admin/risk-flags/${riskFlagId}/resolve`,
|
||||
'POST',
|
||||
{ status },
|
||||
),
|
||||
...publicApi,
|
||||
...authApi,
|
||||
...adminApi,
|
||||
...systemApi,
|
||||
}
|
||||
|
||||
export { AUTH_TOKEN_KEY }
|
||||
export { AUTH_TOKEN_KEY } from './http'
|
||||
export { ApiRequestError } from './http'
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import type {
|
||||
AdminAuditEntriesResponse,
|
||||
AdminAuditQueryOptions,
|
||||
AdminDashboardResponse,
|
||||
AdminOperationalSettingsResponse,
|
||||
AdminRiskFlagsResponse,
|
||||
AdminRiskQueryOptions,
|
||||
AdminRiskRulesResponse,
|
||||
AdminSeasonDetailResponse,
|
||||
AdminSeasonListItem,
|
||||
AdminSiteSettingsResponse,
|
||||
ApproveNominationPayload,
|
||||
BulkResolveRiskFlagsPayload,
|
||||
CreateSeasonPayload,
|
||||
RejectNominationPayload,
|
||||
ResolveRiskFlagPayload,
|
||||
SetAwardResultPayload,
|
||||
UpdateClipStatusPayload,
|
||||
UpdateOperationalSettingsPayload,
|
||||
UpdateRiskRulesPayload,
|
||||
UpdateSeasonPayload,
|
||||
UpdateSiteSettingsPayload,
|
||||
UpsertCandidatePayload,
|
||||
UpsertCategoryPayload,
|
||||
} from '../../types/awards'
|
||||
import { requestJson } from '../http'
|
||||
import { jsonRequest } from './requestOptions'
|
||||
|
||||
function buildAdminAuditEntryParams(options: AdminAuditQueryOptions = {}) {
|
||||
const params = new URLSearchParams({ limit: String(options.limit ?? 100) })
|
||||
const trimmedQuery = options.query?.trim()
|
||||
const trimmedAdmin = options.admin?.trim()
|
||||
const trimmedAction = options.action?.trim()
|
||||
const trimmedEntityType = options.entityType?.trim()
|
||||
|
||||
if (trimmedQuery) params.set('query', trimmedQuery)
|
||||
if (trimmedAdmin) params.set('admin', trimmedAdmin)
|
||||
if (trimmedAction) params.set('action', trimmedAction)
|
||||
if (trimmedEntityType) params.set('entityType', trimmedEntityType)
|
||||
if (options.from) params.set('from', options.from)
|
||||
if (options.to) params.set('to', options.to)
|
||||
if (options.cursor) params.set('cursor', options.cursor)
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
getAdminDashboard: () => requestJson<AdminDashboardResponse>('/api/admin/dashboard'),
|
||||
getAdminAuditEntriesPage: (options: AdminAuditQueryOptions = {}) =>
|
||||
requestJson<AdminAuditEntriesResponse>(`/api/admin/audit-entries?${buildAdminAuditEntryParams(options).toString()}`),
|
||||
getAdminAuditEntries: (limit = 200, query = '') => {
|
||||
const params = buildAdminAuditEntryParams({ limit, query })
|
||||
return requestJson<AdminAuditEntriesResponse>(`/api/admin/audit-entries?${params.toString()}`)
|
||||
.then((response) => response.items)
|
||||
},
|
||||
getAdminRiskFlagsPage: (options: AdminRiskQueryOptions = {}) => {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(options.limit ?? 25),
|
||||
offset: String(options.offset ?? 0),
|
||||
status: options.status ?? 'open',
|
||||
})
|
||||
if (options.severity) {
|
||||
params.set('severity', options.severity)
|
||||
}
|
||||
const trimmedQuery = options.query?.trim()
|
||||
if (trimmedQuery) {
|
||||
params.set('query', trimmedQuery)
|
||||
}
|
||||
if (options.reviewedOnly) {
|
||||
params.set('reviewedOnly', 'true')
|
||||
}
|
||||
|
||||
return requestJson<AdminRiskFlagsResponse>(`/api/admin/risk-flags?${params.toString()}`)
|
||||
},
|
||||
getAdminRiskFlags: (limit = 200, status = 'open', query = '') =>
|
||||
adminApi.getAdminRiskFlagsPage({ limit, status, query }).then((response) => response.items),
|
||||
getAdminRiskRules: () => requestJson<AdminRiskRulesResponse>('/api/admin/risk-rules'),
|
||||
updateAdminRiskRules: (payload: UpdateRiskRulesPayload) =>
|
||||
requestJson<AdminRiskRulesResponse>('/api/admin/risk-rules', jsonRequest('PUT', payload)),
|
||||
getAdminSeasons: () => requestJson<AdminSeasonListItem[]>('/api/admin/seasons'),
|
||||
getAdminSeasonDetail: (seasonId: number) =>
|
||||
requestJson<AdminSeasonDetailResponse>(`/api/admin/seasons/${seasonId}`),
|
||||
getAdminSiteSettings: () => requestJson<AdminSiteSettingsResponse>('/api/admin/site-settings'),
|
||||
getAdminOperationalSettings: () =>
|
||||
requestJson<AdminOperationalSettingsResponse>('/api/admin/operational-settings'),
|
||||
createAdminSeason: (payload: CreateSeasonPayload) =>
|
||||
requestJson<{ saved: boolean; seasonId: number }>('/api/admin/seasons', jsonRequest('POST', payload)),
|
||||
updateAdminSeason: (seasonId: number, payload: UpdateSeasonPayload) =>
|
||||
requestJson<{ saved: boolean; seasonId: number }>(`/api/admin/seasons/${seasonId}`, jsonRequest('PUT', payload)),
|
||||
deleteAdminSeason: (seasonId: number) =>
|
||||
requestJson<{ deleted: boolean; seasonId: number; year: number }>(`/api/admin/seasons/${seasonId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
updateAdminSiteSettings: (payload: UpdateSiteSettingsPayload) =>
|
||||
requestJson<{ saved: boolean }>('/api/admin/site-settings', jsonRequest('PUT', payload)),
|
||||
updateAdminOperationalSettings: (payload: UpdateOperationalSettingsPayload) =>
|
||||
requestJson<{ saved: boolean; demoLoginPasswordSet: boolean }>('/api/admin/operational-settings', jsonRequest('PUT', payload)),
|
||||
createAdminCategory: (seasonId: number, payload: UpsertCategoryPayload) =>
|
||||
requestJson<{ saved: boolean; categoryId: number }>(`/api/admin/seasons/${seasonId}/categories`, jsonRequest('POST', payload)),
|
||||
updateAdminCategory: (categoryId: number, payload: UpsertCategoryPayload) =>
|
||||
requestJson<{ saved: boolean; categoryId: number }>(`/api/admin/categories/${categoryId}`, jsonRequest('PUT', payload)),
|
||||
deleteAdminCategory: (categoryId: number) =>
|
||||
requestJson<{ deleted: boolean; categoryId: number }>(`/api/admin/categories/${categoryId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
createAdminCandidate: (seasonId: number, payload: UpsertCandidatePayload) =>
|
||||
requestJson<{ saved: boolean; candidateId: number }>(`/api/admin/seasons/${seasonId}/candidates`, jsonRequest('POST', payload)),
|
||||
updateAdminCandidate: (candidateId: number, payload: UpsertCandidatePayload) =>
|
||||
requestJson<{ saved: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`, jsonRequest('PUT', payload)),
|
||||
deleteAdminCandidate: (candidateId: number) =>
|
||||
requestJson<{ deleted: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
deleteAdminClip: (clipId: number) =>
|
||||
requestJson<{ deleted: boolean; clipId: number }>(`/api/admin/clips/${clipId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
updateAdminClipStatus: (clipId: number, payload: UpdateClipStatusPayload) =>
|
||||
requestJson<{ saved: boolean; clipId: number; status: string }>(`/api/admin/clips/${clipId}/status`, jsonRequest('POST', payload)),
|
||||
setAdminResult: (seasonId: number, payload: SetAwardResultPayload) =>
|
||||
requestJson<{ saved: boolean; resultId: number; seasonId: number; categoryId: number; candidateId: number }>(
|
||||
`/api/admin/seasons/${seasonId}/results`,
|
||||
jsonRequest('POST', payload),
|
||||
),
|
||||
deleteAdminResult: (resultId: number) =>
|
||||
requestJson<{ deleted: boolean; resultId: number }>(`/api/admin/results/${resultId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
approveAdminNomination: (nominationId: number, payload: ApproveNominationPayload) =>
|
||||
requestJson<{ saved: boolean; nominationId: number; candidateId: number; created: boolean }>(
|
||||
`/api/admin/nominations/${nominationId}/approve`,
|
||||
jsonRequest('POST', payload),
|
||||
),
|
||||
rejectAdminNomination: (nominationId: number, payload: RejectNominationPayload) =>
|
||||
requestJson<{ saved: boolean; nominationId: number; rejected: boolean }>(
|
||||
`/api/admin/nominations/${nominationId}/reject`,
|
||||
jsonRequest('POST', payload),
|
||||
),
|
||||
resolveRiskFlag: (riskFlagId: number, payload: ResolveRiskFlagPayload) =>
|
||||
requestJson<{ saved: boolean; riskFlagId: number; status: string }>(
|
||||
`/api/admin/risk-flags/${riskFlagId}/resolve`,
|
||||
jsonRequest('POST', payload),
|
||||
),
|
||||
bulkResolveRiskFlags: (payload: BulkResolveRiskFlagsPayload) =>
|
||||
requestJson<{ saved: boolean; count: number; status: string }>(
|
||||
'/api/admin/risk-flags/bulk-resolve',
|
||||
jsonRequest('POST', payload),
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { AuthSession, DemoLoginPayload, LoginPayload } from '../../types/awards'
|
||||
import { requestJson } from '../http'
|
||||
import { jsonRequest } from './requestOptions'
|
||||
|
||||
export const authApi = {
|
||||
getSession: () => requestJson<AuthSession>('/api/auth/session'),
|
||||
login: (payload: LoginPayload) =>
|
||||
requestJson<AuthSession>('/api/auth/dev-login', jsonRequest('POST', payload)),
|
||||
demoLogin: (payload: DemoLoginPayload) =>
|
||||
requestJson<AuthSession>('/api/auth/demo-login', jsonRequest('POST', payload)),
|
||||
logout: () =>
|
||||
requestJson<{ loggedOut: boolean }>('/api/auth/logout', jsonRequest('POST', {})),
|
||||
deleteMyData: () =>
|
||||
requestJson<{
|
||||
deleted: boolean
|
||||
twitchUserId: string
|
||||
deletedVoteEntries: number
|
||||
deletedBallots: number
|
||||
deletedNominations: number
|
||||
deletedClips: number
|
||||
deletedRiskFlags: number
|
||||
disabledSessions: number
|
||||
}>('/api/auth/me/data', {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type {
|
||||
CreateClipPayload,
|
||||
CreateNominationPayload,
|
||||
CreateVotePayload,
|
||||
OverviewResponse,
|
||||
PublicSiteStatusResponse,
|
||||
SeasonCategoriesResponse,
|
||||
UserParticipationResponse,
|
||||
WinnerArchiveResponse,
|
||||
} from '../../types/awards'
|
||||
import { requestJson } from '../http'
|
||||
import { jsonRequest } from './requestOptions'
|
||||
|
||||
export const publicApi = {
|
||||
getOverview: () => requestJson<OverviewResponse>('/api/public/overview'),
|
||||
getSiteStatus: () => requestJson<PublicSiteStatusResponse>('/api/public/site-status'),
|
||||
getSeasonCategories: (year: number) =>
|
||||
requestJson<SeasonCategoriesResponse>(`/api/public/seasons/${year}/categories`),
|
||||
getWinnerArchive: (year: number) =>
|
||||
requestJson<WinnerArchiveResponse>(`/api/public/seasons/${year}/winners`),
|
||||
getMyParticipation: (year: number) =>
|
||||
requestJson<UserParticipationResponse>(`/api/public/seasons/${year}/me`),
|
||||
submitNomination: (payload: CreateNominationPayload) =>
|
||||
requestJson<{ saved: number; category: string }>('/api/public/nominations', jsonRequest('POST', payload)),
|
||||
submitVote: (payload: CreateVotePayload) =>
|
||||
requestJson<{ ballotId: number; entries: number }>('/api/public/votes', jsonRequest('POST', payload)),
|
||||
submitClip: (payload: CreateClipPayload) =>
|
||||
requestJson<{ saved: boolean; clipId: number }>('/api/public/clips', jsonRequest('POST', payload)),
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function jsonRequest(method: 'POST' | 'PUT', payload: unknown): RequestInit {
|
||||
return {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { DatabaseHealthResponse } from '../../types/awards'
|
||||
import { requestJson } from '../http'
|
||||
|
||||
export const systemApi = {
|
||||
getDatabaseHealth: () => requestJson<DatabaseHealthResponse>('/api/health/database'),
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
const API_URL = (import.meta.env.VITE_API_URL ?? '').replace(/\/$/, '')
|
||||
const API_LABEL = API_URL || 'same-origin /api'
|
||||
export const AUTH_TOKEN_KEY = 'vtsa-session-token'
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
status: number | null
|
||||
|
||||
constructor(message: string, status: number | null = null) {
|
||||
super(message)
|
||||
this.name = 'ApiRequestError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthToken() {
|
||||
if (typeof window === 'undefined') return null
|
||||
return window.localStorage.getItem(AUTH_TOKEN_KEY)
|
||||
}
|
||||
|
||||
function clearAuthToken() {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.removeItem(AUTH_TOKEN_KEY)
|
||||
}
|
||||
|
||||
async function parseError(response: Response, path: string) {
|
||||
const error = await response.text().catch(() => '')
|
||||
const message = extractErrorMessage(error) || `API request failed for ${path}`
|
||||
throw new ApiRequestError(message, response.status)
|
||||
}
|
||||
|
||||
function extractErrorMessage(error: string) {
|
||||
if (!error.trim()) return ''
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(error) as { message?: unknown; title?: unknown; detail?: unknown }
|
||||
const message = parsed.message ?? parsed.detail ?? parsed.title
|
||||
return typeof message === 'string' ? message : error
|
||||
} catch {
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestJson<TResponse>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<TResponse> {
|
||||
const token = getAuthToken()
|
||||
const headers = new Headers(options.headers)
|
||||
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
}).catch(() => {
|
||||
throw new ApiRequestError(`API nicht erreichbar (${API_LABEL}). Bitte Backend starten.`)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
clearAuthToken()
|
||||
}
|
||||
await parseError(response, path)
|
||||
}
|
||||
|
||||
return response.json() as Promise<TResponse>
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { api } from './api'
|
||||
import type { PublicSiteStatusResponse } from '../types/awards'
|
||||
|
||||
let siteStatusPromise: Promise<PublicSiteStatusResponse | null> | null = null
|
||||
|
||||
export function clearSiteStatusCache() {
|
||||
siteStatusPromise = null
|
||||
}
|
||||
|
||||
export function loadSiteStatus(force = false) {
|
||||
if (force || !siteStatusPromise) {
|
||||
siteStatusPromise = api.getSiteStatus().catch(() => null)
|
||||
}
|
||||
|
||||
return siteStatusPromise
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
siArtstation,
|
||||
siBilibili,
|
||||
siBluesky,
|
||||
siBuymeacoffee,
|
||||
siCarrd,
|
||||
siDiscord,
|
||||
siDeviantart,
|
||||
siFacebook,
|
||||
siFandom,
|
||||
siFiverr,
|
||||
siGithub,
|
||||
siGuilded,
|
||||
siInstagram,
|
||||
siKick,
|
||||
siKofi,
|
||||
siLinktree,
|
||||
siMastodon,
|
||||
siMatrix,
|
||||
siMedium,
|
||||
siNiconico,
|
||||
siOnlyfans,
|
||||
siPatreon,
|
||||
siPicartodottv,
|
||||
siPinterest,
|
||||
siPixiv,
|
||||
siReddit,
|
||||
siSnapchat,
|
||||
siSoundcloud,
|
||||
siSpotify,
|
||||
siSteam,
|
||||
siSubstack,
|
||||
siTelegram,
|
||||
siThreads,
|
||||
siTiktok,
|
||||
siTumblr,
|
||||
siTwitch,
|
||||
siVimeo,
|
||||
siWhatsapp,
|
||||
siX,
|
||||
siYoutube,
|
||||
type SimpleIcon,
|
||||
} from 'simple-icons'
|
||||
|
||||
export type SocialIconOption = {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type SocialIconOptionGroup = {
|
||||
label: string
|
||||
options: SocialIconOption[]
|
||||
}
|
||||
|
||||
const SOCIAL_ICONS: Record<string, SimpleIcon> = {
|
||||
artstation: siArtstation,
|
||||
bilibili: siBilibili,
|
||||
bluesky: siBluesky,
|
||||
buymeacoffee: siBuymeacoffee,
|
||||
carrd: siCarrd,
|
||||
discord: siDiscord,
|
||||
deviantart: siDeviantart,
|
||||
facebook: siFacebook,
|
||||
fandom: siFandom,
|
||||
fiverr: siFiverr,
|
||||
github: siGithub,
|
||||
guilded: siGuilded,
|
||||
instagram: siInstagram,
|
||||
kick: siKick,
|
||||
kofi: siKofi,
|
||||
linktree: siLinktree,
|
||||
mastodon: siMastodon,
|
||||
matrix: siMatrix,
|
||||
medium: siMedium,
|
||||
niconico: siNiconico,
|
||||
onlyfans: siOnlyfans,
|
||||
patreon: siPatreon,
|
||||
picarto: siPicartodottv,
|
||||
picartotv: siPicartodottv,
|
||||
pinterest: siPinterest,
|
||||
pixiv: siPixiv,
|
||||
reddit: siReddit,
|
||||
snapchat: siSnapchat,
|
||||
soundcloud: siSoundcloud,
|
||||
spotify: siSpotify,
|
||||
steam: siSteam,
|
||||
substack: siSubstack,
|
||||
telegram: siTelegram,
|
||||
threads: siThreads,
|
||||
tiktok: siTiktok,
|
||||
tumblr: siTumblr,
|
||||
twitch: siTwitch,
|
||||
twitter: siX,
|
||||
vimeo: siVimeo,
|
||||
whatsapp: siWhatsapp,
|
||||
x: siX,
|
||||
youtube: siYoutube,
|
||||
}
|
||||
|
||||
export const SOCIAL_ICON_OPTION_GROUPS: SocialIconOptionGroup[] = [
|
||||
{
|
||||
label: 'Streaming & Video',
|
||||
options: [
|
||||
{ key: 'twitch', label: 'Twitch' },
|
||||
{ key: 'youtube', label: 'YouTube' },
|
||||
{ key: 'kick', label: 'Kick' },
|
||||
{ key: 'tiktok', label: 'TikTok' },
|
||||
{ key: 'bilibili', label: 'Bilibili' },
|
||||
{ key: 'niconico', label: 'Niconico' },
|
||||
{ key: 'picarto', label: 'Picarto.TV' },
|
||||
{ key: 'vimeo', label: 'Vimeo' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Social & Community',
|
||||
options: [
|
||||
{ key: 'x', label: 'X / Twitter' },
|
||||
{ key: 'instagram', label: 'Instagram' },
|
||||
{ key: 'bluesky', label: 'Bluesky' },
|
||||
{ key: 'threads', label: 'Threads' },
|
||||
{ key: 'mastodon', label: 'Mastodon' },
|
||||
{ key: 'discord', label: 'Discord' },
|
||||
{ key: 'guilded', label: 'Guilded' },
|
||||
{ key: 'matrix', label: 'Matrix' },
|
||||
{ key: 'telegram', label: 'Telegram' },
|
||||
{ key: 'whatsapp', label: 'WhatsApp' },
|
||||
{ key: 'reddit', label: 'Reddit' },
|
||||
{ key: 'facebook', label: 'Facebook' },
|
||||
{ key: 'snapchat', label: 'Snapchat' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Creator & Support',
|
||||
options: [
|
||||
{ key: 'patreon', label: 'Patreon' },
|
||||
{ key: 'kofi', label: 'Ko-fi' },
|
||||
{ key: 'buymeacoffee', label: 'Buy Me a Coffee' },
|
||||
{ key: 'linktree', label: 'Linktree' },
|
||||
{ key: 'carrd', label: 'Carrd' },
|
||||
{ key: 'substack', label: 'Substack' },
|
||||
{ key: 'medium', label: 'Medium' },
|
||||
{ key: 'github', label: 'GitHub' },
|
||||
{ key: 'fiverr', label: 'Fiverr' },
|
||||
{ key: 'onlyfans', label: 'OnlyFans' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Art, Musik & Portfolio',
|
||||
options: [
|
||||
{ key: 'pixiv', label: 'Pixiv' },
|
||||
{ key: 'deviantart', label: 'DeviantArt' },
|
||||
{ key: 'artstation', label: 'ArtStation' },
|
||||
{ key: 'pinterest', label: 'Pinterest' },
|
||||
{ key: 'tumblr', label: 'Tumblr' },
|
||||
{ key: 'fandom', label: 'Fandom' },
|
||||
{ key: 'spotify', label: 'Spotify' },
|
||||
{ key: 'soundcloud', label: 'SoundCloud' },
|
||||
{ key: 'steam', label: 'Steam' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Fallback',
|
||||
options: [
|
||||
{ key: 'website', label: 'Website / Fallback' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const SOCIAL_ICON_OPTIONS: SocialIconOption[] = SOCIAL_ICON_OPTION_GROUPS.flatMap((group) => group.options)
|
||||
|
||||
export function normalizeSocialIconKey(value: string | null | undefined) {
|
||||
return (value ?? 'website').trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function socialIconOptionForKey(value: string | null | undefined) {
|
||||
const normalized = normalizeSocialIconKey(value)
|
||||
return SOCIAL_ICON_OPTIONS.find((option) => option.key === normalized) ?? null
|
||||
}
|
||||
|
||||
export function socialIconOptionForValue(value: string | null | undefined) {
|
||||
const normalized = normalizeSocialIconKey(value)
|
||||
return SOCIAL_ICON_OPTIONS.find((option) =>
|
||||
option.key === normalized ||
|
||||
option.label.trim().toLowerCase() === normalized,
|
||||
) ?? null
|
||||
}
|
||||
|
||||
export function simpleIconForKey(value: string | null | undefined) {
|
||||
return SOCIAL_ICONS[normalizeSocialIconKey(value)] ?? null
|
||||
}
|
||||
Reference in New Issue
Block a user