From 16e4533202d16ba4fcbd2ec41dd02be7c11e32f2 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Wed, 29 Oct 2025 21:20:16 +0100 Subject: [PATCH] dev day #75 --- frontend/src/components/Navbar.tsx | 124 ++++++-- .../src/components/banners/BannerDisplay.tsx | 24 +- .../components/common/CustomRichEditor.tsx | 77 +++-- .../src/components/editor/MyUIbrixEditor.tsx | 51 ++- .../src/components/elements/SpartaNavbar.tsx | 16 +- .../src/components/home/ClubHeroTopbar.tsx | 16 +- frontend/src/components/home/ContactMap.tsx | 115 ++++--- frontend/src/components/home/TeamScroller.tsx | 19 ++ frontend/src/components/layout/MainLayout.tsx | 19 +- .../newsletter/NewsletterSubscribe.tsx | 6 +- .../src/components/pack/StandingsCard.tsx | 92 ++++-- frontend/src/data/defaultElements.ts | 2 +- frontend/src/hooks/usePageElementConfig.ts | 35 +++ frontend/src/pages/AboutPage.tsx | 6 +- frontend/src/pages/ActivityDetailPage.tsx | 18 +- frontend/src/pages/ArticleDetailPage.tsx | 11 +- frontend/src/pages/BlogPage.tsx | 176 +++++++++-- frontend/src/pages/ContactPage.tsx | 4 +- frontend/src/pages/ForbiddenPage.tsx | 95 +++--- frontend/src/pages/HomePage.tsx | 247 ++++++++++----- .../src/pages/NewsletterPreferencesPage.tsx | 20 ++ frontend/src/pages/NotFoundPage.tsx | 95 +++--- frontend/src/pages/PlayerDetailPage.tsx | 16 +- frontend/src/pages/admin/AboutAdminPage.tsx | 100 +++++- .../src/pages/admin/AdminActivitiesPage.tsx | 2 +- frontend/src/pages/admin/AdminMerchPage.tsx | 82 ++++- frontend/src/pages/admin/AdminVideosPage.tsx | 36 ++- .../src/pages/admin/ArticlesAdminPage.tsx | 5 +- frontend/src/pages/admin/BannersAdminPage.tsx | 49 +-- .../src/pages/admin/ContactsAdminPage.tsx | 114 ++++--- frontend/src/pages/admin/FilesAdminPage.tsx | 6 +- .../src/pages/admin/MessagesAdminPage.tsx | 31 +- .../src/pages/admin/NavigationAdminPage.tsx | 137 +++++++-- .../src/pages/admin/NewsletterAdminPage.tsx | 127 ++++++-- frontend/src/pages/admin/PlayersAdminPage.tsx | 45 +-- frontend/src/pages/admin/TeamsAdminPage.tsx | 99 +++--- .../src/services/admin/contactMessages.ts | 21 +- frontend/src/services/banners.ts | 38 +++ frontend/src/services/imageProcessing.ts | 20 +- frontend/src/services/navigation.ts | 51 +-- frontend/src/services/pageElements.ts | 6 +- frontend/src/services/settings.ts | 2 + frontend/src/services/setup.ts | 3 + frontend/src/styles/home-style-pack.css | 2 +- frontend/src/utils/imageUtils.ts | 14 +- frontend/src/utils/url.ts | 84 +++-- internal/controllers/base_controller.go | 290 ++++++++++++++++-- internal/controllers/contact_controller.go | 108 +++++-- .../image_processing_controller.go | 31 +- internal/middleware/security_headers.go | 3 +- internal/models/banner.go | 17 + internal/models/models.go | 9 + internal/routes/routes.go | 11 + pkg/database/database.go | 1 + pkg/email/service.go | 144 +++++++-- templates/emails/base.html | 15 +- templates/emails/contact_confirmation.html | 19 ++ templates/emails/contact_form.html | 18 +- templates/emails/newsletter_setup.html | 18 +- templates/emails/newsletter_welcome.html | 109 +------ templates/emails/newsletter_welcome_back.html | 99 +----- 61 files changed, 2308 insertions(+), 942 deletions(-) create mode 100644 frontend/src/services/banners.ts create mode 100644 internal/models/banner.go create mode 100644 templates/emails/contact_confirmation.html diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 6d45c25..f1502b2 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -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 ( + + + + {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 ( + + ); + })} + + + ); + } + return ( - {Array.isArray(categories) && categories.length > 0 && ( - - {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 ( - - ); - })} - + {Array.isArray(categories) && categories.length > 0 ? ( + <> + + + {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 ( + + ); + })} + + + ) : ( + )} )} @@ -282,6 +316,13 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => { const [dynamicNavItems, setDynamicNavItems] = useState([]); const [navLoading, setNavLoading] = useState(true); const containerMaxW = fullWidth ? 'full' as const : '7xl' as const; + const [windowWidth, setWindowWidth] = useState(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 ( {/* Top bar with socials and quick external links */} @@ -722,7 +789,7 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => { {/* Desktop navigation with hover dropdowns */} - {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 }) => { ); })} + {navSplit.overflow.length > 0 && ( + + )} @@ -930,11 +1000,21 @@ const HoverMenu = ({ label, items, isActive }: { label: string; items: { label: {label} - {items.map((it) => ( - - {it.label} - - ))} + {items.map((it) => { + const isExternal = /^https?:\/\//i.test(it.to); + if (isExternal) { + return ( + + {it.label} + + ); + } + return ( + + {it.label} + + ); + })} diff --git a/frontend/src/components/banners/BannerDisplay.tsx b/frontend/src/components/banners/BannerDisplay.tsx index 73f5214..b64521b 100644 --- a/frontend/src/components/banners/BannerDisplay.tsx +++ b/frontend/src/components/banners/BannerDisplay.tsx @@ -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 = ({ 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 = ({ 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 = ({ banners, placement, conta {activeBanners.map((banner) => ( {banner.name} = ({ const onChangeRef = useRef(onChange); const selectedImageIdRef = useRef(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 = ({ 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 = ({ 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 = ({ // 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ borderWidth="1px" borderColor={borderColor} borderRadius="md" - overflow="hidden" + overflow="visible" bg={bgColor} sx={{ '.ql-toolbar': { @@ -1477,7 +1468,33 @@ const CustomRichEditor: React.FC = ({ > {/* Toolbar Header */} - + { + 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" + > Úprava obrázku diff --git a/frontend/src/components/editor/MyUIbrixEditor.tsx b/frontend/src/components/editor/MyUIbrixEditor.tsx index cd36c6f..67d2e67 100644 --- a/frontend/src/components/editor/MyUIbrixEditor.tsx +++ b/frontend/src/components/editor/MyUIbrixEditor.tsx @@ -105,7 +105,7 @@ import { DEFAULT_HOMEPAGE_ELEMENTS, HOMEPAGE_IMPLEMENTED_ELEMENTS } from '../../ const SUPPORTED_HOME_VARIANTS: Record = { 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 = ({ pageType, onC const [dragOverElement, setDragOverElement] = useState(null); const [viewport] = useState<'desktop'>('desktop'); const [elementStyles, setElementStyles] = useState>({}); - const [showStylePanel, setShowStylePanel] = useState(true); + const [showStylePanel, setShowStylePanel] = useState(false); const [stylePanelRight, setStylePanelRight] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [selectedCategory, setSelectedCategory] = useState('all'); @@ -381,7 +381,7 @@ const MyUIbrixStyleEditor: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ pageType, onC onClick={() => setStylePanelRight(!stylePanelRight)} /> + {unsavedCount > 0 && ( = ({ pageType, onC size="sm" variant="ghost" colorScheme="whiteAlpha" - onClick={() => setIsEditing(false)} + onClick={handleExitEditing} /> @@ -1946,10 +1964,10 @@ const MyUIbrixStyleEditor: React.FC = ({ pageType, onC = ({ 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 = ({ 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 = ({ 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 = ({ pageType, onC )} {/* Unsaved Changes Indicator */} - {isEditing && hasChanges && ( + {false && isEditing && hasChanges && ( { 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 ( + + {nav.label} + {nav.items.map((it) => ( + setMobileOpen(false)}> + {it.label} + + ))} + + ); + } + if (nav.external && nav.to) { return ( setMobileOpen(false)}> diff --git a/frontend/src/components/home/ClubHeroTopbar.tsx b/frontend/src/components/home/ClubHeroTopbar.tsx index 9d4946c..01648d0 100644 --- a/frontend/src/components/home/ClubHeroTopbar.tsx +++ b/frontend/src/components/home/ClubHeroTopbar.tsx @@ -7,7 +7,7 @@ export type ClubHeroTopbarVariant = 'brand' | 'minimal' | 'badge'; const cls = (...parts: Array) => 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
{tagline}
-
- Kalendář - {shopUrl && ( - Fanshop - )} -
+ {variant !== 'minimal' && ( +
+ Kalendář + {shopUrl && ( + Fanshop + )} +
+ )}
); }; diff --git a/frontend/src/components/home/ContactMap.tsx b/frontend/src/components/home/ContactMap.tsx index 5379440..be792b8 100644 --- a/frontend/src/components/home/ContactMap.tsx +++ b/frontend/src/components/home/ContactMap.tsx @@ -110,6 +110,8 @@ const ContactMap: React.FC = ({ }) => { const mapRef = useRef(null); const mapInstanceRef = useRef(null); + const tileLayerRef = useRef(null); + const markerRef = useRef(null); const [isLoaded, setIsLoaded] = React.useState(false); const [loadError, setLoadError] = React.useState(null); @@ -175,52 +177,38 @@ const ContactMap: React.FC = ({ mapInstanceRef.current = map; - // Get tile layer URL based on style - let tileUrl = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; - let attribution = '© OpenStreetMap contributors'; + // Initial tile layer + const { tileUrl, attribution } = (() => { + let url = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; + let attr = '© OpenStreetMap 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 += `${clubName}
`; if (address) popupContent += address; - marker.bindPopup(popupContent); + markerRef.current.bindPopup(popupContent); } // Enable scroll zoom on click @@ -238,14 +226,71 @@ const ContactMap: React.FC = ({ 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 = '© OpenStreetMap 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 += `${clubName}
`; + 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 { diff --git a/frontend/src/components/home/TeamScroller.tsx b/frontend/src/components/home/TeamScroller.tsx index 8a080ba..4ad387a 100644 --- a/frontend/src/components/home/TeamScroller.tsx +++ b/frontend/src/components/home/TeamScroller.tsx @@ -17,6 +17,11 @@ const TeamScroller: React.FC = () => { {p.first_name {p.first_name} {p.last_name} {p.position} + {p.date_of_birth ? ( + + Věk: {calculateAge(p.date_of_birth)} let + + ) : null}
))} @@ -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; diff --git a/frontend/src/components/layout/MainLayout.tsx b/frontend/src/components/layout/MainLayout.tsx index e748927..5854793 100644 --- a/frontend/src/components/layout/MainLayout.tsx +++ b/frontend/src/components/layout/MainLayout.tsx @@ -10,9 +10,10 @@ import SponsorsSection from '../common/SponsorsSection'; interface MainLayoutProps { children: ReactNode; headerInsideContainer?: boolean; + showSponsorsSection?: boolean; } -export const MainLayout: React.FC = ({ children, headerInsideContainer = false }) => { +export const MainLayout: React.FC = ({ 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 = ({ children, headerInsideCo {children} - - - + {showSponsorsSection && ( + + + + )}