mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 18:52:56 +00:00
dev day #90 🥳
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Box, Container, Heading, Image, Spinner, Stack, Text, HStack, Badge, Link, SimpleGrid, Button, AspectRatio, useColorModeValue, Flex, VStack, Tag } from '@chakra-ui/react';
|
||||
import { Box, Container, Heading, Image, Spinner, Stack, Text, HStack, Badge, Link, SimpleGrid, Button, AspectRatio, useColorModeValue, Flex, VStack, Tag, Breadcrumb, BreadcrumbItem, BreadcrumbLink } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useParams, Link as RouterLink } from 'react-router-dom';
|
||||
import { getArticle, getArticleBySlug, getArticleMatchLink, trackArticleView, getArticles } from '../services/articles';
|
||||
@@ -17,6 +17,7 @@ import TeamLogo from '../components/common/TeamLogo';
|
||||
import MatchModal from '../components/home/MatchModal';
|
||||
import { extractPalette } from '../utils/colors';
|
||||
import { getTeamLogo } from '../utils/sportLogosAPI';
|
||||
import { getBanners, Banner as UIBanner } from '../services/banners';
|
||||
import FilePreview from '../components/common/FilePreview';
|
||||
import { usePublicSettings } from '../hooks/usePublicSettings';
|
||||
import InstagramGeneratorButton from '../components/admin/InstagramGeneratorButton';
|
||||
@@ -42,6 +43,8 @@ const ArticleDetailPage: React.FC = () => {
|
||||
enabled: Boolean(slug || id),
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Load competition aliases to resolve category → alias mapping for MatchesWidget filtering
|
||||
const aliasesQ = useQuery<{ list: CompetitionAlias[] }>({
|
||||
queryKey: ['competition-aliases-public'],
|
||||
@@ -336,6 +339,13 @@ const ArticleDetailPage: React.FC = () => {
|
||||
});
|
||||
}, [(data as any)?.content, toAbsoluteUploads]);
|
||||
|
||||
const articleBannersQ = useQuery<UIBanner[]>({
|
||||
queryKey: ['banners', { placement: 'article_inline' }],
|
||||
queryFn: () => getBanners({ active: true, placement: 'article_inline' }),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
const articleBanners = (articleBannersQ.data || []) as UIBanner[];
|
||||
|
||||
const relatedArticlesQuery = useQuery({
|
||||
queryKey: ['related-articles', (data as any)?.category?.id || 'none', (data as any)?.id],
|
||||
enabled: Boolean((data as any)?.id),
|
||||
@@ -526,6 +536,22 @@ const ArticleDetailPage: React.FC = () => {
|
||||
</HStack>
|
||||
) : null}
|
||||
</HStack>
|
||||
<Breadcrumb fontSize="sm" mt={2} color={textMuted} separator="/">
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink as={RouterLink} to="/">Domů</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink as={RouterLink} to="/blog">Blog</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
{(data as any)?.category?.id ? (
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink as={RouterLink} to={`/news?category_id=${(data as any).category.id}`}>{(data as any).category.name}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
) : null}
|
||||
<BreadcrumbItem isCurrentPage>
|
||||
<BreadcrumbLink>{data.title}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
</Container>
|
||||
</Box>
|
||||
<Container maxW="7xl">
|
||||
@@ -541,7 +567,8 @@ const ArticleDetailPage: React.FC = () => {
|
||||
|
||||
{/* Match Section - Card with logos, score/countdown, venue/date */}
|
||||
{(matchLinkQuery.data as any)?.external_match_id && (
|
||||
<Box position="relative" borderWidth="1px" borderRadius="lg" p={{ base: 4, md: 5 }} bg={cardBg} overflow="hidden">
|
||||
<Box position="relative" borderWidth="1px" borderRadius="lg" p={{ base: 4, md: 5 }} bg={cardBg} overflow="hidden" cursor="pointer"
|
||||
onClick={() => { if (facrMatchQuery?.data) { setSelectedMatch({ ...(facrMatchQuery.data as any), competition: (facrMatchQuery.data as any).competitionName }); setIsMatchModalOpen(true); } }}>
|
||||
{/* Edge fades */}
|
||||
<Box position="absolute" top={0} left={0} bottom={0} w={{ base: '6px', md: '12px' }} bgGradient={`linear(to-r, var(--club-primary, #0b5cff), transparent)`} pointerEvents="none" />
|
||||
{opponentColor && (
|
||||
@@ -639,6 +666,27 @@ const ArticleDetailPage: React.FC = () => {
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: safeContentHTML }}
|
||||
/>
|
||||
{articleBanners.length > 0 && (
|
||||
<Box textAlign="center" mt={{ base: 4, md: 6 }}>
|
||||
<a
|
||||
href={articleBanners[0].click_url || '#'}
|
||||
target={articleBanners[0].click_url ? '_blank' : undefined}
|
||||
rel={articleBanners[0].click_url ? 'noopener noreferrer' : undefined}
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
<Image
|
||||
src={assetUrl((articleBanners[0] as any).image_url) || '/images/sponsors/placeholder.png'}
|
||||
alt={articleBanners[0].name}
|
||||
maxW="100%"
|
||||
w={articleBanners[0].width ? `${articleBanners[0].width}px` : '100%'}
|
||||
h={articleBanners[0].height ? `${articleBanners[0].height}px` : 'auto'}
|
||||
borderRadius="md"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</a>
|
||||
</Box>
|
||||
)}
|
||||
{/* YouTube Video Section - simplified with rounded edges */}
|
||||
{(data as any)?.youtube_video_id && (
|
||||
<Box borderRadius="xl" overflow="hidden">
|
||||
@@ -708,6 +756,13 @@ const ArticleDetailPage: React.FC = () => {
|
||||
</Stack>
|
||||
</Box>
|
||||
<VStack align="stretch" spacing={6} gridColumn={{ base: '1 / -1', lg: 'span 4' }}>
|
||||
{/* Polls in sidebar */}
|
||||
{data?.id ? (
|
||||
<Widget title="Anketa">
|
||||
<EmbeddedPoll articleId={(data as any).id} maxPolls={1} />
|
||||
</Widget>
|
||||
) : null}
|
||||
|
||||
{relatedArticlesQuery.isLoading ? null : (() => {
|
||||
const list = ((relatedArticlesQuery.data as any)?.data || [])
|
||||
.filter((a: any) => a?.id !== (data as any)?.id)
|
||||
@@ -768,12 +823,28 @@ const ArticleDetailPage: React.FC = () => {
|
||||
</Widget>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Attachments in sidebar */}
|
||||
{Array.isArray((data as any)?.attachments) && (data as any).attachments.length > 0 && (
|
||||
<Widget title="Přílohy">
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{(data as any).attachments.map((f: any, idx: number) => (
|
||||
<HStack key={idx} justify="space-between">
|
||||
<Text noOfLines={1}>{f.name || f.url}</Text>
|
||||
<FilePreview url={assetUrl(f.url) || f.url} name={f.name || ''} mimeType={f.mime_type || ''} size={f.size} />
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</Widget>
|
||||
)}
|
||||
</VStack>
|
||||
</SimpleGrid>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* Attachments - bottom above CTA */}
|
||||
{/* Polls (Ankety) above attachments */}
|
||||
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
|
||||
{/* Attachments - bottom above comments */}
|
||||
{Array.isArray((data as any)?.attachments) && (data as any).attachments.length > 0 && (
|
||||
<Container maxW="7xl" mt={4}>
|
||||
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={attachmentsBg}>
|
||||
@@ -789,8 +860,6 @@ const ArticleDetailPage: React.FC = () => {
|
||||
</Box>
|
||||
</Container>
|
||||
)}
|
||||
{/* Polls (Ankety) above CTA */}
|
||||
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
|
||||
{/* Comments at the end */}
|
||||
{(data as any)?.id ? (
|
||||
<Container maxW="7xl" mt={4}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Box, Container, Heading, VStack, Image, Text, Skeleton, LinkBox, HStack, Select, Badge, useColorModeValue, Input, InputGroup, InputLeftElement, InputRightElement, IconButton, Grid, GridItem, useMediaQuery } from '@chakra-ui/react';
|
||||
import { Box, Container, Heading, VStack, Image, Text, Skeleton, LinkBox, HStack, Select, Badge, useColorModeValue, Input, InputGroup, InputLeftElement, InputRightElement, IconButton, Grid, GridItem, useMediaQuery, Tooltip } from '@chakra-ui/react';
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||
import { getArticles, Article, Paginated, getFeaturedArticles } from '../services/articles';
|
||||
import { getBanners, Banner as UIBanner } from '../services/banners';
|
||||
@@ -24,6 +24,9 @@ const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({
|
||||
? ({ base: '160px', md: '180px' } as const)
|
||||
: ({ base: '200px', md: '220px' } as const);
|
||||
|
||||
const publishedAt = (article as any).published_at || (article as any).created_at;
|
||||
const publishedDateStr = publishedAt ? (()=>{ try { return new Date(publishedAt).toLocaleDateString('cs-CZ'); } catch { return ''; } })() : '';
|
||||
|
||||
return (
|
||||
<LinkBox
|
||||
as={RouterLink}
|
||||
@@ -49,59 +52,31 @@ const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({
|
||||
fetchPriority={variant === 'large' ? 'high' as any : 'auto' as any}
|
||||
/>
|
||||
<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)) && (
|
||||
<HStack position="absolute" top={2} right={2} spacing={1}>
|
||||
{readTime && (
|
||||
<Badge
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
bg="rgba(0,0,0,0.7)"
|
||||
color="white"
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
>
|
||||
{/* Top info row: category (left), date (center), read time (right) */}
|
||||
<HStack position="absolute" top={2} left={2} right={2} justify="space-between" align="center">
|
||||
{categoryName ? (
|
||||
<Tooltip label="Kategorie" hasArrow>
|
||||
<Badge bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1} borderRadius="md">
|
||||
{categoryName}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : <Box />}
|
||||
{publishedDateStr ? (
|
||||
<Tooltip label="Datum publikace" hasArrow>
|
||||
<Badge bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1} borderRadius="md">
|
||||
{publishedDateStr}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : <Box />}
|
||||
{readTime ? (
|
||||
<Tooltip label="Doba čtení" hasArrow>
|
||||
<Badge display="flex" alignItems="center" gap={1} bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1} borderRadius="md">
|
||||
<Clock size={12} />
|
||||
{readTime} min
|
||||
</Badge>
|
||||
)}
|
||||
{viewCount && viewCount > 0 && (
|
||||
<Badge
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
bg="rgba(0,0,0,0.7)"
|
||||
color="white"
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
>
|
||||
<Eye size={12} />
|
||||
{viewCount}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
</Tooltip>
|
||||
) : <Box />}
|
||||
</HStack>
|
||||
|
||||
<Heading
|
||||
as="h3"
|
||||
@@ -367,9 +342,9 @@ const BlogPage: React.FC = () => {
|
||||
</Container>
|
||||
)}
|
||||
|
||||
<Container maxW="5xl">
|
||||
<Container maxW="7xl">
|
||||
{/* Responsive grid with consistent card sizing */}
|
||||
<Grid templateColumns={{ base: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={6}>
|
||||
<Grid templateColumns={{ base: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={8}>
|
||||
{isLoading && Array.from({ length: 9 }).map((_, i) => (
|
||||
<Skeleton key={i} h={{ base: '260px', md: '300px' }} borderRadius="md" />
|
||||
))}
|
||||
|
||||
@@ -37,6 +37,7 @@ import ContactMap from '../components/home/ContactMap';
|
||||
import { getPublicContacts, GroupedContacts } from '../services/contactInfo';
|
||||
import { facrApi } from '../services/facr/facrApi';
|
||||
import { getCompetitionAliasesPublic } from '../services/competitionAliases';
|
||||
import { getImageUrl } from '../utils/imageUtils';
|
||||
|
||||
type ContactFormData = {
|
||||
name: string;
|
||||
@@ -276,7 +277,7 @@ const ContactPage: React.FC = () => {
|
||||
<Box key={contact.id} bg={bgColor} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
|
||||
<VStack align="start" spacing={3}>
|
||||
{contact.image_url && (
|
||||
<Avatar src={contact.image_url} name={contact.name} size="lg" />
|
||||
<Avatar src={getImageUrl(contact.image_url)} name={contact.name} size="lg" />
|
||||
)}
|
||||
<Box>
|
||||
<Heading size="sm">{contact.name}</Heading>
|
||||
@@ -317,7 +318,7 @@ const ContactPage: React.FC = () => {
|
||||
<Box key={contact.id} bg={bgColor} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
|
||||
<VStack align="start" spacing={3}>
|
||||
{contact.image_url && (
|
||||
<Avatar src={contact.image_url} name={contact.name} size="lg" />
|
||||
<Avatar src={getImageUrl(contact.image_url)} name={contact.name} size="lg" />
|
||||
)}
|
||||
<Box>
|
||||
<Heading size="sm">{contact.name}</Heading>
|
||||
@@ -359,7 +360,7 @@ const ContactPage: React.FC = () => {
|
||||
<Box key={contact.id} bg={bgColor} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
|
||||
<VStack align="start" spacing={3}>
|
||||
{contact.image_url && (
|
||||
<Avatar src={contact.image_url} name={contact.name} size="lg" />
|
||||
<Avatar src={getImageUrl(contact.image_url)} name={contact.name} size="lg" />
|
||||
)}
|
||||
<Box>
|
||||
<Heading size="sm">{contact.name}</Heading>
|
||||
|
||||
+279
-125
@@ -39,6 +39,7 @@ const MatchesSlider = React.lazy(() => import('../components/pack/MatchesSlider'
|
||||
import ActivitiesList from '../components/pack/ActivitiesList';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import SweepstakeWidget from '../components/sweepstakes/SweepstakeWidget';
|
||||
import { sortCategoriesWithOrder } from '../utils/categorySort';
|
||||
|
||||
// Types for real API-driven data
|
||||
type NewsItem = {
|
||||
@@ -92,7 +93,7 @@ const HomePage: React.FC = () => {
|
||||
const [edgeRoleIdx, setEdgeRoleIdx] = useState<number>(0);
|
||||
const blogAutoRef = useRef<HTMLDivElement | null>(null);
|
||||
// FACR competitions with matches (for slider)
|
||||
const [facrCompetitions, setFacrCompetitions] = useState<Array<{ name:string; matches:Array<any>; matches_link?:string }>>([]);
|
||||
const [facrCompetitions, setFacrCompetitions] = useState<Array<{ name:string; matches:Array<any>; matches_link?:string; display_order?: number }>>([]);
|
||||
const [matchesTab, setMatchesTab] = useState<number>(0);
|
||||
const [selectedClub, setSelectedClub] = useState<any>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -118,10 +119,11 @@ const HomePage: React.FC = () => {
|
||||
const [merchItems, setMerchItems] = useState<UiMerch[]>([]);
|
||||
const [merchEnabled, setMerchEnabled] = useState<boolean>(false);
|
||||
const [upcomingEvents, setUpcomingEvents] = useState<UiEvent[]>([]);
|
||||
const [activitiesLoaded, setActivitiesLoaded] = useState<boolean>(false);
|
||||
const [defer, setDefer] = useState<boolean>(false);
|
||||
// Aliases
|
||||
const [aliases, setAliases] = useState<CompetitionAlias[]>([]);
|
||||
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string }>>({});
|
||||
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string; display_order?: number }>>({});
|
||||
const [settings, setSettings] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isEditingMode, setIsEditingMode] = useState<boolean>(false);
|
||||
@@ -164,6 +166,33 @@ const HomePage: React.FC = () => {
|
||||
slug: item.slug,
|
||||
})), [featured]);
|
||||
|
||||
const upcomingCompIndices = useMemo(() => {
|
||||
const now = Date.now();
|
||||
try {
|
||||
return (facrCompetitions || [])
|
||||
.map((c, i) => {
|
||||
const items = Array.isArray(c?.matches) ? c.matches : [];
|
||||
const hasUpcoming = items.some((m: any) => {
|
||||
const t = new Date(`${m.date || ''}T${(m.time || '00:00')}:00`).getTime();
|
||||
return !isNaN(t) && t > now;
|
||||
});
|
||||
return hasUpcoming ? i : -1;
|
||||
})
|
||||
.filter((i) => i !== -1);
|
||||
} catch {
|
||||
return [] as number[];
|
||||
}
|
||||
}, [facrCompetitions]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (!Array.isArray(upcomingCompIndices) || upcomingCompIndices.length === 0) return;
|
||||
if (!upcomingCompIndices.includes(nextCompIdx)) {
|
||||
setNextCompIdx(upcomingCompIndices[0]);
|
||||
}
|
||||
} catch {}
|
||||
}, [upcomingCompIndices, nextCompIdx]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -262,8 +291,8 @@ const HomePage: React.FC = () => {
|
||||
try {
|
||||
aliasesList = await getCompetitionAliasesPublic();
|
||||
} catch {}
|
||||
const amap: Record<string, { alias: string; original_name?: string }> = {};
|
||||
(aliasesList || []).forEach((a) => { if (a?.code && a?.alias) amap[a.code] = { alias: a.alias, original_name: a.original_name }; });
|
||||
const amap: Record<string, { alias: string; original_name?: string; display_order?: number }> = {};
|
||||
(aliasesList || []).forEach((a) => { if (a?.code && a?.alias) amap[a.code] = { alias: a.alias, original_name: a.original_name, display_order: a.display_order }; });
|
||||
// Try live settings API first
|
||||
let liveSettings: any = null;
|
||||
try {
|
||||
@@ -392,10 +421,12 @@ const HomePage: React.FC = () => {
|
||||
return {
|
||||
name: (amap?.[c?.code]?.alias) || c.name || c.code || 'Soutěž',
|
||||
matches_link: c.matches_link,
|
||||
matches: filtered
|
||||
matches: filtered,
|
||||
display_order: (amap?.[c?.code]?.display_order),
|
||||
};
|
||||
});
|
||||
setFacrCompetitions(comps);
|
||||
const sortedComps = sortCategoriesWithOrder(comps as any);
|
||||
setFacrCompetitions(sortedComps as any);
|
||||
|
||||
// Next match FACR link
|
||||
const first = filteredMatches?.[0];
|
||||
@@ -414,7 +445,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
// Load players via API (include inactive to show as non-active instead of hiding)
|
||||
try {
|
||||
const apiPlayers: ApiPlayer[] = await apiGetPlayers({ active: false });
|
||||
const apiPlayers: ApiPlayer[] = await apiGetPlayers();
|
||||
const mappedPlayers: UiPlayer[] = (apiPlayers || []).map((p: ApiPlayer) => ({
|
||||
id: p.id,
|
||||
name: [p.first_name, p.last_name].filter(Boolean).join(' '),
|
||||
@@ -481,7 +512,7 @@ const HomePage: React.FC = () => {
|
||||
const top3 = all.slice(0, 3);
|
||||
setFeatured(top3);
|
||||
setNews((prev) => {
|
||||
const featuredKeys = new Set(top3.map((f) => (f.slug ? `s:${f.slug}` : `i:${f.id}`)));
|
||||
const featuredKeys = new Set(all.map((f) => (f.slug ? `s:${f.slug}` : `i:${f.id}`)));
|
||||
return (prev || []).filter((n) => !featuredKeys.has(n.slug ? `s:${n.slug}` : `i:${n.id}`));
|
||||
});
|
||||
} catch {}
|
||||
@@ -531,6 +562,8 @@ const HomePage: React.FC = () => {
|
||||
if (facrTablesJSON?.competitions?.length) {
|
||||
const comps = (facrTablesJSON.competitions || []).map((c: any) => ({
|
||||
name: (amap?.[c?.code]?.alias) || c.name || c.code,
|
||||
display_order: (amap?.[c?.code]?.display_order),
|
||||
code: c.code,
|
||||
table: (c.table?.overall || []).map((r: any, idx: number) => ({
|
||||
position: Number(r.rank || idx + 1),
|
||||
team: r.team || r.team_name || '-',
|
||||
@@ -544,7 +577,8 @@ const HomePage: React.FC = () => {
|
||||
score: r.score || '0:0',
|
||||
})),
|
||||
}));
|
||||
setStandings(comps);
|
||||
const sortedTables = sortCategoriesWithOrder(comps as any);
|
||||
setStandings(sortedTables);
|
||||
}
|
||||
|
||||
// Club name/logo from FACR if not provided by settings
|
||||
@@ -630,6 +664,9 @@ const HomePage: React.FC = () => {
|
||||
}));
|
||||
if (active) setUpcomingEvents(mapped);
|
||||
} catch {}
|
||||
finally {
|
||||
if (active) setActivitiesLoaded(true);
|
||||
}
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
@@ -1402,13 +1439,17 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
</a>
|
||||
) : (
|
||||
<a href="/news" className="hero-card big" style={{ textDecoration: 'none' }}>
|
||||
<div className="bg" style={{ backgroundImage: `url('/images/news/placeholder.jpg')` }} />
|
||||
<div className="overlay">
|
||||
<div style={{ opacity: 0.9, fontSize: '0.8rem', color: '#ffffff' }}>Aktuality</div>
|
||||
<h2 style={{ margin: '4px 0 0 0', color: '#ffffff' }}>Nejnovější titulek</h2>
|
||||
</div>
|
||||
</a>
|
||||
isLoading ? (
|
||||
<div className="hero-card big skeleton" style={{ borderRadius: 16 }} />
|
||||
) : (
|
||||
<a href="/news" className="hero-card big" style={{ textDecoration: 'none' }}>
|
||||
<div className="bg" style={{ backgroundImage: `url('/images/news/placeholder.jpg')` }} />
|
||||
<div className="overlay">
|
||||
<div style={{ opacity: 0.9, fontSize: '0.8rem', color: '#ffffff' }}>Aktuality</div>
|
||||
<h2 style={{ margin: '4px 0 0 0', color: '#ffffff' }}>Nejnovější titulek</h2>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
<div className="small-col">
|
||||
{featured.slice(1, 3).map((n, idx) => (
|
||||
@@ -1421,13 +1462,17 @@ const HomePage: React.FC = () => {
|
||||
</a>
|
||||
))}
|
||||
{Array.from({ length: Math.max(0, 2 - Math.min(2, Math.max(0, featured.length - 1))) }).map((_, idx) => (
|
||||
<a key={`placeholder-${idx}`} href="/news" className="hero-card small" style={{ textDecoration: 'none' }}>
|
||||
<div className="bg" style={{ backgroundImage: `url('/images/news/placeholder.jpg')`, filter: 'grayscale(50%) brightness(0.7)' }} />
|
||||
<div className="overlay">
|
||||
<div style={{ opacity: 0.8, fontSize: '0.8rem', color: '#fff' }}>Aktuality</div>
|
||||
<h3 style={{ margin: '4px 0 0 0', color: '#fff' }}>Připravujeme...</h3>
|
||||
</div>
|
||||
</a>
|
||||
isLoading ? (
|
||||
<div key={`placeholder-${idx}`} className="hero-card small skeleton" style={{ borderRadius: 16 }} />
|
||||
) : (
|
||||
<a key={`placeholder-${idx}`} href="/news" className="hero-card small" style={{ textDecoration: 'none' }}>
|
||||
<div className="bg" style={{ backgroundImage: `url('/images/news/placeholder.jpg')`, filter: 'grayscale(50%) brightness(0.7)' }} />
|
||||
<div className="overlay">
|
||||
<div style={{ opacity: 0.8, fontSize: '0.8rem', color: '#fff' }}>Aktuality</div>
|
||||
<h3 style={{ margin: '4px 0 0 0', color: '#fff' }}>Připravujeme...</h3>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1438,7 +1483,7 @@ const HomePage: React.FC = () => {
|
||||
{(banners || []).filter(b => b.placement === 'homepage_middle').map((b) => (
|
||||
<a key={b.id} href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'inline-block', margin: 8 }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
<img src={b.image} alt={b.name} style={{ maxWidth: '100%', width: b.width ? `${b.width}px` : undefined, height: b.height ? `${b.height}px` : 'auto' }} />
|
||||
<img loading="lazy" decoding="async" src={b.image} alt={b.name} style={{ maxWidth: '100%', width: b.width ? `${b.width}px` : undefined, height: b.height ? `${b.height}px` : 'auto' }} />
|
||||
</a>
|
||||
))}
|
||||
</section>
|
||||
@@ -1446,34 +1491,37 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Featured articles are now shown in the hero grid above, not here */}
|
||||
|
||||
{/* Sidebar banners (homepage_sidebar) - fixed edge rail, left/right via MyUIbrix variant */}
|
||||
{/* Sidebar banners (homepage_sidebar) - sticky within page container */}
|
||||
{(banners || []).some(b => b.placement === 'homepage_sidebar') && (
|
||||
<section
|
||||
key={`sidebar-${refreshKey}-${getVariant('sidebar', 'right')}`}
|
||||
data-element="sidebar"
|
||||
data-variant={getVariant('sidebar', 'right')}
|
||||
className={`banner banner-sidebar sidebar-${getVariant('sidebar', 'right')}`}
|
||||
style={{
|
||||
// Use configured styles but force fixed rail placement
|
||||
...getStyles('sidebar'),
|
||||
position: 'fixed',
|
||||
top: 112,
|
||||
left: getVariant('sidebar', 'right') === 'left' ? 12 : 'auto',
|
||||
right: getVariant('sidebar', 'right') === 'left' ? 'auto' : 12,
|
||||
width: 320,
|
||||
maxWidth: '100%',
|
||||
zIndex: 50,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
style={{ margin: '24px 0', ...getStyles('sidebar') }}
|
||||
>
|
||||
{(banners || []).filter(b => b.placement === 'homepage_sidebar').map((b) => (
|
||||
<div key={b.id} className="card" style={{ display: 'block', marginBottom: 12, pointerEvents: 'auto', padding: 4 }}>
|
||||
<a href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'block' }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
<img loading="lazy" src={b.image} alt={b.name} style={{ width: b.width ? `${b.width}px` : '100%', height: b.height ? `${b.height}px` : 'auto', maxWidth: '100%' }} />
|
||||
</a>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 112,
|
||||
width: 320,
|
||||
maxWidth: '100%',
|
||||
marginLeft: getVariant('sidebar', 'right') === 'left' ? 0 : 'auto',
|
||||
marginRight: getVariant('sidebar', 'right') === 'left' ? 'auto' : 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{(banners || []).filter(b => b.placement === 'homepage_sidebar').map((b) => (
|
||||
<div key={b.id} className="card" style={{ display: 'block', marginBottom: 12, padding: 4 }}>
|
||||
<a href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'block' }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
<img loading="lazy" decoding="async" src={b.image} alt={b.name} style={{ width: b.width ? `${b.width}px` : '100%', height: b.height ? `${b.height}px` : 'auto', maxWidth: '100%' }} />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{getVariant('hero', heroStyle) === 'scroller' && isVisible('hero', true) && (
|
||||
@@ -1492,58 +1540,68 @@ const HomePage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Next match: categories (competitions) with left/right navigation - synced with matchesTab */}
|
||||
{facrCompetitions.length > 0 && isVisible('matches', true) ? (
|
||||
(() => {
|
||||
const comp = facrCompetitions[Math.max(0, Math.min(matchesTab, facrCompetitions.length - 1))];
|
||||
const items = Array.isArray(comp?.matches) ? comp.matches : [];
|
||||
const upcoming = items
|
||||
.map((m: any) => ({ m, t: new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime() }))
|
||||
.filter((x: any) => !isNaN(x.t) && x.t > Date.now())
|
||||
.sort((a: any, b: any) => a.t - b.t)[0]?.m;
|
||||
const show = upcoming || items[0] || null;
|
||||
const link = (show && (show.facr_link || show.report_url)) || comp?.matches_link || nextMatchLink;
|
||||
const handleNextMatchClick = () => {
|
||||
if (show) {
|
||||
setSelectedMatch({
|
||||
...show,
|
||||
competition: comp?.name,
|
||||
});
|
||||
setIsMatchModalOpen(true);
|
||||
} else if (link) {
|
||||
window.open(link, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
{isVisible('matches', true) ? (
|
||||
facrCompetitions.length > 0 ? (
|
||||
upcomingCompIndices.length > 0 ? (
|
||||
(() => {
|
||||
const safeIndex = Math.max(0, Math.min(nextCompIdx, facrCompetitions.length - 1));
|
||||
const pos = upcomingCompIndices.indexOf(safeIndex);
|
||||
const effectiveIndex = pos >= 0 ? upcomingCompIndices[pos] : upcomingCompIndices[0];
|
||||
const comp = facrCompetitions[effectiveIndex];
|
||||
const items = Array.isArray(comp?.matches) ? comp.matches : [];
|
||||
const upcoming = items
|
||||
.map((m: any) => ({ m, t: new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime() }))
|
||||
.filter((x: any) => !isNaN(x.t) && x.t > Date.now())
|
||||
.sort((a: any, b: any) => a.t - b.t)[0]?.m;
|
||||
const show = upcoming || null;
|
||||
const link = (show && (show.facr_link || show.report_url)) || comp?.matches_link || nextMatchLink;
|
||||
const prevIdx = upcomingCompIndices[(Math.max(0, pos) - 1 + upcomingCompIndices.length) % upcomingCompIndices.length];
|
||||
const nextIdx = upcomingCompIndices[(Math.max(0, pos) + 1) % upcomingCompIndices.length];
|
||||
const handleNextMatchClick = () => {
|
||||
if (show) {
|
||||
setSelectedMatch({
|
||||
...show,
|
||||
competition: comp?.name,
|
||||
});
|
||||
setIsMatchModalOpen(true);
|
||||
} else if (link) {
|
||||
window.open(link, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
return (
|
||||
<NextMatch
|
||||
data={show}
|
||||
competitionName={comp?.name}
|
||||
countdown={countdown}
|
||||
onPrev={() => setNextCompIdx(prevIdx)}
|
||||
onNext={() => setNextCompIdx(nextIdx)}
|
||||
onOpen={handleNextMatchClick}
|
||||
elementProps={{
|
||||
'data-element': 'matches' as any,
|
||||
'data-variant': getVariant('matches', 'compact') as any,
|
||||
'aria-live': 'polite' as any,
|
||||
style: { ...getStyles('matches') },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
) : null
|
||||
) : (
|
||||
<div className="card">
|
||||
<NextMatch
|
||||
data={show}
|
||||
competitionName={comp?.name}
|
||||
countdown={countdown}
|
||||
onPrev={() => setMatchesTab((i) => (i - 1 + facrCompetitions.length) % facrCompetitions.length)}
|
||||
onNext={() => setMatchesTab((i) => (i + 1) % facrCompetitions.length)}
|
||||
onOpen={handleNextMatchClick}
|
||||
elementProps={{
|
||||
'data-element': 'matches' as any,
|
||||
'data-variant': getVariant('matches', 'compact') as any,
|
||||
style: { ...getStyles('matches') },
|
||||
key={`matches-${refreshKey}-${getVariant('matches', 'compact')}`}
|
||||
data={{
|
||||
home: matches[0]?.homeTeam || clubName,
|
||||
home_logo_url: matches[0]?.homeLogoURL || clubLogo,
|
||||
away: matches[0]?.awayTeam || 'Soupeř',
|
||||
away_logo_url: matches[0]?.awayLogoURL,
|
||||
}}
|
||||
countdown={countdown}
|
||||
elementProps={{ 'data-element': 'matches', 'data-variant': getVariant('matches', 'compact'), 'aria-live': 'polite', style: { position: 'relative', ...getStyles('matches') } }}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
) : isVisible('matches', true) ? (
|
||||
<div className="card">
|
||||
<NextMatch
|
||||
key={`matches-${refreshKey}-${getVariant('matches', 'compact')}`}
|
||||
data={{
|
||||
home: matches[0]?.homeTeam || clubName,
|
||||
home_logo_url: matches[0]?.homeLogoURL || clubLogo,
|
||||
away: matches[0]?.awayTeam || 'Soupeř',
|
||||
away_logo_url: matches[0]?.awayLogoURL,
|
||||
}}
|
||||
countdown={countdown}
|
||||
elementProps={{ 'data-element': 'matches', 'data-variant': getVariant('matches', 'compact'), style: { position: 'relative', ...getStyles('matches') } }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{/* Sweepstakes / Lottery widget (visible around matches section) */}
|
||||
@@ -1570,6 +1628,20 @@ const HomePage: React.FC = () => {
|
||||
</Suspense>
|
||||
) : null
|
||||
)}
|
||||
|
||||
{facrCompetitions.length === 0 && isLoading && (
|
||||
<section data-element="matches-slider" data-variant={getVariant('matches-slider', 'carousel')} aria-label="Zápasy" style={{ position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '280px', ...getStyles('matches-slider') }}>
|
||||
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
|
||||
<h3>Zápasy</h3>
|
||||
<a href="/kalendar" className="see-all">Všechny zápasy</a>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 18, overflow: 'hidden', padding: '8px 2px 16px 2px' }}>
|
||||
{[1,2,3].map((i) => (
|
||||
<div key={i} className="card skeleton" style={{ minWidth: 340, height: 160, borderRadius: 12 }} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* News + Tables: split into two independent sections */}
|
||||
{(() => {
|
||||
@@ -1597,23 +1669,31 @@ const HomePage: React.FC = () => {
|
||||
style={{ marginTop: 32 }}
|
||||
>
|
||||
{showNews && (
|
||||
<section key={`news-${refreshKey}-${newsVariant}`} data-element="news" data-variant={newsVariant} className="news-list" style={{ ...getStyles('news') }}>
|
||||
<section key={`news-${refreshKey}-${newsVariant}`} data-element="news" data-variant={newsVariant} className="news-list" aria-labelledby="home-news-heading" style={{ ...getStyles('news'), contentVisibility: 'auto' as any, containIntrinsicSize: '600px' }}>
|
||||
<div className="section-head" style={{ marginTop: 0 }}>
|
||||
<h3>Další aktuality</h3>
|
||||
<h3 id="home-news-heading">Další aktuality</h3>
|
||||
<a href="/news" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
|
||||
</div>
|
||||
{newsVariant === 'scroller' ? (
|
||||
<BlogCardsScroller />
|
||||
) : (
|
||||
<NewsList items={news as any} />
|
||||
isLoading && (!news || (news as any).length === 0) ? (
|
||||
<div className="blog-list">
|
||||
{[1,2,3,4].map(i => (
|
||||
<div key={i} className="card skeleton" style={{ height: 96, borderRadius: 12 }} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<NewsList items={news as any} />
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{showTable && (
|
||||
<div key={`table-${refreshKey}-${getVariant('table', 'split_news')}`} data-element="table" data-variant={getVariant('table', 'split_news')} style={{ ...getStyles('table') }}>
|
||||
<div key={`table-${refreshKey}-${getVariant('table', 'split_news')}`} data-element="table" data-variant={getVariant('table', 'split_news')} role="region" aria-labelledby="home-table-heading" style={{ ...getStyles('table'), contentVisibility: 'auto' as any, containIntrinsicSize: '520px' }}>
|
||||
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
|
||||
<h3>Tabulky</h3>
|
||||
<h3 id="home-table-heading">Tabulky</h3>
|
||||
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
|
||||
</div>
|
||||
{defer ? (
|
||||
@@ -1639,7 +1719,15 @@ const HomePage: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="table-card">
|
||||
<div className="standings">
|
||||
{[1,2,3,4,5,6,7,8].map(i => (
|
||||
<div key={i} className="standing-row skeleton" style={{ borderRadius: 12 }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Banners under the table, inside the table column */}
|
||||
{(banners || []).some(b => b.placement === 'homepage_under_table') && (
|
||||
defer ? (
|
||||
@@ -1657,12 +1745,28 @@ const HomePage: React.FC = () => {
|
||||
{/* (Moved) Banner under tables now renders inside the table column above */}
|
||||
|
||||
{/* Competition tables moved into right column below */}
|
||||
|
||||
{upcomingEvents.length > 0 && isVisible('activities', true) && (
|
||||
<section key={`activities-${refreshKey}-${getVariant('activities', 'list')}`} data-element="activities" data-variant={getVariant('activities', 'list')} style={{ marginTop: 32, marginBottom: 16, position: 'relative', ...getStyles('activities') }}>
|
||||
|
||||
{isVisible('activities', true) && !activitiesLoaded && (
|
||||
<section data-element="activities" data-variant={getVariant('activities', 'list')} aria-labelledby="home-activities-heading" style={{ marginTop: 32, marginBottom: 16, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '600px', ...getStyles('activities') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<div className="section-head" style={{ marginTop: 0 }}>
|
||||
<h3>Aktivity</h3>
|
||||
<h3 id="home-activities-heading">Aktivity</h3>
|
||||
<a href="/aktivity" className="see-all">Zobrazit vše <FiArrowRight /></a>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 12 }}>
|
||||
{[1,2,3].map(i => (
|
||||
<div key={i} className="card skeleton" style={{ height: 120, borderRadius: 12 }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{upcomingEvents.length > 0 && isVisible('activities', true) && (
|
||||
<section key={`activities-${refreshKey}-${getVariant('activities', 'list')}`} data-element="activities" data-variant={getVariant('activities', 'list')} aria-labelledby="home-activities-heading" style={{ marginTop: 32, marginBottom: 16, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '600px', ...getStyles('activities') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<div className="section-head" style={{ marginTop: 0 }}>
|
||||
<h3 id="home-activities-heading">Aktivity</h3>
|
||||
<a href="/aktivity" className="see-all">Zobrazit vše <FiArrowRight /></a>
|
||||
</div>
|
||||
<ActivitiesList items={upcomingEvents as any} />
|
||||
@@ -1671,10 +1775,25 @@ const HomePage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Players scroller */}
|
||||
{players.length > 0 && isVisible('team', false) && (
|
||||
<section key={`team-${refreshKey}-${getVariant('team', 'grid')}`} data-element="team" data-variant={getVariant('team', 'grid')} className="players-scroller" style={{ marginTop: 32, position: 'relative', ...getStyles('team') }}>
|
||||
|
||||
{isVisible('team', false) && players.length === 0 && isLoading && (
|
||||
<section data-element="team" data-variant={getVariant('team', 'grid')} className="players-scroller" aria-labelledby="home-players-heading" style={{ marginTop: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '600px', ...getStyles('team') }}>
|
||||
<div className="section-head">
|
||||
<h3>Hráči</h3>
|
||||
<h3 id="home-players-heading">Hráči</h3>
|
||||
<a href="/players" className="see-all">Zobrazit vše <FiArrowRight /></a>
|
||||
</div>
|
||||
<div className="scroll-x">
|
||||
{[1,2,3,4,5,6].map(i => (
|
||||
<div key={i} className="player-card skeleton" style={{ width: 170, height: 210, borderRadius: 14 }} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{players.length > 0 && isVisible('team', false) && (
|
||||
<section key={`team-${refreshKey}-${getVariant('team', 'grid')}`} data-element="team" data-variant={getVariant('team', 'grid')} className="players-scroller" aria-labelledby="home-players-heading" style={{ marginTop: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '600px', ...getStyles('team') }}>
|
||||
<div className="section-head">
|
||||
<h3 id="home-players-heading">Hráči</h3>
|
||||
<a href="/players" className="see-all">Zobrazit vše <FiArrowRight /></a>
|
||||
</div>
|
||||
<div className="scroll-x">
|
||||
@@ -1691,7 +1810,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Gallery */}
|
||||
{isVisible('gallery', false) && (
|
||||
<section key={`gallery-${refreshKey}-${getVariant('gallery', 'grid')}`} data-element="gallery" data-variant={getVariant('gallery', 'grid')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('gallery') }}>
|
||||
<section key={`gallery-${refreshKey}-${getVariant('gallery', 'grid')}`} data-element="gallery" data-variant={getVariant('gallery', 'grid')} aria-labelledby="home-gallery-heading" style={{ marginTop: 32, marginBottom: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '700px', ...getStyles('gallery') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
{defer ? (
|
||||
<Suspense fallback={null}>
|
||||
@@ -1704,7 +1823,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Videos */}
|
||||
{isVisible('videos', false) && (
|
||||
<section key={`videos-${refreshKey}-${getVariant('videos', 'carousel')}`} data-element="videos" data-variant={getVariant('videos', 'carousel')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('videos') }}>
|
||||
<section key={`videos-${refreshKey}-${getVariant('videos', 'carousel')}`} data-element="videos" data-variant={getVariant('videos', 'carousel')} aria-labelledby="home-videos-heading" style={{ marginTop: 32, marginBottom: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '700px', ...getStyles('videos') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
{defer ? (
|
||||
<Suspense fallback={null}>
|
||||
@@ -1713,26 +1832,50 @@ const HomePage: React.FC = () => {
|
||||
variant={(getVariant('videos', 'carousel') as any) as 'grid' | 'carousel'}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
) : (
|
||||
<>
|
||||
<div className="section-head">
|
||||
<h3 id="home-videos-heading">Videa</h3>
|
||||
<a href="/videa" className="see-all">Více videí</a>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 12 }}>
|
||||
{[1,2,3].map((i) => (
|
||||
<div key={i} className="card skeleton" style={{ height: 240, borderRadius: 12 }} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isVisible('merch', true) && (
|
||||
<section key={`merch-${refreshKey}-${getVariant('merch', 'grid')}`} data-element="merch" data-variant={getVariant('merch', 'grid')} style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('merch') }}>
|
||||
<section key={`merch-${refreshKey}-${getVariant('merch', 'grid')}`} data-element="merch" data-variant={getVariant('merch', 'grid')} aria-labelledby="home-merch-heading" style={{ marginTop: 24, marginBottom: 24, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '600px', ...getStyles('merch') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
{defer ? (
|
||||
<Suspense fallback={null}>
|
||||
<MerchSection variant={(getVariant('merch', 'grid') as any) as 'grid' | 'carousel' | 'featured' | 'list'} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
) : (
|
||||
<>
|
||||
<div className="section-head">
|
||||
<h3 id="home-merch-heading">Oblečení týmu</h3>
|
||||
<a href="/obleceni" className="see-all">Zobrazit vše</a>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||
{[1,2,3,4,5].map((i) => (
|
||||
<div key={i} className="card skeleton" style={{ height: 180, borderRadius: 12 }} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Polls / Voting */}
|
||||
{isVisible('poll', false) && (
|
||||
<section key={`poll-${refreshKey}-${getVariant('poll', 'vertical')}`} data-element="poll" data-variant={getVariant('poll', 'vertical')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('poll') }}>
|
||||
<section key={`poll-${refreshKey}-${getVariant('poll', 'vertical')}`} data-element="poll" data-variant={getVariant('poll', 'vertical')} aria-label="Anketa" style={{ marginTop: 32, marginBottom: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '500px', ...getStyles('poll') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
{defer ? (
|
||||
<Suspense fallback={null}>
|
||||
@@ -1740,7 +1883,9 @@ const HomePage: React.FC = () => {
|
||||
<PollsWidget featuredOnly={true} maxPolls={1} title="Anketa" />
|
||||
</div>
|
||||
</Suspense>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="card skeleton" style={{ height: 320, borderRadius: 12 }} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
@@ -1751,7 +1896,7 @@ const HomePage: React.FC = () => {
|
||||
{(banners || []).filter(b => b.placement === 'homepage_footer').map((b) => (
|
||||
<a key={b.id} href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'inline-block', margin: 8 }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
<img src={b.image} alt={b.name} style={{ maxWidth: '100%', width: b.width ? `${b.width}px` : undefined, height: b.height ? `${b.height}px` : 'auto' }} />
|
||||
<img loading="lazy" decoding="async" src={b.image} alt={b.name} style={{ maxWidth: '100%', width: b.width ? `${b.width}px` : undefined, height: b.height ? `${b.height}px` : 'auto' }} />
|
||||
</a>
|
||||
))}
|
||||
</section>
|
||||
@@ -1759,13 +1904,15 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* CTA (Newsletter) moved up */}
|
||||
{isVisible('newsletter', false) && (
|
||||
<section key={`newsletter-${refreshKey}-${getVariant('newsletter', 'default')}`} data-element="newsletter" data-variant={getVariant('newsletter', 'default')} className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('newsletter') }}>
|
||||
<section key={`newsletter-${refreshKey}-${getVariant('newsletter', 'default')}`} data-element="newsletter" data-variant={getVariant('newsletter', 'default')} className="newsletter-cta" aria-label="Přihlášení k newsletteru" style={{ marginTop: 24, marginBottom: 24, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '420px', ...getStyles('newsletter') }}>
|
||||
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
|
||||
{defer ? (
|
||||
<Suspense fallback={null}>
|
||||
<NewsletterSubscribe />
|
||||
</Suspense>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="skeleton" style={{ height: 280, borderRadius: 12 }} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
@@ -1830,6 +1977,7 @@ const HomePage: React.FC = () => {
|
||||
data-element="sponsors"
|
||||
data-variant={variant}
|
||||
className={`sponsors ${sponsorsTheme === 'dark' ? 'dark' : ''}`}
|
||||
aria-labelledby="home-sponsors-heading"
|
||||
style={{
|
||||
width: '100vw',
|
||||
position: 'relative',
|
||||
@@ -1839,19 +1987,28 @@ const HomePage: React.FC = () => {
|
||||
paddingLeft: 'max(16px, calc((100vw - 1200px) / 2))',
|
||||
paddingRight: 'max(16px, calc((100vw - 1200px) / 2))',
|
||||
boxSizing: 'border-box',
|
||||
contentVisibility: 'auto' as any,
|
||||
containIntrinsicSize: '520px',
|
||||
...getStyles('sponsors')
|
||||
}}
|
||||
>
|
||||
<div className="section-head">
|
||||
<h3>Sponzoři</h3>
|
||||
<h3 id="home-sponsors-heading">Sponzoři</h3>
|
||||
</div>
|
||||
{isLoading && ordered.length === 0 && (
|
||||
<div className="sponsors-grid">
|
||||
{[1,2,3,4,5,6,7,8].map(i => (
|
||||
<div key={i} className="sponsor-tile skeleton" style={{ minHeight: 90, borderRadius: 12 }} />
|
||||
))}
|
||||
</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} />
|
||||
<img loading="lazy" decoding="async" src={assetUrl(g.logo) || '/images/sponsors/placeholder.png'} alt={g.name} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
@@ -1860,7 +2017,7 @@ const HomePage: React.FC = () => {
|
||||
<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} />
|
||||
<img loading="lazy" decoding="async" src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
@@ -1872,7 +2029,7 @@ const HomePage: React.FC = () => {
|
||||
<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} />
|
||||
<img loading="lazy" decoding="async" src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
@@ -1883,7 +2040,7 @@ const HomePage: React.FC = () => {
|
||||
<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} />
|
||||
<img loading="lazy" decoding="async" src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
@@ -1943,11 +2100,8 @@ const HomePage: React.FC = () => {
|
||||
};
|
||||
|
||||
function czYears(n: number): string {
|
||||
const mod100 = n % 100;
|
||||
if (mod100 >= 11 && mod100 <= 14) return 'let';
|
||||
const mod10 = n % 10;
|
||||
if (mod10 === 1) return 'rok';
|
||||
if (mod10 >= 2 && mod10 <= 4) return 'roky';
|
||||
if (n === 1) return 'rok';
|
||||
if (n >= 2 && n <= 4) return 'roky';
|
||||
return 'let';
|
||||
}
|
||||
|
||||
|
||||
@@ -130,11 +130,8 @@ function calculateAge(iso: string): number | null {
|
||||
}
|
||||
|
||||
function czYears(n: number): string {
|
||||
const mod100 = n % 100;
|
||||
if (mod100 >= 11 && mod100 <= 14) return 'let';
|
||||
const mod10 = n % 10;
|
||||
if (mod10 === 1) return 'rok';
|
||||
if (mod10 >= 2 && mod10 <= 4) return 'roky';
|
||||
if (n === 1) return 'rok';
|
||||
if (n >= 2 && n <= 4) return 'roky';
|
||||
return 'let';
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useMemo, useState } from 'react';
|
||||
import { SearchIcon } from '@chakra-ui/icons';
|
||||
|
||||
const PlayersPage: React.FC = () => {
|
||||
const { data, isLoading, isError } = useQuery<Player[]>({ queryKey: ['players'], queryFn: () => getPlayers() });
|
||||
const { data, isLoading, isError } = useQuery<Player[]>({ queryKey: ['players-all'], queryFn: () => getPlayers({ active: false }) });
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textSecondary = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
@@ -49,6 +49,7 @@ import { api } from '../../services/api';
|
||||
// Removed react-datepicker to prevent crash; using native date/time inputs instead
|
||||
import { getPublicSettings } from '../../services/settings';
|
||||
import PollLinker from '../../components/admin/PollLinker';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import FilePreview from '../../components/common/FilePreview';
|
||||
import { facrApi } from '../../services/facr/facrApi';
|
||||
import { getCompetitionAliasesPublic } from '../../services/competitionAliases';
|
||||
@@ -73,6 +74,8 @@ const types: Array<{ value: Event['type']; label: string }> = [
|
||||
];
|
||||
|
||||
const AdminActivitiesPage: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = (user as any)?.role === 'admin';
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const inputBg = useColorModeValue('white', 'gray.700');
|
||||
@@ -1135,7 +1138,7 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
{/* Poll Section */}
|
||||
<Box mt={6} pt={4} borderTopWidth="1px" borderColor={borderColor}>
|
||||
<Heading size="sm" mb={3}>Anketa</Heading>
|
||||
{editing?.id ? (
|
||||
{isAdmin && editing?.id ? (
|
||||
<PollLinker eventId={editing.id} />
|
||||
) : (
|
||||
<Box bg={useColorModeValue('blue.50', 'blue.900')} p={4} borderRadius="md" borderWidth="1px" borderColor="blue.200">
|
||||
|
||||
@@ -99,15 +99,6 @@ const AdminVideosPage: React.FC = () => {
|
||||
if (mounted) setAutoLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveOverrides = async () => {
|
||||
try {
|
||||
await updateAdminSettings({ videos_title_overrides: titleOverrides } as any);
|
||||
toast({ status: 'success', title: 'Přepisy uloženy', description: 'Názvy videí byly aktualizovány.', duration: 2500 });
|
||||
} catch (e) {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se uložit přepisy názvů.', duration: 3000 });
|
||||
}
|
||||
};
|
||||
run();
|
||||
return () => { mounted = false; };
|
||||
}, [loading, videosSource]);
|
||||
@@ -159,6 +150,15 @@ const AdminVideosPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const saveOverrides = async () => {
|
||||
try {
|
||||
await updateAdminSettings({ videos_title_overrides: titleOverrides } as any);
|
||||
toast({ status: 'success', title: 'Přepisy uloženy', description: 'Názvy videí byly aktualizovány.', duration: 2500 });
|
||||
} catch (e) {
|
||||
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se uložit přepisy názvů.', duration: 3000 });
|
||||
}
|
||||
};
|
||||
|
||||
const fetchChannelVideos = async () => {
|
||||
const channel = channelInput?.trim();
|
||||
if (!channel) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { FiEdit2, FiTrash2, FiPlus, FiSearch, FiUpload, FiExternalLink, FiVideo, FiX, FiRefreshCcw, FiLink } from 'react-icons/fi';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Article, deleteArticle, getArticles, createArticle, updateArticle, uploadFile, CreateArticlePayload, UpdateArticlePayload, getArticleMatchLink, putArticleMatchLink, deleteArticleMatchLink } from '../../services/articles';
|
||||
import { generateBlogAI } from '../../services/ai';
|
||||
import { useState, useRef, useCallback, useMemo } from 'react';
|
||||
@@ -172,6 +173,8 @@ const parseYoutubeVideoId = (raw: string): string => {
|
||||
};
|
||||
|
||||
const ArticlesAdminPage = () => {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = (user as any)?.role === 'admin';
|
||||
const toast = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -519,16 +522,20 @@ const ArticlesAdminPage = () => {
|
||||
try {
|
||||
// Set cover image immediately
|
||||
setEditing((prev) => ({ ...(prev as any), image_url: pick.image_url }));
|
||||
// Persist pick to unified cache (admin)
|
||||
await putZoneramaPick({
|
||||
id: pick.id,
|
||||
album_id: pick.album_id,
|
||||
album_url: pick.album_url,
|
||||
page_url: pick.page_url,
|
||||
image_url: pick.image_url,
|
||||
title: pick.title,
|
||||
} as any);
|
||||
toast({ title: 'Obrázek vybrán ze Zonerama', status: 'success' });
|
||||
// Persist pick to unified cache (admin only)
|
||||
if (isAdmin) {
|
||||
await putZoneramaPick({
|
||||
id: pick.id,
|
||||
album_id: pick.album_id,
|
||||
album_url: pick.album_url,
|
||||
page_url: pick.page_url,
|
||||
image_url: pick.image_url,
|
||||
title: pick.title,
|
||||
} as any);
|
||||
toast({ title: 'Obrázek vybrán ze Zonerama', status: 'success' });
|
||||
} else {
|
||||
toast({ title: 'Obrázek nastaven', status: 'success' });
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Uložení výběru selhalo', description: e?.response?.data?.error || e?.message || 'Chyba', status: 'error' });
|
||||
}
|
||||
@@ -537,9 +544,11 @@ const ArticlesAdminPage = () => {
|
||||
// Handle album photo selection for blog content
|
||||
const handleAlbumPhotosSelected = useCallback(async (photos: Array<{ id: string; page_url: string; image_1500: string }>, albumInfo: any) => {
|
||||
try {
|
||||
// Save album to cache
|
||||
toast({ title: 'Ukládám album...', status: 'info', duration: 2000 });
|
||||
await saveAlbumToCache(albumInfo.url, photos.length);
|
||||
// Save album to cache (admins only)
|
||||
if (isAdmin) {
|
||||
toast({ title: 'Ukládám album...', status: 'info', duration: 2000 });
|
||||
await saveAlbumToCache(albumInfo.url, photos.length);
|
||||
}
|
||||
|
||||
// Store album info with article and append images to content
|
||||
setEditing((prev) => {
|
||||
@@ -573,7 +582,7 @@ const ArticlesAdminPage = () => {
|
||||
|
||||
toast({
|
||||
title: 'Album přidáno',
|
||||
description: `${photos.length} fotografií vloženo do článku. Album dostupné také v sekci Média.`,
|
||||
description: isAdmin ? `${photos.length} fotografií vloženo do článku. Album dostupné také v sekci Média.` : `${photos.length} fotografií vloženo do článku.`,
|
||||
status: 'success',
|
||||
duration: 4000
|
||||
});
|
||||
@@ -2092,7 +2101,7 @@ const ArticlesAdminPage = () => {
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{editing?.id ? (
|
||||
{isAdmin && editing?.id ? (
|
||||
<PollLinker articleId={editing.id} onPollsChanged={() => {
|
||||
// Invalidate queries to refresh polls
|
||||
qc.invalidateQueries({ queryKey: ['linked-polls'] });
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { Box, Heading, HStack, VStack, Button, Select, Input, Table, Thead, Tbody, Tr, Th, Td, Text, Badge, IconButton, useToast, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, ModalCloseButton, useDisclosure, FormControl, FormLabel, NumberInput, NumberInputField, Switch } from '@chakra-ui/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { adminListComments, adminUpdateCommentStatus, adminBanUser, adminListUnbanRequests, adminResolveUnban } from '../../services/admin/comments';
|
||||
import { adminListComments, adminUpdateCommentStatus, adminBanUser, adminListUnbanRequests, adminResolveUnban, adminListBans, adminLiftBan } from '../../services/admin/comments';
|
||||
import { deleteComment } from '../../services/comments';
|
||||
import { FiTrash2 } from 'react-icons/fi';
|
||||
import { getArticles } from '../../services/articles';
|
||||
@@ -37,6 +37,11 @@ const CommentsAdminPage: React.FC = () => {
|
||||
queryFn: adminListUnbanRequests,
|
||||
});
|
||||
|
||||
const bansQ = useQuery({
|
||||
queryKey: ['admin-comment-bans'],
|
||||
queryFn: adminListBans,
|
||||
});
|
||||
|
||||
const updateStatusMut = useMutation({
|
||||
mutationFn: (args: { id: number; s: 'visible'|'hidden' }) => adminUpdateCommentStatus(args.id, args.s),
|
||||
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-comments'] }); },
|
||||
@@ -57,7 +62,16 @@ const CommentsAdminPage: React.FC = () => {
|
||||
|
||||
const resolveUnbanMut = useMutation({
|
||||
mutationFn: (args: { id: number; action: 'approve'|'reject' }) => adminResolveUnban(args.id, args.action),
|
||||
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-unban-requests'] }); toast({ status: 'success', title: 'Vyřízeno' }); },
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: ['admin-unban-requests'] });
|
||||
await qc.invalidateQueries({ queryKey: ['admin-comment-bans'] });
|
||||
toast({ status: 'success', title: 'Vyřízeno' });
|
||||
},
|
||||
});
|
||||
|
||||
const liftBanMut = useMutation({
|
||||
mutationFn: (id: number) => adminLiftBan(id),
|
||||
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-comment-bans'] }); toast({ status: 'success', title: 'Ban zrušen' }); },
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -167,7 +181,10 @@ const CommentsAdminPage: React.FC = () => {
|
||||
<Tr key={c.id}>
|
||||
<Td>#{c.id}</Td>
|
||||
<Td>#{c.user?.id} {c.user?.first_name} {c.user?.last_name}</Td>
|
||||
<Td><Badge>{c.target_type}</Badge> <Text as="span">{c.target_id}</Text></Td>
|
||||
<Td>
|
||||
<Badge mr={2}>{c.target_type}</Badge>
|
||||
<Text as="span">{c.target_label || c.target_id}</Text>
|
||||
</Td>
|
||||
<Td maxW="420px"><Text noOfLines={2}>{c.content}</Text></Td>
|
||||
<Td>{(c as any).spam_score ? <Badge colorScheme={(c as any).spam_score > 0.5 ? 'orange' : 'green'}>{(c as any).spam_score.toFixed(2)}</Badge> : '-'}</Td>
|
||||
<Td>{(c as any).reports ? <Badge colorScheme={(c as any).reports > 2 ? 'red' : 'yellow'}>{(c as any).reports}</Badge> : '-'}</Td>
|
||||
@@ -213,7 +230,7 @@ const CommentsAdminPage: React.FC = () => {
|
||||
{(unbanQ.data?.items || []).map((r) => (
|
||||
<Tr key={r.id}>
|
||||
<Td>#{r.id}</Td>
|
||||
<Td>#{r.user_id}</Td>
|
||||
<Td>#{r.user?.id} {r.user?.first_name} {r.user?.last_name} <Text as="span" color="gray.500" fontSize="sm">{r.user?.email}</Text></Td>
|
||||
<Td maxW="480px"><Text noOfLines={2}>{r.message}</Text></Td>
|
||||
<Td><Badge>{r.status}</Badge></Td>
|
||||
<Td>
|
||||
@@ -228,6 +245,39 @@ const CommentsAdminPage: React.FC = () => {
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
<Heading size="sm" mt={6} mb={2}>Zablokovaní uživatelé</Heading>
|
||||
<Box borderWidth="1px" borderRadius="md" overflowX="auto">
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>ID</Th>
|
||||
<Th>Uživatel</Th>
|
||||
<Th>Důvod</Th>
|
||||
<Th>Zabanován</Th>
|
||||
<Th>Platné do</Th>
|
||||
<Th>Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{(bansQ.data?.items || []).map((b) => {
|
||||
const untilText = !b.until ? 'Trvale' : new Date(b.until).toLocaleString();
|
||||
return (
|
||||
<Tr key={b.id}>
|
||||
<Td>#{b.id}</Td>
|
||||
<Td>#{b.user?.id} {b.user?.first_name} {b.user?.last_name} <Text as="span" color="gray.500" fontSize="sm">{b.user?.email}</Text></Td>
|
||||
<Td>{b.reason || '-'}</Td>
|
||||
<Td>{new Date(b.created_at).toLocaleString()}</Td>
|
||||
<Td>{untilText}</Td>
|
||||
<Td>
|
||||
<Button size="xs" variant="outline" onClick={() => liftBanMut.mutate(b.id)}>Zrušit ban</Button>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* Ban modal */}
|
||||
<Modal isOpen={banModal.isOpen} onClose={banModal.onClose} isCentered>
|
||||
<ModalOverlay />
|
||||
|
||||
@@ -101,12 +101,32 @@ const ContactsAdminPage: React.FC = () => {
|
||||
const [savingSettings, setSavingSettings] = useState(false);
|
||||
const [facrCompetitions, setFacrCompetitions] = useState<any[]>([]);
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
// Map of competition code -> alias (public aliases)
|
||||
const [compAliasMap, setCompAliasMap] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
// Load competition aliases map for filtering categories (so alias-named categories are visible)
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const aliases = await getCompetitionAliasesPublic().catch(() => [] as Array<{ code?: string; alias?: string }>);
|
||||
const map: Record<string, string> = {};
|
||||
(aliases || []).forEach((a: any) => {
|
||||
const code = String(a?.code || '').trim();
|
||||
const alias = String(a?.alias || '').trim();
|
||||
if (code && alias) map[code] = alias;
|
||||
});
|
||||
setCompAliasMap(map);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -170,12 +190,15 @@ const ContactsAdminPage: React.FC = () => {
|
||||
for (const comp of facrCompetitions || []) {
|
||||
const n = String(comp?.name || '').trim();
|
||||
if (n) names.add(n);
|
||||
const code = String(comp?.code || '').trim();
|
||||
const alias = code && compAliasMap[code] ? String(compAliasMap[code]).trim() : '';
|
||||
if (alias) names.add(alias);
|
||||
}
|
||||
return Array.from(names);
|
||||
} catch {
|
||||
return [] as string[];
|
||||
}
|
||||
}, [facrCompetitions]);
|
||||
}, [facrCompetitions, compAliasMap]);
|
||||
|
||||
const filteredContactCategories = useMemo(() => {
|
||||
try {
|
||||
|
||||
@@ -88,6 +88,7 @@ const EngagementAdminPage: React.FC = () => {
|
||||
const editModal = useDisclosure();
|
||||
const [editForm, setEditForm] = React.useState<Partial<AdminRewardItem>>({});
|
||||
// Remove raw JSON editing, keep structured metadata only
|
||||
const batchEnabled = false;
|
||||
|
||||
const [batch, setBatch] = React.useState({
|
||||
base_url: '',
|
||||
@@ -330,7 +331,9 @@ const EngagementAdminPage: React.FC = () => {
|
||||
</FormControl>
|
||||
</WrapItem>
|
||||
<WrapItem>
|
||||
<Button size="sm" variant="outline" onClick={batchModal.onOpen}>Dávkové vytvoření</Button>
|
||||
{batchEnabled && (
|
||||
<Button size="sm" variant="outline" onClick={batchModal.onOpen}>Dávkové vytvoření</Button>
|
||||
)}
|
||||
</WrapItem>
|
||||
</Wrap>
|
||||
<HStack align="start" spacing={4}>
|
||||
@@ -361,22 +364,38 @@ const EngagementAdminPage: React.FC = () => {
|
||||
</NumberInput>
|
||||
<FormHelperText>~ {Math.round(Number(form.cost_points || 0) * 0.1)} Kč</FormHelperText>
|
||||
</FormControl>
|
||||
{(form.type !== 'avatar_upload_unlock' && form.type !== 'avatar_animated_upload_unlock') && (
|
||||
<FormControl>
|
||||
<FormLabel>Sklad</FormLabel>
|
||||
<NumberInput value={form.stock} min={-1} onChange={(_v, n) => setForm({ ...form, stock: Number.isFinite(n) ? n : -1 })}>
|
||||
<NumberInputField placeholder="Ks (-1 = neomezeně, 0 = vyprodáno)" />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
)}
|
||||
</HStack>
|
||||
{(form.type === 'avatar_static' || form.type === 'avatar_animated') && (
|
||||
<>
|
||||
<FormControl>
|
||||
<FormLabel>Obrázek URL</FormLabel>
|
||||
<Input placeholder="https://.../avatar-1.png" value={form.image_url} onChange={(e) => setForm({ ...form, image_url: e.target.value })} />
|
||||
<FormHelperText>Pro avatar uveďte URL obrázku.</FormHelperText>
|
||||
</FormControl>
|
||||
<HStack>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e)=>handleUpload(e.target.files?.[0])} />
|
||||
<Button size="sm" variant="outline" onClick={() => fileInputRef.current?.click()}>Nahrát obrázek</Button>
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<FormControl>
|
||||
<FormLabel>Sklad</FormLabel>
|
||||
<NumberInput value={form.stock} min={-1} onChange={(_v, n) => setForm({ ...form, stock: Number.isFinite(n) ? n : -1 })}>
|
||||
<NumberInputField placeholder="Ks (-1 = neomezeně, 0 = vyprodáno)" />
|
||||
</NumberInput>
|
||||
<FormLabel>Platnost od</FormLabel>
|
||||
<Input type="datetime-local" value={meta.valid_from || ''} onChange={(e)=>setMetaField('valid_from', e.target.value)} />
|
||||
</FormControl>
|
||||
</HStack>
|
||||
<FormControl>
|
||||
<FormLabel>Obrázek URL</FormLabel>
|
||||
<Input placeholder="https://.../avatar-1.png" value={form.image_url} onChange={(e) => setForm({ ...form, image_url: e.target.value })} />
|
||||
<FormHelperText>Pro avatar uveďte URL obrázku. Pro odemknutí uploadu není třeba.</FormHelperText>
|
||||
</FormControl>
|
||||
<HStack>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e)=>handleUpload(e.target.files?.[0])} />
|
||||
<Button size="sm" variant="outline" onClick={() => fileInputRef.current?.click()}>Nahrát obrázek</Button>
|
||||
</HStack>
|
||||
<FormControl>
|
||||
<FormLabel>Platnost do</FormLabel>
|
||||
<Input type="datetime-local" value={meta.valid_to || ''} onChange={(e)=>setMetaField('valid_to', e.target.value)} />
|
||||
</FormControl>
|
||||
</VStack>
|
||||
{/* Metadata helpers */}
|
||||
{form.type === 'merch_coupon' && (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
@@ -384,10 +403,6 @@ const EngagementAdminPage: React.FC = () => {
|
||||
<FormLabel>Kód kuponu</FormLabel>
|
||||
<Input value={meta.coupon_code || ''} onChange={(e)=>setMetaField('coupon_code', e.target.value)} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Platnost do (ISO nebo datum)</FormLabel>
|
||||
<Input value={meta.expires_at || ''} onChange={(e)=>setMetaField('expires_at', e.target.value)} placeholder="2025-12-31" />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Poznámka</FormLabel>
|
||||
<Input value={meta.note || ''} onChange={(e)=>setMetaField('note', e.target.value)} />
|
||||
@@ -432,16 +447,18 @@ const EngagementAdminPage: React.FC = () => {
|
||||
<Button colorScheme="blue" onClick={() => createMut.mutate()} isLoading={createMut.isPending} isDisabled={!form.name.trim()}>Vytvořit</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Box>
|
||||
<Text fontSize="sm" mb={2} color="gray.500">Náhled</Text>
|
||||
<Box borderWidth="1px" borderRadius="md" p={2}>
|
||||
{form.image_url ? (
|
||||
<Image src={form.image_url} alt={form.name} boxSize="96px" objectFit="cover" borderRadius="md" />
|
||||
) : (
|
||||
<Box boxSize="96px" borderWidth="1px" borderRadius="md" display="flex" alignItems="center" justifyContent="center" color="gray.400">Bez obrázku</Box>
|
||||
)}
|
||||
{(form.type === 'avatar_static' || form.type === 'avatar_animated') && (
|
||||
<Box>
|
||||
<Text fontSize="sm" mb={2} color="gray.500">Náhled</Text>
|
||||
<Box borderWidth="1px" borderRadius="md" p={2}>
|
||||
{form.image_url ? (
|
||||
<Image src={form.image_url} alt={form.name} boxSize="96px" objectFit="cover" borderRadius="md" />
|
||||
) : (
|
||||
<Box boxSize="96px" borderWidth="1px" borderRadius="md" display="flex" alignItems="center" justifyContent="center" color="gray.400">Bez obrázku</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
@@ -468,6 +485,7 @@ const EngagementAdminPage: React.FC = () => {
|
||||
<Th>Body</Th>
|
||||
<Th>Sklad</Th>
|
||||
<Th>Obrázek</Th>
|
||||
<Th>Platnost</Th>
|
||||
<Th>Aktivní</Th>
|
||||
<Th>Akce</Th>
|
||||
</Tr>
|
||||
@@ -496,6 +514,20 @@ const EngagementAdminPage: React.FC = () => {
|
||||
</NumberInput>
|
||||
</Td>
|
||||
<Td>{r.image_url ? <Image src={r.image_url} alt={r.name} boxSize="40px" objectFit="cover" borderRadius="md" /> : '-'}</Td>
|
||||
<Td>
|
||||
{(() => {
|
||||
const m = (r.metadata || {}) as any;
|
||||
const vf = m.valid_from ? new Date(m.valid_from) : null;
|
||||
const vt = m.valid_to ? new Date(m.valid_to) : null;
|
||||
if (!vf && !vt) return <Text color="gray.500">-</Text>;
|
||||
return (
|
||||
<VStack align="start" spacing={0}>
|
||||
{vf && <Text fontSize="xs">od {vf.toLocaleString()}</Text>}
|
||||
{vt && <Text fontSize="xs">do {vt.toLocaleString()}</Text>}
|
||||
</VStack>
|
||||
);
|
||||
})()}
|
||||
</Td>
|
||||
<Td>
|
||||
<Switch
|
||||
isChecked={!!r.active}
|
||||
@@ -630,7 +662,6 @@ const EngagementAdminPage: React.FC = () => {
|
||||
{editForm.type === 'merch_coupon' && (
|
||||
<>
|
||||
<FormControl><FormLabel>Kód kuponu</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).coupon_code || ''} onChange={(e)=>setEditMetaField('coupon_code', e.target.value)} /></FormControl>
|
||||
<FormControl><FormLabel>Platnost do</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).expires_at || ''} onChange={(e)=>setEditMetaField('expires_at', e.target.value)} /></FormControl>
|
||||
<FormControl><FormLabel>Poznámka</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
|
||||
</>
|
||||
)}
|
||||
@@ -665,6 +696,16 @@ const EngagementAdminPage: React.FC = () => {
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<FormControl>
|
||||
<FormLabel>Platnost od</FormLabel>
|
||||
<Input type="datetime-local" isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).valid_from || ''} onChange={(e)=>setEditMetaField('valid_from', e.target.value)} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Platnost do</FormLabel>
|
||||
<Input type="datetime-local" isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).valid_to || ''} onChange={(e)=>setEditMetaField('valid_to', e.target.value)} />
|
||||
</FormControl>
|
||||
</VStack>
|
||||
{/* Odstraněno: ruční JSON metadata v editoru. */}
|
||||
<HStack>
|
||||
<Text>Aktivní</Text>
|
||||
@@ -699,76 +740,78 @@ const EngagementAdminPage: React.FC = () => {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Batch create modal */}
|
||||
<Modal isOpen={batchModal.isOpen} onClose={batchModal.onClose} isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Dávkové vytvoření odměn</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<FormControl>
|
||||
<FormLabel>Základní URL (použijte {`{i}`} pro index)</FormLabel>
|
||||
<Input placeholder="https://cdn.example.com/avatars/avatar-{i}.png" value={batch.base_url} onChange={(e)=>setBatch({ ...batch, base_url: e.target.value })} />
|
||||
<FormHelperText>Příklad: avatar-{`{i}`}.png → avatar-1.png, avatar-2.png…</FormHelperText>
|
||||
</FormControl>
|
||||
<HStack>
|
||||
{/* Batch create modal (hidden) */}
|
||||
{batchEnabled && (
|
||||
<Modal isOpen={batchModal.isOpen} onClose={batchModal.onClose} isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Dávkové vytvoření odměn</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<FormControl>
|
||||
<FormLabel>Počet</FormLabel>
|
||||
<NumberInput min={1} value={batch.count} onChange={(_v,n)=>setBatch({ ...batch, count: Number.isFinite(n)? n : 1 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Počáteční index</FormLabel>
|
||||
<NumberInput min={0} value={batch.start_index} onChange={(_v,n)=>setBatch({ ...batch, start_index: Number.isFinite(n)? n : 1 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
<FormControl>
|
||||
<FormLabel>Předpona názvu</FormLabel>
|
||||
<Input value={batch.name_prefix} onChange={(e)=>setBatch({ ...batch, name_prefix: e.target.value })} />
|
||||
</FormControl>
|
||||
<HStack>
|
||||
<FormControl>
|
||||
<FormLabel>Typ</FormLabel>
|
||||
<Select value={batch.type} onChange={(e)=>setBatch({ ...batch, type: e.target.value })}>
|
||||
<option value="avatar_static">Avatar (statický)</option>
|
||||
<option value="avatar_animated">Avatar (animovaný)</option>
|
||||
<option value="merch_coupon">Merch kupon</option>
|
||||
<option value="custom">Vlastní</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Body</FormLabel>
|
||||
<NumberInput min={0} value={batch.cost_points} onChange={(_v,n)=>setBatch({ ...batch, cost_points: Number.isFinite(n)? n : 0 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<FormControl>
|
||||
<FormLabel>Sklad</FormLabel>
|
||||
<NumberInput min={-1} value={batch.stock} onChange={(_v,n)=>setBatch({ ...batch, stock: Number.isFinite(n)? n : -1 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
<FormLabel>Základní URL (použijte {`{i}`} pro index)</FormLabel>
|
||||
<Input placeholder="https://cdn.example.com/avatars/avatar-{i}.png" value={batch.base_url} onChange={(e)=>setBatch({ ...batch, base_url: e.target.value })} />
|
||||
<FormHelperText>Příklad: avatar-{`{i}`}.png → avatar-1.png, avatar-2.png…</FormHelperText>
|
||||
</FormControl>
|
||||
<HStack>
|
||||
<Text>Aktivní</Text>
|
||||
<Switch isChecked={batch.active} onChange={(e)=>setBatch({ ...batch, active: e.target.checked })} />
|
||||
<FormControl>
|
||||
<FormLabel>Počet</FormLabel>
|
||||
<NumberInput min={1} value={batch.count} onChange={(_v,n)=>setBatch({ ...batch, count: Number.isFinite(n)? n : 1 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Počáteční index</FormLabel>
|
||||
<NumberInput min={0} value={batch.start_index} onChange={(_v,n)=>setBatch({ ...batch, start_index: Number.isFinite(n)? n : 1 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
<FormControl>
|
||||
<FormLabel>Předpona názvu</FormLabel>
|
||||
<Input value={batch.name_prefix} onChange={(e)=>setBatch({ ...batch, name_prefix: e.target.value })} />
|
||||
</FormControl>
|
||||
<HStack>
|
||||
<FormControl>
|
||||
<FormLabel>Typ</FormLabel>
|
||||
<Select value={batch.type} onChange={(e)=>setBatch({ ...batch, type: e.target.value })}>
|
||||
<option value="avatar_static">Avatar (statický)</option>
|
||||
<option value="avatar_animated">Avatar (animovaný)</option>
|
||||
<option value="merch_coupon">Merch kupon</option>
|
||||
<option value="custom">Vlastní</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Body</FormLabel>
|
||||
<NumberInput min={0} value={batch.cost_points} onChange={(_v,n)=>setBatch({ ...batch, cost_points: Number.isFinite(n)? n : 0 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<FormControl>
|
||||
<FormLabel>Sklad</FormLabel>
|
||||
<NumberInput min={-1} value={batch.stock} onChange={(_v,n)=>setBatch({ ...batch, stock: Number.isFinite(n)? n : -1 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
<HStack>
|
||||
<Text>Aktivní</Text>
|
||||
<Switch isChecked={batch.active} onChange={(e)=>setBatch({ ...batch, active: e.target.checked })} />
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack>
|
||||
<Button onClick={batchModal.onClose}>Zrušit</Button>
|
||||
<Button colorScheme="blue" isLoading={batchMut.isPending} onClick={()=>batchMut.mutate()}>Vytvořit dávku</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack>
|
||||
<Button onClick={batchModal.onClose}>Zrušit</Button>
|
||||
<Button colorScheme="blue" isLoading={batchMut.isPending} onClick={()=>batchMut.mutate()}>Vytvořit dávku</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -78,6 +78,8 @@ const FilesAdminPage: React.FC = () => {
|
||||
const [forceDelete, setForceDelete] = useState(false);
|
||||
const [scanResult, setScanResult] = useState<any>(null);
|
||||
const [refreshResult, setRefreshResult] = useState<any>(null);
|
||||
const [isBulkDeletingUnused, setIsBulkDeletingUnused] = useState(false);
|
||||
const [isBulkDeletingDuplicates, setIsBulkDeletingDuplicates] = useState(false);
|
||||
|
||||
const { isOpen: isUsagesOpen, onOpen: onUsagesOpen, onClose: onUsagesClose } = useDisclosure();
|
||||
const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onClose: onDeleteClose } = useDisclosure();
|
||||
@@ -202,6 +204,71 @@ const FilesAdminPage: React.FC = () => {
|
||||
return full || url;
|
||||
};
|
||||
|
||||
const handleDeleteAllUnused = async () => {
|
||||
if (unusedFiles.length === 0) return;
|
||||
const confirmed = window.confirm(`Opravdu chcete smazat ${unusedFiles.length} nepoužívaných souborů? Tuto akci nelze vrátit.`);
|
||||
if (!confirmed) return;
|
||||
setIsBulkDeletingUnused(true);
|
||||
let deleted = 0;
|
||||
let failed = 0;
|
||||
for (const f of unusedFiles) {
|
||||
try {
|
||||
await deleteFile(f.id, false);
|
||||
deleted++;
|
||||
} catch (e) {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
setIsBulkDeletingUnused(false);
|
||||
qc.invalidateQueries({ queryKey: ['admin-files'] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-files-unused'] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-files-duplicates'] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-files-usage'] });
|
||||
toast({ title: 'Hromadné mazání dokončeno', description: `Smazáno ${deleted} / ${unusedFiles.length}. Chyby: ${failed}.`, status: failed > 0 ? 'warning' : 'success' });
|
||||
};
|
||||
|
||||
const handleDeleteAllDuplicates = async () => {
|
||||
if (duplicateGroups.length === 0) return;
|
||||
const confirmed = window.confirm('Smazat všechny duplicitní soubory bez použití? V každé skupině bude ponechán 1 soubor. Používané soubory budou přeskočeny.');
|
||||
if (!confirmed) return;
|
||||
setIsBulkDeletingDuplicates(true);
|
||||
// Build list of files to delete: in each group keep one (oldest by created_at), delete the rest only if usage_count === 0
|
||||
type FI = typeof duplicateFiles extends Record<string, infer A> ? A extends Array<infer B> ? B : never : never;
|
||||
const toDelete: FI[] = [] as any;
|
||||
duplicateGroups.forEach(([, files]) => {
|
||||
if (files.length <= 1) return;
|
||||
const sorted = [...files].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
||||
const [, ...rest] = sorted;
|
||||
rest.forEach(f => {
|
||||
if ((f.usage_count ?? 0) === 0) toDelete.push(f as any);
|
||||
});
|
||||
});
|
||||
let deleted = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
for (const f of toDelete) {
|
||||
try {
|
||||
await deleteFile((f as any).id, false);
|
||||
deleted++;
|
||||
} catch (e) {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
// Count duplicates with usage to report as skipped
|
||||
duplicateGroups.forEach(([, files]) => {
|
||||
if (files.length <= 1) return;
|
||||
const sorted = [...files].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
||||
const [, ...rest] = sorted;
|
||||
rest.forEach(f => { if ((f.usage_count ?? 0) > 0) skipped++; });
|
||||
});
|
||||
setIsBulkDeletingDuplicates(false);
|
||||
qc.invalidateQueries({ queryKey: ['admin-files'] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-files-unused'] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-files-duplicates'] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-files-usage'] });
|
||||
toast({ title: 'Mazání duplicit dokončeno', description: `Smazáno ${deleted}, přeskočeno (použité) ${skipped}, chyby ${failed}.`, status: failed > 0 ? 'warning' : 'success' });
|
||||
};
|
||||
|
||||
// Mime type options
|
||||
const mimeTypes = useMemo(() => {
|
||||
const types = new Set<string>();
|
||||
@@ -443,7 +510,19 @@ const FilesAdminPage: React.FC = () => {
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
|
||||
<HStack>
|
||||
<Spacer />
|
||||
<Button
|
||||
leftIcon={<FiTrash2 />}
|
||||
colorScheme="red"
|
||||
size="sm"
|
||||
onClick={handleDeleteAllUnused}
|
||||
isLoading={isBulkDeletingUnused}
|
||||
isDisabled={unusedFiles.length === 0}
|
||||
>
|
||||
Vymazat vše
|
||||
</Button>
|
||||
</HStack>
|
||||
<Box overflowX="auto" borderWidth="1px" borderRadius="md" borderColor={borderColor}>
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
@@ -483,7 +562,19 @@ const FilesAdminPage: React.FC = () => {
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
|
||||
<HStack>
|
||||
<Spacer />
|
||||
<Button
|
||||
leftIcon={<FiTrash2 />}
|
||||
colorScheme="red"
|
||||
size="sm"
|
||||
onClick={handleDeleteAllDuplicates}
|
||||
isLoading={isBulkDeletingDuplicates}
|
||||
isDisabled={duplicateGroups.length === 0}
|
||||
>
|
||||
Vymazat vše
|
||||
</Button>
|
||||
</HStack>
|
||||
{duplicateGroups.length === 0 ? (
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color="gray.500">Žádné duplicity nenalezeny</Text>
|
||||
|
||||
@@ -131,7 +131,7 @@ const GalleryAdminPage: React.FC = () => {
|
||||
|
||||
try {
|
||||
// Use the api service which automatically includes authentication
|
||||
await api.post('/admin/gallery/refresh');
|
||||
await api.post('/admin/gallery/refresh', {});
|
||||
|
||||
toast({
|
||||
title: 'Galerie obnovena',
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { putMatchOverride } from '../../services/adminMatches';
|
||||
import { putMatchOverride, fetchAdminMatches } from '../../services/adminMatches';
|
||||
import { getPublicSettings } from '../../services/settings';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -85,51 +85,21 @@ const MatchesAdminPage = () => {
|
||||
const { data: matches = [], isLoading, error } = useQuery<any[], Error>({
|
||||
queryKey: ['admin-matches-list-cache'],
|
||||
queryFn: async () => {
|
||||
// Read cached FACR club info
|
||||
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
|
||||
const url = `${origin}/cache/prefetch/facr_club_info.json`;
|
||||
const res = await fetch(url, { headers: { 'Cache-Control': 'no-cache' } });
|
||||
if (!res.ok) throw new Error(`Failed to load cache: ${res.status}`);
|
||||
const json = await res.json();
|
||||
|
||||
const comps = Array.isArray(json?.competitions) ? json.competitions : [];
|
||||
const items: any[] = comps.flatMap((c: any) =>
|
||||
(Array.isArray(c.matches) ? c.matches : []).map((m: any) => ({ ...m, competitionName: c.name, competition_id: c.id }))
|
||||
);
|
||||
|
||||
// Optional: stable sort by date ascending
|
||||
const items = await fetchAdminMatches();
|
||||
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm';
|
||||
const formatDisplayDate = (s: string): string => {
|
||||
const str = String(s || '').trim();
|
||||
if (!str) return '';
|
||||
try {
|
||||
const dt = parse(str, FACR_DATE_FMT, new Date());
|
||||
if (!isNaN(dt.getTime())) return format(dt, FACR_DATE_FMT);
|
||||
} catch {}
|
||||
const d2 = new Date(str);
|
||||
if (!isNaN(d2.getTime())) return format(d2, FACR_DATE_FMT);
|
||||
return str;
|
||||
};
|
||||
items.sort((a, b) => {
|
||||
const da = parse(String(a.date_time || a.date), FACR_DATE_FMT, new Date()).getTime();
|
||||
const db = parse(String(b.date_time || b.date), FACR_DATE_FMT, new Date()).getTime();
|
||||
return da - db;
|
||||
});
|
||||
|
||||
return items.map((m: any) => ({
|
||||
id: m.match_id,
|
||||
date_time: m.date_time || m.date,
|
||||
competitionName: m.competitionName,
|
||||
competition_id: m.competition_id,
|
||||
home: m.home || m.home_team,
|
||||
home_id: m.home_id || m.home_team_id || m.home_team_facr_id,
|
||||
away: m.away || m.away_team,
|
||||
away_id: m.away_id || m.away_team_id || m.away_team_facr_id,
|
||||
score: m.score,
|
||||
venue: m.venue,
|
||||
home_logo_url: m.home_logo_url,
|
||||
away_logo_url: m.away_logo_url,
|
||||
}));
|
||||
const parseTs = (obj: any): number => {
|
||||
const s = String(obj?.date_time || obj?.date || '').trim();
|
||||
if (!s) return Number.MAX_SAFE_INTEGER;
|
||||
try {
|
||||
const dt = parse(s, FACR_DATE_FMT, new Date());
|
||||
if (!isNaN(dt.getTime())) return dt.getTime();
|
||||
} catch {}
|
||||
const d2 = new Date(s);
|
||||
if (!isNaN(d2.getTime())) return d2.getTime();
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
};
|
||||
items.sort((a: any, b: any) => parseTs(a) - parseTs(b));
|
||||
return items;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -374,7 +344,7 @@ const MatchesAdminPage = () => {
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const externalMatchId: string = selected?.match_id || selected?.id;
|
||||
const externalMatchId: string = String((selected?.match_id ?? selected?.id ?? '')).trim();
|
||||
if (!externalMatchId) throw new Error('Chybí match_id');
|
||||
const payload: any = {
|
||||
venue_override: form.venue_override,
|
||||
|
||||
@@ -132,7 +132,6 @@ const ADMIN_PAGE_PRESETS = [
|
||||
{ value: 'activities', label: 'Aktivity', url: '/admin/aktivity' },
|
||||
{ value: 'players', label: 'Hráči', url: '/admin/hraci' },
|
||||
{ value: 'articles', label: 'Články', url: '/admin/clanky' },
|
||||
{ value: 'categories', label: 'Kategorie', url: '/admin/kategorie' },
|
||||
{ value: 'comments', label: 'Komentáře', url: '/admin/komentare' },
|
||||
{ value: 'about', label: 'O klubu', url: '/admin/o-klubu' },
|
||||
{ value: 'videos', label: 'Videa', url: '/admin/videa' },
|
||||
@@ -1149,6 +1148,8 @@ const NavigationAdminPage = () => {
|
||||
onChildMoveUp={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'up')}
|
||||
onChildMoveDown={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'down')}
|
||||
onToggleVisible={toggleVisible}
|
||||
childrenDroppableId={`admin-children-${item.id}`}
|
||||
draggableChildPrefix={'admin-child'}
|
||||
onEditTarget={(it) => openNavModal(it, undefined, true)}
|
||||
onDeleteTarget={(it) => deleteNav(it.id!)}
|
||||
/>
|
||||
|
||||
@@ -602,6 +602,25 @@ export default function NewsletterAdminPage() {
|
||||
<Text>Automatické rozesílky</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
{/* Weekly schedule detail */}
|
||||
<Box mt={3} color={textSecondary} fontSize="sm">
|
||||
{statusData?.weekly_day ? (
|
||||
<>
|
||||
<Text>
|
||||
<b>Týdenní přehled:</b> {statusData?.weekly_enabled ? 'Zapnuto' : 'Vypnuto'}
|
||||
{statusData?.weekly_enabled ? (
|
||||
<> — {({sun:'Neděle', mon:'Pondělí', tue:'Úterý', wed:'Středa', thu:'Čtvrtek', fri:'Pátek', sat:'Sobota'} as any)[statusData.weekly_day as any]}
|
||||
{' '}{String(statusData?.weekly_hour ?? 9).padStart(2,'0')}:00</>
|
||||
) : null}
|
||||
</Text>
|
||||
{statusData?.weekly_next_scheduled ? (
|
||||
<Text>
|
||||
<b>Příští týdenní odeslání:</b> {format(new Date(statusData.weekly_next_scheduled), 'd. M. yyyy HH:mm', { locale: cs })}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
{statusData?.next_approximate ? (
|
||||
<Text color="gray.600" fontSize="sm" mt={2}>
|
||||
Další automatický newsletter za {(() => {
|
||||
|
||||
@@ -639,11 +639,8 @@ const PlayersAdminPage: React.FC = () => {
|
||||
|
||||
// Czech pluralization for years: 1 rok, 2–4 roky, 5+ let (11–14 let)
|
||||
function czYears(n: number): string {
|
||||
const mod100 = n % 100;
|
||||
if (mod100 >= 11 && mod100 <= 14) return 'let';
|
||||
const mod10 = n % 10;
|
||||
if (mod10 === 1) return 'rok';
|
||||
if (mod10 >= 2 && mod10 <= 4) return 'roky';
|
||||
if (n === 1) return 'rok';
|
||||
if (n >= 2 && n <= 4) return 'roky';
|
||||
return 'let';
|
||||
}
|
||||
|
||||
|
||||
@@ -27,11 +27,14 @@ import {
|
||||
Badge,
|
||||
} from '@chakra-ui/react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { createShortLink, listShortLinks, getShortLinkStats } from '../../services/shortlinks';
|
||||
import { FiClipboard, FiExternalLink, FiRefreshCcw, FiBarChart2 } from 'react-icons/fi';
|
||||
|
||||
const ShortlinksAdminPage: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const { user } = useAuth();
|
||||
const isAdmin = (user as any)?.role === 'admin';
|
||||
const qc = useQueryClient();
|
||||
const [targetUrl, setTargetUrl] = React.useState('');
|
||||
const [title, setTitle] = React.useState('');
|
||||
@@ -77,7 +80,7 @@ const ShortlinksAdminPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<AdminLayout requireAdmin={false}>
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<Text fontSize="xl" fontWeight="bold">Zkrácené odkazy</Text>
|
||||
@@ -125,7 +128,9 @@ const ShortlinksAdminPage: React.FC = () => {
|
||||
<HStack>
|
||||
<IconButton aria-label="Otevřít krátkou URL" icon={<FiExternalLink />} as={ChakraLink as any} href={shortUrl} isExternal />
|
||||
<IconButton aria-label="Zkopírovat" icon={<FiClipboard />} onClick={async ()=>{ await navigator.clipboard.writeText(shortUrl); toast({ title: 'Zkopírováno', description: shortUrl, status: 'success', duration: 2000 }); }} />
|
||||
<IconButton aria-label="Statistiky" icon={<FiBarChart2 />} onClick={()=> openStats(it)} />
|
||||
{isAdmin && (
|
||||
<IconButton aria-label="Statistiky" icon={<FiBarChart2 />} onClick={()=> openStats(it)} />
|
||||
)}
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
@@ -138,7 +143,8 @@ const ShortlinksAdminPage: React.FC = () => {
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* Stats modal */}
|
||||
{/* Stats modal (admins only) */}
|
||||
{isAdmin && (
|
||||
<Modal isOpen={statsModal.isOpen} onClose={statsModal.onClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
@@ -190,6 +196,7 @@ const ShortlinksAdminPage: React.FC = () => {
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
|
||||
@@ -35,6 +35,11 @@ import {
|
||||
Divider,
|
||||
Image,
|
||||
FormHelperText,
|
||||
Tabs,
|
||||
TabList,
|
||||
Tab,
|
||||
TabPanels,
|
||||
TabPanel,
|
||||
} from '@chakra-ui/react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
@@ -88,15 +93,15 @@ const SweepstakesAdminPage: React.FC = () => {
|
||||
const [form, setForm] = useState<any>(defaultForm);
|
||||
const [editing, setEditing] = useState<Sweepstake | null>(null);
|
||||
|
||||
// Prizes modal state
|
||||
const prizesDisc = useDisclosure();
|
||||
const [prizeSweep, setPrizeSweep] = useState<Sweepstake | null>(null);
|
||||
// Prizes state (integrated tab)
|
||||
const [prizes, setPrizes] = useState<SweepstakePrize[]>([]);
|
||||
const [prizeForm, setPrizeForm] = useState<{ name: string; quantity: number; value?: string; image_url?: string; kind?: 'physical'|'points'|'xp'|'points_xp'; points?: number; xp?: number }>(() => ({ name: '', quantity: 1, value: '', image_url: '', kind: 'physical', points: 0, xp: 0 }));
|
||||
const [savingPrize, setSavingPrize] = useState<boolean>(false);
|
||||
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const rulesInputRef = useRef<HTMLInputElement>(null);
|
||||
const [activeTab, setActiveTab] = useState<number>(0);
|
||||
const [coverPreview, setCoverPreview] = useState<string>('');
|
||||
|
||||
const onUploadImage = async (file?: File | null) => {
|
||||
if (!file) return;
|
||||
@@ -143,24 +148,19 @@ const SweepstakesAdminPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const openPrizes = async (it: Sweepstake) => {
|
||||
try {
|
||||
setPrizeSweep(it);
|
||||
prizesDisc.onOpen();
|
||||
const list = await adminListPrizes(it.id);
|
||||
setPrizes(list);
|
||||
} catch {
|
||||
setPrizes([]);
|
||||
}
|
||||
openEdit(it);
|
||||
setActiveTab(2);
|
||||
try { setPrizes(await adminListPrizes(it.id)); } catch { setPrizes([]); }
|
||||
};
|
||||
|
||||
const addPrize = async () => {
|
||||
if (!prizeSweep) return;
|
||||
if (!editing) { toast({ status: 'info', title: 'Uložte soutěž a poté přidejte výhry' }); return; }
|
||||
if (!prizeForm.name.trim()) { toast({ status: 'error', title: 'Název výhry je povinný' }); return; }
|
||||
try {
|
||||
setSavingPrize(true);
|
||||
await adminCreatePrize(prizeSweep.id, { name: prizeForm.name, quantity: prizeForm.quantity, value: prizeForm.value, image_url: prizeForm.image_url, display_order: prizes.length, kind: prizeForm.kind, points: prizeForm.points, xp: prizeForm.xp });
|
||||
await adminCreatePrize(editing.id, { name: prizeForm.name, quantity: prizeForm.quantity, value: prizeForm.value, image_url: prizeForm.image_url, display_order: prizes.length, kind: prizeForm.kind, points: prizeForm.points, xp: prizeForm.xp });
|
||||
setPrizeForm({ name: '', quantity: 1, value: '', image_url: '' });
|
||||
setPrizes(await adminListPrizes(prizeSweep.id));
|
||||
setPrizes(await adminListPrizes(editing.id));
|
||||
} catch (e:any) {
|
||||
toast({ status: 'error', title: 'Nelze uložit výhru' });
|
||||
} finally {
|
||||
@@ -169,14 +169,14 @@ const SweepstakesAdminPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const delPrize = async (p: SweepstakePrize) => {
|
||||
if (!prizeSweep) return;
|
||||
if (!editing) return;
|
||||
if (!window.confirm('Smazat výhru?')) return;
|
||||
await adminDeletePrize(prizeSweep.id, p.id as any);
|
||||
setPrizes(await adminListPrizes(prizeSweep.id));
|
||||
await adminDeletePrize(editing.id, p.id as any);
|
||||
setPrizes(await adminListPrizes(editing.id));
|
||||
};
|
||||
|
||||
const movePrize = async (idx: number, dir: -1 | 1) => {
|
||||
if (!prizeSweep) return;
|
||||
if (!editing) return;
|
||||
const arr = [...prizes];
|
||||
const ni = idx + dir;
|
||||
if (ni < 0 || ni >= arr.length) return;
|
||||
@@ -184,12 +184,12 @@ const SweepstakesAdminPage: React.FC = () => {
|
||||
arr[idx] = arr[ni];
|
||||
arr[ni] = tmp;
|
||||
setPrizes(arr);
|
||||
await adminReorderPrizes(prizeSweep.id, arr.map(p => p.id as any));
|
||||
await adminReorderPrizes(editing.id, arr.map(p => p.id as any));
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [status]);
|
||||
|
||||
const openCreate = () => { setEditing(null); setForm(defaultForm); onOpen(); };
|
||||
const openCreate = () => { setEditing(null); setForm(defaultForm); setPrizes([]); setActiveTab(0); onOpen(); };
|
||||
const openEdit = (it: Sweepstake) => {
|
||||
setEditing(it);
|
||||
setForm({
|
||||
@@ -205,7 +205,9 @@ const SweepstakesAdminPage: React.FC = () => {
|
||||
entry_cost_points: (it as any).entry_cost_points ?? 0,
|
||||
max_entries_per_user: (it as any).max_entries_per_user ?? 1,
|
||||
});
|
||||
setActiveTab(0);
|
||||
onOpen();
|
||||
(async ()=>{ try { setPrizes(await adminListPrizes(it.id)); } catch { setPrizes([]); } })();
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
@@ -229,12 +231,16 @@ const SweepstakesAdminPage: React.FC = () => {
|
||||
if (editing) {
|
||||
await adminUpdateSweepstake(editing.id, payload);
|
||||
toast({ status: 'success', title: 'Uloženo' });
|
||||
onClose();
|
||||
await load();
|
||||
} else {
|
||||
await adminCreateSweepstake(payload);
|
||||
toast({ status: 'success', title: 'Vytvořeno' });
|
||||
const created = await adminCreateSweepstake(payload);
|
||||
toast({ status: 'success', title: 'Vytvořeno', description: 'Nyní můžete přidat výhry' });
|
||||
setEditing(created);
|
||||
setActiveTab(2);
|
||||
try { setPrizes(await adminListPrizes(created.id)); } catch { setPrizes([]); }
|
||||
await load();
|
||||
}
|
||||
onClose();
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
toast({ status: 'error', title: 'Chyba', description: e?.response?.data?.error || 'Operace selhala' });
|
||||
}
|
||||
@@ -325,106 +331,206 @@ const SweepstakesAdminPage: React.FC = () => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="2xl">
|
||||
{/* Create/Edit Modal with tabs */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="3xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>{editing ? 'Upravit soutěž' : 'Nová soutěž'}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Název</FormLabel>
|
||||
<Input value={form.title} onChange={(e)=>setForm({ ...form, title: e.target.value })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Popis</FormLabel>
|
||||
<Textarea value={form.description} onChange={(e)=>setForm({ ...form, description: e.target.value })} />
|
||||
</FormControl>
|
||||
<SimpleGrid columns={2} spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Začátek</FormLabel>
|
||||
<Input type="datetime-local" value={form.start_at} onChange={(e)=>setForm({ ...form, start_at: e.target.value })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Konec</FormLabel>
|
||||
<Input type="datetime-local" value={form.end_at} onChange={(e)=>setForm({ ...form, end_at: e.target.value })} />
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid columns={2} spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Styl vizualizace</FormLabel>
|
||||
<Select value={form.picker_style} onChange={(e)=>setForm({ ...form, picker_style: e.target.value })}>
|
||||
<option value="wheel">Kolo štěstí</option>
|
||||
<option value="cycler">Náhodný přepínač</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl isInvalid={Number(form.total_prizes) < 1 || Number(form.total_prizes) > 100}>
|
||||
<FormLabel>Počet výherců</FormLabel>
|
||||
<NumberInput value={Number(form.total_prizes)||1} min={1} keepWithinRange={false} clampValueOnBlur={false} onChange={(_v, n)=>setForm({ ...form, total_prizes: Number.isFinite(n) ? Math.floor(n) : 1 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
<FormHelperText>Max. 100 výherců</FormHelperText>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
<HStack>
|
||||
<Button variant="outline" onClick={()=> editing ? openPrizes(editing) : toast({ status: 'info', title: 'Uložte soutěž a poté přidejte výhry' })}>Upravit výhry</Button>
|
||||
<Button size="sm" onClick={async ()=>{
|
||||
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
|
||||
try { await adminCreatePrize(editing.id, { name: 'Hlavní výhra', quantity: 1 }); toast({ status:'success', title:'Přidáno: Hlavní výhra' }); } catch { toast({ status:'error', title:'Nelze přidat výhru' }); }
|
||||
}}>1× Hlavní výhra</Button>
|
||||
<Button size="sm" onClick={async ()=>{
|
||||
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
|
||||
try { await adminCreatePrize(editing.id, { name: 'Menší výhra', quantity: 3 }); toast({ status:'success', title:'Přidáno: 3× Menší výhra' }); } catch { toast({ status:'error', title:'Nelze přidat výhry' }); }
|
||||
}}>3× Menší výhry</Button>
|
||||
<Button size="sm" onClick={async ()=>{
|
||||
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
|
||||
try { await adminCreatePrize(editing.id, { name: '100 bodů', kind:'points', points: 100, quantity: 10 }); toast({ status:'success', title:'Přidáno: 10× 100 bodů' }); } catch { toast({ status:'error', title:'Nelze přidat body' }); }
|
||||
}}>10× 100 bodů</Button>
|
||||
<Button size="sm" onClick={async ()=>{
|
||||
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
|
||||
try { await adminCreatePrize(editing.id, { name: '500 XP', kind:'xp', xp: 500, quantity: 5 }); toast({ status:'success', title:'Přidáno: 5× 500 XP' }); } catch { toast({ status:'error', title:'Nelze přidat XP' }); }
|
||||
}}>5× 500 XP</Button>
|
||||
</HStack>
|
||||
<SimpleGrid columns={3} spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Vstupné (body)</FormLabel>
|
||||
<NumberInput min={0} value={Number(form.entry_cost_points)||0} onChange={(v)=>setForm({ ...form, entry_cost_points: Number(v) || 0 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Max. účastí / uživatel</FormLabel>
|
||||
<NumberInput min={1} value={Number(form.max_entries_per_user)||1} onChange={(v)=>setForm({ ...form, max_entries_per_user: Number(v) || 1 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid columns={2} spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Titulní obrázek</FormLabel>
|
||||
<HStack>
|
||||
<Image src={form.image_url || '/dist/img/logo-club-empty.svg'} alt="cover" boxSize="80px" objectFit="cover" borderRadius="md" />
|
||||
<Button as="label" leftIcon={<FiUpload />} variant="outline">
|
||||
Nahrát
|
||||
<Input ref={imageInputRef} type="file" display="none" accept="image/*" onChange={(e)=>onUploadImage(e.target.files?.[0])} />
|
||||
</Button>
|
||||
</HStack>
|
||||
<Input mt={2} placeholder="nebo vložte URL" value={form.image_url} onChange={(e)=>setForm({ ...form, image_url: e.target.value })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Pravidla</FormLabel>
|
||||
<HStack>
|
||||
<Button as="label" leftIcon={<FiUpload />} variant="outline">
|
||||
Nahrát PDF/obrázek
|
||||
<Input ref={rulesInputRef} type="file" display="none" accept="image/*,application/pdf" onChange={(e)=>onUploadRules(e.target.files?.[0])} />
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onCreateRulesArticle}>Vytvořit stránku</Button>
|
||||
</HStack>
|
||||
<Input mt={2} placeholder="nebo vložte URL" value={form.rules_url} onChange={(e)=>setForm({ ...form, rules_url: e.target.value })} />
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
<Tabs index={activeTab} onChange={setActiveTab as any} isFitted>
|
||||
<TabList>
|
||||
<Tab>Základní</Tab>
|
||||
<Tab>Termíny a limity</Tab>
|
||||
<Tab>Výhry</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Název</FormLabel>
|
||||
<Input value={form.title} onChange={(e)=>setForm({ ...form, title: e.target.value })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Popis</FormLabel>
|
||||
<Textarea value={form.description} onChange={(e)=>setForm({ ...form, description: e.target.value })} />
|
||||
</FormControl>
|
||||
<SimpleGrid columns={2} spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Titulní obrázek</FormLabel>
|
||||
<VStack align="start" spacing={2}>
|
||||
<HStack>
|
||||
<Image src={coverPreview || form.image_url || '/dist/img/logo-club-empty.svg'} alt="cover" boxSize="80px" objectFit="cover" borderRadius="md" />
|
||||
<Button as="label" leftIcon={<FiUpload />} variant="outline">
|
||||
Nahrát
|
||||
<Input ref={imageInputRef} type="file" display="none" accept="image/*" onChange={async (e)=>{ const f=e.target.files?.[0]; if(!f) return; try { setCoverPreview(URL.createObjectURL(f)); const r=await uploadFile(f); setForm((prev:any)=>({ ...prev, image_url: r.url })); setCoverPreview(''); toast({ status:'success', title:'Obrázek nahrán' }); } catch { toast({ status:'error', title:'Nahrávání selhalo' }); } }} />
|
||||
</Button>
|
||||
{form.image_url && (<Button size="sm" variant="ghost" onClick={()=>{ setForm((prev:any)=>({ ...prev, image_url: '' })); setCoverPreview(''); }}>Odebrat</Button>)}
|
||||
</HStack>
|
||||
<Input placeholder="nebo vložte URL" value={form.image_url} onChange={(e)=>setForm({ ...form, image_url: e.target.value })} />
|
||||
</VStack>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Pravidla</FormLabel>
|
||||
<VStack align="start" spacing={2}>
|
||||
<HStack>
|
||||
<Button as="label" leftIcon={<FiUpload />} variant="outline">
|
||||
Nahrát PDF/obrázek
|
||||
<Input ref={rulesInputRef} type="file" display="none" accept="image/*,application/pdf" onChange={(e)=>onUploadRules(e.target.files?.[0])} />
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onCreateRulesArticle}>Vytvořit stránku</Button>
|
||||
{form.rules_url && (<Button as={RouterLink} to={form.rules_url} target="_blank" rel="noreferrer noopener" variant="ghost">Otevřít</Button>)}
|
||||
</HStack>
|
||||
<Input placeholder="nebo vložte URL" value={form.rules_url} onChange={(e)=>setForm({ ...form, rules_url: e.target.value })} />
|
||||
</VStack>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<SimpleGrid columns={2} spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Začátek</FormLabel>
|
||||
<Input type="datetime-local" value={form.start_at} onChange={(e)=>setForm({ ...form, start_at: e.target.value })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Konec</FormLabel>
|
||||
<Input type="datetime-local" value={form.end_at} onChange={(e)=>setForm({ ...form, end_at: e.target.value })} />
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid columns={2} spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Styl vizualizace</FormLabel>
|
||||
<Select value={form.picker_style} onChange={(e)=>setForm({ ...form, picker_style: e.target.value })}>
|
||||
<option value="wheel">Kolo štěstí</option>
|
||||
<option value="cycler">Náhodný přepínač</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl isInvalid={Number(form.total_prizes) < 1 || Number(form.total_prizes) > 100}>
|
||||
<FormLabel>Počet výherců</FormLabel>
|
||||
<NumberInput value={Number(form.total_prizes)||1} min={1} keepWithinRange={false} clampValueOnBlur={false} onChange={(_v, n)=>setForm({ ...form, total_prizes: Number.isFinite(n) ? Math.floor(n) : 1 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
<FormHelperText>Max. 100 výherců</FormHelperText>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid columns={3} spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Vstupné (body)</FormLabel>
|
||||
<NumberInput min={0} value={Number(form.entry_cost_points)||0} onChange={(v)=>setForm({ ...form, entry_cost_points: Number(v) || 0 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Max. účastí / uživatel</FormLabel>
|
||||
<NumberInput min={1} value={Number(form.max_entries_per_user)||1} onChange={(v)=>setForm({ ...form, max_entries_per_user: Number(v) || 1 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<HStack>
|
||||
<Button size="sm" onClick={()=>setActiveTab(0)} variant="outline">Zpět na základní</Button>
|
||||
<Button size="sm" onClick={async ()=>{
|
||||
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
|
||||
try { await adminCreatePrize(editing.id, { name: 'Hlavní výhra', quantity: 1 }); toast({ status:'success', title:'Přidáno: Hlavní výhra' }); setPrizes(await adminListPrizes(editing.id)); } catch { toast({ status:'error', title:'Nelze přidat výhru' }); }
|
||||
}}>1× Hlavní výhra</Button>
|
||||
<Button size="sm" onClick={async ()=>{
|
||||
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
|
||||
try { await adminCreatePrize(editing.id, { name: 'Menší výhra', quantity: 3 }); toast({ status:'success', title:'Přidáno: 3× Menší výhra' }); setPrizes(await adminListPrizes(editing.id)); } catch { toast({ status:'error', title:'Nelze přidat výhry' }); }
|
||||
}}>3× Menší výhry</Button>
|
||||
<Button size="sm" onClick={async ()=>{
|
||||
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
|
||||
try { await adminCreatePrize(editing.id, { name: '100 bodů', kind:'points', points: 100, quantity: 10 }); toast({ status:'success', title:'Přidáno: 10× 100 bodů' }); setPrizes(await adminListPrizes(editing.id)); } catch { toast({ status:'error', title:'Nelze přidat body' }); }
|
||||
}}>10× 100 bodů</Button>
|
||||
<Button size="sm" onClick={async ()=>{
|
||||
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
|
||||
try { await adminCreatePrize(editing.id, { name: '500 XP', kind:'xp', xp: 500, quantity: 5 }); toast({ status:'success', title:'Přidáno: 5× 500 XP' }); setPrizes(await adminListPrizes(editing.id)); } catch { toast({ status:'error', title:'Nelze přidat XP' }); }
|
||||
}}>5× 500 XP</Button>
|
||||
</HStack>
|
||||
<Divider />
|
||||
{prizes.length === 0 && <Text color="gray.500">Zatím žádné výhry</Text>}
|
||||
{prizes.map((p, i) => (
|
||||
<HStack key={p.id} spacing={2} borderWidth="1px" borderRadius="md" p={2}>
|
||||
<IconButton aria-label="Nahoru" size="xs" icon={<ArrowUpIcon />} onClick={()=>movePrize(i,-1)} />
|
||||
<IconButton aria-label="Dolů" size="xs" icon={<ArrowDownIcon />} onClick={()=>movePrize(i,1)} />
|
||||
<Text flex={1} fontWeight="600">{p.name}</Text>
|
||||
<Text>×{p.quantity}</Text>
|
||||
{p.kind && (
|
||||
<Text fontSize="xs" px={2} py={0.5} borderRadius="md" borderWidth="1px" color="gray.600">
|
||||
{p.kind === 'physical' ? 'fyzická' : p.kind === 'points' ? `body ${p.points||0}` : p.kind === 'xp' ? `XP ${p.xp||0}` : `body ${p.points||0} + XP ${p.xp||0}`}
|
||||
</Text>
|
||||
)}
|
||||
<Text color="gray.500">{p.value}</Text>
|
||||
<IconButton aria-label="Smazat" size="xs" colorScheme="red" icon={<DeleteIcon />} onClick={()=>delPrize(p)} />
|
||||
</HStack>
|
||||
))}
|
||||
<Divider />
|
||||
<Heading size="sm">Přidat výhru</Heading>
|
||||
<SimpleGrid columns={{ base: 1, md: 4 }} spacing={2} alignItems="end">
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Název</FormLabel>
|
||||
<Input value={prizeForm.name} onChange={(e)=>setPrizeForm({ ...prizeForm, name: e.target.value })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Počet</FormLabel>
|
||||
<NumberInput min={1} value={prizeForm.quantity} onChange={(v)=>setPrizeForm({ ...prizeForm, quantity: Number(v) || 1 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Hodnota</FormLabel>
|
||||
<Input value={prizeForm.value} onChange={(e)=>setPrizeForm({ ...prizeForm, value: e.target.value })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Obrázek URL</FormLabel>
|
||||
<HStack>
|
||||
<Input value={prizeForm.image_url} onChange={(e)=>setPrizeForm({ ...prizeForm, image_url: e.target.value })} />
|
||||
<Button as="label" leftIcon={<FiUpload />} size="sm" variant="outline">
|
||||
Upload
|
||||
<Input type="file" display="none" accept="image/*" onChange={async (e)=>{ const f=e.target.files?.[0]; if(f){ const r=await uploadFile(f); setPrizeForm(prev=>({...prev, image_url: r.url })); } }} />
|
||||
</Button>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={2} alignItems="end">
|
||||
<FormControl>
|
||||
<FormLabel>Typ výhry</FormLabel>
|
||||
<Select value={prizeForm.kind || 'physical'} onChange={(e)=>setPrizeForm({ ...prizeForm, kind: e.target.value as any })}>
|
||||
<option value="physical">Fyzická výhra</option>
|
||||
<option value="points">Body</option>
|
||||
<option value="xp">XP</option>
|
||||
<option value="points_xp">Body + XP</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{(prizeForm.kind === 'points' || prizeForm.kind === 'points_xp') && (
|
||||
<FormControl>
|
||||
<FormLabel>Body</FormLabel>
|
||||
<NumberInput min={0} value={Number(prizeForm.points)||0} onChange={(v)=>setPrizeForm({ ...prizeForm, points: Number(v)||0 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
)}
|
||||
{(prizeForm.kind === 'xp' || prizeForm.kind === 'points_xp') && (
|
||||
<FormControl>
|
||||
<FormLabel>XP</FormLabel>
|
||||
<NumberInput min={0} value={Number(prizeForm.xp)||0} onChange={(v)=>setPrizeForm({ ...prizeForm, xp: Number(v)||0 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
<HStack justify="flex-end">
|
||||
<Button leftIcon={<AddIcon />} colorScheme="blue" size="sm" onClick={addPrize} isLoading={savingPrize}>Přidat</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack>
|
||||
@@ -434,96 +540,6 @@ const SweepstakesAdminPage: React.FC = () => {
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Prizes Modal */}
|
||||
<Modal isOpen={prizesDisc.isOpen} onClose={prizesDisc.onClose} size="2xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Výhry – {prizeSweep?.title}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{prizes.length === 0 && <Text color="gray.500">Zatím žádné výhry</Text>}
|
||||
{prizes.map((p, i) => (
|
||||
<HStack key={p.id} spacing={2} borderWidth="1px" borderRadius="md" p={2}>
|
||||
<IconButton aria-label="Nahoru" size="xs" icon={<ArrowUpIcon />} onClick={()=>movePrize(i,-1)} />
|
||||
<IconButton aria-label="Dolů" size="xs" icon={<ArrowDownIcon />} onClick={()=>movePrize(i,1)} />
|
||||
<Text flex={1} fontWeight="600">{p.name}</Text>
|
||||
<Text>×{p.quantity}</Text>
|
||||
{p.kind && (
|
||||
<Text fontSize="xs" px={2} py={0.5} borderRadius="md" borderWidth="1px" color="gray.600">
|
||||
{p.kind === 'physical' ? 'fyzická' : p.kind === 'points' ? `body ${p.points||0}` : p.kind === 'xp' ? `XP ${p.xp||0}` : `body ${p.points||0} + XP ${p.xp||0}`}
|
||||
</Text>
|
||||
)}
|
||||
<Text color="gray.500">{p.value}</Text>
|
||||
<IconButton aria-label="Smazat" size="xs" colorScheme="red" icon={<DeleteIcon />} onClick={()=>delPrize(p)} />
|
||||
</HStack>
|
||||
))}
|
||||
<Divider />
|
||||
<Heading size="sm">Přidat výhru</Heading>
|
||||
<SimpleGrid columns={{ base: 1, md: 4 }} spacing={2} alignItems="end">
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Název</FormLabel>
|
||||
<Input value={prizeForm.name} onChange={(e)=>setPrizeForm({ ...prizeForm, name: e.target.value })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Počet</FormLabel>
|
||||
<NumberInput min={1} value={prizeForm.quantity} onChange={(v)=>setPrizeForm({ ...prizeForm, quantity: Number(v) || 1 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Hodnota</FormLabel>
|
||||
<Input value={prizeForm.value} onChange={(e)=>setPrizeForm({ ...prizeForm, value: e.target.value })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Obrázek URL</FormLabel>
|
||||
<HStack>
|
||||
<Input value={prizeForm.image_url} onChange={(e)=>setPrizeForm({ ...prizeForm, image_url: e.target.value })} />
|
||||
<Button as="label" leftIcon={<FiUpload />} size="sm" variant="outline">
|
||||
Upload
|
||||
<Input type="file" display="none" accept="image/*" onChange={async (e)=>{ const f=e.target.files?.[0]; if(f){ const r=await uploadFile(f); setPrizeForm(prev=>({...prev, image_url: r.url })); } }} />
|
||||
</Button>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={2} alignItems="end">
|
||||
<FormControl>
|
||||
<FormLabel>Typ výhry</FormLabel>
|
||||
<Select value={prizeForm.kind || 'physical'} onChange={(e)=>setPrizeForm({ ...prizeForm, kind: e.target.value as any })}>
|
||||
<option value="physical">Fyzická výhra</option>
|
||||
<option value="points">Body</option>
|
||||
<option value="xp">XP</option>
|
||||
<option value="points_xp">Body + XP</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{(prizeForm.kind === 'points' || prizeForm.kind === 'points_xp') && (
|
||||
<FormControl>
|
||||
<FormLabel>Body</FormLabel>
|
||||
<NumberInput min={0} value={Number(prizeForm.points)||0} onChange={(v)=>setPrizeForm({ ...prizeForm, points: Number(v)||0 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
)}
|
||||
{(prizeForm.kind === 'xp' || prizeForm.kind === 'points_xp') && (
|
||||
<FormControl>
|
||||
<FormLabel>XP</FormLabel>
|
||||
<NumberInput min={0} value={Number(prizeForm.xp)||0} onChange={(v)=>setPrizeForm({ ...prizeForm, xp: Number(v)||0 })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
<HStack justify="flex-end">
|
||||
<Button leftIcon={<AddIcon />} colorScheme="blue" size="sm" onClick={addPrize} isLoading={savingPrize}>Přidat</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={prizesDisc.onClose}>Zavřít</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Container>
|
||||
</AdminLayout>
|
||||
);
|
||||
|
||||
@@ -63,6 +63,8 @@ function normalize(s: string): string {
|
||||
.toLowerCase();
|
||||
// Unify various dash characters to a simple hyphen
|
||||
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
|
||||
out = out.replace(/\bn\.?\b/g, ' nad ');
|
||||
out = out.replace(/\bp\.?\b/g, ' pod ');
|
||||
// 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)
|
||||
@@ -140,6 +142,16 @@ const TeamsAdminPage = () => {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const overridesById: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
|
||||
// Lowercase-key index for robust UUID lookups irrespective of source casing
|
||||
const overridesByIdLC = useMemo(() => {
|
||||
const m: Record<string, { name?: string; logo_url?: string }> = {};
|
||||
try {
|
||||
for (const [k, v] of Object.entries(overridesById)) {
|
||||
m[String(k).toLowerCase()] = v as any;
|
||||
}
|
||||
} catch {}
|
||||
return m;
|
||||
}, [overridesById]);
|
||||
// Build an index by normalized team name for overrides that carry an ID
|
||||
const overridesNameIndex = useMemo(() => {
|
||||
const idx: Record<string, { id: string; name: string; logo_url: string }> = {};
|
||||
@@ -168,7 +180,7 @@ const TeamsAdminPage = () => {
|
||||
for (const comp of competitions) {
|
||||
const rows: TableRow[] = comp?.table?.overall || [];
|
||||
for (const r of rows) {
|
||||
if (r.team_id) teamIds.add(r.team_id);
|
||||
if (r.team_id) teamIds.add(String(r.team_id).toLowerCase());
|
||||
else {
|
||||
const derived = deriveTeamIdFromLogoUrl(r.team_logo_url);
|
||||
if (derived) teamIds.add(derived);
|
||||
@@ -200,8 +212,9 @@ const TeamsAdminPage = () => {
|
||||
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
|
||||
if (teamId && overridesById[teamId] && overridesById[teamId]?.logo_url) {
|
||||
const u = String(overridesById[teamId].logo_url);
|
||||
const tid = teamId ? String(teamId).toLowerCase() : '';
|
||||
if (tid && overridesByIdLC[tid] && overridesByIdLC[tid]?.logo_url) {
|
||||
const u = String(overridesByIdLC[tid].logo_url);
|
||||
if (u.startsWith('/')) return assetUrl(u) as string;
|
||||
return u;
|
||||
}
|
||||
@@ -254,8 +267,8 @@ const TeamsAdminPage = () => {
|
||||
}
|
||||
|
||||
// Priority 2: logoapi.sportcreative.eu if we have a team ID
|
||||
if (teamId && sportLogosMap[teamId]) {
|
||||
return sportLogosMap[teamId];
|
||||
if (tid && sportLogosMap[tid]) {
|
||||
return sportLogosMap[tid];
|
||||
}
|
||||
|
||||
// Priority 3: FACR original
|
||||
@@ -268,8 +281,9 @@ const TeamsAdminPage = () => {
|
||||
};
|
||||
|
||||
const getName = (teamName?: string, teamId?: string) => {
|
||||
if (teamId && overridesById[teamId] && overridesById[teamId]?.name) {
|
||||
return String(overridesById[teamId].name || '').trim() || String(teamName || '');
|
||||
const tid = teamId ? String(teamId).toLowerCase() : '';
|
||||
if (tid && overridesByIdLC[tid] && overridesByIdLC[tid]?.name) {
|
||||
return String(overridesByIdLC[tid].name || '').trim() || String(teamName || '');
|
||||
}
|
||||
// If no ID, but override exists for the normalized name, use canonical override name
|
||||
try {
|
||||
@@ -326,6 +340,7 @@ const TeamsAdminPage = () => {
|
||||
for (const r of rows) {
|
||||
const rawName = (r.team || '').trim();
|
||||
let teamId = ((r as any).team_id as string | undefined) || deriveTeamIdFromLogoUrl(r.team_logo_url);
|
||||
if (teamId) teamId = String(teamId).toLowerCase();
|
||||
if (!teamId && mainClubId) {
|
||||
const rn = normalize(rawName);
|
||||
if (
|
||||
@@ -431,7 +446,30 @@ const TeamsAdminPage = () => {
|
||||
|
||||
const onSave = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!form.external_team_id) {
|
||||
let extTeamId = (form.external_team_id || '').trim();
|
||||
if (!extTeamId) {
|
||||
let derived: string | undefined = undefined;
|
||||
try { derived = deriveTeamIdFromLogoUrl(form.logo_url); } catch {}
|
||||
if (!derived && selected?.teamLogoUrl) {
|
||||
try { derived = deriveTeamIdFromLogoUrl(selected.teamLogoUrl); } catch {}
|
||||
}
|
||||
if (!derived) {
|
||||
const primaryNameTry = (form.team_name || selected?.teamName || '').trim();
|
||||
if (primaryNameTry) {
|
||||
try {
|
||||
const results = await searchClubs(primaryNameTry);
|
||||
const norm = (s: string) => normalize(s);
|
||||
const exact = results.find(r => norm(r.name) === norm(primaryNameTry));
|
||||
const pick = exact || results[0];
|
||||
if (pick?.id) derived = String(pick.id);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (derived) {
|
||||
extTeamId = derived;
|
||||
}
|
||||
}
|
||||
if (!extTeamId) {
|
||||
throw new Error('Vyberte tým ze seznamu vyhledávání (chybí ID).');
|
||||
}
|
||||
let logoUrl = (form.logo_url || '').trim();
|
||||
@@ -443,8 +481,8 @@ const TeamsAdminPage = () => {
|
||||
.filter(Boolean);
|
||||
// Prefer highest-quality logo from logoapi if available (unless uploading a new file)
|
||||
try {
|
||||
if (!uploadedFile && form.external_team_id) {
|
||||
const apiLogo = await fetchLogoFromLogoAPI(form.external_team_id, primaryName);
|
||||
if (!uploadedFile && extTeamId) {
|
||||
const apiLogo = await fetchLogoFromLogoAPI(extTeamId, primaryName);
|
||||
if (apiLogo) {
|
||||
logoUrl = apiLogo;
|
||||
}
|
||||
@@ -482,10 +520,10 @@ const TeamsAdminPage = () => {
|
||||
}
|
||||
if (logoFileToUpload) {
|
||||
const logaResult = await uploadToLogaSportcreative(
|
||||
form.external_team_id,
|
||||
extTeamId,
|
||||
logoFileToUpload,
|
||||
{
|
||||
filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
|
||||
filename: `${extTeamId}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
|
||||
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
|
||||
}
|
||||
);
|
||||
@@ -497,7 +535,7 @@ const TeamsAdminPage = () => {
|
||||
try {
|
||||
let confirmedUrl: string | null = null;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
confirmedUrl = await fetchLogoFromLogoAPI(form.external_team_id, primaryName);
|
||||
confirmedUrl = await fetchLogoFromLogoAPI(extTeamId, primaryName);
|
||||
if (confirmedUrl) break;
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
}
|
||||
@@ -532,7 +570,7 @@ const TeamsAdminPage = () => {
|
||||
}
|
||||
}
|
||||
|
||||
await putTeamLogoOverride(form.external_team_id, primaryName, logoUrl);
|
||||
await putTeamLogoOverride(extTeamId, primaryName, logoUrl);
|
||||
|
||||
return true;
|
||||
},
|
||||
@@ -706,7 +744,8 @@ const TeamsAdminPage = () => {
|
||||
<Td isNumeric py={1.5} fontSize="xs" fontWeight="bold">{r.points}</Td>
|
||||
<Td py={1.5}>
|
||||
<Button size="xs" fontSize="xs" onClick={() => {
|
||||
const tid = ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url);
|
||||
const tidRaw = ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url);
|
||||
const tid = tidRaw ? String(tidRaw).toLowerCase() : undefined;
|
||||
const displayName = getName(r.team, tid);
|
||||
const key = tid ? `id:${tid}` : normalize(displayName);
|
||||
onOpenEdit(displayName || '', getLogo(r.team, tid, r.team_logo_url), variantsByKey[key], tid);
|
||||
|
||||
@@ -821,6 +821,15 @@ html {
|
||||
100% { transform: translateX(-33.333%); }
|
||||
}
|
||||
|
||||
/* Reduce motion preferences: disable continuous marquee-style animations */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sponsors-slider .track,
|
||||
.sponsors-scroller .belt,
|
||||
.matches-slider.matches-ticker .ticker-belt {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Matches slider */
|
||||
.matches-slider { margin: 12px 0 20px; }
|
||||
.matches-slider .matches-grid {
|
||||
|
||||
Reference in New Issue
Block a user