This commit is contained in:
Tomas Dvorak
2025-11-21 08:44:44 +01:00
parent c941313fd5
commit f5b6f83974
108 changed files with 8642 additions and 5871 deletions
+6 -4
View File
@@ -1,4 +1,5 @@
import api from './api';
const AI_TIMEOUT = Number(process.env.REACT_APP_AI_TIMEOUT_MS || '') || 90000;
export interface AIGenerateBlogReq {
prompt: string;
@@ -13,7 +14,7 @@ export interface AIGenerateBlogResp {
}
export async function generateBlogAI(payload: AIGenerateBlogReq): Promise<AIGenerateBlogResp> {
const { data } = await api.post<AIGenerateBlogResp>('/ai/blog/generate', payload);
const { data } = await api.post<AIGenerateBlogResp>('/ai/blog/generate', payload, { timeout: AI_TIMEOUT });
// Handle potential JSON string response from AI (defensive parsing)
let parsedData = data;
@@ -47,13 +48,14 @@ export interface AIGenerateInstagramReq {
hashtags?: string[];
audience?: string;
tone?: string;
category?: string;
match?: AIGenerateInstagramMatch | null;
}
export interface AIGenerateInstagramResp { text: string }
export async function generateInstagramAI(payload: AIGenerateInstagramReq): Promise<AIGenerateInstagramResp> {
const { data } = await api.post<AIGenerateInstagramResp>('/ai/instagram/generate', payload);
const { data } = await api.post<AIGenerateInstagramResp>('/ai/instagram/generate', payload, { timeout: AI_TIMEOUT });
let parsed: any = data;
if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed); } catch { parsed = { text: '' }; }
@@ -77,7 +79,7 @@ export interface AIGenerateCSSResp {
}
export async function generateCSSAI(payload: AIGenerateCSSReq): Promise<AIGenerateCSSResp> {
const { data } = await api.post<AIGenerateCSSResp>('/ai/css/generate', payload);
const { data } = await api.post<AIGenerateCSSResp>('/ai/css/generate', payload, { timeout: AI_TIMEOUT });
let parsed = data as any;
if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed); } catch { parsed = { css: '' }; }
@@ -101,7 +103,7 @@ export interface AIGenerateAboutResp {
}
export async function generateAboutAI(payload: AIGenerateAboutReq): Promise<AIGenerateAboutResp> {
const { data } = await api.post<AIGenerateAboutResp>('/ai/about/generate', payload);
const { data } = await api.post<AIGenerateAboutResp>('/ai/about/generate', payload, { timeout: AI_TIMEOUT });
// Handle potential JSON string response from AI (defensive parsing)
let parsedData = data;
+114
View File
@@ -0,0 +1,114 @@
import { triggerPrefetch } from './admin/prefetch';
import { Article, CreateArticlePayload, UpdateArticlePayload, createArticle, updateArticle } from './articles';
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function normStr(v: any): string {
return String(v ?? '').trim();
}
function normalizeAttachments(aRaw: any): Array<{ name: string; url: string; mime_type?: string; size?: number }> | undefined {
try {
const arr = Array.isArray(aRaw) ? aRaw : (typeof aRaw === 'string' ? JSON.parse(aRaw) : []);
if (!Array.isArray(arr) || arr.length === 0) return undefined;
return arr.map((it: any) => {
if (typeof it === 'string') {
const name = it.split('/').pop() || 'soubor';
return { name, url: it };
}
const name = it?.name || (String(it?.url || '').split('/').pop() || 'soubor');
const url = String(it?.url || '');
const mime_type = it?.mime_type || it?.type;
const size = typeof it?.size === 'number' ? it.size : undefined;
return { name, url, mime_type, size };
});
} catch {
return undefined;
}
}
function normalizeGalleryIds(raw: any): string[] | undefined {
if (Array.isArray(raw)) return raw.map(String);
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed.map(String);
} catch {}
return raw.split(',').map((s) => s.trim()).filter(Boolean);
}
return undefined;
}
function buildUpdatePayload(editing: Partial<Article>): UpdateArticlePayload {
const attachments = normalizeAttachments((editing as any)?.attachments);
const galleryIds = normalizeGalleryIds((editing as any)?.gallery_photo_ids);
return {
title: normStr((editing as any)?.title),
content: typeof (editing as any)?.content === 'string' ? (editing as any).content : '',
image_url: normStr((editing as any)?.image_url),
...(typeof (editing as any)?.category_id === 'number' ? { category_id: (editing as any).category_id } : {}),
category_name: normStr((editing as any)?.category_name),
slug: normStr((editing as any)?.slug),
seo_title: normStr((editing as any)?.seo_title),
seo_description: normStr((editing as any)?.seo_description),
og_image_url: normStr((editing as any)?.og_image_url),
featured: !!(editing as any)?.featured,
// Gallery
gallery_album_id: normStr((editing as any)?.gallery_album_id),
gallery_album_url: normStr((editing as any)?.gallery_album_url),
...(galleryIds ? { gallery_photo_ids: galleryIds } : {}),
// YouTube
youtube_video_id: normStr((editing as any)?.youtube_video_id),
youtube_video_title: normStr((editing as any)?.youtube_video_title),
youtube_video_url: normStr((editing as any)?.youtube_video_url),
youtube_video_thumbnail: normStr((editing as any)?.youtube_video_thumbnail),
// Attachments
...(attachments ? { attachments } : {}),
} as UpdateArticlePayload;
}
function buildCreatePayload(editing: Partial<Article>): CreateArticlePayload {
const u = buildUpdatePayload(editing);
return u as unknown as CreateArticlePayload;
}
export async function saveArticleReliable(editing: Partial<Article>): Promise<Article> {
const id = (editing as any)?.id;
const isUpdate = !!id;
const payloadU = buildUpdatePayload(editing);
const payloadC = buildCreatePayload(editing);
const maxAttempts = 3;
let lastErr: any;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
let saved: Article;
if (isUpdate) {
try {
saved = await updateArticle(id as any, payloadU);
} catch (e: any) {
if (e?.response?.status === 404) {
saved = await createArticle(payloadC);
} else {
throw e;
}
}
} else {
saved = await createArticle(payloadC);
}
if (saved?.published) {
try { await triggerPrefetch(); } catch {}
}
return saved;
} catch (e) {
lastErr = e;
if (attempt < maxAttempts) {
await sleep(attempt * 400);
continue;
}
throw e;
}
}
throw lastErr;
}
+3 -2
View File
@@ -199,7 +199,7 @@ export async function deleteArticle(id: number | string) {
export async function getArticleBySlug(slug: string) {
try {
const res = await api.get<Article>(`/articles/slug/${encodeURIComponent(slug)}`);
return res.data;
return normalizeArticle(res.data);
} catch (e) {
// Fallback: attempt list query through normalized helper and return first match
const list = await getArticles({ slug });
@@ -239,7 +239,8 @@ export async function uploadFile(file: File) {
export async function trackArticleView(id: number | string) {
try {
await api.post(`/articles/${id}/track-view`);
// Send an explicit empty JSON body to satisfy backend Content-Type validation
await api.post(`/articles/${id}/track-view`, {});
} catch (e) {
console.debug('Failed to track article view:', e);
}
+37 -10
View File
@@ -29,7 +29,8 @@ export function composeInstagramPostFromArticle(params: {
}): string {
const { article, trackingUrl, clubName, hashtags = [], match } = params;
const title = article.title?.trim() || '';
const plain = stripHtml(article.content).slice(0, 280);
const catName = (article as any)?.category?.name || (article as any)?.category_name || '';
const snippet = stripHtml(article.content).slice(0, 160);
const defaultTags = hashtags.length ? hashtags : [
`#${normalizeTag(clubName || 'FKKrnov')}`,
'#fotbal',
@@ -43,15 +44,15 @@ export function composeInstagramPostFromArticle(params: {
const date = match.date_time ? formatDateTime(match.date_time) : '';
const score = match.score && /\d/.test(match.score) ? match.score : '';
const header = `💙💛 ${clubName || 'Náš klub'}: ${title} 💛💙`;
const header = `💙💛 ${(catName || clubName || 'Náš klub')}: ${title} 💛💙`;
const lines = [
header,
'',
score ? `Výsledek: ${home} ${score} ${away}` : `${home} vs ${away}`,
comp || date ? `${comp}${comp && date ? ' • ' : ''}${date}` : '',
match.venue ? `Místo: ${match.venue}` : '',
match.venue ? `Místo: ${cleanVenue(String(match.venue))}` : '',
'',
plain ? `${plain}${plain.length === 280 ? '…' : ''}` : '',
snippet ? `${snippet}${snippet.length === 160 ? '…' : ''}` : '',
'',
'📸 Celý článek najdeš tady 👇',
`🔗 ${trackingUrl}`,
@@ -63,11 +64,11 @@ export function composeInstagramPostFromArticle(params: {
}
// Informative/general article
const header = `💙💛 ${clubName || 'Náš klub'}: ${title} 💛💙`;
const header = `💙💛 ${(catName || clubName || 'Náš klub')}: ${title} 💛💙`;
const lines = [
header,
'',
plain,
snippet,
'',
'📸 Celý článek najdeš tady 👇',
`🔗 ${trackingUrl}`,
@@ -112,12 +113,38 @@ export function composeInstagramPostFromActivity(params: {
return lines.join('\n');
}
function formatDateTime(dt: string): string {
export function formatDateTime(dt: string): string {
const s = String(dt || '').trim();
// Handle FAČR format: dd.mm.yyyy or dd.mm.yyyy HH:MM
const m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}):(\d{2}))?$/);
if (m) {
const dd = parseInt(m[1], 10);
const MM = parseInt(m[2], 10);
const yyyy = parseInt(m[3], 10);
const hh = m[4] ? parseInt(m[4], 10) : 0;
const min = m[5] ? parseInt(m[5], 10) : 0;
const d = new Date(yyyy, MM - 1, dd, hh, min);
const dateStr = d.toLocaleDateString('cs-CZ');
const timeStr = (m[4] ? d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' }) : '');
return timeStr ? `${dateStr} ${timeStr}` : dateStr;
}
// ISO-like or other parseable formats
try {
const d = new Date(dt);
return `${d.toLocaleDateString('cs-CZ')} ${d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}`;
const d = new Date(s);
if (!isNaN(d.getTime())) {
return `${d.toLocaleDateString('cs-CZ')} ${d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}`;
}
} catch {}
return s;
}
export function cleanVenue(v: string): string {
try {
const base = String(v || '').trim();
// Prefer locality before first " - " (e.g., "Kobeřice - tráva" -> "Kobeřice")
return base.split(' - ')[0].trim();
} catch {
return dt;
return v;
}
}
+19
View File
@@ -0,0 +1,19 @@
import api from './api';
export type RembgStatus = {
running: boolean;
total: number;
done: number;
started_at?: string;
finished_at?: string | null;
};
export const getRembgStatus = async (): Promise<RembgStatus> => {
const res = await api.get('/rembg/status', { timeout: 20000 });
return res.data;
};
export const startRembgBatch = async (): Promise<{ started: boolean; status: RembgStatus }> => {
const res = await api.post('/rembg/start', null, { timeout: 20000 });
return res.data;
};
+27 -2
View File
@@ -12,6 +12,8 @@ export type ScoreboardState = {
awayShort?: string;
primaryColor?: string; // home color
secondaryColor?: string; // away color
homeTextColor?: string; // text color for home label/short
awayTextColor?: string; // text color for away label/short
homeScore: number;
awayScore: number;
homeFouls?: number;
@@ -286,15 +288,32 @@ export async function derivePrimaryFromLogo(logoUrl?: string): Promise<string |
// Helpers to map API payloads
function normalizeFromApi(d: any): Partial<ScoreboardState> {
if (!d) return {};
const absolutize = (u?: string) => {
try {
if (!u) return '';
const s = String(u);
if (s.startsWith('/uploads/') || s.startsWith('/dist/')) {
const base = new URL(API_URL || '', typeof window !== 'undefined' ? window.location.origin : undefined);
return `${base.protocol}//${base.host}${s}`;
}
return s;
} catch {
return u || '';
}
};
const rawHome = d.homeLogo || d.home_logo || d.home_logo_url || d.HomeLogoURL || '';
const rawAway = d.awayLogo || d.away_logo || d.away_logo_url || d.AwayLogoURL || '';
return {
homeName: d.homeName || d.home_name || d.HomeName || '',
awayName: d.awayName || d.away_name || d.AwayName || '',
homeLogo: d.homeLogo || d.home_logo || d.home_logo_url || d.HomeLogoURL || '',
awayLogo: d.awayLogo || d.away_logo || d.away_logo_url || d.AwayLogoURL || '',
homeLogo: absolutize(rawHome),
awayLogo: absolutize(rawAway),
homeShort: d.homeShort || d.home_short || d.HomeShort || '',
awayShort: d.awayShort || d.away_short || d.AwayShort || '',
primaryColor: d.primaryColor || d.primary_color || d.PrimaryColor || undefined,
secondaryColor: d.secondaryColor || d.secondary_color || d.SecondaryColor || undefined,
homeTextColor: d.homeTextColor || d.home_text_color || d.HomeTextColor || undefined,
awayTextColor: d.awayTextColor || d.away_text_color || d.AwayTextColor || undefined,
homeScore: typeof d.homeScore === 'number' ? d.homeScore : (typeof d.home_score === 'number' ? d.home_score : 0),
awayScore: typeof d.awayScore === 'number' ? d.awayScore : (typeof d.away_score === 'number' ? d.away_score : 0),
homeFouls: typeof d.homeFouls === 'number' ? d.homeFouls : (typeof d.home_fouls === 'number' ? d.home_fouls : 0),
@@ -322,6 +341,8 @@ function toApiPayload(p: Partial<ScoreboardState>) {
if (p.awayShort !== undefined) out.awayShort = p.awayShort;
if (p.primaryColor !== undefined) out.primaryColor = p.primaryColor;
if (p.secondaryColor !== undefined) out.secondaryColor = p.secondaryColor;
if (p.homeTextColor !== undefined) out.homeTextColor = p.homeTextColor;
if (p.awayTextColor !== undefined) out.awayTextColor = p.awayTextColor;
if (p.homeScore !== undefined) out.homeScore = p.homeScore;
if (p.awayScore !== undefined) out.awayScore = p.awayScore;
if (p.homeFouls !== undefined) out.homeFouls = p.homeFouls;
@@ -337,3 +358,7 @@ function toApiPayload(p: Partial<ScoreboardState>) {
if (p.qrDuration !== undefined) out.qrDuration = p.qrDuration;
return out;
}
export async function deleteQr(): Promise<void> {
await api.delete('/admin/scoreboard/qr');
}
+34 -13
View File
@@ -18,32 +18,53 @@ export interface ShortLinkResponse {
}
export async function createShortLink(payload: CreateShortLinkPayload): Promise<ShortLinkResponse> {
const normalized: CreateShortLinkPayload = { ...payload };
if (normalized.target_url && !/^https?:\/\//i.test(normalized.target_url)) {
normalized.target_url = `https://${normalized.target_url}`;
}
if (typeof normalized.code === 'string') {
const s = normalized.code.trim();
const filtered = s.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 16);
normalized.code = filtered || undefined;
}
// Prefer admin endpoint in admin contexts to avoid 400/403 on public routes
try {
// Prefer editor-accessible endpoint
const res = await api.post<ShortLinkResponse>('/shortlinks', payload);
return res.data;
} catch (e: any) {
// Fallback to admin endpoint (for admin-only contexts)
const res2 = await api.post<ShortLinkResponse>('/admin/shortlinks', payload);
return res2.data;
const resAdmin = await api.post<ShortLinkResponse>('/admin/shortlinks', normalized);
return resAdmin.data;
} catch (_) {
// Fallback to public/editor route if admin path is not available
try {
const resPublic = await api.post<ShortLinkResponse>('/shortlinks', normalized);
return resPublic.data;
} catch (e2: any) {
// Last resort: public-create endpoint (strict allowed-host policy)
const resPub = await api.post<ShortLinkResponse>('/shortlinks/public', {
target_url: normalized.target_url!,
title: normalized.title,
} as any);
return resPub.data;
}
}
}
// Public shortlink creation for visitors (no auth; backend validates allowed host)
export async function createPublicShortLink(payload: { target_url: string; title?: string }): Promise<ShortLinkResponse> {
const res = await api.post<ShortLinkResponse>('/shortlinks/public', payload);
const body = { ...payload };
if (body.target_url && !/^https?:\/\//i.test(body.target_url)) {
body.target_url = `https://${body.target_url}`;
}
const res = await api.post<ShortLinkResponse>('/shortlinks/public', body);
return res.data;
}
export async function listShortLinks(): Promise<{ items: any[] }> {
// Prefer editor-accessible endpoint
// Prefer admin endpoint first in admin context
try {
const resAdmin = await api.get<{ items: any[] }>('/admin/shortlinks');
return resAdmin.data;
} catch (_) {
const res = await api.get<{ items: any[] }>('/shortlinks');
return res.data;
} catch (e) {
// Fallback to admin endpoint (admins only)
const res2 = await api.get<{ items: any[] }>('/admin/shortlinks');
return res2.data;
}
}