Files
MyClub/frontend/src/components/common/TeamLogo.tsx
T
Tomas Dvorak 8762bde4bf dev day #89
2025-11-11 10:29:30 +01:00

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;