Files
MyClub/frontend/src/pages/admin/MatchesAdminPage.tsx
T
Tomas Dvorak 823fabee02 de day #74
2025-10-28 22:38:27 +01:00

1197 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<any | null>(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<string, string> = (overrides as any)?.by_name || {};
const byNameNormalized = useMemo(() => {
const idx: Record<string, string> = {};
for (const k of Object.keys(byName)) idx[normalizeName(k)] = byName[k];
return idx;
}, [byName]);
const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
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<string>('');
const [awayExternalTeamId, setAwayExternalTeamId] = useState<string>('');
const [homeUploadedFile, setHomeUploadedFile] = useState<File | null>(null);
const [awayUploadedFile, setAwayUploadedFile] = useState<File | null>(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<HTMLInputElement | null>(null);
const awayFileRef = useRef<HTMLInputElement | null>(null);
const { data: matches = [], isLoading, error } = useQuery<any[], Error>({
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<string>();
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<string>(''); // YYYY-MM-DD
const [dateTo, setDateTo] = useState<string>(''); // YYYY-MM-DD
const [competitionFilter, setCompetitionFilter] = useState<string>('');
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<string>();
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<string, string> = {};
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<HTMLInputElement | null>(null);
const awayInputRef = useRef<HTMLInputElement | null>(null);
const handleHomeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
setHomeQuery(e.target.value);
};
const handleAwayInput = (e: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLDivElement | null>(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<number | null>(null);
const scrollRaf = useRef<number | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<AdminLayout requireAdmin={false}>
<Box>
<Box mb={6}>
<Heading size="lg" mb={2}>Správa zápasů</Heading>
<Text color={useColorModeValue('gray.600', 'gray.400')}>
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ů.
</Text>
</Box>
{isLoading ? (
<HStack spacing={3} mb={4}>
<Spinner />
<Text>Načítám zápasy</Text>
</HStack>
) : error ? (
<Alert status="error" variant="left-accent" mb={4}>
<AlertIcon />
Nepodařilo se načíst zápasy.
</Alert>
) : (
<Box>
<Wrap mb={4} spacing={3} align="center">
<WrapItem minW="160px">
<Select size="sm" value={sideFilter} onChange={(e) => setSideFilter((e.target.value as any) || '')}>
<option value="">Všechny strany</option>
<option value="home">Domácí</option>
<option value="away">Hosté</option>
</Select>
</WrapItem>
<WrapItem flex={1} minW="220px">
<Input
placeholder="Filtrovat podle týmu…"
value={teamFilter}
onChange={(e) => setTeamFilter(e.target.value)}
size="sm"
/>
</WrapItem>
<WrapItem>
<HStack>
<Input
type="date"
size="sm"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
<Text color="gray.500" fontSize="sm"></Text>
<Input
type="date"
size="sm"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
</HStack>
</WrapItem>
<WrapItem>
<HStack>
<Button size="sm" variant="outline" onClick={setThisWeek} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tento týden</Button>
<Button size="sm" variant="outline" onClick={setNext30Days} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Dalších 30 dní</Button>
</HStack>
</WrapItem>
<WrapItem minW="220px">
<Select size="sm" value={competitionFilter} onChange={(e) => setCompetitionFilter(e.target.value)}>
<option value="">Všechny soutěže</option>
{competitionOptions.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</Select>
</WrapItem>
{(teamFilter || dateFrom || dateTo || competitionFilter || sideFilter) && (
<WrapItem>
<Button size="sm" variant="outline" colorScheme="red" onClick={() => { setTeamFilter(''); setDateFrom(''); setDateTo(''); setCompetitionFilter(''); setSideFilter(''); }} borderRadius="md">
Vymazat filtry
</Button>
</WrapItem>
)}
<WrapItem>
<HStack>
<Text fontSize="sm">Na stránku:</Text>
<Select size="sm" value={pageSize} onChange={(e) => setPageSize(parseInt(e.target.value) || 25)} width="auto">
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</Select>
</HStack>
</WrapItem>
<WrapItem>
<Button size="sm" onClick={exportCsv} bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }} borderRadius="md">Export CSV</Button>
</WrapItem>
<WrapItem>
<Text color="gray.500" fontSize="sm">
Zobrazeno {visibleMatches.length} / {filteredMatches.length}
</Text>
</WrapItem>
</Wrap>
{showScrollHint && (
<Text fontSize="xs" color="blue.600" fontWeight="600" mb={2} display="flex" alignItems="center" gap={1}>
💡 Tip: Tabulku můžete plynule posouvat tažením myší nebo prstem
</Text>
)}
<Box
ref={scrollRef}
overflowX="auto"
borderWidth="2px"
borderRadius="xl"
borderColor={borderColor}
w="full"
bg={cardBg}
boxShadow="md"
maxW="100%"
position="relative"
cursor="grab"
onMouseDown={handleMouseDown}
onMouseLeave={handleMouseLeave}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onScroll={(e) => {
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 && (
<Box position="sticky" left={0} top={0} bottom={0} w="24px" pointerEvents="none"
bgGradient={edgeGradientLeft}
zIndex={1}
/>
)}
{canScrollRight && (
<Box position="sticky" right={0} top={0} bottom={0} w="24px" pointerEvents="none"
bgGradient={edgeGradientRight}
zIndex={1}
/>
)}
<Table size="sm" sx={{ width: 'max-content' }}>
<Thead sx={{ position: 'sticky', top: 0, zIndex: 2, backgroundColor: headerBg, 'th': { bg: headerBg, color: headerText, fontWeight: 'bold', textTransform: 'uppercase', fontSize: 'xs', letterSpacing: '0.05em' } }}>
<Tr>
<Th minW="140px">Datum</Th>
<Th minW="200px">Soutěž</Th>
<Th minW="260px">Domácí</Th>
<Th minW="80px" textAlign="center">Skóre</Th>
<Th minW="260px">Hosté</Th>
<Th minW="220px">Místo</Th>
<Th minW="180px">Akce</Th>
</Tr>
</Thead>
<Tbody>
{filteredMatches.length === 0 ? (
<Tr>
<Td colSpan={6}>
<Text color="gray.500">Žádné zápasy k zobrazení.</Text>
</Td>
</Tr>
) : (
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 (
<Tr
key={m.id ?? idx}
bg={isPast ? pastMatchBg : futureMatchBg}
_hover={{ bg: isPast ? pastMatchHoverBg : futureMatchHoverBg }}
opacity={isPast ? 0.85 : 1}
transition="all 0.2s"
>
<Td>
<HStack spacing={2}>
<Text>{m.date_time || m.date || ''}</Text>
{isPast && <Badge colorScheme="gray" fontSize="xs">Odehráno</Badge>}
{!isPast && <Badge colorScheme="green" fontSize="xs">Nadcházející</Badge>}
</HStack>
</Td>
<Td>
<HStack spacing={2}>
<Badge bg="brand.primary" color="text.onPrimary" borderRadius="md">{m.competitionName}</Badge>
</HStack>
</Td>
<Td>
<HStack spacing={2}>
<Image
src={getLogo(m.home || m.home_team || '', m.home_id, m.home_logo_url)}
alt={m.home || m.home_team || ''}
boxSize="24px"
objectFit="contain"
loading="lazy"
decoding="async"
draggable={false}
/>
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.home || m.home_team || ''}</Text>
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'home')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
</HStack>
</Td>
<Td textAlign="center">
<Text fontWeight={hasScore ? 'bold' : 'normal'} color={hasScore ? 'blue.600' : 'gray.500'}>
{m.score || (m.result_home != null && m.result_away != null ? `${m.result_home}:${m.result_away}` : ':')}
</Text>
</Td>
<Td>
<HStack spacing={2}>
<Image
src={getLogo(m.away || m.away_team || '', m.away_id, m.away_logo_url)}
alt={m.away || m.away_team || ''}
boxSize="24px"
objectFit="contain"
loading="lazy"
decoding="async"
draggable={false}
/>
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.away || m.away_team || ''}</Text>
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'away')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
</HStack>
</Td>
<Td>{m.venue || ''}</Td>
<Td>
<HStack spacing={2}>
<Button size="xs" onClick={() => openEdit(m)} bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }} borderRadius="md">Upravit</Button>
</HStack>
</Td>
</Tr>
);
})
)}
</Tbody>
</Table>
</Box>
{filteredMatches.length > visibleMatches.length && (
<HStack justify="center" mt={6}>
<Button onClick={() => setLimit((n) => n + pageSize)} size="lg" bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }} borderRadius="lg" px={8}>
Načíst další ({filteredMatches.length - visibleMatches.length} zápasů)
</Button>
</HStack>
)}
</Box>
)}
</Box>
{/* Edit Drawer */}
<Drawer isOpen={isOpen} placement="right" onClose={() => setIsOpen(false)} size={drawerSize}>
<DrawerOverlay />
<DrawerContent>
<DrawerHeader>Upravit zápas</DrawerHeader>
<DrawerBody>
{!selected ? (
<Text color="gray.500">Není vybrán žádný zápas.</Text>
) : (
<Stack spacing={4}>
<FormControl>
<FormLabel>Datum a čas (ISO)</FormLabel>
<Input
placeholder="YYYY-MM-DDTHH:mm:ss.sssZ"
value={form.date_time_override}
onChange={(e) => setForm((f) => ({ ...f, date_time_override: e.target.value }))}
/>
{isDateInvalid && (
<FormErrorMessage>Neplatný formát data/času</FormErrorMessage>
)}
</FormControl>
<FormControl>
<FormLabel>Místo</FormLabel>
<Input
placeholder="Místo konání"
value={form.venue_override}
onChange={(e) => setForm((f) => ({ ...f, venue_override: e.target.value }))}
/>
</FormControl>
{/* Home team */}
<FormControl>
<FormLabel>Domácí tým (název)</FormLabel>
<InputGroup>
<Input
ref={homeInputRef}
placeholder="Zadejte název týmu"
value={form.home_name_override}
onChange={(e) => {
setForm((f) => ({ ...f, home_name_override: e.target.value }));
handleHomeInput(e);
}}
/>
<InputRightElement width="4.5rem">
<Button h="1.75rem" size="sm" onClick={() => homeFileRef.current?.click()}>Logo</Button>
</InputRightElement>
</InputGroup>
<input
type="file"
accept="image/*"
hidden
ref={homeFileRef}
onChange={async (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 && (
<Box mt={2} borderWidth="1px" borderRadius="md" p={2} maxH="180px" overflowY="auto">
<List spacing={1}>
{homeResults.map((r: any) => (
<ListItem key={r.id}>
<Button size="xs" variant="ghost" onClick={() => {
setForm((f) => ({ ...f, home_name_override: r.name, home_logo_url: r.logo_url || f.home_logo_url }));
setHomeQuery(r.name);
setHomeExternalTeamId(String(r.id || ''));
}}>
{r.name}
</Button>
</ListItem>
))}
</List>
</Box>
)}
{form.home_logo_url && (
<HStack mt={2} spacing={3}>
<Image src={form.home_logo_url} alt="home logo" boxSize="28px" objectFit="contain" />
<Button size="xs" variant="outline" onClick={() => setForm((f) => ({ ...f, home_logo_url: '' }))}>Odebrat logo</Button>
</HStack>
)}
</FormControl>
{/* Away team */}
<FormControl>
<FormLabel>Hostující tým (název)</FormLabel>
<InputGroup>
<Input
ref={awayInputRef}
placeholder="Zadejte název týmu"
value={form.away_name_override}
onChange={(e) => {
setForm((f) => ({ ...f, away_name_override: e.target.value }));
handleAwayInput(e);
}}
/>
<InputRightElement width="4.5rem">
<Button h="1.75rem" size="sm" onClick={() => awayFileRef.current?.click()}>Logo</Button>
</InputRightElement>
</InputGroup>
<input
type="file"
accept="image/*"
hidden
ref={awayFileRef}
onChange={async (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 && (
<Box mt={2} borderWidth="1px" borderRadius="md" p={2} maxH="180px" overflowY="auto">
<List spacing={1}>
{awayResults.map((r: any) => (
<ListItem key={r.id}>
<Button size="xs" variant="ghost" onClick={() => {
setForm((f) => ({ ...f, away_name_override: r.name, away_logo_url: r.logo_url || f.away_logo_url }));
setAwayQuery(r.name);
setAwayExternalTeamId(String(r.id || ''));
}}>
{r.name}
</Button>
</ListItem>
))}
</List>
</Box>
)}
{form.away_logo_url && (
<HStack mt={2} spacing={3}>
<Image src={form.away_logo_url} alt="away logo" boxSize="28px" objectFit="contain" />
<Button size="xs" variant="outline" onClick={() => setForm((f) => ({ ...f, away_logo_url: '' }))}>Odebrat logo</Button>
</HStack>
)}
</FormControl>
<FormControl>
<FormLabel>Poznámka</FormLabel>
<Input
placeholder="Libovolná poznámka (interní)"
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
/>
</FormControl>
</Stack>
)}
</DrawerBody>
<DrawerFooter>
<HStack spacing={3}>
<Button variant="outline" onClick={() => setIsOpen(false)}>Zavřít</Button>
<Button colorScheme="blue" isLoading={saveMutation.isPending} onClick={() => saveMutation.mutate()} isDisabled={isDateInvalid}>
Uložit změny
</Button>
</HStack>
</DrawerFooter>
</DrawerContent>
</Drawer>
</AdminLayout>
);
};
export default MatchesAdminPage;