mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 18:52:56 +00:00
262 lines
9.1 KiB
TypeScript
262 lines
9.1 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { Image, ImageProps, Skeleton } from '@chakra-ui/react';
|
|
import { getTeamLogo } from '../../utils/sportLogosAPI';
|
|
import { getLogoStyle, getLogoClassName } from '../../utils/logoUtils';
|
|
import '../../styles/logos.css';
|
|
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
|
import { assetUrl } from '../../utils/url';
|
|
import { useIntersectionObserver } from '../../hooks/useIntersectionObserver';
|
|
// Lightweight cached overrides loader
|
|
let __teamOverridesCache: { ts: number; data: { by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> } } | null = null;
|
|
const loadTeamOverrides = async (): Promise<{ by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> }> => {
|
|
const now = Date.now();
|
|
const TTL = 5_000;
|
|
if (__teamOverridesCache && now - __teamOverridesCache.ts < TTL) {
|
|
return __teamOverridesCache.data || {};
|
|
}
|
|
// Try fresh public endpoint first
|
|
try {
|
|
const res = await fetch(`/api/v1/public/team-logo-overrides?t=${now}`, { cache: 'no-cache' });
|
|
if (res.ok) {
|
|
const json = await res.json();
|
|
__teamOverridesCache = { ts: now, data: json || {} };
|
|
return json || {};
|
|
}
|
|
} catch {}
|
|
// Fallback to static cache snapshot
|
|
try {
|
|
const res2 = await fetch('/cache/prefetch/team_logo_overrides.json', { cache: 'no-cache' });
|
|
if (res2.ok) {
|
|
const json = await res2.json();
|
|
__teamOverridesCache = { ts: now, data: json || {} };
|
|
return json || {};
|
|
}
|
|
} catch {}
|
|
// Final fallback: previously cached data or empty
|
|
if (__teamOverridesCache) return __teamOverridesCache.data || {};
|
|
__teamOverridesCache = { ts: now, data: {} };
|
|
return {};
|
|
};
|
|
|
|
// Normalization helpers for name-based matching
|
|
const __normalize = (s?: string) => {
|
|
let out = String(s || '');
|
|
out = out
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.toLowerCase();
|
|
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
|
|
out = out.replace(/\bn\.?\b/g, ' nad ');
|
|
out = out.replace(/\bp\.?\b/g, ' pod ');
|
|
out = out.replace(/[.,!;:()\[\]{}]/g, ' ');
|
|
// Remove legal suffixes often appended to Czech organizations
|
|
out = out.replace(/[\s,]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
|
|
// Remove common organization phrases
|
|
const orgPhrases = [
|
|
'fotbalovy klub',
|
|
'sportovni klub',
|
|
'telovychovna jednota',
|
|
'skolni sportovni klub',
|
|
'spolek',
|
|
'fotbal',
|
|
'futsal',
|
|
];
|
|
for (const phrase of orgPhrases) {
|
|
const re = new RegExp('(^|\\b)'+ phrase + '(\\b|$)', 'g');
|
|
out = out.replace(re, ' ');
|
|
}
|
|
// Remove common short prefixes/tokens (FK, FC, MFK, TJ, SK, SFC, AFK, BFK, HFK, etc.)
|
|
out = out.replace(/\b(1\.)?\s*(sfc|afc|fc|fk|mfk|tj|sk|afk|bfk|hfk)\b\.?/g, ' ');
|
|
out = out.replace(/\s+/g, ' ').trim();
|
|
return out;
|
|
};
|
|
|
|
interface TeamLogoProps extends Omit<ImageProps, 'src'> {
|
|
teamId?: string;
|
|
teamName?: string;
|
|
facrLogo?: string;
|
|
size?: 'small' | 'medium' | 'large' | 'custom';
|
|
fallbackIcon?: React.ReactElement;
|
|
}
|
|
|
|
/**
|
|
* TeamLogo component with automatic logoapi.sportcreative.eu integration
|
|
* Features:
|
|
* - Fetches from logoapi first (with local caching)
|
|
* - Falls back to FACR logo if logoapi doesn't have it
|
|
* - Properly centers and formats logos
|
|
* - Handles SVG optimization
|
|
*/
|
|
export const TeamLogo: React.FC<TeamLogoProps> = ({
|
|
teamId,
|
|
teamName,
|
|
facrLogo,
|
|
size = 'medium',
|
|
fallbackIcon,
|
|
alt,
|
|
...imageProps
|
|
}) => {
|
|
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(false);
|
|
const { data: publicSettings } = usePublicSettings();
|
|
const [observeRef, inView] = useIntersectionObserver({ threshold: 0.01, rootMargin: '150px 0px', freezeOnceVisible: true });
|
|
|
|
useEffect(() => {
|
|
if (!inView) return; // defer fetching until visible
|
|
let mounted = true;
|
|
|
|
const fetchLogo = async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(false);
|
|
// Load admin overrides (cached)
|
|
let overrides: { by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> } = {} as any;
|
|
try { overrides = await loadTeamOverrides(); } catch {}
|
|
// Prefer local club logo for own team when IDs match
|
|
if (
|
|
teamId && publicSettings?.club_id && String(teamId) === String(publicSettings.club_id) && publicSettings?.club_logo_url
|
|
) {
|
|
if (mounted) {
|
|
setLogoUrl(assetUrl(publicSettings.club_logo_url) || publicSettings.club_logo_url);
|
|
}
|
|
} else if (teamId && overrides?.by_id?.[teamId]?.logo_url) {
|
|
const v = overrides.by_id[teamId]!.logo_url as string;
|
|
if (mounted) {
|
|
if (typeof v === 'string' && v.startsWith('/')) {
|
|
setLogoUrl(assetUrl(v) || v);
|
|
} else {
|
|
setLogoUrl(v);
|
|
}
|
|
}
|
|
} else {
|
|
// Try name-based override first if ID override not found
|
|
let appliedByName = false;
|
|
try {
|
|
const byName: Record<string, string> = (overrides as any)?.by_name || {};
|
|
if (teamName && byName && Object.keys(byName).length > 0) {
|
|
const normMap: Record<string, string> = {};
|
|
for (const k of Object.keys(byName)) { normMap[__normalize(k)] = byName[k]; }
|
|
const normTeam = __normalize(teamName);
|
|
let candidate = byName[teamName] || normMap[normTeam];
|
|
if (!candidate) {
|
|
// Suffix/containment match after normalization to handle sponsors/affixes
|
|
const entries = Object.keys(byName).map((k) => ({ keyNorm: __normalize(k), url: byName[k] }));
|
|
for (const { keyNorm, url } of entries) {
|
|
if (!keyNorm) continue;
|
|
if (normTeam.endsWith(keyNorm) || keyNorm.endsWith(normTeam)) { candidate = url; break; }
|
|
}
|
|
}
|
|
if (!candidate) {
|
|
const t1 = normTeam.split(' ')[0];
|
|
if (t1 && t1.length >= 5) {
|
|
for (const { keyNorm, url } of Object.keys(byName).map((k) => ({ keyNorm: __normalize(k), url: byName[k] }))) {
|
|
const k1 = String(keyNorm).split(' ')[0];
|
|
if (k1 === t1) { candidate = url; break; }
|
|
}
|
|
}
|
|
}
|
|
if (candidate) {
|
|
appliedByName = true;
|
|
if (mounted) {
|
|
if (typeof candidate === 'string' && candidate.startsWith('/')) {
|
|
setLogoUrl(assetUrl(candidate) || candidate);
|
|
} else {
|
|
setLogoUrl(candidate);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
if (!appliedByName) {
|
|
const url = await getTeamLogo(teamId, teamName, facrLogo);
|
|
if (mounted) {
|
|
setLogoUrl(url);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch logo:', e);
|
|
if (mounted) {
|
|
setError(true);
|
|
// Fallback to FACR or placeholder
|
|
setLogoUrl(facrLogo || '/logo192.png');
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
fetchLogo();
|
|
|
|
return () => {
|
|
mounted = false;
|
|
};
|
|
}, [teamId, teamName, facrLogo, publicSettings?.club_id, publicSettings?.club_logo_url, inView]);
|
|
|
|
// Size mapping
|
|
const sizeMap = {
|
|
small: { boxSize: '24px' },
|
|
medium: { boxSize: '32px' },
|
|
large: { boxSize: '48px' },
|
|
custom: {},
|
|
};
|
|
|
|
const sizeProps = size !== 'custom' ? sizeMap[size] : {};
|
|
|
|
// Class name based on size
|
|
const className = `match-logo-${size} ${imageProps.className || ''}`.trim();
|
|
|
|
if (loading) {
|
|
return (
|
|
<div ref={observeRef as any} style={{ display: 'inline-block' }}>
|
|
<Skeleton
|
|
{...sizeProps}
|
|
borderRadius="4px"
|
|
className="logo-loading"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Check if this is a circular container
|
|
const isCircular = imageProps.borderRadius === 'full' || imageProps.style?.borderRadius === '50%';
|
|
|
|
// Get appropriate styling and className using utility functions
|
|
// Only pass size to utils if it's not 'custom' (utils only accept standard sizes)
|
|
const utilSize = size !== 'custom' ? size : 'medium';
|
|
const logoStyle = getLogoStyle(logoUrl, isCircular, utilSize);
|
|
const logoClassName = getLogoClassName(logoUrl, isCircular, utilSize);
|
|
|
|
return (
|
|
<div ref={observeRef as any} style={{ display: 'inline-block' }}>
|
|
<Image
|
|
src={(assetUrl(logoUrl || undefined) || logoUrl || '/logo192.png')}
|
|
alt={alt || teamName || 'Team logo'}
|
|
decoding="async"
|
|
draggable={false}
|
|
{...sizeProps}
|
|
{...imageProps}
|
|
className={`${className} ${logoClassName}`}
|
|
objectFit="contain"
|
|
loading="lazy"
|
|
fallback={fallbackIcon}
|
|
style={{
|
|
...imageProps.style,
|
|
...logoStyle
|
|
}}
|
|
onError={() => {
|
|
if (!error) {
|
|
setError(true);
|
|
setLogoUrl(facrLogo || '/logo192.png');
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TeamLogo;
|