mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 18:52:56 +00:00
dev day #89
This commit is contained in:
@@ -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' },
|
||||
|
||||
@@ -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%',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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><img src="/uploads/2025/01/foto.jpg" alt="Popis" /></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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}>Auto‑refresh</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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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á e‑mailová adresa"
|
||||
/>
|
||||
<FormHelperText>
|
||||
Přihlašovací jméno k SMTP (obvykle e‑mailová 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 e‑mail</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 e‑mailech. 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">CRAM‑MD5</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 (self‑signed apod.). Nedoporučeno v produkci – snižuje bezpečnost.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<Divider />
|
||||
|
||||
@@ -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ýherců</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">
|
||||
|
||||
@@ -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>E‑mail</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 ? 'Aktivní' : 'Neaktivní'}
|
||||
</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>E‑mail</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 ? 'Aktivní' : 'Neaktivní'}
|
||||
</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>E‑mail</FormLabel>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter email"
|
||||
placeholder="Zadejte e‑mail"
|
||||
/>
|
||||
</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; }
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user