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 hasChildren = item.type === 'dropdown' && item.children && item.children.length > 0;
const linkProps = linkIsExternal ? { href: item.url } : { to: item.url || '/' }; const linkProps = linkIsExternal ? { href: item.url } : { to: item.url || '/' };
const Comp: any = linkIsExternal ? 'a' : RouterLink; 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 ( return (
<React.Fragment key={item.id || idx}> <React.Fragment key={item.id || idx}>
<Button <Button
@@ -194,20 +224,24 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
})} })}
{hasArticles === true && ( {hasArticles === true && (
<> <>
<Button as={RouterLink} to="/blog" variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button> {Array.isArray(categories) && categories.length > 0 ? (
{Array.isArray(categories) && categories.length > 0 && ( <>
<VStack align="stretch" pl={4} spacing={1}> <Button variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
{categories.map((cat: any) => { <VStack align="stretch" pl={4} spacing={1}>
const catIsExternal = typeof cat.url === 'string' && /^https?:\/\//i.test(cat.url); {categories.map((cat: any) => {
const catHref = cat.url || (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog'); const catIsExternal = typeof cat.url === 'string' && /^https?:\/\//i.test(cat.url);
const catLinkProps = catIsExternal ? { href: catHref } : { to: catHref }; const catHref = cat.url || (cat.id ? `/blog?category_id=${cat.id}` : (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog'));
return ( const catLinkProps = catIsExternal ? { href: catHref } : { to: catHref };
<Button key={cat.slug || cat.name} as={catIsExternal ? 'a' : RouterLink} {...(catLinkProps as any)} variant="ghost" justifyContent="flex-start" fontWeight="normal" size="sm"> return (
{cat.name} <Button key={cat.slug || cat.id || cat.name} as={catIsExternal ? 'a' : RouterLink} {...(catLinkProps as any)} variant="ghost" justifyContent="flex-start" fontWeight="normal" size="sm">
</Button> {cat.name}
); </Button>
})} );
</VStack> })}
</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 [dynamicNavItems, setDynamicNavItems] = useState<NavigationItem[]>([]);
const [navLoading, setNavLoading] = useState(true); const [navLoading, setNavLoading] = useState(true);
const containerMaxW = fullWidth ? 'full' as const : '7xl' as const; 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 // Search modal state
const [query, setQuery] = useState(''); 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 : []; const source = Array.isArray(navCategories) && navCategories.length > 0 ? navCategories : [];
return source.map((cat: any) => ({ return source.map((cat: any) => ({
label: cat.name, 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]); }, [navCategories]);
@@ -659,6 +700,32 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
return links; return links;
}, [filteredDynamicNavItems, navLoading, settings, categoryItems, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, galleryLabel]); }, [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 ( return (
<Box position="sticky" top={0} zIndex={1000}> <Box position="sticky" top={0} zIndex={1000}>
{/* Top bar with socials and quick external links */} {/* Top bar with socials and quick external links */}
@@ -722,7 +789,7 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
</HStack> </HStack>
{/* Desktop navigation with hover dropdowns */} {/* Desktop navigation with hover dropdowns */}
<HStack as="nav" spacing={1} display={{ base: 'none', lg: 'flex' }} ml={4}> <HStack as="nav" spacing={1} display={{ base: 'none', lg: 'flex' }} ml={4}>
{NAV_LINKS.map((nav) => { {navSplit.visible.map((nav) => {
const commonProps = { const commonProps = {
variant: 'ghost' as const, variant: 'ghost' as const,
size: 'sm' as const, size: 'sm' as const,
@@ -754,6 +821,9 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
</Button> </Button>
); );
})} })}
{navSplit.overflow.length > 0 && (
<HoverMenu key="more" label="Další" items={moreItems} />
)}
</HStack> </HStack>
</HStack> </HStack>
@@ -930,11 +1000,21 @@ const HoverMenu = ({ label, items, isActive }: { label: string; items: { label:
{label} {label}
</MenuButton> </MenuButton>
<MenuList> <MenuList>
{items.map((it) => ( {items.map((it) => {
<MenuItem as={RouterLink} to={it.to} key={it.to}> const isExternal = /^https?:\/\//i.test(it.to);
{it.label} if (isExternal) {
</MenuItem> 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> </MenuList>
</Menu> </Menu>
</Box> </Box>
@@ -5,8 +5,10 @@ import { assetUrl } from '../../utils/url';
export interface Banner { export interface Banner {
id: number | string; id: number | string;
name: string; name: string;
image: string; image?: string;
image_url?: string;
url?: string; url?: string;
click_url?: string;
placement?: string; placement?: string;
width?: number; width?: number;
height?: number; height?: number;
@@ -15,7 +17,7 @@ export interface Banner {
interface BannerDisplayProps { interface BannerDisplayProps {
banners: Banner[]; 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; containerStyle?: React.CSSProperties;
} }
@@ -41,6 +43,8 @@ const BannerDisplay: React.FC<BannerDisplayProps> = ({ banners, placement, conta
return 'banner-footer'; return 'banner-footer';
case 'article_inline': case 'article_inline':
return 'banner-article'; return 'banner-article';
case 'homepage_under_table':
return 'banner-under-table';
default: default:
return 'banner'; return 'banner';
} }
@@ -89,6 +93,12 @@ const BannerDisplay: React.FC<BannerDisplayProps> = ({ banners, placement, conta
display: 'block', display: 'block',
margin: '24px 0', margin: '24px 0',
}; };
case 'homepage_under_table':
return {
...base,
margin: '12px 0 0',
justifyContent: 'center',
};
default: default:
return base; return base;
} }
@@ -105,16 +115,16 @@ const BannerDisplay: React.FC<BannerDisplayProps> = ({ banners, placement, conta
{activeBanners.map((banner) => ( {activeBanners.map((banner) => (
<ChakraLink <ChakraLink
key={banner.id} key={banner.id}
href={banner.url || '#'} href={banner.url || banner.click_url || '#'}
isExternal={!!banner.url} isExternal={!!(banner.url || banner.click_url)}
target={banner.url ? '_blank' : undefined} target={(banner.url || banner.click_url) ? '_blank' : undefined}
rel={banner.url ? 'noopener noreferrer' : undefined} rel={(banner.url || banner.click_url) ? 'noopener noreferrer' : undefined}
display="inline-block" display="inline-block"
_hover={{ opacity: 0.9, transform: 'translateY(-2px)' }} _hover={{ opacity: 0.9, transform: 'translateY(-2px)' }}
transition="all 0.2s" transition="all 0.2s"
> >
<img <img
src={assetUrl(banner.image) || banner.image} src={assetUrl(banner.image || banner.image_url || '') || banner.image || banner.image_url || ''}
alt={banner.name} alt={banner.name}
style={{ style={{
maxWidth: '100%', maxWidth: '100%',
@@ -78,6 +78,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const onChangeRef = useRef(onChange); const onChangeRef = useRef(onChange);
const selectedImageIdRef = useRef<string | null>(null); const selectedImageIdRef = useRef<string | null>(null);
const selectImageByIdRef = useRef<(id: string) => void>(() => {}); 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); const [isMounted, setIsMounted] = useState(false);
// Ensure component is mounted before rendering Quill // Ensure component is mounted before rendering Quill
@@ -401,6 +402,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
let startX = 0; let startX = 0;
let startY = 0; let startY = 0;
let startWidth = 0; let startWidth = 0;
let rafId = 0;
const createResizeHandle = (img: HTMLImageElement) => { const createResizeHandle = (img: HTMLImageElement) => {
removeResizeHandle(); removeResizeHandle();
@@ -672,23 +674,13 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const scrollTop = editor.root.scrollTop; const scrollTop = editor.root.scrollTop;
const scrollLeft = editor.root.scrollLeft; 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 toolbarWidth = 380;
const spaceOnRight = window.innerWidth - rect.right; const margin = 8;
const positionRight = spaceOnRight > toolbarWidth + 20; 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 leftPos = positionRight let topPos = rect.top - editorRect.top + scrollTop + margin;
? rect.right - editorRect.left + scrollLeft + 10 topPos = Math.max(margin, topPos);
: 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);
setToolbarPosition({ setToolbarPosition({
top: topPos, top: topPos,
@@ -895,16 +887,18 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Handle scroll to update resize handle position // Handle scroll to update resize handle position
const handleScroll = () => { const handleScroll = () => {
if (selectedImage && resizeHandle) { if (!selectedImage || !resizeHandle) return;
const rect = selectedImage.getBoundingClientRect(); if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const rect = selectedImage!.getBoundingClientRect();
const editorRect = editor.root.getBoundingClientRect(); const editorRect = editor.root.getBoundingClientRect();
const scrollTop = editor.root.scrollTop; const scrollTop = editor.root.scrollTop;
const scrollLeft = editor.root.scrollLeft; const scrollLeft = editor.root.scrollLeft;
resizeHandle.style.left = `${rect.left - editorRect.left + scrollLeft}px`; resizeHandle!.style.left = `${rect.left - editorRect.left + scrollLeft}px`;
resizeHandle.style.top = `${rect.top - editorRect.top + scrollTop}px`; resizeHandle!.style.top = `${rect.top - editorRect.top + scrollTop}px`;
resizeHandle.style.width = `${rect.width}px`; resizeHandle!.style.width = `${rect.width}px`;
resizeHandle.style.height = `${rect.height}px`; resizeHandle!.style.height = `${rect.height}px`;
} });
}; };
// Prevent default drag behavior on images // Prevent default drag behavior on images
@@ -934,6 +928,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('resize', handleScroll); window.removeEventListener('resize', handleScroll);
document.removeEventListener('scroll', handleScroll, true); document.removeEventListener('scroll', handleScroll, true);
if (rafId) cancelAnimationFrame(rafId);
removeResizeHandle(); removeResizeHandle();
deselectImage(); deselectImage();
}; };
@@ -1047,8 +1042,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const editor = quillRef.current?.getEditor(); const editor = quillRef.current?.getEditor();
if (editor) { if (editor) {
onChangeRef.current(editor.root.innerHTML); onChangeRef.current(editor.root.innerHTML);
// Force overlay reposition
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
} }
reselectAfterContentUpdate(); reselectAfterContentUpdate();
@@ -1111,7 +1104,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setManualWidth(finalWidth.toString()); setManualWidth(finalWidth.toString());
if (editor) { if (editor) {
onChangeRef.current(editor.root.innerHTML); onChangeRef.current(editor.root.innerHTML);
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
} }
// Keep selection active for subsequent operations (e.g., 50% → 75%) // Keep selection active for subsequent operations (e.g., 50% → 75%)
reselectAfterContentUpdate(); reselectAfterContentUpdate();
@@ -1132,7 +1124,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setManualWidth(''); setManualWidth('');
if (editor) { if (editor) {
onChangeRef.current(editor.root.innerHTML); onChangeRef.current(editor.root.innerHTML);
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
} }
reselectAfterContentUpdate(); reselectAfterContentUpdate();
toast({ title: 'Šířka resetována', status: 'info', duration: 1200 }); toast({ title: 'Šířka resetována', status: 'info', duration: 1200 });
@@ -1190,7 +1181,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
let cleaned = DOMPurify.sanitize(content, { let cleaned = DOMPurify.sanitize(content, {
USE_PROFILES: { html: true }, USE_PROFILES: { html: true },
ADD_TAGS: ['iframe'], 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 // Replace white and very light colors with dark colors for visibility
@@ -1236,7 +1227,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
borderWidth="1px" borderWidth="1px"
borderColor={borderColor} borderColor={borderColor}
borderRadius="md" borderRadius="md"
overflow="hidden" overflow="visible"
bg={bgColor} bg={bgColor}
sx={{ sx={{
'.ql-toolbar': { '.ql-toolbar': {
@@ -1477,7 +1468,33 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
> >
<VStack align="stretch" spacing={3}> <VStack align="stretch" spacing={3}>
{/* Toolbar Header */} {/* 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}> <HStack spacing={2}>
<Settings size={16} /> <Settings size={16} />
<Text fontWeight="bold" fontSize="sm">Úprava obrázku</Text> <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[]> = { const SUPPORTED_HOME_VARIANTS: Record<string, string[]> = {
hero: ['grid', 'scroller', 'swiper', 'swiper_full'], hero: ['grid', 'scroller', 'swiper', 'swiper_full'],
news: ['grid', 'scroller'], news: ['grid_one', 'grid_two', 'grid', 'scroller'],
matches: ['compact'], matches: ['compact'],
sponsors: ['grid', 'slider', 'scroller', 'pyramid'], sponsors: ['grid', 'slider', 'scroller', 'pyramid'],
gallery: ['grid'], gallery: ['grid'],
@@ -149,7 +149,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const [dragOverElement, setDragOverElement] = useState<string | null>(null); const [dragOverElement, setDragOverElement] = useState<string | null>(null);
const [viewport] = useState<'desktop'>('desktop'); const [viewport] = useState<'desktop'>('desktop');
const [elementStyles, setElementStyles] = useState<Record<string, any>>({}); const [elementStyles, setElementStyles] = useState<Record<string, any>>({});
const [showStylePanel, setShowStylePanel] = useState(true); const [showStylePanel, setShowStylePanel] = useState(false);
const [stylePanelRight, setStylePanelRight] = useState(false); const [stylePanelRight, setStylePanelRight] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('all'); 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 // Auto-open Layers panel on the left by default when entering edit mode
useEffect(() => { useEffect(() => {
if (isEditing) { if (isEditing) {
setShowLayersPanel(true); setShowLayersPanel(false);
} }
}, [isEditing]); }, [isEditing]);
@@ -561,7 +561,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
if (showElementPicker) setShowElementPicker(false); if (showElementPicker) setShowElementPicker(false);
else if (showLayersPanel) setShowLayersPanel(false); else if (showLayersPanel) setShowLayersPanel(false);
else if (selectedElement) setSelectedElement(null); else if (selectedElement) setSelectedElement(null);
else setIsEditing(false); else handleExitEditing();
} }
if ((e.ctrlKey || e.metaKey) && e.key === 's' && hasChanges) { if ((e.ctrlKey || e.metaKey) && e.key === 's' && hasChanges) {
e.preventDefault(); e.preventDefault();
@@ -1515,7 +1515,9 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
display_order: index, display_order: index,
settings: { settings: {
...(configs.find(c => c.element_name === elementName)?.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] [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 // Calculate viewport width - USE REAL DEVICE WIDTHS WITHOUT SCALING
const getViewportConfig = useCallback(() => ({ const getViewportConfig = useCallback(() => ({
width: '100%', width: '100%',
@@ -1887,6 +1897,14 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
onClick={() => setStylePanelRight(!stylePanelRight)} onClick={() => setStylePanelRight(!stylePanelRight)}
/> />
</Tooltip> </Tooltip>
<Button
size="sm"
variant="outline"
colorScheme="whiteAlpha"
onClick={handleStartBlank}
>
Začít s prázdnou stránkou
</Button>
{unsavedCount > 0 && ( {unsavedCount > 0 && (
<Badge <Badge
bg="yellow.400" bg="yellow.400"
@@ -1936,7 +1954,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
size="sm" size="sm"
variant="ghost" variant="ghost"
colorScheme="whiteAlpha" colorScheme="whiteAlpha"
onClick={() => setIsEditing(false)} onClick={handleExitEditing}
/> />
</HStack> </HStack>
</Flex> </Flex>
@@ -1946,10 +1964,10 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
<Box <Box
className="myuibrix-panel" className="myuibrix-panel"
position="fixed" position="fixed"
left={stylePanelRight ? undefined : 4} left={stylePanelRight ? undefined : 0}
right={stylePanelRight ? 4 : undefined} right={stylePanelRight ? 0 : undefined}
top={64} top={0}
bottom={4} bottom={0}
width="380px" width="380px"
zIndex={9998} zIndex={9998}
overflow="hidden" overflow="hidden"
@@ -1958,7 +1976,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
fontFamily="var(--chakra-fonts-body)" fontFamily="var(--chakra-fonts-body)"
bg="rgba(255, 255, 255, 0.95)" bg="rgba(255, 255, 255, 0.95)"
backdropFilter="blur(12px) saturate(180%)" 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)" 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)" border="1px solid rgba(255,255,255,0.3)"
sx={{ sx={{
@@ -1979,7 +1997,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
alignItems="center" alignItems="center"
justifyContent="space-between" justifyContent="space-between"
flexShrink={0} flexShrink={0}
borderTopRadius="2xl" borderTopRadius="0"
boxShadow="0 2px 8px rgba(0,0,0,0.1)" boxShadow="0 2px 8px rgba(0,0,0,0.1)"
borderBottom="1px solid rgba(255,255,255,0.2)" borderBottom="1px solid rgba(255,255,255,0.2)"
> >
@@ -2040,11 +2058,10 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
colorScheme={isEditing ? "red" : "whiteAlpha"} colorScheme={isEditing ? "red" : "whiteAlpha"}
size="lg" size="lg"
onClick={() => { onClick={() => {
setIsEditing(!isEditing);
if (isEditing) { if (isEditing) {
setSelectedElement(null); handleExitEditing();
setShowLayersPanel(false); } else {
setShowElementPicker(false); setIsEditing(true);
} }
}} }}
borderRadius="full" borderRadius="full"
@@ -2357,7 +2374,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
)} )}
{/* Unsaved Changes Indicator */} {/* Unsaved Changes Indicator */}
{isEditing && hasChanges && ( {false && isEditing && hasChanges && (
<Box <Box
position="fixed" position="fixed"
top={4} top={4}
@@ -91,7 +91,7 @@ const SpartaNavbar: React.FC = () => {
const categoryItems = useMemo(() => { const categoryItems = useMemo(() => {
const source = Array.isArray(navCategories) && navCategories.length > 0 ? navCategories : []; 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]); }, [navCategories]);
const NAV_LINKS: NavLink[] = useMemo(() => { const NAV_LINKS: NavLink[] = useMemo(() => {
@@ -155,6 +155,20 @@ const SpartaNavbar: React.FC = () => {
const isActive = isPathActive(nav.to); const isActive = isPathActive(nav.to);
const className = isActive ? 'sparta-button-tertiary' : 'sparta-button-tertiary'; 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) { if (nav.external && nav.to) {
return ( return (
<a key={nav.label} href={nav.to} target="_blank" rel="noreferrer" className={className} onClick={() => setMobileOpen(false)}> <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 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 { data: settings } = usePublicSettings();
const theme = useClubTheme(); const theme = useClubTheme();
const title = settings?.club_name || theme.name || 'Fotbalový klub'; 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 className="club-hero-topbar__tagline">{tagline}</div>
</div> </div>
<div className="club-hero-topbar__spacer" /> <div className="club-hero-topbar__spacer" />
<div className="club-hero-topbar__actions"> {variant !== 'minimal' && (
<a href={calendarUrl} className="sparta-button-tertiary">Kalendář</a> <div className="club-hero-topbar__actions">
{shopUrl && ( <a href={calendarUrl} className="sparta-button-tertiary">Kalendář</a>
<a href={shopUrl} target="_blank" rel="noreferrer" className="sparta-button-primary">Fanshop</a> {shopUrl && (
)} <a href={shopUrl} target="_blank" rel="noreferrer" className="sparta-button-primary">Fanshop</a>
</div> )}
</div>
)}
</div> </div>
); );
}; };
+80 -35
View File
@@ -110,6 +110,8 @@ const ContactMap: React.FC<ContactMapProps> = ({
}) => { }) => {
const mapRef = useRef<HTMLDivElement>(null); const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<any>(null); const mapInstanceRef = useRef<any>(null);
const tileLayerRef = useRef<any>(null);
const markerRef = useRef<any>(null);
const [isLoaded, setIsLoaded] = React.useState(false); const [isLoaded, setIsLoaded] = React.useState(false);
const [loadError, setLoadError] = React.useState<string | null>(null); const [loadError, setLoadError] = React.useState<string | null>(null);
@@ -175,52 +177,38 @@ const ContactMap: React.FC<ContactMapProps> = ({
mapInstanceRef.current = map; mapInstanceRef.current = map;
// Get tile layer URL based on style // Initial tile layer
let tileUrl = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; const { tileUrl, attribution } = (() => {
let attribution = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'; 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 tileLayerRef.current = L.tileLayer(tileUrl, {
if (mapStyle && MAP_STYLES[mapStyle as keyof typeof MAP_STYLES]) { attribution,
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,
maxZoom: 19, maxZoom: 19,
}).addTo(map); }).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 // Create custom marker icon with club colors
const markerColor = clubPrimaryColor || '#3388ff'; const markerColor = clubPrimaryColor || '#3388ff';
const customIcon = createCustomMarkerIcon(markerColor, L); const customIcon = createCustomMarkerIcon(markerColor, L);
// Add marker // 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 // Add popup if address is provided
if (clubName || address) { if (clubName || address) {
let popupContent = ''; let popupContent = '';
if (clubName) popupContent += `<b>${clubName}</b><br>`; if (clubName) popupContent += `<b>${clubName}</b><br>`;
if (address) popupContent += address; if (address) popupContent += address;
marker.bindPopup(popupContent); markerRef.current.bindPopup(popupContent);
} }
// Enable scroll zoom on click // Enable scroll zoom on click
@@ -238,14 +226,71 @@ const ContactMap: React.FC<ContactMapProps> = ({
setLoadError('Failed to initialize map'); setLoadError('Failed to initialize map');
} }
// Cleanup // Cleanup on unmount
return () => { return () => {
if (mapInstanceRef.current) { try {
mapInstanceRef.current.remove(); if (mapInstanceRef.current) {
mapInstanceRef.current.remove();
}
} finally {
mapInstanceRef.current = null; 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 // Helper function to create color filter
function createColorFilter(color: string): string | null { 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" /> <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 fontWeight="bold" textAlign="center">{p.first_name} {p.last_name}</Text>
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>{p.position}</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> </VStack>
))} ))}
</HStack> </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; export default TeamScroller;
+12 -7
View File
@@ -10,9 +10,10 @@ import SponsorsSection from '../common/SponsorsSection';
interface MainLayoutProps { interface MainLayoutProps {
children: ReactNode; children: ReactNode;
headerInsideContainer?: boolean; 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 [showTop, setShowTop] = useState(false);
const { getStyles, getVariant } = useAllPageElementConfigs('homepage'); const { getStyles, getVariant } = useAllPageElementConfigs('homepage');
const headerVariant = getVariant('header', 'unified'); const headerVariant = getVariant('header', 'unified');
@@ -54,9 +55,11 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
</Box> </Box>
{children} {children}
</Container> </Container>
<Box data-element="sponsors" data-variant={sponsorsVariant} style={{ ...getStyles('sponsors') }}> {showSponsorsSection && (
<SponsorsSection /> <Box data-element="sponsors" data-variant={sponsorsVariant} style={{ ...getStyles('sponsors') }}>
</Box> <SponsorsSection />
</Box>
)}
<Box as="footer" data-element="footer" data-variant={footerVariant} style={{ ...getStyles('footer') }}> <Box as="footer" data-element="footer" data-variant={footerVariant} style={{ ...getStyles('footer') }}>
<Footer /> <Footer />
</Box> </Box>
@@ -74,9 +77,11 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
{children} {children}
</Container> </Container>
{/* Global sponsors section across front-facing pages */} {/* Global sponsors section across front-facing pages */}
<Box data-element="sponsors" data-variant={sponsorsVariant} style={{ ...getStyles('sponsors') }}> {showSponsorsSection && (
<SponsorsSection /> <Box data-element="sponsors" data-variant={sponsorsVariant} style={{ ...getStyles('sponsors') }}>
</Box> <SponsorsSection />
</Box>
)}
<Box as="footer" data-element="footer" data-variant={footerVariant} style={{ ...getStyles('footer') }}> <Box as="footer" data-element="footer" data-variant={footerVariant} style={{ ...getStyles('footer') }}>
<Footer /> <Footer />
</Box> </Box>
@@ -63,15 +63,13 @@ export default function NewsletterSubscribe() {
setIsLoading(false); setIsLoading(false);
} }
}; };
const cardBg = useColorModeValue('white', 'transparent');
const cardBorder = useColorModeValue('gray.200', 'gray.600');
const headingColor = useColorModeValue('gray.800', 'white'); const headingColor = useColorModeValue('gray.800', 'white');
const textColor = useColorModeValue('gray.600', 'gray.300'); const textColor = useColorModeValue('gray.600', 'gray.300');
const disclaimerColor = useColorModeValue('gray.500', 'gray.400'); const disclaimerColor = useColorModeValue('gray.500', 'gray.400');
return ( 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"> <VStack spacing={3} align="stretch">
<Text fontSize="xl" fontWeight="bold" textAlign="center" color={headingColor}> <Text fontSize="xl" fontWeight="bold" textAlign="center" color={headingColor}>
Přihlaste se k odběru novinek Přihlaste se k odběru novinek
+61 -31
View File
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { assetUrl, sanitizeClubName } from '../../utils/url';
export interface StandingRow { export interface StandingRow {
position?: number; position?: number;
@@ -21,7 +22,7 @@ export interface StandingRow {
score?: string; 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 : []; const safe = Array.isArray(rows) ? rows : [];
return ( return (
<div className="table-card"> <div className="table-card">
@@ -40,36 +41,65 @@ const StandingsCard: React.FC<{ rows: StandingRow[]; onRowClick?: (row: Standing
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{safe.slice(0, 8).map((row, idx) => ( {safe.slice(0, 8).map((row, idx) => {
<tr const teamNameRaw = (row as any).team?.name ?? (row as any).team ?? (row as any).club ?? '-';
key={idx} const teamName = sanitizeClubName(teamNameRaw);
onClick={() => onRowClick?.(row, idx)} const logo = (row as any).team_logo_url;
style={{ const logoSrc = logo ? (assetUrl(logo) || logo) : null;
cursor: onRowClick ? 'pointer' : 'default', return (
background: 'var(--card-bg)', <tr
border: '1px solid var(--card-border)', key={idx}
borderRadius: '8px', onClick={() => onRowClick?.(row, idx)}
transition: 'all 0.2s ease', style={{
}} cursor: onRowClick ? 'pointer' : 'default',
onMouseEnter={(e) => { background: 'var(--card-bg)',
(e.currentTarget as HTMLTableRowElement).style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)'; border: '1px solid var(--card-border)',
(e.currentTarget as HTMLTableRowElement).style.borderColor = 'var(--primary)'; borderRadius: '8px',
}} transition: 'all 0.2s ease',
onMouseLeave={(e) => { }}
(e.currentTarget as HTMLTableRowElement).style.boxShadow = 'none'; onMouseEnter={(e) => {
(e.currentTarget as HTMLTableRowElement).style.borderColor = 'var(--card-border)'; (e.currentTarget as HTMLTableRowElement).style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)';
}} (e.currentTarget as HTMLTableRowElement).style.borderColor = 'var(--primary)';
> }}
<td style={{ padding: '10px 8px', fontWeight: 700, color: 'var(--secondary)' }}>{row.position ?? row.pos ?? row.rank ?? idx + 1}</td> onMouseLeave={(e) => {
<td style={{ padding: '10px 8px', fontWeight: 600 }}>{(row as any).team?.name ?? (row as any).team ?? (row as any).club ?? '-'}</td> (e.currentTarget as HTMLTableRowElement).style.boxShadow = 'none';
<td style={{ padding: '10px 4px', textAlign: 'center' }}>{(row as any).played ?? (row as any).matches ?? '-'}</td> (e.currentTarget as HTMLTableRowElement).style.borderColor = 'var(--card-border)';
<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 8px', fontWeight: 700, color: 'var(--secondary)' }}>{row.position ?? row.pos ?? row.rank ?? idx + 1}</td>
<td style={{ padding: '10px 4px', textAlign: 'center', display: 'none' }} className="hide-mobile">{(row as any).score ?? '-'}</td> <td style={{ padding: '10px 8px', fontWeight: 600 }}>
<td style={{ padding: '10px 8px', textAlign: 'center', fontWeight: 800 }}>{(row as any).points ?? (row as any).pts ?? '-'}</td> {variant === 'logos' ? (
</tr> <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> </tbody>
</table> </table>
</div> </div>
+1 -1
View File
@@ -60,7 +60,7 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
{ {
page_type: 'homepage', page_type: 'homepage',
element_name: 'news', element_name: 'news',
variant: 'grid', variant: 'grid_one',
visible: true, visible: true,
display_order: 11, display_order: 11,
settings: {}, settings: {},
@@ -77,6 +77,7 @@ export const useAllPageElementConfigs = (pageType: string) => {
if (active) { if (active) {
const configMap: Record<string, string> = {}; const configMap: Record<string, string> = {};
const visMap: Record<string, boolean> = {}; const visMap: Record<string, boolean> = {};
const stylesMap: Record<string, Record<string, any>> = {};
// Sort by display_order to get correct element order // Sort by display_order to get correct element order
const sorted = [...data].sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); const sorted = [...data].sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
@@ -85,10 +86,44 @@ export const useAllPageElementConfigs = (pageType: string) => {
sorted.forEach(config => { sorted.forEach(config => {
configMap[config.element_name] = config.variant; configMap[config.element_name] = config.variant;
visMap[config.element_name] = config.visible !== false; visMap[config.element_name] = config.visible !== false;
// Load saved styles from settings.styles and apply to state
try {
const st = (config.settings && (config.settings as any).styles) || {};
if (st && typeof st === 'object' && Object.keys(st).length > 0) {
stylesMap[config.element_name] = st as Record<string, any>;
}
} catch {}
// Inject saved custom CSS (from settings.customCSS or settings.styles.customCSS)
try {
const css = String(((config.settings as any)?.customCSS) || ((config.settings as any)?.styles?.customCSS) || '').trim();
const styleId = `custom-css-${config.element_name}`;
const existing = document.getElementById(styleId);
if (existing) existing.remove();
if (css) {
const style = document.createElement('style');
style.id = styleId;
const hasBlocks = /\{[^}]*\}|@media|@keyframes/.test(css);
if (hasBlocks) {
style.textContent = css;
} else {
const importantDecls = css
.split(';')
.map(s => s.trim())
.filter(Boolean)
.map(s => (/!important\s*$/.test(s) ? s : `${s} !important`))
.join(';\n ');
style.textContent = `\n [data-element="${config.element_name}"] {\n ${importantDecls};\n }\n `;
}
document.head.appendChild(style);
}
} catch {}
}); });
setConfigs(configMap); setConfigs(configMap);
setVisibility(visMap); setVisibility(visMap);
if (Object.keys(stylesMap).length > 0) {
setStyles(stylesMap);
}
setElementOrder(order); setElementOrder(order);
// Apply initial order to DOM only in editor/preview mode // Apply initial order to DOM only in editor/preview mode
+4 -2
View File
@@ -212,7 +212,7 @@ const AboutPage: React.FC = () => {
'& h2': { fontSize: 'xl' }, '& h2': { fontSize: 'xl' },
'& h3': { fontSize: 'lg' }, '& h3': { fontSize: 'lg' },
'& img': { maxW: '100%', borderRadius: 'md', my: 6 }, '& img': { maxW: '100%', borderRadius: 'md', my: 6 },
'& ul, & ol': { pl: 6, mb: 4 }, '& ul, & ol': { pl: 8, mb: 4 },
'& li': { mb: 2 }, '& li': { mb: 2 },
}} }}
/> />
@@ -272,6 +272,8 @@ const AboutPage: React.FC = () => {
boxShadow: 'md', boxShadow: 'md',
}, },
}, },
'& ul, & ol': { pl: 12, mb: 4 },
'& li': { mb: 2 },
'& img': { maxW: '100%', borderRadius: 'md', my: 6, ml: 12 }, '& img': { maxW: '100%', borderRadius: 'md', my: 6, ml: 12 },
}} }}
/> />
@@ -312,7 +314,7 @@ const AboutPage: React.FC = () => {
'& h2': { fontSize: 'xl' }, '& h2': { fontSize: 'xl' },
'& h3': { fontSize: 'lg' }, '& h3': { fontSize: 'lg' },
'& img': { maxW: '100%', borderRadius: 'md', my: 4 }, '& img': { maxW: '100%', borderRadius: 'md', my: 4 },
'& ul, & ol': { pl: 6, mb: 4 }, '& ul, & ol': { pl: 8, mb: 4 },
'& li': { mb: 2 }, '& li': { mb: 2 },
}} }}
/> />
+17 -1
View File
@@ -6,6 +6,7 @@ import { Box, Container, Heading, Text, VStack, HStack, Badge, Spinner, Button,
import { FiDownload, FiMapPin, FiClock } from 'react-icons/fi'; import { FiDownload, FiMapPin, FiClock } from 'react-icons/fi';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { assetUrl } from '../utils/url'; import { assetUrl } from '../utils/url';
import { API_URL } from '../services/api';
import { trackEvent as umamiTrackEvent } from '../utils/umami'; import { trackEvent as umamiTrackEvent } from '../utils/umami';
import EventLocationMap from '../components/events/EventLocationMap'; import EventLocationMap from '../components/events/EventLocationMap';
import EmbeddedPoll from '../components/polls/EmbeddedPoll'; import EmbeddedPoll from '../components/polls/EmbeddedPoll';
@@ -54,6 +55,21 @@ const ActivityDetailPage: React.FC = () => {
return () => { el.removeEventListener('click', handler); }; return () => { el.removeEventListener('click', handler); };
}, [contentRef.current]); }, [contentRef.current]);
// Normalize uploads links in rich HTML content to backend origin
const toAbsoluteUploads = React.useCallback((html?: string) => {
if (!html) return '';
try {
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
return html
.replace(/src=("|')\s*(\/uploads\/[^"']+)("')/g, (_m, q1, p2, q3) => `src=${q1}${origin}${p2}${q3}`)
.replace(/href=("|')\s*(\/uploads\/[^"']+)("')/g, (_m, q1, p2, q3) => `href=${q1}${origin}${p2}${q3}`)
.replace(/src=("|')\s*https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?(\/uploads\/[^"']+)("')/g, (_m, q1, p2, q3) => `src=${q1}${origin}${p2}${q3}`)
.replace(/href=("|')\s*https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?(\/uploads\/[^"']+)("')/g, (_m, q1, p2, q3) => `href=${q1}${origin}${p2}${q3}`);
} catch {
return html || '';
}
}, []);
// Extract YouTube video ID from various URL formats // Extract YouTube video ID from various URL formats
const getYouTubeEmbedUrl = (url: string): string | null => { const getYouTubeEmbedUrl = (url: string): string | null => {
if (!url) return null; if (!url) return null;
@@ -159,7 +175,7 @@ const ActivityDetailPage: React.FC = () => {
' img': { maxWidth: '100%', borderRadius: 'md' }, ' img': { maxWidth: '100%', borderRadius: 'md' },
}} }}
ref={contentRef} ref={contentRef}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(String(data.description)) }} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(toAbsoluteUploads(String(data.description))) }}
/> />
)} )}
+7 -4
View File
@@ -220,8 +220,11 @@ const ArticleDetailPage: React.FC = () => {
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin; const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
// Replace src="/uploads... and href="/uploads... // Replace src="/uploads... and href="/uploads...
return html return html
.replace(/src=("|')\s*(\/uploads\/[^"']+)("|')/g, (_m, q1, p2, q3) => `src=${q1}${origin}${p2}${q3}`) .replace(/src=("|')\s*(\/uploads\/[^"']+)("')/g, (_m, q1, p2, q3) => `src=${q1}${origin}${p2}${q3}`)
.replace(/href=("|')\s*(\/uploads\/[^"']+)("|')/g, (_m, q1, p2, q3) => `href=${q1}${origin}${p2}${q3}`); .replace(/href=("|')\s*(\/uploads\/[^"']+)("')/g, (_m, q1, p2, q3) => `href=${q1}${origin}${p2}${q3}`)
// Also rewrite absolute localhost/127.0.0.1 uploads links to backend origin
.replace(/src=("|')\s*https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?(\/uploads\/[^"']+)("')/g, (_m, q1, p2, q3) => `src=${q1}${origin}${p2}${q3}`)
.replace(/href=("|')\s*https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?(\/uploads\/[^"']+)("')/g, (_m, q1, p2, q3) => `href=${q1}${origin}${p2}${q3}`);
} catch { } catch {
return html; return html;
} }
@@ -463,8 +466,8 @@ const ArticleDetailPage: React.FC = () => {
</Button> </Button>
</HStack> </HStack>
{/* Custom 5-image mosaic */} {/* Custom 5-image mosaic */}
{galleryAlbumQuery.data.photos && galleryAlbumQuery.data.photos.length > 0 && (() => { {Array.isArray(galleryAlbumQuery.data?.photos) && (galleryAlbumQuery.data?.photos?.length || 0) > 0 && (() => {
const photos = galleryAlbumQuery.data.photos.slice(0, 5); const photos = (galleryAlbumQuery.data?.photos ?? []).slice(0, 5);
if (photos.length < 5) { if (photos.length < 5) {
return ( return (
<SimpleGrid columns={{ base: 2, sm: 3 }} spacing={2}> <SimpleGrid columns={{ base: 2, sm: 3 }} spacing={2}>
+145 -31
View File
@@ -1,20 +1,26 @@
import React from 'react'; import React from 'react';
import { Box, Container, Heading, VStack, Image, Text, Skeleton, LinkBox, HStack, Select, Badge, useColorModeValue } from '@chakra-ui/react'; import { Box, Container, Heading, VStack, Image, Text, Skeleton, LinkBox, HStack, Select, Badge, useColorModeValue, Input, InputGroup, InputLeftElement, InputRightElement, IconButton, Grid, GridItem } from '@chakra-ui/react';
import { useInfiniteQuery } from '@tanstack/react-query'; import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { getArticles, Article, Paginated } from '../services/articles'; import { getArticles, Article, Paginated, getFeaturedArticles } from '../services/articles';
import { Link as RouterLink, useSearchParams } from 'react-router-dom'; import { Link as RouterLink, useSearchParams } from 'react-router-dom';
import { assetUrl } from '../utils/url'; import { assetUrl } from '../utils/url';
import MainLayout from '../components/layout/MainLayout'; import MainLayout from '../components/layout/MainLayout';
import { getCategories, CategoryItem } from '../services/categories'; import { getCategories, CategoryItem } from '../services/categories';
import SponsorsSection from '../components/common/SponsorsSection'; import SponsorsSection from '../components/common/SponsorsSection';
import NewsletterCTA from '../components/common/NewsletterCTA'; import NewsletterCTA from '../components/common/NewsletterCTA';
import { Eye, Clock } from 'lucide-react'; import { Eye, Clock, Search, X } from 'lucide-react';
const BlogTile: React.FC<{ article: Article }> = ({ article }) => { const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({ article, variant }) => {
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`; const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
const readTime = article.read_time || article.estimated_read_minutes; const readTime = article.read_time || article.estimated_read_minutes;
const viewCount = article.view_count; const viewCount = article.view_count;
const bgColor = useColorModeValue('white', 'gray.800'); const bgColor = useColorModeValue('white', 'gray.800');
const categoryName = (article as any)?.category?.name || (article as any)?.category_name;
const imageH = variant === 'large'
? ({ base: '280px', md: '360px' } as const)
: variant === 'small'
? ({ base: '160px', md: '180px' } as const)
: ({ base: '200px', md: '220px' } as const);
return ( return (
<LinkBox <LinkBox
@@ -28,8 +34,23 @@ const BlogTile: React.FC<{ article: Article }> = ({ article }) => {
transition="all 0.25s ease" transition="all 0.25s ease"
> >
<Box position="relative"> <Box position="relative">
<Image src={assetUrl(article.image_url) || '/stadium-placeholder.jpg'} alt={article.title} w="100%" h={{ base: '200px', md: '220px' }} objectFit="cover" /> <Image src={assetUrl(article.image_url) || '/stadium-placeholder.jpg'} alt={article.title} w="100%" h={imageH} objectFit="cover" />
<Box position="absolute" inset={0} bgGradient="linear(to-t, rgba(0,0,0,0.55), rgba(0,0,0,0.15))" /> <Box position="absolute" inset={0} bgGradient="linear(to-t, rgba(0,0,0,0.55), rgba(0,0,0,0.15))" />
{categoryName && (
<Badge
position="absolute"
top={2}
left={2}
bg="rgba(0,0,0,0.7)"
color="white"
fontSize="xs"
px={2}
py={1}
borderRadius="md"
>
{categoryName}
</Badge>
)}
{/* Stats badges at top */} {/* Stats badges at top */}
{(readTime || (viewCount && viewCount > 0)) && ( {(readTime || (viewCount && viewCount > 0)) && (
@@ -71,7 +92,7 @@ const BlogTile: React.FC<{ article: Article }> = ({ article }) => {
<Heading <Heading
as="h3" as="h3"
fontSize={{ base: 'lg', md: 'xl' }} fontSize={variant === 'large' ? { base: 'xl', md: '2xl' } : { base: 'lg', md: 'xl' }}
fontWeight="800" fontWeight="800"
letterSpacing="0.3px" letterSpacing="0.3px"
textTransform="uppercase" textTransform="uppercase"
@@ -100,6 +121,8 @@ const BlogPage: React.FC = () => {
const [categoryId, setCategoryId] = React.useState<number | ''>(initialCategory); const [categoryId, setCategoryId] = React.useState<number | ''>(initialCategory);
const month = searchParams.get('month') || ''; const month = searchParams.get('month') || '';
const matchId = searchParams.get('match_id') || ''; const matchId = searchParams.get('match_id') || '';
const qParam = searchParams.get('q') || '';
const [qInput, setQInput] = React.useState<string>(qParam);
const borderColor = useColorModeValue('gray.200', 'gray.700'); const borderColor = useColorModeValue('gray.200', 'gray.700');
const textColor = useColorModeValue('gray.500', 'gray.400'); const textColor = useColorModeValue('gray.500', 'gray.400');
@@ -111,6 +134,33 @@ const BlogPage: React.FC = () => {
} catch {} } catch {}
})(); })();
}, []); }, []);
// Keep categoryId in sync with URL params (category_id preferred; fallback to category slug)
React.useEffect(() => {
const cid = searchParams.get('category_id');
const slug = searchParams.get('category');
if (cid !== null) {
setCategoryId(cid ? Number(cid) : '');
return;
}
if (slug) {
// Try to resolve slug to id once categories are loaded
const norm = decodeURIComponent(slug).toLowerCase();
const found = categories.find((c) => (c as any)?.slug === slug || String(c.name).toLowerCase() === norm);
setCategoryId(found?.id || '');
return;
}
// No category param present → clear filter
setCategoryId('');
}, [searchParams, categories]);
React.useEffect(() => {
setQInput(qParam);
}, [qParam]);
const featuredQ = useQuery<Paginated<Article>>(
['articles-featured', { page_size: 3 }],
() => getFeaturedArticles({ page_size: 3 }),
{ staleTime: 5 * 60 * 1000 }
);
const { const {
data, data,
isLoading, isLoading,
@@ -118,7 +168,7 @@ const BlogPage: React.FC = () => {
hasNextPage, hasNextPage,
fetchNextPage, fetchNextPage,
} = useInfiniteQuery<Paginated<Article>>( } = useInfiniteQuery<Paginated<Article>>(
['articles-public', { page_size: pageSize, published: true, category_id: categoryId || undefined, month: month || undefined, match_id: matchId || undefined }], ['articles-public', { page_size: pageSize, published: true, category_id: categoryId || undefined, month: month || undefined, match_id: matchId || undefined, q: qParam || undefined }],
({ pageParam = 1 }) => ({ pageParam = 1 }) =>
getArticles({ getArticles({
page: pageParam, page: pageParam,
@@ -127,6 +177,7 @@ const BlogPage: React.FC = () => {
...(categoryId ? { category_id: Number(categoryId) } : {}), ...(categoryId ? { category_id: Number(categoryId) } : {}),
...(month ? { month } : {}), ...(month ? { month } : {}),
...(matchId ? { match_id: matchId } : {}), ...(matchId ? { match_id: matchId } : {}),
...(qParam ? { q: qParam } : {}),
}), }),
{ {
getNextPageParam: (lastPage, allPages) => { getNextPageParam: (lastPage, allPages) => {
@@ -139,6 +190,9 @@ const BlogPage: React.FC = () => {
); );
const articles = data?.pages?.flatMap((p) => p?.data || []) || []; const articles = data?.pages?.flatMap((p) => p?.data || []) || [];
const featuredList = featuredQ.data?.data || [];
const featuredIdSet = React.useMemo(() => new Set((featuredList || []).map((a) => a.id)), [featuredList]);
const visibleArticles = featuredList.length ? articles.filter((a) => !featuredIdSet.has(a.id)) : articles;
// Infinite scroll via intersection observer // Infinite scroll via intersection observer
const sentinelRef = React.useRef<HTMLDivElement | null>(null); const sentinelRef = React.useRef<HTMLDivElement | null>(null);
@@ -161,32 +215,92 @@ const BlogPage: React.FC = () => {
{/* Header like blog.html */} {/* Header like blog.html */}
<Box bg="transparent" color="inherit" py={{ base: 8, md: 10 }} mb={4} borderBottom="1px" borderColor={borderColor}> <Box bg="transparent" color="inherit" py={{ base: 8, md: 10 }} mb={4} borderBottom="1px" borderColor={borderColor}>
<Container maxW="7xl"> <Container maxW="7xl">
<HStack justify="space-between" align="center"> <HStack justify="space-between" align="center" spacing={4}>
<Heading as="h1" size={{ base: 'xl', md: '2xl' }}>Blog</Heading> <Heading as="h1" size={{ base: 'xl', md: '2xl' }}>Blog</Heading>
{!!categories.length && ( <HStack spacing={3} w={{ base: '56%', md: '520px' }}>
<Select <Box flex="1">
maxW={{ base: '52%', md: '320px' }} <InputGroup>
placeholder="Všechny kategorie" <InputLeftElement pointerEvents="none">
value={categoryId} <Search size={16} />
onChange={(e) => { </InputLeftElement>
const val = e.target.value ? Number(e.target.value) : ''; <Input
setCategoryId(val); placeholder="Hledat články…"
const next: Record<string, string> = {}; value={qInput}
if (val) next.category_id = String(val); onChange={(e) => setQInput(e.target.value)}
if (month) next.month = month; onKeyDown={(e) => {
if (matchId) next.match_id = matchId; if (e.key === 'Enter') {
setSearchParams(next); const next: Record<string, string> = {};
}} if (categoryId) next.category_id = String(categoryId);
> if (month) next.month = month;
{categories.map((c) => ( if (matchId) next.match_id = matchId;
<option key={c.id} value={c.id}>{c.name}</option> if (qInput) next.q = qInput;
))} setSearchParams(next);
</Select> }
)} }}
/>
{qInput && (
<InputRightElement>
<IconButton
aria-label="Clear search"
size="sm"
variant="ghost"
onClick={() => {
setQInput('');
const next: Record<string, string> = {};
if (categoryId) next.category_id = String(categoryId);
if (month) next.month = month;
if (matchId) next.match_id = matchId;
setSearchParams(next);
}}
icon={<X size={14} />}
/>
</InputRightElement>
)}
</InputGroup>
</Box>
{!!categories.length && (
<Select
maxW={{ base: '44%', md: '240px' }}
placeholder="Všechny kategorie"
value={categoryId}
onChange={(e) => {
const val = e.target.value ? Number(e.target.value) : '';
setCategoryId(val);
const next: Record<string, string> = {};
if (val) next.category_id = String(val);
if (qParam) next.q = qParam;
if (month) next.month = month;
if (matchId) next.match_id = matchId;
setSearchParams(next);
}}
>
{categories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</Select>
)}
</HStack>
</HStack> </HStack>
</Container> </Container>
</Box> </Box>
{featuredList.length > 0 && (
<Container maxW="7xl" mb={6}>
<Grid templateColumns={{ base: '1fr', md: '2fr 1fr' }} gap={6}>
<GridItem>
<BlogTile article={featuredList[0]} variant="large" />
</GridItem>
<GridItem>
<VStack spacing={6} align="stretch">
{featuredList.slice(1, 3).map((a) => (
<BlogTile key={a.id} article={a} variant="small" />
))}
</VStack>
</GridItem>
</Grid>
</Container>
)}
<Container maxW="7xl"> <Container maxW="7xl">
{/* Masonry using CSS columns */} {/* Masonry using CSS columns */}
<Box <Box
@@ -198,7 +312,7 @@ const BlogPage: React.FC = () => {
{isLoading && Array.from({ length: 9 }).map((_, i) => ( {isLoading && Array.from({ length: 9 }).map((_, i) => (
<Skeleton key={i} h={{ base: '220px', md: '260px' }} borderRadius="md" mb={7} /> <Skeleton key={i} h={{ base: '220px', md: '260px' }} borderRadius="md" mb={7} />
))} ))}
{!isLoading && articles.map((a) => ( {!isLoading && visibleArticles.map((a) => (
<Box <Box
key={a.id} key={a.id}
mb={7} mb={7}
@@ -212,7 +326,7 @@ const BlogPage: React.FC = () => {
</Box> </Box>
))} ))}
</Box> </Box>
{!isLoading && !articles.length && ( {!isLoading && !featuredList.length && !visibleArticles.length && (
<VStack py={16}> <VStack py={16}>
<Text color={textColor}>Žádné články k zobrazení.</Text> <Text color={textColor}>Žádné články k zobrazení.</Text>
</VStack> </VStack>
+2 -2
View File
@@ -151,10 +151,10 @@ const ContactPage: React.FC = () => {
if (!hasLocation && !hasContacts && !hasContactInfo) return null; if (!hasLocation && !hasContacts && !hasContactInfo) return null;
return ( return (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={8}> <SimpleGrid columns={{ base: 1, md: 2 }} spacing={8} alignItems="start">
{/* Map on the left */} {/* Map on the left */}
{hasLocation && ( {hasLocation && (
<Box borderRadius="lg" overflow="hidden" boxShadow="md"> <Box borderRadius="lg" overflow="hidden" boxShadow="md" alignSelf="start">
<ContactMap <ContactMap
latitude={lat} latitude={lat}
longitude={lng} longitude={lng}
+49 -46
View File
@@ -1,61 +1,64 @@
import { Box, Button, Heading, Text, VStack, HStack, Icon, Divider } from '@chakra-ui/react'; import { Box, Button, Heading, Text, VStack, HStack, Icon, Divider } from '@chakra-ui/react';
import { Link as RouterLink, useLocation, useNavigate } from 'react-router-dom'; import { Link as RouterLink, useLocation, useNavigate } from 'react-router-dom';
import { GiWhistle, GiSoccerBall } from 'react-icons/gi'; import { GiWhistle, GiSoccerBall } from 'react-icons/gi';
import MainLayout from '../components/layout/MainLayout';
const ForbiddenPage: React.FC = () => { const ForbiddenPage: React.FC = () => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<Box <MainLayout>
minH="70vh"
display="flex"
alignItems="center"
justifyContent="center"
bgGradient="linear(to-b, rgba(0,0,0,0.02), transparent)"
px={6}
>
<Box <Box
w={{ base: '100%', sm: '560px' }} minH="60vh"
textAlign="center" display="flex"
bg="white" alignItems="center"
_dark={{ bg: 'gray.800' }} justifyContent="center"
borderRadius="xl" bgGradient="linear(to-b, rgba(0,0,0,0.02), transparent)"
borderWidth="1px" px={6}
borderColor="border.subtle"
boxShadow="md"
p={{ base: 6, md: 10 }}
> >
<VStack spacing={4}> <Box
<HStack spacing={3} color="brand.primary"> w={{ base: '100%', sm: '560px' }}
<Icon as={GiWhistle} boxSize={8} /> textAlign="center"
<Heading as="h1" size="2xl" letterSpacing="wide"> bg="white"
403 _dark={{ bg: 'gray.800' }}
borderRadius="xl"
borderWidth="1px"
borderColor="border.subtle"
boxShadow="md"
p={{ base: 6, md: 10 }}
>
<VStack spacing={4}>
<HStack spacing={3} color="brand.primary">
<Icon as={GiWhistle} boxSize={8} />
<Heading as="h1" size="2xl" letterSpacing="wide">
403
</Heading>
<Icon as={GiSoccerBall} boxSize={8} />
</HStack>
<Heading
as="h2"
size="lg"
bgGradient="linear(to-r, brand.primary, brand.accent)"
bgClip="text"
>
Přístup odepřen
</Heading> </Heading>
<Icon as={GiSoccerBall} boxSize={8} /> <Text color="gray.600" _dark={{ color: 'gray.300' }}>
</HStack> Rozhodčí píská: tato část hřiště je jen pro kapitány (adminy).
<Heading </Text>
as="h2" <Divider />
size="lg" <HStack spacing={3} pt={2}>
bgGradient="linear(to-r, brand.primary, brand.accent)" <Button onClick={() => navigate(-1)} colorScheme="blue">
bgClip="text" Zpět
> </Button>
Přístup odepřen <Button as={RouterLink} to="/" variant="outline">
</Heading> Zpět na úvod
<Text color="gray.600" _dark={{ color: 'gray.300' }}> </Button>
Rozhodčí píská: tato část hřiště je jen pro kapitány (adminy). </HStack>
</Text> </VStack>
<Divider /> </Box>
<HStack spacing={3} pt={2}>
<Button onClick={() => navigate(-1)} colorScheme="blue">
Zpět
</Button>
<Button as={RouterLink} to="/" variant="outline">
Zpět na úvod
</Button>
</HStack>
</VStack>
</Box> </Box>
</Box> </MainLayout>
); );
}; };
+172 -75
View File
@@ -10,6 +10,8 @@ import { getPublicSettings } from '../services/settings';
import { assetUrl, sanitizeClubName } from '../utils/url'; import { assetUrl, sanitizeClubName } from '../utils/url';
import { getPlayers as apiGetPlayers, Player as ApiPlayer } from '../services/players'; import { getPlayers as apiGetPlayers, Player as ApiPlayer } from '../services/players';
import { getSponsors as apiGetSponsors, Sponsor as ApiSponsor } from '../services/sponsors'; import { getSponsors as apiGetSponsors, Sponsor as ApiSponsor } from '../services/sponsors';
import { getBanners as apiGetBanners, Banner as ApiBanner } from '../services/banners';
import BannerDisplay from '../components/banners/BannerDisplay';
import BlogCardsScroller from '../components/home/BlogCardsScroller'; import BlogCardsScroller from '../components/home/BlogCardsScroller';
import BlogSwiper from '../components/home/BlogSwiper'; import BlogSwiper from '../components/home/BlogSwiper';
import VideosSection from '../components/home/VideosSection'; import VideosSection from '../components/home/VideosSection';
@@ -98,8 +100,8 @@ const HomePage: React.FC = () => {
// Matches slider auto-centering handled internally by MatchesSlider component // Matches slider auto-centering handled internally by MatchesSlider component
// API-driven players and sponsors // API-driven players and sponsors
type UiPlayer = { id:number|string; name:string; number?:number; position?:string; image?:string; slug?:string }; type UiPlayer = { id:number|string; name:string; number?:number; position?:string; image?:string; slug?:string; age?: number };
type UiSponsor = { id:number|string; name:string; logo:string; url?:string }; type UiSponsor = { id:number|string; name:string; logo:string; url?:string; tier?: string };
type UiBanner = { id:number|string; name:string; image:string; url?:string; placement?:string; width?:number; height?:number }; type UiBanner = { id:number|string; name:string; image:string; url?:string; placement?:string; width?:number; height?:number };
type UiMerch = { id?: number|string; title?: string; image_url: string; url?: string }; type UiMerch = { id?: number|string; title?: string; image_url: string; url?: string };
type UiEvent = { id:number|string; title:string; start_time:string; end_time?:string|null; location?:string|null; type?:string; image_url?:string|null }; type UiEvent = { id:number|string; title:string; start_time:string; end_time?:string|null; location?:string|null; type?:string; image_url?:string|null };
@@ -400,11 +402,21 @@ const HomePage: React.FC = () => {
number: p.jersey_number, number: p.jersey_number,
position: p.position, position: p.position,
image: assetUrl(p.image_url) || undefined, image: assetUrl(p.image_url) || undefined,
age: (function(iso?: string){
if (!iso) return undefined;
const d = new Date(iso);
if (isNaN(d.getTime())) return undefined;
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;
})( (p as any).date_of_birth ),
})); }));
setPlayers(mappedPlayers); setPlayers(mappedPlayers);
} catch {} } catch {}
// Load sponsors via API (also used for banners with placement metadata) // Load sponsors via API (sponsors only)
try { try {
const apiSponsors: ApiSponsor[] = await apiGetSponsors(); const apiSponsors: ApiSponsor[] = await apiGetSponsors();
const mapped: UiSponsor[] = (apiSponsors || []).map((s: ApiSponsor) => ({ const mapped: UiSponsor[] = (apiSponsors || []).map((s: ApiSponsor) => ({
@@ -412,21 +424,24 @@ const HomePage: React.FC = () => {
name: s.name, name: s.name,
logo: assetUrl(s.logo_url) || '/images/sponsors/placeholder.png', logo: assetUrl(s.logo_url) || '/images/sponsors/placeholder.png',
url: s.website_url || undefined, url: s.website_url || undefined,
tier: (s as any).tier,
})); }));
setSponsors(mapped); setSponsors(mapped);
// Extract banners by placement metadata if provided } catch {}
const mappedBanners: UiBanner[] = (apiSponsors || [])
.filter((s: any) => s && (s as any).placement) // Load banners via dedicated API (separate from sponsors)
.map((s: any) => ({ try {
id: s.id, const apiBanners: ApiBanner[] = await apiGetBanners({ active: true });
name: s.name, const mappedBanners: UiBanner[] = (apiBanners || []).map((b: any) => ({
image: assetUrl(s.logo_url) || '/images/sponsors/placeholder.png', id: b.id,
url: s.website_url || undefined, name: b.name,
placement: s.placement, image: assetUrl(b.image_url) || '/images/sponsors/placeholder.png',
width: typeof s.width === 'number' ? s.width : undefined, url: b.click_url || undefined,
height: typeof s.height === 'number' ? s.height : undefined, placement: b.placement,
})); width: typeof b.width === 'number' ? b.width : undefined,
if (mappedBanners.length) setBanners(mappedBanners); height: typeof b.height === 'number' ? b.height : undefined,
}));
setBanners(mappedBanners);
} catch {} } catch {}
// Load featured articles (homepage primary) via API // Load featured articles (homepage primary) via API
@@ -472,6 +487,7 @@ const HomePage: React.FC = () => {
name: s.name || 'Sponsor', name: s.name || 'Sponsor',
logo: s.logo_url || s.logoUrl || s.logo || '/images/sponsors/placeholder.png', logo: s.logo_url || s.logoUrl || s.logo || '/images/sponsors/placeholder.png',
url: s.url || s.website || s.link || '#', url: s.url || s.website || s.link || '#',
tier: s.tier,
})) }))
); );
} }
@@ -1032,7 +1048,8 @@ const HomePage: React.FC = () => {
<div className="photo" style={{ backgroundImage: `url(${assetUrl((p as any).image) || '/images/player-placeholder.jpg'})` }} /> <div className="photo" style={{ backgroundImage: `url(${assetUrl((p as any).image) || '/images/player-placeholder.jpg'})` }} />
<div className="name">{p.name}</div> <div className="name">{p.name}</div>
<div className="role">{p.position || 'Hráč'}</div> <div className="role">{p.position || 'Hráč'}</div>
<div className="number">#{p.number || '—'}</div> {typeof p.number !== 'undefined' && <div className="number">#{p.number}</div>}
{typeof p.age === 'number' && <div className="age">{p.age} let</div>}
</div> </div>
))} ))}
</div> </div>
@@ -1321,14 +1338,14 @@ const HomePage: React.FC = () => {
// } // }
return ( return (
<MainLayout headerInsideContainer> <MainLayout headerInsideContainer showSponsorsSection={false}>
<div className="container" data-element="container" style={{ ...getStyles('container') }}> <div className="container" data-element="container" style={{ ...getStyles('container') }}>
<div data-element="style-pack" data-variant={stylePack} style={{ display: 'none' }} /> <div data-element="style-pack" data-variant={stylePack} style={{ display: 'none' }} />
{/* Above-hero club bar (MyUIbrix managed) */} {/* Above-hero club bar (MyUIbrix managed) */}
{isVisible('hero-topbar', true) && ( {isVisible('hero-topbar', true) && (
<section data-element="hero-topbar" data-variant={getVariant('hero-topbar', 'brand')} style={{ ...getStyles('hero-topbar') }}> <section data-element="hero-topbar" data-variant={getVariant('hero-topbar', 'minimal')} style={{ ...getStyles('hero-topbar') }}>
<ClubHeroTopbar <ClubHeroTopbar
variant={(getVariant('hero-topbar', 'brand') as any) as 'brand' | 'minimal' | 'badge'} variant={(getVariant('hero-topbar', 'minimal') as any) as 'brand' | 'minimal' | 'badge'}
fullBleed={getVariant('header', 'unified') === 'fullwidth'} fullBleed={getVariant('header', 'unified') === 'fullwidth'}
/> />
</section> </section>
@@ -1488,6 +1505,11 @@ const HomePage: React.FC = () => {
/> />
) : null} ) : null}
{/* Full-bleed top banner (homepage_top) */}
{(banners || []).some(b => b.placement === 'homepage_top') && (
<BannerDisplay banners={banners as any} placement="homepage_top" />
)}
{/* Matches slider with scores by competition (moved after news+tables) */} {/* Matches slider with scores by competition (moved after news+tables) */}
{facrCompetitions.length > 0 && ( {facrCompetitions.length > 0 && (
<MatchesSlider <MatchesSlider
@@ -1513,8 +1535,10 @@ const HomePage: React.FC = () => {
(matchingStanding.rows && matchingStanding.rows.length > 0) (matchingStanding.rows && matchingStanding.rows.length > 0)
); );
const newsVariant = getVariant('news', 'grid_one');
const showNews = isVisible('news', true); const showNews = isVisible('news', true);
const showTable = isVisible('table', true) && hasStandingsForCurrentTab; let showTable = isVisible('table', true) && hasStandingsForCurrentTab;
if (newsVariant === 'grid_one') { showTable = false; }
const variant = showNews && showTable ? undefined : 'standard'; const variant = showNews && showTable ? undefined : 'standard';
if (!showNews && !showTable) return null; if (!showNews && !showTable) return null;
@@ -1525,7 +1549,7 @@ const HomePage: React.FC = () => {
style={{ marginTop: 32 }} style={{ marginTop: 32 }}
> >
{showNews && ( {showNews && (
<section data-element="news" data-variant={getVariant('news', 'grid')} className="news-list" style={{ ...getStyles('news') }}> <section data-element="news" data-variant={newsVariant} className="news-list" style={{ ...getStyles('news') }}>
<div className="section-head" style={{ marginTop: 0 }}> <div className="section-head" style={{ marginTop: 0 }}>
<h3>Další aktuality</h3> <h3>Další aktuality</h3>
<a href="/news" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a> <a href="/news" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
@@ -1541,6 +1565,7 @@ const HomePage: React.FC = () => {
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a> <a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
</div> </div>
<StandingsCard <StandingsCard
variant={((): 'logos'|'plain' => { const v = getVariant('table_rows', 'logos'); return v === 'plain' ? 'plain' : 'logos'; })()}
rows={(matchingStanding?.table || matchingStanding?.rows || []) as any} rows={(matchingStanding?.table || matchingStanding?.rows || []) as any}
onRowClick={(row) => { onRowClick={(row) => {
const clubData = { const clubData = {
@@ -1565,6 +1590,11 @@ const HomePage: React.FC = () => {
); );
})()} })()}
{/* Banner under tables (homepage_under_table) */}
{(banners || []).some(b => b.placement === 'homepage_under_table') && (
<BannerDisplay banners={banners as any} placement="homepage_under_table" />
)}
{/* Competition tables moved into right column below */} {/* Competition tables moved into right column below */}
{upcomingEvents.length > 0 && isVisible('activities', true) && ( {upcomingEvents.length > 0 && isVisible('activities', true) && (
@@ -1590,8 +1620,9 @@ const HomePage: React.FC = () => {
{players.map((p) => ( {players.map((p) => (
<a key={p.id} href={p.slug ? `/players/${p.slug}` : `/players/${p.id}`} className="player-card"> <a key={p.id} href={p.slug ? `/players/${p.slug}` : `/players/${p.id}`} className="player-card">
<div className="photo" style={{ backgroundImage: `url(${assetUrl(p.image) || p.image})` }} /> <div className="photo" style={{ backgroundImage: `url(${assetUrl(p.image) || p.image})` }} />
<div className="meta"><span className="nr">#{p.number}</span> {p.name}</div> <div className="meta">{typeof p.number !== 'undefined' ? (<><span className="nr">#{p.number}</span> {p.name}</>) : p.name}</div>
<div className="pos">{p.position}</div> <div className="pos">{p.position}</div>
{typeof p.age === 'number' && <div className="age">{p.age} let</div>}
</a> </a>
))} ))}
</div> </div>
@@ -1654,62 +1685,128 @@ const HomePage: React.FC = () => {
</section> </section>
)} )}
{/* Sponsors: grid or slider (controlled by settings); dark theme supported; full-bleed */} {/* Sponsors: MyUIbrix-controlled variant (grid | slider | scroller | pyramid); dark theme supported; full-bleed */}
{isVisible('sponsors', true) && ( {isVisible('sponsors', true) && (
<section (() => {
data-element="sponsors" const variant = (getVariant('sponsors', sponsorLayout) as any) as 'grid' | 'slider' | 'scroller' | 'pyramid';
className={`sponsors ${sponsorsTheme === 'dark' ? 'dark' : ''}`} const all = sponsors || [];
style={{ const general = all.filter((s: any) => String(s.tier || '').toLowerCase() === 'general' || String(s.tier || '').toLowerCase() === 'title' || String(s.tier || '').toLowerCase() === 'main');
width: '100vw', const standard = all.filter((s: any) => !(String(s.tier || '').toLowerCase() === 'general' || String(s.tier || '').toLowerCase() === 'title' || String(s.tier || '').toLowerCase() === 'main'));
position: 'relative', const ordered = [...general, ...standard];
left: '50%',
right: '50%', const renderPyramid = () => {
transform: 'translateX(-50%)', const capacities = [1, 4, 8, 12, 16];
paddingLeft: 'max(16px, calc((100vw - 1200px) / 2))', const takeRows = (items: typeof ordered) => {
paddingRight: 'max(16px, calc((100vw - 1200px) / 2))', const rows: Array<typeof ordered> = [];
boxSizing: 'border-box', let idx = 0;
...getStyles('sponsors') for (let capIndex = 0; idx < items.length && capIndex < capacities.length; capIndex++) {
}} const cap = capacities[capIndex];
> rows.push(items.slice(idx, idx + cap));
<div className="section-head"> idx += cap;
<h3>Sponzoři</h3> }
</div> // If still remaining, continue with last capacity repeated
{sponsorLayout==='grid' ? ( const lastCap = capacities[capacities.length - 1];
(()=>{ while (idx < items.length) {
const title = sponsors.find((s:any)=>s.tier==='title') || sponsors[0]; rows.push(items.slice(idx, idx + lastCap));
const others = sponsors.filter((s)=>s !== title); idx += lastCap;
}
return rows;
};
const generalRows = takeRows(general);
const standardRows = takeRows(standard);
return ( return (
<> <div className="pyramid">
{title && ( {generalRows.map((row, i) => (
<div className="title-sponsor"> <div key={`gen-${i}`} className="pyramid-row" style={{ display: 'grid', gridTemplateColumns: `repeat(${Math.max(1, row.length)}, 1fr)`, gap: 16, marginBottom: 12 }}>
<a className="sponsor-tile" href={title.url || '#'} target="_blank" rel="noreferrer noopener"> {row.map((s) => (
<img src={assetUrl(title.logo) || '/images/sponsors/placeholder.png'} alt={title.name} /> <a key={`g-${s.id}`} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
</a> <img src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
</a>
))}
</div> </div>
)} ))}
<div className="divider" aria-hidden /> {generalRows.length > 0 && standardRows.length > 0 && <div className="divider" aria-hidden />}
<div className="sponsors-grid"> {standardRows.map((row, i) => (
{others.map((s) => ( <div key={`std-${i}`} className="pyramid-row" style={{ display: 'grid', gridTemplateColumns: `repeat(${Math.max(1, row.length)}, 1fr)`, gap: 16, marginBottom: 12 }}>
<a key={s.id} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener"> {row.map((s) => (
<img src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} /> <a key={`s-${s.id}`} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
</a> <img src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
))} </a>
</div> ))}
</> </div>
))}
</div>
); );
})() };
) : (
<div className="sponsors-slider"> return (
<div className="track"> <section
{[...sponsors, ...sponsors].map((s, idx) => ( data-element="sponsors"
<a key={`${s.id}-${idx}`} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener"> data-variant={variant}
<img src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} /> className={`sponsors ${sponsorsTheme === 'dark' ? 'dark' : ''}`}
</a> style={{
))} width: '100vw',
</div> position: 'relative',
</div> left: '50%',
)} right: '50%',
</section> transform: 'translateX(-50%)',
paddingLeft: 'max(16px, calc((100vw - 1200px) / 2))',
paddingRight: 'max(16px, calc((100vw - 1200px) / 2))',
boxSizing: 'border-box',
...getStyles('sponsors')
}}
>
<div className="section-head">
<h3>Sponzoři</h3>
</div>
{variant === 'grid' && (
<>
{general.length > 0 && (
<div className="title-sponsor">
{general.map((g) => (
<a key={`g-${g.id}`} className="sponsor-tile" href={g.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(g.logo) || '/images/sponsors/placeholder.png'} alt={g.name} />
</a>
))}
</div>
)}
{(general.length > 0 && standard.length > 0) && <div className="divider" aria-hidden />}
<div className="sponsors-grid">
{(standard.length > 0 ? standard : (general.length === 0 ? ordered : [])).map((s) => (
<a key={s.id} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
</a>
))}
</div>
</>
)}
{variant === 'pyramid' && renderPyramid()}
{variant === 'slider' && (
<div className="sponsors-slider">
<div className="track">
{[...ordered, ...ordered].map((s, idx) => (
<a key={`${s.id}-${idx}`} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
</a>
))}
</div>
</div>
)}
{variant === 'scroller' && (
<div className="sponsors-scroller">
<div className="belt">
{[...ordered, ...ordered, ...ordered].map((s, idx) => (
<a key={`${s.id}-${idx}`} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
</a>
))}
</div>
</div>
)}
</section>
);
})()
)} )}
</div> </div>
<ClubModal <ClubModal
@@ -68,6 +68,19 @@ const NewsletterPreferencesPage: React.FC = () => {
const [prefs, setPrefs] = useState<SubscriberPreferences>(initialPrefs); const [prefs, setPrefs] = useState<SubscriberPreferences>(initialPrefs);
React.useEffect(() => { setPrefs(initialPrefs); }, [initialPrefs]); React.useEffect(() => { setPrefs(initialPrefs); }, [initialPrefs]);
// If competitions list is available and user has no selection, default to ALL selected
React.useEffect(() => {
if (Array.isArray(competitions) && competitions.length > 0) {
const raw = String(prefs.competitions || '').trim();
if (!raw) {
const codes = competitions.map((c: any, idx: number) => String(c?.code || c?.id || c?.name || `comp-${idx}`)).filter(Boolean);
if (codes.length > 0) {
setPrefs((prev) => ({ ...prev, competitions: codes.join(', ') }));
}
}
}
}, [competitions]);
const saveMut = useMutation({ const saveMut = useMutation({
mutationFn: () => savePreferences(token, prefs), mutationFn: () => savePreferences(token, prefs),
onSuccess: () => { onSuccess: () => {
@@ -198,6 +211,13 @@ const NewsletterPreferencesPage: React.FC = () => {
<FormLabel>Preferované soutěže</FormLabel> <FormLabel>Preferované soutěže</FormLabel>
{Array.isArray(competitions) && competitions.length > 0 ? ( {Array.isArray(competitions) && competitions.length > 0 ? (
<VStack align="stretch" spacing={1} maxH="220px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}> <VStack align="stretch" spacing={1} maxH="220px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
<HStack mb={1}>
<Button size="xs" variant="outline" onClick={()=>{
const all = competitions.map((c: any, idx: number) => String(c?.code || c?.id || c?.name || `comp-${idx}`)).filter(Boolean);
setPrefs({ ...prefs, competitions: all.join(', ') });
}}>Zapnout vše</Button>
<Button size="xs" variant="ghost" onClick={()=> setPrefs({ ...prefs, competitions: '' })}>Vypnout vše</Button>
</HStack>
{competitions.map((c: any, idx: number) => { {competitions.map((c: any, idx: number) => {
const code = (c?.code || c?.id || c?.name || `comp-${idx}`) as string; const code = (c?.code || c?.id || c?.name || `comp-${idx}`) as string;
const name = (c?.name || c?.code || code) as string; const name = (c?.name || c?.code || code) as string;
+49 -46
View File
@@ -2,60 +2,63 @@ import { Box, Button, Heading, Text, VStack, HStack, Icon, Divider } from '@chak
import { Link as RouterLink, useNavigate } from 'react-router-dom'; import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { GiSoccerBall } from 'react-icons/gi'; import { GiSoccerBall } from 'react-icons/gi';
import { MdSportsSoccer } from 'react-icons/md'; import { MdSportsSoccer } from 'react-icons/md';
import MainLayout from '../components/layout/MainLayout';
const NotFoundPage: React.FC = () => { const NotFoundPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<Box <MainLayout>
minH="70vh"
display="flex"
alignItems="center"
justifyContent="center"
bgGradient="linear(to-b, rgba(0,0,0,0.02), transparent)"
px={6}
>
<Box <Box
w={{ base: '100%', sm: '560px' }} minH="60vh"
textAlign="center" display="flex"
bg="white" alignItems="center"
_dark={{ bg: 'gray.800' }} justifyContent="center"
borderRadius="xl" bgGradient="linear(to-b, rgba(0,0,0,0.02), transparent)"
borderWidth="1px" px={6}
borderColor="border.subtle"
boxShadow="md"
p={{ base: 6, md: 10 }}
> >
<VStack spacing={4}> <Box
<HStack spacing={3} color="brand.primary"> w={{ base: '100%', sm: '560px' }}
<Icon as={GiSoccerBall} boxSize={8} /> textAlign="center"
<Heading as="h1" size="2xl" letterSpacing="wide"> bg="white"
404 _dark={{ bg: 'gray.800' }}
borderRadius="xl"
borderWidth="1px"
borderColor="border.subtle"
boxShadow="md"
p={{ base: 6, md: 10 }}
>
<VStack spacing={4}>
<HStack spacing={3} color="brand.primary">
<Icon as={GiSoccerBall} boxSize={8} />
<Heading as="h1" size="2xl" letterSpacing="wide">
404
</Heading>
<Icon as={MdSportsSoccer} boxSize={8} />
</HStack>
<Heading
as="h2"
size="lg"
bgGradient="linear(to-r, brand.primary, brand.accent)"
bgClip="text"
>
Stránka nenalezena
</Heading> </Heading>
<Icon as={MdSportsSoccer} boxSize={8} /> <Text color="gray.600" _dark={{ color: 'gray.300' }}>
</HStack> Míč je mimo hřiště zkuste to znovu nebo vraťte se.
<Heading </Text>
as="h2" <Divider />
size="lg" <HStack spacing={3} pt={2}>
bgGradient="linear(to-r, brand.primary, brand.accent)" <Button onClick={() => navigate(-1)} colorScheme="blue">
bgClip="text" Zpět
> </Button>
Stránka nenalezena <Button as={RouterLink} to="/" variant="outline">
</Heading> Zpět na úvod
<Text color="gray.600" _dark={{ color: 'gray.300' }}> </Button>
Míč je mimo hřiště zkuste to znovu nebo vraťte se. </HStack>
</Text> </VStack>
<Divider /> </Box>
<HStack spacing={3} pt={2}>
<Button onClick={() => navigate(-1)} colorScheme="blue">
Zpět
</Button>
<Button as={RouterLink} to="/" variant="outline">
Zpět na úvod
</Button>
</HStack>
</VStack>
</Box> </Box>
</Box> </MainLayout>
); );
}; };
+15 -1
View File
@@ -73,7 +73,7 @@ const PlayerDetailPage: React.FC = () => {
<Text><b>Národnost:</b> {translateNationality(data.nationality)}</Text> <Text><b>Národnost:</b> {translateNationality(data.nationality)}</Text>
)} )}
{data.date_of_birth && ( {data.date_of_birth && (
<Text><b>Datum narození:</b> {new Date(data.date_of_birth).toLocaleDateString('cs-CZ')}</Text> <Text><b>Datum narození:</b> {new Date(data.date_of_birth).toLocaleDateString('cs-CZ')} {calculateAge(data.date_of_birth)} let</Text>
)} )}
{(data.height || data.weight) && ( {(data.height || data.weight) && (
<Text> <Text>
@@ -105,4 +105,18 @@ const PlayerDetailPage: React.FC = () => {
); );
}; };
function calculateAge(iso: string): number | null {
try {
const d = new Date(iso);
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 PlayerDetailPage; export default PlayerDetailPage;
+98 -2
View File
@@ -21,11 +21,13 @@ import {
Icon, Icon,
Badge, Badge,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { FiSave, FiEye, FiCode, FiLayout, FiZap } from 'react-icons/fi'; import { FiSave, FiEye, FiCode, FiLayout, FiZap, FiPlus, FiTrash } from 'react-icons/fi';
import AdminLayout from '../../layouts/AdminLayout'; import AdminLayout from '../../layouts/AdminLayout';
import RichTextEditor from '../../components/common/RichTextEditor'; import RichTextEditor from '../../components/common/RichTextEditor';
import api from '../../services/api'; import api from '../../services/api';
import { generateAboutAI } from '../../services/ai'; import { generateAboutAI } from '../../services/ai';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { getCategories, CategoryItem } from '../../services/categories';
type AboutPageData = { type AboutPageData = {
id?: number; id?: number;
@@ -40,11 +42,17 @@ type AboutPageData = {
const AboutAdminPage: React.FC = () => { const AboutAdminPage: React.FC = () => {
const toast = useToast(); const toast = useToast();
const { data: settings } = usePublicSettings();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [aiGenerating, setAiGenerating] = useState(false); const [aiGenerating, setAiGenerating] = useState(false);
const [aiPrompt, setAiPrompt] = useState(''); const [aiPrompt, setAiPrompt] = useState('');
const [aiAudience, setAiAudience] = useState('Fanoušci klubu'); const [aiAudience, setAiAudience] = useState('Fanoušci klubu');
const [categories, setCategories] = useState<CategoryItem[]>([]);
const [foundationYear, setFoundationYear] = useState<string>('');
const [timelineEvents, setTimelineEvents] = useState<Array<{ year: string; title: string }>>([
{ year: '', title: '' },
]);
const [data, setData] = useState<AboutPageData>({ const [data, setData] = useState<AboutPageData>({
title: '', title: '',
subtitle: '', subtitle: '',
@@ -57,6 +65,7 @@ const AboutAdminPage: React.FC = () => {
useEffect(() => { useEffect(() => {
loadData(); loadData();
loadCategories();
}, []); }, []);
const loadData = async () => { const loadData = async () => {
@@ -73,6 +82,13 @@ const AboutAdminPage: React.FC = () => {
} }
}; };
const loadCategories = async () => {
try {
const cats = await getCategories();
setCategories(cats || []);
} catch {}
};
const handleSave = async () => { const handleSave = async () => {
if (!data.title.trim()) { if (!data.title.trim()) {
toast({ title: 'Chyba', description: 'Vyplňte název stránky', status: 'warning' }); toast({ title: 'Chyba', description: 'Vyplňte název stránky', status: 'warning' });
@@ -122,10 +138,28 @@ const AboutAdminPage: React.FC = () => {
setAiGenerating(true); setAiGenerating(true);
try { try {
const clubName = settings?.club_name || '';
const catsText = categories && categories.length
? `Rubriky (kategorie): ${categories.map((c) => `${c.name}${c.description ? ` ${c.description}` : ''}`).join('; ')}`
: '';
const styleIntro = `Zvolený styl: ${data.style}.`;
const timelineDetails = data.style === 'timeline'
? `\nČasová osa dodatečné informace:\nRok založení: ${foundationYear || 'neuvedeno'}.\nKlíčové milníky (rok: událost):\n${timelineEvents
.filter((e) => e.year.trim() || e.title.trim())
.map((e) => `- ${e.year.trim() || '????'}: ${e.title.trim() || ''}`)
.join('\n')}`
: '';
const extraGuidelines = data.style === 'timeline'
? 'Piš chronologicky, používej podnadpisy (h3) s rokem, pod nimi krátký odstavec. Kde se hodí, vlož seznamy (ul/li).'
: 'Rozděl text do sekcí s h2/h3 a odstavci. Kde se hodí, vlož seznamy (ul/li).';
const fullPrompt = `${aiPrompt.trim()}\n\nInformace o klubu:\nNázev klubu: ${clubName || 'Fotbalový klub'}.\n${catsText}\n${styleIntro}${timelineDetails}\n\nPokyny pro výstup: ${extraGuidelines}`;
const result = await generateAboutAI({ const result = await generateAboutAI({
prompt: aiPrompt, prompt: fullPrompt,
audience: aiAudience, audience: aiAudience,
style: data.style, style: data.style,
club_name: clubName,
}); });
setData((prev) => ({ setData((prev) => ({
...prev, ...prev,
@@ -208,6 +242,68 @@ const AboutAdminPage: React.FC = () => {
/> />
</FormControl> </FormControl>
<FormControl>
<FormLabel>Styl stránky</FormLabel>
<Select
value={data.style}
onChange={(e) =>
setData((prev) => ({ ...prev, style: e.target.value as any }))
}
>
{Object.entries(styleDescriptions).map(([key, { name }]) => (
<option key={key} value={key}>
{name}
</option>
))}
</Select>
<Text fontSize="sm" color="gray.600" mt={2}>
{styleDescriptions[data.style]?.desc}
</Text>
</FormControl>
{data.style === 'timeline' && (
<Box borderWidth="1px" borderRadius="md" p={4} bg="gray.50">
<Heading size="sm" mb={3}>Časová osa podklady</Heading>
<VStack align="stretch" spacing={3}>
<FormControl>
<FormLabel>Rok založení</FormLabel>
<Input
type="text"
value={foundationYear}
onChange={(e) => setFoundationYear(e.target.value)}
placeholder="např. 1932"
/>
</FormControl>
<Box>
<HStack justify="space-between" mb={2}>
<Text fontWeight="semibold">Klíčové milníky</Text>
<Button size="sm" leftIcon={<FiPlus />} onClick={() => setTimelineEvents((prev) => [...prev, { year: '', title: '' }])}>Přidat milník</Button>
</HStack>
<VStack align="stretch" spacing={2}>
{timelineEvents.map((ev, idx) => (
<HStack key={idx} spacing={2} align="stretch">
<Input
placeholder="Rok"
value={ev.year}
onChange={(e) => setTimelineEvents((prev) => prev.map((it, i) => i === idx ? { ...it, year: e.target.value } : it))}
maxW="120px"
/>
<Input
placeholder="Událost / popis"
value={ev.title}
onChange={(e) => setTimelineEvents((prev) => prev.map((it, i) => i === idx ? { ...it, title: e.target.value } : it))}
/>
<Button aria-label="Odebrat" size="sm" colorScheme="red" variant="outline" onClick={() => setTimelineEvents((prev) => prev.filter((_, i) => i !== idx))}>
<Icon as={FiTrash} />
</Button>
</HStack>
))}
</VStack>
</Box>
</VStack>
</Box>
)}
{/* Hero image removed: About page uses club logo and name from settings */} {/* Hero image removed: About page uses club logo and name from settings */}
<Box <Box
@@ -480,7 +480,7 @@ const AdminActivitiesPage: React.FC = () => {
/> />
) : ( ) : (
<ChakraImage <ChakraImage
src={settingsQ.data?.club_logo_url || '/dist/img/logo-club-empty.svg'} src={assetUrl(settingsQ.data?.club_logo_url) || assetUrl('/dist/img/logo-club-empty.svg') || '/dist/img/logo-club-empty.svg'}
alt="No image" alt="No image"
boxSize="48px" boxSize="48px"
objectFit="contain" objectFit="contain"
+80 -2
View File
@@ -1,8 +1,8 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import AdminLayout from '../../layouts/AdminLayout'; import AdminLayout from '../../layouts/AdminLayout';
import { Box, Heading, Button, SimpleGrid, FormControl, FormLabel, Input, HStack, VStack, Text, IconButton, useToast, Divider, Textarea, Switch, NumberInput, NumberInputField } from '@chakra-ui/react'; import { Box, Heading, Button, SimpleGrid, FormControl, FormLabel, Input, HStack, VStack, Text, IconButton, useToast, Divider, Textarea, Switch, NumberInput, NumberInputField, Image, Badge, useColorModeValue } from '@chakra-ui/react';
import { getClothingAdmin, createClothing, updateClothing, deleteClothing, ClothingItem } from '../../services/clothing'; import { getClothingAdmin, createClothing, updateClothing, deleteClothing, ClothingItem } from '../../services/clothing';
import { FiPlus, FiTrash2, FiSave } from 'react-icons/fi'; import { FiPlus, FiTrash2, FiSave, FiExternalLink } from 'react-icons/fi';
const emptyItem: Partial<ClothingItem> = { const emptyItem: Partial<ClothingItem> = {
title: '', title: '',
@@ -15,6 +15,77 @@ const emptyItem: Partial<ClothingItem> = {
display_order: 0 display_order: 0
}; };
const PreviewCard: React.FC<{ item: Partial<ClothingItem> }> = ({ item }) => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
return (
<Box
role="group"
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="xl"
overflow="hidden"
transition="all 0.3s"
_hover={{ transform: 'translateY(-4px)', boxShadow: 'lg' }}
>
<Box position="relative" paddingTop="100%" overflow="hidden">
<Image
src={item.image_url}
alt={item.title || 'Náhled produktu'}
position="absolute"
top={0}
left={0}
width="100%"
height="100%"
objectFit="cover"
fallbackSrc="/images/placeholder-clothing.jpg"
/>
{item.url && (
<Box
position="absolute"
top={2}
right={2}
bg="white"
borderRadius="full"
p={2}
opacity={0}
_groupHover={{ opacity: 1 }}
transition="opacity 0.2s"
>
<FiExternalLink size={16} />
</Box>
)}
</Box>
<VStack align="stretch" p={4} spacing={2}>
<Heading as="h3" size="sm" noOfLines={2}>
{item.title || 'Název produktu'}
</Heading>
{item.description && (
<Text fontSize="sm" color="gray.600" noOfLines={2}>
{item.description}
</Text>
)}
<HStack justify="space-between" mt={2}>
{item.price && item.price > 0 ? (
<Badge colorScheme="blue" fontSize="md" px={2} py={1}>
{item.price} {item.currency || 'Kč'}
</Badge>
) : (
<Box />
)}
{item.url && (
<Text fontSize="xs" color="blue.500">
Zobrazit
</Text>
)}
</HStack>
</VStack>
</Box>
);
};
const AdminMerchPage: React.FC = () => { const AdminMerchPage: React.FC = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -213,6 +284,13 @@ const AdminMerchPage: React.FC = () => {
rows={2} rows={2}
/> />
</FormControl> </FormControl>
<Box mt={4}>
<Heading size="xs" mb={2}>Náhled veřejné karty</Heading>
<Box maxW="360px">
<PreviewCard item={it} />
</Box>
</Box>
</Box> </Box>
))} ))}
{items.length === 0 && ( {items.length === 0 && (
+28 -8
View File
@@ -178,7 +178,7 @@ const AdminVideosPage: React.FC = () => {
setSelectedIds((prev) => ({ ...prev, [id]: !prev[id] })); setSelectedIds((prev) => ({ ...prev, [id]: !prev[id] }));
}; };
const importSelected = () => { const importSelected = async () => {
const selected = ytVideos.filter((v) => selectedIds[v.video_id]); const selected = ytVideos.filter((v) => selectedIds[v.video_id]);
if (selected.length === 0) { if (selected.length === 0) {
toast({ status: 'info', title: 'Nic k importu', description: 'Vyberte alespoň jedno video.' }); toast({ status: 'info', title: 'Nic k importu', description: 'Vyberte alespoň jedno video.' });
@@ -203,10 +203,30 @@ const AdminVideosPage: React.FC = () => {
} }
return merged; return merged;
}); });
// If currently in auto mode, switch to manual so the preview reflects newly added items
if (videosSource !== 'manual') {
setVideosSource('manual');
try {
await updateAdminSettings({ videos_source: 'manual' });
toast({ status: 'info', title: 'Přepnuto na ruční správu', description: 'Nově přidaná videa se budou používat. Nezapomeňte uložit seznam.', duration: 3500 });
} catch {
// ignore
}
}
toast({ status: 'success', title: 'Videa přidána', description: `${selected.length} videí bylo přidáno do seznamu.` }); toast({ status: 'success', title: 'Videa přidána', description: `${selected.length} videí bylo přidáno do seznamu.` });
}; };
const addItem = () => setItems((prev) => [...prev, { ...emptyItem }]); const addItem = async () => {
setItems((prev) => [...prev, { ...emptyItem }]);
if (videosSource !== 'manual') {
setVideosSource('manual');
try {
await updateAdminSettings({ videos_source: 'manual' });
} catch {
// ignore
}
}
};
const removeItem = (idx: number) => setItems((prev) => prev.filter((_, i) => i !== idx)); const removeItem = (idx: number) => setItems((prev) => prev.filter((_, i) => i !== idx));
const updateField = (idx: number, key: keyof AdminVideoItem, val: string) => { const updateField = (idx: number, key: keyof AdminVideoItem, val: string) => {
setItems((prev) => prev.map((it, i) => i === idx ? { ...it, [key]: val } : it)); setItems((prev) => prev.map((it, i) => i === idx ? { ...it, [key]: val } : it));
@@ -341,13 +361,13 @@ const AdminVideosPage: React.FC = () => {
Použijte Scraper API. Zadejte handle (např. <code>@FotbalKunovice</code>) nebo URL kanálu a načtěte videa z karty Videa. Použijte Scraper API. Zadejte handle (např. <code>@FotbalKunovice</code>) nebo URL kanálu a načtěte videa z karty Videa.
Služba: <Link href="https://youtube.tdvorak.dev/" isExternal color="blue.500">https://youtube.tdvorak.dev/</Link> Služba: <Link href="https://youtube.tdvorak.dev/" isExternal color="blue.500">https://youtube.tdvorak.dev/</Link>
</Text> </Text>
<HStack align="start" spacing={3}> <HStack align="start" spacing={3} flexWrap="wrap">
<FormControl maxW={{ base: '100%', md: '400px' }}> <FormControl maxW={{ base: '100%', md: '400px' }}>
<FormLabel>Kanál (handle nebo URL)</FormLabel> <FormLabel>Kanál (handle nebo URL)</FormLabel>
<Input id="admin-videos-channel-input" placeholder="@FCBizoniUH nebo https://www.youtube.com/@FCBizoniUH/videos" value={channelInput} onChange={(e) => setChannelInput(e.target.value)} /> <Input id="admin-videos-channel-input" placeholder="@FCBizoniUH nebo https://www.youtube.com/@FCBizoniUH/videos" value={channelInput} onChange={(e) => setChannelInput(e.target.value)} />
</FormControl> </FormControl>
<Button onClick={fetchChannelVideos} isLoading={ytLoading} variant="outline">Načíst videa</Button> <Button onClick={fetchChannelVideos} isLoading={ytLoading} variant="outline" flexShrink={0} minW="max-content">Načíst videa</Button>
<Button colorScheme="green" onClick={importSelected} isDisabled={ytVideos.length === 0}>Přidat vybraná</Button> <Button colorScheme="green" onClick={importSelected} isDisabled={ytVideos.length === 0} flexShrink={0} minW="max-content">Přidat vybraná</Button>
</HStack> </HStack>
{ytError && ( {ytError && (
<Alert status="error" mt={3} borderRadius="md"> <Alert status="error" mt={3} borderRadius="md">
@@ -387,9 +407,9 @@ const AdminVideosPage: React.FC = () => {
<HStack justify="space-between" align="center" mb={2} flexWrap="wrap"> <HStack justify="space-between" align="center" mb={2} flexWrap="wrap">
<Heading size="sm">Náhled: všechna videa (aktivní zdroj)</Heading> <Heading size="sm">Náhled: všechna videa (aktivní zdroj)</Heading>
{videosSource === 'auto' && ( {videosSource === 'auto' && (
<HStack spacing={2}> <HStack spacing={2} flexWrap="wrap">
<Input size="sm" placeholder="Filtrovat podle názvu" value={filter} onChange={(e) => setFilter(e.target.value)} /> <Input size="sm" placeholder="Filtrovat podle názvu" value={filter} onChange={(e) => setFilter(e.target.value)} width={{ base: '100%', md: '260px' }} />
<Button size="sm" onClick={refreshAuto} isLoading={autoLoading} variant="outline">Aktualizovat cache</Button> <Button size="sm" onClick={refreshAuto} isLoading={autoLoading} variant="outline" flexShrink={0} minW="max-content">Aktualizovat cache</Button>
</HStack> </HStack>
)} )}
</HStack> </HStack>
@@ -229,6 +229,7 @@ const ArticlesAdminPage = () => {
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const [isPending, startTransition] = React.useTransition();
const [aiPrompt, setAiPrompt] = useState(''); const [aiPrompt, setAiPrompt] = useState('');
const [aiAudience, setAiAudience] = useState('Fanoušci klubu'); const [aiAudience, setAiAudience] = useState('Fanoušci klubu');
const [aiMinWords, setAiMinWords] = useState<number>(500); const [aiMinWords, setAiMinWords] = useState<number>(500);
@@ -1329,7 +1330,7 @@ const ArticlesAdminPage = () => {
</ModalHeader> </ModalHeader>
<ModalCloseButton /> <ModalCloseButton />
<ModalBody maxH="calc(90vh - 120px)" overflowY="auto"> <ModalBody maxH="calc(90vh - 120px)" overflowY="auto">
<Tabs variant="enclosed" colorScheme="blue" isFitted index={activeTabIndex} onChange={(index) => setActiveTabIndex(index)}> <Tabs variant="enclosed" colorScheme="blue" isFitted index={activeTabIndex} onChange={(index) => setActiveTabIndex(index)} isLazy lazyBehavior="unmount">
<TabList> <TabList>
<Tab>AI</Tab> <Tab>AI</Tab>
<Tab>Základní</Tab> <Tab>Základní</Tab>
@@ -1355,7 +1356,7 @@ const ArticlesAdminPage = () => {
rows={8} rows={8}
placeholder="Napište svůj text s důležitými informacemi. Příklad:&#10;&#10;Dnes naše mužstvo zvládlo důležitý zápas proti TJ Sokol Příbram. Konečný výsledek 3:1 pro nás. První poloasu jsme dominovali, Jana Novák dal dva góly. Ve druhé poloasu sice soupeř snížil, ale Petr Černý svým třetím gólem rozhodl.&#10;&#10;AI váš text rozšíří, přidá strukturu a doplní kontext pokud je krátký." placeholder="Napište svůj text s důležitými informacemi. Příklad:&#10;&#10;Dnes naše mužstvo zvládlo důležitý zápas proti TJ Sokol Příbram. Konečný výsledek 3:1 pro nás. První poloasu jsme dominovali, Jana Novák dal dva góly. Ve druhé poloasu sice soupeř snížil, ale Petr Černý svým třetím gólem rozhodl.&#10;&#10;AI váš text rozšíří, přidá strukturu a doplní kontext pokud je krátký."
value={aiPrompt} value={aiPrompt}
onChange={(e) => setAiPrompt(e.target.value)} onChange={(e) => startTransition(() => setAiPrompt(e.target.value))}
fontSize="md" fontSize="md"
bg={inputBg} bg={inputBg}
/> />
+29 -20
View File
@@ -3,7 +3,7 @@ import { Box, Button, FormControl, FormLabel, Heading, HStack, IconButton, Image
import { FiPlus, FiEdit2, FiTrash2, FiUpload, FiAlertCircle, FiCheckCircle } from 'react-icons/fi'; import { FiPlus, FiEdit2, FiTrash2, FiUpload, FiAlertCircle, FiCheckCircle } from 'react-icons/fi';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout'; import AdminLayout from '../../layouts/AdminLayout';
import { Sponsor, getSponsors, createSponsor, updateSponsor, deleteSponsor } from '../../services/sponsors'; import { Banner as AdminBanner, getBanners, createBanner, updateBanner, deleteBanner } from '../../services/banners';
import { uploadFile } from '../../services/articles'; import { uploadFile } from '../../services/articles';
import { assetUrl } from '../../utils/url'; import { assetUrl } from '../../utils/url';
@@ -15,7 +15,7 @@ type BannerPreset = {
width: number; width: number;
height: number; height: number;
aspectRatio: number; aspectRatio: number;
position: 'top' | 'middle' | 'sidebar' | 'footer' | 'article'; position: 'top' | 'middle' | 'sidebar' | 'footer' | 'article' | 'under_table';
}; };
const BANNER_PRESETS: BannerPreset[] = [ const BANNER_PRESETS: BannerPreset[] = [
@@ -63,6 +63,15 @@ const BANNER_PRESETS: BannerPreset[] = [
height: 90, height: 90,
aspectRatio: 8.09, aspectRatio: 8.09,
position: 'article' position: 'article'
},
{
value: 'homepage_under_table',
label: 'Pod tabulkou (Homepage)',
description: 'Banner pod sekcí Tabulky na titulní stránce',
width: 970,
height: 90,
aspectRatio: 10.78,
position: 'under_table'
} }
]; ];
@@ -72,8 +81,8 @@ const BannersAdminPage: React.FC = () => {
const inputBg = useColorModeValue('white', 'gray.700'); const inputBg = useColorModeValue('white', 'gray.700');
const toast = useToast(); const toast = useToast();
const qc = useQueryClient(); const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ['admin-banners'], queryFn: getSponsors }); const { data, isLoading } = useQuery<AdminBanner[]>(['admin-banners'], () => getBanners());
const [editing, setEditing] = useState<Partial<Sponsor> | null>(null); const [editing, setEditing] = useState<Partial<AdminBanner> | null>(null);
const [imageResolution, setImageResolution] = useState<{ width: number; height: number } | null>(null); const [imageResolution, setImageResolution] = useState<{ width: number; height: number } | null>(null);
const [recommendedPlacements, setRecommendedPlacements] = useState<BannerPreset[]>([]); const [recommendedPlacements, setRecommendedPlacements] = useState<BannerPreset[]>([]);
const [uploadingImage, setUploadingImage] = useState(false); const [uploadingImage, setUploadingImage] = useState(false);
@@ -117,7 +126,7 @@ const BannersAdminPage: React.FC = () => {
setRecommendedPlacements([]); setRecommendedPlacements([]);
onOpen(); onOpen();
}; };
const openEdit = (s: Sponsor) => { const openEdit = (s: AdminBanner) => {
setEditing({ ...s }); setEditing({ ...s });
setImageResolution(null); setImageResolution(null);
setRecommendedPlacements([]); setRecommendedPlacements([]);
@@ -134,17 +143,17 @@ const BannersAdminPage: React.FC = () => {
}; };
const createMut = useMutation({ const createMut = useMutation({
mutationFn: (payload: any) => createSponsor(payload), mutationFn: (payload: any) => createBanner(payload),
onSuccess: () => { toast({ title: 'Banner vytvořen', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-banners'] }); closeModal(); }, onSuccess: () => { toast({ title: 'Banner vytvořen', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-banners'] }); closeModal(); },
onError: (e: any) => toast({ title: 'Vytvoření selhalo', description: e?.response?.data?.message || 'Chyba', status: 'error' }), onError: (e: any) => toast({ title: 'Vytvoření selhalo', description: e?.response?.data?.message || 'Chyba', status: 'error' }),
}); });
const updateMut = useMutation({ const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: number | string; payload: any }) => updateSponsor(id, payload), mutationFn: ({ id, payload }: { id: number | string; payload: any }) => updateBanner(id, payload),
onSuccess: () => { toast({ title: 'Banner upraven', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-banners'] }); closeModal(); }, onSuccess: () => { toast({ title: 'Banner upraven', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-banners'] }); closeModal(); },
onError: (e: any) => toast({ title: 'Aktualizace selhala', description: e?.response?.data?.message || 'Chyba', status: 'error' }), onError: (e: any) => toast({ title: 'Aktualizace selhala', description: e?.response?.data?.message || 'Chyba', status: 'error' }),
}); });
const deleteMut = useMutation({ const deleteMut = useMutation({
mutationFn: (id: number | string) => deleteSponsor(id), mutationFn: (id: number | string) => deleteBanner(id),
onSuccess: () => { toast({ title: 'Banner smazán', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-banners'] }); }, onSuccess: () => { toast({ title: 'Banner smazán', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-banners'] }); },
onError: (e: any) => toast({ title: 'Smazání selhalo', description: e?.response?.data?.message || 'Chyba', status: 'error' }), onError: (e: any) => toast({ title: 'Smazání selhalo', description: e?.response?.data?.message || 'Chyba', status: 'error' }),
}); });
@@ -153,8 +162,8 @@ const BannersAdminPage: React.FC = () => {
if (!editing) return; if (!editing) return;
const payload = { const payload = {
name: editing.name || '', name: editing.name || '',
logo_url: editing.logo_url, image_url: (editing as any).image_url,
website_url: editing.website_url, click_url: (editing as any).click_url,
is_active: editing.is_active ?? true, is_active: editing.is_active ?? true,
placement: (editing as any).placement || '', placement: (editing as any).placement || '',
width: (editing as any).width || undefined, width: (editing as any).width || undefined,
@@ -192,7 +201,7 @@ const BannersAdminPage: React.FC = () => {
const res = await uploadFile(file); const res = await uploadFile(file);
// Update editing state with uploaded URL // Update editing state with uploaded URL
setEditing((prev) => ({ ...(prev || {}), logo_url: res.url })); setEditing((prev) => ({ ...(prev || {}), image_url: res.url }));
// If no placement selected yet, auto-select the best recommendation // If no placement selected yet, auto-select the best recommendation
if (!editing?.placement && recommended.length > 0) { if (!editing?.placement && recommended.length > 0) {
@@ -265,17 +274,17 @@ const BannersAdminPage: React.FC = () => {
{isLoading && ( {isLoading && (
<Tr><Td colSpan={6} textAlign="center"><Spinner size="sm" mr={2} />Načítání</Td></Tr> <Tr><Td colSpan={6} textAlign="center"><Spinner size="sm" mr={2} />Načítání</Td></Tr>
)} )}
{!isLoading && banners.map((b) => { {!isLoading && banners.map((b: AdminBanner) => {
const preset = getPreset((b as any).placement); const preset = getPreset((b as any).placement);
return ( return (
<Tr key={b.id}> <Tr key={b.id}>
<Td> <Td>
<Image src={assetUrl(b.logo_url) || '/logo192.png'} alt={b.name} boxSize="56px" objectFit="contain" bg={inputBg} borderRadius="md" /> <Image src={assetUrl((b as any).image_url) || '/logo192.png'} alt={b.name} boxSize="56px" objectFit="contain" bg={inputBg} borderRadius="md" />
</Td> </Td>
<Td> <Td>
<Text fontWeight="500">{b.name}</Text> <Text fontWeight="500">{b.name}</Text>
{b.website_url && ( {(b as any).click_url && (
<Text fontSize="xs" color="gray.500" noOfLines={1}>{b.website_url}</Text> <Text fontSize="xs" color="gray.500" noOfLines={1}>{(b as any).click_url}</Text>
)} )}
</Td> </Td>
<Td> <Td>
@@ -323,7 +332,7 @@ const BannersAdminPage: React.FC = () => {
</FormControl> </FormControl>
<FormControl> <FormControl>
<FormLabel>Odkaz (po kliku)</FormLabel> <FormLabel>Odkaz (po kliku)</FormLabel>
<Input type="url" value={editing?.website_url || ''} onChange={(e) => setEditing((prev) => ({ ...(prev as any), website_url: e.target.value }))} placeholder="https://partner.cz" /> <Input type="url" value={(editing as any)?.click_url || ''} onChange={(e) => setEditing((prev) => ({ ...(prev as any), click_url: e.target.value }))} placeholder="https://partner.cz" />
</FormControl> </FormControl>
{/* Image resolution info */} {/* Image resolution info */}
{imageResolution && ( {imageResolution && (
@@ -430,7 +439,7 @@ const BannersAdminPage: React.FC = () => {
<FormLabel>Obrázek banneru</FormLabel> <FormLabel>Obrázek banneru</FormLabel>
<VStack align="stretch" spacing={3}> <VStack align="stretch" spacing={3}>
{/* Preview */} {/* Preview */}
{editing?.logo_url && (() => { {(editing as any)?.image_url && (() => {
const preset = getPreset((editing as any)?.placement); const preset = getPreset((editing as any)?.placement);
const previewWidth = preset ? Math.min(preset.width, 600) : 300; const previewWidth = preset ? Math.min(preset.width, 600) : 300;
const previewHeight = preset ? (previewWidth / preset.aspectRatio) : 150; const previewHeight = preset ? (previewWidth / preset.aspectRatio) : 150;
@@ -446,7 +455,7 @@ const BannersAdminPage: React.FC = () => {
bg={inputBg} bg={inputBg}
> >
<Image <Image
src={assetUrl(editing?.logo_url) || '/logo192.png'} src={assetUrl((editing as any)?.image_url) || '/logo192.png'}
alt="banner preview" alt="banner preview"
width={`${previewWidth}px`} width={`${previewWidth}px`}
height={`${previewHeight}px`} height={`${previewHeight}px`}
@@ -475,7 +484,7 @@ const BannersAdminPage: React.FC = () => {
isLoading={uploadingImage} isLoading={uploadingImage}
loadingText="Nahrávání..." loadingText="Nahrávání..."
> >
{editing?.logo_url ? 'Změnit obrázek' : 'Nahrát obrázek'} {(editing as any)?.image_url ? 'Změnit obrázek' : 'Nahrát obrázek'}
<Input <Input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@@ -489,7 +498,7 @@ const BannersAdminPage: React.FC = () => {
{uploadingImage && <Spinner size="sm" />} {uploadingImage && <Spinner size="sm" />}
</HStack> </HStack>
{!editing?.logo_url && ( {!((editing as any)?.image_url) && (
<Alert status="warning" fontSize="xs"> <Alert status="warning" fontSize="xs">
<AlertIcon boxSize="12px" /> <AlertIcon boxSize="12px" />
<Text fontSize="xs">Nahrajte obrázek pro automatické doporučení umístění</Text> <Text fontSize="xs">Nahrajte obrázek pro automatické doporučení umístění</Text>
+56 -58
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { import {
Box, Box,
Button, Button,
@@ -33,7 +33,6 @@ import {
Badge, Badge,
HStack, HStack,
VStack, VStack,
useDisclosure,
AlertDialog, AlertDialog,
AlertDialogBody, AlertDialogBody,
AlertDialogFooter, AlertDialogFooter,
@@ -41,11 +40,10 @@ import {
AlertDialogContent, AlertDialogContent,
AlertDialogOverlay, AlertDialogOverlay,
SimpleGrid, SimpleGrid,
Divider,
FormHelperText, FormHelperText,
useColorModeValue, useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { FiEdit, FiTrash2, FiPlus, FiUser } from 'react-icons/fi'; import { FiEdit, FiTrash2, FiPlus, FiUser, FiUpload } from 'react-icons/fi';
import AdminLayout from '../../layouts/AdminLayout'; import AdminLayout from '../../layouts/AdminLayout';
import { import {
getContacts, getContacts,
@@ -56,13 +54,12 @@ import {
getContactCategories, getContactCategories,
ContactCategory, ContactCategory,
} from '../../services/contactInfo'; } from '../../services/contactInfo';
import api, { uploadImage } from '../../services/api'; import { uploadImage } from '../../services/api';
import { getImageUrl } from '../../utils/imageUtils'; import { getImageUrl } from '../../utils/imageUtils';
import { getAdminSettings, updateAdminSettings, AdminSettings, PublicSettings } from '../../services/settings'; import { getAdminSettings, updateAdminSettings, AdminSettings, PublicSettings } from '../../services/settings';
import MapLinkImporter from '../../components/admin/MapLinkImporter'; import MapLinkImporter from '../../components/admin/MapLinkImporter';
import { MapCoordinates } from '../../utils/mapUrlParser'; import { MapCoordinates } from '../../utils/mapUrlParser';
import ContactMap from '../../components/home/ContactMap'; import { getFacrTablesCache } from '../../services/facr/cache';
import MapStyleSelector from '../../components/admin/MapStyleSelector';
const ContactsAdminPage: React.FC = () => { const ContactsAdminPage: React.FC = () => {
const cardBg = useColorModeValue('white', 'gray.800'); const cardBg = useColorModeValue('white', 'gray.800');
@@ -100,6 +97,8 @@ const ContactsAdminPage: React.FC = () => {
const [uploadingImage, setUploadingImage] = useState(false); const [uploadingImage, setUploadingImage] = useState(false);
const [settings, setSettings] = useState<AdminSettings>({}); const [settings, setSettings] = useState<AdminSettings>({});
const [savingSettings, setSavingSettings] = useState(false); const [savingSettings, setSavingSettings] = useState(false);
const [facrCompetitions, setFacrCompetitions] = useState<any[]>([]);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
useEffect(() => { useEffect(() => {
loadData(); loadData();
@@ -109,12 +108,14 @@ const ContactsAdminPage: React.FC = () => {
const loadData = async () => { const loadData = async () => {
setLoading(true); setLoading(true);
try { try {
const [contactsData, categoriesData] = await Promise.all([ const [contactsData, categoriesData, facrData] = await Promise.all([
getContacts(), getContacts(),
getContactCategories(), getContactCategories(),
getFacrTablesCache(),
]); ]);
setContacts(contactsData); setContacts(contactsData);
setCategories(categoriesData); setCategories(categoriesData);
setFacrCompetitions(Array.isArray(facrData?.competitions) ? facrData!.competitions : []);
} catch (error) { } catch (error) {
toast({ toast({
title: 'Chyba při načítání', title: 'Chyba při načítání',
@@ -127,6 +128,31 @@ const ContactsAdminPage: React.FC = () => {
} }
}; };
const clubCompetitionNames = useMemo(() => {
try {
const names = new Set<string>();
for (const comp of facrCompetitions || []) {
const n = String(comp?.name || '').trim();
if (n) names.add(n);
}
return Array.from(names);
} catch {
return [] as string[];
}
}, [facrCompetitions]);
const filteredContactCategories = useMemo(() => {
try {
if (!Array.isArray(categories)) return [] as ContactCategory[];
if ((clubCompetitionNames || []).length === 0) return categories;
const setNames = new Set(clubCompetitionNames.map((s) => String(s)));
const filtered = categories.filter((c) => setNames.has(String(c.name)));
return filtered.length > 0 ? filtered : categories;
} catch {
return categories;
}
}, [categories, clubCompetitionNames]);
// Contact handlers // Contact handlers
const openContactModal = (contact?: Contact) => { const openContactModal = (contact?: Contact) => {
if (contact) { if (contact) {
@@ -526,6 +552,9 @@ const ContactsAdminPage: React.FC = () => {
currentLongitude={settings.location_longitude} currentLongitude={settings.location_longitude}
currentZoom={settings.map_zoom_level} currentZoom={settings.map_zoom_level}
mapStyle={settings.map_style || 'positron'} mapStyle={settings.map_style || 'positron'}
onMapStyleChange={(value: string) => {
setSettings((prev) => ({ ...prev, map_style: value as PublicSettings['map_style'] }));
}}
clubPrimaryColor={settings.primary_color} clubPrimaryColor={settings.primary_color}
clubSecondaryColor={settings.accent_color} clubSecondaryColor={settings.accent_color}
clubName={settings.club_name} clubName={settings.club_name}
@@ -598,43 +627,7 @@ const ContactsAdminPage: React.FC = () => {
</SimpleGrid> </SimpleGrid>
</Box> </Box>
<Box bg={cardBg} p={6} borderRadius="lg" borderWidth="1px" borderColor={borderColor}> {/* Map style selection is integrated into the section above; single unified preview */}
<MapStyleSelector
value={settings.map_style || 'positron'}
onChange={(value) => {
setSettings((prev) => ({ ...prev, map_style: value as PublicSettings['map_style'] }));
}}
clubPrimaryColor={settings.primary_color}
clubSecondaryColor={settings.accent_color}
showPreview={true}
/>
</Box>
{/* Live Map Preview with Current Coordinates */}
{settings.location_latitude && settings.location_longitude && (
<Box bg={cardBg} p={6} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Heading size="md">Náhled vaší mapy</Heading>
<Badge colorScheme="green">Aktuální poloha</Badge>
</HStack>
<Text fontSize="sm" color={textSecondary}>
Toto je náhled mapy s vaší aktuální polohou a vybraným stylem. Takto se zobrazí návštěvníkům na webu.
</Text>
<ContactMap
latitude={settings.location_latitude}
longitude={settings.location_longitude}
zoom={settings.map_zoom_level || 15}
address={`${settings.contact_address || ''}${settings.contact_city ? ', ' + settings.contact_city : ''}`}
clubName={settings.club_name}
mapStyle={settings.map_style || 'positron'}
clubPrimaryColor={settings.primary_color}
clubSecondaryColor={settings.accent_color}
height={400}
/>
</VStack>
</Box>
)}
<Box bg={infoBg} p={4} borderRadius="md" borderWidth="1px" borderColor={infoBorder}> <Box bg={infoBg} p={4} borderRadius="md" borderWidth="1px" borderColor={infoBorder}>
<HStack justify="space-between" align="center"> <HStack justify="space-between" align="center">
@@ -700,13 +693,13 @@ const ContactsAdminPage: React.FC = () => {
} }
> >
<option value="">Bez přiřazení</option> <option value="">Bez přiřazení</option>
{categories.map((cat) => ( {filteredContactCategories.map((cat) => (
<option key={cat.id} value={cat.id}> <option key={cat.id} value={cat.id}>
{cat.name} {cat.name}
</option> </option>
))} ))}
</Select> </Select>
<FormHelperText fontSize="xs">Přiřaďte kontakt ke konkrétní kategorii</FormHelperText> <FormHelperText fontSize="xs">Přiřaďte kontakt ke konkrétní kategorii (podle soutěží klubu)</FormHelperText>
</FormControl> </FormControl>
<FormControl> <FormControl>
@@ -730,10 +723,26 @@ const ContactsAdminPage: React.FC = () => {
<FormControl> <FormControl>
<FormLabel>Fotografie</FormLabel> <FormLabel>Fotografie</FormLabel>
<HStack>
<Button
leftIcon={<FiUpload />}
variant="outline"
colorScheme="blue"
onClick={() => fileInputRef.current?.click()}
isLoading={uploadingImage}
>
Nahrát fotografii
</Button>
{contactForm.image_url && (
<Badge colorScheme="green">Nahráno</Badge>
)}
</HStack>
<Input <Input
ref={fileInputRef}
type="file" type="file"
accept="image/*" accept="image/*"
onChange={handleImageUpload} onChange={handleImageUpload}
display="none"
disabled={uploadingImage} disabled={uploadingImage}
/> />
{contactForm.image_url && ( {contactForm.image_url && (
@@ -757,17 +766,6 @@ const ContactsAdminPage: React.FC = () => {
/> />
</FormControl> </FormControl>
<FormControl>
<FormLabel>Pořadí zobrazení</FormLabel>
<Input
type="number"
value={contactForm.display_order}
onChange={(e) =>
setContactForm({ ...contactForm, display_order: parseInt(e.target.value) || 0 })
}
/>
</FormControl>
<FormControl display="flex" alignItems="center"> <FormControl display="flex" alignItems="center">
<FormLabel mb="0">Aktivní</FormLabel> <FormLabel mb="0">Aktivní</FormLabel>
<Switch <Switch
+3 -3
View File
@@ -64,6 +64,7 @@ import {
getFileIcon, getFileIcon,
} from '../../services/files'; } from '../../services/files';
import { API_URL } from '../../services/api'; import { API_URL } from '../../services/api';
import { assetUrl } from '../../utils/url';
const FilesAdminPage: React.FC = () => { const FilesAdminPage: React.FC = () => {
const toast = useToast(); const toast = useToast();
@@ -187,9 +188,8 @@ const FilesAdminPage: React.FC = () => {
}; };
const getImageUrl = (url: string) => { const getImageUrl = (url: string) => {
if (url.startsWith('http')) return url; const full = assetUrl(url);
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin; return full || url;
return `${origin}${url}`;
}; };
// Mime type options // Mime type options
+27 -4
View File
@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { import {
Box, Box,
Button, Button,
@@ -54,6 +54,7 @@ import {
import Pagination from '../../components/common/Pagination'; import Pagination from '../../components/common/Pagination';
import MessageDetailModal from '../../components/admin/MessageDetailModal'; import MessageDetailModal from '../../components/admin/MessageDetailModal';
import ConfirmationDialog from '../../components/common/ConfirmationDialog'; import ConfirmationDialog from '../../components/common/ConfirmationDialog';
import { useAuth } from '../../contexts/AuthContext';
export default function MessagesAdminPage() { export default function MessagesAdminPage() {
const cardBg = useColorModeValue('white', 'gray.800'); const cardBg = useColorModeValue('white', 'gray.800');
@@ -88,6 +89,8 @@ export default function MessagesAdminPage() {
} = useDisclosure(); } = useDisclosure();
const [forwardAllEmail, setForwardAllEmail] = useState(''); const [forwardAllEmail, setForwardAllEmail] = useState('');
const [saveForwardDefault, setSaveForwardDefault] = useState<boolean>(true);
const { user } = useAuth();
const [selectedMessage, setSelectedMessage] = useState<ContactMessage | null>(null); const [selectedMessage, setSelectedMessage] = useState<ContactMessage | null>(null);
const toast = useToast(); const toast = useToast();
@@ -148,7 +151,8 @@ export default function MessagesAdminPage() {
}); });
const forwardAllMutation = useMutation({ const forwardAllMutation = useMutation({
mutationFn: forwardAllMessages, mutationFn: (payload: { emails: string | string[]; saveDefault?: boolean }) =>
forwardAllMessages(payload.emails, { saveDefault: payload.saveDefault }),
onSuccess: (data) => { onSuccess: (data) => {
toast({ toast({
title: 'Zprávy se přeposílají', title: 'Zprávy se přeposílají',
@@ -207,9 +211,20 @@ export default function MessagesAdminPage() {
}); });
return; return;
} }
forwardAllMutation.mutate(forwardAllEmail); forwardAllMutation.mutate({ emails: forwardAllEmail, saveDefault: saveForwardDefault });
}; };
useEffect(() => {
if (isForwardAllOpen) {
// Prefill with current user's email if empty
if (!forwardAllEmail && user?.email) {
setForwardAllEmail(user.email);
}
// Default to saving as auto-forward unless user opts out
setSaveForwardDefault(true);
}
}, [isForwardAllOpen, user?.email]);
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => { const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) { if (e.target.checked) {
setSelectedMessages(data?.data.map((msg) => msg.id) || []); setSelectedMessages(data?.data.map((msg) => msg.id) || []);
@@ -482,11 +497,19 @@ export default function MessagesAdminPage() {
<FormLabel>E-mailová adresa</FormLabel> <FormLabel>E-mailová adresa</FormLabel>
<Input <Input
type="email" type="email"
placeholder="prijemce@email.cz" placeholder="např. ja@klub.cz, info@klub.cz"
value={forwardAllEmail} value={forwardAllEmail}
onChange={(e) => setForwardAllEmail(e.target.value)} onChange={(e) => setForwardAllEmail(e.target.value)}
/> />
</FormControl> </FormControl>
<HStack w="full" justify="space-between">
<Checkbox
isChecked={saveForwardDefault}
onChange={(e) => setSaveForwardDefault(e.target.checked)}
>
Uložit jako výchozí (automaticky přeposílat nové zprávy)
</Checkbox>
</HStack>
</VStack> </VStack>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
+119 -18
View File
@@ -45,6 +45,7 @@ import {
Flex, Flex,
Textarea, Textarea,
Collapse, Collapse,
Icon,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import AdminLayout from '../../layouts/AdminLayout'; import AdminLayout from '../../layouts/AdminLayout';
import { import {
@@ -68,6 +69,26 @@ import {
FaLinkedin, FaLinkedin,
FaDiscord, FaDiscord,
FaTwitch, FaTwitch,
FaHome,
FaInfoCircle,
FaCalendarAlt,
FaFutbol,
FaUsers,
FaTable,
FaNewspaper,
FaVideo,
FaCamera,
FaSearch,
FaBars,
FaCog,
FaHandshake,
FaEnvelope,
FaUserShield,
FaFolder,
FaBook,
FaTshirt,
FaLink,
FaPoll,
} from 'react-icons/fa'; } from 'react-icons/fa';
// Using simple up/down buttons instead of drag-drop for better compatibility // Using simple up/down buttons instead of drag-drop for better compatibility
import { import {
@@ -137,6 +158,31 @@ const SOCIAL_PLATFORMS = [
{ value: 'twitch', label: 'Twitch', icon: FaTwitch }, { value: 'twitch', label: 'Twitch', icon: FaTwitch },
]; ];
const NAV_ICON_OPTIONS = [
{ value: 'FaHome', label: 'Domů', icon: FaHome },
{ value: 'FaInfoCircle', label: 'O klubu', icon: FaInfoCircle },
{ value: 'FaCalendarAlt', label: 'Kalendář', icon: FaCalendarAlt },
{ value: 'FaFutbol', label: 'Hráči', icon: FaFutbol },
{ value: 'FaUsers', label: 'Týmy', icon: FaUsers },
{ value: 'FaTable', label: 'Tabulky', icon: FaTable },
{ value: 'FaNewspaper', label: 'Články', icon: FaNewspaper },
{ value: 'FaVideo', label: 'Videa', icon: FaVideo },
{ value: 'FaCamera', label: 'Galerie', icon: FaCamera },
{ value: 'FaHandshake', label: 'Sponzoři', icon: FaHandshake },
{ value: 'FaEnvelope', label: 'Kontakt', icon: FaEnvelope },
{ value: 'FaSearch', label: 'Hledat', icon: FaSearch },
{ value: 'FaBars', label: 'Menu', icon: FaBars },
{ value: 'FaLink', label: 'Odkaz', icon: FaLink },
{ value: 'FaCog', label: 'Nastavení', icon: FaCog },
{ value: 'FaPoll', label: 'Ankety', icon: FaPoll },
{ value: 'FaUserShield', label: 'Uživatelé', icon: FaUserShield },
{ value: 'FaFolder', label: 'Soubory', icon: FaFolder },
{ value: 'FaBook', label: 'Stránka', icon: FaBook },
{ value: 'FaTshirt', label: 'Oblečení', icon: FaTshirt },
];
const ICON_COMPONENTS: Record<string, any> = Object.fromEntries(NAV_ICON_OPTIONS.map(opt => [opt.value, opt.icon]));
// NavItemCard component for hierarchical display // NavItemCard component for hierarchical display
interface NavItemCardProps { interface NavItemCardProps {
item: NavigationItem; item: NavigationItem;
@@ -153,6 +199,8 @@ interface NavItemCardProps {
borderColor: string; borderColor: string;
hoverBg: string; hoverBg: string;
level?: number; level?: number;
onChildMoveUp?: (parentId: number, index: number) => void;
onChildMoveDown?: (parentId: number, index: number) => void;
} }
const NavItemCard: React.FC<NavItemCardProps> = ({ const NavItemCard: React.FC<NavItemCardProps> = ({
@@ -170,6 +218,8 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
borderColor, borderColor,
hoverBg, hoverBg,
level = 0, level = 0,
onChildMoveUp,
onChildMoveDown,
}) => { }) => {
const hasChildren = item.children && item.children.length > 0; const hasChildren = item.children && item.children.length > 0;
const indentPx = level * 32; const indentPx = level * 32;
@@ -299,14 +349,14 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
{/* Render children if expanded */} {/* Render children if expanded */}
{hasChildren && isExpanded && ( {hasChildren && isExpanded && (
<VStack spacing={2} align="stretch" mt={2}> <VStack spacing={2} align="stretch" mt={2}>
{item.children!.map((child) => ( {item.children!.map((child, childIndex) => (
<NavItemCard <NavItemCard
key={child.id} key={child.id}
item={child} item={child}
index={0} index={childIndex}
total={1} total={item.children!.length}
onMoveUp={() => {}} onMoveUp={() => onChildMoveUp && onChildMoveUp(item.id!, childIndex)}
onMoveDown={() => {}} onMoveDown={() => onChildMoveDown && onChildMoveDown(item.id!, childIndex)}
onEdit={() => onEdit()} onEdit={() => onEdit()}
onDelete={() => onDelete()} onDelete={() => onDelete()}
onAddChild={() => {}} onAddChild={() => {}}
@@ -316,6 +366,8 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
borderColor={borderColor} borderColor={borderColor}
hoverBg={hoverBg} hoverBg={hoverBg}
level={level + 1} level={level + 1}
onChildMoveUp={onChildMoveUp}
onChildMoveDown={onChildMoveDown}
/> />
))} ))}
</VStack> </VStack>
@@ -352,13 +404,9 @@ const NavigationAdminPage = () => {
getAllNavigationItems(), getAllNavigationItems(),
getAllSocialLinks(), getAllSocialLinks(),
]); ]);
console.log('Načtená navigace:', navData);
console.log('Načtené sociální odkazy:', socialData);
// Auto-seed if navigation is empty // Auto-seed if navigation is empty
if (!navData || navData.length === 0) { if (!navData || navData.length === 0) {
console.log('Navigace je prázdná, automaticky vytváříme výchozí navigaci...');
try { try {
const seedResult = await seedDefaultNavigation(); const seedResult = await seedDefaultNavigation();
if (seedResult.seeded) { if (seedResult.seeded) {
@@ -408,6 +456,43 @@ const NavigationAdminPage = () => {
} }
}; };
const moveChildNavItem = async (parentId: number, index: number, direction: 'up' | 'down') => {
const moveWithin = async (
list: NavigationItem[],
setList: React.Dispatch<React.SetStateAction<NavigationItem[]>>
): Promise<boolean> => {
const parentIdx = list.findIndex((it) => it.id === parentId);
if (parentIdx === -1) return false;
const parent = list[parentIdx];
const children = Array.isArray(parent.children) ? [...parent.children] : [];
if (children.length === 0) return true;
if (direction === 'up' && index === 0) return true;
if (direction === 'down' && index === children.length - 1) return true;
const targetIndex = direction === 'up' ? index - 1 : index + 1;
[children[index], children[targetIndex]] = [children[targetIndex], children[index]];
const updatedParent: NavigationItem = { ...parent, children };
const updated = [...list];
updated[parentIdx] = updatedParent;
setList(updated);
const orders = children.map((c, idx) => ({ id: c.id!, display_order: idx }));
try {
await reorderNavigationItems(orders);
toast({ title: 'Pořadí aktualizováno', status: 'success', duration: 2000 });
} catch (err) {
toast({ title: 'Chyba při aktualizaci pořadí', status: 'error', duration: 3000 });
loadData();
}
return true;
};
const doneFront = await moveWithin(navItems, setNavItems);
if (!doneFront) {
await moveWithin(adminNavItems, setAdminNavItems);
}
};
const moveNavItem = async (index: number, direction: 'up' | 'down') => { const moveNavItem = async (index: number, direction: 'up' | 'down') => {
if (direction === 'up' && index === 0) return; if (direction === 'up' && index === 0) return;
if (direction === 'down' && index === navItems.length - 1) return; if (direction === 'down' && index === navItems.length - 1) return;
@@ -811,6 +896,8 @@ const NavigationAdminPage = () => {
cardBg={cardBg} cardBg={cardBg}
borderColor={borderColor} borderColor={borderColor}
hoverBg={hoverBg} hoverBg={hoverBg}
onChildMoveUp={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'up')}
onChildMoveDown={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'down')}
/> />
)) ))
)} )}
@@ -860,6 +947,8 @@ const NavigationAdminPage = () => {
cardBg={cardBg} cardBg={cardBg}
borderColor={borderColor} borderColor={borderColor}
hoverBg={hoverBg} hoverBg={hoverBg}
onChildMoveUp={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'up')}
onChildMoveDown={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'down')}
/> />
))} ))}
@@ -1026,6 +1115,25 @@ const NavigationAdminPage = () => {
</FormControl> </FormControl>
)} )}
<FormControl>
<FormLabel>Ikona</FormLabel>
<Select
value={editingNav?.icon || ''}
onChange={(e) => setEditingNav({ ...editingNav!, icon: e.target.value || undefined })}
>
<option value="">Bez ikony</option>
{NAV_ICON_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</Select>
{editingNav?.icon && (
<HStack mt={2} spacing={2} align="center">
<Icon as={ICON_COMPONENTS[editingNav.icon]} boxSize={5} />
<Text fontSize="sm">{editingNav.icon}</Text>
</HStack>
)}
</FormControl>
{editingNav?.parent_id && ( {editingNav?.parent_id && (
<Alert status="warning" fontSize="sm"> <Alert status="warning" fontSize="sm">
<AlertIcon /> <AlertIcon />
@@ -1043,14 +1151,7 @@ const NavigationAdminPage = () => {
/> />
</FormControl> </FormControl>
<FormControl>
<FormLabel>CSS třída (volitelné)</FormLabel>
<Input
value={editingNav?.icon || ''}
onChange={(e) => setEditingNav({ ...editingNav!, icon: e.target.value })}
placeholder="custom-class"
/>
</FormControl>
{editingNav?.type === 'external' && ( {editingNav?.type === 'external' && (
<FormControl> <FormControl>
+107 -20
View File
@@ -50,6 +50,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { cs } from 'date-fns/locale'; import { cs } from 'date-fns/locale';
import AdminLayout from '../../layouts/AdminLayout'; import AdminLayout from '../../layouts/AdminLayout';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { facrApi } from '../../services/facr/facrApi';
import { import {
getNewsletterSubscribers, getNewsletterSubscribers,
sendNewsletter, sendNewsletter,
@@ -143,6 +145,29 @@ export default function NewsletterAdminPage() {
const [sendNowLoading, setSendNowLoading] = useState<boolean>(false); const [sendNowLoading, setSendNowLoading] = useState<boolean>(false);
const openDetails = (t: MailType) => { setActiveType(t); setDetailsOpen(true); }; const openDetails = (t: MailType) => { setActiveType(t); setDetailsOpen(true); };
const closeDetails = () => { setDetailsOpen(false); setActiveType(null); setDetailsCompetitions(''); }; const closeDetails = () => { setDetailsOpen(false); setActiveType(null); setDetailsCompetitions(''); };
// Helpers for competitions multi-select handling
const selectedCompCodes = React.useMemo(() => {
return new Set((competitions || '').split(',').map((s) => s.trim()).filter(Boolean));
}, [competitions]);
const toggleComp = (code: string, on: boolean) => {
const next = new Set(selectedCompCodes);
if (on) next.add(code); else next.delete(code);
setCompetitions(Array.from(next).join(', '));
};
const clearComps = () => setCompetitions('');
const selectAllComps = () => setCompetitions(compOptions.map(o => o.code).join(', '));
const detailsSelectedCompCodes = React.useMemo(() => {
return new Set((detailsCompetitions || '').split(',').map((s) => s.trim()).filter(Boolean));
}, [detailsCompetitions]);
const toggleDetailsComp = (code: string, on: boolean) => {
const next = new Set(detailsSelectedCompCodes);
if (on) next.add(code); else next.delete(code);
setDetailsCompetitions(Array.from(next).join(', '));
};
const detailsClearComps = () => setDetailsCompetitions('');
const detailsSelectAllComps = () => setDetailsCompetitions(compOptions.map(o => o.code).join(', '));
const recipientsForType = (t: MailType): string[] => { const recipientsForType = (t: MailType): string[] => {
const key = t === 'weekly' ? 'weekly' : t; const key = t === 'weekly' ? 'weekly' : t;
return subscribers return subscribers
@@ -212,6 +237,24 @@ export default function NewsletterAdminPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isMobile = useBreakpointValue({ base: true, md: false }); const isMobile = useBreakpointValue({ base: true, md: false });
// Load club competitions for nicer dropdowns (FACR)
const { data: publicSettings } = usePublicSettings();
const clubId = publicSettings?.club_id;
const clubType = (publicSettings?.club_type as 'football' | 'futsal') || 'football';
const { data: clubCompetitions = [] } = useQuery({
queryKey: ['facr', 'competitions', clubId, clubType],
queryFn: async () => {
if (!clubId) return [] as Array<{ code?: string; id?: string; name?: string }>;
const comps = await facrApi.getClubCompetitions(clubId, clubType);
return comps || [];
},
enabled: !!clubId,
});
const compOptions = (clubCompetitions as any[]).map((c) => ({
code: String(c?.code || c?.id || ''),
name: String(c?.name || c?.code || c?.id || ''),
})).filter((o) => o.code);
// Admin settings (for scheduling) // Admin settings (for scheduling)
const settingsQuery = useQuery({ const settingsQuery = useQuery({
queryKey: ['admin', 'settings'], queryKey: ['admin', 'settings'],
@@ -222,10 +265,11 @@ export default function NewsletterAdminPage() {
const [enableMatchReminders, setEnableMatchReminders] = useState<boolean>(!!settings?.enable_match_reminders); const [enableMatchReminders, setEnableMatchReminders] = useState<boolean>(!!settings?.enable_match_reminders);
const [enableResults, setEnableResults] = useState<boolean>(!!settings?.enable_results); const [enableResults, setEnableResults] = useState<boolean>(!!settings?.enable_results);
const [weeklyDay, setWeeklyDay] = useState<AdminSettings['newsletter_weekly_day']>(settings?.newsletter_weekly_day || 'sun'); const [weeklyDay, setWeeklyDay] = useState<AdminSettings['newsletter_weekly_day']>(settings?.newsletter_weekly_day || 'sun');
const [weeklyHour, setWeeklyHour] = useState<number>(typeof settings?.newsletter_weekly_hour === 'number' ? (settings!.newsletter_weekly_hour as number) : 18); const toTimeString = (h?: number) => (String(typeof h === 'number' ? Math.max(0, Math.min(23, h)) : 18).padStart(2, '0')) + ':00';
const [weeklyTime, setWeeklyTime] = useState<string>(toTimeString(settings?.newsletter_weekly_hour as number | undefined));
const [reminderLead, setReminderLead] = useState<number>(typeof settings?.newsletter_reminder_lead_hours === 'number' ? (settings!.newsletter_reminder_lead_hours as number) : 48); const [reminderLead, setReminderLead] = useState<number>(typeof settings?.newsletter_reminder_lead_hours === 'number' ? (settings!.newsletter_reminder_lead_hours as number) : 48);
const [quietStart, setQuietStart] = useState<number>(typeof settings?.newsletter_quiet_start === 'number' ? (settings!.newsletter_quiet_start as number) : 22); const [quietStartTime, setQuietStartTime] = useState<string>(toTimeString(settings?.newsletter_quiet_start as number | undefined));
const [quietEnd, setQuietEnd] = useState<number>(typeof settings?.newsletter_quiet_end === 'number' ? (settings!.newsletter_quiet_end as number) : 7); const [quietEndTime, setQuietEndTime] = useState<string>(toTimeString(settings?.newsletter_quiet_end as number | undefined));
// Sync local state when settings load // Sync local state when settings load
useEffect(() => { useEffect(() => {
@@ -234,22 +278,27 @@ export default function NewsletterAdminPage() {
setEnableMatchReminders(!!settings.enable_match_reminders); setEnableMatchReminders(!!settings.enable_match_reminders);
setEnableResults(!!settings.enable_results); setEnableResults(!!settings.enable_results);
setWeeklyDay(settings.newsletter_weekly_day || 'sun'); setWeeklyDay(settings.newsletter_weekly_day || 'sun');
setWeeklyHour(typeof settings.newsletter_weekly_hour === 'number' ? settings.newsletter_weekly_hour! : 18); setWeeklyTime(toTimeString(typeof settings.newsletter_weekly_hour === 'number' ? settings.newsletter_weekly_hour! : 18));
setReminderLead(typeof settings.newsletter_reminder_lead_hours === 'number' ? settings.newsletter_reminder_lead_hours! : 48); setReminderLead(typeof settings.newsletter_reminder_lead_hours === 'number' ? settings.newsletter_reminder_lead_hours! : 48);
setQuietStart(typeof settings.newsletter_quiet_start === 'number' ? settings.newsletter_quiet_start! : 22); setQuietStartTime(toTimeString(typeof settings.newsletter_quiet_start === 'number' ? settings.newsletter_quiet_start! : 22));
setQuietEnd(typeof settings.newsletter_quiet_end === 'number' ? settings.newsletter_quiet_end! : 7); setQuietEndTime(toTimeString(typeof settings.newsletter_quiet_end === 'number' ? settings.newsletter_quiet_end! : 7));
}, [settings]); }, [settings]);
const parseHour = (t: string) => {
const m = /^\s*(\d{1,2})(?::(\d{1,2}))?/.exec(t || '');
const h = m ? parseInt(m[1], 10) : 18;
return Math.max(0, Math.min(23, isNaN(h) ? 18 : h));
};
const saveScheduleMutation = useMutation({ const saveScheduleMutation = useMutation({
mutationFn: () => updateAdminSettings({ mutationFn: () => updateAdminSettings({
enable_weekly: enableWeekly, enable_weekly: enableWeekly,
enable_match_reminders: enableMatchReminders, enable_match_reminders: enableMatchReminders,
enable_results: enableResults, enable_results: enableResults,
newsletter_weekly_day: weeklyDay, newsletter_weekly_day: weeklyDay,
newsletter_weekly_hour: weeklyHour, newsletter_weekly_hour: parseHour(weeklyTime),
newsletter_reminder_lead_hours: reminderLead, newsletter_reminder_lead_hours: reminderLead,
newsletter_quiet_start: quietStart, newsletter_quiet_start: parseHour(quietStartTime),
newsletter_quiet_end: quietEnd, newsletter_quiet_end: parseHour(quietEndTime),
}), }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] }); queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
@@ -621,9 +670,9 @@ export default function NewsletterAdminPage() {
<option value="sun">Neděle</option> <option value="sun">Neděle</option>
</Select> </Select>
</FormControl> </FormControl>
<FormControl maxW="160px"> <FormControl maxW="200px">
<FormLabel>Hodina</FormLabel> <FormLabel>Čas odeslání</FormLabel>
<Input type="number" min={0} max={23} value={weeklyHour} onChange={(e)=> setWeeklyHour(Math.max(0, Math.min(23, Number(e.target.value)||0)))} /> <Input type="time" step={900} value={weeklyTime} onChange={(e)=> setWeeklyTime(e.target.value)} />
</FormControl> </FormControl>
</HStack> </HStack>
@@ -648,13 +697,13 @@ export default function NewsletterAdminPage() {
<Switch isChecked={enableResults} onChange={(e)=> setEnableResults(e.target.checked)} /> <Switch isChecked={enableResults} onChange={(e)=> setEnableResults(e.target.checked)} />
</HStack> </HStack>
<HStack spacing={3}> <HStack spacing={3}>
<FormControl maxW="160px"> <FormControl maxW="200px">
<FormLabel>Tiché hodiny od</FormLabel> <FormLabel>Tiché hodiny od</FormLabel>
<Input type="number" min={0} max={23} value={quietStart} onChange={(e)=> setQuietStart(Math.max(0, Math.min(23, Number(e.target.value)||0)))} /> <Input type="time" step={900} value={quietStartTime} onChange={(e)=> setQuietStartTime(e.target.value)} />
</FormControl> </FormControl>
<FormControl maxW="160px"> <FormControl maxW="200px">
<FormLabel>Tiché hodiny do</FormLabel> <FormLabel>Tiché hodiny do</FormLabel>
<Input type="number" min={0} max={23} value={quietEnd} onChange={(e)=> setQuietEnd(Math.max(0, Math.min(23, Number(e.target.value)||0)))} /> <Input type="time" step={900} value={quietEndTime} onChange={(e)=> setQuietEndTime(e.target.value)} />
<FormHelperText>E-maily s výsledky se neposílají v tomto intervalu.</FormHelperText> <FormHelperText>E-maily s výsledky se neposílají v tomto intervalu.</FormHelperText>
</FormControl> </FormControl>
</HStack> </HStack>
@@ -918,8 +967,28 @@ export default function NewsletterAdminPage() {
<> <>
<FormControl> <FormControl>
<FormLabel>Filtr soutěží (volitelné)</FormLabel> <FormLabel>Filtr soutěží (volitelné)</FormLabel>
<Input placeholder="NAPŘ. KP, I.A, I.B" value={competitions} onChange={(e)=> setCompetitions(e.target.value)} /> {compOptions.length > 0 ? (
<FormHelperText>Čárkou oddělený seznam kódů soutěží.</FormHelperText> <VStack align="stretch" spacing={2} maxH="220px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
<HStack>
<Button size="xs" variant="outline" onClick={selectAllComps}>Vybrat vše</Button>
<Button size="xs" variant="ghost" onClick={clearComps}>Zrušit vše</Button>
</HStack>
{compOptions.map((o) => {
const checked = selectedCompCodes.has(o.code.toLowerCase()) || selectedCompCodes.has(o.code);
return (
<HStack key={o.code} justify="space-between">
<Text>{o.name}</Text>
<Switch isChecked={checked} onChange={(e)=> toggleComp(o.code, e.target.checked)} />
</HStack>
);
})}
</VStack>
) : (
<>
<Input placeholder="NAPŘ. KP, I.A, I.B" value={competitions} onChange={(e)=> setCompetitions(e.target.value)} />
<FormHelperText>Čárkou oddělený seznam kódů soutěží.</FormHelperText>
</>
)}
</FormControl> </FormControl>
<HStack> <HStack>
<Button variant="outline" onClick={async ()=>{ <Button variant="outline" onClick={async ()=>{
@@ -1009,9 +1078,27 @@ export default function NewsletterAdminPage() {
<ModalBody> <ModalBody>
<VStack align="stretch" spacing={4}> <VStack align="stretch" spacing={4}>
<HStack spacing={4} align="flex-end"> <HStack spacing={4} align="flex-end">
<FormControl maxW="360px"> <FormControl maxW="420px">
<FormLabel>Filtr soutěží (volitelné)</FormLabel> <FormLabel>Filtr soutěží (volitelné)</FormLabel>
<Input placeholder="NAPŘ. KP, I.A, I.B" value={detailsCompetitions} onChange={(e)=> setDetailsCompetitions(e.target.value)} /> {compOptions.length > 0 ? (
<VStack align="stretch" spacing={2} maxH="220px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
<HStack>
<Button size="xs" variant="outline" onClick={detailsSelectAllComps}>Vybrat vše</Button>
<Button size="xs" variant="ghost" onClick={detailsClearComps}>Zrušit vše</Button>
</HStack>
{compOptions.map((o) => {
const checked = detailsSelectedCompCodes.has(o.code.toLowerCase()) || detailsSelectedCompCodes.has(o.code);
return (
<HStack key={o.code} justify="space-between">
<Text>{o.name}</Text>
<Switch isChecked={checked} onChange={(e)=> toggleDetailsComp(o.code, e.target.checked)} />
</HStack>
);
})}
</VStack>
) : (
<Input placeholder="NAPŘ. KP, I.A, I.B" value={detailsCompetitions} onChange={(e)=> setDetailsCompetitions(e.target.value)} />
)}
</FormControl> </FormControl>
<Button onClick={async()=>{ if(!activeType) return; setDetailsLoading(true); try { await loadPreviewForType(activeType, detailsCompetitions); } finally { setDetailsLoading(false); } }} isLoading={detailsLoading}>Aktualizovat náhled</Button> <Button onClick={async()=>{ if(!activeType) return; setDetailsLoading(true); try { await loadPreviewForType(activeType, detailsCompetitions); } finally { setDetailsLoading(false); } }} isLoading={detailsLoading}>Aktualizovat náhled</Button>
{activeType && typePreview[activeType]?.subject && ( {activeType && typePreview[activeType]?.subject && (
+23 -22
View File
@@ -42,7 +42,7 @@ import { Player, getPlayers, createPlayer, updatePlayer, deletePlayer } from '..
import { uploadFile } from '../../services/articles'; import { uploadFile } from '../../services/articles';
import { translateNationality } from '../../utils/nationality'; import { translateNationality } from '../../utils/nationality';
import ThumbnailPreview from '../../components/common/ThumbnailPreview'; import ThumbnailPreview from '../../components/common/ThumbnailPreview';
import { API_URL } from '../../services/api'; import { assetUrl } from '../../utils/url';
type Editing = Partial<Player> & { id?: number }; type Editing = Partial<Player> & { id?: number };
@@ -51,16 +51,7 @@ const PlayersAdminPage: React.FC = () => {
const borderColor = useColorModeValue('gray.200', 'gray.700'); const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.700'); const inputBg = useColorModeValue('white', 'gray.700');
const toast = useToast(); const toast = useToast();
const normalizeImageUrl = (url?: string) => {
if (!url || url === '') return '/logo192.png';
// If it's already absolute, return as-is
if (/^https?:\/\//i.test(url)) return url;
// If it's an uploads path, prefix with API origin
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
if (url.startsWith('/uploads/')) return `${origin}${url}`;
// Fallback: treat as relative to origin
return `${origin}${url.startsWith('/') ? '' : '/'}${url}`;
};
// Hoisted helper: convert country code to flag emoji // Hoisted helper: convert country code to flag emoji
function countryCodeToEmoji(cc: string) { function countryCodeToEmoji(cc: string) {
@@ -231,7 +222,6 @@ const PlayersAdminPage: React.FC = () => {
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const JERSEY_MIN = 0; const JERSEY_MIN = 0;
const JERSEY_MAX = 99;
const HEIGHT_MIN = 0; const HEIGHT_MIN = 0;
const HEIGHT_MAX = 250; const HEIGHT_MAX = 250;
const WEIGHT_MIN = 0; const WEIGHT_MIN = 0;
@@ -315,14 +305,12 @@ const PlayersAdminPage: React.FC = () => {
return; return;
} }
const tooBig = ( const tooBig = (
typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > JERSEY_MAX
) || (
typeof editing.height === 'number' && Number.isFinite(editing.height) && editing.height > HEIGHT_MAX typeof editing.height === 'number' && Number.isFinite(editing.height) && editing.height > HEIGHT_MAX
) || ( ) || (
typeof editing.weight === 'number' && Number.isFinite(editing.weight) && editing.weight > WEIGHT_MAX typeof editing.weight === 'number' && Number.isFinite(editing.weight) && editing.weight > WEIGHT_MAX
); );
if (tooBig) { if (tooBig) {
toast({ title: 'Neplatná čísla', description: `Maxima: číslo dresu ${JERSEY_MAX}, výška ${HEIGHT_MAX} cm, váha ${WEIGHT_MAX} kg`, status: 'warning' }); toast({ title: 'Neplatná čísla', description: `Maxima: výška ${HEIGHT_MAX} cm, váha ${WEIGHT_MAX} kg`, status: 'warning' });
return; return;
} }
// Require date of birth: all three values must be selected // Require date of birth: all three values must be selected
@@ -337,7 +325,7 @@ const PlayersAdminPage: React.FC = () => {
}; };
if (editing.date_of_birth) payload.date_of_birth = editing.date_of_birth; if (editing.date_of_birth) payload.date_of_birth = editing.date_of_birth;
if (editing.position) payload.position = editing.position; if (editing.position) payload.position = editing.position;
if (typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > 0) { if (typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number >= 0) {
payload.jersey_number = editing.jersey_number; payload.jersey_number = editing.jersey_number;
} }
if (editing.nationality) payload.nationality = editing.nationality; if (editing.nationality) payload.nationality = editing.nationality;
@@ -391,7 +379,7 @@ const PlayersAdminPage: React.FC = () => {
<Tr key={p.id}> <Tr key={p.id}>
<Td> <Td>
<ThumbnailPreview <ThumbnailPreview
src={normalizeImageUrl(p.image_url)} src={assetUrl(p.image_url) || '/logo192.png'}
alt={`${p.first_name} ${p.last_name}`} alt={`${p.first_name} ${p.last_name}`}
size="48px" size="48px"
previewSize="300px" previewSize="300px"
@@ -450,7 +438,7 @@ const PlayersAdminPage: React.FC = () => {
</Select> </Select>
</HStack> </HStack>
<Box mt={2} fontSize="sm" color="gray.500"> <Box mt={2} fontSize="sm" color="gray.500">
{formatDobPreview(dobParts)} {formatDobPreview(dobParts)}{calculateAgeFromParts(dobParts) != null ? `${calculateAgeFromParts(dobParts)} let` : ''}
</Box> </Box>
</FormControl> </FormControl>
@@ -466,12 +454,11 @@ const PlayersAdminPage: React.FC = () => {
</Select> </Select>
</FormControl> </FormControl>
<FormControl isInvalid={typeof editing?.jersey_number === 'number' && (editing?.jersey_number as number) > JERSEY_MAX}> <FormControl>
<FormLabel>Číslo dresu</FormLabel> <FormLabel>Číslo dresu</FormLabel>
<NumberInput min={JERSEY_MIN} max={JERSEY_MAX} keepWithinRange={false} clampValueOnBlur={false} value={typeof editing?.jersey_number === 'number' ? editing?.jersey_number : ''} onChange={(_, v) => setEditing((p) => ({ ...(p as any), jersey_number: Number.isFinite(v) ? v : undefined }))}> <NumberInput min={JERSEY_MIN} keepWithinRange={false} clampValueOnBlur={false} value={typeof editing?.jersey_number === 'number' ? editing?.jersey_number : ''} onChange={(_, v) => setEditing((p) => ({ ...(p as any), jersey_number: Number.isFinite(v) && v >= 0 ? v : undefined }))}>
<NumberInputField inputMode="numeric" /> <NumberInputField inputMode="numeric" />
</NumberInput> </NumberInput>
<FormErrorMessage>Maximální číslo dresu je {JERSEY_MAX}.</FormErrorMessage>
</FormControl> </FormControl>
<FormControl> <FormControl>
@@ -547,7 +534,7 @@ const PlayersAdminPage: React.FC = () => {
<FormControl> <FormControl>
<FormLabel>Fotka</FormLabel> <FormLabel>Fotka</FormLabel>
<HStack> <HStack>
<Image src={normalizeImageUrl(editing?.image_url)} alt="photo" boxSize="56px" objectFit="cover" borderRadius="md" fallbackSrc="/dist/img/logo-club-empty.svg" /> <Image src={assetUrl(editing?.image_url) || '/logo192.png'} alt="photo" boxSize="56px" objectFit="cover" borderRadius="md" fallbackSrc="/dist/img/logo-club-empty.svg" />
<Button as="label" type="button" leftIcon={<FiUpload />}>Nahrát <Button as="label" type="button" leftIcon={<FiUpload />}>Nahrát
<Input <Input
type="file" type="file"
@@ -614,6 +601,20 @@ const PlayersAdminPage: React.FC = () => {
return `${dd}.${mm}.${yyyy}`; return `${dd}.${mm}.${yyyy}`;
} }
function calculateAgeFromParts(parts: { day: string; month: string; year: string }): number | null {
if (!parts.day || !parts.month || !parts.year) return null;
const y = Number(parts.year);
const m = Number(parts.month);
const d = Number(parts.day);
if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) return null;
const today = new Date();
let age = today.getFullYear() - y;
const month = today.getMonth() + 1;
const day = today.getDate();
if (month < m || (month === m && day < d)) age--;
return age;
}
// Update DOB parts and, when complete, compose YYYY-MM-DD. Clamp day to month length. // Update DOB parts and, when complete, compose YYYY-MM-DD. Clamp day to month length.
function updateDobPart(part: 'day'|'month'|'year', value: string) { function updateDobPart(part: 'day'|'month'|'year', value: string) {
setDobParts((prev) => { setDobParts((prev) => {
+52 -47
View File
@@ -54,7 +54,53 @@ import { assetUrl } from '../../utils/url';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { API_URL } from '../../services/api'; import { API_URL } from '../../services/api';
function normalize(s: string): string {
let out = String(s || '');
// Normalize diacritics and case
out = out
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
// Unify various dash characters to a simple hyphen
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
// Remove legal suffixes like ", z.s." / ", z. s." / " z.s." / "o.s." at end
out = out.replace(/[,,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
// Remove organization phrases/prefixes anywhere (keep core locality/name)
const orgPhrases = [
'fotbalovy klub',
'sportovni klub',
'telovychovna jednota',
'skolni sportovni klub',
'fotbal',
'futsal',
];
for (const phrase of orgPhrases) {
const re = new RegExp('(^|\\b)'+ phrase + '(\\b|$)', 'g');
out = out.replace(re, ' ');
}
// Remove common short prefixes (tokens) like FC, FK, MFK, TJ, SK, SFC, AFK at word boundaries
out = out.replace(/\b(1\.)?\s*(sfc|afc|fc|fk|mfk|tj|sk|afk)\b\.?/g, ' ');
// Remove punctuation except hyphen
out = out.replace(/[\.,!;:()\[\]{}]/g, ' ');
// Collapse multiple spaces and trim
out = out.replace(/\s+/g, ' ').trim();
return out;
}
// Derive FACR team UUID from the logo URL if team_id is missing in the row
// Example: https://is1.fotbal.cz/media/kluby/<UUID>/<UUID>_crop.jpg
function deriveTeamIdFromLogoUrl(url?: string): string | undefined {
try {
const u = String(url || '');
if (!u) return undefined;
const m = u.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/);
return m ? m[0].toLowerCase() : undefined;
} catch {
return undefined;
}
}
type TableRow = { type TableRow = {
rank?: string; rank?: string;
team?: string; team?: string;
@@ -82,6 +128,9 @@ const TeamsAdminPage = () => {
const mainClubBase: string = useMemo(() => normalize(String(data?.name || '')), [data?.name]); const mainClubBase: string = useMemo(() => normalize(String(data?.name || '')), [data?.name]);
// Backend origin (used to resolve relative URLs like /uploads/...) // Backend origin (used to resolve relative URLs like /uploads/...)
const backendOrigin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin; const backendOrigin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
const theadBg = useColorModeValue('gray.50', 'gray.700');
const rowHoverBg = useColorModeValue('gray.50', 'gray.700');
const searchBg = useColorModeValue('white', 'gray.800');
// Load public/admin overrides map to apply on cache-fed view // Load public/admin overrides map to apply on cache-fed view
const { data: overrides = {} } = useQuery({ const { data: overrides = {} } = useQuery({
@@ -120,38 +169,6 @@ const TeamsAdminPage = () => {
.catch((err) => console.error('Failed to fetch sport logos:', err)) .catch((err) => console.error('Failed to fetch sport logos:', err))
.finally(() => setSportLogosLoading(false)); .finally(() => setSportLogosLoading(false));
}, [competitions]); }, [competitions]);
const normalize = (s: string) => {
let out = String(s || '');
// Normalize diacritics and case
out = out
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
// Unify various dash characters to a simple hyphen
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
// Remove legal suffixes like ", z.s." / ", z. s." / " z.s." / "o.s." at end
out = out.replace(/[,,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
// Remove organization phrases/prefixes anywhere (keep core locality/name)
const orgPhrases = [
'fotbalovy klub',
'sportovni klub',
'telovychovna jednota',
'skolni sportovni klub',
'fotbal',
'futsal',
];
for (const phrase of orgPhrases) {
const re = new RegExp('(^|\\b)'+ phrase + '(\\b|$)', 'g');
out = out.replace(re, ' ');
}
// Remove common short prefixes (tokens) like FC, FK, MFK, TJ, SK, SFC, AFK at word boundaries
out = out.replace(/\b(1\.)?\s*(sfc|afc|fc|fk|mfk|tj|sk|afk)\b\.?/g, ' ');
// Remove punctuation except hyphen
out = out.replace(/[\.,!;:()\[\]{}]/g, ' ');
// Collapse multiple spaces and trim
out = out.replace(/\s+/g, ' ').trim();
return out;
};
const byName: Record<string, string> = (overrides as any)?.by_name || {}; const byName: Record<string, string> = (overrides as any)?.by_name || {};
const byNameNormalized = useMemo(() => { const byNameNormalized = useMemo(() => {
const idx: Record<string, string> = {}; const idx: Record<string, string> = {};
@@ -161,18 +178,6 @@ const TeamsAdminPage = () => {
return idx; return idx;
}, [byName]); }, [byName]);
// Derive FACR team UUID from the logo URL if team_id is missing in the row
// Example: https://is1.fotbal.cz/media/kluby/<UUID>/<UUID>_crop.jpg
const deriveTeamIdFromLogoUrl = (url?: string): string | undefined => {
try {
const u = String(url || '');
if (!u) return undefined;
const m = u.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/);
return m ? m[0].toLowerCase() : undefined;
} catch {
return undefined;
}
};
const getLogo = (teamName?: string, teamId?: string, original?: string) => { const getLogo = (teamName?: string, teamId?: string, original?: string) => {
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string; if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
// Priority 0: Admin override by team ID // Priority 0: Admin override by team ID
@@ -561,7 +566,7 @@ const TeamsAdminPage = () => {
}} }}
> >
<Table size="sm" variant="simple"> <Table size="sm" variant="simple">
<Thead bg={useColorModeValue('gray.50', 'gray.700')}> <Thead bg={theadBg}>
<Tr> <Tr>
<Th w="40px" fontSize="xs" py={2}>#</Th> <Th w="40px" fontSize="xs" py={2}>#</Th>
<Th fontSize="xs" py={2}>Tým</Th> <Th fontSize="xs" py={2}>Tým</Th>
@@ -576,7 +581,7 @@ const TeamsAdminPage = () => {
</Thead> </Thead>
<Tbody> <Tbody>
{rowsFiltered.map((r, idx) => ( {rowsFiltered.map((r, idx) => (
<Tr key={`${comp.id}-${idx}`} _hover={{ bg: useColorModeValue('gray.50', 'gray.700') }}> <Tr key={`${comp.id}-${idx}`} _hover={{ bg: rowHoverBg }}>
<Td py={1.5} fontSize="xs">{r.rank}</Td> <Td py={1.5} fontSize="xs">{r.rank}</Td>
<Td py={1.5}> <Td py={1.5}>
<HStack spacing={2} align="center"> <HStack spacing={2} align="center">
@@ -687,7 +692,7 @@ const TeamsAdminPage = () => {
{searchResults.length > 0 && ( {searchResults.length > 0 && (
<Box <Box
mt={4} mt={4}
bg={useColorModeValue('white', 'gray.800')} bg={searchBg}
borderWidth="1px" borderWidth="1px"
borderRadius="lg" borderRadius="lg"
overflowX="auto" overflowX="auto"
+19 -2
View File
@@ -87,7 +87,24 @@ export const forwardMessage = async (id: string, toEmail: string) => {
await api.post(`/admin/contact-messages/${id}/forward`, { to_email: toEmail }); await api.post(`/admin/contact-messages/${id}/forward`, { to_email: toEmail });
}; };
export const forwardAllMessages = async (toEmail: string) => { export const forwardAllMessages = async (
const response = await api.post('/admin/contact-messages/forward-all', { to_email: toEmail }); emails: string | string[],
options?: { saveDefault?: boolean }
) => {
let payload: any = {};
if (Array.isArray(emails)) {
const arr = emails.map((e) => String(e || '').trim()).filter(Boolean);
payload = arr.length > 1 ? { to_emails: arr } : { to_email: arr[0] || '' };
} else {
const parts = String(emails || '')
.split(/[;\,\s]+/)
.map((s) => s.trim())
.filter(Boolean);
payload = parts.length > 1 ? { to_emails: parts } : { to_email: parts[0] || '' };
}
if (options?.saveDefault) {
payload.save_default = true;
}
const response = await api.post('/admin/contact-messages/forward-all', payload);
return response.data; return response.data;
}; };
+38
View File
@@ -0,0 +1,38 @@
import api from './api';
export interface Banner {
id: number | string;
name: string;
image_url?: string;
click_url?: string;
placement?: string;
width?: number;
height?: number;
is_active?: boolean;
display_order?: number;
}
export async function getBanners(params?: { active?: boolean; placement?: string }): Promise<Banner[]> {
const res = await api.get<any>('/banners', { params });
const body = res.data;
const list = Array.isArray(body) ? body : (Array.isArray(body?.data) ? body.data : []);
return (list || []).map((b: any) => ({
...b,
id: b.id ?? b.ID ?? b.Id ?? b.iD,
}));
}
export async function createBanner(payload: { name: string; image_url?: string; click_url?: string; placement?: string; width?: number; height?: number; is_active?: boolean; display_order?: number }) {
const res = await api.post<Banner>('/banners', payload);
return res.data;
}
export async function updateBanner(id: number | string, payload: Partial<{ name: string; image_url?: string; click_url?: string; placement?: string; width?: number; height?: number; is_active?: boolean; display_order?: number }>) {
const res = await api.put<Banner>(`/banners/${id}`, payload);
return res.data;
}
export async function deleteBanner(id: number | string) {
const res = await api.delete<{ zprava: string }>(`/banners/${id}`);
return res.data;
}
+16 -4
View File
@@ -1,4 +1,5 @@
import api from './api'; import api from './api';
import { assetUrl } from '../utils/url';
export interface ImageProcessRequest { export interface ImageProcessRequest {
image_url: string; image_url: string;
@@ -44,7 +45,11 @@ export interface ImageProcessResponse {
*/ */
export const processImage = async (request: ImageProcessRequest): Promise<ImageProcessResponse> => { export const processImage = async (request: ImageProcessRequest): Promise<ImageProcessResponse> => {
const response = await api.post('/image-processing/process', request); const response = await api.post('/image-processing/process', request);
return response.data; const data = response.data || {};
if (data && typeof data.url === 'string') {
data.url = assetUrl(data.url) || data.url;
}
return data;
}; };
/** /**
@@ -52,7 +57,11 @@ export const processImage = async (request: ImageProcessRequest): Promise<ImageP
*/ */
export const quickEditImage = async (request: QuickEditRequest): Promise<ImageProcessResponse> => { export const quickEditImage = async (request: QuickEditRequest): Promise<ImageProcessResponse> => {
const response = await api.post('/image-processing/quick-edit', request); const response = await api.post('/image-processing/quick-edit', request);
return response.data; const data = response.data || {};
if (data && typeof data.url === 'string') {
data.url = assetUrl(data.url) || data.url;
}
return data;
}; };
/** /**
@@ -79,8 +88,11 @@ export const cropAndUpload = async (
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
}); });
const data = response.data || {};
return response.data; if (data && typeof data.url === 'string') {
data.url = assetUrl(data.url) || data.url;
}
return data;
}; };
/** /**
+14 -37
View File
@@ -1,5 +1,4 @@
import axios from 'axios'; import api, { API_URL as API_BASE_URL } from './api';
import { API_URL as API_BASE_URL } from './api';
export interface NavigationItem { export interface NavigationItem {
id?: number; id?: number;
@@ -30,86 +29,64 @@ export interface SocialLink {
// Public endpoints // Public endpoints
export const getNavigationItems = async (): Promise<NavigationItem[]> => { export const getNavigationItems = async (): Promise<NavigationItem[]> => {
const response = await axios.get(`${API_BASE_URL}/navigation`); const response = await api.get(`/navigation`);
return response.data; return response.data;
}; };
export const getSocialLinks = async (): Promise<SocialLink[]> => { export const getSocialLinks = async (): Promise<SocialLink[]> => {
const response = await axios.get(`${API_BASE_URL}/social-links`); const response = await api.get(`/social-links`);
return response.data; return response.data;
}; };
// Admin endpoints // Admin endpoints
export const getAllNavigationItems = async (): Promise<NavigationItem[]> => { export const getAllNavigationItems = async (): Promise<NavigationItem[]> => {
const response = await axios.get(`${API_BASE_URL}/admin/navigation`, { const response = await api.get(`/admin/navigation`);
withCredentials: true,
});
return response.data; return response.data;
}; };
export const createNavigationItem = async (item: Partial<NavigationItem>): Promise<NavigationItem> => { export const createNavigationItem = async (item: Partial<NavigationItem>): Promise<NavigationItem> => {
const response = await axios.post(`${API_BASE_URL}/admin/navigation`, item, { const response = await api.post(`/admin/navigation`, item);
withCredentials: true,
});
return response.data; return response.data;
}; };
export const updateNavigationItem = async (id: number, item: Partial<NavigationItem>): Promise<NavigationItem> => { export const updateNavigationItem = async (id: number, item: Partial<NavigationItem>): Promise<NavigationItem> => {
const response = await axios.put(`${API_BASE_URL}/admin/navigation/${id}`, item, { const response = await api.put(`/admin/navigation/${id}`, item);
withCredentials: true,
});
return response.data; return response.data;
}; };
export const deleteNavigationItem = async (id: number): Promise<void> => { export const deleteNavigationItem = async (id: number): Promise<void> => {
await axios.delete(`${API_BASE_URL}/admin/navigation/${id}`, { await api.delete(`/admin/navigation/${id}`);
withCredentials: true,
});
}; };
export const reorderNavigationItems = async (orders: { id: number; display_order: number }[]): Promise<void> => { export const reorderNavigationItems = async (orders: { id: number; display_order: number }[]): Promise<void> => {
await axios.post(`${API_BASE_URL}/admin/navigation/reorder`, orders, { await api.post(`/admin/navigation/reorder`, orders);
withCredentials: true,
});
}; };
// Social links admin endpoints // Social links admin endpoints
export const getAllSocialLinks = async (): Promise<SocialLink[]> => { export const getAllSocialLinks = async (): Promise<SocialLink[]> => {
const response = await axios.get(`${API_BASE_URL}/admin/social-links`, { const response = await api.get(`/admin/social-links`);
withCredentials: true,
});
return response.data; return response.data;
}; };
export const createSocialLink = async (link: Partial<SocialLink>): Promise<SocialLink> => { export const createSocialLink = async (link: Partial<SocialLink>): Promise<SocialLink> => {
const response = await axios.post(`${API_BASE_URL}/admin/social-links`, link, { const response = await api.post(`/admin/social-links`, link);
withCredentials: true,
});
return response.data; return response.data;
}; };
export const updateSocialLink = async (id: number, link: Partial<SocialLink>): Promise<SocialLink> => { export const updateSocialLink = async (id: number, link: Partial<SocialLink>): Promise<SocialLink> => {
const response = await axios.put(`${API_BASE_URL}/admin/social-links/${id}`, link, { const response = await api.put(`/admin/social-links/${id}`, link);
withCredentials: true,
});
return response.data; return response.data;
}; };
export const deleteSocialLink = async (id: number): Promise<void> => { export const deleteSocialLink = async (id: number): Promise<void> => {
await axios.delete(`${API_BASE_URL}/admin/social-links/${id}`, { await api.delete(`/admin/social-links/${id}`);
withCredentials: true,
});
}; };
export const reorderSocialLinks = async (orders: { id: number; display_order: number }[]): Promise<void> => { export const reorderSocialLinks = async (orders: { id: number; display_order: number }[]): Promise<void> => {
await axios.post(`${API_BASE_URL}/admin/social-links/reorder`, orders, { await api.post(`/admin/social-links/reorder`, orders);
withCredentials: true,
});
}; };
export const seedDefaultNavigation = async (): Promise<{ message: string; count: number; seeded: boolean }> => { export const seedDefaultNavigation = async (): Promise<{ message: string; count: number; seeded: boolean }> => {
const response = await axios.post(`${API_BASE_URL}/admin/navigation/seed`, {}, { const response = await api.post(`/admin/navigation/seed`, {});
withCredentials: true,
});
return response.data; return response.data;
}; };
+4 -2
View File
@@ -116,14 +116,14 @@ export const PREDEFINED_ELEMENTS: PredefinedElement[] = [
// Layout - Rozvržení // Layout - Rozvržení
{ name: 'style-pack', label: 'Styl balíček', description: 'Globální vizuální balíček pro celou stránku', icon: FaCube, category: 'layout', defaultVariant: 'default' }, { name: 'style-pack', label: 'Styl balíček', description: 'Globální vizuální balíček pro celou stránku', icon: FaCube, category: 'layout', defaultVariant: 'default' },
{ name: 'header', label: 'Hlavička', description: 'Hlavička stránky s logem a navigací', icon: FaRegClipboard, category: 'layout', defaultVariant: 'unified' }, { name: 'header', label: 'Hlavička', description: 'Hlavička stránky s logem a navigací', icon: FaRegClipboard, category: 'layout', defaultVariant: 'unified' },
{ name: 'hero-topbar', label: 'Klub lišta nad hero', description: 'Pruh nad hero s logem klubu, názvem a akcemi', icon: FaCube, category: 'layout', defaultVariant: 'brand' }, { name: 'hero-topbar', label: 'Klub lišta nad hero', description: 'Pruh nad hero s logem klubu, názvem a akcemi', icon: FaCube, category: 'layout', defaultVariant: 'minimal' },
{ name: 'hero', label: 'Hlavní Sekce', description: 'Hlavní obsahová oblast s úvodním obsahem', icon: FaBullseye, category: 'layout', defaultVariant: 'grid' }, { name: 'hero', label: 'Hlavní Sekce', description: 'Hlavní obsahová oblast s úvodním obsahem', icon: FaBullseye, category: 'layout', defaultVariant: 'grid' },
{ name: 'footer', label: 'Patička', description: 'Spodní část stránky s odkazy a kontakty', icon: FaMapSigns, category: 'layout', defaultVariant: 'standard' }, { name: 'footer', label: 'Patička', description: 'Spodní část stránky s odkazy a kontakty', icon: FaMapSigns, category: 'layout', defaultVariant: 'standard' },
{ name: 'sidebar', label: 'Boční Panel', description: 'Boční sloupec s doplňkovým obsahem', icon: FaColumns, category: 'layout', defaultVariant: 'right' }, { name: 'sidebar', label: 'Boční Panel', description: 'Boční sloupec s doplňkovým obsahem', icon: FaColumns, category: 'layout', defaultVariant: 'right' },
{ name: 'banner', label: 'Banner', description: 'Reklamní nebo informační banner', icon: FaFlag, category: 'layout', defaultVariant: 'top' }, { name: 'banner', label: 'Banner', description: 'Reklamní nebo informační banner', icon: FaFlag, category: 'layout', defaultVariant: 'top' },
// Content - Obsah // Content - Obsah
{ name: 'news', label: 'Novinky', description: 'Nejnovější články a zprávy', icon: FaNewspaper, category: 'content', defaultVariant: 'grid' }, { name: 'news', label: 'Novinky', description: 'Nejnovější články a zprávy', icon: FaNewspaper, category: 'content', defaultVariant: 'grid_one' },
{ name: 'matches', label: 'Zápasy', description: 'Nadcházející a poslední zápasy', icon: FaFutbol, category: 'content', defaultVariant: 'compact' }, { name: 'matches', label: 'Zápasy', description: 'Nadcházející a poslední zápasy', icon: FaFutbol, category: 'content', defaultVariant: 'compact' },
{ name: 'matches-slider', label: 'Zápasy (slider)', description: 'Přehled zápasů podle soutěže ve slideru', icon: FaFutbol, category: 'content', defaultVariant: 'carousel' }, { name: 'matches-slider', label: 'Zápasy (slider)', description: 'Přehled zápasů podle soutěže ve slideru', icon: FaFutbol, category: 'content', defaultVariant: 'carousel' },
{ name: 'team', label: 'Tým', description: 'Hráči a realizační tým', icon: FaUsers, category: 'content', defaultVariant: 'grid' }, { name: 'team', label: 'Tým', description: 'Hráči a realizační tým', icon: FaUsers, category: 'content', defaultVariant: 'grid' },
@@ -190,6 +190,8 @@ export const ELEMENT_VARIANTS: Record<string, ElementVariant[]> = {
{ value: 'sparta_featured_carousel', label: 'Sparta Featured Carousel', description: 'Hero header s pozadím, článek s kategoriemi, thumbnail navigace, auto-swap' }, { value: 'sparta_featured_carousel', label: 'Sparta Featured Carousel', description: 'Hero header s pozadím, článek s kategoriemi, thumbnail navigace, auto-swap' },
], ],
news: [ news: [
{ value: 'grid_one', label: 'Mřížka (1 sloupec)', description: 'Jednosloupcová mřížka bez tabulek vpravo (skryje sekci Tabulky)' },
{ value: 'grid_two', label: 'Mřížka (2 sloupce)', description: 'Aktuality vlevo a Tabulky vpravo (pokud jsou k dispozici)' },
{ value: 'grid', label: 'Mřížka', description: 'Rozložení karet v mřížce' }, { value: 'grid', label: 'Mřížka', description: 'Rozložení karet v mřížce' },
{ value: 'scroller', label: 'Posuvník', description: 'Horizontální posuvník' }, { value: 'scroller', label: 'Posuvník', description: 'Horizontální posuvník' },
{ value: 'hero_carousel', label: 'Hero Karusel', description: 'Jeden článek najednou. Tlačítko: ZJISTIT VÍCE (vlevo dole). Numerace: 01 02 03 (vpravo dole). Auto-swap' }, { value: 'hero_carousel', label: 'Hero Karusel', description: 'Jeden článek najednou. Tlačítko: ZJISTIT VÍCE (vlevo dole). Numerace: 01 02 03 (vpravo dole). Auto-swap' },
+2
View File
@@ -116,6 +116,8 @@ export type AdminSettings = PublicSettings & {
location_longitude?: number; location_longitude?: number;
map_zoom_level?: number; map_zoom_level?: number;
show_map_on_homepage?: boolean; show_map_on_homepage?: boolean;
frontend_base_url?: string;
api_base_url?: string;
// Homepage matches display configuration // Homepage matches display configuration
finished_match_display_days?: number; // Number of days to show finished matches with scores on homepage finished_match_display_days?: number; // Number of days to show finished matches with scores on homepage
}; };
+3
View File
@@ -15,6 +15,9 @@ export type SetupInitializePayload = {
club_name?: string; club_name?: string;
club_logo_url?: string; club_logo_url?: string;
club_url?: string; club_url?: string;
// deployment bases
frontend_base_url?: string;
api_base_url?: string;
frontpage_style?: 'unified' | 'magazine' | 'pro' | 'edge'; frontpage_style?: 'unified' | 'magazine' | 'pro' | 'edge';
primary_color?: string; primary_color?: string;
secondary_color?: string; secondary_color?: string;
+1 -1
View File
@@ -258,4 +258,4 @@ body.style-pack-modern [data-element="team"] .player-card {
[data-element="poll"] .card { border-radius: 12px; border: 1px solid var(--card-border, rgba(0,0,0,0.08)); } [data-element="poll"] .card { border-radius: 12px; border: 1px solid var(--card-border, rgba(0,0,0,0.08)); }
/* Newsletter */ /* Newsletter */
[data-element="newsletter"] .card { border-radius: 12px; border: 1px solid var(--card-border, rgba(0,0,0,0.08)); background: var(--card-bg, #fff); } [data-element="newsletter"] .card { border-radius: var(--pack-radius, 12px); }
+3 -11
View File
@@ -1,18 +1,10 @@
import { API_URL } from '../services/api'; import { assetUrl } from './url';
/** /**
* Get the full URL for an image, handling both absolute and relative URLs * Get the full URL for an image, handling both absolute and relative URLs
*/ */
export const getImageUrl = (imageUrl: string | undefined | null): string | undefined => { export const getImageUrl = (imageUrl: string | undefined | null): string | undefined => {
if (!imageUrl) return undefined; if (!imageUrl) return undefined;
const url = assetUrl(imageUrl);
// If already an absolute URL, return as-is return url || imageUrl || undefined;
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
return imageUrl;
}
// Otherwise, prepend the backend base URL
const backendUrl = API_URL.replace('/api/v1', '');
const path = imageUrl.startsWith('/') ? imageUrl : `/${imageUrl}`;
return `${backendUrl}${path}`;
}; };
+61 -23
View File
@@ -1,31 +1,69 @@
// Utility to resolve asset URLs. If the input starts with /uploads, resolve it against the API base URL import { API_URL } from '../services/api';
// so that in development the images are loaded from the backend (e.g., http://localhost:8080/uploads/...)
// and in production it resolves relative to the deployed backend origin. // Prefer explicit asset base when provided (e.g., http://127.0.0.1:8080)
const ASSET_BASE = process.env.REACT_APP_ASSET_BASE_URL;
const API_BASE = process.env.REACT_APP_API_BASE_URL;
// Compute backend origin from API_URL (works with absolute or relative '/api/v1').
export function getBackendOrigin(): string {
try {
if (typeof window !== 'undefined') {
const host = window.location.hostname;
const port = window.location.port;
if (/^(localhost|127\.0\.0\.1)$/i.test(host) && port === '3000') {
return `${window.location.protocol}//${host}:8080`;
}
}
// 1) If REACT_APP_ASSET_BASE_URL is set, use its origin
if (ASSET_BASE && ASSET_BASE.trim() !== '') {
const u = new URL(ASSET_BASE, typeof window !== 'undefined' ? window.location.origin : 'http://localhost');
const winHost = typeof window !== 'undefined' ? window.location.hostname : '';
if ((/^(localhost|127\.0\.0\.1)$/i.test(u.hostname)) && winHost && !/^(localhost|127\.0\.0\.1)$/i.test(winHost)) {
return typeof window !== 'undefined' ? window.location.origin : u.origin;
}
return u.origin;
}
// 2) Derive from API base or API_URL (works with absolute or relative '/api/v1')
const src = (API_BASE && API_BASE.trim() !== '') ? API_BASE : API_URL;
const base = new URL(src, typeof window !== 'undefined' ? window.location.origin : 'http://localhost');
if ((/^(localhost|127\.0\.0\.1)$/i.test(base.hostname)) && typeof window !== 'undefined' && window.location.port === '3000') {
return `${base.protocol}//${base.hostname}:8080`;
}
return base.origin;
} catch {
return typeof window !== 'undefined' ? window.location.origin : '';
}
}
// Utility to resolve asset URLs robustly.
// - For '/uploads' and '/dist' paths, always prefix with backend origin
// - For absolute URLs pointing to localhost/127.0.0.1, rewrite origin to backend origin if the path is '/uploads' or '/dist'
// - For data: URIs, return as-is
export function assetUrl(pathOrUrl?: string | null): string | undefined { export function assetUrl(pathOrUrl?: string | null): string | undefined {
if (!pathOrUrl) return undefined; if (!pathOrUrl) return undefined;
try { try {
// If already absolute (http/https/data), return as-is const val = String(pathOrUrl);
if (/^(?:https?:)?\/\//i.test(pathOrUrl) || /^data:/i.test(pathOrUrl)) { const backendOrigin = getBackendOrigin();
return pathOrUrl; // data URI
} if (/^data:/i.test(val)) return val;
// Known backend-served asset paths (/uploads, optionally /dist)
if (pathOrUrl.startsWith('/uploads') || pathOrUrl.startsWith('/dist')) { // Absolute URL
const explicit = process.env.REACT_APP_ASSET_BASE_URL || process.env.REACT_APP_API_BASE_URL || ''; if (/^(?:https?:)?\/\//i.test(val)) {
if (explicit && !explicit.startsWith('/')) { const u = new URL(val, window.location.origin);
const baseUrl = new URL(explicit, typeof window !== 'undefined' ? window.location.origin : undefined); const isLocalHost = /^(localhost|127\.0\.0\.1)(:\d+)?$/i.test(u.host);
baseUrl.pathname = '/'; const isBackendAsset = u.pathname.startsWith('/uploads') || u.pathname.startsWith('/dist');
return new URL(pathOrUrl, baseUrl).toString(); if (isLocalHost && isBackendAsset) {
return new URL(u.pathname + u.search + u.hash, backendOrigin + '/').toString();
} }
if (process.env.NODE_ENV !== 'production') { return u.toString();
try {
const devOrigin = 'http://127.0.0.1:8080';
return new URL(pathOrUrl, devOrigin).toString();
} catch {}
}
return pathOrUrl;
} }
// Otherwise return as-is (relative or other paths)
return pathOrUrl; // Relative URL
if (val.startsWith('/uploads') || val.startsWith('/dist')) {
return new URL(val, backendOrigin + '/').toString();
}
// Otherwise leave as-is (component may resolve relative to current origin)
return val;
} catch { } catch {
return pathOrUrl || undefined; return pathOrUrl || undefined;
} }
+272 -18
View File
@@ -2266,8 +2266,6 @@ func (bc *BaseController) PatchTeamLogoOverride(c *gin.Context) {
c.JSON(http.StatusOK, item) c.JSON(http.StatusOK, item)
} }
// ProxyImage streams a remote image to the client to avoid browser CORS restrictions for Canvas operations
// GET /api/v1/proxy/image?url=<remote_image_url>
func (bc *BaseController) ProxyImage(c *gin.Context) { func (bc *BaseController) ProxyImage(c *gin.Context) {
raw := c.Query("url") raw := c.Query("url")
if raw == "" { if raw == "" {
@@ -2287,8 +2285,15 @@ func (bc *BaseController) ProxyImage(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "request init failed"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "request init failed"})
return return
} }
// Some CDNs require a UA // Use realistic browser headers - some CDNs block unknown clients
req.Header.Set("User-Agent", "fotbal-club/1.0 (+https://localhost)") req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36")
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
req.Header.Set("Accept-Language", "cs-CZ,cs;q=0.9,en;q=0.8")
// Set a benign referer tied to the target host to satisfy anti-hotlink checks
if u.Host != "" {
ref := u.Scheme + "://" + u.Host + "/"
req.Header.Set("Referer", ref)
}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "fetch failed"}) c.JSON(http.StatusBadGateway, gin.H{"error": "fetch failed"})
@@ -2366,6 +2371,10 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
ClubLogoURL string `json:"club_logo_url"` ClubLogoURL string `json:"club_logo_url"`
ClubURL string `json:"club_url"` ClubURL string `json:"club_url"`
// Optional bases
FrontendBaseURL string `json:"frontend_base_url"`
APIBaseURL string `json:"api_base_url"`
// Social profiles (optional) // Social profiles (optional)
FacebookURL string `json:"facebook_url"` FacebookURL string `json:"facebook_url"`
InstagramURL string `json:"instagram_url"` InstagramURL string `json:"instagram_url"`
@@ -2473,6 +2482,45 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
if body.FrontpageStyle != "" { if body.FrontpageStyle != "" {
s.FrontpageStyle = body.FrontpageStyle s.FrontpageStyle = body.FrontpageStyle
} }
// Detect and persist base URLs
if v := strings.TrimSpace(body.APIBaseURL); v != "" {
s.APIBaseURL = v
}
if v := strings.TrimSpace(body.FrontendBaseURL); v != "" {
s.FrontendBaseURL = v
}
// If not provided, infer from current request and proxy headers
{
scheme := "http"
if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") {
scheme = "https"
}
host := c.Request.Host
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
parts := strings.Split(xf, ",")
if len(parts) > 0 {
if h := strings.TrimSpace(parts[0]); h != "" { host = h }
}
}
if !strings.Contains(host, ":") {
if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" {
if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") {
host = host + ":" + xfp
}
}
}
if strings.TrimSpace(s.APIBaseURL) == "" {
s.APIBaseURL = scheme + "://" + host + "/api/v1"
}
if strings.TrimSpace(s.FrontendBaseURL) == "" {
if origin := strings.TrimSpace(c.Request.Header.Get("Origin")); origin != "" {
s.FrontendBaseURL = origin
} else {
s.FrontendBaseURL = scheme + "://" + host
}
}
}
// SMTP overrides from initial setup // SMTP overrides from initial setup
if body.SMTP != nil { if body.SMTP != nil {
if v := strings.TrimSpace(body.SMTP.Host); v != "" { if v := strings.TrimSpace(body.SMTP.Host); v != "" {
@@ -2594,7 +2642,11 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
_ = os.WriteFile(tmp, b, 0o644) _ = os.WriteFile(tmp, b, 0o644)
_ = os.Rename(tmp, outPath) _ = os.Rename(tmp, outPath)
}(s) }(s)
go services.PrefetchOnce(getPrefetchBaseURL()) {
base := strings.TrimSpace(s.APIBaseURL)
if base == "" { base = getPrefetchBaseURL() }
go services.PrefetchOnce(strings.TrimRight(base, "/"))
}
if strings.TrimSpace(s.YoutubeURL) != "" { if strings.TrimSpace(s.YoutubeURL) != "" {
go func(u string) { _ = services.RefreshYouTubeChannelNow(u) }(s.YoutubeURL) go func(u string) { _ = services.RefreshYouTubeChannelNow(u) }(s.YoutubeURL)
} }
@@ -2603,7 +2655,7 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
go func(link string) { _ = services.RefreshZoneramaNow(link) }(g) go func(link string) { _ = services.RefreshZoneramaNow(link) }(g)
} }
c.JSON(http.StatusOK, gin.H{"message": "Inicializace již byla provedena"}) c.JSON(http.StatusOK, gin.H{"message": "Inicializace již byla provedena", "frontend_base_url": s.FrontendBaseURL, "api_base_url": s.APIBaseURL})
return return
} }
@@ -2666,6 +2718,10 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
From string `json:"from"` From string `json:"from"`
UseTLS *bool `json:"use_tls"` UseTLS *bool `json:"use_tls"`
} `json:"smtp"` } `json:"smtp"`
// Optional bases
FrontendBaseURL string `json:"frontend_base_url"`
APIBaseURL string `json:"api_base_url"`
} }
var body reqBody var body reqBody
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
@@ -2806,6 +2862,44 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
if body.FrontpageStyle != "" { if body.FrontpageStyle != "" {
s.FrontpageStyle = body.FrontpageStyle s.FrontpageStyle = body.FrontpageStyle
} }
// Persist base URLs (prefer request body, otherwise infer)
if v := strings.TrimSpace(body.APIBaseURL); v != "" {
s.APIBaseURL = v
}
if v := strings.TrimSpace(body.FrontendBaseURL); v != "" {
s.FrontendBaseURL = v
}
{
scheme := "http"
if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") {
scheme = "https"
}
host := c.Request.Host
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
parts := strings.Split(xf, ",")
if len(parts) > 0 {
if h := strings.TrimSpace(parts[0]); h != "" { host = h }
}
}
if !strings.Contains(host, ":") {
if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" {
if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") {
host = host + ":" + xfp
}
}
}
if strings.TrimSpace(s.APIBaseURL) == "" {
s.APIBaseURL = scheme + "://" + host + "/api/v1"
}
if strings.TrimSpace(s.FrontendBaseURL) == "" {
if origin := strings.TrimSpace(c.Request.Header.Get("Origin")); origin != "" {
s.FrontendBaseURL = origin
} else {
s.FrontendBaseURL = scheme + "://" + host
}
}
}
// SMTP overrides from initial setup // SMTP overrides from initial setup
if body.SMTP != nil { if body.SMTP != nil {
if v := strings.TrimSpace(body.SMTP.Host); v != "" { if v := strings.TrimSpace(body.SMTP.Host); v != "" {
@@ -2940,12 +3034,13 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
logger.Info("Starting initial data prefetch and setup operations in background...") logger.Info("Starting initial data prefetch and setup operations in background...")
// Run all setup operations in a single background goroutine // Run all setup operations in a single background goroutine
go func(settingsID uint, youtubeURL, galleryURL, adminEmail string) { go func(settingsID uint, youtubeURL, galleryURL, adminEmail string, apiBase string) {
defer func() { _ = recover() }() defer func() { _ = recover() }()
// 1. Trigger prefetch (matches, standings, etc.) // 1. Trigger prefetch (matches, standings, etc.)
baseURL := getPrefetchBaseURL() baseURL := strings.TrimSpace(apiBase)
services.PrefetchOnce(baseURL) if baseURL == "" { baseURL = getPrefetchBaseURL() }
services.PrefetchOnce(strings.TrimRight(baseURL, "/"))
logger.Info("Background prefetch completed") logger.Info("Background prefetch completed")
// Auto-populate competition aliases from FACR data // Auto-populate competition aliases from FACR data
@@ -2984,10 +3079,10 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
} }
logger.Info("All background setup operations completed") logger.Info("All background setup operations completed")
}(s.ID, s.YoutubeURL, s.GalleryURL, admin.Email) }(s.ID, s.YoutubeURL, s.GalleryURL, admin.Email, s.APIBaseURL)
logger.Info("SetupInitialize finished successfully - background operations running") logger.Info("SetupInitialize finished successfully - background operations running")
c.JSON(http.StatusOK, gin.H{"message": "Setup completed successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Setup completed successfully", "frontend_base_url": s.FrontendBaseURL, "api_base_url": s.APIBaseURL})
} }
// UpdateSettings updates settings (upsert singleton) // UpdateSettings updates settings (upsert singleton)
@@ -3103,6 +3198,10 @@ func (bc *BaseController) UpdateSettings(c *gin.Context) {
// Homepage matches display configuration // Homepage matches display configuration
FinishedMatchDisplayDays *int `json:"finished_match_display_days"` FinishedMatchDisplayDays *int `json:"finished_match_display_days"`
// Deployment base URLs (optional, for domain/IP change)
FrontendBaseURL *string `json:"frontend_base_url"`
APIBaseURL *string `json:"api_base_url"`
} }
var body reqBody var body reqBody
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
@@ -3404,6 +3503,14 @@ func (bc *BaseController) UpdateSettings(c *gin.Context) {
s.FinishedMatchDisplayDays = *body.FinishedMatchDisplayDays s.FinishedMatchDisplayDays = *body.FinishedMatchDisplayDays
} }
// Deployment base URLs
if body.FrontendBaseURL != nil {
s.FrontendBaseURL = strings.TrimSpace(*body.FrontendBaseURL)
}
if body.APIBaseURL != nil {
s.APIBaseURL = strings.TrimSpace(*body.APIBaseURL)
}
if s.ID == 0 { if s.ID == 0 {
if err := bc.DB.Create(&s).Error; err != nil { if err := bc.DB.Create(&s).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit nastavení"}) c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit nastavení"})
@@ -3425,10 +3532,11 @@ func (bc *BaseController) UpdateSettings(c *gin.Context) {
} }
logger.Info("UpdateSettings saved: club_id=%s club_name=%s gallery_url=%s gallery_label=%s", s.ClubID, s.ClubName, s.GalleryURL, s.GalleryLabel) logger.Info("UpdateSettings saved: club_id=%s club_name=%s gallery_url=%s gallery_label=%s", s.ClubID, s.ClubName, s.GalleryURL, s.GalleryLabel)
// Best-effort: trigger prefetch so cached settings.json and dependent files update immediately // Best-effort: trigger prefetch so cached settings.json and dependent files update immediately
go func() { go func(urlFromSettings string) {
base := getPrefetchBaseURL() base := strings.TrimSpace(urlFromSettings)
services.PrefetchOnce(base) if base == "" { base = getPrefetchBaseURL() }
}() services.PrefetchOnce(strings.TrimRight(base, "/"))
}(s.APIBaseURL)
// If gallery_url is a Zonerama link, refresh Zonerama cache immediately // If gallery_url is a Zonerama link, refresh Zonerama cache immediately
if g := strings.TrimSpace(s.GalleryURL); g != "" && strings.Contains(strings.ToLower(g), "zonerama.com") { if g := strings.TrimSpace(s.GalleryURL); g != "" && strings.Contains(strings.ToLower(g), "zonerama.com") {
go func(link string) { _ = services.RefreshZoneramaNow(link) }(g) go func(link string) { _ = services.RefreshZoneramaNow(link) }(g)
@@ -3569,6 +3677,9 @@ func (bc *BaseController) GetPublicSettings(c *gin.Context) {
"map_zoom_level": s.MapZoomLevel, "map_zoom_level": s.MapZoomLevel,
"map_style": s.MapStyle, "map_style": s.MapStyle,
"show_map_on_homepage": s.ShowMapOnHomepage, "show_map_on_homepage": s.ShowMapOnHomepage,
// Deployment base URLs (hints for frontend tooling)
"frontend_base_url": s.FrontendBaseURL,
"api_base_url": s.APIBaseURL,
} }
logger.Debug("GetPublicSettings response includes gallery: url=%s label=%s", s.GalleryURL, s.GalleryLabel) logger.Debug("GetPublicSettings response includes gallery: url=%s label=%s", s.GalleryURL, s.GalleryLabel)
c.JSON(http.StatusOK, resp) c.JSON(http.StatusOK, resp)
@@ -4087,6 +4198,127 @@ func (bc *BaseController) DeleteSponsor(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"zprava": "Sponzor byl smazán"}) c.JSON(http.StatusOK, gin.H{"zprava": "Sponzor byl smazán"})
} }
// Banners (separate from sponsors)
func (bc *BaseController) GetBanners(c *gin.Context) {
var items []models.Banner
q := bc.DB.Model(&models.Banner{})
activeOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("active", "true"))) != "false"
if activeOnly {
q = q.Where("is_active = ?", true)
}
if p := strings.TrimSpace(c.Query("placement")); p != "" {
q = q.Where("placement = ?", p)
}
if err := q.Order("display_order ASC, created_at ASC").Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
c.JSON(http.StatusOK, items)
}
func (bc *BaseController) CreateBanner(c *gin.Context) {
var body struct {
Name string `json:"name" binding:"required"`
ImageURL string `json:"image_url"`
ClickURL string `json:"click_url"`
Placement string `json:"placement"`
Width *int `json:"width"`
Height *int `json:"height"`
IsActive *bool `json:"is_active"`
DisplayOrder *int `json:"display_order"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název banneru je povinný"})
return
}
item := models.Banner{
Name: name,
ImageURL: strings.TrimSpace(body.ImageURL),
ClickURL: strings.TrimSpace(body.ClickURL),
Placement: strings.TrimSpace(body.Placement),
IsActive: true,
}
if body.Width != nil { item.Width = *body.Width }
if body.Height != nil { item.Height = *body.Height }
if body.DisplayOrder != nil { item.DisplayOrder = *body.DisplayOrder }
if body.IsActive != nil { item.IsActive = *body.IsActive }
if err := bc.DB.Create(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit banner"})
return
}
c.JSON(http.StatusCreated, item)
}
func (bc *BaseController) UpdateBanner(c *gin.Context) {
id := c.Param("id")
var item models.Banner
if err := bc.DB.First(&item, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"chyba": "Banner nenalezen"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
var body struct {
Name *string `json:"name"`
ImageURL *string `json:"image_url"`
ClickURL *string `json:"click_url"`
Placement *string `json:"placement"`
Width *int `json:"width"`
Height *int `json:"height"`
IsActive *bool `json:"is_active"`
DisplayOrder *int `json:"display_order"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
return
}
if body.Name != nil {
v := strings.TrimSpace(*body.Name)
if v == "" {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název banneru nemůže být prázdný"})
return
}
item.Name = v
}
if body.ImageURL != nil { item.ImageURL = strings.TrimSpace(*body.ImageURL) }
if body.ClickURL != nil { item.ClickURL = strings.TrimSpace(*body.ClickURL) }
if body.Placement != nil { item.Placement = strings.TrimSpace(*body.Placement) }
if body.Width != nil { item.Width = *body.Width }
if body.Height != nil { item.Height = *body.Height }
if body.IsActive != nil { item.IsActive = *body.IsActive }
if body.DisplayOrder != nil { item.DisplayOrder = *body.DisplayOrder }
if err := bc.DB.Save(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat banner"})
return
}
c.JSON(http.StatusOK, item)
}
func (bc *BaseController) DeleteBanner(c *gin.Context) {
id := c.Param("id")
var item models.Banner
if err := bc.DB.First(&item, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"chyba": "Banner nenalezen"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
if err := bc.DB.Delete(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat banner"})
return
}
c.JSON(http.StatusOK, gin.H{"zprava": "Banner byl smazán"})
}
func (bc *BaseController) UploadImage(c *gin.Context) { func (bc *BaseController) UploadImage(c *gin.Context) {
f, err := c.FormFile("file") f, err := c.FormFile("file")
if err != nil { if err != nil {
@@ -4150,15 +4382,37 @@ func (bc *BaseController) UploadImage(c *gin.Context) {
// Build absolute URL from request (supports proxies) // Build absolute URL from request (supports proxies)
scheme := "http" scheme := "http"
if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") { if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") {
scheme = "https" scheme = "https"
} }
host := c.Request.Host host := c.Request.Host
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" { if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
host = xf // Take the first value if comma-separated
parts := strings.Split(xf, ",")
if len(parts) > 0 {
h := strings.TrimSpace(parts[0])
if h != "" { host = h }
}
}
// Append forwarded port when host has no explicit port and it's non-default
if !strings.Contains(host, ":") {
if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" {
if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") {
host = host + ":" + xfp
}
}
} }
absolute := scheme + "://" + host + urlPath absolute := scheme + "://" + host + urlPath
c.JSON(http.StatusOK, gin.H{"url": absolute}) c.JSON(http.StatusOK, gin.H{
// Always return a backend-relative path for storage
"url": urlPath,
// Convenience absolute URL for immediate usage in UIs
"absolute_url": absolute,
// Basic metadata (best-effort)
"name": outName,
"type": mimeType,
"size": f.Size,
})
} }
// Global newsletter automation instance (set from main) // Global newsletter automation instance (set from main)
+83 -25
View File
@@ -1099,15 +1099,15 @@ func (cc *ContactController) ForwardContactMessage(c *gin.Context) {
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"})
} else { } else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch message"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
} }
return return
} }
// Prepare email data for forwarding // Prepare email data for forwarding (Czech subject)
forwardData := &email.EmailData{ forwardData := &email.EmailData{
Subject: fmt.Sprintf("Fwd: Contact Form - %s", message.Subject), Subject: fmt.Sprintf("Přeposláno: Kontaktní formulář - %s", message.Subject),
To: []string{input.ToEmail}, To: []string{input.ToEmail},
Template: "contact_form", Template: "contact_form",
Data: struct { Data: struct {
Name string Name string
@@ -1128,26 +1128,21 @@ func (cc *ContactController) ForwardContactMessage(c *gin.Context) {
}, },
} }
// Send email asynchronously if err := cc.emailService.SendEmail(forwardData); err != nil {
go func() { logger.Error("Failed to forward contact message %d to %s: %v", message.ID, input.ToEmail, err)
if err := cc.emailService.SendEmail(forwardData); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to forward message"})
logger.Error("Failed to forward contact message %d to %s: %v", id, input.ToEmail, err) return
} else { }
logger.Info("Contact message %d forwarded to %s", id, input.ToEmail) c.JSON(http.StatusOK, gin.H{"message": "Message forwarded"})
}
}()
c.JSON(http.StatusOK, gin.H{"message": "Message is being forwarded to " + input.ToEmail})
} }
// ForwardAllContactMessages forwards all contact messages to a specified email (admin only)
// @Summary Forward all contact messages // @Summary Forward all contact messages
// @Description Forwards all contact messages to a specified email address (admin only) // @Description Forwards all contact messages to a specified email address (admin only)
// @Tags admin // @Tags admin
// @Security Bearer // @Security Bearer
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param input body map[string]string true "{ to_email: string }" // @Param input body map[string]string true "{ to_email: string, to_emails: []string, save_default: bool }"
// @Success 200 {object} map[string]string // @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string // @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string // @Failure 401 {object} map[string]string
@@ -1160,13 +1155,76 @@ func (cc *ContactController) ForwardAllContactMessages(c *gin.Context) {
} }
var input struct { var input struct {
ToEmail string `json:"to_email" binding:"required,email"` ToEmail string `json:"to_email"`
ToEmails []string `json:"to_emails"`
SaveDefault bool `json:"save_default"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Valid email address is required"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return return
} }
// Build recipients list (supports comma/semicolon/space separated string or array)
recipients := make([]string, 0)
add := func(s string) {
v := strings.TrimSpace(s)
if v != "" {
recipients = append(recipients, v)
}
}
if len(input.ToEmails) > 0 {
for _, e := range input.ToEmails {
add(e)
}
}
if input.ToEmail != "" {
// split by common separators to allow multiple addresses in a single string
parts := strings.FieldsFunc(input.ToEmail, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t' })
if len(parts) > 1 {
for _, p := range parts {
add(p)
}
} else {
add(input.ToEmail)
}
}
// Deduplicate
if len(recipients) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No recipient email provided"})
return
}
uniq := make(map[string]struct{})
out := make([]string, 0, len(recipients))
for _, e := range recipients {
v := strings.TrimSpace(strings.ToLower(e))
if v == "" {
continue
}
if _, ok := uniq[v]; ok {
continue
}
uniq[v] = struct{}{}
out = append(out, e)
}
recipients = out
// Optionally save as default auto-forward list in Settings
if input.SaveDefault {
var set models.Settings
if err := cc.DB.First(&set).Error; err != nil {
if err == gorm.ErrRecordNotFound {
set = models.Settings{}
set.ContactForwardEnabled = true
set.ContactForwardList = strings.Join(recipients, ", ")
_ = cc.DB.Create(&set).Error
}
} else {
set.ContactForwardEnabled = true
set.ContactForwardList = strings.Join(recipients, ", ")
_ = cc.DB.Save(&set).Error
}
}
// Fetch all messages // Fetch all messages
var messages []models.ContactMessage var messages []models.ContactMessage
if err := cc.DB.Order("created_at DESC").Find(&messages).Error; err != nil { if err := cc.DB.Order("created_at DESC").Find(&messages).Error; err != nil {
@@ -1180,12 +1238,12 @@ func (cc *ContactController) ForwardAllContactMessages(c *gin.Context) {
} }
// Forward all messages asynchronously // Forward all messages asynchronously
go func(msgs []models.ContactMessage, toEmail string) { go func(msgs []models.ContactMessage, dest []string) {
successCount := 0 successCount := 0
for _, message := range msgs { for _, message := range msgs {
forwardData := &email.EmailData{ forwardData := &email.EmailData{
Subject: fmt.Sprintf("Fwd: Contact Form - %s", message.Subject), Subject: fmt.Sprintf("Přeposláno: Kontaktní formulář - %s", message.Subject),
To: []string{toEmail}, To: dest,
Template: "contact_form", Template: "contact_form",
Data: struct { Data: struct {
Name string Name string
@@ -1207,16 +1265,16 @@ func (cc *ContactController) ForwardAllContactMessages(c *gin.Context) {
} }
if err := cc.emailService.SendEmail(forwardData); err != nil { if err := cc.emailService.SendEmail(forwardData); err != nil {
logger.Error("Failed to forward contact message %d to %s: %v", message.ID, toEmail, err) logger.Error("Failed to forward contact message %d to %v: %v", message.ID, dest, err)
} else { } else {
successCount++ successCount++
} }
} }
logger.Info("Forwarded %d of %d contact messages to %s", successCount, len(msgs), toEmail) logger.Info("Forwarded %d of %d contact messages to %v", successCount, len(msgs), dest)
}(messages, input.ToEmail) }(messages, recipients)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"message": fmt.Sprintf("Forwarding %d message(s) to %s", len(messages), input.ToEmail), "message": fmt.Sprintf("Přeposílám %d zpráv na: %s", len(messages), strings.Join(recipients, ", ")),
"count": len(messages), "count": len(messages),
}) })
} }
@@ -159,8 +159,9 @@ func (ctrl *ImageProcessingController) ProcessImage(c *gin.Context) {
} }
absolute := scheme + "://" + host + outputPath absolute := scheme + "://" + host + outputPath
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"url": absolute, "url": outputPath,
"format": format, "absolute_url": absolute,
"format": format,
}) })
} }
@@ -346,7 +347,8 @@ func (ctrl *ImageProcessingController) CropAndUpload(c *gin.Context) {
} }
absolute := scheme + "://" + host + outputPath absolute := scheme + "://" + host + outputPath
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"url": absolute, "url": outputPath,
"absolute_url": absolute,
}) })
} }
@@ -431,7 +433,28 @@ func (ctrl *ImageProcessingController) QuickEdit(c *gin.Context) {
return return
} }
scheme := "http"
if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") {
scheme = "https"
}
host := c.Request.Host
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
parts := strings.Split(xf, ",")
if len(parts) > 0 {
h := strings.TrimSpace(parts[0])
if h != "" { host = h }
}
}
if !strings.Contains(host, ":") {
if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" {
if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") {
host = host + ":" + xfp
}
}
}
absolute := scheme + "://" + host + outputPath
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"url": outputPath, "url": outputPath,
"absolute_url": absolute,
}) })
} }
+2 -1
View File
@@ -36,7 +36,8 @@ func SecurityHeaders() gin.HandlerFunc {
c.Header("X-Permitted-Cross-Domain-Policies", "none") c.Header("X-Permitted-Cross-Domain-Policies", "none")
c.Header("Cross-Origin-Embedder-Policy", "require-corp") c.Header("Cross-Origin-Embedder-Policy", "require-corp")
c.Header("Cross-Origin-Opener-Policy", "same-origin") c.Header("Cross-Origin-Opener-Policy", "same-origin")
c.Header("Cross-Origin-Resource-Policy", "same-origin") // Allow assets (e.g., /uploads) to be embedded from different origin (frontend vs backend)
c.Header("Cross-Origin-Resource-Policy", "cross-origin")
c.Next() c.Next()
} }
+17
View File
@@ -0,0 +1,17 @@
package models
import "gorm.io/gorm"
type Banner struct {
gorm.Model
Name string `json:"name" gorm:"not null"`
ImageURL string `json:"image_url"`
ClickURL string `json:"click_url"`
Placement string `json:"placement" gorm:"index"` // e.g., homepage_top, homepage_sidebar, homepage_under_table
Width int `json:"width"`
Height int `json:"height"`
IsActive bool `json:"is_active" gorm:"default:true;index"`
DisplayOrder int `json:"display_order" gorm:"default:0;index"`
}
func (Banner) TableName() string { return "banners" }
+9
View File
@@ -187,6 +187,12 @@ type Settings struct {
AdditionalMeta string `gorm:"type:text" json:"additional_meta"` // raw extra meta AdditionalMeta string `gorm:"type:text" json:"additional_meta"` // raw extra meta
EnableIndexing bool `json:"enable_indexing"` // robots allow/disallow EnableIndexing bool `json:"enable_indexing"` // robots allow/disallow
// Deployment base URLs (optional runtime hints)
// FrontendBaseURL: e.g. https://club.example.com
FrontendBaseURL string `json:"frontend_base_url"`
// APIBaseURL: full API root, e.g. https://api.example.com/api/v1 or https://backend.example.com/api/v1
APIBaseURL string `json:"api_base_url"`
// Social profiles // Social profiles
FacebookURL string `json:"facebook_url"` FacebookURL string `json:"facebook_url"`
InstagramURL string `json:"instagram_url"` InstagramURL string `json:"instagram_url"`
@@ -241,6 +247,9 @@ type Settings struct {
ContactCountry string `json:"contact_country"` ContactCountry string `json:"contact_country"`
ContactPhone string `json:"contact_phone"` ContactPhone string `json:"contact_phone"`
ContactEmail string `json:"contact_email"` ContactEmail string `json:"contact_email"`
// Contact form auto-forwarding
ContactForwardEnabled bool `json:"contact_forward_enabled"`
ContactForwardList string `gorm:"type:text" json:"contact_forward_list"` // comma/space/semicolon-separated emails
LocationLatitude float64 `json:"location_latitude"` LocationLatitude float64 `json:"location_latitude"`
LocationLongitude float64 `json:"location_longitude"` LocationLongitude float64 `json:"location_longitude"`
MapZoomLevel int `gorm:"default:15" json:"map_zoom_level"` MapZoomLevel int `gorm:"default:15" json:"map_zoom_level"`
+11
View File
@@ -199,6 +199,15 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
sponsors.DELETE("/:id", baseController.DeleteSponsor) sponsors.DELETE("/:id", baseController.DeleteSponsor)
} }
// Banners (protected CRUD)
banners := protected.Group("/banners")
banners.Use(middleware.RoleAuth("admin"))
{
banners.POST("", baseController.CreateBanner)
banners.PUT("/:id", baseController.UpdateBanner)
banners.DELETE("/:id", baseController.DeleteBanner)
}
// Admin routes (single consolidated group) // Admin routes (single consolidated group)
admin := protected.Group("/admin") admin := protected.Group("/admin")
admin.Use(middleware.RoleAuth("admin")) admin.Use(middleware.RoleAuth("admin"))
@@ -488,6 +497,8 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
api.GET("/players", baseController.GetPlayers) api.GET("/players", baseController.GetPlayers)
api.GET("/players/:id", baseController.GetPlayer) api.GET("/players/:id", baseController.GetPlayer)
api.GET("/sponsors", baseController.GetSponsors) api.GET("/sponsors", baseController.GetSponsors)
// Public banners
api.GET("/banners", baseController.GetBanners)
api.GET("/matches", baseController.GetMatches) api.GET("/matches", baseController.GetMatches)
api.GET("/matches/history", baseController.GetMatchesHistory) api.GET("/matches/history", baseController.GetMatchesHistory)
api.GET("/standings", baseController.GetStandings) api.GET("/standings", baseController.GetStandings)
+1
View File
@@ -123,6 +123,7 @@ func MigrateDB(db *gorm.DB) error {
&models.Team{}, &models.Team{},
&models.Player{}, &models.Player{},
&models.Sponsor{}, &models.Sponsor{},
&models.Banner{},
&models.Settings{}, &models.Settings{},
&models.MatchOverride{}, &models.MatchOverride{},
&models.TeamLogoOverride{}, &models.TeamLogoOverride{},
+110 -34
View File
@@ -770,7 +770,11 @@ func (s *emailService) SendEmail(data *EmailData) error {
// Website and contact (best-effort) // Website and contact (best-effort)
vm["ClubURL"] = strings.TrimSpace(set.ClubURL) vm["ClubURL"] = strings.TrimSpace(set.ClubURL)
vm["WebsiteURL"] = strings.TrimSpace(set.CanonicalBaseURL) vm["WebsiteURL"] = strings.TrimSpace(set.CanonicalBaseURL)
contactEmail := strings.TrimSpace(s.config.AdminEmail) // Prefer club contact email from Settings, then AdminEmail, then SMTPFrom
contactEmail := strings.TrimSpace(getStringField(set, "ContactEmail"))
if contactEmail == "" {
contactEmail = strings.TrimSpace(s.config.AdminEmail)
}
if contactEmail == "" { if contactEmail == "" {
contactEmail = strings.TrimSpace(s.config.SMTPFrom) contactEmail = strings.TrimSpace(s.config.SMTPFrom)
} }
@@ -781,6 +785,42 @@ func (s *emailService) SendEmail(data *EmailData) error {
} }
vm["ContactURL"] = contactURL vm["ContactURL"] = contactURL
// Provide recipient and link fallbacks for templates
if _, ok := vm["RecipientEmail"]; !ok {
if len(data.To) > 0 {
vm["RecipientEmail"] = strings.TrimSpace(data.To[0])
}
}
if _, ok := vm["UnsubscribeURL"]; !ok {
if v, ok2 := vm["UnsubscribeLink"]; ok2 {
if s, ok3 := v.(string); ok3 && strings.TrimSpace(s) != "" {
vm["UnsubscribeURL"] = strings.TrimSpace(s)
}
}
}
if _, ok := vm["ManageURL"]; !ok {
if v, ok2 := vm["UnsubscribeURL"]; ok2 {
if s, ok3 := v.(string); ok3 && strings.TrimSpace(s) != "" {
vm["ManageURL"] = strings.TrimSpace(s)
}
} else if v2, ok4 := vm["UnsubscribeLink"]; ok4 {
if s, ok5 := v2.(string); ok5 && strings.TrimSpace(s) != "" {
vm["ManageURL"] = strings.TrimSpace(s)
}
} else if v3, ok6 := vm["SetupURL"]; ok6 {
if s, ok7 := v3.(string); ok7 && strings.TrimSpace(s) != "" {
vm["ManageURL"] = strings.TrimSpace(s)
}
}
}
if _, ok := vm["UnsubscribeURL"]; !ok {
if v, ok2 := vm["SetupURL"]; ok2 {
if s, ok3 := v.(string); ok3 && strings.TrimSpace(s) != "" {
vm["UnsubscribeURL"] = strings.TrimSpace(s)
}
}
}
// Parse base + template with functions // Parse base + template with functions
basePath := filepath.Join(s.config.EmailTemplateDir, "base.html") basePath := filepath.Join(s.config.EmailTemplateDir, "base.html")
templatePath := filepath.Join(s.config.EmailTemplateDir, data.Template+".html") templatePath := filepath.Join(s.config.EmailTemplateDir, data.Template+".html")
@@ -867,42 +907,76 @@ func (s *emailService) SendEmail(data *EmailData) error {
} }
func (s *emailService) SendContactForm(data *ContactFormData) error { func (s *emailService) SendContactForm(data *ContactFormData) error {
templateData := struct { templateData := struct {
Name string Name string
Email string Email string
Subject string Subject string
Message string Message string
Time string Time string
IP string IP string
Agent string Agent string
}{ }{
Name: data.Name, Name: data.Name,
Email: data.Email, Email: data.Email,
Subject: data.Subject, Subject: data.Subject,
Message: data.Message, Message: data.Message,
Time: time.Now().Format(time.RFC1123Z), Time: time.Now().Format(time.RFC1123Z),
IP: data.IPAddress, IP: data.IPAddress,
Agent: data.UserAgent, Agent: data.UserAgent,
} }
emailData := &EmailData{ // Build recipients: admin email + optional auto-forward list from Settings
Subject: "New Contact Form: " + data.Subject, recipients := make([]string, 0, 4)
To: []string{s.config.AdminEmail}, if v := strings.TrimSpace(s.config.AdminEmail); v != "" {
Template: "contact_form", recipients = append(recipients, v)
Data: templateData, }
From: s.config.SMTPFrom, // Load settings to check auto-forwarding
FromName: s.config.SMTPFromName, var set models.Settings
} if s.db != nil {
_ = s.db.First(&set).Error
if set.ContactForwardEnabled && strings.TrimSpace(set.ContactForwardList) != "" {
parts := strings.FieldsFunc(set.ContactForwardList, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t' })
for _, p := range parts {
if v := strings.TrimSpace(p); v != "" {
recipients = append(recipients, v)
}
}
}
}
// Deduplicate and ensure at least one recipient
uniq := make(map[string]struct{})
dedup := make([]string, 0, len(recipients))
for _, e := range recipients {
v := strings.ToLower(strings.TrimSpace(e))
if v == "" { continue }
if _, ok := uniq[v]; ok { continue }
uniq[v] = struct{}{}
dedup = append(dedup, e)
}
if len(dedup) == 0 {
if v := strings.TrimSpace(s.config.SMTPFrom); v != "" {
dedup = []string{v}
}
}
emailData := &EmailData{
Subject: "Nová zpráva z formuláře: " + data.Subject,
To: dedup,
Template: "contact_form",
Data: templateData,
From: s.config.SMTPFrom,
FromName: s.config.SMTPFromName,
}
// Send confirmation to user // Send confirmation to user
if data.Email != "" { if data.Email != "" {
confirmationData := &EmailData{ confirmationData := &EmailData{
Subject: "We've received your message", Subject: "Obdrželi jsme vaši zprávu",
To: []string{data.Email}, To: []string{data.Email},
Template: "contact_confirmation", Template: "contact_confirmation",
Data: struct { Data: struct {
Name string Name string
Message string Message string
}{ }{
Name: data.Name, Name: data.Name,
Message: data.Message, Message: data.Message,
@@ -1048,6 +1122,7 @@ func (s *emailService) SendNewsletter(data *NewsletterData) error {
ClubURL string ClubURL string
ContactEmail string ContactEmail string
ContactURL string ContactURL string
RecipientEmail string
}{ }{
Subject: data.Subject, Subject: data.Subject,
Content: data.Content, Content: data.Content,
@@ -1073,13 +1148,14 @@ func (s *emailService) SendNewsletter(data *NewsletterData) error {
} }
return makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {clubURL}}) return makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {clubURL}})
}(), }(),
ContactEmail: contactEmail, ContactEmail: contactEmail,
ContactURL: func() string { ContactURL: func() string {
if contactURL == "" { if contactURL == "" {
return "" return ""
} }
return makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {contactURL}}) return makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {contactURL}})
}(), }(),
RecipientEmail: recipient,
} }
// Wrap socials if present // Wrap socials if present
+10 -5
View File
@@ -201,6 +201,16 @@
{{if .ContactEmail}}<a href="mailto:{{.ContactEmail}}">E-mail</a>{{end}} {{if .ContactEmail}}<a href="mailto:{{.ContactEmail}}">E-mail</a>{{end}}
</p> </p>
{{end}} {{end}}
{{if .RecipientEmail}}
<p>Tento email byl odeslán na adresu <strong>{{.RecipientEmail}}</strong>.</p>
{{end}}
{{if or .UnsubscribeURL .ManageURL}}
<p>
{{if .UnsubscribeURL}}<a href="{{.UnsubscribeURL}}" class="unsubscribe-link">Odhlásit odběr</a>{{end}}
{{if and .UnsubscribeURL .ManageURL}} | {{end}}
{{if .ManageURL}}<a href="{{.ManageURL}}" class="unsubscribe-link">Upravit předvolby</a>{{end}}
</p>
{{end}}
{{if or .FacebookURL .InstagramURL .YouTubeURL .TwitterURL}} {{if or .FacebookURL .InstagramURL .YouTubeURL .TwitterURL}}
<div class="social-links"> <div class="social-links">
{{if .FacebookURL}}<a href="{{.FacebookURL}}" target="_blank">Facebook</a>{{end}} {{if .FacebookURL}}<a href="{{.FacebookURL}}" target="_blank">Facebook</a>{{end}}
@@ -211,11 +221,6 @@
{{end}} {{end}}
<p>{{.ClubName}} &copy; {{now.Format "2006"}}. Všechna práva vyhrazena.</p> <p>{{.ClubName}} &copy; {{now.Format "2006"}}. Všechna práva vyhrazena.</p>
<p>Pokud jste tento email obdrželi omylem, prosíme o jeho smazání.</p> <p>Pokud jste tento email obdrželi omylem, prosíme o jeho smazání.</p>
{{if .UnsubscribeURL}}
<p style="margin-top: 15px;">
<a href="{{.UnsubscribeURL}}" class="unsubscribe-link">Odhlásit se z odběru</a>
</p>
{{end}}
<p style="margin-top: 12px; font-size: 12px; color: #a0aec0;"> <p style="margin-top: 12px; font-size: 12px; color: #a0aec0;">
Made by <a href="https://sportcreative.cz/myclub" target="_blank" style="color:#718096;">MyClub by Sportcreative</a> Made by <a href="https://sportcreative.cz/myclub" target="_blank" style="color:#718096;">MyClub by Sportcreative</a>
</p> </p>
@@ -0,0 +1,19 @@
{{define "content"}}
<h2>Děkujeme za zprávu</h2>
<p>Vaši zprávu jsme úspěšně obdrželi. Ozveme se vám co nejdříve.</p>
<table cellpadding="10" cellspacing="0" style="width: 100%; border-collapse: collapse;">
<tr>
<td style="border: 1px solid #e0e0e0; background-color: #f9f9f9; font-weight: bold; width: 150px;">Jméno:</td>
<td style="border: 1px solid #e0e0e0;">{{.Name}}</td>
</tr>
<tr>
<td style="border: 1px solid #e0e0e0; background-color: #f9f9f9; font-weight: bold; vertical-align: top;">Vaše zpráva:</td>
<td style="border: 1px solid #e0e0e0; white-space: pre-wrap;">{{.Message}}</td>
</tr>
</table>
<p style="margin-top: 20px;">
<a href="{{if .ContactURL}}{{.ContactURL}}{{else}}{{.WebsiteURL}}{{end}}" class="btn">Otevřít web</a>
</p>
{{end}}
+9 -9
View File
@@ -1,39 +1,39 @@
{{define "content"}} {{define "content"}}
<h2>New Contact Form Submission</h2> <h2>Nová zpráva z kontaktního formuláře</h2>
<p>You've received a new message from the contact form on your website.</p> <p>Obdrželi jste novou zprávu z kontaktního formuláře na webu.</p>
<table cellpadding="10" cellspacing="0" style="width: 100%; border-collapse: collapse;"> <table cellpadding="10" cellspacing="0" style="width: 100%; border-collapse: collapse;">
<tr> <tr>
<td style="border: 1px solid #e0e0e0; background-color: #f9f9f9; font-weight: bold; width: 150px;">Name:</td> <td style="border: 1px solid #e0e0e0; background-color: #f9f9f9; font-weight: bold; width: 150px;">Jméno:</td>
<td style="border: 1px solid #e0e0e0;">{{.Name}}</td> <td style="border: 1px solid #e0e0e0;">{{.Name}}</td>
</tr> </tr>
<tr> <tr>
<td style="border: 1px solid #e0e0e0; background-color: #f9f9f9; font-weight: bold;">Email:</td> <td style="border: 1px solid #e0e0e0; background-color: #f9f9f9; font-weight: bold;">E-mail:</td>
<td style="border: 1px solid #e0e0e0;"><a href="mailto:{{.Email}}">{{.Email}}</a></td> <td style="border: 1px solid #e0e0e0;"><a href="mailto:{{.Email}}">{{.Email}}</a></td>
</tr> </tr>
<tr> <tr>
<td style="border: 1px solid #e0e0e0; background-color: #f9f9f9; font-weight: bold;">Subject:</td> <td style="border: 1px solid #e0e0e0; background-color: #f9f9f9; font-weight: bold;">Předmět:</td>
<td style="border: 1px solid #e0e0e0;">{{.Subject}}</td> <td style="border: 1px solid #e0e0e0;">{{.Subject}}</td>
</tr> </tr>
<tr> <tr>
<td style="border: 1px solid #e0e0e0; background-color: #f9f9f9; font-weight: bold; vertical-align: top;">Message:</td> <td style="border: 1px solid #e0e0e0; background-color: #f9f9f9; font-weight: bold; vertical-align: top;">Zpráva:</td>
<td style="border: 1px solid #e0e0e0; white-space: pre-wrap;">{{.Message}}</td> <td style="border: 1px solid #e0e0e0; white-space: pre-wrap;">{{.Message}}</td>
</tr> </tr>
{{if .IPAddress}} {{if .IPAddress}}
<tr> <tr>
<td style="border: 1px solid #e0e0e0; background-color: #f9f9f9; font-weight: bold;">IP Address:</td> <td style="border: 1px solid #e0e0e0; background-color: #f9f9f9; font-weight: bold;">IP adresa:</td>
<td style="border: 1px solid #e0e0e0;">{{.IPAddress}}</td> <td style="border: 1px solid #e0e0e0;">{{.IPAddress}}</td>
</tr> </tr>
{{end}} {{end}}
{{if .UserAgent}} {{if .UserAgent}}
<tr> <tr>
<td style="border: 1px solid #e0e0e0; background-color: #f9f9f9; font-weight: bold;">User Agent:</td> <td style="border: 1px solid #e0e0e0; background-color: #f9f9f9; font-weight: bold;">Prohlížeč:</td>
<td style="border: 1px solid #e0e0e0;">{{.UserAgent}}</td> <td style="border: 1px solid #e0e0e0;">{{.UserAgent}}</td>
</tr> </tr>
{{end}} {{end}}
</table> </table>
<p style="margin-top: 20px;"> <p style="margin-top: 20px;">
<a href="mailto:{{.Email}}" class="btn">Reply to {{.Name}}</a> <a href="mailto:{{.Email}}" class="btn">Odpovědět {{.Name}}</a>
</p> </p>
{{end}} {{end}}
+10 -8
View File
@@ -1,8 +1,10 @@
<html> {{define "content"}}
<body> <h2 style="margin-top:0;">Vítejte v newsletteru</h2>
<h1>Vítejte v newsletteru</h1> <p>Děkujeme za přihlášení k odběru. Klikněte na tlačítko níže a nastavte, jaké zprávy chcete dostávat:</p>
<p>Děkujeme za přihlášení k odběru. Klikněte na odkaz níže pro nastavení, jaké zprávy chcete dostávat:</p> {{if .SetupURL}}
<p><a href="{{.SetupURL}}">Nastavit preference newsletteru</a></p> <p style="text-align:center;">
<p>Pokud jste se k odběru nepřihlásili, můžete tento e-mail ignorovat nebo se odhlásit.</p> <a href="{{.SetupURL}}" class="btn">Nastavit preference</a>
</body> </p>
</html> {{end}}
<p>Pokud jste se k odběru nepřihlásili, můžete tento email ignorovat.</p>
{{end}}
+16 -93
View File
@@ -1,97 +1,20 @@
{{define "content"}} {{define "content"}}
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif; color: #2d3748; background: #ffffff;"> <h2 style="margin-top:0;">Vítejte v našem newsletteru!</h2>
<!-- Header with club colors --> <p style="font-size:16px; line-height:1.6;">
<div style="background: linear-gradient(135deg, #1a365d 0%, #2c5282 100%); padding: 30px 20px; text-align: center; border-radius: 8px 8px 0 0;"> Děkujeme, že jste se přihlásili k odběru našeho newsletteru. Jsme rádi, že jste součástí naší fotbalové komunity a budeme vás pravidelně informovat o nejnovějším dění v klubu.
<img src="https://fotbalclub.cz/logo.png" alt="FK Fotbal Club" style="max-width: 180px; height: auto; margin-bottom: 15px;"> </p>
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700;">Vítejte v našem newsletteru!</h1>
</div> <h3 style="margin:24px 0 8px;">Co pro vás připravujeme?</h3>
<ul style="margin:0 0 16px 18px; padding:0;">
<!-- Main content --> <li><strong>Novinky z klubu:</strong> aktuality, rozhovory a zajímavosti ze zákulisí.</li>
<div style="padding: 30px 25px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;"> <li><strong>Zápasy a výsledky:</strong> přehled nadcházejících zápasů a reporty z odehraných.</li>
<p style="font-size: 16px; line-height: 1.6; margin: 0 0 25px 0;"> <li><strong>Speciální nabídky:</strong> výhody a slevy pro odběratele.</li>
Děkujeme, že jste se přihlásili k odběru našeho newsletteru. Jsme di, že jste součástí naší fotbalové komunity a budeme vás pravidelně informovat o nejnovějším dění v klubu. <li><strong>Rozhovory:</strong> s hči, trenéry a členy klubu.</li>
</ul>
<div style="background:#ebf8ff; border-left:4px solid #3182ce; padding:12px 14px; border-radius:0 6px 6px 0;">
<p style="margin:0; color:#2c5282; font-size:14px;">
Aby vám naše emaily nechyběly, přidejte si adresu <strong>{{.FromEmail}}</strong> do kontaktů.
</p> </p>
<!-- Features grid -->
<div style="margin: 30px 0;">
<h2 style="color: #2d3748; font-size: 20px; margin: 0 0 20px 0; text-align: center; position: relative; padding-bottom: 10px;">
<span style="background: #fff; padding: 0 15px; position: relative; z-index: 1;">Co pro vás připravujeme?</span>
<span style="position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background: #e2e8f0; z-index: 0; display: block; margin-top: 15px;"></span>
</h2>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 25px;">
<div style="background: #f8fafc; border-left: 4px solid #e53e3e; padding: 15px; border-radius: 0 6px 6px 0;">
<h3 style="color: #2b6cb0; margin: 0 0 8px 0; font-size: 16px; display: flex; align-items: center;">
<span style="margin-right: 8px;">📰</span> Novinky z klubu
</h3>
<p style="margin: 0; color: #4a5568; font-size: 14px; line-height: 1.5;">Aktuality, rozhovory a zajímavosti ze zákulisí našeho klubu.</p>
</div>
<div style="background: #f8fafc; border-left: 4px solid #3182ce; padding: 15px; border-radius: 0 6px 6px 0;">
<h3 style="color: #2b6cb0; margin: 0 0 8px 0; font-size: 16px; display: flex; align-items: center;">
<span style="margin-right: 8px;"></span> Zápasy a výsledky
</h3>
<p style="margin: 0; color: #4a5568; font-size: 14px; line-height: 1.5;">Přehled nadcházejících zápasů a reporty z těch odehraných.</p>
</div>
<div style="background: #f8fafc; border-left: 4px solid #38a169; padding: 15px; border-radius: 0 6px 6px 0;">
<h3 style="color: #2b6cb0; margin: 0 0 8px 0; font-size: 16px; display: flex; align-items: center;">
<span style="margin-right: 8px;">🎟️</span> Speciální nabídky
</h3>
<p style="margin: 0; color: #4a5568; font-size: 14px; line-height: 1.5;">Výhody a slevy výhradně pro naše odběratele.</p>
</div>
<div style="background: #f8fafc; border-left: 4px solid #d69e2e; padding: 15px; border-radius: 0 6px 6px 0;">
<h3 style="color: #2b6cb0; margin: 0 0 8px 0; font-size: 16px; display: flex; align-items: center;">
<span style="margin-right: 8px;">👥</span> Rozhovory
</h3>
<p style="margin: 0; color: #4a5568; font-size: 14px; line-height: 1.5;">Rozhovory s hráči, trenéry a dalšími členy klubu.</p>
</div>
</div>
</div>
<!-- Next steps -->
<div style="background: #ebf8ff; border-left: 4px solid #3182ce; padding: 15px; margin: 25px 0; border-radius: 0 6px 6px 0;">
<h3 style="color: #2c5282; margin: 0 0 10px 0; font-size: 16px; display: flex; align-items: center;">
<span style="margin-right: 8px;"></span> Důležité
</h3>
<p style="margin: 0; color: #2c5282; font-size: 14px; line-height: 1.5;">
Aby vám naše e-maily nechyběly, přidejte si adresu <strong>{{.FromEmail}}</strong> do vašich kontaktů.
</p>
</div>
<!-- Unsubscribe notice -->
<div style="margin: 35px 0 25px 0; text-align: center;">
<p style="font-size: 13px; color: #718096; margin: 0 0 15px 0; line-height: 1.5;">
Tento e-mail vám byl zaslán na adresu <strong>{{.Email}}</strong> v rámci odebírání novinek FK Fotbal Club.
</p>
<p style="font-size: 13px; color: #718096; margin: 0; line-height: 1.5;">
<a href="{{.UnsubscribeLink}}" style="color: #2b6cb0; text-decoration: underline;">Odhlásit odběr</a> |
<a href="https://fotbalclub.cz/nastaveni" style="color: #2b6cb0; text-decoration: underline;">Upravit předvolby</a> |
<a href="mailto:help@tdvorak.dev" style="color: #2b6cb0; text-decoration: underline;">Napsat nám</a>
</p>
</div>
<!-- Footer -->
<div style="border-top: 1px solid #e2e8f0; padding-top: 20px; margin-top: 20px; text-align: center;">
<p style="margin: 0 0 15px 0;">
<a href="https://facebook.com/fotbalclub" style="margin: 0 8px; display: inline-block;">
<img src="https://fotbalclub.cz/icons/facebook.png" alt="Facebook" style="width: 24px; height: 24px; opacity: 0.7;">
</a>
<a href="https://instagram.com/fotbalclub" style="margin: 0 8px; display: inline-block;">
<img src="https://fotbalclub.cz/icons/instagram.png" alt="Instagram" style="width: 24px; height: 24px; opacity: 0.7;">
</a>
<a href="https://youtube.com/fotbalclub" style="margin: 0 8px; display: inline-block;">
<img src="https://fotbalclub.cz/icons/youtube.png" alt="YouTube" style="width: 24px; height: 24px; opacity: 0.7;">
</a>
</p>
<p style="margin: 0 0 5px 0; font-size: 12px; color: #a0aec0;">
© {{.Year}} FK Fotbal Club. Všechna práva vyhrazena.
</p>
<p style="margin: 0; font-size: 12px; color: #a0aec0;">
Tento e-mail byl odeslán na adresu {{.Email}}
</p>
</div>
</div> </div>
</div>
{{end}} {{end}}
+11 -88
View File
@@ -1,90 +1,13 @@
{{define "content"}} {{define "content"}}
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif; color: #2d3748; background: #ffffff;"> <h2 style="margin-top:0;">Vítejte zpět u nás!</h2>
<!-- Header with club colors --> <p style="font-size:16px; line-height:1.6;">
<div style="background: linear-gradient(135deg, #2c5282 0%, #1a365d 100%); padding: 30px 20px; text-align: center; border-radius: 8px 8px 0 0;"> Děkujeme, že jste se znovu přihlásili k odběru našeho newsletteru. Jsme rádi, že jste zpátky a těšíme se na vaši zpětnou vazbu!
<img src="https://fotbalclub.cz/logo.png" alt="FK Fotbal Club" style="max-width: 180px; height: auto; margin-bottom: 15px;"> </p>
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700;">Vítejte zpět u nás!</h1>
</div> <h3 style="margin:24px 0 8px;">Co je u nás nového?</h3>
<ul style="margin:0 0 16px 18px; padding:0;">
<!-- Main content --> <li>Aktuální rozvrh zápasů a výsledků.</li>
<div style="padding: 30px 25px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;"> <li>Novinky z klubu a rozhovory.</li>
<p style="font-size: 16px; line-height: 1.6; margin: 0 0 25px 0;"> <li>Akce pro fanoušky a speciální nabídky.</li>
Děkujeme, že jste se znovu přihlásili k odběru našeho newsletteru. Jsme opravdu rádi, že jste se k nám vrátili a těšíme se na vaši zpětnou vazbu! </ul>
</p>
<!-- What's new section -->
<div style="margin: 30px 0;">
<h2 style="color: #2d3748; font-size: 20px; margin: 0 0 20px 0; text-align: center; position: relative; padding-bottom: 10px;">
<span style="background: #fff; padding: 0 15px; position: relative; z-index: 1;">Co je u nás nového?</span>
<span style="position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background: #e2e8f0; z-index: 0; display: block; margin-top: 15px;"></span>
</h2>
<div style="display: grid; grid-template-columns: 1fr; gap: 15px; margin-bottom: 25px;">
<div style="background: #f8fafc; border-left: 4px solid #e53e3e; padding: 15px; border-radius: 0 6px 6px 0;">
<h3 style="color: #2b6cb0; margin: 0 0 8px 0; font-size: 16px; display: flex; align-items: center;">
<span style="margin-right: 8px;">👕</span> Nové dresy {{.Year}}
</h3>
<p style="margin: 0; color: #4a5568; font-size: 14px; line-height: 1.5;">Představili jsme zbrusu nové dresy na sezónu {{.Year}}. Podívejte se na ně v našem <a href="https://fotbalclub.cz/fotogalerie" style="color: #2b6cb0; text-decoration: underline;">fotoalbu</a>.</p>
</div>
<div style="background: #f8fafc; border-left: 4px solid #3182ce; padding: 15px; border-radius: 0 6px 6px 0;">
<h3 style="color: #2b6cb0; margin: 0 0 8px 0; font-size: 16px; display: flex; align-items: center;">
<span style="margin-right: 8px;">📅</span> Aktuální rozvrh zápasů
</h3>
<p style="margin: 0; color: #4a5568; font-size: 14px; line-height: 1.5;">Připravili jsme pro vás kompletní rozvrh zápasů na nadcházející měsíce. <a href="https://fotbalclub.cz/zapasy" style="color: #2b6cb0; text-decoration: underline;">Zobrazit kalendář</a>.</p>
</div>
<div style="background: #f8fafc; border-left: 4px solid #38a169; padding: 15px; border-radius: 0 6px 6px 0;">
<h3 style="color: #2b6cb0; margin: 0 0 8px 0; font-size: 16px; display: flex; align-items: center;">
<span style="margin-right: 8px;">🏟️</span> Akce pro fanoušky
</h3>
<p style="margin: 0; color: #4a5568; font-size: 14px; line-height: 1.5;">Připravili jsme sérii akcí pro naše věrné fanoušky. Více informací naleznete v <a href="https://fotbalclub.cz/akce" style="color: #2b6cb0; text-decoration: underline;">kalendáři akcí</a>.</p>
</div>
</div>
</div>
<!-- Next steps -->
<div style="background: #ebf8ff; border-left: 4px solid #3182ce; padding: 15px; margin: 25px 0; border-radius: 0 6px 6px 0;">
<h3 style="color: #2c5282; margin: 0 0 10px 0; font-size: 16px; display: flex; align-items: center;">
<span style="margin-right: 8px;">📱</span> Sledujte nás
</h3>
<p style="margin: 0; color: #2c5282; font-size: 14px; line-height: 1.5;">
Sledujte nás na sociálních sítích a buďte mezi prvními, kdo se dozví o novinkách z našeho klubu.
</p>
<div style="margin-top: 10px;">
<a href="https://facebook.com/fotbalclub" style="margin-right: 10px; display: inline-block;">
<img src="https://fotbalclub.cz/icons/facebook.png" alt="Facebook" style="width: 24px; height: 24px; opacity: 0.8;">
</a>
<a href="https://instagram.com/fotbalclub" style="margin-right: 10px; display: inline-block;">
<img src="https://fotbalclub.cz/icons/instagram.png" alt="Instagram" style="width: 24px; height: 24px; opacity: 0.8;">
</a>
<a href="https://youtube.com/fotbalclub" style="display: inline-block;">
<img src="https://fotbalclub.cz/icons/youtube.png" alt="YouTube" style="width: 24px; height: 24px; opacity: 0.8;">
</a>
</div>
</div>
<!-- Unsubscribe notice -->
<div style="margin: 35px 0 25px 0; text-align: center;">
<p style="font-size: 13px; color: #718096; margin: 0 0 15px 0; line-height: 1.5;">
Tento e-mail vám byl zaslán na adresu <strong>{{.Email}}</strong> v rámci odebírání novinek FK Fotbal Club.
</p>
<p style="font-size: 13px; color: #718096; margin: 0; line-height: 1.5;">
<a href="{{.UnsubscribeLink}}" style="color: #2b6cb0; text-decoration: underline;">Odhlásit odběr</a> |
<a href="https://fotbalclub.cz/nastaveni" style="color: #2b6cb0; text-decoration: underline;">Upravit předvolby</a> |
<a href="mailto:help@tdvorak.dev" style="color: #2b6cb0; text-decoration: underline;">Napsat nám</a>
</p>
</div>
<!-- Footer -->
<div style="border-top: 1px solid #e2e8f0; padding-top: 20px; margin-top: 20px; text-align: center;">
<p style="margin: 0 0 5px 0; font-size: 12px; color: #a0aec0;">
© {{.Year}} FK Fotbal Club. Všechna práva vyhrazena.
</p>
<p style="margin: 0; font-size: 12px; color: #a0aec0;">
Tento e-mail byl odeslán na adresu {{.Email}}
</p>
</div>
</div>
</div>
{{end}} {{end}}