mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
update
This commit is contained in:
@@ -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
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -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)',
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user