mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #79
This commit is contained in:
@@ -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}>E‑mailové 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,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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}` : '' })()}
|
||||
|
||||
@@ -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' }}
|
||||
/>
|
||||
|
||||
@@ -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 }}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user