This commit is contained in:
Tomas Dvorak
2025-10-29 21:20:16 +01:00
parent 823fabee02
commit 16e4533202
61 changed files with 2308 additions and 942 deletions
+102 -22
View File
@@ -116,7 +116,37 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
const hasChildren = item.type === 'dropdown' && item.children && item.children.length > 0;
const linkProps = linkIsExternal ? { href: item.url } : { to: item.url || '/' };
const Comp: any = linkIsExternal ? 'a' : RouterLink;
// Special handling for Blog/Články: when categories exist, make parent non-clickable and list categories
const isArticlesLink = (item.label === 'Články' || item.label === 'Blog' || ((item.url || '') as string).startsWith('/blog'));
const hasCats = Array.isArray(categories) && categories.length > 0;
if (isArticlesLink && hasCats) {
return (
<React.Fragment key={item.id || idx}>
<Button
variant="ghost"
justifyContent="flex-start"
fontWeight="bold"
>
{item.label}
</Button>
<VStack align="stretch" pl={4} spacing={1}>
{categories.map((cat: any) => {
const catIsExternal = typeof cat.url === 'string' && /^https?:\/\//i.test(cat.url);
const catHref = cat.url || (cat.id ? `/blog?category_id=${cat.id}` : (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog'));
const catLinkProps = catIsExternal ? { href: catHref } : { to: catHref };
const CatComp: any = catIsExternal ? 'a' : RouterLink;
return (
<Button key={cat.slug || cat.id || cat.name} as={CatComp} {...(catLinkProps as any)} variant="ghost" justifyContent="flex-start" fontWeight="normal" size="sm">
{cat.name}
</Button>
);
})}
</VStack>
</React.Fragment>
);
}
return (
<React.Fragment key={item.id || idx}>
<Button
@@ -194,20 +224,24 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
})}
{hasArticles === true && (
<>
<Button as={RouterLink} to="/blog" variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
{Array.isArray(categories) && categories.length > 0 && (
<VStack align="stretch" pl={4} spacing={1}>
{categories.map((cat: any) => {
const catIsExternal = typeof cat.url === 'string' && /^https?:\/\//i.test(cat.url);
const catHref = cat.url || (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog');
const catLinkProps = catIsExternal ? { href: catHref } : { to: catHref };
return (
<Button key={cat.slug || cat.name} as={catIsExternal ? 'a' : RouterLink} {...(catLinkProps as any)} variant="ghost" justifyContent="flex-start" fontWeight="normal" size="sm">
{cat.name}
</Button>
);
})}
</VStack>
{Array.isArray(categories) && categories.length > 0 ? (
<>
<Button variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
<VStack align="stretch" pl={4} spacing={1}>
{categories.map((cat: any) => {
const catIsExternal = typeof cat.url === 'string' && /^https?:\/\//i.test(cat.url);
const catHref = cat.url || (cat.id ? `/blog?category_id=${cat.id}` : (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog'));
const catLinkProps = catIsExternal ? { href: catHref } : { to: catHref };
return (
<Button key={cat.slug || cat.id || cat.name} as={catIsExternal ? 'a' : RouterLink} {...(catLinkProps as any)} variant="ghost" justifyContent="flex-start" fontWeight="normal" size="sm">
{cat.name}
</Button>
);
})}
</VStack>
</>
) : (
<Button as={RouterLink} to="/blog" variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
)}
</>
)}
@@ -282,6 +316,13 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
const [dynamicNavItems, setDynamicNavItems] = useState<NavigationItem[]>([]);
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);
useEffect(() => {
const onResize = () => setWindowWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
// Search modal state
const [query, setQuery] = useState('');
@@ -535,7 +576,7 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
const source = Array.isArray(navCategories) && navCategories.length > 0 ? navCategories : [];
return source.map((cat: any) => ({
label: cat.name,
to: cat.url || (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog')
to: cat.url || (cat.id ? `/blog?category_id=${cat.id}` : (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog'))
}));
}, [navCategories]);
@@ -659,6 +700,32 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
return links;
}, [filteredDynamicNavItems, navLoading, settings, categoryItems, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, galleryLabel]);
// Split navigation into visible and overflow for desktop
const navSplit = useMemo(() => {
const links = NAV_LINKS;
let maxVisible = 8;
const w = windowWidth;
if (w >= 1600) maxVisible = 10;
else if (w >= 1400) maxVisible = 9;
else if (w >= 1200) maxVisible = 8;
else if (w >= 1100) maxVisible = 7;
else maxVisible = 6;
if (links.length <= maxVisible) return { visible: links, overflow: [] as NavLink[] };
const visibleCount = Math.max(1, maxVisible - 1); // reserve one slot for "Další"
return { visible: links.slice(0, visibleCount), overflow: links.slice(visibleCount) };
}, [NAV_LINKS, windowWidth]);
const moreItems = useMemo(() => {
const out: { label: string; to: string }[] = [];
navSplit.overflow.forEach((nav) => {
if (nav.to) out.push({ label: nav.label, to: nav.to });
if (Array.isArray(nav.items)) {
nav.items.forEach((it) => out.push({ label: it.label, to: it.to }));
}
});
return out;
}, [navSplit]);
return (
<Box position="sticky" top={0} zIndex={1000}>
{/* Top bar with socials and quick external links */}
@@ -722,7 +789,7 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
</HStack>
{/* Desktop navigation with hover dropdowns */}
<HStack as="nav" spacing={1} display={{ base: 'none', lg: 'flex' }} ml={4}>
{NAV_LINKS.map((nav) => {
{navSplit.visible.map((nav) => {
const commonProps = {
variant: 'ghost' as const,
size: 'sm' as const,
@@ -754,6 +821,9 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
</Button>
);
})}
{navSplit.overflow.length > 0 && (
<HoverMenu key="more" label="Další" items={moreItems} />
)}
</HStack>
</HStack>
@@ -930,11 +1000,21 @@ const HoverMenu = ({ label, items, isActive }: { label: string; items: { label:
{label}
</MenuButton>
<MenuList>
{items.map((it) => (
<MenuItem as={RouterLink} to={it.to} key={it.to}>
{it.label}
</MenuItem>
))}
{items.map((it) => {
const isExternal = /^https?:\/\//i.test(it.to);
if (isExternal) {
return (
<MenuItem as="a" href={it.to} key={it.to} target="_blank" rel="noreferrer">
{it.label}
</MenuItem>
);
}
return (
<MenuItem as={RouterLink} to={it.to} key={it.to}>
{it.label}
</MenuItem>
);
})}
</MenuList>
</Menu>
</Box>
@@ -5,8 +5,10 @@ import { assetUrl } from '../../utils/url';
export interface Banner {
id: number | string;
name: string;
image: string;
image?: string;
image_url?: string;
url?: string;
click_url?: string;
placement?: string;
width?: number;
height?: number;
@@ -15,7 +17,7 @@ export interface Banner {
interface BannerDisplayProps {
banners: Banner[];
placement: 'homepage_top' | 'homepage_middle' | 'homepage_sidebar' | 'homepage_footer' | 'article_inline';
placement: 'homepage_top' | 'homepage_middle' | 'homepage_sidebar' | 'homepage_footer' | 'article_inline' | 'homepage_under_table';
containerStyle?: React.CSSProperties;
}
@@ -41,6 +43,8 @@ const BannerDisplay: React.FC<BannerDisplayProps> = ({ banners, placement, conta
return 'banner-footer';
case 'article_inline':
return 'banner-article';
case 'homepage_under_table':
return 'banner-under-table';
default:
return 'banner';
}
@@ -89,6 +93,12 @@ const BannerDisplay: React.FC<BannerDisplayProps> = ({ banners, placement, conta
display: 'block',
margin: '24px 0',
};
case 'homepage_under_table':
return {
...base,
margin: '12px 0 0',
justifyContent: 'center',
};
default:
return base;
}
@@ -105,16 +115,16 @@ const BannerDisplay: React.FC<BannerDisplayProps> = ({ banners, placement, conta
{activeBanners.map((banner) => (
<ChakraLink
key={banner.id}
href={banner.url || '#'}
isExternal={!!banner.url}
target={banner.url ? '_blank' : undefined}
rel={banner.url ? 'noopener noreferrer' : undefined}
href={banner.url || banner.click_url || '#'}
isExternal={!!(banner.url || banner.click_url)}
target={(banner.url || banner.click_url) ? '_blank' : undefined}
rel={(banner.url || banner.click_url) ? 'noopener noreferrer' : undefined}
display="inline-block"
_hover={{ opacity: 0.9, transform: 'translateY(-2px)' }}
transition="all 0.2s"
>
<img
src={assetUrl(banner.image) || banner.image}
src={assetUrl(banner.image || banner.image_url || '') || banner.image || banner.image_url || ''}
alt={banner.name}
style={{
maxWidth: '100%',
@@ -78,6 +78,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const onChangeRef = useRef(onChange);
const selectedImageIdRef = useRef<string | null>(null);
const selectImageByIdRef = useRef<(id: string) => void>(() => {});
const toolbarDragRef = useRef<{ active: boolean; startX: number; startY: number; startLeft: number; startTop: number }>({ active: false, startX: 0, startY: 0, startLeft: 0, startTop: 0 });
const [isMounted, setIsMounted] = useState(false);
// Ensure component is mounted before rendering Quill
@@ -401,6 +402,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
let startX = 0;
let startY = 0;
let startWidth = 0;
let rafId = 0;
const createResizeHandle = (img: HTMLImageElement) => {
removeResizeHandle();
@@ -672,23 +674,13 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const scrollTop = editor.root.scrollTop;
const scrollLeft = editor.root.scrollLeft;
// Position toolbar to the right of the image, or left if not enough space
// Place toolbar close to the image (top-right corner inside the image area when possible)
const toolbarWidth = 380;
const spaceOnRight = window.innerWidth - rect.right;
const positionRight = spaceOnRight > toolbarWidth + 20;
let leftPos = positionRight
? rect.right - editorRect.left + scrollLeft + 10
: rect.left - editorRect.left + scrollLeft - toolbarWidth - 10;
// Ensure toolbar is visible horizontally
leftPos = Math.max(10, Math.min(leftPos, editorRect.width - toolbarWidth - 10));
// Position vertically aligned with top of image
let topPos = rect.top - editorRect.top + scrollTop;
// Ensure toolbar is visible vertically
topPos = Math.max(10, topPos);
const margin = 8;
let leftPos = rect.left - editorRect.left + scrollLeft + (rect.width > toolbarWidth + margin ? (rect.width - toolbarWidth - margin) : margin);
leftPos = Math.max(margin, Math.min(leftPos, editorRect.width - toolbarWidth - margin));
let topPos = rect.top - editorRect.top + scrollTop + margin;
topPos = Math.max(margin, topPos);
setToolbarPosition({
top: topPos,
@@ -895,16 +887,18 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Handle scroll to update resize handle position
const handleScroll = () => {
if (selectedImage && resizeHandle) {
const rect = selectedImage.getBoundingClientRect();
if (!selectedImage || !resizeHandle) return;
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const rect = selectedImage!.getBoundingClientRect();
const editorRect = 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.width = `${rect.width}px`;
resizeHandle.style.height = `${rect.height}px`;
}
resizeHandle!.style.left = `${rect.left - editorRect.left + scrollLeft}px`;
resizeHandle!.style.top = `${rect.top - editorRect.top + scrollTop}px`;
resizeHandle!.style.width = `${rect.width}px`;
resizeHandle!.style.height = `${rect.height}px`;
});
};
// Prevent default drag behavior on images
@@ -934,6 +928,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
document.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('resize', handleScroll);
document.removeEventListener('scroll', handleScroll, true);
if (rafId) cancelAnimationFrame(rafId);
removeResizeHandle();
deselectImage();
};
@@ -1047,8 +1042,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const editor = quillRef.current?.getEditor();
if (editor) {
onChangeRef.current(editor.root.innerHTML);
// Force overlay reposition
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
reselectAfterContentUpdate();
@@ -1111,7 +1104,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setManualWidth(finalWidth.toString());
if (editor) {
onChangeRef.current(editor.root.innerHTML);
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
// Keep selection active for subsequent operations (e.g., 50% → 75%)
reselectAfterContentUpdate();
@@ -1132,7 +1124,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setManualWidth('');
if (editor) {
onChangeRef.current(editor.root.innerHTML);
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
reselectAfterContentUpdate();
toast({ title: 'Šířka resetována', status: 'info', duration: 1200 });
@@ -1190,7 +1181,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
let cleaned = DOMPurify.sanitize(content, {
USE_PROFILES: { html: true },
ADD_TAGS: ['iframe'],
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters'],
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id'],
});
// Replace white and very light colors with dark colors for visibility
@@ -1236,7 +1227,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
overflow="hidden"
overflow="visible"
bg={bgColor}
sx={{
'.ql-toolbar': {
@@ -1477,7 +1468,33 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
>
<VStack align="stretch" spacing={3}>
{/* Toolbar Header */}
<HStack justify="space-between">
<HStack
justify="space-between"
onMouseDown={(e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
toolbarDragRef.current.active = true;
toolbarDragRef.current.startX = e.clientX;
toolbarDragRef.current.startY = e.clientY;
toolbarDragRef.current.startLeft = toolbarPosition.left;
toolbarDragRef.current.startTop = toolbarPosition.top;
const onMove = (ev: MouseEvent) => {
if (!toolbarDragRef.current.active) return;
const dx = ev.clientX - toolbarDragRef.current.startX;
const dy = ev.clientY - toolbarDragRef.current.startY;
setToolbarPosition((pos) => ({ top: Math.max(0, toolbarDragRef.current.startTop + dy), left: Math.max(0, toolbarDragRef.current.startLeft + dx) }));
};
const onUp = () => {
toolbarDragRef.current.active = false;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
cursor="move"
>
<HStack spacing={2}>
<Settings size={16} />
<Text fontWeight="bold" fontSize="sm">Úprava obrázku</Text>
@@ -105,7 +105,7 @@ import { DEFAULT_HOMEPAGE_ELEMENTS, HOMEPAGE_IMPLEMENTED_ELEMENTS } from '../../
const SUPPORTED_HOME_VARIANTS: Record<string, string[]> = {
hero: ['grid', 'scroller', 'swiper', 'swiper_full'],
news: ['grid', 'scroller'],
news: ['grid_one', 'grid_two', 'grid', 'scroller'],
matches: ['compact'],
sponsors: ['grid', 'slider', 'scroller', 'pyramid'],
gallery: ['grid'],
@@ -149,7 +149,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const [dragOverElement, setDragOverElement] = useState<string | null>(null);
const [viewport] = useState<'desktop'>('desktop');
const [elementStyles, setElementStyles] = useState<Record<string, any>>({});
const [showStylePanel, setShowStylePanel] = useState(true);
const [showStylePanel, setShowStylePanel] = useState(false);
const [stylePanelRight, setStylePanelRight] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('all');
@@ -381,7 +381,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
// Auto-open Layers panel on the left by default when entering edit mode
useEffect(() => {
if (isEditing) {
setShowLayersPanel(true);
setShowLayersPanel(false);
}
}, [isEditing]);
@@ -561,7 +561,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
if (showElementPicker) setShowElementPicker(false);
else if (showLayersPanel) setShowLayersPanel(false);
else if (selectedElement) setSelectedElement(null);
else setIsEditing(false);
else handleExitEditing();
}
if ((e.ctrlKey || e.metaKey) && e.key === 's' && hasChanges) {
e.preventDefault();
@@ -1515,7 +1515,9 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
display_order: index,
settings: {
...(configs.find(c => c.element_name === elementName)?.settings || {}),
customCSS: (elementStyles[elementName]?.customCSS || ''),
// Persist full styles object and custom CSS
styles: (elementStyles[elementName] || (configs.find(c => c.element_name === elementName)?.settings as any)?.styles || {}),
customCSS: (elementStyles[elementName]?.customCSS ?? (configs.find(c => c.element_name === elementName)?.settings as any)?.customCSS ?? ''),
},
}));
@@ -1565,6 +1567,14 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
[selectedElement, localChanges, normalizeVariant]
);
const handleExitEditing = useCallback(() => {
try { document.body.classList.remove('myuibrix-edit-mode'); } catch {}
setIsEditing(false);
setTimeout(() => {
try { window.location.reload(); } catch {}
}, 120);
}, []);
// Calculate viewport width - USE REAL DEVICE WIDTHS WITHOUT SCALING
const getViewportConfig = useCallback(() => ({
width: '100%',
@@ -1887,6 +1897,14 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
onClick={() => setStylePanelRight(!stylePanelRight)}
/>
</Tooltip>
<Button
size="sm"
variant="outline"
colorScheme="whiteAlpha"
onClick={handleStartBlank}
>
Začít s prázdnou stránkou
</Button>
{unsavedCount > 0 && (
<Badge
bg="yellow.400"
@@ -1936,7 +1954,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
size="sm"
variant="ghost"
colorScheme="whiteAlpha"
onClick={() => setIsEditing(false)}
onClick={handleExitEditing}
/>
</HStack>
</Flex>
@@ -1946,10 +1964,10 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
<Box
className="myuibrix-panel"
position="fixed"
left={stylePanelRight ? undefined : 4}
right={stylePanelRight ? 4 : undefined}
top={64}
bottom={4}
left={stylePanelRight ? undefined : 0}
right={stylePanelRight ? 0 : undefined}
top={0}
bottom={0}
width="380px"
zIndex={9998}
overflow="hidden"
@@ -1958,7 +1976,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
fontFamily="var(--chakra-fonts-body)"
bg="rgba(255, 255, 255, 0.95)"
backdropFilter="blur(12px) saturate(180%)"
borderRadius="2xl"
borderRadius="0"
boxShadow="0 20px 60px rgba(0,0,0,0.3), 0 8px 24px rgba(0,0,0,0.2)"
border="1px solid rgba(255,255,255,0.3)"
sx={{
@@ -1979,7 +1997,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
alignItems="center"
justifyContent="space-between"
flexShrink={0}
borderTopRadius="2xl"
borderTopRadius="0"
boxShadow="0 2px 8px rgba(0,0,0,0.1)"
borderBottom="1px solid rgba(255,255,255,0.2)"
>
@@ -2040,11 +2058,10 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
colorScheme={isEditing ? "red" : "whiteAlpha"}
size="lg"
onClick={() => {
setIsEditing(!isEditing);
if (isEditing) {
setSelectedElement(null);
setShowLayersPanel(false);
setShowElementPicker(false);
handleExitEditing();
} else {
setIsEditing(true);
}
}}
borderRadius="full"
@@ -2357,7 +2374,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
)}
{/* Unsaved Changes Indicator */}
{isEditing && hasChanges && (
{false && isEditing && hasChanges && (
<Box
position="fixed"
top={4}
@@ -91,7 +91,7 @@ const SpartaNavbar: React.FC = () => {
const categoryItems = useMemo(() => {
const source = Array.isArray(navCategories) && navCategories.length > 0 ? navCategories : [];
return source.map((cat: any) => ({ label: cat.name, to: cat.url || (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog') }));
return source.map((cat: any) => ({ label: cat.name, to: cat.url || (cat.id ? `/blog?category_id=${cat.id}` : (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog')) }));
}, [navCategories]);
const NAV_LINKS: NavLink[] = useMemo(() => {
@@ -155,6 +155,20 @@ const SpartaNavbar: React.FC = () => {
const isActive = isPathActive(nav.to);
const className = isActive ? 'sparta-button-tertiary' : 'sparta-button-tertiary';
// When categories are present under Články/Blog, render non-clickable label + category links
if (nav.items && nav.items.length > 0 && (nav.label === 'Články' || nav.label === 'Blog' || (nav.to || '').startsWith('/blog'))) {
return (
<React.Fragment key={nav.label}>
<span className="sparta-button-tertiary" style={{ pointerEvents: 'none', opacity: 0.9 }}>{nav.label}</span>
{nav.items.map((it) => (
<RouterLink key={`${nav.label}-${it.to}`} to={it.to} className={className} onClick={() => setMobileOpen(false)}>
{it.label}
</RouterLink>
))}
</React.Fragment>
);
}
if (nav.external && nav.to) {
return (
<a key={nav.label} href={nav.to} target="_blank" rel="noreferrer" className={className} onClick={() => setMobileOpen(false)}>
@@ -7,7 +7,7 @@ export type ClubHeroTopbarVariant = 'brand' | 'minimal' | 'badge';
const cls = (...parts: Array<string | false | null | undefined>) => parts.filter(Boolean).join(' ');
const ClubHeroTopbar: React.FC<{ variant?: ClubHeroTopbarVariant; fullBleed?: boolean }>= ({ variant = 'brand', fullBleed = false }) => {
const ClubHeroTopbar: React.FC<{ variant?: ClubHeroTopbarVariant; fullBleed?: boolean }>= ({ variant = 'minimal', fullBleed = false }) => {
const { data: settings } = usePublicSettings();
const theme = useClubTheme();
const title = settings?.club_name || theme.name || 'Fotbalový klub';
@@ -31,12 +31,14 @@ const ClubHeroTopbar: React.FC<{ variant?: ClubHeroTopbarVariant; fullBleed?: bo
<div className="club-hero-topbar__tagline">{tagline}</div>
</div>
<div className="club-hero-topbar__spacer" />
<div className="club-hero-topbar__actions">
<a href={calendarUrl} className="sparta-button-tertiary">Kalendář</a>
{shopUrl && (
<a href={shopUrl} target="_blank" rel="noreferrer" className="sparta-button-primary">Fanshop</a>
)}
</div>
{variant !== 'minimal' && (
<div className="club-hero-topbar__actions">
<a href={calendarUrl} className="sparta-button-tertiary">Kalendář</a>
{shopUrl && (
<a href={shopUrl} target="_blank" rel="noreferrer" className="sparta-button-primary">Fanshop</a>
)}
</div>
)}
</div>
);
};
+80 -35
View File
@@ -110,6 +110,8 @@ const ContactMap: React.FC<ContactMapProps> = ({
}) => {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<any>(null);
const tileLayerRef = useRef<any>(null);
const markerRef = useRef<any>(null);
const [isLoaded, setIsLoaded] = React.useState(false);
const [loadError, setLoadError] = React.useState<string | null>(null);
@@ -175,52 +177,38 @@ const ContactMap: React.FC<ContactMapProps> = ({
mapInstanceRef.current = map;
// Get tile layer URL based on style
let tileUrl = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
let attribution = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
// Initial tile layer
const { tileUrl, attribution } = (() => {
let url = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
let attr = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
if (mapStyle && MAP_STYLES[mapStyle as keyof typeof MAP_STYLES]) {
const style = MAP_STYLES[mapStyle as keyof typeof MAP_STYLES];
url = style.url;
attr = style.attribution;
} else if (mapStyle && mapStyle.startsWith('http')) {
url = mapStyle;
}
return { tileUrl: url, attribution: attr };
})();
// Use predefined styles or custom URL
if (mapStyle && MAP_STYLES[mapStyle as keyof typeof MAP_STYLES]) {
const style = MAP_STYLES[mapStyle as keyof typeof MAP_STYLES];
tileUrl = style.url;
attribution = style.attribution;
} else if (mapStyle && mapStyle.startsWith('http')) {
// Custom tile URL
tileUrl = mapStyle;
}
// Add tile layer
const tileLayer = L.tileLayer(tileUrl, {
attribution: attribution,
tileLayerRef.current = L.tileLayer(tileUrl, {
attribution,
maxZoom: 19,
}).addTo(map);
// Apply club color overlay if provided
if (clubPrimaryColor && clubPrimaryColor !== '') {
const colorFilter = createColorFilter(clubPrimaryColor);
if (colorFilter) {
const pane = map.createPane('colorOverlay');
pane.style.zIndex = '400';
pane.style.pointerEvents = 'none';
pane.style.mixBlendMode = 'multiply';
pane.style.backgroundColor = colorFilter;
pane.style.opacity = '0.15';
}
}
// Create custom marker icon with club colors
const markerColor = clubPrimaryColor || '#3388ff';
const customIcon = createCustomMarkerIcon(markerColor, L);
// Add marker
const marker = L.marker([latitude, longitude], { icon: customIcon }).addTo(map);
markerRef.current = L.marker([latitude, longitude], { icon: customIcon }).addTo(map);
// Add popup if address is provided
if (clubName || address) {
let popupContent = '';
if (clubName) popupContent += `<b>${clubName}</b><br>`;
if (address) popupContent += address;
marker.bindPopup(popupContent);
markerRef.current.bindPopup(popupContent);
}
// Enable scroll zoom on click
@@ -238,14 +226,71 @@ const ContactMap: React.FC<ContactMapProps> = ({
setLoadError('Failed to initialize map');
}
// Cleanup
// Cleanup on unmount
return () => {
if (mapInstanceRef.current) {
mapInstanceRef.current.remove();
try {
if (mapInstanceRef.current) {
mapInstanceRef.current.remove();
}
} finally {
mapInstanceRef.current = null;
tileLayerRef.current = null;
markerRef.current = null;
}
};
}, [isLoaded, latitude, longitude, zoom, address, clubName, mapStyle, clubPrimaryColor, clubSecondaryColor]);
}, [isLoaded]);
// Update center/zoom and marker when coords/zoom change
useEffect(() => {
if (!mapInstanceRef.current || !L) return;
try {
const map = mapInstanceRef.current;
if (typeof latitude === 'number' && typeof longitude === 'number') {
map.setView([latitude, longitude], typeof zoom === 'number' ? zoom : map.getZoom());
if (markerRef.current) {
markerRef.current.setLatLng([latitude, longitude]);
}
}
} catch {}
}, [latitude, longitude, zoom]);
// Update map style (tile layer) when mapStyle changes
useEffect(() => {
if (!mapInstanceRef.current || !L) return;
try {
const map = mapInstanceRef.current;
// Compute URL and attribution
let url = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
let attr = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
if (mapStyle && MAP_STYLES[mapStyle as keyof typeof MAP_STYLES]) {
const style = MAP_STYLES[mapStyle as keyof typeof MAP_STYLES];
url = style.url;
attr = style.attribution;
} else if (mapStyle && mapStyle.startsWith('http')) {
url = mapStyle;
}
if (tileLayerRef.current) {
map.removeLayer(tileLayerRef.current);
}
tileLayerRef.current = L.tileLayer(url, { attribution: attr, maxZoom: 19 }).addTo(map);
} catch {}
}, [mapStyle]);
// Update popup content when clubName/address change
useEffect(() => {
try {
if (markerRef.current) {
if (clubName || address) {
let popupContent = '';
if (clubName) popupContent += `<b>${clubName}</b><br>`;
if (address) popupContent += address;
markerRef.current.bindPopup(popupContent);
} else {
markerRef.current.unbindPopup();
}
}
} catch {}
}, [clubName, address]);
// Helper function to create color filter
function createColorFilter(color: string): string | null {
@@ -17,6 +17,11 @@ 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.date_of_birth ? (
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>
Věk: {calculateAge(p.date_of_birth)} let
</Text>
) : null}
</VStack>
))}
</HStack>
@@ -24,4 +29,18 @@ const TeamScroller: React.FC = () => {
);
};
function calculateAge(dob: string): number | null {
try {
const d = new Date(dob);
if (isNaN(d.getTime())) return null;
const today = new Date();
let age = today.getFullYear() - d.getFullYear();
const m = today.getMonth() - d.getMonth();
if (m < 0 || (m === 0 && today.getDate() < d.getDate())) age--;
return age;
} catch {
return null;
}
}
export default TeamScroller;
+12 -7
View File
@@ -10,9 +10,10 @@ import SponsorsSection from '../common/SponsorsSection';
interface MainLayoutProps {
children: ReactNode;
headerInsideContainer?: boolean;
showSponsorsSection?: boolean;
}
export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideContainer = false }) => {
export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideContainer = false, showSponsorsSection = true }) => {
const [showTop, setShowTop] = useState(false);
const { getStyles, getVariant } = useAllPageElementConfigs('homepage');
const headerVariant = getVariant('header', 'unified');
@@ -54,9 +55,11 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
</Box>
{children}
</Container>
<Box data-element="sponsors" data-variant={sponsorsVariant} style={{ ...getStyles('sponsors') }}>
<SponsorsSection />
</Box>
{showSponsorsSection && (
<Box data-element="sponsors" data-variant={sponsorsVariant} style={{ ...getStyles('sponsors') }}>
<SponsorsSection />
</Box>
)}
<Box as="footer" data-element="footer" data-variant={footerVariant} style={{ ...getStyles('footer') }}>
<Footer />
</Box>
@@ -74,9 +77,11 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
{children}
</Container>
{/* Global sponsors section across front-facing pages */}
<Box data-element="sponsors" data-variant={sponsorsVariant} style={{ ...getStyles('sponsors') }}>
<SponsorsSection />
</Box>
{showSponsorsSection && (
<Box data-element="sponsors" data-variant={sponsorsVariant} style={{ ...getStyles('sponsors') }}>
<SponsorsSection />
</Box>
)}
<Box as="footer" data-element="footer" data-variant={footerVariant} style={{ ...getStyles('footer') }}>
<Footer />
</Box>
@@ -63,15 +63,13 @@ export default function NewsletterSubscribe() {
setIsLoading(false);
}
};
const cardBg = useColorModeValue('white', 'transparent');
const cardBorder = useColorModeValue('gray.200', 'gray.600');
const headingColor = useColorModeValue('gray.800', 'white');
const textColor = useColorModeValue('gray.600', 'gray.300');
const disclaimerColor = useColorModeValue('gray.500', 'gray.400');
return (
<Box w="100%" maxW="xl" mx="auto" p={4} bg={cardBg} borderRadius="md" boxShadow="sm" borderWidth="1px" borderColor={cardBorder}>
<Box w="100%" maxW="xl" mx="auto" p={4}>
<VStack spacing={3} align="stretch">
<Text fontSize="xl" fontWeight="bold" textAlign="center" color={headingColor}>
Přihlaste se k odběru novinek
+61 -31
View File
@@ -1,4 +1,5 @@
import React from 'react';
import { assetUrl, sanitizeClubName } from '../../utils/url';
export interface StandingRow {
position?: number;
@@ -21,7 +22,7 @@ export interface StandingRow {
score?: string;
}
const StandingsCard: React.FC<{ rows: StandingRow[]; onRowClick?: (row: StandingRow, index: number) => void }>= ({ rows, onRowClick }) => {
const StandingsCard: React.FC<{ rows: StandingRow[]; onRowClick?: (row: StandingRow, index: number) => void; variant?: 'logos' | 'plain' }>= ({ rows, onRowClick, variant = 'logos' }) => {
const safe = Array.isArray(rows) ? rows : [];
return (
<div className="table-card">
@@ -40,36 +41,65 @@ const StandingsCard: React.FC<{ rows: StandingRow[]; onRowClick?: (row: Standing
</tr>
</thead>
<tbody>
{safe.slice(0, 8).map((row, idx) => (
<tr
key={idx}
onClick={() => onRowClick?.(row, idx)}
style={{
cursor: onRowClick ? 'pointer' : 'default',
background: 'var(--card-bg)',
border: '1px solid var(--card-border)',
borderRadius: '8px',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLTableRowElement).style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)';
(e.currentTarget as HTMLTableRowElement).style.borderColor = 'var(--primary)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLTableRowElement).style.boxShadow = 'none';
(e.currentTarget as HTMLTableRowElement).style.borderColor = 'var(--card-border)';
}}
>
<td style={{ padding: '10px 8px', fontWeight: 700, color: 'var(--secondary)' }}>{row.position ?? row.pos ?? row.rank ?? idx + 1}</td>
<td style={{ padding: '10px 8px', fontWeight: 600 }}>{(row as any).team?.name ?? (row as any).team ?? (row as any).club ?? '-'}</td>
<td style={{ padding: '10px 4px', textAlign: 'center' }}>{(row as any).played ?? (row as any).matches ?? '-'}</td>
<td style={{ padding: '10px 4px', textAlign: 'center' }}>{(row as any).wins ?? (row as any).win ?? '-'}</td>
<td style={{ padding: '10px 4px', textAlign: 'center' }}>{(row as any).draws ?? (row as any).draw ?? '-'}</td>
<td style={{ padding: '10px 4px', textAlign: 'center' }}>{(row as any).losses ?? (row as any).loss ?? '-'}</td>
<td style={{ padding: '10px 4px', textAlign: 'center', display: 'none' }} className="hide-mobile">{(row as any).score ?? '-'}</td>
<td style={{ padding: '10px 8px', textAlign: 'center', fontWeight: 800 }}>{(row as any).points ?? (row as any).pts ?? '-'}</td>
</tr>
))}
{safe.slice(0, 8).map((row, idx) => {
const teamNameRaw = (row as any).team?.name ?? (row as any).team ?? (row as any).club ?? '-';
const teamName = sanitizeClubName(teamNameRaw);
const logo = (row as any).team_logo_url;
const logoSrc = logo ? (assetUrl(logo) || logo) : null;
return (
<tr
key={idx}
onClick={() => onRowClick?.(row, idx)}
style={{
cursor: onRowClick ? 'pointer' : 'default',
background: 'var(--card-bg)',
border: '1px solid var(--card-border)',
borderRadius: '8px',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLTableRowElement).style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)';
(e.currentTarget as HTMLTableRowElement).style.borderColor = 'var(--primary)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLTableRowElement).style.boxShadow = 'none';
(e.currentTarget as HTMLTableRowElement).style.borderColor = 'var(--card-border)';
}}
>
<td style={{ padding: '10px 8px', fontWeight: 700, color: 'var(--secondary)' }}>{row.position ?? row.pos ?? row.rank ?? idx + 1}</td>
<td style={{ padding: '10px 8px', fontWeight: 600 }}>
{variant === 'logos' ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
{logoSrc ? (
<img
src={logoSrc as string}
alt={teamName || 'Tým'}
loading="lazy"
style={{
width: 20,
height: 20,
borderRadius: '50%',
objectFit: 'cover',
background: 'var(--bg-soft)',
border: '1px solid var(--card-border)',
}}
/>
) : null}
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{teamName}</span>
</span>
) : (
teamNameRaw
)}
</td>
<td style={{ padding: '10px 4px', textAlign: 'center' }}>{(row as any).played ?? (row as any).matches ?? '-'}</td>
<td style={{ padding: '10px 4px', textAlign: 'center' }}>{(row as any).wins ?? (row as any).win ?? '-'}</td>
<td style={{ padding: '10px 4px', textAlign: 'center' }}>{(row as any).draws ?? (row as any).draw ?? '-'}</td>
<td style={{ padding: '10px 4px', textAlign: 'center' }}>{(row as any).losses ?? (row as any).loss ?? '-'}</td>
<td style={{ padding: '10px 4px', textAlign: 'center', display: 'none' }} className="hide-mobile">{(row as any).score ?? '-'}</td>
<td style={{ padding: '10px 8px', textAlign: 'center', fontWeight: 800 }}>{(row as any).points ?? (row as any).pts ?? '-'}</td>
</tr>
);
})}
</tbody>
</table>
</div>