This commit is contained in:
Tomas Dvorak
2025-11-11 10:29:30 +01:00
parent d5b4faea61
commit 8762bde4bf
139 changed files with 7240 additions and 2870 deletions
+16
View File
@@ -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);
}
+43
View File
@@ -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, {
+31 -1
View File
@@ -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;
};
+1
View File
@@ -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;
+147
View File
@@ -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 });
});
}
+72
View File
@@ -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;
}
+9 -29
View File
@@ -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;
};
+9 -2
View File
@@ -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 -2
View File
@@ -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);
+63 -4
View File
@@ -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
+9
View File
@@ -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> => {
+3
View File
@@ -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;
};