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
+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