import { Box, Heading, Text, Spinner, Alert, AlertIcon, Table, Thead, Tbody, Tr, Th, Td, HStack, Badge, Button, useToast, Drawer, DrawerOverlay, DrawerContent, DrawerHeader, DrawerBody, DrawerFooter, FormControl, FormLabel, Input, Stack, InputGroup, InputRightElement, List, ListItem, FormErrorMessage, Image, useBreakpointValue, Wrap, WrapItem, useColorModeValue, Select } from '@chakra-ui/react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import AdminLayout from '../../layouts/AdminLayout'; import { putMatchOverride, patchMatchOverride, searchClubs, uploadImage, fetchLogoAsBlob, uploadToLogaSportcreative, fetchTeamLogoOverrides } from '../../services/adminMatches'; import { getPublicSettings } from '../../services/settings'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { parse } from 'date-fns'; import { assetUrl } from '../../utils/url'; import { batchFetchLogosFromSportLogosAPI } from '../../utils/sportLogosAPI'; import { API_URL } from '../../services/api'; const MatchesAdminPage = () => { const queryClient = useQueryClient(); const toast = useToast(); const [isOpen, setIsOpen] = useState(false); const [focusSide, setFocusSide] = useState<'home' | 'away' | null>(null); const [selected, setSelected] = useState(null); const [form, setForm] = useState({ home_name_override: '', away_name_override: '', venue_override: '', date_time_override: '', home_logo_url: '', away_logo_url: '', notes: '', }); const { data: overrides = {} } = useQuery({ queryKey: ['teamLogoOverrides'], queryFn: fetchTeamLogoOverrides, staleTime: 5 * 60 * 1000, }); const normalizeName = (s: string) => { let out = String(s || ''); out = out .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase(); out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-'); const orgPhrases = [ 'fotbalovy klub', 'sportovni klub', 'telovychovna jednota', 'skolni sportovni klub', 'fotbal', 'futsal', ]; for (const phrase of orgPhrases) { const re = new RegExp(`(^|\\b)${phrase}(\\b|$)`, 'g'); out = out.replace(re, ' '); } out = out.replace(/\b(1\.)?\s*(sfc|afc|fc|fk|mfk|tj|sk|afk)\b\.?/g, ' '); out = out.replace(/[\.,!;:()\[\]{}]/g, ' '); out = out.replace(/\s+/g, ' ').trim(); return out; }; const byName: Record = (overrides as any)?.by_name || {}; const byNameNormalized = useMemo(() => { const idx: Record = {}; for (const k of Object.keys(byName)) idx[normalizeName(k)] = byName[k]; return idx; }, [byName]); const [sportLogosMap, setSportLogosMap] = useState>({}); const getLogo = (teamName?: string, teamId?: string, facrOriginal?: string) => { if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string; if (teamId && sportLogosMap[String(teamId)]) return sportLogosMap[String(teamId)]; let overrideUrl = byName[teamName]; if (!overrideUrl) overrideUrl = byNameNormalized[normalizeName(teamName)]; if (overrideUrl) { if (overrideUrl.startsWith('/')) return assetUrl(overrideUrl) as string; return overrideUrl; } if (facrOriginal) return facrOriginal; return '/dist/img/logo-club-empty.svg'; }; // External logo upload helpers/state const [homeExternalTeamId, setHomeExternalTeamId] = useState(''); const [awayExternalTeamId, setAwayExternalTeamId] = useState(''); const [homeUploadedFile, setHomeUploadedFile] = useState(null); const [awayUploadedFile, setAwayUploadedFile] = useState(null); // Team search state const [homeQuery, setHomeQuery] = useState(''); const [awayQuery, setAwayQuery] = useState(''); const [debouncedHome, setDebouncedHome] = useState(''); const [debouncedAway, setDebouncedAway] = useState(''); useEffect(() => { const t = setTimeout(() => setDebouncedHome(homeQuery), 300); return () => clearTimeout(t); }, [homeQuery]); useEffect(() => { const t = setTimeout(() => setDebouncedAway(awayQuery), 300); return () => clearTimeout(t); }, [awayQuery]); const { data: homeResults = [] } = useQuery({ queryKey: ['club-search-home', debouncedHome], queryFn: () => searchClubs(debouncedHome), enabled: debouncedHome.trim().length >= 2, }); const { data: awayResults = [] } = useQuery({ queryKey: ['club-search-away', debouncedAway], queryFn: () => searchClubs(debouncedAway), enabled: debouncedAway.trim().length >= 2, }); // Upload refs const homeFileRef = useRef(null); const awayFileRef = useRef(null); const { data: matches = [], isLoading, error } = useQuery({ queryKey: ['admin-matches-list-cache'], queryFn: async () => { // Read cached FACR club info const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin; const url = `${origin}/cache/prefetch/facr_club_info.json`; const res = await fetch(url, { headers: { 'Cache-Control': 'no-cache' } }); if (!res.ok) throw new Error(`Failed to load cache: ${res.status}`); const json = await res.json(); const comps = Array.isArray(json?.competitions) ? json.competitions : []; const items: any[] = comps.flatMap((c: any) => (Array.isArray(c.matches) ? c.matches : []).map((m: any) => ({ ...m, competitionName: c.name, competition_id: c.id })) ); // Optional: stable sort by date ascending const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm'; items.sort((a, b) => { const da = parse(String(a.date_time || a.date), FACR_DATE_FMT, new Date()).getTime(); const db = parse(String(b.date_time || b.date), FACR_DATE_FMT, new Date()).getTime(); return da - db; }); return items.map((m: any) => ({ id: m.match_id, date_time: m.date_time || m.date, competitionName: m.competitionName, competition_id: m.competition_id, home: m.home || m.home_team, home_id: m.home_id || m.home_team_id || m.home_team_facr_id, away: m.away || m.away_team, away_id: m.away_id || m.away_team_id || m.away_team_facr_id, score: m.score, venue: m.venue, home_logo_url: m.home_logo_url, away_logo_url: m.away_logo_url, })); }, }); useEffect(() => { if (!Array.isArray(matches) || matches.length === 0) return; const ids = new Set(); for (const m of matches as any[]) { if (m.home_id) ids.add(String(m.home_id)); if (m.away_id) ids.add(String(m.away_id)); } if (ids.size === 0) return; (async () => { try { const map = await batchFetchLogosFromSportLogosAPI(Array.from(ids)); setSportLogosMap(map); } catch (e) { console.warn('Failed to batch fetch logos:', e); } })(); }, [matches]); // Filters const [teamFilter, setTeamFilter] = useState(''); const [dateFrom, setDateFrom] = useState(''); // YYYY-MM-DD const [dateTo, setDateTo] = useState(''); // YYYY-MM-DD const [competitionFilter, setCompetitionFilter] = useState(''); const [sideFilter, setSideFilter] = useState<'home' | 'away' | ''>(''); const normalizedTeam = teamFilter.trim().toLowerCase(); const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm'; // Club name (for side filter) const { data: publicSettings } = useQuery({ queryKey: ['public-settings'], queryFn: getPublicSettings, }); const { data: facrClubInfo } = useQuery({ queryKey: ['facr-club-info-name'], queryFn: async () => { const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin; const url = `${origin}/cache/prefetch/facr_club_info.json`; const res = await fetch(url, { headers: { 'Cache-Control': 'no-cache' } }); if (!res.ok) return null; return await res.json(); }, }); const clubName: string = (publicSettings as any)?.club_name || (facrClubInfo as any)?.name || ''; 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); // Common Czech club prefixes/words x = x.replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g, '').replace(/\s+/g, ' ').trim(); return x; }; const clubNorm = normalize(clubName); const clubStrip = stripPrefixes(clubName); const teamMatchesClub = (team: string): boolean => { const t = normalize(team); const ts = stripPrefixes(team); return !!clubNorm && (t.includes(clubNorm) || ts.includes(clubStrip) || t.endsWith(clubStrip) || clubStrip.endsWith(ts)); }; const competitionOptions = useMemo(() => { const set = new Set(); for (const m of matches) { if (m.competitionName) set.add(String(m.competitionName)); } return Array.from(set).sort((a, b) => a.localeCompare(b)); }, [matches]); const filteredMatches = matches.filter((m: any) => { // team filter const teamOk = normalizedTeam ? ( sideFilter === 'home' ? [m.home, m.home_team].filter(Boolean).some((v: string) => String(v).toLowerCase().includes(normalizedTeam)) : sideFilter === 'away' ? [m.away, m.away_team].filter(Boolean).some((v: string) => String(v).toLowerCase().includes(normalizedTeam)) : [m.home, m.home_team, m.away, m.away_team] .filter(Boolean) .some((v: string) => String(v).toLowerCase().includes(normalizedTeam)) ) : true; if (!teamOk) return false; // competition filter if (competitionFilter && String(m.competitionName || '') !== competitionFilter) return false; // side filter based on club name if (sideFilter && clubNorm) { const homeName = String(m.home || m.home_team || ''); const awayName = String(m.away || m.away_team || ''); if (sideFilter === 'home' && !teamMatchesClub(homeName)) return false; if (sideFilter === 'away' && !teamMatchesClub(awayName)) return false; } // date parse const dtStr = String(m.date_time || m.date || ''); let ts = NaN; try { ts = parse(dtStr, FACR_DATE_FMT, new Date()).getTime(); } catch (_) { const d2 = new Date(dtStr); ts = d2.getTime(); } if (isNaN(ts)) return true; // if can't parse, let it pass other filters // date range filter if (dateFrom) { const fromTs = new Date(dateFrom + 'T00:00:00').getTime(); if (!isNaN(fromTs) && ts < fromTs) return false; } if (dateTo) { const toTs = new Date(dateTo + 'T23:59:59').getTime(); if (!isNaN(toTs) && ts > toTs) return false; } return true; }); // Pagination (Load more) + page size selector const [pageSize, setPageSize] = useState(50); const [limit, setLimit] = useState(50); const [searchParams, setSearchParams] = useSearchParams(); // Initialize filters from URL on first load and when data changes (so comps are known) useEffect(() => { const spTeam = searchParams.get('team') || ''; const spFrom = searchParams.get('from') || ''; const spTo = searchParams.get('to') || ''; const spComp = searchParams.get('comp') || ''; const spVenue = searchParams.get('venue') || ''; const spSide = searchParams.get('side') || ''; const spSize = parseInt(searchParams.get('size') || '') || undefined; const spLimit = parseInt(searchParams.get('limit') || '') || undefined; if (spTeam) setTeamFilter(spTeam); if (spFrom) setDateFrom(spFrom); if (spTo) setDateTo(spTo); if (spComp) setCompetitionFilter(spComp); // venue filter removed if (spSide === 'home' || spSide === 'away') setSideFilter(spSide); if (spSize) { setPageSize(spSize); setLimit(spSize); } if (spLimit) setLimit(spLimit); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Keep URL in sync when filters/pagination change useEffect(() => { const params: Record = {}; if (teamFilter) params.team = teamFilter; if (dateFrom) params.from = dateFrom; if (dateTo) params.to = dateTo; if (competitionFilter) params.comp = competitionFilter; // venue filter removed if (sideFilter) params.side = sideFilter; if (pageSize !== 50) params.size = String(pageSize); if (limit !== pageSize) params.limit = String(limit); setSearchParams(params, { replace: true }); }, [teamFilter, dateFrom, dateTo, competitionFilter, sideFilter, pageSize, limit, setSearchParams]); useEffect(() => { // reset pagination on filter change setLimit(pageSize); }, [normalizedTeam, dateFrom, dateTo, competitionFilter, sideFilter, clubNorm, pageSize]); const visibleMatches = filteredMatches.slice(0, limit); // Date presets const setThisWeek = () => { const now = new Date(); const day = now.getDay(); // 0 Sun .. 6 Sat const diffToMonday = (day === 0 ? -6 : 1 - day); // Monday start const monday = new Date(now); monday.setDate(now.getDate() + diffToMonday); const sunday = new Date(monday); sunday.setDate(monday.getDate() + 6); const f = monday.toISOString().slice(0, 10); const t = sunday.toISOString().slice(0, 10); setDateFrom(f); setDateTo(t); }; const setNext30Days = () => { const now = new Date(); const to = new Date(now); to.setDate(now.getDate() + 30); const f = now.toISOString().slice(0, 10); const t = to.toISOString().slice(0, 10); setDateFrom(f); setDateTo(t); }; // Export CSV of filtered results const exportCsv = () => { const rows = filteredMatches.map((m: any) => { const date = m.date_time || m.date || ''; const comp = m.competitionName || ''; const home = m.home || m.home_team || ''; const away = m.away || m.away_team || ''; const score = m.score || (m.result_home != null && m.result_away != null ? `${m.result_home}:${m.result_away}` : ''); const venue = m.venue || ''; return { date, competition: comp, home, away, score, venue }; }); const headers = ['date', 'competition', 'home', 'away', 'score', 'venue']; const escape = (v: any) => { const s = String(v ?? ''); if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"'; return s; }; const csv = [headers.join(','), ...rows.map(r => headers.map(h => escape((r as any)[h])).join(','))].join('\n'); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'matches.csv'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; // Datetime validation (RFC3339-ish) const isDateInvalid = form.date_time_override.trim() !== '' && isNaN(Date.parse(form.date_time_override)); const saveMutation = useMutation({ mutationFn: async () => { const externalMatchId: string = selected?.match_id || selected?.id; if (!externalMatchId) throw new Error('Chybí match_id'); const payload: any = { ...form }; // normalize empty strings to null so backend can clear values Object.keys(payload).forEach((k) => { if (payload[k as keyof typeof payload] === '') payload[k as keyof typeof payload] = null; }); // First store current overrides await putMatchOverride(externalMatchId, payload); // Best-effort upload to logoapi.sportcreative.eu for home/away const results: { home?: { success: boolean; error?: string }; away?: { success: boolean; error?: string } } = {}; const processSide = async ( side: 'home' | 'away', externalTeamId: string, uploadedFile: File | null, nameOverride: string, logoUrl: string | null ) => { try { if (!externalTeamId) return { success: false, error: 'Chybí ID týmu' }; let file: File | Blob | null = uploadedFile; if (!file && logoUrl) { file = await fetchLogoAsBlob(logoUrl); } if (!file) return { success: false, error: 'Nelze získat soubor loga' }; const up = await uploadToLogaSportcreative(externalTeamId, file, { filename: file instanceof File ? file.name : `${externalTeamId}.png`, clubName: nameOverride || 'Neznámý klub', clubType: 'football', }); if (!up.success) return { success: false, error: up.error || 'Upload selhal' }; if (up.url) { // Patch override to immediately use external URL await patchMatchOverride( externalMatchId, side === 'home' ? { home_logo_url: up.url } : { away_logo_url: up.url } ); } return { success: true }; } catch (e: any) { return { success: false, error: e?.message || 'Chyba při uploadu' }; } }; if (homeExternalTeamId && (form.home_logo_url || homeUploadedFile)) { results.home = await processSide('home', homeExternalTeamId, homeUploadedFile, form.home_name_override, form.home_logo_url); } if (awayExternalTeamId && (form.away_logo_url || awayUploadedFile)) { results.away = await processSide('away', awayExternalTeamId, awayUploadedFile, form.away_name_override, form.away_logo_url); } return { ok: true, results }; }, onSuccess: (res: any) => { const r = res?.results || {}; const parts: string[] = []; if (r.home) parts.push(r.home.success ? 'Logo domácích nahráno' : `Domácí: ${r.home.error || 'chyba'}`); if (r.away) parts.push(r.away.success ? 'Logo hostů nahráno' : `Hosté: ${r.away.error || 'chyba'}`); const description = parts.length ? parts.join(' • ') : undefined; toast({ title: 'Uloženo', description, status: 'success' }); setIsOpen(false); setSelected(null); setHomeUploadedFile(null); setAwayUploadedFile(null); // Invalidate the cache-backed list to refresh any merged overrides queryClient.invalidateQueries({ queryKey: ['admin-matches-list-cache'] }); }, onError: (e: any) => { toast({ title: 'Uložení selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' }); }, }); const openEdit = (m: any, side?: 'home' | 'away') => { setSelected(m); // Convert FACR-style date (e.g., 25.08.2025 18:30) to RFC3339 for backend const facrStr: string = m.date_time || m.date || ''; let iso = ''; if (facrStr) { try { const dt = parse(String(facrStr), 'dd.MM.yyyy HH:mm', new Date()); if (!isNaN(dt.getTime())) iso = dt.toISOString(); } catch (_) { // If it's already ISO or another parseable format, keep as-is if valid const d2 = new Date(facrStr); if (!isNaN(d2.getTime())) iso = d2.toISOString(); } } setForm({ home_name_override: m.home || m.home_team || '', away_name_override: m.away || m.away_team || '', venue_override: m.venue || '', date_time_override: iso, home_logo_url: m.home_logo_url || '', away_logo_url: m.away_logo_url || '', notes: '', }); setIsOpen(true); setFocusSide(side ?? null); // Reset external selections and uploaded files to avoid stale state setHomeExternalTeamId(''); setAwayExternalTeamId(''); setHomeUploadedFile(null); setAwayUploadedFile(null); }; // Autofocus on the selected team input when drawer opens const homeInputRef = useRef(null); const awayInputRef = useRef(null); const handleHomeInput = (e: React.ChangeEvent) => { setHomeQuery(e.target.value); }; const handleAwayInput = (e: React.ChangeEvent) => { setAwayQuery(e.target.value); }; useEffect(() => { if (isOpen && focusSide) { const t = setTimeout(() => { if (focusSide === 'home') homeInputRef.current?.focus(); if (focusSide === 'away') awayInputRef.current?.focus(); }, 50); return () => clearTimeout(t); } }, [isOpen, focusSide]); const drawerSize = useBreakpointValue({ base: 'full', md: 'md' }); // Horizontal scroll affordance const scrollRef = useRef(null); const [canScrollLeft, setCanScrollLeft] = useState(false); const [canScrollRight, setCanScrollRight] = useState(false); const [showScrollHint, setShowScrollHint] = useState(true); // Drag-to-scroll state const [isDragging, setIsDragging] = useState(false); const [startX, setStartX] = useState(0); const [scrollLeft, setScrollLeft] = useState(0); const lastXRef = useRef(0); const lastTimeRef = useRef(0); const velocityRef = useRef(0); const animationRef = useRef(null); const scrollRaf = useRef(null); const updateScrollShadow = () => { const el = scrollRef.current; if (!el) return; const left = el.scrollLeft > 0; const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1; if (left !== canScrollLeft) setCanScrollLeft(left); if (right !== canScrollRight) setCanScrollRight(right); }; // Drag-to-scroll handlers const handleMouseDown = (e: React.MouseEvent) => { if (!scrollRef.current) return; // Cancel any ongoing momentum animation if (animationRef.current) { cancelAnimationFrame(animationRef.current); animationRef.current = null; } setIsDragging(true); setStartX(e.pageX - scrollRef.current.offsetLeft); setScrollLeft(scrollRef.current.scrollLeft); lastXRef.current = e.pageX; lastTimeRef.current = Date.now(); velocityRef.current = 0; scrollRef.current.style.cursor = 'grabbing'; scrollRef.current.style.userSelect = 'none'; scrollRef.current.style.scrollBehavior = 'auto'; // Disable smooth scroll during drag }; const handleMouseLeave = () => { setIsDragging(false); if (scrollRef.current) { scrollRef.current.style.cursor = 'grab'; scrollRef.current.style.userSelect = 'auto'; } }; const handleMouseUp = () => { setIsDragging(false); if (scrollRef.current) { scrollRef.current.style.cursor = 'grab'; scrollRef.current.style.userSelect = 'auto'; scrollRef.current.style.scrollBehavior = 'smooth'; // Apply momentum scrolling const velocity = velocityRef.current; if (Math.abs(velocity) > 0.5) { const applyMomentum = () => { if (!scrollRef.current) return; velocityRef.current *= 0.95; // Deceleration factor scrollRef.current.scrollLeft -= velocityRef.current; if (Math.abs(velocityRef.current) > 0.5) { animationRef.current = requestAnimationFrame(applyMomentum); } else { animationRef.current = null; } }; animationRef.current = requestAnimationFrame(applyMomentum); } } }; const handleMouseMove = (e: React.MouseEvent) => { if (!isDragging || !scrollRef.current) return; e.preventDefault(); const x = e.pageX - scrollRef.current.offsetLeft; const walk = (x - startX) * 1.5; // Scroll speed multiplier (reduced for smoother feel) scrollRef.current.scrollLeft = scrollLeft - walk; // Calculate velocity for momentum const now = Date.now(); const timeDelta = now - lastTimeRef.current; if (timeDelta > 0) { const currentX = e.pageX; const distance = currentX - lastXRef.current; velocityRef.current = distance / timeDelta * 16; // Normalize to ~60fps lastXRef.current = currentX; lastTimeRef.current = now; } }; // Touch handlers for mobile const handleTouchStart = (e: React.TouchEvent) => { if (!scrollRef.current) return; if (animationRef.current) { cancelAnimationFrame(animationRef.current); animationRef.current = null; } const touch = e.touches[0]; setIsDragging(true); setStartX(touch.pageX - scrollRef.current.offsetLeft); setScrollLeft(scrollRef.current.scrollLeft); lastXRef.current = touch.pageX; lastTimeRef.current = Date.now(); velocityRef.current = 0; if (scrollRef.current) scrollRef.current.style.scrollBehavior = 'auto'; }; const handleTouchMove = (e: React.TouchEvent) => { if (!isDragging || !scrollRef.current) return; const touch = e.touches[0]; const x = touch.pageX - scrollRef.current.offsetLeft; const walk = (x - startX) * 1.5; scrollRef.current.scrollLeft = scrollLeft - walk; const now = Date.now(); const timeDelta = now - lastTimeRef.current; if (timeDelta > 0) { const currentX = touch.pageX; const distance = currentX - lastXRef.current; velocityRef.current = distance / timeDelta * 16; lastXRef.current = currentX; lastTimeRef.current = now; } }; const handleTouchEnd = () => { setIsDragging(false); if (scrollRef.current) { scrollRef.current.style.scrollBehavior = 'smooth'; const velocity = velocityRef.current; if (Math.abs(velocity) > 0.5) { const applyMomentum = () => { if (!scrollRef.current) return; velocityRef.current *= 0.95; scrollRef.current.scrollLeft -= velocityRef.current; if (Math.abs(velocityRef.current) > 0.5) { animationRef.current = requestAnimationFrame(applyMomentum); } else { animationRef.current = null; } }; animationRef.current = requestAnimationFrame(applyMomentum); } } }; // Utility to check if match is in the past const isMatchPast = (dateTimeStr: string): boolean => { if (!dateTimeStr) return false; try { const dt = parse(dateTimeStr, FACR_DATE_FMT, new Date()); if (!isNaN(dt.getTime())) { return dt.getTime() < Date.now(); } } catch (_) { const d = new Date(dateTimeStr); if (!isNaN(d.getTime())) { return d.getTime() < Date.now(); } } return false; }; useEffect(() => { updateScrollShadow(); const onResize = () => updateScrollShadow(); window.addEventListener('resize', onResize); return () => { window.removeEventListener('resize', onResize); // Cleanup momentum animation on unmount if (animationRef.current) { cancelAnimationFrame(animationRef.current); } }; }, []); const headerBg = useColorModeValue('brand.primary', 'gray.700'); const headerText = useColorModeValue('text.onPrimary', 'white'); const cardBg = useColorModeValue('white', 'gray.800'); const borderColor = useColorModeValue('gray.200', 'gray.700'); const edgeGradientLeft = useColorModeValue('linear(to-r, white, transparent)', 'linear(to-r, gray.800, transparent)'); const edgeGradientRight = useColorModeValue('linear(to-l, white, transparent)', 'linear(to-l, gray.800, transparent)'); const pastMatchBg = useColorModeValue('gray.100', 'gray.700'); const futureMatchBg = useColorModeValue('white', 'gray.800'); const pastMatchHoverBg = useColorModeValue('gray.200', 'gray.600'); const futureMatchHoverBg = useColorModeValue('gray.50', 'gray.700'); return ( Správa zápasů Správa a úprava zápasů. Můžete upravovat informace o zápasech, včetně názvů týmů, termínů, log a dalších detailů. {isLoading ? ( Načítám zápasy… ) : error ? ( Nepodařilo se načíst zápasy. ) : ( setTeamFilter(e.target.value)} size="sm" /> setDateFrom(e.target.value)} /> setDateTo(e.target.value)} /> {(teamFilter || dateFrom || dateTo || competitionFilter || sideFilter) && ( )} Na stránku: Zobrazeno {visibleMatches.length} / {filteredMatches.length} {showScrollHint && ( 💡 Tip: Tabulku můžete plynule posouvat tažením myší nebo prstem → )} { if (scrollRaf.current == null) { scrollRaf.current = requestAnimationFrame(() => { const el = scrollRef.current; if (el) { updateScrollShadow(); if (el.scrollLeft > 0 && showScrollHint) setShowScrollHint(false); } scrollRaf.current = null; }); } }} sx={{ WebkitOverflowScrolling: 'touch', scrollBehavior: 'smooth', transform: 'translateZ(0)', willChange: 'transform', overscrollBehaviorX: 'contain', touchAction: 'pan-x', 'th, td': { whiteSpace: 'nowrap' }, '::-webkit-scrollbar': { height: '14px' }, '::-webkit-scrollbar-thumb': { background: '#3182ce', borderRadius: '10px', border: '3px solid transparent', backgroundClip: 'content-box', transition: 'background 0.2s ease', '&:hover': { background: '#2c5aa0', backgroundClip: 'content-box' }, '&:active': { background: '#2a4e8a', backgroundClip: 'content-box' } }, '::-webkit-scrollbar-track': { background: useColorModeValue('#f7fafc', '#2d3748'), borderRadius: '10px', margin: '0 8px', border: '1px solid', borderColor: useColorModeValue('#e2e8f0', '#4a5568') }, }} > {/* Gradient edges to indicate horizontal scroll */} {canScrollLeft && ( )} {canScrollRight && ( )} {filteredMatches.length === 0 ? ( ) : ( visibleMatches.map((m: any, idx: number) => { const isPast = isMatchPast(m.date_time || m.date || ''); const hasScore = m.score || (m.result_home != null && m.result_away != null); return ( ); }) )}
Datum Soutěž Domácí Skóre Hosté Místo Akce
Žádné zápasy k zobrazení.
{m.date_time || m.date || ''} {isPast && Odehráno} {!isPast && Nadcházející} {m.competitionName} {m.home {m.home || m.home_team || ''} {m.score || (m.result_home != null && m.result_away != null ? `${m.result_home}:${m.result_away}` : '–:–')} {m.away {m.away || m.away_team || ''} {m.venue || ''}
{filteredMatches.length > visibleMatches.length && ( )}
)}
{/* Edit Drawer */} setIsOpen(false)} size={drawerSize}> Upravit zápas {!selected ? ( Není vybrán žádný zápas. ) : ( Datum a čas (ISO) setForm((f) => ({ ...f, date_time_override: e.target.value }))} /> {isDateInvalid && ( Neplatný formát data/času )} Místo setForm((f) => ({ ...f, venue_override: e.target.value }))} /> {/* Home team */} Domácí tým (název) { setForm((f) => ({ ...f, home_name_override: e.target.value })); handleHomeInput(e); }} /> { const file = e.target.files?.[0]; if (!file) return; try { const up = await uploadImage(file); setForm((f) => ({ ...f, home_logo_url: up.url })); setHomeUploadedFile(file); toast({ title: 'Logo nahráno (domácí)', status: 'success' }); } catch (err: any) { toast({ title: 'Nahrání se nezdařilo', description: err?.message || '', status: 'error' }); } finally { if (homeFileRef.current) homeFileRef.current.value = '' as any; } }} /> {homeResults.length > 0 && ( {homeResults.map((r: any) => ( ))} )} {form.home_logo_url && ( home logo )} {/* Away team */} Hostující tým (název) { setForm((f) => ({ ...f, away_name_override: e.target.value })); handleAwayInput(e); }} /> { const file = e.target.files?.[0]; if (!file) return; try { const up = await uploadImage(file); setForm((f) => ({ ...f, away_logo_url: up.url })); setAwayUploadedFile(file); toast({ title: 'Logo nahráno (hosté)', status: 'success' }); } catch (err: any) { toast({ title: 'Nahrání se nezdařilo', description: err?.message || '', status: 'error' }); } finally { if (awayFileRef.current) awayFileRef.current.value = '' as any; } }} /> {awayResults.length > 0 && ( {awayResults.map((r: any) => ( ))} )} {form.away_logo_url && ( away logo )} Poznámka setForm((f) => ({ ...f, notes: e.target.value }))} /> )}
); }; export default MatchesAdminPage;