This commit is contained in:
Tomas Dvorak
2025-11-02 01:04:02 +01:00
parent ac886502e0
commit b9cea0cd77
153 changed files with 43713 additions and 1700 deletions
+4
View File
@@ -52,6 +52,8 @@ import FilesAdminPage from './pages/admin/FilesAdminPage';
import ContactsAdminPage from './pages/admin/ContactsAdminPage';
import NavigationAdminPage from './pages/admin/NavigationAdminPage';
import ShortlinksAdminPage from './pages/admin/ShortlinksAdminPage';
import CommentsAdminPage from './pages/admin/CommentsAdminPage';
import EngagementAdminPage from './pages/admin/EngagementAdminPage';
import SemiAdminPage from './pages/SemiAdminPage';
import PollsAdminPage from './pages/admin/PollsAdminPage';
// Admin pages render their own AdminLayout internally
@@ -454,6 +456,8 @@ const App: React.FC = () => {
<Route path="/admin/soubory" element={<FilesAdminPage />} />
<Route path="/admin/kontakty" element={<ContactsAdminPage />} />
<Route path="/admin/navigace" element={<NavigationAdminPage />} />
<Route path="/admin/komentare" element={<CommentsAdminPage />} />
<Route path="/admin/engagement" element={<EngagementAdminPage />} />
</Route>
{/* Remaining protected routes that don't use AdminLayout */}
+65 -2
View File
@@ -24,6 +24,7 @@ import {
VStack,
Divider,
Container,
Progress,
Modal,
ModalOverlay,
ModalContent,
@@ -53,6 +54,9 @@ import { getZoneramaManifestWithFallbacks } from '../services/zonerama';
import { getMyNewsletterToken } from '../services/public/newsletter';
import { API_URL } from '../services/api';
import { assetUrl } from '../utils/url';
import { getProfile as getEngagementProfile, EngagementProfile } from '../services/engagement';
import AchievementsModal from './engagement/AchievementsModal';
import RewardsModal from './engagement/RewardsModal';
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
@@ -317,6 +321,9 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
const [navLoading, setNavLoading] = useState(true);
const containerMaxW = fullWidth ? 'full' as const : '7xl' as const;
const [windowWidth, setWindowWidth] = useState<number>(typeof window !== 'undefined' ? window.innerWidth : 1920);
const [engProfile, setEngProfile] = useState<EngagementProfile | null>(null);
const { isOpen: isAchOpen, onOpen: onAchOpen, onClose: onAchClose } = useDisclosure();
const { isOpen: isRewOpen, onOpen: onRewOpen, onClose: onRewClose } = useDisclosure();
useEffect(() => {
const onResize = () => setWindowWidth(window.innerWidth);
@@ -324,6 +331,34 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
return () => window.removeEventListener('resize', onResize);
}, []);
// Load engagement profile for avatar
useEffect(() => {
let mounted = true;
(async () => {
try {
if (!isAuthenticated) { setEngProfile(null); return; }
const p = await getEngagementProfile();
if (mounted) setEngProfile(p);
} catch {
if (mounted) setEngProfile(null);
}
})();
return () => { mounted = false; };
}, [isAuthenticated]);
useEffect(() => {
if (!isAuthenticated) return;
let disposed = false;
const handler = async () => {
try {
const p = await getEngagementProfile();
if (!disposed) setEngProfile(p);
} catch {}
};
window.addEventListener('engagement:refresh', handler as any);
return () => { disposed = true; window.removeEventListener('engagement:refresh', handler as any); };
}, [isAuthenticated]);
// Search modal state
const [query, setQuery] = useState('');
const submitSearch = () => {
@@ -737,11 +772,25 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
const navBorderBottomWidth = isTransparent ? '0px' : '1px';
const navBoxShadow = (isTransparent || isMinimal) ? 'none' : (scrolled ? 'sm' : 'none');
const levelProgress = useMemo(() => {
const xp = engProfile?.xp ?? 0;
let lvl = 1;
let threshold = 100;
let remaining = xp;
while (remaining >= threshold && lvl < 200) {
remaining -= threshold;
lvl++;
threshold += 100;
}
const pct = Math.max(0, Math.min(100, Math.floor((remaining / threshold) * 100)));
return { pct };
}, [engProfile?.xp]);
return (
<Box position="sticky" top={0} zIndex={1000}>
{/* Top bar with socials and quick external links */}
{(settings?.facebook_url || settings?.instagram_url || settings?.youtube_url || settings?.shop_url) && (
<Box bg={topBarBg} borderBottomWidth="1px" borderColor="border.subtle" py={1}>
<Box bg={topBarBg} borderBottomWidth="1px" borderColor="border.subtle" py={1} display={{ base: 'none', md: 'block' }}>
<Container maxW={containerMaxW} px={fullWidth ? 0 : undefined}>
<Flex align="center" justify="space-between" gap={2}>
<HStack spacing={2}>
@@ -928,9 +977,16 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
minW={0}
ml={2}
>
<Avatar size="sm" name={user?.name || 'Uživatel'} />
<Avatar size="sm" name={user?.name || 'Uživatel'} src={engProfile?.animated_avatar_url || engProfile?.avatar_url || undefined} />
</MenuButton>
<MenuList>
<MenuItem isDisabled>{`Úroveň ${engProfile?.level ?? 1}${engProfile?.points ?? 0} bodů`}</MenuItem>
<Box px={3} py={2}>
<Text fontSize="xs" color="gray.500">Progres</Text>
<Progress value={levelProgress.pct} size="xs" colorScheme="blue" borderRadius="full" mt={1} />
</Box>
<MenuItem onClick={onAchOpen}>Úspěchy</MenuItem>
<MenuItem onClick={onRewOpen}>Odměny</MenuItem>
<MenuItem as={RouterLink} to={accountPath}>Můj účet</MenuItem>
<MenuItem onClick={openMyNewsletterPrefs}>Emailové preference</MenuItem>
{isAdmin && <MenuItem as={RouterLink} to="/admin/nastaveni">Nastavení stránky</MenuItem>}
@@ -980,6 +1036,13 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
</ModalBody>
</ModalContent>
</Modal>
<AchievementsModal isOpen={isAchOpen} onClose={onAchClose} onOpenRewards={onRewOpen} />
<RewardsModal
isOpen={isRewOpen}
onClose={onRewClose}
availablePoints={engProfile?.points || 0}
onRedeemed={async () => { try { const p = await getEngagementProfile(); setEngProfile(p); } catch {} }}
/>
</Box>
</Box>
+31 -22
View File
@@ -31,7 +31,8 @@ import {
FaBullhorn,
FaUserShield,
FaFileAlt,
FaLink
FaLink,
FaComments
} from 'react-icons/fa';
import { useAuth } from '../../contexts/AuthContext';
import { useQuery } from '@tanstack/react-query';
@@ -149,6 +150,7 @@ const getIconForPageType = (pageType?: string): any => {
files: FaFolder,
docs: FaBook,
shortlinks: FaLink,
engagement: FaAward,
};
return iconMap[pageType || ''] || FaFileAlt;
};
@@ -181,6 +183,9 @@ const AdminSidebar = ({
const hasShortlinks = useMemo(() => {
return navItems.some(it => (it.page_type === 'shortlinks') || (it.url === '/admin/shortlinks'));
}, [navItems]);
const hasEngagement = useMemo(() => {
return navItems.some(it => (it.page_type === 'engagement') || (it.url === '/admin/engagement'));
}, [navItems]);
// Restore scroll on mount
useEffect(() => {
@@ -361,16 +366,6 @@ const AdminSidebar = ({
);
})}
{/* MyUIbrix Editor - Special item */}
<NavItem
icon={FaPaintBrush}
onClick={(e) => {
e?.preventDefault();
window.open('/?myuibrix=edit', '_blank');
}}
>
MyUIbrix Editor
</NavItem>
{/* Ensure Shortlinks is present even if not configured in dynamic nav */}
{!hasShortlinks && (
<NavItem
@@ -381,6 +376,17 @@ const AdminSidebar = ({
Zkrácené odkazy
</NavItem>
)}
{/* Ensure Engagement page is present even if not configured in dynamic nav */}
{!hasEngagement && (
<NavItem
icon={FaAward}
to="/admin/engagement"
onClick={onClose}
>
Odměny & Úspěchy
</NavItem>
)}
</>
) : (
// Fallback to hardcoded navigation
@@ -530,6 +536,13 @@ const AdminSidebar = ({
>
Zprávy
</NavItem>
<NavItem
icon={FaComments}
to="/admin/komentare"
onClick={onClose}
>
Komentáře
</NavItem>
<NavItem
icon={FaAddressBook}
to="/admin/kontakty"
@@ -551,7 +564,13 @@ const AdminSidebar = ({
>
Ankety
</NavItem>
<NavItem
icon={FaAward}
to="/admin/engagement"
onClick={onClose}
>
Odměny & Úspěchy
</NavItem>
<Divider my={2} />
{isAdmin && (
@@ -560,16 +579,6 @@ const AdminSidebar = ({
Nastavení
</Text>
<NavItem
icon={FaPaintBrush}
onClick={(e) => {
e?.preventDefault();
window.open('/?myuibrix=edit', '_blank');
}}
>
MyUIbrix Editor
</NavItem>
<NavItem
icon={FaBars}
to="/admin/navigace"
@@ -1,11 +1,12 @@
import React from 'react';
import { IconButton, Button, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, Textarea, useToast, Tooltip, Box } from '@chakra-ui/react';
import { Share2 } from 'lucide-react';
import { IconButton, Button, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, Textarea, useToast, Tooltip, Box, Menu, MenuButton, MenuList, MenuItem } from '@chakra-ui/react';
import { Share2, Instagram, Twitter, Facebook, Copy } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import { createShortLink } from '../../services/shortlinks';
import { createShortLink, createPublicShortLink } from '../../services/shortlinks';
import { Article, getArticleMatchLink } from '../../services/articles';
import { API_URL } from '../../services/api';
import { composeInstagramPostFromArticle, composeInstagramPostFromActivity, MatchSnapshot } from '../../services/instagram';
import { composeInstagramPostFromArticle, composeInstagramPostFromActivity, MatchSnapshot, stripHtml } from '../../services/instagram';
import { generateInstagramAI } from '../../services/ai';
import { usePublicSettings } from '../../hooks/usePublicSettings';
interface Props {
@@ -20,6 +21,7 @@ interface Props {
zIndex?: number;
variant?: 'button' | 'icon';
onGenerated?: (text: string, shortUrl: string) => void;
align?: 'left' | 'right';
}
const InstagramGeneratorButton: React.FC<Props> = ({
@@ -34,6 +36,7 @@ const InstagramGeneratorButton: React.FC<Props> = ({
zIndex = 40,
variant = 'icon',
onGenerated,
align = 'right',
}) => {
const { user } = useAuth();
const role = String(user?.role || '').toLowerCase();
@@ -45,7 +48,12 @@ const InstagramGeneratorButton: React.FC<Props> = ({
const [shortUrl, setShortUrl] = React.useState('');
const [loading, setLoading] = React.useState(false);
if (!isAdmin) return null;
// Build deterministic campaign id for UTM and shortlink code
const campaignId = React.useMemo(() => {
if (article?.id) return `article-${article.id}`;
if (activity?.id) return `activity-${activity.id}`;
return 'share';
}, [article?.id, activity?.id]);
const computeTarget = () => {
if (targetUrl) return targetUrl;
@@ -58,8 +66,7 @@ const InstagramGeneratorButton: React.FC<Props> = ({
const u = new URL(urlStr, typeof window !== 'undefined' ? window.location.origin : 'http://localhost');
if (!u.searchParams.get('utm_source')) u.searchParams.set('utm_source', 'instagram');
if (!u.searchParams.get('utm_medium')) u.searchParams.set('utm_medium', 'social');
const campaignBase = article ? `article-${article.id}` : (activity ? `activity-${activity.id}` : 'share');
if (!u.searchParams.get('utm_campaign')) u.searchParams.set('utm_campaign', campaignBase);
if (!u.searchParams.get('utm_campaign')) u.searchParams.set('utm_campaign', campaignId);
return u.toString();
} catch {
return urlStr;
@@ -76,14 +83,29 @@ const InstagramGeneratorButton: React.FC<Props> = ({
const fullUrl = withUtm(computeTarget());
if (!fullUrl) throw new Error('Nelze zjistit URL článku/aktivity');
// Deterministic shortlink code to keep link stable across generations
const code = article?.id ? `ig-a-${article.id}` : (activity?.id ? `ig-e-${activity.id}` : `ig-share`);
const payload = {
target_url: fullUrl,
title: article?.title || activity?.title || 'Link',
source_type: article ? 'article' : (activity ? 'event' : 'other'),
source_id: article?.id || activity?.id,
code,
} as any;
const res = await createShortLink(payload);
const sUrl = res?.short_url || '';
let sUrl = '';
try {
const res = await createShortLink(payload);
sUrl = res?.short_url || '';
} catch (err) {
// If code already exists or creation fails, fallback to computed short URL path
try {
const origin = typeof window !== 'undefined' ? window.location.origin : '';
sUrl = origin ? `${origin}/s/${code}` : fullUrl;
} catch {
sUrl = fullUrl;
}
}
setShortUrl(sUrl || fullUrl);
const clubName = publicSettings?.club_name || undefined;
@@ -130,9 +152,43 @@ const InstagramGeneratorButton: React.FC<Props> = ({
}
} catch {}
}
composed = composeInstagramPostFromArticle({ article, trackingUrl: sUrl || fullUrl, clubName, match: resolvedMatch });
// Try real AI caption generation first; fallback to template composer
try {
const ai = await generateInstagramAI({
type: 'article',
title: article.title,
content: stripHtml(article.content),
club_name: clubName,
link: sUrl || fullUrl,
match: resolvedMatch ? {
home: resolvedMatch.home,
away: resolvedMatch.away,
competition: resolvedMatch.competition,
date_time: resolvedMatch.date_time,
venue: resolvedMatch.venue,
score: resolvedMatch.score,
} : undefined,
});
composed = ai?.text?.trim() || '';
} catch {}
if (!composed) {
composed = composeInstagramPostFromArticle({ article, trackingUrl: sUrl || fullUrl, clubName, match: resolvedMatch });
}
} else if (activity) {
composed = composeInstagramPostFromActivity({ activity, trackingUrl: sUrl || fullUrl, clubName });
// Try AI generation first
try {
const ai = await generateInstagramAI({
type: 'event',
title: String(activity?.title || ''),
content: stripHtml(String(activity?.description || '')),
club_name: clubName,
link: sUrl || fullUrl,
});
composed = ai?.text?.trim() || '';
} catch {}
if (!composed) {
composed = composeInstagramPostFromActivity({ activity, trackingUrl: sUrl || fullUrl, clubName });
}
} else {
composed = `${clubName || 'Náš klub'}\n\n🔗 ${sUrl || fullUrl}`;
}
@@ -155,8 +211,63 @@ const InstagramGeneratorButton: React.FC<Props> = ({
}
};
const ButtonEl = (
<Tooltip label="Vygenerovat Instagram příspěvek" placement="left">
// Build share URL with platform-specific UTMs (base long URL)
const buildShareUrl = (platform: 'instagram' | 'twitter' | 'facebook' | 'copy'): string => {
const base = computeTarget();
try {
const u = new URL(base, typeof window !== 'undefined' ? window.location.origin : 'http://localhost');
if (!u.searchParams.get('utm_source')) u.searchParams.set('utm_source', platform);
if (!u.searchParams.get('utm_medium')) u.searchParams.set('utm_medium', 'social');
if (!u.searchParams.get('utm_campaign')) u.searchParams.set('utm_campaign', campaignId);
return u.toString();
} catch {
return base;
}
};
// Create or reuse a public shortlink for visitors for a given long URL
const getPublicShortUrl = async (longUrl: string): Promise<string> => {
try {
const res = await createPublicShortLink({ target_url: longUrl, title: article?.title || activity?.title });
return res?.short_url || longUrl;
} catch {
return longUrl;
}
};
const handleShareClick = async (platform: 'instagram' | 'twitter' | 'facebook' | 'copy') => {
const longUrl = buildShareUrl(platform);
const sUrl = await getPublicShortUrl(longUrl);
if (platform === 'copy') {
try {
await navigator.clipboard.writeText(sUrl);
toast({ status: 'success', title: 'Krátký odkaz zkopírován' });
} catch {
toast({ status: 'warning', title: 'Kopírování se nezdařilo' });
}
return;
}
if (platform === 'twitter') {
const text = article?.title || activity?.title || '';
const tw = `https://twitter.com/intent/tweet?url=${encodeURIComponent(sUrl)}&text=${encodeURIComponent(text)}`;
window.open(tw, '_blank');
return;
}
if (platform === 'facebook') {
const fb = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(sUrl)}`;
window.open(fb, '_blank');
return;
}
// Instagram has no official web share with prefilled text; open site and copy short link
try {
await navigator.clipboard.writeText(sUrl);
toast({ status: 'info', title: 'Krátký odkaz zkopírován', description: 'Vložte jej do Instagramu.' });
} catch {}
window.open('https://www.instagram.com/', '_blank');
};
const AdminButtonEl = (
<Tooltip label="Vygenerovat Instagram příspěvek" placement="right">
{variant === 'icon' ? (
<IconButton aria-label="IG post" icon={<Share2 size={18} />} colorScheme="brand" onClick={handleGenerate} isLoading={loading} size={size} />
) : (
@@ -167,15 +278,27 @@ const InstagramGeneratorButton: React.FC<Props> = ({
</Tooltip>
);
const VisitorShareEl = (
<Menu placement="top-start">
<MenuButton as={IconButton} aria-label="Sdílet" icon={<Share2 size={18} />} variant="solid" colorScheme="brand" />
<MenuList>
<MenuItem onClick={() => handleShareClick('instagram')} icon={<Instagram size={16} />}>Instagram</MenuItem>
<MenuItem onClick={() => handleShareClick('twitter')} icon={<Twitter size={16} />}>Twitter</MenuItem>
<MenuItem onClick={() => handleShareClick('facebook')} icon={<Facebook size={16} />}>Facebook</MenuItem>
<MenuItem onClick={() => handleShareClick('copy')} icon={<Copy size={16} />}>Kopírovat odkaz</MenuItem>
</MenuList>
</Menu>
);
return (
<>
{placement === 'fixed' ? (
<Box position="fixed" right={mr} bottom={mb} zIndex={zIndex}>
{ButtonEl}
<Box position="fixed" bottom={mb} zIndex={zIndex} {...(align === 'left' ? { left: mr } : { right: mr })}>
{isAdmin ? AdminButtonEl : VisitorShareEl}
</Box>
) : (
<Box position="absolute" top={2} right={2} zIndex={zIndex}>
{ButtonEl}
<Box position="absolute" top={2} zIndex={zIndex} {...(align === 'left' ? { left: 2 } : { right: 2 })}>
{isAdmin ? AdminButtonEl : VisitorShareEl}
</Box>
)}
+6 -2
View File
@@ -272,9 +272,13 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
}));
};
// Filter out polls that are already linked
// Filter out polls that are already linked elsewhere to avoid accidental reuse
const linkedPollIds = new Set(linkedPolls?.map(p => p.id) || []);
const availablePolls = allPolls?.filter(p => !linkedPollIds.has(p.id)) || [];
const availablePolls = allPolls?.filter(p => {
if (linkedPollIds.has(p.id)) return false; // already linked to this content, handled above
const linkedElsewhere = !!(p.related_article_id || p.related_event_id || p.related_match_id || p.related_video_url);
return !linkedElsewhere;
}) || [];
if (!articleId && !eventId) {
return null;
@@ -0,0 +1,250 @@
import React from 'react';
import { Box, VStack, HStack, Text, Heading, Textarea, Button, Avatar, IconButton, useColorModeValue, Spinner, Link as ChakraLink, Badge } from '@chakra-ui/react';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { listComments, createComment, updateComment, deleteComment, CommentItem, reactComment, unreactComment, requestUnban, reportComment } from '../../services/comments';
import { useAuth } from '../../contexts/AuthContext';
import { Pencil, Trash2, Send } from 'lucide-react';
import { Link as RouterLink } from 'react-router-dom';
type Props = {
targetType: 'article' | 'event' | 'gallery_album' | 'youtube_video';
targetId: string;
};
const PAGE_SIZE = 20;
const displayName = (u?: CommentItem['user']) => {
if (!u) return 'Anonym';
const name = `${u.first_name || ''} ${u.last_name || ''}`.trim();
return name || (u.email || 'Uživatel');
};
const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
const cardBg = useColorModeValue('white', 'gray.800');
const border = useColorModeValue('gray.200', 'gray.700');
const muted = useColorModeValue('gray.600', 'gray.400');
const queryClient = useQueryClient();
const { isAuthenticated, user } = useAuth();
const commentsQuery = useInfiniteQuery({
queryKey: ['comments', targetType, targetId],
queryFn: ({ pageParam = 1 }) => listComments({ target_type: targetType, target_id: targetId, page: pageParam, page_size: PAGE_SIZE }),
getNextPageParam: (lastPage, pages) => {
const loaded = pages.reduce((sum, p) => sum + (p.items?.length || 0), 0);
return loaded < lastPage.total ? pages.length + 1 : undefined;
},
staleTime: 30_000,
});
const allItems = (commentsQuery.data?.pages || []).flatMap(p => p.items || []);
const [newContent, setNewContent] = React.useState('');
const [editingId, setEditingId] = React.useState<number | null>(null);
const [editContent, setEditContent] = React.useState('');
const [replyTo, setReplyTo] = React.useState<number | null>(null);
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
const [canRequestUnban, setCanRequestUnban] = React.useState<boolean>(false);
const createMut = useMutation({
mutationFn: (body: { content: string; parent_id?: number | null }) => createComment({ target_type: targetType, target_id: targetId, content: body.content, parent_id: body.parent_id }),
onSuccess: async (created) => {
setNewContent('');
setReplyTo(null);
setErrorMsg(null);
await queryClient.invalidateQueries({ queryKey: ['comments', targetType, targetId] });
if ((created as any)?.status === 'hidden') {
setErrorMsg('Váš komentář čeká na schválení (automatická moderace).');
}
try { window.dispatchEvent(new CustomEvent('engagement:refresh')); } catch {}
},
onError: (e: any) => {
const msg = e?.response?.data?.error || 'Nepodařilo se odeslat komentář';
setErrorMsg(msg);
if ((e?.response?.status || 0) === 403) setCanRequestUnban(true);
}
});
const updateMut = useMutation({
mutationFn: (args: { id: number; content: string }) => updateComment(args.id, { content: args.content }),
onSuccess: async () => {
setEditingId(null);
setEditContent('');
await queryClient.invalidateQueries({ queryKey: ['comments', targetType, targetId] });
},
});
const deleteMut = useMutation({
mutationFn: (id: number) => deleteComment(id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['comments', targetType, targetId] });
},
});
const reactMut = useMutation({
mutationFn: (args: { id: number; type: string }) => reactComment(args.id, args.type),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['comments', targetType, targetId] });
},
});
const unreactMut = useMutation({
mutationFn: (id: number) => unreactComment(id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['comments', targetType, targetId] });
},
});
const unbanMut = useMutation({
mutationFn: (message: string) => requestUnban(message),
onSuccess: () => {
setCanRequestUnban(false);
setErrorMsg('Žádost o odblokování odeslána.');
}
});
const reportMut = useMutation({
mutationFn: (args: { id: number; reason?: string }) => reportComment(args.id, args.reason),
onSuccess: async () => {
setErrorMsg('Děkujeme za nahlášení. Moderátor se na komentář podívá.');
},
});
const canEdit = (c: CommentItem) => {
if (!user) return false;
if (user.role === 'admin') return true;
return Number(user.id) === Number(c.user?.id);
};
const minChars = 6;
// Build simple threaded structure
const byParent: Record<string, CommentItem[]> = React.useMemo(() => {
const map: Record<string, CommentItem[]> = {};
for (const c of allItems) {
const key = String(c.parent_id || 0);
(map[key] = map[key] || []).push(c);
}
return map;
}, [allItems]);
const ReactionBar: React.FC<{ c: CommentItem }> = ({ c }) => {
const options: { key: string; label: string }[] = [
{ key: 'thumbs_up', label: '👍' },
{ key: 'heart', label: '❤️' },
{ key: 'smile', label: '😀' },
{ key: 'surprised', label: '😮' },
{ key: 'thumbs_down', label: '👎' },
];
const counts = c.reactions || {};
const active = c.my_reaction;
return (
<HStack spacing={2} mt={1}>
{options.map((o) => (
<Button key={o.key} size="xs" variant={active === o.key ? 'solid' : 'outline'} onClick={() => {
if (!isAuthenticated) return;
if (active === o.key) unreactMut.mutate(c.id); else reactMut.mutate({ id: c.id, type: o.key });
}}>
<HStack spacing={1}><Text as="span">{o.label}</Text><Text as="span" fontSize="xs">{counts[o.key] || 0}</Text></HStack>
</Button>
))}
</HStack>
);
};
const renderThread = (parentId: number | null, depth = 0) => {
const list = byParent[String(parentId || 0)] || [];
return (
<VStack align="stretch" spacing={3} pl={depth ? 6 : 0}>
{list.map((c) => (
<HStack key={c.id} align="start" spacing={3} borderWidth="1px" borderColor={border} borderRadius="md" p={3}>
<Avatar name={displayName(c.user)} size="sm" src={(c as any)?.user?.avatar_url || undefined} />
<VStack align="stretch" spacing={1} flex={1}>
<HStack justify="space-between">
<HStack spacing={2}>
<Text fontWeight="600">{displayName(c.user)}</Text>
<Text fontSize="sm" color={muted}>{new Date(c.created_at).toLocaleString()}</Text>
{c.is_edited && <Text fontSize="xs" color={muted}>(upraveno)</Text>}
</HStack>
{canEdit(c) && (
<HStack spacing={1}>
<IconButton aria-label="Upravit" size="xs" variant="ghost" icon={<Pencil size={16} />} onClick={() => { setEditingId(c.id); setEditContent(c.content); }} />
<IconButton aria-label="Smazat" size="xs" variant="ghost" colorScheme="red" icon={<Trash2 size={16} />} onClick={() => deleteMut.mutate(c.id)} />
</HStack>
)}
</HStack>
{editingId === c.id ? (
<VStack align="stretch" spacing={2}>
<Textarea value={editContent} onChange={(e) => setEditContent(e.target.value)} rows={3} />
<HStack>
<Button size="sm" colorScheme="blue" onClick={() => updateMut.mutate({ id: c.id, content: editContent.trim() })} isLoading={updateMut.isPending}>Uložit</Button>
<Button size="sm" variant="ghost" onClick={() => { setEditingId(null); setEditContent(''); }}>Zrušit</Button>
</HStack>
</VStack>
) : (
<Text whiteSpace="pre-wrap">{c.content}</Text>
)}
<ReactionBar c={c} />
<HStack>
{isAuthenticated && <Button size="xs" variant="ghost" onClick={() => setReplyTo(c.id)}>Odpovědět</Button>}
{isAuthenticated && <Button size="xs" variant="ghost" colorScheme="red" onClick={() => reportMut.mutate({ id: c.id })}>Nahlásit</Button>}
{c.status === 'hidden' && <Badge colorScheme="yellow">Čeká na schválení</Badge>}
</HStack>
{/* Replies */}
{renderThread(c.id, depth + 1)}
</VStack>
</HStack>
))}
</VStack>
);
};
return (
<Box mt={6} borderWidth="1px" borderColor={border} borderRadius="lg" bg={cardBg} p={4}>
<Heading as="h3" size="md" mb={3}>Komentáře</Heading>
{commentsQuery.isLoading ? (
<HStack><Spinner size="sm" /><Text>Načítám</Text></HStack>
) : (
<VStack align="stretch" spacing={3}>
{allItems.length === 0 && (
<Text color={muted}>Zatím žádné komentáře.</Text>
)}
{renderThread(null)}
{commentsQuery.hasNextPage && (
<Button onClick={() => commentsQuery.fetchNextPage()} isLoading={commentsQuery.isFetchingNextPage} alignSelf="center" size="sm" variant="outline">Načíst další</Button>
)}
</VStack>
)}
<Box mt={4}>
{errorMsg && (
<Text color="orange.500" fontSize="sm" mb={2}>{errorMsg}</Text>
)}
{isAuthenticated ? (
<VStack align="stretch" spacing={2}>
{replyTo && (
<HStack justify="space-between">
<Text fontSize="sm" color={muted}>Odpověď na komentář #{replyTo}</Text>
<Button size="xs" variant="ghost" onClick={() => setReplyTo(null)}>Zrušit odpověď</Button>
</HStack>
)}
<Textarea placeholder="Napište komentář…" value={newContent} onChange={(e) => setNewContent(e.target.value)} rows={3} />
<HStack>
<Button leftIcon={<Send size={16} />} colorScheme="blue" onClick={() => createMut.mutate({ content: newContent.trim(), parent_id: replyTo })} isLoading={createMut.isPending} isDisabled={newContent.trim().length < minChars}>Odeslat</Button>
<Text fontSize="sm" color={muted}>Respektujte prosím pravidla slušné diskuse.</Text>
</HStack>
{canRequestUnban && (
<HStack>
<Button size="sm" variant="outline" onClick={() => unbanMut.mutate('Prosím o odblokování komentářů. Děkuji.')}>Požádat o odblokování</Button>
</HStack>
)}
</VStack>
) : (
<Text color={muted}>Pro přidání komentáře se prosím <ChakraLink as={RouterLink} to="/login" color="blue.500">přihlaste</ChakraLink>.</Text>
)}
</Box>
</Box>
);
};
export default CommentsSection;
@@ -98,10 +98,16 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const [cropFile, setCropFile] = useState<File | null>(null);
const [crop, setCrop] = useState<Crop>({ unit: '%', width: 80, height: 80, x: 10, y: 10 });
const [cropQuality, setCropQuality] = useState<number>(85);
const [cropMaxWidth, setCropMaxWidth] = useState<number>(1500);
const [cropMaxWidth, setCropMaxWidth] = useState<number>(1920);
const [cropProcessing, setCropProcessing] = useState(false);
const imgRef = useRef<HTMLImageElement | null>(null);
// Link modal state
const [isLinkOpen, setIsLinkOpen] = useState(false);
const [linkText, setLinkText] = useState('');
const [linkUrl, setLinkUrl] = useState('');
const linkRangeRef = useRef<{ index: number; length: number } | null>(null);
// Force white mode for better readability in admin
const borderColor = 'gray.200';
const bgColor = 'white';
@@ -163,6 +169,38 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
return toolbarConfigs[toolbar] || toolbarConfigs.full;
}, [toolbar]);
// Clean transient selection styles before saving HTML
const cleanEditorHTML = useCallback((html: string): string => {
try {
const wrapper = document.createElement('div');
wrapper.innerHTML = html || '';
const imgs = wrapper.querySelectorAll('img');
imgs.forEach((img) => {
try {
img.removeAttribute('draggable');
const style = (img.getAttribute('style') || '').trim();
if (style) {
// Remove outline, box-shadow, cursor declarations
const filtered = style
.split(';')
.map((s) => s.trim())
.filter(Boolean)
.filter((decl) => {
const k = decl.split(':')[0]?.trim().toLowerCase();
return k !== 'outline' && k !== 'box-shadow' && k !== 'cursor';
})
.join('; ');
if (filtered) img.setAttribute('style', filtered);
else img.removeAttribute('style');
}
} catch {}
});
return wrapper.innerHTML;
} catch {
return html;
}
}, []);
// Image upload handler
const handleImageUpload = useCallback(() => {
const input = document.createElement('input');
@@ -183,17 +221,50 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
}, []);
// Memoize modules to prevent Quill reinitialization
const handleLinkToolbar = useCallback(() => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const range = quill.getSelection();
const index = range ? range.index : quill.getLength();
const length = range ? range.length : 0;
linkRangeRef.current = { index, length };
const selectedText = length > 0 ? quill.getText(index, length) : '';
setLinkText(selectedText || '');
setLinkUrl('');
setIsLinkOpen(true);
}, []);
const quillModules = useMemo(() => ({
toolbar: {
container: toolbarConfig,
handlers: {
image: onImageUpload ? handleImageUpload : undefined,
link: handleLinkToolbar,
},
},
clipboard: {
matchVisual: false,
},
}), [toolbarConfig, onImageUpload, handleImageUpload]);
}), [toolbarConfig, onImageUpload, handleImageUpload, handleLinkToolbar]);
const quillFormats = useMemo(
() => [
'header',
'bold',
'italic',
'underline',
'strike',
'blockquote',
'list',
'indent',
'align',
'link',
'image',
'color',
'background',
],
[]
);
// Localize Quill toolbar tooltips/labels to Czech
useEffect(() => {
@@ -356,10 +427,31 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
img.onload = () => {
try {
quill.insertEmbed(index, 'image', absoluteUrl, 'api');
// Set default width to 50% of editor width (for better layout)
try {
const anyQuill = quill as any;
const leafInfo = anyQuill?.getLeaf ? anyQuill.getLeaf(index) : null;
const leaf = Array.isArray(leafInfo) ? leafInfo[0] : null;
const editorWidth = quill.root?.clientWidth || 0;
const px = Math.max(50, Math.round(editorWidth * 0.5));
let targetImg: HTMLImageElement | null = null;
if (leaf && leaf.domNode && (leaf.domNode as HTMLElement).tagName === 'IMG') {
targetImg = leaf.domNode as HTMLImageElement;
}
if (!targetImg) {
targetImg = quill.root.querySelector(`img[src="${absoluteUrl}"]`) as HTMLImageElement | null;
}
if (targetImg) {
targetImg.style.width = `${px}px`;
targetImg.style.maxWidth = '100%';
targetImg.style.height = 'auto';
try { targetImg.setAttribute('width', String(px)); } catch {}
}
} catch {}
// Move cursor after the image
quill.setSelection(index + 1, 0, 'api');
// Force content change to trigger re-render
onChangeRef.current(quill.root.innerHTML);
// Persist content so default width is saved
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
toast({ title: 'Obrázek vložen', status: 'success', duration: 2000 });
} catch (e) {
console.error('Insert after preload error:', e);
@@ -386,7 +478,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setCropFile(null);
setCrop({ unit: '%', width: 80, height: 80, x: 10, y: 10 });
setCropQuality(85);
setCropMaxWidth(1500);
setCropMaxWidth(1920);
}
};
@@ -416,10 +508,9 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
z-index: 1000;
`;
const rect = img.getBoundingClientRect();
const editorRect = editor.root.getBoundingClientRect();
const scrollTop = editor.root.scrollTop;
const scrollLeft = editor.root.scrollLeft;
// Position relative to Quill container (parent of .ql-editor)
const editorContainer = editor.root.parentElement as HTMLElement | null;
if (!editorContainer) return null;
const sizeLabel = document.createElement('div');
sizeLabel.style.cssText = `
position: absolute;
@@ -458,12 +549,12 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const updateHandlePositions = () => {
const rect = img.getBoundingClientRect();
const editorRect = editor.root.getBoundingClientRect();
const containerRect = editorContainer.getBoundingClientRect();
const scrollTop = editor.root.scrollTop;
const scrollLeft = editor.root.scrollLeft;
container.style.left = `${rect.left - editorRect.left + scrollLeft}px`;
container.style.top = `${rect.top - editorRect.top + scrollTop}px`;
container.style.left = `${rect.left - containerRect.left + scrollLeft}px`;
container.style.top = `${rect.top - containerRect.top + scrollTop}px`;
container.style.width = `${rect.width}px`;
container.style.height = `${rect.height}px`;
};
@@ -581,7 +672,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
isResizing = false;
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
onChangeRef.current(editor.root.innerHTML);
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
const id = selectedImageIdRef.current;
setTimeout(() => { if (id) { try { selectImageByIdRef.current?.(id); } catch {} } }, 30);
};
@@ -594,8 +685,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
updateHandlePositions();
updateSizeLabel(img.offsetWidth || img.width || 0);
editor.root.style.position = 'relative';
editor.root.appendChild(container);
editorContainer.style.position = editorContainer.style.position || 'relative';
editorContainer.appendChild(container);
container.appendChild(sizeLabel);
resizeHandle = container;
@@ -798,7 +889,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
isResizing = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
onChangeRef.current(editor.root.innerHTML);
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
};
document.addEventListener('mousemove', onMouseMove);
@@ -859,7 +950,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
if (selectedImage) {
onChangeRef.current(editor.root.innerHTML);
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
}
};
@@ -880,7 +971,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
e.preventDefault();
selectedImage.remove();
deselectImage();
onChangeRef.current(editor.root.innerHTML);
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
toast({ title: 'Obrázek odstraněn', status: 'info', duration: 1500 });
}
};
@@ -891,11 +982,12 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const rect = selectedImage!.getBoundingClientRect();
const editorRect = editor.root.getBoundingClientRect();
const editorContainer = editor.root.parentElement as HTMLElement | null;
const containerRect = editorContainer ? editorContainer.getBoundingClientRect() : editor.root.getBoundingClientRect();
const scrollTop = editor.root.scrollTop;
const scrollLeft = editor.root.scrollLeft;
resizeHandle!.style.left = `${rect.left - editorRect.left + scrollLeft}px`;
resizeHandle!.style.top = `${rect.top - editorRect.top + scrollTop}px`;
resizeHandle!.style.left = `${rect.left - containerRect.left + scrollLeft}px`;
resizeHandle!.style.top = `${rect.top - containerRect.top + scrollTop}px`;
resizeHandle!.style.width = `${rect.width}px`;
resizeHandle!.style.height = `${rect.height}px`;
});
@@ -974,6 +1066,10 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setImageFilters(defaultFilters);
if (selectedImageElement) {
applyFiltersToImage(selectedImageElement, defaultFilters);
const editor = quillRef.current?.getEditor();
if (editor) {
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
}
}
}, [selectedImageElement, applyFiltersToImage]);
@@ -983,6 +1079,10 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const newFilters = { ...prev, [key]: value };
if (selectedImageElement) {
applyFiltersToImage(selectedImageElement, newFilters);
const editor = quillRef.current?.getEditor();
if (editor) {
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
}
}
return newFilters;
});
@@ -1041,7 +1141,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Update editor content
const editor = quillRef.current?.getEditor();
if (editor) {
onChangeRef.current(editor.root.innerHTML);
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
}
reselectAfterContentUpdate();
@@ -1073,7 +1173,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const editor = quillRef.current?.getEditor();
if (editor) {
onChangeRef.current(editor.root.innerHTML);
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
// Force overlay reposition
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
@@ -1103,7 +1203,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setImageWidth(finalWidth);
setManualWidth(finalWidth.toString());
if (editor) {
onChangeRef.current(editor.root.innerHTML);
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
}
// Keep selection active for subsequent operations (e.g., 50% → 75%)
reselectAfterContentUpdate();
@@ -1123,7 +1223,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setImageWidth(currentWidth);
setManualWidth('');
if (editor) {
onChangeRef.current(editor.root.innerHTML);
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
}
reselectAfterContentUpdate();
toast({ title: 'Šířka resetována', status: 'info', duration: 1200 });
@@ -1169,7 +1269,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setShowImageToolbar(false);
const editor = quillRef.current?.getEditor();
if (editor) {
onChangeRef.current(editor.root.innerHTML);
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
}
toast({ title: 'Obrázek odstraněn', status: 'info', duration: 1500 });
}
@@ -1200,26 +1300,83 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
cleaned = cleaned.replace(pattern, 'color: #1a202c');
});
onChangeRef.current(cleaned);
onChangeRef.current(cleanEditorHTML(cleaned));
};
// Apply bullet style (disc | circle | square) to the current list
const applyBulletStyle = useCallback((style: 'disc' | 'circle' | 'square') => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const range = quill.getSelection();
if (!range) return;
const [line] = quill.getLine(range.index);
const node = (line as any)?.domNode as HTMLElement | null;
if (!node) return;
// find nearest UL
let el: HTMLElement | null = node;
while (el && el.tagName !== 'UL' && el !== quill.root) {
el = el.parentElement;
}
if (el && el.tagName === 'UL') {
(el as HTMLElement).style.listStyleType = style;
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
}
}, [onChangeRef]);
const insertOrUpdateLink = useCallback(() => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const range = linkRangeRef.current || quill.getSelection() || { index: quill.getLength(), length: 0 };
const text = linkText?.trim() || linkUrl?.trim();
const url = linkUrl?.trim();
if (!url) {
toast({ title: 'Zadejte URL', status: 'warning', duration: 1500 });
return;
}
quill.focus();
if (range.length > 0) {
// Replace selected text with provided text and link
quill.deleteText(range.index, range.length, 'user');
quill.insertText(range.index, text || url, 'link', url, 'user');
quill.setSelection(range.index + (text || url).length, 0, 'user');
} else {
quill.insertText(range.index, text || url, 'link', url, 'user');
quill.setSelection(range.index + (text || url).length, 0, 'user');
}
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
setIsLinkOpen(false);
setLinkText('');
setLinkUrl('');
}, [linkText, linkUrl, toast]);
return (
<Box>
{/* Editor Controls */}
{!readOnly && onImageUpload && (
<HStack mb={2} spacing={2} justify="flex-start" flexWrap="wrap">
<Button
size="sm"
leftIcon={<ImageIcon size={16} />}
colorScheme="purple"
onClick={handleImageUpload}
>
Vložit obrázek
</Button>
<Text fontSize="xs" color="gray.500">
nebo použijte tlačítko obrázku v nástrojové liště
</Text>
</HStack>
{!readOnly && (
<VStack align="stretch" spacing={1} mb={2}>
{onImageUpload && (
<HStack spacing={2} justify="flex-start" flexWrap="wrap">
<Button
size="sm"
leftIcon={<ImageIcon size={16} />}
colorScheme="purple"
onClick={handleImageUpload}
>
Vložit obrázek
</Button>
<Text fontSize="xs" color="gray.500">
nebo použijte tlačítko obrázku v nástrojové liště
</Text>
</HStack>
)}
{/* Bullet style controls */}
<HStack spacing={2} justify="flex-start" flexWrap="wrap" pt={1}>
<Text fontSize="xs" color="gray.600">Styl odrážek:</Text>
<Button size="xs" variant="outline" onClick={() => applyBulletStyle('disc')}> plné</Button>
<Button size="xs" variant="outline" onClick={() => applyBulletStyle('circle')}> kruh</Button>
<Button size="xs" variant="outline" onClick={() => applyBulletStyle('square')}> čtverec</Button>
</HStack>
</VStack>
)}
<Box
@@ -1293,7 +1450,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
maxHeight: '70vh',
overflowY: 'auto',
bg: 'white !important',
color: '#1a202c !important',
color: '#1a202c',
padding: '16px',
lineHeight: '1.6',
'&::-webkit-scrollbar': {
@@ -1306,9 +1463,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
bg: 'gray.400',
borderRadius: '4px',
},
'h1, h2, h3, h4, h5, h6': {
color: '#1a202c !important',
},
'h1, h2, h3, h4, h5, h6': {},
'h1': {
fontSize: '2em !important',
fontWeight: 'bold !important',
@@ -1330,17 +1485,9 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
marginBottom: '1em !important',
lineHeight: '1.4 !important',
},
'p, li, span, div': {
color: '#2d3748 !important',
},
'strong, b': {
color: '#1a202c !important',
fontWeight: 'bold !important',
},
'a': {
color: '#3182ce !important',
textDecoration: 'underline',
},
'p, li, span, div': {},
'strong, b': { fontWeight: 'bold' },
'a': { textDecoration: 'underline' },
'blockquote': {
borderLeft: '4px solid #3182ce',
paddingLeft: '16px',
@@ -1391,22 +1538,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
color: '#a0aec0 !important',
fontStyle: 'italic',
},
// Prevent white and very light text colors
'.ql-editor [style*="color: rgb(255, 255, 255)"]': {
color: '#1a202c !important',
},
'.ql-editor [style*="color: white"]': {
color: '#1a202c !important',
},
'.ql-editor [style*="color: #fff"]': {
color: '#1a202c !important',
},
'.ql-editor [style*="color: #ffffff"]': {
color: '#1a202c !important',
},
'.ql-editor [style*="color: rgb(255,255,255)"]': {
color: '#1a202c !important',
},
// Allow user-chosen colors to show. White-on-white is handled during paste/sanitize only.
}}
>
{isMounted && (
@@ -1418,6 +1550,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
placeholder={placeholder}
ref={quillRef}
modules={quillModules}
formats={quillFormats}
/>
)}
</Box>
@@ -1449,7 +1582,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
pointerEvents="auto"
onClick={(e) => { e.stopPropagation(); }}
onMouseDown={(e) => { e.stopPropagation(); }}
onMouseUp={(e) => { e.stopPropagation(); }}
css={{
'&::-webkit-scrollbar': {
width: '6px',
@@ -1783,6 +1915,42 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
</Box>
)}
{/* Crop Modal */}
{/* Link Modal */}
<Modal isOpen={isLinkOpen} onClose={() => setIsLinkOpen(false)} isCentered>
<ModalOverlay />
<ModalContent maxW="lg">
<ModalHeader>Vložit odkaz</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={3}>
<FormControl>
<FormLabel>Text odkazu</FormLabel>
<Input
value={linkText}
onChange={(e) => setLinkText(e.target.value)}
placeholder="Zobrazovaný text (nepovinné)"
/>
</FormControl>
<FormControl isRequired>
<FormLabel>URL</FormLabel>
<Input
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://... nebo /cesta"
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); insertOrUpdateLink(); } }}
/>
<FormHelperText>Zadejte plný nebo relativní odkaz</FormHelperText>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={() => setIsLinkOpen(false)}>Zrušit</Button>
<Button colorScheme="blue" onClick={insertOrUpdateLink}>Vložit</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Crop Modal */}
{/* Image Preview Modal */}
<Modal isOpen={isPreviewOpen} onClose={() => setIsPreviewOpen(false)} size="6xl" isCentered>
@@ -8,6 +8,7 @@ interface Sponsor {
logo: string;
url?: string;
tier?: string;
display_order?: number;
}
interface SponsorsSectionProps {
@@ -52,6 +53,7 @@ const SponsorsSection: React.FC<SponsorsSectionProps> = ({
logo: assetUrl(s.logo_url) || '/images/sponsors/placeholder.png',
url: s.website_url || undefined,
tier: s.tier,
display_order: typeof s.display_order === 'number' ? s.display_order : undefined,
}));
setSponsors(mapped);
setLoading(false);
@@ -72,9 +74,10 @@ const SponsorsSection: React.FC<SponsorsSectionProps> = ({
sponsorsData.map((s: any, i: number) => ({
id: s.id ?? i + 1,
name: s.name || 'Sponsor',
logo: s.logo_url || s.logoUrl || s.logo || '/images/sponsors/placeholder.png',
logo: assetUrl(s.logo_url || s.logoUrl || s.logo) || '/images/sponsors/placeholder.png',
url: s.url || s.website || s.link || '#',
tier: s.tier,
display_order: typeof s.display_order === 'number' ? s.display_order : undefined,
}))
);
}
@@ -95,8 +98,17 @@ const SponsorsSection: React.FC<SponsorsSectionProps> = ({
return null;
}
const title = sponsors.find((s: any) => s.tier === 'title') || sponsors[0];
const others = sponsors.filter((s) => s !== title);
const sorted = [...sponsors].sort((a: any, b: any) => {
const at = a.tier === 'general' ? 0 : 1;
const bt = b.tier === 'general' ? 0 : 1;
if (at !== bt) return at - bt;
const ao = (a as any).display_order ?? 9999;
const bo = (b as any).display_order ?? 9999;
if (ao !== bo) return ao - bo;
return String(a.name || '').localeCompare(String(b.name || ''));
});
const title = sorted.find((s: any) => s.tier === 'general') || sorted[0];
const others = sorted.filter((s) => s !== title);
return (
<section
+83 -5
View File
@@ -9,9 +9,11 @@ import { assetUrl } from '../../utils/url';
let __teamOverridesCache: { ts: number; data: { by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> } } | null = null;
const loadTeamOverrides = async (): Promise<{ by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> }> => {
const now = Date.now();
if (__teamOverridesCache && now - __teamOverridesCache.ts < 60_000) {
const TTL = 5_000;
if (__teamOverridesCache && now - __teamOverridesCache.ts < TTL) {
return __teamOverridesCache.data || {};
}
// Try fresh public endpoint first
try {
const res = await fetch(`/api/v1/public/team-logo-overrides?t=${now}`, { cache: 'no-cache' });
if (res.ok) {
@@ -20,6 +22,7 @@ const loadTeamOverrides = async (): Promise<{ by_id?: Record<string, { name?: st
return json || {};
}
} catch {}
// Fallback to static cache snapshot
try {
const res2 = await fetch('/cache/prefetch/team_logo_overrides.json', { cache: 'no-cache' });
if (res2.ok) {
@@ -28,10 +31,45 @@ const loadTeamOverrides = async (): Promise<{ by_id?: Record<string, { name?: st
return json || {};
}
} catch {}
// Final fallback: previously cached data or empty
if (__teamOverridesCache) return __teamOverridesCache.data || {};
__teamOverridesCache = { ts: now, data: {} };
return {};
};
// Normalization helpers for name-based matching
const __normalize = (s?: string) => {
let out = String(s || '');
out = out
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
out = out.replace(/\bn\.?\b/g, ' nad ');
out = out.replace(/\bp\.?\b/g, ' pod ');
out = out.replace(/[.,!;:()\[\]{}]/g, ' ');
// Remove legal suffixes often appended to Czech organizations
out = out.replace(/[\s,]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
// Remove common organization phrases
const orgPhrases = [
'fotbalovy klub',
'sportovni klub',
'telovychovna jednota',
'skolni sportovni klub',
'spolek',
'fotbal',
'futsal',
];
for (const phrase of orgPhrases) {
const re = new RegExp('(^|\\b)'+ phrase + '(\\b|$)', 'g');
out = out.replace(re, ' ');
}
// Remove common short prefixes/tokens (FK, FC, MFK, TJ, SK, SFC, AFK, BFK, HFK, etc.)
out = out.replace(/\b(1\.)?\s*(sfc|afc|fc|fk|mfk|tj|sk|afk|bfk|hfk)\b\.?/g, ' ');
out = out.replace(/\s+/g, ' ').trim();
return out;
};
interface TeamLogoProps extends Omit<ImageProps, 'src'> {
teamId?: string;
teamName?: string;
@@ -70,7 +108,7 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
setLoading(true);
setError(false);
// Load admin overrides (cached)
let overrides: { by_id?: Record<string, { name?: string; logo_url?: string }> } = {};
let overrides: { by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> } = {} as any;
try { overrides = await loadTeamOverrides(); } catch {}
// Prefer local club logo for own team when IDs match
if (
@@ -89,9 +127,49 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
}
}
} else {
const url = await getTeamLogo(teamId, teamName, facrLogo);
if (mounted) {
setLogoUrl(url);
// Try name-based override first if ID override not found
let appliedByName = false;
try {
const byName: Record<string, string> = (overrides as any)?.by_name || {};
if (teamName && byName && Object.keys(byName).length > 0) {
const normMap: Record<string, string> = {};
for (const k of Object.keys(byName)) { normMap[__normalize(k)] = byName[k]; }
const normTeam = __normalize(teamName);
let candidate = byName[teamName] || normMap[normTeam];
if (!candidate) {
// Suffix/containment match after normalization to handle sponsors/affixes
const entries = Object.keys(byName).map((k) => ({ keyNorm: __normalize(k), url: byName[k] }));
for (const { keyNorm, url } of entries) {
if (!keyNorm) continue;
if (normTeam.endsWith(keyNorm) || keyNorm.endsWith(normTeam)) { candidate = url; break; }
}
}
if (!candidate) {
const t1 = normTeam.split(' ')[0];
if (t1 && t1.length >= 5) {
for (const { keyNorm, url } of Object.keys(byName).map((k) => ({ keyNorm: __normalize(k), url: byName[k] }))) {
const k1 = String(keyNorm).split(' ')[0];
if (k1 === t1) { candidate = url; break; }
}
}
}
if (candidate) {
appliedByName = true;
if (mounted) {
if (typeof candidate === 'string' && candidate.startsWith('/')) {
setLogoUrl(assetUrl(candidate) || candidate);
} else {
setLogoUrl(candidate);
}
}
}
}
} catch {}
if (!appliedByName) {
const url = await getTeamLogo(teamId, teamName, facrLogo);
if (mounted) {
setLogoUrl(url);
}
}
}
} catch (e) {
+77 -125
View File
@@ -97,6 +97,7 @@ import {
PREDEFINED_ELEMENTS,
PredefinedElement,
} from '../../services/pageElements';
import api from '../../services/api';
import { safeDOM } from '../../services/myuibrix';
import { useAuth } from '../../contexts/AuthContext';
import { useClubTheme } from '../../contexts/ClubThemeContext';
@@ -1515,12 +1516,22 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const handleSave = async () => {
try {
const configsToSave: PageElementConfig[] = elementOrder.map((elementName, index) => ({
// Persist ALL elements that have order, styles, or variant/visibility changes
const knownOrder = [...elementOrder];
const extraNames = Array.from(new Set([
...Object.keys(elementStyles || {}),
...Object.keys(localChanges || {}),
...Array.from(visibleElements || new Set<string>())
].filter(Boolean))).filter(name => !knownOrder.includes(name));
const allNames = [...knownOrder, ...extraNames];
const configsToSave: PageElementConfig[] = allNames.map((elementName, idx) => ({
page_type: pageType,
element_name: elementName,
variant: localChanges[elementName] || 'default',
visible: visibleElements.has(elementName),
display_order: index,
visible: visibleElements.has(elementName) || elementName === 'style-pack' || elementName === 'container',
display_order: knownOrder.includes(elementName) ? knownOrder.indexOf(elementName) : idx,
settings: {
...(configs.find(c => c.element_name === elementName)?.settings || {}),
// Persist full styles object and custom CSS
@@ -1529,7 +1540,31 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
},
}));
await batchUpdatePageElementConfigs(configsToSave);
// Try admin batch first (works for admins in dev/prod)
let saved = false;
try {
await batchUpdatePageElementConfigs(configsToSave);
saved = true;
} catch (err: any) {
// Fallback for editors (403/401): use editor preview apply endpoint
const status = err?.response?.status || err?.status;
if (status !== 401 && status !== 403) throw err;
const sessionId = `${pageType}-autosave`;
const elements = configsToSave.map(cfg => ({
element_name: cfg.element_name,
variant: cfg.variant,
visible: Boolean(cfg.visible),
display_order: Number(cfg.display_order || 0),
custom_styles: ((cfg.settings as any)?.styles) || {},
}));
await api.post(`/editor/preview/${encodeURIComponent(sessionId)}/apply`, {
page_type: pageType,
elements,
});
saved = true;
}
if (!saved) return;
toast({
title: 'Změny úspěšně uloženy!',
@@ -1637,131 +1672,48 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
document.body.style.backgroundColor = '#e2e8f0';
document.body.style.userSelect = 'none'; // Prevent text selection during editing
// Apply viewport wrapper - wrap ALL content including navbar
if (!safeDOM.querySelector('.myuibrix-viewport-wrapper')) {
// Find all chakra containers (navbar + content) using safeDOM
const allContainers = safeDOM.querySelectorAll('.chakra-container');
const pageContainer = safeDOM.querySelector('.container');
if (allContainers.length > 0 && pageContainer) {
// Create viewport wrapper
const wrapper = document.createElement('div');
wrapper.className = 'myuibrix-viewport-wrapper';
wrapper.style.cssText = `
margin: 0 auto;
transition: all 0.3s ease;
background: white;
min-height: 100vh;
position: relative;
overflow: visible;
cursor: default;
width: 100%;
max-width: 100%;
`;
// Store reference to parent and next sibling for restoration
const firstContainer = allContainers[0];
const parent = firstContainer.parentElement;
const nextSibling = firstContainer.nextSibling;
if (parent) {
parent.setAttribute('data-myuibrix-restore', 'true');
// Move all chakra containers into wrapper using safeDOM
allContainers.forEach(container => {
// Store original styles
container.setAttribute('data-myuibrix-original-maxw',
(container as HTMLElement).style.maxWidth || '');
container.setAttribute('data-myuibrix-original-width',
(container as HTMLElement).style.width || '');
// Remove max-width constraints for viewport simulation
(container as HTMLElement).style.maxWidth = 'none';
(container as HTMLElement).style.width = '100%';
// Use safe appendChild to avoid React conflicts
safeDOM.appendChild(wrapper, container);
});
// Insert wrapper back into DOM using safeDOM
if (nextSibling) {
safeDOM.insertBefore(parent, wrapper, nextSibling);
} else {
safeDOM.appendChild(parent, wrapper);
}
}
}
const wrapperEl = safeDOM.querySelector('.container') as HTMLElement | null;
if (wrapperEl) {
try { wrapperEl.classList.add('myuibrix-viewport-wrapper'); } catch {}
try { wrapperEl.setAttribute('data-myuibrix-wrapped', '1'); } catch {}
try { wrapperEl.style.width = '100%'; } catch {}
try { wrapperEl.style.maxWidth = '100%'; } catch {}
try { wrapperEl.style.transition = 'all 0.3s ease'; } catch {}
try { wrapperEl.style.margin = '0 auto'; } catch {}
try { wrapperEl.style.transform = 'none'; } catch {}
try { wrapperEl.style.transformOrigin = ''; } catch {}
}
} else {
document.body.style.paddingTop = '0';
document.body.style.backgroundColor = '';
document.body.style.userSelect = '';
// Remove viewport wrapper and restore original structure
const wrapper = safeDOM.querySelector('.myuibrix-viewport-wrapper');
if (wrapper) {
const parent = safeDOM.querySelector('[data-myuibrix-restore]');
if (parent) {
// Move all children back to parent using safeDOM
const children = Array.from(wrapper.children);
children.forEach(child => {
// Restore original styles
const originalMaxW = child.getAttribute('data-myuibrix-original-maxw');
const originalWidth = child.getAttribute('data-myuibrix-original-width');
if (originalMaxW !== null) {
(child as HTMLElement).style.maxWidth = originalMaxW;
child.removeAttribute('data-myuibrix-original-maxw');
}
if (originalWidth !== null) {
(child as HTMLElement).style.width = originalWidth;
child.removeAttribute('data-myuibrix-original-width');
}
// Use safe appendChild to avoid React conflicts
safeDOM.appendChild(parent, child as Element);
});
parent.removeAttribute('data-myuibrix-restore');
// Use safeDOM to remove wrapper
if (wrapper.parentElement) {
safeDOM.removeChild(wrapper.parentElement, wrapper);
}
}
const wrapperEl = safeDOM.querySelector('.myuibrix-viewport-wrapper') as HTMLElement | null;
if (wrapperEl) {
try { wrapperEl.classList.remove('myuibrix-viewport-wrapper'); } catch {}
try { wrapperEl.removeAttribute('data-myuibrix-wrapped'); } catch {}
try { wrapperEl.style.width = ''; } catch {}
try { wrapperEl.style.maxWidth = ''; } catch {}
try { wrapperEl.style.transition = ''; } catch {}
try { wrapperEl.style.margin = ''; } catch {}
try { wrapperEl.style.transform = ''; } catch {}
try { wrapperEl.style.transformOrigin = ''; } catch {}
}
}
return () => {
document.body.style.paddingTop = '0';
document.body.style.backgroundColor = '';
document.body.style.userSelect = '';
const wrapper = safeDOM.querySelector('.myuibrix-viewport-wrapper');
if (wrapper) {
const parent = safeDOM.querySelector('[data-myuibrix-restore]');
if (parent) {
const children = Array.from(wrapper.children);
children.forEach(child => {
const originalMaxW = child.getAttribute('data-myuibrix-original-maxw');
const originalWidth = child.getAttribute('data-myuibrix-original-width');
if (originalMaxW !== null) {
(child as HTMLElement).style.maxWidth = originalMaxW;
child.removeAttribute('data-myuibrix-original-maxw');
}
if (originalWidth !== null) {
(child as HTMLElement).style.width = originalWidth;
child.removeAttribute('data-myuibrix-original-width');
}
// Use safe appendChild to avoid React conflicts
safeDOM.appendChild(parent, child as Element);
});
parent.removeAttribute('data-myuibrix-restore');
// Use safeDOM to remove wrapper
if (wrapper.parentElement) {
safeDOM.removeChild(wrapper.parentElement, wrapper);
}
}
const wrapperEl = safeDOM.querySelector('.myuibrix-viewport-wrapper') as HTMLElement | null;
if (wrapperEl) {
try { wrapperEl.classList.remove('myuibrix-viewport-wrapper'); } catch {}
try { wrapperEl.removeAttribute('data-myuibrix-wrapped'); } catch {}
try { wrapperEl.style.width = ''; } catch {}
try { wrapperEl.style.maxWidth = ''; } catch {}
try { wrapperEl.style.transition = ''; } catch {}
try { wrapperEl.style.margin = ''; } catch {}
try { wrapperEl.style.transform = ''; } catch {}
try { wrapperEl.style.transformOrigin = ''; } catch {}
}
};
}, [isEditing]);
@@ -1822,13 +1774,13 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
top={0}
left={0}
right={0}
bgGradient={`linear(to-r, ${primaryColor}, ${primaryColor}dd)`}
bg="rgba(17, 25, 40, 0.55)"
color="white"
p={3}
zIndex={9999}
boxShadow="0 4px 20px rgba(0,0,0,0.15), 0 2px 8px rgba(0,0,0,0.1)"
backdropFilter="blur(10px)"
borderBottom="1px solid rgba(255,255,255,0.1)"
zIndex={10000}
boxShadow="0 10px 30px rgba(0,0,0,0.2)"
backdropFilter="saturate(180%) blur(14px)"
borderBottom="1px solid rgba(255,255,255,0.12)"
fontFamily="var(--chakra-fonts-body)"
>
<Flex align="center" justify="space-between" maxW="100%">
@@ -1974,10 +1926,10 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
position="fixed"
left={stylePanelRight ? undefined : 0}
right={stylePanelRight ? 0 : undefined}
top={0}
top="72px"
bottom={0}
width="380px"
zIndex={9998}
zIndex={10002}
overflow="hidden"
display="flex"
flexDirection="column"
@@ -2515,7 +2467,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
position="fixed"
left={panelPositions.layersPanel.x === 0 ? undefined : `${panelPositions.layersPanel.x}px`}
right={panelPositions.layersPanel.x === 0 ? 4 : undefined}
top={panelPositions.layersPanel.y === 0 ? 4 : `${panelPositions.layersPanel.y}px`}
top={panelPositions.layersPanel.y === 0 ? '72px' : `${panelPositions.layersPanel.y}px`}
bottom={panelPositions.layersPanel.y === 0 ? 4 : undefined}
transform={undefined}
width={`${panelPositions.layersPanel.width}px`}
@@ -107,19 +107,28 @@ class MyUIbrixErrorBoundary extends Component<Props, State> {
}
});
// Remove viewport wrapper if exists
const wrapper = document.querySelector('.myuibrix-viewport-wrapper');
// Remove/cleanup viewport wrapper if exists
const wrapper = document.querySelector('.myuibrix-viewport-wrapper') as HTMLElement | null;
if (wrapper && wrapper.parentElement) {
try {
const parent = wrapper.parentElement;
Array.from(wrapper.children).forEach(child => {
try {
parent.appendChild(child);
} catch (e) {
console.warn('Failed to move child:', e);
}
});
wrapper.remove();
const parent = wrapper.parentElement as HTMLElement;
const parentHasRestore = parent.hasAttribute('data-myuibrix-restore');
if (parentHasRestore) {
Array.from(wrapper.children).forEach(child => {
try { parent.appendChild(child); } catch (e) { console.warn('Failed to move child:', e); }
});
wrapper.remove();
parent.removeAttribute('data-myuibrix-restore');
} else {
try { wrapper.classList.remove('myuibrix-viewport-wrapper'); } catch {}
try { wrapper.removeAttribute('data-myuibrix-wrapped'); } catch {}
try { (wrapper as HTMLElement).style.width = ''; } catch {}
try { (wrapper as HTMLElement).style.maxWidth = ''; } catch {}
try { (wrapper as HTMLElement).style.transition = ''; } catch {}
try { (wrapper as HTMLElement).style.margin = ''; } catch {}
try { (wrapper as HTMLElement).style.transform = ''; } catch {}
try { (wrapper as HTMLElement).style.transformOrigin = ''; } catch {}
}
} catch (e) {
console.warn('Failed to cleanup viewport wrapper:', e);
}
@@ -0,0 +1,81 @@
import React from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
Button,
VStack,
HStack,
Text,
Badge,
Icon,
useColorModeValue,
Spinner,
} from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getAchievements } from '../../services/engagement';
import { CheckCircle, Circle } from 'lucide-react';
export type AchievementsModalProps = {
isOpen: boolean;
onClose: () => void;
onOpenRewards?: () => void;
};
const AchievementsModal: React.FC<AchievementsModalProps> = ({ isOpen, onClose, onOpenRewards }) => {
const cardBg = useColorModeValue('gray.50', 'gray.800');
const q = useQuery({
queryKey: ['engagement', 'achievements'],
queryFn: getAchievements,
enabled: isOpen,
staleTime: 10000,
});
const items = q.data?.achievements || [];
const counters = q.data?.counters || {} as any;
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalOverlay />
<ModalContent>
<ModalHeader>Úspěchy</ModalHeader>
<ModalCloseButton />
<ModalBody>
{q.isLoading ? (
<HStack><Spinner size="sm" /><Text>Načítám</Text></HStack>
) : (
<VStack align="stretch" spacing={3}>
<HStack spacing={3} bg={cardBg} p={3} borderRadius="md">
<Badge colorScheme="blue">Komentáře: {counters?.comments ?? 0}</Badge>
<Badge colorScheme="green">Hlasování: {counters?.votes ?? 0}</Badge>
<Badge colorScheme={counters?.newsletter ? 'purple' : 'gray'}>Newsletter: {counters?.newsletter ? 'ANO' : 'NE'}</Badge>
</HStack>
{items.map((a: any) => (
<HStack key={a.id} spacing={3} p={2} borderWidth="1px" borderRadius="md">
<Icon as={a.achieved ? CheckCircle : Circle} color={a.achieved ? 'green.400' : 'gray.400'} />
<VStack align="start" spacing={0} flex={1}>
<Text fontWeight="600">{a.title}</Text>
<Text fontSize="sm" color="gray.500">{a.description}</Text>
</VStack>
<Badge>{a.points} bodů</Badge>
</HStack>
))}
</VStack>
)}
</ModalBody>
<ModalFooter>
{onOpenRewards && (
<Button mr={3} onClick={() => { onClose(); onOpenRewards(); }} colorScheme="blue">Odměny</Button>
)}
<Button variant="ghost" onClick={onClose}>Zavřít</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default AchievementsModal;
@@ -0,0 +1,105 @@
import React from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
Button,
VStack,
HStack,
Text,
Image,
Badge,
useColorModeValue,
Spinner,
useToast,
} from '@chakra-ui/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getRewards, redeemReward, RewardItem } from '../../services/engagement';
export type RewardsModalProps = {
isOpen: boolean;
onClose: () => void;
availablePoints?: number;
onRedeemed?: () => void;
};
const RewardsModal: React.FC<RewardsModalProps> = ({ isOpen, onClose, availablePoints = 0, onRedeemed }) => {
const cardBg = useColorModeValue('gray.50', 'gray.800');
const toast = useToast();
const qc = useQueryClient();
const q = useQuery({
queryKey: ['engagement', 'rewards'],
queryFn: getRewards,
enabled: isOpen,
staleTime: 10000,
});
const redeemMut = useMutation({
mutationFn: (id: number) => redeemReward(id),
onSuccess: async (res) => {
toast({ title: res.status === 'approved' ? 'Odměna aktivována' : 'Žádost o odměnu odeslána', status: 'success', duration: 3000 });
await qc.invalidateQueries({ queryKey: ['engagement', 'profile'] });
if (onRedeemed) onRedeemed();
},
onError: (e: any) => {
const msg = e?.response?.data?.error || 'Nepodařilo se uplatnit odměnu';
toast({ title: 'Chyba', description: msg, status: 'error', duration: 3500 });
}
});
const items = (q.data || []) as RewardItem[];
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalOverlay />
<ModalContent>
<ModalHeader>Odměny</ModalHeader>
<ModalCloseButton />
<ModalBody>
{q.isLoading ? (
<HStack><Spinner size="sm" /><Text>Načítám</Text></HStack>
) : (
<VStack align="stretch" spacing={3}>
<HStack spacing={3} bg={cardBg} p={3} borderRadius="md">
<Badge colorScheme="blue">Dostupné body: {availablePoints}</Badge>
</HStack>
{items.map((it) => (
<HStack key={it.id} spacing={3} p={2} borderWidth="1px" borderRadius="md" align="center">
{it.image_url && (
<Image src={it.image_url} alt={it.name} boxSize="48px" objectFit="cover" borderRadius="md" />
)}
<VStack align="start" spacing={0} flex={1}>
<Text fontWeight="600">{it.name}</Text>
<Text fontSize="sm" color="gray.500">{it.type}</Text>
</VStack>
<Badge>{it.cost_points} bodů</Badge>
<Button
size="sm"
colorScheme="blue"
onClick={() => redeemMut.mutate(it.id)}
isLoading={redeemMut.isPending}
isDisabled={availablePoints < (it.cost_points || 0)}
>
Uplatnit
</Button>
</HStack>
))}
{items.length === 0 && (
<Text color="gray.500">Zatím nejsou k dispozici žádné odměny.</Text>
)}
</VStack>
)}
</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={onClose}>Zavřít</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default RewardsModal;
@@ -221,6 +221,14 @@ const ContactMap: React.FC<ContactMapProps> = ({
map.scrollWheelZoom.disable();
});
// Invalidate size after initial mount to ensure tiles render fully
// (use a short delay to let layout settle)
try {
setTimeout(() => {
try { map.invalidateSize(); } catch {}
}, 150);
} catch {}
} catch (error) {
console.error('Error initializing map:', error);
setLoadError('Failed to initialize map');
@@ -240,6 +248,37 @@ const ContactMap: React.FC<ContactMapProps> = ({
};
}, [isLoaded]);
// Observe container visibility to invalidate size when it becomes visible (e.g., tabs, accordions)
useEffect(() => {
if (!mapRef.current) return;
const el = mapRef.current;
const observer = new IntersectionObserver((entries) => {
const e = entries[0];
if (e && e.isIntersecting && mapInstanceRef.current) {
try {
// Next frame ensures correct layout measurement
requestAnimationFrame(() => {
try { mapInstanceRef.current!.invalidateSize(); } catch {}
});
} catch {}
}
}, { root: null, threshold: 0.1 });
observer.observe(el);
return () => observer.disconnect();
}, [isLoaded]);
// Observe container resize to keep Leaflet in sync with layout changes
useEffect(() => {
if (!mapRef.current || !('ResizeObserver' in window)) return;
const el = mapRef.current;
const ro = new ResizeObserver(() => {
if (!mapInstanceRef.current) return;
try { mapInstanceRef.current.invalidateSize(); } catch {}
});
ro.observe(el);
return () => ro.disconnect();
}, [isLoaded]);
// Update center/zoom and marker when coords/zoom change
useEffect(() => {
if (!mapInstanceRef.current || !L) return;
+56 -1
View File
@@ -1,9 +1,10 @@
import { Box, SimpleGrid, Heading, Text, useColorModeValue, HStack, Button, Link, Badge } from '@chakra-ui/react';
import { useEffect, useState } from 'react';
import HorizontalScroller from '../ui/HorizontalScroller';
import { getClothing, ClothingItem } from '../../services/clothing';
import { Link as RouterLink } from 'react-router-dom';
const MerchSection: React.FC = () => {
const MerchSection: React.FC<{ variant?: 'grid' | 'carousel' | 'featured' | 'list' }> = ({ variant = 'grid' }) => {
const [items, setItems] = useState<ClothingItem[]>([]);
const [loading, setLoading] = useState(true);
const cardBg = useColorModeValue('white', 'gray.800');
@@ -26,6 +27,59 @@ const MerchSection: React.FC = () => {
if (loading || items.length === 0) return null;
// Carousel variant: horizontally scrollable cards
if (variant === 'carousel') {
return (
<Box>
<HStack justify="space-between" mb={3}>
<Heading as="h3" size="md">Oblečení týmu</Heading>
<Link as={RouterLink} to="/obleceni">
<Button size="sm" variant="outline" colorScheme="blue">Zobrazit vše</Button>
</Link>
</HStack>
<HorizontalScroller draggable>
{items.map((it) => (
<Box key={it.id} minW={{ base: '70%', md: '45%', lg: '28%' }}>
<a
href={it.url || '/obleceni'}
target={it.url ? "_blank" : undefined}
rel={it.url ? "noreferrer noopener" : undefined}
>
<Box
className="card"
bg={cardBg}
borderRadius="xl"
overflow="hidden"
boxShadow="sm"
borderWidth="1px"
transition="all 0.2s"
_hover={{ transform: 'translateY(-4px)', boxShadow: 'md' }}
>
<Box
aria-hidden
height={{ base: 140, md: 180 }}
bgSize="cover"
bgPos="center"
style={{ backgroundImage: `url(${it.image_url})` }}
/>
<Box p={3} borderTopWidth="1px">
<Text noOfLines={1} fontWeight="semibold" fontSize="sm">{it.title}</Text>
{it.price && it.price > 0 && (
<Badge colorScheme="blue" mt={1} fontSize="xs">
{it.price} {it.currency || 'Kč'}
</Badge>
)}
</Box>
</Box>
</a>
</Box>
))}
</HorizontalScroller>
</Box>
);
}
// Grid/list (default) variant
return (
<Box>
<HStack justify="space-between" mb={3}>
@@ -43,6 +97,7 @@ const MerchSection: React.FC = () => {
rel={it.url ? "noreferrer noopener" : undefined}
>
<Box
className="card"
bg={cardBg}
borderRadius="xl"
overflow="hidden"
@@ -2,6 +2,7 @@ import { Box, Heading, HStack, VStack, Image, Text, useColorModeValue } from '@c
import { useQuery } from '@tanstack/react-query';
import { getPlayers, Player } from '../../services/players';
import { assetUrl } from '../../utils/url';
import { getCountryFlag, translateNationality } from '../../utils/nationality';
const TeamScroller: React.FC = () => {
const { data } = useQuery({ queryKey: ['players'], queryFn: getPlayers });
@@ -17,6 +18,12 @@ const TeamScroller: React.FC = () => {
<Image src={assetUrl(p.image_url) || '/logo192.png'} alt={p.first_name + ' ' + p.last_name} w="140px" h="140px" objectFit="cover" borderRadius="lg" fallbackSrc="/dist/img/logo-club-empty.svg" />
<Text fontWeight="bold" textAlign="center">{p.first_name} {p.last_name}</Text>
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>{p.position}</Text>
{p.nationality ? (
<HStack spacing={2}>
<Text as="span" fontSize="lg">{getCountryFlag(p.nationality)}</Text>
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>{translateNationality(p.nationality)}</Text>
</HStack>
) : null}
{p.date_of_birth ? (
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>
Věk: {(() => { const a = calculateAge(p.date_of_birth); return a != null ? `${a} ${czYears(a)}` : '' })()}
+15 -2
View File
@@ -65,6 +65,13 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
const limit = Math.max(1, Math.min(12, settings?.videos_limit ?? 6));
const youtubeUrl = (settings as any)?.youtube_url || (settings as any)?.social_youtube || null;
useEffect(() => {
try {
if (isOpen) onClose();
setSelectedVideo(null);
} catch {}
}, [style, isOpen, onClose]);
useEffect(() => {
let canceled = false;
const run = async () => {
@@ -137,6 +144,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
return (
<Box
className="video-card card"
bg={cardBg}
borderRadius="xl"
overflow="hidden"
@@ -169,6 +177,9 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
alt={it.title}
width="100%"
height="100%"
loading="lazy"
decoding="async"
referrerPolicy="origin-when-cross-origin"
style={{ objectFit: 'cover' }}
/>
) : (
@@ -259,7 +270,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
</Button>
</Link>
</Box>
<HorizontalScroller draggable>
<HorizontalScroller key={`videos-hs-${style}-${items.length}`} draggable>
{items.map((it, idx) => (
<Box
key={it.key}
@@ -285,6 +296,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
title={selectedVideo.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
style={{ borderRadius: '8px' }}
/>
@@ -306,7 +318,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
<Button size="sm" variant="outline" colorScheme="blue">Více videí</Button>
</Link>
</Box>
<SimpleGrid columns={cols} spacing={4}>
<SimpleGrid key={`videos-grid-${style}-${items.length}`} columns={cols} spacing={4}>
{items.map((it, idx) => (
<Card key={it.key} it={it} idx={idx} />
))}
@@ -325,6 +337,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
title={selectedVideo.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
style={{ borderRadius: '8px' }}
/>
+15 -3
View File
@@ -27,6 +27,8 @@ interface Sponsor {
logo_url?: string;
website_url?: string;
is_active?: boolean;
tier?: string;
display_order?: number;
}
const Footer: React.FC = () => {
@@ -114,7 +116,17 @@ const Footer: React.FC = () => {
spacing={6}
w="full"
>
{sponsors.map((sponsor) => (
{[...sponsors]
.sort((a: any, b: any) => {
const at = a?.tier === 'general' ? 0 : 1;
const bt = b?.tier === 'general' ? 0 : 1;
if (at !== bt) return at - bt;
const ao = (a as any)?.display_order ?? 9999;
const bo = (b as any)?.display_order ?? 9999;
if (ao !== bo) return ao - bo;
return String(a?.name || '').localeCompare(String(b?.name || ''));
})
.map((sponsor) => (
<Link
key={sponsor.id}
href={sponsor.website_url || '#'}
@@ -137,7 +149,6 @@ const Footer: React.FC = () => {
maxH="60px"
maxW="full"
objectFit="contain"
filter="brightness(0) invert(1)"
opacity={0.9}
_hover={{ opacity: 1 }}
/>
@@ -246,10 +257,11 @@ const Footer: React.FC = () => {
{/* Left: MyClub Logo & Text */}
<HStack spacing={4} align="center">
<Image
src="https://myclub.sportcreative.eu/logo.svg"
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 50'%3E%3Crect width='200' height='50' fill='%23007bff'/%3E%3Ctext x='10' y='35' font-family='Arial' font-size='24' font-weight='bold' fill='%23fff'%3EMyClub%3C/text%3E%3C/svg%3E"
alt="MyClub"
h={{ base: '32px', md: '40px' }}
w="auto"
loading="lazy"
fallbackSrc="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 50'%3E%3Ctext x='10' y='35' font-family='Arial' font-size='24' font-weight='bold' fill='%23000'%3EMyClub%3C/text%3E%3C/svg%3E"
/>
<VStack align="start" spacing={0}>
@@ -15,7 +15,7 @@ interface MainLayoutProps {
export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideContainer = false, showSponsorsSection = true }) => {
const [showTop, setShowTop] = useState(false);
const { getStyles, getVariant } = useAllPageElementConfigs('homepage');
const { getStyles, getVariant, refreshKey } = useAllPageElementConfigs('homepage');
const headerVariant = getVariant('header', 'unified');
const sponsorsVariant = getVariant('sponsors', 'grid');
const footerVariant = getVariant('footer', 'standard');
@@ -46,7 +46,7 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
{headerIsInside ? (
<>
<Container maxW="container.xl" py={8}>
<Box as="header" data-element="header" data-variant={headerVariant} style={{ ...getStyles('header') }}>
<Box key={`header-${refreshKey}-${headerVariant}`} as="header" data-element="header" data-variant={headerVariant} style={{ ...getStyles('header') }}>
{headerVariant === 'sparta_navbar' ? (
<SpartaNavbar />
) : (
@@ -56,17 +56,17 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
{children}
</Container>
{showSponsorsSection && (
<Box data-element="sponsors" data-variant={sponsorsVariant} style={{ ...getStyles('sponsors') }}>
<Box key={`sponsors-${refreshKey}-${sponsorsVariant}`} data-element="sponsors" data-variant={sponsorsVariant} style={{ ...getStyles('sponsors') }}>
<SponsorsSection />
</Box>
)}
<Box as="footer" data-element="footer" data-variant={footerVariant} style={{ ...getStyles('footer') }}>
<Box key={`footer-${refreshKey}-${footerVariant}`} as="footer" data-element="footer" data-variant={footerVariant} style={{ ...getStyles('footer') }}>
<Footer />
</Box>
</>
) : (
<>
<Box as="header" data-element="header" data-variant={headerVariant} style={{ ...getStyles('header') }}>
<Box key={`header-${refreshKey}-${headerVariant}`} as="header" data-element="header" data-variant={headerVariant} style={{ ...getStyles('header') }}>
{headerVariant === 'sparta_navbar' ? (
<SpartaNavbar />
) : (
@@ -78,11 +78,11 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
</Container>
{/* Global sponsors section across front-facing pages */}
{showSponsorsSection && (
<Box data-element="sponsors" data-variant={sponsorsVariant} style={{ ...getStyles('sponsors') }}>
<Box key={`sponsors-${refreshKey}-${sponsorsVariant}`} data-element="sponsors" data-variant={sponsorsVariant} style={{ ...getStyles('sponsors') }}>
<SponsorsSection />
</Box>
)}
<Box as="footer" data-element="footer" data-variant={footerVariant} style={{ ...getStyles('footer') }}>
<Box key={`footer-${refreshKey}-${footerVariant}`} as="footer" data-element="footer" data-variant={footerVariant} style={{ ...getStyles('footer') }}>
<Footer />
</Box>
</>
@@ -45,6 +45,7 @@ export default function NewsletterSubscribe() {
duration: 7000,
isClosable: true,
});
try { window.dispatchEvent(new CustomEvent('engagement:refresh')); } catch {}
reset();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Nastala chyba při přihlašování k odběru';
@@ -85,6 +86,7 @@ export default function NewsletterSubscribe() {
id="email"
type="email"
placeholder="Váš e-mail"
autoComplete="email"
{...register('email', {
required: 'E-mail je povinný',
pattern: {
@@ -71,7 +71,7 @@ const MatchesSlider: React.FC<{
{looped.map((m, idx) => (
<div
key={`${m.id || idx}-ticker`}
className="match-card"
className="match-card card"
onClick={(e) => { e.preventDefault(); onMatchClick?.(m, current?.name); }}
style={{ cursor: onMatchClick ? 'pointer' as const : 'default' as const }}
>
@@ -114,7 +114,7 @@ const MatchesSlider: React.FC<{
{(current?.matches || []).map((m, idx) => (
<div
key={m.id || idx}
className="match-card"
className="match-card card"
onClick={(e) => { e.preventDefault(); onMatchClick?.(m, current?.name); }}
style={{ cursor: onMatchClick ? 'pointer' as const : 'default' as const }}
>
+35 -9
View File
@@ -105,8 +105,8 @@ const PollCard: React.FC<PollCardProps> = ({
return votePoll(poll.id, {
option_ids: selectedOptions,
session_token: sessionToken,
voter_name: isAuthenticated ? (voterName || (user as any)?.name || undefined) : undefined,
voter_email: isAuthenticated ? (voterEmail || user?.email || undefined) : undefined,
voter_name: voterName || (isAuthenticated ? (user as any)?.name : undefined),
voter_email: voterEmail || (isAuthenticated ? user?.email : undefined),
});
},
onSuccess: async () => {
@@ -149,6 +149,10 @@ const PollCard: React.FC<PollCardProps> = ({
if (onVoteSuccess) {
onVoteSuccess();
}
try {
window.dispatchEvent(new CustomEvent('engagement:refresh'));
} catch {}
},
onError: (error: any) => {
toast({
@@ -540,6 +544,7 @@ const PollCard: React.FC<PollCardProps> = ({
<FormLabel fontSize="sm">Jméno (volitelné)</FormLabel>
<Input
size="sm"
autoComplete="name"
value={voterName || ((user as any)?.name || '')}
onChange={(e) => setVoterName(e.target.value)}
/>
@@ -549,19 +554,40 @@ const PollCard: React.FC<PollCardProps> = ({
<Input
size="sm"
type="email"
autoComplete="email"
value={voterEmail || (user?.email || '')}
onChange={(e) => setVoterEmail(e.target.value)}
/>
</FormControl>
</VStack>
) : (
<Text fontSize="sm" color="gray.500">
Chcete připojit své jméno k hlasu?{' '}
<Link as={RouterLink} color="blue.500" to="/login" state={{ from: location }}>
Přihlaste se
</Link>
.
</Text>
<VStack spacing={3} align="stretch">
<FormControl>
<FormLabel fontSize="sm">Jméno (volitelné)</FormLabel>
<Input
size="sm"
autoComplete="name"
value={voterName}
onChange={(e) => setVoterName(e.target.value)}
/>
</FormControl>
<FormControl>
<FormLabel fontSize="sm">E-mail (volitelné)</FormLabel>
<Input
size="sm"
type="email"
autoComplete="email"
value={voterEmail}
onChange={(e) => setVoterEmail(e.target.value)}
/>
</FormControl>
<Text fontSize="xs" color="gray.500">
Přihlášením se jméno a e-mail doplní automaticky.{' '}
<Link as={RouterLink} color="blue.500" to="/login" state={{ from: location }}>
Přihlásit se
</Link>
</Text>
</VStack>
)}
<Button
@@ -11,6 +11,7 @@ import { cs } from 'date-fns/locale';
import { Match } from '../../types';
import { fetchTeamLogoOverrides } from '@/services/adminMatches';
import { assetUrl, sanitizeClubName } from '@/utils/url';
import { getCompetitionAliasesPublic, CompetitionAlias } from '@/services/competitionAliases';
import { TeamLogo } from '../common/TeamLogo';
import '../../styles/logos.css';
@@ -34,7 +35,11 @@ const formatMatchDate = (dateString: string) => {
}
};
export const MatchesWidget = () => {
export const MatchesWidget: React.FC<{
categoryName?: string;
hideEmpty?: boolean;
onMatchClick?: (match: Match) => void;
}> = ({ categoryName, hideEmpty = false, onMatchClick }) => {
const toast = useToast();
const [email, setEmail] = useState<string>('');
const [prefWeekly, setPrefWeekly] = useState<boolean>(true);
@@ -162,6 +167,53 @@ export const MatchesWidget = () => {
},
});
// Load competition aliases (public) to resolve competition names to unified aliases like "U11"
const aliasesQ = useQuery<{ list: CompetitionAlias[] }>({
queryKey: ['competition-aliases-public'],
queryFn: async () => {
try {
const list = await getCompetitionAliasesPublic();
return { list };
} catch {
return { list: [] as CompetitionAlias[] };
}
},
staleTime: 5 * 60 * 1000,
});
const normalize = (s?: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const resolveAliasName = React.useCallback((compName?: string): string => {
const name = String(compName || '');
const normComp = normalize(name);
const list = aliasesQ.data?.list || [];
for (const a of list) {
const aAlias = normalize(a.alias);
const aOrig = normalize(a.original_name || '');
if (aOrig && (normComp.includes(aOrig) || aOrig.includes(normComp))) return a.alias;
if (aAlias && (normComp.includes(aAlias) || aAlias.includes(normComp))) return a.alias;
}
return name;
}, [aliasesQ.data?.list]);
const filteredMatches = React.useMemo(() => {
if (!Array.isArray(matches)) return [] as Match[];
if (!categoryName) return matches as Match[];
const needle = normalize(categoryName);
return (matches as Match[]).filter((m: any) => {
const comp = String((m as any).competitionName || '');
const resolved = resolveAliasName(comp);
const nComp = normalize(comp);
const nResolved = normalize(resolved);
return nResolved.includes(needle) || nComp.includes(needle);
});
}, [matches, categoryName, resolveAliasName]);
if (isLoading) {
return (
<Widget title="Nadcházející zápasy">
@@ -184,7 +236,8 @@ export const MatchesWidget = () => {
);
}
if (!matches || matches.length === 0) {
if (!filteredMatches || filteredMatches.length === 0) {
if (hideEmpty) return null;
return (
<Widget title="Nadcházející zápasy">
<VStack p={4} spacing={4}>
@@ -200,7 +253,7 @@ export const MatchesWidget = () => {
return (
<Widget title="Nadcházející zápasy">
<VStack spacing={{ base: 2, md: 3 }} align="stretch" divider={<Box borderBottomWidth="1px" borderColor="gray.200" />}>
{matches.map((match) => (
{filteredMatches.map((match) => (
<Box
key={match.id}
p={{ base: 3, md: 4 }}
@@ -209,6 +262,17 @@ export const MatchesWidget = () => {
borderRadius="lg"
transition="background-color 0.2s"
shadow="sm"
cursor={onMatchClick ? 'pointer' : 'default'}
role={onMatchClick ? 'button' as any : undefined}
tabIndex={onMatchClick ? 0 : undefined}
onClick={() => onMatchClick && onMatchClick(match)}
onKeyDown={(e) => {
if (!onMatchClick) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onMatchClick(match);
}
}}
>
<HStack justify="space-between" mb={1} spacing={2} flexWrap="wrap">
<Text
+1 -1
View File
@@ -100,7 +100,7 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
{
page_type: 'homepage',
element_name: 'videos',
variant: 'grid',
variant: 'carousel',
visible: false,
display_order: 7,
settings: {},
+185 -5
View File
@@ -73,6 +73,7 @@ export const useAllPageElementConfigs = (pageType: string) => {
// Helper: inject style properties for each element into a single <style> tag
// This ensures style applying/previewing works even if components do not spread getStyles()
// Uses ultra-high specificity to override any component CSS
const updateInjectedStyleProps = (stylesMap: Record<string, Record<string, any>>) => {
try {
const styleId = 'myuibrix-style-props';
@@ -80,56 +81,190 @@ export const useAllPageElementConfigs = (pageType: string) => {
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = styleId;
// Insert at end of head for maximum specificity
document.head.appendChild(styleEl);
}
const cssBlocks: string[] = [];
const toPx = (v: any): string => (typeof v === 'number' ? `${v}px` : `${v}`);
const addDecl = (decls: string[], prop: string, val: any, unit?: 'px' | '') => {
if (val === undefined || val === null || val === '') return;
const needsPx = unit === 'px';
const v = typeof val === 'number' && needsPx ? `${val}px` : `${val}`;
decls.push(`${prop}: ${v} !important;`);
};
const toKebab = (key: string) => key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
Object.entries(stylesMap || {}).forEach(([name, st]) => {
if (!st || typeof st !== 'object') return;
const decls: string[] = [];
// Track known keys we handle explicitly so we can inject the rest generically
const handled = new Set<string>();
addDecl(decls, 'font-family', st.fontFamily);
handled.add('fontFamily');
addDecl(decls, 'font-size', st.fontSize, 'px');
handled.add('fontSize');
addDecl(decls, 'font-weight', st.fontWeight);
handled.add('fontWeight');
addDecl(decls, 'line-height', st.lineHeight);
handled.add('lineHeight');
addDecl(decls, 'letter-spacing', st.letterSpacing, 'px');
handled.add('letterSpacing');
addDecl(decls, 'text-transform', st.textTransform);
handled.add('textTransform');
addDecl(decls, 'color', st.color);
handled.add('color');
addDecl(decls, 'background-color', st.backgroundColor);
handled.add('backgroundColor');
addDecl(decls, 'padding-top', st.paddingTop, 'px');
handled.add('paddingTop');
addDecl(decls, 'padding-right', st.paddingRight, 'px');
handled.add('paddingRight');
addDecl(decls, 'padding-bottom', st.paddingBottom, 'px');
handled.add('paddingBottom');
addDecl(decls, 'padding-left', st.paddingLeft, 'px');
handled.add('paddingLeft');
addDecl(decls, 'margin-top', st.marginTop, 'px');
handled.add('marginTop');
addDecl(decls, 'margin-right', st.marginRight, 'px');
handled.add('marginRight');
addDecl(decls, 'margin-bottom', st.marginBottom, 'px');
handled.add('marginBottom');
addDecl(decls, 'margin-left', st.marginLeft, 'px');
handled.add('marginLeft');
// width/height may be numbers (px) or strings (%, auto, etc.)
if (st.width !== undefined) addDecl(decls, 'width', st.width, typeof st.width === 'number' ? 'px' : '');
handled.add('width');
if (st.height !== undefined) addDecl(decls, 'height', st.height, typeof st.height === 'number' ? 'px' : '');
handled.add('height');
addDecl(decls, 'display', st.display);
handled.add('display');
addDecl(decls, 'grid-template-columns', st.gridTemplateColumns);
handled.add('gridTemplateColumns');
addDecl(decls, 'grid-template-rows', st.gridTemplateRows);
handled.add('gridTemplateRows');
addDecl(decls, 'grid-auto-flow', st.gridAutoFlow);
handled.add('gridAutoFlow');
addDecl(decls, 'grid-column-gap', st.gridColumnGap, 'px');
handled.add('gridColumnGap');
addDecl(decls, 'grid-row-gap', st.gridRowGap, 'px');
handled.add('gridRowGap');
addDecl(decls, 'align-items', st.alignItems);
handled.add('alignItems');
addDecl(decls, 'justify-items', st.justifyItems);
handled.add('justifyItems');
// Additional commonly used properties
addDecl(decls, 'gap', st.gap, 'px');
handled.add('gap');
// Borders
addDecl(decls, 'border', st.border);
handled.add('border');
if (st.borderTop !== undefined) addDecl(decls, 'border-top', st.borderTop);
handled.add('borderTop');
if (st.borderRight !== undefined) addDecl(decls, 'border-right', st.borderRight);
handled.add('borderRight');
if (st.borderBottom !== undefined) addDecl(decls, 'border-bottom', st.borderBottom);
handled.add('borderBottom');
if (st.borderLeft !== undefined) addDecl(decls, 'border-left', st.borderLeft);
handled.add('borderLeft');
if (st.borderRadius !== undefined) addDecl(decls, 'border-radius', st.borderRadius, typeof st.borderRadius === 'number' ? 'px' : '');
handled.add('borderRadius');
if (st.borderTopLeftRadius !== undefined) addDecl(decls, 'border-top-left-radius', st.borderTopLeftRadius, typeof st.borderTopLeftRadius === 'number' ? 'px' : '');
handled.add('borderTopLeftRadius');
if (st.borderTopRightRadius !== undefined) addDecl(decls, 'border-top-right-radius', st.borderTopRightRadius, typeof st.borderTopRightRadius === 'number' ? 'px' : '');
handled.add('borderTopRightRadius');
if (st.borderBottomLeftRadius !== undefined) addDecl(decls, 'border-bottom-left-radius', st.borderBottomLeftRadius, typeof st.borderBottomLeftRadius === 'number' ? 'px' : '');
handled.add('borderBottomLeftRadius');
if (st.borderBottomRightRadius !== undefined) addDecl(decls, 'border-bottom-right-radius', st.borderBottomRightRadius, typeof st.borderBottomRightRadius === 'number' ? 'px' : '');
handled.add('borderBottomRightRadius');
// Effects
addDecl(decls, 'box-shadow', st.boxShadow);
handled.add('boxShadow');
addDecl(decls, 'opacity', st.opacity);
handled.add('opacity');
// Positioning
addDecl(decls, 'position', st.position);
handled.add('position');
if (st.top !== undefined) addDecl(decls, 'top', st.top, typeof st.top === 'number' ? 'px' : '');
handled.add('top');
if (st.right !== undefined) addDecl(decls, 'right', st.right, typeof st.right === 'number' ? 'px' : '');
handled.add('right');
if (st.bottom !== undefined) addDecl(decls, 'bottom', st.bottom, typeof st.bottom === 'number' ? 'px' : '');
handled.add('bottom');
if (st.left !== undefined) addDecl(decls, 'left', st.left, typeof st.left === 'number' ? 'px' : '');
handled.add('left');
addDecl(decls, 'z-index', st.zIndex);
handled.add('zIndex');
// Overflow
addDecl(decls, 'overflow', st.overflow);
handled.add('overflow');
addDecl(decls, 'overflow-x', st.overflowX);
handled.add('overflowX');
addDecl(decls, 'overflow-y', st.overflowY);
handled.add('overflowY');
// Alignment (flex/grid)
addDecl(decls, 'justify-content', st.justifyContent);
handled.add('justifyContent');
addDecl(decls, 'align-content', st.alignContent);
handled.add('alignContent');
addDecl(decls, 'justify-self', st.justifySelf);
handled.add('justifySelf');
addDecl(decls, 'align-self', st.alignSelf);
handled.add('alignSelf');
addDecl(decls, 'place-items', st.placeItems);
handled.add('placeItems');
addDecl(decls, 'place-content', st.placeContent);
handled.add('placeContent');
addDecl(decls, 'place-self', st.placeSelf);
handled.add('placeSelf');
// Sizing constraints
if (st.maxWidth !== undefined) addDecl(decls, 'max-width', st.maxWidth, typeof st.maxWidth === 'number' ? 'px' : '');
handled.add('maxWidth');
if (st.minWidth !== undefined) addDecl(decls, 'min-width', st.minWidth, typeof st.minWidth === 'number' ? 'px' : '');
handled.add('minWidth');
if (st.maxHeight !== undefined) addDecl(decls, 'max-height', st.maxHeight, typeof st.maxHeight === 'number' ? 'px' : '');
handled.add('maxHeight');
if (st.minHeight !== undefined) addDecl(decls, 'min-height', st.minHeight, typeof st.minHeight === 'number' ? 'px' : '');
handled.add('minHeight');
// Text alignment
addDecl(decls, 'text-align', st.textAlign);
handled.add('textAlign');
// Generic fallback: inject any additional properties provided in styles
Object.keys(st).forEach((k) => {
if (k === 'customCSS') return;
if (!handled.has(k)) {
const val = (st as any)[k];
if (val !== undefined && val !== null && val !== '') {
addDecl(decls, toKebab(k), val);
}
}
});
if (decls.length > 0) {
cssBlocks.push(`[data-element="${name}"] { ${decls.join(' ')} }`);
// Ultra-high specificity selector: body > .container [data-element="name"]
// This overrides most component CSS without needing to modify every component
cssBlocks.push(`
body [data-element="${name}"],
.container [data-element="${name}"],
[data-element="${name}"].chakra-box,
[data-element="${name}"].section,
[data-element="${name}"] {
${decls.join('\n ')}
}`);
}
});
styleEl.textContent = cssBlocks.join('\n');
} catch {}
styleEl.textContent = `/* MyUIbrix Dynamic Styles - Auto-generated */\n${cssBlocks.join('\n\n')}`;
// Force browser to recalculate styles
if (typeof document !== 'undefined') {
// Trigger a reflow to ensure styles are applied immediately
document.body.offsetHeight; // Read operation forces reflow
}
} catch (e) {
console.error('[MyUIbrix] Style injection failed:', e);
}
};
const loadConfigs = async () => {
@@ -282,6 +417,8 @@ export const useAllPageElementConfigs = (pageType: string) => {
const { elementName, styles: newStyles, previewMode } = event.detail;
if (previewMode) {
console.log(`[MyUIbrix] Style change received for: ${elementName}`, newStyles);
// Only update state - let React apply the styles through component rendering
// This prevents conflicts with React's virtual DOM
setStyles(prev => {
@@ -289,22 +426,65 @@ export const useAllPageElementConfigs = (pageType: string) => {
...prev,
[elementName]: newStyles
};
// Also update injected CSS for global preview application
updateInjectedStyleProps(next);
// This ensures immediate visual feedback even before React re-renders
requestAnimationFrame(() => {
updateInjectedStyleProps(next);
});
// Live-inject custom CSS if provided, with enhanced specificity
try {
const css = String((newStyles && (newStyles as any).customCSS) || '').trim();
const styleId = `custom-css-${elementName}`;
const existing = document.getElementById(styleId);
if (existing) existing.remove();
if (css) {
const style = document.createElement('style');
style.id = styleId;
const hasBlocks = /\{[^}]*\}|@media|@keyframes/.test(css);
if (hasBlocks) {
// User wrote full CSS rules - use as-is
style.textContent = `/* Custom CSS for ${elementName} */\n${css}`;
} else {
// Simple declarations - wrap with high-specificity selector
const importantDecls = css
.split(';')
.map(s => s.trim())
.filter(Boolean)
.map(s => (/!important\s*$/.test(s) ? s : `${s} !important`))
.join(';\n ');
style.textContent = `/* Custom CSS for ${elementName} */\nbody [data-element="${elementName}"],\n.container [data-element="${elementName}"] {\n ${importantDecls};\n}`;
}
document.head.appendChild(style);
}
} catch (e) {
console.error(`[MyUIbrix] Custom CSS injection failed for ${elementName}:`, e);
}
return next;
});
// Force React re-render of affected component by incrementing refresh key
setRefreshKey(prev => prev + 1);
}
}) as EventListener;
window.addEventListener('myuibrix-change', handleMyUIbrixChange);
window.addEventListener('myuibrix-reorder', handleMyUIbrixReorder);
window.addEventListener('myuibrix-style-change', handleMyUIbrixStyleChange);
// Support force refresh event from editorController
const handleForceRefresh = ((event: CustomEvent) => {
updateInjectedStyleProps(styles);
}) as EventListener;
window.addEventListener('myuibrix-force-refresh', handleForceRefresh);
return () => {
active = false;
window.removeEventListener('myuibrix-change', handleMyUIbrixChange);
window.removeEventListener('myuibrix-reorder', handleMyUIbrixReorder);
window.removeEventListener('myuibrix-style-change', handleMyUIbrixStyleChange);
window.removeEventListener('myuibrix-force-refresh', handleForceRefresh);
try {
const s = document.getElementById('myuibrix-style-props');
if (s) s.remove();
+13 -7
View File
@@ -10,10 +10,13 @@ import 'react-quill/dist/quill.snow.css';
import 'react-image-crop/dist/ReactCrop.css';
// Custom editor styles AFTER quill base styles to ensure proper override
import './styles/custom-editor.css';
import App, { theme } from './App';
import { theme } from './App';
import AppLazy from './App.lazy';
import { ColorModeScript } from '@chakra-ui/react';
import reportWebVitals from './reportWebVitals';
import { HelmetProvider } from 'react-helmet-async';
import reportWebVitals from './reportWebVitals';
import * as serviceWorkerRegistration from './serviceWorkerRegistration';
import { promptUserToUpdate } from './serviceWorkerRegistration';
// Cookie consent utilities
type Consent = { analytics?: boolean };
const getConsent = (): Consent | null => {
@@ -117,13 +120,13 @@ if (!rootElement) {
root.render(
<React.StrictMode>
<ErrorBoundary>
<HelmetProvider>
<HelmetProvider>
<ErrorBoundary>
{/* Ensure color mode (light/dark) persists and matches Chakra config before UI renders */}
<ColorModeScript initialColorMode={theme.config.initialColorMode} />
<App />
</HelmetProvider>
</ErrorBoundary>
<AppLazy />
</ErrorBoundary>
</HelmetProvider>
</React.StrictMode>
);
// App rendered
@@ -153,3 +156,6 @@ if (!rootElement) {
// Report web vitals (disabled logging by default). Hook up your analytics here if needed.
reportWebVitals();
// Enable PWA service worker with user prompt on updates
serviceWorkerRegistration.register({ onUpdate: promptUserToUpdate });
-3
View File
@@ -339,9 +339,6 @@ const AboutPage: React.FC = () => {
{/* Newsletter CTA */}
<NewsletterCTA />
{/* Sponsors Section */}
<SponsorsSection />
</MainLayout>
);
};
+25 -3
View File
@@ -12,6 +12,7 @@ import EventLocationMap from '../components/events/EventLocationMap';
import EmbeddedPoll from '../components/polls/EmbeddedPoll';
import FilePreview from '../components/common/FilePreview';
import InstagramGeneratorButton from '../components/admin/InstagramGeneratorButton';
import CommentsSection from '../components/comments/CommentsSection';
const ActivityDetailPage: React.FC = () => {
const { id } = useParams();
@@ -122,6 +123,7 @@ const ActivityDetailPage: React.FC = () => {
targetUrl={typeof window !== 'undefined' ? window.location.href : undefined}
placement="fixed"
size="md"
align="left"
/>
<Container maxW="3xl">
{loading && (
@@ -179,10 +181,24 @@ const ActivityDetailPage: React.FC = () => {
' p': { lineHeight: 1.8, mb: 3 },
' ul, ol': { pl: 6, mb: 3 },
' a': { color: linkColor, textDecoration: 'underline', _hover: { color: linkHoverColor } },
' img': { maxWidth: '100%', borderRadius: 'md' },
' img': {
display: 'block',
maxWidth: '100%',
height: 'auto',
mt: 2,
border: 'none !important',
outline: 'none !important',
boxShadow: 'none !important',
cursor: 'default',
borderRadius: 'md',
},
}}
ref={contentRef}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(toAbsoluteUploads(String(data.description))) }}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(toAbsoluteUploads(String(data.description)), {
USE_PROFILES: { html: true },
ADD_TAGS: ['iframe'],
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id'],
}) }}
/>
)}
@@ -194,7 +210,9 @@ const ActivityDetailPage: React.FC = () => {
src={getYouTubeEmbedUrl(data.youtube_url) || ''}
title={data.title}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
</Box>
@@ -214,6 +232,10 @@ const ActivityDetailPage: React.FC = () => {
<EmbeddedPoll eventId={data.id} maxPolls={2} />
)}
{data?.id && (
<CommentsSection targetType="event" targetId={String(data.id)} />
)}
{(Array.isArray(data.attachments) && data.attachments.length > 0) && (
<VStack align="stretch" spacing={3}>
<Heading as="h3" size="sm">Přílohy</Heading>
+8
View File
@@ -22,6 +22,7 @@ import { ChevronRight, ExternalLink, Calendar, Image as ImageIcon } from 'lucide
import MainLayout from '../components/layout/MainLayout';
import { API_URL } from '../services/api';
import PhotoModal from '../components/gallery/PhotoModal';
import CommentsSection from '../components/comments/CommentsSection';
interface Photo {
id: string;
@@ -287,6 +288,13 @@ const AlbumDetailPage: React.FC = () => {
</Text>
</Box>
)}
{/* Comments */}
{album.id && (
<Box mt={6}>
<CommentsSection targetType="gallery_album" targetId={String(album.id)} />
</Box>
)}
</Container>
</Box>
+158 -90
View File
@@ -6,14 +6,14 @@ import MainLayout from '../components/layout/MainLayout';
import DOMPurify from 'dompurify';
import { Helmet } from 'react-helmet-async';
import NewsletterCTA from '../components/common/NewsletterCTA';
import SponsorsSection from '../components/common/SponsorsSection';
import EmbeddedPoll from '../components/polls/EmbeddedPoll';
import { ExternalLink, ArrowRight, Eye, Clock } from 'lucide-react';
import { ArrowRight, Eye, Clock, SearchX } from 'lucide-react';
import React from 'react';
import { trackEvent as umamiTrackEvent, trackMatchView as umamiTrackMatchView, trackVideoPlay as umamiTrackVideoPlay, trackArticleView as umamiTrackArticleView } from '../utils/umami';
import { assetUrl } from '../utils/url';
import { API_URL } from '../services/api';
import TeamLogo from '../components/common/TeamLogo';
import MatchModal from '../components/home/MatchModal';
import { extractPalette } from '../utils/colors';
import { getTeamLogo } from '../utils/sportLogosAPI';
import FilePreview from '../components/common/FilePreview';
@@ -23,6 +23,7 @@ import { MatchSnapshot } from '../services/instagram';
import { Widget } from '../components/widgets/Widget';
import { MatchesWidget } from '../components/widgets/MatchesWidget';
import { getUpcomingEvents } from '../services/eventService';
import CommentsSection from '../components/comments/CommentsSection';
const toText = (html?: string) => {
if (!html) return '';
@@ -39,8 +40,7 @@ const ArticleDetailPage: React.FC = () => {
enabled: Boolean(slug || id),
});
// UI colors and public settings
const { data: publicSettings } = usePublicSettings();
const cardBg = useColorModeValue('white','gray.900');
@@ -54,6 +54,8 @@ const ArticleDetailPage: React.FC = () => {
// Derive opponent color (for right edge fade) from team logo
const [opponentColor, setOpponentColor] = React.useState<string | null>(null);
const [isMatchModalOpen, setIsMatchModalOpen] = React.useState(false);
const [selectedMatch, setSelectedMatch] = React.useState<any>(null);
// Placeholders; moved tracking effects below to avoid using variables before declaration
@@ -272,7 +274,7 @@ const ArticleDetailPage: React.FC = () => {
return DOMPurify.sanitize(transformed || '', {
USE_PROFILES: { html: true },
ADD_TAGS: ['iframe'],
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen'],
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id'],
});
}, [(data as any)?.content, toAbsoluteUploads]);
@@ -294,8 +296,74 @@ const ArticleDetailPage: React.FC = () => {
staleTime: 60_000,
});
const latestArticlesQuery = useQuery({
queryKey: ['latest-articles'],
queryFn: () => getArticles({ page: 1, page_size: 6, published: true }),
enabled: Boolean((isError || !data) && (slug || id)),
staleTime: 60_000,
});
if (isLoading) return <Spinner />;
if (isError || !data) return <Text color="red.500">Článek nenalezen</Text>;
if (isError || !data) return (
<MainLayout>
<Helmet>
<title>Článek nenalezen</title>
<meta name="robots" content="noindex" />
</Helmet>
<Container maxW="4xl" py={{ base: 12, md: 16 }}>
<Stack spacing={6} align="center" textAlign="center">
<Box color="blue.500">
<SearchX size={64} />
</Box>
<Heading size="xl">Článek nenalezen</Heading>
<Text color={textMuted} maxW="2xl">
Je nám líto, ale hledaný článek neexistuje, byl smazán nebo byl přesunut.
</Text>
<HStack spacing={3}>
<Button as={RouterLink} to="/news" colorScheme="blue">Zpět na novinky</Button>
<Button as={RouterLink} to="/" variant="ghost">Domů</Button>
</HStack>
</Stack>
{Array.isArray((latestArticlesQuery.data as any)?.data) && ((latestArticlesQuery.data as any)?.data?.length || 0) > 0 && (
<Box mt={12}>
<Heading as="h2" size="md" mb={4}>Nejnovější články</Heading>
<SimpleGrid columns={{ base: 1, sm: 2, md: 3 }} spacing={4}>
{((latestArticlesQuery.data as any).data || []).slice(0, 6).map((a: any) => {
const link = a.slug ? `/news/${a.slug}` : `/articles/${a.id}`;
return (
<Box
key={a.id}
as={RouterLink}
to={link}
borderWidth="1px"
borderRadius="lg"
p={3}
bg={cardBg}
_hover={{ textDecoration: 'none', boxShadow: 'md' }}
>
<Image
src={assetUrl(a.image_url) || '/stadium-placeholder.jpg'}
alt={a.title}
w="100%"
h="140px"
objectFit="cover"
borderRadius="md"
mb={2}
/>
<Text fontWeight="600" noOfLines={2}>{a.title}</Text>
{a.published_at && (
<Text fontSize="sm" color={textMuted}>{new Date(a.published_at).toLocaleDateString('cs-CZ')}</Text>
)}
</Box>
);
})}
</SimpleGrid>
</Box>
)}
</Container>
</MainLayout>
);
const title = (data as any).seo_title || data.title;
const description = (data as any).seo_description || toText(data.content).slice(0, 160);
@@ -317,6 +385,7 @@ const ArticleDetailPage: React.FC = () => {
targetUrl={typeof window !== 'undefined' ? window.location.href : undefined}
placement="fixed"
size="md"
align="left"
/>
<Helmet>
<title>{title}</title>
@@ -407,35 +476,14 @@ const ArticleDetailPage: React.FC = () => {
<Stack spacing={6}>
{/* Featured Image - smaller with subtle overlay */}
{data.image_url && (
<Box position="relative" borderRadius="xl" overflow="hidden">
<Image src={assetUrl(data.image_url) || data.image_url} alt={data.title} w="100%" h={{ base: '220px', md: '360px' }} objectFit="cover" />
<Box position="absolute" inset={0} bg="brand.primary" opacity={0.08} pointerEvents="none" />
<Box position="absolute" inset={0} bgGradient="linear(to-b, rgba(0,0,0,0.12), rgba(0,0,0,0.02))" pointerEvents="none" />
</Box>
)}
{/* YouTube Video Section - smaller and rounded */}
{(data as any)?.youtube_video_id && (
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={videoBg}>
<Heading as="h3" size="md" mb={2}>🎬 Video k článku</Heading>
<Box maxW="3xl" mx="auto" borderRadius="lg" overflow="hidden">
<AspectRatio ratio={16 / 9}>
<Box
as="iframe"
src={`https://www.youtube-nocookie.com/embed/${(data as any).youtube_video_id}`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
title={(data as any).youtube_video_title || 'YouTube video'}
onLoad={() => umamiTrackEvent('Video Widget Shown', { id: (data as any).youtube_video_id, title: (data as any).youtube_video_title })}
onClick={() => umamiTrackVideoPlay((data as any).youtube_video_id, (data as any).youtube_video_title)}
/>
</AspectRatio>
</Box>
{(data as any).youtube_video_title ? (
<Text mt={2} color={videoTitleColor}>{(data as any).youtube_video_title}</Text>
) : null}
<Box borderRadius="xl" overflow="hidden">
<Image src={assetUrl(data.image_url) || data.image_url} alt={data.title} w="100%" h="auto" objectFit="contain" />
</Box>
)}
{(data as any)?.id ? (
<CommentsSection targetType="article" targetId={String((data as any).id)} />
) : null}
{/* Match Section - Card with logos, score/countdown, venue/date */}
{(matchLinkQuery.data as any)?.external_match_id && (
<Box position="relative" borderWidth="1px" borderRadius="lg" p={{ base: 4, md: 5 }} bg={cardBg} overflow="hidden">
@@ -476,12 +524,12 @@ const ArticleDetailPage: React.FC = () => {
const mins = Math.max(0, Math.floor((ms % (1000*60*60))/(1000*60)));
return (<Text fontSize="lg" fontWeight="700">Za {days} d {hours} h {mins} min</Text>);
})()}
{(facrMatchQuery.data as any).venue && <Text fontSize="sm" color={textMuted}>{String((facrMatchQuery.data as any).venue)}</Text>}
{(() => {
const dRaw = String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '');
const d = new Date(dRaw);
return <Text fontSize="sm" color={textMuted}>{d.toLocaleDateString('cs-CZ')} {d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}</Text>;
})()}
{(facrMatchQuery.data as any).venue && <Text fontSize="sm" color={textMuted}>{String((facrMatchQuery.data as any).venue)}</Text>}
</VStack>
<VStack flex={1} spacing={2} minW="0">
<TeamLogo size="custom" style={{ width: 64, height: 64 }} teamId={String((facrMatchQuery.data as any).away_team_id || (facrMatchQuery.data as any).away_id || '')} teamName={String((facrMatchQuery.data as any).away || (facrMatchQuery.data as any).away_team || '')} />
@@ -507,10 +555,45 @@ const ArticleDetailPage: React.FC = () => {
borderRadius="lg"
p={{ base: 4, md: 6 }}
ref={contentRef}
sx={{ 'ul, ol': { pl: 6, listStylePosition: 'outside' }, 'ul': { listStyleType: 'disc' }, 'ol': { listStyleType: 'decimal' }, 'li': { mb: 2 } }}
sx={{
'ul, ol': { pl: 6, listStylePosition: 'outside' },
'ul': { listStyleType: 'disc' },
'ol': { listStyleType: 'decimal' },
'li': { mb: 2 },
'img': {
display: 'block',
maxWidth: '100%',
height: 'auto',
mt: 2,
border: 'none !important',
outline: 'none !important',
boxShadow: 'none !important',
cursor: 'default !important',
},
}}
dangerouslySetInnerHTML={{ __html: safeContentHTML }}
/>
{/* YouTube Video Section - simplified */}
{(data as any)?.youtube_video_id && (
<Box>
<AspectRatio ratio={16 / 9}>
<Box
as="iframe"
src={`https://www.youtube-nocookie.com/embed/${(data as any).youtube_video_id}`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
title={(data as any).youtube_video_title || 'YouTube video'}
onLoad={() => umamiTrackEvent('Video Widget Shown', { id: (data as any).youtube_video_id, title: (data as any).youtube_video_title })}
onClick={() => umamiTrackVideoPlay((data as any).youtube_video_id, (data as any).youtube_video_title)}
/>
</AspectRatio>
{(data as any).youtube_video_title ? (
<Text mt={2} color={videoTitleColor}>{(data as any).youtube_video_title}</Text>
) : null}
</Box>
)}
{/* Gallery Section - Mosaic of 5 images with grayscale + hover color */}
{((data as any)?.gallery_album_id || (data as any)?.gallery_album_url) && (
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={galleryBg} borderColor={galleryBorder}>
@@ -529,70 +612,46 @@ const ArticleDetailPage: React.FC = () => {
Zobrazit galerii
</Button>
</HStack>
{/* Custom 5-image mosaic */}
{Array.isArray(galleryAlbumQuery.data?.photos) && (galleryAlbumQuery.data?.photos?.length || 0) > 0 && (() => {
const photos = (galleryAlbumQuery.data?.photos ?? []).slice(0, 5);
if (photos.length < 5) {
return (
<SimpleGrid columns={{ base: 2, sm: 3 }} spacing={2}>
<SimpleGrid columns={{ base: 2, sm: 3 }} spacing={2} role="group">
{photos.map((p: any) => (
<Image key={p.id} src={p.image_1500} alt={String(p.id)} w="100%" h="140px" objectFit="cover" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} />
<Image key={p.id} src={p.image_1500} alt={String(p.id)} w="100%" h="140px" objectFit="cover" filter="grayscale(100%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(0)' }} />
))}
</SimpleGrid>
);
}
return (
<Box position="relative" sx={{
<Box position="relative" role="group" sx={{
display: 'grid',
gridTemplateColumns: '1fr 1.2fr 1fr',
gridTemplateRows: 'repeat(2, 140px)',
gap: '8px'
}}>
<Image src={photos[0].image_1500} alt={String(photos[0].id)} sx={{ gridColumn: 1, gridRow: 1 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
<Image src={photos[1].image_1500} alt={String(photos[1].id)} sx={{ gridColumn: 1, gridRow: 2 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
<Image src={photos[2].image_1500} alt={String(photos[2].id)} sx={{ gridColumn: 2, gridRow: '1 / span 2' }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
<Image src={photos[3].image_1500} alt={String(photos[3].id)} sx={{ gridColumn: 3, gridRow: 1 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
<Image src={photos[4].image_1500} alt={String(photos[4].id)} sx={{ gridColumn: 3, gridRow: 2 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
<Image src={photos[0].image_1500} alt={String(photos[0].id)} sx={{ gridColumn: 1, gridRow: 1 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(0)' }} borderRadius="md" />
<Image src={photos[1].image_1500} alt={String(photos[1].id)} sx={{ gridColumn: 1, gridRow: 2 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(0)' }} borderRadius="md" />
<Image src={photos[2].image_1500} alt={String(photos[2].id)} sx={{ gridColumn: 2, gridRow: '1 / span 2' }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(0)' }} borderRadius="md" />
<Image src={photos[3].image_1500} alt={String(photos[3].id)} sx={{ gridColumn: 3, gridRow: 1 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(0)' }} borderRadius="md" />
<Image src={photos[4].image_1500} alt={String(photos[4].id)} sx={{ gridColumn: 3, gridRow: 2 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(0)' }} borderRadius="md" />
<Button as={RouterLink} to={(data as any).gallery_album_id ? `/galerie/album/${(data as any).gallery_album_id}` : '#'} size="sm" colorScheme="blue" position="absolute" top="50%" left="50%" transform="translate(-50%, -50%)" onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })}>Zobrazit galerii</Button>
</Box>
);
})()}
{/* Zonerama Attribution */}
<HStack mt={3} spacing={1} fontSize="xs" color="blue.700">
<Text>📸 Fotografie z</Text>
<Link
href={(data as any).gallery_album_url || `https://zonerama.com`}
isExternal
fontWeight="600"
color="blue.600"
display="inline-flex"
alignItems="center"
gap={1}
>
Zonerama
<ExternalLink size={12} />
</Link>
</HStack>
</Box>
</Box>
)}
{/* Embedded Poll - directly under content/gallery */}
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
</Stack>
</Box>
<VStack align="stretch" spacing={6} gridColumn={{ base: '1 / -1', lg: 'span 4' }}>
<Widget title="Podobné články">
{relatedArticlesQuery.isLoading ? (
<Text color={textMuted}>Načítám</Text>
) : (() => {
const list = ((relatedArticlesQuery.data as any)?.data || [])
.filter((a: any) => a?.id !== (data as any)?.id)
.slice(0, 4);
if (!list.length) return <Text color={textMuted}>Žádné související články</Text>;
return (
{relatedArticlesQuery.isLoading ? null : (() => {
const list = ((relatedArticlesQuery.data as any)?.data || [])
.filter((a: any) => a?.id !== (data as any)?.id)
.slice(0, 4);
if (!list.length) return null;
return (
<Widget title="Podobné články">
<VStack spacing={3} align="stretch">
{list.map((a: any) => {
const link = a.slug ? `/news/${a.slug}` : `/articles/${a.id}`;
@@ -609,19 +668,28 @@ const ArticleDetailPage: React.FC = () => {
);
})}
</VStack>
);
})()}
</Widget>
</Widget>
);
})()}
<MatchesWidget />
<MatchesWidget
categoryName={(data as any)?.category?.name}
hideEmpty
onMatchClick={(m: any) => {
setSelectedMatch({ ...m, competition: (m as any).competitionName, competitionName: (m as any).competitionName });
setIsMatchModalOpen(true);
}}
/>
<Widget title="Nejbližší aktivity">
{upcomingEventsQuery.isLoading ? (
<Text color={textMuted}>Načítám</Text>
) : (() => {
const items = Array.isArray(upcomingEventsQuery.data) ? (upcomingEventsQuery.data as any[]).slice(0, 3) : [];
if (!items.length) return <Text color={textMuted}>Žádné plánované aktivity</Text>;
return (
{(() => {
const all = Array.isArray(upcomingEventsQuery.data) ? (upcomingEventsQuery.data as any[]) : [];
const cat = (data as any)?.category?.name;
const items = cat
? all.filter((ev: any) => !ev?.category_name || String(ev.category_name) === String(cat)).slice(0, 3)
: all.slice(0, 3);
if (!items.length) return null;
return (
<Widget title="Nejbližší aktivity">
<VStack spacing={3} align="stretch">
{items.map((ev: any) => (
<HStack key={ev.id} as={RouterLink} to={`/aktivita/${ev.id}`} _hover={{ textDecoration: 'none' }} align="flex-start" spacing={3}>
@@ -634,9 +702,9 @@ const ArticleDetailPage: React.FC = () => {
</HStack>
))}
</VStack>
);
})()}
</Widget>
</Widget>
);
})()}
</VStack>
</SimpleGrid>
</Container>
@@ -658,11 +726,11 @@ const ArticleDetailPage: React.FC = () => {
</Box>
</Container>
)}
{/* Polls (Ankety) above CTA */}
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
{/* Newsletter CTA */}
<NewsletterCTA />
{/* Sponsors Section */}
<SponsorsSection />
<MatchModal isOpen={isMatchModalOpen} onClose={() => setIsMatchModalOpen(false)} match={selectedMatch} />
</MainLayout>
);
};
+99 -17
View File
@@ -1,15 +1,16 @@
import React from 'react';
import { Box, Container, Heading, VStack, Image, Text, Skeleton, LinkBox, HStack, Select, Badge, useColorModeValue, Input, InputGroup, InputLeftElement, InputRightElement, IconButton, Grid, GridItem } from '@chakra-ui/react';
import { 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 { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { getArticles, Article, Paginated, getFeaturedArticles } from '../services/articles';
import { getBanners, Banner as UIBanner } from '../services/banners';
import { Link as RouterLink, useSearchParams } from 'react-router-dom';
import { assetUrl } from '../utils/url';
import MainLayout from '../components/layout/MainLayout';
import { getCategories, CategoryItem } from '../services/categories';
import SponsorsSection from '../components/common/SponsorsSection';
import NewsletterCTA from '../components/common/NewsletterCTA';
import { Eye, Clock, Search, X } from 'lucide-react';
import InstagramGeneratorButton from '../components/admin/InstagramGeneratorButton';
import { Helmet } from 'react-helmet-async';
const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({ article, variant }) => {
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
@@ -36,7 +37,17 @@ const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({
position="relative"
>
<Box position="relative">
<Image src={assetUrl(article.image_url) || '/stadium-placeholder.jpg'} alt={article.title} w="100%" h={imageH} objectFit="cover" />
<Image
src={assetUrl(article.image_url) || '/stadium-placeholder.jpg'}
alt={article.title}
w="100%"
h={imageH}
objectFit="cover"
loading={variant === 'large' ? 'eager' : 'lazy'}
decoding="async"
sizes={variant === 'large' ? '(min-width: 768px) 60vw, 100vw' : '100vw'}
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
@@ -204,6 +215,17 @@ const BlogPage: React.FC = () => {
const featuredIdSet = React.useMemo(() => new Set((featuredList || []).map((a) => a.id)), [featuredList]);
const visibleArticles = featuredList.length ? articles.filter((a) => !featuredIdSet.has(a.id)) : articles;
// Fetch inline article banners (active, placement=article_inline)
const articleBannersQ = useQuery<UIBanner[]>(
['banners', { placement: 'article_inline' }],
() => getBanners({ active: true, placement: 'article_inline' }),
{ staleTime: 60 * 1000 }
);
const articleBanners = (articleBannersQ.data || []) as UIBanner[];
// Decide insertion index depending on layout (1 column vs multi-column)
const [isSmUp] = useMediaQuery('(min-width: 30em)'); // Chakra sm breakpoint (~480px)
const insertionIndex = isSmUp ? 5 : 2;
// Infinite scroll via intersection observer
const sentinelRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
@@ -219,8 +241,42 @@ const BlogPage: React.FC = () => {
return () => io.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
// Derive page SEO title/description
const parts: string[] = ['Blog'];
if (categoryId) {
const cat = categories.find((c) => c.id === Number(categoryId));
if (cat?.name) parts.push(cat.name);
}
if (month) parts.push(month);
if (matchId) parts.push('Zápas');
if (qParam) parts.push(`Hledání: ${qParam}`);
const pageTitle = parts.join(' · ');
const pageDesc = qParam
? `Výsledky hledání článků pro „${qParam}“.`
: categoryId
? `Články v kategorii ${(categories.find((c) => c.id === Number(categoryId))?.name) || ''}.`
: 'Nejnovější články, rozhovory a novinky z klubu.';
const canonical = typeof window !== 'undefined' ? window.location.href : undefined;
return (
<MainLayout>
<Helmet>
<title>{pageTitle}</title>
<meta name="description" content={pageDesc} />
{canonical && <link rel="canonical" href={canonical} />}
<script type="application/ld+json">
{JSON.stringify({
'@context': 'https://schema.org',
'@type': 'ItemList',
itemListElement: (featuredList.concat(visibleArticles)).slice(0, 12).map((a, idx) => ({
'@type': 'ListItem',
position: idx + 1,
url: (typeof window !== 'undefined' ? window.location.origin : '') + (a.slug ? `/news/${a.slug}` : `/articles/${a.id}`),
name: a.title,
})),
})}
</script>
</Helmet>
<Box>
{/* Header like blog.html */}
<Box bg="transparent" color="inherit" py={{ base: 8, md: 10 }} mb={4} borderBottom="1px" borderColor={borderColor}>
@@ -322,18 +378,46 @@ const BlogPage: React.FC = () => {
{isLoading && Array.from({ length: 9 }).map((_, i) => (
<Skeleton key={i} h={{ base: '220px', md: '260px' }} borderRadius="md" mb={7} />
))}
{!isLoading && visibleArticles.map((a) => (
<Box
key={a.id}
mb={7}
sx={{
breakInside: 'avoid',
WebkitColumnBreakInside: 'avoid',
pageBreakInside: 'avoid',
}}
>
<BlogTile article={a} />
</Box>
{!isLoading && visibleArticles.map((a, idx) => (
<React.Fragment key={`row-${a.id}`}>
<Box
mb={7}
sx={{
breakInside: 'avoid',
WebkitColumnBreakInside: 'avoid',
pageBreakInside: 'avoid',
}}
>
<BlogTile article={a} />
</Box>
{articleBanners.length > 0 && idx === insertionIndex && (
<Box
key={`banner-inline-${articleBanners[0].id}`}
mb={7}
sx={{
breakInside: 'avoid',
WebkitColumnBreakInside: 'avoid',
pageBreakInside: 'avoid',
}}
>
<a
href={articleBanners[0].click_url || '#'}
target={articleBanners[0].click_url ? '_blank' : undefined}
rel={articleBanners[0].click_url ? 'noopener noreferrer' : undefined}
style={{ display: 'block' }}
>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img
src={assetUrl(articleBanners[0].image_url) || '/images/sponsors/placeholder.png'}
alt={articleBanners[0].name}
style={{ width: '100%', height: 'auto', display: 'block' }}
loading="lazy"
decoding="async"
/>
</a>
</Box>
)}
</React.Fragment>
))}
</Box>
{!isLoading && !featuredList.length && !visibleArticles.length && (
@@ -353,8 +437,6 @@ const BlogPage: React.FC = () => {
{/* Newsletter CTA */}
<NewsletterCTA />
{/* Sponsors Section */}
<SponsorsSection />
</Box>
</MainLayout>
);
-4
View File
@@ -7,7 +7,6 @@ import { cs } from 'date-fns/locale';
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
import { useSearchParams } from 'react-router-dom';
import { useCountdown, useMultipleCountdowns } from '../hooks/useCountdown';
import SponsorsSection from '../components/common/SponsorsSection';
import NewsletterCTA from '../components/common/NewsletterCTA';
import { sortCategoriesWithOrder } from '../utils/categorySort';
import ClubModal from '../components/home/ClubModal';
@@ -1298,9 +1297,6 @@ const CalendarPage: React.FC = () => {
{/* Newsletter CTA */}
<NewsletterCTA />
{/* Sponsors Section */}
<SponsorsSection />
{/* Club Modal for team statistics */}
<ClubModal
-3
View File
@@ -3,7 +3,6 @@ import MainLayout from '../components/layout/MainLayout';
import { Box, Container, Heading, Text, Stack, Image, SimpleGrid, Divider } from '@chakra-ui/react';
import { usePublicSettings } from '../hooks/usePublicSettings';
import { assetUrl } from '../utils/url';
import SponsorsSection from '../components/common/SponsorsSection';
import NewsletterCTA from '../components/common/NewsletterCTA';
const ClubPage: React.FC = () => {
@@ -48,8 +47,6 @@ const ClubPage: React.FC = () => {
{/* Newsletter CTA */}
<NewsletterCTA />
{/* Sponsors Section */}
<SponsorsSection />
</MainLayout>
);
};
+172 -88
View File
@@ -33,9 +33,10 @@ import { useSettings } from '../hooks/useSettings';
import MainLayout from '../components/layout/MainLayout';
import { FiMail, FiPhone, FiMapPin } from 'react-icons/fi';
import { trackContactSubmit, trackFormSubmit } from '../utils/umami';
import SponsorsSection from '../components/common/SponsorsSection';
import ContactMap from '../components/home/ContactMap';
import { getPublicContacts, GroupedContacts } from '../services/contactInfo';
import { facrApi } from '../services/facr/facrApi';
import { getCompetitionAliasesPublic } from '../services/competitionAliases';
type ContactFormData = {
name: string;
@@ -56,6 +57,9 @@ const ContactPage: React.FC = () => {
// Public contacts (grouped by category)
const [contactsData, setContactsData] = useState<GroupedContacts | null>(null);
const [contactsLoading, setContactsLoading] = useState(true);
// Club competitions (for tabs fallback) and aliases map
const [competitions, setCompetitions] = useState<Array<{ code?: string; name: string }>>([]);
const [aliasesMap, setAliasesMap] = useState<Record<string, string>>({});
const {
register,
@@ -121,6 +125,32 @@ const ContactPage: React.FC = () => {
return () => { mounted = false; };
}, []);
// Load club competitions + aliases (used to populate tabs when no contact categories are defined)
useEffect(() => {
(async () => {
try {
const clubId = (settings as any)?.club_id || '';
const clubType = ((settings as any)?.club_type || 'football') as 'football' | 'futsal';
let comps: Array<{ code?: string; name: string }> = [];
if (clubId) {
try {
const club = await facrApi.getClub(String(clubId), clubType);
const arr = Array.isArray((club as any)?.competitions) ? (club as any).competitions : [];
arr.forEach((c: any) => comps.push({ code: c.code, name: c.name || c.code }));
} catch {}
}
let amap: Record<string, string> = {};
try {
const list = await getCompetitionAliasesPublic();
list.forEach((a) => { if (a.code && a.alias) amap[a.code] = a.alias; });
} catch {}
const withAliases = comps.map((c) => ({ code: c.code, name: (c.code && amap[c.code]) ? amap[c.code] : c.name }));
setAliasesMap(amap);
setCompetitions(withAliases);
} catch {}
})();
}, [settings]);
const onSubmit = (data: ContactFormData) => {
mutate(data);
};
@@ -222,91 +252,148 @@ const ContactPage: React.FC = () => {
{hasContacts && (
<Box bg={bgColor} p={4} borderRadius="lg" borderWidth="1px" borderColor={borderColor} boxShadow="sm">
<Heading size="md" mb={3}>Kontaktní osoby</Heading>
<Tabs colorScheme="blue" isFitted>
<TabList>
{categories.map(([name]) => (
<Tab key={name}>{name}</Tab>
))}
{uncategorized.length > 0 && <Tab>Ostatní</Tab>}
</TabList>
<TabPanels>
{categories.map(([name, persons]) => (
<TabPanel key={name} pt={4}>
<SimpleGrid columns={{ base: 1, sm: 2 }} spacing={4}>
{persons.map((contact) => (
<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" />
)}
<Box>
<Heading size="sm">{contact.name}</Heading>
{contact.position && (
<Badge colorScheme="blue" mt={1}>{contact.position}</Badge>
)}
</Box>
{contact.description && (
<Text fontSize="sm" color="gray.600">{contact.description}</Text>
)}
<VStack align="start" spacing={1}>
{contact.email && (
<HStack spacing={2}>
<Icon as={FiMail} color="blue.500" />
<Link href={`mailto:${contact.email}`} color="blue.500" fontSize="sm">{contact.email}</Link>
</HStack>
)}
{contact.phone && (
<HStack spacing={2}>
<Icon as={FiPhone} color="blue.500" />
<Link href={`tel:${contact.phone}`} color="blue.500" fontSize="sm">{contact.phone}</Link>
</HStack>
)}
</VStack>
</VStack>
</Box>
<Tabs colorScheme="blue" isFitted isLazy>
{(() => {
const categoryEntries = Object.entries(contactsData?.categories || {});
const compNames = competitions.map((c) => (c.code && aliasesMap[c.code]) ? aliasesMap[c.code] : c.name).filter(Boolean);
const useCategories = categoryEntries.length > 0;
const tabs = useCategories ? categoryEntries.map(([n]) => n) : compNames;
const hasOthers = uncategorized.length > 0;
return (
<>
<TabList>
{tabs.map((n) => (
<Tab key={n}>{n}</Tab>
))}
</SimpleGrid>
</TabPanel>
))}
{uncategorized.length > 0 && (
<TabPanel pt={4}>
<SimpleGrid columns={{ base: 1, sm: 2 }} spacing={4}>
{uncategorized.map((contact) => (
<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" />
)}
<Box>
<Heading size="sm">{contact.name}</Heading>
{contact.position && (
<Badge colorScheme="blue" mt={1}>{contact.position}</Badge>
)}
</Box>
{contact.description && (
<Text fontSize="sm" color="gray.600">{contact.description}</Text>
)}
<VStack align="start" spacing={1}>
{contact.email && (
<HStack spacing={2}>
<Icon as={FiMail} color="blue.500" />
<Link href={`mailto:${contact.email}`} color="blue.500" fontSize="sm">{contact.email}</Link>
</HStack>
)}
{contact.phone && (
<HStack spacing={2}>
<Icon as={FiPhone} color="blue.500" />
<Link href={`tel:${contact.phone}`} color="blue.500" fontSize="sm">{contact.phone}</Link>
</HStack>
)}
</VStack>
</VStack>
</Box>
))}
</SimpleGrid>
</TabPanel>
)}
</TabPanels>
{hasOthers && <Tab>Ostatní</Tab>}
</TabList>
<TabPanels>
{useCategories
? categoryEntries.map(([name, persons]) => (
<TabPanel key={name} pt={4}>
<SimpleGrid columns={{ base: 1, sm: 2 }} spacing={4}>
{persons.map((contact) => (
<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" />
)}
<Box>
<Heading size="sm">{contact.name}</Heading>
{contact.position && (
<Badge colorScheme="blue" mt={1}>{contact.position}</Badge>
)}
</Box>
{contact.description && (
<Text fontSize="sm" color="gray.600">{contact.description}</Text>
)}
<VStack align="start" spacing={1}>
{contact.email && (
<HStack spacing={2}>
<Icon as={FiMail} color="blue.500" />
<Link href={`mailto:${contact.email}`} color="blue.500" fontSize="sm">{contact.email}</Link>
</HStack>
)}
{contact.phone && (
<HStack spacing={2}>
<Icon as={FiPhone} color="blue.500" />
<Link href={`tel:${contact.phone}`} color="blue.500" fontSize="sm">{contact.phone}</Link>
</HStack>
)}
</VStack>
</VStack>
</Box>
))}
</SimpleGrid>
</TabPanel>
))
: tabs.map((name) => {
const persons = (contactsData?.categories || {})[name] || [];
return (
<TabPanel key={name} pt={4}>
{persons.length > 0 ? (
<SimpleGrid columns={{ base: 1, sm: 2 }} spacing={4}>
{persons.map((contact) => (
<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" />
)}
<Box>
<Heading size="sm">{contact.name}</Heading>
{contact.position && (
<Badge colorScheme="blue" mt={1}>{contact.position}</Badge>
)}
</Box>
{contact.description && (
<Text fontSize="sm" color="gray.600">{contact.description}</Text>
)}
<VStack align="start" spacing={1}>
{contact.email && (
<HStack spacing={2}>
<Icon as={FiMail} color="blue.500" />
<Link href={`mailto:${contact.email}`} color="blue.500" fontSize="sm">{contact.email}</Link>
</HStack>
)}
{contact.phone && (
<HStack spacing={2}>
<Icon as={FiPhone} color="blue.500" />
<Link href={`tel:${contact.phone}`} color="blue.500" fontSize="sm">{contact.phone}</Link>
</HStack>
)}
</VStack>
</VStack>
</Box>
))}
</SimpleGrid>
) : (
<Text color="gray.500">Pro tuto kategorii zatím nemáme kontaktní osobu.</Text>
)}
</TabPanel>
);
})}
{hasOthers && (
<TabPanel pt={4}>
<SimpleGrid columns={{ base: 1, sm: 2 }} spacing={4}>
{uncategorized.map((contact) => (
<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" />
)}
<Box>
<Heading size="sm">{contact.name}</Heading>
{contact.position && (
<Badge colorScheme="blue" mt={1}>{contact.position}</Badge>
)}
</Box>
{contact.description && (
<Text fontSize="sm" color="gray.600">{contact.description}</Text>
)}
<VStack align="start" spacing={1}>
{contact.email && (
<HStack spacing={2}>
<Icon as={FiMail} color="blue.500" />
<Link href={`mailto:${contact.email}`} color="blue.500" fontSize="sm">{contact.email}</Link>
</HStack>
)}
{contact.phone && (
<HStack spacing={2}>
<Icon as={FiPhone} color="blue.500" />
<Link href={`tel:${contact.phone}`} color="blue.500" fontSize="sm">{contact.phone}</Link>
</HStack>
)}
</VStack>
</VStack>
</Box>
))}
</SimpleGrid>
</TabPanel>
)}
</TabPanels>
</>
);
})()}
</Tabs>
</Box>
)}
@@ -422,9 +509,6 @@ const ContactPage: React.FC = () => {
</Box>
</VStack>
</Container>
{/* Sponsors Section */}
<SponsorsSection />
</MainLayout>
);
};
-4
View File
@@ -17,7 +17,6 @@ import {
import { Calendar, Image as ImageIcon, ExternalLink } from 'lucide-react';
import MainLayout from '../components/layout/MainLayout';
import { API_URL } from '../services/api';
import SponsorsSection from '../components/common/SponsorsSection';
import NewsletterCTA from '../components/common/NewsletterCTA';
interface Album {
@@ -317,9 +316,6 @@ const GalleryPage: React.FC = () => {
{/* Newsletter CTA */}
<NewsletterCTA />
{/* Sponsors Section */}
<SponsorsSection />
</MainLayout>
);
};
+201 -98
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState, useMemo } from 'react';
import React, { useEffect, useRef, useState, useMemo, Suspense } from 'react';
import { IconButton, Tooltip } from '@chakra-ui/react';
import MainLayout from '../components/layout/MainLayout';
import { FiArrowRight, FiCalendar, FiUsers, FiAward, FiChevronLeft, FiChevronRight } from 'react-icons/fi';
import { FiArrowRight, FiCalendar, FiUsers, FiAward, FiChevronLeft, FiChevronRight, FiEdit } from 'react-icons/fi';
import '../styles/theme.css';
import '../styles/sparta-styles.css';
import '../styles/club-styles.css';
@@ -11,19 +12,20 @@ import { assetUrl, sanitizeClubName } from '../utils/url';
import { getPlayers as apiGetPlayers, Player as ApiPlayer } from '../services/players';
import { getSponsors as apiGetSponsors, Sponsor as ApiSponsor } from '../services/sponsors';
import { getBanners as apiGetBanners, Banner as ApiBanner } from '../services/banners';
import BannerDisplay from '../components/banners/BannerDisplay';
import BlogCardsScroller from '../components/home/BlogCardsScroller';
import BlogSwiper from '../components/home/BlogSwiper';
import VideosSection from '../components/home/VideosSection';
import MerchSection from '../components/home/MerchSection';
import PollsWidget from '../components/home/PollsWidget';
import GallerySection from '../components/home/GallerySection';
import { translateNationality, getCountryFlag } from '../utils/nationality';
const BannerDisplay = React.lazy(() => import('../components/banners/BannerDisplay'));
const BlogCardsScroller = React.lazy(() => import('../components/home/BlogCardsScroller'));
const BlogSwiper = React.lazy(() => import('../components/home/BlogSwiper'));
const VideosSection = React.lazy(() => import('../components/home/VideosSection'));
const MerchSection = React.lazy(() => import('../components/home/MerchSection'));
const PollsWidget = React.lazy(() => import('../components/home/PollsWidget'));
const GallerySection = React.lazy(() => import('../components/home/GallerySection'));
import { getArticles as apiGetArticles, Article as ApiArticle } from '../services/articles';
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
import { getUpcomingEvents } from '../services/eventService';
import NewsletterSubscribe from '../components/newsletter/NewsletterSubscribe';
import MyUIbrixStyleEditor from '../components/editor/MyUIbrixEditor';
import MyUIbrixErrorBoundary from '../components/editor/MyUIbrixErrorBoundary';
const NewsletterSubscribe = React.lazy(() => import('../components/newsletter/NewsletterSubscribe'));
const MyUIbrixStyleEditor = React.lazy(() => import('../components/editor/MyUIbrixEditor'));
const MyUIbrixErrorBoundary = React.lazy(() => import('../components/editor/MyUIbrixErrorBoundary'));
import ClubModal from '../components/home/ClubModal';
import MatchModal from '../components/home/MatchModal';
import { useAllPageElementConfigs } from '../hooks/usePageElementConfig';
@@ -31,10 +33,11 @@ import { API_URL } from '../services/api';
import { TeamLogo } from '../components/common/TeamLogo';
import ClubHeroTopbar from '../components/home/ClubHeroTopbar';
import NewsList from '../components/pack/NewsList';
import StandingsCard from '../components/pack/StandingsCard';
const StandingsCard = React.lazy(() => import('../components/pack/StandingsCard'));
import NextMatch from '../components/pack/NextMatch';
import MatchesSlider from '../components/pack/MatchesSlider';
const MatchesSlider = React.lazy(() => import('../components/pack/MatchesSlider'));
import ActivitiesList from '../components/pack/ActivitiesList';
import { useAuth } from '../contexts/AuthContext';
// Types for real API-driven data
type NewsItem = {
@@ -100,7 +103,7 @@ const HomePage: React.FC = () => {
// Matches slider auto-centering handled internally by MatchesSlider component
// API-driven players and sponsors
type UiPlayer = { id:number|string; name:string; number?:number; position?:string; image?:string; slug?:string; age?: number };
type UiPlayer = { id:number|string; name:string; number?:number; position?:string; image?:string; slug?:string; age?: number; nationality?: string };
type UiSponsor = { id:number|string; name:string; logo:string; url?:string; tier?: string };
type UiBanner = { id:number|string; name:string; image:string; url?:string; placement?:string; width?:number; height?:number };
type UiMerch = { id?: number|string; title?: string; image_url: string; url?: string };
@@ -114,11 +117,14 @@ const HomePage: React.FC = () => {
const [merchItems, setMerchItems] = useState<UiMerch[]>([]);
const [merchEnabled, setMerchEnabled] = useState<boolean>(false);
const [upcomingEvents, setUpcomingEvents] = useState<UiEvent[]>([]);
const [defer, setDefer] = useState<boolean>(false);
// Aliases
const [aliases, setAliases] = useState<CompetitionAlias[]>([]);
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string }>>({});
const [settings, setSettings] = useState<any>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isEditingMode, setIsEditingMode] = useState<boolean>(false);
const { user } = useAuth();
// MyUIbrix element configuration hook for live preview
const { getVariant, isVisible, getStyles, loading: configLoading, refreshKey } = useAllPageElementConfigs('homepage');
@@ -133,6 +139,18 @@ const HomePage: React.FC = () => {
} catch {}
}, [stylePack]);
useEffect(() => {
const ric: any = (window as any).requestIdleCallback || ((cb: any) => setTimeout(cb, 1));
ric(() => setDefer(true));
}, []);
useEffect(() => {
try {
const has = typeof document !== 'undefined' && document.body.classList.contains('myuibrix-edit-mode');
setIsEditingMode(!!has);
} catch {}
}, []);
const heroFallbackArticles = useMemo(() => featured.map((item, index) => ({
id: typeof item.id === 'number' ? item.id : index,
title: item.title,
@@ -402,6 +420,7 @@ const HomePage: React.FC = () => {
number: p.jersey_number,
position: p.position,
image: assetUrl(p.image_url) || undefined,
nationality: (p as any).nationality,
age: (function(iso?: string){
if (!iso) return undefined;
const d = new Date(iso);
@@ -1343,7 +1362,7 @@ const HomePage: React.FC = () => {
<div data-element="style-pack" data-variant={stylePack} style={{ display: 'none' }} />
{/* Above-hero club bar (MyUIbrix managed) */}
{isVisible('hero-topbar', true) && (
<section data-element="hero-topbar" data-variant={getVariant('hero-topbar', 'minimal')} style={{ ...getStyles('hero-topbar') }}>
<section key={`hero-topbar-${refreshKey}-${getVariant('hero-topbar', 'minimal')}`} data-element="hero-topbar" data-variant={getVariant('hero-topbar', 'minimal')} style={{ ...getStyles('hero-topbar') }}>
<ClubHeroTopbar
variant={(getVariant('hero-topbar', 'minimal') as any) as 'brand' | 'minimal' | 'badge'}
fullBleed={getVariant('header', 'unified') === 'fullwidth'}
@@ -1425,31 +1444,48 @@ const HomePage: React.FC = () => {
{/* Featured articles are now shown in the hero grid above, not here */}
{/* Sidebar banners (homepage_sidebar) */}
{/* Sidebar banners (homepage_sidebar) - fixed edge rail, left/right via MyUIbrix variant */}
{(banners || []).some(b => b.placement === 'homepage_sidebar') && (
<section data-element="sidebar" data-variant={getVariant('sidebar', 'right')} className="banner banner-sidebar" style={{ margin: '24px 0', ...getStyles('sidebar') }}>
{/* Simple responsive behavior: stack on mobile, sticky right rail on desktop */}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<div style={{ width: 320, maxWidth: '100%', position: 'sticky' as const, top: 96 }}>
{(banners || []).filter(b => b.placement === 'homepage_sidebar').map((b) => (
<a key={b.id} href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'block', marginBottom: 12 }}>
{/* 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>
))}
<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',
}}
>
{(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>
</div>
))}
</section>
)}
{getVariant('hero', heroStyle) === 'scroller' && isVisible('hero', true) && (
<section key={`hero-scroller-${refreshKey}`} data-element="hero" data-variant={getVariant('hero', heroStyle)} style={{ position: 'relative', ...getStyles('hero') }}>
<BlogCardsScroller />
<Suspense fallback={<div style={{ minHeight: 240 }} />}>
<BlogCardsScroller />
</Suspense>
</section>
)}
{(getVariant('hero', heroStyle) === 'swiper' || getVariant('hero', heroStyle) === 'swiper_full') && isVisible('hero', true) && (
<section key={`hero-swiper-${refreshKey}`} data-element="hero" data-variant={getVariant('hero', heroStyle)} style={getVariant('hero', heroStyle) === 'swiper_full' ? { position: 'relative', marginLeft: 'calc(50% - 50vw)', marginRight: 'calc(50% - 50vw)', ...getStyles('hero') } : { position: 'relative', ...getStyles('hero') }}>
<BlogSwiper fallbackArticles={heroFallbackArticles}
/>
<Suspense fallback={<div style={{ minHeight: 280 }} />}>
<BlogSwiper fallbackArticles={heroFallbackArticles} />
</Suspense>
</section>
)}
@@ -1493,36 +1529,41 @@ const HomePage: React.FC = () => {
);
})()
) : isVisible('matches', true) ? (
<NextMatch
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 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>
) : null}
{/* Full-bleed top banner (homepage_top) */}
{(banners || []).some(b => b.placement === 'homepage_top') && (
<BannerDisplay banners={banners as any} placement="homepage_top" />
)}
{/* (Removed) Full-bleed top banner (homepage_top) */}
{/* Matches slider with scores by competition (moved after news+tables) */}
{facrCompetitions.length > 0 && (
<MatchesSlider
comps={facrCompetitions as any}
activeIndex={matchesTab}
onActiveChange={setMatchesTab}
onMatchClick={(m: any, compName?: string) => {
setSelectedMatch({ ...m, competition: compName, competitionName: compName });
setIsMatchModalOpen(true);
}}
variant={getVariant('matches-slider', 'carousel') as any}
elementProps={{ 'data-element': 'matches-slider', 'data-variant': getVariant('matches-slider', 'carousel'), style: { position: 'relative', ...getStyles('matches-slider') } }}
/>
defer ? (
<Suspense fallback={null}>
<MatchesSlider
key={`matches-slider-${refreshKey}-${getVariant('matches-slider', 'carousel')}`}
comps={facrCompetitions as any}
activeIndex={matchesTab}
onActiveChange={setMatchesTab}
onMatchClick={(m: any, compName?: string) => {
setSelectedMatch({ ...m, competition: compName, competitionName: compName });
setIsMatchModalOpen(true);
}}
variant={getVariant('matches-slider', 'carousel') as any}
elementProps={{ 'data-element': 'matches-slider', 'data-variant': getVariant('matches-slider', 'carousel'), style: { position: 'relative', ...getStyles('matches-slider') } }}
/>
</Suspense>
) : null
)}
{/* News + Tables: split into two independent sections */}
@@ -1545,12 +1586,13 @@ const HomePage: React.FC = () => {
return (
<section
key={`news-table-${refreshKey}-${newsVariant}-${getVariant('table', 'split_news')}`}
className="standings"
data-variant={variant}
style={{ marginTop: 32 }}
>
{showNews && (
<section 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" style={{ ...getStyles('news') }}>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Další aktuality</h3>
<a href="/news" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
@@ -1564,46 +1606,55 @@ const HomePage: React.FC = () => {
)}
{showTable && (
<div 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')} style={{ ...getStyles('table') }}>
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
<h3>Tabulky</h3>
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
</div>
<StandingsCard
variant={((): 'logos'|'plain' => { const v = getVariant('table_rows', 'logos'); return v === 'plain' ? 'plain' : 'logos'; })()}
rows={(matchingStanding?.table || matchingStanding?.rows || []) as any}
onRowClick={(row) => {
const clubData = {
team: (row as any).team?.name ?? (row as any).team ?? (row as any).club ?? '-',
team_id: (row as any).team_id || '',
team_logo_url: (row as any).team_logo_url,
rank: (row as any).position ?? (row as any).pos ?? (row as any).rank ?? 0,
played: (row as any).played ?? (row as any).matches ?? '-',
wins: (row as any).wins ?? (row as any).win ?? '-',
draws: (row as any).draws ?? (row as any).draw ?? '-',
losses: (row as any).losses ?? (row as any).loss ?? '-',
score: (row as any).score ?? '-',
points: (row as any).points ?? (row as any).pts ?? '-',
};
setSelectedClub(clubData);
setIsModalOpen(true);
}}
/>
{defer ? (
<Suspense fallback={null}>
<StandingsCard
variant={((): 'logos'|'plain' => { const v = getVariant('table_rows', 'logos'); return v === 'plain' ? 'plain' : 'logos'; })()}
rows={(matchingStanding?.table || matchingStanding?.rows || []) as any}
onRowClick={(row) => {
const clubData = {
team: (row as any).team?.name ?? (row as any).team ?? (row as any).club ?? '-',
team_id: (row as any).team_id || '',
team_logo_url: (row as any).team_logo_url,
rank: (row as any).position ?? (row as any).pos ?? (row as any).rank ?? 0,
played: (row as any).played ?? (row as any).matches ?? '-',
wins: (row as any).wins ?? (row as any).win ?? '-',
draws: (row as any).draws ?? (row as any).draw ?? '-',
losses: (row as any).losses ?? (row as any).loss ?? '-',
score: (row as any).score ?? '-',
points: (row as any).points ?? (row as any).pts ?? '-',
};
setSelectedClub(clubData);
setIsModalOpen(true);
}}
/>
</Suspense>
) : null}
{/* Banners under the table, inside the table column */}
{(banners || []).some(b => b.placement === 'homepage_under_table') && (
defer ? (
<Suspense fallback={null}>
<BannerDisplay banners={banners as any} placement="homepage_under_table" />
</Suspense>
) : null
)}
</div>
)}
</section>
);
})()}
{/* Banner under tables (homepage_under_table) */}
{(banners || []).some(b => b.placement === 'homepage_under_table') && (
<BannerDisplay banners={banners as any} placement="homepage_under_table" />
)}
{/* (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 data-element="activities" data-variant={getVariant('activities', 'list')} style={{ marginTop: 32, marginBottom: 16, position: 'relative', ...getStyles('activities') }}>
<section key={`activities-${refreshKey}-${getVariant('activities', 'list')}`} data-element="activities" data-variant={getVariant('activities', 'list')} style={{ marginTop: 32, marginBottom: 16, position: 'relative', ...getStyles('activities') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Aktivity</h3>
@@ -1616,17 +1667,18 @@ const HomePage: React.FC = () => {
{/* Players scroller */}
{players.length > 0 && isVisible('team', false) && (
<section data-element="team" data-variant={getVariant('team', 'grid')} className="players-scroller" style={{ marginTop: 32, position: 'relative', ...getStyles('team') }}>
<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') }}>
<div className="section-head">
<h3>Hráči</h3>
<a href="/players" className="see-all">Zobrazit vše <FiArrowRight /></a>
</div>
<div className="scroll-x">
{players.map((p) => (
<a key={p.id} href={p.slug ? `/players/${p.slug}` : `/players/${p.id}`} className="player-card">
<a key={p.id} href={p.slug ? `/players/${p.slug}` : `/players/${p.id}`} className="player-card card">
<div className="photo" style={{ backgroundImage: `url(${assetUrl(p.image) || p.image})` }} />
<div className="meta">{typeof p.number !== 'undefined' ? (<><span className="nr">#{p.number}</span> {p.name}</>) : p.name}</div>
<div className="pos">{p.position}</div>
{p.nationality ? (<div className="nat"><span className="flag" style={{ marginRight: 6 }}>{getCountryFlag(p.nationality)}</span>{translateNationality(p.nationality)}</div>) : null}
{typeof p.age === 'number' && <div className="age">{p.age} let</div>}
</a>
))}
@@ -1636,35 +1688,56 @@ const HomePage: React.FC = () => {
{/* Gallery */}
{isVisible('gallery', false) && (
<section 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')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('gallery') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<GallerySection zoneramaUrl={galleryUrl} />
{defer ? (
<Suspense fallback={null}>
<GallerySection zoneramaUrl={galleryUrl} />
</Suspense>
) : null}
</div>
</section>
)}
{/* Videos */}
{isVisible('videos', false) && (
<section data-element="videos" data-variant={getVariant('videos', 'grid')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('videos') }}>
<section key={`videos-${refreshKey}-${getVariant('videos', 'carousel')}`} data-element="videos" data-variant={getVariant('videos', 'carousel')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('videos') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<VideosSection variant={(getVariant('videos', 'grid') as any) as 'grid' | 'carousel'} />
{defer ? (
<Suspense fallback={null}>
<VideosSection
key={`videos-comp-${refreshKey}-${getVariant('videos', 'carousel')}`}
variant={(getVariant('videos', 'carousel') as any) as 'grid' | 'carousel'}
/>
</Suspense>
) : null}
</div>
</section>
)}
{isVisible('merch', true) && (
<section 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')} style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('merch') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<MerchSection />
{defer ? (
<Suspense fallback={null}>
<MerchSection variant={(getVariant('merch', 'grid') as any) as 'grid' | 'carousel' | 'featured' | 'list'} />
</Suspense>
) : null}
</div>
</section>
)}
{/* Polls / Voting */}
{isVisible('poll', false) && (
<section 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')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('poll') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<PollsWidget featuredOnly={true} maxPolls={1} title="Anketa" />
{defer ? (
<Suspense fallback={null}>
<div className="card">
<PollsWidget featuredOnly={true} maxPolls={1} title="Anketa" />
</div>
</Suspense>
) : null}
</div>
</section>
)}
@@ -1683,9 +1756,13 @@ const HomePage: React.FC = () => {
{/* CTA (Newsletter) moved up */}
{isVisible('newsletter', false) && (
<section 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" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('newsletter') }}>
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
<NewsletterSubscribe />
{defer ? (
<Suspense fallback={null}>
<NewsletterSubscribe />
</Suspense>
) : null}
</div>
</section>
)}
@@ -1829,9 +1906,35 @@ const HomePage: React.FC = () => {
console.log('Team clicked:', teamName);
}}
/>
<MyUIbrixErrorBoundary>
<MyUIbrixStyleEditor pageType="homepage" />
</MyUIbrixErrorBoundary>
{isEditingMode ? (
<Suspense fallback={null}>
<MyUIbrixErrorBoundary>
<MyUIbrixStyleEditor pageType="homepage" />
</MyUIbrixErrorBoundary>
</Suspense>
) : null}
{user?.role === 'admin' && !isEditingMode ? (
<div style={{ position: 'fixed', left: 16, bottom: 16, zIndex: 10000 }}>
<Tooltip label="Aktivovat MyUIbrix Editor" placement="right">
<IconButton
aria-label="Upravit stránku"
icon={<FiEdit />}
colorScheme="blue"
size="lg"
borderRadius="full"
onClick={() => {
try {
const url = new URL(window.location.href);
url.searchParams.set('myuibrix', 'edit');
window.history.replaceState({}, '', url.toString());
} catch {}
try { document.body.classList.add('myuibrix-edit-mode'); } catch {}
setIsEditingMode(true);
}}
/>
</Tooltip>
</div>
) : null}
</MainLayout>
);
};
-4
View File
@@ -5,7 +5,6 @@ import { Link as RouterLink, useParams } from 'react-router-dom';
import { usePublicSettings } from '../hooks/usePublicSettings';
import { useQuery } from '@tanstack/react-query';
import { getCompetitionAliasesPublic } from '../services/competitionAliases';
import SponsorsSection from '../components/common/SponsorsSection';
import NewsletterCTA from '../components/common/NewsletterCTA';
import { API_URL } from '../services/api';
@@ -291,9 +290,6 @@ const MatchDetailPage: React.FC = () => {
{/* Newsletter CTA */}
<NewsletterCTA />
{/* Sponsors Section */}
<SponsorsSection />
</MainLayout>
);
};
+26 -11
View File
@@ -3,15 +3,16 @@ import { useParams, Link as RouterLink } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { getPlayer } from '../services/public';
import { assetUrl } from '../utils/url';
import { Box, Badge, Button, Container, Divider, Heading, HStack, Image, SimpleGrid, Skeleton, Stack, Text, VStack } from '@chakra-ui/react';
import { Box, Badge, Button, Container, Divider, Heading, HStack, Image, SimpleGrid, Skeleton, Stack, Text, VStack, useColorModeValue } from '@chakra-ui/react';
import MainLayout from '../components/layout/MainLayout';
import SponsorsSection from '../components/common/SponsorsSection';
import NewsletterCTA from '../components/common/NewsletterCTA';
import { translateNationality } from '../utils/nationality';
import { translateNationality, getCountryFlag } from '../utils/nationality';
const PlayerDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const { data, isLoading, isError } = useQuery({ queryKey: ['player', id], queryFn: () => getPlayer(String(id)) });
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
if (isLoading) {
return (
@@ -45,6 +46,12 @@ const PlayerDetailPage: React.FC = () => {
<HStack justify="space-between">
<HStack spacing={3}>
<Heading as="h1" size={{ base: 'xl', md: '2xl' }}>{fullName}</Heading>
{typeof data.jersey_number === 'number' && (
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>#{data.jersey_number}</Badge>
)}
{data.position && (
<Badge variant="subtle" colorScheme="purple" fontSize="md" px={3} py={1}>{data.position}</Badge>
)}
{!data.is_active && (
<Badge colorScheme="gray" fontSize="md" px={3} py={1}>Neaktivní</Badge>
)}
@@ -63,7 +70,7 @@ const PlayerDetailPage: React.FC = () => {
h={{ base: '300px', md: '400px' }}
/>
</Box>
<Stack spacing={3} bg="white" borderWidth="1px" borderRadius="lg" p={6} shadow="sm">
<Stack spacing={3} bg={cardBg} borderWidth="1px" borderColor={borderColor} borderRadius="lg" p={6} shadow="sm">
<Heading size="md" mb={2}>Informace o hráči</Heading>
{data.position && (
<Text><b>Pozice:</b> {data.position}</Text>
@@ -72,11 +79,25 @@ const PlayerDetailPage: React.FC = () => {
<Text><b>Číslo dresu:</b> <Text as="span" color="brand.primary" fontWeight="700">#{data.jersey_number}</Text></Text>
)}
{data.nationality && (
<Text><b>Národnost:</b> {translateNationality(data.nationality)}</Text>
<HStack>
<Text><b>Národnost:</b></Text>
<Text as="span" fontSize="xl">{getCountryFlag(data.nationality)}</Text>
<Text>{translateNationality(data.nationality)}</Text>
{data.date_of_birth ? (
<Text color={useColorModeValue('gray.600', 'gray.400')}>
{(() => { const a = calculateAge(data.date_of_birth); return a != null ? `${a} ${czYears(a)}` : ''; })()}
</Text>
) : null}
</HStack>
)}
{data.date_of_birth && (
<Text><b>Datum narození:</b> {new Date(data.date_of_birth).toLocaleDateString('cs-CZ')} {(() => { const a = calculateAge(data.date_of_birth); return a != null ? `${a} ${czYears(a)}` : '' })()}</Text>
)}
{data.team?.name ? (
<Text><b>Tým:</b> {data.team.name}</Text>
) : (typeof data.team_id === 'number' && data.team_id > 0) ? (
<Text><b>Tým ID:</b> {data.team_id}</Text>
) : null}
{(data.height || data.weight) && (
<Text>
<b>Výška/Váha:</b> {data.height ? `${data.height} cm` : '-'} / {data.weight ? `${data.weight} kg` : '-'}
@@ -88,18 +109,12 @@ const PlayerDetailPage: React.FC = () => {
{data.phone && (
<Text><b>Telefon:</b> <a href={`tel:${normalizeTel(data.phone)}`}>{data.phone}</a></Text>
)}
{typeof data.team_id === 'number' && data.team_id > 0 && (
<Text><b>Tým ID:</b> {data.team_id}</Text>
)}
</Stack>
</SimpleGrid>
</VStack>
</Container>
<NewsletterCTA />
<SponsorsSection />
</Box>
</MainLayout>
);
+19 -15
View File
@@ -1,12 +1,12 @@
import { Box, Container, Heading, Image, SimpleGrid, Spinner, Stack, Text, VStack, useColorModeValue } from '@chakra-ui/react';
import { Box, Container, Heading, HStack, Image, SimpleGrid, Spinner, Stack, Text, VStack, useColorModeValue, Badge } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getPlayers } from '../services/public';
import type { Player } from '../services/public';
import { assetUrl } from '../utils/url';
import { Link as RouterLink } from 'react-router-dom';
import MainLayout from '../components/layout/MainLayout';
import SponsorsSection from '../components/common/SponsorsSection';
import NewsletterCTA from '../components/common/NewsletterCTA';
import { translateNationality, getCountryFlag } from '../utils/nationality';
const PlayersPage: React.FC = () => {
const { data, isLoading, isError } = useQuery<Player[]>({ queryKey: ['players'], queryFn: getPlayers });
@@ -55,18 +55,25 @@ const PlayersPage: React.FC = () => {
transition="all 0.2s ease"
spacing={3}
>
<Image
src={assetUrl(p.image_url) || '/logo512.png'}
alt={`${p.first_name} ${p.last_name}`}
objectFit="cover"
borderRadius="md"
w="100%"
h="240px"
/>
<Box position="relative" borderRadius="md" overflow="hidden">
<Image
src={assetUrl(p.image_url) || '/logo512.png'}
alt={`${p.first_name} ${p.last_name}`}
objectFit="cover"
w="100%"
h="240px"
/>
{typeof p.jersey_number === 'number' && (
<Badge position="absolute" top="10px" left="10px" colorScheme="blue" fontSize="0.85rem" px={3} py={1} borderRadius="md" boxShadow="sm">#{p.jersey_number}</Badge>
)}
</Box>
<Text fontWeight="bold" fontSize="lg">{p.first_name} {p.last_name}</Text>
<Text color={textSecondary}>{p.position}</Text>
{p.jersey_number ? (
<Text color="brand.primary" fontWeight="600">#{p.jersey_number}</Text>
{p.nationality ? (
<HStack spacing={2} color={textSecondary}>
<Text as="span" fontSize="lg">{getCountryFlag(p.nationality)}</Text>
<Text>{translateNationality(p.nationality)}</Text>
</HStack>
) : null}
</Stack>
))}
@@ -76,9 +83,6 @@ const PlayersPage: React.FC = () => {
{/* Newsletter CTA */}
<NewsletterCTA />
{/* Sponsors Section */}
<SponsorsSection />
</Box>
</MainLayout>
);
+22 -4
View File
@@ -31,7 +31,6 @@ import { useSearchParams, useNavigate, Link as RouterLink } from 'react-router-d
import MainLayout from '../components/layout/MainLayout';
import { searchAll, SearchResults } from '../services/search';
import NewsletterCTA from '../components/common/NewsletterCTA';
import SponsorsSection from '../components/common/SponsorsSection';
import { assetUrl } from '../utils/url';
const SearchPage: React.FC = () => {
@@ -372,6 +371,23 @@ const SearchPage: React.FC = () => {
{m.date && <Text fontSize="xs" color="gray.500">{m.date} {m.time}</Text>}
</Flex>
))}
{results.matches.length < 3 && results.matchesPast.slice(0, 3 - results.matches.length).map((m) => (
<Flex key={m.id} p={3} bg={bgColor} borderWidth="1px" borderRadius="md" justify="space-between" flexWrap="wrap" gap={2}>
<HStack gap={2}>
{m.metadata?.home_logo_url && <Image src={m.metadata.home_logo_url} alt="" boxSize="24px" objectFit="contain" />}
<Text fontSize="sm" fontWeight="medium">{highlight(m.title, q)}</Text>
{m.metadata?.away_logo_url && <Image src={m.metadata.away_logo_url} alt="" boxSize="24px" objectFit="contain" />}
</HStack>
<HStack gap={2}>
{(typeof m.metadata?.result_home === 'number' && typeof m.metadata?.result_away === 'number') ? (
<Badge colorScheme="purple" fontSize="xs">{m.metadata.result_home}:{m.metadata.result_away}</Badge>
) : (m.metadata?.result ? (
<Badge colorScheme="purple" fontSize="xs">{m.metadata.result}</Badge>
) : null)}
{m.date && <Text fontSize="xs" color="gray.500">{m.date} {m.time}</Text>}
</HStack>
</Flex>
))}
</VStack>
{(results.matches.length + results.matchesPast.length) > 3 && (
<Button mt={3} size="sm" onClick={() => setActiveTab('matches')}>
@@ -506,6 +522,11 @@ const SearchPage: React.FC = () => {
{m.metadata?.away_logo_url && <Image src={m.metadata.away_logo_url} alt="" boxSize="32px" objectFit="contain" />}
</HStack>
<HStack gap={2}>
{(typeof m.metadata?.result_home === 'number' && typeof m.metadata?.result_away === 'number') ? (
<Badge colorScheme="purple" fontSize="sm">{m.metadata.result_home}:{m.metadata.result_away}</Badge>
) : (m.metadata?.result ? (
<Badge colorScheme="purple" fontSize="sm">{m.metadata.result}</Badge>
) : null)}
{m.date && <Text fontSize="sm" color="gray.500">{m.date} {m.time}</Text>}
{m.subtitle && <Badge>{highlight(m.subtitle, q)}</Badge>}
</HStack>
@@ -765,9 +786,6 @@ const SearchPage: React.FC = () => {
{/* Newsletter CTA */}
<NewsletterCTA />
{/* Sponsors Section */}
<SponsorsSection />
</Container>
</MainLayout>
);
+1
View File
@@ -899,6 +899,7 @@ const SetupPage: React.FC = () => {
<FormControl>
<FormLabel>JWT tajemství</FormLabel>
<Input value={jwtSecret} onChange={(e) => setJwtSecret(e.target.value)} placeholder="Ponechte prázdné pro stávající hodnotu" />
<FormHelperText>Tajný klíč pro přihlášení (JWT). Nechte prázdné pro ponechání stávající hodnoty.</FormHelperText>
<Button mt={2} size="sm" onClick={() => setJwtSecret(generateJwtSecret())}>Vygenerovat bezpečné tajemství</Button>
</FormControl>
<Box>
+12 -5
View File
@@ -3,6 +3,7 @@ import { useQuery, useMutation } from '@tanstack/react-query';
import { getSponsors, sendContact } from '../services/public';
import MainLayout from '../components/layout/MainLayout';
import { useForm } from 'react-hook-form';
import { assetUrl } from '../utils/url';
type ContactFormData = { name: string; email: string; subject: string; message: string; source?: string };
@@ -41,9 +42,15 @@ const SponsorsPage: React.FC = () => {
return <Text color="red.500">{errMsg}</Text>;
}
// Group sponsors by tier
const generalPartners = data?.filter((s) => s.tier === 'general') || [];
const standardSponsors = data?.filter((s) => s.tier !== 'general') || [];
// Group sponsors by tier and sort within groups
const sorter = (a: any, b: any) => {
const ao = (a?.display_order ?? 9999);
const bo = (b?.display_order ?? 9999);
if (ao !== bo) return ao - bo;
return String(a?.name || '').localeCompare(String(b?.name || ''));
};
const generalPartners = (data?.filter((s) => s.tier === 'general') || []).slice().sort(sorter);
const standardSponsors = (data?.filter((s) => s.tier !== 'general') || []).slice().sort(sorter);
return (
<MainLayout>
@@ -60,7 +67,7 @@ const SponsorsPage: React.FC = () => {
{generalPartners.map((s) => (
<Stack key={s.id} align="center" bg={cardBg} p={6} borderRadius="lg" borderWidth="2px" borderColor={borderColor} boxShadow="md">
<Link href={s.website_url || '#'} isExternal>
<Image src={s.logo_url || '/logo192.png'} alt={s.name} height="100px" objectFit="contain" />
<Image src={assetUrl(s.logo_url) || '/logo192.png'} alt={s.name} height="100px" objectFit="contain" />
</Link>
<Text fontWeight="semibold" fontSize="lg">{s.name}</Text>
</Stack>
@@ -77,7 +84,7 @@ const SponsorsPage: React.FC = () => {
{standardSponsors.map((s) => (
<Stack key={s.id} align="center" bg={cardBg} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
<Link href={s.website_url || '#'} isExternal>
<Image src={s.logo_url || '/logo192.png'} alt={s.name} height="60px" objectFit="contain" />
<Image src={assetUrl(s.logo_url) || '/logo192.png'} alt={s.name} height="60px" objectFit="contain" />
</Link>
<Text fontSize="sm">{s.name}</Text>
</Stack>
-4
View File
@@ -2,7 +2,6 @@ import React, { useEffect, useMemo, useState } from 'react';
import MainLayout from '../components/layout/MainLayout';
import { Box, Container, Heading, Text, Tabs, TabList, TabPanels, Tab, TabPanel, Table, Thead, Tbody, Tr, Th, Td, Flex, Spinner, Badge, Link, useColorModeValue } from '@chakra-ui/react';
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
import SponsorsSection from '../components/common/SponsorsSection';
import NewsletterCTA from '../components/common/NewsletterCTA';
import ClubModal from '../components/home/ClubModal';
import { usePublicSettings } from '../hooks/usePublicSettings';
@@ -237,9 +236,6 @@ const TablesPage: React.FC = () => {
{/* Newsletter CTA */}
<NewsletterCTA />
{/* Sponsors Section */}
<SponsorsSection />
<ClubModal
isOpen={isModalOpen}
+10 -4
View File
@@ -26,8 +26,8 @@ import { useClubTheme } from '../contexts/ClubThemeContext';
import { usePublicSettings } from '../hooks/usePublicSettings';
import { getCachedYouTube, YouTubeVideo } from '../services/youtube';
import { FaPlay, FaExternalLinkAlt, FaYoutube } from 'react-icons/fa';
import SponsorsSection from '../components/common/SponsorsSection';
import NewsletterCTA from '../components/common/NewsletterCTA';
import CommentsSection from '../components/comments/CommentsSection';
type RenderItem = {
key: string;
@@ -179,6 +179,9 @@ const VideosPage: React.FC = () => {
alt={item.title}
width="100%"
height="100%"
loading="lazy"
decoding="async"
referrerPolicy="origin-when-cross-origin"
style={{ objectFit: 'cover' }}
/>
) : (
@@ -330,6 +333,7 @@ const VideosPage: React.FC = () => {
title={selectedVideo.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
style={{ borderRadius: '8px' }}
/>
@@ -362,6 +366,11 @@ const VideosPage: React.FC = () => {
</Link>
)}
</HStack>
{selectedVideo.videoId && (
<Box mt={4}>
<CommentsSection targetType="youtube_video" targetId={selectedVideo.videoId} />
</Box>
)}
</Box>
</Box>
)}
@@ -371,9 +380,6 @@ const VideosPage: React.FC = () => {
{/* Newsletter CTA */}
<NewsletterCTA />
{/* Sponsors Section */}
<SponsorsSection />
</MainLayout>
);
};
@@ -103,14 +103,31 @@ const AdminActivitiesPage: React.FC = () => {
onSave: async (data) => {
// If event has ID, update it
if (data.id) {
return await updateEvent(data.id, data);
try {
return await updateEvent(data.id, data);
} catch (e: any) {
const status = e?.response?.status;
if (status === 404) {
if (data.title?.trim() && data.start_time) {
const created = await createEvent(data);
if (created?.id) {
setEditing(prev => ({ ...prev, id: created.id } as any));
setDraftKey(`draft-activity-${created.id}`);
try { localStorage.removeItem('draft-activity-new'); } catch {}
}
return created;
}
}
throw e;
}
}
// If no ID and has title, create as draft
// If no ID and has minimal required fields, create as draft
if (data.title?.trim() && data.start_time) {
const created = await createEvent(data);
// Update editing state with new ID
if (created?.id) {
setEditing(prev => ({ ...prev, id: created.id } as any));
setDraftKey(`draft-activity-${created.id}`);
try { localStorage.removeItem('draft-activity-new'); } catch {}
}
return created;
}
@@ -258,11 +275,16 @@ const AdminActivitiesPage: React.FC = () => {
const handleRecoverDraft = () => {
const draft = loadDraft<Partial<Event>>(draftKey);
if (draft) {
setEditing(draft);
const isNewDraft = draftKey === 'draft-activity-new';
const restored: any = { ...draft };
if (isNewDraft && restored.id) {
delete restored.id;
}
setEditing(restored);
// Restore location if present
if ((draft as any)?.latitude && (draft as any)?.longitude) {
setLocationLat((draft as any).latitude);
setLocationLng((draft as any).longitude);
if ((restored as any)?.latitude && (restored as any)?.longitude) {
setLocationLat((restored as any).latitude);
setLocationLng((restored as any).longitude);
}
onOpen();
}
@@ -334,12 +356,15 @@ const AdminActivitiesPage: React.FC = () => {
try {
setAiLoading(true);
const e = editing || {};
// Build a helpful Czech prompt including known fields
const stripHtml = (s: string) => String(s || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
const lines: string[] = [];
const clubName = String(settingsQ?.data?.club_name || '').trim();
if (clubName) lines.push(`Klub: ${clubName}`);
if (e.type) lines.push(`Typ: ${e.type}`);
if (e.description) lines.push(`Poznámky: ${e.description}`);
if (!aiOverwrite && e.description) {
const plain = stripHtml(e.description as any);
if (plain) lines.push(`Stávající text (pro kontext): ${plain}`);
}
const base = lines.join('\n');
const toneText = aiTone === 'informative'
? 'neutrálním, věcným a stručným stylem (bez nadsázky)'
@@ -347,12 +372,12 @@ const AdminActivitiesPage: React.FC = () => {
? 'formálním a profesionálním stylem (bez příkras)'
: 'přátelským, ale věcným a stručným stylem (bez nadsázky)';
const safeUserPrompt = (aiPrompt || 'Napiš krátkou neutrální pozvánku na klubovou aktivitu.').trim();
const constraints = 'Nevkládej datum ani místo (lokalitu) do textu. Neuváděj konkrétní čas nebo adresu. Vyhýbej se superlativům, hyperbolám a marketingovým frázím. Nepoužívej slova jako „neopakovatelný“, „epický“, „úchvatný“ apod. Preferuj 12 krátké odstavce nebo stručné odrážky. Dbej na věcný a střízlivý tón.';
const prompt = `${safeUserPrompt}\n\nPiš ${toneText}, česky, s důrazem na jasnost a pozvánku k účasti. ${constraints}\nDetaily:\n${base}`.trim();
const constraints = 'Nevkládej datum ani místo (lokalitu) do textu. Neuváděj konkrétní čas nebo adresu. Vyhýbej se superlativům, hyperbolám a marketingovým frázím. Nepoužívej slova jako „neopakovatelný“, „epický“, „úchvatný“ apod. Preferuj 23 krátké odstavce NEBO stručný seznam s odrážkami. Používej HTML značky ul/li pro odrážky a strong pro zvýraznění. Bez nadpisů (nepoužívej H1/H2). Dbej na věcný a střízlivý tón.';
const prompt = `${safeUserPrompt}\n\nPiš ${toneText}, česky, s důrazem na jasnost a pozvánku k účasti. ${constraints}\nCílová délka: 80120 slov.\nDetaily:\n${base}`.trim();
const { data } = await api.post('/ai/blog/generate', {
prompt,
audience: clubName ? `Fanoušci klubu ${clubName}, oznámení/pozvánka` : 'Fanoušci klubu, oznámení/pozvánka',
min_words: 60,
min_words: 100,
});
// Handle potential JSON string response from AI (defensive parsing)
@@ -535,7 +560,7 @@ const AdminActivitiesPage: React.FC = () => {
<Tr><Td colSpan={8}>Načítání</Td></Tr>
)}
{!isLoading && events.map(ev => (
<Tr key={ev.id}>
<Tr key={ev.id} opacity={ev.is_public ? 1 : 0.6}>
<Td>
{(ev as any).image_url ? (
<ThumbnailPreview
+165 -61
View File
@@ -26,7 +26,7 @@ import PollLinker from '../../components/admin/PollLinker';
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
import FilePreview from '../../components/common/FilePreview';
import SaveStatusIndicator from '../../components/common/SaveStatusIndicator';
import DraftRecoveryModal from '../../components/common/DraftRecoveryModal';
import { useAutoSave, loadDraft, getDraftMetadata } from '../../hooks/useAutoSave';
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
@@ -91,15 +91,16 @@ const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
const label = m
? `${String(m.home || m.home_team || '')} ${String(scoreText)} ${String(m.away || m.away_team || '')}`
: `ID: ${String(mid)}`;
const linkHref = (m && (m.facr_link || m.report_url)) ? String(m.facr_link || m.report_url) : '';
return (
<HStack spacing={2}>
<Badge colorScheme={color as any} title={m?.competitionName ? String(m.competitionName) : undefined}>Zápas: {label}</Badge>
{m?.report_url ? (
{linkHref ? (
<IconButton
aria-label="Otevřít FACR"
aria-label="Otevřít zápas na fotbal.cz"
size="xs"
as="a"
href={String(m.report_url)}
href={linkHref}
target="_blank"
rel="noopener noreferrer"
icon={<FiExternalLink />}
@@ -180,6 +181,7 @@ const ArticlesAdminPage = () => {
const [editing, setEditing] = useState<EditingArticle | null>(null);
const [showDraftRecovery, setShowDraftRecovery] = useState(false);
const [draftKey, setDraftKey] = useState<string>('');
const [localDraft, setLocalDraft] = useState<EditingArticle | null>(null);
@@ -268,7 +270,33 @@ const ArticlesAdminPage = () => {
onSave: async (data) => {
// If article has ID, update it as draft
if (data.id) {
return await updateArticle(data.id, { ...data as any, published: false });
try {
return await updateArticle(data.id, { ...data as any, published: false });
} catch (e: any) {
const status = e?.response?.status;
if (status === 404 && data.title?.trim()) {
const payload: CreateArticlePayload = {
title: data.title || 'Koncept článku',
content: data.content || '',
image_url: data.image_url || '',
category_name: data.category_name,
published: false,
slug: data.slug || '',
seo_title: data.seo_title || '',
seo_description: data.seo_description || '',
og_image_url: data.og_image_url || '',
featured: data.featured || false,
};
const created = await createArticle(payload);
if (created?.id) {
setEditing(prev => ({ ...prev, id: created.id } as any));
setDraftKey(`draft-article-${created.id}`);
try { localStorage.removeItem('draft-article-new'); } catch {}
}
return created;
}
throw e;
}
}
// If no ID, create as draft
if (data.title?.trim()) {
@@ -277,7 +305,7 @@ const ArticlesAdminPage = () => {
content: data.content || '',
image_url: data.image_url || '',
category_name: data.category_name,
published: false, // Always save as draft
published: false,
slug: data.slug || '',
seo_title: data.seo_title || '',
seo_description: data.seo_description || '',
@@ -285,9 +313,10 @@ const ArticlesAdminPage = () => {
featured: data.featured || false,
};
const created = await createArticle(payload);
// Update editing state with new ID
if (created?.id) {
setEditing(prev => ({ ...prev, id: created.id } as any));
setDraftKey(`draft-article-${created.id}`);
try { localStorage.removeItem('draft-article-new'); } catch {}
}
return created;
}
@@ -298,16 +327,28 @@ const ArticlesAdminPage = () => {
enabled: isOpen && editing !== null,
});
// Check for draft on component mount
React.useEffect(() => {
const key = 'draft-article-new';
setDraftKey(key);
const metadata = getDraftMetadata(key);
if (metadata && metadata.age < 1440) { // Less than 24 hours old
setShowDraftRecovery(true);
}
// Load local new-draft and expose in list (no popup)
const refreshLocalDraft = React.useCallback(() => {
try {
const key = 'draft-article-new';
const metadata = getDraftMetadata(key);
if (metadata && metadata.age < 1440) {
const d = loadDraft<EditingArticle>(key);
if (d) {
const restored: any = { ...d };
if (restored.id) delete restored.id;
setLocalDraft(restored);
return;
}
}
} catch {}
setLocalDraft(null);
}, []);
React.useEffect(() => {
refreshLocalDraft();
}, [refreshLocalDraft]);
// Fetch cached Zonerama gallery from prefetch
const fetchCachedGallery = useCallback(async () => {
try {
@@ -661,7 +702,7 @@ const ArticlesAdminPage = () => {
return;
}
const { seoTitle, seoDescription } = generateSeoMetadata(aiTitle);
const { seoTitle, seoDescription } = generateSeoMetadata(aiTitle, aiHtml);
setEditing((prev) => ({
...(prev || {}),
title: aiTitle,
@@ -685,20 +726,17 @@ const ArticlesAdminPage = () => {
});
const openCreate = () => {
// Check for existing draft
const key = 'draft-article-new';
setDraftKey(key);
const metadata = getDraftMetadata(key);
if (metadata && metadata.age < 1440) {
// Show recovery modal
setShowDraftRecovery(true);
if (localDraft) {
setEditing(localDraft);
setActiveTabIndex(1);
} else {
// No draft, start fresh
setEditing({ title: '', content: '', featured: false, published: false } as any);
setActiveTabIndex(0); // Start on AI tab for new articles
setAiPrompt(''); // Clear AI prompt
onOpen();
setActiveTabIndex(0);
}
setAiPrompt('');
onOpen();
};
const openEdit = (a: Article) => {
@@ -733,14 +771,20 @@ const ArticlesAdminPage = () => {
setMatchIdInput('');
setEditing(null);
onClose();
refreshLocalDraft();
};
// Draft recovery handlers
const handleRecoverDraft = () => {
const draft = loadDraft<EditingArticle>(draftKey);
if (draft) {
setEditing(draft);
setActiveTabIndex(1); // Go to Základní tab
const isNewDraft = draftKey === 'draft-article-new';
const restored: any = { ...draft };
if (isNewDraft && restored.id) {
delete restored.id;
}
setEditing(restored);
setActiveTabIndex(1);
onOpen();
}
setShowDraftRecovery(false);
@@ -859,15 +903,45 @@ const ArticlesAdminPage = () => {
[deleteMut, toast]
);
const generateSeoMetadata = (title: string) => {
const baseTitle = title ? `${title} | ${process.env.REACT_APP_SITE_NAME || 'Fotbalový klub'}` : (process.env.REACT_APP_SITE_NAME || 'Fotbalový klub');
const description = title
? `Přečtěte si více o ${title.toLowerCase()}. Aktuální informace, novinky a zajímavosti z našeho fotbalového klubu.`
: 'Oficiální stránky našeho fotbalového klubu. Aktuality, zápasy, výsledky a další informace.';
const generateSeoMetadata = (title: string, content?: string) => {
const clubName = String(settingsQ.data?.club_name || process.env.REACT_APP_SITE_NAME || 'Fotbalový klub');
const baseTitle = title ? `${title} | ${clubName}` : clubName;
const toPlain = (html?: string): string => {
try {
const div = document.createElement('div');
div.innerHTML = String(html || '');
return (div.textContent || div.innerText || '').replace(/\s+/g, ' ').trim();
} catch {
return String(html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
};
const makeExcerpt = (text: string, limit = 28): string => {
if (!text) return '';
const words = text.split(' ').filter(Boolean);
const excerpt = words.slice(0, limit).join(' ');
return words.length > limit ? `${excerpt}...` : excerpt;
};
let description = '';
const src = toPlain(content);
if (src) {
description = makeExcerpt(src, 28);
}
if (!description) {
const t = (title || '').trim();
description = t ? `Přečtěte si více: ${t}.` : `Aktuální informace z klubu ${clubName}.`;
}
if (description.length > 160) {
description = description.slice(0, 157).replace(/\s+\S*$/, '') + '...';
}
return {
seoTitle: baseTitle,
seoDescription: description.length > 160 ? description.substring(0, 157) + '...' : description
seoDescription: description
};
};
@@ -897,7 +971,7 @@ const ArticlesAdminPage = () => {
const handleTitleChange = (title: string) => {
if (!editing) return;
const { seoTitle, seoDescription } = generateSeoMetadata(title);
const { seoTitle, seoDescription } = generateSeoMetadata(title, (editing as any)?.content);
setEditing(prev => ({
...(prev as any),
title,
@@ -1232,8 +1306,49 @@ const ArticlesAdminPage = () => {
{isLoading && (
<Tr><Td colSpan={6}><Spinner size="sm" /></Td></Tr>
)}
{!isLoading && localDraft && (
<Tr key="local-draft" opacity={0.6}>
<Td>
<ThumbnailPreview
src={assetUrl((localDraft as any).image_url) || '/dist/img/logo-club-empty.svg'}
alt={(localDraft as any).title || 'Koncept'}
size="48px"
previewSize="350px"
/>
</Td>
<Td>
<VStack align="start" spacing={0}>
<Text fontWeight="medium">{(localDraft as any).title || 'Bez názvu (koncept)'}</Text>
<Text fontSize="xs" color="gray.500">Koncept (lokálně uložený)</Text>
</VStack>
</Td>
<Td>
<Badge colorScheme="gray" fontSize="xs">
{(localDraft as any).category_name || 'Bez kategorie'}
</Badge>
</Td>
<Td>
<Switch size="sm" isChecked={!!(localDraft as any).featured} isDisabled />
</Td>
<Td>
<Badge colorScheme="gray">Koncept</Badge>
</Td>
<Td isNumeric>
<HStack spacing={1} justify="flex-end">
<IconButton aria-label="Upravit koncept" size="sm" icon={<FiEdit2 />} onClick={openCreate} />
<IconButton
aria-label="Smazat koncept"
size="sm"
colorScheme="red"
icon={<FiTrash2 />}
onClick={() => { try { localStorage.removeItem('draft-article-new'); } catch {} setLocalDraft(null); toast({ title: 'Koncept odstraněn', status: 'success', duration: 2000 }); }}
/>
</HStack>
</Td>
</Tr>
)}
{!isLoading && articles.map((a) => (
<Tr key={a.id}>
<Tr key={a.id} opacity={a.published ? 1 : 0.6}>
<Td>
<ThumbnailPreview
src={assetUrl(a.image_url) || '/dist/img/logo-club-empty.svg'}
@@ -1769,20 +1884,7 @@ const ArticlesAdminPage = () => {
</VStack>
</Box>
{/* OG Image for Social Sharing */}
<FormControl>
<FormLabel>OG obrázek pro sdílení (volitelné)</FormLabel>
<FormHelperText mb={2}>
Speciální obrázek pro sdílení na sociálních sítích. Pokud není nastaveno, použije se titulní obrázek.
</FormHelperText>
<HStack>
<Image src={assetUrl((editing as any)?.og_image_url) || assetUrl(editing?.image_url) || '/dist/img/logo-club-empty.svg'} alt="og" boxSize="80px" objectFit="cover" borderRadius="md" />
<Button as="label" leftIcon={<FiUpload />} variant="outline">
Nahrát OG obrázek
<Input type="file" display="none" accept="image/*" onChange={(e) => onUploadOg(e.target.files?.[0])} />
</Button>
</HStack>
</FormControl>
{/* File Attachments */}
<FormControl>
@@ -2009,6 +2111,19 @@ const ArticlesAdminPage = () => {
/>
<FormHelperText fontSize="xs">Automaticky generováno z obsahu článku</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>OG obrázek pro sdílení (volitelné)</FormLabel>
<FormHelperText mb={2}>
Speciální obrázek pro sdílení na sociálních sítích. Pokud není nastaveno, použije se titulní obrázek.
</FormHelperText>
<HStack>
<Image src={assetUrl((editing as any)?.og_image_url) || assetUrl(editing?.image_url) || '/dist/img/logo-club-empty.svg'} alt="og" boxSize="80px" objectFit="cover" borderRadius="md" />
<Button as="label" leftIcon={<FiUpload />} variant="outline">
Nahrát OG obrázek
<Input type="file" display="none" accept="image/*" onChange={(e) => onUploadOg(e.target.files?.[0])} />
</Button>
</HStack>
</FormControl>
</VStack>
</AccordionPanel>
</AccordionItem>
@@ -2223,17 +2338,6 @@ const ArticlesAdminPage = () => {
</Modal>
{/* Draft Recovery Modal */}
<DraftRecoveryModal
isOpen={showDraftRecovery}
onClose={() => setShowDraftRecovery(false)}
onRecover={handleRecoverDraft}
onDiscard={handleDiscardDraft}
onDeleteOnly={handleDeleteOnly}
draftAge={getDraftMetadata(draftKey)?.age || null}
entityType="článek"
/>
</AdminLayout>
);
};
+35 -19
View File
@@ -1,10 +1,10 @@
import React, { useState, useRef } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { Box, Button, FormControl, FormLabel, Heading, HStack, IconButton, Image, Input, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Spinner, Table, Tbody, Td, Th, Thead, Tr, useColorModeValue, useDisclosure, useToast, VStack, Select, Text, Switch, Badge, Alert, AlertIcon, AlertTitle, AlertDescription, Divider, Grid, GridItem } from '@chakra-ui/react';
import { FiPlus, FiEdit2, FiTrash2, FiUpload, FiAlertCircle, FiCheckCircle } from 'react-icons/fi';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { Banner as AdminBanner, getBanners, createBanner, updateBanner, deleteBanner } from '../../services/banners';
import { uploadFile } from '../../services/articles';
import { uploadFile, getArticles } from '../../services/articles';
import { assetUrl } from '../../utils/url';
// Banner placement presets with dimensions and descriptions
@@ -19,15 +19,6 @@ type BannerPreset = {
};
const BANNER_PRESETS: BannerPreset[] = [
{
value: 'homepage_top',
label: 'Hlavní banner (Homepage - vrchol)',
description: 'Hlavní reklamní plocha nahoře, zobrazena všem návštěvníkům',
width: 1200,
height: 200,
aspectRatio: 6,
position: 'top'
},
{
value: 'homepage_middle',
label: 'Střední banner (Homepage - střed)',
@@ -39,8 +30,8 @@ const BANNER_PRESETS: BannerPreset[] = [
},
{
value: 'homepage_sidebar',
label: 'Postranní banner (Homepage - sidebar)',
description: 'Menší banner v pravém postranním panelu',
label: 'Postranní banner (Homepage - okraj obrazovky)',
description: 'Menší banner ukotvený u levého/pravého okraje obrazovky (nastavitelné v editoru: Sidebar varianta vlevo/vpravo)',
width: 300,
height: 250,
aspectRatio: 1.2,
@@ -86,6 +77,7 @@ const BannersAdminPage: React.FC = () => {
const [imageResolution, setImageResolution] = useState<{ width: number; height: number } | null>(null);
const [recommendedPlacements, setRecommendedPlacements] = useState<BannerPreset[]>([]);
const [uploadingImage, setUploadingImage] = useState(false);
const [hasArticles, setHasArticles] = useState<boolean>(true);
const fileInputRef = useRef<HTMLInputElement>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
@@ -142,6 +134,18 @@ const BannersAdminPage: React.FC = () => {
onClose();
};
// Determine if at least one published article exists to allow "Banner v článcích"
useEffect(() => {
(async () => {
try {
const resp = await getArticles({ page: 1, page_size: 1, published: true });
setHasArticles(((resp?.total ?? 0) > 0) || ((resp?.data?.length ?? 0) > 0));
} catch {
setHasArticles(true); // fail-open so UI is not unnecessarily blocked
}
})();
}, []);
const createMut = useMutation({
mutationFn: (payload: any) => createBanner(payload),
onSuccess: () => { toast({ title: 'Banner vytvořen', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-banners'] }); closeModal(); },
@@ -277,7 +281,7 @@ const BannersAdminPage: React.FC = () => {
{!isLoading && banners.map((b: AdminBanner) => {
const preset = getPreset((b as any).placement);
return (
<Tr key={b.id}>
<Tr key={b.id} opacity={b.is_active ? 1 : 0.6}>
<Td>
<Image src={assetUrl((b as any).image_url) || '/logo192.png'} alt={b.name} boxSize="56px" objectFit="contain" bg={inputBg} borderRadius="md" />
</Td>
@@ -402,11 +406,23 @@ const BannersAdminPage: React.FC = () => {
}}
>
<option value=""> vyberte umístění </option>
{BANNER_PRESETS.map(preset => (
<option key={preset.value} value={preset.value}>
{preset.label} ({preset.width}×{preset.height})
</option>
))}
{BANNER_PRESETS.map(preset => {
const isArticleInline = preset.value === 'article_inline';
const disabled = isArticleInline && !hasArticles;
const label = isArticleInline && !hasArticles
? `${preset.label} — nelze použít (na webu zatím není žádný článek)`
: `${preset.label} (${preset.width}×${preset.height})`;
return (
<option
key={preset.value}
value={preset.value}
disabled={disabled}
title={isArticleInline && !hasArticles ? 'Tuto pozici lze použít až když existuje alespoň 1 publikovaný článek.' : preset.description}
>
{label}
</option>
);
})}
</Select>
{editing?.placement && (() => {
const preset = getPreset((editing as any).placement);
@@ -0,0 +1,178 @@
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 } from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { adminListComments, adminUpdateCommentStatus, adminBanUser, adminListUnbanRequests, adminResolveUnban } from '../../services/admin/comments';
import { deleteComment } from '../../services/comments';
import { FiTrash2 } from 'react-icons/fi';
const CommentsAdminPage: React.FC = () => {
const [status, setStatus] = React.useState<string>('');
const [targetType, setTargetType] = React.useState<string>('');
const [targetId, setTargetId] = React.useState<string>('');
const [userId, setUserId] = React.useState<string>('');
const [page, setPage] = React.useState<number>(1);
const toast = useToast();
const qc = useQueryClient();
const listQ = useQuery({
queryKey: ['admin-comments', { status, targetType, targetId, userId, page }],
queryFn: () => adminListComments({ status: status as any, target_type: targetType, target_id: targetId, user_id: userId, page, page_size: 50 }),
keepPreviousData: true,
});
const unbanQ = useQuery({
queryKey: ['admin-unban-requests'],
queryFn: adminListUnbanRequests,
});
const updateStatusMut = useMutation({
mutationFn: (args: { id: number; s: 'visible'|'hidden' }) => adminUpdateCommentStatus(args.id, args.s),
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-comments'] }); },
});
const deleteMut = useMutation({
mutationFn: (id: number) => deleteComment(id),
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-comments'] }); toast({ status: 'success', title: 'Smazáno' }); },
});
const [banUserId, setBanUserId] = React.useState<number | null>(null);
const banModal = useDisclosure();
const [banReason, setBanReason] = React.useState<string>('Porušení pravidel diskuse');
const [banHours, setBanHours] = React.useState<number>(0);
const banMut = useMutation({
mutationFn: () => adminBanUser(banUserId || 0, banReason, banHours),
onSuccess: async () => { banModal.onClose(); setBanUserId(null); toast({ status: 'success', title: 'Uživatel zablokován' }); },
});
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' }); },
});
const items = listQ.data?.items || [];
return (
<AdminLayout>
<Box>
<Heading size="md" mb={4}>Komentáře (moderace)</Heading>
<VStack align="stretch" spacing={3} mb={4}>
<HStack>
<Select placeholder="Status" value={status} onChange={(e) => { setStatus(e.target.value); setPage(1); }} maxW="200px">
<option value="visible">Viditelné</option>
<option value="hidden">Skryté</option>
</Select>
<Select placeholder="Typ cíle" value={targetType} onChange={(e) => { setTargetType(e.target.value); setPage(1); }} maxW="220px">
<option value="article">Článek</option>
<option value="event">Aktivita</option>
<option value="gallery_album">Galerie</option>
<option value="youtube_video">YouTube video</option>
</Select>
<Input placeholder="Target ID" value={targetId} onChange={(e) => { setTargetId(e.target.value); setPage(1); }} maxW="200px" />
<Input placeholder="User ID" value={userId} onChange={(e) => { setUserId(e.target.value); setPage(1); }} maxW="200px" />
</HStack>
</VStack>
<Box borderWidth="1px" borderRadius="md" overflowX="auto">
<Table size="sm">
<Thead>
<Tr>
<Th>ID</Th>
<Th>Uživatel</Th>
<Th>Cíl</Th>
<Th>Obsah</Th>
<Th>Spam</Th>
<Th>Status</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
{items.map((c) => (
<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 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>
<HStack>
<Button size="xs" variant={c.status === 'visible' ? 'solid' : 'outline'} onClick={() => updateStatusMut.mutate({ id: c.id, s: 'visible' })}>Viditelné</Button>
<Button size="xs" variant={c.status === 'hidden' ? 'solid' : 'outline'} onClick={() => updateStatusMut.mutate({ id: c.id, s: 'hidden' })}>Skryté</Button>
</HStack>
</Td>
<Td>
<HStack>
<IconButton aria-label="Smazat" size="xs" icon={<FiTrash2 />} onClick={() => deleteMut.mutate(c.id)} />
<Button size="xs" variant="outline" onClick={() => { setBanUserId(c.user?.id as any); banModal.onOpen(); }}>Ban</Button>
</HStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
<Heading size="sm" mt={6} mb={2}>Žádosti o odblokování</Heading>
<Box borderWidth="1px" borderRadius="md" overflowX="auto">
<Table size="sm">
<Thead>
<Tr>
<Th>ID</Th>
<Th>Uživatel</Th>
<Th>Text</Th>
<Th>Status</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
{(unbanQ.data?.items || []).map((r) => (
<Tr key={r.id}>
<Td>#{r.id}</Td>
<Td>#{r.user_id}</Td>
<Td maxW="480px"><Text noOfLines={2}>{r.message}</Text></Td>
<Td><Badge>{r.status}</Badge></Td>
<Td>
<HStack>
<Button size="xs" colorScheme="green" variant="outline" onClick={() => resolveUnbanMut.mutate({ id: r.id, action: 'approve' })}>Povolit</Button>
<Button size="xs" colorScheme="red" variant="outline" onClick={() => resolveUnbanMut.mutate({ id: r.id, action: 'reject' })}>Zamítnout</Button>
</HStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
{/* Ban modal */}
<Modal isOpen={banModal.isOpen} onClose={banModal.onClose} isCentered>
<ModalOverlay />
<ModalContent>
<ModalHeader>Zablokovat uživatele #{banUserId}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={3}>
<FormControl>
<FormLabel>Důvod</FormLabel>
<Input value={banReason} onChange={(e) => setBanReason(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Doba (hodiny) 0 = trvale</FormLabel>
<NumberInput min={0} value={banHours} onChange={(v) => setBanHours(Number(v) || 0)}>
<NumberInputField />
</NumberInput>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<HStack>
<Button onClick={banModal.onClose}>Zrušit</Button>
<Button colorScheme="red" isLoading={banMut.isPending} onClick={() => banMut.mutate()}>Zablokovat</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
</AdminLayout>
);
};
export default CommentsAdminPage;
@@ -0,0 +1,209 @@
import React from 'react';
import AdminLayout from '../../layouts/AdminLayout';
import {
Box,
Heading,
HStack,
VStack,
Button,
Input,
Select,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Text,
Badge,
IconButton,
useToast,
Switch,
NumberInput,
NumberInputField,
Image,
Divider,
} from '@chakra-ui/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
adminListRewards,
adminCreateReward,
adminUpdateReward,
adminDeleteReward,
adminListRedemptions,
adminUpdateRedemptionStatus,
AdminRewardItem,
AdminRedemption,
} from '../../services/admin/engagement';
import { FiTrash2 } from 'react-icons/fi';
const EngagementAdminPage: React.FC = () => {
const toast = useToast();
const qc = useQueryClient();
const rewardsQ = useQuery({
queryKey: ['admin-engagement-rewards'],
queryFn: () => adminListRewards(),
});
const redemptionsQ = useQuery({
queryKey: ['admin-engagement-redemptions'],
queryFn: () => adminListRedemptions(),
});
const [form, setForm] = React.useState({
name: '',
type: 'avatar_static',
cost_points: 50,
image_url: '',
stock: 0,
active: true,
});
const createMut = useMutation({
mutationFn: () => adminCreateReward(form),
onSuccess: async () => {
setForm({ name: '', type: 'avatar_static', cost_points: 50, image_url: '', stock: 0, active: true });
await qc.invalidateQueries({ queryKey: ['admin-engagement-rewards'] });
toast({ status: 'success', title: 'Odměna vytvořena' });
},
onError: (e: any) => toast({ status: 'error', title: e?.response?.data?.error || 'Chyba při vytváření odměny' }),
});
const updateMut = useMutation({
mutationFn: (args: { id: number; body: Partial<AdminRewardItem> }) => adminUpdateReward(args.id, args.body as any),
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-engagement-rewards'] }); toast({ status: 'success', title: 'Aktualizováno' }); },
});
const deleteMut = useMutation({
mutationFn: (id: number) => adminDeleteReward(id),
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-engagement-rewards'] }); toast({ status: 'success', title: 'Smazáno' }); },
});
const redStatusMut = useMutation({
mutationFn: (args: { id: number; action: 'approve'|'reject'|'fulfill' }) => adminUpdateRedemptionStatus(args.id, args.action),
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-engagement-redemptions'] }); toast({ status: 'success', title: 'Status aktualizován' }); },
});
const rewards = rewardsQ.data || [];
const redemptions = redemptionsQ.data || [];
return (
<AdminLayout>
<Box>
<Heading size="md" mb={4}>Odměny & Úspěchy</Heading>
<VStack align="stretch" spacing={4}>
<Box>
<Heading size="sm" mb={2}>Vytvořit novou odměnu</Heading>
<VStack align="stretch" spacing={3} borderWidth="1px" borderRadius="md" p={3}>
<HStack>
<Input placeholder="Název" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} maxW="280px" />
<Select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })} maxW="220px">
<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>
<NumberInput value={form.cost_points} min={0} maxW="180px" onChange={(v) => setForm({ ...form, cost_points: Number(v) || 0 })}>
<NumberInputField placeholder="Body" />
</NumberInput>
<NumberInput value={form.stock} min={0} maxW="160px" onChange={(v) => setForm({ ...form, stock: Number(v) || 0 })}>
<NumberInputField placeholder="Sklad" />
</NumberInput>
<Input placeholder="Obrázek URL" value={form.image_url} onChange={(e) => setForm({ ...form, image_url: e.target.value })} />
<HStack>
<Text>Aktivní</Text>
<Switch isChecked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} />
</HStack>
<Button colorScheme="blue" onClick={() => createMut.mutate()} isLoading={createMut.isPending} isDisabled={!form.name.trim()}>Vytvořit</Button>
</HStack>
</VStack>
</Box>
<Divider />
<Box>
<Heading size="sm" mb={2}>Odměny</Heading>
<Box borderWidth="1px" borderRadius="md" overflowX="auto">
<Table size="sm">
<Thead>
<Tr>
<Th>ID</Th>
<Th>Název</Th>
<Th>Typ</Th>
<Th>Body</Th>
<Th>Sklad</Th>
<Th>Obrázek</Th>
<Th>Aktivní</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
{rewards.map((r: AdminRewardItem) => (
<Tr key={r.id}>
<Td>#{r.id}</Td>
<Td>{r.name}</Td>
<Td><Badge>{r.type}</Badge></Td>
<Td>
<NumberInput size="sm" value={r.cost_points} min={0} maxW="120px" onChange={(v) => updateMut.mutate({ id: r.id, body: { cost_points: Number(v) || 0 } })}>
<NumberInputField />
</NumberInput>
</Td>
<Td>
<NumberInput size="sm" value={r.stock || 0} min={0} maxW="100px" onChange={(v) => updateMut.mutate({ id: r.id, body: { stock: Number(v) || 0 } })}>
<NumberInputField />
</NumberInput>
</Td>
<Td>{r.image_url ? <Image src={r.image_url} alt={r.name} boxSize="40px" objectFit="cover" borderRadius="md" /> : '-'}</Td>
<Td>
<Switch isChecked={!!r.active} onChange={(e) => updateMut.mutate({ id: r.id, body: { active: e.target.checked } })} />
</Td>
<Td>
<IconButton aria-label="Smazat" size="xs" icon={<FiTrash2 />} onClick={() => deleteMut.mutate(r.id)} />
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
</Box>
<Box>
<Heading size="sm" mt={6} mb={2}>Uplatnění odměn</Heading>
<Box borderWidth="1px" borderRadius="md" overflowX="auto">
<Table size="sm">
<Thead>
<Tr>
<Th>ID</Th>
<Th>Uživatel</Th>
<Th>Odměna</Th>
<Th>Status</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
{redemptions.map((d: AdminRedemption) => (
<Tr key={d.id}>
<Td>#{d.id}</Td>
<Td>#{d.user_id}</Td>
<Td>#{d.reward_id}</Td>
<Td><Badge>{d.status}</Badge></Td>
<Td>
<HStack>
<Button size="xs" variant="outline" onClick={() => redStatusMut.mutate({ id: d.id, action: 'approve' })}>Schválit</Button>
<Button size="xs" variant="outline" colorScheme="red" onClick={() => redStatusMut.mutate({ id: d.id, action: 'reject' })}>Zamítnout</Button>
<Button size="xs" variant="outline" colorScheme="green" onClick={() => redStatusMut.mutate({ id: d.id, action: 'fulfill' })}>Vydat</Button>
</HStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
</Box>
</VStack>
</Box>
</AdminLayout>
);
};
export default EngagementAdminPage;
@@ -45,6 +45,7 @@ import {
Divider,
Code,
Icon,
Progress,
} from '@chakra-ui/react';
import { useState, useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -62,6 +63,7 @@ import {
refreshFileTracking,
formatFileSize,
getFileIcon,
getStorageUsage,
} from '../../services/files';
import { API_URL } from '../../services/api';
import { assetUrl } from '../../utils/url';
@@ -103,6 +105,12 @@ const FilesAdminPage: React.FC = () => {
queryFn: getDuplicateFiles,
});
// Storage usage
const { data: storageUsage } = useQuery({
queryKey: ['admin-files-usage'],
queryFn: getStorageUsage,
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: ({ id, force }: { id: number; force: boolean }) => deleteFile(id, force),
@@ -111,6 +119,7 @@ const FilesAdminPage: React.FC = () => {
qc.invalidateQueries({ queryKey: ['admin-files'] });
qc.invalidateQueries({ queryKey: ['admin-files-unused'] });
qc.invalidateQueries({ queryKey: ['admin-files-duplicates'] });
qc.invalidateQueries({ queryKey: ['admin-files-usage'] });
onDeleteClose();
setDeleteTarget(null);
setForceDelete(false);
@@ -144,6 +153,7 @@ const FilesAdminPage: React.FC = () => {
qc.invalidateQueries({ queryKey: ['admin-files'] });
qc.invalidateQueries({ queryKey: ['admin-files-unused'] });
qc.invalidateQueries({ queryKey: ['admin-files-duplicates'] });
qc.invalidateQueries({ queryKey: ['admin-files-usage'] });
},
onError: () => {
toast({ title: 'Chyba při skenování', status: 'error' });
@@ -307,6 +317,37 @@ const FilesAdminPage: React.FC = () => {
</HStack>
</HStack>
{storageUsage && (
<VStack align="stretch" spacing={2}>
{(storageUsage.status === 'warn' || storageUsage.status === 'critical') && (
<Alert status={storageUsage.status === 'critical' ? 'error' : 'warning'} borderRadius="md">
<AlertIcon />
<Box>
<AlertTitle>
{storageUsage.status === 'critical' ? 'Úložiště téměř plné' : 'Dochází místo v úložišti'}
</AlertTitle>
<AlertDescription>
Využito {storageUsage.percent.toFixed(1)}% ({formatFileSize(storageUsage.used_bytes)} z {formatFileSize(storageUsage.quota_bytes)}).
</AlertDescription>
</Box>
</Alert>
)}
<HStack>
<Text fontWeight="medium">Využití úložiště</Text>
<Spacer />
<Text fontSize="sm" color="gray.500">
{formatFileSize(storageUsage.used_bytes)} / {formatFileSize(storageUsage.quota_bytes)} ({storageUsage.percent.toFixed(1)}%)
</Text>
</HStack>
<Progress
value={Math.min(100, storageUsage.percent)}
colorScheme={storageUsage.status === 'critical' ? 'red' : storageUsage.status === 'warn' ? 'orange' : 'blue'}
height="10px"
borderRadius="md"
/>
</VStack>
)}
<Tabs colorScheme="blue" variant="enclosed">
<TabList>
<Tab>Všechny soubory ({allFiles.length})</Tab>
+43 -111
View File
@@ -25,12 +25,7 @@ import {
FormLabel,
Input,
Stack,
InputGroup,
InputRightElement,
List,
ListItem,
FormErrorMessage,
Image,
useBreakpointValue,
Wrap,
WrapItem,
@@ -39,15 +34,15 @@ import {
} from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { putMatchOverride, fetchTeamLogoOverrides } from '../../services/adminMatches';
import { putMatchOverride } from '../../services/adminMatches';
import { getPublicSettings } from '../../services/settings';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { parse, format } from 'date-fns';
import { assetUrl } from '../../utils/url';
import { batchFetchLogosFromSportLogosAPI } from '../../utils/sportLogosAPI';
import { API_URL } from '../../services/api';
import TeamLogo from '../../components/common/TeamLogo';
import { getCompetitionAliasesPublic } from '../../services/competitionAliases';
const MatchesAdminPage = () => {
const queryClient = useQueryClient();
@@ -57,16 +52,8 @@ const MatchesAdminPage = () => {
const [form, setForm] = useState({
venue_override: '',
date_time_edit: '',
notes: '',
});
const { data: overrides = {} } = useQuery({
queryKey: ['teamLogoOverrides'],
queryFn: fetchTeamLogoOverrides,
staleTime: 5 * 60 * 1000,
});
const overridesById: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
const normalizeName = (s: string) => {
let out = String(s || '');
out = out
@@ -91,64 +78,8 @@ const MatchesAdminPage = () => {
out = out.replace(/\s+/g, ' ').trim();
return out;
};
const byName: Record<string, string> = (overrides as any)?.by_name || {};
const byNameNormalized = useMemo(() => {
const idx: Record<string, string> = {};
for (const k of Object.keys(byName)) idx[normalizeName(k)] = byName[k];
return idx;
}, [byName]);
// Build name index from overrides by_id for cases where team_id is missing in cached data
const overridesNameIndex = useMemo(() => {
const idx: Record<string, { id: string; name: string; logo_url: string }> = {};
try {
for (const [id, v] of Object.entries(overridesById)) {
const name = String((v as any)?.name || '').trim();
const logo = String((v as any)?.logo_url || '').trim();
if (!name) continue;
const norm = normalizeName(name);
if (!norm) continue;
idx[norm] = { id, name, logo_url: logo };
}
} catch {}
return idx;
}, [overridesById]);
const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
const getLogo = (teamName?: string, teamId?: string, facrOriginal?: string) => {
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
// 0) Admin override by team ID takes precedence
if (teamId && overridesById[teamId] && overridesById[teamId]?.logo_url) {
const u = String(overridesById[teamId].logo_url);
if (u.startsWith('/')) return assetUrl(u) as string;
return u;
}
// 0.5) If no ID, but override exists for normalized name, use it
try {
const hit = overridesNameIndex[normalizeName(teamName)];
if (hit && hit.logo_url) {
const u = String(hit.logo_url);
if (u.startsWith('/')) return assetUrl(u) as string;
return u;
}
} catch {}
// 1) LogoAPI map by team ID
if (teamId && sportLogosMap[String(teamId)]) return sportLogosMap[String(teamId)];
// 2) Local/legacy overrides by name
let overrideUrl = byName[teamName];
if (!overrideUrl) overrideUrl = byNameNormalized[normalizeName(teamName)];
if (overrideUrl) {
if (overrideUrl.startsWith('/')) return assetUrl(overrideUrl) as string;
return overrideUrl;
}
// 3) FACR original if provided
if (facrOriginal) return facrOriginal;
// Fallback placeholder
return '/dist/img/logo-club-empty.svg';
};
// Team name/logo editing removed
const { data: matches = [], isLoading, error } = useQuery<any[], Error>({
@@ -201,23 +132,7 @@ const MatchesAdminPage = () => {
}));
},
});
useEffect(() => {
if (!Array.isArray(matches) || matches.length === 0) return;
const ids = new Set<string>();
for (const m of matches as any[]) {
if (m.home_id) ids.add(String(m.home_id));
if (m.away_id) ids.add(String(m.away_id));
}
if (ids.size === 0) return;
(async () => {
try {
const map = await batchFetchLogosFromSportLogosAPI(Array.from(ids));
setSportLogosMap(map);
} catch (e) {
console.warn('Failed to batch fetch logos:', e);
}
})();
}, [matches]);
// Filters
const [teamFilter, setTeamFilter] = useState('');
@@ -273,13 +188,37 @@ const MatchesAdminPage = () => {
const ts = stripPrefixes(team);
return !!clubNorm && (t.includes(clubNorm) || ts.includes(clubStrip) || t.endsWith(clubStrip) || clubStrip.endsWith(ts));
};
// Load competition aliases (ordered by display_order in backend)
const { data: compAliases = [] } = useQuery({
queryKey: ['competition-aliases-public'],
queryFn: getCompetitionAliasesPublic,
});
const competitionOptions = useMemo(() => {
const set = new Set<string>();
for (const m of matches) {
if (m.competitionName) set.add(String(m.competitionName));
}
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [matches]);
const arr = Array.from(set);
const getOrder = (name: string): number => {
if (!Array.isArray(compAliases) || compAliases.length === 0) return Number.MAX_SAFE_INTEGER;
const n = normalizeName(name);
for (let i = 0; i < compAliases.length; i++) {
const al: any = compAliases[i] as any;
const a1 = normalizeName(String(al.alias || ''));
const a2 = normalizeName(String(al.original_name || ''));
if ((a1 && (n.includes(a1) || a1.includes(n))) || (a2 && (n.includes(a2) || a2.includes(n)))) {
return i;
}
}
return Number.MAX_SAFE_INTEGER;
};
return arr.sort((a, b) => {
const oa = getOrder(a);
const ob = getOrder(b);
if (oa !== ob) return oa - ob;
return a.localeCompare(b);
});
}, [matches, compAliases]);
const filteredMatches = matches.filter((m: any) => {
// team filter
const teamOk = normalizedTeam
@@ -440,7 +379,6 @@ const MatchesAdminPage = () => {
const payload: any = {
venue_override: form.venue_override,
date_time_override: form.date_time_edit,
notes: form.notes,
};
Object.keys(payload).forEach((k) => {
if (payload[k as keyof typeof payload] === '') payload[k as keyof typeof payload] = null;
@@ -481,7 +419,6 @@ const MatchesAdminPage = () => {
setForm({
venue_override: m.venue || '',
date_time_edit: localStr,
notes: '',
});
setIsOpen(true);
};
@@ -902,11 +839,12 @@ const MatchesAdminPage = () => {
</Td>
<Td>
<HStack spacing={2}>
<Image
src={getLogo(m.home || m.home_team || '', m.home_id, m.home_logo_url)}
<TeamLogo
teamId={m.home_id ? String(m.home_id) : undefined}
teamName={m.home || m.home_team || ''}
facrLogo={m.home_logo_url}
size="small"
alt={m.home || m.home_team || ''}
boxSize="24px"
objectFit="contain"
loading="lazy"
decoding="async"
draggable={false}
@@ -922,11 +860,12 @@ const MatchesAdminPage = () => {
</Td>
<Td>
<HStack spacing={2}>
<Image
src={getLogo(m.away || m.away_team || '', m.away_id, m.away_logo_url)}
<TeamLogo
teamId={m.away_id ? String(m.away_id) : undefined}
teamName={m.away || m.away_team || ''}
facrLogo={m.away_logo_url}
size="small"
alt={m.away || m.away_team || ''}
boxSize="24px"
objectFit="contain"
loading="lazy"
decoding="async"
draggable={false}
@@ -992,14 +931,7 @@ const MatchesAdminPage = () => {
{/* Team name/logo editing removed */}
<FormControl>
<FormLabel>Poznámka</FormLabel>
<Input
placeholder="Libovolná poznámka (interní)"
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
/>
</FormControl>
</Stack>
)}
</DrawerBody>
+10 -3
View File
@@ -40,7 +40,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { Player, getPlayers, createPlayer, updatePlayer, deletePlayer } from '../../services/players';
import { uploadFile } from '../../services/articles';
import { translateNationality } from '../../utils/nationality';
import { translateNationality, getCountryFlag } from '../../utils/nationality';
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
import { assetUrl } from '../../utils/url';
@@ -376,7 +376,7 @@ const PlayersAdminPage: React.FC = () => {
<Tbody>
{isLoading && (<Tr><Td colSpan={7}>Načítám...</Td></Tr>)}
{!isLoading && (data || []).map((p) => (
<Tr key={p.id}>
<Tr key={p.id} opacity={p.is_active ? 1 : 0.6}>
<Td>
<ThumbnailPreview
src={assetUrl(p.image_url) || '/logo192.png'}
@@ -388,7 +388,14 @@ const PlayersAdminPage: React.FC = () => {
</Td>
<Td>{p.first_name} {p.last_name}</Td>
<Td>{p.position || '-'}</Td>
<Td>{p.nationality ? translateNationality(p.nationality) : '-'}</Td>
<Td>
{p.nationality ? (
<HStack spacing={2}>
<span>{getCountryFlag(p.nationality)}</span>
<span>{translateNationality(p.nationality)}</span>
</HStack>
) : '-'}
</Td>
<Td>{p.jersey_number ?? '-'}</Td>
<Td><Switch isChecked={!!p.is_active} onChange={() => { if (p.id != null) updateMut.mutate({ id: p.id, payload: { is_active: !p.is_active } }); }} /></Td>
<Td>
+156 -9
View File
@@ -66,12 +66,18 @@ import {
updatePoll,
deletePoll,
getPollStats,
getPollVotes,
Poll,
CreatePollRequest,
UpdatePollRequest,
PollStats,
PollVote,
} from '../../services/polls';
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
import { Doughnut, Line, Bar } from 'react-chartjs-2';
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, PointElement, LineElement, BarElement } from 'chart.js';
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, PointElement, LineElement, BarElement);
const PollsAdminPage: React.FC = () => {
const toast = useToast();
@@ -187,6 +193,40 @@ const PollsAdminPage: React.FC = () => {
enabled: !!selectedPollStats?.poll?.id,
});
// Votes list (admin details)
const { data: votesData, isLoading: isLoadingVotes } = useQuery<PollVote[]>({
queryKey: ['poll-votes', selectedPollStats?.poll?.id],
queryFn: () => getPollVotes(selectedPollStats!.poll.id),
enabled: !!selectedPollStats?.poll?.id,
});
const exportVotesCSV = () => {
if (!votesData) return;
const header = ['id','poll_id','option_id','option_text','user_id','user_email','user_first_name','user_last_name','voter_name','voter_email','session_token','created_at'];
const rows = votesData.map(v => [
v.id,
v.poll_id,
v.option_id,
JSON.stringify(v.option_text || ''),
v.user_id ?? '',
v.user_email || '',
v.user_first_name || '',
v.user_last_name || '',
v.voter_name || '',
v.voter_email || '',
v.session_token || '',
v.created_at,
]);
const csv = [header.join(','), ...rows.map(r => r.join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `poll_${selectedPollStats?.poll?.id || ''}_votes.csv`;
a.click();
URL.revokeObjectURL(url);
};
const resetForm = () => {
setFormData({
title: '',
@@ -514,7 +554,7 @@ const PollsAdminPage: React.FC = () => {
</Thead>
<Tbody>
{polls?.map((poll) => (
<Tr key={poll.id}>
<Tr key={poll.id} opacity={poll.status === 'draft' ? 0.6 : 1}>
<Td>
<VStack align="start" spacing={0}>
<Text fontWeight="bold">{poll.title}</Text>
@@ -1010,16 +1050,123 @@ const PollsAdminPage: React.FC = () => {
<Heading size="sm" mb={4}>
Hlasy podle dnů
</Heading>
<VStack spacing={2} align="stretch">
{(statsData.votes_by_day || []).map((day) => (
<HStack key={day.date} justify="space-between">
<Text>{new Date(day.date).toLocaleDateString('cs-CZ')}</Text>
<Badge>{day.count} hlasů</Badge>
</HStack>
))}
</VStack>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={6}>
<Box>
<VStack spacing={2} align="stretch">
{(statsData.votes_by_day || []).map((day) => (
<HStack key={day.date} justify="space-between">
<Text>{new Date(day.date).toLocaleDateString('cs-CZ')}</Text>
<Badge>{day.count} hlasů</Badge>
</HStack>
))}
</VStack>
</Box>
<Box>
<Line
data={{
labels: (statsData.votes_by_day || []).map(d => new Date(d.date).toLocaleDateString('cs-CZ')),
datasets: [
{
label: 'Hlasy',
data: (statsData.votes_by_day || []).map(d => d.count),
borderColor: '#3182ce',
backgroundColor: 'rgba(49,130,206,0.2)',
tension: 0.3,
},
],
}}
options={{ responsive: true, plugins: { legend: { display: false } } }}
/>
</Box>
</SimpleGrid>
</Box>
)}
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={6}>
<Box>
<Heading size="sm" mb={3}>Složení hlasujících</Heading>
<Doughnut
data={{
labels: ['Přihlášení', 'Hosté'],
datasets: [
{
data: [statsData.authenticated_votes, statsData.guest_votes],
backgroundColor: ['#2f855a', '#718096'],
},
],
}}
options={{ plugins: { legend: { position: 'bottom' } } }}
/>
</Box>
<Box>
<Heading size="sm" mb={3}>Rozdělení hlasů podle možností</Heading>
<Bar
data={{
labels: (statsData.poll.options || []).map(o => o.text),
datasets: [
{
label: 'Hlasy',
data: (statsData.poll.options || []).map(o => o.vote_count),
backgroundColor: '#3182ce',
},
],
}}
options={{
responsive: true,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, ticks: { precision: 0 } } },
}}
/>
</Box>
</SimpleGrid>
<Box>
<HStack justify="space-between" mb={3}>
<Heading size="sm">Hlasující</Heading>
<Button size="sm" onClick={exportVotesCSV} isDisabled={!votesData || votesData.length === 0}>Export CSV</Button>
</HStack>
{isLoadingVotes ? (
<HStack><Spinner size="sm" /><Text>Načítání hlasů...</Text></HStack>
) : (votesData && votesData.length > 0) ? (
<Box overflowX="auto">
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th>Datum</Th>
<Th>Jméno</Th>
<Th>E-mail</Th>
<Th>Typ</Th>
<Th>Možnost</Th>
<Th>Session</Th>
</Tr>
</Thead>
<Tbody>
{votesData.slice(0, 100).map((v) => {
const name = v.voter_name || ((v.user_first_name || '') + ' ' + (v.user_last_name || '')).trim();
const email = v.voter_email || v.user_email || '';
const type = v.user_id ? 'Přihlášený' : 'Host';
const session = (v.session_token || '').slice(-8);
return (
<Tr key={v.id}>
<Td>{new Date(v.created_at).toLocaleString('cs-CZ')}</Td>
<Td>{name || '-'}</Td>
<Td>{email || '-'}</Td>
<Td><Badge colorScheme={v.user_id ? 'green' : 'gray'}>{type}</Badge></Td>
<Td>{v.option_text}</Td>
<Td>{session}</Td>
</Tr>
);
})}
</Tbody>
</Table>
{votesData.length > 100 && (
<Text fontSize="xs" color="gray.500" mt={2}>Zobrazeno 100 z {votesData.length} hlasů</Text>
)}
</Box>
) : (
<Text fontSize="sm" color="gray.500">Žádné hlasy k zobrazení.</Text>
)}
</Box>
</VStack>
) : null}
</ModalBody>
@@ -184,7 +184,7 @@ const SponsorsAdminPage: React.FC = () => {
<Tbody>
{isLoading && (<Tr><Td colSpan={7}>Načítám...</Td></Tr>)}
{!isLoading && (data || []).map((s) => (
<Tr key={s.id}>
<Tr key={s.id} opacity={s.is_active ? 1 : 0.6}>
<Td>
<Image src={normalizeImageUrl(s.logo_url)} alt={s.name} boxSize="48px" objectFit="contain" />
</Td>
+76 -6
View File
@@ -193,6 +193,9 @@ const TeamsAdminPage = () => {
}
return idx;
}, [byName]);
const byNamePairs = useMemo(() => {
return Object.keys(byName || {}).map((k) => ({ keyNorm: normalize(k), url: byName[k] }));
}, [byName]);
const getLogo = (teamName?: string, teamId?: string, original?: string) => {
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
@@ -204,9 +207,27 @@ const TeamsAdminPage = () => {
}
// Priority 0.5: Try match by override name when team_id is missing
try {
const hit = overridesNameIndex[normalize(teamName)];
if (hit && hit.logo_url) {
const u = String(hit.logo_url);
const norm = normalize(teamName);
let hit = overridesNameIndex[norm];
if (!hit) {
// Suffix/containment match: allow sponsor words before/after core name
for (const [keyNorm, val] of Object.entries(overridesNameIndex)) {
if (!keyNorm) continue;
if (norm.endsWith(keyNorm) || keyNorm.endsWith(norm)) { hit = val as any; break; }
}
}
if (!hit) {
const norm2 = normalize(teamName);
const t1 = norm2.split(' ')[0];
if (t1 && t1.length >= 5) {
for (const [keyNorm, val] of Object.entries(overridesNameIndex)) {
const k1 = String(keyNorm).split(' ')[0];
if (k1 === t1) { hit = val as any; break; }
}
}
}
if (hit && (hit as any).logo_url) {
const u = String((hit as any).logo_url);
if (u.startsWith('/')) return assetUrl(u) as string;
return u;
}
@@ -217,6 +238,14 @@ const TeamsAdminPage = () => {
const norm = normalize(teamName);
overrideUrl = byNameNormalized[norm];
}
if (!overrideUrl) {
// Suffix/containment against normalized keys
const norm = normalize(teamName);
for (const { keyNorm, url } of byNamePairs) {
if (!keyNorm) continue;
if (norm.endsWith(keyNorm) || keyNorm.endsWith(norm)) { overrideUrl = url; break; }
}
}
if (overrideUrl) {
if (typeof overrideUrl === 'string' && overrideUrl.startsWith('/')) {
return assetUrl(overrideUrl) as string;
@@ -245,9 +274,25 @@ const TeamsAdminPage = () => {
// If no ID, but override exists for the normalized name, use canonical override name
try {
if (teamName) {
const hit = overridesNameIndex[normalize(teamName)];
if (hit && hit.name) {
return hit.name;
const norm = normalize(teamName);
let hit = overridesNameIndex[norm];
if (!hit) {
for (const [keyNorm, val] of Object.entries(overridesNameIndex)) {
if (!keyNorm) continue;
if (norm.endsWith(keyNorm) || keyNorm.endsWith(norm)) { hit = val as any; break; }
}
}
if (!hit) {
const t1 = norm.split(' ')[0];
if (t1 && t1.length >= 5) {
for (const [keyNorm, val] of Object.entries(overridesNameIndex)) {
const k1 = String(keyNorm).split(' ')[0];
if (k1 === t1) { hit = val as any; break; }
}
}
}
if (hit && (hit as any).name) {
return (hit as any).name as string;
}
}
} catch {}
@@ -407,6 +452,7 @@ const TeamsAdminPage = () => {
} catch {}
if (logoUrl) {
let uploadAttempted = false;
let shouldUpload = Boolean(uploadedFile);
try {
const abs = logoUrl.startsWith('/') ? new URL(logoUrl, backendOrigin).toString() : logoUrl;
@@ -426,6 +472,7 @@ const TeamsAdminPage = () => {
} catch {}
if (shouldUpload) {
uploadAttempted = true;
setExternalUploadStatus('uploading');
setExternalUploadError(null);
try {
@@ -447,6 +494,17 @@ const TeamsAdminPage = () => {
if (logaResult.url) {
logoUrl = logaResult.url;
}
try {
let confirmedUrl: string | null = null;
for (let i = 0; i < 10; i++) {
confirmedUrl = await fetchLogoFromLogoAPI(form.external_team_id, primaryName);
if (confirmedUrl) break;
await new Promise((r) => setTimeout(r, 700));
}
if (confirmedUrl) {
logoUrl = confirmedUrl;
}
} catch {}
} else {
setExternalUploadStatus('error');
setExternalUploadError(logaResult.error || 'Nepodařilo se nahrát logo');
@@ -460,6 +518,18 @@ const TeamsAdminPage = () => {
setExternalUploadError(error?.message || 'Upload failed');
}
}
if (uploadAttempted) {
try {
const abs = logoUrl.startsWith('/') ? new URL(logoUrl, backendOrigin).toString() : logoUrl;
const host = new URL(abs).hostname.toLowerCase();
if (host !== 'logoapi.sportcreative.eu') {
throw new Error('Externí upload loga ještě není dostupný. Zkuste uložit znovu za chvíli.');
}
} catch (e: any) {
throw new Error(e?.message || 'Externí upload loga selhal');
}
}
}
await putTeamLogoOverride(form.external_team_id, primaryName, logoUrl);
+44
View File
@@ -0,0 +1,44 @@
import api from '../../services/api';
import { CommentItem } from '../../services/comments';
export type AdminCommentsList = {
items: CommentItem[];
total: number;
page: number;
page_size: number;
};
export async function adminListComments(params: { status?: 'visible'|'hidden'; target_type?: string; target_id?: string; user_id?: string; page?: number; page_size?: number; }): Promise<AdminCommentsList> {
const res = await api.get('/admin/comments', { params });
return res.data as AdminCommentsList;
}
export async function adminUpdateCommentStatus(id: number, status: 'visible'|'hidden'): Promise<{ ok: boolean }>{
const res = await api.patch(`/admin/comments/${id}/status`, { status });
return res.data as { ok: boolean };
}
export async function adminBanUser(user_id: number, reason: string, duration_hours?: number): Promise<{ ok: boolean }>{
const res = await api.post('/admin/comments/ban', { user_id, reason, duration_hours: duration_hours || 0 });
return res.data as { ok: boolean };
}
export type UnbanRequest = {
id: number;
user_id: number;
message: string;
status: 'pending'|'approved'|'rejected';
created_at: string;
resolved_by_id?: number | null;
resolved_at?: string | null;
};
export async function adminListUnbanRequests(): Promise<{ items: UnbanRequest[] }>{
const res = await api.get('/admin/comments/unban-requests');
return res.data as { items: UnbanRequest[] };
}
export async function adminResolveUnban(id: number, action: 'approve'|'reject'): Promise<{ ok: boolean }>{
const res = await api.post(`/admin/comments/unban-requests/${id}/resolve`, { action });
return res.data as { ok: boolean };
}
+68
View File
@@ -0,0 +1,68 @@
import api from '../api';
import { RewardItem } from '../../services/engagement';
export type AdminRewardItem = RewardItem & {
active: boolean;
stock: number;
metadata?: Record<string, any>;
created_at?: string;
updated_at?: string;
};
export type AdminRewardsResponse = { items: AdminRewardItem[] };
export async function adminListRewards(params?: { active?: boolean }): Promise<AdminRewardItem[]> {
const res = await api.get('/admin/engagement/rewards', { params });
return (res.data as AdminRewardsResponse).items || [];
}
export async function adminCreateReward(body: {
name: string;
type: string;
cost_points: number;
image_url?: string;
stock?: number;
active?: boolean;
metadata?: Record<string, any>;
}): Promise<AdminRewardItem> {
const res = await api.post('/admin/engagement/rewards', body);
return res.data as AdminRewardItem;
}
export async function adminUpdateReward(id: number, body: Partial<{
name: string;
type: string;
cost_points: number;
image_url: string;
stock: number;
active: boolean;
metadata: Record<string, any>;
}>): Promise<{ ok: boolean }>{
const res = await api.put(`/admin/engagement/rewards/${id}`, body);
return res.data as { ok: boolean };
}
export async function adminDeleteReward(id: number): Promise<{ ok: boolean }>{
const res = await api.delete(`/admin/engagement/rewards/${id}`);
return res.data as { ok: boolean };
}
export type AdminRedemption = {
id: number;
user_id: number;
reward_id: number;
status: 'pending'|'approved'|'rejected'|'fulfilled'|string;
created_at?: string;
};
export type AdminRedemptionsResponse = { items: AdminRedemption[] };
export async function adminListRedemptions(params?: { status?: string }): Promise<AdminRedemption[]> {
const res = await api.get('/admin/engagement/redemptions', { params });
return (res.data as AdminRedemptionsResponse).items || [];
}
export async function adminUpdateRedemptionStatus(id: number, action: 'approve'|'reject'|'fulfill'): Promise<{ ok: boolean; status: string }>{
const res = await api.patch(`/admin/engagement/redemptions/${id}`, { action });
return res.data as { ok: boolean; status: string };
}
+33
View File
@@ -28,6 +28,39 @@ export async function generateBlogAI(payload: AIGenerateBlogReq): Promise<AIGene
return parsedData;
}
// Instagram generation
export interface AIGenerateInstagramMatch {
home?: string;
away?: string;
competition?: string;
date_time?: string;
venue?: string;
score?: string;
}
export interface AIGenerateInstagramReq {
type?: 'article' | 'event' | 'generic' | string;
title?: string;
content?: string;
club_name?: string;
link: string;
hashtags?: string[];
audience?: string;
tone?: string;
match?: AIGenerateInstagramMatch | null;
}
export interface AIGenerateInstagramResp { text: string }
export async function generateInstagramAI(payload: AIGenerateInstagramReq): Promise<AIGenerateInstagramResp> {
const { data } = await api.post<AIGenerateInstagramResp>('/ai/instagram/generate', payload);
let parsed: any = data;
if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed); } catch { parsed = { text: '' }; }
}
return parsed as AIGenerateInstagramResp;
}
export interface AIGenerateCSSReq {
prompt: string;
element_name?: string;
+37 -2
View File
@@ -35,14 +35,49 @@ export const api: AxiosInstance = axios.create({
timeout: 20000, // 20 seconds to better tolerate slower endpoints
});
// Simple in-memory CSRF token cache
let csrfTokenCache: { token: string; fetchedAt: number } | null = null;
async function getCsrfToken(): Promise<string | null> {
try {
// Refresh token every 45 minutes
const now = Date.now();
if (csrfTokenCache && now - csrfTokenCache.fetchedAt < 45 * 60 * 1000) {
return csrfTokenCache.token;
}
const res = await fetch(`${API_URL.replace(/\/$/, '')}/csrf-token`, {
credentials: 'include',
headers: { 'Accept': 'application/json' },
});
if (!res.ok) return null;
const data = await res.json();
const token = data?.csrf_token || null;
if (token) {
csrfTokenCache = { token, fetchedAt: now };
}
return token;
} catch {
return null;
}
}
// Request interceptor - attach bearer token when available
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
async (config: InternalAxiosRequestConfig) => {
const token = getToken();
config.headers = config.headers || {};
if (token) {
config.headers = config.headers || {};
(config.headers as any).Authorization = `Bearer ${token}`;
}
// For cookie-based flows (no Bearer header), attach X-CSRF-Token on mutating methods
const method = (config.method || 'get').toLowerCase();
const isMutating = method === 'post' || method === 'put' || method === 'patch' || method === 'delete';
const hasAuth = !!(config.headers as any).Authorization;
if (isMutating && !hasAuth) {
const csrf = await getCsrfToken();
if (csrf) {
(config.headers as any)['X-CSRF-Token'] = csrf;
}
}
return config;
},
(error) => {
+7 -18
View File
@@ -1,5 +1,4 @@
import axios from 'axios';
import { API_URL as API_BASE_URL } from './api';
import api from './api';
export interface ClothingItem {
id: number;
@@ -21,44 +20,34 @@ export interface ClothingResponse {
// Public endpoint - get all active clothing items
export const getClothing = async (): Promise<ClothingItem[]> => {
const response = await axios.get<ClothingResponse>(`${API_BASE_URL}/clothing`);
const response = await api.get<ClothingResponse>('/clothing');
return response.data.data;
};
// Admin endpoint - get all clothing items
export const getClothingAdmin = async (): Promise<ClothingItem[]> => {
const response = await axios.get<ClothingResponse>(`${API_BASE_URL}/admin/clothing`, {
withCredentials: true,
});
const response = await api.get<ClothingResponse>('/admin/clothing');
return response.data.data;
};
// Admin endpoint - create clothing item
export const createClothing = async (data: Partial<ClothingItem>): Promise<ClothingItem> => {
const response = await axios.post<ClothingItem>(`${API_BASE_URL}/admin/clothing`, data, {
withCredentials: true,
});
const response = await api.post<ClothingItem>('/admin/clothing', data);
return response.data;
};
// Admin endpoint - update clothing item
export const updateClothing = async (id: number, data: Partial<ClothingItem>): Promise<ClothingItem> => {
const response = await axios.put<ClothingItem>(`${API_BASE_URL}/admin/clothing/${id}`, data, {
withCredentials: true,
});
const response = await api.put<ClothingItem>(`/admin/clothing/${id}`, data);
return response.data;
};
// Admin endpoint - delete clothing item
export const deleteClothing = async (id: number): Promise<void> => {
await axios.delete(`${API_BASE_URL}/admin/clothing/${id}`, {
withCredentials: true,
});
await api.delete(`/admin/clothing/${id}`);
};
// Admin endpoint - update display order
export const updateClothingOrder = async (items: Array<{ id: number; display_order: number }>): Promise<void> => {
await axios.post(`${API_BASE_URL}/admin/clothing/reorder`, items, {
withCredentials: true,
});
await api.post('/admin/clothing/reorder', items);
};
+72
View File
@@ -0,0 +1,72 @@
import api from './api';
export type TargetType = 'article' | 'event' | 'gallery_album' | 'youtube_video';
export type CommentItem = {
id: number;
target_type: TargetType;
target_id: string;
parent_id?: number | null;
content: string;
status?: 'visible' | 'hidden';
is_edited?: boolean;
edited_at?: string | null;
created_at: string;
updated_at: string;
reactions?: Record<string, number>;
my_reaction?: string;
user: {
id: number;
first_name?: string;
last_name?: string;
email?: string;
role?: string;
};
};
export type CommentsResponse = {
items: CommentItem[];
total: number;
page: number;
page_size: number;
};
export async function listComments(params: { target_type: TargetType; target_id: string; page?: number; page_size?: number; }): Promise<CommentsResponse> {
const res = await api.get('/comments', { params });
return res.data as CommentsResponse;
}
export async function createComment(body: { target_type: TargetType; target_id: string; content: string; parent_id?: number | null; }): Promise<CommentItem> {
const res = await api.post('/comments', body);
return res.data as CommentItem;
}
export async function updateComment(id: number, body: { content: string; }): Promise<CommentItem> {
const res = await api.put(`/comments/${id}`, body);
return res.data as CommentItem;
}
export async function deleteComment(id: number): Promise<{ ok: boolean }>{
const res = await api.delete(`/comments/${id}`);
return res.data as { ok: boolean };
}
export async function reactComment(id: number, type: string): Promise<{ ok: boolean }>{
const res = await api.post(`/comments/${id}/react`, { type });
return res.data as { ok: boolean };
}
export async function unreactComment(id: number): Promise<{ ok: boolean }>{
const res = await api.delete(`/comments/${id}/react`);
return res.data as { ok: boolean };
}
export async function requestUnban(message: string): Promise<{ ok: boolean }>{
const res = await api.post('/comments/unban-request', { message });
return res.data as { ok: boolean };
}
export async function reportComment(id: number, reason?: string): Promise<{ ok: boolean }>{
const res = await api.post(`/comments/${id}/report`, { reason });
return res.data as { ok: boolean };
}
+66
View File
@@ -0,0 +1,66 @@
import api from './api';
export type EngagementProfile = {
user_id: number;
points: number;
level: number;
xp: number;
avatar_url?: string;
animated_avatar_url?: string;
achievements: number;
};
export async function getProfile(): Promise<EngagementProfile> {
const res = await api.get('/engagement/profile');
return res.data as EngagementProfile;
}
export type RewardItem = {
id: number;
name: string;
type: 'avatar_static' | 'avatar_animated' | 'merch_coupon' | 'custom' | string;
cost_points: number;
image_url?: string;
stock?: number;
active?: boolean;
metadata?: Record<string, any>;
};
export async function getRewards(): Promise<RewardItem[]> {
const res = await api.get('/engagement/rewards');
return res.data as RewardItem[];
}
export async function patchAvatar(body: { avatar_url?: string; animated_avatar_url?: string }): Promise<{ ok: boolean }>{
const res = await api.patch('/engagement/avatar', body);
return res.data as { ok: boolean };
}
export async function redeemReward(reward_id: number): Promise<{ ok: boolean; status: string }>{
const res = await api.post('/engagement/redeem', { reward_id });
return res.data as { ok: boolean; status: string };
}
export type AchievementsResponse = {
achievements: Array<{
id: number;
code: string;
title: string;
description: string;
points: number;
xp: number;
icon?: string;
achieved: boolean;
achieved_at?: string;
}>;
counters: {
comments: number;
votes: number;
newsletter: boolean;
};
};
export async function getAchievements(): Promise<AchievementsResponse> {
const res = await api.get('/engagement/achievements');
return res.data as AchievementsResponse;
}
+18
View File
@@ -45,6 +45,17 @@ export interface DuplicateFiles {
[hash: string]: FileInfo[];
}
export interface StorageUsage {
used_bytes: number;
used_count: number;
quota_mb: number;
quota_bytes: number;
percent: number;
warn_percent: number;
critical_percent: number;
status: 'ok' | 'warn' | 'critical';
}
export const getAllFiles = async (params?: {
search?: string;
mime_type?: string;
@@ -72,6 +83,13 @@ export const getDuplicateFiles = async (): Promise<DuplicateFiles> => {
return response.data;
};
export const getStorageUsage = async (): Promise<StorageUsage> => {
const response = await axios.get(`${API_URL}/admin/files/usage`, {
withCredentials: true,
});
return response.data;
};
export const getFileUsages = async (fileId: number): Promise<any[]> => {
const response = await axios.get(`${API_URL}/admin/files/${fileId}/usages`, {
withCredentials: true,
+51 -8
View File
@@ -27,31 +27,73 @@ export interface SocialLink {
icon?: string;
}
// Normalize backend objects (backend may return `ID` instead of `id`)
function normalizeNavItem(raw: any): NavigationItem {
if (!raw || typeof raw !== 'object') return raw as NavigationItem;
const id = raw.id ?? raw.ID;
const children = Array.isArray(raw.children)
? raw.children.map((c: any) => normalizeNavItem(c))
: undefined;
return {
id,
label: raw.label,
url: raw.url,
icon: raw.icon,
type: raw.type,
page_type: raw.page_type,
page_id: raw.page_id,
visible: raw.visible,
display_order: raw.display_order,
parent_id: raw.parent_id,
children,
target: raw.target,
css_class: raw.css_class,
requires_auth: raw.requires_auth,
requires_admin: raw.requires_admin,
} as NavigationItem;
}
function normalizeSocialLink(raw: any): SocialLink {
if (!raw || typeof raw !== 'object') return raw as SocialLink;
const id = raw.id ?? raw.ID;
return {
id,
platform: raw.platform,
url: raw.url,
display_order: raw.display_order,
visible: raw.visible,
icon: raw.icon,
} as SocialLink;
}
// Public endpoints
export const getNavigationItems = async (): Promise<NavigationItem[]> => {
const response = await api.get(`/navigation`);
return response.data;
const data = Array.isArray(response.data) ? response.data : [];
return data.map((it: any) => normalizeNavItem(it));
};
export const getSocialLinks = async (): Promise<SocialLink[]> => {
const response = await api.get(`/social-links`);
return response.data;
const data = Array.isArray(response.data) ? response.data : [];
return data.map((it: any) => normalizeSocialLink(it));
};
// Admin endpoints
export const getAllNavigationItems = async (): Promise<NavigationItem[]> => {
const response = await api.get(`/admin/navigation`);
return response.data;
const data = Array.isArray(response.data) ? response.data : [];
return data.map((it: any) => normalizeNavItem(it));
};
export const createNavigationItem = async (item: Partial<NavigationItem>): Promise<NavigationItem> => {
const response = await api.post(`/admin/navigation`, item);
return response.data;
return normalizeNavItem(response.data);
};
export const updateNavigationItem = async (id: number, item: Partial<NavigationItem>): Promise<NavigationItem> => {
const response = await api.put(`/admin/navigation/${id}`, item);
return response.data;
return normalizeNavItem(response.data);
};
export const deleteNavigationItem = async (id: number): Promise<void> => {
@@ -65,17 +107,18 @@ export const reorderNavigationItems = async (orders: { id: number; display_order
// Social links admin endpoints
export const getAllSocialLinks = async (): Promise<SocialLink[]> => {
const response = await api.get(`/admin/social-links`);
return response.data;
const data = Array.isArray(response.data) ? response.data : [];
return data.map((it: any) => normalizeSocialLink(it));
};
export const createSocialLink = async (link: Partial<SocialLink>): Promise<SocialLink> => {
const response = await api.post(`/admin/social-links`, link);
return response.data;
return normalizeSocialLink(response.data);
};
export const updateSocialLink = async (id: number, link: Partial<SocialLink>): Promise<SocialLink> => {
const response = await api.put(`/admin/social-links/${id}`, link);
return response.data;
return normalizeSocialLink(response.data);
};
export const deleteSocialLink = async (id: number): Promise<void> => {
+8 -19
View File
@@ -1,5 +1,4 @@
import axios from 'axios';
import { API_URL as API_BASE_URL } from './api';
import api from './api';
import { IconType } from 'react-icons';
import {
FaRegClipboard,
@@ -52,7 +51,7 @@ export interface PageElementConfig {
// Public endpoints
export const getPageElementConfigs = async (pageType: string): Promise<PageElementConfig[]> => {
const response = await axios.get(`${API_BASE_URL}/page-elements`, {
const response = await api.get('/page-elements', {
params: { page_type: pageType }
});
return response.data || [];
@@ -60,36 +59,26 @@ export const getPageElementConfigs = async (pageType: string): Promise<PageEleme
// Admin endpoints
export const getAllPageElementConfigs = async (): Promise<PageElementConfig[]> => {
const response = await axios.get(`${API_BASE_URL}/admin/page-elements`, {
withCredentials: true,
});
const response = await api.get('/admin/page-elements');
return response.data || [];
};
export const createOrUpdatePageElementConfig = async (config: Partial<PageElementConfig>): Promise<PageElementConfig> => {
const response = await axios.post(`${API_BASE_URL}/admin/page-elements`, config, {
withCredentials: true,
});
const response = await api.post('/admin/page-elements', config);
return response.data;
};
export const updatePageElementConfig = async (id: number, config: Partial<PageElementConfig>): Promise<PageElementConfig> => {
const response = await axios.put(`${API_BASE_URL}/admin/page-elements/${id}`, config, {
withCredentials: true,
});
const response = await api.put(`/admin/page-elements/${id}`, config);
return response.data;
};
export const deletePageElementConfig = async (id: number): Promise<void> => {
await axios.delete(`${API_BASE_URL}/admin/page-elements/${id}`, {
withCredentials: true,
});
await api.delete(`/admin/page-elements/${id}`);
};
export const batchUpdatePageElementConfigs = async (configs: PageElementConfig[]): Promise<{ message: string; updated: number; created: number }> => {
const response = await axios.post(`${API_BASE_URL}/admin/page-elements/batch`, configs, {
withCredentials: true,
});
const response = await api.post('/admin/page-elements/batch', configs);
return response.data;
};
@@ -138,7 +127,7 @@ export const PREDEFINED_ELEMENTS: PredefinedElement[] = [
// Media - Média
{ name: 'gallery', label: 'Galerie', description: 'Fotogalerie', icon: FaImages, category: 'media', defaultVariant: 'grid' },
{ name: 'videos', label: 'Videa', description: 'YouTube videa a sestřihy', icon: FaVideo, category: 'media', defaultVariant: 'grid' },
{ name: 'videos', label: 'Videa', description: 'YouTube videa a sestřihy', icon: FaVideo, category: 'media', defaultVariant: 'carousel' },
{ name: 'live', label: 'Live Stream', description: 'Živé přenosy zápasů', icon: FaBroadcastTower, category: 'media', defaultVariant: 'featured' },
{ name: 'podcast', label: 'Podcast', description: 'Zvukové podcasty a komentáře', icon: FaPodcast, category: 'media', defaultVariant: 'list' },
{ name: 'social', label: 'Sociální Sítě', description: 'Příspěvky ze sociálních sítí', icon: FaHashtag, category: 'media', defaultVariant: 'grid' },
+20
View File
@@ -161,6 +161,21 @@ export interface PollStats {
guest_votes: number;
}
export interface PollVote {
id: number;
poll_id: number;
option_id: number;
option_text: string;
user_id?: number;
user_email?: string;
user_first_name?: string;
user_last_name?: string;
voter_name?: string;
voter_email?: string;
session_token?: string;
created_at: string;
}
// Public API
export const getPolls = async (params?: {
@@ -242,6 +257,11 @@ export const getPollStats = async (id: number): Promise<PollStats> => {
return response.data;
};
export const getPollVotes = async (id: number): Promise<PollVote[]> => {
const response = await api.get(`/admin/polls/${id}/votes`);
return response.data.votes as PollVote[];
};
// Helper to generate a session token for guest voting
export const generateSessionToken = (): string => {
const stored = localStorage.getItem('poll_session_token');
+3 -1
View File
@@ -18,6 +18,7 @@ export type Player = {
email?: string;
phone?: string;
team_id?: number;
team?: { id?: number; name?: string };
};
export type Sponsor = { id: number; name: string; logo_url?: string; website_url?: string; tier?: string; display_order?: number };
export type Category = { id?: number; name: string; slug?: string; url?: string; children?: Category[] };
@@ -39,7 +40,8 @@ function normalizePlayer(p: any): Player {
weight: p.weight ?? p.Weight ?? undefined,
email: p.email ?? p.Email ?? undefined,
phone: p.phone ?? p.Phone ?? undefined,
team_id: p.team_id ?? p.TeamID ?? undefined,
team_id: p.team_id ?? p.TeamID ?? (p.team?.id ?? p.Team?.ID) ?? undefined,
team: (p.team || p.Team) ? { id: (p.team?.id ?? p.Team?.ID), name: (p.team?.name ?? p.Team?.Name ?? p.Team?.name) } : undefined,
} as Player;
}
+19
View File
@@ -200,6 +200,17 @@ export async function searchAll(query: string): Promise<SearchResults> {
if (!(ts instanceof Date) || isNaN(ts.getTime())) continue;
// Past only
if (ts.getTime() >= now.getTime()) continue;
// Parse score if present (e.g., "2:1")
const scoreText = String(m?.score || '').trim();
let result_home: number | undefined;
let result_away: number | undefined;
if (scoreText) {
const mm = scoreText.match(/^(\d+)\s*:\s*(\d+)$/);
if (mm) {
result_home = parseInt(mm[1], 10);
result_away = parseInt(mm[2], 10);
}
}
out.push({
id: m?.match_id || m?.matchId,
home: m?.home,
@@ -210,6 +221,10 @@ export async function searchAll(query: string): Promise<SearchResults> {
venue: m?.venue,
home_logo_url: m?.home_logo_url,
away_logo_url: m?.away_logo_url,
// include score fields when available so UI can render them
score: scoreText || undefined,
result_home,
result_away,
});
}
}
@@ -387,6 +402,10 @@ export async function searchAll(query: string): Promise<SearchResults> {
away_logo_url: m.away_logo_url,
venue: m.venue,
external_match_id: m.match_id || m.id,
// Pass through result fields when available
result: (m.result || m.result_text || m.score) || undefined,
result_home: typeof m.result_home === 'number' ? m.result_home : undefined,
result_away: typeof m.result_away === 'number' ? m.result_away : undefined,
},
score: Math.max(
scoreMatch(m.home || '', q),
+6
View File
@@ -29,6 +29,12 @@ export async function createShortLink(payload: CreateShortLinkPayload): Promise<
}
}
// Public shortlink creation for visitors (no auth; backend validates allowed host)
export async function createPublicShortLink(payload: { target_url: string; title?: string }): Promise<ShortLinkResponse> {
const res = await api.post<ShortLinkResponse>('/shortlinks/public', payload);
return res.data;
}
export async function listShortLinks(): Promise<{ items: any[] }> {
const res = await api.get<{ items: any[] }>('/admin/shortlinks');
return res.data;
+22 -5
View File
@@ -18,20 +18,37 @@ export type YouTubeChannelPayload = {
videos: YouTubeVideo[];
};
// Simple in-memory cache for YouTube payload (per-session)
let ytMemCache: { payload: YouTubeChannelPayload | null; fetchedAt: number } | null = null;
const YT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
export const getCachedYouTube = async (): Promise<YouTubeChannelPayload | null> => {
const now = Date.now();
if (ytMemCache && now - ytMemCache.fetchedAt < YT_CACHE_TTL_MS) {
return sortByPublishedDate(ytMemCache.payload) as YouTubeChannelPayload | null;
}
try {
const res = await api.get('/youtube/videos');
const primary = res?.data as YouTubeChannelPayload | undefined;
const hasVideos = Array.isArray(primary?.videos) && (primary!.videos.length > 0);
// If backend responded with 204 or empty payload, fall back to the static cached JSON
let out: YouTubeChannelPayload | null = null;
if (res.status === 204 || !primary || !hasVideos) {
const fallback = await fetchStaticYouTubeCache();
return sortByPublishedDate(fallback);
out = await fetchStaticYouTubeCache();
} else {
out = primary || null;
}
return sortByPublishedDate(primary) as YouTubeChannelPayload;
if (out) {
ytMemCache = { payload: out, fetchedAt: now };
}
return sortByPublishedDate(out) as YouTubeChannelPayload | null;
} catch {
// Fallback: fetch static cached JSON directly from backend /cache path to avoid stressing external API
return await fetchStaticYouTubeCache();
const out = await fetchStaticYouTubeCache();
if (out) {
ytMemCache = { payload: out, fetchedAt: now };
}
return out;
}
};
@@ -40,7 +57,7 @@ const fetchStaticYouTubeCache = async (): Promise<YouTubeChannelPayload | null>
try {
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
const url = `${origin}/cache/prefetch/youtube_channel.json`;
const resp = await fetch(url, { cache: 'no-cache' });
const resp = await fetch(url, { cache: 'force-cache' });
if (!resp.ok) return null;
const data = (await resp.json()) as YouTubeChannelPayload;
return sortByPublishedDate(data);
+26 -21
View File
@@ -76,6 +76,10 @@
margin-right: 2px;
border-radius: 4px;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
}
.ql-toolbar.ql-snow button:hover {
@@ -131,6 +135,11 @@
border-radius: 4px;
padding: 4px 8px;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
min-width: 32px;
}
.ql-toolbar.ql-snow .ql-picker-label:hover {
@@ -155,6 +164,22 @@
z-index: 6000 !important;
}
/* Center icons and enlarge align icon */
.ql-toolbar .ql-picker-label svg {
width: 18px;
height: 18px;
}
.ql-toolbar .ql-align .ql-picker-label svg {
width: 18px;
height: 18px;
}
.ql-snow .ql-color-picker .ql-picker-label,
.ql-snow .ql-background .ql-picker-label {
display: inline-flex;
align-items: center;
justify-content: center;
}
.ql-toolbar.ql-snow .ql-picker-options .ql-picker-item {
border-radius: 4px;
padding: 6px 8px;
@@ -195,7 +220,6 @@
font-weight: 700;
margin: 0.67em 0;
line-height: 1.2;
color: #1a202c;
}
.ql-editor h2 {
@@ -203,7 +227,6 @@
font-weight: 600;
margin: 0.75em 0;
line-height: 1.3;
color: #1a202c;
}
.ql-editor h3 {
@@ -211,7 +234,6 @@
font-weight: 600;
margin: 1em 0;
line-height: 1.4;
color: #2d3748;
}
.ql-editor p {
@@ -379,26 +401,9 @@
color: #1a202c !important;
}
/* Ensure all text elements have visible colors */
.ql-editor p,
.ql-editor span,
.ql-editor div,
.ql-editor li {
color: #2d3748;
}
.ql-editor h1,
.ql-editor h2,
.ql-editor h3,
.ql-editor h4,
.ql-editor h5,
.ql-editor h6 {
color: #1a202c;
}
/* Let Quill inline color styles take precedence; keep only weight for bold */
.ql-editor strong,
.ql-editor b {
color: #1a202c;
font-weight: bold;
}
+2 -1
View File
@@ -66,7 +66,8 @@ body.style-pack-modern .section-head h3::after { width: 64px; }
/* Banner placements */
[data-element="banner"][data-variant="top"],
[data-element="banner"][data-variant="bottom"] { text-align: center; }
[data-element="sidebar"] img { border-radius: 8px; }
[data-element="sidebar"] img { border-radius: 10px; }
[data-element="sidebar"] { scroll-margin-top: 112px; }
/* two-column news + table layout */
.standings {
+105
View File
@@ -0,0 +1,105 @@
/**
* Production-safe logging utility
* Automatically suppresses console.log in production while preserving errors/warnings
*/
const isDevelopment = process.env.NODE_ENV === 'development';
const isTest = process.env.NODE_ENV === 'test';
export const logger = {
/**
* Debug logs - only in development
*/
debug: (...args: any[]) => {
if (isDevelopment) {
console.log('[DEBUG]', ...args);
}
},
/**
* Info logs - development and staging
*/
info: (...args: any[]) => {
if (isDevelopment || process.env.REACT_APP_ENV === 'staging') {
console.log('[INFO]', ...args);
}
},
/**
* Warning logs - always shown
*/
warn: (...args: any[]) => {
console.warn('[WARN]', ...args);
},
/**
* Error logs - always shown and sent to monitoring
*/
error: (...args: any[]) => {
console.error('[ERROR]', ...args);
// Send to error tracking service in production
if (!isDevelopment && !isTest && window.umami) {
try {
const errorMessage = args.map(arg =>
typeof arg === 'object' ? JSON.stringify(arg) : String(arg)
).join(' ');
window.umami.track('error', { message: errorMessage.substring(0, 500) });
} catch (e) {
// Silently fail if tracking unavailable
}
}
},
/**
* Performance logging - measures execution time
*/
time: (label: string) => {
if (isDevelopment) {
console.time(label);
}
},
timeEnd: (label: string) => {
if (isDevelopment) {
console.timeEnd(label);
}
},
/**
* Group logging - only in development
*/
group: (label: string) => {
if (isDevelopment) {
console.group(label);
}
},
groupEnd: () => {
if (isDevelopment) {
console.groupEnd();
}
},
/**
* Table logging - only in development
*/
table: (data: any) => {
if (isDevelopment) {
console.table(data);
}
}
};
// Export as default for easier imports
export default logger;
// Type declaration for umami
declare global {
interface Window {
umami?: {
track: (eventName: string, eventData?: Record<string, any>) => void;
};
}
}
+124
View File
@@ -118,3 +118,127 @@ export function translateNationality(code?: string): string {
// Return original if no translation found
return code;
}
// Convert a 2-letter ISO country code to a flag emoji
export function countryCodeToEmoji(cc?: string): string {
if (!cc) return '';
const v = cc.trim().toUpperCase();
if (!/^[A-Z]{2}$/.test(v)) return '';
return v.replace(/./g, (ch) => String.fromCodePoint(127397 + ch.charCodeAt(0)));
}
// Get an emoji flag for a given nationality string (code like "CZ" or English name like "Czechia")
export function getCountryFlag(nationality?: string): string {
if (!nationality) return '';
const n = nationality.trim();
if (!n) return '';
// If already a 2-letter code
if (/^[A-Za-z]{2}$/.test(n)) {
// Normalize UK to GB for emoji
const code = n.toUpperCase() === 'UK' ? 'GB' : n.toUpperCase();
return countryCodeToEmoji(code);
}
// Map of English and Czech names to ISO alpha-2 codes (subset covering our translations)
const nameToCode: Record<string, string> = {
'Czechia': 'CZ',
'Czech Republic': 'CZ',
'Česká republika': 'CZ',
'Slovakia': 'SK',
'Slovensko': 'SK',
'Poland': 'PL',
'Polsko': 'PL',
'Germany': 'DE',
'Německo': 'DE',
'Austria': 'AT',
'Rakousko': 'AT',
'Ukraine': 'UA',
'Ukrajina': 'UA',
'France': 'FR',
'Francie': 'FR',
'Spain': 'ES',
'Španělsko': 'ES',
'Italy': 'IT',
'Itálie': 'IT',
'England': 'GB',
'United Kingdom': 'GB',
'Velká Británie': 'GB',
'United States': 'US',
'USA': 'US',
'Brazil': 'BR',
'Argentina': 'AR',
'Portugal': 'PT',
'Netherlands': 'NL',
'Nizozemsko': 'NL',
'Belgium': 'BE',
'Belgie': 'BE',
'Switzerland': 'CH',
'Švýcarsko': 'CH',
'Sweden': 'SE',
'Švédsko': 'SE',
'Norway': 'NO',
'Norsko': 'NO',
'Denmark': 'DK',
'Dánsko': 'DK',
'Finland': 'FI',
'Finsko': 'FI',
'Russia': 'RU',
'Rusko': 'RU',
'Croatia': 'HR',
'Chorvatsko': 'HR',
'Serbia': 'RS',
'Srbsko': 'RS',
'Slovenia': 'SI',
'Slovinsko': 'SI',
'Hungary': 'HU',
'Maďarsko': 'HU',
'Romania': 'RO',
'Rumunsko': 'RO',
'Bulgaria': 'BG',
'Bulharsko': 'BG',
'Greece': 'GR',
'Řecko': 'GR',
'Turkey': 'TR',
'Turecko': 'TR',
'Japan': 'JP',
'Japonsko': 'JP',
'China': 'CN',
'Čína': 'CN',
'South Korea': 'KR',
'Jižní Korea': 'KR',
'Australia': 'AU',
'Austrálie': 'AU',
'New Zealand': 'NZ',
'Nový Zéland': 'NZ',
'Canada': 'CA',
'Kanada': 'CA',
'Mexico': 'MX',
'Mexiko': 'MX',
'Ireland': 'IE',
'Irsko': 'IE',
'Iceland': 'IS',
'Island': 'IS',
'Bosnia and Herzegovina': 'BA',
'Bosna a Hercegovina': 'BA',
'Montenegro': 'ME',
'Černá Hora': 'ME',
'North Macedonia': 'MK',
'Severní Makedonie': 'MK',
'Albania': 'AL',
'Albánie': 'AL',
'Luxembourg': 'LU',
'Lucembursko': 'LU',
'Moldova': 'MD',
'Moldavsko': 'MD',
'Lithuania': 'LT',
'Litva': 'LT',
'Latvia': 'LV',
'Lotyšsko': 'LV',
'Estonia': 'EE',
'Estonsko': 'EE',
'Belarus': 'BY',
'Bělorusko': 'BY',
};
const code = nameToCode[n] || nameToCode[n.replace(/\s+/g, ' ').trim()] || nameToCode[n.toLowerCase().replace(/\b\w/g, (l) => l.toUpperCase())];
if (code) return countryCodeToEmoji(code);
return '';
}