mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #63
This commit is contained in:
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user