mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 18:52:56 +00:00
dev day #99
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user