mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 18:52:56 +00:00
dev day #89
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
export type AdminAction =
|
||||
| { type: 'nav'; at: number; path: string }
|
||||
| { type: 'request'; at: number; method: string; url: string; status?: number; ms?: number; ok?: boolean };
|
||||
|
||||
const CAP = 200;
|
||||
let buf: AdminAction[] = [];
|
||||
|
||||
export function logAction(a: AdminAction) {
|
||||
buf.push(a);
|
||||
if (buf.length > CAP) buf = buf.slice(buf.length - CAP);
|
||||
}
|
||||
|
||||
export function getRecentActions(limit = 12): AdminAction[] {
|
||||
const n = Math.max(1, Math.min(limit, CAP));
|
||||
return buf.slice(-n);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import axios, { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||
import { reportError } from './errorReporter';
|
||||
import { getToken } from '../utils/auth';
|
||||
import { logAction } from './actionLog';
|
||||
|
||||
function readStored(key: string): string | null {
|
||||
try { return localStorage.getItem(key); } catch { return null; }
|
||||
@@ -63,11 +65,22 @@ async function getCsrfToken(): Promise<string | null> {
|
||||
// Request interceptor - attach bearer token when available
|
||||
api.interceptors.request.use(
|
||||
async (config: InternalAxiosRequestConfig) => {
|
||||
(config as any).metadata = { ...(config as any).metadata, start: Date.now() };
|
||||
const token = getToken();
|
||||
config.headers = config.headers || {};
|
||||
if (token) {
|
||||
(config.headers as any).Authorization = `Bearer ${token}`;
|
||||
}
|
||||
// Dev helper: attach X-Admin-Token from localStorage if present (allows admin calls without rebuild)
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
try {
|
||||
const devAdmin = readStored('fc_admin_token');
|
||||
if (devAdmin && !(config.headers as any)['X-Admin-Token']) {
|
||||
(config.headers as any)['X-Admin-Token'] = devAdmin;
|
||||
(config.headers as any)['X-Dev-Admin'] = 'true';
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
// For cookie-based flows (no Bearer header), attach X-CSRF-Token on mutating methods
|
||||
const method = (config.method || 'get').toLowerCase();
|
||||
const isMutating = method === 'post' || method === 'put' || method === 'patch' || method === 'delete';
|
||||
@@ -89,6 +102,23 @@ api.interceptors.request.use(
|
||||
api.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
(error) => {
|
||||
try {
|
||||
const status = error?.response?.status;
|
||||
try {
|
||||
const cfg = error?.config || {};
|
||||
const method: string = (cfg?.method || 'get').toUpperCase();
|
||||
const url: string = cfg?.url || '';
|
||||
const start: number | undefined = (cfg as any)?.metadata?.start;
|
||||
const ms = typeof start === 'number' ? Date.now() - start : undefined;
|
||||
logAction({ type: 'request', at: Date.now(), method, url, status, ms, ok: false });
|
||||
} catch {}
|
||||
if (typeof status === 'number' && status >= 500) {
|
||||
const reqUrl: string = error.config?.url || '';
|
||||
const method: string = (error.config?.method || 'get').toUpperCase();
|
||||
const requestId: string | undefined = error.response?.headers?.['x-request-id'] || error.response?.headers?.['X-Request-ID'];
|
||||
reportError({ message: `HTTP ${status} ${method} ${reqUrl}`, status, method, url: reqUrl, request_id: requestId });
|
||||
}
|
||||
} catch {}
|
||||
if (error.response?.status === 401) {
|
||||
// Avoid redirect loop on the login call itself
|
||||
const reqUrl: string = error.config?.url || '';
|
||||
@@ -108,6 +138,19 @@ api.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
api.interceptors.response.use((response: AxiosResponse) => {
|
||||
try {
|
||||
const cfg = response.config || {} as any;
|
||||
const method: string = (cfg?.method || 'get').toUpperCase();
|
||||
const url: string = cfg?.url || '';
|
||||
const start: number | undefined = (cfg as any)?.metadata?.start;
|
||||
const ms = typeof start === 'number' ? Date.now() - start : undefined;
|
||||
const status = response.status;
|
||||
logAction({ type: 'request', at: Date.now(), method, url, status, ms, ok: true });
|
||||
} catch {}
|
||||
return response;
|
||||
});
|
||||
|
||||
// Upload image helper
|
||||
export const uploadImage = async (formData: FormData): Promise<{ url: string }> => {
|
||||
const res = await api.post('/upload', formData, {
|
||||
|
||||
@@ -2,15 +2,45 @@ import api, { API_URL } from './api';
|
||||
import { getToken } from '../utils/auth';
|
||||
|
||||
const normalizeArticle = (raw: any): Article => {
|
||||
if (!raw) return raw;
|
||||
if (!raw) return raw as Article;
|
||||
const id = raw.id ?? raw.ID ?? raw.article_id ?? raw.articleId;
|
||||
const category = raw.category ?? raw.Category;
|
||||
const author = raw.author ?? raw.Author;
|
||||
|
||||
// Normalize attachments: backend may send a JSON string or an array
|
||||
let attachments: Array<{ name: string; url: string; mime_type?: string; size?: number }> | undefined = undefined;
|
||||
const aRaw = raw.attachments ?? raw.Attachments;
|
||||
try {
|
||||
if (Array.isArray(aRaw)) {
|
||||
attachments = aRaw.map((it: any) => {
|
||||
if (typeof it === 'string') {
|
||||
const name = it.split('/').pop() || 'soubor';
|
||||
return { name, url: it };
|
||||
}
|
||||
return { name: it?.name || (String(it?.url || '').split('/').pop() || 'soubor'), url: it?.url || '', mime_type: it?.mime_type || it?.type, size: it?.size };
|
||||
});
|
||||
} else if (typeof aRaw === 'string' && aRaw.trim() !== '') {
|
||||
const parsed = JSON.parse(aRaw);
|
||||
if (Array.isArray(parsed)) {
|
||||
attachments = parsed.map((it: any) => {
|
||||
if (typeof it === 'string') {
|
||||
const name = it.split('/').pop() || 'soubor';
|
||||
return { name, url: it };
|
||||
}
|
||||
return { name: it?.name || (String(it?.url || '').split('/').pop() || 'soubor'), url: it?.url || '', mime_type: it?.mime_type || it?.type, size: it?.size };
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed attachments
|
||||
}
|
||||
|
||||
return {
|
||||
...(raw as Article),
|
||||
id,
|
||||
category,
|
||||
author,
|
||||
...(attachments ? { attachments } : {}),
|
||||
} as Article;
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export type CommentItem = {
|
||||
updated_at: string;
|
||||
reactions?: Record<string, number>;
|
||||
my_reaction?: string;
|
||||
admin_liked?: boolean;
|
||||
user: {
|
||||
id: number;
|
||||
first_name?: string;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { getRecentActions } from './actionLog';
|
||||
|
||||
export type FEErrorEvent = {
|
||||
origin: 'frontend';
|
||||
language?: 'ts' | 'tsx' | string;
|
||||
severity?: 'error' | 'warn' | 'fatal';
|
||||
message: string;
|
||||
stack?: string;
|
||||
component?: string;
|
||||
file?: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
url?: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
request_id?: string;
|
||||
user_id?: number;
|
||||
session_token?: string;
|
||||
tags?: Record<string, string>;
|
||||
context?: Record<string, any>;
|
||||
env?: string;
|
||||
version?: string;
|
||||
hostname?: string;
|
||||
occurred_at?: string;
|
||||
};
|
||||
|
||||
function readLS(key: string): string | null {
|
||||
try { return localStorage.getItem(key); } catch { return null; }
|
||||
}
|
||||
|
||||
function getIngestUrl(): string | null {
|
||||
if (process.env.REACT_APP_ERROR_INGEST_URL) return process.env.REACT_APP_ERROR_INGEST_URL as string;
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
const host = window.location.hostname || '';
|
||||
const isLocal = host === 'localhost' || host === '127.0.0.1' || host === '::1' || /^[0-9.]+$/.test(host) || host.endsWith('.local');
|
||||
if (!isLocal) {
|
||||
return 'https://errors.tdvorak.dev/api/v1/errors';
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return '/api/v1/errors';
|
||||
}
|
||||
|
||||
function getIngestToken(): string | null {
|
||||
return (process.env.REACT_APP_ERROR_INGEST_TOKEN as string) || null;
|
||||
}
|
||||
|
||||
let lastSentAt = 0;
|
||||
let lastHash = '';
|
||||
|
||||
function fingerprint(ev: FEErrorEvent): string {
|
||||
const basis = [ev.message, ev.stack || '', ev.url || '', ev.component || ''].join('|');
|
||||
let h = 0;
|
||||
for (let i = 0; i < basis.length; i++) {
|
||||
h = ((h << 5) - h) + basis.charCodeAt(i);
|
||||
h |= 0;
|
||||
}
|
||||
return String(h);
|
||||
}
|
||||
|
||||
export async function reportError(ev: Partial<FEErrorEvent>): Promise<void> {
|
||||
const url = getIngestUrl();
|
||||
if (!url) return; // disabled until configured
|
||||
|
||||
const now = Date.now();
|
||||
const full: FEErrorEvent = {
|
||||
origin: 'frontend',
|
||||
language: ev.language || 'tsx',
|
||||
severity: ev.severity || 'error',
|
||||
message: ev.message || 'Unknown error',
|
||||
stack: ev.stack,
|
||||
component: ev.component,
|
||||
file: ev.file,
|
||||
line: ev.line,
|
||||
column: ev.column,
|
||||
url: ev.url || (typeof window !== 'undefined' ? window.location.pathname + window.location.search : undefined),
|
||||
method: ev.method,
|
||||
status: ev.status,
|
||||
request_id: ev.request_id,
|
||||
user_id: ev.user_id,
|
||||
session_token: ev.session_token,
|
||||
tags: {
|
||||
service: 'frontend',
|
||||
instance_env: String(process.env.NODE_ENV || ''),
|
||||
instance_host: (typeof window !== 'undefined' && window.location && window.location.hostname) ? window.location.hostname : '',
|
||||
...(ev.tags || {}),
|
||||
},
|
||||
context: {
|
||||
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
|
||||
referrer: typeof document !== 'undefined' ? document.referrer : undefined,
|
||||
viewport: typeof window !== 'undefined' ? { w: window.innerWidth, h: window.innerHeight } : undefined,
|
||||
recentActions: getRecentActions(18),
|
||||
...ev.context,
|
||||
},
|
||||
env: ev.env || process.env.NODE_ENV,
|
||||
version: ev.version,
|
||||
hostname: ev.hostname || (typeof window !== 'undefined' ? window.location.hostname : undefined),
|
||||
occurred_at: new Date(now).toISOString(),
|
||||
};
|
||||
|
||||
const hash = fingerprint(full);
|
||||
if (hash === lastHash && (now - lastSentAt) < 1500) {
|
||||
return; // basic de-dupe burst
|
||||
}
|
||||
lastHash = hash;
|
||||
lastSentAt = now;
|
||||
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(getIngestToken() ? { 'Authorization': `Bearer ${getIngestToken()}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(full),
|
||||
credentials: 'omit',
|
||||
keepalive: true,
|
||||
});
|
||||
} catch {
|
||||
// swallow
|
||||
}
|
||||
}
|
||||
|
||||
export function installGlobalErrorHandlers() {
|
||||
if (typeof window === 'undefined') return;
|
||||
// window.onerror
|
||||
window.addEventListener('error', (event: ErrorEvent) => {
|
||||
const isResourceError = (event as any).message === undefined && event.filename === '';
|
||||
const message = (event.message || (isResourceError ? 'Resource load error' : 'Unhandled error')) as string;
|
||||
reportError({
|
||||
message,
|
||||
stack: event.error?.stack,
|
||||
file: event.filename,
|
||||
line: event.lineno,
|
||||
column: event.colno,
|
||||
url: window.location.pathname + window.location.search,
|
||||
});
|
||||
});
|
||||
// unhandledrejection
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const reason: any = (event as any).reason;
|
||||
const message = typeof reason === 'string' ? reason : (reason?.message || 'Unhandled rejection');
|
||||
const stack = typeof reason === 'object' ? (reason?.stack || '') : '';
|
||||
reportError({ message, stack, url: window.location.pathname + window.location.search });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import api from './api';
|
||||
|
||||
export interface ErrorEvent {
|
||||
id: number;
|
||||
origin: string;
|
||||
language?: string;
|
||||
severity?: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
component?: string;
|
||||
file?: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
url?: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
request_id?: string;
|
||||
user_id?: number;
|
||||
session_token?: string;
|
||||
tags?: Record<string, any> | null;
|
||||
context?: Record<string, any> | null;
|
||||
env?: string;
|
||||
version?: string;
|
||||
hostname?: string;
|
||||
occurred_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ErrorListResponse {
|
||||
items: ErrorEvent[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export async function getErrors(params?: {
|
||||
origin?: string;
|
||||
severity?: string;
|
||||
method?: string;
|
||||
status?: string | number;
|
||||
search?: string;
|
||||
from?: string; // ISO
|
||||
to?: string; // ISO
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}): Promise<ErrorListResponse> {
|
||||
const res = await api.get('/admin/errors', { params });
|
||||
return res.data as ErrorListResponse;
|
||||
}
|
||||
|
||||
export async function getError(id: number): Promise<ErrorEvent> {
|
||||
const res = await api.get(`/admin/errors/${id}`);
|
||||
return res.data as ErrorEvent;
|
||||
}
|
||||
|
||||
export async function getExternalErrors(params?: {
|
||||
origin?: string;
|
||||
severity?: string;
|
||||
method?: string;
|
||||
status?: string | number;
|
||||
search?: string;
|
||||
from?: string; // ISO
|
||||
to?: string; // ISO
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}): Promise<ErrorListResponse> {
|
||||
const res = await api.get('/admin/errors/external', { params });
|
||||
return res.data as ErrorListResponse;
|
||||
}
|
||||
|
||||
export async function getExternalError(id: number): Promise<ErrorEvent> {
|
||||
const res = await api.get(`/admin/errors/external/${id}`);
|
||||
return res.data as ErrorEvent;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import axios from 'axios';
|
||||
import { API_URL } from './api';
|
||||
import api from './api';
|
||||
|
||||
// Use shared API_URL which already resolves to '/api/v1' under current origin
|
||||
|
||||
@@ -62,46 +61,32 @@ export const getAllFiles = async (params?: {
|
||||
sort_by?: string;
|
||||
sort_order?: string;
|
||||
}): Promise<FileInfo[]> => {
|
||||
const response = await axios.get(`${API_URL}/admin/files`, {
|
||||
params,
|
||||
withCredentials: true,
|
||||
});
|
||||
const response = await api.get(`/admin/files`, { params });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getUnusedFiles = async (): Promise<FileInfo[]> => {
|
||||
const response = await axios.get(`${API_URL}/admin/files/unused`, {
|
||||
withCredentials: true,
|
||||
});
|
||||
const response = await api.get(`/admin/files/unused`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getDuplicateFiles = async (): Promise<DuplicateFiles> => {
|
||||
const response = await axios.get(`${API_URL}/admin/files/duplicates`, {
|
||||
withCredentials: true,
|
||||
});
|
||||
const response = await api.get(`/admin/files/duplicates`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getStorageUsage = async (): Promise<StorageUsage> => {
|
||||
const response = await axios.get(`${API_URL}/admin/files/usage`, {
|
||||
withCredentials: true,
|
||||
});
|
||||
const response = await api.get(`/admin/files/usage`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getFileUsages = async (fileId: number): Promise<any[]> => {
|
||||
const response = await axios.get(`${API_URL}/admin/files/${fileId}/usages`, {
|
||||
withCredentials: true,
|
||||
});
|
||||
const response = await api.get(`/admin/files/${fileId}/usages`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteFile = async (fileId: number, force: boolean = false): Promise<void> => {
|
||||
await axios.delete(`${API_URL}/admin/files/${fileId}`, {
|
||||
params: { force },
|
||||
withCredentials: true,
|
||||
});
|
||||
await api.delete(`/admin/files/${fileId}`, { params: { force } });
|
||||
};
|
||||
|
||||
export const scanAndSyncFiles = async (): Promise<{
|
||||
@@ -113,9 +98,7 @@ export const scanAndSyncFiles = async (): Promise<{
|
||||
new_files_list?: string[];
|
||||
orphaned_list?: string[];
|
||||
}> => {
|
||||
const response = await axios.post(`${API_URL}/admin/files/scan`, {}, {
|
||||
withCredentials: true,
|
||||
});
|
||||
const response = await api.post(`/admin/files/scan`, {});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -131,10 +114,7 @@ export const refreshFileTracking = async (entityType?: string): Promise<{
|
||||
settings_scanned: number;
|
||||
};
|
||||
}> => {
|
||||
const response = await axios.post(`${API_URL}/admin/files/refresh-tracking`, {}, {
|
||||
params: entityType ? { entity_type: entityType } : {},
|
||||
withCredentials: true,
|
||||
});
|
||||
const response = await api.post(`/admin/files/refresh-tracking`, {}, { params: entityType ? { entity_type: entityType } : {} });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
@@ -44,8 +44,15 @@ function normalize(p: any): Player {
|
||||
} as Player;
|
||||
}
|
||||
|
||||
export async function getPlayers(): Promise<Player[]> {
|
||||
const res = await api.get<any[] | { data?: any[]; items?: any[] }>('/players');
|
||||
export async function getPlayers(opts?: { active?: boolean; team_id?: number | string }): Promise<Player[]> {
|
||||
let url = '/players';
|
||||
const params = new URLSearchParams();
|
||||
if (opts && opts.active === false) params.set('active', 'false');
|
||||
if (opts && opts.team_id != null) params.set('team_id', String(opts.team_id));
|
||||
if (Array.from(params.keys()).length > 0) {
|
||||
url += `?${params.toString()}`;
|
||||
}
|
||||
const res = await api.get<any[] | { data?: any[]; items?: any[] }>(url);
|
||||
const raw = Array.isArray(res.data)
|
||||
? res.data
|
||||
: ((res.data as any).data || (res.data as any).items);
|
||||
|
||||
@@ -9,6 +9,7 @@ export type Player = {
|
||||
position?: string;
|
||||
jersey_number?: number;
|
||||
image_url?: string;
|
||||
gender?: string;
|
||||
is_active?: boolean;
|
||||
// Extended detail fields (optional on public endpoint)
|
||||
nationality?: string;
|
||||
@@ -33,6 +34,7 @@ function normalizePlayer(p: any): Player {
|
||||
position: p.position ?? p.Position ?? undefined,
|
||||
jersey_number: p.jersey_number ?? p.JerseyNumber ?? undefined,
|
||||
image_url: p.image_url ?? p.ImageURL ?? undefined,
|
||||
gender: p.gender ?? p.Gender ?? undefined,
|
||||
is_active: Boolean(p.is_active ?? p.IsActive ?? true),
|
||||
nationality: p.nationality ?? p.Nationality ?? undefined,
|
||||
date_of_birth: p.date_of_birth ?? p.DateOfBirth ?? undefined,
|
||||
@@ -55,8 +57,13 @@ export async function getStandings() {
|
||||
return Array.isArray(res.data) ? res.data : res.data.data;
|
||||
}
|
||||
|
||||
export async function getPlayers() {
|
||||
const res = await api.get<any[] | { data?: any[]; items?: any[] }>('/players');
|
||||
export async function getPlayers(opts?: { active?: boolean; team_id?: number | string }) {
|
||||
let url = '/players';
|
||||
const params = new URLSearchParams();
|
||||
if (opts && opts.active === false) params.set('active', 'false');
|
||||
if (opts && opts.team_id != null) params.set('team_id', String(opts.team_id));
|
||||
if (Array.from(params.keys()).length > 0) url += `?${params.toString()}`;
|
||||
const res = await api.get<any[] | { data?: any[]; items?: any[] }>(url);
|
||||
const raw = Array.isArray(res.data)
|
||||
? res.data
|
||||
: ((res.data as any).data || (res.data as any).items);
|
||||
|
||||
@@ -162,13 +162,20 @@ export async function loadPreset(filename: string): Promise<void> {
|
||||
// Admin: sponsors management
|
||||
export async function listSponsorsAdmin(): Promise<string[]> {
|
||||
const res = await api.get<string[]>('/admin/scoreboard/sponsors');
|
||||
return res.data || [];
|
||||
const list = res.data || [];
|
||||
try {
|
||||
const base = new URL(API_URL || '', typeof window !== 'undefined' ? window.location.origin : undefined);
|
||||
const origin = `${base.protocol}//${base.host}`;
|
||||
return list.map((u) => (u && u.startsWith('/uploads/') ? origin + u : u));
|
||||
} catch {
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadSponsors(files: File[]): Promise<{ saved: number }> {
|
||||
export async function uploadSponsors(files: File[]): Promise<{ saved: number; files?: string[] }> {
|
||||
const fd = new FormData();
|
||||
for (const f of files) fd.append('files', f);
|
||||
const res = await api.post<{ saved: number }>('/admin/scoreboard/sponsors/upload', fd, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||
const res = await api.post<{ saved: number; files?: string[] }>('/admin/scoreboard/sponsors/upload', fd, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||
return res.data || { saved: 0 };
|
||||
}
|
||||
|
||||
@@ -176,10 +183,62 @@ export async function deleteSponsor(name: string): Promise<void> {
|
||||
await api.delete('/admin/scoreboard/sponsors', { params: { name } });
|
||||
}
|
||||
|
||||
export async function prefillSponsorsFromPage(ids?: number[]): Promise<{ saved: number; files?: string[] }> {
|
||||
const payload = Array.isArray(ids) && ids.length ? { ids } : {};
|
||||
const res = await api.post<{ saved: number; files?: string[] }>('/admin/scoreboard/sponsors/prefill', payload);
|
||||
return res.data || { saved: 0 };
|
||||
}
|
||||
|
||||
// Public: sponsors list for overlay
|
||||
export async function listSponsorsPublic(): Promise<string[]> {
|
||||
const res = await api.get<string[]>('/scoreboard/sponsors');
|
||||
return res.data || [];
|
||||
const list = res.data || [];
|
||||
try {
|
||||
const base = new URL(API_URL || '', typeof window !== 'undefined' ? window.location.origin : undefined);
|
||||
const origin = `${base.protocol}//${base.host}`;
|
||||
return list.map((u) => (u && u.startsWith('/uploads/') ? origin + u : u));
|
||||
} catch {
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
// Admin/public: QR management
|
||||
export async function getQr(): Promise<string> {
|
||||
// Prefer admin endpoint when available to avoid public cache
|
||||
try {
|
||||
const res = await api.get<{ qr?: string }>('/admin/scoreboard/qr');
|
||||
const u = res.data?.qr || '';
|
||||
if (u && u.startsWith('/uploads/')) {
|
||||
try {
|
||||
const base = new URL(API_URL || '', typeof window !== 'undefined' ? window.location.origin : undefined);
|
||||
return `${base.protocol}//${base.host}${u}`;
|
||||
} catch {}
|
||||
}
|
||||
return u;
|
||||
} catch {
|
||||
try {
|
||||
const base = (API_URL || '').replace(/\/$/, '');
|
||||
const r = await fetch(`${base}/scoreboard/qr`, { credentials: 'include' });
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
const u = data?.qr || '';
|
||||
if (u && u.startsWith('/uploads/')) {
|
||||
try {
|
||||
const b = new URL(API_URL || '', typeof window !== 'undefined' ? window.location.origin : undefined);
|
||||
return `${b.protocol}//${b.host}${u}`;
|
||||
} catch {}
|
||||
}
|
||||
return u;
|
||||
}
|
||||
} catch {}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadQr(file: File): Promise<void> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
await api.post('/admin/scoreboard/qr', fd, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||
}
|
||||
|
||||
// Utilities
|
||||
|
||||
@@ -49,6 +49,8 @@ export type PublicSettings = {
|
||||
videos_style?: 'slider' | 'grid3' | 'grid';
|
||||
videos_source?: 'auto' | 'manual';
|
||||
videos_limit?: number;
|
||||
// Auto videos title overrides (YouTube): video_id -> title
|
||||
videos_title_overrides?: Record<string, string>;
|
||||
// Merch module
|
||||
merch_module_enabled?: boolean;
|
||||
merch_style?: 'grid' | 'slider';
|
||||
@@ -126,6 +128,13 @@ export type AdminSettings = PublicSettings & {
|
||||
storage_quota_mb?: number;
|
||||
storage_warn_threshold?: number;
|
||||
storage_critical_threshold?: number;
|
||||
|
||||
// External error-review integration
|
||||
error_review_ingest_url?: string;
|
||||
error_review_ingest_token?: string;
|
||||
error_review_admin_url?: string;
|
||||
error_review_admin_token?: string;
|
||||
error_review_ui_url?: string;
|
||||
};
|
||||
|
||||
export const getPublicSettings = async (): Promise<PublicSettings> => {
|
||||
|
||||
@@ -12,6 +12,9 @@ export type Sweepstake = {
|
||||
picker_style?: 'wheel' | 'cycler' | string;
|
||||
total_prizes?: number;
|
||||
prize_summary?: string;
|
||||
entry_cost_points?: number;
|
||||
entry_fee_czk?: number;
|
||||
max_entries_per_user?: number;
|
||||
winners_selected_at?: string | null;
|
||||
visibility_until?: string | null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user