This commit is contained in:
Tomas Dvorak
2025-10-17 17:39:11 +02:00
parent 35d0954afd
commit e9a63073e5
61 changed files with 3824 additions and 1061 deletions
@@ -57,6 +57,8 @@ import ContactMap from '../../components/home/ContactMap';
import RichTextEditor from '../../components/common/RichTextEditor';
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
import { FiVideo, FiYoutube, FiLink } from 'react-icons/fi';
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
import { assetUrl } from '../../utils/url';
const types: Array<{ value: Event['type']; label: string }> = [
{ value: 'match', label: 'Zápas' },
@@ -373,6 +375,7 @@ const AdminActivitiesPage: React.FC = () => {
<Table size="sm">
<Thead>
<Tr>
<Th>Náhled</Th>
<Th>Název</Th>
<Th>Typ</Th>
<Th>Začátek</Th>
@@ -384,10 +387,28 @@ const AdminActivitiesPage: React.FC = () => {
</Thead>
<Tbody>
{isLoading && (
<Tr><Td colSpan={7}>Načítání</Td></Tr>
<Tr><Td colSpan={8}>Načítání</Td></Tr>
)}
{!isLoading && events.map(ev => (
<Tr key={ev.id}>
<Td>
{(ev as any).image_url ? (
<ThumbnailPreview
src={assetUrl((ev as any).image_url) || (ev as any).image_url}
alt={ev.title}
size="48px"
previewSize="350px"
/>
) : (
<ChakraImage
src={settingsQ.data?.club_logo_url || '/dist/img/logo-club-empty.svg'}
alt="No image"
boxSize="48px"
objectFit="contain"
opacity={0.3}
/>
)}
</Td>
<Td>{ev.title}</Td>
<Td>{ev.type}</Td>
<Td>{new Date(ev.start_time).toLocaleString()}</Td>
@@ -716,89 +716,6 @@ const AnalyticsAdminPage: React.FC = () => {
</Card>
)}
{/* Pageviews Chart */}
<Card bg={bgColor} borderColor={borderColor}>
<CardHeader>
<HStack spacing={2}>
<Icon as={FiTrendingUp} color="blue.500" boxSize={5} />
<Heading size="md">Zobrazení stránek v čase</Heading>
</HStack>
</CardHeader>
<CardBody>
{loading && pageviewsData.length === 0 ? (
<Flex justify="center" py={8}>
<Spinner size="lg" />
</Flex>
) : pageviewsData.length === 0 || pageviewsData.every(d => d.value === 0) ? (
<Flex justify="center" align="center" direction="column" py={8}>
<Icon as={FiTrendingUp} color="gray.300" boxSize={12} mb={3} />
<Text color="gray.500" fontWeight="medium">Žádná data pro zobrazení</Text>
<Text color="gray.400" fontSize="sm" mt={1}>Pro vybrané časové období nejsou k dispozici žádná data o návštěvnosti</Text>
</Flex>
) : (
<Box height="300px">
<Bar
data={{
labels: pageviewsData.map(d => d.date),
datasets: [
{
label: 'Zobrazení',
data: pageviewsData.map(d => d.value),
backgroundColor: 'rgba(66, 153, 225, 0.6)',
borderColor: 'rgb(66, 153, 225)',
borderWidth: 1,
borderRadius: 4,
},
],
}}
options={{
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
padding: 12,
displayColors: false,
callbacks: {
label: function(context) {
return `Zobrazení: ${context.parsed.y}`;
},
},
},
},
scales: {
y: {
beginAtZero: true,
ticks: {
color: '#718096',
},
grid: {
color: 'rgba(0, 0, 0, 0.1)',
},
},
x: {
ticks: {
color: '#718096',
},
grid: {
display: false,
},
},
},
}}
/>
</Box>
)}
</CardBody>
</Card>
<Divider />
{/* Country Flags Section */}
+151 -13
View File
@@ -22,6 +22,7 @@ import { getZoneramaManifestWithFallbacks, getZoneramaAlbum, putZoneramaPick, sa
import { facrApi } from '../../services/facr/facrApi';
import AlbumPhotoPicker from '../../components/admin/AlbumPhotoPicker';
import PollLinker from '../../components/admin/PollLinker';
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
@@ -30,8 +31,9 @@ const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
const linkQ = useQuery({
queryKey: ['article-match-link', articleId],
queryFn: () => getArticleMatchLink(articleId),
enabled: typeof articleId !== 'undefined' && articleId !== null,
enabled: typeof articleId !== 'undefined' && articleId !== null && (typeof articleId === 'number' ? articleId > 0 : String(articleId).trim() !== ''),
staleTime: 60_000,
retry: false,
});
const mid = (linkQ.data as any)?.external_match_id;
if (!mid) return <Badge colorScheme="gray">Nepropojeno</Badge>;
@@ -169,12 +171,18 @@ const ArticlesAdminPage = () => {
if (!res.ok) return;
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) => ({
id: String(m.match_id || m.id || ''),
date: m.date_time || m.date || '',
label: `${m.date_time || m.date || ''}${m.home || m.home_team || ''} ${m.score || (m.result_home!=null&&m.result_away!=null?`${m.result_home}:${m.result_away}`:'vs')} ${m.away || m.away_team || ''} ${c?.name ? '('+c.name+')' : ''}`.trim(),
competition: c?.name || ''
})));
const items: any[] = comps.flatMap((c: any) => (Array.isArray(c.matches) ? c.matches : []).map((m: any) => {
const score = m.score || (m.result_home!=null&&m.result_away!=null?`${m.result_home}:${m.result_away}`:'vs');
return {
id: String(m.match_id || m.id || ''),
date: m.date_time || m.date || '',
label: `${m.date_time || m.date || ''}${m.home || m.home_team || ''} ${score} ${m.away || m.away_team || ''} ${c?.name ? '('+c.name+')' : ''}`.trim(),
competition: c?.name || '',
home: m.home || m.home_team || '',
away: m.away || m.away_team || '',
score: score
};
}));
// keep latest 200 for performance
setMatchOptions(items.slice(-200).reverse());
} catch { /* ignore */ }
@@ -207,7 +215,7 @@ const ArticlesAdminPage = () => {
const [linkedMatchId, setLinkedMatchId] = useState<string>('');
const [linkedMatchTitle, setLinkedMatchTitle] = useState<string>('');
const [matchIdInput, setMatchIdInput] = useState<string>('');
const [matchOptions, setMatchOptions] = useState<Array<{ id: string; label: string; date?: string; competition?: string }>>([]);
const [matchOptions, setMatchOptions] = useState<Array<{ id: string; label: string; date?: string; competition?: string; home?: string; away?: string; score?: string }>>([]);
const [matchSearch, setMatchSearch] = useState<string>('');
const [matchDateFilter, setMatchDateFilter] = useState<string>('');
const [tempMatchLink, setTempMatchLink] = useState<string>(''); // Temporary storage for new articles
@@ -218,12 +226,40 @@ const ArticlesAdminPage = () => {
const [zLoading, setZLoading] = useState<boolean>(false);
const [albumPickerOpen, setAlbumPickerOpen] = useState<boolean>(false);
const { isOpen: isAlbumPickerOpen, onOpen: onAlbumPickerOpen, onClose: onAlbumPickerClose } = useDisclosure();
const { isOpen: isGalleryPickerOpen, onOpen: onGalleryPickerOpen, onClose: onGalleryPickerClose } = useDisclosure();
const [cachedAlbums, setCachedAlbums] = useState<Array<{ id: string; date: string; title?: string; photos: Array<{ id: string; image_1500: string; page_url: string }> }>>([]);
const [galleryLoading, setGalleryLoading] = useState<boolean>(false);
const [youtubeVideos, setYoutubeVideos] = useState<YouTubeVideo[]>([]);
const [youtubeLoading, setYoutubeLoading] = useState<boolean>(false);
const [youtubeSearch, setYoutubeSearch] = useState<string>('');
const [youtubeManualInput, setYoutubeManualInput] = useState<string>('');
const { isOpen: isYouTubeModalOpen, onOpen: onYouTubeModalOpen, onClose: onYouTubeModalClose } = useDisclosure();
// Fetch cached Zonerama gallery from prefetch
const fetchCachedGallery = useCallback(async () => {
try {
setGalleryLoading(true);
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const origin = new URL(apiUrl).origin;
const url = `${origin}/cache/prefetch/zonerama_profile.json`;
const res = await fetch(url, { cache: 'no-cache' });
if (!res.ok) throw new Error('Failed to load gallery cache');
const data = await res.json();
const albums = Array.isArray(data?.albums) ? data.albums : [];
// Filter albums with photos
const validAlbums = albums.filter((a: any) => Array.isArray(a.photos) && a.photos.length > 0);
setCachedAlbums(validAlbums);
if (validAlbums.length === 0) {
toast({ title: 'Žádné alba nenalezena', description: 'Cache galerie je prázdná nebo neobsahuje fotografie.', status: 'info', duration: 4000 });
}
} catch (e: any) {
toast({ title: 'Načtení galerie selhalo', description: e?.message || 'Zkuste to prosím znovu.', status: 'error' });
} finally {
setGalleryLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Remove toast from dependencies to prevent infinite loops
const fetchYouTubeVideos = useCallback(async () => {
try {
@@ -251,6 +287,12 @@ const ArticlesAdminPage = () => {
}
}, [isYouTubeModalOpen, youtubeVideos.length, youtubeLoading, fetchYouTubeVideos]);
React.useEffect(() => {
if (isGalleryPickerOpen && cachedAlbums.length === 0 && !galleryLoading) {
fetchCachedGallery();
}
}, [isGalleryPickerOpen, cachedAlbums.length, galleryLoading, fetchCachedGallery]);
const filteredYoutubeVideos = useMemo(() => {
const q = youtubeSearch.trim().toLowerCase();
if (!q) return youtubeVideos;
@@ -943,7 +985,12 @@ const ArticlesAdminPage = () => {
{!isLoading && articles.map((a) => (
<Tr key={a.id}>
<Td>
<Image src={assetUrl(a.image_url) || '/dist/img/logo-club-empty.svg'} alt={a.title} boxSize="48px" objectFit="cover" />
<ThumbnailPreview
src={assetUrl(a.image_url) || '/dist/img/logo-club-empty.svg'}
alt={a.title}
size="48px"
previewSize="350px"
/>
</Td>
<Td>{a.title}</Td>
<Td>{a.published ? 'Ano' : 'Ne'}</Td>
@@ -1156,11 +1203,12 @@ const ArticlesAdminPage = () => {
minute: '2-digit'
}) : '';
// Parse match info from label
const parts = match.label.split('');
const teams = parts[1]?.split(/\(|vs/)[0]?.trim() || '';
const score = teams.match(/\d+:\d+/)?.[0] || 'vs';
// Use match data directly
const home = match.home || '';
const away = match.away || '';
const score = match.score || 'vs';
const hasScore = score !== 'vs';
const teams = `${home} ${score} ${away}`.trim();
return (
<Box
@@ -1275,6 +1323,7 @@ const ArticlesAdminPage = () => {
</FormControl>
<HStack>
<Button size="sm" onClick={fetchAlbumByLink} isLoading={zLoading}>Načíst album</Button>
<Button size="sm" colorScheme="purple" onClick={onGalleryPickerOpen}>Vybrat z galerie</Button>
{zAlbumLink ? (
<Button size="sm" as="a" href={zAlbumLink} target="_blank" rel="noopener noreferrer" rightIcon={<FiExternalLink />}>Otevřít album</Button>
) : null}
@@ -1561,6 +1610,95 @@ const ArticlesAdminPage = () => {
</ModalFooter>
</ModalContent>
</Modal>
{/* Zonerama Gallery Picker Modal */}
<Modal isOpen={isGalleryPickerOpen} onClose={onGalleryPickerClose} size="6xl">
<ModalOverlay />
<ModalContent maxH="90vh">
<ModalHeader>Vybrat fotku z galerie</ModalHeader>
<ModalCloseButton />
<ModalBody overflowY="auto">
<VStack align="stretch" spacing={4}>
{/* Loading State */}
{galleryLoading && (
<HStack spacing={2} justify="center" py={8}>
<Spinner size="lg" color="purple.500" />
<Text color="gray.600">Načítám alba z galerie...</Text>
</HStack>
)}
{/* Albums Grid */}
{!galleryLoading && cachedAlbums.length > 0 && (
<VStack align="stretch" spacing={6}>
{cachedAlbums.map((album) => (
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('white', 'gray.700')}>
<HStack justify="space-between" mb={3}>
<VStack align="start" spacing={0}>
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
<Text fontSize="sm" color="gray.500">{album.date} {album.photos.length} fotografií</Text>
</VStack>
</HStack>
<SimpleGrid columns={{ base: 3, md: 4, lg: 6 }} spacing={2}>
{album.photos.map((photo) => (
<Box
key={photo.id}
borderWidth="1px"
borderRadius="md"
overflow="hidden"
cursor="pointer"
transition="all 0.2s"
_hover={{ boxShadow: 'lg', transform: 'scale(1.05)' }}
onClick={() => {
pickZoneramaImage({
id: photo.id,
album_id: album.id,
album_url: `https://eu.zonerama.com/FKKofolaKrnov/Album/${album.id}`,
page_url: photo.page_url,
image_url: photo.image_1500,
title: album.title
});
onGalleryPickerClose();
}}
>
<AspectRatio ratio={1}>
<Image
src={photo.image_1500}
alt={photo.id}
objectFit="cover"
/>
</AspectRatio>
</Box>
))}
</SimpleGrid>
</Box>
))}
</VStack>
)}
{/* Empty State */}
{!galleryLoading && cachedAlbums.length === 0 && (
<VStack py={8} spacing={3}>
<Icon as={FiSearch} boxSize={12} color="gray.400" />
<Text color="gray.600" textAlign="center">
Žádná alba nebyla nalezena v cache.
</Text>
<Text fontSize="sm" color="gray.500" textAlign="center">
Zkontrolujte nastavení Zonerama nebo obnovte cache.
</Text>
<Button size="sm" onClick={fetchCachedGallery} leftIcon={<FiRefreshCcw />}>
Obnovit seznam
</Button>
</VStack>
)}
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={onGalleryPickerClose}>
Zavřít
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</AdminLayout>
);
};
+114 -9
View File
@@ -59,6 +59,7 @@ import {
getDuplicateFiles,
deleteFile,
scanAndSyncFiles,
refreshFileTracking,
formatFileSize,
getFileIcon,
} from '../../services/files';
@@ -72,10 +73,12 @@ const FilesAdminPage: React.FC = () => {
const [deleteTarget, setDeleteTarget] = useState<FileInfo | null>(null);
const [forceDelete, setForceDelete] = useState(false);
const [scanResult, setScanResult] = useState<any>(null);
const [refreshResult, setRefreshResult] = useState<any>(null);
const { isOpen: isUsagesOpen, onOpen: onUsagesOpen, onClose: onUsagesClose } = useDisclosure();
const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onClose: onDeleteClose } = useDisclosure();
const { isOpen: isScanResultOpen, onOpen: onScanResultOpen, onClose: onScanResultClose } = useDisclosure();
const { isOpen: isRefreshResultOpen, onOpen: onRefreshResultOpen, onClose: onRefreshResultClose } = useDisclosure();
const borderColor = useColorModeValue('gray.200', 'gray.600');
const bgHover = useColorModeValue('gray.50', 'gray.700');
@@ -145,6 +148,21 @@ const FilesAdminPage: React.FC = () => {
},
});
// Refresh tracking mutation
const refreshTrackingMutation = useMutation({
mutationFn: refreshFileTracking,
onSuccess: (data) => {
setRefreshResult(data);
onRefreshResultOpen();
qc.invalidateQueries({ queryKey: ['admin-files'] });
qc.invalidateQueries({ queryKey: ['admin-files-unused'] });
qc.invalidateQueries({ queryKey: ['admin-files-duplicates'] });
},
onError: () => {
toast({ title: 'Chyba při aktualizaci sledování', status: 'error' });
},
});
const handleDelete = (file: FileInfo) => {
setDeleteTarget(file);
setForceDelete(false);
@@ -266,15 +284,27 @@ const FilesAdminPage: React.FC = () => {
<VStack align="stretch" spacing={6}>
<HStack justify="space-between">
<Heading size="lg">Správa souborů</Heading>
<Button
leftIcon={<FiRefreshCw />}
onClick={() => scanMutation.mutate()}
isLoading={scanMutation.isPending}
colorScheme="blue"
size="sm"
>
Skenovat soubory
</Button>
<HStack spacing={2}>
<Button
leftIcon={<FiRefreshCw />}
onClick={() => refreshTrackingMutation.mutate(undefined)}
isLoading={refreshTrackingMutation.isPending}
colorScheme="green"
size="sm"
variant="outline"
>
Aktualizovat sledování
</Button>
<Button
leftIcon={<FiRefreshCw />}
onClick={() => scanMutation.mutate()}
isLoading={scanMutation.isPending}
colorScheme="blue"
size="sm"
>
Skenovat soubory
</Button>
</HStack>
</HStack>
<Tabs colorScheme="blue" variant="enclosed">
@@ -657,6 +687,81 @@ const FilesAdminPage: React.FC = () => {
</ModalFooter>
</ModalContent>
</Modal>
{/* Refresh Tracking Result Modal */}
<Modal isOpen={isRefreshResultOpen} onClose={onRefreshResultClose} size="lg">
<ModalOverlay />
<ModalContent>
<ModalHeader>Výsledky aktualizace sledování</ModalHeader>
<ModalCloseButton />
<ModalBody>
{refreshResult && (
<VStack align="stretch" spacing={4}>
<Alert status="success">
<AlertIcon />
<Box>
<AlertTitle>Sledování aktualizováno!</AlertTitle>
<AlertDescription>
{refreshResult.message}
</AlertDescription>
</Box>
</Alert>
<Stack spacing={3}>
<Text fontWeight="bold" fontSize="lg" mb={2}>Statistiky:</Text>
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
<Text fontWeight="medium">Články:</Text>
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
{refreshResult.stats.articles_scanned}
</Badge>
</HStack>
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
<Text fontWeight="medium">Aktivity:</Text>
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
{refreshResult.stats.events_scanned}
</Badge>
</HStack>
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
<Text fontWeight="medium">Hráči:</Text>
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
{refreshResult.stats.players_scanned}
</Badge>
</HStack>
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
<Text fontWeight="medium">Sponzoři:</Text>
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
{refreshResult.stats.sponsors_scanned}
</Badge>
</HStack>
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
<Text fontWeight="medium">Kontakty:</Text>
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
{refreshResult.stats.contacts_scanned}
</Badge>
</HStack>
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
<Text fontWeight="medium">Týmy:</Text>
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
{refreshResult.stats.teams_scanned}
</Badge>
</HStack>
</Stack>
</VStack>
)}
</ModalBody>
<ModalFooter>
<Button colorScheme="blue" onClick={onRefreshResultClose}>
Zavřít
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</AdminLayout>
);
};
+126 -10
View File
@@ -482,6 +482,10 @@ const MatchesAdminPage = () => {
const [isDragging, setIsDragging] = useState(false);
const [startX, setStartX] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0);
const [lastX, setLastX] = useState(0);
const [lastTime, setLastTime] = useState(0);
const velocityRef = useRef(0);
const animationRef = useRef<number | null>(null);
// Color modes for past/future matches
const pastMatchBg = useColorModeValue('gray.100', 'gray.700');
@@ -499,11 +503,20 @@ const MatchesAdminPage = () => {
// 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);
setLastX(e.pageX);
setLastTime(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 = () => {
@@ -519,6 +532,24 @@ const MatchesAdminPage = () => {
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);
}
}
};
@@ -526,8 +557,77 @@ const MatchesAdminPage = () => {
if (!isDragging || !scrollRef.current) return;
e.preventDefault();
const x = e.pageX - scrollRef.current.offsetLeft;
const walk = (x - startX) * 2; // Scroll speed multiplier
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 - lastTime;
if (timeDelta > 0) {
const currentX = e.pageX;
const distance = currentX - lastX;
velocityRef.current = distance / timeDelta * 16; // Normalize to ~60fps
setLastX(currentX);
setLastTime(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);
setLastX(touch.pageX);
setLastTime(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 - lastTime;
if (timeDelta > 0) {
const currentX = touch.pageX;
const distance = currentX - lastX;
velocityRef.current = distance / timeDelta * 16;
setLastX(currentX);
setLastTime(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
@@ -551,7 +651,13 @@ const MatchesAdminPage = () => {
updateScrollShadow();
const onResize = () => updateScrollShadow();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('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');
@@ -656,8 +762,8 @@ const MatchesAdminPage = () => {
</WrapItem>
</Wrap>
{showScrollHint && (
<Text fontSize="xs" color="blue.600" fontWeight="600" mb={2}>
💡 Tip: Tabulku můžete posouvat tažením myší nebo touchem
<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
@@ -676,23 +782,33 @@ const MatchesAdminPage = () => {
onMouseLeave={handleMouseLeave}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onScroll={(e) => {
updateScrollShadow();
if ((e.currentTarget as HTMLDivElement).scrollLeft > 0 && showScrollHint) setShowScrollHint(false);
}}
sx={{
WebkitOverflowScrolling: 'touch',
scrollBehavior: 'smooth',
'th, td': { whiteSpace: 'nowrap' },
'::-webkit-scrollbar': { height: '12px' },
'::-webkit-scrollbar': { height: '14px' },
'::-webkit-scrollbar-thumb': {
background: '#3182ce',
borderRadius: '8px',
'&:hover': { background: '#2c5aa0' }
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: '#e2e8f0',
borderRadius: '8px',
margin: '0 4px'
background: useColorModeValue('#f7fafc', '#2d3748'),
borderRadius: '10px',
margin: '0 8px',
border: '1px solid',
borderColor: useColorModeValue('#e2e8f0', '#4a5568')
},
}}
>
@@ -40,6 +40,7 @@ import AdminLayout from '../../layouts/AdminLayout';
import { Player, getPlayers, createPlayer, updatePlayer, deletePlayer } from '../../services/players';
import { uploadFile } from '../../services/articles';
import { translateNationality } from '../../utils/nationality';
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
type Editing = Partial<Player> & { id?: number };
@@ -337,7 +338,13 @@ const PlayersAdminPage: React.FC = () => {
{!isLoading && (data || []).map((p) => (
<Tr key={p.id}>
<Td>
<Image src={normalizeImageUrl(p.image_url)} alt={p.first_name} boxSize="48px" objectFit="cover" borderRadius="md" />
<ThumbnailPreview
src={normalizeImageUrl(p.image_url)}
alt={`${p.first_name} ${p.last_name}`}
size="48px"
previewSize="300px"
borderRadius="md"
/>
</Td>
<Td>{p.first_name} {p.last_name}</Td>
<Td>{p.position || '-'}</Td>
File diff suppressed because it is too large Load Diff