mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 18:52:56 +00:00
411 lines
19 KiB
TypeScript
411 lines
19 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import MainLayout from '../components/layout/MainLayout';
|
|
import { Box, Container, Heading, Text, Tabs, TabList, TabPanels, Tab, TabPanel, Table, Thead, Tbody, Tr, Th, Td, Flex, Spinner, Badge, Link, useColorModeValue } from '@chakra-ui/react';
|
|
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
|
|
import NewsletterCTA from '../components/common/NewsletterCTA';
|
|
import ClubModal from '../components/home/ClubModal';
|
|
import { usePublicSettings } from '../hooks/usePublicSettings';
|
|
import { sortCategoriesWithOrder } from '../utils/categorySort';
|
|
import { TeamLogo } from '../components/common/TeamLogo';
|
|
import { API_URL } from '../services/api';
|
|
|
|
type TableRow = {
|
|
rank: string;
|
|
team: string;
|
|
team_id?: string;
|
|
team_logo_url?: string;
|
|
played: string;
|
|
wins: string;
|
|
draws: string;
|
|
losses: string;
|
|
score: string;
|
|
points: string;
|
|
};
|
|
|
|
function deriveTeamIdFromLogoUrl(url?: string): string | undefined {
|
|
try {
|
|
const u = String(url || '');
|
|
if (!u) return undefined;
|
|
const m = u.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/);
|
|
return m ? m[0].toLowerCase() : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
type CompetitionTable = {
|
|
id: string;
|
|
name: string;
|
|
code?: string;
|
|
matches_link?: string;
|
|
rows: TableRow[];
|
|
};
|
|
|
|
const resolveBackendUrl = (path: string) => {
|
|
try {
|
|
if (/^https?:\/\//i.test(path)) return path;
|
|
if (path.startsWith('/cache') || path.startsWith('/uploads')) {
|
|
const u = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : undefined);
|
|
const abs = new URL(path, `${u.protocol}//${u.host}`);
|
|
return abs.toString();
|
|
}
|
|
return path;
|
|
} catch {
|
|
return path;
|
|
}
|
|
};
|
|
|
|
const TablesPage: React.FC = () => {
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [competitions, setCompetitions] = useState<CompetitionTable[]>([]);
|
|
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string; display_order?: number }>>({});
|
|
const [selectedClub, setSelectedClub] = useState<any>(null);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [overrides, setOverrides] = useState<{ by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> } | null>(null);
|
|
const { data: settings } = usePublicSettings();
|
|
const cardBg = useColorModeValue('white', 'gray.800');
|
|
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
|
const textSecondary = useColorModeValue('gray.600', 'gray.400');
|
|
const tableBg = useColorModeValue('white', 'gray.800');
|
|
const tableHeaderBg = useColorModeValue('var(--primary, #C53030)', 'var(--primary, #9b2c2c)');
|
|
const tableTextColor = useColorModeValue('gray.800', 'gray.200');
|
|
const rowOddBg = useColorModeValue('white', 'gray.800');
|
|
const rowEvenBg = useColorModeValue('gray.50', 'gray.700');
|
|
|
|
type SortKey = 'rank' | 'team' | 'played' | 'wins' | 'draws' | 'losses' | 'score' | 'points';
|
|
type SortOrder = 'desc' | 'asc';
|
|
const [sortState, setSortState] = useState<Record<string, { key: SortKey; order: SortOrder } | null>>({});
|
|
const toNumber = (v: any): number => {
|
|
if (typeof v === 'number') return v;
|
|
const n = parseFloat(String(v ?? '').replace(/[^0-9\-\.]/g, ''));
|
|
return isNaN(n) ? 0 : n;
|
|
};
|
|
const scoreDiff = (s: any): number => {
|
|
const str = String(s ?? '').trim();
|
|
const m = str.match(/(-?\d+)\s*[:\-]\s*(-?\d+)/);
|
|
if (m) return Number(m[1]) - Number(m[2]);
|
|
return toNumber(str);
|
|
};
|
|
const toggleSort = (compId: string, key: SortKey) => {
|
|
const cur = sortState[compId];
|
|
if (!cur || cur.key !== key) { setSortState({ ...sortState, [compId]: { key, order: 'desc' } }); return; }
|
|
if (cur.order === 'desc') { setSortState({ ...sortState, [compId]: { key, order: 'asc' } }); return; }
|
|
const next = { ...sortState }; next[compId] = null; setSortState(next);
|
|
};
|
|
const arrow = (compId: string, key: SortKey) => {
|
|
const cur = sortState[compId];
|
|
if (!cur || cur.key !== key) return '';
|
|
return cur.order === 'desc' ? '▼' : '▲';
|
|
};
|
|
|
|
const handleClubClick = (club: any) => {
|
|
setSelectedClub(club);
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
// Load overrides (API + cached file)
|
|
try {
|
|
const now = Date.now();
|
|
let ovr: any = null;
|
|
try {
|
|
const res = await fetch(`/api/v1/public/team-logo-overrides?t=${now}`, { cache: 'no-cache' });
|
|
if (res.ok) ovr = await res.json();
|
|
} catch {}
|
|
if (!ovr) {
|
|
try {
|
|
const res2 = await fetch('/cache/prefetch/team_logo_overrides.json', { cache: 'no-cache' });
|
|
if (res2.ok) ovr = await res2.json();
|
|
} catch {}
|
|
}
|
|
if (!cancelled) setOverrides(ovr || { by_id: {}, by_name: {} });
|
|
} catch {}
|
|
// Load aliases first
|
|
let amap: Record<string, { alias: string; original_name?: string; display_order?: number }> = {};
|
|
try {
|
|
const aliases: CompetitionAlias[] = await getCompetitionAliasesPublic();
|
|
(aliases || []).forEach((a) => { if (a?.code && a?.alias) amap[a.code] = { alias: a.alias, original_name: a.original_name, display_order: a.display_order }; });
|
|
} catch {}
|
|
const res = await fetch(resolveBackendUrl('/cache/prefetch/facr_tables.json'), { cache: 'no-cache' });
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const json = await res.json();
|
|
const comps: CompetitionTable[] = Array.isArray(json?.competitions)
|
|
? json.competitions.map((c: any, idx: number) => {
|
|
const overall = c?.table?.overall || [];
|
|
const rows: TableRow[] = Array.isArray(overall) ? overall.map((r: any) => ({
|
|
rank: String(r.rank ?? ''),
|
|
team: String(r.team ?? ''),
|
|
team_id: r.team_id,
|
|
team_logo_url: r.team_logo_url ? resolveBackendUrl(r.team_logo_url) : undefined,
|
|
played: String(r.played ?? ''),
|
|
wins: String(r.wins ?? ''),
|
|
draws: String(r.draws ?? ''),
|
|
losses: String(r.losses ?? ''),
|
|
score: String(r.score ?? ''),
|
|
points: String(r.points ?? ''),
|
|
})) : [];
|
|
return {
|
|
id: String(c.id || idx),
|
|
name: (amap?.[c?.code]?.alias) || (amap?.[String(c.id || idx)]?.alias) || c.name || c.code || `Soutěž ${idx+1}`,
|
|
alias: (amap?.[c?.code]?.alias) || (amap?.[String(c.id || idx)]?.alias),
|
|
display_order: (amap?.[c?.code]?.display_order) ?? (amap?.[String(c.id || idx)]?.display_order),
|
|
code: c.code,
|
|
matches_link: c.matches_link,
|
|
rows,
|
|
};
|
|
}) : [];
|
|
if (!cancelled) {
|
|
setAliasMap(amap);
|
|
// Only keep competitions that have at least one row in the table
|
|
const filtered = (comps || []).filter((c) => Array.isArray(c.rows) && c.rows.length > 0);
|
|
// Sort competitions by age (Muži first, then U19, U17, etc.) and respect custom order
|
|
const sorted = sortCategoriesWithOrder(filtered) as typeof filtered;
|
|
setCompetitions(sorted);
|
|
}
|
|
} catch (e: any) {
|
|
if (!cancelled) setError(e?.message || 'Nepodařilo se načíst tabulky.');
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
})();
|
|
return () => { cancelled = true; };
|
|
}, []);
|
|
|
|
// Normalization helpers (same as CalendarPage/TableSection)
|
|
const normalize = (s: string) => String(s || '')
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.toLowerCase();
|
|
const stripPrefixes = (s: string) => {
|
|
let x = normalize(s);
|
|
x = x.replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g, '');
|
|
return x.replace(/\s+/g, ' ').trim();
|
|
};
|
|
const byNameMap = useMemo(() => {
|
|
const m: Record<string, string> = {};
|
|
const src = overrides?.by_name || {};
|
|
for (const k of Object.keys(src)) m[normalize(k)] = src[k];
|
|
return m;
|
|
}, [overrides]);
|
|
const byIdMap = useMemo(() => (overrides?.by_id || {}) as Record<string, { name?: string; logo_url?: string }>, [overrides]);
|
|
const overridesNameIndex = useMemo(() => {
|
|
const idx: Record<string, { id: string; name: string }> = {};
|
|
try {
|
|
for (const [id, v] of Object.entries(byIdMap)) {
|
|
const name = String((v as any)?.name || '').trim();
|
|
if (!name) continue;
|
|
const key = normalize(name);
|
|
if (!key) continue;
|
|
idx[key] = { id, name };
|
|
}
|
|
} catch {}
|
|
return idx;
|
|
}, [byIdMap]);
|
|
const pickName = (teamId?: string, original?: string, logoUrl?: string) => {
|
|
const id = String(teamId || '') || deriveTeamIdFromLogoUrl(logoUrl) || '';
|
|
const v = id ? byIdMap?.[id]?.name : undefined;
|
|
if (v && String(v).trim().length > 0) return String(v);
|
|
const orig = String(original || '');
|
|
if (orig) {
|
|
const n = normalize(orig);
|
|
let hit = overridesNameIndex[n];
|
|
if (!hit) {
|
|
for (const [k, val] of Object.entries(overridesNameIndex)) {
|
|
if (!k) continue;
|
|
if (n.endsWith(k) || k.endsWith(n)) { hit = val as any; break; }
|
|
}
|
|
}
|
|
if (!hit) {
|
|
const t1 = n.split(' ')[0];
|
|
if (t1 && t1.length >= 5) {
|
|
for (const [k, val] of Object.entries(overridesNameIndex)) {
|
|
const k1 = String(k).split(' ')[0];
|
|
if (k1 === t1) { hit = val as any; break; }
|
|
}
|
|
}
|
|
}
|
|
if (hit?.name) return hit.name;
|
|
}
|
|
return orig;
|
|
};
|
|
const pickLogo = (teamId?: string, teamName?: string, original?: string): string | undefined => {
|
|
if (teamId && byIdMap?.[teamId]?.logo_url) return byIdMap[teamId]!.logo_url as string;
|
|
if (teamName) {
|
|
const exact = (overrides?.by_name || {})[teamName];
|
|
if (exact) return exact;
|
|
const n = normalize(teamName);
|
|
const cand = byNameMap[n];
|
|
if (cand) return cand;
|
|
const stripped = stripPrefixes(teamName);
|
|
for (const k of Object.keys(overrides?.by_name || {})) {
|
|
const kn = stripPrefixes(k);
|
|
if (!kn) continue;
|
|
if (stripped.endsWith(kn) || kn.endsWith(stripped)) return (overrides!.by_name as any)[k];
|
|
}
|
|
}
|
|
return original;
|
|
};
|
|
|
|
return (
|
|
<MainLayout>
|
|
<Container maxW="7xl" py={{ base: 6, md: 10 }}>
|
|
<Heading as="h1" size="xl" mb={2}>Tabulky</Heading>
|
|
<Text color={textSecondary} mb={6}>Oficiální tabulky FACR podle soutěží.</Text>
|
|
|
|
{loading && (
|
|
<Flex align="center" gap={3} color={textSecondary} mb={6}>
|
|
<Spinner size="sm" />
|
|
<span>Načítám tabulky…</span>
|
|
</Flex>
|
|
)}
|
|
{error && (
|
|
<Box color="red.600" mb={6}>{error}</Box>
|
|
)}
|
|
|
|
{!!competitions.length && (
|
|
<Tabs variant="enclosed" size="sm">
|
|
<TabList px={2} pt={2} overflowX="auto" overflowY="hidden" css={{
|
|
'&::-webkit-scrollbar': { height: '4px' },
|
|
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
|
'&::-webkit-scrollbar-thumb': { background: 'var(--chakra-colors-gray-300)', borderRadius: '4px' },
|
|
}}>
|
|
{competitions.map((c) => (
|
|
<Tab
|
|
key={c.id}
|
|
_selected={{ bg: 'brand.primary', color: 'text.onPrimary', borderColor: 'brand.primary' }}
|
|
_hover={{ bg: 'rgba(0,0,0,0.04)' }}
|
|
flex="0 0 auto"
|
|
px={3}
|
|
py={2}
|
|
>
|
|
<Text as="span" noOfLines={1} maxW="300px" title={c.name}>{c.name}</Text>
|
|
</Tab>
|
|
))}
|
|
</TabList>
|
|
<TabPanels>
|
|
{competitions.map((c) => (
|
|
<TabPanel key={c.id} px={0}>
|
|
<Flex align="center" justify="space-between" mb={3}>
|
|
<Heading as="h2" size="md">{c.name}</Heading>
|
|
{c.matches_link && (
|
|
<Link href={c.matches_link} isExternal color="brand.primary" _hover={{ filter: 'brightness(0.9)' }}>Rozpis a detail soutěže</Link>
|
|
)}
|
|
</Flex>
|
|
<Box borderWidth="1px" borderColor={borderColor} borderRadius="lg" overflowX="auto" boxShadow="sm" bg={tableBg}>
|
|
<Table size="sm" variant="unstyled" color={tableTextColor}>
|
|
<Thead position="sticky" top={0} zIndex={2}>
|
|
<Tr bg={tableHeaderBg} color="white">
|
|
<Th w="56px" color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'rank')}># {arrow(c.id, 'rank')}</Th>
|
|
<Th color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'team')}>Tým {arrow(c.id, 'team')}</Th>
|
|
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'played')}>Z {arrow(c.id, 'played')}</Th>
|
|
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'wins')}>V {arrow(c.id, 'wins')}</Th>
|
|
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'draws')}>R {arrow(c.id, 'draws')}</Th>
|
|
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'losses')}>P {arrow(c.id, 'losses')}</Th>
|
|
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'score')}>Skóre {arrow(c.id, 'score')}</Th>
|
|
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'points')}>Body {arrow(c.id, 'points')}</Th>
|
|
</Tr>
|
|
</Thead>
|
|
<Tbody>
|
|
{(() => {
|
|
const cur = sortState[c.id];
|
|
const arr = [...(c.rows || [])];
|
|
if (cur) {
|
|
arr.sort((a: any, b: any) => {
|
|
let va: any; let vb: any; let isText = false;
|
|
switch (cur.key) {
|
|
case 'team': va = pickName(a.team_id, a.team, a.team_logo_url); vb = pickName(b.team_id, b.team, b.team_logo_url); isText = true; break;
|
|
case 'rank': va = toNumber(a.rank); vb = toNumber(b.rank); break;
|
|
case 'played': va = toNumber(a.played); vb = toNumber(b.played); break;
|
|
case 'wins': va = toNumber(a.wins); vb = toNumber(b.wins); break;
|
|
case 'draws': va = toNumber(a.draws); vb = toNumber(b.draws); break;
|
|
case 'losses': va = toNumber(a.losses); vb = toNumber(b.losses); break;
|
|
case 'score': va = scoreDiff(a.score); vb = scoreDiff(b.score); break;
|
|
case 'points': va = toNumber(a.points); vb = toNumber(b.points); break;
|
|
default: va = 0; vb = 0;
|
|
}
|
|
let res = isText ? String(va).localeCompare(String(vb)) : (va as number) - (vb as number);
|
|
if (cur.order === 'desc') res = -res;
|
|
if (res === 0) {
|
|
const ra = toNumber(a.rank); const rb = toNumber(b.rank);
|
|
res = ra - rb;
|
|
}
|
|
return res;
|
|
});
|
|
}
|
|
return arr.map((r, idx) => {
|
|
const displayTeam = pickName(r.team_id, r.team, r.team_logo_url);
|
|
const displayLogo = pickLogo(r.team_id, displayTeam, r.team_logo_url);
|
|
return (
|
|
<Tr
|
|
key={`${c.id}-${r.rank}-${r.team}`}
|
|
transition="all 0.15s"
|
|
_hover={{ transform: 'translateY(-3px)', boxShadow: 'lg', zIndex: 3, cursor: 'pointer' }}
|
|
position="relative"
|
|
bg={idx % 2 === 0 ? rowOddBg : rowEvenBg}
|
|
onClick={() => handleClubClick(r)}
|
|
>
|
|
<Td>
|
|
<Badge variant="subtle" bg={useColorModeValue('gray.100', 'gray.700')} color={tableTextColor} borderWidth="1px" borderColor={borderColor}>{r.rank}</Badge>
|
|
</Td>
|
|
<Td>
|
|
<Flex align="center" gap={3}>
|
|
<TeamLogo
|
|
teamId={r.team_id || deriveTeamIdFromLogoUrl(r.team_logo_url)}
|
|
teamName={displayTeam}
|
|
facrLogo={displayLogo}
|
|
size="small"
|
|
alt={displayTeam}
|
|
objectFit="contain"
|
|
bg={cardBg}
|
|
borderWidth="1px"
|
|
borderColor={borderColor}
|
|
/>
|
|
<Text fontWeight="medium" color={tableTextColor}>{displayTeam}</Text>
|
|
</Flex>
|
|
</Td>
|
|
<Td isNumeric color={tableTextColor}>{r.played}</Td>
|
|
<Td isNumeric color={tableTextColor}>{r.wins}</Td>
|
|
<Td isNumeric color={tableTextColor}>{r.draws}</Td>
|
|
<Td isNumeric color={tableTextColor}>{r.losses}</Td>
|
|
<Td isNumeric color={tableTextColor}>{r.score}</Td>
|
|
<Td isNumeric>
|
|
<Badge variant="solid" bg="blue.600" color="white">{r.points}</Badge>
|
|
</Td>
|
|
</Tr>
|
|
);});})()}
|
|
</Tbody>
|
|
</Table>
|
|
</Box>
|
|
</TabPanel>
|
|
))}
|
|
</TabPanels>
|
|
</Tabs>
|
|
)}
|
|
{!loading && !error && competitions.length === 0 && (
|
|
<Text color={textSecondary}>Pro tento klub nejsou dostupné tabulky.</Text>
|
|
)}
|
|
</Container>
|
|
|
|
{/* Newsletter CTA */}
|
|
<NewsletterCTA />
|
|
|
|
<ClubModal
|
|
isOpen={isModalOpen}
|
|
onClose={() => setIsModalOpen(false)}
|
|
club={selectedClub}
|
|
clubType={(settings?.club_type as 'football' | 'futsal') || 'football'}
|
|
/>
|
|
</MainLayout>
|
|
);
|
|
};
|
|
|
|
export default TablesPage;
|