This commit is contained in:
Tomas Dvorak
2025-11-11 10:29:30 +01:00
parent d5b4faea61
commit 8762bde4bf
139 changed files with 7240 additions and 2870 deletions
+22
View File
@@ -207,6 +207,17 @@ const AboutPage: React.FC = () => {
dangerouslySetInnerHTML={{ __html: cleanContent }}
sx={{
'& p': { mb: 4, lineHeight: 1.8 },
'& a': { color: 'blue.600', textDecoration: 'underline', _hover: { color: 'blue.700' } },
'& blockquote': {
borderLeft: '4px solid #3182ce',
paddingLeft: '16px',
margin: '1em 0',
color: textSecondary,
fontStyle: 'italic',
backgroundColor: 'gray.50',
padding: '12px 16px',
borderRadius: '4px',
},
'& h1, & h2, & h3': { mt: 8, mb: 4, fontWeight: 'bold' },
'& h1': { fontSize: '2xl' },
'& h2': { fontSize: 'xl' },
@@ -309,6 +320,17 @@ const AboutPage: React.FC = () => {
dangerouslySetInnerHTML={{ __html: cleanContent }}
sx={{
'& p': { mb: 4, lineHeight: 1.8 },
'& a': { color: 'blue.600', textDecoration: 'underline', _hover: { color: 'blue.700' } },
'& blockquote': {
borderLeft: '4px solid #3182ce',
paddingLeft: '16px',
margin: '1em 0',
color: textSecondary,
fontStyle: 'italic',
backgroundColor: 'gray.50',
padding: '12px 16px',
borderRadius: '4px',
},
'& h1, & h2, & h3': { mt: 6, mb: 3, fontWeight: 'bold' },
'& h1': { fontSize: '2xl' },
'& h2': { fontSize: 'xl' },
+10
View File
@@ -181,6 +181,16 @@ const ActivityDetailPage: React.FC = () => {
' p': { lineHeight: 1.8, mb: 3 },
' ul, ol': { pl: 6, mb: 3 },
' a': { color: linkColor, textDecoration: 'underline', _hover: { color: linkHoverColor } },
' blockquote': {
borderLeft: '4px solid #3182ce',
paddingLeft: '16px',
margin: '1em 0',
color: useColorModeValue('#4a5568','#cbd5e0'),
fontStyle: 'italic',
backgroundColor: useColorModeValue('#f7fafc','#1a202c'),
padding: '12px 16px',
borderRadius: '4px',
},
' img': {
display: 'block',
maxWidth: '100%',
+6 -1
View File
@@ -23,6 +23,7 @@ import MainLayout from '../components/layout/MainLayout';
import { API_URL } from '../services/api';
import PhotoModal from '../components/gallery/PhotoModal';
import CommentsSection from '../components/comments/CommentsSection';
import { Helmet } from 'react-helmet-async';
interface Photo {
id: string;
@@ -160,6 +161,10 @@ const AlbumDetailPage: React.FC = () => {
return (
<MainLayout>
<Helmet>
<title>{album.title} | Fotogalerie</title>
<meta name="description" content={`Fotogalerie: ${album.title}.`} />
</Helmet>
<Box bg={bgColor} minH="100vh" py={8}>
<Container maxW="7xl">
{/* Breadcrumbs */}
@@ -233,7 +238,7 @@ const AlbumDetailPage: React.FC = () => {
📸 Všechny fotografie jsou z platformy{' '}
<Text
as="a"
href="https://zonerama.com"
href={album.url || 'https://zonerama.com'}
target="_blank"
rel="noopener noreferrer"
fontWeight="600"
+79 -29
View File
@@ -23,6 +23,7 @@ import InstagramGeneratorButton from '../components/admin/InstagramGeneratorButt
import { MatchSnapshot } from '../services/instagram';
import { Widget } from '../components/widgets/Widget';
import { MatchesWidget } from '../components/widgets/MatchesWidget';
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
import { getUpcomingEvents } from '../services/eventService';
import CommentsSection from '../components/comments/CommentsSection';
@@ -41,6 +42,49 @@ const ArticleDetailPage: React.FC = () => {
enabled: Boolean(slug || id),
});
// Load competition aliases to resolve category → alias mapping for MatchesWidget filtering
const aliasesQ = useQuery<{ list: CompetitionAlias[] }>({
queryKey: ['competition-aliases-public'],
queryFn: async () => {
try {
const list = await getCompetitionAliasesPublic();
return { list };
} catch {
return { list: [] as CompetitionAlias[] };
}
},
staleTime: 5 * 60 * 1000,
});
const normalize = (s?: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const resolveAliasName = React.useCallback((compName?: string): string => {
const name = String(compName || '');
const normComp = normalize(name);
const list = aliasesQ.data?.list || [];
for (const a of list) {
const aAlias = normalize(a.alias);
const aOrig = normalize(a.original_name || '');
if (aOrig && (normComp.includes(aOrig) || aOrig.includes(normComp))) return a.alias;
if (aAlias && (normComp.includes(aAlias) || aAlias.includes(normComp))) return a.alias;
}
return name;
}, [aliasesQ.data?.list]);
// Determine which category name to use for MatchesWidget (prefer backend-provided competition_alias)
const categoryNameForMatches = React.useMemo(() => {
const fromBackend = (data as any)?.competition_alias;
if (fromBackend) return fromBackend as string;
const cat = (data as any)?.category?.name as string | undefined;
if (!cat) return undefined;
return resolveAliasName(cat);
}, [(data as any)?.competition_alias, (data as any)?.category?.name, resolveAliasName]);
// UI colors and public settings
const { data: publicSettings } = usePublicSettings();
@@ -49,8 +93,8 @@ const ArticleDetailPage: React.FC = () => {
const textMuted = useColorModeValue('gray.600','gray.400');
// Hoist all color mode values to top-level to avoid conditional hook calls
const videoTitleColor = useColorModeValue('gray.700','gray.300');
const galleryBg = useColorModeValue('blue.50','blue.900');
const galleryBorder = useColorModeValue('blue.200','blue.700');
const galleryBg = useColorModeValue('gray.50','gray.800');
const galleryBorder = useColorModeValue('gray.200','gray.700');
const attachmentsBg = useColorModeValue('gray.50','gray.800');
// Derive opponent color (for right edge fade) from team logo
@@ -218,10 +262,8 @@ const ArticleDetailPage: React.FC = () => {
const profileData = await profileRes.value.json();
const album = (profileData.albums || []).find((a: any) => a.id === albumId);
if (album) {
// Filter photos by selected IDs if available
const photos = photoIds.length > 0
? album.photos.filter((p: any) => photoIds.includes(p.id))
: album.photos;
// Use full album photos for the article preview (ignore selected IDs)
const photos = album.photos;
return { ...album, photos };
}
}
@@ -232,9 +274,7 @@ const ArticleDetailPage: React.FC = () => {
const blogAlbums = Array.isArray(albumsData) ? albumsData : [];
const album = blogAlbums.find((a: any) => a.id === albumId);
if (album) {
const photos = photoIds.length > 0
? album.photos.filter((p: any) => photoIds.includes(p.id))
: album.photos;
const photos = album.photos;
return { ...album, photos };
}
}
@@ -252,7 +292,6 @@ const ArticleDetailPage: React.FC = () => {
} else if (Array.isArray(payload?.photos)) {
photos = payload.photos;
}
if (photoIds.length > 0) photos = photos.filter((p: any) => photoIds.includes(p.id));
return { id: albumUrl, title: 'Album', date: '', photos } as any;
}
}
@@ -500,9 +539,6 @@ const ArticleDetailPage: React.FC = () => {
</Box>
)}
{(data as any)?.id ? (
<CommentsSection targetType="article" targetId={String((data as any).id)} />
) : null}
{/* Match Section - Card with logos, score/countdown, venue/date */}
{(matchLinkQuery.data as any)?.external_match_id && (
<Box position="relative" borderWidth="1px" borderRadius="lg" p={{ base: 4, md: 5 }} bg={cardBg} overflow="hidden">
@@ -579,6 +615,17 @@ const ArticleDetailPage: React.FC = () => {
'ul': { listStyleType: 'disc' },
'ol': { listStyleType: 'decimal' },
'li': { mb: 2 },
'a': { color: 'blue.600', textDecoration: 'underline', _hover: { color: 'blue.700' } },
'blockquote': {
borderLeft: '4px solid #3182ce',
paddingLeft: '16px',
margin: '1em 0',
color: useColorModeValue('#4a5568','#cbd5e0'),
fontStyle: 'italic',
backgroundColor: useColorModeValue('#f7fafc','#1a202c'),
padding: '12px 16px',
borderRadius: '4px',
},
'img': {
display: 'block',
maxWidth: '100%',
@@ -592,10 +639,9 @@ const ArticleDetailPage: React.FC = () => {
}}
dangerouslySetInnerHTML={{ __html: safeContentHTML }}
/>
{/* YouTube Video Section - simplified */}
{/* YouTube Video Section - simplified with rounded edges */}
{(data as any)?.youtube_video_id && (
<Box>
<Box borderRadius="xl" overflow="hidden">
<AspectRatio ratio={16 / 9}>
<Box
as="iframe"
@@ -607,13 +653,10 @@ const ArticleDetailPage: React.FC = () => {
onClick={() => umamiTrackVideoPlay((data as any).youtube_video_id, (data as any).youtube_video_title)}
/>
</AspectRatio>
{(data as any).youtube_video_title ? (
<Text mt={2} color={videoTitleColor}>{(data as any).youtube_video_title}</Text>
) : null}
</Box>
)}
{/* Gallery Section - Mosaic of 5 images with grayscale + hover color */}
{/* Video title intentionally hidden per requirement */}
{/* Gallery Section - Mosaic of 5 color images (random) */}
{((data as any)?.gallery_album_id || (data as any)?.gallery_album_url) && (
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={galleryBg} borderColor={galleryBorder}>
<Box mb={3}>
@@ -632,12 +675,13 @@ const ArticleDetailPage: React.FC = () => {
</Button>
</HStack>
{Array.isArray(galleryAlbumQuery.data?.photos) && (galleryAlbumQuery.data?.photos?.length || 0) > 0 && (() => {
const photos = (galleryAlbumQuery.data?.photos ?? []).slice(0, 5);
const all = galleryAlbumQuery.data?.photos ?? [];
const photos = [...all].sort(() => Math.random() - 0.5).slice(0, Math.min(5, all.length));
if (photos.length < 5) {
return (
<SimpleGrid columns={{ base: 2, sm: 3 }} spacing={2} role="group">
{photos.map((p: any) => (
<Image key={p.id} src={p.image_1500} alt={String(p.id)} w="100%" h="140px" objectFit="cover" filter="grayscale(100%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(0)' }} />
<Image key={p.id} src={p.image_1500} alt={String(p.id)} w="100%" h="140px" objectFit="cover" borderRadius="md" />
))}
</SimpleGrid>
);
@@ -649,11 +693,11 @@ const ArticleDetailPage: React.FC = () => {
gridTemplateRows: 'repeat(2, 140px)',
gap: '8px'
}}>
<Image src={photos[0].image_1500} alt={String(photos[0].id)} sx={{ gridColumn: 1, gridRow: 1 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(0)' }} borderRadius="md" />
<Image src={photos[1].image_1500} alt={String(photos[1].id)} sx={{ gridColumn: 1, gridRow: 2 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(0)' }} borderRadius="md" />
<Image src={photos[2].image_1500} alt={String(photos[2].id)} sx={{ gridColumn: 2, gridRow: '1 / span 2' }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(0)' }} borderRadius="md" />
<Image src={photos[3].image_1500} alt={String(photos[3].id)} sx={{ gridColumn: 3, gridRow: 1 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(0)' }} borderRadius="md" />
<Image src={photos[4].image_1500} alt={String(photos[4].id)} sx={{ gridColumn: 3, gridRow: 2 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(0)' }} borderRadius="md" />
<Image src={photos[0].image_1500} alt={String(photos[0].id)} sx={{ gridColumn: 1, gridRow: 1 }} objectFit="cover" w="100%" h="100%" borderRadius="md" />
<Image src={photos[1].image_1500} alt={String(photos[1].id)} sx={{ gridColumn: 1, gridRow: 2 }} objectFit="cover" w="100%" h="100%" borderRadius="md" />
<Image src={photos[2].image_1500} alt={String(photos[2].id)} sx={{ gridColumn: 2, gridRow: '1 / span 2' }} objectFit="cover" w="100%" h="100%" borderRadius="md" />
<Image src={photos[3].image_1500} alt={String(photos[3].id)} sx={{ gridColumn: 3, gridRow: 1 }} objectFit="cover" w="100%" h="100%" borderRadius="md" />
<Image src={photos[4].image_1500} alt={String(photos[4].id)} sx={{ gridColumn: 3, gridRow: 2 }} objectFit="cover" w="100%" h="100%" borderRadius="md" />
<Button as={RouterLink} to={(data as any).gallery_album_id ? `/galerie/album/${(data as any).gallery_album_id}` : '#'} size="sm" colorScheme="blue" position="absolute" top="50%" left="50%" transform="translate(-50%, -50%)" onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })}>Zobrazit galerii</Button>
</Box>
);
@@ -692,7 +736,7 @@ const ArticleDetailPage: React.FC = () => {
})()}
<MatchesWidget
categoryName={(data as any)?.category?.name}
categoryName={categoryNameForMatches}
hideEmpty
onMatchClick={(m: any) => {
setSelectedMatch({ ...m, competition: (m as any).competitionName, competitionName: (m as any).competitionName });
@@ -747,6 +791,12 @@ const ArticleDetailPage: React.FC = () => {
)}
{/* Polls (Ankety) above CTA */}
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
{/* Comments at the end */}
{(data as any)?.id ? (
<Container maxW="7xl" mt={4}>
<CommentsSection targetType="article" targetId={String((data as any).id)} />
</Container>
) : null}
{/* Newsletter CTA */}
<NewsletterCTA />
<MatchModal isOpen={isMatchModalOpen} onClose={() => setIsMatchModalOpen(false)} match={selectedMatch} />
+29 -56
View File
@@ -119,14 +119,13 @@ const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({
{article.title}
</Heading>
</Box>
<Box position="absolute" top={2} right={2} zIndex={2}>
<InstagramGeneratorButton
article={article as any}
targetUrl={typeof window !== 'undefined' ? new URL(link, window.location.origin).toString() : undefined}
placement="inline"
size="sm"
/>
</Box>
<InstagramGeneratorButton
article={article as any}
targetUrl={typeof window !== 'undefined' ? new URL(link, window.location.origin).toString() : undefined}
placement="inline"
position="bottom-right"
size="sm"
/>
</LinkBox>
);
};
@@ -180,7 +179,7 @@ const BlogPage: React.FC = () => {
const featuredQ = useQuery<Paginated<Article>>(
['articles-featured', { page_size: 3 }],
() => getFeaturedArticles({ page_size: 3 }),
{ staleTime: 5 * 60 * 1000 }
{ refetchOnWindowFocus: true, refetchOnMount: true, refetchInterval: 30000, staleTime: 0 }
);
const {
data,
@@ -258,6 +257,17 @@ const BlogPage: React.FC = () => {
: 'Nejnovější články, rozhovory a novinky z klubu.';
const canonical = typeof window !== 'undefined' ? window.location.href : undefined;
// Debounced search param update when typing
React.useEffect(() => {
const next: Record<string, string> = {};
if (categoryId) next.category_id = String(categoryId);
if (month) next.month = month;
if (matchId) next.match_id = matchId;
if (qInput) next.q = qInput;
const id = window.setTimeout(() => setSearchParams(next), 400);
return () => window.clearTimeout(id);
}, [qInput, categoryId, month, matchId]);
return (
<MainLayout>
<Helmet>
@@ -293,16 +303,6 @@ const BlogPage: React.FC = () => {
placeholder="Hledat články…"
value={qInput}
onChange={(e) => setQInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const next: Record<string, string> = {};
if (categoryId) next.category_id = String(categoryId);
if (month) next.month = month;
if (matchId) next.match_id = matchId;
if (qInput) next.q = qInput;
setSearchParams(next);
}
}}
/>
{qInput && (
<InputRightElement>
@@ -367,39 +367,19 @@ const BlogPage: React.FC = () => {
</Container>
)}
<Container maxW="7xl">
{/* Masonry using CSS columns */}
<Box
sx={{
columnCount: { base: 1, sm: 2, lg: 3 } as any,
columnGap: '28px',
}}
>
<Container maxW="5xl">
{/* Responsive grid with consistent card sizing */}
<Grid templateColumns={{ base: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={6}>
{isLoading && Array.from({ length: 9 }).map((_, i) => (
<Skeleton key={i} h={{ base: '220px', md: '260px' }} borderRadius="md" mb={7} />
<Skeleton key={i} h={{ base: '260px', md: '300px' }} borderRadius="md" />
))}
{!isLoading && visibleArticles.map((a, idx) => (
<React.Fragment key={`row-${a.id}`}>
<Box
mb={7}
sx={{
breakInside: 'avoid',
WebkitColumnBreakInside: 'avoid',
pageBreakInside: 'avoid',
}}
>
<GridItem>
<BlogTile article={a} />
</Box>
</GridItem>
{articleBanners.length > 0 && idx === insertionIndex && (
<Box
key={`banner-inline-${articleBanners[0].id}`}
mb={7}
sx={{
breakInside: 'avoid',
WebkitColumnBreakInside: 'avoid',
pageBreakInside: 'avoid',
}}
>
<GridItem key={`banner-inline-${articleBanners[0].id}`} colSpan={{ base: 1, sm: 2, lg: 3 }}>
<a
href={articleBanners[0].click_url || '#'}
target={articleBanners[0].click_url ? '_blank' : undefined}
@@ -410,23 +390,16 @@ const BlogPage: React.FC = () => {
<img
src={assetUrl(articleBanners[0].image_url) || '/images/sponsors/placeholder.png'}
alt={articleBanners[0].name}
style={{ width: '100%', height: 'auto', display: 'block' }}
style={{ width: '100%', height: 'auto', display: 'block', borderRadius: 8 }}
loading="lazy"
decoding="async"
/>
</a>
</Box>
</GridItem>
)}
</React.Fragment>
))}
</Box>
{!isLoading && !featuredList.length && !visibleArticles.length && (
<VStack py={16}>
<Text color={textColor}>Žádné články k zobrazení.</Text>
</VStack>
)}
{/* Infinite scroll sentinel */}
<Box ref={sentinelRef} h="1px" />
</Grid>
{isFetchingNextPage && (
<VStack py={6}>
<Text color={textColor}>Načítání</Text>
+280 -222
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
import MainLayout from '../components/layout/MainLayout';
import { Box, Container, Heading, Text, Tabs, TabList, TabPanels, Tab, TabPanel, Flex, Badge, Stack, Spinner, Grid, IconButton, ButtonGroup, Button, Link, Image, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, ModalFooter, useToast, Input, useColorModeValue } from '@chakra-ui/react';
import { Box, Container, Heading, Text, Tabs, TabList, TabPanels, Tab, TabPanel, Flex, Badge, Stack, Spinner, Grid, IconButton, ButtonGroup, Button, Link, Image, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, ModalFooter, useToast, Input, useColorModeValue, Tooltip } from '@chakra-ui/react';
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
import { addMonths, format, isSameDay, isSameMonth, startOfMonth, startOfWeek, addDays, parse } from 'date-fns';
import { cs } from 'date-fns/locale';
@@ -58,6 +58,9 @@ const CalendarPage: React.FC = () => {
const [clubType, setClubType] = useState<'football' | 'futsal'>('football');
const [standings, setStandings] = useState<any[]>([]);
// Active competition for current tab (memoized)
const activeCompetition = useMemo(() => competitions[tabIndex], [competitions, tabIndex]);
// Color mode values for dark/light theme
const calendarDayBg = useColorModeValue('white', 'gray.800');
const calendarDayBorder = useColorModeValue('gray.200', 'gray.700');
@@ -442,13 +445,12 @@ const CalendarPage: React.FC = () => {
// Get upcoming matches for live countdowns (only future matches)
const upcomingMatches = useMemo(() => {
return competitions.flatMap(comp =>
comp.matches.filter(match => {
const matchTime = new Date(`${match.date}T${match.time || '00:00'}:00`).getTime();
return matchTime > Date.now();
})
);
}, [competitions]);
const list = activeCompetition?.matches || [];
return list.filter(match => {
const matchTime = new Date(`${match.date}T${match.time || '00:00'}:00`).getTime();
return matchTime > Date.now();
});
}, [activeCompetition]);
const liveCountdowns = useMultipleCountdowns(upcomingMatches, 30000); // Update every 30 seconds for better performance
@@ -491,6 +493,20 @@ const CalendarPage: React.FC = () => {
const [viewMode, setViewMode] = useState<'calendar'|'list'>('calendar');
const [expandedDates, setExpandedDates] = useState<Record<string, boolean>>({});
const [showPast, setShowPast] = useState<boolean>(false);
// Compute today's date string in Prague timezone once and reuse
const pragueTodayStr = useMemo(() => {
try {
const parts = new Intl.DateTimeFormat('cs-CZ', {
timeZone: 'Europe/Prague',
year: 'numeric', month: '2-digit', day: '2-digit'
}).formatToParts(new Date());
const y = parts.find(p => p.type === 'year')?.value;
const m = parts.find(p => p.type === 'month')?.value;
const d = parts.find(p => p.type === 'day')?.value;
if (y && m && d) return `${y}-${m}-${d}`;
} catch {}
return format(new Date(), 'yyyy-MM-dd');
}, []);
const weeks = useMemo(() => {
const start = startOfWeek(startOfMonth(monthRef), { weekStartsOn: 1 });
// Build 6 weeks x 7 days
@@ -527,6 +543,20 @@ const CalendarPage: React.FC = () => {
return map;
};
const getMatchCompInfo = (m: MatchItem, comp?: Competition): { display: string; alias?: string } => {
try {
let baseComp: Competition | undefined = comp;
if ((comp?.id === 'all' || !comp) && m.__compId) {
baseComp = competitions.find((cc) => String(cc.id) === String(m.__compId));
}
const display = (m.__compName) || (baseComp?.name || '');
const alias = baseComp?.code ? (aliasMap[baseComp.code]?.alias) : undefined;
return { display, alias };
} catch {
return { display: m.__compName || comp?.name || '', alias: undefined };
}
};
// Sentiment helpers
const isClubTeam = (team: string) => {
try {
@@ -601,7 +631,7 @@ const CalendarPage: React.FC = () => {
)}
{!!competitions.length && (
<Tabs variant="soft-rounded" colorScheme="blue" index={tabIndex} onChange={(i) => setTabIndex(i)}>
<Tabs variant="soft-rounded" colorScheme="blue" index={tabIndex} onChange={(i) => setTabIndex(i)} isLazy lazyBehavior="keepMounted">
{/* Compact, wrapped TabList with better spacing (no overlap) */}
<Box mb={3} position="relative" zIndex={1}>
<TabList
@@ -638,87 +668,106 @@ const CalendarPage: React.FC = () => {
</TabList>
</Box>
<TabPanels>
{competitions.map((c) => {
const byDate = groupByDate(c.matches);
{competitions.map((c, idx) => {
const isActive = idx === tabIndex;
const byDate = isActive ? groupByDate(c.matches) : (new Map() as Map<string, MatchItem[]>);
const mkHref = (m: MatchItem) => (m.facr_link || m.report_url || undefined) ?? (`/zapas/${m.id}`);
// Build latest results (only matches with score)
const nowTs = Date.now();
const nowTs = isActive ? Date.now() : 0;
const compareByDateDesc = (a: MatchItem, b: MatchItem) => new Date(`${b.date}T${(b.time||'00:00')}:00`).getTime() - new Date(`${a.date}T${(a.time||'00:00')}:00`).getTime();
let latestResults: MatchItem[] = [];
if (c.id === 'all') {
// For 'all', pick most recent scored match per competition
const grouped: Record<string, MatchItem[]> = {};
(c.matches || []).forEach((m) => {
if (!m.score) return;
const ts = new Date(`${m.date}T${(m.time||'00:00')}:00`).getTime();
if (isNaN(ts) || ts > nowTs) return; // future results not allowed
const key = m.__compId || 'na';
grouped[key] = grouped[key] || [];
grouped[key].push(m);
});
latestResults = Object.values(grouped)
.map(list => list.sort(compareByDateDesc)[0])
.filter(Boolean)
.sort(compareByDateDesc);
} else {
// Single competition: pick the most recent scored match
latestResults = (c.matches || [])
.filter(m => !!m.score && new Date(`${m.date}T${(m.time||'00:00')}:00`).getTime() <= nowTs)
.sort(compareByDateDesc)
.slice(0, 1);
if (isActive) {
if (c.id === 'all') {
// For 'all', pick most recent scored match per competition
const grouped: Record<string, MatchItem[]> = {};
(c.matches || []).forEach((m) => {
if (!m.score) return;
const ts = new Date(`${m.date}T${(m.time||'00:00')}:00`).getTime();
if (isNaN(ts) || ts > nowTs) return; // future results not allowed
const key = m.__compId || 'na';
grouped[key] = grouped[key] || [];
grouped[key].push(m);
});
latestResults = Object.values(grouped)
.map(list => list.sort(compareByDateDesc)[0])
.filter(Boolean)
.sort(compareByDateDesc);
} else {
// Single competition: pick the most recent scored match
latestResults = (c.matches || [])
.filter(m => !!m.score && new Date(`${m.date}T${(m.time||'00:00')}:00`).getTime() <= nowTs)
.sort(compareByDateDesc)
.slice(0, 1);
}
}
return (
<TabPanel key={c.id} px={0}>
{/* Latest results header list rendered above both calendar and list modes */}
{latestResults.length > 0 && (
{isActive && latestResults.length > 0 && (
<Box mb={4}>
<Heading as="h3" size="md" mb={2}>Nejnovější výsledky</Heading>
<Grid templateColumns={{ base: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={3}>
{latestResults.map((m) => {
const href = mkHref(m);
const info = getMatchCompInfo(m, c);
return (
<Box key={`latest-${c.id}-${m.id}`} position="relative" data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)} borderWidth="1px" borderRadius="md" p={2} _hover={{ textDecoration: 'none', bg: 'rgba(0,0,0,0.03)', borderColor: 'brand.primary', cursor: 'pointer' }}>
{href && (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => { if (!(e.ctrlKey || e.metaKey || e.shiftKey || e.altKey)) e.preventDefault(); }}
onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
onAuxClick={(e) => { if ((e as any).button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
style={{ position: 'absolute', inset: 0, zIndex: 1 }}
aria-hidden
/>
<Tooltip
key={`latest-${c.id}-${m.id}`}
label={(
<Box>
<Text fontSize="xs">Kategorie: {info.display || '—'}</Text>
{info.alias && info.alias !== info.display && (
<Text fontSize="xs">Alias: {info.alias}</Text>
)}
</Box>
)}
<Flex align="center" justify="space-between" mb={2}>
<Text fontSize="sm" color="gray.700">{m.date} {m.time || ''}</Text>
<Badge colorScheme="purple">{m.__compName || c.name}</Badge>
</Flex>
<Flex align="center" gap={2} justify="center">
<TeamLogo
teamId={m.home_id}
teamName={m.home}
facrLogo={m.home_logo_url}
size="custom"
boxSize="18px"
alt={m.home}
borderRadius="full"
/>
<Text fontSize="sm">{m.home}</Text>
<Badge colorScheme={getSentiment(m)?.color || 'gray'}>{m.score || 'vs'}</Badge>
<TeamLogo
teamId={m.away_id}
teamName={m.away}
facrLogo={m.away_logo_url}
size="custom"
boxSize="18px"
alt={m.away}
borderRadius="full"
/>
<Text fontSize="sm">{m.away}</Text>
</Flex>
{href && <Link href={href} isExternal onClick={(e)=> e.stopPropagation()} display="none"/>}
</Box>
hasArrow
placement="top"
openDelay={200}
>
<Box position="relative" data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)} borderWidth="1px" borderRadius="md" p={2} _hover={{ textDecoration: 'none', bg: 'rgba(0,0,0,0.03)', borderColor: 'brand.primary', cursor: 'pointer' }}>
{href && (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => { if (!(e.ctrlKey || e.metaKey || e.shiftKey || e.altKey)) e.preventDefault(); }}
onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
onAuxClick={(e) => { if ((e as any).button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
style={{ position: 'absolute', inset: 0, zIndex: 1 }}
aria-hidden
/>
)}
<Flex align="center" justify="space-between" mb={2}>
<Text fontSize="sm" color="gray.700">{m.date} {m.time || ''}</Text>
<Badge colorScheme="purple">{m.__compName || c.name}</Badge>
</Flex>
<Flex align="center" gap={2} justify="center">
<TeamLogo
teamId={m.home_id}
teamName={m.home}
facrLogo={m.home_logo_url}
size="custom"
boxSize="18px"
alt={m.home}
borderRadius="full"
/>
<Text fontSize="sm">{m.home}</Text>
<Badge colorScheme={getSentiment(m)?.color || 'gray'}>{m.score || 'vs'}</Badge>
<TeamLogo
teamId={m.away_id}
teamName={m.away}
facrLogo={m.away_logo_url}
size="custom"
boxSize="18px"
alt={m.away}
borderRadius="full"
/>
<Text fontSize="sm">{m.away}</Text>
</Flex>
{href && <Link href={href} isExternal onClick={(e)=> e.stopPropagation()} display="none"/>}
</Box>
</Tooltip>
);
})}
</Grid>
@@ -783,22 +832,7 @@ const CalendarPage: React.FC = () => {
const key = format(day, 'yyyy-MM-dd');
const list = byDate.get(key) || [];
const faded = !isSameMonth(day, monthRef);
const today = (() => {
try {
const parts = new Intl.DateTimeFormat('cs-CZ', {
timeZone: 'Europe/Prague',
year: 'numeric', month: '2-digit', day: '2-digit'
}).formatToParts(new Date());
const y = parts.find(p => p.type === 'year')?.value;
const m = parts.find(p => p.type === 'month')?.value;
const d = parts.find(p => p.type === 'day')?.value;
if (y && m && d) {
const pragueToday = parse(`${y}-${m}-${d}`, 'yyyy-MM-dd', new Date());
return isSameDay(day, pragueToday);
}
} catch {}
return isSameDay(day, new Date());
})();
const today = format(day, 'yyyy-MM-dd') === pragueTodayStr;
return (
<Box
key={idx}
@@ -822,45 +856,61 @@ const CalendarPage: React.FC = () => {
const href = mkHref(m);
const isPast = new Date(`${m.date}T${(m.time||'00:00')}:00`).getTime() < Date.now();
const countdown = liveCountdowns[String(m.id)];
const info = getMatchCompInfo(m, c);
return (
<Box key={m.id} position="relative" _hover={{ textDecoration: 'none' }} data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)}>
{href && (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => { if (!(e.ctrlKey || e.metaKey || e.shiftKey || e.altKey)) e.preventDefault(); }}
onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
onAuxClick={(e) => { if ((e as any).button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
style={{ position: 'absolute', inset: 0, zIndex: 1 }}
aria-hidden
/>
<Tooltip
key={m.id}
label={(
<Box>
<Text fontSize="xs">Kategorie: {info.display || '—'}</Text>
{info.alias && info.alias !== info.display && (
<Text fontSize="xs">Alias: {info.alias}</Text>
)}
</Box>
)}
<Box p={2} borderWidth="1px" borderRadius="md" bg={calendarMatchBg} _hover={{ bg: calendarMatchHoverBg, borderColor: 'brand.primary', cursor: 'pointer' }} textAlign="center">
{!isPast && countdown ? (
<>
<Flex align="center" justify="center" gap={2} mb={1}>
{m.home_logo_url && <Image src={m.home_logo_url} alt={m.home} boxSize="18px" borderRadius="full" objectFit="cover" />}
<Badge colorScheme="orange">za {countdown}</Badge>
{m.away_logo_url && <Image src={m.away_logo_url} alt={m.away} boxSize="18px" borderRadius="full" objectFit="cover" />}
</Flex>
<Text fontSize="xs" color="text.secondary">{m.time || '—'}</Text>
</>
) : (
<>
<Flex align="center" justify="center" gap={2} mb={1}>
{m.home_logo_url && <Image src={m.home_logo_url} alt={m.home} boxSize="18px" borderRadius="full" objectFit="cover" />}
<Badge colorScheme={isPast && m.score ? (getSentiment(m)?.color || 'gray') : 'gray'}>{isPast && m.score ? m.score : 'vs'}</Badge>
{m.away_logo_url && <Image src={m.away_logo_url} alt={m.away} boxSize="18px" borderRadius="full" objectFit="cover" />}
</Flex>
<Text fontSize="xs" color="text.secondary">{m.time || '—'}</Text>
</>
hasArrow
placement="top"
openDelay={200}
>
<Box position="relative" _hover={{ textDecoration: 'none' }} data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)}>
{href && (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => { if (!(e.ctrlKey || e.metaKey || e.shiftKey || e.altKey)) e.preventDefault(); }}
onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
onAuxClick={(e) => { if ((e as any).button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
style={{ position: 'absolute', inset: 0, zIndex: 1 }}
aria-hidden
/>
)}
<Box p={2} borderWidth="1px" borderRadius="md" bg={calendarMatchBg} _hover={{ bg: calendarMatchHoverBg, borderColor: 'brand.primary', cursor: 'pointer' }} textAlign="center">
{!isPast && countdown ? (
<>
<Flex align="center" justify="center" gap={2} mb={1}>
{m.home_logo_url && <Image src={m.home_logo_url} alt={m.home} boxSize="18px" borderRadius="full" objectFit="cover" loading="lazy" decoding="async" />}
<Badge colorScheme="orange">za {countdown}</Badge>
{m.away_logo_url && <Image src={m.away_logo_url} alt={m.away} boxSize="18px" borderRadius="full" objectFit="cover" loading="lazy" decoding="async" />}
</Flex>
<Text fontSize="xs" color="text.secondary">{m.time || '—'}</Text>
</>
) : (
<>
<Flex align="center" justify="center" gap={2} mb={1}>
{m.home_logo_url && <Image src={m.home_logo_url} alt={m.home} boxSize="18px" borderRadius="full" objectFit="cover" loading="lazy" decoding="async" />}
<Badge colorScheme={isPast && m.score ? (getSentiment(m)?.color || 'gray') : 'gray'}>{isPast && m.score ? m.score : 'vs'}</Badge>
{m.away_logo_url && <Image src={m.away_logo_url} alt={m.away} boxSize="18px" borderRadius="full" objectFit="cover" loading="lazy" decoding="async" />}
</Flex>
<Text fontSize="xs" color="text.secondary">{m.time || '—'}</Text>
</>
)}
</Box>
{href && (
<Link href={href} isExternal onClick={(e)=> e.stopPropagation()} display="none"/>
)}
</Box>
{href && (
<Link href={href} isExternal onClick={(e)=> e.stopPropagation()} display="none"/>
)}
</Box>
</Tooltip>
);
})}
{list.length > 3 && !expandedDates[key] && (
@@ -884,19 +934,7 @@ const CalendarPage: React.FC = () => {
<Stack spacing={4}>
{(() => {
const keys = Array.from(byDate.keys());
const todayStr = (() => {
try {
const parts = new Intl.DateTimeFormat('cs-CZ', {
timeZone: 'Europe/Prague',
year: 'numeric', month: '2-digit', day: '2-digit'
}).formatToParts(new Date());
const y = parts.find(p => p.type === 'year')?.value;
const m = parts.find(p => p.type === 'month')?.value;
const d = parts.find(p => p.type === 'day')?.value;
if (y && m && d) return `${y}-${m}-${d}`;
} catch {}
return format(new Date(), 'yyyy-MM-dd');
})();
const todayStr = pragueTodayStr;
const pastKeys = keys.filter(k => k < todayStr).sort().reverse();
const futureKeys = keys.filter(k => k >= todayStr).sort();
const renderGroup = (dKey: string, highlight: boolean) => {
@@ -925,96 +963,116 @@ const CalendarPage: React.FC = () => {
const isPast = new Date(`${m.date}T${(m.time||'00:00')}:00`).getTime() < Date.now();
const sentiment = isPast ? getSentiment(m) : null;
const countdown = liveCountdowns[String(m.id)];
const info = getMatchCompInfo(m, c);
return (
<Box key={m.id} position="relative" _hover={{ textDecoration: 'none' }} data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)}>
{href && (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => { e.preventDefault(); }}
onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
onAuxClick={(e) => { if ((e as any).button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
style={{ position: 'absolute', inset: 0, zIndex: 1 }}
aria-hidden
/>
<Tooltip
key={m.id}
label={(
<Box>
<Text fontSize="xs">Kategorie: {info.display || '—'}</Text>
{info.alias && info.alias !== info.display && (
<Text fontSize="xs">Alias: {info.alias}</Text>
)}
</Box>
)}
<Flex
align="center"
justify="space-between"
p={3}
borderWidth="1px"
borderRadius="md"
bg={listMatchBg}
borderColor={listMatchBorder}
_hover={{ bg: listMatchHoverBg, borderColor: 'brand.primary', cursor: 'pointer', transform: 'translateY(-2px)', boxShadow: 'md' }}
transition="all 0.2s"
gap={3}
>
<Flex direction="column" minW="100px">
<Text fontWeight="semibold" color={listDateText} fontSize="sm">{m.date}</Text>
<Text color={listTimeText} fontSize="sm">{m.time || '—'}</Text>
{m.venue && <Text color={listVenueText} fontSize="xs" mt={1}>{m.venue}</Text>}
</Flex>
<Flex align="center" gap={3} flex="1">
{/* Home Team */}
<Flex align="center" gap={2} flex="1" justify="flex-end">
<Text fontSize="sm" fontWeight="medium" textAlign="right" color={listDateText}>
{m.home}
</Text>
{m.home_logo_url && (
<Image
src={m.home_logo_url}
alt={m.home}
boxSize="32px"
borderRadius="full"
objectFit="cover"
border="2px solid"
borderColor="gray.200"
/>
)}
hasArrow
placement="top"
openDelay={200}
>
<Box position="relative" _hover={{ textDecoration: 'none' }} data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)}>
{href && (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => { e.preventDefault(); }}
onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
onAuxClick={(e) => { if ((e as any).button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
style={{ position: 'absolute', inset: 0, zIndex: 1 }}
aria-hidden
/>
)}
<Flex
align="center"
justify="space-between"
p={3}
borderWidth="1px"
borderRadius="md"
bg={listMatchBg}
borderColor={listMatchBorder}
_hover={{ bg: listMatchHoverBg, borderColor: 'brand.primary', cursor: 'pointer', transform: 'translateY(-2px)', boxShadow: 'md' }}
transition="all 0.2s"
gap={3}
>
<Flex direction="column" minW="100px">
<Text fontWeight="semibold" color={listDateText} fontSize="sm">{m.date}</Text>
<Text color={listTimeText} fontSize="sm">{m.time || '—'}</Text>
{m.venue && <Text color={listVenueText} fontSize="xs" mt={1}>{m.venue}</Text>}
</Flex>
{/* Score or Countdown */}
<Flex direction="column" align="center" gap={1} minW="80px">
{!isPast && countdown ? (
<Badge colorScheme="orange" fontSize="sm" px={2}>za {countdown}</Badge>
) : (
<Badge colorScheme={isPast && m.score ? (getSentiment(m)?.color || 'gray') : 'gray'} fontSize="md" px={3} py={1}>
{isPast && m.score ? m.score : 'vs'}
</Badge>
)}
{sentiment && (
<Text fontSize="xs" color={`${sentiment.color}.600`} fontWeight="semibold">
{sentiment.label}
<Flex align="center" gap={3} flex="1">
{/* Home Team */}
<Flex align="center" gap={2} flex="1" justify="flex-end">
<Text fontSize="sm" fontWeight="medium" textAlign="right" color={listDateText}>
{m.home}
</Text>
)}
</Flex>
{/* Away Team */}
<Flex align="center" gap={2} flex="1" justify="flex-start">
{m.away_logo_url && (
<Image
src={m.away_logo_url}
alt={m.away}
boxSize="32px"
borderRadius="full"
objectFit="cover"
border="2px solid"
borderColor="gray.200"
/>
)}
<Text fontSize="sm" fontWeight="medium" textAlign="left" color={listDateText}>
{m.away}
</Text>
{m.home_logo_url && (
<Image
src={m.home_logo_url}
alt={m.home}
boxSize="32px"
borderRadius="full"
objectFit="cover"
border="2px solid"
borderColor="gray.200"
loading="lazy"
decoding="async"
/>
)}
</Flex>
{/* Score or Countdown */}
<Flex direction="column" align="center" gap={1} minW="80px">
{!isPast && countdown ? (
<Badge colorScheme="orange" fontSize="sm" px={2}>za {countdown}</Badge>
) : (
<Badge colorScheme={isPast && m.score ? (getSentiment(m)?.color || 'gray') : 'gray'} fontSize="md" px={3} py={1}>
{isPast && m.score ? m.score : 'vs'}
</Badge>
)}
{sentiment && (
<Text fontSize="xs" color={`${sentiment.color}.600`} fontWeight="semibold">
{sentiment.label}
</Text>
)}
</Flex>
{/* Away Team */}
<Flex align="center" gap={2} flex="1" justify="flex-start">
{m.away_logo_url && (
<Image
src={m.away_logo_url}
alt={m.away}
boxSize="32px"
borderRadius="full"
objectFit="cover"
border="2px solid"
borderColor="gray.200"
loading="lazy"
decoding="async"
/>
)}
<Text fontSize="sm" fontWeight="medium" textAlign="left" color={listDateText}>
{m.away}
</Text>
</Flex>
</Flex>
</Flex>
</Flex>
{href && (
<Link href={href} isExternal onClick={(e)=> e.stopPropagation()} display="none"/>
)}
</Box>
{href && (
<Link href={href} isExternal onClick={(e)=> e.stopPropagation()} display="none"/>
)}
</Box>
</Tooltip>
);
})}
</Stack>
+6 -1
View File
@@ -18,6 +18,7 @@ import { Calendar, Image as ImageIcon, ExternalLink } from 'lucide-react';
import MainLayout from '../components/layout/MainLayout';
import { API_URL } from '../services/api';
import NewsletterCTA from '../components/common/NewsletterCTA';
import { Helmet } from 'react-helmet-async';
interface Album {
id: string;
@@ -133,6 +134,10 @@ const GalleryPage: React.FC = () => {
return (
<MainLayout>
<Helmet>
<title>Fotogalerie</title>
<meta name="description" content="Prohlédněte si alba a fotografie našeho klubu." />
</Helmet>
<Box bg={bgApp} minH="100vh" py={8}>
<Container maxW="7xl">
{/* Header */}
@@ -153,7 +158,7 @@ const GalleryPage: React.FC = () => {
📸 Všechny fotografie jsou z platformy{' '}
<Text
as="a"
href="https://zonerama.com"
href={zoneramaProfileUrl}
target="_blank"
rel="noopener noreferrer"
fontWeight="600"
+14 -15
View File
@@ -20,7 +20,7 @@ const VideosSection = React.lazy(() => import('../components/home/VideosSection'
const MerchSection = React.lazy(() => import('../components/home/MerchSection'));
const PollsWidget = React.lazy(() => import('../components/home/PollsWidget'));
const GallerySection = React.lazy(() => import('../components/home/GallerySection'));
import { getArticles as apiGetArticles, Article as ApiArticle } from '../services/articles';
import { getArticles as apiGetArticles, getFeaturedArticles, Article as ApiArticle } from '../services/articles';
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
import { getUpcomingEvents } from '../services/eventService';
const NewsletterSubscribe = React.lazy(() => import('../components/newsletter/NewsletterSubscribe'));
@@ -104,7 +104,7 @@ const HomePage: React.FC = () => {
// Matches slider auto-centering handled internally by MatchesSlider component
// API-driven players and sponsors
type UiPlayer = { id:number|string; name:string; number?:number; position?:string; image?:string; slug?:string; age?: number; nationality?: string };
type UiPlayer = { id:number|string; name:string; number?:number; position?:string; image?:string; slug?:string; age?: number; nationality?: string; active?: boolean };
type UiSponsor = { id:number|string; name:string; logo:string; url?:string; tier?: string };
type UiBanner = { id:number|string; name:string; image:string; url?:string; placement?:string; width?:number; height?:number };
type UiMerch = { id?: number|string; title?: string; image_url: string; url?: string };
@@ -412,9 +412,9 @@ const HomePage: React.FC = () => {
if (name) setClubName(name);
if (logo) setClubLogo(logo);
// Load players via API
// Load players via API (include inactive to show as non-active instead of hiding)
try {
const apiPlayers: ApiPlayer[] = await apiGetPlayers();
const apiPlayers: ApiPlayer[] = await apiGetPlayers({ active: false });
const mappedPlayers: UiPlayer[] = (apiPlayers || []).map((p: ApiPlayer) => ({
id: p.id,
name: [p.first_name, p.last_name].filter(Boolean).join(' '),
@@ -422,6 +422,7 @@ const HomePage: React.FC = () => {
position: p.position,
image: assetUrl(p.image_url) || undefined,
nationality: (p as any).nationality,
active: Boolean((p as any).is_active),
age: (function(iso?: string){
if (!iso) return undefined;
const d = new Date(iso);
@@ -464,10 +465,10 @@ const HomePage: React.FC = () => {
setBanners(mappedBanners);
} catch {}
// Load featured articles (homepage primary) via API
// Load featured articles (homepage primary) via dedicated endpoint
try {
const resp = await apiGetArticles({ featured: true, page_size: 3 });
const items = (resp?.data || []).map((a: ApiArticle, idx: number) => ({
const resp = await getFeaturedArticles({ page_size: 100 });
const all = (resp?.data || []).map((a: ApiArticle, idx: number) => ({
id: a.id ?? idx + 1,
title: a.title,
excerpt: (a as any).excerpt || (a.content || '').slice(0, 140),
@@ -476,10 +477,11 @@ const HomePage: React.FC = () => {
category: 'Aktuality',
slug: a.slug,
}));
setFeatured(items);
// Ensure non-featured 'news' excludes featured items
// Show only first 3 in hero; exclude only those 3 from the other news list
const top3 = all.slice(0, 3);
setFeatured(top3);
setNews((prev) => {
const featuredKeys = new Set(items.map((f) => (f.slug ? `s:${f.slug}` : `i:${f.id}`)));
const featuredKeys = new Set(top3.map((f) => (f.slug ? `s:${f.slug}` : `i:${f.id}`)));
return (prev || []).filter((n) => !featuredKeys.has(n.slug ? `s:${n.slug}` : `i:${n.id}`));
});
} catch {}
@@ -1064,12 +1066,11 @@ const HomePage: React.FC = () => {
</div>
<div className="track">
{items.map((p)=> (
<div key={p.id} className="card">
<div key={p.id} className="card" style={{ opacity: p.active === false ? 0.6 : 1 }}>
<div className="photo" style={{ backgroundImage: `url(${assetUrl((p as any).image) || '/images/player-placeholder.jpg'})` }} />
<div className="name">{p.name}</div>
<div className="role">{p.position || 'Hráč'}</div>
{typeof p.number !== 'undefined' && <div className="number">#{p.number}</div>}
{typeof p.age === 'number' && <div className="age">{p.age} {czYears(p.age)}</div>}
</div>
))}
</div>
@@ -1678,12 +1679,10 @@ const HomePage: React.FC = () => {
</div>
<div className="scroll-x">
{players.map((p) => (
<a key={p.id} href={p.slug ? `/players/${p.slug}` : `/players/${p.id}`} className="player-card card">
<a key={p.id} href={p.slug ? `/players/${p.slug}` : `/players/${p.id}`} className="player-card card" style={{ opacity: p.active === false ? 0.6 : 1 }}>
<div className="photo" style={{ backgroundImage: `url(${assetUrl(p.image) || p.image})` }} />
<div className="meta">{typeof p.number !== 'undefined' ? (<><span className="nr">#{p.number}</span> {p.name}</>) : p.name}</div>
<div className="pos">{p.position}</div>
{p.nationality ? (<div className="nat"><span className="flag" style={{ marginRight: 6 }}>{getCountryFlag(p.nationality)}</span>{translateNationality(p.nationality)}</div>) : null}
{typeof p.age === 'number' && <div className="age">{p.age} let</div>}
</a>
))}
</div>
+1 -1
View File
@@ -12,7 +12,7 @@ const OverlaySponsorsPage: React.FC = () => {
});
return (
<Box minH="100vh" bg={bg} display="flex" alignItems="center" justifyContent="center" p={6}>
<Box minH="100vh" bg={bg} display="flex" alignItems="center" justifyContent="center" p={4}>
{isLoading ? (
<Center><Spinner /></Center>
) : (
-5
View File
@@ -83,11 +83,6 @@ const PlayerDetailPage: React.FC = () => {
<Text><b>Národnost:</b></Text>
<Text as="span" fontSize="xl">{getCountryFlag(data.nationality)}</Text>
<Text>{translateNationality(data.nationality)}</Text>
{data.date_of_birth ? (
<Text color={useColorModeValue('gray.600', 'gray.400')}>
{(() => { const a = calculateAge(data.date_of_birth); return a != null ? `${a} ${czYears(a)}` : ''; })()}
</Text>
) : null}
</HStack>
)}
{data.date_of_birth && (
+54 -10
View File
@@ -1,4 +1,4 @@
import { Box, Container, Heading, HStack, Image, SimpleGrid, Spinner, Stack, Text, VStack, useColorModeValue, Badge } from '@chakra-ui/react';
import { Box, Container, Heading, HStack, Image, SimpleGrid, Spinner, Stack, Text, VStack, useColorModeValue, Badge, Input, Select, Checkbox, InputGroup, InputLeftElement } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getPlayers } from '../services/public';
import type { Player } from '../services/public';
@@ -6,14 +6,43 @@ import { assetUrl } from '../utils/url';
import { Link as RouterLink } from 'react-router-dom';
import MainLayout from '../components/layout/MainLayout';
import NewsletterCTA from '../components/common/NewsletterCTA';
import { translateNationality, getCountryFlag } from '../utils/nationality';
// nationality display removed per requirements
import { useMemo, useState } from 'react';
import { SearchIcon } from '@chakra-ui/icons';
const PlayersPage: React.FC = () => {
const { data, isLoading, isError } = useQuery<Player[]>({ queryKey: ['players'], queryFn: getPlayers });
const { data, isLoading, isError } = useQuery<Player[]>({ queryKey: ['players'], queryFn: () => getPlayers() });
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textSecondary = useColorModeValue('gray.600', 'gray.400');
const [q, setQ] = useState('');
const [gender, setGender] = useState('');
const [position, setPosition] = useState('');
const [activeOnly, setActiveOnly] = useState(true);
const positions = useMemo(() => {
const all = (data || []).map(p => p.position).filter(Boolean) as string[];
return Array.from(new Set(all));
}, [data]);
const filtered = useMemo(() => {
let list = (data || []).slice();
if (activeOnly) list = list.filter(p => p.is_active !== false);
if (gender) list = list.filter(p => (p.gender || '').toLowerCase() === gender);
if (position) list = list.filter(p => (p.position || '') === position);
if (q.trim()) {
const needle = q.trim().toLowerCase();
list = list.filter(p => {
const name = `${p.first_name} ${p.last_name}`.trim().toLowerCase();
const pos = (p.position || '').toLowerCase();
const jersey = typeof p.jersey_number === 'number' ? String(p.jersey_number) : '';
return name.includes(needle) || pos.includes(needle) || jersey.includes(needle);
});
}
return list;
}, [data, q, gender, position, activeOnly]);
if (isLoading) {
return (
<MainLayout>
@@ -40,8 +69,28 @@ const PlayersPage: React.FC = () => {
<Container maxW="7xl" py={{ base: 6, md: 10 }}>
<VStack align="stretch" spacing={6}>
<Heading as="h1" size={{ base: 'xl', md: '2xl' }}>Hráči</Heading>
<SimpleGrid columns={{ base: 1, md: 4 }} spacing={4}>
<InputGroup>
<InputLeftElement pointerEvents="none">
<SearchIcon color="gray.400" />
</InputLeftElement>
<Input value={q} onChange={(e)=>setQ(e.target.value)} placeholder="Hledat jméno, číslo, pozici" />
</InputGroup>
<Select value={gender} onChange={(e)=>setGender(e.target.value)} placeholder="Pohlaví">
<option value="men">Muž</option>
<option value="women">Žena</option>
</Select>
<Select value={position} onChange={(e)=>setPosition(e.target.value)} placeholder="Pozice">
{positions.map((pos)=> (
<option key={pos} value={pos}>{pos}</option>
))}
</Select>
<HStack>
<Checkbox isChecked={activeOnly} onChange={(e)=>setActiveOnly(e.target.checked)}>Pouze aktivní</Checkbox>
</HStack>
</SimpleGrid>
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={6}>
{data?.map((p) => (
{filtered.map((p) => (
<Stack
key={p.id}
as={RouterLink}
@@ -69,12 +118,7 @@ const PlayersPage: React.FC = () => {
</Box>
<Text fontWeight="bold" fontSize="lg">{p.first_name} {p.last_name}</Text>
<Text color={textSecondary}>{p.position}</Text>
{p.nationality ? (
<HStack spacing={2} color={textSecondary}>
<Text as="span" fontSize="lg">{getCountryFlag(p.nationality)}</Text>
<Text>{translateNationality(p.nationality)}</Text>
</HStack>
) : null}
{/* Národnost skryta */}
</Stack>
))}
</SimpleGrid>
+176 -16
View File
@@ -22,6 +22,17 @@ type TableRow = {
points: string;
};
function deriveTeamIdFromLogoUrl(url?: string): string | undefined {
try {
const u = String(url || '');
if (!u) return undefined;
const m = u.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/);
return m ? m[0].toLowerCase() : undefined;
} catch {
return undefined;
}
}
type CompetitionTable = {
id: string;
name: string;
@@ -51,6 +62,7 @@ const TablesPage: React.FC = () => {
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string; display_order?: number }>>({});
const [selectedClub, setSelectedClub] = useState<any>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [overrides, setOverrides] = useState<{ by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> } | null>(null);
const { data: settings } = usePublicSettings();
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
@@ -61,6 +73,32 @@ const TablesPage: React.FC = () => {
const rowOddBg = useColorModeValue('white', 'gray.800');
const rowEvenBg = useColorModeValue('gray.50', 'gray.700');
type SortKey = 'rank' | 'team' | 'played' | 'wins' | 'draws' | 'losses' | 'score' | 'points';
type SortOrder = 'desc' | 'asc';
const [sortState, setSortState] = useState<Record<string, { key: SortKey; order: SortOrder } | null>>({});
const toNumber = (v: any): number => {
if (typeof v === 'number') return v;
const n = parseFloat(String(v ?? '').replace(/[^0-9\-\.]/g, ''));
return isNaN(n) ? 0 : n;
};
const scoreDiff = (s: any): number => {
const str = String(s ?? '').trim();
const m = str.match(/(-?\d+)\s*[:\-]\s*(-?\d+)/);
if (m) return Number(m[1]) - Number(m[2]);
return toNumber(str);
};
const toggleSort = (compId: string, key: SortKey) => {
const cur = sortState[compId];
if (!cur || cur.key !== key) { setSortState({ ...sortState, [compId]: { key, order: 'desc' } }); return; }
if (cur.order === 'desc') { setSortState({ ...sortState, [compId]: { key, order: 'asc' } }); return; }
const next = { ...sortState }; next[compId] = null; setSortState(next);
};
const arrow = (compId: string, key: SortKey) => {
const cur = sortState[compId];
if (!cur || cur.key !== key) return '';
return cur.order === 'desc' ? '▼' : '▲';
};
const handleClubClick = (club: any) => {
setSelectedClub(club);
setIsModalOpen(true);
@@ -72,6 +110,22 @@ const TablesPage: React.FC = () => {
setLoading(true);
setError(null);
try {
// Load overrides (API + cached file)
try {
const now = Date.now();
let ovr: any = null;
try {
const res = await fetch(`/api/v1/public/team-logo-overrides?t=${now}`, { cache: 'no-cache' });
if (res.ok) ovr = await res.json();
} catch {}
if (!ovr) {
try {
const res2 = await fetch('/cache/prefetch/team_logo_overrides.json', { cache: 'no-cache' });
if (res2.ok) ovr = await res2.json();
} catch {}
}
if (!cancelled) setOverrides(ovr || { by_id: {}, by_name: {} });
} catch {}
// Load aliases first
let amap: Record<string, { alias: string; original_name?: string; display_order?: number }> = {};
try {
@@ -123,6 +177,83 @@ const TablesPage: React.FC = () => {
return () => { cancelled = true; };
}, []);
// Normalization helpers (same as CalendarPage/TableSection)
const normalize = (s: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const stripPrefixes = (s: string) => {
let x = normalize(s);
x = x.replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g, '');
return x.replace(/\s+/g, ' ').trim();
};
const byNameMap = useMemo(() => {
const m: Record<string, string> = {};
const src = overrides?.by_name || {};
for (const k of Object.keys(src)) m[normalize(k)] = src[k];
return m;
}, [overrides]);
const byIdMap = useMemo(() => (overrides?.by_id || {}) as Record<string, { name?: string; logo_url?: string }>, [overrides]);
const overridesNameIndex = useMemo(() => {
const idx: Record<string, { id: string; name: string }> = {};
try {
for (const [id, v] of Object.entries(byIdMap)) {
const name = String((v as any)?.name || '').trim();
if (!name) continue;
const key = normalize(name);
if (!key) continue;
idx[key] = { id, name };
}
} catch {}
return idx;
}, [byIdMap]);
const pickName = (teamId?: string, original?: string, logoUrl?: string) => {
const id = String(teamId || '') || deriveTeamIdFromLogoUrl(logoUrl) || '';
const v = id ? byIdMap?.[id]?.name : undefined;
if (v && String(v).trim().length > 0) return String(v);
const orig = String(original || '');
if (orig) {
const n = normalize(orig);
let hit = overridesNameIndex[n];
if (!hit) {
for (const [k, val] of Object.entries(overridesNameIndex)) {
if (!k) continue;
if (n.endsWith(k) || k.endsWith(n)) { hit = val as any; break; }
}
}
if (!hit) {
const t1 = n.split(' ')[0];
if (t1 && t1.length >= 5) {
for (const [k, val] of Object.entries(overridesNameIndex)) {
const k1 = String(k).split(' ')[0];
if (k1 === t1) { hit = val as any; break; }
}
}
}
if (hit?.name) return hit.name;
}
return orig;
};
const pickLogo = (teamId?: string, teamName?: string, original?: string): string | undefined => {
if (teamId && byIdMap?.[teamId]?.logo_url) return byIdMap[teamId]!.logo_url as string;
if (teamName) {
const exact = (overrides?.by_name || {})[teamName];
if (exact) return exact;
const n = normalize(teamName);
const cand = byNameMap[n];
if (cand) return cand;
const stripped = stripPrefixes(teamName);
for (const k of Object.keys(overrides?.by_name || {})) {
const kn = stripPrefixes(k);
if (!kn) continue;
if (stripped.endsWith(kn) || kn.endsWith(stripped)) return (overrides!.by_name as any)[k];
}
}
return original;
};
return (
<MainLayout>
<Container maxW="7xl" py={{ base: 6, md: 10 }}>
@@ -172,18 +303,47 @@ const TablesPage: React.FC = () => {
<Table size="sm" variant="unstyled" color={tableTextColor}>
<Thead position="sticky" top={0} zIndex={2}>
<Tr bg={tableHeaderBg} color="white">
<Th w="56px" color="white">#</Th>
<Th color="white">Tým</Th>
<Th isNumeric color="white">Z</Th>
<Th isNumeric color="white">V</Th>
<Th isNumeric color="white">R</Th>
<Th isNumeric color="white">P</Th>
<Th isNumeric color="white">Skóre</Th>
<Th isNumeric color="white">Body</Th>
<Th w="56px" color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'rank')}># {arrow(c.id, 'rank')}</Th>
<Th color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'team')}>Tým {arrow(c.id, 'team')}</Th>
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'played')}>Z {arrow(c.id, 'played')}</Th>
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'wins')}>V {arrow(c.id, 'wins')}</Th>
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'draws')}>R {arrow(c.id, 'draws')}</Th>
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'losses')}>P {arrow(c.id, 'losses')}</Th>
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'score')}>Skóre {arrow(c.id, 'score')}</Th>
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'points')}>Body {arrow(c.id, 'points')}</Th>
</Tr>
</Thead>
<Tbody>
{c.rows.map((r, idx) => (
{(() => {
const cur = sortState[c.id];
const arr = [...(c.rows || [])];
if (cur) {
arr.sort((a: any, b: any) => {
let va: any; let vb: any; let isText = false;
switch (cur.key) {
case 'team': va = pickName(a.team_id, a.team, a.team_logo_url); vb = pickName(b.team_id, b.team, b.team_logo_url); isText = true; break;
case 'rank': va = toNumber(a.rank); vb = toNumber(b.rank); break;
case 'played': va = toNumber(a.played); vb = toNumber(b.played); break;
case 'wins': va = toNumber(a.wins); vb = toNumber(b.wins); break;
case 'draws': va = toNumber(a.draws); vb = toNumber(b.draws); break;
case 'losses': va = toNumber(a.losses); vb = toNumber(b.losses); break;
case 'score': va = scoreDiff(a.score); vb = scoreDiff(b.score); break;
case 'points': va = toNumber(a.points); vb = toNumber(b.points); break;
default: va = 0; vb = 0;
}
let res = isText ? String(va).localeCompare(String(vb)) : (va as number) - (vb as number);
if (cur.order === 'desc') res = -res;
if (res === 0) {
const ra = toNumber(a.rank); const rb = toNumber(b.rank);
res = ra - rb;
}
return res;
});
}
return arr.map((r, idx) => {
const displayTeam = pickName(r.team_id, r.team, r.team_logo_url);
const displayLogo = pickLogo(r.team_id, displayTeam, r.team_logo_url);
return (
<Tr
key={`${c.id}-${r.rank}-${r.team}`}
transition="all 0.15s"
@@ -198,17 +358,17 @@ const TablesPage: React.FC = () => {
<Td>
<Flex align="center" gap={3}>
<TeamLogo
teamId={r.team_id}
teamName={r.team}
facrLogo={r.team_logo_url}
teamId={r.team_id || deriveTeamIdFromLogoUrl(r.team_logo_url)}
teamName={displayTeam}
facrLogo={displayLogo}
size="small"
alt={r.team}
borderRadius="full"
alt={displayTeam}
objectFit="contain"
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
/>
<Text fontWeight="medium" color={tableTextColor}>{r.team}</Text>
<Text fontWeight="medium" color={tableTextColor}>{displayTeam}</Text>
</Flex>
</Td>
<Td isNumeric color={tableTextColor}>{r.played}</Td>
@@ -220,7 +380,7 @@ const TablesPage: React.FC = () => {
<Badge variant="solid" bg="blue.600" color="white">{r.points}</Badge>
</Td>
</Tr>
))}
);});})()}
</Tbody>
</Table>
</Box>
+8 -2
View File
@@ -28,6 +28,7 @@ import { getCachedYouTube, YouTubeVideo } from '../services/youtube';
import { FaPlay, FaExternalLinkAlt, FaYoutube } from 'react-icons/fa';
import NewsletterCTA from '../components/common/NewsletterCTA';
import CommentsSection from '../components/comments/CommentsSection';
import { Helmet } from 'react-helmet-async';
type RenderItem = {
key: string;
@@ -79,6 +80,7 @@ const VideosPage: React.FC = () => {
const source = settings?.videos_source || 'auto';
const youtubeUrl = (settings as any)?.youtube_url || (settings as any)?.social_youtube || null;
const titleOverrides: Record<string, string> = (settings as any)?.videos_title_overrides || {};
useEffect(() => {
let canceled = false;
@@ -113,7 +115,7 @@ const VideosPage: React.FC = () => {
if (source === 'auto') {
return (yt || []).map((v) => ({
key: v.video_id,
title: v.title,
title: (titleOverrides?.[v.video_id]?.trim()) || v.title,
embedUrl: toEmbed(v.video_id),
thumbnail: v.thumbnail_url,
date: v.published_date,
@@ -142,7 +144,7 @@ const VideosPage: React.FC = () => {
};
});
return manual.length ? manual : legacy;
}, [source, yt, settings?.videos_items, settings]);
}, [source, yt, settings?.videos_items, settings, titleOverrides]);
const openVideo = (item: RenderItem) => {
setSelectedVideo(item);
@@ -267,6 +269,10 @@ const VideosPage: React.FC = () => {
return (
<MainLayout>
<Helmet>
<title>Videa</title>
<meta name="description" content="Sledujte naše nejnovější videa a zápasy." />
</Helmet>
<Container maxW="7xl" py={{ base: 6, md: 10 }}>
<Box mb={6}>
<HStack justify="space-between" mb={2} flexWrap="wrap">
@@ -4,11 +4,9 @@ import AdminLayout from '../../layouts/AdminLayout';
import { getAnalytics, AnalyticsData, getAnalyticsOverview, getTopPages, AnalyticsOverview, PageStats } from '../../services/analyticsService';
import { MatchesWidget } from '../../components/widgets/MatchesWidget';
import { ArticlesWidget } from '../../components/widgets/ArticlesWidget';
import { FaUsers, FaCalendarAlt, FaNewspaper, FaTrophy, FaChartLine, FaCog, FaBook, FaRocket, FaEye, FaMousePointer } from 'react-icons/fa';
import { FaUsers, FaCalendarAlt, FaNewspaper, FaChartLine, FaCog, FaBook, FaRocket, FaEye, FaMousePointer } from 'react-icons/fa';
import AdminHelp from '../../components/admin/AdminHelp';
import { getFacrTablesCache } from '../../services/facr/cache';
import ScoreboardPreview from '../../components/scoreboard/ScoreboardPreview';
import { getScoreboardState, ScoreboardState } from '../../services/scoreboard';
import { Link as RouterLink } from 'react-router-dom';
import api from '../../services/api';
@@ -197,13 +195,7 @@ const AdminDashboardPage = () => {
return 0;
}
})();
// Scoreboard state for compact preview
const { data: scoreboardState } = useQuery<ScoreboardState>({
queryKey: ['scoreboard-state'],
queryFn: getScoreboardState,
staleTime: 60 * 1000,
});
return (
<AdminLayout>
@@ -449,29 +441,6 @@ const AdminDashboardPage = () => {
<SimpleGrid columns={{ base: 1, md: 2, xl: 3 }} spacing={6} mb={8}>
<MatchesWidget />
<ArticlesWidget />
{/* Compact Scoreboard card */}
<Box
bg={useColorModeValue('white', 'gray.800')}
p={5}
borderRadius="xl"
boxShadow="md"
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'gray.700')}
>
<HStack justify="space-between" mb={4}>
<Text fontWeight="bold" fontSize="lg">Aktuální tabule</Text>
<Link as={RouterLink} to="/admin/scoreboard" color="blue.500" fontSize="sm" fontWeight="semibold">
Upravit
</Link>
</HStack>
{scoreboardState ? (
<Box display="flex" justifyContent="center">
<ScoreboardPreview state={scoreboardState} />
</Box>
) : (
<Skeleton height="40px" />
)}
</Box>
</SimpleGrid>
{/* Admin guidance */}
+8 -8
View File
@@ -337,8 +337,8 @@ const AdminDocsPage: React.FC = () => {
{ icon: FaNewspaper, title: 'Články', desc: 'Publikujte novinky a reportáže', link: '/admin/clanky' },
{ icon: FaFutbol, title: 'Zápasy', desc: 'Automatické načítání z FAČR', link: '/admin/zapasy' },
{ icon: FaUsers, title: 'Hráči a týmy', desc: 'Správa soupisek', link: '/admin/hraci' },
{ icon: FaImage, title: 'Galerie', desc: 'Fotogalerie a alba', link: '/admin/gallery' },
{ icon: FaPhotoVideo, title: 'Média', desc: 'Nahrávání obrázků a souborů', link: '/admin/media' },
{ icon: FaImage, title: 'Galerie', desc: 'Fotogalerie a alba', link: '/admin/galerie' },
{ icon: FaPhotoVideo, title: 'Média', desc: 'Nahrávání obrázků a souborů', link: '/admin/soubory' },
{ icon: FaEnvelope, title: 'Newsletter', desc: 'E-mailové kampaně', link: '/admin/newsletter' },
{ icon: FaCog, title: 'Nastavení klubu', desc: 'Logo, barvy, kontakty', link: '/admin/nastaveni' },
{ icon: FaHandshake, title: 'Sponzoři', desc: 'Správa partnerů klubu', link: '/admin/sponzori' },
@@ -442,7 +442,7 @@ const AdminDocsPage: React.FC = () => {
</ListItem>
<ListItem>
<strong>Logo klubu</strong> Nejdříve nahrajte logo do sekce{' '}
<Link href="/admin/media" color="blue.600" fontWeight="bold">
<Link href="/admin/soubory" color="blue.600" fontWeight="bold">
Média
</Link>
, poté zkopírujte adresu obrázku (URL) a vložte ji sem
@@ -631,7 +631,7 @@ const AdminDocsPage: React.FC = () => {
</ListItem>
<ListItem>
<strong>Přidejte hlavní obrázek</strong> Nejprve nahrajte obrázek do{' '}
<Link href="/admin/media" color="blue.600" fontWeight="bold">Média</Link>,
<Link href="/admin/soubory" color="blue.600" fontWeight="bold">Média</Link>,
poté zkopírujte jeho adresu (URL) a vložte ji do pole "Obrázek"
</ListItem>
<ListItem>
@@ -660,7 +660,7 @@ const AdminDocsPage: React.FC = () => {
<strong>Pro obrázky:</strong>
</Text>
<OrderedList spacing={2} fontSize="sm">
<ListItem>Nahrajte obrázek v sekci <Link href="/admin/media" color="blue.600">Média</Link></ListItem>
<ListItem>Nahrajte obrázek v sekci <Link href="/admin/soubory" color="blue.600">Média</Link></ListItem>
<ListItem>Zkopírujte adresu obrázku (např. <Code>/uploads/2025/01/foto.jpg</Code>)</ListItem>
<ListItem>V editoru článku použijte HTML: <Code>&lt;img src="/uploads/2025/01/foto.jpg" alt="Popis" /&gt;</Code></ListItem>
</OrderedList>
@@ -891,7 +891,7 @@ const AdminDocsPage: React.FC = () => {
Vše, co nahrajete zde, můžete pak použít v článcích, bannerech, newsletterech nebo na stránce O klubu.
</Text>
<Link href="/admin/media" isExternal>
<Link href="/admin/soubory" isExternal>
<HStack
p={3}
bg={useColorModeValue('blue.50', 'blue.900')}
@@ -913,7 +913,7 @@ const AdminDocsPage: React.FC = () => {
<OrderedList spacing={2} pl={5}>
<ListItem>
Otevřete sekci{' '}
<Link href="/admin/media" color="blue.600" fontWeight="bold">Média</Link>
<Link href="/admin/soubory" color="blue.600" fontWeight="bold">Média</Link>
</ListItem>
<ListItem>
Klikněte na tlačítko <strong>"Nahrát soubor"</strong> nebo <strong>"Upload"</strong>
@@ -1806,7 +1806,7 @@ const AdminDocsPage: React.FC = () => {
<List spacing={2} styleType="disc" pl={5}>
<ListItem>
Nahrajte obrázek do{' '}
<Link href="/admin/media" color="blue.600">
<Link href="/admin/soubory" color="blue.600">
Média
</Link>
</ListItem>
+33 -2
View File
@@ -45,6 +45,8 @@ const AdminVideosPage: React.FC = () => {
const [autoLoading, setAutoLoading] = useState<boolean>(false);
const [autoError, setAutoError] = useState<string>('');
const [filter, setFilter] = useState<string>('');
// Title overrides for auto mode (video_id -> title)
const [titleOverrides, setTitleOverrides] = useState<Record<string, string>>({});
// Derived flags
const hasChannel = useMemo(() => (channelInput || '').trim().length > 0, [channelInput]);
@@ -67,6 +69,8 @@ const AdminVideosPage: React.FC = () => {
// Prefill channel handle from settings if available (social/youtube_url)
const ytUrl = (s as any).youtube_url || (s as any).social_youtube || '';
if (ytUrl) setChannelInput(ytUrl);
// Load existing overrides
setTitleOverrides(((s as any).videos_title_overrides as any) || {});
} catch (e) {
// ignore
} finally {
@@ -95,6 +99,15 @@ const AdminVideosPage: React.FC = () => {
if (mounted) setAutoLoading(false);
}
};
const saveOverrides = async () => {
try {
await updateAdminSettings({ videos_title_overrides: titleOverrides } as any);
toast({ status: 'success', title: 'Přepisy uloženy', description: 'Názvy videí byly aktualizovány.', duration: 2500 });
} catch (e) {
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se uložit přepisy názvů.', duration: 3000 });
}
};
run();
return () => { mounted = false; };
}, [loading, videosSource]);
@@ -410,6 +423,7 @@ const AdminVideosPage: React.FC = () => {
<HStack spacing={2} flexWrap="wrap">
<Input size="sm" placeholder="Filtrovat podle názvu" value={filter} onChange={(e) => setFilter(e.target.value)} width={{ base: '100%', md: '260px' }} />
<Button size="sm" onClick={refreshAuto} isLoading={autoLoading} variant="outline" flexShrink={0} minW="max-content">Aktualizovat cache</Button>
<Button size="sm" colorScheme="blue" variant="solid" onClick={saveOverrides} flexShrink={0} minW="max-content">Uložit přepisy názvů</Button>
</HStack>
)}
</HStack>
@@ -422,10 +436,10 @@ const AdminVideosPage: React.FC = () => {
<HStack color="gray.600"><Spinner size="sm" /><Text>Načítám videa</Text></HStack>
) : (
<>
<Text fontSize="sm" color="gray.600" mb={2}>Počet videí: {autoVideos.filter(v => v.title.toLowerCase().includes(filter.toLowerCase())).length}</Text>
<Text fontSize="sm" color="gray.600" mb={2}>Počet videí: {autoVideos.filter(v => (v.title || '').toLowerCase().includes(filter.toLowerCase())).length}</Text>
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={3}>
{autoVideos
.filter(v => v.title.toLowerCase().includes(filter.toLowerCase()))
.filter(v => (v.title || '').toLowerCase().includes(filter.toLowerCase()))
.map((v) => (
<Box key={v.video_id} borderWidth="1px" borderRadius="md" p={2}>
<VStack align="stretch" spacing={2}>
@@ -435,6 +449,23 @@ const AdminVideosPage: React.FC = () => {
<HStack spacing={2} color="gray.600" fontSize="sm">
{v.published_date && <Badge>{new Date(v.published_date).toLocaleDateString('cs-CZ')}</Badge>}
</HStack>
<FormControl mt={2}>
<FormLabel fontSize="xs" mb={1}>Přepis názvu (volitelné)</FormLabel>
<Input
size="sm"
placeholder="Např. Zápas A-týmu vs. B-tým"
value={(titleOverrides[v.video_id] ?? '')}
onChange={(e) => {
const val = e.target.value;
setTitleOverrides(prev => ({ ...prev, [v.video_id]: val }));
}}
/>
</FormControl>
{!!(titleOverrides[v.video_id]?.length) && (
<HStack justify="flex-end" mt={1}>
<Button size="xs" variant="ghost" onClick={() => setTitleOverrides(prev => { const n = { ...prev }; delete n[v.video_id]; return n; })}>Vymazat přepis</Button>
</HStack>
)}
</Box>
</VStack>
</Box>
+91 -38
View File
@@ -214,6 +214,7 @@ const ArticlesAdminPage = () => {
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 || '',
competition_code: c?.code || c?.id || '',
home: m.home || m.home_team || '',
away: m.away || m.away_team || '',
score: score
@@ -248,6 +249,7 @@ const ArticlesAdminPage = () => {
const [featSwitchLoading, setFeatSwitchLoading] = useState<boolean>(false);
const [competitions, setCompetitions] = useState<Array<{ code?: string; name: string }>>([]);
const [aliasesMap, setAliasesMap] = useState<Record<string, string>>({});
const [aliasesList, setAliasesList] = useState<Array<{ code: string; alias: string; original_name?: string }>>([]);
// Match link state
const [linkedMatchId, setLinkedMatchId] = useState<string>('');
const [linkedMatchTitle, setLinkedMatchTitle] = useState<string>('');
@@ -281,7 +283,51 @@ const ArticlesAdminPage = () => {
// If article has ID, update it as draft
if (data.id) {
try {
return await updateArticle(data.id, { ...data as any, published: false });
// Build safe minimal payload the backend expects
const attachmentsNorm = (() => {
const a: any = (data as any)?.attachments;
if (!Array.isArray(a) || a.length === 0) return undefined;
return a.map((it: any) => {
const name = it?.name || (String(it?.url || '').split('/').pop() || 'soubor');
const url = it?.url || '';
const mime_type = it?.mime_type || it?.type;
const size = typeof it?.size === 'number' ? it.size : undefined;
return { name, url, mime_type, size };
});
})();
const galleryIdsNorm = (() => {
const g: any = (data as any)?.gallery_photo_ids;
if (Array.isArray(g)) return g.map(String);
return undefined;
})();
const isPublished = !!(data as any)?.published;
const payload: UpdateArticlePayload = {
title: (data as any)?.title || '',
...(((typeof (data as any)?.content === 'string') && ((String((data as any)?.content || '').trim().length > 0) || !isPublished)) ? { content: (data as any)?.content || '' } : {}),
image_url: (data as any)?.image_url || '',
...(typeof (data as any)?.category_id === 'number' ? { category_id: (data as any).category_id } : {}),
category_name: (data as any)?.category_name || undefined,
slug: (data as any)?.slug || undefined,
seo_title: (data as any)?.seo_title || undefined,
seo_description: (data as any)?.seo_description || undefined,
og_image_url: (data as any)?.og_image_url || undefined,
featured: !!(data as any)?.featured,
// Gallery fields
gallery_album_id: (data as any)?.gallery_album_id || undefined,
gallery_album_url: (data as any)?.gallery_album_url || undefined,
...(galleryIdsNorm ? { gallery_photo_ids: galleryIdsNorm } : {}),
// YouTube fields
youtube_video_id: (data as any)?.youtube_video_id || undefined,
youtube_video_title: (data as any)?.youtube_video_title || undefined,
youtube_video_url: (data as any)?.youtube_video_url || undefined,
youtube_video_thumbnail: (data as any)?.youtube_video_thumbnail || undefined,
// Attachments
...(attachmentsNorm ? { attachments: attachmentsNorm } : {}),
} as UpdateArticlePayload;
return await updateArticle(data.id, payload);
} catch (e: any) {
const status = e?.response?.status;
if (status === 404 && data.title?.trim()) {
@@ -422,7 +468,7 @@ const ArticlesAdminPage = () => {
if (!q) return youtubeVideos;
return youtubeVideos.filter((video) => {
const title = (video.title || '').toLowerCase();
return title.includes(q) || video.video_id.toLowerCase().includes(q);
return title.includes(q) || String(video.video_id || '').toLowerCase().includes(q);
});
}, [youtubeVideos, youtubeSearch]);
@@ -586,48 +632,54 @@ const ArticlesAdminPage = () => {
const filteredMatchOptions = useMemo(() => {
let opts = matchOptions;
// Get category name and find all possible matches (including via aliases)
const cat = String((editing as any)?.category_name || '').trim().toLowerCase();
const normalize = (s?: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const toAlias = (compName?: string): string => {
const n = normalize(compName);
for (const a of aliasesList) {
const aAlias = normalize(a.alias);
const aOrig = normalize(a.original_name || '');
if ((aOrig && (n === aOrig || n.includes(aOrig) || aOrig.includes(n))) ||
(aAlias && (n === aAlias || n.includes(aAlias) || aAlias.includes(n)))) {
return a.alias;
}
}
return String(compName || '');
};
const catRaw = String((editing as any)?.category_name || '').trim();
const cat = normalize(catRaw);
if (cat) {
// Find matching competition codes from aliases
const matchingCodes = Object.entries(aliasesMap)
.filter(([code, alias]) => alias.toLowerCase() === cat)
.map(([code]) => code);
// Find matching competition names from competitions list
const matchingNames = competitions
.filter(c => c.name.toLowerCase() === cat)
.map(c => c.code)
.filter(Boolean) as string[];
const allCodes = [...new Set([...matchingCodes, ...matchingNames])];
// Filter matches by competition code or name
const selectedCodes = new Set<string>();
for (const a of aliasesList) {
const aAlias = normalize(a.alias);
const aOrig = normalize(a.original_name || '');
if ((aAlias && aAlias === cat) || (aOrig && aOrig === cat)) {
selectedCodes.add(a.code);
}
}
opts = opts.filter(o => {
const compName = (o.competition || '').toLowerCase();
// Match by alias, category name, or competition name
if (compName.includes(cat)) return true;
// Check if competition name matches any of our codes via reverse lookup
const compCode = Object.entries(aliasesMap).find(([code, alias]) =>
compName.includes(alias.toLowerCase())
)?.[0];
if (compCode && allCodes.includes(compCode)) return true;
// Direct code matching
return allCodes.some(code => {
const aliasForCode = aliasesMap[code] || '';
return compName.includes(code.toLowerCase()) || compName.includes(aliasForCode.toLowerCase());
});
const compName = String(o.competition || '');
const compNorm = normalize(compName);
const compAlias = normalize(toAlias(compName));
const code = String((o as any).competition_code || '');
const codeMatch = selectedCodes.size > 0 && code ? selectedCodes.has(code) : false;
return codeMatch || compNorm === cat || compAlias === cat || compNorm.includes(cat) || compAlias.includes(cat);
});
}
// Search filter
const q = matchSearch.trim().toLowerCase();
if (q) {
opts = opts.filter(o => o.label.toLowerCase().includes(q));
}
// Date filter
if (matchDateFilter) {
opts = opts.filter(o => {
const dateStr = o.date || '';
@@ -660,9 +712,9 @@ const ArticlesAdminPage = () => {
if (aUpcoming) return da - db;
return Math.abs(da) - Math.abs(db);
});
return opts;
}, [matchOptions, matchSearch, matchDateFilter, (editing as any)?.category_name, aliasesMap, competitions]);
}, [matchOptions, matchSearch, matchDateFilter, (editing as any)?.category_name, aliasesList]);
// Load club competitions + aliases for quick category pick
React.useEffect(() => {
@@ -683,6 +735,7 @@ const ArticlesAdminPage = () => {
try {
const list = await getCompetitionAliasesPublic();
list.forEach((a) => { if (a.code && a.alias) amap[a.code] = a.alias; });
setAliasesList(list as any);
} catch {}
// Apply aliases to names for display
const withAliases = comps.map((c) => ({ code: c.code, name: (c.code && amap[c.code]) ? amap[c.code] : c.name }));
@@ -3,14 +3,9 @@ import {
Box,
Button,
Flex,
FormControl,
FormLabel,
Heading,
HStack,
IconButton,
Input,
Select,
Spinner,
Table,
Tbody,
Td,
@@ -18,17 +13,12 @@ import {
Th,
Thead,
Tr,
useDisclosure,
useToast,
VStack,
Badge,
Tooltip,
Alert,
AlertIcon,
useColorModeValue,
Divider,
} from '@chakra-ui/react';
import { FiPlus, FiTrash2, FiSave, FiRefreshCcw, FiDownload, FiEdit3, FiMove } from 'react-icons/fi';
import { FiTrash2, FiSave, FiRefreshCcw, FiDownload, FiEdit3, FiMove } from 'react-icons/fi';
import {
CompetitionAlias,
getCompetitionAliasesAdmin,
@@ -42,13 +32,9 @@ import { API_URL } from '../../services/api';
const CompetitionAliasesAdminPage: React.FC = () => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.700');
const toast = useToast();
const [items, setItems] = useState<CompetitionAlias[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [newCode, setNewCode] = useState('');
const [newAlias, setNewAlias] = useState('');
const [editing, setEditing] = useState<Record<string, { alias: string }>>({});
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [reorderMode, setReorderMode] = useState<boolean>(false);
@@ -152,26 +138,6 @@ const CompetitionAliasesAdminPage: React.FC = () => {
}
};
const onAdd = async () => {
const code = newCode.trim();
const alias = newAlias.trim();
if (!code || !alias) {
toast({ title: 'Vyplňte code a alias', status: 'warning' });
return;
}
try {
const saved = await upsertCompetitionAlias(code, { alias });
setItems((prev) => {
const filtered = prev.filter((i) => i.code !== saved.code);
return [...filtered, saved].sort((a, b) => a.code.localeCompare(b.code));
});
setNewCode(''); setNewAlias('');
toast({ title: 'Alias uložen', status: 'success' });
} catch (e: any) {
toast({ title: 'Uložení selhalo', description: e?.message || 'Zkuste znovu', status: 'error' });
}
};
const onSave = async (code: string) => {
const data = editing[code];
if (!data) return;
@@ -408,71 +374,6 @@ const CompetitionAliasesAdminPage: React.FC = () => {
)}
</Flex>
{/* Add New Section - Hidden in reorder mode */}
{!reorderMode && (
<Box
bg={cardBg}
borderWidth="1px"
borderColor="gray.200"
borderRadius="lg"
p={6}
mb={6}
shadow="sm"
_hover={{ shadow: 'md' }}
transition="all 0.2s"
>
<HStack mb={4} spacing={2}>
<Box bg="blue.500" p={2} borderRadius="md">
<FiPlus color="white" size={18} />
</Box>
<Heading size="md" color="gray.700">Přidat nový alias</Heading>
</HStack>
<Divider mb={4} />
<Flex gap={3} wrap="wrap" align="flex-end">
<VStack align="flex-start" spacing={2} flex="0 0 240px">
<Text fontSize="sm" fontWeight="medium" color="gray.600">Kód soutěže</Text>
<Input
placeholder="např. A1A"
value={newCode}
onChange={(e) => setNewCode(e.target.value.toUpperCase())}
size="md"
bg={useColorModeValue('gray.50', 'gray.900')}
borderColor="gray.300"
_hover={{ borderColor: 'blue.400', bg: 'white' }}
_focus={{ borderColor: 'blue.500', bg: 'white', shadow: 'sm' }}
fontFamily="mono"
fontWeight="semibold"
/>
</VStack>
<VStack align="flex-start" spacing={2} flex="1" minW="300px">
<Text fontSize="sm" fontWeight="medium" color="gray.600">Zobrazovaný název (alias)</Text>
<Input
placeholder="např. Krajský přebor"
value={newAlias}
onChange={(e) => setNewAlias(e.target.value)}
size="md"
bg={useColorModeValue('gray.50', 'gray.900')}
borderColor="gray.300"
_hover={{ borderColor: 'blue.400', bg: 'white' }}
_focus={{ borderColor: 'blue.500', bg: 'white', shadow: 'sm' }}
/>
</VStack>
<Button
leftIcon={<FiPlus />}
onClick={onAdd}
colorScheme="blue"
size="md"
px={8}
shadow="sm"
_hover={{ shadow: 'md', transform: 'translateY(-1px)' }}
transition="all 0.2s"
>
Přidat
</Button>
</Flex>
</Box>
)}
{/* Table Section */}
<Box
bg={cardBg}
@@ -668,8 +569,8 @@ const CompetitionAliasesAdminPage: React.FC = () => {
Žádné aliasy zatím nejsou
</Text>
<Text color="gray.400" fontSize="sm">
Přidejte nový alias nebo importujte ze soutěží
</Text>
Importujte ze soutěží (FACR) pomocí tlačítka nahoře
</Text>
</VStack>
</Td>
</Tr>
@@ -52,8 +52,10 @@ import {
deleteContact,
Contact,
getContactCategories,
createContactCategory,
ContactCategory,
} from '../../services/contactInfo';
import { getCompetitionAliasesPublic } from '../../services/competitionAliases';
import { uploadImage } from '../../services/api';
import { getImageUrl } from '../../utils/imageUtils';
import { getAdminSettings, updateAdminSettings, AdminSettings, PublicSettings } from '../../services/settings';
@@ -116,6 +118,40 @@ const ContactsAdminPage: React.FC = () => {
setContacts(contactsData);
setCategories(categoriesData);
setFacrCompetitions(Array.isArray(facrData?.competitions) ? facrData!.competitions : []);
// Auto-seed contact categories from club competitions (with aliases) if none exist yet
if ((categoriesData || []).length === 0 && Array.isArray(facrData?.competitions) && facrData.competitions.length > 0) {
try {
const aliases = await getCompetitionAliasesPublic().catch(() => [] as Array<{ code?: string; alias?: string; original_name?: string }>);
const aliasMap: Record<string, string> = {};
(aliases || []).forEach((a: any) => { if (a?.code && a?.alias) aliasMap[String(a.code)] = String(a.alias); });
const namesSet = new Set<string>();
for (const c of facrData.competitions) {
const code = String(c?.code || '').trim();
const name = String(c?.name || c?.code || '').trim();
const display = code && aliasMap[code] ? aliasMap[code] : name;
if (display) namesSet.add(display);
}
const names = Array.from(namesSet);
if (names.length > 0) {
// Create categories sequentially to avoid overwhelming API
let order = 0;
for (const n of names) {
try {
await createContactCategory({ name: n, display_order: order, is_active: true });
order += 10;
} catch (e) {
// ignore duplicates or transient errors
}
}
const refreshed = await getContactCategories();
setCategories(refreshed);
toast({ title: 'Kategorie doplněny', description: 'Kategorie pro kontakty byly doplněny podle soutěží klubu.', status: 'success', duration: 3000 });
}
} catch (e: any) {
// Best-effort seeding; keep silent on failure
}
}
} catch (error) {
toast({
title: 'Chyba při načítání',
@@ -80,7 +80,7 @@ const EngagementAdminPage: React.FC = () => {
type: 'avatar_static',
cost_points: 50,
image_url: '',
stock: 0,
stock: -1,
active: true,
});
@@ -96,7 +96,7 @@ const EngagementAdminPage: React.FC = () => {
start_index: 1,
type: 'avatar_static' as string,
cost_points: 50,
stock: 0,
stock: -1,
active: true,
});
const batchModal = useDisclosure();
@@ -124,7 +124,7 @@ const EngagementAdminPage: React.FC = () => {
setTemplate(tpl);
switch (tpl) {
case 'avatar_upload_unlock':
setForm((prev) => ({ ...prev, type: 'avatar_upload_unlock', cost_points: 250, stock: 0, image_url: '' }));
setForm((prev) => ({ ...prev, type: 'avatar_upload_unlock', cost_points: 50, stock: -1, image_url: '' }));
break;
case 'avatar_animated_upload_unlock':
setForm((prev) => ({ ...prev, type: 'avatar_animated_upload_unlock', cost_points: 150, stock: 0 }));
@@ -194,7 +194,7 @@ const EngagementAdminPage: React.FC = () => {
return adminCreateReward({ ...form, metadata });
},
onSuccess: async () => {
setForm({ name: '', type: 'avatar_static', cost_points: 50, image_url: '', stock: 0, active: true });
setForm({ name: '', type: 'avatar_static', cost_points: 50, image_url: '', stock: -1, active: true });
await qc.invalidateQueries({ queryKey: ['admin-engagement-rewards'] });
toast({ status: 'success', title: 'Odměna vytvořena' });
},
@@ -320,7 +320,7 @@ const EngagementAdminPage: React.FC = () => {
<FormControl>
<FormLabel m={0} fontSize="sm">Šablona odměny</FormLabel>
<Select size="sm" maxW="280px" value={template} onChange={(e)=>applyTemplate(e.target.value)}>
<option value="avatar_upload_unlock">Odemknutí vlastního avataru (250b)</option>
<option value="avatar_upload_unlock">Odemknutí vlastního avataru (50b)</option>
<option value="avatar_animated_upload_unlock">Odemknutí animovaného avataru (150b)</option>
<option value="avatar_static_50">Avatar (statický) 50b</option>
<option value="merch_coupon_1000">Merch kupon (1000b)</option>
@@ -363,8 +363,8 @@ const EngagementAdminPage: React.FC = () => {
</FormControl>
<FormControl>
<FormLabel>Sklad</FormLabel>
<NumberInput value={form.stock} min={0} onChange={(_v, n) => setForm({ ...form, stock: Number.isFinite(n) ? n : 0 })}>
<NumberInputField placeholder="Ks (0 = neomezeně)" />
<NumberInput value={form.stock} min={-1} onChange={(_v, n) => setForm({ ...form, stock: Number.isFinite(n) ? n : -1 })}>
<NumberInputField placeholder="Ks (-1 = neomezeně, 0 = vyprodáno)" />
</NumberInput>
</FormControl>
</HStack>
@@ -484,18 +484,31 @@ const EngagementAdminPage: React.FC = () => {
</NumberInput>
</Td>
<Td>
<NumberInput size="sm" value={r.stock || 0} min={0} maxW="100px" onChange={(_v, n) => updateMut.mutate({ id: r.id, body: { stock: Number.isFinite(n) ? n : 0 } })}>
<NumberInput
size="sm"
value={r.stock ?? 0}
min={-1}
maxW="100px"
isDisabled={r.type === 'avatar_upload_unlock'}
onChange={(_v, n) => updateMut.mutate({ id: r.id, body: { stock: Number.isFinite(n) ? n : 0 } })}
>
<NumberInputField />
</NumberInput>
</Td>
<Td>{r.image_url ? <Image src={r.image_url} alt={r.name} boxSize="40px" objectFit="cover" borderRadius="md" /> : '-'}</Td>
<Td>
<Switch isChecked={!!r.active} onChange={(e) => updateMut.mutate({ id: r.id, body: { active: e.target.checked } })} />
<Switch
isChecked={!!r.active}
isDisabled={r.type === 'avatar_upload_unlock'}
onChange={(e) => updateMut.mutate({ id: r.id, body: { active: e.target.checked } })}
/>
</Td>
<Td>
<HStack>
<IconButton aria-label="Upravit" size="xs" icon={<FiEdit2 />} onClick={() => { setEditItem(r); setEditForm(r); setEditMeta(r.metadata || {}); editModal.onOpen(); }} />
<IconButton aria-label="Smazat" size="xs" icon={<FiTrash2 />} onClick={() => deleteMut.mutate(r.id)} />
{r.type !== 'avatar_upload_unlock' && (
<IconButton aria-label="Smazat" size="xs" icon={<FiTrash2 />} onClick={() => deleteMut.mutate(r.id)} />
)}
</HStack>
</Td>
</Tr>
@@ -574,11 +587,11 @@ const EngagementAdminPage: React.FC = () => {
<VStack align="stretch" spacing={3}>
<FormControl>
<FormLabel>Název</FormLabel>
<Input value={editForm.name || ''} onChange={(e)=>setEditForm({ ...editForm, name: e.target.value })} />
<Input value={editForm.name || ''} onChange={(e)=>setEditForm({ ...editForm, name: e.target.value })} isDisabled={editItem?.type === 'avatar_upload_unlock'} />
</FormControl>
<FormControl>
<FormLabel>Typ</FormLabel>
<Select value={editForm.type || ''} onChange={(e)=>setEditForm({ ...editForm, type: e.target.value })}>
<Select value={editForm.type || ''} onChange={(e)=>setEditForm({ ...editForm, type: e.target.value })} isDisabled={editItem?.type === 'avatar_upload_unlock'}>
<option value="avatar_static">Avatar (statický)</option>
<option value="avatar_animated">Avatar (animovaný)</option>
<option value="avatar_upload_unlock">Odemknutí vlastního avataru</option>
@@ -598,51 +611,51 @@ const EngagementAdminPage: React.FC = () => {
</FormControl>
<FormControl>
<FormLabel>Sklad</FormLabel>
<NumberInput value={Number(editForm.stock || 0)} min={0} onChange={(_v, n)=>setEditForm({ ...editForm, stock: Number.isFinite(n)? n : 0 })}>
<NumberInput value={Number(editForm.stock || 0)} min={-1} onChange={(_v, n)=>setEditForm({ ...editForm, stock: Number.isFinite(n)? n : 0 })} isDisabled={editItem?.type === 'avatar_upload_unlock'}>
<NumberInputField />
</NumberInput>
</FormControl>
</HStack>
<FormControl>
<FormLabel>Obrázek URL</FormLabel>
<Input value={editForm.image_url || ''} onChange={(e)=>setEditForm({ ...editForm, image_url: e.target.value })} />
<Input value={editForm.image_url || ''} onChange={(e)=>setEditForm({ ...editForm, image_url: e.target.value })} isDisabled={editItem?.type === 'avatar_upload_unlock'} />
</FormControl>
<HStack>
<input ref={editFileInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e)=>handleUploadEdit(e.target.files?.[0])} />
<Button size="sm" variant="outline" onClick={() => editFileInputRef.current?.click()}>Nahrát obrázek</Button>
<Button size="sm" variant="outline" onClick={() => editFileInputRef.current?.click()} isDisabled={editItem?.type === 'avatar_upload_unlock'}>Nahrát obrázek</Button>
</HStack>
{/* Edit metadata helpers (structured) */}
{ (editForm.type === 'merch_coupon' || editForm.type === 'merch_physical' || editForm.type === 'merch_digital' || editForm.type === 'custom') && (
<VStack align="stretch" spacing={2}>
{editForm.type === 'merch_coupon' && (
<>
<FormControl><FormLabel>Kód kuponu</FormLabel><Input value={(editMeta as any).coupon_code || ''} onChange={(e)=>setEditMetaField('coupon_code', e.target.value)} /></FormControl>
<FormControl><FormLabel>Platnost do</FormLabel><Input value={(editMeta as any).expires_at || ''} onChange={(e)=>setEditMetaField('expires_at', e.target.value)} /></FormControl>
<FormControl><FormLabel>Poznámka</FormLabel><Input value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
<FormControl><FormLabel>Kód kuponu</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).coupon_code || ''} onChange={(e)=>setEditMetaField('coupon_code', e.target.value)} /></FormControl>
<FormControl><FormLabel>Platnost do</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).expires_at || ''} onChange={(e)=>setEditMetaField('expires_at', e.target.value)} /></FormControl>
<FormControl><FormLabel>Poznámka</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
</>
)}
{editForm.type === 'merch_physical' && (
<>
<FormControl><FormLabel>SKU</FormLabel><Input value={(editMeta as any).sku || ''} onChange={(e)=>setEditMetaField('sku', e.target.value)} /></FormControl>
<FormControl><FormLabel>SKU</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).sku || ''} onChange={(e)=>setEditMetaField('sku', e.target.value)} /></FormControl>
<HStack>
<FormControl><FormLabel>Velikost</FormLabel><Input value={(editMeta as any).size || ''} onChange={(e)=>setEditMetaField('size', e.target.value)} /></FormControl>
<FormControl><FormLabel>Barva</FormLabel><Input value={(editMeta as any).color || ''} onChange={(e)=>setEditMetaField('color', e.target.value)} /></FormControl>
<FormControl><FormLabel>Velikost</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).size || ''} onChange={(e)=>setEditMetaField('size', e.target.value)} /></FormControl>
<FormControl><FormLabel>Barva</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).color || ''} onChange={(e)=>setEditMetaField('color', e.target.value)} /></FormControl>
</HStack>
<FormControl><FormLabel>Poznámka</FormLabel><Input value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
<FormControl><FormLabel>Poznámka</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
</>
)}
{editForm.type === 'merch_digital' && (
<>
<FormControl><FormLabel>Licenční klíč</FormLabel><Input value={(editMeta as any).license_key || ''} onChange={(e)=>setEditMetaField('license_key', e.target.value)} /></FormControl>
<FormControl><FormLabel>Stažení (URL)</FormLabel><Input value={(editMeta as any).download_url || ''} onChange={(e)=>setEditMetaField('download_url', e.target.value)} /></FormControl>
<FormControl><FormLabel>Poznámka</FormLabel><Input value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
<FormControl><FormLabel>Licenční klíč</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).license_key || ''} onChange={(e)=>setEditMetaField('license_key', e.target.value)} /></FormControl>
<FormControl><FormLabel>Stažení (URL)</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).download_url || ''} onChange={(e)=>setEditMetaField('download_url', e.target.value)} /></FormControl>
<FormControl><FormLabel>Poznámka</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
</>
)}
{editForm.type === 'custom' && (
<HStack>
<Input placeholder="klíč" id="edit-kv-key" />
<Input placeholder="hodnota" id="edit-kv-value" />
<Button size="sm" onClick={()=>{
<Input placeholder="klíč" id="edit-kv-key" isDisabled={editItem?.type === 'avatar_upload_unlock'} />
<Input placeholder="hodnota" id="edit-kv-value" isDisabled={editItem?.type === 'avatar_upload_unlock'} />
<Button size="sm" isDisabled={editItem?.type === 'avatar_upload_unlock'} onClick={()=>{
const k = (document.getElementById('edit-kv-key') as HTMLInputElement)?.value?.trim();
const v = (document.getElementById('edit-kv-value') as HTMLInputElement)?.value?.trim();
if (!k) return;
@@ -655,7 +668,7 @@ const EngagementAdminPage: React.FC = () => {
{/* Odstraněno: ruční JSON metadata v editoru. */}
<HStack>
<Text>Aktivní</Text>
<Switch isChecked={!!editForm.active} onChange={(e)=>setEditForm({ ...editForm, active: e.target.checked })} />
<Switch isChecked={!!editForm.active} onChange={(e)=>setEditForm({ ...editForm, active: e.target.checked })} isDisabled={editItem?.type === 'avatar_upload_unlock'} />
{editForm.image_url ? <Image src={editForm.image_url} alt={String(editForm.name || '')} boxSize="56px" objectFit="cover" borderRadius="md" /> : null}
</HStack>
</VStack>
@@ -665,16 +678,20 @@ const EngagementAdminPage: React.FC = () => {
<Button onClick={editModal.onClose}>Zrušit</Button>
<Button colorScheme="blue" isLoading={updateMut.isPending} onClick={async ()=>{
if (!editItem) return;
const metadata: Record<string, any> | undefined = Object.keys(editMeta || {}).length ? (editMeta as any) : {} as any;
await updateMut.mutateAsync({ id: editItem.id, body: {
name: editForm.name,
type: editForm.type,
cost_points: editForm.cost_points as any,
stock: editForm.stock as any,
image_url: editForm.image_url,
active: editForm.active as any,
metadata: metadata as any,
} as any });
if (editItem.type === 'avatar_upload_unlock') {
await updateMut.mutateAsync({ id: editItem.id, body: { cost_points: editForm.cost_points as any } });
} else {
const metadata: Record<string, any> | undefined = Object.keys(editMeta || {}).length ? (editMeta as any) : {} as any;
await updateMut.mutateAsync({ id: editItem.id, body: {
name: editForm.name,
type: editForm.type,
cost_points: editForm.cost_points as any,
stock: editForm.stock as any,
image_url: editForm.image_url,
active: editForm.active as any,
metadata: metadata as any,
} as any });
}
editModal.onClose();
}}>Uložit</Button>
</HStack>
@@ -733,7 +750,7 @@ const EngagementAdminPage: React.FC = () => {
<HStack>
<FormControl>
<FormLabel>Sklad</FormLabel>
<NumberInput min={0} value={batch.stock} onChange={(_v,n)=>setBatch({ ...batch, stock: Number.isFinite(n)? n : 0 })}>
<NumberInput min={-1} value={batch.stock} onChange={(_v,n)=>setBatch({ ...batch, stock: Number.isFinite(n)? n : -1 })}>
<NumberInputField />
</NumberInput>
</FormControl>
@@ -0,0 +1,302 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Box,
Heading,
HStack,
VStack,
Input,
Select,
Button,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Text,
Badge,
useColorModeValue,
Spinner,
Checkbox,
useDisclosure,
Drawer,
DrawerOverlay,
DrawerContent,
DrawerHeader,
DrawerBody,
Code,
IconButton,
} from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { ErrorEvent, ErrorListResponse, getError, getErrors, getExternalError, getExternalErrors } from '../../services/errors';
import { RepeatIcon } from '@chakra-ui/icons';
import { getAdminSettings } from '../../services/settings';
const useAutoRefresh = (enabled: boolean, tickMs: number, onTick: () => void) => {
useEffect(() => {
if (!enabled) return;
const id = setInterval(onTick, tickMs);
return () => clearInterval(id);
}, [enabled, tickMs, onTick]);
};
const Row: React.FC<{ ev: ErrorEvent; onOpenDetail: (id: number) => void }> = ({ ev, onOpenDetail }) => {
const color = ev.severity === 'fatal' ? 'red' : ev.severity === 'warn' ? 'yellow' : 'gray';
let tags: any = (ev as any).tags;
try { if (typeof tags === 'string') tags = JSON.parse(tags); } catch {}
const isSupport = tags && tags.type === 'support';
return (
<Tr _hover={{ bg: useColorModeValue('gray.50', 'gray.700'), cursor: 'pointer' }} onClick={() => onOpenDetail(ev.id)}>
<Td whiteSpace="nowrap">{new Date(ev.occurred_at || ev.created_at).toLocaleString()}</Td>
<Td><Badge colorScheme={color}>{ev.severity || 'error'}</Badge></Td>
<Td>{ev.origin}</Td>
<Td>
<Text noOfLines={1} maxW="420px" title={ev.message}>{ev.message}</Text>
</Td>
<Td>{ev.method}</Td>
<Td><Text noOfLines={1} maxW="260px" title={ev.url}>{ev.url}</Text></Td>
<Td>{ev.status || ''}</Td>
<Td>
<HStack spacing={1} onClick={(e) => e.stopPropagation()}>
<Code fontSize="xs">{ev.request_id || ''}</Code>
{ev.request_id ? (
<Button size="xs" variant="ghost" onClick={async () => { try { await navigator.clipboard.writeText(ev.request_id || ''); } catch {} }}>Kopírovat</Button>
) : null}
</HStack>
</Td>
<Td>{isSupport ? <Badge colorScheme="purple">Podpora</Badge> : null}</Td>
</Tr>
);
};
const ErrorsAdminPage: React.FC = () => {
const [origin, setOrigin] = useState('');
const [severity, setSeverity] = useState('');
const [method, setMethod] = useState('');
const [status, setStatus] = useState('');
const [search, setSearch] = useState('');
const [from, setFrom] = useState('');
const [to, setTo] = useState('');
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(20);
const [autoRefresh, setAutoRefresh] = useState(true);
const [selectedId, setSelectedId] = useState<number | null>(null);
const detail = useDisclosure();
const [useExternal, setUseExternal] = useState(false);
const [extUI, setExtUI] = useState<string>('');
const [supportOnly, setSupportOnly] = useState(false);
const effectiveSearch = useMemo(() => supportOnly ? ((search && search.trim()) ? `Support: ${search.trim()}` : 'Support:') : search, [supportOnly, search]);
const effectiveSeverity = useMemo(() => supportOnly ? (severity || 'warn') : severity, [supportOnly, severity]);
const effectiveOrigin = useMemo(() => supportOnly ? (origin || 'frontend') : origin, [supportOnly, origin]);
const params = useMemo(() => ({ origin: effectiveOrigin, severity: effectiveSeverity, method, status: status || undefined, search: effectiveSearch, from, to, page, limit }), [effectiveOrigin, effectiveSeverity, method, status, effectiveSearch, from, to, page, limit]);
const query = useQuery<ErrorListResponse>({
queryKey: ['admin', 'errors', useExternal ? 'external' : 'local', params],
queryFn: () => (useExternal ? getExternalErrors(params) : getErrors(params)),
keepPreviousData: true,
staleTime: 10_000,
});
useAutoRefresh(autoRefresh, 10_000, () => query.refetch());
const [detailData, setDetailData] = useState<ErrorEvent | null>(null);
useEffect(() => {
if (!detail.isOpen || selectedId == null) return;
let cancelled = false;
(async () => {
try {
const d = useExternal ? await getExternalError(selectedId) : await getError(selectedId);
if (!cancelled) setDetailData(d);
} catch {}
})();
return () => { cancelled = true; };
}, [detail.isOpen, selectedId, useExternal]);
useEffect(() => {
(async () => {
try {
const s = await getAdminSettings();
const u = (s as any)?.error_review_ui_url;
if (u) setExtUI(u);
} catch {}
})();
}, []);
const bg = useColorModeValue('white', 'gray.800');
const border = useColorModeValue('gray.200', 'gray.700');
const detailCtxBoxBg = useColorModeValue('gray.50','gray.700');
const detailCtxBorder = useColorModeValue('gray.200','gray.600');
return (
<AdminLayout>
<Box maxW="1400px" mx="auto">
<HStack justify="space-between" mb={6} align="center">
<Heading size="lg">Chyby a výjimky</Heading>
<HStack>
<Checkbox isChecked={autoRefresh} onChange={(e) => setAutoRefresh(e.target.checked)}>Autorefresh</Checkbox>
<IconButton aria-label="Refresh" icon={<RepeatIcon />} size="sm" onClick={() => query.refetch()} isLoading={query.isRefetching} />
<Checkbox isChecked={useExternal} onChange={(e) => setUseExternal(e.target.checked)}>Externí zdroj</Checkbox>
{extUI ? (
<Button as="a" href={extUI} target="_blank" rel="noreferrer" size="sm" variant="outline">Konzole</Button>
) : null}
</HStack>
</HStack>
<VStack align="stretch" spacing={4} mb={4}>
<HStack>
<Select placeholder="Původ (origin)" value={origin} onChange={(e) => setOrigin(e.target.value)} maxW="220px">
<option value="frontend">frontend</option>
<option value="backend">backend</option>
<option value="docker">docker</option>
</Select>
<Select placeholder="Závažnost" value={severity} onChange={(e) => setSeverity(e.target.value)} maxW="200px">
<option value="fatal">fatal</option>
<option value="error">error</option>
<option value="warn">warn</option>
</Select>
<Select placeholder="Metoda" value={method} onChange={(e) => setMethod(e.target.value)} maxW="160px">
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
<option value="DELETE">DELETE</option>
</Select>
<Input placeholder="Status" value={status} onChange={(e) => setStatus(e.target.value)} maxW="120px" />
<Input placeholder="Hledat zpráva/stack/url" value={search} onChange={(e) => setSearch(e.target.value)} />
</HStack>
<HStack>
<Input type="datetime-local" placeholder="Od" value={from} onChange={(e) => setFrom(e.target.value)} maxW="240px" />
<Input type="datetime-local" placeholder="Do" value={to} onChange={(e) => setTo(e.target.value)} maxW="240px" />
<Select value={limit} onChange={(e) => setLimit(parseInt(e.target.value || '20', 10))} maxW="140px">
{[20,50,100,200].map(n => <option key={n} value={n}>{n}/strana</option>)}
</Select>
<HStack>
<Button size="sm" onClick={() => { setPage(1); query.refetch(); }}>Filtr</Button>
<Button size="sm" variant="outline" onClick={() => { setOrigin(''); setSeverity(''); setMethod(''); setStatus(''); setSearch(''); setFrom(''); setTo(''); setPage(1); setSupportOnly(false); }}>Reset</Button>
<Checkbox isChecked={supportOnly} onChange={(e) => setSupportOnly(e.target.checked)}>Pouze podpora</Checkbox>
</HStack>
</HStack>
</VStack>
<Box bg={bg} borderWidth="1px" borderColor={border} borderRadius="lg" overflow="hidden">
<Table size="sm">
<Thead>
<Tr>
<Th>Čas</Th>
<Th>Sev.</Th>
<Th>Původ</Th>
<Th>Zpráva</Th>
<Th>Metoda</Th>
<Th>URL</Th>
<Th>Status</Th>
<Th>Request ID</Th>
<Th>Tagy</Th>
</Tr>
</Thead>
<Tbody>
{query.isLoading ? (
<Tr><Td colSpan={9}><HStack><Spinner size="sm" /><Text>Načítám...</Text></HStack></Td></Tr>
) : (
query.data?.items?.length ? (
query.data.items.map(ev => <Row key={ev.id} ev={ev} onOpenDetail={(id) => { setSelectedId(id); detail.onOpen(); }} />)
) : (
<Tr><Td colSpan={9}><Text color="gray.500">Žádné chyby</Text></Td></Tr>
)
)}
</Tbody>
</Table>
</Box>
<HStack justify="space-between" mt={4}>
<Text color="gray.500">Celkem: {query.data?.total ?? 0}</Text>
<HStack>
<Button size="sm" onClick={() => setPage(p => Math.max(1, p-1))} isDisabled={page === 1}>Předchozí</Button>
<Text>Strana {page}</Text>
<Button size="sm" onClick={() => setPage(p => p + 1)} isDisabled={(query.data?.items?.length || 0) < limit}>Další</Button>
</HStack>
</HStack>
<HStack mt={3} spacing={2} wrap="wrap">
<Text fontSize="sm" color="gray.500">Rychlé filtry:</Text>
<Button size="xs" variant="outline" onClick={() => {
const now = new Date();
const start = new Date(); start.setHours(0,0,0,0);
setFrom(start.toISOString().slice(0,16));
setTo(now.toISOString().slice(0,16));
setPage(1);
}}>Dnes</Button>
<Button size="xs" variant="outline" onClick={() => {
const now = new Date();
const past = new Date(now.getTime() - 24*60*60*1000);
setFrom(past.toISOString().slice(0,16));
setTo(now.toISOString().slice(0,16));
setPage(1);
}}>24 h</Button>
<Button size="xs" variant="outline" onClick={() => {
const now = new Date();
const past = new Date(now.getTime() - 7*24*60*60*1000);
setFrom(past.toISOString().slice(0,16));
setTo(now.toISOString().slice(0,16));
setPage(1);
}}>7 dní</Button>
<Button size="xs" variant="ghost" onClick={() => { setFrom(''); setTo(''); }}>Vymazat</Button>
</HStack>
<Drawer isOpen={detail.isOpen} placement="right" onClose={detail.onClose} size="lg">
<DrawerOverlay />
<DrawerContent>
<DrawerHeader>Detail chyby</DrawerHeader>
<DrawerBody>
{selectedId == null ? null : detailData ? (
<VStack align="stretch" spacing={3}>
<HStack><Text fontWeight="bold">Čas:</Text><Text>{new Date(detailData.occurred_at || detailData.created_at).toLocaleString()}</Text></HStack>
<HStack><Text fontWeight="bold">Původ:</Text><Text>{detailData.origin}</Text></HStack>
<HStack><Text fontWeight="bold">Závažnost:</Text><Badge>{detailData.severity || 'error'}</Badge></HStack>
<HStack><Text fontWeight="bold">URL:</Text><Text>{detailData.method} {detailData.url}</Text></HStack>
<HStack><Text fontWeight="bold">Status:</Text><Text>{detailData.status || ''}</Text></HStack>
<HStack><Text fontWeight="bold">Request ID:</Text><Code>{detailData.request_id || ''}</Code></HStack>
{(() => {
let tags: any = (detailData as any).tags; try { if (typeof tags === 'string') tags = JSON.parse(tags); } catch {}
if (!tags) return null;
return <HStack><Text fontWeight="bold">Tagy:</Text>{Object.entries(tags).map(([k,v]) => <Badge key={k} colorScheme={k==='type'&&v==='support'?'purple':'gray'}>{String(k)}={String(v)}</Badge>)}</HStack>;
})()}
{(() => {
let ctx: any = (detailData as any).context; try { if (typeof ctx === 'string') ctx = JSON.parse(ctx); } catch {}
const ra = ctx?.recentActions;
if (!ra) return null;
return (
<Box>
<Text fontWeight="bold" mb={1}>Poslední akce</Text>
<Box bg={detailCtxBoxBg} borderWidth="1px" borderColor={detailCtxBorder} borderRadius="md" p={2} maxH="220px" overflowY="auto">
{(Array.isArray(ra) ? ra : []).map((a: any, i: number) => (
<Text key={i} fontFamily="mono" fontSize="xs">{new Date((a.at)||Date.now()).toLocaleTimeString()} {a.type === 'nav' ? `NAV ${a.path}` : `${(a.method||'').toUpperCase()} ${a.url} ${a.status ?? ''} ${a.ms ? a.ms+'ms' : ''}`}</Text>
))}
</Box>
</Box>
);
})()}
<Box>
<Text fontWeight="bold" mb={1}>Zpráva</Text>
<Code whiteSpace="pre-wrap" width="100%" p={2}>{detailData.message}</Code>
</Box>
{detailData.stack ? (
<Box>
<Text fontWeight="bold" mb={1}>Stack</Text>
<Code whiteSpace="pre" width="100%" p={2} display="block">{detailData.stack}</Code>
</Box>
) : null}
</VStack>
) : (
<HStack><Spinner size="sm" /><Text>Načítání</Text></HStack>
)}
</DrawerBody>
</DrawerContent>
</Drawer>
</Box>
</AdminLayout>
);
};
export default ErrorsAdminPage;
+113 -11
View File
@@ -23,8 +23,19 @@ import {
AlertTitle,
AlertDescription,
useColorModeValue,
Input,
FormControl,
FormLabel,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
useDisclosure,
} from '@chakra-ui/react';
import { RefreshCw, ExternalLink, Calendar, Image as ImageIcon, Eye } from 'lucide-react';
import { RefreshCw, ExternalLink, Calendar, Image as ImageIcon, Eye, Plus } from 'lucide-react';
import AdminLayout from '../../layouts/AdminLayout';
import api from '../../services/api';
@@ -66,6 +77,10 @@ const GalleryAdminPage: React.FC = () => {
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string>('');
const toast = useToast();
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure();
const [albumUrl, setAlbumUrl] = useState<string>('');
const [photoLimit, setPhotoLimit] = useState<number>(50);
const [adding, setAdding] = useState<boolean>(false);
const fetchAlbums = async () => {
setLoading(true);
@@ -145,6 +160,46 @@ const GalleryAdminPage: React.FC = () => {
}
};
const handleAddAlbum = async () => {
const link = albumUrl.trim();
if (!link || !link.includes('/Album/')) {
toast({
title: 'Neplatný odkaz',
description: 'URL musí obsahovat "/Album/"',
status: 'error',
duration: 4000,
isClosable: true,
});
return;
}
setAdding(true);
try {
const limit = Number.isFinite(photoLimit) && photoLimit > 0 ? photoLimit : 50;
await api.post('/admin/gallery/albums/fetch', { link, photo_limit: limit });
toast({
title: 'Album přidáno',
description: 'Album bylo načteno a uloženo.',
status: 'success',
duration: 3000,
isClosable: true,
});
onAddClose();
setAlbumUrl('');
await fetchAlbums();
} catch (err: any) {
const errorMessage = err?.response?.data?.error || err?.message || 'Nepodařilo se přidat album';
toast({
title: 'Chyba při přidání alba',
description: errorMessage,
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setAdding(false);
}
};
useEffect(() => {
fetchAlbums();
}, []);
@@ -164,16 +219,25 @@ const GalleryAdminPage: React.FC = () => {
Správa alb a fotografií ze Zonerama
</Text>
</VStack>
<Button
leftIcon={<RefreshCw size={18} />}
colorScheme="blue"
onClick={handleRefresh}
isLoading={refreshing}
loadingText="Obnova..."
>
Obnovit z Zonerama
</Button>
<HStack spacing={3}>
<Button
leftIcon={<Plus size={18} />}
colorScheme="green"
onClick={onAddOpen}
>
Přidat album
</Button>
<Button
leftIcon={<RefreshCw size={18} />}
colorScheme="blue"
onClick={handleRefresh}
isLoading={refreshing}
loadingText="Obnova..."
>
Obnovit z Zonerama
</Button>
</HStack>
</HStack>
{/* Zonerama Info */}
@@ -352,6 +416,44 @@ const GalleryAdminPage: React.FC = () => {
</Table>
</Box>
)}
<Modal isOpen={isAddOpen} onClose={onAddClose} size="lg">
<ModalOverlay />
<ModalContent>
<ModalHeader>Přidat Zonerama album</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={4}>
<FormControl>
<FormLabel>URL Zonerama alba</FormLabel>
<Input
placeholder="https://eu.zonerama.com/…/Album/12345"
value={albumUrl}
onChange={(e) => setAlbumUrl(e.target.value)}
/>
</FormControl>
<FormControl>
<FormLabel>Limit fotek</FormLabel>
<Input
type="number"
min={1}
max={200}
value={String(photoLimit)}
onChange={(e) => {
const v = parseInt(e.target.value || '0', 10);
setPhotoLimit(Number.isFinite(v) ? v : 50);
}}
/>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<HStack spacing={3}>
<Button variant="ghost" onClick={onAddClose}>Zrušit</Button>
<Button colorScheme="blue" onClick={handleAddAlbum} isLoading={adding}>Načíst album</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
</VStack>
</Container>
</AdminLayout>
@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Box, Button, Center, HStack, Heading, Image, SimpleGrid, Text, useColorModeValue, useToast, VStack } from '@chakra-ui/react';
import AdminLayout from '@/layouts/AdminLayout';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { getAdminScoreboard, updateAdminScoreboard, ScoreboardState } from '@/services/scoreboard';
import { getAdminScoreboard, updateAdminScoreboard, ScoreboardState, startTimer, pauseTimer, resetTimer } from '@/services/scoreboard';
const MobileScoreboardControlPage: React.FC = () => {
const toast = useToast();
@@ -13,8 +13,8 @@ const MobileScoreboardControlPage: React.FC = () => {
const { data: state, isLoading } = useQuery<ScoreboardState>({
queryKey: ['admin-scoreboard-mobile'],
queryFn: getAdminScoreboard,
refetchInterval: 5000,
staleTime: 3000,
refetchInterval: 1000,
staleTime: 500,
});
const setPartial = async (patch: Partial<ScoreboardState>) => {
@@ -26,30 +26,25 @@ const MobileScoreboardControlPage: React.FC = () => {
}
};
// Simple local match timer (upwards). Does not persist to backend; overlay remains score-only.
const [running, setRunning] = useState(false);
const [elapsed, setElapsed] = useState(0); // seconds
const startRef = useRef<number | null>(null);
useEffect(() => {
let raf: number;
const tick = () => {
if (running) {
const now = Date.now();
const base = startRef.current ?? now;
startRef.current = base;
setElapsed(Math.floor((now - base) / 1000));
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [running]);
const resetTimer = () => { setRunning(false); setElapsed(0); startRef.current = null; };
// Use backend timer; control via API and reflect state via polling
const handleStartTimer = async () => {
await startTimer();
await qc.invalidateQueries({ queryKey: ['admin-scoreboard-mobile'] });
};
const handlePauseTimer = async () => {
await pauseTimer();
await qc.invalidateQueries({ queryKey: ['admin-scoreboard-mobile'] });
};
const handleResetTimer = async () => {
await resetTimer();
await qc.invalidateQueries({ queryKey: ['admin-scoreboard-mobile'] });
};
const mmss = useMemo(() => {
const mm = Math.floor(elapsed / 60);
const ss = elapsed % 60;
return `${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
}, [elapsed]);
return state?.timer || '00:00';
}, [state?.timer]);
if (isLoading || !state) {
return (
@@ -82,8 +77,8 @@ const MobileScoreboardControlPage: React.FC = () => {
<VStack spacing={2}>
<Text fontSize="5xl" fontWeight="black">{state.homeScore} : {state.awayScore}</Text>
<HStack>
<Button onClick={() => setRunning((r) => !r)}>{running ? 'Stop' : 'Start'}</Button>
<Button variant="outline" onClick={resetTimer}>Reset</Button>
<Button onClick={() => (state.running ? handlePauseTimer() : handleStartTimer())}>{state.running ? 'Stop' : 'Start'}</Button>
<Button variant="outline" onClick={handleResetTimer}>Reset</Button>
</HStack>
<Text fontSize="2xl" fontFamily="mono">{mmss}</Text>
</VStack>
@@ -103,17 +98,7 @@ const MobileScoreboardControlPage: React.FC = () => {
</SimpleGrid>
</Box>
<Box borderWidth="1px" borderColor={borderCol} bg={cardBg} borderRadius="lg" p={3}>
<HStack justify="space-between">
<Text>Vybraný zápas</Text>
<Text fontWeight="bold">{state.externalMatchId ? state.externalMatchId : '—'}</Text>
</HStack>
<HStack mt={2} spacing={2}>
<Button onClick={() => setPartial({ active: true })} colorScheme="blue">Aktivovat</Button>
<Button variant="outline" onClick={() => setPartial({ active: false })}>Deaktivovat</Button>
<Button variant="ghost" onClick={() => setPartial({ homeScore: 0, awayScore: 0 })}>Reset skóre</Button>
</HStack>
</Box>
{/* Removed 'Vybraný zápas' section for remote managed on main Tabule page */}
</VStack>
</Box>
</AdminLayout>
+170 -29
View File
@@ -201,8 +201,8 @@ interface NavItemCardProps {
total: number;
onMoveUp: () => void;
onMoveDown: () => void;
onEdit: () => void;
onDelete: () => void;
onEdit?: () => void;
onDelete?: () => void;
onAddChild: () => void;
isExpanded: boolean;
onToggleExpand: () => void;
@@ -212,6 +212,11 @@ interface NavItemCardProps {
level?: number;
onChildMoveUp?: (parentId: number, index: number) => void;
onChildMoveDown?: (parentId: number, index: number) => void;
onToggleVisible: (item: NavigationItem) => void;
childrenDroppableId?: string;
draggableChildPrefix?: string;
onEditTarget?: (item: NavigationItem) => void;
onDeleteTarget?: (item: NavigationItem) => void;
}
const NavItemCard: React.FC<NavItemCardProps> = ({
@@ -231,6 +236,11 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
level = 0,
onChildMoveUp,
onChildMoveDown,
onToggleVisible,
childrenDroppableId,
draggableChildPrefix,
onEditTarget,
onDeleteTarget,
}) => {
const hasChildren = item.children && item.children.length > 0;
const indentPx = level * 32;
@@ -321,6 +331,15 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
{/* Action buttons */}
<HStack spacing={1}>
<Tooltip label={item.visible ? 'Skrýt' : 'Zobrazit'}>
<IconButton
aria-label={item.visible ? 'Skrýt' : 'Zobrazit'}
icon={item.visible ? <ViewOffIcon /> : <ViewIcon />}
size="sm"
variant="ghost"
onClick={() => onToggleVisible(item)}
/>
</Tooltip>
{item.type === 'dropdown' && (
<Tooltip label="Přidat podpoložku">
<IconButton
@@ -339,7 +358,7 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
icon={<EditIcon />}
size="sm"
variant="ghost"
onClick={onEdit}
onClick={() => (typeof onEditTarget === 'function' ? onEditTarget(item) : onEdit && onEdit())}
/>
</Tooltip>
<Tooltip label="Smazat">
@@ -349,7 +368,7 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
size="sm"
colorScheme="red"
variant="ghost"
onClick={onDelete}
onClick={() => (typeof onDeleteTarget === 'function' ? onDeleteTarget(item) : onDelete && onDelete())}
/>
</Tooltip>
</HStack>
@@ -357,31 +376,45 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
</CardBody>
</Card>
{/* Render children if expanded */}
{/* Render children with nested DnD if expanded */}
{hasChildren && isExpanded && (
<VStack spacing={2} align="stretch" mt={2}>
{item.children!.map((child, childIndex) => (
<NavItemCard
key={child.id}
item={child}
index={childIndex}
total={item.children!.length}
onMoveUp={() => onChildMoveUp && onChildMoveUp(item.id!, childIndex)}
onMoveDown={() => onChildMoveDown && onChildMoveDown(item.id!, childIndex)}
onEdit={() => onEdit()}
onDelete={() => onDelete()}
onAddChild={() => {}}
isExpanded={false}
onToggleExpand={() => {}}
cardBg={cardBg}
borderColor={borderColor}
hoverBg={hoverBg}
level={level + 1}
onChildMoveUp={onChildMoveUp}
onChildMoveDown={onChildMoveDown}
/>
))}
</VStack>
<Droppable droppableId={childrenDroppableId || `children-${item.id}`}>
{(provided) => (
<VStack spacing={2} align="stretch" mt={2} ref={provided.innerRef} {...provided.droppableProps}>
{item.children!.map((child, childIndex) => (
<Draggable key={String(child.id)} draggableId={`${(draggableChildPrefix || 'child')}-${child.id}`} index={childIndex}>
{(dragProvided) => (
<Box ref={dragProvided.innerRef} {...dragProvided.draggableProps} {...dragProvided.dragHandleProps}>
<NavItemCard
key={child.id}
item={child}
index={childIndex}
total={item.children!.length}
onMoveUp={() => onChildMoveUp && onChildMoveUp(item.id!, childIndex)}
onMoveDown={() => onChildMoveDown && onChildMoveDown(item.id!, childIndex)}
onEdit={() => (typeof onEditTarget === 'function' ? onEditTarget(child) : onEdit && onEdit())}
onDelete={() => (typeof onDeleteTarget === 'function' ? onDeleteTarget(child) : onDelete && onDelete())}
onAddChild={() => {}}
isExpanded={false}
onToggleExpand={() => {}}
cardBg={cardBg}
borderColor={borderColor}
hoverBg={hoverBg}
level={level + 1}
onChildMoveUp={onChildMoveUp}
onChildMoveDown={onChildMoveDown}
onToggleVisible={onToggleVisible}
onEditTarget={onEditTarget}
onDeleteTarget={onDeleteTarget}
/>
</Box>
)}
</Draggable>
))}
{provided.placeholder}
</VStack>
)}
</Droppable>
)}
</Box>
);
@@ -467,9 +500,29 @@ const NavigationAdminPage = () => {
}
};
const toggleVisible = async (target: NavigationItem) => {
const newVisible = !target.visible;
try {
await updateNavigationItem(target.id!, { visible: newVisible } as any);
const applyToggle = (list: NavigationItem[]): NavigationItem[] =>
(list || []).map((it) => {
if (it.id === target.id) return { ...it, visible: newVisible } as NavigationItem;
const children = it.children ? applyToggle(it.children) : it.children;
return { ...it, children } as NavigationItem;
});
setNavItems((prev) => applyToggle(prev));
setAdminNavItems((prev) => applyToggle(prev));
toast({ title: newVisible ? 'Zobrazeno' : 'Skryto', status: 'success', duration: 1500 });
} catch (e) {
toast({ title: 'Chyba při změně viditelnosti', status: 'error', duration: 3000 });
}
};
const onDragEnd = async (result: DropResult) => {
if (!result.destination) return;
const { source, destination } = result;
const parseAdminChildrenId = (id: string) => id.startsWith('admin-children-') ? parseInt(id.replace('admin-children-', ''), 10) : null;
if (source.droppableId === 'frontend-nav') {
const items = Array.from(navItems);
const [moved] = items.splice(source.index, 1);
@@ -483,7 +536,7 @@ const NavigationAdminPage = () => {
toast({ title: 'Chyba při aktualizaci pořadí', status: 'error', duration: 3000 });
loadData();
}
} else if (source.droppableId === 'admin-nav') {
} else if (source.droppableId === 'admin-nav' && destination.droppableId === 'admin-nav') {
const items = Array.from(adminNavItems);
const [moved] = items.splice(source.index, 1);
items.splice(destination.index, 0, moved);
@@ -496,6 +549,86 @@ const NavigationAdminPage = () => {
toast({ title: 'Chyba při aktualizaci pořadí', status: 'error', duration: 3000 });
loadData();
}
} else if (
source.droppableId.startsWith('admin-children-') || destination.droppableId.startsWith('admin-children-') ||
(source.droppableId === 'admin-nav' && destination.droppableId.startsWith('admin-children-')) ||
(source.droppableId.startsWith('admin-children-') && destination.droppableId === 'admin-nav')
) {
const srcParentId = parseAdminChildrenId(source.droppableId);
const destParentId = parseAdminChildrenId(destination.droppableId);
const items = Array.from(adminNavItems);
// Helper to find parent index by id
const findParentIndex = (pid: number | null) => {
if (pid === null) return -1;
return items.findIndex((it) => it.id === pid);
};
let moved: NavigationItem | null = null;
// Remove from source list
if (srcParentId === null) {
const [m] = items.splice(source.index, 1);
moved = m;
} else {
const pIdx = findParentIndex(srcParentId);
if (pIdx >= 0) {
const srcChildren = Array.isArray(items[pIdx].children) ? Array.from(items[pIdx].children!) : [];
const [m] = srcChildren.splice(source.index, 1);
moved = m;
items[pIdx] = { ...items[pIdx], children: srcChildren } as NavigationItem;
}
}
if (!moved) return;
// Insert into destination list
if (destParentId === null) {
items.splice(destination.index, 0, moved);
} else {
const dIdx = findParentIndex(destParentId);
if (dIdx >= 0) {
const destChildren = Array.isArray(items[dIdx].children) ? Array.from(items[dIdx].children!) : [];
destChildren.splice(destination.index, 0, moved);
items[dIdx] = { ...items[dIdx], children: destChildren } as NavigationItem;
}
}
setAdminNavItems(items);
// Persist parent change and reorder siblings at both source and destination
try {
await updateNavigationItem(moved.id!, { parent_id: destParentId === null ? null : destParentId, display_order: destination.index } as any);
// Reorder source siblings
if (srcParentId === null) {
const topOrders = items.map((it, idx) => ({ id: it.id!, display_order: idx }));
await reorderNavigationItems(topOrders);
} else {
const srcIdx = findParentIndex(srcParentId);
if (srcIdx >= 0) {
const orders = (items[srcIdx].children || []).map((c, idx) => ({ id: c.id!, display_order: idx }));
await reorderNavigationItems(orders);
}
}
// Reorder destination siblings
if (destParentId === null) {
const topOrders = items.map((it, idx) => ({ id: it.id!, display_order: idx }));
await reorderNavigationItems(topOrders);
} else {
const destIdx = findParentIndex(destParentId);
if (destIdx >= 0) {
const orders = (items[destIdx].children || []).map((c, idx) => ({ id: c.id!, display_order: idx }));
await reorderNavigationItems(orders);
}
}
toast({ title: 'Přesunuto', status: 'success', duration: 2000 });
} catch (error) {
toast({ title: 'Chyba při přesunu', status: 'error', duration: 3000 });
loadData();
}
}
};
@@ -947,6 +1080,11 @@ const NavigationAdminPage = () => {
hoverBg={hoverBg}
onChildMoveUp={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'up')}
onChildMoveDown={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'down')}
onToggleVisible={toggleVisible}
childrenDroppableId={`frontend-children-${item.id}`}
draggableChildPrefix={'front-child'}
onEditTarget={(it) => openNavModal(it)}
onDeleteTarget={(it) => deleteNav(it.id!)}
/>
</Box>
)}
@@ -1010,6 +1148,9 @@ const NavigationAdminPage = () => {
hoverBg={hoverBg}
onChildMoveUp={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'up')}
onChildMoveDown={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'down')}
onToggleVisible={toggleVisible}
onEditTarget={(it) => openNavModal(it, undefined, true)}
onDeleteTarget={(it) => deleteNav(it.id!)}
/>
</Box>
)}
@@ -68,8 +68,8 @@ const PlayersAdminPage: React.FC = () => {
// Simple fuzzy match scoring: higher is better
function fuzzyScore(text: string, query: string): number {
if (!query) return 0;
const t = text.toLowerCase();
const q = query.toLowerCase();
const t = (text || '').toLowerCase();
const q = (query || '').toLowerCase();
// Exact and prefix bonuses
if (t === q) return 1000;
if (t.startsWith(q)) return 800 - (t.length - q.length);
@@ -216,7 +216,7 @@ const PlayersAdminPage: React.FC = () => {
}
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ['admin-players'], queryFn: getPlayers });
const { data, isLoading } = useQuery({ queryKey: ['admin-players', { active: false }], queryFn: () => getPlayers({ active: false }) });
const [editing, setEditing] = useState<Editing | null>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
@@ -238,7 +238,7 @@ const PlayersAdminPage: React.FC = () => {
mutationFn: (payload: any) => createPlayer(payload),
onSuccess: (created: any) => {
try {
qc.setQueryData(['admin-players'], (old: any) => {
qc.setQueryData(['admin-players', { active: false }], (old: any) => {
const list = Array.isArray(old) ? old : (old?.data || []);
const newList = [created, ...list];
if (old && old.data) return { ...old, data: newList };
@@ -246,7 +246,7 @@ const PlayersAdminPage: React.FC = () => {
});
} catch (e) {}
toast({ title: 'Hráč vytvořen', status: 'success' });
qc.invalidateQueries({ queryKey: ['admin-players'] });
qc.invalidateQueries({ queryKey: ['admin-players', { active: false }] });
closeModal();
},
onError: (e: any) => {
@@ -257,7 +257,7 @@ const PlayersAdminPage: React.FC = () => {
});
const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: number | string; payload: any }) => updatePlayer(id, payload),
onSuccess: () => { toast({ title: 'Hráč upraven', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-players'] }); closeModal(); },
onSuccess: () => { toast({ title: 'Hráč upraven', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-players', { active: false }] }); closeModal(); },
onError: (e: any) => {
const status = e?.response?.status;
const msg = e?.response?.data?.chyba || e?.response?.data?.error || e?.message || 'Chyba';
@@ -266,7 +266,7 @@ const PlayersAdminPage: React.FC = () => {
});
const deleteMut = useMutation({
mutationFn: (id: number) => deletePlayer(id),
onSuccess: () => { toast({ title: 'Hráč smazán', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-players'] }); },
onSuccess: () => { toast({ title: 'Hráč smazán', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-players', { active: false }] }); },
onError: (e: any) => {
const status = e?.response?.status;
const msg = e?.response?.data?.chyba || e?.response?.data?.error || e?.message || 'Chyba';
@@ -468,8 +468,8 @@ const PlayersAdminPage: React.FC = () => {
<FormLabel>Pohlaví</FormLabel>
<Select value={(editing as any)?.gender || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), gender: e.target.value }))}>
<option value=""> nevybráno </option>
<option value="men">Muži</option>
<option value="women">Ženy</option>
<option value="men">Muž</option>
<option value="women">Žena</option>
</Select>
</FormControl>
+105 -49
View File
@@ -46,6 +46,9 @@ import {
listSponsorsAdmin,
uploadSponsors,
deleteSponsor,
prefillSponsorsFromPage,
getQr,
uploadQr,
} from '@/services/scoreboard';
import { useFacrApi } from '@/hooks/useFacrApi';
import { SearchResult } from '@/services/facr/types';
@@ -53,6 +56,7 @@ import { API_URL } from '@/services/api';
import { useQuery } from '@tanstack/react-query';
import { AdminMatch, fetchAdminMatches } from '@/services/adminMatches';
import { getFacrClubInfoCache } from '@/services/facr/cache';
import { createSponsor } from '@/services/sponsors';
const themes: ScoreboardTheme[] = ['classic', 'pill', 'var1', 'var2', 'var3', 'var4'];
@@ -79,6 +83,8 @@ const ScoreboardAdminPage: React.FC = () => {
const [presetName, setPresetName] = useState('');
const [sponsors, setSponsors] = useState<string[]>([]);
const [sUploadBusy, setSUploadBusy] = useState(false);
const [qrUrl, setQrUrl] = useState<string>('');
const [qrBusy, setQrBusy] = useState(false);
// Club search inline (home/away target)
const [clubQuery, setClubQuery] = useState('');
@@ -93,6 +99,7 @@ const ScoreboardAdminPage: React.FC = () => {
// load presets & sponsors lists
try { setPresets(await listPresets()); } catch {}
try { setSponsors(await listSponsorsAdmin()); } catch {}
try { setQrUrl(await getQr()); } catch {}
})();
}, []);
@@ -539,63 +546,112 @@ const ScoreboardAdminPage: React.FC = () => {
<Divider my={6} />
<HStack spacing={3}>
<Button onClick={() => setPartial({ homeScore: (state.homeScore || 0) + 1 })}>+ Gól DOM</Button>
<Button onClick={() => setPartial({ homeScore: Math.max(0, (state.homeScore || 0) - 1) })}> Gól DOM</Button>
<Button onClick={() => setPartial({ awayScore: (state.awayScore || 0) + 1 })}>+ Gól HOS</Button>
<Button onClick={() => setPartial({ awayScore: Math.max(0, (state.awayScore || 0) - 1) })}> Gól HOS</Button>
<Button variant="outline" onClick={() => setPartial({ homeScore: 0, awayScore: 0 })}>Reset skóre</Button>
<Button variant="outline" onClick={async () => { await resetTimer(); const s = await getScoreboardState(); setState(s); }}>Reset čas</Button>
<Button variant="outline" onClick={() => setPartial({ homeFouls: 0, awayFouls: 0 })}>Reset fauly</Button>
</HStack>
<Divider my={6} />
{/* Timer controls */}
{/* Časovač ovládá pouze mobilní ovladač. Na této stránce ponechány pouze reset akce. */}
<Box borderWidth="1px" borderRadius="lg" p={4} bg={cardBg} mb={6}>
<Heading size="md" mb={3}>Časovač</Heading>
<HStack spacing={4} align="center" flexWrap="wrap">
<Text fontSize="4xl" fontFamily="mono" minW="120px">{state.timer || '00:00'}</Text>
{state.running ? (
<Badge colorScheme="green">Běží</Badge>
) : (
<Badge>Stojí</Badge>
)}
<HStack>
<Button colorScheme="green" onClick={async () => {
await startTimer();
const s = await getScoreboardState();
setState(s);
}}>Start</Button>
<Button onClick={async () => {
await pauseTimer();
const s = await getScoreboardState();
setState(s);
}}>Pauza</Button>
<Button variant="outline" onClick={async () => {
await resetTimer();
const s = await getScoreboardState();
setState(s);
}}>Reset</Button>
<Button colorScheme="purple" onClick={async () => {
await startSecondHalf();
const s = await getScoreboardState();
setState(s);
}}>Začít 2. poločas</Button>
<Heading size="md" mb={3}>Sponzoři (overlay)</Heading>
{(() => { const href = (typeof window !== 'undefined' ? window.location.origin.replace(/\/$/, '') : '') + '/overlay/sponsors'; return (
<HStack spacing={3} mb={3}>
<Badge colorScheme="green">OBS</Badge>
<Button as="a" href={href} target="_blank" rel="noreferrer">Otevřít overlay sponzoři</Button>
<Text fontSize="sm" color="gray.500">Veřejná URL: {href}</Text>
</HStack>
); })()}
<HStack spacing={3} mb={3}>
<Button as="label" isLoading={sUploadBusy}>Nahrát loga
<Input type="file" accept="image/*,image/svg+xml" display="none" multiple onChange={async (e) => {
const files = Array.from(e.target.files || []);
if (!files.length) return;
try {
setSUploadBusy(true);
const res = await uploadSponsors(files);
setSponsors(await listSponsorsAdmin());
(e.target as HTMLInputElement).value = '';
toast({ title: 'Loga nahrána', status: 'success' });
try {
const urls = (res?.files || []).filter(Boolean) as string[];
if (urls.length > 0) {
const want = window.confirm('Chcete přidat nahraná loga i jako nové sponzory na web?');
if (want) {
for (const u of urls) {
const fname = (u.split('/').pop() || '').replace(/\.[a-z0-9]+$/i, '');
const name = window.prompt('Název sponzora pro logo '+fname, fname) || '';
if (!name.trim()) continue;
const website = window.prompt('Web sponzora (volitelné, včetně https://)', '') || '';
try { await createSponsor({ name: name.trim(), logo_url: u, website_url: website.trim() || undefined, is_active: true }); } catch {}
}
toast({ title: 'Sponzoři přidáni', status: 'success' });
}
}
} catch {}
} catch (err: any) {
toast({ title: 'Nahrání selhalo', description: err?.message, status: 'error' });
} finally {
setSUploadBusy(false);
}
}} />
</Button>
<Button variant="ghost" onClick={async ()=>{ try { setSponsors(await listSponsorsAdmin()); toast({ title: 'Seznam aktualizován', status: 'info' }); } catch {} }}>Obnovit</Button>
<Button variant="outline" onClick={async ()=>{
try {
const res = await prefillSponsorsFromPage();
setSponsors(await listSponsorsAdmin());
toast({ title: 'Předvyplněno ze Sponzorů', description: `Přidáno ${res?.saved || 0} log`, status: 'success' });
} catch (e:any) {
toast({ title: 'Předvyplnění selhalo', description: e?.message, status: 'error' });
}
}}>Předvyplnit ze stránky Sponzoři</Button>
</HStack>
<HStack mt={3} spacing={3} align="center">
<FormControl maxW="160px" isDisabled={!!state.running}>
<FormLabel>Nastavit čas (MM:SS)</FormLabel>
<Input
value={state.timer || '00:00'}
onChange={async (e) => {
const v = e.target.value.trim();
// allow edit only when not running
if (!state.running) {
const next = await saveScoreboardState({ timer: v });
setState(next);
}
}}
/>
</FormControl>
<SimpleGrid columns={{ base: 2, md: 4, lg: 6 }} spacing={3}>
{sponsors.map((u) => {
const name = (u || '').split('/').pop() || '';
return (
<VStack key={u} spacing={2} borderWidth="1px" borderRadius="md" p={2}>
<Image src={u} alt={name} boxSize="64px" objectFit="contain" />
<Text fontSize="xs" noOfLines={1} maxW="120px">{name}</Text>
<Button size="xs" colorScheme="red" variant="ghost" onClick={async ()=>{ try { await deleteSponsor(name); setSponsors(await listSponsorsAdmin()); toast({ title: 'Smazáno', status: 'success' }); } catch { toast({ title: 'Smazání selhalo', status: 'error' }); } }}>Smazat</Button>
</VStack>
);
})}
</SimpleGrid>
</Box>
<Box borderWidth="1px" borderRadius="lg" p={4} bg={cardBg} mb={6}>
<Heading size="md" mb={3}>QR kód</Heading>
<HStack spacing={4} align="flex-start" flexWrap="wrap">
<VStack align="start" spacing={2}>
<Text fontSize="sm" color="gray.500">Aktuální QR:</Text>
{qrUrl ? (
<Image src={qrUrl} alt="QR" boxSize="128px" objectFit="contain" borderWidth="1px" borderRadius="md" />
) : (
<Text fontSize="sm" color="gray.400">Nenahrán</Text>
)}
</VStack>
<Button as="label" isLoading={qrBusy}>Nahrát QR
<Input type="file" accept="image/*" display="none" onChange={async (e)=>{
const f = e.target.files?.[0];
if (!f) return;
try {
setQrBusy(true);
await uploadQr(f);
setQrUrl(await getQr());
(e.target as HTMLInputElement).value = '';
toast({ title: 'QR nahrán', status: 'success' });
} catch (err: any) {
toast({ title: 'Nahrání selhalo', description: err?.message, status: 'error' });
} finally {
setQrBusy(false);
}
}} />
</Button>
<Button variant="ghost" onClick={async ()=>{ try { setQrUrl(await getQr()); toast({ title: 'Obnoveno', status: 'info' }); } catch {} }}>Obnovit</Button>
</HStack>
</Box>
+79 -11
View File
@@ -155,6 +155,14 @@ const SettingsAdminPage: React.FC = () => {
setSettings((prev) => ({ ...prev, [key]: e.target.value }));
};
const presetStorageThresholds = () => {
setSettings((prev) => ({
...prev,
storage_warn_threshold: 80 as any,
storage_critical_threshold: 95 as any,
}));
};
const handleSave = async () => {
setSaving(true);
try {
@@ -167,7 +175,6 @@ const SettingsAdminPage: React.FC = () => {
youtube_url: (settings as any).youtube_url,
// generic gallery (preferred)
gallery_url: (settings as any).gallery_url,
gallery_label: (settings as any).gallery_label,
// backward compatibility
zonerama_url: (settings as any).zonerama_url,
// SMTP
@@ -203,6 +210,9 @@ const SettingsAdminPage: React.FC = () => {
finished_match_display_days: (settings as any).finished_match_display_days as any,
storage_warn_threshold: (settings as any).storage_warn_threshold as any,
storage_critical_threshold: (settings as any).storage_critical_threshold as any,
// error-review integration (domain managed via .env; only tokens are saved)
error_review_admin_token: (settings as any).error_review_admin_token,
error_review_ingest_token: (settings as any).error_review_ingest_token,
};
const saved = await updateAdminSettings(payload);
setSettings((prev) => ({ ...prev, ...saved }));
@@ -224,6 +234,11 @@ const SettingsAdminPage: React.FC = () => {
try { (api as any).defaults.baseURL = ab; } catch {}
setTimeout(() => { try { window.location.reload(); } catch {} }, 600);
}
// Persist ingest token for frontend errorReporter (URL is fixed by default wiring)
try {
const ingestToken = (saved as any).error_review_ingest_token || (settings as any).error_review_ingest_token;
if (ingestToken) localStorage.setItem('fc_error_ingest_token', String(ingestToken));
} catch {}
} catch {}
} catch (e: any) {
toast({ title: 'Chyba', description: e?.message || 'Uložení nastavení se nezdařilo', status: 'error' });
@@ -301,6 +316,10 @@ const SettingsAdminPage: React.FC = () => {
onChange={handleNumChange('storage_critical_threshold' as any)}
/>
</FormControl>
<FormControl>
<FormLabel>Přednastavit</FormLabel>
<Button onClick={presetStorageThresholds} variant="outline">80 % / 95 %</Button>
</FormControl>
</SimpleGrid>
<FormControl>
<FormLabel>Logo klubu</FormLabel>
@@ -369,10 +388,6 @@ const SettingsAdminPage: React.FC = () => {
<FormLabel>Fotogalerie URL</FormLabel>
<Input value={(settings as any).gallery_url || ''} onChange={handleChange('gallery_url')} />
</FormControl>
<FormControl>
<FormLabel>Popisek fotogalerie</FormLabel>
<Input value={(settings as any).gallery_label || ''} onChange={handleChange('gallery_label')} />
</FormControl>
<HStack>
<Button onClick={handleSave} isLoading={saving} bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }}>Uložit nastavení</Button>
@@ -435,27 +450,71 @@ const SettingsAdminPage: React.FC = () => {
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
<FormControl>
<FormLabel>SMTP Host</FormLabel>
<Input value={(settings as any).smtp_host || ''} onChange={handleChange('smtp_host' as any)} />
<Input
value={(settings as any).smtp_host || ''}
onChange={handleChange('smtp_host' as any)}
placeholder="smtp.seznam.cz nebo smtp.gmail.com"
/>
<FormHelperText>
Adresa SMTP serveru. Příklad: smtp.seznam.cz, smtp.gmail.com, smtp.office365.com
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>SMTP Port</FormLabel>
<Input type="number" value={(settings as any).smtp_port ?? ''} onChange={handleNumChange('smtp_port' as any)} />
<Input
type="number"
value={(settings as any).smtp_port ?? ''}
onChange={handleNumChange('smtp_port' as any)}
placeholder="587 pro TLS, 465 pro SSL, 25 bez šifrování"
/>
<FormHelperText>
Nejčastěji 587 (TLS/STARTTLS) nebo 465 (SSL). Port 25 je bez šifrování a často blokovaný.
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>SMTP Uživatel</FormLabel>
<Input value={(settings as any).smtp_user || ''} onChange={handleChange('smtp_user' as any)} />
<Input
value={(settings as any).smtp_user || ''}
onChange={handleChange('smtp_user' as any)}
placeholder="většinou celá emailová adresa"
/>
<FormHelperText>
Přihlašovací jméno k SMTP (obvykle emailová adresa). Nechte prázdné, pokud server nevyžaduje přihlášení.
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>SMTP Heslo</FormLabel>
<Input type="password" value={(settings as any).smtp_password || ''} onChange={handleChange('smtp_password' as any)} />
<Input
type="password"
value={(settings as any).smtp_password || ''}
onChange={handleChange('smtp_password' as any)}
placeholder="heslo nebo aplikační heslo"
/>
<FormHelperText>
Heslo k účtu nebo aplikační heslo (Gmail/Seznam/Office 365 často vyžadují). Vkládejte bez mezer.
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>From email</FormLabel>
<Input value={(settings as any).smtp_from || ''} onChange={handleChange('smtp_from' as any)} />
<Input
value={(settings as any).smtp_from || ''}
onChange={handleChange('smtp_from' as any)}
placeholder="noreply@vasklub.cz"
/>
<FormHelperText>
Adresa odesílatele uvedená v emailech. Ideálně existující schránka na vašem SMTP serveru.
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>From jméno</FormLabel>
<Input value={(settings as any).smtp_from_name || ''} onChange={handleChange('smtp_from_name' as any)} />
<Input
value={(settings as any).smtp_from_name || ''}
onChange={handleChange('smtp_from_name' as any)}
placeholder="FK Váš Klub"
/>
<FormHelperText>
Zobrazované jméno odesílatele (např. FK Váš Klub).
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Šifrování</FormLabel>
@@ -464,6 +523,9 @@ const SettingsAdminPage: React.FC = () => {
<option value="ssl">SSL</option>
<option value="tls">TLS</option>
</Select>
<FormHelperText>
SSL = implicitní šifrování (obvykle port 465). TLS/STARTTLS = šifrování po navázání spojení (obvykle port 587).
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Autentizace</FormLabel>
@@ -472,11 +534,17 @@ const SettingsAdminPage: React.FC = () => {
<option value="login">LOGIN</option>
<option value="cram-md5">CRAMMD5</option>
</Select>
<FormHelperText>
Mechanismus přihlášení k SMTP. Pokud si nejste jisti, zvolte PLAIN nebo LOGIN. Někteří poskytovatelé vyžadují konkrétní metodu.
</FormHelperText>
</FormControl>
</SimpleGrid>
<FormControl display="flex" alignItems="center">
<FormLabel mb={0}>Přeskočit ověření certifikátu</FormLabel>
<Switch isChecked={!!(settings as any).smtp_skip_verify} onChange={handleBoolChange('smtp_skip_verify' as any)} />
<FormHelperText ml={{ base: 0, md: 4 }}>
Pokročilé: povolte pouze při chybách certifikátu (selfsigned apod.). Nedoporučeno v produkci snižuje bezpečnost.
</FormHelperText>
</FormControl>
<Divider />
+134 -17
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Button,
@@ -33,6 +33,8 @@ import {
NumberInputField,
IconButton,
Divider,
Image,
FormHelperText,
} from '@chakra-ui/react';
import { Link as RouterLink } from 'react-router-dom';
import AdminLayout from '../../layouts/AdminLayout';
@@ -53,6 +55,8 @@ import {
SweepstakePrize,
} from '../../services/sweepstakes';
import { AddIcon, ArrowUpIcon, ArrowDownIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons';
import { FiUpload } from 'react-icons/fi';
import { uploadFile, createArticle } from '../../services/articles';
const fmt = (iso?: string | null) => {
if (!iso) return '';
@@ -70,6 +74,8 @@ const defaultForm = {
picker_style: 'wheel',
total_prizes: 1,
prize_summary: '',
entry_cost_points: 0,
max_entries_per_user: 1,
};
const SweepstakesAdminPage: React.FC = () => {
@@ -89,6 +95,43 @@ const SweepstakesAdminPage: React.FC = () => {
const [prizeForm, setPrizeForm] = useState<{ name: string; quantity: number; value?: string; image_url?: string; kind?: 'physical'|'points'|'xp'|'points_xp'; points?: number; xp?: number }>(() => ({ name: '', quantity: 1, value: '', image_url: '', kind: 'physical', points: 0, xp: 0 }));
const [savingPrize, setSavingPrize] = useState<boolean>(false);
const imageInputRef = useRef<HTMLInputElement>(null);
const rulesInputRef = useRef<HTMLInputElement>(null);
const onUploadImage = async (file?: File | null) => {
if (!file) return;
try {
const res = await uploadFile(file);
setForm((prev: any) => ({ ...prev, image_url: res.url }));
toast({ status: 'success', title: 'Obrázek nahrán' });
} catch (e: any) {
toast({ status: 'error', title: 'Nahrávání selhalo' });
}
};
const onUploadRules = async (file?: File | null) => {
if (!file) return;
try {
const res = await uploadFile(file);
setForm((prev: any) => ({ ...prev, rules_url: res.url }));
toast({ status: 'success', title: 'Pravidla nahrána' });
} catch (e: any) {
toast({ status: 'error', title: 'Nahrávání selhalo' });
}
};
const onCreateRulesArticle = async () => {
try {
const title = (form.title ? `Pravidla soutěže: ${form.title}` : 'Pravidla soutěže').trim();
const a = await createArticle({ title, content: '<p>Zde doplňte pravidla soutěže.</p>', published: true });
const url = `/articles/${a.id}`;
setForm((prev: any) => ({ ...prev, rules_url: url }));
toast({ status: 'success', title: 'Stránka pravidel vytvořena' });
} catch (e: any) {
toast({ status: 'error', title: 'Nelze vytvořit stránku pravidel' });
}
};
const load = async () => {
setLoading(true);
try {
@@ -159,6 +202,8 @@ const SweepstakesAdminPage: React.FC = () => {
picker_style: (it as any).picker_style || 'wheel',
total_prizes: (it as any).total_prizes || 1,
prize_summary: (it as any).prize_summary || '',
entry_cost_points: (it as any).entry_cost_points ?? 0,
max_entries_per_user: (it as any).max_entries_per_user ?? 1,
});
onOpen();
};
@@ -169,11 +214,23 @@ const SweepstakesAdminPage: React.FC = () => {
toast({ status: 'error', title: 'Vyplňte název a datumy' });
return;
}
const tpRaw = Number(form.total_prizes || 1);
const tp = Number.isFinite(tpRaw) ? Math.floor(tpRaw) : 1;
const total_prizes = tp < 1 ? 1 : (tp > 100 ? 100 : tp);
// Normalize datetime-local (YYYY-MM-DDTHH:mm) to RFC3339 with timezone for Go backend
const s = new Date(form.start_at);
const e = new Date(form.end_at);
const payload = {
...form,
total_prizes,
start_at: isNaN(s.getTime()) ? form.start_at : s.toISOString(),
end_at: isNaN(e.getTime()) ? form.end_at : e.toISOString(),
};
if (editing) {
await adminUpdateSweepstake(editing.id, form);
await adminUpdateSweepstake(editing.id, payload);
toast({ status: 'success', title: 'Uloženo' });
} else {
await adminCreateSweepstake(form);
await adminCreateSweepstake(payload);
toast({ status: 'success', title: 'Vytvořeno' });
}
onClose();
@@ -239,6 +296,14 @@ const SweepstakesAdminPage: React.FC = () => {
<VStack align="start" spacing={0}>
<Text fontWeight="bold">{it.title}</Text>
{it.prize_summary && <Text fontSize="xs" opacity={0.8}>{it.prize_summary}</Text>}
<HStack spacing={2} wrap="wrap">
<Badge colorScheme={(it as any).entry_cost_points ? 'purple' : 'green'} fontSize="0.7rem">
{(it as any).entry_cost_points ? `Vstup: ${(it as any).entry_cost_points} bodů` : 'Vstup: zdarma'}
</Badge>
{(it as any).max_entries_per_user > 1 && (
<Badge colorScheme="gray" fontSize="0.7rem">max {(it as any).max_entries_per_user}×/osoba</Badge>
)}
</HStack>
</VStack>
</Td>
<Td>{fmt((it as any).start_at)} {fmt((it as any).end_at)}</Td>
@@ -261,7 +326,7 @@ const SweepstakesAdminPage: React.FC = () => {
)}
{/* Create/Edit Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<Modal isOpen={isOpen} onClose={onClose} size="2xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>{editing ? 'Upravit soutěž' : 'Nová soutěž'}</ModalHeader>
@@ -294,23 +359,69 @@ const SweepstakesAdminPage: React.FC = () => {
<option value="cycler">Náhodný přepínač</option>
</Select>
</FormControl>
<FormControl>
<FormLabel>Počet výher</FormLabel>
<Input type="number" value={form.total_prizes} onChange={(e)=>setForm({ ...form, total_prizes: Number(e.target.value) || 1 })} />
<FormControl isInvalid={Number(form.total_prizes) < 1 || Number(form.total_prizes) > 100}>
<FormLabel>Počet výher</FormLabel>
<NumberInput value={Number(form.total_prizes)||1} min={1} keepWithinRange={false} clampValueOnBlur={false} onChange={(_v, n)=>setForm({ ...form, total_prizes: Number.isFinite(n) ? Math.floor(n) : 1 })}>
<NumberInputField />
</NumberInput>
<FormHelperText>Max. 100 výherců</FormHelperText>
</FormControl>
</SimpleGrid>
<FormControl>
<FormLabel>Souhrn výher</FormLabel>
<Input value={form.prize_summary} onChange={(e)=>setForm({ ...form, prize_summary: e.target.value })} />
</FormControl>
<SimpleGrid columns={2} spacing={4}>
<HStack>
<Button variant="outline" onClick={()=> editing ? openPrizes(editing) : toast({ status: 'info', title: 'Uložte soutěž a poté přidejte výhry' })}>Upravit výhry</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: 'Hlavní výhra', quantity: 1 }); toast({ status:'success', title:'Přidáno: Hlavní výhra' }); } catch { toast({ status:'error', title:'Nelze přidat výhru' }); }
}}>1× Hlavní výhra</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: 'Menší výhra', quantity: 3 }); toast({ status:'success', title:'Přidáno: 3× Menší výhra' }); } catch { toast({ status:'error', title:'Nelze přidat výhry' }); }
}}>3× Menší výhry</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: '100 bodů', kind:'points', points: 100, quantity: 10 }); toast({ status:'success', title:'Přidáno: 10× 100 bodů' }); } catch { toast({ status:'error', title:'Nelze přidat body' }); }
}}>10× 100 bodů</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: '500 XP', kind:'xp', xp: 500, quantity: 5 }); toast({ status:'success', title:'Přidáno: 5× 500 XP' }); } catch { toast({ status:'error', title:'Nelze přidat XP' }); }
}}>5× 500 XP</Button>
</HStack>
<SimpleGrid columns={3} spacing={4}>
<FormControl>
<FormLabel>Obrázek (URL)</FormLabel>
<Input value={form.image_url} onChange={(e)=>setForm({ ...form, image_url: e.target.value })} />
<FormLabel>Vstupné (body)</FormLabel>
<NumberInput min={0} value={Number(form.entry_cost_points)||0} onChange={(v)=>setForm({ ...form, entry_cost_points: Number(v) || 0 })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Pravidla (URL)</FormLabel>
<Input value={form.rules_url} onChange={(e)=>setForm({ ...form, rules_url: e.target.value })} />
<FormLabel>Max. účastí / uživatel</FormLabel>
<NumberInput min={1} value={Number(form.max_entries_per_user)||1} onChange={(v)=>setForm({ ...form, max_entries_per_user: Number(v) || 1 })}>
<NumberInputField />
</NumberInput>
</FormControl>
</SimpleGrid>
<SimpleGrid columns={2} spacing={4}>
<FormControl>
<FormLabel>Titulní obrázek</FormLabel>
<HStack>
<Image src={form.image_url || '/dist/img/logo-club-empty.svg'} alt="cover" boxSize="80px" objectFit="cover" borderRadius="md" />
<Button as="label" leftIcon={<FiUpload />} variant="outline">
Nahrát
<Input ref={imageInputRef} type="file" display="none" accept="image/*" onChange={(e)=>onUploadImage(e.target.files?.[0])} />
</Button>
</HStack>
<Input mt={2} placeholder="nebo vložte URL" value={form.image_url} onChange={(e)=>setForm({ ...form, image_url: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Pravidla</FormLabel>
<HStack>
<Button as="label" leftIcon={<FiUpload />} variant="outline">
Nahrát PDF/obrázek
<Input ref={rulesInputRef} type="file" display="none" accept="image/*,application/pdf" onChange={(e)=>onUploadRules(e.target.files?.[0])} />
</Button>
<Button variant="outline" onClick={onCreateRulesArticle}>Vytvořit stránku</Button>
</HStack>
<Input mt={2} placeholder="nebo vložte URL" value={form.rules_url} onChange={(e)=>setForm({ ...form, rules_url: e.target.value })} />
</FormControl>
</SimpleGrid>
</VStack>
@@ -367,7 +478,13 @@ const SweepstakesAdminPage: React.FC = () => {
</FormControl>
<FormControl>
<FormLabel>Obrázek URL</FormLabel>
<Input value={prizeForm.image_url} onChange={(e)=>setPrizeForm({ ...prizeForm, image_url: e.target.value })} />
<HStack>
<Input value={prizeForm.image_url} onChange={(e)=>setPrizeForm({ ...prizeForm, image_url: e.target.value })} />
<Button as="label" leftIcon={<FiUpload />} size="sm" variant="outline">
Upload
<Input type="file" display="none" accept="image/*" onChange={async (e)=>{ const f=e.target.files?.[0]; if(f){ const r=await uploadFile(f); setPrizeForm(prev=>({...prev, image_url: r.url })); } }} />
</Button>
</HStack>
</FormControl>
</SimpleGrid>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={2} alignItems="end">
+43 -43
View File
@@ -80,8 +80,8 @@ const UsersAdminPage = () => {
} catch (error) {
console.error('Error fetching users:', error);
toast({
title: 'Error',
description: 'Failed to fetch users',
title: 'Chyba',
description: 'Nepodařilo se načíst uživatele',
status: 'error',
duration: 5000,
isClosable: true,
@@ -125,8 +125,8 @@ const UsersAdminPage = () => {
}
await api.put(`/admin/users/${selectedUser.id}`, payload);
toast({
title: 'Success',
description: 'User updated successfully',
title: 'Hotovo',
description: 'Uživatel aktualizován',
status: 'success',
duration: 3000,
isClosable: true,
@@ -135,8 +135,8 @@ const UsersAdminPage = () => {
// Create new user
await api.post('/admin/users', formData);
toast({
title: 'Success',
description: 'User created successfully',
title: 'Hotovo',
description: 'Uživatel vytvořen',
status: 'success',
duration: 3000,
isClosable: true,
@@ -148,8 +148,8 @@ const UsersAdminPage = () => {
} catch (error: any) {
console.error('Error saving user:', error);
toast({
title: 'Error',
description: error.response?.data?.error || error.response?.data?.message || 'Failed to save user',
title: 'Chyba',
description: error.response?.data?.error || error.response?.data?.message || 'Nepodařilo se uložit uživatele',
status: 'error',
duration: 5000,
isClosable: true,
@@ -169,12 +169,12 @@ const UsersAdminPage = () => {
toast({ title: 'Zakázáno', description: 'Nemůžete smazat sám sebe.', status: 'warning' });
return;
}
if (window.confirm('Are you sure you want to delete this user?')) {
if (window.confirm('Opravdu smazat tohoto uživatele?')) {
try {
await api.delete(`/admin/users/${userId}`);
toast({
title: 'Success',
description: 'User deleted successfully',
title: 'Hotovo',
description: 'Uživatel smazán',
status: 'success',
duration: 3000,
isClosable: true,
@@ -183,8 +183,8 @@ const UsersAdminPage = () => {
} catch (error: any) {
console.error('Error deleting user:', error);
toast({
title: 'Error',
description: error.response?.data?.error || error.response?.data?.message || 'Failed to delete user',
title: 'Chyba',
description: error.response?.data?.error || error.response?.data?.message || 'Nepodařilo se smazat uživatele',
status: 'error',
duration: 5000,
isClosable: true,
@@ -241,12 +241,12 @@ const UsersAdminPage = () => {
<Table variant="simple">
<Thead>
<Tr>
<Th>Name</Th>
<Th>Email</Th>
<Th>Jméno</Th>
<Th>Email</Th>
<Th>Role</Th>
<Th>Status</Th>
<Th>Created</Th>
<Th>Actions</Th>
<Th>Stav</Th>
<Th>Vytvořeno</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
@@ -261,7 +261,7 @@ const UsersAdminPage = () => {
</Td>
<Td>
<Badge colorScheme={user.isActive ? 'green' : 'red'}>
{user.isActive ? 'Active' : 'Inactive'}
{user.isActive ? 'Aktiv' : 'Neaktiv'}
</Badge>
</Td>
<Td>{new Date(user.createdAt).toLocaleDateString()}</Td>
@@ -276,7 +276,7 @@ const UsersAdminPage = () => {
/>
<MenuList>
<MenuItem icon={<EditIcon />} onClick={() => openEditModal(user)}>
Edit
Upravit
</MenuItem>
<MenuItem onClick={async () => {
try {
@@ -292,7 +292,7 @@ const UsersAdminPage = () => {
</MenuItem>
{user.role !== 'admin' && String(authUser?.id) !== String(user.id) && (
<MenuItem icon={<DeleteIcon />} color="red.500" onClick={() => handleDelete(user.id)}>
Delete
Smazat
</MenuItem>
)}
</MenuList>
@@ -309,12 +309,12 @@ const UsersAdminPage = () => {
<Table variant="simple">
<Thead>
<Tr>
<Th>Name</Th>
<Th>Email</Th>
<Th>Jméno</Th>
<Th>Email</Th>
<Th>Role</Th>
<Th>Status</Th>
<Th>Created</Th>
<Th>Actions</Th>
<Th>Stav</Th>
<Th>Vytvořeno</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
@@ -324,12 +324,12 @@ const UsersAdminPage = () => {
<Td>{user.email}</Td>
<Td>
<Badge colorScheme={'gray'}>
Fan
Fanoušek
</Badge>
</Td>
<Td>
<Badge colorScheme={user.isActive ? 'green' : 'red'}>
{user.isActive ? 'Active' : 'Inactive'}
{user.isActive ? 'Aktiv' : 'Neaktiv'}
</Badge>
</Td>
<Td>{new Date(user.createdAt).toLocaleDateString()}</Td>
@@ -344,11 +344,11 @@ const UsersAdminPage = () => {
/>
<MenuList>
<MenuItem icon={<EditIcon />} onClick={() => openEditModal(user)}>
Edit
Upravit
</MenuItem>
{String(authUser?.id) !== String(user.id) && (
<MenuItem icon={<DeleteIcon />} color="red.500" onClick={() => handleDelete(user.id)}>
Delete
Smazat
</MenuItem>
)}
</MenuList>
@@ -365,45 +365,45 @@ const UsersAdminPage = () => {
<ModalOverlay />
<ModalContent as="form" onSubmit={handleSubmit}>
<ModalHeader>
{selectedUser ? 'Edit User' : 'Add New User'}
{selectedUser ? 'Upravit uživatele' : 'Přidat uživatele'}
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel>Full Name</FormLabel>
<FormLabel>Jméno a příjmení</FormLabel>
<Input
name="name"
value={formData.name}
onChange={handleInputChange}
placeholder="Enter full name"
placeholder="Zadejte jméno a příjmení"
/>
</FormControl>
<FormControl isRequired>
<FormLabel>Email</FormLabel>
<FormLabel>Email</FormLabel>
<Input
type="email"
name="email"
value={formData.email}
onChange={handleInputChange}
placeholder="Enter email"
placeholder="Zadejte email"
/>
</FormControl>
{!selectedUser && (
<FormControl isRequired={!selectedUser}>
<FormLabel>Password</FormLabel>
<FormLabel>Heslo</FormLabel>
<Input
type="password"
name="password"
value={formData.password}
onChange={handleInputChange}
placeholder="Enter password"
placeholder="Zadejte heslo"
minLength={8}
/>
<FormHelperText>
Password must be at least 8 characters long
Heslo musí mít alespoň 8 znaků
</FormHelperText>
</FormControl>
)}
@@ -429,7 +429,7 @@ const UsersAdminPage = () => {
value={formData.role}
onChange={handleInputChange}
>
<option value="fan">Fan</option>
<option value="fan">Fanoušek</option>
<option value="editor" disabled={!!selectedUser && selectedUser.role === 'admin'}>Editor</option>
<option value="admin">Admin</option>
</Select>
@@ -440,7 +440,7 @@ const UsersAdminPage = () => {
<FormControl display="flex" alignItems="center">
<FormLabel mb="0" mr={2}>
Active
Aktivní
</FormLabel>
<Switch
name="isActive"
@@ -454,15 +454,15 @@ const UsersAdminPage = () => {
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
Cancel
Zrušit
</Button>
<Button
colorScheme="blue"
type="submit"
isLoading={isSubmitting}
loadingText="Saving..."
loadingText="Ukládám..."
>
{selectedUser ? 'Update' : 'Create'} User
{selectedUser ? 'Uložit' : 'Vytvořit'}
</Button>
</ModalFooter>
</ModalContent>
@@ -49,6 +49,7 @@ const vendorScripts: string[] = [
'/premium-assets/js/modernizr-2.6.2.min.js',
'/premium-assets/js/bootstrap.min.js',
'/premium-assets/js/imagesloaded.min.js',
'/premium-assets/js/masonry.min.js',
'/premium-assets/js/jquery.masonry.min.js',
'/premium-assets/js/jquery.nicescroll.js',
'/premium-assets/js/jquery.selectBox.min.js',
@@ -141,6 +142,7 @@ function useInjectAssets() {
if (typeof $.fn.magnificPopup !== 'function') { $.fn.magnificPopup = function(){ return this; }; }
if (typeof $.fn.counterUp !== 'function') { $.fn.counterUp = function(){ return this; }; }
if (typeof $.fn.ripples !== 'function') { $.fn.ripples = function(){ return this; }; }
if (typeof $.fn.masonry !== 'function') { $.fn.masonry = function(){ return this; }; }
return true;
} catch(e){ return true; }
}
+23 -3
View File
@@ -11,6 +11,12 @@ import { getClothing, ClothingItem } from '../../services/clothing';
const PremiumHomePage: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubName = settings?.club_name || 'Fotbal Club';
const igUrl = React.useMemo(() => {
const u = (settings?.instagram_url || '').trim();
if (!u) return '';
const rx = /^https?:\/\/(?:www\.)?instagram\.com\/(?:p|reel|tv)\/[^\/?#]+/i;
return rx.test(u) ? u : '';
}, [settings?.instagram_url]);
// Build zoom slider images from featured/news
const [heroImages, setHeroImages] = React.useState<string[]>([]);
@@ -118,7 +124,7 @@ const PremiumHomePage: React.FC = () => {
const items = (resp?.data || []).slice(0, 4);
const frag = document.createDocumentFragment();
items.forEach((a) => {
const col = h('div', { class: 'items col-xl-6 col-lg-6 col-md-6 col-sm-6 col-ms-6 col-xs-12' });
const col = h('div', { class: 'item div-thumbnail col-xl-6 col-lg-6 col-md-6 col-sm-6 col-ms-6 col-xs-12' });
const art = h('article', { class: 'post type-post has-post-thumbnail hentry' });
const url = a.slug ? `/news/${a.slug}` : `/articles/${a.id}`;
const aPhoto = h('a', { href: url, class: 'lte-photo' });
@@ -134,6 +140,16 @@ const PremiumHomePage: React.FC = () => {
});
mount.innerHTML = '';
mount.appendChild(frag);
// Re-init Masonry for the home grid
try {
const w: any = window as any;
const $: any = (w && (w.jQuery || w.$)) || null;
if ($ && typeof $.fn.imagesLoaded === 'function') {
$(mount).imagesLoaded(() => { try { if (typeof w.initMasonry === 'function') w.initMasonry(); } catch {} });
} else {
try { if (typeof w.initMasonry === 'function') w.initMasonry(); } catch {}
}
} catch {}
} catch {
mount.innerHTML = '<div style="width:100%;text-align:center;padding:12px;color:#c00;">Nepodařilo se načíst novinky.</div>';
}
@@ -480,7 +496,7 @@ const PremiumHomePage: React.FC = () => {
<div className="elementor-element elementor-widget elementor-widget-lte-blog">
<div className="elementor-widget-container">
<div className="blog lte-blog-sc row centered layout-posts">
<div id="latest-blog-items" className="row" aria-live="polite"></div>
<div id="latest-blog-items" className="row masonry" aria-live="polite"></div>
</div>
</div>
</div>
@@ -593,7 +609,11 @@ const PremiumHomePage: React.FC = () => {
</div>
</div>
<div className="col-xl-6 col-lg-6 col-md-12 col-sm-12 col-xs-12">
<blockquote className="instagram-media" data-instgrm-permalink={settings?.instagram_url || 'https://www.instagram.com/'} data-instgrm-version="14" style={{ background: '#FFF', border: 0, borderRadius: 3, boxShadow: '0 0 1px 0 rgba(0,0,0,0.5),0 1px 10px 0 rgba(0,0,0,0.15)', margin: 1, maxWidth: 658, minWidth: 326, padding: 0, width: '99.375%' }}></blockquote>
{igUrl ? (
<blockquote className="instagram-media" data-instgrm-permalink={igUrl} data-instgrm-version="14" style={{ background: '#FFF', border: 0, borderRadius: 3, boxShadow: '0 0 1px 0 rgba(0,0,0,0.5),0 1px 10px 0 rgba(0,0,0,0.15)', margin: 1, maxWidth: 658, minWidth: 326, padding: 0, width: '99.375%' }}></blockquote>
) : (
<a href={settings?.instagram_url || 'https://www.instagram.com/'} target="_blank" rel="noreferrer">Instagram</a>
)}
</div>
</div>
</div>
@@ -3,9 +3,11 @@ import { usePublicSettings } from '../../hooks/usePublicSettings';
import PremiumAssetsLoader from './PremiumAssetsLoader';
import { assetUrl } from '../../utils/url';
import { useAuth } from '../../contexts/AuthContext';
import { useClubTheme } from '../../hooks/useClubTheme';
const PremiumLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { data: s } = usePublicSettings();
const theme = useClubTheme();
const clubLogo = s?.club_logo_url || '/dist/img/logo-club-empty.svg';
const clubName = s?.club_name || 'Fotbal Club';
const galleryUrl = s?.gallery_url || s?.zonerama_url || undefined;
@@ -13,6 +15,61 @@ const PremiumLayout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
const role = String(user?.role || '').toLowerCase();
const accountHref = role === 'admin' || role === 'editor' ? '/admin' : '/semiadmin';
React.useEffect(() => {
try {
const root = document.documentElement as HTMLElement;
root.style.setProperty('--main', theme.primaryColor);
root.style.setProperty('--second', theme.secondaryColor);
root.style.setProperty('--accent', theme.accentColor);
root.style.setProperty('--background', theme.backgroundColor);
document.body.style.backgroundColor = theme.backgroundColor;
} catch {}
}, [theme.primaryColor, theme.secondaryColor, theme.accentColor, theme.backgroundColor]);
React.useEffect(() => {
const t = setTimeout(() => {
try {
const w: any = window as any;
if (typeof w.initStyles === 'function') { w.initStyles(); }
if (typeof w.setResizeStyles === 'function') { w.setResizeStyles(); }
if (typeof w.checkNavbar === 'function') { w.checkNavbar(); }
if (typeof w.initMasonry === 'function') { w.initMasonry(); }
if (typeof w.initParallax === 'function') { w.initParallax(); }
if ((w.jQuery || w.$) && typeof (w.jQuery || w.$)(window).trigger === 'function') {
(w.jQuery || w.$)(window).trigger('resize');
} else {
window.dispatchEvent(new Event('resize'));
}
} catch {}
}, 50);
return () => clearTimeout(t);
}, []);
React.useEffect(() => {
const w: any = window as any;
let raf = 0;
const relayout = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
try { if (typeof w.initMasonry === 'function') w.initMasonry(); } catch {}
});
};
// Reflow on new images loading inside masonry containers
const imgs: NodeListOf<HTMLImageElement> = document.querySelectorAll('.masonry img');
imgs.forEach((img) => { if (!img.complete) img.addEventListener('load', relayout, { once: true } as any); });
// Observe DOM mutations inside masonry containers
const containers = document.querySelectorAll('.masonry');
const observers: MutationObserver[] = [];
containers.forEach((c) => {
const mo = new MutationObserver(() => relayout());
mo.observe(c, { childList: true, subtree: true });
observers.push(mo);
});
// Initial attempt
relayout();
return () => { observers.forEach((o) => o.disconnect()); cancelAnimationFrame(raf); };
}, []);
return (
<div className="lte-content-wrapper lte-layout-transparent-full">
<PremiumAssetsLoader />
+13
View File
@@ -863,6 +863,12 @@ html {
padding: 8px 2px 16px 2px;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
touch-action: pan-x pinch-zoom;
overscroll-behavior-x: contain;
user-select: none;
-webkit-user-select: none;
scroll-snap-type: x proximity;
contain: paint;
}
.matches-slider .matches-track::-webkit-scrollbar {
height: 12px;
@@ -894,6 +900,10 @@ html {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
position: relative;
scroll-snap-align: start;
will-change: transform;
content-visibility: auto;
contain-intrinsic-size: 160px 340px;
}
.match-card::after {
content: '';
@@ -952,6 +962,9 @@ html {
object-fit: cover;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transition: box-shadow 0.2s ease;
pointer-events: none;
-webkit-user-drag: none;
user-select: none;
}
.match-card:hover .team img {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);