This commit is contained in:
Tomas Dvorak
2026-03-13 14:34:19 +01:00
parent 84a8acf944
commit 30d70a6aeb
126 changed files with 27297 additions and 29069 deletions
+2 -2
View File
@@ -1,11 +1,11 @@
import React, { lazy, Suspense } from 'react';
import { ChakraProvider, extendTheme, Spinner, Center, Box } from '@chakra-ui/react';
import { ChakraProvider, Spinner, Center, Box } from '@chakra-ui/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter as Router, Routes, Route, Navigate, Outlet } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { ClubThemeProvider } from './contexts/ClubThemeContext';
import { HelmetProvider } from 'react-helmet-async';
import { theme } from './App';
import { theme } from './theme/siteTheme';
import { useUmami } from './hooks/useUmami';
import { useFontLoader } from './hooks/useFontLoader';
import DefaultSEO from './components/seo/DefaultSEO';
+2 -154
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { ChakraProvider, extendTheme } from '@chakra-ui/react';
import { ChakraProvider } from '@chakra-ui/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter as Router, Routes, Route, Navigate, Outlet, useLocation } from 'react-router-dom';
import './styles/custom-scrollbar.css';
@@ -94,6 +94,7 @@ import { useUmami } from './hooks/useUmami';
import { checkin } from './services/engagement';
import { useFontLoader } from './hooks/useFontLoader';
import { usePublicSettings } from './hooks/usePublicSettings';
import { theme } from './theme/siteTheme';
import { logAction } from './services/actionLog';
const RouteLogger: React.FC = () => {
@@ -117,159 +118,6 @@ const queryClient = new QueryClient({
},
});
// Theme configuration drawing colors from ClubTheme CSS variables for personalization
export const theme = extendTheme({
config: {
initialColorMode: 'light',
useSystemColorMode: false,
},
// Provide a brand color scale so colorScheme="brand" components style correctly
colors: {
brand: {
50: '#e6f7ff',
100: '#b3e0ff',
200: '#80caff',
300: '#4db3ff',
400: '#1a9cff',
500: 'var(--club-primary, #0b5cff)',
600: '#0066cc',
700: '#004d99',
800: '#003366',
900: '#001a33',
},
},
// Semantic tokens allow live updates when ClubThemeContext changes CSS variables
semanticTokens: {
colors: {
'brand.primary': {
default: 'var(--club-primary, #0b5cff)',
},
'brand.secondary': {
default: 'var(--club-secondary, #ffd200)',
},
'brand.accent': {
default: 'var(--club-accent, #141414)',
},
'text.onPrimary': {
default: 'var(--club-text-on-primary, #ffffff)',
},
'bg.app': {
default: '#f8f9fb',
_dark: '#0f1115',
},
'text.app': {
default: '#1a1a1a',
_dark: '#e8eaf0',
},
// Backdrop/outline shades
'border.subtle': {
default: 'rgba(0,0,0,0.06)',
_dark: 'rgba(255,255,255,0.12)',
},
'bg.card': {
default: '#ffffff',
_dark: '#1a1d29',
},
'bg.elevated': {
default: '#ffffff',
_dark: '#242831',
},
},
},
styles: {
global: {
'html, body, #root': {
height: '100%',
fontFamily: 'var(--font-body, Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial)',
},
body: {
bg: 'bg.app',
color: 'text.app',
lineHeight: 1.5,
fontFamily: 'var(--font-body, Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial)',
},
'h1, h2, h3, h4, h5, h6': {
fontFamily: 'var(--font-heading, Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial)',
},
a: {
transition: 'color 0.2s ease',
},
'::selection': {
background: 'brand.accent',
color: 'black',
},
},
},
components: {
Container: {
baseStyle: {
px: { base: 4, md: 6 },
},
sizes: {
'7xl': '88rem',
},
},
Button: {
baseStyle: {
fontWeight: '700',
borderRadius: 'md',
letterSpacing: '0.4px',
_hover: { transform: 'translateY(-1px)', boxShadow: 'md' },
_active: { transform: 'translateY(0)' },
},
variants: {
solid: {
bg: 'brand.primary',
color: 'text.onPrimary',
_hover: { filter: 'brightness(0.95)' },
},
outline: {
border: '2px solid',
borderColor: 'brand.primary',
color: 'brand.primary',
_hover: { bg: 'rgba(0,0,0,0.02)' },
},
ghost: {
color: 'brand.secondary',
_hover: { bg: 'rgba(0,0,0,0.04)' },
},
},
},
Card: {
baseStyle: {
container: {
borderRadius: 'lg',
boxShadow: 'sm',
overflow: 'hidden',
transition: 'all 0.2s',
borderWidth: '1px',
borderColor: 'border.subtle',
_hover: { transform: 'translateY(-4px)', boxShadow: 'lg' },
},
},
},
Divider: {
baseStyle: {
borderColor: 'border.subtle',
},
},
Heading: {
baseStyle: {
fontFamily: 'var(--font-heading, Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial)',
},
},
Text: {
baseStyle: {
fontFamily: 'var(--font-body, Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial)',
},
},
},
fonts: {
heading: 'var(--font-heading, Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial)',
body: 'var(--font-body, Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial)',
},
});
// Component to initialize analytics inside Router context
const AnalyticsInitializer: React.FC = () => {
useUmami();
@@ -6,6 +6,7 @@ import { useAuth } from '../../contexts/AuthContext';
import { Pencil, Trash2, Send, CheckCircle2 } from 'lucide-react';
import { Link as RouterLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { sanitizeRichHtml } from '../../utils/sanitizeHtml';
type Props = {
targetType: 'article' | 'event' | 'gallery_album' | 'youtube_video';
@@ -382,7 +383,7 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
</VStack>
) : (
c.content_html ? (
<Box sx={{ '.cw': { textDecoration: 'underline dotted', cursor: 'help' } }} dangerouslySetInnerHTML={{ __html: c.content_html }} />
<Box sx={{ '.cw': { textDecoration: 'underline dotted', cursor: 'help' } }} dangerouslySetInnerHTML={{ __html: sanitizeRichHtml(c.content_html) }} />
) : (
<Text whiteSpace="pre-wrap">{c.content}</Text>
)
@@ -0,0 +1,44 @@
import React from 'react';
import { useCountdown } from '../../hooks/useCountdown';
const formatVerboseCountdown = (timeRemaining: number) => {
if (timeRemaining <= 0) {
return '';
}
const totalSeconds = Math.floor(timeRemaining / 1000);
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `${days} d ${hours} h ${minutes} m ${seconds} s`;
};
export const buildKickoffIso = (date?: string, time?: string) => {
if (!date) {
return null;
}
return `${date}T${(time || '00:00')}:00`;
};
const MatchCountdownText: React.FC<{
targetDate?: string | Date | null;
fallback?: string;
startedLabel?: string;
}> = ({ targetDate = null, fallback = '—', startedLabel = 'Začátek' }) => {
const { targetTime, timeRemaining } = useCountdown(targetDate, 1000);
if (!targetTime) {
return <>{fallback}</>;
}
if (timeRemaining <= 0) {
return <>{startedLabel}</>;
}
return <>{formatVerboseCountdown(timeRemaining)}</>;
};
export default React.memo(MatchCountdownText);
+222 -93
View File
@@ -130,6 +130,8 @@ interface ElementPosition {
height: number;
}
type DragInsertPosition = 'before' | 'after';
const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onConfigChange }) => {
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
@@ -149,6 +151,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const [elementOrder, setElementOrder] = useState<string[]>([]);
const [draggedElement, setDraggedElement] = useState<string | null>(null);
const [dragOverElement, setDragOverElement] = useState<string | null>(null);
const [dragOverPlacement, setDragOverPlacement] = useState<DragInsertPosition | null>(null);
const [viewport, setViewport] = useState<'desktop' | 'tablet' | 'mobile'>('desktop');
const [elementStyles, setElementStyles] = useState<Record<string, any>>({});
const [showStylePanel, setShowStylePanel] = useState(false);
@@ -163,6 +166,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const [pendingInsertIndex, setPendingInsertIndex] = useState<number | null>(null);
const [containerGridCols, setContainerGridCols] = useState<number>(0);
const elementOrderRef = useRef<string[]>([]);
const draggedElementRef = useRef<string | null>(null);
useEffect(() => { elementOrderRef.current = elementOrder; }, [elementOrder]);
const applyVisualReorderRef = useRef<(order: string[]) => void>(() => {});
@@ -221,6 +225,53 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
return getDefaultVariant(elementName);
}, [getAvailableVariants, getDefaultVariant]);
const getDropPlacement = useCallback((clientY: number, rect: Pick<DOMRect, 'top' | 'height'>): DragInsertPosition => {
return clientY < rect.top + (rect.height / 2) ? 'before' : 'after';
}, []);
const reorderElements = useCallback((
currentOrder: string[],
draggedName: string,
targetName: string,
placement: DragInsertPosition = 'before'
) => {
const draggedIndex = currentOrder.indexOf(draggedName);
const targetIndex = currentOrder.indexOf(targetName);
if (draggedIndex === -1 || targetIndex === -1 || draggedName === targetName) {
return currentOrder;
}
const nextOrder = [...currentOrder];
nextOrder.splice(draggedIndex, 1);
const adjustedTargetIndex = nextOrder.indexOf(targetName);
const insertionIndex = placement === 'after' ? adjustedTargetIndex + 1 : adjustedTargetIndex;
nextOrder.splice(Math.max(0, insertionIndex), 0, draggedName);
const unchanged = nextOrder.length === currentOrder.length && nextOrder.every((name, index) => name === currentOrder[index]);
return unchanged ? currentOrder : nextOrder;
}, []);
const resetDragState = useCallback(() => {
draggedElementRef.current = null;
setDraggedElement(null);
setDragOverElement(null);
setDragOverPlacement(null);
}, []);
const commitElementOrderChange = useCallback((nextOrder: string[]) => {
setElementOrder(nextOrder);
setHasChanges(true);
if (isEditing) {
requestAnimationFrame(() => {
applyVisualReorderRef.current(nextOrder);
window.dispatchEvent(new CustomEvent('myuibrix-reorder', {
detail: { order: nextOrder, previewMode: true }
}));
});
}
}, [isEditing]);
// Draggable panel handlers
const handlePanelMouseDown = useCallback((panelName: string, e: React.MouseEvent) => {
// Only allow dragging from header area
@@ -681,6 +732,8 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const overlay = document.createElement('div');
overlay.className = 'elementor-overlay';
overlay.dataset.elementName = elementName;
overlay.setAttribute('data-editor-overlay', elementName);
overlay.style.cssText = `
position: absolute;
top: 0;
@@ -732,6 +785,31 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
pointer-events: auto;
`;
const dragHandleBtn = document.createElement('button');
dragHandleBtn.textContent = '::';
dragHandleBtn.title = 'Přetáhnout pro změnu pořadí';
dragHandleBtn.setAttribute('aria-label', `Přetáhnout ${elementName}`);
dragHandleBtn.setAttribute('data-editor-drag-handle', elementName);
dragHandleBtn.style.cssText = `
background: ${secondaryColor};
color: ${clubTheme.textOnSecondary || 'white'};
border: none;
width: 32px;
height: 32px;
border-radius: 6px;
cursor: grab;
font-size: 14px;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
transition: transform 0.2s;
`;
dragHandleBtn.draggable = true;
dragHandleBtn.onmouseover = () => dragHandleBtn.style.transform = 'scale(1.1)';
dragHandleBtn.onmouseout = () => dragHandleBtn.style.transform = 'scale(1)';
// Edit button
const editBtn = document.createElement('button');
editBtn.innerHTML = '⚙️';
@@ -842,6 +920,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}
// Use safeDOM to build overlay structure
safeDOM.appendChild(actionsBar, dragHandleBtn);
safeDOM.appendChild(actionsBar, editBtn);
safeDOM.appendChild(actionsBar, moveUpBtn);
safeDOM.appendChild(actionsBar, moveDownBtn);
@@ -866,6 +945,24 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
return;
}
const applyOverlayIdleState = () => {
overlay.style.boxShadow = '';
if (selectedElement !== elementName) {
overlay.style.border = `2px dashed ${primaryColor}`;
overlay.style.background = `${primaryColor}15`;
}
};
const applyOverlayDropIndicator = (placement: DragInsertPosition) => {
overlay.style.border = `3px solid ${secondaryColor}`;
overlay.style.background = `${secondaryColor}18`;
overlay.style.boxShadow = placement === 'before'
? `inset 0 4px 0 ${secondaryColor}`
: `inset 0 -4px 0 ${secondaryColor}`;
setDragOverElement(elementName);
setDragOverPlacement(placement);
};
// Click to auto-select and open style panel
overlay.addEventListener('click', (e) => {
// Don't trigger if clicking on action buttons
@@ -895,6 +992,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
overlay.addEventListener('mouseleave', () => {
setHoveredElement(null);
overlay.style.boxShadow = '';
if (selectedElement !== elementName) {
overlay.style.border = '2px dashed transparent';
overlay.style.background = 'transparent';
@@ -958,57 +1056,77 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
// per-column insert handled by elementor-col-picker buttons above
// Make overlay draggable
overlay.draggable = true;
overlay.addEventListener('dragstart', (e) => {
dragHandleBtn.addEventListener('dragstart', (e) => {
e.stopPropagation();
try { (e as DragEvent).dataTransfer?.setData('text/plain', elementName); } catch {}
try {
if ((e as DragEvent).dataTransfer) {
(e as DragEvent).dataTransfer!.effectAllowed = 'move';
}
} catch {}
draggedElementRef.current = elementName;
setDraggedElement(elementName);
setDragOverElement(null);
setDragOverPlacement(null);
dragHandleBtn.style.cursor = 'grabbing';
overlay.style.opacity = '0.5';
});
overlay.addEventListener('dragend', (e) => {
dragHandleBtn.addEventListener('dragend', (e) => {
e.stopPropagation();
dragHandleBtn.style.cursor = 'grab';
overlay.style.opacity = '1';
setDraggedElement(null);
overlay.style.boxShadow = '';
resetDragState();
});
overlay.addEventListener('dragenter', (e) => {
e.preventDefault();
e.stopPropagation();
const activeDragged = draggedElementRef.current;
if (activeDragged && activeDragged !== elementName) {
const placement = getDropPlacement((e as DragEvent).clientY, overlay.getBoundingClientRect());
applyOverlayDropIndicator(placement);
}
});
overlay.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
try { (e as DragEvent).dataTransfer!.dropEffect = 'move'; } catch {}
if (draggedElement && draggedElement !== elementName) {
overlay.style.border = `3px solid ${secondaryColor}`;
setDragOverElement(elementName);
const activeDragged = draggedElementRef.current;
if (activeDragged && activeDragged !== elementName) {
const placement = getDropPlacement((e as DragEvent).clientY, overlay.getBoundingClientRect());
applyOverlayDropIndicator(placement);
}
});
overlay.addEventListener('dragleave', (e) => {
e.stopPropagation();
if (selectedElement !== elementName) {
overlay.style.border = `2px dashed ${primaryColor}`;
const nextTarget = document.elementFromPoint((e as DragEvent).clientX, (e as DragEvent).clientY);
if (nextTarget && overlay.contains(nextTarget)) {
return;
}
applyOverlayIdleState();
setDragOverElement(null);
setDragOverPlacement(null);
});
overlay.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
if (draggedElement && draggedElement !== elementName) {
// Reorder elements
const newOrder = [...elementOrderRef.current];
const draggedIndex = newOrder.indexOf(draggedElement as string);
const targetIndex = newOrder.indexOf(elementName);
if (draggedIndex !== -1 && targetIndex !== -1) {
newOrder.splice(draggedIndex, 1);
newOrder.splice(targetIndex, 0, draggedElement as string);
setElementOrder(newOrder);
setHasChanges(true);
applyVisualReorderRef.current(newOrder);
const activeDragged = draggedElementRef.current;
if (activeDragged && activeDragged !== elementName) {
const placement = dragOverPlacement || getDropPlacement((e as DragEvent).clientY, overlay.getBoundingClientRect());
const newOrder = reorderElements(elementOrderRef.current, activeDragged, elementName, placement);
if (newOrder !== elementOrderRef.current) {
pushHistorySnapshot();
commitElementOrderChange(newOrder);
}
}
overlay.style.border = `2px dashed ${primaryColor}`;
applyOverlayIdleState();
resetDragState();
setDragOverElement(null);
});
});
@@ -1069,7 +1187,22 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
clearTimeout(debounceTimerRef.current);
}
};
}, [isEditing, selectedElement, pageType, elementOrder, visibleElements]);
}, [
isEditing,
selectedElement,
pageType,
elementOrder,
visibleElements,
containerGridCols,
primaryColor,
secondaryColor,
clubTheme.textOnSecondary,
getDropPlacement,
reorderElements,
resetDragState,
pushHistorySnapshot,
commitElementOrderChange,
]);
// Update selected element overlay styling
useEffect(() => {
@@ -1425,17 +1558,8 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const newOrder = [...elementOrder];
[newOrder[currentIndex - 1], newOrder[currentIndex]] = [newOrder[currentIndex], newOrder[currentIndex - 1]];
setElementOrder(newOrder);
setHasChanges(true);
// Trigger reorder event and apply visual reordering
if (isEditing) {
applyVisualReorder(newOrder);
window.dispatchEvent(new CustomEvent('myuibrix-reorder', {
detail: { order: newOrder, previewMode: true }
}));
}
}, [elementOrder, isEditing, applyVisualReorder]);
commitElementOrderChange(newOrder);
}, [elementOrder, commitElementOrderChange, pushHistorySnapshot]);
const handleMoveDown = useCallback((elementName: string) => {
pushHistorySnapshot();
@@ -1444,83 +1568,70 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const newOrder = [...elementOrder];
[newOrder[currentIndex], newOrder[currentIndex + 1]] = [newOrder[currentIndex + 1], newOrder[currentIndex]];
setElementOrder(newOrder);
setHasChanges(true);
// Trigger reorder event and apply visual reordering
if (isEditing) {
applyVisualReorder(newOrder);
window.dispatchEvent(new CustomEvent('myuibrix-reorder', {
detail: { order: newOrder, previewMode: true }
}));
}
}, [elementOrder, isEditing, applyVisualReorder]);
commitElementOrderChange(newOrder);
}, [elementOrder, commitElementOrderChange, pushHistorySnapshot]);
// Drag and drop handlers with improved state management
const handleDragStart = useCallback((elementName: string, e: React.DragEvent) => {
const handle = (e.target as HTMLElement | null)?.closest('[data-drag-handle="true"]');
if (!handle) {
e.preventDefault();
return;
}
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', elementName);
e.dataTransfer.setData('text/plain', elementName);
draggedElementRef.current = elementName;
setDraggedElement(elementName);
setDragOverElement(null);
setDragOverPlacement(null);
}, []);
const handleDragOver = useCallback((e: React.DragEvent, elementName: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (draggedElement !== elementName) {
const activeDragged = draggedElementRef.current;
if (activeDragged && activeDragged !== elementName) {
const placement = getDropPlacement(e.clientY, (e.currentTarget as HTMLElement).getBoundingClientRect());
setDragOverElement(elementName);
setDragOverPlacement(placement);
}
}, [draggedElement]);
}, [getDropPlacement]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
// Only clear if we're leaving to a non-child element
const relatedTarget = e.relatedTarget as HTMLElement;
if (!relatedTarget || !(e.currentTarget as HTMLElement).contains(relatedTarget)) {
setDragOverElement(null);
setDragOverPlacement(null);
}
}, []);
const handleDragEnd = useCallback(() => {
setDraggedElement(null);
setDragOverElement(null);
}, []);
resetDragState();
}, [resetDragState]);
const handleDrop = useCallback((e: React.DragEvent, targetElementName: string) => {
e.preventDefault();
if (!draggedElement || draggedElement === targetElementName) {
setDraggedElement(null);
setDragOverElement(null);
const activeDragged = draggedElementRef.current;
if (!activeDragged || activeDragged === targetElementName) {
resetDragState();
return;
}
const newOrder = [...elementOrder];
const draggedIndex = newOrder.indexOf(draggedElement);
const targetIndex = newOrder.indexOf(targetElementName);
if (draggedIndex === -1 || targetIndex === -1) {
setDraggedElement(null);
setDragOverElement(null);
const placement = dragOverPlacement || getDropPlacement(e.clientY, (e.currentTarget as HTMLElement).getBoundingClientRect());
const newOrder = reorderElements(elementOrder, activeDragged, targetElementName, placement);
if (newOrder === elementOrder) {
resetDragState();
return;
}
// Remove dragged element and insert at target position
newOrder.splice(draggedIndex, 1);
newOrder.splice(targetIndex, 0, draggedElement);
setElementOrder(newOrder);
setHasChanges(true);
setDraggedElement(null);
setDragOverElement(null);
// Apply visual reordering
if (isEditing) {
applyVisualReorder(newOrder);
window.dispatchEvent(new CustomEvent('myuibrix-reorder', {
detail: { order: newOrder, previewMode: true }
}));
}
}, [draggedElement, elementOrder, isEditing, applyVisualReorder]);
pushHistorySnapshot();
commitElementOrderChange(newOrder);
resetDragState();
}, [dragOverPlacement, elementOrder, getDropPlacement, reorderElements, pushHistorySnapshot, commitElementOrderChange, resetDragState]);
// Start with a blank layout: hide all elements and clear order
const handleStartBlank = useCallback(() => {
@@ -2661,6 +2772,8 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const isDragging = draggedElement === elementName;
const isDragOver = dragOverElement === elementName;
const isDragOverBefore = isDragOver && dragOverPlacement === 'before';
const isDragOverAfter = isDragOver && dragOverPlacement === 'after';
return (
<Box
@@ -2668,16 +2781,24 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
p={3}
borderRadius="lg"
border="2px"
borderColor={isDragOver ? primaryColor : isSelected ? secondaryColor : 'gray.200'}
borderColor={isSelected ? secondaryColor : isDragOver ? primaryColor : 'gray.200'}
bgGradient={isDragging ? 'linear(to-r, gray.100, gray.200)' : isSelected ? `linear(135deg, ${secondaryColor}15, ${secondaryColor}25)` : isVisible ? 'linear(to-r, white, gray.50)' : 'linear(to-r, gray.100, gray.150)'}
cursor={isDragging ? 'grabbing' : 'grab'}
opacity={isDragging ? 0.6 : isVisible ? 1 : 0.5}
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
transform={isDragOver ? 'scale(1.03) translateX(8px)' : undefined}
boxShadow={isSelected ? '0 4px 12px rgba(0,0,0,0.1)' : '0 2px 4px rgba(0,0,0,0.05)'}
transform={isDragOver ? 'scale(1.02) translateX(6px)' : undefined}
boxShadow={
isDragOverBefore
? `inset 0 4px 0 ${primaryColor}, 0 6px 16px rgba(0,0,0,0.12)`
: isDragOverAfter
? `inset 0 -4px 0 ${primaryColor}, 0 6px 16px rgba(0,0,0,0.12)`
: isSelected
? '0 4px 12px rgba(0,0,0,0.1)'
: '0 2px 4px rgba(0,0,0,0.05)'
}
_hover={{
borderColor: secondaryColor,
transform: isDragOver ? 'scale(1.03) translateX(8px)' : 'translateX(6px) translateY(-2px)',
transform: isDragOver ? 'scale(1.02) translateX(6px)' : 'translateX(6px) translateY(-2px)',
boxShadow: '0 6px 16px rgba(0,0,0,0.12)'
}}
draggable
@@ -2708,13 +2829,21 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
>
<Flex align="center" justify="space-between">
<HStack flex={1} spacing={2}>
<Icon
as={FaGripVertical}
boxSize={4}
color="gray.400"
cursor="grab"
_active={{ cursor: 'grabbing' }}
/>
<Box
data-drag-handle="true"
p={1.5}
borderRadius="md"
bg={isDragOver ? `${primaryColor}18` : 'gray.100'}
cursor={isDragging ? 'grabbing' : 'grab'}
_hover={{ bg: `${primaryColor}15` }}
>
<Icon
as={FaGripVertical}
boxSize={4}
color="gray.500"
pointerEvents="none"
/>
</Box>
<Box
p={2}
bg={isSelected ? secondaryColor : `${secondaryColor}20`}
@@ -0,0 +1,129 @@
import React from 'react';
type HomeCardsSkeletonProps = {
actionWidth?: number | string;
cardCount?: number;
cardHeight?: number | string;
columns?: string;
layout?: 'grid' | 'carousel' | 'list';
minCardWidth?: number | string;
titleWidth?: number | string;
};
const SkeletonBar: React.FC<{ height?: number | string; width?: number | string }> = ({
height = 14,
width = '100%',
}) => (
<div
className="skeleton"
style={{
width,
height,
borderRadius: 999,
}}
/>
);
const SectionHeadSkeleton: React.FC<{
actionWidth?: number | string;
titleWidth?: number | string;
}> = ({
actionWidth = 104,
titleWidth = 180,
}) => (
<div className="section-head" style={{ marginTop: 0 }}>
<SkeletonBar height={24} width={titleWidth} />
<SkeletonBar height={14} width={actionWidth} />
</div>
);
export const HomeHeroSkeleton: React.FC = () => (
<div className="hero-grid" aria-hidden="true">
<div className="hero-card big skeleton" style={{ borderRadius: 16 }} />
<div className="small-col">
<div className="hero-card small skeleton" style={{ borderRadius: 16 }} />
<div className="hero-card small skeleton" style={{ borderRadius: 16 }} />
</div>
</div>
);
export const HomeCardsSkeleton: React.FC<HomeCardsSkeletonProps> = ({
actionWidth,
cardCount = 3,
cardHeight = 220,
columns = 'repeat(3, minmax(0, 1fr))',
layout = 'grid',
minCardWidth = 260,
titleWidth,
}) => {
const containerStyle: React.CSSProperties =
layout === 'carousel'
? {
display: 'flex',
gap: 18,
overflow: 'hidden',
padding: '8px 2px 16px 2px',
}
: layout === 'list'
? {
display: 'grid',
gridTemplateColumns: '1fr',
gap: 12,
}
: {
display: 'grid',
gridTemplateColumns: columns,
gap: 12,
};
return (
<div aria-hidden="true">
<SectionHeadSkeleton actionWidth={actionWidth} titleWidth={titleWidth} />
<div style={containerStyle}>
{Array.from({ length: cardCount }).map((_, index) => (
<div
key={index}
className="card skeleton"
style={{
borderRadius: 16,
flex: layout === 'carousel' ? '0 0 auto' : undefined,
height: cardHeight,
minWidth: layout === 'carousel' ? minCardWidth : undefined,
}}
/>
))}
</div>
</div>
);
};
export const HomeStandingsSkeleton: React.FC = () => (
<div aria-hidden="true">
<SectionHeadSkeleton actionWidth={92} titleWidth={140} />
<div className="table-card">
<div className="standings">
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className="standing-row skeleton"
style={{ borderRadius: 12 }}
/>
))}
</div>
</div>
</div>
);
export const HomeSponsorsSkeleton: React.FC = () => (
<div aria-hidden="true">
<div className="sponsors-grid">
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className="sponsor-tile skeleton"
style={{ minHeight: 90, borderRadius: 12 }}
/>
))}
</div>
</div>
);
+4 -2
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { FiChevronLeft, FiChevronRight } from 'react-icons/fi';
import { TeamLogo } from '../common/TeamLogo';
import MatchCountdownText from '../common/MatchCountdownText';
import { sanitizeClubName } from '../../utils/url';
export type NextMatchData = {
@@ -17,11 +18,12 @@ const NextMatch: React.FC<{
data: NextMatchData | null;
competitionName?: string;
countdown?: string;
kickoffIso?: string | null;
onPrev?: () => void;
onNext?: () => void;
onOpen?: () => void;
elementProps?: any;
}> = ({ data, competitionName, countdown, onPrev, onNext, onOpen, elementProps }) => {
}> = ({ data, competitionName, countdown, kickoffIso = null, onPrev, onNext, onOpen, elementProps }) => {
const show = data;
return (
<section
@@ -63,7 +65,7 @@ const NextMatch: React.FC<{
{competitionName && (
<div style={{ fontSize: '0.8rem', opacity: 0.85, marginBottom: 4 }}>{competitionName}</div>
)}
{countdown || '—'}
{countdown ? countdown : <MatchCountdownText targetDate={kickoffIso} />}
<div style={{ fontSize: '0.8rem', opacity: 0.85 }}>Začátek zápasu</div>
</div>
+167 -77
View File
@@ -20,6 +20,23 @@ export interface NavbarData {
hasGallery: boolean;
}
type CachedNavigationData = Pick<NavbarData, 'categories' | 'dynamicNavItems'>;
type CachedAvailabilityData = Pick<
NavbarData,
'hasTables' | 'hasActivities' | 'hasPlayers' | 'hasArticles' | 'hasVideos' | 'hasGallery'
>;
type CacheEntry<T> = {
expiresAt: number;
value: T;
};
const NAVBAR_CACHE_TTL_MS = 5 * 60 * 1000;
let cachedNavigationData: CacheEntry<CachedNavigationData> | null = null;
let navigationRequest: Promise<CachedNavigationData> | null = null;
let cachedAvailabilityData: CacheEntry<CachedAvailabilityData> | null = null;
let availabilityRequest: Promise<CachedAvailabilityData> | null = null;
export const useNavbarData = (isAdmin: boolean, settings?: any): NavbarData => {
const [categories, setCategories] = useState<Category[] | null>(null);
const [dynamicNavItems, setDynamicNavItems] = useState<NavigationItem[]>([]);
@@ -30,66 +47,93 @@ export const useNavbarData = (isAdmin: boolean, settings?: any): NavbarData => {
const [hasArticles, setHasArticles] = useState<boolean>(false);
const [hasVideos, setHasVideos] = useState<boolean>(false);
const [hasGallery, setHasGallery] = useState<boolean>(false);
const settingsCategories = Array.isArray(settings?.categories) ? (settings.categories as Category[]) : null;
const settingsHasVideos = Boolean(
settings?.youtube_url ||
settings?.videos_items?.length ||
settings?.videos?.length
);
const settingsHasGallery = Boolean(settings?.gallery_url || settings?.zonerama_url);
// Combined data loading effect
useEffect(() => {
let active = true;
const loadAllData = async () => {
try {
// Load navigation and categories in parallel
const [navItems, cats] = await Promise.all([
const applyNavigationData = (data: CachedNavigationData) => {
setDynamicNavItems(data.dynamicNavItems);
setCategories(
Array.isArray(data.categories) && data.categories.length > 0
? data.categories
: settingsCategories
);
setNavLoading(false);
};
const loadNavigationData = async () => {
if (cachedNavigationData && cachedNavigationData.expiresAt > Date.now()) {
return cachedNavigationData.value;
}
if (!navigationRequest) {
navigationRequest = Promise.all([
getNavigationItems().catch(() => []),
getCategories().catch(() => [])
]);
getCategories().catch(() => []),
])
.then(([navItems, cats]) => {
const publicItems = Array.isArray(navItems)
? navItems.filter((item) => !item.requires_admin)
: [];
if (active) {
// Process navigation
const publicItems = Array.isArray(navItems)
? navItems.filter(item => !item.requires_admin)
: [];
// Auto-seed if navigation is empty (only if user is authenticated as admin)
if (publicItems.length === 0 && isAdmin) {
try {
console.log('Navigation empty, auto-seeding...');
// Note: seedDefaultNavigation() would need to be imported
// For now, continue with empty navigation
} catch (seedError) {
console.error('Auto-seed failed:', seedError);
if (publicItems.length === 0 && isAdmin) {
try {
console.log('Navigation empty, auto-seeding...');
} catch (seedError) {
console.error('Auto-seed failed:', seedError);
}
}
}
setDynamicNavItems(publicItems);
// Process categories
if (Array.isArray(cats) && cats.length > 0) {
setCategories(cats);
} else if (Array.isArray(settings?.categories)) {
setCategories(settings.categories as any);
} else {
setCategories(null);
}
const value: CachedNavigationData = {
dynamicNavItems: publicItems,
categories: Array.isArray(cats) && cats.length > 0 ? cats : null,
};
cachedNavigationData = {
expiresAt: Date.now() + NAVBAR_CACHE_TTL_MS,
value,
};
return value;
})
.finally(() => {
navigationRequest = null;
});
}
return navigationRequest;
};
const run = async () => {
try {
const data = await loadNavigationData();
if (active) {
applyNavigationData(data);
}
} catch (error) {
console.error('Failed to load navigation/categories:', error);
if (active) {
setDynamicNavItems([]);
setCategories(Array.isArray(settings?.categories) ? settings.categories as any : null);
setCategories(settingsCategories);
setNavLoading(false);
}
} finally {
if (active) setNavLoading(false);
}
};
loadAllData();
run();
return () => { active = false };
}, [isAdmin, settings?.categories]);
}, [isAdmin, settingsCategories]);
// Load content availability data in parallel
useEffect(() => {
let disposed = false;
const resolveBackendUrl = (path: string) => {
try {
if (/^https?:\/\//i.test(path)) return path;
@@ -101,47 +145,93 @@ export const useNavbarData = (isAdmin: boolean, settings?: any): NavbarData => {
} catch { return path; }
};
const loadContentData = async () => {
try {
// Load all content availability checks in parallel
const [
tablesResponse,
events,
players,
articlesResponse,
youtube,
manifest
] = await Promise.allSettled([
const applyAvailabilityData = (data: CachedAvailabilityData) => {
setHasTables(data.hasTables);
setHasActivities(data.hasActivities);
setHasPlayers(data.hasPlayers);
setHasArticles(data.hasArticles);
setHasVideos(data.hasVideos || settingsHasVideos);
setHasGallery(data.hasGallery || settingsHasGallery);
};
const loadAvailabilityData = async () => {
if (cachedAvailabilityData && cachedAvailabilityData.expiresAt > Date.now()) {
return cachedAvailabilityData.value;
}
if (!availabilityRequest) {
availabilityRequest = Promise.allSettled([
fetch(resolveBackendUrl('/cache/prefetch/facr_tables.json'), { cache: 'no-cache' }),
getEvents().catch(() => []),
getPlayers().catch(() => []),
getArticles({ page: 1, page_size: 1, published: true }).catch(() => ({ total: 0 })),
getCachedYouTube().catch(() => null),
getZoneramaManifestWithFallbacks().catch(() => [])
]);
getZoneramaManifestWithFallbacks().catch(() => []),
])
.then(async ([tablesResponse, events, players, articlesResponse, youtube, manifest]) => {
let hasTables = false;
if (!disposed) {
// Process tables
if (tablesResponse.status === 'fulfilled') {
const res = tablesResponse.value;
if (res.ok) {
const json = await res.json();
const anyRows = Array.isArray(json?.competitions) &&
json.competitions.some((c: any) => Array.isArray(c?.table?.overall) && c.table.overall.length > 0);
setHasTables(!!anyRows);
} else {
setHasTables(false);
if (tablesResponse.status === 'fulfilled') {
const res = tablesResponse.value;
if (res.ok) {
const json = await res.json();
hasTables =
Array.isArray(json?.competitions) &&
json.competitions.some(
(competition: any) =>
Array.isArray(competition?.table?.overall) &&
competition.table.overall.length > 0
);
}
}
} else {
setHasTables(false);
}
// Process other content with proper type guards
setHasActivities(events.status === 'fulfilled' && Array.isArray(events.value) && events.value.length > 0);
setHasPlayers(players.status === 'fulfilled' && Array.isArray(players.value) && players.value.length > 0);
setHasArticles(articlesResponse.status === 'fulfilled' && typeof articlesResponse.value === 'object' && articlesResponse.value !== null && 'total' in articlesResponse.value && (articlesResponse.value as any).total > 0);
setHasVideos(youtube.status === 'fulfilled' && youtube.value !== null && typeof youtube.value === 'object' && 'videos' in youtube.value && Array.isArray((youtube.value as any).videos) && (youtube.value as any).videos.length > 0);
setHasGallery(manifest.status === 'fulfilled' && Array.isArray(manifest.value) && manifest.value.length > 0);
const value: CachedAvailabilityData = {
hasTables,
hasActivities:
events.status === 'fulfilled' && Array.isArray(events.value) && events.value.length > 0,
hasPlayers:
players.status === 'fulfilled' &&
Array.isArray(players.value) &&
players.value.length > 0,
hasArticles:
articlesResponse.status === 'fulfilled' &&
typeof articlesResponse.value === 'object' &&
articlesResponse.value !== null &&
'total' in articlesResponse.value &&
(articlesResponse.value as any).total > 0,
hasVideos:
youtube.status === 'fulfilled' &&
youtube.value !== null &&
typeof youtube.value === 'object' &&
'videos' in youtube.value &&
Array.isArray((youtube.value as any).videos) &&
(youtube.value as any).videos.length > 0,
hasGallery:
manifest.status === 'fulfilled' &&
Array.isArray(manifest.value) &&
manifest.value.length > 0,
};
cachedAvailabilityData = {
expiresAt: Date.now() + NAVBAR_CACHE_TTL_MS,
value,
};
return value;
})
.finally(() => {
availabilityRequest = null;
});
}
return availabilityRequest;
};
const run = async () => {
try {
const data = await loadAvailabilityData();
if (!disposed) {
applyAvailabilityData(data);
}
} catch (error) {
console.error('Failed to load content data:', error);
@@ -150,15 +240,15 @@ export const useNavbarData = (isAdmin: boolean, settings?: any): NavbarData => {
setHasActivities(false);
setHasPlayers(false);
setHasArticles(false);
setHasVideos(false);
setHasGallery(false);
setHasVideos(settingsHasVideos);
setHasGallery(settingsHasGallery);
}
}
};
loadContentData();
run();
return () => { disposed = true; };
}, []);
}, [settingsHasGallery, settingsHasVideos]);
return {
categories,
+33 -18
View File
@@ -1,5 +1,21 @@
import { useState, useEffect } from 'react';
import { PageElementConfig, getPageElementConfigs } from '../services/pageElements';
import { getPageElementConfigs } from '../services/pageElementsPublic';
const detectEditMode = () => {
try {
if (typeof document !== 'undefined' && document.body?.classList?.contains('myuibrix-edit-mode')) {
return true;
}
if (typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
return params.get('myuibrix') === 'edit';
}
} catch {
return false;
}
return false;
};
export const usePageElementConfig = (pageType: string, elementName: string, defaultVariant: string = 'unified') => {
const [variant, setVariant] = useState<string>(defaultVariant);
@@ -10,7 +26,7 @@ export const usePageElementConfig = (pageType: string, elementName: string, defa
const loadConfig = async () => {
try {
const configs = await getPageElementConfigs(pageType);
const configs = await getPageElementConfigs(pageType, { force: detectEditMode() });
if (active) {
const config = configs.find(c => c.element_name === elementName);
if (config) {
@@ -46,6 +62,7 @@ export const useAllPageElementConfigs = (pageType: string) => {
useEffect(() => {
let active = true;
const isEditingMode = detectEditMode();
// Helper function to apply DOM order
const applyDOMOrder = (order: string[]) => {
@@ -257,10 +274,9 @@ body [data-element="${name}"],
styleEl.textContent = `/* MyUIbrix Dynamic Styles - Auto-generated */\n${cssBlocks.join('\n\n')}`;
// Force browser to recalculate styles
if (typeof document !== 'undefined') {
// Trigger a reflow to ensure styles are applied immediately
document.body.offsetHeight; // Read operation forces reflow
// Immediate reflow is only useful in live preview mode and is expensive in production.
if (isEditingMode && typeof document !== 'undefined') {
document.body.offsetHeight;
}
} catch (e) {
console.error('[MyUIbrix] Style injection failed:', e);
@@ -269,7 +285,7 @@ body [data-element="${name}"],
const loadConfigs = async () => {
try {
const data = await getPageElementConfigs(pageType);
const data = await getPageElementConfigs(pageType, { force: isEditingMode });
if (active) {
const configMap: Record<string, string> = {};
const visMap: Record<string, boolean> = {};
@@ -336,17 +352,6 @@ body [data-element="${name}"],
// Inject style properties so they apply even without inline spreading
updateInjectedStyleProps(stylesMap);
// Apply initial order to DOM only in editor/preview mode
const isEditingMode = (() => {
try {
if (typeof document !== 'undefined' && (document.body?.classList?.contains('myuibrix-edit-mode'))) return true;
const params = new URLSearchParams(window.location.search);
return params.get('myuibrix') === 'edit';
} catch {
return false;
}
})();
if (order.length > 0 && isEditingMode) {
requestAnimationFrame(() => {
applyDOMOrder(order);
@@ -364,6 +369,16 @@ body [data-element="${name}"],
loadConfigs();
if (!isEditingMode) {
return () => {
active = false;
try {
const s = document.getElementById('myuibrix-style-props');
if (s) s.remove();
} catch {}
};
}
// Listen for live updates from MyUIbrix editor (ONLY in preview mode)
const handleMyUIbrixChange = ((event: CustomEvent) => {
const { elementName, variant, visible, previewMode, timestamp } = event.detail;
+45 -16
View File
@@ -10,6 +10,9 @@ interface UmamiConfig {
script_url: string;
}
let cachedUmamiConfig: UmamiConfig | null | undefined;
let umamiConfigPromise: Promise<UmamiConfig | null> | null = null;
// Extend window object to include umami
declare global {
interface Window {
@@ -31,31 +34,57 @@ export const useUmami = () => {
pathname === '/setup';
};
const fetchConfigOnce = async () => {
if (cachedUmamiConfig !== undefined) {
return cachedUmamiConfig;
}
if (!umamiConfigPromise) {
umamiConfigPromise = axios
.get(`${API_URL}/insights/config`)
.then((response) => {
cachedUmamiConfig = response.data as UmamiConfig;
return cachedUmamiConfig;
})
.catch((error) => {
console.error('Failed to load Umami config:', error);
cachedUmamiConfig = null;
return null;
})
.finally(() => {
umamiConfigPromise = null;
});
}
return umamiConfigPromise;
};
useEffect(() => {
// Don't load Umami for admin pages
if (isAdminRoute(location.pathname)) {
console.log('Umami tracking disabled for admin pages');
return;
}
// Fetch Umami configuration from backend
const fetchConfig = async () => {
try {
const response = await axios.get(`${API_URL}/insights/config`);
const umamiConfig = response.data as UmamiConfig;
setConfig(umamiConfig);
let active = true;
// If enabled and not already loaded, inject the script
if (umamiConfig.enabled && umamiConfig.website_id && !isLoaded) {
loadUmamiScript(umamiConfig.script_url, umamiConfig.website_id);
}
} catch (error) {
console.error('Failed to load Umami config:', error);
const ensureConfig = async () => {
const umamiConfig = await fetchConfigOnce();
if (!active || !umamiConfig) {
return;
}
setConfig(umamiConfig);
if (umamiConfig.enabled && umamiConfig.website_id && !isLoaded) {
loadUmamiScript(umamiConfig.script_url, umamiConfig.website_id);
}
};
fetchConfig();
}, [location.pathname]);
ensureConfig();
return () => {
active = false;
};
}, [isLoaded, location.pathname]);
// Track page views when location changes (skip admin routes for Umami)
useEffect(() => {
+1 -1
View File
@@ -14,7 +14,7 @@ import './styles/custom-editor.css';
import './styles/public-rich-content.css';
// Import i18n configuration
import './i18n';
import { theme } from './App';
import { theme } from './theme/siteTheme';
import AppLazy from './App.lazy';
import { ColorModeScript } from '@chakra-ui/react';
import { HelmetProvider } from 'react-helmet-async';
+4 -2
View File
@@ -4,11 +4,13 @@ import { Box, Container, Heading, Text, Stack, Image, SimpleGrid, Divider } from
import { usePublicSettings } from '../hooks/usePublicSettings';
import { useTranslation } from 'react-i18next';
import { assetUrl } from '../utils/url';
import { sanitizeRichHtml } from '../utils/sanitizeHtml';
import NewsletterCTA from '../components/common/NewsletterCTA';
const ClubPage: React.FC = () => {
const { data: settings } = usePublicSettings();
const { t } = useTranslation();
const aboutHtml = React.useMemo(() => sanitizeRichHtml(settings?.about_html), [settings?.about_html]);
return (
<MainLayout>
@@ -24,9 +26,9 @@ const ClubPage: React.FC = () => {
</Box>
</Stack>
{settings?.about_html ? (
{aboutHtml ? (
<Box>
<Box className="prose" color="gray.800" dangerouslySetInnerHTML={{ __html: settings.about_html as any }} />
<Box className="prose" color="gray.800" dangerouslySetInnerHTML={{ __html: aboutHtml }} />
</Box>
) : (
<Box>
+29 -65
View File
@@ -31,7 +31,14 @@ import MatchModal from '../components/home/MatchModal';
import { useAllPageElementConfigs } from '../hooks/usePageElementConfig';
import { API_URL } from '../services/api';
import { TeamLogo } from '../components/common/TeamLogo';
import MatchCountdownText, { buildKickoffIso } from '../components/common/MatchCountdownText';
import ClubHeroTopbar from '../components/home/ClubHeroTopbar';
import {
HomeCardsSkeleton,
HomeHeroSkeleton,
HomeSponsorsSkeleton,
HomeStandingsSkeleton,
} from '../components/home/HomeLoadingSkeletons';
import NewsList from '../components/pack/NewsList';
const StandingsCard = React.lazy(() => import('../components/pack/StandingsCard'));
import NextMatch from '../components/pack/NextMatch';
@@ -75,7 +82,6 @@ const HomePage: React.FC = () => {
// Local state now starts empty; filled by FACR/cache/live APIs
const [news, setNews] = useState<NewsItem[]>([]);
const [matches, setMatches] = useState<MatchItem[]>([]);
const [countdown, setCountdown] = useState<string>('');
const [clubName, setClubName] = useState<string>('');
const [clubLogo, setClubLogo] = useState<string>('');
const [standings, setStandings] = useState<any[]>([]);
@@ -771,49 +777,6 @@ const HomePage: React.FC = () => {
// MyUIbrix events are handled by useAllPageElementConfigs hook
// It automatically updates getVariant() and isVisible() when changes occur in edit mode
// Countdown to next match (uses selected competition upcoming if available)
useEffect(() => {
const getUpcomingForComp = (c: any) => {
const items = Array.isArray(c?.matches) ? c.matches : [];
const future = items
.map((m: any) => ({ m, t: new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime() }))
.filter((x: any) => !isNaN(x.t) && x.t > Date.now())
.sort((a: any, b: any) => a.t - b.t);
return future[0]?.m || null;
};
const getNextKickoff = () => {
if (facrCompetitions.length) {
const sel = facrCompetitions[Math.max(0, Math.min(matchesTab, facrCompetitions.length - 1))];
const up = getUpcomingForComp(sel);
if (up) {
const iso = `${up.date}T${(up.time || '00:00')}:00`;
const d = new Date(iso);
return isNaN(d.getTime()) ? null : d;
}
}
if (!matches.length) return null;
const m = matches[0];
const iso = `${m.date}T${(m.time || '00:00')}:00`;
const d = new Date(iso);
return isNaN(d.getTime()) ? null : d;
};
const update = () => {
const next = getNextKickoff();
if (!next) { setCountdown(''); return; }
const diff = next.getTime() - Date.now();
if (diff <= 0) { setCountdown('Začátek'); return; }
const s = Math.floor(diff / 1000);
const days = Math.floor(s / 86400);
const hrs = Math.floor((s % 86400) / 3600);
const mins = Math.floor((s % 3600) / 60);
const secs = s % 60;
setCountdown(`${days} d ${hrs} h ${mins} m ${secs} s`);
};
update();
const id = setInterval(update, 1000);
return () => clearInterval(id);
}, [matches, facrCompetitions, matchesTab]);
useEffect(() => {
let active = true;
(async () => {
@@ -1181,7 +1144,14 @@ const HomePage: React.FC = () => {
<div>{show?.home || clubName}</div>
</div>
<div className="meta">
{isFuture ? <><div style={{ fontWeight: 800 }}>{countdown || '—'}</div><div>Začátek</div></> : <div style={{ fontWeight: 800 }}>{show?.score || '—'}</div>}
{isFuture ? (
<>
<div style={{ fontWeight: 800 }}>
<MatchCountdownText targetDate={buildKickoffIso(show?.date, show?.time)} />
</div>
<div>Začátek</div>
</>
) : <div style={{ fontWeight: 800 }}>{show?.score || '—'}</div>}
</div>
<div className="team">
<img src={assetUrl(show?.away_logo_url) || '/images/club-opponent.svg'} alt="Hosté" />
@@ -1682,7 +1652,7 @@ const HomePage: React.FC = () => {
{getVariant('hero', heroStyle) === 'scroller' && isVisible('hero', true) && (
<section key={`hero-scroller-${refreshKey}`} data-element="hero" data-variant={getVariant('hero', heroStyle)} style={{ position: 'relative', ...getStyles('hero') }}>
<Suspense fallback={<div style={{ minHeight: 240 }} />}>
<Suspense fallback={<HomeHeroSkeleton />}>
<BlogCardsScroller />
</Suspense>
</section>
@@ -1694,7 +1664,7 @@ const HomePage: React.FC = () => {
data-variant={getVariant('hero', heroStyle)}
style={{ position: 'relative', ...getStyles('hero') }}
>
<Suspense fallback={<div style={{ minHeight: 280 }} />}>
<Suspense fallback={<HomeHeroSkeleton />}>
<BlogSwiper fallbackArticles={heroFallbackArticles} />
</Suspense>
</section>
@@ -1736,7 +1706,7 @@ const HomePage: React.FC = () => {
<NextMatch
data={show}
competitionName={comp?.name}
countdown={countdown}
kickoffIso={buildKickoffIso(show?.date, show?.time)}
onPrev={() => { setNextCompIdx(prevIdx); setMatchesTab(prevIdx); }}
onNext={() => { setNextCompIdx(nextIdx); setMatchesTab(nextIdx); }}
onOpen={handleNextMatchClick}
@@ -1769,7 +1739,7 @@ const HomePage: React.FC = () => {
away: next?.awayTeam || 'Soupeř',
away_logo_url: next?.awayLogoURL,
}}
countdown={countdown}
kickoffIso={buildKickoffIso(next?.date, next?.time)}
elementProps={{ 'data-element': 'matches', 'data-variant': getVariant('matches', 'compact'), 'aria-live': 'polite', style: { position: 'relative', ...getStyles('matches') } }}
/>
</div>
@@ -1786,7 +1756,7 @@ const HomePage: React.FC = () => {
{/* Matches slider with scores by competition (moved after news+tables) */}
{facrCompetitions.length > 0 && (
defer ? (
<Suspense fallback={null}>
<Suspense fallback={<HomeCardsSkeleton layout="carousel" cardCount={3} cardHeight={160} minCardWidth={340} titleWidth={140} actionWidth={96} />}>
<MatchesSlider
key={`matches-slider-${refreshKey}-${getVariant('matches-slider', 'carousel')}`}
comps={facrCompetitions as any}
@@ -1800,7 +1770,7 @@ const HomePage: React.FC = () => {
elementProps={{ 'data-element': 'matches-slider', 'data-variant': getVariant('matches-slider', 'carousel'), style: { position: 'relative', ...getStyles('matches-slider') } }}
/>
</Suspense>
) : null
) : <HomeCardsSkeleton layout="carousel" cardCount={3} cardHeight={160} minCardWidth={340} titleWidth={140} actionWidth={96} />
)}
{facrCompetitions.length === 0 && isLoading && (
@@ -1871,7 +1841,7 @@ const HomePage: React.FC = () => {
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>{t('nav.view_all')} <FiArrowRight size={14} /></a>
</div>
{defer ? (
<Suspense fallback={null}>
<Suspense fallback={<HomeStandingsSkeleton />}>
<StandingsCard
variant={((): 'logos'|'plain' => { const v = getVariant('table_rows', 'logos'); return v === 'plain' ? 'plain' : 'logos'; })()}
rows={(matchingStanding?.table || matchingStanding?.rows || []) as any}
@@ -2040,10 +2010,10 @@ const HomePage: React.FC = () => {
<section key={`gallery-${refreshKey}-${getVariant('gallery', 'grid')}`} data-element="gallery" data-variant={getVariant('gallery', 'grid')} aria-labelledby="home-gallery-heading" style={{ marginTop: 32, marginBottom: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '700px', ...getStyles('gallery') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
{defer ? (
<Suspense fallback={null}>
<Suspense fallback={<HomeCardsSkeleton cardCount={3} cardHeight={300} titleWidth={140} actionWidth={96} />}>
<GallerySection zoneramaUrl={galleryUrl} />
</Suspense>
) : null}
) : <HomeCardsSkeleton cardCount={3} cardHeight={300} titleWidth={140} actionWidth={96} />}
</div>
</section>
)}
@@ -2053,7 +2023,7 @@ const HomePage: React.FC = () => {
<section key={`videos-${refreshKey}-${getVariant('videos', 'carousel')}`} data-element="videos" data-variant={getVariant('videos', 'carousel')} aria-labelledby="home-videos-heading" style={{ marginTop: 32, marginBottom: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '700px', ...getStyles('videos') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
{defer ? (
<Suspense fallback={null}>
<Suspense fallback={<HomeCardsSkeleton layout="list" cardCount={3} cardHeight={240} titleWidth={110} actionWidth={110} />}>
<VideosSection
key={`videos-comp-${refreshKey}-${getVariant('videos', 'carousel')}`}
variant={(getVariant('videos', 'carousel') as any) as 'grid' | 'carousel'}
@@ -2080,7 +2050,7 @@ const HomePage: React.FC = () => {
<section key={`merch-${refreshKey}-${getVariant('merch', 'grid')}`} data-element="merch" data-variant={getVariant('merch', 'grid')} aria-labelledby="home-merch-heading" style={{ marginTop: 24, marginBottom: 24, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '600px', ...getStyles('merch') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
{defer ? (
<Suspense fallback={null}>
<Suspense fallback={<HomeCardsSkeleton cardCount={5} cardHeight={180} titleWidth={170} actionWidth={96} />}>
<MerchSection variant={(getVariant('merch', 'grid') as any) as 'grid' | 'carousel' | 'featured' | 'list'} />
</Suspense>
) : (
@@ -2105,7 +2075,7 @@ const HomePage: React.FC = () => {
<section key={`poll-${refreshKey}-${getVariant('poll', 'vertical')}`} data-element="poll" data-variant={getVariant('poll', 'vertical')} aria-label="Anketa" style={{ marginTop: 32, marginBottom: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '500px', ...getStyles('poll') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
{defer ? (
<Suspense fallback={null}>
<Suspense fallback={<HomeCardsSkeleton layout="grid" columns="1fr" cardCount={1} cardHeight={320} titleWidth={140} actionWidth={88} />}>
<PollsWidget featuredOnly={false} maxPolls={1} title="Anketa" />
</Suspense>
) : (
@@ -2132,7 +2102,7 @@ const HomePage: React.FC = () => {
<section key={`newsletter-${refreshKey}-${getVariant('newsletter', 'default')}`} data-element="newsletter" data-variant={getVariant('newsletter', 'default')} className="newsletter-cta" aria-label="Přihlášení k newsletteru" style={{ marginTop: 24, marginBottom: 24, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '420px', ...getStyles('newsletter') }}>
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
{defer ? (
<Suspense fallback={null}>
<Suspense fallback={<HomeCardsSkeleton layout="grid" columns="1fr" cardCount={1} cardHeight={280} titleWidth={180} actionWidth={120} />}>
<NewsletterSubscribe />
</Suspense>
) : (
@@ -2220,13 +2190,7 @@ const HomePage: React.FC = () => {
<div className="section-head">
<h3 id="home-sponsors-heading">{t('nav.sponsors')}</h3>
</div>
{isLoading && ordered.length === 0 && (
<div className="sponsors-grid">
{[1,2,3,4,5,6,7,8].map(i => (
<div key={i} className="sponsor-tile skeleton" style={{ minHeight: 90, borderRadius: 12 }} />
))}
</div>
)}
{isLoading && ordered.length === 0 && <HomeSponsorsSkeleton />}
{variant === 'grid' && (
<>
{general.length > 0 && (
+1 -1
View File
@@ -1 +1 @@
/// <reference types="react-scripts" />
/// <reference types="vite/client" />
+13 -20
View File
@@ -1,5 +1,9 @@
import api from './api';
import { IconType } from 'react-icons';
import { invalidatePageElementConfigs } from './pageElementsPublic';
import type { PageElementConfig } from './pageElementsPublic';
export type { PageElementConfig } from './pageElementsPublic';
export { getPageElementConfigs } from './pageElementsPublic';
import {
FaRegClipboard,
FaBullseye,
@@ -37,26 +41,6 @@ import {
// Use shared API base URL
export interface PageElementConfig {
id?: number;
page_type: string;
element_name: string;
variant: string;
visible?: boolean;
display_order?: number;
settings?: Record<string, any>;
created_at?: string;
updated_at?: string;
}
// Public endpoints
export const getPageElementConfigs = async (pageType: string): Promise<PageElementConfig[]> => {
const response = await api.get('/page-elements', {
params: { page_type: pageType }
});
return response.data || [];
};
// Admin endpoints
export const getAllPageElementConfigs = async (): Promise<PageElementConfig[]> => {
const response = await api.get('/admin/page-elements');
@@ -65,20 +49,29 @@ export const getAllPageElementConfigs = async (): Promise<PageElementConfig[]> =
export const createOrUpdatePageElementConfig = async (config: Partial<PageElementConfig>): Promise<PageElementConfig> => {
const response = await api.post('/admin/page-elements', config);
invalidatePageElementConfigs(config.page_type);
return response.data;
};
export const updatePageElementConfig = async (id: number, config: Partial<PageElementConfig>): Promise<PageElementConfig> => {
const response = await api.put(`/admin/page-elements/${id}`, config);
invalidatePageElementConfigs(config.page_type);
return response.data;
};
export const deletePageElementConfig = async (id: number): Promise<void> => {
await api.delete(`/admin/page-elements/${id}`);
invalidatePageElementConfigs();
};
export const batchUpdatePageElementConfigs = async (configs: PageElementConfig[]): Promise<{ message: string; updated: number; created: number }> => {
const response = await api.post('/admin/page-elements/batch', configs);
const pageTypes = new Set(configs.map((config) => config.page_type).filter(Boolean));
if (pageTypes.size === 0) {
invalidatePageElementConfigs();
} else {
pageTypes.forEach((pageType) => invalidatePageElementConfigs(pageType));
}
return response.data;
};
@@ -0,0 +1,75 @@
import api from './api';
export interface PageElementConfig {
id?: number;
page_type: string;
element_name: string;
variant: string;
visible?: boolean;
display_order?: number;
settings?: Record<string, any>;
created_at?: string;
updated_at?: string;
}
type CacheEntry = {
expiresAt: number;
value: PageElementConfig[];
};
const PAGE_ELEMENT_CACHE_TTL_MS = 5 * 60 * 1000;
const pageElementCache = new Map<string, CacheEntry>();
const inFlightPageElementRequests = new Map<string, Promise<PageElementConfig[]>>();
export const invalidatePageElementConfigs = (pageType?: string) => {
if (pageType) {
pageElementCache.delete(pageType);
inFlightPageElementRequests.delete(pageType);
return;
}
pageElementCache.clear();
inFlightPageElementRequests.clear();
};
export const getPageElementConfigs = async (
pageType: string,
options?: { force?: boolean }
): Promise<PageElementConfig[]> => {
const force = options?.force === true;
const cacheKey = String(pageType || '').trim();
if (!force) {
const cached = pageElementCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const pending = inFlightPageElementRequests.get(cacheKey);
if (pending) {
return pending;
}
}
const request = api
.get('/page-elements', {
params: { page_type: pageType },
})
.then((response) => {
const data = Array.isArray(response.data) ? response.data : [];
pageElementCache.set(cacheKey, {
expiresAt: Date.now() + PAGE_ELEMENT_CACHE_TTL_MS,
value: data,
});
return data;
})
.finally(() => {
inFlightPageElementRequests.delete(cacheKey);
});
if (!force) {
inFlightPageElementRequests.set(cacheKey, request);
}
return request;
};
-152
View File
@@ -1,152 +0,0 @@
const { createProxyMiddleware } = require('http-proxy-middleware');
function resolveBackendOrigin() {
const raw = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || '';
const fallback = 'http://127.0.0.1:8080';
try {
if (!raw || raw.startsWith('/')) {
return fallback;
}
const u = new URL(raw);
u.pathname = '/';
return u.toString();
} catch (e) {
return fallback;
}
}
module.exports = function(app) {
// Proxy /uploads requests to backend
app.use(
'/uploads',
createProxyMiddleware({
target: resolveBackendOrigin(),
changeOrigin: true,
logLevel: 'debug',
onError: (err, req, res) => {
console.error('Proxy error for /uploads:', err);
},
})
);
app.use(
'/api',
createProxyMiddleware({
target: resolveBackendOrigin(),
changeOrigin: true,
logLevel: 'debug',
onError: (err, req, res) => {
console.error('Proxy error for /api:', err);
},
})
);
// Proxy short links and tracked redirect so CRA dev server doesn't 404 or capture the route
app.use(
'/s',
createProxyMiddleware({
target: resolveBackendOrigin(),
changeOrigin: true,
logLevel: 'debug',
onError: (err, req, res) => {
console.error('Proxy error for /s:', err);
},
})
);
app.use(
'/r',
createProxyMiddleware({
target: resolveBackendOrigin(),
changeOrigin: true,
logLevel: 'debug',
onError: (err, req, res) => {
console.error('Proxy error for /r:', err);
},
})
);
// Proxy /static requests to backend (for any static assets served by Go)
app.use(
'/static',
createProxyMiddleware({
target: resolveBackendOrigin(),
changeOrigin: true,
logLevel: 'debug',
onError: (err, req, res) => {
console.error('Proxy error for /static:', err);
},
})
);
// Proxy /dist requests to backend (assets served by Go under /dist)
app.use(
'/dist',
createProxyMiddleware({
target: resolveBackendOrigin(),
changeOrigin: true,
logLevel: 'debug',
onError: (err, req, res) => {
console.error('Proxy error for /dist:', err);
},
})
);
// Proxy /cache requests to backend (for FACR cache files, etc.)
app.use(
'/cache',
createProxyMiddleware({
target: resolveBackendOrigin(),
changeOrigin: true,
logLevel: 'debug',
onError: (err, req, res) => {
console.error('Proxy error for /cache:', err);
},
})
);
// Additional common static/image paths that may be referenced directly
app.use(
[
'/images',
'/img',
'/media',
'/files',
'/logos',
'/avatars',
'/downloads',
'/public',
'/favicon.ico',
],
createProxyMiddleware({
target: resolveBackendOrigin(),
changeOrigin: true,
logLevel: 'debug',
onError: (err, req, res) => {
console.error('Proxy error for image/static path:', err);
},
})
);
// Fallback: proxy any direct image file requests to the backend
// This will not affect Webpack Dev Server assets since they are handled earlier in the middleware chain
app.use(
createProxyMiddleware(
(pathname, req) => {
try {
if (pathname.startsWith('/sockjs-node') || pathname.startsWith('/ws')) return false;
return /\.(?:png|jpe?g|gif|svg|webp|ico|avif)$/i.test(pathname);
} catch {
return false;
}
},
{
target: resolveBackendOrigin(),
changeOrigin: true,
logLevel: 'debug',
onError: (err, req, res) => {
console.error('Proxy error for image extension fallback:', err);
},
}
)
);
};
+150
View File
@@ -0,0 +1,150 @@
import { extendTheme } from '@chakra-ui/react';
export const theme = extendTheme({
config: {
initialColorMode: 'light',
useSystemColorMode: false,
},
colors: {
brand: {
50: '#e6f7ff',
100: '#b3e0ff',
200: '#80caff',
300: '#4db3ff',
400: '#1a9cff',
500: 'var(--club-primary, #0b5cff)',
600: '#0066cc',
700: '#004d99',
800: '#003366',
900: '#001a33',
},
},
semanticTokens: {
colors: {
'brand.primary': {
default: 'var(--club-primary, #0b5cff)',
},
'brand.secondary': {
default: 'var(--club-secondary, #ffd200)',
},
'brand.accent': {
default: 'var(--club-accent, #141414)',
},
'text.onPrimary': {
default: 'var(--club-text-on-primary, #ffffff)',
},
'bg.app': {
default: '#f8f9fb',
_dark: '#0f1115',
},
'text.app': {
default: '#1a1a1a',
_dark: '#e8eaf0',
},
'border.subtle': {
default: 'rgba(0,0,0,0.06)',
_dark: 'rgba(255,255,255,0.12)',
},
'bg.card': {
default: '#ffffff',
_dark: '#1a1d29',
},
'bg.elevated': {
default: '#ffffff',
_dark: '#242831',
},
},
},
styles: {
global: {
'html, body, #root': {
height: '100%',
fontFamily: 'var(--font-body, Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial)',
},
body: {
bg: 'bg.app',
color: 'text.app',
lineHeight: 1.5,
fontFamily: 'var(--font-body, Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial)',
},
'h1, h2, h3, h4, h5, h6': {
fontFamily: 'var(--font-heading, Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial)',
},
a: {
transition: 'color 0.2s ease',
},
'::selection': {
background: 'brand.accent',
color: 'black',
},
},
},
components: {
Container: {
baseStyle: {
px: { base: 4, md: 6 },
},
sizes: {
'7xl': '88rem',
},
},
Button: {
baseStyle: {
fontWeight: '700',
borderRadius: 'md',
letterSpacing: '0.4px',
_hover: { transform: 'translateY(-1px)', boxShadow: 'md' },
_active: { transform: 'translateY(0)' },
},
variants: {
solid: {
bg: 'brand.primary',
color: 'text.onPrimary',
_hover: { filter: 'brightness(0.95)' },
},
outline: {
border: '2px solid',
borderColor: 'brand.primary',
color: 'brand.primary',
_hover: { bg: 'rgba(0,0,0,0.02)' },
},
ghost: {
color: 'brand.secondary',
_hover: { bg: 'rgba(0,0,0,0.04)' },
},
},
},
Card: {
baseStyle: {
container: {
borderRadius: 'lg',
boxShadow: 'sm',
overflow: 'hidden',
transition: 'all 0.2s',
borderWidth: '1px',
borderColor: 'border.subtle',
_hover: { transform: 'translateY(-4px)', boxShadow: 'lg' },
},
},
},
Divider: {
baseStyle: {
borderColor: 'border.subtle',
},
},
Heading: {
baseStyle: {
fontFamily: 'var(--font-heading, Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial)',
},
},
Text: {
baseStyle: {
fontFamily: 'var(--font-body, Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial)',
},
},
},
fonts: {
heading: 'var(--font-heading, Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial)',
body: 'var(--font-body, Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial)',
},
});
+39
View File
@@ -0,0 +1,39 @@
import DOMPurify from 'dompurify';
const ADDITIONAL_TAGS = ['iframe'];
const ADDITIONAL_ATTRS = [
'allow',
'allowfullscreen',
'class',
'data-bullets',
'data-filters',
'data-img-id',
'data-list',
'rel',
'style',
'target',
];
export function sanitizeRichHtml(html: string | null | undefined): string {
const sanitized = DOMPurify.sanitize(html ?? '', {
USE_PROFILES: { html: true },
ADD_TAGS: ADDITIONAL_TAGS,
ADD_ATTR: ADDITIONAL_ATTRS,
});
if (typeof window === 'undefined' || !sanitized) {
return sanitized;
}
const template = window.document.createElement('template');
template.innerHTML = sanitized;
template.content.querySelectorAll<HTMLAnchorElement>('a[target="_blank"]').forEach((anchor) => {
const rel = new Set((anchor.getAttribute('rel') ?? '').split(/\s+/).filter(Boolean));
rel.add('noopener');
rel.add('noreferrer');
anchor.setAttribute('rel', Array.from(rel).join(' '));
});
return template.innerHTML;
}