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
-20
View File
@@ -15,27 +15,7 @@ COPY . .
# Build the app with production settings
ENV NODE_ENV=production
# Disable ESLint during build to avoid CRA/ESLint v9 plugin incompatibilities
ENV DISABLE_ESLINT_PLUGIN=true
# Skip CRA preflight checks (peer deps, eslint presence) to prevent extra work in CI builds
ENV SKIP_PREFLIGHT_CHECK=true
# Disable source maps to reduce memory usage
ENV GENERATE_SOURCEMAP=false
# Reduce memory footprint - Node.js in Docker needs more conservative settings
ENV NODE_OPTIONS="--max-old-space-size=1024"
# Limit webpack parallelism to reduce memory usage
ENV CI=true
# Allow build to continue even if TypeScript diagnostics exist; avoids heavy TS checks from blocking
ENV TSC_COMPILE_ON_ERROR=true
# Disable ForkTsCheckerWebpackPlugin via craco filter to further reduce memory
ENV DISABLE_TS_TYPECHECK=true
# Disable ESLint plugin completely
ENV ESLINT_NO_DEV_ERRORS=true
# Clean npm cache before build to free up memory
RUN npm cache clean --force 2>/dev/null || true
# Build with standard npm
RUN --mount=type=cache,target=/root/.npm \
npm run build
-68
View File
@@ -1,68 +0,0 @@
const path = require('path');
module.exports = {
webpack: {
alias: {
'@': path.resolve(__dirname, 'src/')
},
configure: (webpackConfig) => {
// Always remove ESLint for better performance
webpackConfig.plugins = (webpackConfig.plugins || []).filter((plugin) => {
const name = plugin && plugin.constructor && plugin.constructor.name;
if (name === 'ESLintWebpackPlugin') return false;
return true;
});
// Optimize for production builds
if (process.env.NODE_ENV === 'production') {
// Reduce memory usage during build
webpackConfig.optimization = {
...webpackConfig.optimization,
splitChunks: {
chunks: 'all',
cacheGroups: {
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10,
reuseExistingChunk: true,
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
// Use single runtime chunk
runtimeChunk: 'single',
};
// Limit parallelism of existing minimizers to lower memory footprint
if (Array.isArray(webpackConfig.optimization.minimizer)) {
webpackConfig.optimization.minimizer = webpackConfig.optimization.minimizer.map((minimizer) => {
const name = minimizer && minimizer.constructor && minimizer.constructor.name;
if (name === 'TerserPlugin') {
if (minimizer.options) {
minimizer.options.parallel = false;
minimizer.options.extractComments = false;
}
}
if (name === 'CssMinimizerPlugin' || name === 'CssMinimizerWebpackPlugin') {
if (minimizer.options) {
minimizer.options.parallel = 1;
}
}
return minimizer;
});
}
// Disable source maps if env variable is set
if (process.env.GENERATE_SOURCEMAP === 'false') {
webpackConfig.devtool = false;
}
}
return webpackConfig;
},
}
};
+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#000000" />
<meta name="color-scheme" content="light dark" />
<meta
name="description"
content="Oficiální webové stránky fotbalového klubu - aktuality, zápasy, tabulky, hráči a fotogalerie"
/>
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preconnect" href="https://www.youtube.com" crossorigin />
<link rel="preconnect" href="https://i.ytimg.com" crossorigin />
<link rel="preconnect" href="https://s.ytimg.com" crossorigin />
<link rel="preconnect" href="https://www.google.com" crossorigin />
<title>Fotbal Club</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+1992 -13353
View File
File diff suppressed because it is too large Load Diff
+13 -14
View File
@@ -3,18 +3,15 @@
"version": "0.1.0",
"private": true,
"scripts": {
"start": "craco start",
"build": "craco build",
"test": "craco test",
"eject": "react-scripts eject",
"dev": "bun start",
"dev:fast": "bun --hot start",
"build:bun": "bun run build"
"start": "vite",
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@chakra-ui/icons": "^2.1.1",
"@chakra-ui/react": "^2.8.2",
"@craco/craco": "^7.1.0",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@hookform/resolvers": "^3.3.4",
@@ -26,15 +23,15 @@
"@testing-library/user-event": "^13.5.0",
"@tinymce/tinymce-react": "^6.3.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.126",
"@types/node": "^20.17.47",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@types/react-frame-component": "^4.1.6",
"axios": "^1.6.2",
"axios": "^1.13.6",
"chart.js": "^4.4.1",
"date-fns": "^4.1.0",
"dompurify": "^3.2.6",
"dompurify": "^3.3.0",
"framer-motion": "^10.16.4",
"i18next": "^23.7.16",
"i18next-browser-languagedetector": "^7.2.0",
@@ -58,8 +55,7 @@
"react-image-crop": "^11.0.10",
"react-markdown": "^10.1.0",
"react-quill": "^2.0.0",
"react-router-dom": "^6.20.1",
"react-scripts": "5.0.1",
"react-router-dom": "^6.30.2",
"react-simple-maps": "^3.0.0",
"react-syntax-highlighter": "^15.6.6",
"tinymce": "^8.2.2",
@@ -76,9 +72,12 @@
"@types/react-chartjs-2": "^2.0.2",
"@types/react-image-crop": "^8.1.6",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^4.4.1",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react-hooks": "^7.0.1",
"http-proxy-middleware": "^3.0.5"
"jsdom": "^26.1.0",
"vite": "^6.3.5",
"vitest": "^3.1.1"
},
"browserslist": {
"production": [
-55
View File
@@ -1,55 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<!-- Club favicon/logo -->
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<!-- Optional SVG logo if available in public folder (keeps ICO as fallback) -->
<!-- <link rel="icon" type="image/svg+xml" href="%PUBLIC_URL%/logo.svg" /> -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#000000" />
<meta name="color-scheme" content="light dark" />
<meta
name="description"
content="Oficiální webové stránky fotbalového klubu - aktuality, zápasy, tabulky, hráči a fotogalerie"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!-- Performance: preconnect to Google Fonts origins used by the app -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- Performance: preconnect to YouTube origins for faster video/thumbnail loads -->
<link rel="preconnect" href="https://www.youtube.com" crossorigin />
<link rel="preconnect" href="https://i.ytimg.com" crossorigin />
<link rel="preconnect" href="https://s.ytimg.com" crossorigin />
<link rel="preconnect" href="https://www.google.com" crossorigin />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Fotbal Club</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
+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;
}
+5 -1
View File
@@ -22,7 +22,11 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"types": [
"vite/client",
"vitest/globals"
]
},
"include": [
"src"
+102
View File
@@ -0,0 +1,102 @@
import path from 'node:path';
import { URL } from 'node:url';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
function resolveBackendOrigin(env: Record<string, string>) {
const raw = env.REACT_APP_API_BASE_URL || env.REACT_APP_API_URL || '';
const fallback = 'http://127.0.0.1:8080';
try {
if (!raw || raw.startsWith('/')) {
return fallback;
}
const parsed = new URL(raw);
parsed.pathname = '/';
parsed.search = '';
parsed.hash = '';
return parsed.toString();
} catch {
return fallback;
}
}
function buildProcessEnv(mode: string, env: Record<string, string>, base: string) {
const processEnv: Record<string, string> = {
NODE_ENV: mode === 'production' ? 'production' : mode,
PUBLIC_URL: base === '/' ? '' : base.replace(/\/$/, ''),
};
for (const [key, value] of Object.entries(env)) {
if (key.startsWith('REACT_APP_')) {
processEnv[key] = value;
}
}
return processEnv;
}
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const base = env.PUBLIC_URL ? (env.PUBLIC_URL.endsWith('/') ? env.PUBLIC_URL : `${env.PUBLIC_URL}/`) : '/';
const backendOrigin = resolveBackendOrigin(env);
const processEnv = buildProcessEnv(mode, env, base);
const commonProxy = {
target: backendOrigin,
changeOrigin: true,
secure: false,
};
return {
base,
publicDir: 'public',
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
define: {
'process.env': JSON.stringify(processEnv),
global: 'globalThis',
},
build: {
outDir: 'build',
assetsDir: 'static',
emptyOutDir: true,
sourcemap: false,
},
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': commonProxy,
'/uploads': commonProxy,
'/s': commonProxy,
'/r': commonProxy,
'/static': commonProxy,
'/dist': commonProxy,
'/cache': commonProxy,
'/images': commonProxy,
'/img': commonProxy,
'/media': commonProxy,
'/files': commonProxy,
'/logos': commonProxy,
'/avatars': commonProxy,
'/downloads': commonProxy,
'/public': commonProxy,
'/favicon.ico': commonProxy,
},
},
preview: {
host: '0.0.0.0',
port: 4173,
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: 'src/setupTests.ts',
},
};
});