This commit is contained in:
Tomas Dvorak
2025-10-29 21:20:16 +01:00
parent 823fabee02
commit 16e4533202
61 changed files with 2308 additions and 942 deletions
+4 -2
View File
@@ -212,7 +212,7 @@ const AboutPage: React.FC = () => {
'& h2': { fontSize: 'xl' },
'& h3': { fontSize: 'lg' },
'& img': { maxW: '100%', borderRadius: 'md', my: 6 },
'& ul, & ol': { pl: 6, mb: 4 },
'& ul, & ol': { pl: 8, mb: 4 },
'& li': { mb: 2 },
}}
/>
@@ -272,6 +272,8 @@ const AboutPage: React.FC = () => {
boxShadow: 'md',
},
},
'& ul, & ol': { pl: 12, mb: 4 },
'& li': { mb: 2 },
'& img': { maxW: '100%', borderRadius: 'md', my: 6, ml: 12 },
}}
/>
@@ -312,7 +314,7 @@ const AboutPage: React.FC = () => {
'& h2': { fontSize: 'xl' },
'& h3': { fontSize: 'lg' },
'& img': { maxW: '100%', borderRadius: 'md', my: 4 },
'& ul, & ol': { pl: 6, mb: 4 },
'& ul, & ol': { pl: 8, mb: 4 },
'& li': { mb: 2 },
}}
/>
+17 -1
View File
@@ -6,6 +6,7 @@ import { Box, Container, Heading, Text, VStack, HStack, Badge, Spinner, Button,
import { FiDownload, FiMapPin, FiClock } from 'react-icons/fi';
import DOMPurify from 'dompurify';
import { assetUrl } from '../utils/url';
import { API_URL } from '../services/api';
import { trackEvent as umamiTrackEvent } from '../utils/umami';
import EventLocationMap from '../components/events/EventLocationMap';
import EmbeddedPoll from '../components/polls/EmbeddedPoll';
@@ -54,6 +55,21 @@ const ActivityDetailPage: React.FC = () => {
return () => { el.removeEventListener('click', handler); };
}, [contentRef.current]);
// Normalize uploads links in rich HTML content to backend origin
const toAbsoluteUploads = React.useCallback((html?: string) => {
if (!html) return '';
try {
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
return html
.replace(/src=("|')\s*(\/uploads\/[^"']+)("')/g, (_m, q1, p2, q3) => `src=${q1}${origin}${p2}${q3}`)
.replace(/href=("|')\s*(\/uploads\/[^"']+)("')/g, (_m, q1, p2, q3) => `href=${q1}${origin}${p2}${q3}`)
.replace(/src=("|')\s*https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?(\/uploads\/[^"']+)("')/g, (_m, q1, p2, q3) => `src=${q1}${origin}${p2}${q3}`)
.replace(/href=("|')\s*https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?(\/uploads\/[^"']+)("')/g, (_m, q1, p2, q3) => `href=${q1}${origin}${p2}${q3}`);
} catch {
return html || '';
}
}, []);
// Extract YouTube video ID from various URL formats
const getYouTubeEmbedUrl = (url: string): string | null => {
if (!url) return null;
@@ -159,7 +175,7 @@ const ActivityDetailPage: React.FC = () => {
' img': { maxWidth: '100%', borderRadius: 'md' },
}}
ref={contentRef}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(String(data.description)) }}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(toAbsoluteUploads(String(data.description))) }}
/>
)}
+7 -4
View File
@@ -220,8 +220,11 @@ const ArticleDetailPage: React.FC = () => {
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
// Replace src="/uploads... and href="/uploads...
return html
.replace(/src=("|')\s*(\/uploads\/[^"']+)("|')/g, (_m, q1, p2, q3) => `src=${q1}${origin}${p2}${q3}`)
.replace(/href=("|')\s*(\/uploads\/[^"']+)("|')/g, (_m, q1, p2, q3) => `href=${q1}${origin}${p2}${q3}`);
.replace(/src=("|')\s*(\/uploads\/[^"']+)("')/g, (_m, q1, p2, q3) => `src=${q1}${origin}${p2}${q3}`)
.replace(/href=("|')\s*(\/uploads\/[^"']+)("')/g, (_m, q1, p2, q3) => `href=${q1}${origin}${p2}${q3}`)
// Also rewrite absolute localhost/127.0.0.1 uploads links to backend origin
.replace(/src=("|')\s*https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?(\/uploads\/[^"']+)("')/g, (_m, q1, p2, q3) => `src=${q1}${origin}${p2}${q3}`)
.replace(/href=("|')\s*https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?(\/uploads\/[^"']+)("')/g, (_m, q1, p2, q3) => `href=${q1}${origin}${p2}${q3}`);
} catch {
return html;
}
@@ -463,8 +466,8 @@ const ArticleDetailPage: React.FC = () => {
</Button>
</HStack>
{/* Custom 5-image mosaic */}
{galleryAlbumQuery.data.photos && galleryAlbumQuery.data.photos.length > 0 && (() => {
const photos = galleryAlbumQuery.data.photos.slice(0, 5);
{Array.isArray(galleryAlbumQuery.data?.photos) && (galleryAlbumQuery.data?.photos?.length || 0) > 0 && (() => {
const photos = (galleryAlbumQuery.data?.photos ?? []).slice(0, 5);
if (photos.length < 5) {
return (
<SimpleGrid columns={{ base: 2, sm: 3 }} spacing={2}>
+145 -31
View File
@@ -1,20 +1,26 @@
import React from 'react';
import { Box, Container, Heading, VStack, Image, Text, Skeleton, LinkBox, HStack, Select, Badge, useColorModeValue } from '@chakra-ui/react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { getArticles, Article, Paginated } from '../services/articles';
import { Box, Container, Heading, VStack, Image, Text, Skeleton, LinkBox, HStack, Select, Badge, useColorModeValue, Input, InputGroup, InputLeftElement, InputRightElement, IconButton, Grid, GridItem } from '@chakra-ui/react';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { getArticles, Article, Paginated, getFeaturedArticles } from '../services/articles';
import { Link as RouterLink, useSearchParams } from 'react-router-dom';
import { assetUrl } from '../utils/url';
import MainLayout from '../components/layout/MainLayout';
import { getCategories, CategoryItem } from '../services/categories';
import SponsorsSection from '../components/common/SponsorsSection';
import NewsletterCTA from '../components/common/NewsletterCTA';
import { Eye, Clock } from 'lucide-react';
import { Eye, Clock, Search, X } from 'lucide-react';
const BlogTile: React.FC<{ article: Article }> = ({ article }) => {
const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({ article, variant }) => {
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
const readTime = article.read_time || article.estimated_read_minutes;
const viewCount = article.view_count;
const bgColor = useColorModeValue('white', 'gray.800');
const categoryName = (article as any)?.category?.name || (article as any)?.category_name;
const imageH = variant === 'large'
? ({ base: '280px', md: '360px' } as const)
: variant === 'small'
? ({ base: '160px', md: '180px' } as const)
: ({ base: '200px', md: '220px' } as const);
return (
<LinkBox
@@ -28,8 +34,23 @@ const BlogTile: React.FC<{ article: Article }> = ({ article }) => {
transition="all 0.25s ease"
>
<Box position="relative">
<Image src={assetUrl(article.image_url) || '/stadium-placeholder.jpg'} alt={article.title} w="100%" h={{ base: '200px', md: '220px' }} objectFit="cover" />
<Image src={assetUrl(article.image_url) || '/stadium-placeholder.jpg'} alt={article.title} w="100%" h={imageH} objectFit="cover" />
<Box position="absolute" inset={0} bgGradient="linear(to-t, rgba(0,0,0,0.55), rgba(0,0,0,0.15))" />
{categoryName && (
<Badge
position="absolute"
top={2}
left={2}
bg="rgba(0,0,0,0.7)"
color="white"
fontSize="xs"
px={2}
py={1}
borderRadius="md"
>
{categoryName}
</Badge>
)}
{/* Stats badges at top */}
{(readTime || (viewCount && viewCount > 0)) && (
@@ -71,7 +92,7 @@ const BlogTile: React.FC<{ article: Article }> = ({ article }) => {
<Heading
as="h3"
fontSize={{ base: 'lg', md: 'xl' }}
fontSize={variant === 'large' ? { base: 'xl', md: '2xl' } : { base: 'lg', md: 'xl' }}
fontWeight="800"
letterSpacing="0.3px"
textTransform="uppercase"
@@ -100,6 +121,8 @@ const BlogPage: React.FC = () => {
const [categoryId, setCategoryId] = React.useState<number | ''>(initialCategory);
const month = searchParams.get('month') || '';
const matchId = searchParams.get('match_id') || '';
const qParam = searchParams.get('q') || '';
const [qInput, setQInput] = React.useState<string>(qParam);
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textColor = useColorModeValue('gray.500', 'gray.400');
@@ -111,6 +134,33 @@ const BlogPage: React.FC = () => {
} catch {}
})();
}, []);
// Keep categoryId in sync with URL params (category_id preferred; fallback to category slug)
React.useEffect(() => {
const cid = searchParams.get('category_id');
const slug = searchParams.get('category');
if (cid !== null) {
setCategoryId(cid ? Number(cid) : '');
return;
}
if (slug) {
// Try to resolve slug to id once categories are loaded
const norm = decodeURIComponent(slug).toLowerCase();
const found = categories.find((c) => (c as any)?.slug === slug || String(c.name).toLowerCase() === norm);
setCategoryId(found?.id || '');
return;
}
// No category param present → clear filter
setCategoryId('');
}, [searchParams, categories]);
React.useEffect(() => {
setQInput(qParam);
}, [qParam]);
const featuredQ = useQuery<Paginated<Article>>(
['articles-featured', { page_size: 3 }],
() => getFeaturedArticles({ page_size: 3 }),
{ staleTime: 5 * 60 * 1000 }
);
const {
data,
isLoading,
@@ -118,7 +168,7 @@ const BlogPage: React.FC = () => {
hasNextPage,
fetchNextPage,
} = useInfiniteQuery<Paginated<Article>>(
['articles-public', { page_size: pageSize, published: true, category_id: categoryId || undefined, month: month || undefined, match_id: matchId || undefined }],
['articles-public', { page_size: pageSize, published: true, category_id: categoryId || undefined, month: month || undefined, match_id: matchId || undefined, q: qParam || undefined }],
({ pageParam = 1 }) =>
getArticles({
page: pageParam,
@@ -127,6 +177,7 @@ const BlogPage: React.FC = () => {
...(categoryId ? { category_id: Number(categoryId) } : {}),
...(month ? { month } : {}),
...(matchId ? { match_id: matchId } : {}),
...(qParam ? { q: qParam } : {}),
}),
{
getNextPageParam: (lastPage, allPages) => {
@@ -139,6 +190,9 @@ const BlogPage: React.FC = () => {
);
const articles = data?.pages?.flatMap((p) => p?.data || []) || [];
const featuredList = featuredQ.data?.data || [];
const featuredIdSet = React.useMemo(() => new Set((featuredList || []).map((a) => a.id)), [featuredList]);
const visibleArticles = featuredList.length ? articles.filter((a) => !featuredIdSet.has(a.id)) : articles;
// Infinite scroll via intersection observer
const sentinelRef = React.useRef<HTMLDivElement | null>(null);
@@ -161,32 +215,92 @@ const BlogPage: React.FC = () => {
{/* Header like blog.html */}
<Box bg="transparent" color="inherit" py={{ base: 8, md: 10 }} mb={4} borderBottom="1px" borderColor={borderColor}>
<Container maxW="7xl">
<HStack justify="space-between" align="center">
<HStack justify="space-between" align="center" spacing={4}>
<Heading as="h1" size={{ base: 'xl', md: '2xl' }}>Blog</Heading>
{!!categories.length && (
<Select
maxW={{ base: '52%', md: '320px' }}
placeholder="Všechny kategorie"
value={categoryId}
onChange={(e) => {
const val = e.target.value ? Number(e.target.value) : '';
setCategoryId(val);
const next: Record<string, string> = {};
if (val) next.category_id = String(val);
if (month) next.month = month;
if (matchId) next.match_id = matchId;
setSearchParams(next);
}}
>
{categories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</Select>
)}
<HStack spacing={3} w={{ base: '56%', md: '520px' }}>
<Box flex="1">
<InputGroup>
<InputLeftElement pointerEvents="none">
<Search size={16} />
</InputLeftElement>
<Input
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>
<IconButton
aria-label="Clear search"
size="sm"
variant="ghost"
onClick={() => {
setQInput('');
const next: Record<string, string> = {};
if (categoryId) next.category_id = String(categoryId);
if (month) next.month = month;
if (matchId) next.match_id = matchId;
setSearchParams(next);
}}
icon={<X size={14} />}
/>
</InputRightElement>
)}
</InputGroup>
</Box>
{!!categories.length && (
<Select
maxW={{ base: '44%', md: '240px' }}
placeholder="Všechny kategorie"
value={categoryId}
onChange={(e) => {
const val = e.target.value ? Number(e.target.value) : '';
setCategoryId(val);
const next: Record<string, string> = {};
if (val) next.category_id = String(val);
if (qParam) next.q = qParam;
if (month) next.month = month;
if (matchId) next.match_id = matchId;
setSearchParams(next);
}}
>
{categories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</Select>
)}
</HStack>
</HStack>
</Container>
</Box>
{featuredList.length > 0 && (
<Container maxW="7xl" mb={6}>
<Grid templateColumns={{ base: '1fr', md: '2fr 1fr' }} gap={6}>
<GridItem>
<BlogTile article={featuredList[0]} variant="large" />
</GridItem>
<GridItem>
<VStack spacing={6} align="stretch">
{featuredList.slice(1, 3).map((a) => (
<BlogTile key={a.id} article={a} variant="small" />
))}
</VStack>
</GridItem>
</Grid>
</Container>
)}
<Container maxW="7xl">
{/* Masonry using CSS columns */}
<Box
@@ -198,7 +312,7 @@ const BlogPage: React.FC = () => {
{isLoading && Array.from({ length: 9 }).map((_, i) => (
<Skeleton key={i} h={{ base: '220px', md: '260px' }} borderRadius="md" mb={7} />
))}
{!isLoading && articles.map((a) => (
{!isLoading && visibleArticles.map((a) => (
<Box
key={a.id}
mb={7}
@@ -212,7 +326,7 @@ const BlogPage: React.FC = () => {
</Box>
))}
</Box>
{!isLoading && !articles.length && (
{!isLoading && !featuredList.length && !visibleArticles.length && (
<VStack py={16}>
<Text color={textColor}>Žádné články k zobrazení.</Text>
</VStack>
+2 -2
View File
@@ -151,10 +151,10 @@ const ContactPage: React.FC = () => {
if (!hasLocation && !hasContacts && !hasContactInfo) return null;
return (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={8}>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={8} alignItems="start">
{/* Map on the left */}
{hasLocation && (
<Box borderRadius="lg" overflow="hidden" boxShadow="md">
<Box borderRadius="lg" overflow="hidden" boxShadow="md" alignSelf="start">
<ContactMap
latitude={lat}
longitude={lng}
+49 -46
View File
@@ -1,61 +1,64 @@
import { Box, Button, Heading, Text, VStack, HStack, Icon, Divider } from '@chakra-ui/react';
import { Link as RouterLink, useLocation, useNavigate } from 'react-router-dom';
import { GiWhistle, GiSoccerBall } from 'react-icons/gi';
import MainLayout from '../components/layout/MainLayout';
const ForbiddenPage: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
return (
<Box
minH="70vh"
display="flex"
alignItems="center"
justifyContent="center"
bgGradient="linear(to-b, rgba(0,0,0,0.02), transparent)"
px={6}
>
<MainLayout>
<Box
w={{ base: '100%', sm: '560px' }}
textAlign="center"
bg="white"
_dark={{ bg: 'gray.800' }}
borderRadius="xl"
borderWidth="1px"
borderColor="border.subtle"
boxShadow="md"
p={{ base: 6, md: 10 }}
minH="60vh"
display="flex"
alignItems="center"
justifyContent="center"
bgGradient="linear(to-b, rgba(0,0,0,0.02), transparent)"
px={6}
>
<VStack spacing={4}>
<HStack spacing={3} color="brand.primary">
<Icon as={GiWhistle} boxSize={8} />
<Heading as="h1" size="2xl" letterSpacing="wide">
403
<Box
w={{ base: '100%', sm: '560px' }}
textAlign="center"
bg="white"
_dark={{ bg: 'gray.800' }}
borderRadius="xl"
borderWidth="1px"
borderColor="border.subtle"
boxShadow="md"
p={{ base: 6, md: 10 }}
>
<VStack spacing={4}>
<HStack spacing={3} color="brand.primary">
<Icon as={GiWhistle} boxSize={8} />
<Heading as="h1" size="2xl" letterSpacing="wide">
403
</Heading>
<Icon as={GiSoccerBall} boxSize={8} />
</HStack>
<Heading
as="h2"
size="lg"
bgGradient="linear(to-r, brand.primary, brand.accent)"
bgClip="text"
>
Přístup odepřen
</Heading>
<Icon as={GiSoccerBall} boxSize={8} />
</HStack>
<Heading
as="h2"
size="lg"
bgGradient="linear(to-r, brand.primary, brand.accent)"
bgClip="text"
>
Přístup odepřen
</Heading>
<Text color="gray.600" _dark={{ color: 'gray.300' }}>
Rozhodčí píská: tato část hřiště je jen pro kapitány (adminy).
</Text>
<Divider />
<HStack spacing={3} pt={2}>
<Button onClick={() => navigate(-1)} colorScheme="blue">
Zpět
</Button>
<Button as={RouterLink} to="/" variant="outline">
Zpět na úvod
</Button>
</HStack>
</VStack>
<Text color="gray.600" _dark={{ color: 'gray.300' }}>
Rozhodčí píská: tato část hřiště je jen pro kapitány (adminy).
</Text>
<Divider />
<HStack spacing={3} pt={2}>
<Button onClick={() => navigate(-1)} colorScheme="blue">
Zpět
</Button>
<Button as={RouterLink} to="/" variant="outline">
Zpět na úvod
</Button>
</HStack>
</VStack>
</Box>
</Box>
</Box>
</MainLayout>
);
};
+172 -75
View File
@@ -10,6 +10,8 @@ import { getPublicSettings } from '../services/settings';
import { assetUrl, sanitizeClubName } from '../utils/url';
import { getPlayers as apiGetPlayers, Player as ApiPlayer } from '../services/players';
import { getSponsors as apiGetSponsors, Sponsor as ApiSponsor } from '../services/sponsors';
import { getBanners as apiGetBanners, Banner as ApiBanner } from '../services/banners';
import BannerDisplay from '../components/banners/BannerDisplay';
import BlogCardsScroller from '../components/home/BlogCardsScroller';
import BlogSwiper from '../components/home/BlogSwiper';
import VideosSection from '../components/home/VideosSection';
@@ -98,8 +100,8 @@ 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 };
type UiSponsor = { id:number|string; name:string; logo:string; url?:string };
type UiPlayer = { id:number|string; name:string; number?:number; position?:string; image?:string; slug?:string; age?: number };
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 };
type UiEvent = { id:number|string; title:string; start_time:string; end_time?:string|null; location?:string|null; type?:string; image_url?:string|null };
@@ -400,11 +402,21 @@ const HomePage: React.FC = () => {
number: p.jersey_number,
position: p.position,
image: assetUrl(p.image_url) || undefined,
age: (function(iso?: string){
if (!iso) return undefined;
const d = new Date(iso);
if (isNaN(d.getTime())) return undefined;
const today = new Date();
let age = today.getFullYear() - d.getFullYear();
const m = today.getMonth() - d.getMonth();
if (m < 0 || (m === 0 && today.getDate() < d.getDate())) age--;
return age;
})( (p as any).date_of_birth ),
}));
setPlayers(mappedPlayers);
} catch {}
// Load sponsors via API (also used for banners with placement metadata)
// Load sponsors via API (sponsors only)
try {
const apiSponsors: ApiSponsor[] = await apiGetSponsors();
const mapped: UiSponsor[] = (apiSponsors || []).map((s: ApiSponsor) => ({
@@ -412,21 +424,24 @@ const HomePage: React.FC = () => {
name: s.name,
logo: assetUrl(s.logo_url) || '/images/sponsors/placeholder.png',
url: s.website_url || undefined,
tier: (s as any).tier,
}));
setSponsors(mapped);
// Extract banners by placement metadata if provided
const mappedBanners: UiBanner[] = (apiSponsors || [])
.filter((s: any) => s && (s as any).placement)
.map((s: any) => ({
id: s.id,
name: s.name,
image: assetUrl(s.logo_url) || '/images/sponsors/placeholder.png',
url: s.website_url || undefined,
placement: s.placement,
width: typeof s.width === 'number' ? s.width : undefined,
height: typeof s.height === 'number' ? s.height : undefined,
}));
if (mappedBanners.length) setBanners(mappedBanners);
} catch {}
// Load banners via dedicated API (separate from sponsors)
try {
const apiBanners: ApiBanner[] = await apiGetBanners({ active: true });
const mappedBanners: UiBanner[] = (apiBanners || []).map((b: any) => ({
id: b.id,
name: b.name,
image: assetUrl(b.image_url) || '/images/sponsors/placeholder.png',
url: b.click_url || undefined,
placement: b.placement,
width: typeof b.width === 'number' ? b.width : undefined,
height: typeof b.height === 'number' ? b.height : undefined,
}));
setBanners(mappedBanners);
} catch {}
// Load featured articles (homepage primary) via API
@@ -472,6 +487,7 @@ const HomePage: React.FC = () => {
name: s.name || 'Sponsor',
logo: s.logo_url || s.logoUrl || s.logo || '/images/sponsors/placeholder.png',
url: s.url || s.website || s.link || '#',
tier: s.tier,
}))
);
}
@@ -1032,7 +1048,8 @@ const HomePage: React.FC = () => {
<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>
<div className="number">#{p.number || '—'}</div>
{typeof p.number !== 'undefined' && <div className="number">#{p.number}</div>}
{typeof p.age === 'number' && <div className="age">{p.age} let</div>}
</div>
))}
</div>
@@ -1321,14 +1338,14 @@ const HomePage: React.FC = () => {
// }
return (
<MainLayout headerInsideContainer>
<MainLayout headerInsideContainer showSponsorsSection={false}>
<div className="container" data-element="container" style={{ ...getStyles('container') }}>
<div data-element="style-pack" data-variant={stylePack} style={{ display: 'none' }} />
{/* Above-hero club bar (MyUIbrix managed) */}
{isVisible('hero-topbar', true) && (
<section data-element="hero-topbar" data-variant={getVariant('hero-topbar', 'brand')} style={{ ...getStyles('hero-topbar') }}>
<section data-element="hero-topbar" data-variant={getVariant('hero-topbar', 'minimal')} style={{ ...getStyles('hero-topbar') }}>
<ClubHeroTopbar
variant={(getVariant('hero-topbar', 'brand') as any) as 'brand' | 'minimal' | 'badge'}
variant={(getVariant('hero-topbar', 'minimal') as any) as 'brand' | 'minimal' | 'badge'}
fullBleed={getVariant('header', 'unified') === 'fullwidth'}
/>
</section>
@@ -1488,6 +1505,11 @@ const HomePage: React.FC = () => {
/>
) : null}
{/* Full-bleed top banner (homepage_top) */}
{(banners || []).some(b => b.placement === 'homepage_top') && (
<BannerDisplay banners={banners as any} placement="homepage_top" />
)}
{/* Matches slider with scores by competition (moved after news+tables) */}
{facrCompetitions.length > 0 && (
<MatchesSlider
@@ -1513,8 +1535,10 @@ const HomePage: React.FC = () => {
(matchingStanding.rows && matchingStanding.rows.length > 0)
);
const newsVariant = getVariant('news', 'grid_one');
const showNews = isVisible('news', true);
const showTable = isVisible('table', true) && hasStandingsForCurrentTab;
let showTable = isVisible('table', true) && hasStandingsForCurrentTab;
if (newsVariant === 'grid_one') { showTable = false; }
const variant = showNews && showTable ? undefined : 'standard';
if (!showNews && !showTable) return null;
@@ -1525,7 +1549,7 @@ const HomePage: React.FC = () => {
style={{ marginTop: 32 }}
>
{showNews && (
<section data-element="news" data-variant={getVariant('news', 'grid')} className="news-list" style={{ ...getStyles('news') }}>
<section data-element="news" data-variant={newsVariant} className="news-list" style={{ ...getStyles('news') }}>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Další aktuality</h3>
<a href="/news" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
@@ -1541,6 +1565,7 @@ const HomePage: React.FC = () => {
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
</div>
<StandingsCard
variant={((): 'logos'|'plain' => { const v = getVariant('table_rows', 'logos'); return v === 'plain' ? 'plain' : 'logos'; })()}
rows={(matchingStanding?.table || matchingStanding?.rows || []) as any}
onRowClick={(row) => {
const clubData = {
@@ -1565,6 +1590,11 @@ const HomePage: React.FC = () => {
);
})()}
{/* Banner under tables (homepage_under_table) */}
{(banners || []).some(b => b.placement === 'homepage_under_table') && (
<BannerDisplay banners={banners as any} placement="homepage_under_table" />
)}
{/* Competition tables moved into right column below */}
{upcomingEvents.length > 0 && isVisible('activities', true) && (
@@ -1590,8 +1620,9 @@ const HomePage: React.FC = () => {
{players.map((p) => (
<a key={p.id} href={p.slug ? `/players/${p.slug}` : `/players/${p.id}`} className="player-card">
<div className="photo" style={{ backgroundImage: `url(${assetUrl(p.image) || p.image})` }} />
<div className="meta"><span className="nr">#{p.number}</span> {p.name}</div>
<div className="meta">{typeof p.number !== 'undefined' ? (<><span className="nr">#{p.number}</span> {p.name}</>) : p.name}</div>
<div className="pos">{p.position}</div>
{typeof p.age === 'number' && <div className="age">{p.age} let</div>}
</a>
))}
</div>
@@ -1654,62 +1685,128 @@ const HomePage: React.FC = () => {
</section>
)}
{/* Sponsors: grid or slider (controlled by settings); dark theme supported; full-bleed */}
{/* Sponsors: MyUIbrix-controlled variant (grid | slider | scroller | pyramid); dark theme supported; full-bleed */}
{isVisible('sponsors', true) && (
<section
data-element="sponsors"
className={`sponsors ${sponsorsTheme === 'dark' ? 'dark' : ''}`}
style={{
width: '100vw',
position: 'relative',
left: '50%',
right: '50%',
transform: 'translateX(-50%)',
paddingLeft: 'max(16px, calc((100vw - 1200px) / 2))',
paddingRight: 'max(16px, calc((100vw - 1200px) / 2))',
boxSizing: 'border-box',
...getStyles('sponsors')
}}
>
<div className="section-head">
<h3>Sponzoři</h3>
</div>
{sponsorLayout==='grid' ? (
(()=>{
const title = sponsors.find((s:any)=>s.tier==='title') || sponsors[0];
const others = sponsors.filter((s)=>s !== title);
(() => {
const variant = (getVariant('sponsors', sponsorLayout) as any) as 'grid' | 'slider' | 'scroller' | 'pyramid';
const all = sponsors || [];
const general = all.filter((s: any) => String(s.tier || '').toLowerCase() === 'general' || String(s.tier || '').toLowerCase() === 'title' || String(s.tier || '').toLowerCase() === 'main');
const standard = all.filter((s: any) => !(String(s.tier || '').toLowerCase() === 'general' || String(s.tier || '').toLowerCase() === 'title' || String(s.tier || '').toLowerCase() === 'main'));
const ordered = [...general, ...standard];
const renderPyramid = () => {
const capacities = [1, 4, 8, 12, 16];
const takeRows = (items: typeof ordered) => {
const rows: Array<typeof ordered> = [];
let idx = 0;
for (let capIndex = 0; idx < items.length && capIndex < capacities.length; capIndex++) {
const cap = capacities[capIndex];
rows.push(items.slice(idx, idx + cap));
idx += cap;
}
// If still remaining, continue with last capacity repeated
const lastCap = capacities[capacities.length - 1];
while (idx < items.length) {
rows.push(items.slice(idx, idx + lastCap));
idx += lastCap;
}
return rows;
};
const generalRows = takeRows(general);
const standardRows = takeRows(standard);
return (
<>
{title && (
<div className="title-sponsor">
<a className="sponsor-tile" href={title.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(title.logo) || '/images/sponsors/placeholder.png'} alt={title.name} />
</a>
<div className="pyramid">
{generalRows.map((row, i) => (
<div key={`gen-${i}`} className="pyramid-row" style={{ display: 'grid', gridTemplateColumns: `repeat(${Math.max(1, row.length)}, 1fr)`, gap: 16, marginBottom: 12 }}>
{row.map((s) => (
<a key={`g-${s.id}`} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
</a>
))}
</div>
)}
<div className="divider" aria-hidden />
<div className="sponsors-grid">
{others.map((s) => (
<a key={s.id} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
</a>
))}
</div>
</>
))}
{generalRows.length > 0 && standardRows.length > 0 && <div className="divider" aria-hidden />}
{standardRows.map((row, i) => (
<div key={`std-${i}`} className="pyramid-row" style={{ display: 'grid', gridTemplateColumns: `repeat(${Math.max(1, row.length)}, 1fr)`, gap: 16, marginBottom: 12 }}>
{row.map((s) => (
<a key={`s-${s.id}`} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
</a>
))}
</div>
))}
</div>
);
})()
) : (
<div className="sponsors-slider">
<div className="track">
{[...sponsors, ...sponsors].map((s, idx) => (
<a key={`${s.id}-${idx}`} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
</a>
))}
</div>
</div>
)}
</section>
};
return (
<section
data-element="sponsors"
data-variant={variant}
className={`sponsors ${sponsorsTheme === 'dark' ? 'dark' : ''}`}
style={{
width: '100vw',
position: 'relative',
left: '50%',
right: '50%',
transform: 'translateX(-50%)',
paddingLeft: 'max(16px, calc((100vw - 1200px) / 2))',
paddingRight: 'max(16px, calc((100vw - 1200px) / 2))',
boxSizing: 'border-box',
...getStyles('sponsors')
}}
>
<div className="section-head">
<h3>Sponzoři</h3>
</div>
{variant === 'grid' && (
<>
{general.length > 0 && (
<div className="title-sponsor">
{general.map((g) => (
<a key={`g-${g.id}`} className="sponsor-tile" href={g.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(g.logo) || '/images/sponsors/placeholder.png'} alt={g.name} />
</a>
))}
</div>
)}
{(general.length > 0 && standard.length > 0) && <div className="divider" aria-hidden />}
<div className="sponsors-grid">
{(standard.length > 0 ? standard : (general.length === 0 ? ordered : [])).map((s) => (
<a key={s.id} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
</a>
))}
</div>
</>
)}
{variant === 'pyramid' && renderPyramid()}
{variant === 'slider' && (
<div className="sponsors-slider">
<div className="track">
{[...ordered, ...ordered].map((s, idx) => (
<a key={`${s.id}-${idx}`} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
</a>
))}
</div>
</div>
)}
{variant === 'scroller' && (
<div className="sponsors-scroller">
<div className="belt">
{[...ordered, ...ordered, ...ordered].map((s, idx) => (
<a key={`${s.id}-${idx}`} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
</a>
))}
</div>
</div>
)}
</section>
);
})()
)}
</div>
<ClubModal
@@ -68,6 +68,19 @@ const NewsletterPreferencesPage: React.FC = () => {
const [prefs, setPrefs] = useState<SubscriberPreferences>(initialPrefs);
React.useEffect(() => { setPrefs(initialPrefs); }, [initialPrefs]);
// If competitions list is available and user has no selection, default to ALL selected
React.useEffect(() => {
if (Array.isArray(competitions) && competitions.length > 0) {
const raw = String(prefs.competitions || '').trim();
if (!raw) {
const codes = competitions.map((c: any, idx: number) => String(c?.code || c?.id || c?.name || `comp-${idx}`)).filter(Boolean);
if (codes.length > 0) {
setPrefs((prev) => ({ ...prev, competitions: codes.join(', ') }));
}
}
}
}, [competitions]);
const saveMut = useMutation({
mutationFn: () => savePreferences(token, prefs),
onSuccess: () => {
@@ -198,6 +211,13 @@ const NewsletterPreferencesPage: React.FC = () => {
<FormLabel>Preferované soutěže</FormLabel>
{Array.isArray(competitions) && competitions.length > 0 ? (
<VStack align="stretch" spacing={1} maxH="220px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
<HStack mb={1}>
<Button size="xs" variant="outline" onClick={()=>{
const all = competitions.map((c: any, idx: number) => String(c?.code || c?.id || c?.name || `comp-${idx}`)).filter(Boolean);
setPrefs({ ...prefs, competitions: all.join(', ') });
}}>Zapnout vše</Button>
<Button size="xs" variant="ghost" onClick={()=> setPrefs({ ...prefs, competitions: '' })}>Vypnout vše</Button>
</HStack>
{competitions.map((c: any, idx: number) => {
const code = (c?.code || c?.id || c?.name || `comp-${idx}`) as string;
const name = (c?.name || c?.code || code) as string;
+49 -46
View File
@@ -2,60 +2,63 @@ import { Box, Button, Heading, Text, VStack, HStack, Icon, Divider } from '@chak
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { GiSoccerBall } from 'react-icons/gi';
import { MdSportsSoccer } from 'react-icons/md';
import MainLayout from '../components/layout/MainLayout';
const NotFoundPage: React.FC = () => {
const navigate = useNavigate();
return (
<Box
minH="70vh"
display="flex"
alignItems="center"
justifyContent="center"
bgGradient="linear(to-b, rgba(0,0,0,0.02), transparent)"
px={6}
>
<MainLayout>
<Box
w={{ base: '100%', sm: '560px' }}
textAlign="center"
bg="white"
_dark={{ bg: 'gray.800' }}
borderRadius="xl"
borderWidth="1px"
borderColor="border.subtle"
boxShadow="md"
p={{ base: 6, md: 10 }}
minH="60vh"
display="flex"
alignItems="center"
justifyContent="center"
bgGradient="linear(to-b, rgba(0,0,0,0.02), transparent)"
px={6}
>
<VStack spacing={4}>
<HStack spacing={3} color="brand.primary">
<Icon as={GiSoccerBall} boxSize={8} />
<Heading as="h1" size="2xl" letterSpacing="wide">
404
<Box
w={{ base: '100%', sm: '560px' }}
textAlign="center"
bg="white"
_dark={{ bg: 'gray.800' }}
borderRadius="xl"
borderWidth="1px"
borderColor="border.subtle"
boxShadow="md"
p={{ base: 6, md: 10 }}
>
<VStack spacing={4}>
<HStack spacing={3} color="brand.primary">
<Icon as={GiSoccerBall} boxSize={8} />
<Heading as="h1" size="2xl" letterSpacing="wide">
404
</Heading>
<Icon as={MdSportsSoccer} boxSize={8} />
</HStack>
<Heading
as="h2"
size="lg"
bgGradient="linear(to-r, brand.primary, brand.accent)"
bgClip="text"
>
Stránka nenalezena
</Heading>
<Icon as={MdSportsSoccer} boxSize={8} />
</HStack>
<Heading
as="h2"
size="lg"
bgGradient="linear(to-r, brand.primary, brand.accent)"
bgClip="text"
>
Stránka nenalezena
</Heading>
<Text color="gray.600" _dark={{ color: 'gray.300' }}>
Míč je mimo hřiště zkuste to znovu nebo vraťte se.
</Text>
<Divider />
<HStack spacing={3} pt={2}>
<Button onClick={() => navigate(-1)} colorScheme="blue">
Zpět
</Button>
<Button as={RouterLink} to="/" variant="outline">
Zpět na úvod
</Button>
</HStack>
</VStack>
<Text color="gray.600" _dark={{ color: 'gray.300' }}>
Míč je mimo hřiště zkuste to znovu nebo vraťte se.
</Text>
<Divider />
<HStack spacing={3} pt={2}>
<Button onClick={() => navigate(-1)} colorScheme="blue">
Zpět
</Button>
<Button as={RouterLink} to="/" variant="outline">
Zpět na úvod
</Button>
</HStack>
</VStack>
</Box>
</Box>
</Box>
</MainLayout>
);
};
+15 -1
View File
@@ -73,7 +73,7 @@ const PlayerDetailPage: React.FC = () => {
<Text><b>Národnost:</b> {translateNationality(data.nationality)}</Text>
)}
{data.date_of_birth && (
<Text><b>Datum narození:</b> {new Date(data.date_of_birth).toLocaleDateString('cs-CZ')}</Text>
<Text><b>Datum narození:</b> {new Date(data.date_of_birth).toLocaleDateString('cs-CZ')} {calculateAge(data.date_of_birth)} let</Text>
)}
{(data.height || data.weight) && (
<Text>
@@ -105,4 +105,18 @@ const PlayerDetailPage: React.FC = () => {
);
};
function calculateAge(iso: string): number | null {
try {
const d = new Date(iso);
if (isNaN(d.getTime())) return null;
const today = new Date();
let age = today.getFullYear() - d.getFullYear();
const m = today.getMonth() - d.getMonth();
if (m < 0 || (m === 0 && today.getDate() < d.getDate())) age--;
return age;
} catch {
return null;
}
}
export default PlayerDetailPage;
+98 -2
View File
@@ -21,11 +21,13 @@ import {
Icon,
Badge,
} from '@chakra-ui/react';
import { FiSave, FiEye, FiCode, FiLayout, FiZap } from 'react-icons/fi';
import { FiSave, FiEye, FiCode, FiLayout, FiZap, FiPlus, FiTrash } from 'react-icons/fi';
import AdminLayout from '../../layouts/AdminLayout';
import RichTextEditor from '../../components/common/RichTextEditor';
import api from '../../services/api';
import { generateAboutAI } from '../../services/ai';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { getCategories, CategoryItem } from '../../services/categories';
type AboutPageData = {
id?: number;
@@ -40,11 +42,17 @@ type AboutPageData = {
const AboutAdminPage: React.FC = () => {
const toast = useToast();
const { data: settings } = usePublicSettings();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [aiGenerating, setAiGenerating] = useState(false);
const [aiPrompt, setAiPrompt] = useState('');
const [aiAudience, setAiAudience] = useState('Fanoušci klubu');
const [categories, setCategories] = useState<CategoryItem[]>([]);
const [foundationYear, setFoundationYear] = useState<string>('');
const [timelineEvents, setTimelineEvents] = useState<Array<{ year: string; title: string }>>([
{ year: '', title: '' },
]);
const [data, setData] = useState<AboutPageData>({
title: '',
subtitle: '',
@@ -57,6 +65,7 @@ const AboutAdminPage: React.FC = () => {
useEffect(() => {
loadData();
loadCategories();
}, []);
const loadData = async () => {
@@ -73,6 +82,13 @@ const AboutAdminPage: React.FC = () => {
}
};
const loadCategories = async () => {
try {
const cats = await getCategories();
setCategories(cats || []);
} catch {}
};
const handleSave = async () => {
if (!data.title.trim()) {
toast({ title: 'Chyba', description: 'Vyplňte název stránky', status: 'warning' });
@@ -122,10 +138,28 @@ const AboutAdminPage: React.FC = () => {
setAiGenerating(true);
try {
const clubName = settings?.club_name || '';
const catsText = categories && categories.length
? `Rubriky (kategorie): ${categories.map((c) => `${c.name}${c.description ? ` ${c.description}` : ''}`).join('; ')}`
: '';
const styleIntro = `Zvolený styl: ${data.style}.`;
const timelineDetails = data.style === 'timeline'
? `\nČasová osa dodatečné informace:\nRok založení: ${foundationYear || 'neuvedeno'}.\nKlíčové milníky (rok: událost):\n${timelineEvents
.filter((e) => e.year.trim() || e.title.trim())
.map((e) => `- ${e.year.trim() || '????'}: ${e.title.trim() || ''}`)
.join('\n')}`
: '';
const extraGuidelines = data.style === 'timeline'
? 'Piš chronologicky, používej podnadpisy (h3) s rokem, pod nimi krátký odstavec. Kde se hodí, vlož seznamy (ul/li).'
: 'Rozděl text do sekcí s h2/h3 a odstavci. Kde se hodí, vlož seznamy (ul/li).';
const fullPrompt = `${aiPrompt.trim()}\n\nInformace o klubu:\nNázev klubu: ${clubName || 'Fotbalový klub'}.\n${catsText}\n${styleIntro}${timelineDetails}\n\nPokyny pro výstup: ${extraGuidelines}`;
const result = await generateAboutAI({
prompt: aiPrompt,
prompt: fullPrompt,
audience: aiAudience,
style: data.style,
club_name: clubName,
});
setData((prev) => ({
...prev,
@@ -208,6 +242,68 @@ const AboutAdminPage: React.FC = () => {
/>
</FormControl>
<FormControl>
<FormLabel>Styl stránky</FormLabel>
<Select
value={data.style}
onChange={(e) =>
setData((prev) => ({ ...prev, style: e.target.value as any }))
}
>
{Object.entries(styleDescriptions).map(([key, { name }]) => (
<option key={key} value={key}>
{name}
</option>
))}
</Select>
<Text fontSize="sm" color="gray.600" mt={2}>
{styleDescriptions[data.style]?.desc}
</Text>
</FormControl>
{data.style === 'timeline' && (
<Box borderWidth="1px" borderRadius="md" p={4} bg="gray.50">
<Heading size="sm" mb={3}>Časová osa podklady</Heading>
<VStack align="stretch" spacing={3}>
<FormControl>
<FormLabel>Rok založení</FormLabel>
<Input
type="text"
value={foundationYear}
onChange={(e) => setFoundationYear(e.target.value)}
placeholder="např. 1932"
/>
</FormControl>
<Box>
<HStack justify="space-between" mb={2}>
<Text fontWeight="semibold">Klíčové milníky</Text>
<Button size="sm" leftIcon={<FiPlus />} onClick={() => setTimelineEvents((prev) => [...prev, { year: '', title: '' }])}>Přidat milník</Button>
</HStack>
<VStack align="stretch" spacing={2}>
{timelineEvents.map((ev, idx) => (
<HStack key={idx} spacing={2} align="stretch">
<Input
placeholder="Rok"
value={ev.year}
onChange={(e) => setTimelineEvents((prev) => prev.map((it, i) => i === idx ? { ...it, year: e.target.value } : it))}
maxW="120px"
/>
<Input
placeholder="Událost / popis"
value={ev.title}
onChange={(e) => setTimelineEvents((prev) => prev.map((it, i) => i === idx ? { ...it, title: e.target.value } : it))}
/>
<Button aria-label="Odebrat" size="sm" colorScheme="red" variant="outline" onClick={() => setTimelineEvents((prev) => prev.filter((_, i) => i !== idx))}>
<Icon as={FiTrash} />
</Button>
</HStack>
))}
</VStack>
</Box>
</VStack>
</Box>
)}
{/* Hero image removed: About page uses club logo and name from settings */}
<Box
@@ -480,7 +480,7 @@ const AdminActivitiesPage: React.FC = () => {
/>
) : (
<ChakraImage
src={settingsQ.data?.club_logo_url || '/dist/img/logo-club-empty.svg'}
src={assetUrl(settingsQ.data?.club_logo_url) || assetUrl('/dist/img/logo-club-empty.svg') || '/dist/img/logo-club-empty.svg'}
alt="No image"
boxSize="48px"
objectFit="contain"
+80 -2
View File
@@ -1,8 +1,8 @@
import React, { useEffect, useState } from 'react';
import AdminLayout from '../../layouts/AdminLayout';
import { Box, Heading, Button, SimpleGrid, FormControl, FormLabel, Input, HStack, VStack, Text, IconButton, useToast, Divider, Textarea, Switch, NumberInput, NumberInputField } from '@chakra-ui/react';
import { Box, Heading, Button, SimpleGrid, FormControl, FormLabel, Input, HStack, VStack, Text, IconButton, useToast, Divider, Textarea, Switch, NumberInput, NumberInputField, Image, Badge, useColorModeValue } from '@chakra-ui/react';
import { getClothingAdmin, createClothing, updateClothing, deleteClothing, ClothingItem } from '../../services/clothing';
import { FiPlus, FiTrash2, FiSave } from 'react-icons/fi';
import { FiPlus, FiTrash2, FiSave, FiExternalLink } from 'react-icons/fi';
const emptyItem: Partial<ClothingItem> = {
title: '',
@@ -15,6 +15,77 @@ const emptyItem: Partial<ClothingItem> = {
display_order: 0
};
const PreviewCard: React.FC<{ item: Partial<ClothingItem> }> = ({ item }) => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
return (
<Box
role="group"
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="xl"
overflow="hidden"
transition="all 0.3s"
_hover={{ transform: 'translateY(-4px)', boxShadow: 'lg' }}
>
<Box position="relative" paddingTop="100%" overflow="hidden">
<Image
src={item.image_url}
alt={item.title || 'Náhled produktu'}
position="absolute"
top={0}
left={0}
width="100%"
height="100%"
objectFit="cover"
fallbackSrc="/images/placeholder-clothing.jpg"
/>
{item.url && (
<Box
position="absolute"
top={2}
right={2}
bg="white"
borderRadius="full"
p={2}
opacity={0}
_groupHover={{ opacity: 1 }}
transition="opacity 0.2s"
>
<FiExternalLink size={16} />
</Box>
)}
</Box>
<VStack align="stretch" p={4} spacing={2}>
<Heading as="h3" size="sm" noOfLines={2}>
{item.title || 'Název produktu'}
</Heading>
{item.description && (
<Text fontSize="sm" color="gray.600" noOfLines={2}>
{item.description}
</Text>
)}
<HStack justify="space-between" mt={2}>
{item.price && item.price > 0 ? (
<Badge colorScheme="blue" fontSize="md" px={2} py={1}>
{item.price} {item.currency || 'Kč'}
</Badge>
) : (
<Box />
)}
{item.url && (
<Text fontSize="xs" color="blue.500">
Zobrazit
</Text>
)}
</HStack>
</VStack>
</Box>
);
};
const AdminMerchPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -213,6 +284,13 @@ const AdminMerchPage: React.FC = () => {
rows={2}
/>
</FormControl>
<Box mt={4}>
<Heading size="xs" mb={2}>Náhled veřejné karty</Heading>
<Box maxW="360px">
<PreviewCard item={it} />
</Box>
</Box>
</Box>
))}
{items.length === 0 && (
+28 -8
View File
@@ -178,7 +178,7 @@ const AdminVideosPage: React.FC = () => {
setSelectedIds((prev) => ({ ...prev, [id]: !prev[id] }));
};
const importSelected = () => {
const importSelected = async () => {
const selected = ytVideos.filter((v) => selectedIds[v.video_id]);
if (selected.length === 0) {
toast({ status: 'info', title: 'Nic k importu', description: 'Vyberte alespoň jedno video.' });
@@ -203,10 +203,30 @@ const AdminVideosPage: React.FC = () => {
}
return merged;
});
// If currently in auto mode, switch to manual so the preview reflects newly added items
if (videosSource !== 'manual') {
setVideosSource('manual');
try {
await updateAdminSettings({ videos_source: 'manual' });
toast({ status: 'info', title: 'Přepnuto na ruční správu', description: 'Nově přidaná videa se budou používat. Nezapomeňte uložit seznam.', duration: 3500 });
} catch {
// ignore
}
}
toast({ status: 'success', title: 'Videa přidána', description: `${selected.length} videí bylo přidáno do seznamu.` });
};
const addItem = () => setItems((prev) => [...prev, { ...emptyItem }]);
const addItem = async () => {
setItems((prev) => [...prev, { ...emptyItem }]);
if (videosSource !== 'manual') {
setVideosSource('manual');
try {
await updateAdminSettings({ videos_source: 'manual' });
} catch {
// ignore
}
}
};
const removeItem = (idx: number) => setItems((prev) => prev.filter((_, i) => i !== idx));
const updateField = (idx: number, key: keyof AdminVideoItem, val: string) => {
setItems((prev) => prev.map((it, i) => i === idx ? { ...it, [key]: val } : it));
@@ -341,13 +361,13 @@ const AdminVideosPage: React.FC = () => {
Použijte Scraper API. Zadejte handle (např. <code>@FotbalKunovice</code>) nebo URL kanálu a načtěte videa z karty Videa.
Služba: <Link href="https://youtube.tdvorak.dev/" isExternal color="blue.500">https://youtube.tdvorak.dev/</Link>
</Text>
<HStack align="start" spacing={3}>
<HStack align="start" spacing={3} flexWrap="wrap">
<FormControl maxW={{ base: '100%', md: '400px' }}>
<FormLabel>Kanál (handle nebo URL)</FormLabel>
<Input id="admin-videos-channel-input" placeholder="@FCBizoniUH nebo https://www.youtube.com/@FCBizoniUH/videos" value={channelInput} onChange={(e) => setChannelInput(e.target.value)} />
</FormControl>
<Button onClick={fetchChannelVideos} isLoading={ytLoading} variant="outline">Načíst videa</Button>
<Button colorScheme="green" onClick={importSelected} isDisabled={ytVideos.length === 0}>Přidat vybraná</Button>
<Button onClick={fetchChannelVideos} isLoading={ytLoading} variant="outline" flexShrink={0} minW="max-content">Načíst videa</Button>
<Button colorScheme="green" onClick={importSelected} isDisabled={ytVideos.length === 0} flexShrink={0} minW="max-content">Přidat vybraná</Button>
</HStack>
{ytError && (
<Alert status="error" mt={3} borderRadius="md">
@@ -387,9 +407,9 @@ const AdminVideosPage: React.FC = () => {
<HStack justify="space-between" align="center" mb={2} flexWrap="wrap">
<Heading size="sm">Náhled: všechna videa (aktivní zdroj)</Heading>
{videosSource === 'auto' && (
<HStack spacing={2}>
<Input size="sm" placeholder="Filtrovat podle názvu" value={filter} onChange={(e) => setFilter(e.target.value)} />
<Button size="sm" onClick={refreshAuto} isLoading={autoLoading} variant="outline">Aktualizovat cache</Button>
<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>
</HStack>
)}
</HStack>
@@ -229,6 +229,7 @@ const ArticlesAdminPage = () => {
const { isOpen, onOpen, onClose } = useDisclosure();
const [isPending, startTransition] = React.useTransition();
const [aiPrompt, setAiPrompt] = useState('');
const [aiAudience, setAiAudience] = useState('Fanoušci klubu');
const [aiMinWords, setAiMinWords] = useState<number>(500);
@@ -1329,7 +1330,7 @@ const ArticlesAdminPage = () => {
</ModalHeader>
<ModalCloseButton />
<ModalBody maxH="calc(90vh - 120px)" overflowY="auto">
<Tabs variant="enclosed" colorScheme="blue" isFitted index={activeTabIndex} onChange={(index) => setActiveTabIndex(index)}>
<Tabs variant="enclosed" colorScheme="blue" isFitted index={activeTabIndex} onChange={(index) => setActiveTabIndex(index)} isLazy lazyBehavior="unmount">
<TabList>
<Tab>AI</Tab>
<Tab>Základní</Tab>
@@ -1355,7 +1356,7 @@ const ArticlesAdminPage = () => {
rows={8}
placeholder="Napište svůj text s důležitými informacemi. Příklad:&#10;&#10;Dnes naše mužstvo zvládlo důležitý zápas proti TJ Sokol Příbram. Konečný výsledek 3:1 pro nás. První poloasu jsme dominovali, Jana Novák dal dva góly. Ve druhé poloasu sice soupeř snížil, ale Petr Černý svým třetím gólem rozhodl.&#10;&#10;AI váš text rozšíří, přidá strukturu a doplní kontext pokud je krátký."
value={aiPrompt}
onChange={(e) => setAiPrompt(e.target.value)}
onChange={(e) => startTransition(() => setAiPrompt(e.target.value))}
fontSize="md"
bg={inputBg}
/>
+29 -20
View File
@@ -3,7 +3,7 @@ import { Box, Button, FormControl, FormLabel, Heading, HStack, IconButton, Image
import { FiPlus, FiEdit2, FiTrash2, FiUpload, FiAlertCircle, FiCheckCircle } from 'react-icons/fi';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { Sponsor, getSponsors, createSponsor, updateSponsor, deleteSponsor } from '../../services/sponsors';
import { Banner as AdminBanner, getBanners, createBanner, updateBanner, deleteBanner } from '../../services/banners';
import { uploadFile } from '../../services/articles';
import { assetUrl } from '../../utils/url';
@@ -15,7 +15,7 @@ type BannerPreset = {
width: number;
height: number;
aspectRatio: number;
position: 'top' | 'middle' | 'sidebar' | 'footer' | 'article';
position: 'top' | 'middle' | 'sidebar' | 'footer' | 'article' | 'under_table';
};
const BANNER_PRESETS: BannerPreset[] = [
@@ -63,6 +63,15 @@ const BANNER_PRESETS: BannerPreset[] = [
height: 90,
aspectRatio: 8.09,
position: 'article'
},
{
value: 'homepage_under_table',
label: 'Pod tabulkou (Homepage)',
description: 'Banner pod sekcí Tabulky na titulní stránce',
width: 970,
height: 90,
aspectRatio: 10.78,
position: 'under_table'
}
];
@@ -72,8 +81,8 @@ const BannersAdminPage: React.FC = () => {
const inputBg = useColorModeValue('white', 'gray.700');
const toast = useToast();
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ['admin-banners'], queryFn: getSponsors });
const [editing, setEditing] = useState<Partial<Sponsor> | null>(null);
const { data, isLoading } = useQuery<AdminBanner[]>(['admin-banners'], () => getBanners());
const [editing, setEditing] = useState<Partial<AdminBanner> | null>(null);
const [imageResolution, setImageResolution] = useState<{ width: number; height: number } | null>(null);
const [recommendedPlacements, setRecommendedPlacements] = useState<BannerPreset[]>([]);
const [uploadingImage, setUploadingImage] = useState(false);
@@ -117,7 +126,7 @@ const BannersAdminPage: React.FC = () => {
setRecommendedPlacements([]);
onOpen();
};
const openEdit = (s: Sponsor) => {
const openEdit = (s: AdminBanner) => {
setEditing({ ...s });
setImageResolution(null);
setRecommendedPlacements([]);
@@ -134,17 +143,17 @@ const BannersAdminPage: React.FC = () => {
};
const createMut = useMutation({
mutationFn: (payload: any) => createSponsor(payload),
mutationFn: (payload: any) => createBanner(payload),
onSuccess: () => { toast({ title: 'Banner vytvořen', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-banners'] }); closeModal(); },
onError: (e: any) => toast({ title: 'Vytvoření selhalo', description: e?.response?.data?.message || 'Chyba', status: 'error' }),
});
const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: number | string; payload: any }) => updateSponsor(id, payload),
mutationFn: ({ id, payload }: { id: number | string; payload: any }) => updateBanner(id, payload),
onSuccess: () => { toast({ title: 'Banner upraven', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-banners'] }); closeModal(); },
onError: (e: any) => toast({ title: 'Aktualizace selhala', description: e?.response?.data?.message || 'Chyba', status: 'error' }),
});
const deleteMut = useMutation({
mutationFn: (id: number | string) => deleteSponsor(id),
mutationFn: (id: number | string) => deleteBanner(id),
onSuccess: () => { toast({ title: 'Banner smazán', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-banners'] }); },
onError: (e: any) => toast({ title: 'Smazání selhalo', description: e?.response?.data?.message || 'Chyba', status: 'error' }),
});
@@ -153,8 +162,8 @@ const BannersAdminPage: React.FC = () => {
if (!editing) return;
const payload = {
name: editing.name || '',
logo_url: editing.logo_url,
website_url: editing.website_url,
image_url: (editing as any).image_url,
click_url: (editing as any).click_url,
is_active: editing.is_active ?? true,
placement: (editing as any).placement || '',
width: (editing as any).width || undefined,
@@ -192,7 +201,7 @@ const BannersAdminPage: React.FC = () => {
const res = await uploadFile(file);
// Update editing state with uploaded URL
setEditing((prev) => ({ ...(prev || {}), logo_url: res.url }));
setEditing((prev) => ({ ...(prev || {}), image_url: res.url }));
// If no placement selected yet, auto-select the best recommendation
if (!editing?.placement && recommended.length > 0) {
@@ -265,17 +274,17 @@ const BannersAdminPage: React.FC = () => {
{isLoading && (
<Tr><Td colSpan={6} textAlign="center"><Spinner size="sm" mr={2} />Načítání</Td></Tr>
)}
{!isLoading && banners.map((b) => {
{!isLoading && banners.map((b: AdminBanner) => {
const preset = getPreset((b as any).placement);
return (
<Tr key={b.id}>
<Td>
<Image src={assetUrl(b.logo_url) || '/logo192.png'} alt={b.name} boxSize="56px" objectFit="contain" bg={inputBg} borderRadius="md" />
<Image src={assetUrl((b as any).image_url) || '/logo192.png'} alt={b.name} boxSize="56px" objectFit="contain" bg={inputBg} borderRadius="md" />
</Td>
<Td>
<Text fontWeight="500">{b.name}</Text>
{b.website_url && (
<Text fontSize="xs" color="gray.500" noOfLines={1}>{b.website_url}</Text>
{(b as any).click_url && (
<Text fontSize="xs" color="gray.500" noOfLines={1}>{(b as any).click_url}</Text>
)}
</Td>
<Td>
@@ -323,7 +332,7 @@ const BannersAdminPage: React.FC = () => {
</FormControl>
<FormControl>
<FormLabel>Odkaz (po kliku)</FormLabel>
<Input type="url" value={editing?.website_url || ''} onChange={(e) => setEditing((prev) => ({ ...(prev as any), website_url: e.target.value }))} placeholder="https://partner.cz" />
<Input type="url" value={(editing as any)?.click_url || ''} onChange={(e) => setEditing((prev) => ({ ...(prev as any), click_url: e.target.value }))} placeholder="https://partner.cz" />
</FormControl>
{/* Image resolution info */}
{imageResolution && (
@@ -430,7 +439,7 @@ const BannersAdminPage: React.FC = () => {
<FormLabel>Obrázek banneru</FormLabel>
<VStack align="stretch" spacing={3}>
{/* Preview */}
{editing?.logo_url && (() => {
{(editing as any)?.image_url && (() => {
const preset = getPreset((editing as any)?.placement);
const previewWidth = preset ? Math.min(preset.width, 600) : 300;
const previewHeight = preset ? (previewWidth / preset.aspectRatio) : 150;
@@ -446,7 +455,7 @@ const BannersAdminPage: React.FC = () => {
bg={inputBg}
>
<Image
src={assetUrl(editing?.logo_url) || '/logo192.png'}
src={assetUrl((editing as any)?.image_url) || '/logo192.png'}
alt="banner preview"
width={`${previewWidth}px`}
height={`${previewHeight}px`}
@@ -475,7 +484,7 @@ const BannersAdminPage: React.FC = () => {
isLoading={uploadingImage}
loadingText="Nahrávání..."
>
{editing?.logo_url ? 'Změnit obrázek' : 'Nahrát obrázek'}
{(editing as any)?.image_url ? 'Změnit obrázek' : 'Nahrát obrázek'}
<Input
ref={fileInputRef}
type="file"
@@ -489,7 +498,7 @@ const BannersAdminPage: React.FC = () => {
{uploadingImage && <Spinner size="sm" />}
</HStack>
{!editing?.logo_url && (
{!((editing as any)?.image_url) && (
<Alert status="warning" fontSize="xs">
<AlertIcon boxSize="12px" />
<Text fontSize="xs">Nahrajte obrázek pro automatické doporučení umístění</Text>
+56 -58
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import {
Box,
Button,
@@ -33,7 +33,6 @@ import {
Badge,
HStack,
VStack,
useDisclosure,
AlertDialog,
AlertDialogBody,
AlertDialogFooter,
@@ -41,11 +40,10 @@ import {
AlertDialogContent,
AlertDialogOverlay,
SimpleGrid,
Divider,
FormHelperText,
useColorModeValue,
} from '@chakra-ui/react';
import { FiEdit, FiTrash2, FiPlus, FiUser } from 'react-icons/fi';
import { FiEdit, FiTrash2, FiPlus, FiUser, FiUpload } from 'react-icons/fi';
import AdminLayout from '../../layouts/AdminLayout';
import {
getContacts,
@@ -56,13 +54,12 @@ import {
getContactCategories,
ContactCategory,
} from '../../services/contactInfo';
import api, { uploadImage } from '../../services/api';
import { uploadImage } from '../../services/api';
import { getImageUrl } from '../../utils/imageUtils';
import { getAdminSettings, updateAdminSettings, AdminSettings, PublicSettings } from '../../services/settings';
import MapLinkImporter from '../../components/admin/MapLinkImporter';
import { MapCoordinates } from '../../utils/mapUrlParser';
import ContactMap from '../../components/home/ContactMap';
import MapStyleSelector from '../../components/admin/MapStyleSelector';
import { getFacrTablesCache } from '../../services/facr/cache';
const ContactsAdminPage: React.FC = () => {
const cardBg = useColorModeValue('white', 'gray.800');
@@ -100,6 +97,8 @@ const ContactsAdminPage: React.FC = () => {
const [uploadingImage, setUploadingImage] = useState(false);
const [settings, setSettings] = useState<AdminSettings>({});
const [savingSettings, setSavingSettings] = useState(false);
const [facrCompetitions, setFacrCompetitions] = useState<any[]>([]);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
useEffect(() => {
loadData();
@@ -109,12 +108,14 @@ const ContactsAdminPage: React.FC = () => {
const loadData = async () => {
setLoading(true);
try {
const [contactsData, categoriesData] = await Promise.all([
const [contactsData, categoriesData, facrData] = await Promise.all([
getContacts(),
getContactCategories(),
getFacrTablesCache(),
]);
setContacts(contactsData);
setCategories(categoriesData);
setFacrCompetitions(Array.isArray(facrData?.competitions) ? facrData!.competitions : []);
} catch (error) {
toast({
title: 'Chyba při načítání',
@@ -127,6 +128,31 @@ const ContactsAdminPage: React.FC = () => {
}
};
const clubCompetitionNames = useMemo(() => {
try {
const names = new Set<string>();
for (const comp of facrCompetitions || []) {
const n = String(comp?.name || '').trim();
if (n) names.add(n);
}
return Array.from(names);
} catch {
return [] as string[];
}
}, [facrCompetitions]);
const filteredContactCategories = useMemo(() => {
try {
if (!Array.isArray(categories)) return [] as ContactCategory[];
if ((clubCompetitionNames || []).length === 0) return categories;
const setNames = new Set(clubCompetitionNames.map((s) => String(s)));
const filtered = categories.filter((c) => setNames.has(String(c.name)));
return filtered.length > 0 ? filtered : categories;
} catch {
return categories;
}
}, [categories, clubCompetitionNames]);
// Contact handlers
const openContactModal = (contact?: Contact) => {
if (contact) {
@@ -526,6 +552,9 @@ const ContactsAdminPage: React.FC = () => {
currentLongitude={settings.location_longitude}
currentZoom={settings.map_zoom_level}
mapStyle={settings.map_style || 'positron'}
onMapStyleChange={(value: string) => {
setSettings((prev) => ({ ...prev, map_style: value as PublicSettings['map_style'] }));
}}
clubPrimaryColor={settings.primary_color}
clubSecondaryColor={settings.accent_color}
clubName={settings.club_name}
@@ -598,43 +627,7 @@ const ContactsAdminPage: React.FC = () => {
</SimpleGrid>
</Box>
<Box bg={cardBg} p={6} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
<MapStyleSelector
value={settings.map_style || 'positron'}
onChange={(value) => {
setSettings((prev) => ({ ...prev, map_style: value as PublicSettings['map_style'] }));
}}
clubPrimaryColor={settings.primary_color}
clubSecondaryColor={settings.accent_color}
showPreview={true}
/>
</Box>
{/* Live Map Preview with Current Coordinates */}
{settings.location_latitude && settings.location_longitude && (
<Box bg={cardBg} p={6} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Heading size="md">Náhled vaší mapy</Heading>
<Badge colorScheme="green">Aktuální poloha</Badge>
</HStack>
<Text fontSize="sm" color={textSecondary}>
Toto je náhled mapy s vaší aktuální polohou a vybraným stylem. Takto se zobrazí návštěvníkům na webu.
</Text>
<ContactMap
latitude={settings.location_latitude}
longitude={settings.location_longitude}
zoom={settings.map_zoom_level || 15}
address={`${settings.contact_address || ''}${settings.contact_city ? ', ' + settings.contact_city : ''}`}
clubName={settings.club_name}
mapStyle={settings.map_style || 'positron'}
clubPrimaryColor={settings.primary_color}
clubSecondaryColor={settings.accent_color}
height={400}
/>
</VStack>
</Box>
)}
{/* Map style selection is integrated into the section above; single unified preview */}
<Box bg={infoBg} p={4} borderRadius="md" borderWidth="1px" borderColor={infoBorder}>
<HStack justify="space-between" align="center">
@@ -700,13 +693,13 @@ const ContactsAdminPage: React.FC = () => {
}
>
<option value="">Bez přiřazení</option>
{categories.map((cat) => (
{filteredContactCategories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</Select>
<FormHelperText fontSize="xs">Přiřaďte kontakt ke konkrétní kategorii</FormHelperText>
<FormHelperText fontSize="xs">Přiřaďte kontakt ke konkrétní kategorii (podle soutěží klubu)</FormHelperText>
</FormControl>
<FormControl>
@@ -730,10 +723,26 @@ const ContactsAdminPage: React.FC = () => {
<FormControl>
<FormLabel>Fotografie</FormLabel>
<HStack>
<Button
leftIcon={<FiUpload />}
variant="outline"
colorScheme="blue"
onClick={() => fileInputRef.current?.click()}
isLoading={uploadingImage}
>
Nahrát fotografii
</Button>
{contactForm.image_url && (
<Badge colorScheme="green">Nahráno</Badge>
)}
</HStack>
<Input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
display="none"
disabled={uploadingImage}
/>
{contactForm.image_url && (
@@ -757,17 +766,6 @@ const ContactsAdminPage: React.FC = () => {
/>
</FormControl>
<FormControl>
<FormLabel>Pořadí zobrazení</FormLabel>
<Input
type="number"
value={contactForm.display_order}
onChange={(e) =>
setContactForm({ ...contactForm, display_order: parseInt(e.target.value) || 0 })
}
/>
</FormControl>
<FormControl display="flex" alignItems="center">
<FormLabel mb="0">Aktivní</FormLabel>
<Switch
+3 -3
View File
@@ -64,6 +64,7 @@ import {
getFileIcon,
} from '../../services/files';
import { API_URL } from '../../services/api';
import { assetUrl } from '../../utils/url';
const FilesAdminPage: React.FC = () => {
const toast = useToast();
@@ -187,9 +188,8 @@ const FilesAdminPage: React.FC = () => {
};
const getImageUrl = (url: string) => {
if (url.startsWith('http')) return url;
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
return `${origin}${url}`;
const full = assetUrl(url);
return full || url;
};
// Mime type options
+27 -4
View File
@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, useEffect } from 'react';
import {
Box,
Button,
@@ -54,6 +54,7 @@ import {
import Pagination from '../../components/common/Pagination';
import MessageDetailModal from '../../components/admin/MessageDetailModal';
import ConfirmationDialog from '../../components/common/ConfirmationDialog';
import { useAuth } from '../../contexts/AuthContext';
export default function MessagesAdminPage() {
const cardBg = useColorModeValue('white', 'gray.800');
@@ -88,6 +89,8 @@ export default function MessagesAdminPage() {
} = useDisclosure();
const [forwardAllEmail, setForwardAllEmail] = useState('');
const [saveForwardDefault, setSaveForwardDefault] = useState<boolean>(true);
const { user } = useAuth();
const [selectedMessage, setSelectedMessage] = useState<ContactMessage | null>(null);
const toast = useToast();
@@ -148,7 +151,8 @@ export default function MessagesAdminPage() {
});
const forwardAllMutation = useMutation({
mutationFn: forwardAllMessages,
mutationFn: (payload: { emails: string | string[]; saveDefault?: boolean }) =>
forwardAllMessages(payload.emails, { saveDefault: payload.saveDefault }),
onSuccess: (data) => {
toast({
title: 'Zprávy se přeposílají',
@@ -207,9 +211,20 @@ export default function MessagesAdminPage() {
});
return;
}
forwardAllMutation.mutate(forwardAllEmail);
forwardAllMutation.mutate({ emails: forwardAllEmail, saveDefault: saveForwardDefault });
};
useEffect(() => {
if (isForwardAllOpen) {
// Prefill with current user's email if empty
if (!forwardAllEmail && user?.email) {
setForwardAllEmail(user.email);
}
// Default to saving as auto-forward unless user opts out
setSaveForwardDefault(true);
}
}, [isForwardAllOpen, user?.email]);
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setSelectedMessages(data?.data.map((msg) => msg.id) || []);
@@ -482,11 +497,19 @@ export default function MessagesAdminPage() {
<FormLabel>E-mailová adresa</FormLabel>
<Input
type="email"
placeholder="prijemce@email.cz"
placeholder="např. ja@klub.cz, info@klub.cz"
value={forwardAllEmail}
onChange={(e) => setForwardAllEmail(e.target.value)}
/>
</FormControl>
<HStack w="full" justify="space-between">
<Checkbox
isChecked={saveForwardDefault}
onChange={(e) => setSaveForwardDefault(e.target.checked)}
>
Uložit jako výchozí (automaticky přeposílat nové zprávy)
</Checkbox>
</HStack>
</VStack>
</ModalBody>
<ModalFooter>
+119 -18
View File
@@ -45,6 +45,7 @@ import {
Flex,
Textarea,
Collapse,
Icon,
} from '@chakra-ui/react';
import AdminLayout from '../../layouts/AdminLayout';
import {
@@ -68,6 +69,26 @@ import {
FaLinkedin,
FaDiscord,
FaTwitch,
FaHome,
FaInfoCircle,
FaCalendarAlt,
FaFutbol,
FaUsers,
FaTable,
FaNewspaper,
FaVideo,
FaCamera,
FaSearch,
FaBars,
FaCog,
FaHandshake,
FaEnvelope,
FaUserShield,
FaFolder,
FaBook,
FaTshirt,
FaLink,
FaPoll,
} from 'react-icons/fa';
// Using simple up/down buttons instead of drag-drop for better compatibility
import {
@@ -137,6 +158,31 @@ const SOCIAL_PLATFORMS = [
{ value: 'twitch', label: 'Twitch', icon: FaTwitch },
];
const NAV_ICON_OPTIONS = [
{ value: 'FaHome', label: 'Domů', icon: FaHome },
{ value: 'FaInfoCircle', label: 'O klubu', icon: FaInfoCircle },
{ value: 'FaCalendarAlt', label: 'Kalendář', icon: FaCalendarAlt },
{ value: 'FaFutbol', label: 'Hráči', icon: FaFutbol },
{ value: 'FaUsers', label: 'Týmy', icon: FaUsers },
{ value: 'FaTable', label: 'Tabulky', icon: FaTable },
{ value: 'FaNewspaper', label: 'Články', icon: FaNewspaper },
{ value: 'FaVideo', label: 'Videa', icon: FaVideo },
{ value: 'FaCamera', label: 'Galerie', icon: FaCamera },
{ value: 'FaHandshake', label: 'Sponzoři', icon: FaHandshake },
{ value: 'FaEnvelope', label: 'Kontakt', icon: FaEnvelope },
{ value: 'FaSearch', label: 'Hledat', icon: FaSearch },
{ value: 'FaBars', label: 'Menu', icon: FaBars },
{ value: 'FaLink', label: 'Odkaz', icon: FaLink },
{ value: 'FaCog', label: 'Nastavení', icon: FaCog },
{ value: 'FaPoll', label: 'Ankety', icon: FaPoll },
{ value: 'FaUserShield', label: 'Uživatelé', icon: FaUserShield },
{ value: 'FaFolder', label: 'Soubory', icon: FaFolder },
{ value: 'FaBook', label: 'Stránka', icon: FaBook },
{ value: 'FaTshirt', label: 'Oblečení', icon: FaTshirt },
];
const ICON_COMPONENTS: Record<string, any> = Object.fromEntries(NAV_ICON_OPTIONS.map(opt => [opt.value, opt.icon]));
// NavItemCard component for hierarchical display
interface NavItemCardProps {
item: NavigationItem;
@@ -153,6 +199,8 @@ interface NavItemCardProps {
borderColor: string;
hoverBg: string;
level?: number;
onChildMoveUp?: (parentId: number, index: number) => void;
onChildMoveDown?: (parentId: number, index: number) => void;
}
const NavItemCard: React.FC<NavItemCardProps> = ({
@@ -170,6 +218,8 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
borderColor,
hoverBg,
level = 0,
onChildMoveUp,
onChildMoveDown,
}) => {
const hasChildren = item.children && item.children.length > 0;
const indentPx = level * 32;
@@ -299,14 +349,14 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
{/* Render children if expanded */}
{hasChildren && isExpanded && (
<VStack spacing={2} align="stretch" mt={2}>
{item.children!.map((child) => (
{item.children!.map((child, childIndex) => (
<NavItemCard
key={child.id}
item={child}
index={0}
total={1}
onMoveUp={() => {}}
onMoveDown={() => {}}
index={childIndex}
total={item.children!.length}
onMoveUp={() => onChildMoveUp && onChildMoveUp(item.id!, childIndex)}
onMoveDown={() => onChildMoveDown && onChildMoveDown(item.id!, childIndex)}
onEdit={() => onEdit()}
onDelete={() => onDelete()}
onAddChild={() => {}}
@@ -316,6 +366,8 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
borderColor={borderColor}
hoverBg={hoverBg}
level={level + 1}
onChildMoveUp={onChildMoveUp}
onChildMoveDown={onChildMoveDown}
/>
))}
</VStack>
@@ -352,13 +404,9 @@ const NavigationAdminPage = () => {
getAllNavigationItems(),
getAllSocialLinks(),
]);
console.log('Načtená navigace:', navData);
console.log('Načtené sociální odkazy:', socialData);
// Auto-seed if navigation is empty
if (!navData || navData.length === 0) {
console.log('Navigace je prázdná, automaticky vytváříme výchozí navigaci...');
try {
const seedResult = await seedDefaultNavigation();
if (seedResult.seeded) {
@@ -408,6 +456,43 @@ const NavigationAdminPage = () => {
}
};
const moveChildNavItem = async (parentId: number, index: number, direction: 'up' | 'down') => {
const moveWithin = async (
list: NavigationItem[],
setList: React.Dispatch<React.SetStateAction<NavigationItem[]>>
): Promise<boolean> => {
const parentIdx = list.findIndex((it) => it.id === parentId);
if (parentIdx === -1) return false;
const parent = list[parentIdx];
const children = Array.isArray(parent.children) ? [...parent.children] : [];
if (children.length === 0) return true;
if (direction === 'up' && index === 0) return true;
if (direction === 'down' && index === children.length - 1) return true;
const targetIndex = direction === 'up' ? index - 1 : index + 1;
[children[index], children[targetIndex]] = [children[targetIndex], children[index]];
const updatedParent: NavigationItem = { ...parent, children };
const updated = [...list];
updated[parentIdx] = updatedParent;
setList(updated);
const orders = children.map((c, idx) => ({ id: c.id!, display_order: idx }));
try {
await reorderNavigationItems(orders);
toast({ title: 'Pořadí aktualizováno', status: 'success', duration: 2000 });
} catch (err) {
toast({ title: 'Chyba při aktualizaci pořadí', status: 'error', duration: 3000 });
loadData();
}
return true;
};
const doneFront = await moveWithin(navItems, setNavItems);
if (!doneFront) {
await moveWithin(adminNavItems, setAdminNavItems);
}
};
const moveNavItem = async (index: number, direction: 'up' | 'down') => {
if (direction === 'up' && index === 0) return;
if (direction === 'down' && index === navItems.length - 1) return;
@@ -811,6 +896,8 @@ const NavigationAdminPage = () => {
cardBg={cardBg}
borderColor={borderColor}
hoverBg={hoverBg}
onChildMoveUp={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'up')}
onChildMoveDown={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'down')}
/>
))
)}
@@ -860,6 +947,8 @@ const NavigationAdminPage = () => {
cardBg={cardBg}
borderColor={borderColor}
hoverBg={hoverBg}
onChildMoveUp={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'up')}
onChildMoveDown={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'down')}
/>
))}
@@ -1026,6 +1115,25 @@ const NavigationAdminPage = () => {
</FormControl>
)}
<FormControl>
<FormLabel>Ikona</FormLabel>
<Select
value={editingNav?.icon || ''}
onChange={(e) => setEditingNav({ ...editingNav!, icon: e.target.value || undefined })}
>
<option value="">Bez ikony</option>
{NAV_ICON_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</Select>
{editingNav?.icon && (
<HStack mt={2} spacing={2} align="center">
<Icon as={ICON_COMPONENTS[editingNav.icon]} boxSize={5} />
<Text fontSize="sm">{editingNav.icon}</Text>
</HStack>
)}
</FormControl>
{editingNav?.parent_id && (
<Alert status="warning" fontSize="sm">
<AlertIcon />
@@ -1043,14 +1151,7 @@ const NavigationAdminPage = () => {
/>
</FormControl>
<FormControl>
<FormLabel>CSS třída (volitelné)</FormLabel>
<Input
value={editingNav?.icon || ''}
onChange={(e) => setEditingNav({ ...editingNav!, icon: e.target.value })}
placeholder="custom-class"
/>
</FormControl>
{editingNav?.type === 'external' && (
<FormControl>
+107 -20
View File
@@ -50,6 +50,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { cs } from 'date-fns/locale';
import AdminLayout from '../../layouts/AdminLayout';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { facrApi } from '../../services/facr/facrApi';
import {
getNewsletterSubscribers,
sendNewsletter,
@@ -143,6 +145,29 @@ export default function NewsletterAdminPage() {
const [sendNowLoading, setSendNowLoading] = useState<boolean>(false);
const openDetails = (t: MailType) => { setActiveType(t); setDetailsOpen(true); };
const closeDetails = () => { setDetailsOpen(false); setActiveType(null); setDetailsCompetitions(''); };
// Helpers for competitions multi-select handling
const selectedCompCodes = React.useMemo(() => {
return new Set((competitions || '').split(',').map((s) => s.trim()).filter(Boolean));
}, [competitions]);
const toggleComp = (code: string, on: boolean) => {
const next = new Set(selectedCompCodes);
if (on) next.add(code); else next.delete(code);
setCompetitions(Array.from(next).join(', '));
};
const clearComps = () => setCompetitions('');
const selectAllComps = () => setCompetitions(compOptions.map(o => o.code).join(', '));
const detailsSelectedCompCodes = React.useMemo(() => {
return new Set((detailsCompetitions || '').split(',').map((s) => s.trim()).filter(Boolean));
}, [detailsCompetitions]);
const toggleDetailsComp = (code: string, on: boolean) => {
const next = new Set(detailsSelectedCompCodes);
if (on) next.add(code); else next.delete(code);
setDetailsCompetitions(Array.from(next).join(', '));
};
const detailsClearComps = () => setDetailsCompetitions('');
const detailsSelectAllComps = () => setDetailsCompetitions(compOptions.map(o => o.code).join(', '));
const recipientsForType = (t: MailType): string[] => {
const key = t === 'weekly' ? 'weekly' : t;
return subscribers
@@ -212,6 +237,24 @@ export default function NewsletterAdminPage() {
const queryClient = useQueryClient();
const isMobile = useBreakpointValue({ base: true, md: false });
// Load club competitions for nicer dropdowns (FACR)
const { data: publicSettings } = usePublicSettings();
const clubId = publicSettings?.club_id;
const clubType = (publicSettings?.club_type as 'football' | 'futsal') || 'football';
const { data: clubCompetitions = [] } = useQuery({
queryKey: ['facr', 'competitions', clubId, clubType],
queryFn: async () => {
if (!clubId) return [] as Array<{ code?: string; id?: string; name?: string }>;
const comps = await facrApi.getClubCompetitions(clubId, clubType);
return comps || [];
},
enabled: !!clubId,
});
const compOptions = (clubCompetitions as any[]).map((c) => ({
code: String(c?.code || c?.id || ''),
name: String(c?.name || c?.code || c?.id || ''),
})).filter((o) => o.code);
// Admin settings (for scheduling)
const settingsQuery = useQuery({
queryKey: ['admin', 'settings'],
@@ -222,10 +265,11 @@ export default function NewsletterAdminPage() {
const [enableMatchReminders, setEnableMatchReminders] = useState<boolean>(!!settings?.enable_match_reminders);
const [enableResults, setEnableResults] = useState<boolean>(!!settings?.enable_results);
const [weeklyDay, setWeeklyDay] = useState<AdminSettings['newsletter_weekly_day']>(settings?.newsletter_weekly_day || 'sun');
const [weeklyHour, setWeeklyHour] = useState<number>(typeof settings?.newsletter_weekly_hour === 'number' ? (settings!.newsletter_weekly_hour as number) : 18);
const toTimeString = (h?: number) => (String(typeof h === 'number' ? Math.max(0, Math.min(23, h)) : 18).padStart(2, '0')) + ':00';
const [weeklyTime, setWeeklyTime] = useState<string>(toTimeString(settings?.newsletter_weekly_hour as number | undefined));
const [reminderLead, setReminderLead] = useState<number>(typeof settings?.newsletter_reminder_lead_hours === 'number' ? (settings!.newsletter_reminder_lead_hours as number) : 48);
const [quietStart, setQuietStart] = useState<number>(typeof settings?.newsletter_quiet_start === 'number' ? (settings!.newsletter_quiet_start as number) : 22);
const [quietEnd, setQuietEnd] = useState<number>(typeof settings?.newsletter_quiet_end === 'number' ? (settings!.newsletter_quiet_end as number) : 7);
const [quietStartTime, setQuietStartTime] = useState<string>(toTimeString(settings?.newsletter_quiet_start as number | undefined));
const [quietEndTime, setQuietEndTime] = useState<string>(toTimeString(settings?.newsletter_quiet_end as number | undefined));
// Sync local state when settings load
useEffect(() => {
@@ -234,22 +278,27 @@ export default function NewsletterAdminPage() {
setEnableMatchReminders(!!settings.enable_match_reminders);
setEnableResults(!!settings.enable_results);
setWeeklyDay(settings.newsletter_weekly_day || 'sun');
setWeeklyHour(typeof settings.newsletter_weekly_hour === 'number' ? settings.newsletter_weekly_hour! : 18);
setWeeklyTime(toTimeString(typeof settings.newsletter_weekly_hour === 'number' ? settings.newsletter_weekly_hour! : 18));
setReminderLead(typeof settings.newsletter_reminder_lead_hours === 'number' ? settings.newsletter_reminder_lead_hours! : 48);
setQuietStart(typeof settings.newsletter_quiet_start === 'number' ? settings.newsletter_quiet_start! : 22);
setQuietEnd(typeof settings.newsletter_quiet_end === 'number' ? settings.newsletter_quiet_end! : 7);
setQuietStartTime(toTimeString(typeof settings.newsletter_quiet_start === 'number' ? settings.newsletter_quiet_start! : 22));
setQuietEndTime(toTimeString(typeof settings.newsletter_quiet_end === 'number' ? settings.newsletter_quiet_end! : 7));
}, [settings]);
const parseHour = (t: string) => {
const m = /^\s*(\d{1,2})(?::(\d{1,2}))?/.exec(t || '');
const h = m ? parseInt(m[1], 10) : 18;
return Math.max(0, Math.min(23, isNaN(h) ? 18 : h));
};
const saveScheduleMutation = useMutation({
mutationFn: () => updateAdminSettings({
enable_weekly: enableWeekly,
enable_match_reminders: enableMatchReminders,
enable_results: enableResults,
newsletter_weekly_day: weeklyDay,
newsletter_weekly_hour: weeklyHour,
newsletter_weekly_hour: parseHour(weeklyTime),
newsletter_reminder_lead_hours: reminderLead,
newsletter_quiet_start: quietStart,
newsletter_quiet_end: quietEnd,
newsletter_quiet_start: parseHour(quietStartTime),
newsletter_quiet_end: parseHour(quietEndTime),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
@@ -621,9 +670,9 @@ export default function NewsletterAdminPage() {
<option value="sun">Neděle</option>
</Select>
</FormControl>
<FormControl maxW="160px">
<FormLabel>Hodina</FormLabel>
<Input type="number" min={0} max={23} value={weeklyHour} onChange={(e)=> setWeeklyHour(Math.max(0, Math.min(23, Number(e.target.value)||0)))} />
<FormControl maxW="200px">
<FormLabel>Čas odeslání</FormLabel>
<Input type="time" step={900} value={weeklyTime} onChange={(e)=> setWeeklyTime(e.target.value)} />
</FormControl>
</HStack>
@@ -648,13 +697,13 @@ export default function NewsletterAdminPage() {
<Switch isChecked={enableResults} onChange={(e)=> setEnableResults(e.target.checked)} />
</HStack>
<HStack spacing={3}>
<FormControl maxW="160px">
<FormControl maxW="200px">
<FormLabel>Tiché hodiny od</FormLabel>
<Input type="number" min={0} max={23} value={quietStart} onChange={(e)=> setQuietStart(Math.max(0, Math.min(23, Number(e.target.value)||0)))} />
<Input type="time" step={900} value={quietStartTime} onChange={(e)=> setQuietStartTime(e.target.value)} />
</FormControl>
<FormControl maxW="160px">
<FormControl maxW="200px">
<FormLabel>Tiché hodiny do</FormLabel>
<Input type="number" min={0} max={23} value={quietEnd} onChange={(e)=> setQuietEnd(Math.max(0, Math.min(23, Number(e.target.value)||0)))} />
<Input type="time" step={900} value={quietEndTime} onChange={(e)=> setQuietEndTime(e.target.value)} />
<FormHelperText>E-maily s výsledky se neposílají v tomto intervalu.</FormHelperText>
</FormControl>
</HStack>
@@ -918,8 +967,28 @@ export default function NewsletterAdminPage() {
<>
<FormControl>
<FormLabel>Filtr soutěží (volitelné)</FormLabel>
<Input placeholder="NAPŘ. KP, I.A, I.B" value={competitions} onChange={(e)=> setCompetitions(e.target.value)} />
<FormHelperText>Čárkou oddělený seznam kódů soutěží.</FormHelperText>
{compOptions.length > 0 ? (
<VStack align="stretch" spacing={2} maxH="220px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
<HStack>
<Button size="xs" variant="outline" onClick={selectAllComps}>Vybrat vše</Button>
<Button size="xs" variant="ghost" onClick={clearComps}>Zrušit vše</Button>
</HStack>
{compOptions.map((o) => {
const checked = selectedCompCodes.has(o.code.toLowerCase()) || selectedCompCodes.has(o.code);
return (
<HStack key={o.code} justify="space-between">
<Text>{o.name}</Text>
<Switch isChecked={checked} onChange={(e)=> toggleComp(o.code, e.target.checked)} />
</HStack>
);
})}
</VStack>
) : (
<>
<Input placeholder="NAPŘ. KP, I.A, I.B" value={competitions} onChange={(e)=> setCompetitions(e.target.value)} />
<FormHelperText>Čárkou oddělený seznam kódů soutěží.</FormHelperText>
</>
)}
</FormControl>
<HStack>
<Button variant="outline" onClick={async ()=>{
@@ -1009,9 +1078,27 @@ export default function NewsletterAdminPage() {
<ModalBody>
<VStack align="stretch" spacing={4}>
<HStack spacing={4} align="flex-end">
<FormControl maxW="360px">
<FormControl maxW="420px">
<FormLabel>Filtr soutěží (volitelné)</FormLabel>
<Input placeholder="NAPŘ. KP, I.A, I.B" value={detailsCompetitions} onChange={(e)=> setDetailsCompetitions(e.target.value)} />
{compOptions.length > 0 ? (
<VStack align="stretch" spacing={2} maxH="220px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
<HStack>
<Button size="xs" variant="outline" onClick={detailsSelectAllComps}>Vybrat vše</Button>
<Button size="xs" variant="ghost" onClick={detailsClearComps}>Zrušit vše</Button>
</HStack>
{compOptions.map((o) => {
const checked = detailsSelectedCompCodes.has(o.code.toLowerCase()) || detailsSelectedCompCodes.has(o.code);
return (
<HStack key={o.code} justify="space-between">
<Text>{o.name}</Text>
<Switch isChecked={checked} onChange={(e)=> toggleDetailsComp(o.code, e.target.checked)} />
</HStack>
);
})}
</VStack>
) : (
<Input placeholder="NAPŘ. KP, I.A, I.B" value={detailsCompetitions} onChange={(e)=> setDetailsCompetitions(e.target.value)} />
)}
</FormControl>
<Button onClick={async()=>{ if(!activeType) return; setDetailsLoading(true); try { await loadPreviewForType(activeType, detailsCompetitions); } finally { setDetailsLoading(false); } }} isLoading={detailsLoading}>Aktualizovat náhled</Button>
{activeType && typePreview[activeType]?.subject && (
+23 -22
View File
@@ -42,7 +42,7 @@ import { Player, getPlayers, createPlayer, updatePlayer, deletePlayer } from '..
import { uploadFile } from '../../services/articles';
import { translateNationality } from '../../utils/nationality';
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
import { API_URL } from '../../services/api';
import { assetUrl } from '../../utils/url';
type Editing = Partial<Player> & { id?: number };
@@ -51,16 +51,7 @@ const PlayersAdminPage: React.FC = () => {
const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.700');
const toast = useToast();
const normalizeImageUrl = (url?: string) => {
if (!url || url === '') return '/logo192.png';
// If it's already absolute, return as-is
if (/^https?:\/\//i.test(url)) return url;
// If it's an uploads path, prefix with API origin
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
if (url.startsWith('/uploads/')) return `${origin}${url}`;
// Fallback: treat as relative to origin
return `${origin}${url.startsWith('/') ? '' : '/'}${url}`;
};
// Hoisted helper: convert country code to flag emoji
function countryCodeToEmoji(cc: string) {
@@ -231,7 +222,6 @@ const PlayersAdminPage: React.FC = () => {
const { isOpen, onOpen, onClose } = useDisclosure();
const JERSEY_MIN = 0;
const JERSEY_MAX = 99;
const HEIGHT_MIN = 0;
const HEIGHT_MAX = 250;
const WEIGHT_MIN = 0;
@@ -315,14 +305,12 @@ const PlayersAdminPage: React.FC = () => {
return;
}
const tooBig = (
typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > JERSEY_MAX
) || (
typeof editing.height === 'number' && Number.isFinite(editing.height) && editing.height > HEIGHT_MAX
) || (
typeof editing.weight === 'number' && Number.isFinite(editing.weight) && editing.weight > WEIGHT_MAX
);
if (tooBig) {
toast({ title: 'Neplatná čísla', description: `Maxima: číslo dresu ${JERSEY_MAX}, výška ${HEIGHT_MAX} cm, váha ${WEIGHT_MAX} kg`, status: 'warning' });
toast({ title: 'Neplatná čísla', description: `Maxima: výška ${HEIGHT_MAX} cm, váha ${WEIGHT_MAX} kg`, status: 'warning' });
return;
}
// Require date of birth: all three values must be selected
@@ -337,7 +325,7 @@ const PlayersAdminPage: React.FC = () => {
};
if (editing.date_of_birth) payload.date_of_birth = editing.date_of_birth;
if (editing.position) payload.position = editing.position;
if (typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > 0) {
if (typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number >= 0) {
payload.jersey_number = editing.jersey_number;
}
if (editing.nationality) payload.nationality = editing.nationality;
@@ -391,7 +379,7 @@ const PlayersAdminPage: React.FC = () => {
<Tr key={p.id}>
<Td>
<ThumbnailPreview
src={normalizeImageUrl(p.image_url)}
src={assetUrl(p.image_url) || '/logo192.png'}
alt={`${p.first_name} ${p.last_name}`}
size="48px"
previewSize="300px"
@@ -450,7 +438,7 @@ const PlayersAdminPage: React.FC = () => {
</Select>
</HStack>
<Box mt={2} fontSize="sm" color="gray.500">
{formatDobPreview(dobParts)}
{formatDobPreview(dobParts)}{calculateAgeFromParts(dobParts) != null ? `${calculateAgeFromParts(dobParts)} let` : ''}
</Box>
</FormControl>
@@ -466,12 +454,11 @@ const PlayersAdminPage: React.FC = () => {
</Select>
</FormControl>
<FormControl isInvalid={typeof editing?.jersey_number === 'number' && (editing?.jersey_number as number) > JERSEY_MAX}>
<FormControl>
<FormLabel>Číslo dresu</FormLabel>
<NumberInput min={JERSEY_MIN} max={JERSEY_MAX} keepWithinRange={false} clampValueOnBlur={false} value={typeof editing?.jersey_number === 'number' ? editing?.jersey_number : ''} onChange={(_, v) => setEditing((p) => ({ ...(p as any), jersey_number: Number.isFinite(v) ? v : undefined }))}>
<NumberInput min={JERSEY_MIN} keepWithinRange={false} clampValueOnBlur={false} value={typeof editing?.jersey_number === 'number' ? editing?.jersey_number : ''} onChange={(_, v) => setEditing((p) => ({ ...(p as any), jersey_number: Number.isFinite(v) && v >= 0 ? v : undefined }))}>
<NumberInputField inputMode="numeric" />
</NumberInput>
<FormErrorMessage>Maximální číslo dresu je {JERSEY_MAX}.</FormErrorMessage>
</FormControl>
<FormControl>
@@ -547,7 +534,7 @@ const PlayersAdminPage: React.FC = () => {
<FormControl>
<FormLabel>Fotka</FormLabel>
<HStack>
<Image src={normalizeImageUrl(editing?.image_url)} alt="photo" boxSize="56px" objectFit="cover" borderRadius="md" fallbackSrc="/dist/img/logo-club-empty.svg" />
<Image src={assetUrl(editing?.image_url) || '/logo192.png'} alt="photo" boxSize="56px" objectFit="cover" borderRadius="md" fallbackSrc="/dist/img/logo-club-empty.svg" />
<Button as="label" type="button" leftIcon={<FiUpload />}>Nahrát
<Input
type="file"
@@ -614,6 +601,20 @@ const PlayersAdminPage: React.FC = () => {
return `${dd}.${mm}.${yyyy}`;
}
function calculateAgeFromParts(parts: { day: string; month: string; year: string }): number | null {
if (!parts.day || !parts.month || !parts.year) return null;
const y = Number(parts.year);
const m = Number(parts.month);
const d = Number(parts.day);
if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) return null;
const today = new Date();
let age = today.getFullYear() - y;
const month = today.getMonth() + 1;
const day = today.getDate();
if (month < m || (month === m && day < d)) age--;
return age;
}
// Update DOB parts and, when complete, compose YYYY-MM-DD. Clamp day to month length.
function updateDobPart(part: 'day'|'month'|'year', value: string) {
setDobParts((prev) => {
+52 -47
View File
@@ -54,7 +54,53 @@ import { assetUrl } from '../../utils/url';
import { useEffect, useMemo, useRef, useState } from 'react';
import { API_URL } from '../../services/api';
function normalize(s: string): string {
let out = String(s || '');
// Normalize diacritics and case
out = out
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
// Unify various dash characters to a simple hyphen
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
// Remove legal suffixes like ", z.s." / ", z. s." / " z.s." / "o.s." at end
out = out.replace(/[,,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
// Remove organization phrases/prefixes anywhere (keep core locality/name)
const orgPhrases = [
'fotbalovy klub',
'sportovni klub',
'telovychovna jednota',
'skolni sportovni klub',
'fotbal',
'futsal',
];
for (const phrase of orgPhrases) {
const re = new RegExp('(^|\\b)'+ phrase + '(\\b|$)', 'g');
out = out.replace(re, ' ');
}
// Remove common short prefixes (tokens) like FC, FK, MFK, TJ, SK, SFC, AFK at word boundaries
out = out.replace(/\b(1\.)?\s*(sfc|afc|fc|fk|mfk|tj|sk|afk)\b\.?/g, ' ');
// Remove punctuation except hyphen
out = out.replace(/[\.,!;:()\[\]{}]/g, ' ');
// Collapse multiple spaces and trim
out = out.replace(/\s+/g, ' ').trim();
return out;
}
// Derive FACR team UUID from the logo URL if team_id is missing in the row
// Example: https://is1.fotbal.cz/media/kluby/<UUID>/<UUID>_crop.jpg
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 TableRow = {
rank?: string;
team?: string;
@@ -82,6 +128,9 @@ const TeamsAdminPage = () => {
const mainClubBase: string = useMemo(() => normalize(String(data?.name || '')), [data?.name]);
// Backend origin (used to resolve relative URLs like /uploads/...)
const backendOrigin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
const theadBg = useColorModeValue('gray.50', 'gray.700');
const rowHoverBg = useColorModeValue('gray.50', 'gray.700');
const searchBg = useColorModeValue('white', 'gray.800');
// Load public/admin overrides map to apply on cache-fed view
const { data: overrides = {} } = useQuery({
@@ -120,38 +169,6 @@ const TeamsAdminPage = () => {
.catch((err) => console.error('Failed to fetch sport logos:', err))
.finally(() => setSportLogosLoading(false));
}, [competitions]);
const normalize = (s: string) => {
let out = String(s || '');
// Normalize diacritics and case
out = out
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
// Unify various dash characters to a simple hyphen
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
// Remove legal suffixes like ", z.s." / ", z. s." / " z.s." / "o.s." at end
out = out.replace(/[,,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
// Remove organization phrases/prefixes anywhere (keep core locality/name)
const orgPhrases = [
'fotbalovy klub',
'sportovni klub',
'telovychovna jednota',
'skolni sportovni klub',
'fotbal',
'futsal',
];
for (const phrase of orgPhrases) {
const re = new RegExp('(^|\\b)'+ phrase + '(\\b|$)', 'g');
out = out.replace(re, ' ');
}
// Remove common short prefixes (tokens) like FC, FK, MFK, TJ, SK, SFC, AFK at word boundaries
out = out.replace(/\b(1\.)?\s*(sfc|afc|fc|fk|mfk|tj|sk|afk)\b\.?/g, ' ');
// Remove punctuation except hyphen
out = out.replace(/[\.,!;:()\[\]{}]/g, ' ');
// Collapse multiple spaces and trim
out = out.replace(/\s+/g, ' ').trim();
return out;
};
const byName: Record<string, string> = (overrides as any)?.by_name || {};
const byNameNormalized = useMemo(() => {
const idx: Record<string, string> = {};
@@ -161,18 +178,6 @@ const TeamsAdminPage = () => {
return idx;
}, [byName]);
// Derive FACR team UUID from the logo URL if team_id is missing in the row
// Example: https://is1.fotbal.cz/media/kluby/<UUID>/<UUID>_crop.jpg
const 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;
}
};
const getLogo = (teamName?: string, teamId?: string, original?: string) => {
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
// Priority 0: Admin override by team ID
@@ -561,7 +566,7 @@ const TeamsAdminPage = () => {
}}
>
<Table size="sm" variant="simple">
<Thead bg={useColorModeValue('gray.50', 'gray.700')}>
<Thead bg={theadBg}>
<Tr>
<Th w="40px" fontSize="xs" py={2}>#</Th>
<Th fontSize="xs" py={2}>Tým</Th>
@@ -576,7 +581,7 @@ const TeamsAdminPage = () => {
</Thead>
<Tbody>
{rowsFiltered.map((r, idx) => (
<Tr key={`${comp.id}-${idx}`} _hover={{ bg: useColorModeValue('gray.50', 'gray.700') }}>
<Tr key={`${comp.id}-${idx}`} _hover={{ bg: rowHoverBg }}>
<Td py={1.5} fontSize="xs">{r.rank}</Td>
<Td py={1.5}>
<HStack spacing={2} align="center">
@@ -687,7 +692,7 @@ const TeamsAdminPage = () => {
{searchResults.length > 0 && (
<Box
mt={4}
bg={useColorModeValue('white', 'gray.800')}
bg={searchBg}
borderWidth="1px"
borderRadius="lg"
overflowX="auto"