This commit is contained in:
Tomáš Dvořák
2025-10-16 13:32:05 +02:00
commit 12cba639b9
663 changed files with 168914 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
# Exclude heavy and unnecessary files from Docker build context
# VCS
.git
.gitignore
# Dependencies
node_modules
.pnpm-store
# Build outputs / caches
build
dist
.cache
.next
coverage
# Logs and temp
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
.DS_Store
Thumbs.db
.tmp
.tmp/**
.temp
.temp/**
tmp
tmp/**
# Env files
.env
.env.*
!.env.example
# Editor / OS
.vscode
.idea
+27
View File
@@ -0,0 +1,27 @@
REACT_APP_API_URL=http://localhost:8080/api/v1
# Alternatively, you can set only the backend origin and the app will auto-append /api/v1
# REACT_APP_API_BASE_URL=http://localhost:8080
REACT_APP_NAME=Fotbal Club Manager
REACT_APP_ENV=development
# FACR API Configuration - Local instance
# Backend exposes the FACR proxy under /api/v1/facr
REACT_APP_FACR_API_BASE_URL=http://localhost:8080/api/v1/facr
REACT_APP_FACR_API_TIMEOUT=5000 # 5 seconds
REACT_APP_FACR_CACHE_TTL=3600000 # 1 hour in milliseconds
# Club selection for homepage (required)
# Example: Sparta Praha UUID
# REACT_APP_FACR_CLUB_ID=00000000-0000-0000-0000-000000000000
# football | futsal (default football)
# REACT_APP_FACR_CLUB_TYPE=football
# Homepage Layout Configuration
# Options: 'sparta' (premium layout) or 'classic' (default layout)
REACT_APP_HOMEPAGE_LAYOUT=classic
# Mapy.cz REST API Key (used for place suggestions on Admin Activities)
# Get a key at: https://developer.mapy.com/my-account/project-and-api-keys/
# The key is sent in header X-Mapy-Api-Key
# Example:
# REACT_APP_MAPY_API_KEY=your_mapy_cz_api_key_here
+54
View File
@@ -0,0 +1,54 @@
# Build stage
FROM node:18-alpine as build
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies with cache mount
RUN --mount=type=cache,target=/root/.npm \
npm i -g npm@10 \
&& npm ci --prefer-offline --no-audit --no-fund || npm install --no-audit --no-fund
# Copy source code
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
# Workaround for OpenSSL error with webpack on Node 18
ENV NODE_OPTIONS=--openssl-legacy-provider
RUN --mount=type=cache,target=/root/.npm \
npm run build
# Production stage
FROM nginx:alpine
# Remove default nginx static assets
RUN rm -rf /usr/share/nginx/html/*
# Copy built assets from build stage
COPY --from=build /app/build /usr/share/nginx/html
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Set proper permissions
RUN chown -R nginx:nginx /usr/share/nginx/html && \
chmod -R 755 /usr/share/nginx/html && \
chown -R nginx:nginx /var/cache/nginx && \
chown -R nginx:nginx /var/log/nginx && \
chown -R nginx:nginx /etc/nginx/conf.d && \
touch /var/run/nginx.pid && \
chown -R nginx:nginx /var/run/nginx.pid
# Switch to non-root user
USER nginx
# Expose port 80
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
+51
View File
@@ -0,0 +1,51 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
## Project Notes
- Home uses a single unified layout implemented in `src/pages/HomePage.tsx` with styles in `src/pages/styles/UnifiedHome.css`.
- Previously separate style variants (`SpartaHome.tsx`, `ClassicHome.tsx`) have been removed to reduce duplication and maintenance overhead.
+9
View File
@@ -0,0 +1,9 @@
const path = require('path');
module.exports = {
webpack: {
alias: {
'@': path.resolve(__dirname, 'src/')
}
}
};
+94
View File
@@ -0,0 +1,94 @@
server {
listen 80;
server_name localhost;
# Enable gzip compression for text-based assets
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript
application/json application/javascript application/xml+rss
application/atom+xml image/svg+xml text/html;
gzip_disable "msie6";
gzip_proxied any;
# Static assets with aggressive caching
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp)$ {
root /usr/share/nginx/html;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location ~* \.(css|js)$ {
root /usr/share/nginx/html;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location ~* \.(woff|woff2|ttf|eot)$ {
root /usr/share/nginx/html;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# SEO files - proxy to backend for dynamic generation
location = /robots.txt {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_cache_control;
add_header Cache-Control "public, max-age=3600";
}
location = /sitemap.xml {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_cache_control;
add_header Cache-Control "public, max-age=3600";
}
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
# Cache HTML files for shorter period
add_header Cache-Control "public, max-age=3600, must-revalidate";
}
# API proxy with compression
location /api/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Accept-Encoding gzip;
# Enable buffering for better performance
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
}
# Error pages
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
+22574
View File
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "craco start",
"build": "craco build",
"test": "craco test",
"eject": "react-scripts eject"
},
"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",
"@tanstack/react-query": "^4.36.1",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.126",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"axios": "^1.6.2",
"chart.js": "^4.4.1",
"date-fns": "^4.1.0",
"dompurify": "^3.2.6",
"framer-motion": "^10.16.4",
"lucide-react": "^0.379.0",
"maplibre-gl": "^5.9.0",
"popmotion": "^11.0.5",
"quill": "^2.0.3",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-datepicker": "^8.7.0",
"react-dom": "^18.2.0",
"react-helmet-async": "^2.0.5",
"react-hook-form": "^7.48.2",
"react-icons": "^4.12.0",
"react-image-crop": "^11.0.10",
"react-quill": "^2.0.0",
"react-router-dom": "^6.20.1",
"react-scripts": "5.0.1",
"react-simple-maps": "^3.0.0",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4",
"yup": "^1.3.3"
},
"devDependencies": {
"@types/chart.js": "^2.9.41",
"@types/dompurify": "^3.0.5",
"@types/geojson": "^7946.0.8",
"@types/maplibre-gl": "^1.13.2",
"@types/react-chartjs-2": "^2.0.2",
"@types/react-image-crop": "^8.1.6",
"eslint-plugin-jsx-a11y": "^6.7.1"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+46
View File
@@ -0,0 +1,46 @@
<!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" />
<meta name="theme-color" content="#000000" />
<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" />
<!--
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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

+25
View File
@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
+10
View File
@@ -0,0 +1,10 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow: /admin/
Disallow: /api/
Disallow: /login
Disallow: /setup
Allow: /
# Sitemap will be served dynamically from backend
# Sitemap: /sitemap.xml
+248
View File
@@ -0,0 +1,248 @@
/* eslint-disable no-restricted-globals */
// Service Worker for PWA support and offline functionality
const CACHE_VERSION = 'v1.0.0';
const CACHE_NAME = `fotbal-club-cache-${CACHE_VERSION}`;
// Assets to cache on install
const STATIC_ASSETS = [
'/',
'/index.html',
'/static/css/main.css',
'/static/js/main.js',
'/manifest.json',
'/favicon.ico',
'/logo192.png',
'/logo512.png',
];
// API endpoints to cache
const API_CACHE_ENDPOINTS = [
'/api/v1/settings/public',
'/api/v1/seo',
];
// Install event - cache static assets
self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker...');
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('[SW] Caching static assets');
return cache.addAll(STATIC_ASSETS.map(url => new Request(url, { cache: 'reload' })));
}).catch((error) => {
console.error('[SW] Failed to cache static assets:', error);
})
);
// Activate immediately
self.skipWaiting();
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker...');
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
})
);
// Take control immediately
return self.clients.claim();
});
// Fetch event - serve from cache, fall back to network
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') {
return;
}
// Skip Chrome extensions and non-http(s) requests
if (!url.protocol.startsWith('http')) {
return;
}
// Skip admin routes
if (url.pathname.startsWith('/admin')) {
return;
}
// Handle API requests
if (url.pathname.startsWith('/api/')) {
event.respondWith(handleAPIRequest(request));
return;
}
// Handle static assets and pages
event.respondWith(handleStaticRequest(request));
});
// Handle static requests - Cache First strategy
async function handleStaticRequest(request) {
try {
// Try cache first
const cachedResponse = await caches.match(request);
if (cachedResponse) {
// Return cached response and update in background
fetchAndUpdateCache(request);
return cachedResponse;
}
// Not in cache - fetch from network
const networkResponse = await fetch(request);
// Cache successful responses
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.error('[SW] Fetch failed:', error);
// Return offline page if available
const cachedOffline = await caches.match('/offline.html');
if (cachedOffline) {
return cachedOffline;
}
// Return basic offline response
return new Response('Offline - Please check your connection', {
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'text/plain' },
});
}
}
// Handle API requests - Network First strategy with cache fallback
async function handleAPIRequest(request) {
try {
// Try network first for fresh data
const networkResponse = await fetch(request);
// Cache successful responses
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.log('[SW] Network failed, trying cache:', request.url);
// Fall back to cache
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
// Return error response
return new Response(
JSON.stringify({ error: 'Offline - cached data not available' }),
{
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'application/json' },
}
);
}
}
// Update cache in background
async function fetchAndUpdateCache(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response);
}
} catch (error) {
// Silent fail - we already returned cached version
}
}
// Handle background sync for offline actions
self.addEventListener('sync', (event) => {
console.log('[SW] Background sync:', event.tag);
if (event.tag === 'sync-data') {
event.waitUntil(syncOfflineData());
}
});
async function syncOfflineData() {
// Implement offline data sync logic here
// For example, sync form submissions, votes, etc.
console.log('[SW] Syncing offline data...');
}
// Handle push notifications
self.addEventListener('push', (event) => {
const data = event.data ? event.data.json() : {};
const title = data.title || 'Fotbal Club';
const options = {
body: data.body || 'Nová notifikace',
icon: '/logo192.png',
badge: '/logo192.png',
data: data.url || '/',
};
event.waitUntil(
self.registration.showNotification(title, options)
);
});
// Handle notification clicks
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const urlToOpen = event.notification.data || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
// Check if there's already a window open
for (const client of clientList) {
if (client.url === urlToOpen && 'focus' in client) {
return client.focus();
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
});
// Message handler for manual cache updates
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
if (event.data && event.data.type === 'CLEAR_CACHE') {
event.waitUntil(
caches.delete(CACHE_NAME).then(() => {
return caches.open(CACHE_NAME);
})
);
}
});
console.log('[SW] Service Worker loaded successfully');
+38
View File
@@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
+281
View File
@@ -0,0 +1,281 @@
import React, { lazy, Suspense } from 'react';
import { ChakraProvider, extendTheme, 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 { useUmami } from './hooks/useUmami';
import { useFontLoader } from './hooks/useFontLoader';
import DefaultSEO from './components/seo/DefaultSEO';
import CookieBanner from './components/CookieBanner';
import ProtectedRoute from './components/ProtectedRoute';
import { getSetupStatus } from './services/setup';
import { useState, useEffect } from 'react';
// Create a client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
cacheTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnMount: false,
retry: 1,
},
},
});
// Loading component
const PageLoader = () => (
<Center h="100vh">
<Box textAlign="center">
<Spinner size="xl" color="brand.primary" thickness="4px" />
<Box mt={4} fontSize="sm" color="gray.600">Načítání...</Box>
</Box>
</Center>
);
// Lazy load pages for code splitting
const HomePage = lazy(() => import('./pages/HomePage'));
const BlogPage = lazy(() => import('./pages/BlogPage'));
const ArticleDetailPage = lazy(() => import('./pages/ArticleDetailPage'));
const ActivityDetailPage = lazy(() => import('./pages/ActivityDetailPage'));
const MatchDetailPage = lazy(() => import('./pages/MatchDetailPage'));
const ClubPage = lazy(() => import('./pages/ClubPage'));
const CalendarPage = lazy(() => import('./pages/CalendarPage'));
const TablesPage = lazy(() => import('./pages/TablesPage'));
const MatchesPage = lazy(() => import('./pages/MatchesPage'));
const PlayersPage = lazy(() => import('./pages/PlayersPage'));
const PlayerDetailPage = lazy(() => import('./pages/PlayerDetailPage'));
const SponsorsPage = lazy(() => import('./pages/SponsorsPage'));
const ContactPage = lazy(() => import('./pages/ContactPage'));
const GalleryPage = lazy(() => import('./pages/GalleryPage'));
const AlbumDetailPage = lazy(() => import('./pages/AlbumDetailPage'));
const AuthPage = lazy(() => import('./pages/AuthPage'));
const ForgotPasswordPage = lazy(() => import('./pages/ForgotPasswordPage'));
const ResetPasswordPage = lazy(() => import('./pages/ResetPasswordPage'));
const ActivitiesCalendarPage = lazy(() => import('./pages/ActivitiesCalendarPage'));
const AboutPage = lazy(() => import('./pages/AboutPage'));
const SetupPage = lazy(() => import('./pages/SetupPage'));
const StylePreviewPage = lazy(() => import('./pages/StylePreviewPage'));
const NewsletterUnsubscribePage = lazy(() => import('./pages/NewsletterUnsubscribePage'));
const NewsletterPreferencesPage = lazy(() => import('./pages/NewsletterPreferencesPage'));
const VideosPage = lazy(() => import('./pages/VideosPage'));
const SearchPage = lazy(() => import('./pages/SearchPage'));
const ClothingPage = lazy(() => import('./pages/ClothingPage'));
const PollsPage = lazy(() => import('./pages/PollsPage'));
const OverlayScoreboardPage = lazy(() => import('./pages/OverlayScoreboardPage'));
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
const ForbiddenPage = lazy(() => import('./pages/ForbiddenPage'));
// Legal pages
const CookiePolicyPage = lazy(() => import('./pages/legal/CookiePolicyPage'));
const TermsPage = lazy(() => import('./pages/legal/TermsPage'));
const PrivacyPolicyPage = lazy(() => import('./pages/legal/PrivacyPolicyPage'));
// Admin pages
const AdminDashboardPage = lazy(() => import('./pages/admin/AdminDashboardPage'));
const ArticlesAdminPage = lazy(() => import('./pages/admin/ArticlesAdminPage'));
const SponsorsAdminPage = lazy(() => import('./pages/admin/SponsorsAdminPage'));
const CategoriesAdminPage = lazy(() => import('./pages/admin/CategoriesAdminPage'));
const MediaAdminPage = lazy(() => import('./pages/admin/MediaAdminPage'));
const MatchesAdminPage = lazy(() => import('./pages/admin/MatchesAdminPage'));
const PlayersAdminPage = lazy(() => import('./pages/admin/PlayersAdminPage'));
const TeamsAdminPage = lazy(() => import('./pages/admin/TeamsAdminPage'));
const BannersAdminPage = lazy(() => import('./pages/admin/BannersAdminPage'));
const MessagesAdminPage = lazy(() => import('./pages/admin/MessagesAdminPage'));
const SettingsAdminPage = lazy(() => import('./pages/admin/SettingsAdminPage'));
const UsersAdminPage = lazy(() => import('./pages/admin/UsersAdminPage'));
const NewsletterAdminPage = lazy(() => import('./pages/admin/NewsletterAdminPage'));
const CompetitionAliasesAdminPage = lazy(() => import('./pages/admin/CompetitionAliasesAdminPage'));
const PrefetchAdminPage = lazy(() => import('./pages/admin/PrefetchAdminPage'));
const AdminVideosPage = lazy(() => import('./pages/admin/AdminVideosPage'));
const GalleryAdminPage = lazy(() => import('./pages/admin/GalleryAdminPage'));
const AdminActivitiesPage = lazy(() => import('./pages/admin/AdminActivitiesPage'));
const AdminMerchPage = lazy(() => import('./pages/admin/AdminMerchPage'));
const AdminResetPasswordPage = lazy(() => import('./pages/admin/AdminResetPasswordPage'));
const AboutAdminPage = lazy(() => import('./pages/admin/AboutAdminPage'));
const AnalyticsAdminPage = lazy(() => import('./pages/admin/AnalyticsAdminPage'));
const FilesAdminPage = lazy(() => import('./pages/admin/FilesAdminPage'));
const ContactsAdminPage = lazy(() => import('./pages/admin/ContactsAdminPage'));
const NavigationAdminPage = lazy(() => import('./pages/admin/NavigationAdminPage'));
const PollsAdminPage = lazy(() => import('./pages/admin/PollsAdminPage'));
const AdminDocsPage = lazy(() => import('./pages/admin/AdminDocsPage'));
const ScoreboardAdminPage = lazy(() => import('./pages/admin/ScoreboardAdminPage'));
const MobileScoreboardControlPage = lazy(() => import('./pages/admin/MobileScoreboardControlPage'));
// Analytics and font loader
const AnalyticsInitializer: React.FC = () => {
useUmami();
return null;
};
const FontLoader: React.FC = () => {
useFontLoader();
return null;
};
// Public route wrapper
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated, isLoading } = useAuth();
const [checkingSetup, setCheckingSetup] = useState(true);
const [requiresSetup, setRequiresSetup] = useState<boolean>(false);
useEffect(() => {
let mounted = true;
(async () => {
try {
const s = await getSetupStatus();
if (mounted) setRequiresSetup(!!s.requires_setup);
} catch (_) {
if (mounted) setRequiresSetup(false);
} finally {
if (mounted) setCheckingSetup(false);
}
})();
return () => { mounted = false; };
}, []);
if (isLoading || checkingSetup) {
return <PageLoader />;
}
if (isAuthenticated) {
return <Navigate to="/admin" replace />;
}
const currentPath = window.location.pathname;
if (requiresSetup && currentPath !== '/setup') {
return <Navigate to="/setup" replace />;
}
return <>{children}</>;
};
const AdminRoutesWrapper = () => {
return <Outlet />;
};
const AppLazy: React.FC = () => {
return (
<ChakraProvider theme={theme}>
<QueryClientProvider client={queryClient}>
<Router>
<AuthProvider>
<ClubThemeProvider>
<HelmetProvider>
<AnalyticsInitializer />
<FontLoader />
<DefaultSEO />
<Suspense fallback={<PageLoader />}>
<Routes>
{/* Public routes */}
<Route path="/" element={<HomePage />} />
<Route path="/hledat" element={<SearchPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/overlay/scoreboard" element={<OverlayScoreboardPage />} />
<Route path="/blog" element={<BlogPage />} />
<Route path="/klub" element={<ClubPage />} />
<Route path="/o-klubu" element={<AboutPage />} />
<Route path="/kalendar" element={<CalendarPage />} />
<Route path="/aktivity" element={<ActivitiesCalendarPage />} />
<Route path="/tabulky" element={<TablesPage />} />
<Route path="/zapasy" element={<MatchesPage />} />
<Route path="/players" element={<PlayersPage />} />
<Route path="/hraci" element={<PlayersPage />} />
<Route path="/players/:id" element={<PlayerDetailPage />} />
<Route path="/hraci/:id" element={<PlayerDetailPage />} />
<Route path="/sponzori" element={<SponsorsPage />} />
<Route path="/kontakt" element={<ContactPage />} />
<Route path="/ankety" element={<PollsPage />} />
<Route path="/galerie" element={<GalleryPage />} />
<Route path="/galerie/album/:id" element={<AlbumDetailPage />} />
<Route path="/videa" element={<VideosPage />} />
<Route path="/obleceni" element={<ClothingPage />} />
{/* Legal pages */}
<Route path="/pravidla-cookies" element={<CookiePolicyPage />} />
<Route path="/obchodni-podminky" element={<TermsPage />} />
<Route path="/zasady-ochrany-osobnich-udaju" element={<PrivacyPolicyPage />} />
{/* Article routes */}
<Route path="/news" element={<Navigate to="/blog" replace />} />
<Route path="/news/:slug" element={<ArticleDetailPage />} />
<Route path="/articles/slug/:slug" element={<ArticleDetailPage />} />
<Route path="/articles/:id" element={<ArticleDetailPage />} />
<Route path="/zapas/:id" element={<MatchDetailPage />} />
<Route path="/aktivita/:id" element={<ActivityDetailPage />} />
{/* Redirects */}
<Route path="/clanky" element={<Navigate to="/blog" replace />} />
<Route path="/aktuality" element={<Navigate to="/blog" replace />} />
{/* Setup */}
<Route path="/setup" element={<PublicRoute><SetupPage /></PublicRoute>} />
<Route path="/setup/styl" element={<PublicRoute><StylePreviewPage /></PublicRoute>} />
{/* Auth */}
<Route path="/login" element={<PublicRoute><AuthPage /></PublicRoute>} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/newsletter/unsubscribe/:email" element={<NewsletterUnsubscribePage />} />
<Route path="/newsletter/preferences" element={<NewsletterPreferencesPage />} />
<Route path="/403" element={<ForbiddenPage />} />
{/* Admin routes */}
<Route element={<ProtectedRoute requiredRole="admin"><AdminRoutesWrapper /></ProtectedRoute>}>
<Route path="/admin" element={<AdminDashboardPage />} />
<Route path="/admin/docs" element={<AdminDocsPage />} />
<Route path="/admin/clanky" element={<ArticlesAdminPage />} />
<Route path="/admin/o-klubu" element={<AboutAdminPage />} />
<Route path="/admin/videa" element={<AdminVideosPage />} />
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
<Route path="/admin/aktivity" element={<AdminActivitiesPage />} />
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
<Route path="/admin/kategorie" element={<CategoriesAdminPage />} />
<Route path="/admin/media" element={<MediaAdminPage />} />
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
<Route path="/admin/tymy" element={<TeamsAdminPage />} />
<Route path="/admin/uzivatele" element={<UsersAdminPage />} />
<Route path="/admin/bannery" element={<BannersAdminPage />} />
<Route path="/admin/zpravy" element={<MessagesAdminPage />} />
<Route path="/admin/nastaveni" element={<SettingsAdminPage />} />
<Route path="/admin/newsletter" element={<NewsletterAdminPage />} />
<Route path="/admin/ankety" element={<PollsAdminPage />} />
<Route path="/admin/aliasy-soutezi" element={<CompetitionAliasesAdminPage />} />
<Route path="/admin/prefetch" element={<PrefetchAdminPage />} />
<Route path="/admin/users/send-reset" element={<AdminResetPasswordPage />} />
<Route path="/admin/scoreboard" element={<ScoreboardAdminPage />} />
<Route path="/admin/scoreboard/remote" element={<MobileScoreboardControlPage />} />
<Route path="/admin/analytika" element={<AnalyticsAdminPage />} />
<Route path="/admin/soubory" element={<FilesAdminPage />} />
<Route path="/admin/kontakty" element={<ContactsAdminPage />} />
<Route path="/admin/navigace" element={<NavigationAdminPage />} />
</Route>
{/* Legacy admin routes */}
<Route path="/dashboard" element={<Navigate to="/admin" replace />} />
<Route path="/admin/sponsors" element={<ProtectedRoute requiredRole="admin"><SponsorsAdminPage /></ProtectedRoute>} />
<Route path="/admin/banners" element={<ProtectedRoute requiredRole="admin"><BannersAdminPage /></ProtectedRoute>} />
<Route path="/admin/messages" element={<ProtectedRoute requiredRole="admin"><MessagesAdminPage /></ProtectedRoute>} />
<Route path="/admin/settings" element={<ProtectedRoute requiredRole="admin"><SettingsAdminPage /></ProtectedRoute>} />
{/* 404 */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
<CookieBanner />
</HelmetProvider>
</ClubThemeProvider>
</AuthProvider>
</Router>
</QueryClientProvider>
</ChakraProvider>
);
};
export default AppLazy;
+9
View File
@@ -0,0 +1,9 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
+471
View File
@@ -0,0 +1,471 @@
import React, { useEffect, useState } from 'react';
import { ChakraProvider, extendTheme } from '@chakra-ui/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter as Router, Routes, Route, Navigate, Outlet } from 'react-router-dom';
import './styles/custom-scrollbar.css';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import AuthPage from './pages/AuthPage';
import DashboardPage from './pages/DashboardPage';
import ArticlesListPage from './pages/ArticlesListPage';
import HomePage from './pages/HomePage';
import BlogPage from './pages/BlogPage';
import ArticleDetailPage from './pages/ArticleDetailPage';
import ActivityDetailPage from './pages/ActivityDetailPage';
import MatchDetailPage from './pages/MatchDetailPage';
import ClubPage from './pages/ClubPage';
import CalendarPage from './pages/CalendarPage';
import TablesPage from './pages/TablesPage';
import MatchesPage from './pages/MatchesPage';
import PlayersPage from './pages/PlayersPage';
import PlayerDetailPage from './pages/PlayerDetailPage';
import SponsorsPage from './pages/SponsorsPage';
import ContactPage from './pages/ContactPage';
import GalleryPage from './pages/GalleryPage';
import AlbumDetailPage from './pages/AlbumDetailPage';
import ForgotPasswordPage from './pages/ForgotPasswordPage';
import ResetPasswordPage from './pages/ResetPasswordPage';
import ActivitiesCalendarPage from './pages/ActivitiesCalendarPage';
import AdminDashboardPage from './pages/admin/AdminDashboardPage';
import ArticlesAdminPage from './pages/admin/ArticlesAdminPage';
import SponsorsAdminPage from './pages/admin/SponsorsAdminPage';
import CategoriesAdminPage from './pages/admin/CategoriesAdminPage';
import MediaAdminPage from './pages/admin/MediaAdminPage';
import MatchesAdminPage from './pages/admin/MatchesAdminPage';
import PlayersAdminPage from './pages/admin/PlayersAdminPage';
import TeamsAdminPage from './pages/admin/TeamsAdminPage';
import BannersAdminPage from './pages/admin/BannersAdminPage';
import MessagesAdminPage from './pages/admin/MessagesAdminPage';
import SettingsAdminPage from './pages/admin/SettingsAdminPage';
import UsersAdminPage from './pages/admin/UsersAdminPage';
import NewsletterAdminPage from './pages/admin/NewsletterAdminPage';
import CompetitionAliasesAdminPage from './pages/admin/CompetitionAliasesAdminPage';
import PrefetchAdminPage from './pages/admin/PrefetchAdminPage';
import AdminVideosPage from './pages/admin/AdminVideosPage';
import GalleryAdminPage from './pages/admin/GalleryAdminPage';
import AdminActivitiesPage from './pages/admin/AdminActivitiesPage';
import AdminMerchPage from './pages/admin/AdminMerchPage';
import AdminResetPasswordPage from './pages/admin/AdminResetPasswordPage';
import AboutAdminPage from './pages/admin/AboutAdminPage';
import AnalyticsAdminPage from './pages/admin/AnalyticsAdminPage';
import FilesAdminPage from './pages/admin/FilesAdminPage';
import ContactsAdminPage from './pages/admin/ContactsAdminPage';
import NavigationAdminPage from './pages/admin/NavigationAdminPage';
import PollsAdminPage from './pages/admin/PollsAdminPage';
// Admin pages render their own AdminLayout internally
import SetupPage from './pages/SetupPage';
import StylePreviewPage from './pages/StylePreviewPage';
import AboutPage from './pages/AboutPage';
import AdminDocsPage from './pages/admin/AdminDocsPage';
import ScoreboardAdminPage from './pages/admin/ScoreboardAdminPage';
import MobileScoreboardControlPage from './pages/admin/MobileScoreboardControlPage';
import { getSetupStatus } from './services/setup';
import NewsletterUnsubscribePage from './pages/NewsletterUnsubscribePage';
import NewsletterPreferencesPage from './pages/NewsletterPreferencesPage';
import { ClubThemeProvider } from './contexts/ClubThemeContext';
import CookiePolicyPage from './pages/legal/CookiePolicyPage';
import OverlayScoreboardPage from './pages/OverlayScoreboardPage';
import CookieBanner from './components/CookieBanner';
import DefaultSEO from './components/seo/DefaultSEO';
import ProtectedRoute from './components/ProtectedRoute';
import TermsPage from './pages/legal/TermsPage';
import PrivacyPolicyPage from './pages/legal/PrivacyPolicyPage';
import ForbiddenPage from './pages/ForbiddenPage';
import NotFoundPage from './pages/NotFoundPage';
import VideosPage from './pages/VideosPage';
import SearchPage from './pages/SearchPage';
import ClothingPage from './pages/ClothingPage';
import PollsPage from './pages/PollsPage';
import { useUmami } from './hooks/useUmami';
import { useFontLoader } from './hooks/useFontLoader';
// Create a client with better cache configuration
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
refetchOnMount: false,
retry: 1,
},
},
});
// 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();
return null;
};
// Component to load and apply club fonts
const FontLoader: React.FC = () => {
useFontLoader();
return null;
};
const App: React.FC = () => {
// Uses shared ProtectedRoute component for auth guard
// Public Route component - redirects to admin if already authenticated
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated, isLoading } = useAuth();
const [checkingSetup, setCheckingSetup] = useState(true);
const [requiresSetup, setRequiresSetup] = useState<boolean>(false);
useEffect(() => {
let mounted = true;
(async () => {
try {
const s = await getSetupStatus();
if (mounted) setRequiresSetup(!!s.requires_setup);
} catch (_) {
if (mounted) setRequiresSetup(false);
} finally {
if (mounted) setCheckingSetup(false);
}
})();
return () => { mounted = false; };
}, []);
if (isLoading || checkingSetup) {
return <div>Načítání</div>;
}
if (isAuthenticated) {
return <Navigate to="/admin" replace />;
}
// If setup is required, redirect to setup wizard unless already on setup
const currentPath = window.location.pathname;
if (requiresSetup && currentPath !== '/setup') {
return <Navigate to="/setup" replace />;
}
return <>{children}</>;
};
// Admin routes group wrapper (no layout here; pages render their own AdminLayout)
const AdminRoutesWrapper = () => {
return <Outlet />;
};
return (
<ChakraProvider theme={theme}>
<QueryClientProvider client={queryClient}>
<Router>
<AuthProvider>
<ClubThemeProvider>
<AnalyticsInitializer />
<FontLoader />
<DefaultSEO />
<Routes>
{/* Public routes */}
<Route path="/" element={<HomePage />} />
<Route path="/hledat" element={<SearchPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/overlay/scoreboard" element={<OverlayScoreboardPage />} />
<Route path="/blog" element={<BlogPage />} />
<Route path="/klub" element={<ClubPage />} />
<Route path="/o-klubu" element={<AboutPage />} />
<Route path="/kalendar" element={<CalendarPage />} />
<Route path="/aktivity" element={<ActivitiesCalendarPage />} />
<Route path="/tabulky" element={<TablesPage />} />
<Route path="/zapasy" element={<MatchesPage />} />
<Route path="/players" element={<PlayersPage />} />
<Route path="/hraci" element={<PlayersPage />} />
<Route path="/players/:id" element={<PlayerDetailPage />} />
<Route path="/hraci/:id" element={<PlayerDetailPage />} />
<Route path="/sponzori" element={<SponsorsPage />} />
<Route path="/kontakt" element={<ContactPage />} />
<Route path="/ankety" element={<PollsPage />} />
<Route path="/galerie" element={<GalleryPage />} />
<Route path="/galerie/album/:id" element={<AlbumDetailPage />} />
<Route path="/videa" element={<VideosPage />} />
<Route path="/obleceni" element={<ClothingPage />} />
{/* Legal pages */}
<Route path="/pravidla-cookies" element={<CookiePolicyPage />} />
<Route path="/obchodni-podminky" element={<TermsPage />} />
<Route path="/zasady-ochrany-osobnich-udaju" element={<PrivacyPolicyPage />} />
<Route path="/news" element={<Navigate to="/blog" replace />} />
{/* Slug routes must precede id route to avoid conflicts */}
<Route path="/news/:slug" element={<ArticleDetailPage />} />
<Route path="/articles/slug/:slug" element={<ArticleDetailPage />} />
<Route path="/articles/:id" element={<ArticleDetailPage />} />
{/* Internal match detail */}
<Route path="/zapas/:id" element={<MatchDetailPage />} />
<Route path="/aktivita/:id" element={<ActivityDetailPage />} />
{/* Legacy redirects */}
<Route path="/clanky" element={<Navigate to="/blog" replace />} />
<Route path="/aktuality" element={<Navigate to="/blog" replace />} />
<Route
path="/setup"
element={
<PublicRoute>
<SetupPage />
</PublicRoute>
}
/>
<Route
path="/setup/styl"
element={
<PublicRoute>
<StylePreviewPage />
</PublicRoute>
}
/>
<Route
path="/login"
element={
<PublicRoute>
<AuthPage />
</PublicRoute>
}
/>
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/newsletter/unsubscribe/:email" element={<NewsletterUnsubscribePage />} />
<Route path="/newsletter/preferences" element={<NewsletterPreferencesPage />} />
<Route path="/403" element={<ForbiddenPage />} />
{/* Admin area (pages include AdminLayout themselves) */}
<Route element={
<ProtectedRoute requiredRole="admin">
<AdminRoutesWrapper />
</ProtectedRoute>
}>
<Route path="/admin" element={<AdminDashboardPage />} />
<Route path="/admin/docs" element={<AdminDocsPage />} />
<Route path="/admin/clanky" element={<ArticlesAdminPage />} />
<Route path="/admin/o-klubu" element={<AboutAdminPage />} />
<Route path="/admin/videa" element={<AdminVideosPage />} />
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
<Route path="/admin/aktivity" element={<AdminActivitiesPage />} />
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
<Route path="/admin/kategorie" element={<CategoriesAdminPage />} />
<Route path="/admin/media" element={<MediaAdminPage />} />
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
<Route path="/admin/tymy" element={<TeamsAdminPage />} />
<Route path="/admin/uzivatele" element={<UsersAdminPage />} />
<Route path="/admin/bannery" element={<BannersAdminPage />} />
<Route path="/admin/zpravy" element={<MessagesAdminPage />} />
<Route path="/admin/nastaveni" element={<SettingsAdminPage />} />
<Route path="/admin/newsletter" element={<NewsletterAdminPage />} />
<Route path="/admin/ankety" element={<PollsAdminPage />} />
<Route path="/admin/aliasy-soutezi" element={<CompetitionAliasesAdminPage />} />
<Route path="/admin/prefetch" element={<PrefetchAdminPage />} />
<Route path="/admin/users/send-reset" element={<AdminResetPasswordPage />} />
<Route path="/admin/scoreboard" element={<ScoreboardAdminPage />} />
<Route path="/admin/scoreboard/remote" element={<MobileScoreboardControlPage />} />
<Route path="/admin/analytika" element={<AnalyticsAdminPage />} />
<Route path="/admin/soubory" element={<FilesAdminPage />} />
<Route path="/admin/kontakty" element={<ContactsAdminPage />} />
<Route path="/admin/navigace" element={<NavigationAdminPage />} />
</Route>
{/* Remaining protected routes that don't use AdminLayout */}
<Route
path="/dashboard"
element={<Navigate to="/admin" replace />}
/>
<Route
path="/admin/sponsors"
element={
<ProtectedRoute requiredRole="admin">
<SponsorsAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/banners"
element={
<ProtectedRoute requiredRole="admin">
<BannersAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/messages"
element={
<ProtectedRoute requiredRole="admin">
<MessagesAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/settings"
element={
<ProtectedRoute requiredRole="admin">
<SettingsAdminPage />
</ProtectedRoute>
}
/>
{/* Not found route */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
{/* Cookie consent banner shown across the whole site */}
<CookieBanner />
</ClubThemeProvider>
</AuthProvider>
</Router>
</QueryClientProvider>
</ChakraProvider>
);
};
export default App;
+123
View File
@@ -0,0 +1,123 @@
import { Box, Button, Flex, Text, Link } from '@chakra-ui/react';
import { useEffect, useState } from 'react';
const STORAGE_KEY = 'cookie_consent';
type Consent = {
version: number;
necessary: true; // always true
preferences: boolean;
analytics: boolean;
marketing: boolean;
timestamp: string; // ISO
};
const defaultConsent: Consent = {
version: 1,
necessary: true,
preferences: false,
analytics: false,
marketing: false,
timestamp: new Date().toISOString(),
};
const CookieBanner: React.FC = () => {
const [visible, setVisible] = useState(false);
const [managing, setManaging] = useState(false);
const [consent, setConsent] = useState<Consent>(defaultConsent);
useEffect(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved) as Consent;
setConsent(parsed);
setVisible(false);
} else {
setVisible(true);
}
} catch {
setVisible(true);
}
}, []);
const saveAndClose = (c: Consent) => {
const payload = { ...c, timestamp: new Date().toISOString() };
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
setConsent(payload);
setVisible(false);
setManaging(false);
// Dispatch a small custom event so analytics loaders can react
window.dispatchEvent(new CustomEvent('cookie-consent-change', { detail: payload }));
};
const acceptAll = () => {
saveAndClose({ ...defaultConsent, preferences: true, analytics: true, marketing: true });
};
const rejectNonEssential = () => {
saveAndClose({ ...defaultConsent });
};
if (!visible) return null;
return (
<Box role="dialog" aria-live="polite" position="fixed" bottom={0} left={0} right={0} bg="gray.900" color="gray.100" zIndex={1000} py={4} px={4}>
<Flex align="start" justify="space-between" gap={6} wrap="wrap">
<Box maxW={{ base: '100%', md: '70%' }}>
<Text fontSize="sm" mb={2}>
Tento web používá soubory cookies pro zajištění správného fungování (nezbytné) a za účelem vylepšení obsahu.
O vybraných kategoriích rozhodujete vy. Podrobnosti najdete v&nbsp;
<Link href="/pravidla-cookies" color="blue.300" textDecoration="underline">Pravidlech cookies</Link>.
</Text>
{managing && (
<Box mt={3} bg="gray.800" borderRadius="md" p={3} border="1px solid" borderColor="gray.700">
<Text fontWeight="semibold" mb={2}>Nastavení preferencí</Text>
<Flex direction="column" gap={2}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked readOnly />
<Text fontSize="sm">Nezbytné cookies (vždy aktivní)</Text>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={!!consent.preferences}
onChange={(e) => setConsent((c) => ({ ...c, preferences: e.target.checked }))}
/>
<Text fontSize="sm">Preferenční cookies (např. zapamatování voleb)</Text>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={!!consent.analytics}
onChange={(e) => setConsent((c) => ({ ...c, analytics: e.target.checked }))}
/>
<Text fontSize="sm">Analytické cookies (anonymní měření návštěvnosti)</Text>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={!!consent.marketing}
onChange={(e) => setConsent((c) => ({ ...c, marketing: e.target.checked }))}
/>
<Text fontSize="sm">Marketingové cookies</Text>
</label>
<Flex gap={2} mt={2} wrap="wrap">
<Button size="sm" colorScheme="blue" onClick={() => saveAndClose(consent)}>Uložit nastavení</Button>
<Button size="sm" variant="outline" onClick={() => setManaging(false)}>Zpět</Button>
</Flex>
</Flex>
</Box>
)}
</Box>
<Flex gap={2} align="center" wrap="wrap">
<Button size="sm" onClick={() => setManaging((v) => !v)} variant="outline">Nastavit</Button>
<Button size="sm" onClick={rejectNonEssential} variant="ghost">Odmítnout nepovinné</Button>
<Button size="sm" colorScheme="blue" onClick={acceptAll}>Přijmout vše</Button>
</Flex>
</Flex>
</Box>
);
};
export default CookieBanner;
+717
View File
@@ -0,0 +1,717 @@
import React, { useEffect, useState, useMemo } from 'react';
import {
Box,
Flex,
Button,
useColorModeValue,
useColorMode,
IconButton,
Avatar,
Menu,
MenuButton,
MenuList,
MenuItem,
Text,
HStack,
Tooltip,
useDisclosure,
Drawer,
DrawerBody,
DrawerHeader,
DrawerOverlay,
DrawerContent,
DrawerCloseButton,
VStack,
Divider,
Container,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
InputGroup,
InputLeftElement,
Input,
} from '@chakra-ui/react';
import { MoonIcon, SunIcon, HamburgerIcon, EditIcon, ChevronDownIcon } from '@chakra-ui/icons';
import { FaFacebook, FaInstagram, FaYoutube, FaPhotoVideo, FaExternalLinkAlt, FaShoppingBag, FaCamera, FaSearch } from 'react-icons/fa';
import { Link as RouterLink, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { usePublicSettings } from '../hooks/usePublicSettings';
import { useClubTheme } from '../contexts/ClubThemeContext';
import { Image } from '@chakra-ui/react';
import { getCategories, Category } from '../services/public';
import { FaSearch as FaSearchIcon } from 'react-icons/fa';
import { getNavigationItems, NavigationItem, seedDefaultNavigation } from '../services/navigation';
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
// Minimal normalization for social URLs so admins can input @handle or domain-less usernames
const normalizeSocialUrl = (network: 'facebook' | 'instagram' | 'youtube', raw?: string | null): string | null => {
let v = String(raw || '').trim();
if (!v) return null;
v = v.replace(/\s+/g, '');
if (v.startsWith('@')) {
const handle = v.slice(1);
if (network === 'facebook') return `https://www.facebook.com/${handle}`;
if (network === 'instagram') return `https://www.instagram.com/${handle}`;
if (network === 'youtube') return `https://www.youtube.com/@${handle}`;
}
if (!/^https?:\/\//i.test(v) && !v.includes('/') && !v.includes('.')) {
if (network === 'facebook') return `https://www.facebook.com/${v}`;
if (network === 'instagram') return `https://www.instagram.com/${v}`;
if (network === 'youtube') return `https://www.youtube.com/@${v}`;
}
if (!/^https?:\/\//i.test(v)) {
if (/^facebook\.com\//i.test(v)) return `https://www.${v}`;
if (/^instagram\.com\//i.test(v)) return `https://www.${v}`;
if (/^youtube\.com\//i.test(v)) return `https://www.${v}`;
}
return v;
};
// Mobile menu component
const MobileMenu = ({ isOpen, onClose, isAdmin, menuBg, dividerColor, settings, categories, galleryHref, galleryLabel, hasTables, dynamicNavItems, navLoading }: {
isOpen: boolean;
onClose: () => void;
isAdmin: boolean;
menuBg: string;
dividerColor: string;
settings?: any;
categories?: Category[] | null;
galleryHref?: string | null;
galleryLabel?: string;
hasTables?: boolean | null;
dynamicNavItems: NavigationItem[];
navLoading: boolean;
}) => (
<Drawer isOpen={isOpen} placement="left" onClose={onClose}>
<DrawerOverlay />
<DrawerContent bg={menuBg}>
<DrawerCloseButton />
<DrawerHeader borderBottomWidth="1px" borderColor="border.subtle">Menu</DrawerHeader>
<DrawerBody>
<VStack align="stretch" spacing={2}>
{/* Dynamic navigation items in mobile */}
{(!navLoading && dynamicNavItems.length > 0) ? (
// Use dynamic navigation
dynamicNavItems.map((item, idx) => {
const linkIsExternal = item.type === 'external';
const hasChildren = item.type === 'dropdown' && item.children && item.children.length > 0;
const linkProps = linkIsExternal ? { href: item.url } : { to: item.url || '/' };
const Comp: any = linkIsExternal ? 'a' : RouterLink;
return (
<React.Fragment key={item.id || idx}>
<Button
as={Comp}
{...linkProps}
target={linkIsExternal ? '_blank' : undefined}
rel={linkIsExternal ? 'noreferrer' : undefined}
variant="ghost"
justifyContent="flex-start"
fontWeight={hasChildren ? 'bold' : 'normal'}
>
{item.label}
</Button>
{/* Render children for dropdown items */}
{hasChildren && (
<VStack align="stretch" pl={4} spacing={1}>
{item.children!.map((child) => {
const childIsExternal = child.type === 'external';
const childLinkProps = childIsExternal ? { href: child.url } : { to: child.url || '/' };
const ChildComp: any = childIsExternal ? 'a' : RouterLink;
return (
<Button
key={child.id}
as={ChildComp}
{...(childLinkProps as any)}
variant="ghost"
justifyContent="flex-start"
fontWeight="normal"
size="sm"
>
{child.label}
</Button>
);
})}
</VStack>
)}
</React.Fragment>
);
})
) : (
// Fallback to hardcoded navigation
<>
<Button as={RouterLink} to="/" variant="ghost" justifyContent="flex-start">Domů</Button>
{(settings?.show_about_in_nav ?? true) && (
<Button as={RouterLink} to="/o-klubu" variant="ghost" justifyContent="flex-start">O klubu</Button>
)}
<Button as={RouterLink} to="/kalendar" variant="ghost" justifyContent="flex-start">Kalendář</Button>
<Button as={RouterLink} to="/zapasy" variant="ghost" justifyContent="flex-start">Zápasy</Button>
<Button as={RouterLink} to="/aktivity" variant="ghost" justifyContent="flex-start">Aktivity</Button>
<Button as={RouterLink} to="/hraci" variant="ghost" justifyContent="flex-start">Hráči</Button>
{hasTables ? (
<Button as={RouterLink} to="/tabulky" variant="ghost" justifyContent="flex-start">Tabulky</Button>
) : null}
{Array.isArray(settings?.custom_nav) && settings.custom_nav.length > 0 && settings.custom_nav.map((item: any, idx: number) => {
const customLinkIsExternal = typeof item?.url === 'string' && /^https?:\/\//i.test(item.url);
const linkProps = customLinkIsExternal ? { href: item.url } : { to: item.url || '/' };
const Comp: any = customLinkIsExternal ? 'a' : RouterLink;
return (
<Button
key={`custom-nav-${idx}-${item?.label || 'link'}`}
as={Comp}
{...linkProps}
target={customLinkIsExternal ? '_blank' : undefined}
rel={customLinkIsExternal ? 'noreferrer' : undefined}
variant="ghost"
justifyContent="flex-start"
>
{item?.label || 'Stránka'}
</Button>
);
})}
<Button as={RouterLink} to="/blog" variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
{Array.isArray(categories) && categories.length > 0 && (
<VStack align="stretch" pl={4} spacing={1}>
{categories.map((cat: any) => {
const catIsExternal = typeof cat.url === 'string' && /^https?:\/\//i.test(cat.url);
const catHref = cat.url || (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog');
const catLinkProps = catIsExternal ? { href: catHref } : { to: catHref };
return (
<Button key={cat.slug || cat.name} as={catIsExternal ? 'a' : RouterLink} {...(catLinkProps as any)} variant="ghost" justifyContent="flex-start" fontWeight="normal" size="sm">
{cat.name}
</Button>
);
})}
</VStack>
)}
<Button as={RouterLink} to="/videa" variant="ghost" justifyContent="flex-start">Videa</Button>
<Button as={RouterLink} to="/hledat" variant="ghost" justifyContent="flex-start">Hledat</Button>
<Button as={RouterLink} to="/galerie" variant="ghost" justifyContent="flex-start">{galleryLabel || 'Fotogalerie'}</Button>
{settings?.shop_url && (
<Button as="a" href={settings.shop_url} target="_blank" rel="noreferrer" variant="ghost" justifyContent="flex-start">Fanshop</Button>
)}
<Button as={RouterLink} to="/sponzori" variant="ghost" justifyContent="flex-start">Sponzoři</Button>
<Button as={RouterLink} to="/kontakt" variant="ghost" justifyContent="flex-start">Kontakt</Button>
</>
)}
{isAdmin && (
<>
<Divider my={2} borderColor={dividerColor} />
<Text fontWeight="bold" mt={2} color={dividerColor}>Administrace</Text>
<Button as={RouterLink} to="/admin" variant="ghost" justifyContent="flex-start" colorScheme="blue">
Administrace
</Button>
</>
)}
</VStack>
</DrawerBody>
</DrawerContent>
</Drawer>
);
const Navbar = () => {
const { colorMode, toggleColorMode } = useColorMode();
const { isAuthenticated, logout, user } = useAuth();
const { isOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isSearchOpen, onOpen: onSearchOpen, onClose: onSearchClose } = useDisclosure();
const isAdmin = user?.role === 'admin';
const { data: settings } = usePublicSettings();
const theme = useClubTheme();
const location = useLocation();
const navigate = useNavigate();
const menuBg = useColorModeValue('white', '#0f1115');
const dividerColor = useColorModeValue('gray.600', 'gray.300');
const hoverBg = useColorModeValue('blackAlpha.100', 'whiteAlpha.200');
const activeBg = useColorModeValue('blackAlpha.50', 'whiteAlpha.100');
const activeTextColor = useColorModeValue('brand.primary', 'brand.accent');
const navTextColor = useColorModeValue('gray.700', 'gray.200');
const [scrolled, setScrolled] = useState(false);
const [hasTables, setHasTables] = useState<boolean | null>(null);
const [dynamicNavItems, setDynamicNavItems] = useState<NavigationItem[]>([]);
const [navLoading, setNavLoading] = useState(true);
// Search modal state
const [query, setQuery] = useState('');
const submitSearch = () => {
const text = query.trim();
if (!text) return;
onSearchClose();
setQuery('');
navigate(`/hledat?q=${encodeURIComponent(text)}`);
};
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
onScroll();
window.addEventListener('scroll', onScroll, { passive: true } as any);
return () => window.removeEventListener('scroll', onScroll as any);
}, []);
// Also set document title to club name ASAP (SEO component will refine further)
useEffect(() => {
const name = settings?.club_name || theme.name;
if (name && typeof document !== 'undefined') {
document.title = name;
}
}, [settings?.club_name, theme.name]);
// Set favicon/logo in head for fan pages (SPA)
useEffect(() => {
try {
let url = settings?.club_logo_url || theme.logoUrl || '/dist/img/logo-club-empty.svg';
if (!url) return;
// Normalize relative upload paths to API origin so favicon resolves on all pages
try {
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const apiOrigin = new URL(apiUrl).origin;
if (/^\/.+/.test(url) && !/^https?:\/\//i.test(url)) {
// If starts with /uploads or any absolute path, prefix API origin
url = apiOrigin + url;
}
} catch {}
const setIcon = (rel: string) => {
let link = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`);
if (!link) {
link = document.createElement('link');
link.rel = rel as any;
document.head.appendChild(link);
}
link.href = url;
// Try to hint type if svg
if (url.endsWith('.svg')) link.type = 'image/svg+xml';
};
setIcon('icon');
setIcon('shortcut icon');
} catch {}
}, [settings?.club_logo_url, theme.logoUrl]);
// gallery link (generic first, fallback to zonerama)
const galleryHref = settings?.gallery_url || settings?.zonerama_url;
const galleryLabel = settings?.gallery_label || 'Fotogalerie';
// Load dynamic navigation from API
useEffect(() => {
let active = true;
(async () => {
try {
const items = await getNavigationItems();
if (active && Array.isArray(items)) {
// Filter out admin-only navigation items for public display
const publicItems = items.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...');
await seedDefaultNavigation();
const newItems = await getNavigationItems();
if (active && Array.isArray(newItems)) {
const publicNewItems = newItems.filter(item => !item.requires_admin);
setDynamicNavItems(publicNewItems);
}
} catch (seedError) {
console.error('Auto-seed failed:', seedError);
// Continue with empty navigation
}
} else {
setDynamicNavItems(publicItems);
}
}
} catch (error) {
console.error('Failed to load navigation:', error);
} finally {
if (active) setNavLoading(false);
}
})();
return () => { active = false };
}, [isAdmin]);
// categories: prefer API, fallback to settings.categories
const [navCategories, setNavCategories] = useState<Category[] | null>(null);
useEffect(() => {
let active = true;
(async () => {
try {
const cats = await getCategories();
if (active && Array.isArray(cats) && cats.length > 0) {
setNavCategories(cats);
} else if (active && Array.isArray(settings?.categories)) {
setNavCategories(settings!.categories as any);
}
} catch {
if (active && Array.isArray(settings?.categories)) {
setNavCategories(settings!.categories as any);
}
}
})();
return () => { active = false };
}, [settings?.categories]);
// Determine if there is any table data available (prefetch snapshot)
useEffect(() => {
let disposed = false;
const resolveBackendUrl = (path: string) => {
try {
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const origin = new URL(base).origin;
return new URL(path, origin).toString();
}
return path;
} catch { return path; }
};
(async () => {
try {
const res = await fetch(resolveBackendUrl('/cache/prefetch/facr_tables.json'), { cache: 'no-cache' });
if (!res.ok) { if (!disposed) setHasTables(false); return; }
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);
if (!disposed) setHasTables(!!anyRows);
} catch {
if (!disposed) setHasTables(false);
}
})();
return () => { disposed = true; };
}, []);
const isPathActive = (to?: string) => {
if (!to) return false;
// Active when current pathname starts with target (handles subroutes)
return location.pathname === to || location.pathname.startsWith(to + '/');
};
// Convert NavigationItem to NavLink format
const convertToNavLink = (item: NavigationItem): NavLink => {
const link: NavLink = {
label: item.label,
to: item.url || '#',
external: item.type === 'external',
};
// Add children for dropdown items
if (item.type === 'dropdown' && item.children && item.children.length > 0) {
link.items = item.children.map(child => ({
label: child.label,
to: child.url || '#',
}));
}
return link;
};
// Build categories as items for Články dropdown (fallback)
const categoryItems = useMemo(() => {
const source = Array.isArray(navCategories) && navCategories.length > 0 ? navCategories : [];
return source.map((cat: any) => ({
label: cat.name,
to: cat.url || (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog')
}));
}, [navCategories]);
// Use dynamic navigation if available, otherwise fallback to hardcoded
let NAV_LINKS: NavLink[] = useMemo(() => {
if (!navLoading && dynamicNavItems.length > 0) {
// Use dynamic navigation from API
return dynamicNavItems.map(convertToNavLink);
}
// Fallback to hardcoded navigation
let links: NavLink[] = [
{ label: 'Domů', to: '/' },
...(settings?.show_about_in_nav === false ? [] : [{ label: 'O klubu', to: '/o-klubu' } as NavLink]),
{ label: 'Kalendář', to: '/kalendar' },
{ label: 'Zápasy', to: '/zapasy' },
{ label: 'Aktivity', to: '/aktivity' },
{ label: 'Hráči', to: '/hraci' },
{ label: 'Tabulky', to: '/tabulky' },
// Články with categories as subcategories
categoryItems.length > 0
? { label: 'Články', to: '/blog', items: categoryItems }
: { label: 'Články', to: '/blog' },
{ label: 'Videa', to: '/videa' },
{ label: galleryLabel, to: '/galerie' },
...(settings?.shop_url ? [{ label: 'Fanshop', to: settings.shop_url, external: true } as NavLink] : []),
{ label: 'Sponzoři', to: '/sponzori' },
{ label: 'Kontakt', to: '/kontakt' },
];
// Inject custom pages from settings.custom_nav (label + url + external?)
const customNav = Array.isArray((settings as any)?.custom_nav) ? ((settings as any).custom_nav as any[]) : [];
if (customNav.length > 0) {
const mapped: NavLink[] = customNav.map((it) => ({ label: String(it.label || 'Stránka'), to: String(it.url || '#'), external: Boolean(it.external) }));
const insertIdx = links.findIndex((n) => n.label === 'Tabulky');
if (insertIdx >= 0) {
links = [...links.slice(0, insertIdx + 1), ...mapped, ...links.slice(insertIdx + 1)];
} else {
links = [...links, ...mapped];
}
}
// Hide Tabulky when there is no table data
if (hasTables === false) {
links = links.filter((n) => n.label !== 'Tabulky');
}
return links;
}, [dynamicNavItems, navLoading, settings, categoryItems, hasTables, galleryLabel]);
return (
<Box position="sticky" top={0} zIndex={1000}>
{/* Top bar with socials and quick external links */}
{(settings?.facebook_url || settings?.instagram_url || settings?.youtube_url || settings?.shop_url) && (
<Box bg={useColorModeValue('gray.50', 'blackAlpha.500')} borderBottomWidth="1px" borderColor="border.subtle" py={1}>
<Container maxW="7xl">
<Flex align="center" justify="space-between" gap={2}>
<HStack spacing={2}>
{settings?.shop_url && (
<Button as="a" href={settings.shop_url} target="_blank" rel="noreferrer" variant="link" size="xs" leftIcon={<FaShoppingBag />}>
Fanshop
</Button>
)}
</HStack>
<HStack spacing={1}>
{normalizeSocialUrl('facebook', settings?.facebook_url) && (
<IconButton as="a" href={normalizeSocialUrl('facebook', settings?.facebook_url) || undefined} target="_blank" rel="noreferrer" aria-label="Facebook" icon={<FaFacebook />} variant="ghost" size="xs" />
)}
{normalizeSocialUrl('instagram', settings?.instagram_url) && (
<IconButton as="a" href={normalizeSocialUrl('instagram', settings?.instagram_url) || undefined} target="_blank" rel="noreferrer" aria-label="Instagram" icon={<FaInstagram />} variant="ghost" size="xs" />
)}
{normalizeSocialUrl('youtube', settings?.youtube_url) && (
<IconButton as="a" href={normalizeSocialUrl('youtube', settings?.youtube_url) || undefined} target="_blank" rel="noreferrer" aria-label="YouTube" icon={<FaYoutube />} variant="ghost" size="xs" />
)}
</HStack>
</Flex>
</Container>
</Box>
)}
{/* Main Nav Bar */}
<Box
bg={useColorModeValue('rgba(255,255,255,0.9)', 'rgba(15,17,21,0.85)')}
backdropFilter="saturate(180%) blur(10px)"
borderBottomWidth="1px"
borderColor="border.subtle"
boxShadow={scrolled ? 'sm' : 'none'}
transition="box-shadow 0.2s ease, background-color 0.2s ease, backdrop-filter 0.2s ease"
>
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navCategories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={hasTables} dynamicNavItems={dynamicNavItems} navLoading={navLoading} />
<Container maxW="7xl">
<Flex h={16} alignItems="center" justifyContent="space-between">
<HStack spacing={4} alignItems="center">
{/* Club Logo only */}
<HStack as={RouterLink} to="/" spacing={3} align="center">
{(settings?.club_logo_url || theme.logoUrl) && (
<Image
src={settings?.club_logo_url || theme.logoUrl}
alt={settings?.club_name || theme.name || 'Logo'}
boxSize={{ base: '36px', md: '40px' }}
objectFit="contain"
borderRadius="full"
borderWidth="2px"
borderColor="brand.primary"
style={{
padding: (settings?.club_logo_url || theme.logoUrl)?.includes('logoapi.sportcreative.eu') ? '4px' : '0px',
boxSizing: 'border-box'
}}
/>
)}
</HStack>
{/* Desktop navigation with hover dropdowns */}
<HStack as="nav" spacing={1} display={{ base: 'none', lg: 'flex' }} ml={4}>
{NAV_LINKS.map((nav) => {
const commonProps = {
variant: 'ghost' as const,
size: 'sm' as const,
px: 3,
_hover: { bg: hoverBg, transform: 'translateY(-1px)' },
fontWeight: isPathActive(nav.to) ? '700' : '600',
color: isPathActive(nav.to) ? activeTextColor : navTextColor,
bg: isPathActive(nav.to) ? activeBg : 'transparent',
transition: 'all 0.2s',
};
// Handle items with dropdown (like Články with categories)
if (nav.items && nav.items.length > 0) {
return (
<HoverMenu key={nav.label} label={nav.label} items={nav.items} isActive={isPathActive(nav.to)} />
);
}
if (nav.external && nav.to) {
return (
<Button key={nav.label} as="a" href={nav.to} target="_blank" rel="noreferrer" rightIcon={<FaExternalLinkAlt />} {...commonProps}>
{nav.label}
</Button>
);
}
return (
<Button key={nav.label} as={RouterLink} to={nav.to || '#'} {...commonProps}>
{nav.label}
</Button>
);
})}
</HStack>
</HStack>
<Flex alignItems="center">
{/* Mobile menu button */}
<IconButton
display={{ base: 'flex', md: 'none' }}
onClick={onOpen}
icon={<HamburgerIcon />}
aria-label="Otevřít menu"
variant="ghost"
mr={2}
/>
{/* Space reserved (socials moved to top bar) */}
<Box display={{ base: 'none', md: 'flex' }} mr={2} />
{/* Search button */}
<Tooltip label="Hledat" hasArrow>
<IconButton
aria-label="Hledat"
icon={<FaSearch />}
size="sm"
mr={2}
variant="ghost"
onClick={onSearchOpen}
/>
</Tooltip>
{/* Admin edit button */}
{isAdmin && (
<Tooltip label="Správa obsahu" hasArrow>
<IconButton
as={RouterLink}
to="/admin"
aria-label="Správa obsahu"
icon={<EditIcon />}
size="sm"
mr={2}
colorScheme="blue"
variant="ghost"
/>
</Tooltip>
)}
{/* Color mode toggle */}
<IconButton
size="md"
fontSize="lg"
aria-label="Přepnout barevné téma"
variant="ghost"
color="current"
onClick={toggleColorMode}
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
/>
{isAuthenticated && (
<Menu>
<MenuButton
as={Button}
rounded="full"
variant="link"
cursor="pointer"
minW={0}
ml={2}
>
<Avatar size="sm" name={user?.name || 'Uživatel'} />
</MenuButton>
<MenuList>
<MenuItem as={RouterLink} to="/admin/nastaveni">Můj účet</MenuItem>
{isAdmin && <MenuItem as={RouterLink} to="/admin">Administrace</MenuItem>}
<MenuItem onClick={logout}>Odhlásit se</MenuItem>
</MenuList>
</Menu>
)}
</Flex>
{/* Close outer Flex */}
</Flex>
</Container>
{/* Search Modal */}
<Modal isOpen={isSearchOpen} onClose={onSearchClose} size="md" motionPreset="scale">
<ModalOverlay />
<ModalContent>
<ModalHeader>Vyhledávání</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<form
onSubmit={(e) => {
e.preventDefault();
submitSearch();
}}
>
<VStack spacing={4}>
<InputGroup size="lg">
<InputLeftElement pointerEvents="none">
<FaSearchIcon />
</InputLeftElement>
<Input
placeholder="Hledat kluby, zápasy, články, hráče..."
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
</InputGroup>
<Button type="submit" colorScheme="blue" size="lg" w="full" leftIcon={<FaSearchIcon />}>
Vyhledat
</Button>
</VStack>
</form>
<Text fontSize="sm" color="gray.500" mt={4} textAlign="center">
Zadejte klíčová slova pro vyhledávání
</Text>
</ModalBody>
</ModalContent>
</Modal>
</Box>
</Box>
);
};
// HoverMenu component for desktop dropdown nav
const HoverMenu = ({ label, items, isActive }: { label: string; items: { label: string; to: string }[]; isActive?: boolean }) => {
const { isOpen, onOpen, onClose } = useDisclosure();
return (
<Box onMouseEnter={onOpen} onMouseLeave={onClose}>
<Menu isOpen={isOpen} placement="bottom-start" gutter={4}>
<MenuButton
as={Button}
rightIcon={<ChevronDownIcon />}
variant="ghost"
size="sm"
px={3}
fontWeight={isActive ? '700' : '600'}
color={useColorModeValue(isActive ? 'brand.primary' : 'gray.700', isActive ? 'brand.accent' : 'gray.200')}
bg={isActive ? useColorModeValue('blackAlpha.50', 'whiteAlpha.100') : 'transparent'}
_hover={{ bg: useColorModeValue('blackAlpha.100', 'whiteAlpha.200'), transform: 'translateY(-1px)' }}
transition="all 0.2s"
>
{label}
</MenuButton>
<MenuList>
{items.map((it) => (
<MenuItem as={RouterLink} to={it.to} key={it.to}>
{it.label}
</MenuItem>
))}
</MenuList>
</Menu>
</Box>
);
};
export default Navbar;
// Search Modal rendered alongside Navbar content
// Note: We append the modal inside Navbar return to keep code compact
@@ -0,0 +1,57 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useEffect, useState } from 'react';
import { getSetupStatus } from '../services/setup';
interface ProtectedRouteProps {
children: JSX.Element;
requiredRole?: string;
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children, requiredRole }) => {
const { isAuthenticated, isLoading, user } = useAuth();
const location = useLocation();
const [checkingSetup, setCheckingSetup] = useState(true);
const [requiresSetup, setRequiresSetup] = useState<boolean>(false);
// Check if setup is required
useEffect(() => {
let mounted = true;
(async () => {
try {
const s = await getSetupStatus();
if (mounted) setRequiresSetup(!!s.requires_setup);
} catch (_) {
if (mounted) setRequiresSetup(false);
} finally {
if (mounted) setCheckingSetup(false);
}
})();
return () => { mounted = false; };
}, []);
if (isLoading || checkingSetup) {
// Show loading spinner or skeleton
return <div>Načítání</div>;
}
// If setup is required, redirect to setup page
if (requiresSetup) {
return <Navigate to="/setup" replace />;
}
if (!isAuthenticated) {
// Redirect to login page, but save the current location
return <Navigate to="/login" state={{ from: location }} replace />;
}
// Role-based access control
if (requiredRole && user && user.role && user.role !== requiredRole && user.role !== 'admin') {
// Redirect to 403 Forbidden page
return <Navigate to="/403" state={{ from: location.pathname }} replace />;
}
return children;
};
export default ProtectedRoute;
+11
View File
@@ -0,0 +1,11 @@
// Deprecated: use `components/admin/AdminSidebar` via `layouts/AdminLayout`.
// This thin wrapper keeps backward compatibility by rendering the new AdminSidebar.
import AdminSidebar from './admin/AdminSidebar';
const Sidebar = () => {
return (
<AdminSidebar isOpen={true} onClose={() => {}} />
);
};
export default Sidebar;
+34
View File
@@ -0,0 +1,34 @@
import { Box, HStack, Image, Link, Spinner, Text, useColorModeValue } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import api from '../services/api';
import { assetUrl } from '../utils/url';
type Sponsor = { id: number; name: string; logo_url?: string; website_url?: string };
type SponsorsResponse = Sponsor[] | { data: Sponsor[] };
const fetchSponsors = async (): Promise<Sponsor[]> => {
const res = await api.get<SponsorsResponse>('/sponsors');
const data = Array.isArray(res.data) ? res.data : res.data.data;
return data || [];
};
const SponsorsStrip: React.FC = () => {
const { data, isLoading, isError } = useQuery({ queryKey: ['sponsors'], queryFn: fetchSponsors });
return (
<Box bg={useColorModeValue('white', 'gray.800')} borderTopWidth="1px" borderColor={useColorModeValue('gray.200', 'gray.700')} mt={8} py={4}>
<HStack spacing={6} overflowX="auto" px={4}>
{isLoading && <Spinner />}
{isError && <Text color="red.500">Chyba při načítání sponzorů</Text>}
{data?.map((s) => (
<Link key={s.id} href={s.website_url || '#'} isExternal>
<Image src={assetUrl(s.logo_url) || '/logo192.png'} alt={s.name} height="50px" objectFit="contain" />
</Link>
))}
</HStack>
</Box>
);
};
export default SponsorsStrip;
@@ -0,0 +1,45 @@
import { Box, BoxProps, useColorModeValue } from '@chakra-ui/react';
import { ReactNode } from 'react';
interface AdminCardProps extends BoxProps {
children: ReactNode;
variant?: 'outline' | 'filled' | 'unstyled';
hoverEffect?: boolean;
}
export const AdminCard = ({
children,
variant = 'outline',
hoverEffect = false,
...props
}: AdminCardProps) => {
const bg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const hoverBg = useColorModeValue('gray.50', 'gray.750');
const variants = {
outline: {
bg,
border: '1px solid',
borderColor,
},
filled: {
bg: useColorModeValue('gray.50', 'gray.750'),
},
unstyled: {},
} as const;
return (
<Box
borderRadius="lg"
p={6}
boxShadow="sm"
transition="all 0.2s"
_hover={hoverEffect ? { transform: 'translateY(-2px)', boxShadow: 'md' } : {}}
{...variants[variant]}
{...props}
>
{children}
</Box>
);
};
@@ -0,0 +1,321 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { FiCommand, FiSave, FiRefreshCw, FiSearch } from 'react-icons/fi';
import { useToast } from '@chakra-ui/react';
/**
* AdminEnhancer - Adds admin-specific functionality
* - Keyboard shortcuts (Ctrl+S, Ctrl+K, etc.)
* - Auto-save drafts
* - Unsaved changes warning
* - Quick search
* - Keyboard shortcuts help
*/
interface AdminEnhancerProps {
children: React.ReactNode;
onSave?: () => void | Promise<void>;
onRefresh?: () => void | Promise<void>;
onSearch?: () => void;
hasUnsavedChanges?: boolean;
}
const AdminEnhancer: React.FC<AdminEnhancerProps> = ({
children,
onSave,
onRefresh,
onSearch,
hasUnsavedChanges = false,
}) => {
const [showShortcuts, setShowShortcuts] = useState(false);
const [lastSaved, setLastSaved] = useLocalStorage('admin-last-saved', '');
const toast = useToast();
// Warn before leaving with unsaved changes
useEffect(() => {
if (!hasUnsavedChanges) return;
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = '';
return '';
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [hasUnsavedChanges]);
// Handle save
const handleSave = useCallback(async () => {
if (!onSave) return;
try {
await onSave();
const now = new Date().toISOString();
setLastSaved(now);
toast({
title: 'Uloženo',
description: 'Změny byly úspěšně uloženy',
status: 'success',
duration: 2000,
position: 'bottom-right',
});
} catch (error) {
toast({
title: 'Chyba',
description: 'Nepodařilo se uložit změny',
status: 'error',
duration: 3000,
position: 'bottom-right',
});
}
}, [onSave, setLastSaved, toast]);
// Handle refresh
const handleRefresh = useCallback(async () => {
if (!onRefresh) return;
try {
await onRefresh();
toast({
title: 'Obnoveno',
description: 'Data byla aktualizována',
status: 'info',
duration: 2000,
position: 'bottom-right',
});
} catch (error) {
toast({
title: 'Chyba',
description: 'Nepodařilo se obnovit data',
status: 'error',
duration: 3000,
position: 'bottom-right',
});
}
}, [onRefresh, toast]);
// Admin keyboard shortcuts
useKeyboardShortcuts([
{
key: 's',
ctrlKey: true,
callback: () => {
handleSave();
},
description: 'Uložit změny',
},
{
key: 'k',
ctrlKey: true,
callback: () => {
if (onSearch) onSearch();
},
description: 'Otevřít vyhledávání',
},
{
key: 'r',
ctrlKey: true,
callback: () => {
handleRefresh();
},
description: 'Obnovit data',
},
{
key: '?',
shiftKey: true,
callback: () => setShowShortcuts(true),
description: 'Zobrazit klávesové zkratky',
},
{
key: 'Escape',
callback: () => setShowShortcuts(false),
},
]);
return (
<>
{children}
{/* Unsaved changes indicator */}
{hasUnsavedChanges && (
<div
style={{
position: 'fixed',
bottom: 24,
left: 24,
padding: '12px 16px',
background: '#f59e0b',
color: 'white',
borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
display: 'flex',
alignItems: 'center',
gap: 8,
zIndex: 9999,
animation: 'pulse 2s infinite',
}}
>
<span style={{ fontSize: 14, fontWeight: 600 }}>
Máte neuložené změny
</span>
{onSave && (
<button
onClick={handleSave}
style={{
background: 'white',
color: '#f59e0b',
border: 'none',
padding: '6px 12px',
borderRadius: 4,
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
}}
>
Uložit nyní
</button>
)}
</div>
)}
{/* Keyboard shortcuts modal */}
{showShortcuts && (
<>
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.6)',
backdropFilter: 'blur(4px)',
zIndex: 10000,
}}
onClick={() => setShowShortcuts(false)}
/>
<div
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: 'white',
borderRadius: 12,
padding: 24,
maxWidth: 500,
width: '90%',
maxHeight: '80vh',
overflow: 'auto',
zIndex: 10001,
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
<FiCommand size={24} />
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>
Klávesové zkratky
</h2>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<ShortcutItem keys={['Ctrl', 'S']} description="Uložit změny" icon={<FiSave />} />
<ShortcutItem keys={['Ctrl', 'K']} description="Vyhledávání" icon={<FiSearch />} />
<ShortcutItem keys={['Ctrl', 'R']} description="Obnovit data" icon={<FiRefreshCw />} />
<ShortcutItem keys={['?']} description="Zobrazit zkratky" icon={<FiCommand />} />
<ShortcutItem keys={['Esc']} description="Zavřít modál" />
<ShortcutItem keys={['Home']} description="Na začátek stránky" />
<ShortcutItem keys={['End']} description="Na konec stránky" />
</div>
<button
onClick={() => setShowShortcuts(false)}
style={{
marginTop: 20,
width: '100%',
padding: '10px 20px',
background: 'var(--primary, #C53030)',
color: 'white',
border: 'none',
borderRadius: 8,
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
}}
>
Zavřít
</button>
</div>
</>
)}
{/* Shortcut hint button */}
<button
onClick={() => setShowShortcuts(true)}
style={{
position: 'fixed',
bottom: 24,
right: 24,
width: 48,
height: 48,
borderRadius: '50%',
background: 'white',
border: '2px solid #e2e8f0',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
transition: 'all 0.3s ease',
zIndex: 9998,
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-4px)';
e.currentTarget.style.boxShadow = '0 6px 16px rgba(0,0,0,0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
}}
title="Klávesové zkratky (Shift + ?)"
>
<FiCommand size={24} color="#4a5568" />
</button>
</>
);
};
const ShortcutItem: React.FC<{ keys: string[]; description: string; icon?: React.ReactNode }> = ({
keys,
description,
icon,
}) => (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{icon && <span style={{ color: '#718096' }}>{icon}</span>}
<span style={{ fontSize: 14, color: '#2d3748' }}>{description}</span>
</div>
<div style={{ display: 'flex', gap: 4 }}>
{keys.map((key, i) => (
<React.Fragment key={key}>
<kbd
style={{
padding: '4px 8px',
background: '#e2e8f0',
borderRadius: 4,
fontSize: 12,
fontFamily: 'monospace',
fontWeight: 600,
color: '#4a5568',
border: '1px solid #cbd5e0',
}}
>
{key}
</kbd>
{i < keys.length - 1 && <span style={{ color: '#a0aec0' }}>+</span>}
</React.Fragment>
))}
</div>
</div>
);
export default AdminEnhancer;
@@ -0,0 +1,142 @@
import {
Box,
Flex,
IconButton,
useColorMode,
Text,
Menu,
MenuButton,
MenuList,
MenuItem,
Avatar,
useColorModeValue,
HStack,
BoxProps,
Tooltip,
Link as ChakraLink
} from '@chakra-ui/react';
import { FaBars, FaMoon, FaSun, FaSignOutAlt, FaUserCog, FaBook } from 'react-icons/fa';
import { useAuth } from '../../contexts/AuthContext';
import { User } from '../../types';
import { ReactNode } from 'react';
import { Link as RouterLink } from 'react-router-dom';
interface AdminHeaderProps extends BoxProps {
onMenuToggle: () => void;
rightContent?: ReactNode;
}
const AdminHeader = ({ onMenuToggle, rightContent, ...rest }: AdminHeaderProps) => {
const { colorMode, toggleColorMode } = useColorMode();
const { user, logout } = useAuth();
const bg = useColorModeValue('white', '#1a1d29');
const borderColor = useColorModeValue('gray.200', 'rgba(255, 255, 255, 0.12)');
const textColor = useColorModeValue('gray.800', '#e2e8f0');
const userData = user as User | null;
const headerShadow = useColorModeValue('sm', 'none');
return (
<Box
as="header"
position="sticky"
top={0}
left={0}
right={0}
bg={bg}
borderBottomWidth="1px"
borderColor={borderColor}
zIndex={20}
height="60px"
px={{ base: 3, md: 6 }}
boxShadow={headerShadow}
transition="all 0.2s"
{...rest}
>
<Flex h="100%" alignItems="center" justifyContent="space-between">
<Flex alignItems="center">
<IconButton
display={{ base: 'flex', md: 'none' }}
aria-label="Otevřít menu"
icon={<FaBars />}
variant="ghost"
onClick={onMenuToggle}
mr={2}
/>
<Text fontSize="xl" fontWeight="bold" display={{ base: 'none', md: 'block' }}>
Fotbal Admin
</Text>
</Flex>
<HStack spacing={4}>
{rightContent || (
<>
<Tooltip label="Dokumentace" hasArrow>
<ChakraLink as={RouterLink} to="/admin/docs">
<IconButton
aria-label="Dokumentace"
icon={<FaBook />}
variant="ghost"
size="sm"
mr={1}
/>
</ChakraLink>
</Tooltip>
<IconButton
aria-label="Přepnout barevné schéma"
icon={colorMode === 'light' ? <FaMoon /> : <FaSun />}
variant="ghost"
onClick={toggleColorMode}
size="sm"
/>
<Menu>
<MenuButton>
<Avatar
size="sm"
name={userData?.name || 'Uživatel'}
src={userData?.avatar}
cursor="pointer"
border="2px solid"
borderColor={useColorModeValue('gray.200', 'gray.600')}
_hover={{
transform: 'scale(1.05)',
transition: 'transform 0.2s'
}}
/>
</MenuButton>
<MenuList zIndex={30}>
{userData?.name && (
<Box px={3} py={2} borderBottomWidth="1px" borderColor={borderColor}>
<Text fontWeight="medium">{userData.name}</Text>
<Text fontSize="sm" color="gray.500">{userData.email}</Text>
</Box>
)}
<MenuItem
icon={<FaUserCog />}
_hover={{
bg: useColorModeValue('gray.100', 'gray.700')
}}
>
Můj účet
</MenuItem>
<MenuItem
icon={<FaSignOutAlt />}
color="red.500"
_hover={{
bg: useColorModeValue('red.50', 'red.900')
}}
onClick={logout}
>
Odhlásit se
</MenuItem>
</MenuList>
</Menu>
</>
)}
</HStack>
</Flex>
</Box>
);
};
export default AdminHeader;
@@ -0,0 +1,19 @@
import { Box, Text, Link, Alert, AlertIcon } from '@chakra-ui/react';
const AdminHelp: React.FC = () => {
return (
<Box mt={6}>
<Alert status="info" borderRadius="md">
<AlertIcon />
<Text>
Pro kompletní dokumentaci navštivte{' '}
<Link href="/docs" color="blue.600" fontWeight="semibold" textDecoration="underline">
dokumentaci administrace
</Link>
</Text>
</Alert>
</Box>
);
};
export default AdminHelp;
@@ -0,0 +1,193 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
InputGroup,
InputLeftElement,
Input,
List,
ListItem,
HStack,
Text,
Badge,
Icon,
Box,
Kbd,
} from '@chakra-ui/react';
import { FaSearch, FaCog, FaNewspaper, FaUsers, FaImage, FaHandshake, FaEnvelope, FaAward, FaSyncAlt, FaVideo, FaCalendarAlt, FaPalette, FaCommentAlt, FaKey, FaChartLine, FaBook, FaTools, FaBell, FaBars } from 'react-icons/fa';
export type AdminSearchItem = {
label: string;
path: string;
section: string;
keywords?: string[];
icon?: any;
};
const adminIndex: AdminSearchItem[] = [
{ label: 'Dashboard', path: '/admin', section: 'Core', keywords: ['overview', 'stat', 'dashboard'], icon: FaTools },
{ label: 'Články', path: '/admin/clanky', section: 'Obsah', keywords: ['articles', 'posts', 'blog'], icon: FaNewspaper },
{ label: 'Hráči', path: '/admin/hraci', section: 'Kádry', keywords: ['players'], icon: FaUsers },
{ label: 'Týmy', path: '/admin/tymy', section: 'Kádry', keywords: ['teams'], icon: FaUsers },
{ label: 'Zápasy', path: '/admin/zapasy', section: 'FAČR', keywords: ['matches', 'facr'], icon: FaCalendarAlt },
{ label: 'Média', path: '/admin/media', section: 'Obsah', keywords: ['uploads', 'images'], icon: FaImage },
{ label: 'Sponzoři', path: '/admin/sponzori', section: 'Marketing', keywords: ['sponsors', 'partners'], icon: FaHandshake },
{ label: 'Bannery', path: '/admin/bannery', section: 'Marketing', keywords: ['banners'], icon: FaImage },
{ label: 'Kategorie', path: '/admin/kategorie', section: 'Obsah', keywords: ['categories'], icon: FaAward },
{ label: 'Nastavení', path: '/admin/nastaveni', section: 'Systém', keywords: ['settings', 'config'], icon: FaCog },
{ label: 'Newsletter', path: '/admin/newsletter', section: 'Komunikace', keywords: ['email', 'campaign'], icon: FaEnvelope },
{ label: 'Uživatelé', path: '/admin/uzivatele', section: 'Systém', keywords: ['users', 'accounts'], icon: FaKey },
{ label: 'Prefetch', path: '/admin/prefetch', section: 'Systém', keywords: ['cache', 'fetch'], icon: FaSyncAlt },
{ label: 'Galerie', path: '/admin/galerie', section: 'Média', keywords: ['gallery', 'zonerama'], icon: FaImage },
{ label: 'Videa', path: '/admin/videa', section: 'Média', keywords: ['youtube', 'videos'], icon: FaVideo },
{ label: 'Analytika', path: '/admin/analytika', section: 'SEO', keywords: ['analytics', 'umami'], icon: FaChartLine },
{ label: 'O klubu', path: '/admin/o-klubu', section: 'Obsah', keywords: ['about'], icon: FaPalette },
{ label: 'Navigace', path: '/admin/navigace', section: 'Systém', keywords: ['navigation', 'menu', 'sidebar'], icon: FaBars },
{ label: 'Notifikace: Zápasy', path: '/admin/notifications', section: 'Komunikace', keywords: ['notifications', 'match'], icon: FaBell },
// Docs
{ label: 'Dokumentace (Úvod)', path: '/admin/docs#uvod', section: 'Docs', keywords: ['docs', 'documentation'], icon: FaBook },
{ label: 'Dokumentace (Nastavení)', path: '/admin/docs#nastaveni', section: 'Docs', keywords: ['docs', 'settings'], icon: FaBook },
{ label: 'Dokumentace (Články)', path: '/admin/docs#clanky', section: 'Docs', keywords: ['docs', 'articles'], icon: FaBook },
{ label: 'Dokumentace (Newsletter)', path: '/admin/docs#newsletter', section: 'Docs', keywords: ['docs', 'email'], icon: FaBook },
{ label: 'Dokumentace (Řešení problémů)', path: '/admin/docs#troubleshooting', section: 'Docs', keywords: ['docs', 'troubleshooting'], icon: FaBook },
];
function highlight(text: string, q: string) {
if (!q) return text;
try {
const esc = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(esc, 'gi');
const parts = text.split(re);
const matches = text.match(re) || [];
const out: any[] = [];
parts.forEach((p, idx) => {
out.push(p);
if (idx < matches.length) out.push(<mark key={idx} style={{ backgroundColor: '#fde68a' }}>{matches[idx]}</mark>);
});
return <>{out}</>;
} catch {
return text;
}
}
function score(item: AdminSearchItem, q: string) {
const t = (item.label || '').toLowerCase();
const b = q.toLowerCase();
const kws = (item.keywords || []).join(' ').toLowerCase();
let s = 0;
if (!b) return s;
if (t === b) s += 200;
if (t.startsWith(b)) s += 120;
if (t.includes(b)) s += 80 - t.indexOf(b);
if (kws.includes(b)) s += 40;
// small preference for Docs when # present
if (item.section === 'Docs' && item.path.includes('#')) s += 5;
return s;
}
export default function AdminSearchModal({ isOpen, onClose, onSelectPath }: { isOpen: boolean; onClose: () => void; onSelectPath: (path: string) => void }) {
const [q, setQ] = useState('');
const [debounced, setDebounced] = useState('');
const [idx, setIdx] = useState(-1);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
const id = setTimeout(() => setDebounced(q.trim()), 250);
return () => clearTimeout(id);
}, [q]);
useEffect(() => {
if (isOpen) {
setQ('');
setDebounced('');
setIdx(-1);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [isOpen]);
const results = useMemo(() => {
const arr = adminIndex.map((it) => ({ it, s: score(it, debounced) }))
.filter((r) => r.s > 0 || !debounced)
.sort((a, b) => b.s - a.s || a.it.label.localeCompare(b.it.label))
.slice(0, 12)
.map((r) => r.it);
return arr;
}, [debounced]);
const onSelect = useCallback((path: string) => {
onClose();
onSelectPath(path);
}, [onClose, onSelectPath]);
const onKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
const n = results.length;
if (e.key === 'ArrowDown') { e.preventDefault(); setIdx((i) => Math.min(n - 1, i + 1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setIdx((i) => Math.max(-1, i - 1)); }
else if (e.key === 'Enter') {
const chosen = idx >= 0 ? results[idx] : results[0];
if (chosen) onSelect(chosen.path);
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault(); onClose();
} else if (e.key === 'Escape') {
onClose();
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg" motionPreset="scale">
<ModalOverlay />
<ModalContent>
<ModalHeader>
Admin vyhledávání
<Box as="span" ml={3} color="gray.500" fontSize="sm">
<Kbd>Ctrl</Kbd>+<Kbd>K</Kbd>
</Box>
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={4}>
<InputGroup size="lg">
<InputLeftElement pointerEvents="none">
<Icon as={FaSearch} />
</InputLeftElement>
<Input
placeholder="Hledat v administraci (stránky, nastavení, dokumentace)"
value={q}
onChange={(e) => { setQ(e.target.value); setIdx(-1); }}
onKeyDown={onKeyDown}
ref={inputRef}
autoFocus
/>
</InputGroup>
<List mt={4} spacing={1}>
{results.map((r, i) => (
<ListItem
key={r.path}
px={3}
py={2}
borderRadius="md"
cursor="pointer"
bg={i === idx ? 'blackAlpha.50' : 'transparent'}
_hover={{ bg: 'blackAlpha.50' }}
onClick={() => onSelect(r.path)}
>
<HStack>
{r.icon ? <Icon as={r.icon} color="blue.500" /> : null}
<Text fontWeight="semibold">{highlight(r.label, debounced)}</Text>
<Badge ml="auto" colorScheme="gray">{r.section}</Badge>
</HStack>
</ListItem>
))}
{results.length === 0 && (
<Box color="gray.500" fontSize="sm" px={1} py={2}>Žádné výsledky</Box>
)}
</List>
</ModalBody>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,631 @@
import { Box, VStack, Text, useColorModeValue, Icon, Link as ChakraLink, Divider, Image, Flex, Spinner } from '@chakra-ui/react';
import { Link as RouterLink, useLocation } from 'react-router-dom';
import { useEffect, useRef, useCallback, useState } from 'react';
import {
FaTachometerAlt,
FaUsers,
FaFutbol,
FaCalendarAlt,
FaNewspaper,
FaHandshake,
FaImage,
FaEnvelope,
FaCog,
FaPalette,
FaHome,
FaSignOutAlt,
FaPaperPlane,
FaAward,
FaSyncAlt,
FaBook,
FaMobileAlt,
FaChartBar,
FaFolder,
FaAddressBook,
FaBars,
FaPoll,
FaPaintBrush,
FaVideo,
FaCamera,
FaTshirt,
FaBullhorn,
FaUserShield,
FaFileAlt
} from 'react-icons/fa';
import { useAuth } from '../../contexts/AuthContext';
import { useQuery } from '@tanstack/react-query';
import { getUpcomingEvents } from '../../services/eventService';
import { getAllNavigationItems, NavigationItem, seedDefaultNavigation } from '../../services/navigation';
interface NavItemProps {
icon: any;
to?: string;
children: React.ReactNode;
onClick?: (e?: React.MouseEvent) => void;
}
const NavItem = ({ icon, to, children, onClick }: NavItemProps) => {
const location = useLocation();
const isActive = to ? location.pathname.startsWith(to) : false;
const activeBg = useColorModeValue('blue.50', 'blue.900');
const activeColor = useColorModeValue('blue.600', 'blue.300');
const handleClick = (e: React.MouseEvent) => {
// Call the onClick handler first
if (onClick) {
onClick(e);
// If onClick called preventDefault, respect it
if (e.isDefaultPrevented()) {
return;
}
}
// Allow RouterLink to handle navigation normally
};
// If onClick is provided without `to`, render as a button-like link
const LinkComponent = to ? RouterLink : 'a';
const linkProps = to ? { to } : { href: '#' };
return (
<ChakraLink
as={LinkComponent}
{...linkProps}
display="flex"
alignItems="center"
px={3}
py={2.5}
borderRadius="lg"
bg={isActive ? activeBg : 'transparent'}
color={isActive ? activeColor : 'inherit'}
fontWeight={isActive ? 'semibold' : 'medium'}
fontSize="sm"
_hover={{
textDecoration: 'none',
bg: isActive ? activeBg : useColorModeValue('gray.100', 'gray.700'),
transform: 'translateX(2px)',
}}
transition="all 0.2s ease"
onClick={handleClick}
data-navitem="true"
data-active={isActive ? 'true' : undefined}
position="relative"
_before={isActive ? {
content: '""',
position: 'absolute',
left: 0,
top: '50%',
transform: 'translateY(-50%)',
width: '3px',
height: '60%',
bg: activeColor,
borderRadius: 'full',
} : {}}
>
<Icon as={icon} mr={3} boxSize={4} />
<Text flex={1}>{children}</Text>
</ChakraLink>
);
};
interface AdminSidebarProps {
isOpen: boolean;
onClose: () => void;
bg?: string;
borderRight?: string;
borderColor?: string;
}
// Icon mapping for navigation items
const getIconForPageType = (pageType?: string): any => {
const iconMap: Record<string, any> = {
dashboard: FaTachometerAlt,
analytics: FaChartBar,
teams: FaUsers,
matches: FaCalendarAlt,
activities: FaCalendarAlt,
players: FaFutbol,
articles: FaNewspaper,
categories: FaFileAlt,
about: FaBook,
videos: FaVideo,
gallery: FaImage,
scoreboard: FaTachometerAlt,
scoreboard_remote: FaMobileAlt,
clothing: FaTshirt,
sponsors: FaHandshake,
banners: FaBullhorn,
messages: FaEnvelope,
contacts: FaAddressBook,
newsletter: FaPaperPlane,
polls: FaPoll,
navigation: FaBars,
competition_aliases: FaAward,
prefetch: FaSyncAlt,
users: FaUserShield,
settings: FaPalette,
files: FaFolder,
docs: FaBook,
};
return iconMap[pageType || ''] || FaFileAlt;
};
const AdminSidebar = ({
isOpen,
onClose,
bg: bgProp,
borderRight = '1px',
borderColor: borderColorProp
}: AdminSidebarProps) => {
const { logout, user } = useAuth();
const isAdmin = (user as any)?.role === 'admin';
const defaultBg = useColorModeValue('white', '#1a1d29');
const defaultBorderColor = useColorModeValue('gray.200', 'rgba(255, 255, 255, 0.12)');
const textColor = useColorModeValue('gray.800', '#e2e8f0');
const bg = bgProp || defaultBg;
const borderColor = borderColorProp || defaultBorderColor;
// Upcoming events count for badge
const { data: upcomingEvents } = useQuery({ queryKey: ['admin-sidebar-upcoming-events'], queryFn: getUpcomingEvents });
const upcomingCount = Array.isArray(upcomingEvents) ? upcomingEvents.length : 0;
const scrollRef = useRef<HTMLDivElement | null>(null);
const location = useLocation();
const STORAGE_KEY = 'admin-sidebar-scroll';
// Dynamic navigation state
const [navItems, setNavItems] = useState<NavigationItem[]>([]);
const [navLoading, setNavLoading] = useState(true);
// Restore scroll on mount
useEffect(() => {
const node = scrollRef.current;
if (!node) return;
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const top = parseInt(saved, 10);
if (!Number.isNaN(top)) {
node.scrollTop = top;
}
} catch {}
}
}, []);
// Save scroll on scroll
const handleScroll = useCallback(() => {
const node = scrollRef.current;
if (!node) return;
sessionStorage.setItem(STORAGE_KEY, String(node.scrollTop));
}, []);
// Load dynamic navigation from API
useEffect(() => {
let active = true;
(async () => {
try {
const items = await getAllNavigationItems();
if (active && Array.isArray(items)) {
// Filter only admin navigation items
const adminItems = items.filter(item => item.requires_admin);
// Auto-seed if admin navigation is empty and user is admin
if (adminItems.length === 0 && isAdmin) {
try {
console.log('Admin navigation empty, auto-seeding...');
await seedDefaultNavigation();
const newItems = await getAllNavigationItems();
if (active && Array.isArray(newItems)) {
const newAdminItems = newItems.filter(item => item.requires_admin);
setNavItems(newAdminItems);
}
} catch (seedError) {
console.error('Auto-seed failed:', seedError);
// Continue with empty navigation (will show fallback)
setNavItems(adminItems);
}
} else {
setNavItems(adminItems);
}
}
} catch (error) {
console.error('Failed to load admin navigation:', error);
} finally {
if (active) setNavLoading(false);
}
})();
return () => { active = false };
}, [isAdmin]);
// Keep active item in view upon route change - but only if it's not visible
useEffect(() => {
const node = scrollRef.current;
if (!node) return;
const active = node.querySelector('[data-navitem][data-active="true"]') as HTMLElement | null;
if (active) {
// Check if the active item is already visible in the viewport
const containerRect = node.getBoundingClientRect();
const activeRect = active.getBoundingClientRect();
const isVisible = (
activeRect.top >= containerRect.top &&
activeRect.bottom <= containerRect.bottom
);
// Only scroll if the active item is not fully visible
if (!isVisible) {
active.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
}
}
}, [location.pathname]);
return (
<Box
as="nav"
position="fixed"
left={0}
top={0}
bottom={0}
width="260px"
bg={bg}
borderRightWidth={borderRight}
borderColor={borderColor}
pt={5}
display={{ base: isOpen ? 'block' : 'none', md: 'block' }}
zIndex={10}
overflowY="auto"
overflowX="hidden"
boxShadow="lg"
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
ref={scrollRef}
onScroll={handleScroll}
css={{
'&::-webkit-scrollbar': { width: '4px' },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: useColorModeValue('gray.300', 'gray.600'), borderRadius: '2px' },
'&::-webkit-scrollbar-thumb:hover': { background: useColorModeValue('gray.400', 'gray.500') },
}}
>
<VStack align="stretch" spacing={1} px={3} pb={6}>
<Box px={3} mb={8}>
<Flex align="center" gap={3} mb={2}>
<Image
src="/api/logo"
alt="Club Logo"
boxSize="48px"
objectFit="contain"
fallbackSrc="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%23e2e8f0'/%3E%3Ctext x='50' y='55' text-anchor='middle' font-size='40' fill='%23718096'%3EMC%3C/text%3E%3C/svg%3E"
borderRadius="md"
/>
<VStack align="start" spacing={0}>
<Text
fontSize="xl"
fontWeight="extrabold"
color={useColorModeValue('gray.800', 'white')}
letterSpacing="tight"
>
My Club
</Text>
<Text fontSize="xs" color={useColorModeValue('gray.500', 'gray.400')} fontWeight="semibold" textTransform="uppercase" letterSpacing="wider">
Admin Panel
</Text>
</VStack>
</Flex>
</Box>
<NavItem
icon={FaHome}
to="/"
onClick={onClose}
>
Zpět na web
</NavItem>
<Divider my={2} />
{/* Dynamic Navigation */}
{navLoading ? (
<Flex justify="center" py={8}>
<Spinner size="sm" />
</Flex>
) : navItems.length > 0 ? (
// Render dynamic navigation
<>
{navItems.filter(item => item.visible).map((item, index) => {
const itemIcon = getIconForPageType(item.page_type);
const itemUrl = item.url || '#';
// Add badge for activities showing upcoming count
const isActivities = item.page_type === 'activities';
const showBadge = isActivities && upcomingCount > 0;
return (
<NavItem
key={item.id || index}
icon={itemIcon}
to={itemUrl}
onClick={onClose}
>
<Text as="span">
{item.label}
{showBadge && (
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('green.100','green.900')} color={useColorModeValue('green.700','green.200')} borderWidth="1px" borderColor={useColorModeValue('green.200','green.700')}>
{upcomingCount}
</Text>
)}
</Text>
</NavItem>
);
})}
{/* MyUIbrix Editor - Special item */}
<NavItem
icon={FaPaintBrush}
onClick={(e) => {
e?.preventDefault();
window.open('/?myuibrix=edit', '_blank');
}}
>
MyUIbrix Editor
</NavItem>
</>
) : (
// Fallback to hardcoded navigation
<>
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={useColorModeValue('gray.500', 'gray.400')} textTransform="uppercase" letterSpacing="wider">
Hlavní
</Text>
<NavItem
icon={FaTachometerAlt}
to="/admin"
onClick={onClose}
>
Nástěnka
</NavItem>
{isAdmin && (
<NavItem
icon={FaChartBar}
to="/admin/analytika"
onClick={onClose}
>
Analytika
</NavItem>
)}
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={useColorModeValue('gray.500', 'gray.400')} textTransform="uppercase" letterSpacing="wider" mt={4}>
Obsah
</Text>
{/* Core sports entities first */}
<NavItem
icon={FaUsers}
to="/admin/tymy"
onClick={onClose}
>
Týmy
</NavItem>
<NavItem
icon={FaCalendarAlt}
to="/admin/zapasy"
onClick={onClose}
>
{/* Add subtle scroller hint */}
<Text as="span">
Zápasy
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('gray.100','whiteAlpha.200')} color={useColorModeValue('gray.700','gray.300')} borderWidth="1px" borderColor={useColorModeValue('gray.200','whiteAlpha.300')}>
scroller
</Text>
</Text>
</NavItem>
<NavItem
icon={FaCalendarAlt}
to="/admin/aktivity"
onClick={onClose}
>
<Text as="span">
Aktivity
{upcomingCount > 0 && (
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('green.100','green.900')} color={useColorModeValue('green.700','green.200')} borderWidth="1px" borderColor={useColorModeValue('green.200','green.700')}>
{upcomingCount}
</Text>
)}
</Text>
</NavItem>
<NavItem
icon={FaFutbol}
to="/admin/hraci"
onClick={onClose}
>
Hráči
</NavItem>
{/* Other content */}
<NavItem
icon={FaNewspaper}
to="/admin/clanky"
onClick={onClose}
>
Články
</NavItem>
<NavItem
icon={FaFileAlt}
to="/admin/kategorie"
onClick={onClose}
>
Kategorie
</NavItem>
<NavItem
icon={FaBook}
to="/admin/o-klubu"
onClick={onClose}
>
O klubu
</NavItem>
<NavItem
icon={FaImage}
to="/admin/videa"
onClick={onClose}
>
Videa
</NavItem>
<NavItem
icon={FaImage}
to="/admin/galerie"
onClick={onClose}
>
Galerie (Zonerama)
</NavItem>
<NavItem
icon={FaTachometerAlt}
to="/admin/scoreboard"
onClick={onClose}
>
Tabule (Scoreboard)
</NavItem>
<NavItem
icon={FaMobileAlt}
to="/admin/scoreboard/remote"
onClick={onClose}
>
Scoreboard Remote
</NavItem>
<NavItem
icon={FaPalette}
to="/admin/obleceni"
onClick={onClose}
>
Oblečení
</NavItem>
<NavItem
icon={FaHandshake}
to="/admin/sponzori"
onClick={onClose}
>
Sponzoři
</NavItem>
<NavItem
icon={FaImage}
to="/admin/bannery"
onClick={onClose}
>
Bannery
</NavItem>
<NavItem
icon={FaEnvelope}
to="/admin/zpravy"
onClick={onClose}
>
Zprávy
</NavItem>
<NavItem
icon={FaAddressBook}
to="/admin/kontakty"
onClick={onClose}
>
Kontakty
</NavItem>
<NavItem
icon={FaPaperPlane}
to="/admin/newsletter"
onClick={onClose}
>
Zpravodaj
</NavItem>
<NavItem
icon={FaPoll}
to="/admin/ankety"
onClick={onClose}
>
Ankety
</NavItem>
<Divider my={2} />
{isAdmin && (
<>
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={useColorModeValue('gray.500', 'gray.400')} textTransform="uppercase" letterSpacing="wider" mt={4}>
Nastavení
</Text>
<NavItem
icon={FaPaintBrush}
onClick={(e) => {
e?.preventDefault();
window.open('/?myuibrix=edit', '_blank');
}}
>
MyUIbrix Editor
</NavItem>
<NavItem
icon={FaBars}
to="/admin/navigace"
onClick={onClose}
>
Navigace
</NavItem>
<NavItem
icon={FaAward}
to="/admin/aliasy-soutezi"
onClick={onClose}
>
Alias soutěží
</NavItem>
<NavItem
icon={FaSyncAlt}
to="/admin/prefetch"
onClick={onClose}
>
Prefetch & Cache
</NavItem>
<NavItem
icon={FaUsers}
to="/admin/uzivatele"
onClick={onClose}
>
Uživatelé
</NavItem>
<NavItem
icon={FaPalette}
to="/admin/nastaveni"
onClick={onClose}
>
Nastavení
</NavItem>
<NavItem
icon={FaFolder}
to="/admin/soubory"
onClick={onClose}
>
Soubory
</NavItem>
</>
)}
</>
)}
<Box mt="auto" mb={4} px={2}>
<ChakraLink
as="button"
display="flex"
alignItems="center"
w="100%"
px={4}
py={2}
borderRadius="md"
_hover={{
textDecoration: 'none',
bg: useColorModeValue('red.50', 'red.900'),
color: 'red.500',
}}
onClick={logout}
color={useColorModeValue('red.500', 'red.300')}
>
<Icon as={FaSignOutAlt} mr={3} />
<Text>Odhlásit se</Text>
</ChakraLink>
</Box>
</VStack>
</Box>
);
};
export default AdminSidebar;
@@ -0,0 +1,149 @@
import {
Table as ChakraTable,
Thead,
Tbody,
Tr,
Th,
Td,
TableProps as ChakraTableProps,
TableContainer,
Text,
Skeleton,
useColorModeValue,
Box,
} from '@chakra-ui/react';
import { ReactNode } from 'react';
export interface Column<T> {
header: string;
accessor: keyof T | ((item: T) => ReactNode);
isNumeric?: boolean;
width?: string | number;
cellProps?: (item: T) => Record<string, any>;
}
interface AdminTableProps<T> extends ChakraTableProps {
columns: Column<T>[];
data: T[] | undefined;
isLoading?: boolean;
emptyMessage?: string;
onRowClick?: (item: T) => void;
rowHoverEffect?: boolean;
}
export function AdminTable<T>({
columns,
data,
isLoading = false,
emptyMessage = 'No data available',
onRowClick,
rowHoverEffect = true,
...props
}: AdminTableProps<T>) {
const borderColor = useColorModeValue('gray.200', 'gray.700');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
const headerBg = useColorModeValue('gray.50', 'gray.700');
const headerColor = useColorModeValue('gray.600', 'gray.300');
if (isLoading) {
return (
<TableContainer>
<ChakraTable variant="simple" {...props}>
<Thead>
<Tr>
{columns.map((column, index) => (
<Th key={index} bg={headerBg} color={headerColor}>
{column.header}
</Th>
))}
</Tr>
</Thead>
<Tbody>
{[1, 2, 3].map((row) => (
<Tr key={row}>
{columns.map((column, colIndex) => (
<Td key={colIndex}>
<Skeleton height="20px" />
</Td>
))}
</Tr>
))}
</Tbody>
</ChakraTable>
</TableContainer>
);
}
if (!data || data.length === 0) {
return (
<Box
p={8}
textAlign="center"
borderWidth="1px"
borderRadius="md"
borderColor={borderColor}
>
<Text color="gray.500">{emptyMessage}</Text>
</Box>
);
}
return (
<TableContainer
borderWidth="1px"
borderRadius="md"
borderColor={borderColor}
overflowX="auto"
>
<ChakraTable variant="simple" {...props}>
<Thead>
<Tr>
{columns.map((column, index) => (
<Th
key={index}
isNumeric={column.isNumeric}
width={column.width}
bg={headerBg}
color={headerColor}
textTransform="uppercase"
fontSize="xs"
letterSpacing="wider"
borderBottomWidth="1px"
borderColor={borderColor}
>
{column.header}
</Th>
))}
</Tr>
</Thead>
<Tbody>
{data.map((item, rowIndex) => (
<Tr
key={rowIndex}
onClick={() => onRowClick?.(item)}
cursor={onRowClick ? 'pointer' : 'default'}
_hover={rowHoverEffect ? { bg: hoverBg } : {}}
transition="background-color 0.2s"
>
{columns.map((column, colIndex) => {
const cellProps = column.cellProps?.(item) || {};
return (
<Td
key={colIndex}
isNumeric={column.isNumeric}
borderColor={borderColor}
{...cellProps}
>
{typeof column.accessor === 'function'
? column.accessor(item)
: (item[column.accessor] as ReactNode)}
</Td>
);
})}
</Tr>
))}
</Tbody>
</ChakraTable>
</TableContainer>
);
}
@@ -0,0 +1,327 @@
import React, { useState } from 'react';
import {
Box,
Button,
VStack,
HStack,
Input,
Text,
SimpleGrid,
Image,
Checkbox,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
useToast,
Spinner,
Badge,
FormControl,
FormLabel,
FormHelperText,
} from '@chakra-ui/react';
import { ExternalLink, Download } from 'lucide-react';
import { getZoneramaAlbum } from '../../services/zonerama';
interface Photo {
id: string;
page_url: string;
image_1500: string;
}
interface Album {
id: string;
title: string;
url: string;
date: string;
photos_count: number;
photos: Photo[];
}
interface AlbumPhotoPickerProps {
isOpen: boolean;
onClose: () => void;
onPhotosSelected: (photos: Photo[], albumInfo: Album) => void;
}
const AlbumPhotoPicker: React.FC<AlbumPhotoPickerProps> = ({
isOpen,
onClose,
onPhotosSelected,
}) => {
const [albumLink, setAlbumLink] = useState('');
const [loading, setLoading] = useState(false);
const [album, setAlbum] = useState<Album | null>(null);
const [selectedPhotos, setSelectedPhotos] = useState<Set<string>>(new Set());
const toast = useToast();
const handleFetchAlbum = async () => {
if (!albumLink.trim()) {
toast({
title: 'Zadejte URL alba',
status: 'warning',
duration: 3000,
});
return;
}
if (!albumLink.includes('/Album/')) {
toast({
title: 'Neplatný odkaz',
description: 'URL musí obsahovat "/Album/"',
status: 'error',
duration: 3000,
});
return;
}
setLoading(true);
try {
const result = await getZoneramaAlbum(albumLink, { photo_limit: 100 }) as any;
// Handle both response formats: { album, photos } and { albums: [{ photos }] }
let albumData: any = null;
let photos: any[] = [];
if (result.albums && Array.isArray(result.albums) && result.albums.length > 0) {
// New format: { albums: [{ id, title, url, date, photos }] }
albumData = result.albums[0];
photos = albumData.photos || [];
} else if (result.album && result.photos) {
// Old format: { album: {...}, photos: [...] }
albumData = result.album;
photos = result.photos;
} else {
throw new Error('Album nenalezeno - neplatná odpověď ze serveru');
}
if (!albumData) {
throw new Error('Album nenalezeno');
}
const mappedPhotos = photos.map(p => ({
id: p.id,
page_url: p.page_url,
image_1500: p.image_1500 || '',
}));
setAlbum({
id: albumData.id || '',
title: albumData.title || '',
url: albumData.url || albumLink,
date: albumData.date || '', // Now properly extracting date
photos_count: mappedPhotos.length,
photos: mappedPhotos,
});
setSelectedPhotos(new Set());
toast({
title: 'Album načteno',
description: `${mappedPhotos.length} fotografií`,
status: 'success',
duration: 2000,
});
} catch (error: any) {
console.error('Album fetch error:', error);
toast({
title: 'Chyba načítání alba',
description: error.message || 'Nepodařilo se načíst album',
status: 'error',
duration: 5000,
});
} finally {
setLoading(false);
}
};
const togglePhoto = (photoId: string) => {
const newSelected = new Set(selectedPhotos);
if (newSelected.has(photoId)) {
newSelected.delete(photoId);
} else {
newSelected.add(photoId);
}
setSelectedPhotos(newSelected);
};
const handleSelectAll = () => {
if (album) {
if (selectedPhotos.size === album.photos.length) {
setSelectedPhotos(new Set());
} else {
setSelectedPhotos(new Set(album.photos.map(p => p.id)));
}
}
};
const handleConfirm = () => {
if (!album || selectedPhotos.size === 0) {
toast({
title: 'Vyberte fotografie',
status: 'warning',
duration: 3000,
});
return;
}
const selected = album.photos.filter(p => selectedPhotos.has(p.id));
onPhotosSelected(selected, album);
handleClose();
};
const handleClose = () => {
setAlbumLink('');
setAlbum(null);
setSelectedPhotos(new Set());
onClose();
};
return (
<Modal isOpen={isOpen} onClose={handleClose} size="6xl">
<ModalOverlay />
<ModalContent maxH="90vh">
<ModalHeader>Vybrat fotografie z alba</ModalHeader>
<ModalCloseButton />
<ModalBody overflowY="auto">
<VStack align="stretch" spacing={4}>
{/* Album URL Input */}
<FormControl>
<FormLabel>URL Zonerama alba</FormLabel>
<HStack>
<Input
value={albumLink}
onChange={(e) => setAlbumLink(e.target.value)}
placeholder="https://eu.zonerama.com/Account/Album/12345"
onKeyPress={(e) => e.key === 'Enter' && handleFetchAlbum()}
/>
<Button
onClick={handleFetchAlbum}
isLoading={loading}
colorScheme="blue"
leftIcon={<Download size={18} />}
>
Načíst
</Button>
</HStack>
<FormHelperText>
Vložte odkaz na Zonerama album (musí obsahovat /Album/)
</FormHelperText>
</FormControl>
{/* Loading State */}
{loading && (
<VStack py={8}>
<Spinner size="xl" color="blue.500" />
<Text color="gray.600">Načítám album...</Text>
</VStack>
)}
{/* Album Info & Photos */}
{album && !loading && (
<>
{/* Album Header */}
<Box
p={4}
bg="blue.50"
borderRadius="md"
borderWidth="1px"
borderColor="blue.200"
>
<VStack align="start" spacing={2}>
<HStack justify="space-between" w="full">
<Text fontWeight="bold" fontSize="lg">
{album.title}
</Text>
<Button
as="a"
href={album.url}
target="_blank"
rel="noopener noreferrer"
size="sm"
variant="ghost"
rightIcon={<ExternalLink size={14} />}
>
Zonerama
</Button>
</HStack>
<HStack spacing={4} fontSize="sm" color="gray.700">
{album.date && <Text>📅 {album.date}</Text>}
<Badge colorScheme="blue">{album.photos.length} fotografií</Badge>
</HStack>
</VStack>
</Box>
{/* Select All */}
<HStack justify="space-between">
<Checkbox
isChecked={selectedPhotos.size === album.photos.length}
isIndeterminate={
selectedPhotos.size > 0 && selectedPhotos.size < album.photos.length
}
onChange={handleSelectAll}
>
Vybrat vše ({selectedPhotos.size}/{album.photos.length})
</Checkbox>
</HStack>
{/* Photos Grid */}
<SimpleGrid columns={{ base: 3, md: 4, lg: 5 }} spacing={3}>
{album.photos.map((photo) => (
<Box
key={photo.id}
position="relative"
cursor="pointer"
onClick={() => togglePhoto(photo.id)}
borderRadius="md"
overflow="hidden"
borderWidth="2px"
borderColor={selectedPhotos.has(photo.id) ? 'blue.500' : 'transparent'}
transition="all 0.2s"
_hover={{ transform: 'scale(1.05)' }}
>
<Image
src={photo.image_1500}
alt={photo.id}
w="100%"
h="150px"
objectFit="cover"
/>
<Checkbox
position="absolute"
top={2}
right={2}
isChecked={selectedPhotos.has(photo.id)}
pointerEvents="none"
bg="white"
borderRadius="sm"
/>
</Box>
))}
</SimpleGrid>
</>
)}
</VStack>
</ModalBody>
<ModalFooter>
<HStack spacing={3}>
<Button variant="ghost" onClick={handleClose}>
Zrušit
</Button>
<Button
colorScheme="blue"
onClick={handleConfirm}
isDisabled={!album || selectedPhotos.size === 0}
>
Vybrat ({selectedPhotos.size})
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default AlbumPhotoPicker;
@@ -0,0 +1,277 @@
import React, { useEffect, useState } from 'react';
import {
Box,
SimpleGrid,
Stat,
StatLabel,
StatNumber,
StatHelpText,
StatArrow,
Heading,
Text,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Badge,
VStack,
HStack,
Spinner,
Icon,
Card,
CardBody,
Link,
} from '@chakra-ui/react';
import { FiEye, FiTrendingUp, FiFileText, FiUsers } from 'react-icons/fi';
import api from '../../services/api';
import { Link as RouterLink } from 'react-router-dom';
type AnalyticsStats = {
total_page_views: number;
unique_visitors: number;
total_articles: number;
published_articles: number;
page_views_today: number;
page_views_week: number;
unique_visitors_week: number;
avg_time_on_site: number;
};
type TopPage = {
page_path: string;
page_name: string;
view_count: number;
unique_visitors: number;
};
type TopArticle = {
id: number;
title: string;
slug: string;
view_count: number;
published_at: string;
};
const AnalyticsDashboard: React.FC = () => {
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<AnalyticsStats | null>(null);
const [topPages, setTopPages] = useState<TopPage[]>([]);
const [topArticles, setTopArticles] = useState<TopArticle[]>([]);
useEffect(() => {
loadAnalytics();
}, []);
const loadAnalytics = async () => {
setLoading(true);
try {
// Load overview stats
const statsRes = await api.get('/admin/analytics/overview');
setStats(statsRes.data);
// Load top pages
const pagesRes = await api.get('/admin/analytics/top-pages?limit=10');
setTopPages(pagesRes.data || []);
// Load top articles
const articlesRes = await api.get('/admin/analytics/top-articles?limit=10');
setTopArticles(articlesRes.data || []);
} catch (error) {
console.error('Failed to load analytics:', error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<Box textAlign="center" py={10}>
<Spinner size="xl" />
<Text mt={4} color="gray.600">Načítám statistiky...</Text>
</Box>
);
}
return (
<VStack align="stretch" spacing={6}>
<Heading size="lg">Analytika a statistiky</Heading>
{/* Overview Stats */}
<SimpleGrid columns={{ base: 1, md: 2, lg: 4 }} spacing={4}>
<Card>
<CardBody>
<Stat>
<HStack>
<Icon as={FiEye} boxSize={6} color="blue.500" />
<StatLabel>Celkem zobrazení</StatLabel>
</HStack>
<StatNumber>{stats?.total_page_views || 0}</StatNumber>
<StatHelpText>
<StatArrow type="increase" />
{stats?.page_views_today || 0} dnes
</StatHelpText>
</Stat>
</CardBody>
</Card>
<Card>
<CardBody>
<Stat>
<HStack>
<Icon as={FiUsers} boxSize={6} color="green.500" />
<StatLabel>Unikátní návštěvníci</StatLabel>
</HStack>
<StatNumber>{stats?.unique_visitors || 0}</StatNumber>
<StatHelpText>
{stats?.unique_visitors_week || 0} tento týden
</StatHelpText>
</Stat>
</CardBody>
</Card>
<Card>
<CardBody>
<Stat>
<HStack>
<Icon as={FiFileText} boxSize={6} color="purple.500" />
<StatLabel>Publikované články</StatLabel>
</HStack>
<StatNumber>{stats?.published_articles || 0}</StatNumber>
<StatHelpText>
z {stats?.total_articles || 0} celkem
</StatHelpText>
</Stat>
</CardBody>
</Card>
<Card>
<CardBody>
<Stat>
<HStack>
<Icon as={FiTrendingUp} boxSize={6} color="orange.500" />
<StatLabel>Zobrazení (týden)</StatLabel>
</HStack>
<StatNumber>{stats?.page_views_week || 0}</StatNumber>
<StatHelpText>
Ø {Math.round((stats?.page_views_week || 0) / 7)} / den
</StatHelpText>
</Stat>
</CardBody>
</Card>
</SimpleGrid>
{/* Top Articles */}
<Box>
<Heading size="md" mb={4}>Nejčtenější články</Heading>
<Card>
<CardBody>
{topArticles.length === 0 ? (
<Text color="gray.600">Zatím žádná data</Text>
) : (
<Table size="sm">
<Thead>
<Tr>
<Th>Článek</Th>
<Th isNumeric>Zobrazení</Th>
<Th>Datum publikace</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
{topArticles.map((article, index) => (
<Tr key={article.id}>
<Td>
<HStack>
<Badge colorScheme="blue">{index + 1}</Badge>
<Text fontWeight="medium">{article.title}</Text>
</HStack>
</Td>
<Td isNumeric>
<Badge colorScheme="green">{article.view_count}</Badge>
</Td>
<Td>
<Text fontSize="sm" color="gray.600">
{new Date(article.published_at).toLocaleDateString('cs-CZ')}
</Text>
</Td>
<Td>
<HStack spacing={2}>
<Link
as={RouterLink}
to={`/blog/${article.slug}`}
fontSize="sm"
color="blue.600"
>
Zobrazit
</Link>
<Link
as={RouterLink}
to={`/admin/clanky?edit=${article.id}`}
fontSize="sm"
color="purple.600"
>
Upravit
</Link>
</HStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</CardBody>
</Card>
</Box>
{/* Top Pages */}
<Box>
<Heading size="md" mb={4}>Nejnavštěvovanější stránky</Heading>
<Card>
<CardBody>
{topPages.length === 0 ? (
<Text color="gray.600">Zatím žádná data</Text>
) : (
<Table size="sm">
<Thead>
<Tr>
<Th>Stránka</Th>
<Th>Cesta</Th>
<Th isNumeric>Zobrazení</Th>
<Th isNumeric>Unikátní</Th>
</Tr>
</Thead>
<Tbody>
{topPages.map((page, index) => (
<Tr key={page.page_path}>
<Td>
<HStack>
<Badge colorScheme="purple">{index + 1}</Badge>
<Text>{page.page_name || page.page_path}</Text>
</HStack>
</Td>
<Td>
<Text fontSize="sm" color="gray.600" fontFamily="mono">
{page.page_path}
</Text>
</Td>
<Td isNumeric>
<Badge colorScheme="blue">{page.view_count}</Badge>
</Td>
<Td isNumeric>
<Badge colorScheme="green">{page.unique_visitors}</Badge>
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</CardBody>
</Card>
</Box>
</VStack>
);
};
export default AnalyticsDashboard;
@@ -0,0 +1,339 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Button,
FormControl,
FormLabel,
FormHelperText,
Input,
VStack,
HStack,
Text,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Badge,
Divider,
Link,
useColorModeValue,
} from '@chakra-ui/react';
import MapStyleSelector from './MapStyleSelector';
import { FiMapPin, FiCheck, FiX, FiExternalLink } from 'react-icons/fi';
import { parseMapUrl, MapCoordinates, validateCoordinates, reverseGeocode } from '../../utils/mapUrlParser';
import ContactMap from '../home/ContactMap';
interface MapLinkImporterProps {
onImport: (coordinates: MapCoordinates) => void;
currentLatitude?: number;
currentLongitude?: number;
currentZoom?: number;
mapStyle?: string;
onMapStyleChange?: (style: string) => void;
clubPrimaryColor?: string;
clubSecondaryColor?: string;
clubName?: string;
}
const MapLinkImporter: React.FC<MapLinkImporterProps> = ({
onImport,
currentLatitude,
currentLongitude,
currentZoom,
mapStyle,
onMapStyleChange,
clubPrimaryColor,
clubSecondaryColor,
clubName,
}) => {
const [urlInput, setUrlInput] = useState('');
const [parsedData, setParsedData] = useState<MapCoordinates | null>(null);
const [error, setError] = useState<string | null>(null);
const [previewCoords, setPreviewCoords] = useState<MapCoordinates | null>(null);
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
useEffect(() => {
// Initialize preview with current coordinates if available
if (currentLatitude && currentLongitude) {
setPreviewCoords({
latitude: currentLatitude,
longitude: currentLongitude,
zoom: currentZoom,
source: 'unknown',
});
}
}, [currentLatitude, currentLongitude, currentZoom]);
const handleUrlChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setUrlInput(value);
setError(null);
setParsedData(null);
if (!value.trim()) {
return;
}
// Try to parse the URL
const result = parseMapUrl(value);
if (result) {
if (validateCoordinates(result.latitude, result.longitude)) {
// Perform reverse geocoding to get detailed address
try {
const addressDetails = await reverseGeocode(result.latitude, result.longitude);
const enrichedResult = { ...result, ...addressDetails };
setParsedData(enrichedResult);
setPreviewCoords(enrichedResult);
setError(null);
} catch (err) {
// If geocoding fails, still use the basic data
setParsedData(result);
setPreviewCoords(result);
setError(null);
}
} else {
setError('Souřadnice jsou mimo platný rozsah');
setParsedData(null);
}
} else {
setError('Nepodařilo se rozpoznat URL mapy. Podporované: mapy.cz, Google Maps');
setParsedData(null);
}
};
const handleImport = () => {
if (parsedData) {
onImport(parsedData);
setUrlInput('');
setParsedData(null);
setError(null);
}
};
const handleClear = () => {
setUrlInput('');
setParsedData(null);
setError(null);
// Reset preview to current coordinates
if (currentLatitude && currentLongitude) {
setPreviewCoords({
latitude: currentLatitude,
longitude: currentLongitude,
zoom: currentZoom,
source: 'unknown',
});
} else {
setPreviewCoords(null);
}
};
return (
<VStack spacing={4} align="stretch">
<Box>
<FormControl>
<FormLabel display="flex" alignItems="center" gap={2}>
<FiMapPin /> Importovat z URL mapy
</FormLabel>
<Input
placeholder="Vložte URL z mapy.cz nebo Google Maps..."
value={urlInput}
onChange={handleUrlChange}
size="md"
/>
<FormHelperText>
Podporované formáty:
<Text as="span" fontWeight="semibold" ml={1}>mapy.cz</Text> (mapy.com/en/letecka?x=...&y=...),
<Text as="span" fontWeight="semibold" ml={1}>Google Maps</Text> (google.com/maps/place/@lat,lng,zoom)
</FormHelperText>
<HStack mt={2} spacing={3} fontSize="sm">
<Text color="gray.600">Quick links:</Text>
<Link
href="https://mapy.com/cs/"
isExternal
color="blue.500"
display="flex"
alignItems="center"
gap={1}
_hover={{ color: 'blue.600', textDecoration: 'underline' }}
>
Mapy.cz <FiExternalLink size={12} />
</Link>
<Text color="gray.400"></Text>
<Link
href="https://www.google.com/maps/"
isExternal
color="blue.500"
display="flex"
alignItems="center"
gap={1}
_hover={{ color: 'blue.600', textDecoration: 'underline' }}
>
Google Maps <FiExternalLink size={12} />
</Link>
</HStack>
</FormControl>
{parsedData && (
<Alert status="success" mt={3} borderRadius="md">
<AlertIcon />
<Box flex="1">
<AlertTitle>Úspěšně rozpoznáno!</AlertTitle>
<AlertDescription display="block">
<VStack align="start" spacing={1} mt={2}>
<HStack>
<Badge colorScheme="green">
{parsedData.source === 'mapy.cz' ? 'Mapy.cz' : 'Google Maps'}
</Badge>
</HStack>
<Text fontSize="sm">
<strong>Šířka:</strong> {parsedData.latitude.toFixed(7)}
</Text>
<Text fontSize="sm">
<strong>Délka:</strong> {parsedData.longitude.toFixed(7)}
</Text>
{parsedData.zoom && (
<Text fontSize="sm">
<strong>Zoom:</strong> {parsedData.zoom}
</Text>
)}
{parsedData.street && (
<Text fontSize="sm">
<strong>Ulice:</strong> {parsedData.street}
</Text>
)}
{parsedData.city && (
<Text fontSize="sm">
<strong>Město:</strong> {parsedData.city}
</Text>
)}
{parsedData.zip && (
<Text fontSize="sm">
<strong>PSČ:</strong> {parsedData.zip}
</Text>
)}
{parsedData.country && (
<Text fontSize="sm">
<strong>Země:</strong> {parsedData.country}
</Text>
)}
{parsedData.address && (
<Text fontSize="sm">
<strong>Celá adresa:</strong> {parsedData.address}
</Text>
)}
</VStack>
</AlertDescription>
</Box>
<HStack ml={2}>
<Button
leftIcon={<FiCheck />}
colorScheme="green"
size="sm"
onClick={handleImport}
>
Importovat
</Button>
<Button
leftIcon={<FiX />}
variant="ghost"
size="sm"
onClick={handleClear}
>
Zrušit
</Button>
</HStack>
</Alert>
)}
{error && (
<Alert status="error" mt={3} borderRadius="md">
<AlertIcon />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</Box>
{/* Map Preview */}
{previewCoords && (
<>
<Divider />
<Box>
<Text fontWeight="semibold" mb={2}>
Náhled mapy
</Text>
<Box
borderRadius="md"
overflow="hidden"
borderWidth="1px"
borderColor={borderColor}
>
<ContactMap
latitude={previewCoords.latitude}
longitude={previewCoords.longitude}
zoom={previewCoords.zoom || 15}
address={previewCoords.address}
clubName={clubName}
mapStyle={mapStyle || 'positron'}
clubPrimaryColor={clubPrimaryColor}
clubSecondaryColor={clubSecondaryColor}
height={300}
/>
</Box>
<Text fontSize="xs" color="gray.500" mt={2}>
Souřadnice: {previewCoords.latitude.toFixed(6)}, {previewCoords.longitude.toFixed(6)}
{previewCoords.zoom && ` | Zoom: ${previewCoords.zoom}`}
</Text>
</Box>
{/* Map Style Selector */}
{onMapStyleChange && (
<>
<Divider />
<Box>
<Text fontWeight="semibold" mb={2}>
Styl mapy
</Text>
<Text fontSize="sm" color="gray.600" mb={3}>
Vyberte vzhled mapy, který se zobrazí na vašem webu.
</Text>
<MapStyleSelector
value={mapStyle || 'positron'}
onChange={onMapStyleChange}
clubPrimaryColor={clubPrimaryColor}
clubSecondaryColor={clubSecondaryColor}
showPreview={false}
/>
</Box>
</>
)}
</>
)}
{/* Example URLs */}
<Box
bg={bgColor}
p={3}
borderRadius="md"
borderWidth="1px"
borderColor={borderColor}
fontSize="sm"
>
<Text fontWeight="semibold" mb={2}>Příklady podporovaných URL:</Text>
<VStack align="start" spacing={1}>
<Text fontSize="xs" color="gray.600">
<strong>Mapy.cz:</strong><br />
mapy.cz/en/letecka?x=17.6996859&y=50.0947150&z=19
</Text>
<Text fontSize="xs" color="gray.600">
<strong>Google Maps:</strong><br />
google.com/maps/place/@50.0948669,17.7001456,226m
</Text>
</VStack>
</Box>
</VStack>
);
};
export default MapLinkImporter;
@@ -0,0 +1,178 @@
import React, { useState } from 'react';
import {
Box,
FormControl,
FormLabel,
Select,
SimpleGrid,
Text,
VStack,
Badge,
Image,
HStack,
useColorModeValue,
} from '@chakra-ui/react';
import { MAP_STYLES } from '../home/ContactMap';
import ContactMap from '../home/ContactMap';
interface MapStyleSelectorProps {
value: string;
onChange: (value: string) => void;
clubPrimaryColor?: string;
clubSecondaryColor?: string;
showPreview?: boolean;
}
const MapStyleSelector: React.FC<MapStyleSelectorProps> = ({
value,
onChange,
clubPrimaryColor,
clubSecondaryColor,
showPreview = true,
}) => {
const previewBg = useColorModeValue('gray.50', 'gray.700');
const tipsBg = useColorModeValue('blue.50', 'blue.900');
const tipsBorder = useColorModeValue('blue.200', 'blue.700');
const textColor = useColorModeValue('gray.700', 'gray.300');
const secondaryText = useColorModeValue('gray.600', 'gray.400');
const selectBg = useColorModeValue('white', 'gray.700');
const styleCategories = {
'Light & Minimal': ['positron', 'positron-no-labels', 'default'],
'Dark Themes': ['dark', 'dark-no-labels'],
'Black & White': ['toner', 'toner-lite'],
'Colorful': ['voyager', 'terrain', 'watercolor'],
'Satellite': ['satellite'],
};
const selectedStyle = MAP_STYLES[value as keyof typeof MAP_STYLES] || MAP_STYLES.default;
return (
<VStack align="stretch" spacing={4}>
<FormControl>
<FormLabel>Styl mapy</FormLabel>
<Select value={value} onChange={(e) => onChange(e.target.value)} bg={selectBg}>
{Object.entries(styleCategories).map(([category, styles]) => (
<optgroup key={category} label={category}>
{styles.map((styleKey) => {
const style = MAP_STYLES[styleKey as keyof typeof MAP_STYLES];
return (
<option key={styleKey} value={styleKey}>
{style.name}
</option>
);
})}
</optgroup>
))}
</Select>
</FormControl>
{showPreview && (
<VStack align="stretch" spacing={3}>
<Box
p={4}
borderWidth="1px"
borderRadius="md"
bg={previewBg}
>
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Text fontWeight="semibold">{selectedStyle.name}</Text>
<Badge colorScheme="blue">Náhled stylu</Badge>
</HStack>
<Text fontSize="sm" color={secondaryText}>
{selectedStyle.description}
</Text>
{clubPrimaryColor && (
<Box>
<Text fontSize="sm" fontWeight="medium" mb={2}>
Barvy klubu:
</Text>
<HStack>
<Box
w="40px"
h="40px"
borderRadius="md"
bg={clubPrimaryColor}
borderWidth="1px"
borderColor="gray.300"
/>
{clubSecondaryColor && (
<Box
w="40px"
h="40px"
borderRadius="md"
bg={clubSecondaryColor}
borderWidth="1px"
borderColor="gray.300"
/>
)}
<Text fontSize="xs" color={secondaryText}>
Použity pro marker a overlay
</Text>
</HStack>
</Box>
)}
</VStack>
</Box>
{/* Interactive Map Preview */}
<Box
borderWidth="1px"
borderRadius="md"
overflow="hidden"
boxShadow="sm"
>
<ContactMap
latitude={50.0755}
longitude={14.4378}
zoom={13}
address="Praha, Česká republika"
clubName="Náhled mapy"
mapStyle={value}
clubPrimaryColor={clubPrimaryColor}
clubSecondaryColor={clubSecondaryColor}
height={300}
/>
</Box>
<Text fontSize="xs" color={secondaryText} textAlign="center">
Náhled interaktivní mapy se zvoleným stylem
</Text>
</VStack>
)}
<Box
p={3}
bg={tipsBg}
borderWidth="1px"
borderColor={tipsBorder}
borderRadius="md"
>
<Text fontSize="sm" fontWeight="semibold" mb={1}>
💡 Tipy pro výběr stylu:
</Text>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={2}>
<Text fontSize="xs" color={textColor}>
<strong>Positron/Toner Lite</strong> - nejlepší pro barevné markery
</Text>
<Text fontSize="xs" color={textColor}>
<strong>Dark Matter</strong> - skvělé pro tmavý design
</Text>
<Text fontSize="xs" color={textColor}>
<strong>Toner B&W</strong> - vysoký kontrast, elegantní
</Text>
<Text fontSize="xs" color={textColor}>
<strong>Voyager</strong> - vyváženě pro všechny případy
</Text>
</SimpleGrid>
</Box>
<Text fontSize="xs" color={secondaryText}>
Všechny mapy jsou open-source a bezplatné.
{clubPrimaryColor && ' Mapa bude automaticky obarvena barvami klubu.'}
</Text>
</VStack>
);
};
export default MapStyleSelector;
@@ -0,0 +1,276 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
Button,
VStack,
Text,
Divider,
HStack,
Badge,
useClipboard,
useToast,
Box,
Input,
FormControl,
FormLabel,
useDisclosure,
Popover,
PopoverTrigger,
PopoverContent,
PopoverHeader,
PopoverBody,
PopoverArrow,
PopoverCloseButton,
} from '@chakra-ui/react';
import { EmailIcon, CopyIcon, CheckIcon, DeleteIcon, ArrowForwardIcon } from '@chakra-ui/icons';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { forwardMessage } from '../../services/admin/contactMessages';
import { format } from 'date-fns';
import { cs } from 'date-fns/locale';
interface MessageDetailModalProps {
isOpen: boolean;
onClose: () => void;
message: {
id: string;
name: string;
email: string;
subject?: string;
message: string;
source?: string;
ipAddress?: string;
userAgent?: string;
isRead: boolean;
createdAt: string;
};
onDelete: () => void;
onMarkAsRead: () => void;
}
export default function MessageDetailModal({
isOpen,
onClose,
message,
onDelete,
onMarkAsRead,
}: MessageDetailModalProps) {
const toast = useToast();
const queryClient = useQueryClient();
const { hasCopied, onCopy } = useClipboard(message.email);
const { isOpen: isPopoverOpen, onOpen: onPopoverOpen, onClose: onPopoverClose } = useDisclosure();
const [forwardEmail, setForwardEmail] = useState('');
const formatDate = (dateString: string) => {
return format(new Date(dateString), 'd. M. yyyy HH:mm', { locale: cs });
};
const handleCopyEmail = () => {
onCopy();
toast({
title: 'E-mail zkopírován do schránky',
status: 'success',
duration: 2000,
isClosable: true,
});
};
const forwardMutation = useMutation({
mutationFn: (toEmail: string) => forwardMessage(message.id, toEmail),
onSuccess: () => {
toast({
title: 'Zpráva přeposílána',
description: `Zpráva bude odeslána na ${forwardEmail}`,
status: 'success',
duration: 3000,
isClosable: true,
});
setForwardEmail('');
onPopoverClose();
},
onError: () => {
toast({
title: 'Chyba',
description: 'Nepodařilo se přeposlat zprávu',
status: 'error',
duration: 3000,
isClosable: true,
});
},
});
const handleForward = () => {
if (!forwardEmail || !forwardEmail.includes('@')) {
toast({
title: 'Chyba',
description: 'Zadejte platnou e-mailovou adresu',
status: 'error',
duration: 3000,
});
return;
}
forwardMutation.mutate(forwardEmail);
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>
<Box>
<Text fontSize="lg" fontWeight="bold">
{message.subject || 'Bez předmětu'}
</Text>
<HStack mt={1} fontSize="sm" color="gray.500">
<Text>{formatDate(message.createdAt)}</Text>
{!message.isRead && (
<Badge colorScheme="blue">
Nová zpráva
</Badge>
)}
{message.source && (
<Badge colorScheme={message.source === 'sponsor' ? 'purple' : 'gray'}>
{message.source === 'sponsor' ? 'Sponzor' : 'Kontakt'}
</Badge>
)}
</HStack>
</Box>
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={4}>
<Box>
<Text fontWeight="bold" mb={1}>
Od:
</Text>
<HStack>
<Text>{message.name}</Text>
<HStack
as="button"
onClick={handleCopyEmail}
color="blue.500"
_hover={{ textDecoration: 'underline' }}
spacing={1}
>
<Text>&lt;{message.email}&gt;</Text>
{hasCopied ? (
<CheckIcon boxSize={3} />
) : (
<CopyIcon boxSize={3} />
)}
</HStack>
</HStack>
</Box>
<Divider />
<Box>
<Text fontWeight="bold" mb={2}>
Zpráva:
</Text>
<Text whiteSpace="pre-wrap" p={3} bg="gray.50" borderRadius="md">
{message.message}
</Text>
</Box>
{(message.ipAddress || message.userAgent) && (
<Box mt={4} fontSize="sm" color="gray.500">
<Text fontWeight="bold" mb={1}>
Technické informace:
</Text>
{message.ipAddress && (
<Text>
<Text as="span" fontWeight="medium">IP adresa:</Text>{' '}
{message.ipAddress}
</Text>
)}
{message.userAgent && (
<Text mt={1} isTruncated title={message.userAgent}>
<Text as="span" fontWeight="medium">Prohlížeč:</Text>{' '}
{message.userAgent.length > 50
? `${message.userAgent.substring(0, 47)}...`
: message.userAgent}
</Text>
)}
</Box>
)}
</VStack>
</ModalBody>
<ModalFooter>
<HStack spacing={2} flexWrap="wrap" justify="flex-end" w="full">
<Button
variant="outline"
colorScheme="red"
leftIcon={<DeleteIcon />}
onClick={onDelete}
size={{ base: "sm", md: "md" }}
>
Smazat
</Button>
<Popover isOpen={isPopoverOpen} onClose={onPopoverClose}>
<PopoverTrigger>
<Button
colorScheme="teal"
leftIcon={<ArrowForwardIcon />}
onClick={onPopoverOpen}
size={{ base: "sm", md: "md" }}
>
Přeposlat
</Button>
</PopoverTrigger>
<PopoverContent>
<PopoverArrow />
<PopoverCloseButton />
<PopoverHeader>Přeposlat zprávu</PopoverHeader>
<PopoverBody>
<VStack spacing={3}>
<FormControl>
<FormLabel fontSize="sm">E-mailová adresa</FormLabel>
<Input
placeholder="prijemce@email.cz"
value={forwardEmail}
onChange={(e) => setForwardEmail(e.target.value)}
type="email"
size="sm"
/>
</FormControl>
<Button
size="sm"
colorScheme="teal"
width="full"
onClick={handleForward}
isLoading={forwardMutation.isLoading}
>
Odeslat
</Button>
</VStack>
</PopoverBody>
</PopoverContent>
</Popover>
{!message.isRead && (
<Button
colorScheme="blue"
leftIcon={<EmailIcon />}
onClick={onMarkAsRead}
size={{ base: "sm", md: "md" }}
>
Označit jako přečtené
</Button>
)}
<Button variant="ghost" onClick={onClose} size={{ base: "sm", md: "md" }}>
Zavřít
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,235 @@
import React, { useState, useRef } from 'react';
import {
Box,
Button,
VStack,
HStack,
Text,
IconButton,
Progress,
Badge,
List,
ListItem,
Icon,
useToast,
Flex,
} from '@chakra-ui/react';
import { FiUpload, FiX, FiFile, FiImage, FiFileText } from 'react-icons/fi';
import { uploadFile } from '../../services/articles';
export type UploadedFile = {
url: string;
name: string;
type: string;
size: number;
};
interface MultiFileUploadProps {
onFilesUploaded: (files: UploadedFile[]) => void;
existingFiles?: UploadedFile[];
acceptedTypes?: string;
maxFiles?: number;
}
const MultiFileUpload: React.FC<MultiFileUploadProps> = ({
onFilesUploaded,
existingFiles = [],
acceptedTypes = 'image/*,application/pdf,.doc,.docx,.xls,.xlsx',
maxFiles = 10,
}) => {
const [files, setFiles] = useState<UploadedFile[]>(existingFiles);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const toast = useToast();
const getFileIcon = (type: string) => {
if (type.startsWith('image/')) return FiImage;
if (type.includes('pdf')) return FiFileText;
return FiFile;
};
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = e.target.files;
if (!selectedFiles || selectedFiles.length === 0) return;
if (files.length + selectedFiles.length > maxFiles) {
toast({
title: 'Příliš mnoho souborů',
description: `Můžete nahrát maximálně ${maxFiles} souborů`,
status: 'warning',
});
return;
}
setUploading(true);
setUploadProgress(0);
const uploadedFiles: UploadedFile[] = [];
const totalFiles = selectedFiles.length;
try {
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i];
// Check file size (max 10MB)
if (file.size > 10 * 1024 * 1024) {
toast({
title: 'Soubor je příliš velký',
description: `${file.name} překračuje limit 10MB`,
status: 'error',
});
continue;
}
try {
const result = await uploadFile(file);
uploadedFiles.push({
url: result.url,
name: file.name,
type: file.type,
size: file.size,
});
setUploadProgress(((i + 1) / totalFiles) * 100);
} catch (error: any) {
toast({
title: 'Chyba při nahrávání',
description: `${file.name}: ${error.message}`,
status: 'error',
});
}
}
const updatedFiles = [...files, ...uploadedFiles];
setFiles(updatedFiles);
onFilesUploaded(updatedFiles);
if (uploadedFiles.length > 0) {
toast({
title: 'Úspěšně nahráno',
description: `${uploadedFiles.length} souborů bylo nahráno`,
status: 'success',
});
}
} finally {
setUploading(false);
setUploadProgress(0);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleRemoveFile = (index: number) => {
const updatedFiles = files.filter((_, i) => i !== index);
setFiles(updatedFiles);
onFilesUploaded(updatedFiles);
};
const handleCopyUrl = (url: string) => {
navigator.clipboard.writeText(url);
toast({
title: 'Zkopírováno',
description: 'URL byla zkopírována do schránky',
status: 'success',
duration: 2000,
});
};
return (
<Box>
<input
ref={fileInputRef}
type="file"
accept={acceptedTypes}
multiple
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
<Button
leftIcon={<FiUpload />}
onClick={() => fileInputRef.current?.click()}
isLoading={uploading}
isDisabled={files.length >= maxFiles}
colorScheme="blue"
variant="outline"
size="sm"
>
Nahrát soubory ({files.length}/{maxFiles})
</Button>
{uploading && (
<Progress value={uploadProgress} size="sm" colorScheme="blue" mt={2} />
)}
{files.length > 0 && (
<List spacing={2} mt={4}>
{files.map((file, index) => (
<ListItem
key={index}
p={2}
borderWidth="1px"
borderRadius="md"
bg="gray.50"
_hover={{ bg: 'gray.100' }}
>
<Flex align="center" justify="space-between">
<HStack spacing={3} flex={1}>
<Icon as={getFileIcon(file.type)} boxSize={5} color="blue.500" />
<VStack align="start" spacing={0} flex={1}>
<Text fontSize="sm" fontWeight="medium" noOfLines={1}>
{file.name}
</Text>
<HStack spacing={2}>
<Badge fontSize="xs" colorScheme="gray">
{formatFileSize(file.size)}
</Badge>
{file.type.startsWith('image/') && (
<Badge fontSize="xs" colorScheme="blue">Obrázek</Badge>
)}
{file.type.includes('pdf') && (
<Badge fontSize="xs" colorScheme="red">PDF</Badge>
)}
</HStack>
</VStack>
</HStack>
<HStack>
<Button
size="xs"
variant="ghost"
onClick={() => handleCopyUrl(file.url)}
>
Kopírovat URL
</Button>
<IconButton
aria-label="Odstranit"
icon={<FiX />}
size="xs"
colorScheme="red"
variant="ghost"
onClick={() => handleRemoveFile(index)}
/>
</HStack>
</Flex>
</ListItem>
))}
</List>
)}
<Text fontSize="xs" color="gray.600" mt={2}>
Podporované formáty: obrázky, PDF, Word, Excel (max 10MB na soubor)
</Text>
</Box>
);
};
export default MultiFileUpload;
@@ -0,0 +1,111 @@
import { Box, Heading, Text, HStack, Button, ButtonProps, useColorModeValue, VStack, Icon, Flex, Badge } from '@chakra-ui/react';
import { ReactElement, ReactNode } from 'react';
interface PageHeaderProps {
title: string;
description?: string;
icon?: any; // Icon component
badge?: {
label: string;
colorScheme?: string;
};
action?: {
label: string;
icon?: ReactElement;
onClick: () => void;
colorScheme?: ButtonProps['colorScheme'];
isLoading?: boolean;
isDisabled?: boolean;
};
children?: ReactNode;
}
export const PageHeader = ({
title,
description,
icon,
badge,
action,
children,
}: PageHeaderProps) => {
const borderColor = useColorModeValue('gray.200', 'gray.700');
const iconBg = useColorModeValue('blue.50', 'blue.900');
const iconColor = useColorModeValue('blue.600', 'blue.300');
return (
<Box
mb={8}
pb={6}
borderBottomWidth="1px"
borderColor={borderColor}
>
<Flex justify="space-between" align="flex-start" wrap="wrap" gap={4}>
<HStack spacing={4} align="flex-start" flex={1}>
{icon && (
<Box
p={3}
bg={iconBg}
borderRadius="xl"
display={{ base: 'none', md: 'block' }}
>
<Icon as={icon} boxSize={6} color={iconColor} />
</Box>
)}
<VStack align="flex-start" spacing={2} flex={1}>
<HStack spacing={3} wrap="wrap">
<Heading
size="xl"
fontWeight="extrabold"
bgGradient={useColorModeValue(
'linear(to-r, gray.800, gray.600)',
'linear(to-r, white, gray.300)'
)}
bgClip="text"
>
{title}
</Heading>
{badge && (
<Badge
colorScheme={badge.colorScheme || 'blue'}
fontSize="sm"
px={3}
py={1}
borderRadius="full"
>
{badge.label}
</Badge>
)}
</HStack>
{description && (
<Text
color={useColorModeValue('gray.600', 'gray.400')}
fontSize="md"
maxW="2xl"
>
{description}
</Text>
)}
</VStack>
</HStack>
{action && (
<Button
leftIcon={action.icon}
onClick={action.onClick}
colorScheme={action.colorScheme || 'blue'}
isLoading={action.isLoading}
isDisabled={action.isDisabled}
size="lg"
shadow="sm"
_hover={{ shadow: 'md', transform: 'translateY(-1px)' }}
transition="all 0.2s"
>
{action.label}
</Button>
)}
</Flex>
{children}
</Box>
);
};
@@ -0,0 +1,292 @@
import React, { useState } from 'react';
import {
Box,
VStack,
HStack,
FormControl,
FormLabel,
Button,
Badge,
Text,
useToast,
Select,
Spinner,
Alert,
AlertIcon,
IconButton,
useColorModeValue,
Collapse,
Divider,
} from '@chakra-ui/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AddIcon, DeleteIcon, ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
import { getPolls, createPoll, updatePoll, Poll, CreatePollRequest } from '../../services/polls';
interface PollLinkerProps {
articleId?: number;
eventId?: number;
onPollsChanged?: () => void;
}
/**
* PollLinker - Component to manage poll associations with articles/events
* Can be embedded in article and activity admin pages
*/
const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChanged }) => {
const toast = useToast();
const queryClient = useQueryClient();
const [isExpanded, setIsExpanded] = useState(false);
const [selectedPollId, setSelectedPollId] = useState<string>('');
const bgBox = useColorModeValue('gray.50', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
// Query for existing polls
const queryParams = articleId ? { article_id: articleId } : eventId ? { event_id: eventId } : {};
const { data: linkedPolls, isLoading: isLoadingLinked } = useQuery({
queryKey: ['linked-polls', queryParams],
queryFn: () => getPolls(queryParams),
enabled: !!(articleId || eventId),
});
// Query for all available polls
const { data: allPolls, isLoading: isLoadingAll } = useQuery({
queryKey: ['all-admin-polls'],
queryFn: () => getPolls({ status: 'active' }),
});
// Mutation to link existing poll
const linkPollMutation = useMutation({
mutationFn: async (pollId: number) => {
const updateData: any = {};
if (articleId) updateData.related_article_id = articleId;
if (eventId) updateData.related_event_id = eventId;
return updatePoll(pollId, updateData);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['linked-polls'] });
queryClient.invalidateQueries({ queryKey: ['all-admin-polls'] });
toast({
title: 'Anketa propojena',
status: 'success',
duration: 3000,
});
setSelectedPollId('');
if (onPollsChanged) onPollsChanged();
},
onError: (error: any) => {
toast({
title: 'Chyba',
description: error.response?.data?.error || 'Nepodařilo se propojit anketu',
status: 'error',
duration: 5000,
});
},
});
// Mutation to unlink poll
const unlinkPollMutation = useMutation({
mutationFn: async (pollId: number) => {
const updateData: any = {};
if (articleId) updateData.related_article_id = null;
if (eventId) updateData.related_event_id = null;
return updatePoll(pollId, updateData);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['linked-polls'] });
queryClient.invalidateQueries({ queryKey: ['all-admin-polls'] });
toast({
title: 'Anketa odpojena',
status: 'success',
duration: 3000,
});
if (onPollsChanged) onPollsChanged();
},
onError: (error: any) => {
toast({
title: 'Chyba',
description: error.response?.data?.error || 'Nepodařilo se odpojit anketu',
status: 'error',
duration: 5000,
});
},
});
const handleLinkPoll = () => {
if (!selectedPollId) {
toast({
title: 'Vyberte anketu',
description: 'Prosím vyberte anketu ze seznamu',
status: 'warning',
duration: 3000,
});
return;
}
linkPollMutation.mutate(parseInt(selectedPollId));
};
const handleUnlinkPoll = (pollId: number) => {
if (window.confirm('Opravdu chcete odpojit tuto anketu?')) {
unlinkPollMutation.mutate(pollId);
}
};
// Filter out polls that are already linked
const linkedPollIds = new Set(linkedPolls?.map(p => p.id) || []);
const availablePolls = allPolls?.filter(p => !linkedPollIds.has(p.id)) || [];
if (!articleId && !eventId) {
return null;
}
return (
<Box
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
p={4}
bg={bgBox}
>
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<HStack>
<Text fontWeight="bold" fontSize="sm">
Ankety ({linkedPolls?.length || 0})
</Text>
{(linkedPolls?.length || 0) > 0 && (
<Badge colorScheme="blue">{linkedPolls!.length} připojeno</Badge>
)}
</HStack>
<IconButton
aria-label={isExpanded ? 'Skrýt' : 'Zobrazit'}
icon={isExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
size="sm"
variant="ghost"
onClick={() => setIsExpanded(!isExpanded)}
/>
</HStack>
<Collapse in={isExpanded}>
<VStack spacing={4} align="stretch">
{isLoadingLinked ? (
<HStack justify="center" py={4}>
<Spinner size="sm" />
<Text fontSize="sm">Načítání anket...</Text>
</HStack>
) : linkedPolls && linkedPolls.length > 0 ? (
<VStack spacing={2} align="stretch">
<Text fontSize="xs" fontWeight="bold" color="gray.500">
Připojené ankety:
</Text>
{linkedPolls.map((poll) => (
<HStack
key={poll.id}
p={2}
borderWidth="1px"
borderRadius="md"
justify="space-between"
bg="white"
_dark={{ bg: 'gray.800' }}
>
<VStack align="start" spacing={0} flex={1}>
<Text fontSize="sm" fontWeight="medium">
{poll.title}
</Text>
<HStack spacing={2}>
<Badge size="sm" colorScheme={poll.status === 'active' ? 'green' : 'gray'}>
{poll.status}
</Badge>
<Text fontSize="xs" color="gray.500">
{poll.total_votes} hlasů
</Text>
</HStack>
</VStack>
<IconButton
aria-label="Odpojit anketu"
icon={<DeleteIcon />}
size="sm"
colorScheme="red"
variant="ghost"
onClick={() => handleUnlinkPoll(poll.id)}
isLoading={unlinkPollMutation.isPending}
/>
</HStack>
))}
</VStack>
) : (
<Alert status="info" size="sm">
<AlertIcon />
<Text fontSize="sm">Žádné ankety nejsou připojeny</Text>
</Alert>
)}
<Divider />
{isLoadingAll ? (
<HStack justify="center" py={4}>
<Spinner size="sm" />
</HStack>
) : availablePolls.length > 0 ? (
<VStack spacing={3} align="stretch">
<Text fontSize="xs" fontWeight="bold" color="gray.500">
Připojit existující anketu:
</Text>
<HStack>
<Select
value={selectedPollId}
onChange={(e) => setSelectedPollId(e.target.value)}
placeholder="Vyberte anketu..."
size="sm"
flex={1}
>
{availablePolls.map((poll) => (
<option key={poll.id} value={poll.id}>
{poll.title} ({poll.status}) - {poll.total_votes} hlasů
</option>
))}
</Select>
<Button
leftIcon={<AddIcon />}
onClick={handleLinkPoll}
size="sm"
colorScheme="blue"
isLoading={linkPollMutation.isPending}
isDisabled={!selectedPollId}
>
Připojit
</Button>
</HStack>
</VStack>
) : (
<Alert status="warning" size="sm">
<AlertIcon />
<Text fontSize="sm">
Žádné dostupné ankety. Vytvořte novou v{' '}
<Button
as="a"
href="/admin/ankety"
target="_blank"
variant="link"
size="sm"
colorScheme="blue"
>
správě anket
</Button>
</Text>
</Alert>
)}
<Text fontSize="xs" color="gray.500">
💡 Tip: Ankety se zobrazí automaticky na konci {articleId ? 'článku' : 'aktivity'}
</Text>
</VStack>
</Collapse>
</VStack>
</Box>
);
};
export default PollLinker;
@@ -0,0 +1,207 @@
import React from 'react';
import {
Box,
FormControl,
FormLabel,
Select,
SimpleGrid,
Text,
VStack,
Badge,
HStack,
Switch,
Code,
Alert,
AlertIcon,
Link,
} from '@chakra-ui/react';
import { VECTOR_STYLES } from '../home/VectorMap';
import { ExternalLinkIcon } from '@chakra-ui/icons';
interface VectorMapStyleSelectorProps {
value: string;
onChange: (value: string) => void;
clubPrimaryColor?: string;
clubSecondaryColor?: string;
showPreview?: boolean;
useVectorMaps?: boolean;
onToggleVectorMaps?: (enabled: boolean) => void;
}
const VectorMapStyleSelector: React.FC<VectorMapStyleSelectorProps> = ({
value,
onChange,
clubPrimaryColor,
clubSecondaryColor,
showPreview = true,
useVectorMaps = false,
onToggleVectorMaps,
}) => {
const selectedStyle = VECTOR_STYLES[value as keyof typeof VECTOR_STYLES] || VECTOR_STYLES.positron;
return (
<VStack align="stretch" spacing={4}>
{onToggleVectorMaps && (
<FormControl display="flex" alignItems="center">
<FormLabel mb={0} htmlFor="vector-maps-toggle">
Použít vektorové mapy (MapLibre GL)
</FormLabel>
<Switch
id="vector-maps-toggle"
isChecked={useVectorMaps}
onChange={(e) => onToggleVectorMaps(e.target.checked)}
colorScheme="purple"
/>
<Badge ml={2} colorScheme="purple">Experimentální</Badge>
</FormControl>
)}
{useVectorMaps && (
<Alert status="info" borderRadius="md">
<AlertIcon />
<Box fontSize="sm">
<Text fontWeight="bold" mb={1}>Vektorové mapy aktivovány!</Text>
<Text>
Využíváte MapLibre GL s OpenMapTiles. Výhody: lepší výkon, ostřejší zobrazení,
možnost plné customizace stylů přes JSON.
</Text>
</Box>
</Alert>
)}
<FormControl>
<FormLabel>Styl mapy</FormLabel>
<Select value={value} onChange={(e) => onChange(e.target.value)}>
<option value="positron">Positron (Light)</option>
<option value="dark-matter">Dark Matter</option>
<option value="osm-bright">OSM Bright</option>
<option value="klokantech-basic">Basic</option>
</Select>
<Text fontSize="sm" color="gray.600" mt={1}>
{selectedStyle.description}
</Text>
</FormControl>
{showPreview && useVectorMaps && (
<Box
p={4}
borderWidth="1px"
borderRadius="md"
bg="gray.50"
>
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Text fontWeight="semibold">{selectedStyle.name}</Text>
<Badge colorScheme="purple">Vector Tiles</Badge>
</HStack>
<Text fontSize="sm" color="gray.600">
{selectedStyle.description}
</Text>
{clubPrimaryColor && (
<Box>
<Text fontSize="sm" fontWeight="medium" mb={2}>
Barvy klubu (aplikovány dynamicky):
</Text>
<HStack>
<Box
w="40px"
h="40px"
borderRadius="md"
bg={clubPrimaryColor}
borderWidth="1px"
borderColor="gray.300"
/>
{clubSecondaryColor && (
<Box
w="40px"
h="40px"
borderRadius="md"
bg={clubSecondaryColor}
borderWidth="1px"
borderColor="gray.300"
/>
)}
<Text fontSize="xs" color="gray.500">
Marker + vodní plochy
</Text>
</HStack>
</Box>
)}
</VStack>
</Box>
)}
<Box
p={3}
bg="purple.50"
borderWidth="1px"
borderColor="purple.200"
borderRadius="md"
>
<Text fontSize="sm" fontWeight="semibold" mb={2}>
🚀 Výhody vektorových map:
</Text>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={2}>
<Text fontSize="xs" color="gray.700">
<strong>Lepší výkon</strong> - rychlejší načítání
</Text>
<Text fontSize="xs" color="gray.700">
<strong>Ostrý obraz</strong> - perfektní na Retina
</Text>
<Text fontSize="xs" color="gray.700">
<strong>Dynamické styly</strong> - změna za běhu
</Text>
<Text fontSize="xs" color="gray.700">
<strong>Club colors</strong> - automatická integrace
</Text>
</SimpleGrid>
</Box>
<Box
p={3}
bg="blue.50"
borderWidth="1px"
borderColor="blue.200"
borderRadius="md"
>
<Text fontSize="sm" fontWeight="semibold" mb={1}>
📚 Vlastní styly:
</Text>
<Text fontSize="xs" color="gray.700" mb={2}>
Pro pokročilé použití můžete vytvořit vlastní style JSON podle{' '}
<Link
href="https://github.com/openmaptiles/positron-gl-style"
isExternal
color="blue.600"
>
Positron GL Style <ExternalLinkIcon mx="2px" />
</Link>
</Text>
<Code fontSize="xs" p={2} borderRadius="md" display="block">
customStyleUrl: "https://your-server.com/style.json"
</Code>
</Box>
<Text fontSize="xs" color="gray.500">
Používáme MapLibre GL JS (open-source) s MapTiler tiles.
{clubPrimaryColor && ' Barvy klubu jsou aplikovány automaticky na markery a vybrané prvky mapy.'}
</Text>
{useVectorMaps && (
<Alert status="warning" borderRadius="md" fontSize="sm">
<AlertIcon />
<Text>
<strong>API Key:</strong> Nastavte <Code>REACT_APP_MAPTILER_KEY</Code> v <Code>.env</Code> souboru
pro produkční použití. Získejte zdarma na{' '}
<Link href="https://www.maptiler.com/" isExternal color="blue.600">
maptiler.com <ExternalLinkIcon mx="2px" />
</Link>
</Text>
</Alert>
)}
</VStack>
);
};
export default VectorMapStyleSelector;
@@ -0,0 +1,385 @@
import React, { useMemo, useState, useEffect } from 'react';
import {
Box,
Card,
CardBody,
CardHeader,
Flex,
Heading,
Skeleton,
Text,
useColorModeValue,
VStack,
Tooltip,
Icon,
} from '@chakra-ui/react';
import { FaInfoCircle } from 'react-icons/fa';
import { ComposableMap, Geographies, Geography } from 'react-simple-maps';
import type { Feature } from 'geojson';
import { useClubTheme } from '../../contexts/ClubThemeContext';
const GEO_URL = 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json';
type CountryDatum = {
code: string;
value: number;
name?: string | null;
};
type VisitorCountriesMapProps = {
title?: string;
metrics: CountryDatum[];
isLoading?: boolean;
height?: number;
onCountryClick?: (countryCode: string, countryName: string, value: number) => void;
clearSelection?: boolean;
};
type HoverState = {
name: string;
value: number;
};
type ClickedState = {
name: string;
value: number;
code: string;
};
const mixChannel = (start: number, end: number, factor: number) => {
return Math.round(start + (end - start) * factor);
};
const hexToRgb = (hex: string) => {
const normalized = hex.replace('#', '');
const bigint = parseInt(normalized, 16);
return {
r: (bigint >> 16) & 255,
g: (bigint >> 8) & 255,
b: bigint & 255,
};
};
const interpolateColor = (startHex: string, endHex: string, factor: number) => {
const start = hexToRgb(startHex);
const end = hexToRgb(endHex);
const clamped = Math.min(Math.max(factor, 0), 1);
const r = mixChannel(start.r, end.r, clamped);
const g = mixChannel(start.g, end.g, clamped);
const b = mixChannel(start.b, end.b, clamped);
return `rgb(${r}, ${g}, ${b})`;
};
const getDisplayNames = () => {
if (typeof Intl !== 'undefined' && typeof (Intl as any).DisplayNames === 'function') {
try {
return new Intl.DisplayNames(['cs', 'en'], { type: 'region' });
} catch (error) {
// Some browsers might throw when the locale is unsupported
return new Intl.DisplayNames(['en'], { type: 'region' });
}
}
return null;
};
export const VisitorCountriesMap: React.FC<VisitorCountriesMapProps> = ({
title = 'Mapa návštěvníků podle země',
metrics = [],
isLoading = false,
height = 400,
onCountryClick,
clearSelection = false,
}) => {
const [hovered, setHovered] = useState<HoverState | null>(null);
const [clicked, setClicked] = useState<ClickedState | null>(null);
const displayNames = useMemo(getDisplayNames, []);
const numberFormatter = useMemo(() => new Intl.NumberFormat('cs-CZ'), []);
const clubTheme = useClubTheme();
// Clear selection when clearSelection prop changes
useEffect(() => {
if (clearSelection) {
setClicked(null);
}
}, [clearSelection]);
const dataMap = useMemo(() => {
const map = new Map<string, { value: number; name: string }>();
metrics.forEach((item) => {
if (!item?.code || typeof item.value !== 'number') return;
const normalizedCode = item.code.toUpperCase();
const fallbackName = item.name ||
(normalizedCode.length === 2 ? displayNames?.of(normalizedCode) ?? normalizedCode : normalizedCode);
map.set(normalizedCode, {
value: item.value,
name: fallbackName || normalizedCode,
});
});
return map;
}, [metrics, displayNames]);
const maxValue = useMemo(() => {
let max = 0;
dataMap.forEach(({ value }) => {
if (value > max) max = value;
});
return max;
}, [dataMap]);
const borderColor = useColorModeValue('gray.200', 'gray.700');
const defaultFill = useColorModeValue('#EDF2F7', '#2D3748');
// Use club colors for the map gradient
const startFill = useColorModeValue(
hexToRgb(clubTheme.primary).r > 200 ? clubTheme.secondary : clubTheme.primary,
clubTheme.primary
);
const endFill = useColorModeValue(
clubTheme.accent || clubTheme.primary,
clubTheme.secondary || clubTheme.primary
);
const tooltipBg = useColorModeValue('white', 'gray.800');
const tooltipBorder = useColorModeValue('gray.200', 'gray.600');
// Enhanced border color for better visibility
const countryBorderColor = useColorModeValue('#CBD5E0', '#4A5568');
const hoveredBorderColor = clubTheme.secondary || '#F6AD55';
const getDatumForGeo = (geo: Feature) => {
const properties = (geo.properties || {}) as Record<string, any>;
const iso2 = (properties.ISO_A2 || properties.iso_a2 || '').toUpperCase();
const iso3 = (properties.ISO_A3 || properties.iso_a3 || '').toUpperCase();
return (iso2 && dataMap.get(iso2)) || (iso3 && dataMap.get(iso3)) || null;
};
const getFillForDatum = (datum: { value: number } | null, isHovered: boolean = false, isClicked: boolean = false) => {
if (!datum || maxValue <= 0) return defaultFill;
const ratio = datum.value / maxValue;
const baseColor = interpolateColor(startFill, endFill, ratio);
// Enhanced visual feedback
if (isClicked) {
// Make clicked country more prominent
const rgb = hexToRgb(baseColor.startsWith('#') ? baseColor : '#000000');
return `rgb(${Math.min(rgb.r + 50, 255)}, ${Math.min(rgb.g + 50, 255)}, ${Math.min(rgb.b + 50, 255)})`;
} else if (isHovered) {
// Brighten on hover
const rgb = hexToRgb(baseColor.startsWith('#') ? baseColor : '#000000');
return `rgb(${Math.min(rgb.r + 30, 255)}, ${Math.min(rgb.g + 30, 255)}, ${Math.min(rgb.b + 30, 255)})`;
}
return baseColor;
};
const hasData = metrics?.some((item) => item.value > 0);
return (
<Card borderColor={borderColor} overflow="hidden">
<CardHeader>
<Flex align="center" justify="space-between">
<Heading size="md">{title}</Heading>
<Tooltip
label="Klikněte na zemi pro zobrazení detailních analytických dat"
placement="top"
hasArrow
>
<Icon as={FaInfoCircle} color="gray.400" cursor="help" />
</Tooltip>
</Flex>
</CardHeader>
<CardBody>
{isLoading ? (
<Skeleton height={`${height}px`} borderRadius="md" />
) : !hasData ? (
<Box textAlign="center" py={12} color="gray.500">
<Text>Pro vybraný rozsah nejsou k dispozici data o zemích návštěvníků.</Text>
</Box>
) : (
<Box
position="relative"
width="100%"
overflow="hidden"
borderRadius="md"
boxShadow="sm"
transition="all 0.3s ease-in-out"
_hover={{
boxShadow: 'md',
}}
>
<ComposableMap
projectionConfig={{
scale: 150,
center: [0, 20],
}}
height={height}
width={800}
style={{
width: '100%',
height: 'auto',
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))',
}}
>
<Geographies geography={GEO_URL}>
{({ geographies }: { geographies: Feature[] }) =>
geographies.map((geo: Feature) => {
const datum = getDatumForGeo(geo);
const properties = (geo.properties || {}) as Record<string, any>;
const countryCode = (properties.ISO_A2 || properties.iso_a2 || '').toUpperCase();
const isHovered = hovered?.name === datum?.name;
const isClicked = clicked?.code === countryCode;
const hasData = datum !== null;
return (
<Geography
key={geo.rsmKey}
geography={geo}
stroke={isClicked ? clubTheme.primary : (isHovered ? hoveredBorderColor : countryBorderColor)}
strokeWidth={isClicked ? 2.5 : (isHovered ? 1.5 : 0.7)}
fill={getFillForDatum(datum, isHovered, isClicked)}
style={{
default: {
outline: 'none',
cursor: hasData ? 'pointer' : 'default',
transition: 'all 0.2s ease-in-out',
},
hover: {
outline: 'none',
cursor: hasData ? 'pointer' : 'default',
transition: 'all 0.2s ease-in-out',
},
pressed: {
outline: 'none',
cursor: hasData ? 'pointer' : 'default',
transition: 'all 0.2s ease-in-out',
},
}}
onMouseEnter={() => {
if (!datum) {
setHovered(null);
return;
}
setHovered({
name: datum.name,
value: datum.value,
});
}}
onMouseLeave={() => setHovered(null)}
onClick={() => {
if (datum && onCountryClick) {
setClicked({
name: datum.name,
value: datum.value,
code: countryCode,
});
onCountryClick(countryCode, datum.name, datum.value);
}
}}
/>
);
})
}
</Geographies>
</ComposableMap>
{hovered && (
<Box
position="absolute"
bottom={4}
left={4}
bg={tooltipBg}
border="1px solid"
borderColor={tooltipBorder}
borderRadius="md"
px={3}
py={2}
boxShadow="lg"
zIndex={10}
>
<VStack spacing={1} align="start">
<Text fontWeight="semibold">{hovered.name}</Text>
<Text fontSize="sm" color="gray.500">
{numberFormatter.format(hovered.value)} návštěv
</Text>
<Text fontSize="xs" color="blue.500" fontWeight="medium">
Klikněte pro detaily
</Text>
</VStack>
</Box>
)}
{clicked && (
<Box
position="absolute"
top={4}
right={4}
bg={clubTheme.primary}
color="white"
borderRadius="md"
px={3}
py={2}
boxShadow="lg"
zIndex={10}
>
<VStack spacing={1} align="start">
<Text fontWeight="semibold" fontSize="sm">
Vybraná země
</Text>
<Text fontSize="sm">{clicked.name}</Text>
<Text fontSize="xs" opacity={0.9}>
{numberFormatter.format(clicked.value)} návštěv
</Text>
</VStack>
</Box>
)}
</Box>
)}
{hasData && !isLoading && (
<VStack spacing={3} mt={4} align="stretch">
<Flex justify="space-between" align="center">
<Text fontSize="sm" color="gray.500" fontWeight="medium">
Méně návštěv
</Text>
<Text fontSize="xs" color="gray.400" textAlign="center">
Intenzita návštěvnosti
</Text>
<Text fontSize="sm" color="gray.500" fontWeight="medium">
Více návštěv
</Text>
</Flex>
<Box
height="12px"
borderRadius="full"
bgGradient={`linear(to-r, ${defaultFill}, ${startFill}, ${endFill})`}
boxShadow="inset 0 1px 2px rgba(0,0,0,0.1)"
position="relative"
overflow="hidden"
>
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
bgGradient="linear(to-r, transparent, rgba(255,255,255,0.2), transparent)"
borderRadius="full"
/>
</Box>
{clicked && (
<Text fontSize="xs" color="blue.500" textAlign="center" fontWeight="medium">
💡 Klikněte na jinou zemi pro porovnání dat
</Text>
)}
</VStack>
)}
</CardBody>
</Card>
);
};
export default VisitorCountriesMap;
@@ -0,0 +1,25 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { ReactNode } from 'react';
interface ProtectedRouteProps {
children: ReactNode;
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return <div>Loading...</div>; // Or a loading spinner
}
if (!isAuthenticated) {
// Redirect to login page, saving the current location they were trying to go to
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <>{children}</>;
};
export default ProtectedRoute;
@@ -0,0 +1,135 @@
import React from 'react';
import { Box, Link as ChakraLink } from '@chakra-ui/react';
import { assetUrl } from '../../utils/url';
export interface Banner {
id: number | string;
name: string;
image: string;
url?: string;
placement?: string;
width?: number;
height?: number;
is_active?: boolean;
}
interface BannerDisplayProps {
banners: Banner[];
placement: 'homepage_top' | 'homepage_middle' | 'homepage_sidebar' | 'homepage_footer' | 'article_inline';
containerStyle?: React.CSSProperties;
}
const BannerDisplay: React.FC<BannerDisplayProps> = ({ banners, placement, containerStyle }) => {
// Filter active banners for this placement
const activeBanners = (banners || []).filter(
b => b.placement === placement && (b.is_active !== false)
);
if (activeBanners.length === 0) {
return null;
}
const getContainerClass = () => {
switch (placement) {
case 'homepage_top':
return 'banner-top';
case 'homepage_middle':
return 'banner-middle';
case 'homepage_sidebar':
return 'banner-sidebar';
case 'homepage_footer':
return 'banner-footer';
case 'article_inline':
return 'banner-article';
default:
return 'banner';
}
};
const getDefaultContainerStyle = (): React.CSSProperties => {
const base: React.CSSProperties = {
margin: '24px 0',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '16px',
flexWrap: 'wrap',
};
switch (placement) {
case 'homepage_top':
return {
...base,
width: '100vw',
position: 'relative',
left: '50%',
right: '50%',
marginLeft: '-50vw',
marginRight: '-50vw',
backgroundColor: 'rgba(0, 0, 0, 0.02)',
padding: '16px',
borderTop: '1px solid rgba(0, 0, 0, 0.05)',
borderBottom: '1px solid rgba(0, 0, 0, 0.05)',
};
case 'homepage_footer':
return {
...base,
width: '100vw',
position: 'relative',
left: '50%',
right: '50%',
marginLeft: '-50vw',
marginRight: '-50vw',
backgroundColor: 'rgba(0, 0, 0, 0.02)',
padding: '24px 16px',
borderTop: '1px solid rgba(0, 0, 0, 0.05)',
};
case 'homepage_sidebar':
return {
display: 'block',
margin: '24px 0',
};
default:
return base;
}
};
const finalContainerStyle = { ...getDefaultContainerStyle(), ...containerStyle };
return (
<Box
as="section"
className={getContainerClass()}
sx={finalContainerStyle}
>
{activeBanners.map((banner) => (
<ChakraLink
key={banner.id}
href={banner.url || '#'}
isExternal={!!banner.url}
target={banner.url ? '_blank' : undefined}
rel={banner.url ? 'noopener noreferrer' : undefined}
display="inline-block"
_hover={{ opacity: 0.9, transform: 'translateY(-2px)' }}
transition="all 0.2s"
>
<img
src={assetUrl(banner.image) || banner.image}
alt={banner.name}
style={{
maxWidth: '100%',
width: banner.width ? `${banner.width}px` : 'auto',
height: banner.height ? `${banner.height}px` : 'auto',
objectFit: 'contain',
borderRadius: placement === 'homepage_sidebar' ? '8px' : '4px',
boxShadow: placement === 'homepage_sidebar' ? '0 2px 8px rgba(0,0,0,0.1)' : 'none',
}}
loading="lazy"
/>
</ChakraLink>
))}
</Box>
);
};
export default BannerDisplay;
@@ -0,0 +1,199 @@
import React, { useState, useEffect } from 'react';
import { format, startOfWeek, addDays, isSameDay, parseISO, isBefore } from 'date-fns';
import { cs } from 'date-fns/locale';
import { Event } from '../../types/event';
import { getEvents } from '../../services/eventService';
import { getMatches } from '../../services/public';
interface CalendarProps {
onEventClick?: (event: Event) => void;
}
const Calendar: React.FC<CalendarProps> = ({ onEventClick }) => {
const [currentDate, setCurrentDate] = useState(new Date());
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [latestResults, setLatestResults] = useState<any[]>([]);
useEffect(() => {
const fetchEvents = async () => {
try {
const data = await getEvents();
setEvents(data);
} catch (err) {
setError('Nepodařilo se načíst události');
console.error('Error fetching events:', err);
} finally {
setLoading(false);
}
};
fetchEvents();
}, []);
// Fetch latest results (small sidebar)
useEffect(() => {
let active = true;
(async () => {
try {
const matches = await getMatches();
// Expecting items with date or date_time and score/result
const now = new Date();
const normalized = (Array.isArray(matches) ? matches : []).map((m: any) => {
const dt = parseISO(m.date_time || m.date || m.match_date || m.datetime || '');
return { ...m, __dt: isNaN(dt as any) ? null : dt };
}).filter((m: any) => m.__dt && isBefore(m.__dt, now));
normalized.sort((a: any, b: any) => (b.__dt as any) - (a.__dt as any));
const recent = normalized.slice(0, 6);
if (active) setLatestResults(recent);
} catch (e) {
// silent fail for sidebar
}
})();
return () => { active = false };
}, []);
const startDate = startOfWeek(currentDate, { weekStartsOn: 1 }); // Start on Monday
const days = [];
for (let i = 0; i < 7; i++) {
const day = addDays(startDate, i);
days.push(day);
}
const getEventsForDay = (day: Date) => {
return events.filter(event => {
const eventDate = parseISO(event.start_time);
return isSameDay(eventDate, day);
});
};
const getEventTypeColor = (type: string) => {
switch (type) {
case 'match':
return 'bg-red-100 text-red-800 border-red-200';
case 'training':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'meeting':
return 'bg-green-100 text-green-800 border-green-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
if (loading) return <div>Načítám kalendář...</div>;
if (error) return <div className="text-red-500">{error}</div>;
return (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Main Calendar (more prominent) */}
<div className="lg:col-span-3">
<div className="bg-white rounded-lg shadow overflow-hidden">
{/* Week header */}
<div className="grid grid-cols-7 gap-px bg-gray-200">
{days.map((day, i) => {
const isToday = isSameDay(day, new Date());
return (
<div key={i} className={`bg-white p-3 text-center ${isToday ? 'ring-2 ring-blue-500 ring-offset-2 ring-offset-white' : ''}`}>
<div className="font-semibold text-gray-900 uppercase tracking-wide text-xs">
{format(day, 'EEEE', { locale: cs })}
</div>
<div className={`mt-1 text-xl font-bold ${isToday ? 'text-blue-600' : 'text-gray-900'}`}>
{format(day, 'd')}
</div>
</div>
);
})}
</div>
{/* Grouped events by day */}
<div className="p-4">
{days.map((day, i) => {
const dayEvents = getEventsForDay(day);
const isToday = isSameDay(day, new Date());
return (
<div key={i} className="mb-5">
<div className={`sticky top-0 z-10 px-3 py-2 ${isToday ? 'bg-blue-50 border-l-4 border-blue-500' : 'bg-gray-50 border-l-4 border-gray-300'}`}>
<h3 className={`text-sm md:text-base font-semibold tracking-wide ${isToday ? 'text-blue-700' : 'text-gray-800'}`}>
{format(day, 'EEEE d. M. yyyy', { locale: cs })}
{isToday && <span className="ml-2 text-[10px] md:text-xs font-bold text-blue-700 uppercase bg-blue-100 px-2 py-0.5 rounded-full">Dnes</span>}
</h3>
</div>
{dayEvents.length > 0 ? (
<div className="mt-2 space-y-2">
{dayEvents.map((event) => (
<div
key={event.id}
onClick={() => onEventClick?.(event)}
className={`p-3 rounded-md border cursor-pointer transition-colors ${getEventTypeColor(event.type)} hover:opacity-95`}
style={{ borderColor: 'rgba(0,0,0,0.08)' }}
>
<div className="flex items-center justify-between">
<div className="font-medium truncate pr-2">{event.title}</div>
<div className="text-[10px] md:text-xs px-2 py-0.5 rounded-full bg-white/70 border border-black/10 text-gray-700">
{event.type}
</div>
</div>
<div className="text-sm text-gray-700">
{format(parseISO(event.start_time), 'H:mm', { locale: cs })}
{event.location && `${event.location}`}
</div>
</div>
))}
</div>
) : (
<p className="mt-2 text-gray-500 text-sm">Žádné události</p>
)}
</div>
);
})}
</div>
</div>
</div>
{/* Sidebar: Latest Results (compact, space-saving) */}
<aside className="lg:col-span-1">
<div className="bg-white rounded-lg shadow p-2 lg:sticky lg:top-2">
<h3 className="text-xs font-semibold text-gray-800 mb-2 tracking-wide uppercase">Nejnovější výsledky</h3>
{latestResults.length === 0 ? (
<p className="text-gray-500 text-xs">Zatím žádné výsledky</p>
) : (
<ul className="space-y-1">
{latestResults.slice(0,6).map((m: any, idx: number) => {
const nameHome = (m.home || m.home_team) || 'Domácí';
const nameAway = (m.away || m.away_team) || 'Hosté';
const score = m.score || (typeof m.result_home === 'number' && typeof m.result_away === 'number' ? `${m.result_home}:${m.result_away}` : '-');
const dtRaw = (m.date_time || m.date || m.match_date || m.datetime || '') as string;
let shortDate = '';
try {
const parsed = parseISO(dtRaw);
if (!isNaN((parsed as any))) {
shortDate = format(parsed as any, 'd.M.', { locale: cs });
}
} catch {}
return (
<li key={idx} className="flex items-center justify-between gap-2 p-1.5 rounded border border-gray-200/70 hover:bg-gray-50">
<div className="flex-1 min-w-0 text-[11px] font-medium text-gray-900 truncate">
<span className="truncate inline-block max-w-[46%] align-bottom">{nameHome}</span>
<span className="mx-1 text-gray-500">vs</span>
<span className="truncate inline-block max-w-[46%] align-bottom">{nameAway}</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{shortDate && (
<span className="hidden sm:inline-block text-[10px] text-gray-600 bg-gray-100 border border-gray-200 rounded px-1 py-0.5">{shortDate}</span>
)}
<span className="text-[10px] font-extrabold bg-gray-800 text-white rounded px-1.5 py-0.5">{score}</span>
</div>
</li>
);
})}
</ul>
)}
</div>
</aside>
</div>
);
};
export default Calendar;
@@ -0,0 +1,80 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Button,
useDisclosure,
useToast,
} from '@chakra-ui/react';
import { useRef } from 'react';
interface ConfirmationDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
isDanger?: boolean;
isLoading?: boolean;
}
export default function ConfirmationDialog({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Potvrdit',
cancelText = 'Zrušit',
isDanger = false,
isLoading = false,
}: ConfirmationDialogProps) {
const cancelRef = useRef<HTMLButtonElement>(null);
const handleConfirm = () => {
onConfirm();
};
return (
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={cancelRef}
onClose={onClose}
isCentered
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{title}
</AlertDialogHeader>
<AlertDialogBody>{message}</AlertDialogBody>
<AlertDialogFooter>
<Button
ref={cancelRef}
onClick={onClose}
isDisabled={isLoading}
>
{cancelText}
</Button>
<Button
colorScheme={isDanger ? 'red' : 'blue'}
onClick={handleConfirm}
ml={3}
isLoading={isLoading}
loadingText="Zpracovávám..."
>
{confirmText}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
);
}
@@ -0,0 +1,956 @@
import React, { useRef, useCallback, useState, useEffect } from 'react';
import {
Box,
Button,
HStack,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
FormControl,
FormLabel,
FormHelperText,
Input,
Text,
SimpleGrid,
useToast,
VStack,
useColorModeValue,
ButtonGroup,
IconButton,
Tooltip,
} from '@chakra-ui/react';
import ReactQuill from 'react-quill';
import ReactCrop, { Crop } from 'react-image-crop';
import DOMPurify from 'dompurify';
import 'react-quill/dist/quill.snow.css';
import 'react-image-crop/dist/ReactCrop.css';
import '../../styles/custom-editor.css';
import {
Image as ImageIcon, Code, Type, Trash2, AlignLeft, AlignCenter, AlignRight,
RotateCw, RotateCcw, FlipHorizontal, FlipVertical, Sun, Droplets, Eye,
Sparkles, Contrast, ZoomIn, ZoomOut, Move, Maximize2, Settings,
Circle, Square, X, Check, Filter
} from 'lucide-react';
interface ImageFilters {
brightness: number;
contrast: number;
saturation: number;
blur: number;
grayscale: number;
sepia: number;
hueRotate: number;
rotation: number;
flipH: boolean;
flipV: boolean;
}
interface CustomRichEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
height?: string;
readOnly?: boolean;
onImageUpload?: (file: File) => Promise<{ url: string }>;
showImageResize?: boolean;
toolbar?: 'full' | 'basic' | 'minimal';
}
const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
value,
onChange,
placeholder = 'Začněte psát...',
height = '400px',
readOnly = false,
onImageUpload,
showImageResize = true,
toolbar = 'full',
}) => {
const toast = useToast();
const quillRef = useRef<ReactQuill | null>(null);
const [editorMode, setEditorMode] = useState<'rich' | 'html'>('rich');
// Crop modal state
const [cropOpen, setCropOpen] = useState(false);
const [cropSrc, setCropSrc] = useState<string | null>(null);
const [crop, setCrop] = useState<Crop>({ unit: '%', width: 80, height: 80, x: 10, y: 10 });
const [cropQuality, setCropQuality] = useState<number>(85);
const [cropMaxWidth, setCropMaxWidth] = useState<number>(1500);
const imgRef = useRef<HTMLImageElement | null>(null);
const borderColor = useColorModeValue('gray.200', 'gray.600');
const bgColor = useColorModeValue('white', 'gray.800');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
const toolbarBg = useColorModeValue('white', 'gray.800');
const toolbarBorder = useColorModeValue('gray.200', 'gray.700');
// Image editing state
const [selectedImageElement, setSelectedImageElement] = useState<HTMLImageElement | null>(null);
const [imageFilters, setImageFilters] = useState<ImageFilters>({
brightness: 100,
contrast: 100,
saturation: 100,
blur: 0,
grayscale: 0,
sepia: 0,
hueRotate: 0,
rotation: 0,
flipH: false,
flipV: false,
});
const [showImageToolbar, setShowImageToolbar] = useState(false);
const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 });
// Define toolbar configurations
const toolbarConfigs = {
full: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ color: [] }, { background: [] }],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ align: [] }],
['link', 'image', 'video'],
['blockquote', 'code-block'],
['clean'],
],
basic: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline'],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ align: [] }],
['link', 'image'],
['clean'],
],
minimal: [
['bold', 'italic', 'underline'],
[{ list: 'bullet' }],
['link'],
['clean'],
],
};
const getToolbarConfig = () => {
return toolbarConfigs[toolbar] || toolbarConfigs.full;
};
// Image upload handler
const handleImageUpload = useCallback(() => {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.onchange = async () => {
const file = (input.files || [])[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
setCropSrc(reader.result as string);
setCropOpen(true);
};
reader.readAsDataURL(file);
};
input.click();
}, []);
// Get cropped blob
const getCroppedBlob = (image: HTMLImageElement, cropPixels: { x: number; y: number; width: number; height: number }): Promise<Blob> => {
const canvas = document.createElement('canvas');
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
let outputWidth = Math.max(1, Math.round(cropPixels.width * scaleX));
let outputHeight = Math.max(1, Math.round(cropPixels.height * scaleY));
if (outputWidth > cropMaxWidth) {
const scale = cropMaxWidth / outputWidth;
outputWidth = cropMaxWidth;
outputHeight = Math.round(outputHeight * scale);
}
canvas.width = outputWidth;
canvas.height = outputHeight;
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) throw new Error('Canvas not supported');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(
image,
Math.round(cropPixels.x * scaleX),
Math.round(cropPixels.y * scaleY),
Math.round(cropPixels.width * scaleX),
Math.round(cropPixels.height * scaleY),
0,
0,
outputWidth,
outputHeight
);
return new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob as Blob), 'image/jpeg', cropQuality / 100);
});
};
// Confirm crop and insert
const confirmCropAndInsert = async () => {
try {
if (!imgRef.current) {
toast({ title: 'Chyba', description: 'Obrázek není načten', status: 'error' });
return;
}
if (!crop.width || !crop.height || crop.width <= 0 || crop.height <= 0) {
toast({ title: 'Chyba', description: 'Vyberte oblast k oříznutí', status: 'warning' });
return;
}
const img = imgRef.current;
const percToPx = (val: number, size: number) => (crop.unit === '%' ? (val / 100) * size : val);
const cropPx = {
x: Math.max(0, percToPx(crop.x || 0, img.width)),
y: Math.max(0, percToPx(crop.y || 0, img.height)),
width: Math.min(img.width, percToPx(crop.width || img.width, img.width)),
height: Math.min(img.height, percToPx(crop.height || img.height, img.height)),
};
if (cropPx.x + cropPx.width > img.width) {
cropPx.width = img.width - cropPx.x;
}
if (cropPx.y + cropPx.height > img.height) {
cropPx.height = img.height - cropPx.y;
}
const blob = await getCroppedBlob(img, cropPx);
const file = new File([blob], 'cropped-image.jpg', { type: 'image/jpeg' });
if (onImageUpload) {
toast({ title: 'Nahrávám obrázek...', status: 'info', duration: 2000 });
const res = await onImageUpload(file);
if (!res.url) {
throw new Error('Upload failed - no URL returned');
}
const quill = quillRef.current?.getEditor();
if (quill) {
const range = quill.getSelection(true);
const index = range ? range.index : quill.getLength();
quill.insertEmbed(index, 'image', res.url, 'user');
quill.setSelection(index + 1, 0);
toast({ title: 'Obrázek vložen', status: 'success', duration: 2000 });
}
}
} catch (e: any) {
console.error('Crop and insert error:', e);
toast({ title: 'Zpracování obrázku selhalo', description: e?.message || 'Chyba', status: 'error' });
} finally {
setCropOpen(false);
setCropSrc(null);
setCrop({ unit: '%', width: 80, height: 80, x: 10, y: 10 });
setCropQuality(85);
setCropMaxWidth(1500);
}
};
// Make images draggable and resizable
useEffect(() => {
const editor = quillRef.current?.getEditor();
if (!editor || readOnly) return;
let selectedImage: HTMLImageElement | null = null;
let resizeHandle: HTMLDivElement | null = null;
let isResizing = false;
let isDragging = false;
let startX = 0;
let startY = 0;
let startWidth = 0;
const createResizeHandle = (img: HTMLImageElement) => {
removeResizeHandle();
const handle = document.createElement('div');
handle.className = 'custom-image-resize-handle';
handle.style.cssText = `
position: absolute;
width: 14px;
height: 14px;
background: linear-gradient(135deg, #3182ce 0%, #2c5aa0 100%);
border: 2px solid white;
border-radius: 50%;
cursor: nwse-resize;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
transition: transform 0.2s;
`;
const updateHandlePosition = () => {
const rect = img.getBoundingClientRect();
const editorRect = editor.root.getBoundingClientRect();
handle.style.left = `${rect.right - editorRect.left - 7}px`;
handle.style.top = `${rect.bottom - editorRect.top - 7}px`;
};
updateHandlePosition();
editor.root.style.position = 'relative';
editor.root.appendChild(handle);
resizeHandle = handle;
handle.addEventListener('mouseenter', () => {
handle.style.transform = 'scale(1.2)';
});
handle.addEventListener('mouseleave', () => {
handle.style.transform = 'scale(1)';
});
handle.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
isResizing = true;
startX = e.clientX;
startWidth = img.offsetWidth;
const onMouseMove = (e: MouseEvent) => {
if (!isResizing) return;
const deltaX = e.clientX - startX;
const newWidth = Math.max(50, startWidth + deltaX);
img.style.width = `${newWidth}px`;
img.style.maxWidth = '100%';
updateHandlePosition();
};
const onMouseUp = () => {
isResizing = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
onChange(editor.root.innerHTML);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
return handle;
};
const removeResizeHandle = () => {
if (resizeHandle && resizeHandle.parentNode) {
resizeHandle.parentNode.removeChild(resizeHandle);
resizeHandle = null;
}
};
const selectImage = (img: HTMLImageElement) => {
if (selectedImage) {
selectedImage.style.outline = '';
selectedImage.style.cursor = '';
selectedImage.style.boxShadow = '';
}
selectedImage = img;
img.style.outline = '3px solid #3182ce';
img.style.cursor = 'move';
img.style.boxShadow = '0 4px 12px rgba(49, 130, 206, 0.3)';
createResizeHandle(img);
// Set selected image state and load filters
setSelectedImageElement(img);
const filtersData = img.getAttribute('data-filters');
if (filtersData) {
try {
const savedFilters = JSON.parse(filtersData);
setImageFilters(savedFilters);
} catch {
// If parsing fails, use defaults
}
}
// Show toolbar and position it
const rect = img.getBoundingClientRect();
const editorRect = editor.root.getBoundingClientRect();
setToolbarPosition({
top: rect.top - editorRect.top - 50,
left: rect.left - editorRect.left,
});
setShowImageToolbar(true);
};
const deselectImage = () => {
if (selectedImage) {
selectedImage.style.outline = '';
selectedImage.style.cursor = '';
selectedImage.style.boxShadow = '';
selectedImage = null;
}
removeResizeHandle();
setSelectedImageElement(null);
setShowImageToolbar(false);
};
const handleImageClick = (e: Event) => {
const target = e.target as HTMLElement;
if (target.tagName === 'IMG') {
e.preventDefault();
e.stopPropagation();
selectImage(target as HTMLImageElement);
} else if (!target.classList.contains('custom-image-resize-handle')) {
deselectImage();
}
};
const handleMouseDown = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === 'IMG' && selectedImage === target) {
e.preventDefault();
isDragging = true;
startX = e.clientX;
startY = e.clientY;
const onMouseMove = (e: MouseEvent) => {
if (!isDragging || !selectedImage) return;
const deltaX = e.clientX - startX;
if (Math.abs(deltaX) > 20) {
if (deltaX > 0) {
selectedImage.style.display = 'block';
selectedImage.style.marginLeft = 'auto';
selectedImage.style.marginRight = '0';
} else {
selectedImage.style.display = 'block';
selectedImage.style.marginLeft = '0';
selectedImage.style.marginRight = 'auto';
}
}
};
const onMouseUp = () => {
isDragging = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
if (selectedImage) {
onChange(editor.root.innerHTML);
}
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
};
// Delete selected image on Delete key
const handleKeyDown = (e: KeyboardEvent) => {
if (selectedImage && (e.key === 'Delete' || e.key === 'Backspace')) {
e.preventDefault();
selectedImage.remove();
deselectImage();
onChange(editor.root.innerHTML);
toast({ title: 'Obrázek odstraněn', status: 'info', duration: 1500 });
}
};
editor.root.addEventListener('click', handleImageClick);
editor.root.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
editor.root.removeEventListener('click', handleImageClick);
editor.root.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKeyDown);
removeResizeHandle();
deselectImage();
};
}, [value, onChange, readOnly, toast]);
// Apply filters to selected image
const applyFiltersToImage = useCallback((img: HTMLImageElement, filters: ImageFilters) => {
const filterString = `
brightness(${filters.brightness}%)
contrast(${filters.contrast}%)
saturate(${filters.saturation}%)
blur(${filters.blur}px)
grayscale(${filters.grayscale}%)
sepia(${filters.sepia}%)
hue-rotate(${filters.hueRotate}deg)
`.trim();
const transform = `
rotate(${filters.rotation}deg)
scaleX(${filters.flipH ? -1 : 1})
scaleY(${filters.flipV ? -1 : 1})
`.trim();
img.style.filter = filterString;
img.style.transform = transform;
img.setAttribute('data-filters', JSON.stringify(filters));
}, []);
// Reset filters
const resetFilters = useCallback(() => {
const defaultFilters: ImageFilters = {
brightness: 100,
contrast: 100,
saturation: 100,
blur: 0,
grayscale: 0,
sepia: 0,
hueRotate: 0,
rotation: 0,
flipH: false,
flipV: false,
};
setImageFilters(defaultFilters);
if (selectedImageElement) {
applyFiltersToImage(selectedImageElement, defaultFilters);
}
}, [selectedImageElement, applyFiltersToImage]);
// Update filter and apply to image
const updateFilter = useCallback((key: keyof ImageFilters, value: any) => {
setImageFilters(prev => {
const newFilters = { ...prev, [key]: value };
if (selectedImageElement) {
applyFiltersToImage(selectedImageElement, newFilters);
}
return newFilters;
});
}, [selectedImageElement, applyFiltersToImage]);
// Sanitize HTML on change
const handleChange = (content: string) => {
const cleaned = DOMPurify.sanitize(content, {
USE_PROFILES: { html: true },
ADD_TAGS: ['iframe'],
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters'],
});
onChange(cleaned);
};
return (
<Box>
{/* Editor Controls */}
{!readOnly && (
<HStack mb={2} spacing={2} justify="space-between" flexWrap="wrap">
<ButtonGroup size="sm" isAttached variant="outline">
<Button
leftIcon={<Type size={16} />}
variant={editorMode === 'rich' ? 'solid' : 'outline'}
colorScheme={editorMode === 'rich' ? 'blue' : 'gray'}
onClick={() => setEditorMode('rich')}
>
Editor
</Button>
<Button
leftIcon={<Code size={16} />}
variant={editorMode === 'html' ? 'solid' : 'outline'}
colorScheme={editorMode === 'html' ? 'blue' : 'gray'}
onClick={() => setEditorMode('html')}
>
HTML
</Button>
</ButtonGroup>
{editorMode === 'rich' && onImageUpload && (
<Button
size="sm"
leftIcon={<ImageIcon size={16} />}
colorScheme="purple"
onClick={handleImageUpload}
>
Vložit obrázek
</Button>
)}
</HStack>
)}
{editorMode === 'rich' ? (
<Box
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
overflow="hidden"
bg={bgColor}
sx={{
'.ql-toolbar': {
borderBottom: '1px solid',
borderColor: borderColor,
bg: hoverBg,
},
'.ql-container': {
fontSize: '16px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
},
'.ql-editor': {
minHeight: height,
maxHeight: '70vh',
overflowY: 'auto',
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
bg: 'gray.100',
},
'&::-webkit-scrollbar-thumb': {
bg: 'gray.400',
borderRadius: '4px',
},
img: {
cursor: 'pointer',
maxWidth: '100%',
height: 'auto',
display: 'block',
margin: '12px 0',
transition: 'all 0.2s ease',
borderRadius: '4px',
userSelect: 'none',
'&:hover': {
opacity: 0.95,
transform: 'scale(1.01)',
},
},
},
'.ql-editor.ql-blank::before': {
color: 'gray.400',
fontStyle: 'italic',
},
}}
>
<ReactQuill
theme="snow"
value={value}
onChange={handleChange}
readOnly={readOnly}
placeholder={placeholder}
ref={quillRef}
modules={{
toolbar: {
container: getToolbarConfig(),
handlers: {
image: onImageUpload ? handleImageUpload : undefined,
},
},
clipboard: {
matchVisual: false,
},
}}
/>
</Box>
) : (
<Box
as="textarea"
value={value}
onChange={(e: any) => onChange(e.target.value)}
fontFamily="mono"
fontSize="sm"
p={4}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
bg={bgColor}
resize="vertical"
minH={height}
maxH="70vh"
width="100%"
/>
)}
{!readOnly && editorMode === 'rich' && (
<Text fontSize="xs" color="gray.500" mt={2}>
💡 Tip: Klikněte na obrázek pro výběr a úpravu. Používejte nástrojovou lištu pro filtry a transformace.
</Text>
)}
{/* Floating Image Editing Toolbar */}
{showImageToolbar && selectedImageElement && !readOnly && (
<Box
position="absolute"
top={`${toolbarPosition.top}px`}
left={`${toolbarPosition.left}px`}
bg={toolbarBg}
borderWidth="1px"
borderColor={toolbarBorder}
borderRadius="lg"
boxShadow="lg"
p={3}
zIndex={1500}
minW="320px"
maxW="400px"
>
<VStack align="stretch" spacing={3}>
{/* Toolbar Header */}
<HStack justify="space-between">
<HStack spacing={2}>
<Settings size={16} />
<Text fontWeight="bold" fontSize="sm">Úprava obrázku</Text>
</HStack>
<IconButton
aria-label="Close"
icon={<X size={16} />}
size="xs"
onClick={() => setShowImageToolbar(false)}
variant="ghost"
/>
</HStack>
{/* Transform Buttons */}
<HStack spacing={2} flexWrap="wrap">
<Tooltip label="Otočit doleva">
<IconButton
aria-label="Rotate left"
icon={<RotateCcw size={16} />}
size="sm"
onClick={() => updateFilter('rotation', (imageFilters.rotation - 90) % 360)}
colorScheme="blue"
variant="outline"
/>
</Tooltip>
<Tooltip label="Otočit doprava">
<IconButton
aria-label="Rotate right"
icon={<RotateCw size={16} />}
size="sm"
onClick={() => updateFilter('rotation', (imageFilters.rotation + 90) % 360)}
colorScheme="blue"
variant="outline"
/>
</Tooltip>
<Tooltip label="Převrátit horizontálně">
<IconButton
aria-label="Flip horizontal"
icon={<FlipHorizontal size={16} />}
size="sm"
onClick={() => updateFilter('flipH', !imageFilters.flipH)}
colorScheme="blue"
variant={imageFilters.flipH ? 'solid' : 'outline'}
/>
</Tooltip>
<Tooltip label="Převrátit vertikálně">
<IconButton
aria-label="Flip vertical"
icon={<FlipVertical size={16} />}
size="sm"
onClick={() => updateFilter('flipV', !imageFilters.flipV)}
colorScheme="blue"
variant={imageFilters.flipV ? 'solid' : 'outline'}
/>
</Tooltip>
<Tooltip label="Resetovat vše">
<IconButton
aria-label="Reset filters"
icon={<RotateCcw size={16} />}
size="sm"
onClick={resetFilters}
colorScheme="red"
variant="outline"
/>
</Tooltip>
</HStack>
{/* Filter Sliders */}
<VStack align="stretch" spacing={2}>
<FormControl>
<HStack justify="space-between">
<HStack spacing={1}>
<Sun size={14} />
<FormLabel fontSize="xs" mb={0}>Jas</FormLabel>
</HStack>
<Text fontSize="xs" color="gray.500">{imageFilters.brightness}%</Text>
</HStack>
<input
type="range"
min="0"
max="200"
value={imageFilters.brightness}
onChange={(e) => updateFilter('brightness', Number(e.target.value))}
style={{ width: '100%' }}
/>
</FormControl>
<FormControl>
<HStack justify="space-between">
<HStack spacing={1}>
<Contrast size={14} />
<FormLabel fontSize="xs" mb={0}>Kontrast</FormLabel>
</HStack>
<Text fontSize="xs" color="gray.500">{imageFilters.contrast}%</Text>
</HStack>
<input
type="range"
min="0"
max="200"
value={imageFilters.contrast}
onChange={(e) => updateFilter('contrast', Number(e.target.value))}
style={{ width: '100%' }}
/>
</FormControl>
<FormControl>
<HStack justify="space-between">
<HStack spacing={1}>
<Droplets size={14} />
<FormLabel fontSize="xs" mb={0}>Sytost</FormLabel>
</HStack>
<Text fontSize="xs" color="gray.500">{imageFilters.saturation}%</Text>
</HStack>
<input
type="range"
min="0"
max="200"
value={imageFilters.saturation}
onChange={(e) => updateFilter('saturation', Number(e.target.value))}
style={{ width: '100%' }}
/>
</FormControl>
<FormControl>
<HStack justify="space-between">
<HStack spacing={1}>
<Eye size={14} />
<FormLabel fontSize="xs" mb={0}>Rozostření</FormLabel>
</HStack>
<Text fontSize="xs" color="gray.500">{imageFilters.blur}px</Text>
</HStack>
<input
type="range"
min="0"
max="10"
step="0.5"
value={imageFilters.blur}
onChange={(e) => updateFilter('blur', Number(e.target.value))}
style={{ width: '100%' }}
/>
</FormControl>
</VStack>
{/* Quick Filters */}
<HStack spacing={2} flexWrap="wrap">
<Button
size="xs"
onClick={() => {
updateFilter('grayscale', imageFilters.grayscale === 100 ? 0 : 100);
}}
colorScheme={imageFilters.grayscale === 100 ? 'purple' : 'gray'}
variant={imageFilters.grayscale === 100 ? 'solid' : 'outline'}
leftIcon={<Filter size={12} />}
>
Černobílá
</Button>
<Button
size="xs"
onClick={() => {
updateFilter('sepia', imageFilters.sepia === 100 ? 0 : 100);
}}
colorScheme={imageFilters.sepia === 100 ? 'orange' : 'gray'}
variant={imageFilters.sepia === 100 ? 'solid' : 'outline'}
leftIcon={<Sparkles size={12} />}
>
Sepia
</Button>
</HStack>
</VStack>
</Box>
)}
{/* Crop Modal */}
<Modal isOpen={cropOpen} onClose={() => { setCropOpen(false); setCropSrc(null); }} size="6xl">
<ModalOverlay bg="blackAlpha.700" backdropFilter="blur(4px)" />
<ModalContent maxW="90vw" maxH="90vh">
<ModalHeader>Oříznout a upravit obrázek</ModalHeader>
<ModalCloseButton />
<ModalBody maxH="calc(90vh - 140px)" overflowY="auto" overflowX="hidden">
<VStack align="stretch" spacing={4}>
{cropSrc && (
<Box
display="flex"
justifyContent="center"
alignItems="center"
p={4}
bg={useColorModeValue('gray.50', 'gray.900')}
borderRadius="md"
>
<ReactCrop
crop={crop}
onChange={(c: Crop) => setCrop(c)}
minWidth={50}
minHeight={50}
keepSelection
>
<img
ref={imgRef as any}
src={cropSrc}
alt="Crop preview"
style={{
maxWidth: '100%',
maxHeight: '60vh',
display: 'block',
margin: 'auto'
}}
/>
</ReactCrop>
</Box>
)}
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
<FormControl>
<FormLabel fontSize="sm">Max. šířka (px)</FormLabel>
<HStack>
<Input
type="number"
value={cropMaxWidth}
onChange={(e) => setCropMaxWidth(Math.max(100, Math.min(3000, Number(e.target.value))))}
min={100}
max={3000}
step={100}
size="sm"
/>
<Text fontSize="sm" color="gray.600" whiteSpace="nowrap">px</Text>
</HStack>
<FormHelperText fontSize="xs">
Větší obrázky budou zmenšeny (optimalizace výkonu)
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel fontSize="sm">Kvalita JPEG</FormLabel>
<HStack>
<Input
type="number"
value={cropQuality}
onChange={(e) => setCropQuality(Math.max(1, Math.min(100, Number(e.target.value))))}
min={1}
max={100}
step={5}
size="sm"
/>
<Text fontSize="sm" color="gray.600" whiteSpace="nowrap">%</Text>
</HStack>
<FormHelperText fontSize="xs">
85% je doporučená hodnota (menší velikost souboru)
</FormHelperText>
</FormControl>
</SimpleGrid>
<Text fontSize="sm" color="gray.600">
💡 Přetáhněte rohy a hrany modré oblasti pro výběr části obrázku k oříznutí.
</Text>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={() => { setCropOpen(false); setCropSrc(null); }}>
Zrušit
</Button>
<Button colorScheme="blue" onClick={confirmCropAndInsert}>
Oříznout a vložit
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
);
};
export default CustomRichEditor;
@@ -0,0 +1,14 @@
import React from 'react';
import NewsletterSubscribe from '../newsletter/NewsletterSubscribe';
const NewsletterCTA: React.FC = () => {
return (
<section className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24 }}>
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
<NewsletterSubscribe />
</div>
</section>
);
};
export default NewsletterCTA;
@@ -0,0 +1,97 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { useSmoothScroll } from '../../hooks/useSmoothScroll';
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts';
import { FiArrowUp } from 'react-icons/fi';
/**
* PageEnhancer - Adds universal functionality to all pages
* - Back to top button
* - Keyboard shortcuts
* - Scroll to top on route change
* - Skip to content link
*/
interface PageEnhancerProps {
children: React.ReactNode;
}
const PageEnhancer: React.FC<PageEnhancerProps> = ({ children }) => {
const [showBackToTop, setShowBackToTop] = useState(false);
const { scrollToTop, scrollToElement } = useSmoothScroll();
const location = useLocation();
// Scroll to top when route changes
useEffect(() => {
window.scrollTo(0, 0);
}, [location.pathname]);
// Show/hide back to top button
useEffect(() => {
const handleScroll = () => {
setShowBackToTop(window.scrollY > 300);
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// Handle back to top click
const handleBackToTop = useCallback(() => {
scrollToTop();
}, [scrollToTop]);
// Global keyboard shortcuts
useKeyboardShortcuts([
{
key: 'Home',
callback: scrollToTop,
description: 'Scroll to top',
},
{
key: 'End',
callback: () => {
window.scrollTo({
top: document.body.scrollHeight,
behavior: 'smooth',
});
},
description: 'Scroll to bottom',
},
{
key: 'Escape',
callback: () => {
// Close any open modals (implement based on your modal system)
const event = new CustomEvent('closeAllModals');
window.dispatchEvent(event);
},
description: 'Close modals',
},
]);
return (
<>
{/* Skip to main content link for accessibility */}
<a href="#main-content" className="skip-to-content">
Přeskočit na hlavní obsah
</a>
{/* Main content with ID for skip link */}
<div id="main-content">
{children}
</div>
{/* Back to top button */}
<button
className={`back-to-top ${showBackToTop ? 'visible' : ''}`}
onClick={handleBackToTop}
aria-label="Zpět nahoru"
title="Zpět nahoru"
>
<FiArrowUp size={24} />
</button>
</>
);
};
export default PageEnhancer;
@@ -0,0 +1,99 @@
import { Button, HStack, Text } from '@chakra-ui/react';
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
maxVisiblePages?: number;
}
export default function Pagination({
currentPage,
totalPages,
onPageChange,
maxVisiblePages = 5,
}: PaginationProps) {
if (totalPages <= 1) return null;
const getPageNumbers = () => {
const pages = [];
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
const endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
if (endPage - startPage + 1 < maxVisiblePages) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return pages;
};
const pages = getPageNumbers();
return (
<HStack spacing={1}>
<Button
size="sm"
variant="outline"
onClick={() => onPageChange(currentPage - 1)}
isDisabled={currentPage === 1}
aria-label="Předchozí stránka"
>
<ChevronLeftIcon />
</Button>
{!pages.includes(1) && (
<>
<Button
size="sm"
variant={currentPage === 1 ? 'solid' : 'outline'}
onClick={() => onPageChange(1)}
>
1
</Button>
{!pages.includes(2) && <Text>...</Text>}
</>
)}
{pages.map((page) => (
<Button
key={page}
size="sm"
variant={currentPage === page ? 'solid' : 'outline'}
colorScheme={currentPage === page ? 'blue' : 'gray'}
onClick={() => onPageChange(page)}
aria-current={currentPage === page ? 'page' : undefined}
>
{page}
</Button>
))}
{!pages.includes(totalPages) && (
<>
{!pages.includes(totalPages - 1) && <Text>...</Text>}
<Button
size="sm"
variant={currentPage === totalPages ? 'solid' : 'outline'}
onClick={() => onPageChange(totalPages)}
>
{totalPages}
</Button>
</>
)}
<Button
size="sm"
variant="outline"
onClick={() => onPageChange(currentPage + 1)}
isDisabled={currentPage === totalPages}
aria-label="Další stránka"
>
<ChevronRightIcon />
</Button>
</HStack>
);
}
@@ -0,0 +1,49 @@
import React from 'react';
import CustomRichEditor from './CustomRichEditor';
import { uploadFile } from '../../services/articles';
import { assetUrl } from '../../utils/url';
interface RichTextEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
height?: string;
readOnly?: boolean;
onImageUpload?: (file: File) => Promise<{ url: string }>;
showImageResize?: boolean;
toolbar?: 'full' | 'basic' | 'minimal' | string;
}
const RichTextEditor: React.FC<RichTextEditorProps> = ({
value,
onChange,
placeholder = 'Začněte psát...',
height = '400px',
readOnly = false,
onImageUpload = uploadFile,
showImageResize = true,
toolbar = 'full',
}) => {
// Wrapper function to handle URL transformation
const handleImageUpload = async (file: File) => {
const res = await onImageUpload(file);
// Transform URL if needed
const url = assetUrl(res.url) || res.url;
return { url };
};
return (
<CustomRichEditor
value={value}
onChange={onChange}
placeholder={placeholder}
height={height}
readOnly={readOnly}
onImageUpload={handleImageUpload}
showImageResize={showImageResize}
toolbar={toolbar as 'full' | 'basic' | 'minimal'}
/>
);
};
export default RichTextEditor;
@@ -0,0 +1,153 @@
import React, { useEffect, useState } from 'react';
import { assetUrl } from '../../utils/url';
interface Sponsor {
id: number | string;
name: string;
logo: string;
url?: string;
tier?: string;
}
interface SponsorsSectionProps {
layout?: 'grid' | 'slider' | 'scroller' | 'pyramid';
theme?: 'dark' | 'light';
}
const resolveBackendUrl = (path: string) => {
try {
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const b = new URL(base);
const abs = new URL(path, `${b.protocol}//${b.host}`);
return abs.toString();
}
return path;
} catch {
return path;
}
};
const SponsorsSection: React.FC<SponsorsSectionProps> = ({
layout = 'grid',
theme = 'light'
}) => {
const [sponsors, setSponsors] = useState<Sponsor[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
const fetchSponsors = async () => {
try {
// Try API first
const apiRes = await fetch(`${process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1'}/public/sponsors`);
if (apiRes.ok) {
const data = await apiRes.json();
if (!cancelled && Array.isArray(data)) {
const mapped = data.map((s: any) => ({
id: s.id,
name: s.name,
logo: assetUrl(s.logo_url) || '/images/sponsors/placeholder.png',
url: s.website_url || undefined,
tier: s.tier,
}));
setSponsors(mapped);
setLoading(false);
return;
}
}
} catch {}
// Fallback to cache
try {
const cacheRes = await fetch(resolveBackendUrl('/cache/prefetch/settings.json'), { cache: 'no-cache' });
if (cacheRes.ok) {
const settings = await cacheRes.json();
if (!cancelled) {
const sponsorsData = settings?.sponsors || settings?.partners || [];
if (Array.isArray(sponsorsData) && sponsorsData.length) {
setSponsors(
sponsorsData.map((s: any, i: number) => ({
id: s.id ?? i + 1,
name: s.name || 'Sponsor',
logo: s.logo_url || s.logoUrl || s.logo || '/images/sponsors/placeholder.png',
url: s.url || s.website || s.link || '#',
tier: s.tier,
}))
);
}
}
}
} catch {}
if (!cancelled) {
setLoading(false);
}
};
fetchSponsors();
return () => { cancelled = true; };
}, []);
if (loading || sponsors.length === 0) {
return null;
}
const title = sponsors.find((s: any) => s.tier === 'title') || sponsors[0];
const others = sponsors.filter((s) => s !== title);
return (
<section
className={`sponsors ${theme === 'dark' ? 'dark' : ''}`}
style={{
width: '100vw',
position: 'relative',
left: '50%',
right: '50%',
transform: 'translateX(-50%)',
paddingLeft: 'max(16px, calc((100vw - 1200px) / 2))',
paddingRight: 'max(16px, calc((100vw - 1200px) / 2))',
boxSizing: 'border-box',
marginTop: '32px',
marginBottom: '32px',
}}
>
<div className="section-head">
<h3>Sponzoři</h3>
</div>
{layout === 'grid' ? (
<>
{title && (
<div className="title-sponsor">
<a className="sponsor-tile" href={title.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={title.logo} alt={title.name} />
</a>
</div>
)}
<div className="divider" aria-hidden />
<div className="sponsors-grid">
{others.map((s) => (
<a key={s.id} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={s.logo} alt={s.name} />
</a>
))}
</div>
</>
) : (
<div className="sponsors-slider">
<div className="track">
{[...sponsors, ...sponsors].map((s, idx) => (
<a key={`${s.id}-${idx}`} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={s.logo} alt={s.name} />
</a>
))}
</div>
</div>
)}
</section>
);
};
export default SponsorsSection;
+126
View File
@@ -0,0 +1,126 @@
import React, { useEffect, useState } from 'react';
import { Image, ImageProps, Skeleton } from '@chakra-ui/react';
import { getTeamLogo } from '../../utils/sportLogosAPI';
import { getLogoStyle, getLogoClassName } from '../../utils/logoUtils';
import '../../styles/logos.css';
interface TeamLogoProps extends Omit<ImageProps, 'src'> {
teamId?: string;
teamName?: string;
facrLogo?: string;
size?: 'small' | 'medium' | 'large' | 'custom';
fallbackIcon?: React.ReactElement;
}
/**
* TeamLogo component with automatic logoapi.sportcreative.eu integration
* Features:
* - Fetches from logoapi first (with local caching)
* - Falls back to FACR logo if logoapi doesn't have it
* - Properly centers and formats logos
* - Handles SVG optimization
*/
export const TeamLogo: React.FC<TeamLogoProps> = ({
teamId,
teamName,
facrLogo,
size = 'medium',
fallbackIcon,
alt,
...imageProps
}) => {
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let mounted = true;
const fetchLogo = async () => {
try {
setLoading(true);
setError(false);
const url = await getTeamLogo(teamId, teamName, facrLogo);
if (mounted) {
setLogoUrl(url);
}
} catch (e) {
console.error('Failed to fetch logo:', e);
if (mounted) {
setError(true);
// Fallback to FACR or placeholder
setLogoUrl(facrLogo || '/logo192.png');
}
} finally {
if (mounted) {
setLoading(false);
}
}
};
fetchLogo();
return () => {
mounted = false;
};
}, [teamId, teamName, facrLogo]);
// Size mapping
const sizeMap = {
small: { boxSize: '24px' },
medium: { boxSize: '32px' },
large: { boxSize: '48px' },
custom: {},
};
const sizeProps = size !== 'custom' ? sizeMap[size] : {};
// Class name based on size
const className = `match-logo-${size} ${imageProps.className || ''}`.trim();
if (loading) {
return (
<Skeleton
{...sizeProps}
borderRadius="4px"
className="logo-loading"
/>
);
}
// Check if this is a circular container
const isCircular = imageProps.borderRadius === 'full' || imageProps.style?.borderRadius === '50%';
// Get appropriate styling and className using utility functions
// Only pass size to utils if it's not 'custom' (utils only accept standard sizes)
const utilSize = size !== 'custom' ? size : 'medium';
const logoStyle = getLogoStyle(logoUrl, isCircular, utilSize);
const logoClassName = getLogoClassName(logoUrl, isCircular, utilSize);
return (
<Image
src={logoUrl || '/logo192.png'}
alt={alt || teamName || 'Team logo'}
{...sizeProps}
{...imageProps}
className={`${className} ${logoClassName}`}
objectFit="contain"
loading="lazy"
fallback={fallbackIcon}
style={{
...imageProps.style,
...logoStyle
}}
onError={() => {
if (!error) {
setError(true);
setLogoUrl(facrLogo || '/logo192.png');
}
}}
/>
);
};
export default TeamLogo;
@@ -0,0 +1,333 @@
import React from 'react';
import {
VStack,
HStack,
FormControl,
FormLabel,
Input,
Select,
Slider,
SliderTrack,
SliderFilledTrack,
SliderThumb,
Text,
Box,
Divider,
Button,
IconButton,
Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
PopoverArrow,
SimpleGrid,
useColorModeValue,
} from '@chakra-ui/react';
import { FiRefreshCw } from 'react-icons/fi';
interface AdvancedStyleControlsProps {
elementName: string;
settings?: Record<string, any>;
onChange?: (settings: Record<string, any>) => void;
}
const AdvancedStyleControls: React.FC<AdvancedStyleControlsProps> = ({
elementName,
settings = {},
onChange,
}) => {
const updateSetting = (key: string, value: any) => {
if (onChange) {
onChange({ ...settings, [key]: value });
}
};
const presetColors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8',
'#F7DC6F', '#BB8FCE', '#85C1E2', '#F8B739', '#52B788',
];
return (
<VStack align="stretch" spacing={4}>
{/* Spacing Controls */}
<Box>
<Text fontWeight="bold" fontSize="sm" mb={2}>Spacing</Text>
<VStack spacing={3}>
<FormControl>
<HStack justify="space-between">
<FormLabel fontSize="xs" mb={0}>Margin Top</FormLabel>
<Text fontSize="xs" color="gray.500">{settings.marginTop || 0}px</Text>
</HStack>
<Slider
value={settings.marginTop || 0}
min={0}
max={100}
step={4}
onChange={(val) => updateSetting('marginTop', val)}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</FormControl>
<FormControl>
<HStack justify="space-between">
<FormLabel fontSize="xs" mb={0}>Margin Bottom</FormLabel>
<Text fontSize="xs" color="gray.500">{settings.marginBottom || 0}px</Text>
</HStack>
<Slider
value={settings.marginBottom || 0}
min={0}
max={100}
step={4}
onChange={(val) => updateSetting('marginBottom', val)}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</FormControl>
<FormControl>
<HStack justify="space-between">
<FormLabel fontSize="xs" mb={0}>Padding</FormLabel>
<Text fontSize="xs" color="gray.500">{settings.padding || 0}px</Text>
</HStack>
<Slider
value={settings.padding || 0}
min={0}
max={100}
step={4}
onChange={(val) => updateSetting('padding', val)}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</FormControl>
</VStack>
</Box>
<Divider />
{/* Background Controls */}
<Box>
<Text fontWeight="bold" fontSize="sm" mb={2}>Background</Text>
<VStack spacing={3}>
<FormControl>
<FormLabel fontSize="xs">Background Color</FormLabel>
<HStack>
<Input
type="color"
value={settings.backgroundColor || '#ffffff'}
onChange={(e) => updateSetting('backgroundColor', e.target.value)}
size="sm"
w="60px"
h="40px"
p={1}
cursor="pointer"
/>
<Input
value={settings.backgroundColor || '#ffffff'}
onChange={(e) => updateSetting('backgroundColor', e.target.value)}
size="sm"
placeholder="#ffffff"
flex={1}
/>
</HStack>
</FormControl>
{/* Color Presets */}
<SimpleGrid columns={5} spacing={2}>
{presetColors.map((color) => (
<Box
key={color}
w="100%"
h="30px"
bg={color}
borderRadius="md"
cursor="pointer"
border="2px"
borderColor={settings.backgroundColor === color ? 'blue.500' : 'transparent'}
_hover={{ transform: 'scale(1.1)' }}
transition="all 0.2s"
onClick={() => updateSetting('backgroundColor', color)}
/>
))}
</SimpleGrid>
<FormControl>
<HStack justify="space-between">
<FormLabel fontSize="xs" mb={0}>Background Opacity</FormLabel>
<Text fontSize="xs" color="gray.500">{settings.backgroundOpacity || 100}%</Text>
</HStack>
<Slider
value={settings.backgroundOpacity || 100}
min={0}
max={100}
onChange={(val) => updateSetting('backgroundOpacity', val)}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</FormControl>
</VStack>
</Box>
<Divider />
{/* Border Controls */}
<Box>
<Text fontWeight="bold" fontSize="sm" mb={2}>Border</Text>
<VStack spacing={3}>
<FormControl>
<HStack justify="space-between">
<FormLabel fontSize="xs" mb={0}>Border Width</FormLabel>
<Text fontSize="xs" color="gray.500">{settings.borderWidth || 0}px</Text>
</HStack>
<Slider
value={settings.borderWidth || 0}
min={0}
max={10}
onChange={(val) => updateSetting('borderWidth', val)}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</FormControl>
<FormControl>
<FormLabel fontSize="xs">Border Color</FormLabel>
<HStack>
<Input
type="color"
value={settings.borderColor || '#000000'}
onChange={(e) => updateSetting('borderColor', e.target.value)}
size="sm"
w="60px"
h="40px"
p={1}
cursor="pointer"
/>
<Input
value={settings.borderColor || '#000000'}
onChange={(e) => updateSetting('borderColor', e.target.value)}
size="sm"
placeholder="#000000"
flex={1}
/>
</HStack>
</FormControl>
<FormControl>
<HStack justify="space-between">
<FormLabel fontSize="xs" mb={0}>Border Radius</FormLabel>
<Text fontSize="xs" color="gray.500">{settings.borderRadius || 0}px</Text>
</HStack>
<Slider
value={settings.borderRadius || 0}
min={0}
max={50}
onChange={(val) => updateSetting('borderRadius', val)}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</FormControl>
</VStack>
</Box>
<Divider />
{/* Shadow Controls */}
<Box>
<Text fontWeight="bold" fontSize="sm" mb={2}>Shadow</Text>
<VStack spacing={3}>
<FormControl>
<FormLabel fontSize="xs">Shadow Type</FormLabel>
<Select
value={settings.boxShadow || 'none'}
onChange={(e) => updateSetting('boxShadow', e.target.value)}
size="sm"
>
<option value="none">None</option>
<option value="sm">Small</option>
<option value="md">Medium</option>
<option value="lg">Large</option>
<option value="xl">Extra Large</option>
</Select>
</FormControl>
</VStack>
</Box>
<Divider />
{/* Animation Controls */}
<Box>
<Text fontWeight="bold" fontSize="sm" mb={2}>Animation</Text>
<VStack spacing={3}>
<FormControl>
<FormLabel fontSize="xs">Entrance Animation</FormLabel>
<Select
value={settings.animation || 'none'}
onChange={(e) => updateSetting('animation', e.target.value)}
size="sm"
>
<option value="none">None</option>
<option value="fadeIn">Fade In</option>
<option value="slideInUp">Slide In Up</option>
<option value="slideInDown">Slide In Down</option>
<option value="slideInLeft">Slide In Left</option>
<option value="slideInRight">Slide In Right</option>
<option value="zoomIn">Zoom In</option>
<option value="bounceIn">Bounce In</option>
</Select>
</FormControl>
<FormControl>
<HStack justify="space-between">
<FormLabel fontSize="xs" mb={0}>Animation Duration</FormLabel>
<Text fontSize="xs" color="gray.500">{settings.animationDuration || 1000}ms</Text>
</HStack>
<Slider
value={settings.animationDuration || 1000}
min={200}
max={3000}
step={100}
onChange={(val) => updateSetting('animationDuration', val)}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</FormControl>
</VStack>
</Box>
<Divider />
{/* Reset Button */}
<Button
size="sm"
leftIcon={<FiRefreshCw />}
variant="outline"
onClick={() => onChange && onChange({})}
>
Reset to Default
</Button>
</VStack>
);
};
export default AdvancedStyleControls;
@@ -0,0 +1,17 @@
import React, { ReactNode } from 'react';
interface ConditionalElementProps {
visible: boolean;
children: ReactNode;
}
/**
* Wrapper that conditionally renders children based on visibility
* Used with Elementor editor to show/hide elements
*/
const ConditionalElement: React.FC<ConditionalElementProps> = ({ visible, children }) => {
if (!visible) return null;
return <>{children}</>;
};
export default ConditionalElement;
@@ -0,0 +1,32 @@
import React, { ReactNode } from 'react';
import { Box } from '@chakra-ui/react';
interface EditableElementProps {
elementName: string;
children: ReactNode;
className?: string;
style?: React.CSSProperties;
}
/**
* Wrapper component that marks an element as editable in the visual editor
*/
const EditableElement: React.FC<EditableElementProps> = ({
elementName,
children,
className,
style
}) => {
return (
<Box
data-element={elementName}
className={className}
style={style}
position="relative"
>
{children}
</Box>
);
};
export default EditableElement;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,293 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Flex,
IconButton,
VStack,
HStack,
Text,
Select,
Button,
useToast,
Tooltip,
Badge,
Collapse,
useDisclosure,
Drawer,
DrawerBody,
DrawerHeader,
DrawerOverlay,
DrawerContent,
DrawerCloseButton,
FormControl,
FormLabel,
Switch,
Divider,
} from '@chakra-ui/react';
import { FiEdit, FiSave, FiX, FiEye, FiEyeOff, FiSettings } from 'react-icons/fi';
import {
PageElementConfig,
getPageElementConfigs,
batchUpdatePageElementConfigs,
ELEMENT_VARIANTS
} from '../../services/pageElements';
import { useAuth } from '../../contexts/AuthContext';
interface VisualPageEditorProps {
pageType: string; // e.g., 'homepage', 'about'
onConfigChange?: (configs: PageElementConfig[]) => void;
}
const VisualPageEditor: React.FC<VisualPageEditorProps> = ({ pageType, onConfigChange }) => {
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const [isEditing, setIsEditing] = useState(false);
const [configs, setConfigs] = useState<PageElementConfig[]>([]);
const [localChanges, setLocalChanges] = useState<Record<string, string>>({});
const [hasChanges, setHasChanges] = useState(false);
const [isVisible, setIsVisible] = useState(true);
const { isOpen, onOpen, onClose } = useDisclosure();
const toast = useToast();
// Load configurations
useEffect(() => {
if (!isAdmin) return;
const loadConfigs = async () => {
try {
const data = await getPageElementConfigs(pageType);
setConfigs(data);
// Initialize local changes from existing configs
const changes: Record<string, string> = {};
data.forEach(cfg => {
changes[cfg.element_name] = cfg.variant;
});
setLocalChanges(changes);
} catch (error) {
console.error('Failed to load page element configs:', error);
}
};
loadConfigs();
}, [pageType, isAdmin]);
// Notify parent of config changes
useEffect(() => {
if (onConfigChange) {
onConfigChange(configs);
}
}, [configs, onConfigChange]);
const handleVariantChange = (elementName: string, variant: string) => {
setLocalChanges(prev => ({
...prev,
[elementName]: variant,
}));
setHasChanges(true);
};
const handleSave = async () => {
try {
// Build configs array from local changes
const configsToSave: PageElementConfig[] = Object.entries(localChanges).map(([elementName, variant]) => ({
page_type: pageType,
element_name: elementName,
variant,
}));
const result = await batchUpdatePageElementConfigs(configsToSave);
toast({
title: 'Changes saved',
description: `Updated ${result.updated} configs, created ${result.created} new`,
status: 'success',
duration: 3000,
isClosable: true,
});
// Reload configs
const updated = await getPageElementConfigs(pageType);
setConfigs(updated);
setHasChanges(false);
// Reload the page to apply changes
window.location.reload();
} catch (error) {
toast({
title: 'Failed to save changes',
description: 'An error occurred while saving',
status: 'error',
duration: 5000,
isClosable: true,
});
}
};
const handleCancel = () => {
// Reset local changes to match configs
const changes: Record<string, string> = {};
configs.forEach(cfg => {
changes[cfg.element_name] = cfg.variant;
});
setLocalChanges(changes);
setHasChanges(false);
setIsEditing(false);
};
if (!isAdmin) return null;
return (
<>
{/* Floating control button */}
<Box
position="fixed"
right={4}
bottom={20}
zIndex={9999}
display={isVisible ? 'block' : 'none'}
>
<Tooltip label="Visual Page Editor" hasArrow placement="left">
<IconButton
aria-label="Open visual editor"
icon={<FiSettings />}
colorScheme="purple"
size="lg"
borderRadius="full"
boxShadow="lg"
onClick={onOpen}
_hover={{ transform: 'scale(1.1)' }}
transition="all 0.2s"
/>
</Tooltip>
</Box>
{/* Editor Drawer */}
<Drawer isOpen={isOpen} placement="right" onClose={onClose} size="md">
<DrawerOverlay />
<DrawerContent>
<DrawerCloseButton />
<DrawerHeader borderBottomWidth="1px">
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<Text>Visual Page Editor</Text>
<Badge colorScheme="purple">Admin</Badge>
</HStack>
<Text fontSize="sm" fontWeight="normal" color="gray.500">
Configure visual variants for page elements
</Text>
</VStack>
</DrawerHeader>
<DrawerBody>
<VStack align="stretch" spacing={6} py={4}>
{/* Editor Status */}
<Flex justify="space-between" align="center">
<Text fontSize="sm" color="gray.600">
Page: <strong>{pageType}</strong>
</Text>
<HStack>
<Switch
size="sm"
isChecked={isEditing}
onChange={(e) => setIsEditing(e.target.checked)}
/>
<Text fontSize="sm" fontWeight="bold">
{isEditing ? 'Editing' : 'Preview'}
</Text>
</HStack>
</Flex>
<Divider />
{/* Element Controls */}
{Object.entries(ELEMENT_VARIANTS).map(([elementName, variants]) => (
<Box key={elementName}>
<FormControl>
<FormLabel fontSize="sm" fontWeight="bold" textTransform="capitalize">
{elementName}
</FormLabel>
<Select
value={localChanges[elementName] || variants[0].value}
onChange={(e) => handleVariantChange(elementName, e.target.value)}
size="md"
isDisabled={!isEditing}
>
{variants.map((variant) => (
<option key={variant.value} value={variant.value}>
{variant.label} - {variant.description}
</option>
))}
</Select>
{/* Show current variant info */}
{localChanges[elementName] && (
<Text fontSize="xs" color="gray.500" mt={1}>
Current: {variants.find(v => v.value === localChanges[elementName])?.description || localChanges[elementName]}
</Text>
)}
</FormControl>
</Box>
))}
<Divider />
{/* Action Buttons */}
<VStack spacing={3}>
{hasChanges && (
<Badge colorScheme="orange" fontSize="sm" p={2} borderRadius="md" w="full" textAlign="center">
You have unsaved changes
</Badge>
)}
<HStack spacing={2} w="full">
<Button
leftIcon={<FiSave />}
colorScheme="green"
onClick={handleSave}
isDisabled={!hasChanges || !isEditing}
flex={1}
>
Save & Reload
</Button>
<Button
leftIcon={<FiX />}
variant="outline"
onClick={handleCancel}
isDisabled={!hasChanges}
flex={1}
>
Cancel
</Button>
</HStack>
<Button
size="sm"
variant="ghost"
onClick={() => setIsVisible(!isVisible)}
leftIcon={isVisible ? <FiEyeOff /> : <FiEye />}
w="full"
>
{isVisible ? 'Hide' : 'Show'} Editor Button
</Button>
</VStack>
{/* Help Text */}
<Box bg="blue.50" p={3} borderRadius="md" fontSize="sm">
<Text fontWeight="bold" mb={1}>How to use:</Text>
<VStack align="stretch" spacing={1} fontSize="xs">
<Text>1. Toggle editing mode ON</Text>
<Text>2. Select variants for each element</Text>
<Text>3. Click "Save & Reload" to apply</Text>
<Text>4. Page will reload with new styles</Text>
</VStack>
</Box>
</VStack>
</DrawerBody>
</DrawerContent>
</Drawer>
</>
);
};
export default VisualPageEditor;
@@ -0,0 +1,713 @@
import React, { useState } from 'react';
import {
Box,
VStack,
HStack,
Text,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
FormControl,
FormLabel,
Input,
Slider,
SliderTrack,
SliderFilledTrack,
SliderThumb,
Select,
Switch,
NumberInput,
NumberInputField,
NumberInputStepper,
NumberIncrementStepper,
NumberDecrementStepper,
IconButton,
Divider,
Button,
useColorModeValue,
} from '@chakra-ui/react';
import { FiType, FiLayout, FiBox, FiDroplet, FiGrid, FiSmartphone, FiBarChart2, FiSidebar } from 'react-icons/fi';
import { FaRegNewspaper, FaRegSquare, FaColumns } from 'react-icons/fa';
import { useClubTheme } from '../../contexts/ClubThemeContext';
interface VisualStylePanelProps {
elementName: string;
onStyleChange: (styles: Record<string, any>) => void;
currentStyles?: Record<string, any>;
}
const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
elementName,
onStyleChange,
currentStyles = {},
}) => {
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const clubTheme = useClubTheme();
const primaryColor = clubTheme.primary || '#0b5cff';
const [styles, setStyles] = useState({
// Typography
fontFamily: currentStyles.fontFamily || 'Inter',
fontSize: currentStyles.fontSize || 16,
fontWeight: currentStyles.fontWeight || 400,
lineHeight: currentStyles.lineHeight || 1.5,
letterSpacing: currentStyles.letterSpacing || 0,
textTransform: currentStyles.textTransform || 'none',
// Colors
color: currentStyles.color || '#000000',
backgroundColor: currentStyles.backgroundColor || '#ffffff',
// Spacing
paddingTop: currentStyles.paddingTop || 0,
paddingRight: currentStyles.paddingRight || 0,
paddingBottom: currentStyles.paddingBottom || 0,
paddingLeft: currentStyles.paddingLeft || 0,
marginTop: currentStyles.marginTop || 0,
marginRight: currentStyles.marginRight || 0,
marginBottom: currentStyles.marginBottom || 0,
marginLeft: currentStyles.marginLeft || 0,
// Layout
width: currentStyles.width || 'auto',
height: currentStyles.height || 'auto',
display: currentStyles.display || 'block',
// Grid Layout
gridTemplateColumns: currentStyles.gridTemplateColumns || 'repeat(3, 1fr)',
gridTemplateRows: currentStyles.gridTemplateRows || 'auto',
gridColumnGap: currentStyles.gridColumnGap || 16,
gridRowGap: currentStyles.gridRowGap || 16,
gridAutoFlow: currentStyles.gridAutoFlow || 'row',
alignItems: currentStyles.alignItems || 'stretch',
justifyItems: currentStyles.justifyItems || 'stretch',
...currentStyles,
});
const updateStyle = (key: string, value: any) => {
const newStyles = { ...styles, [key]: value };
setStyles(newStyles);
onStyleChange(newStyles);
};
return (
<Box
width="280px"
bg={bgColor}
borderRight="1px"
borderColor={primaryColor}
height="100vh"
overflowY="auto"
pt="60px"
>
<Tabs size="sm" colorScheme="blue">
<TabList px={2}>
<Tab><FiType /> <Text ml={1}>Content</Text></Tab>
<Tab><FiLayout /> <Text ml={1}>Style</Text></Tab>
<Tab><FiGrid /> <Text ml={1}>Grid</Text></Tab>
<Tab><FiBox /> <Text ml={1}>Advanced</Text></Tab>
</TabList>
<TabPanels>
{/* Content Tab */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Typography
</Text>
{/* Font Family */}
<FormControl>
<FormLabel fontSize="xs">Font Family</FormLabel>
<Select
size="sm"
value={styles.fontFamily}
onChange={(e) => updateStyle('fontFamily', e.target.value)}
>
<option value="Inter">Inter</option>
<option value="Roboto">Roboto</option>
<option value="Open Sans">Open Sans</option>
<option value="Lato">Lato</option>
<option value="Montserrat">Montserrat</option>
<option value="Poppins">Poppins</option>
<option value="Georgia">Georgia</option>
<option value="Times New Roman">Times New Roman</option>
</Select>
</FormControl>
{/* Font Size */}
<FormControl>
<FormLabel fontSize="xs">Size (px)</FormLabel>
<HStack>
<NumberInput
size="sm"
value={styles.fontSize}
min={8}
max={128}
onChange={(_, val) => updateStyle('fontSize', val)}
flex={1}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</HStack>
</FormControl>
{/* Font Weight */}
<FormControl>
<FormLabel fontSize="xs">Weight</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.fontWeight}
min={100}
max={900}
step={100}
onChange={(val) => updateStyle('fontWeight', val)}
flex={1}
>
<SliderTrack>
<SliderFilledTrack bg={primaryColor} />
</SliderTrack>
<SliderThumb />
</Slider>
<Text fontSize="xs" minW="40px">{styles.fontWeight}</Text>
</HStack>
</FormControl>
{/* Line Height */}
<FormControl>
<FormLabel fontSize="xs">Line Height</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.lineHeight}
min={0.5}
max={3}
step={0.1}
onChange={(val) => updateStyle('lineHeight', val)}
flex={1}
>
<SliderTrack>
<SliderFilledTrack bg={primaryColor} />
</SliderTrack>
<SliderThumb />
</Slider>
<Text fontSize="xs" minW="40px">{styles.lineHeight.toFixed(1)}</Text>
</HStack>
</FormControl>
{/* Letter Spacing */}
<FormControl>
<FormLabel fontSize="xs">Letter Spacing (px)</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.letterSpacing}
min={-5}
max={10}
step={0.1}
onChange={(val) => updateStyle('letterSpacing', val)}
flex={1}
>
<SliderTrack>
<SliderFilledTrack bg={primaryColor} />
</SliderTrack>
<SliderThumb />
</Slider>
<Text fontSize="xs" minW="40px">{styles.letterSpacing.toFixed(1)}</Text>
</HStack>
</FormControl>
{/* Text Transform */}
<FormControl>
<FormLabel fontSize="xs">Transform</FormLabel>
<Select
size="sm"
value={styles.textTransform}
onChange={(e) => updateStyle('textTransform', e.target.value)}
>
<option value="none">None</option>
<option value="uppercase">UPPERCASE</option>
<option value="lowercase">lowercase</option>
<option value="capitalize">Capitalize</option>
</Select>
</FormControl>
</VStack>
</TabPanel>
{/* Style Tab */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Colors
</Text>
{/* Text Color */}
<FormControl>
<FormLabel fontSize="xs">Text Color</FormLabel>
<HStack>
<Input
type="color"
value={styles.color}
onChange={(e) => updateStyle('color', e.target.value)}
size="sm"
w="60px"
p={1}
/>
<Input
value={styles.color}
onChange={(e) => updateStyle('color', e.target.value)}
size="sm"
placeholder="#000000"
/>
</HStack>
</FormControl>
{/* Background Color */}
<FormControl>
<FormLabel fontSize="xs">Background Color</FormLabel>
<HStack>
<Input
type="color"
value={styles.backgroundColor}
onChange={(e) => updateStyle('backgroundColor', e.target.value)}
size="sm"
w="60px"
p={1}
/>
<Input
value={styles.backgroundColor}
onChange={(e) => updateStyle('backgroundColor', e.target.value)}
size="sm"
placeholder="#ffffff"
/>
</HStack>
</FormControl>
<Divider my={2} />
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Spacing
</Text>
{/* Padding */}
<FormControl>
<FormLabel fontSize="xs">Padding (px)</FormLabel>
<VStack spacing={2}>
<HStack width="100%">
<Text fontSize="xs" minW="20px">T</Text>
<NumberInput
size="xs"
value={styles.paddingTop}
min={0}
onChange={(_, val) => updateStyle('paddingTop', val)}
flex={1}
>
<NumberInputField />
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">R</Text>
<NumberInput
size="xs"
value={styles.paddingRight}
min={0}
onChange={(_, val) => updateStyle('paddingRight', val)}
flex={1}
>
<NumberInputField />
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">B</Text>
<NumberInput
size="xs"
value={styles.paddingBottom}
min={0}
onChange={(_, val) => updateStyle('paddingBottom', val)}
flex={1}
>
<NumberInputField />
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">L</Text>
<NumberInput
size="xs"
value={styles.paddingLeft}
min={0}
onChange={(_, val) => updateStyle('paddingLeft', val)}
flex={1}
>
<NumberInputField />
</NumberInput>
</HStack>
</VStack>
</FormControl>
{/* Margin */}
<FormControl>
<FormLabel fontSize="xs">Margin (px)</FormLabel>
<VStack spacing={2}>
<HStack width="100%">
<Text fontSize="xs" minW="20px">T</Text>
<NumberInput
size="xs"
value={styles.marginTop}
onChange={(_, val) => updateStyle('marginTop', val)}
flex={1}
>
<NumberInputField />
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">R</Text>
<NumberInput
size="xs"
value={styles.marginRight}
onChange={(_, val) => updateStyle('marginRight', val)}
flex={1}
>
<NumberInputField />
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">B</Text>
<NumberInput
size="xs"
value={styles.marginBottom}
onChange={(_, val) => updateStyle('marginBottom', val)}
flex={1}
>
<NumberInputField />
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">L</Text>
<NumberInput
size="xs"
value={styles.marginLeft}
onChange={(_, val) => updateStyle('marginLeft', val)}
flex={1}
>
<NumberInputField />
</NumberInput>
</HStack>
</VStack>
</FormControl>
</VStack>
</TabPanel>
{/* Grid Tab */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Grid Layout
</Text>
{/* Enable Grid */}
<FormControl display="flex" alignItems="center">
<FormLabel fontSize="xs" mb={0} flex={1}>Enable Grid Layout</FormLabel>
<Switch
size="sm"
isChecked={styles.display === 'grid'}
onChange={(e) => updateStyle('display', e.target.checked ? 'grid' : 'block')}
sx={{
'span[data-checked]': {
bg: primaryColor,
borderColor: primaryColor,
}
}}
/>
</FormControl>
{styles.display === 'grid' && (
<>
<Divider />
{/* Quick Templates */}
<FormControl>
<FormLabel fontSize="xs" fontWeight="bold">Quick Templates</FormLabel>
<VStack spacing={2}>
<Button
size="xs"
width="100%"
variant="outline"
onClick={() => updateStyle('gridTemplateColumns', '1fr')}
justifyContent="flex-start"
>
<HStack spacing={2}>
<FiSmartphone />
<Text>Single Column</Text>
</HStack>
</Button>
<Button
size="xs"
width="100%"
variant="outline"
onClick={() => updateStyle('gridTemplateColumns', '1fr 1fr')}
justifyContent="flex-start"
>
<HStack spacing={2}>
<FaColumns />
<Text>Two Equal (50% / 50%)</Text>
</HStack>
</Button>
<Button
size="xs"
width="100%"
variant="outline"
onClick={() => updateStyle('gridTemplateColumns', '2fr 1fr')}
justifyContent="flex-start"
>
<HStack spacing={2}>
<FiBarChart2 />
<Text>Left Larger (66% / 33%)</Text>
</HStack>
</Button>
<Button
size="xs"
width="100%"
variant="outline"
onClick={() => updateStyle('gridTemplateColumns', '1fr 2fr')}
justifyContent="flex-start"
>
<HStack spacing={2}>
<FiBarChart2 style={{ transform: 'scaleX(-1)' }} />
<Text>Right Larger (33% / 66%)</Text>
</HStack>
</Button>
<Button
size="xs"
width="100%"
variant="outline"
onClick={() => updateStyle('gridTemplateColumns', '1fr 1fr 1fr')}
justifyContent="flex-start"
>
<HStack spacing={2}>
<FiGrid />
<Text>Three Equal (33% / 33% / 33%)</Text>
</HStack>
</Button>
<Button
size="xs"
width="100%"
variant="outline"
onClick={() => updateStyle('gridTemplateColumns', '2fr 1fr 1fr')}
justifyContent="flex-start"
>
<HStack spacing={2}>
<FaRegNewspaper />
<Text>Featured + Two (50% / 25% / 25%)</Text>
</HStack>
</Button>
<Button
size="xs"
width="100%"
variant="outline"
onClick={() => updateStyle('gridTemplateColumns', 'repeat(4, 1fr)')}
justifyContent="flex-start"
>
<HStack spacing={2}>
<FaRegSquare />
<Text>Four Equal (25% each)</Text>
</HStack>
</Button>
<Button
size="xs"
width="100%"
variant="outline"
onClick={() => updateStyle('gridTemplateColumns', '3fr 1fr')}
justifyContent="flex-start"
>
<HStack spacing={2}>
<FiSidebar />
<Text>Main + Sidebar (75% / 25%)</Text>
</HStack>
</Button>
</VStack>
</FormControl>
<Divider />
{/* Custom Columns */}
<FormControl>
<FormLabel fontSize="xs">Grid Template Columns</FormLabel>
<Input
size="sm"
value={styles.gridTemplateColumns}
onChange={(e) => updateStyle('gridTemplateColumns', e.target.value)}
placeholder="e.g. 1fr 2fr or 300px 1fr"
fontFamily="monospace"
fontSize="xs"
/>
<Text fontSize="10px" color="gray.500" mt={1}>
Examples: 1fr 1fr | 2fr 1fr | 200px 1fr | repeat(3, 1fr)
</Text>
</FormControl>
{/* Grid Template Rows */}
<FormControl>
<FormLabel fontSize="xs">Grid Template Rows</FormLabel>
<Input
size="sm"
value={styles.gridTemplateRows}
onChange={(e) => updateStyle('gridTemplateRows', e.target.value)}
placeholder="auto or 200px 1fr"
fontFamily="monospace"
fontSize="xs"
/>
</FormControl>
{/* Column Gap */}
<FormControl>
<FormLabel fontSize="xs">Column Gap (px)</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.gridColumnGap}
min={0}
max={100}
step={4}
onChange={(val) => updateStyle('gridColumnGap', val)}
flex={1}
>
<SliderTrack>
<SliderFilledTrack bg="purple.500" />
</SliderTrack>
<SliderThumb />
</Slider>
<Text fontSize="xs" minW="40px">{styles.gridColumnGap}px</Text>
</HStack>
</FormControl>
{/* Row Gap */}
<FormControl>
<FormLabel fontSize="xs">Row Gap (px)</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.gridRowGap}
min={0}
max={100}
step={4}
onChange={(val) => updateStyle('gridRowGap', val)}
flex={1}
>
<SliderTrack>
<SliderFilledTrack bg="purple.500" />
</SliderTrack>
<SliderThumb />
</Slider>
<Text fontSize="xs" minW="40px">{styles.gridRowGap}px</Text>
</HStack>
</FormControl>
<Divider />
{/* Grid Auto Flow */}
<FormControl>
<FormLabel fontSize="xs">Auto Flow</FormLabel>
<Select
size="sm"
value={styles.gridAutoFlow}
onChange={(e) => updateStyle('gridAutoFlow', e.target.value)}
>
<option value="row">Row (horizontal)</option>
<option value="column">Column (vertical)</option>
<option value="row dense">Row Dense</option>
<option value="column dense">Column Dense</option>
</Select>
</FormControl>
{/* Align Items */}
<FormControl>
<FormLabel fontSize="xs">Align Items (vertical)</FormLabel>
<Select
size="sm"
value={styles.alignItems}
onChange={(e) => updateStyle('alignItems', e.target.value)}
>
<option value="stretch">Stretch</option>
<option value="start">Start</option>
<option value="center">Center</option>
<option value="end">End</option>
<option value="baseline">Baseline</option>
</Select>
</FormControl>
{/* Justify Items */}
<FormControl>
<FormLabel fontSize="xs">Justify Items (horizontal)</FormLabel>
<Select
size="sm"
value={styles.justifyItems}
onChange={(e) => updateStyle('justifyItems', e.target.value)}
>
<option value="stretch">Stretch</option>
<option value="start">Start</option>
<option value="center">Center</option>
<option value="end">End</option>
</Select>
</FormControl>
</>
)}
</VStack>
</TabPanel>
{/* Advanced Tab */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Layout
</Text>
{/* Display */}
<FormControl>
<FormLabel fontSize="xs">Display</FormLabel>
<Select
size="sm"
value={styles.display}
onChange={(e) => updateStyle('display', e.target.value)}
>
<option value="block">Block</option>
<option value="inline-block">Inline Block</option>
<option value="flex">Flex</option>
<option value="grid">Grid</option>
<option value="none">None</option>
</Select>
</FormControl>
{/* Width */}
<FormControl>
<FormLabel fontSize="xs">Width</FormLabel>
<Input
size="sm"
value={styles.width}
onChange={(e) => updateStyle('width', e.target.value)}
placeholder="auto, 100%, 500px"
/>
</FormControl>
{/* Height */}
<FormControl>
<FormLabel fontSize="xs">Height</FormLabel>
<Input
size="sm"
value={styles.height}
onChange={(e) => updateStyle('height', e.target.value)}
placeholder="auto, 100%, 500px"
/>
</FormControl>
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</Box>
);
};
export default VisualStylePanel;
@@ -0,0 +1,271 @@
import React, { useState, useRef, useEffect } from 'react';
import { Box, IconButton, Flex, Heading, Link as ChakraLink } from '@chakra-ui/react';
import { FiChevronLeft, FiChevronRight, FiArrowRight } from 'react-icons/fi';
import '../../styles/sparta-styles.css';
interface Article {
id: string;
title: string;
slug: string;
image: string;
categories: string[];
date: string;
duration?: string;
isVideo?: boolean;
unlimited?: boolean;
}
interface SpartaHorizontalSliderProps {
title: string;
titleLink?: string;
articles: Article[];
itemsPerView?: {
mobile: number;
tablet: number;
desktop: number;
};
gap?: number;
showControls?: boolean;
enableDrag?: boolean;
showUnlimitedBadge?: boolean;
showCategories?: boolean;
showDuration?: boolean;
}
const SpartaHorizontalSlider: React.FC<SpartaHorizontalSliderProps> = ({
title,
titleLink = '#',
articles,
itemsPerView = { mobile: 1, tablet: 2, desktop: 3 },
gap = 16,
showControls = true,
enableDrag = true,
showUnlimitedBadge = true,
showCategories = true,
showDuration = true,
}) => {
const [currentIndex, setCurrentIndex] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [startX, setStartX] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0);
const trackRef = useRef<HTMLDivElement>(null);
// Calculate how many items are visible based on viewport
const getItemsPerView = () => {
if (typeof window === 'undefined') return itemsPerView.desktop;
const width = window.innerWidth;
if (width < 768) return itemsPerView.mobile;
if (width < 1024) return itemsPerView.tablet;
return itemsPerView.desktop;
};
const [visibleItems, setVisibleItems] = useState(getItemsPerView());
useEffect(() => {
const handleResize = () => {
setVisibleItems(getItemsPerView());
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const maxIndex = Math.max(0, articles.length - visibleItems);
const canGoNext = currentIndex < maxIndex;
const canGoPrev = currentIndex > 0;
const handleNext = () => {
if (canGoNext) {
setCurrentIndex(prev => Math.min(prev + 1, maxIndex));
}
};
const handlePrev = () => {
if (canGoPrev) {
setCurrentIndex(prev => Math.max(prev - 1, 0));
}
};
// Drag functionality
const handleMouseDown = (e: React.MouseEvent) => {
if (!enableDrag || !trackRef.current) return;
setIsDragging(true);
setStartX(e.pageX - trackRef.current.offsetLeft);
setScrollLeft(trackRef.current.scrollLeft);
};
const handleMouseUp = () => {
setIsDragging(false);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging || !trackRef.current) return;
e.preventDefault();
const x = e.pageX - trackRef.current.offsetLeft;
const walk = (x - startX) * 2;
trackRef.current.scrollLeft = scrollLeft - walk;
};
// Calculate transform based on current index
const slideWidth = trackRef.current?.children[0]?.clientWidth || 0;
const transformValue = -(currentIndex * (slideWidth + gap));
return (
<Box className="sparta-slider-container sparta-container sparta-section">
{/* Header with title and controls */}
<Flex className="sparta-slider-header" justifyContent="space-between" alignItems="center" mb={4}>
<Heading className="sparta-slider-title" as="h2">
<ChakraLink href={titleLink} display="inline-flex" alignItems="center" gap={2}>
{title}
<Box as={FiArrowRight} />
</ChakraLink>
</Heading>
{showControls && (
<Flex className="sparta-slider-controls" gap={2}>
<IconButton
aria-label="Previous"
icon={<FiChevronLeft />}
onClick={handlePrev}
isDisabled={!canGoPrev}
className="sparta-slider-button"
variant="ghost"
/>
<IconButton
aria-label="Next"
icon={<FiChevronRight />}
onClick={handleNext}
isDisabled={!canGoNext}
className="sparta-slider-button"
variant="ghost"
/>
</Flex>
)}
</Flex>
{/* Slider viewport */}
<Box className="sparta-slider-viewport" overflow="hidden">
<Flex
ref={trackRef}
className="sparta-slider-track"
gap={`${gap}px`}
transition="transform 0.3s cubic-bezier(0.4, 0, 0.6, 1)"
transform={`translate3d(${transformValue}px, 0, 0)`}
cursor={enableDrag ? (isDragging ? 'grabbing' : 'grab') : 'default'}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseUp}
>
{articles.map((article) => (
<Box
key={article.id}
className="sparta-slider-slide"
flexShrink={0}
data-dragging={isDragging}
>
<ChakraLink
href={`/articles/${article.slug}`}
className="sparta-article-card"
textDecoration="none"
_hover={{ textDecoration: 'none' }}
>
{/* Article Image */}
<Box className="sparta-article-image" position="relative">
<img
src={article.image}
alt={article.title}
loading="lazy"
draggable={false}
/>
{/* Meta info overlay */}
{(showUnlimitedBadge && article.unlimited) && (
<Box className="sparta-article-meta">
<Box className="sparta-article-badge">
UNLIMITED
</Box>
</Box>
)}
{/* Video duration */}
{showDuration && article.duration && (
<Box
position="absolute"
bottom="8px"
right="8px"
padding="4px 8px"
background="rgba(0, 0, 0, 0.8)"
borderRadius="4px"
fontSize="0.75rem"
fontWeight="500"
>
{article.duration}
</Box>
)}
</Box>
{/* Article Details */}
<Box className="sparta-article-details">
{showCategories && article.categories.length > 0 && (
<Flex className="sparta-article-categories">
{article.categories.map((cat, idx) => (
<React.Fragment key={idx}>
<span>{cat}</span>
{idx < article.categories.length - 1 && (
<Box className="sparta-hero-separator" />
)}
</React.Fragment>
))}
</Flex>
)}
<Heading className="sparta-article-title" as="h3" size="sm">
{article.title}
</Heading>
<Box className="sparta-article-date" mt="auto">
{new Date(article.date).toLocaleDateString('cs-CZ', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</Box>
</Box>
</ChakraLink>
</Box>
))}
</Flex>
</Box>
</Box>
);
};
export default SpartaHorizontalSlider;
// Example usage:
/*
<SpartaHorizontalSlider
title="Videa"
titleLink="/sparta-tv"
articles={[
{
id: '1',
title: 'HIGHLIGHTS: Sparta - Slavia',
slug: 'highlights-sparta-slavia',
image: 'https://example.com/image.jpg',
categories: ['Match content', 'Highlights'],
date: '2025-10-05',
duration: '4:32',
isVideo: true,
unlimited: true,
},
// ... more articles
]}
itemsPerView={{ mobile: 1, tablet: 2, desktop: 3 }}
showUnlimitedBadge={true}
showCategories={true}
showDuration={true}
/>
*/
@@ -0,0 +1,191 @@
import React from 'react';
import { Alert, AlertIcon, Box, Link, Spinner, Text, VStack } from '@chakra-ui/react';
import ContactMap from '../home/ContactMap';
import { getPublicSettings } from '../../services/settings';
interface EventLocationMapProps {
location: string;
title?: string;
latitude?: number | null;
longitude?: number | null;
}
type GeocodeResult = {
lat: number;
lon: number;
displayName: string;
};
const NOMINATIM_BASE_URL = process.env.REACT_APP_NOMINATIM_URL || 'https://nominatim.openstreetmap.org';
const NOMINATIM_EMAIL = process.env.REACT_APP_NOMINATIM_EMAIL;
const geocodeCache = new Map<string, GeocodeResult>();
async function geocodeLocation(query: string, signal: AbortSignal): Promise<GeocodeResult> {
const cacheKey = query.trim().toLowerCase();
if (geocodeCache.has(cacheKey)) {
return geocodeCache.get(cacheKey)!;
}
const params = new URLSearchParams({
format: 'jsonv2',
limit: '1',
q: query,
'accept-language': 'cs',
});
if (NOMINATIM_EMAIL) {
params.append('email', NOMINATIM_EMAIL);
}
const endpoint = `${NOMINATIM_BASE_URL}/search?${params.toString()}`;
const response = await fetch(endpoint, {
headers: { Accept: 'application/json' },
signal,
});
if (!response.ok) {
throw new Error('Nepodařilo se načíst mapová data.');
}
const json = await response.json();
if (!Array.isArray(json) || json.length === 0) {
throw new Error('Poloha nebyla nalezena.');
}
const first = json[0];
const lat = Number(first.lat);
const lon = Number(first.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
throw new Error('Neplatné souřadnice.');
}
const result: GeocodeResult = {
lat,
lon,
displayName: String(first.display_name || query),
};
geocodeCache.set(cacheKey, result);
return result;
}
const EventLocationMap: React.FC<EventLocationMapProps> = ({ location, title, latitude, longitude }) => {
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [coords, setCoords] = React.useState<GeocodeResult | null>(null);
const [settings, setSettings] = React.useState<any>(null);
// Load settings for club colors
React.useEffect(() => {
getPublicSettings()
.then(setSettings)
.catch(() => {});
}, []);
React.useEffect(() => {
const trimmed = (location || '').trim();
if (!trimmed) {
setCoords(null);
setError(null);
return;
}
// If coordinates are provided, use them directly
if (latitude != null && longitude != null && Number.isFinite(latitude) && Number.isFinite(longitude)) {
setCoords({
lat: latitude,
lon: longitude,
displayName: trimmed,
});
setLoading(false);
setError(null);
return;
}
// Otherwise, geocode the location
let active = true;
const controller = new AbortController();
setLoading(true);
setError(null);
geocodeLocation(trimmed, controller.signal)
.then((result) => {
if (!active) return;
setCoords(result);
})
.catch((err: any) => {
if (!active) return;
setCoords(null);
setError(err?.message || 'Mapu se nepodařilo načíst.');
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
controller.abort();
};
}, [location, latitude, longitude]);
if (!location?.trim()) {
return null;
}
const openStreetMapUrl = `https://www.openstreetmap.org/search?query=${encodeURIComponent(location.trim())}`;
return (
<VStack align="stretch" spacing={3} mt={4} data-testid="event-location-map">
<Text fontWeight="semibold" fontSize="lg">Mapa místa</Text>
{loading && (
<HStackWithSpinner />
)}
{!loading && error && (
<Alert status="error" borderRadius="md">
<AlertIcon />
<Box>
<Text mb={1}>{error}</Text>
<Link href={openStreetMapUrl} isExternal color="blue.400">
Otevřít v OpenStreetMap
</Link>
</Box>
</Alert>
)}
{!loading && !error && coords && (
<Box borderWidth="1px" borderRadius="lg" overflow="hidden" borderColor="border.subtle">
<ContactMap
latitude={coords.lat}
longitude={coords.lon}
zoom={15}
address={coords.displayName}
clubName={title}
height={320}
mapStyle={settings?.map_style || 'default'}
clubPrimaryColor={settings?.primary_color}
clubSecondaryColor={settings?.accent_color}
/>
</Box>
)}
<Text fontSize="sm" color="gray.500">
Přesnost určena pomocí otevřených mapových dat.{' '}
<Link href={openStreetMapUrl} isExternal color="blue.400">
Zobrazit v OpenStreetMap
</Link>
</Text>
</VStack>
);
};
const HStackWithSpinner: React.FC = () => (
<Box display="flex" alignItems="center" gap={2} color="gray.500">
<Spinner size="sm" />
<Text>Načítám mapu</Text>
</Box>
);
export default EventLocationMap;
+168
View File
@@ -0,0 +1,168 @@
import { useState, useEffect } from 'react';
import {
Input,
InputGroup,
InputLeftElement,
VStack,
Box,
Text,
Spinner,
Icon,
useToast,
Image,
Flex,
Badge,
} from '@chakra-ui/react';
import { FaSearch, FaFutbol, FaFutbol as FaFutsal } from 'react-icons/fa';
import { useFacrApi } from '../../hooks/useFacrApi';
export const ClubSearch = () => {
const [searchQuery, setSearchQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const { searchClubs, searchResults, searchLoading, searchError } = useFacrApi();
const toast = useToast();
// Debounce search input
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedQuery(searchQuery);
}, 500);
return () => {
clearTimeout(handler);
};
}, [searchQuery]);
// Trigger search when debounced query changes
useEffect(() => {
if (debouncedQuery) {
searchClubs(debouncedQuery).catch(() => {
toast({
title: 'Error',
description: 'Failed to search for clubs',
status: 'error',
duration: 5000,
isClosable: true,
});
});
}
}, [debouncedQuery, searchClubs, toast]);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
};
return (
<Box width="100%" maxW="800px" mx="auto" p={4}>
<InputGroup size="lg" mb={6}>
<InputLeftElement pointerEvents="none">
<Icon as={FaSearch} color="gray.400" />
</InputLeftElement>
<Input
type="text"
placeholder="Search for a club..."
value={searchQuery}
onChange={handleSearchChange}
bg="white"
borderColor="gray.200"
_hover={{ borderColor: 'gray.300' }}
_focus={{
borderColor: 'blue.500',
boxShadow: '0 0 0 1px #3182ce',
}}
/>
</InputGroup>
{searchLoading && (
<Flex justify="center" my={8}>
<Spinner size="xl" color="blue.500" />
</Flex>
)}
{searchError && (
<Text color="red.500" textAlign="center" my={4}>
Error: {searchError.message}
</Text>
)}
{!searchLoading && searchResults.length > 0 && (
<VStack spacing={4} align="stretch">
<Text fontSize="lg" fontWeight="bold" mb={2}>
Search Results:
</Text>
{searchResults.map((club) => (
<Box
key={`${club.club_id}-${club.name}`}
p={4}
borderWidth="1px"
borderRadius="lg"
bg="white"
_hover={{
shadow: 'md',
transform: 'translateY(-2px)',
transition: 'all 0.2s',
}}
>
<Flex align="center">
{club.logo_url ? (
<Image
src={club.logo_url}
alt={`${club.name} logo`}
boxSize="50px"
objectFit="contain"
mr={4}
/>
) : (
<Box
boxSize="50px"
bg="gray.100"
display="flex"
alignItems="center"
justifyContent="center"
borderRadius="md"
mr={4}
>
<Icon
as={club.club_type === 'football' ? FaFutbol : FaFutsal}
color="gray.400"
boxSize={6}
/>
</Box>
)}
<Box flex={1}>
<Text fontWeight="bold" fontSize="lg">
{club.name}
</Text>
<Flex mt={1} alignItems="center">
<Badge
colorScheme={club.club_type === 'football' ? 'blue' : 'green'}
mr={2}
>
{club.club_type === 'football' ? 'Football' : 'Futsal'}
</Badge>
<Text color="gray.600" fontSize="sm">
{club.category}
</Text>
</Flex>
{club.address && (
<Text color="gray.600" fontSize="sm" mt={1}>
{club.address}
</Text>
)}
</Box>
</Flex>
</Box>
))}
</VStack>
)}
{!searchLoading && searchQuery && searchResults.length === 0 && (
<Text textAlign="center" color="gray.500" my={8}>
No clubs found matching "{searchQuery}"
</Text>
)}
</Box>
);
};
export default ClubSearch;
@@ -0,0 +1,184 @@
import React from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalBody,
ModalCloseButton,
Image,
Box,
HStack,
Button,
Text,
useToast,
IconButton,
VStack,
} from '@chakra-ui/react';
import { Download, ExternalLink } from 'lucide-react';
interface PhotoModalProps {
isOpen: boolean;
onClose: () => void;
photoUrl: string;
pageUrl: string;
albumTitle?: string;
}
const PhotoModal: React.FC<PhotoModalProps> = ({
isOpen,
onClose,
photoUrl,
pageUrl,
albumTitle,
}) => {
const toast = useToast();
const getProxyUrl = (url: string) => {
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080';
return `${apiUrl}/api/v1/gallery/proxy-image?url=${encodeURIComponent(url)}`;
};
const handleDownload = async () => {
try {
const response = await fetch(getProxyUrl(photoUrl));
if (!response.ok) {
throw new Error('Failed to fetch image');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `fotka-${Date.now()}.jpg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
toast({
title: 'Stahování zahájeno',
description: 'Fotka se stahuje',
status: 'success',
duration: 2000,
isClosable: true,
});
} catch (error) {
console.error('Failed to download image:', error);
toast({
title: 'Chyba',
description: 'Nepodařilo se stáhnout obrázek',
status: 'error',
duration: 2000,
isClosable: true,
});
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
<ModalOverlay bg="blackAlpha.800" backdropFilter="blur(10px)" />
<ModalContent bg="transparent" boxShadow="none" maxW="90vw">
<ModalCloseButton
color="white"
bg="blackAlpha.600"
_hover={{ bg: 'blackAlpha.800' }}
size="lg"
top={2}
right={2}
zIndex={2}
/>
<ModalBody p={0}>
<VStack spacing={4} align="stretch">
{/* Image */}
<Box
position="relative"
borderRadius="lg"
overflow="hidden"
maxH="80vh"
display="flex"
alignItems="center"
justifyContent="center"
>
<Image
src={photoUrl}
alt={albumTitle || 'Fotka'}
maxH="80vh"
maxW="100%"
objectFit="contain"
loading="lazy"
/>
</Box>
{/* Controls */}
<Box
bg="bg.elevated"
borderWidth="1px"
borderColor="border.subtle"
borderRadius="lg"
p={4}
boxShadow="xl"
>
<VStack spacing={3} align="stretch">
{albumTitle && (
<Text fontSize="md" fontWeight="600" color="gray.700">
{albumTitle}
</Text>
)}
<HStack spacing={2} justify="space-between" flexWrap="wrap">
<HStack spacing={2}>
<Button
leftIcon={<Download size={18} />}
onClick={handleDownload}
colorScheme="green"
size="sm"
>
Stáhnout
</Button>
<Button
as="a"
href={pageUrl}
target="_blank"
rel="noopener noreferrer"
leftIcon={<ExternalLink size={18} />}
colorScheme="purple"
size="sm"
>
Zobrazit originál
</Button>
</HStack>
</HStack>
{/* Zonerama Copyright */}
<Box
pt={2}
borderTopWidth="1px"
borderColor="gray.200"
>
<HStack spacing={2} fontSize="xs" color="gray.500">
<Text>
© Fotografie z{' '}
<Text
as="a"
href="https://zonerama.com"
target="_blank"
rel="noopener noreferrer"
color="blue.500"
fontWeight="600"
_hover={{ textDecoration: 'underline' }}
>
Zonerama
</Text>
</Text>
</HStack>
</Box>
</VStack>
</Box>
</VStack>
</ModalBody>
</ModalContent>
</Modal>
);
};
export default PhotoModal;
@@ -0,0 +1,123 @@
import React from 'react';
import { Box, Image, Heading, Text, VStack, HStack, Badge, Skeleton, useColorModeValue, Button } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getArticles, Article } from '../../services/articles';
import HorizontalScroller from '../ui/HorizontalScroller';
import { Link as RouterLink } from 'react-router-dom';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { assetUrl } from '../../utils/url';
import { Eye, Clock } from 'lucide-react';
const Card: React.FC<{ a: Article }> = ({ a }) => {
const cardBg = useColorModeValue('white', 'gray.800');
const border = useColorModeValue('gray.200', 'whiteAlpha.300');
const theme = useClubTheme();
const link = a.slug ? `/news/${a.slug}` : `/articles/${a.id}`;
const categoryBadgeColor = useColorModeValue('gray.100', 'whiteAlpha.200');
const categoryName = (a as any)?.category?.name || '';
return (
<Box
as={RouterLink}
to={link}
minW={{ base: '85%', md: '60%', lg: '33%' }}
scrollSnapAlign="start"
bg={cardBg}
borderRadius="xl"
overflow="hidden"
boxShadow="lg"
borderWidth="1px"
borderColor={border}
_hover={{ transform: 'translateY(-4px)', boxShadow: '2xl' }}
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
position="relative"
>
<Box position="relative" overflow="hidden">
<Image
src={assetUrl(a.image_url) || '/stadium-placeholder.jpg'}
alt={a.title}
w="100%"
h={{ base: '200px', md: '240px' }}
objectFit="cover"
transition="transform 0.3s ease"
_groupHover={{ transform: 'scale(1.05)' }}
/>
{categoryName && (
<Badge
position="absolute"
top={3}
left={3}
colorScheme="blue"
fontSize="xs"
px={3}
py={1}
borderRadius="full"
textTransform="uppercase"
fontWeight="bold"
>
{categoryName}
</Badge>
)}
</Box>
<VStack align="stretch" spacing={3} p={5}>
<Heading size="md" noOfLines={2} lineHeight="1.3">{a.title}</Heading>
{a.content && (
<Text fontSize="sm" color="gray.600" noOfLines={3} lineHeight="1.5">
{a.content.replace(/<[^>]*>/g, '').trim()}
</Text>
)}
<HStack spacing={3} pt={2} borderTopWidth="1px" borderColor={border} flexWrap="wrap">
{(a.read_time || a.estimated_read_minutes) && (
<HStack spacing={1}>
<Clock size={14} color="gray" />
<Text fontSize="xs" color="gray.500">
{a.read_time || a.estimated_read_minutes} min
</Text>
</HStack>
)}
{a.view_count !== undefined && a.view_count > 0 && (
<HStack spacing={1}>
<Eye size={14} color="gray" />
<Text fontSize="xs" color="gray.500">
{a.view_count}
</Text>
</HStack>
)}
{a.published_at && (
<Text fontSize="xs" color="gray.500">
{new Date(a.published_at).toLocaleDateString('cs-CZ')}
</Text>
)}
</HStack>
</VStack>
</Box>
);
};
const BlogCardsScroller: React.FC = () => {
const theme = useClubTheme();
const { data, isLoading } = useQuery({
queryKey: ['articles', { page: 1, page_size: 12, published: true }],
queryFn: () => getArticles({ page: 1, page_size: 12, published: true }),
});
const list: Article[] = data?.data || [];
return (
<Box>
<HorizontalScroller
title="Novinky"
rightAction={<Button as={RouterLink} to="/blog" variant="link" color="brand.primary">Více</Button>}
>
{isLoading && Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} minW={{ base: '85%', md: '60%', lg: '33%' }} h={{ base: '260px', md: '300px' }} borderRadius="xl" />
))}
{!isLoading && list.map((a) => (
<Card key={a.id} a={a} />
))}
</HorizontalScroller>
</Box>
);
};
export default BlogCardsScroller;
+103
View File
@@ -0,0 +1,103 @@
import { Box, Heading, SimpleGrid, Image, Text, VStack, HStack, Button, Skeleton, Badge, useColorModeValue } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getArticles, Article } from '../../services/articles';
import { Link as RouterLink } from 'react-router-dom';
import { assetUrl } from '../../utils/url';
const BlogCard: React.FC<{ article: Article }> = ({ article }) => {
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
const cardBg = useColorModeValue('white', 'gray.800');
const border = useColorModeValue('gray.200', 'whiteAlpha.300');
const categoryName = (article as any)?.category?.name || '';
return (
<VStack
as={RouterLink}
to={link}
align="stretch"
spacing={0}
borderWidth="1px"
borderRadius="xl"
bg={cardBg}
overflow="hidden"
boxShadow="lg"
borderColor={border}
_hover={{ boxShadow: '2xl', transform: 'translateY(-4px)' }}
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
>
<Box position="relative" overflow="hidden">
<Image
src={assetUrl(article.image_url) || '/logo192.png'}
alt={article.title}
objectFit="cover"
w="100%"
h="200px"
transition="transform 0.3s ease"
_groupHover={{ transform: 'scale(1.05)' }}
/>
{categoryName && (
<Badge
position="absolute"
top={3}
left={3}
colorScheme="blue"
fontSize="xs"
px={3}
py={1}
borderRadius="full"
textTransform="uppercase"
fontWeight="bold"
>
{categoryName}
</Badge>
)}
</Box>
<VStack align="stretch" spacing={3} p={5}>
<Heading size="md" noOfLines={2} lineHeight="1.3">{article.title}</Heading>
<Text noOfLines={3} color="gray.600" fontSize="sm" lineHeight="1.5">
{article.content?.replace(/<[^>]*>/g, '').slice(0, 160)}
</Text>
<HStack spacing={2} pt={2} borderTopWidth="1px" borderColor={border}>
{article.estimated_read_minutes && (
<Text fontSize="xs" color="gray.500">
{article.estimated_read_minutes} min čtení
</Text>
)}
{article.published_at && (
<Text fontSize="xs" color="gray.500">
{new Date(article.published_at).toLocaleDateString('cs-CZ')}
</Text>
)}
</HStack>
</VStack>
</VStack>
);
};
const BlogGrid: React.FC = () => {
const { data, isLoading } = useQuery({
queryKey: ['articles', { page: 1, page_size: 10, published: true }],
queryFn: () => getArticles({ page: 1, page_size: 10, published: true }),
});
const articles = data?.data || [];
return (
<Box>
<HStack justify="space-between" mb={4}>
<Heading size="lg">Aktuality</Heading>
<Button as={RouterLink} to="/blog" size="sm" variant="link">Zobrazit všechny</Button>
</HStack>
<SimpleGrid columns={{ base: 1, sm: 2, md: 3 }} spacing={6}>
{isLoading && Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} height="240px" />
))}
{!isLoading && articles.map((a) => (
<BlogCard key={a.id} article={a} />
))}
</SimpleGrid>
</Box>
);
};
export default BlogGrid;
+302
View File
@@ -0,0 +1,302 @@
import React, { useRef, useState, useCallback } from 'react';
import { Box, Image, Heading, Text, VStack, HStack, Skeleton, Button, IconButton, Flex, useBreakpointValue, Container } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getArticles, getFeaturedArticles, Article } from '../../services/articles';
import { Link as RouterLink } from 'react-router-dom';
import { assetUrl } from '../../utils/url';
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { motion, AnimatePresence } from 'framer-motion';
import { wrap } from 'popmotion';
const MotionBox = motion(Box);
const MotionImage = motion(Image);
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 1000 : -1000,
opacity: 0
}),
center: {
zIndex: 1,
x: 0,
opacity: 1
},
exit: (direction: number) => ({
zIndex: 0,
x: direction < 0 ? 1000 : -1000,
opacity: 0
})
};
const swipeConfidenceThreshold = 10000;
const swipePower = (offset: number, velocity: number) => {
return Math.abs(offset) * velocity;
};
const HeroSlide: React.FC<{ article: Article }> = ({ article }) => {
const theme = useClubTheme();
const excerpt = (article.content || '').replace(/<[^>]*>/g, '').slice(0, 200) + '...';
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
return (
<Box
position="relative"
w="100%"
h={{ base: '500px', md: '600px' }}
overflow="hidden"
borderRadius={{ base: 'none', md: 'xl' }}
boxShadow="lg"
>
<MotionImage
src={assetUrl(article.image_url) || '/stadium-placeholder.jpg'}
alt={article.title}
w="100%"
h="100%"
objectFit="cover"
initial={{ opacity: 0.7 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
/>
<Box
position="absolute"
bottom={0}
left={0}
right={0}
p={{ base: 6, md: 10 }}
bgGradient="linear(to-t, blackAlpha.900, blackAlpha.700, transparent)"
color="white"
>
<Container maxW="7xl" px={{ base: 4, md: 6 }}>
{/* Top-left BLOG link badge */}
<HStack spacing={3} mb={4}>
<Button
as={RouterLink}
to="/blog"
size="sm"
px={3}
height="28px"
borderRadius="full"
bg={theme.primary}
color="white"
_hover={{ bg: theme.accent }}
>
BLOG
</Button>
<Text fontSize={{ base: 'xs', md: 'sm' }} opacity={0.85}></Text>
<Text fontSize={{ base: 'xs', md: 'sm' }} opacity={0.85}>Klubové aktuality</Text>
</HStack>
<Box maxW={{ base: '100%', md: '70%', lg: '55%' }}>
<Text
fontSize={{ base: 'sm', md: 'md' }}
fontWeight="bold"
color={theme.accent}
textTransform="uppercase"
letterSpacing="0.1em"
mb={2}
>
Nejnovější aktualita
</Text>
<Heading
as="h2"
size={{ base: 'xl', md: '2xl', lg: '3xl' }}
mb={4}
lineHeight="1.2"
textShadow="0 2px 4px rgba(0,0,0,0.5)"
>
{article.title}
</Heading>
<Text
fontSize={{ base: 'sm', md: 'md' }}
noOfLines={3}
mb={6}
textShadow="0 1px 2px rgba(0,0,0,0.5)"
>
{excerpt}
</Text>
<HStack spacing={4}>
<Button
as={RouterLink}
to={link}
size="lg"
bg={theme.primary}
color="white"
rightIcon={<ChevronRightIcon />}
_hover={{
bg: theme.accent,
transform: 'translateY(-2px)',
boxShadow: 'lg',
}}
>
Číst více
</Button>
<Button
as={RouterLink}
to="/blog"
size="lg"
variant="outline"
borderColor="whiteAlpha.700"
color="white"
_hover={{ bg: 'whiteAlpha.200' }}
>
Všechny články
</Button>
</HStack>
</Box>
</Container>
</Box>
</Box>
);
};
const BlogSwiper: React.FC = () => {
const [page, setPage] = useState(0);
const [[slideIndex, direction], setSlideIndex] = useState([0, 0]);
const { data: featuredData, isLoading: loadingFeatured } = useQuery({
queryKey: ['featured-articles', { page: 1, page_size: 5 }],
queryFn: () => getFeaturedArticles({ page: 1, page_size: 5 }),
});
// Fallback to latest published if no featured are available
const { data: latestData } = useQuery({
queryKey: ['latest-articles', { page: 1, page_size: 5, published: true }],
queryFn: () => getArticles({ page: 1, page_size: 5, published: true }),
enabled: Boolean(!loadingFeatured && !(featuredData?.data?.length)),
});
const articles = (featuredData?.data?.length ? featuredData.data : (latestData?.data || []));
const articleIndex = wrap(0, articles.length, slideIndex);
const paginate = useCallback(
(newDirection: number) => {
setSlideIndex([slideIndex + newDirection, newDirection]);
},
[slideIndex]
);
// Auto-advance slides
React.useEffect(() => {
if (articles.length <= 1) return;
const timer = setInterval(() => {
paginate(1);
}, 8000);
return () => clearInterval(timer);
}, [articles.length, paginate]);
if (loadingFeatured) {
return (
<Skeleton
w="100%"
h={{ base: '500px', md: '600px' }}
borderRadius={{ base: 'none', md: 'xl' }}
/>
);
}
if (!articles.length) return null;
const currentArticle = articles[articleIndex];
if (!currentArticle) return null;
return (
<Box position="relative" w="100%" overflow="hidden">
<AnimatePresence initial={false} custom={direction}>
<MotionBox
key={slideIndex}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: 'spring', stiffness: 300, damping: 30 },
opacity: { duration: 0.2 }
}}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={1}
onDragEnd={(e, { offset, velocity }) => {
const swipe = swipePower(offset.x, velocity.x);
if (swipe < -swipeConfidenceThreshold) {
paginate(1);
} else if (swipe > swipeConfidenceThreshold) {
paginate(-1);
}
}}
position="relative"
w="100%"
h="100%"
>
<HeroSlide article={currentArticle} />
</MotionBox>
</AnimatePresence>
{articles.length > 1 && (
<>
<IconButton
aria-label="Předchozí slide"
icon={<ChevronLeftIcon />}
position="absolute"
left={4}
top="50%"
transform="translateY(-50%)"
zIndex={2}
borderRadius="full"
colorScheme="blackAlpha"
onClick={() => paginate(-1)}
size="lg"
/>
<IconButton
aria-label="Další slide"
icon={<ChevronRightIcon />}
position="absolute"
right={4}
top="50%"
transform="translateY(-50%)"
zIndex={2}
borderRadius="full"
colorScheme="blackAlpha"
onClick={() => paginate(1)}
size="lg"
/>
<Flex
position="absolute"
bottom={8}
left="50%"
transform="translateX(-50%)"
zIndex={2}
gap={2}
>
{articles.map((_, index) => (
<Box
key={index}
as="button"
px={2}
h="20px"
display="flex"
alignItems="center"
justifyContent="center"
fontSize="xs"
fontWeight="700"
color={index === articleIndex ? 'black' : 'white'}
bg={index === articleIndex ? 'white' : 'whiteAlpha.500'}
borderRadius="sm"
onClick={() => setSlideIndex([index, index > articleIndex ? 1 : -1])}
transition="all 0.3s"
_hover={{
bg: 'white',
color: 'black',
}}
>
{String(index + 1).padStart(2, '0')}
</Box>
))}
</Flex>
</>
)}
</Box>
);
};
export default BlogSwiper;
@@ -0,0 +1,85 @@
import React from 'react';
import { Box, HStack, Image, Skeleton, useBreakpointValue, Tooltip } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getArticles, Article } from '../../services/articles';
import { Link as RouterLink } from 'react-router-dom';
import { assetUrl } from '../../utils/url';
const modulo = (n: number, m: number) => ((n % m) + m) % m;
const BlogThumbStrip: React.FC = () => {
const { data, isLoading } = useQuery({
queryKey: ['thumb-articles', { page: 1, page_size: 12, published: true }],
queryFn: () => getArticles({ page: 1, page_size: 12, published: true }),
});
const articles = data?.data?.filter(a => !!a.image_url) || [];
const visible = useBreakpointValue({ base: 2, md: 3, lg: 5 }) || 5;
const [index, setIndex] = React.useState(0);
const [paused, setPaused] = React.useState(false);
React.useEffect(() => {
if (articles.length <= visible) return;
const id = setInterval(() => {
if (!paused) setIndex((i) => i + 1);
}, 3000);
return () => clearInterval(id);
}, [articles.length, visible, paused]);
if (isLoading) {
return (
<HStack spacing={3}>
{Array.from({ length: visible }).map((_, i) => (
<Skeleton key={i} w={{ base: '50%', md: `${100/visible}%` }} h={{ base: '90px', md: '120px', lg: '140px' }} borderRadius="md" />
))}
</HStack>
);
}
if (!articles.length) return null;
const items = Array.from({ length: visible }).map((_, i) => {
const idx = modulo(index + i, articles.length);
return articles[idx];
});
return (
<Box
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<HStack spacing={3}>
{items.map((a: Article) => (
<Box
key={a.id}
as={RouterLink}
to={a.slug ? `/news/${a.slug}` : `/articles/${a.id}`}
flex={`0 0 ${100/visible}%`}
position="relative"
borderRadius="md"
overflow="hidden"
_hover={{ transform: 'translateY(-2px)', boxShadow: 'lg' }}
transition="all 0.25s ease"
>
<Image
src={assetUrl(a.image_url) || '/stadium-placeholder.jpg'}
alt={a.title}
w="100%"
h={{ base: '90px', md: '120px', lg: '140px' }}
objectFit="cover"
/>
<Box position="absolute" bottom={0} left={0} right={0} h="36px" bgGradient="linear(to-t, blackAlpha.700, transparent)" />
<Tooltip label={a.title} openDelay={300}>
<Box position="absolute" bottom={1} left={2} right={2} color="white" fontSize="xs" noOfLines={1}>
{a.title}
</Box>
</Tooltip>
</Box>
))}
</HStack>
</Box>
);
};
export default BlogThumbStrip;
@@ -0,0 +1,41 @@
import { Box, Flex, Heading, Image, HStack, Button, Text } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { facrApi } from '../../services/facr/facrApi';
import { FACR_CLUB_ID, FACR_CLUB_TYPE } from '../../config/facr';
import { usePublicSettings } from '../../hooks/usePublicSettings';
const ClubHeader: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubId = settings?.club_id || FACR_CLUB_ID;
const clubType = settings?.club_type || FACR_CLUB_TYPE;
const { data, isLoading, isError } = useQuery({
queryKey: ['facr-club', clubId, clubType],
queryFn: () => facrApi.getClub(clubId, clubType),
enabled: Boolean(clubId),
});
return (
<Flex align="center" justify="space-between" bg="white" borderWidth="1px" borderRadius="lg" p={4}>
<HStack spacing={4}>
<Image src={data?.logo_url || '/logo192.png'} alt={data?.name || 'Club'} boxSize="64px" objectFit="contain" />
<Box>
<Heading size="lg">{data?.name || 'Club Name'}</Heading>
<Text color="gray.600" fontSize="sm">
{data?.address || (!clubId ? 'Nastavte klub v Nastavení (Admin) nebo REACT_APP_FACR_CLUB_ID' : '')}
</Text>
</Box>
</HStack>
<HStack spacing={2}>
<Button as="a" href="https://facebook.com" target="_blank" size="sm" variant="ghost">FB</Button>
<Button as="a" href="https://instagram.com" target="_blank" size="sm" variant="ghost">IG</Button>
<Button as="a" href="https://youtube.com" target="_blank" size="sm" variant="ghost">YT</Button>
{data?.url && (
<Button as="a" href={data.url} target="_blank" size="sm" colorScheme="blue">FAČR profil</Button>
)}
</HStack>
</Flex>
);
};
export default ClubHeader;
+211
View File
@@ -0,0 +1,211 @@
import React from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
Button,
Box,
Text,
VStack,
HStack,
Badge,
Flex,
useColorModeValue,
} from '@chakra-ui/react';
import { TeamLogo } from '../common/TeamLogo';
interface ClubModalProps {
isOpen: boolean;
onClose: () => void;
club: {
team: string;
team_id?: string;
team_logo_url?: string;
rank?: string | number;
played?: string | number;
wins?: string | number;
draws?: string | number;
losses?: string | number;
score?: string;
points?: string | number;
// Additional fields from FACR
goals_scored?: string | number;
goals_conceded?: string | number;
goal_difference?: string | number;
form?: string; // Last 5 matches form (e.g., "WWDWL")
position_change?: number; // +/- change in position
} | null;
clubType?: 'football' | 'futsal';
}
const ClubModal: React.FC<ClubModalProps> = ({ isOpen, onClose, club, clubType = 'football' }) => {
if (!club) return null;
// Theme-aware colors
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'whiteAlpha.300');
const fallbackBg = useColorModeValue('gray.100', 'gray.700');
const fallbackText = useColorModeValue('gray.600', 'gray.300');
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg" isCentered>
<ModalOverlay bg="blackAlpha.600" backdropFilter="blur(4px)" />
<ModalContent>
<ModalHeader>
<Flex align="center" gap={3}>
<TeamLogo
teamId={club.team_id}
teamName={club.team}
facrLogo={club.team_logo_url}
size="large"
alt={club.team}
borderRadius="full"
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
fallbackIcon={
<Box
w="48px"
h="48px"
bg={fallbackBg}
borderRadius="full"
display="flex"
alignItems="center"
justifyContent="center"
color={fallbackText}
fontSize="lg"
fontWeight="bold"
borderWidth="1px"
borderColor={borderColor}
>
{club.team.substring(0, 2).toUpperCase()}
</Box>
}
/>
<Box>
<Text fontSize="xl" fontWeight="bold">
{club.team}
</Text>
{club.rank && (
<Badge colorScheme="blue" fontSize="sm">
{club.rank}. místo
</Badge>
)}
</Box>
</Flex>
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4} align="stretch">
{/* Statistics */}
<Box
borderWidth="1px"
borderRadius="lg"
p={4}
bg={useColorModeValue('gray.50', 'gray.700')}
borderColor={borderColor}
>
<Text fontSize="md" fontWeight="semibold" mb={3} color={useColorModeValue('gray.700', 'gray.200')}>
Statistiky
</Text>
<VStack spacing={2} align="stretch">
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Odehráno zápasů:</Text>
<Text fontWeight="bold" color={useColorModeValue('gray.800', 'gray.100')}>{club.played || 0}</Text>
</HStack>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Výhry:</Text>
<Text fontWeight="bold" color="green.600">
{club.wins || 0}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Remízy:</Text>
<Text fontWeight="bold" color={useColorModeValue('gray.600', 'gray.400')}>
{club.draws || 0}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Prohry:</Text>
<Text fontWeight="bold" color="red.600">
{club.losses || 0}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Skóre:</Text>
<Text fontWeight="bold" color={useColorModeValue('gray.800', 'gray.100')}>{club.score || '0:0'}</Text>
</HStack>
{(club.goals_scored !== undefined || club.goals_conceded !== undefined) && (
<>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Vstřelené góly:</Text>
<Text fontWeight="bold" color="green.500">{club.goals_scored || 0}</Text>
</HStack>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Obdržené góly:</Text>
<Text fontWeight="bold" color="red.500">{club.goals_conceded || 0}</Text>
</HStack>
</>
)}
{club.goal_difference !== undefined && (
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Skóre rozdíl:</Text>
<Text fontWeight="bold" color={Number(club.goal_difference) >= 0 ? 'green.600' : 'red.600'}>
{Number(club.goal_difference) > 0 ? '+' : ''}{club.goal_difference}
</Text>
</HStack>
)}
<HStack justify="space-between" pt={2} borderTopWidth="1px" borderColor={borderColor}>
<Text color={useColorModeValue('gray.700', 'gray.200')} fontWeight="semibold">Body:</Text>
<Badge colorScheme="blue" fontSize="lg" px={3} py={1}>
{club.points || 0}
</Badge>
</HStack>
</VStack>
</Box>
{/* Form (Last 5 matches) */}
{club.form && (
<Box
borderWidth="1px"
borderRadius="lg"
p={4}
bg={useColorModeValue('gray.50', 'gray.700')}
borderColor={borderColor}
>
<Text fontSize="md" fontWeight="semibold" mb={3} color={useColorModeValue('gray.700', 'gray.200')}>
Forma (posledních 5 zápasů)
</Text>
<HStack spacing={2} justify="center">
{club.form.split('').map((result, idx) => (
<Badge
key={idx}
colorScheme={result === 'W' ? 'green' : result === 'D' ? 'yellow' : 'red'}
fontSize="md"
px={3}
py={1}
borderRadius="md"
>
{result === 'W' ? 'V' : result === 'D' ? 'R' : 'P'}
</Badge>
))}
</HStack>
</Box>
)}
</VStack>
</ModalBody>
<ModalFooter>
<Button colorScheme="gray" onClick={onClose}>
Zavřít
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default ClubModal;
@@ -0,0 +1,151 @@
import { Box, Tabs, TabList, TabPanels, Tab, TabPanel, VStack, HStack, Image, Text, Skeleton, Badge } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import { facrApi } from '../../services/facr/facrApi';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { getCompetitionAliasesPublic, CompetitionAlias } from '../../services/competitionAliases';
import { TeamLogo } from '../common/TeamLogo';
import { sortCategoriesWithOrder } from '../../utils/categorySort';
import '../../styles/logos.css';
const Row: React.FC<{ d: string; h: string; hid?: string; hl?: string; a: string; aid?: string; al?: string; s?: string; clubName?: string }> = ({ d, h, hid, hl, a, aid, al, s, clubName }) => (
<HStack justify="space-between" borderRadius="lg" p={3} bg="white" boxShadow="sm">
<Text w="140px" fontSize="sm" color="gray.600">{d}</Text>
<HStack flex={1} justify="flex-end" spacing={4}>
<HStack minW="40%" justify="flex-end" spacing={2}>
<Text noOfLines={1} textAlign="right" flex={1}>{h}</Text>
<Box className="logo-container" w="28px" h="28px">
<TeamLogo
teamId={hid}
teamName={h}
facrLogo={hl}
size="custom"
boxSize="28px"
/>
</Box>
</HStack>
<HStack minW="60px" justify="center" spacing={2}>
<Text fontWeight="bold" textAlign="center">{s || '-:-'}</Text>
{(() => {
if (!s || !clubName) return null;
const m = s.match(/^(\d+)\s*[:\-]\s*(\d+)$/);
if (!m) return null;
const hG = parseInt(m[1], 10), aG = parseInt(m[2], 10);
const norm = (x: string) => String(x||'').normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/\s+/g,' ').trim().toLowerCase();
const strip = (x: string) => norm(x).replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g,'').replace(/\s+/g,' ').trim();
const ourHome = (() => { const A = strip(h); const B = strip(clubName); return A && B && (A===B || A.endsWith(B) || B.endsWith(A)); })();
const ourAway = (() => { const A = strip(a); const B = strip(clubName); return A && B && (A===B || A.endsWith(B) || B.endsWith(A)); })();
if (!ourHome && !ourAway) return null;
if (hG === aG) return <Badge colorScheme="blue" variant="subtle">Remíza</Badge>;
const our = ourHome ? hG : aG; const opp = ourHome ? aG : hG;
return our > opp ? <Badge colorScheme="green" variant="subtle">Výhra</Badge> : <Badge colorScheme="red" variant="subtle">Prohra</Badge>;
})()}
</HStack>
<HStack minW="40%" spacing={2}>
<Box className="logo-container" w="28px" h="28px">
<TeamLogo
teamId={aid}
teamName={a}
facrLogo={al}
size="custom"
boxSize="28px"
/>
</Box>
<Text noOfLines={1} flex={1}>{a}</Text>
</HStack>
</HStack>
</HStack>
);
const CompetitionMatches: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubId = settings?.club_id;
const clubType = settings?.club_type || 'football';
const { data, isLoading } = useQuery({
queryKey: ['facr-club', clubId, clubType],
queryFn: () => facrApi.getClub(clubId!, clubType as any),
enabled: !!clubId,
});
// Load competition aliases
const [aliases, setAliases] = React.useState<Record<string, { alias: string; original_name?: string; display_order?: number }>>({});
React.useEffect(() => {
let mounted = true;
(async () => {
try {
const list: CompetitionAlias[] = await getCompetitionAliasesPublic();
if (!mounted) return;
const map: Record<string, { alias: string; original_name?: string; display_order?: number }> = {};
(list || []).forEach((a) => { if (a?.code && a?.alias) map[a.code] = { alias: a.alias, original_name: a.original_name, display_order: a.display_order }; });
setAliases(map);
} catch {}
})();
return () => { mounted = false; };
}, []);
// Precompute sorted competitions safely (must be before any early returns to keep hooks order stable)
const competitions = data?.competitions ?? [];
const sortedCompetitions = React.useMemo(() => {
const arr = Array.isArray(competitions) ? competitions : [];
return sortCategoriesWithOrder(
arr.map(c => ({
...c,
name: aliases[c.code]?.alias || aliases[c.id]?.alias || c.name,
alias: aliases[c.code]?.alias || aliases[c.id]?.alias,
display_order: (aliases[c.code]?.display_order) ?? (aliases[c.id]?.display_order),
}))
);
}, [competitions, aliases]);
if (isLoading) return <Skeleton height="200px" />;
if (!clubId) {
return (
<Box p={4} bg="yellow.50" borderRadius="md" borderWidth="1px" borderColor="yellow.200">
<Text color="gray.700">
Pro zobrazení zápasů je potřeba nastavit klub v administraci (Nastavení Základní údaje).
</Text>
</Box>
);
}
if (!data || !data.competitions || data.competitions.length === 0) {
return (
<Box p={4} bg="gray.50" borderRadius="md" borderWidth="1px" borderColor="gray.200">
<Text color="gray.600">
Žádné soutěže ani zápasy nejsou k dispozici pro vybraný klub.
</Text>
</Box>
);
}
// Sort competitions by age (Muži first, then U19, U17, etc.) and respect custom order (computed above)
return (
<Box>
<Tabs variant="soft-rounded" colorScheme="blue" isFitted>
<TabList>
{sortedCompetitions.map((c) => {
const label = c.alias || c.name;
return <Tab key={c.id}>{label}</Tab>;
})}
</TabList>
<TabPanels>
{sortedCompetitions.map((c) => (
<TabPanel key={c.id} px={0}>
<VStack align="stretch" spacing={3}>
{(c.matches || []).slice(0, 6).map((m, idx) => (
<Row key={m.match_id || idx} d={m.date_time} h={m.home} hid={m.home_id} hl={m.home_logo_url} a={m.away} aid={m.away_id} al={m.away_logo_url} s={m.score} clubName={data.name} />
))}
{(c.matches || []).length === 0 && (
<Text color="gray.500">Žádné zápasy k dispozici.</Text>
)}
</VStack>
</TabPanel>
))}
</TabPanels>
</Tabs>
</Box>
);
};
export default CompetitionMatches;
+328
View File
@@ -0,0 +1,328 @@
import React, { useEffect, useRef } from 'react';
import { Box } from '@chakra-ui/react';
// Dynamically load Leaflet
let L: any = null;
interface ContactMapProps {
latitude: number;
longitude: number;
zoom?: number;
address?: string;
clubName?: string;
mapStyle?: string;
height?: number;
clubPrimaryColor?: string;
clubSecondaryColor?: string;
}
// Available map styles
export const MAP_STYLES = {
// Clean & Minimal
'positron': {
name: 'Positron (Light)',
url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
attribution: '© OpenStreetMap © CartoDB',
description: 'Clean light map, perfect for overlays'
},
'positron-no-labels': {
name: 'Positron No Labels',
url: 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png',
attribution: '© OpenStreetMap © CartoDB',
description: 'Minimal light map without labels'
},
// Dark Themes
'dark': {
name: 'Dark Matter',
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
attribution: '© OpenStreetMap © CartoDB',
description: 'Dark theme, great for night mode'
},
'dark-no-labels': {
name: 'Dark No Labels',
url: 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
attribution: '© OpenStreetMap © CartoDB',
description: 'Dark map without labels'
},
// Black & White
'toner': {
name: 'Toner (B&W)',
url: 'https://tiles.stadiamaps.com/tiles/stamen_toner/{z}/{x}/{y}{r}.png',
attribution: '© Stamen Design © OpenStreetMap',
description: 'High contrast black and white'
},
'toner-lite': {
name: 'Toner Lite (B&W)',
url: 'https://tiles.stadiamaps.com/tiles/stamen_toner_lite/{z}/{x}/{y}{r}.png',
attribution: '© Stamen Design © OpenStreetMap',
description: 'Subtle black and white'
},
// Colorful Options
'voyager': {
name: 'Voyager',
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
attribution: '© OpenStreetMap © CartoDB',
description: 'Balanced colors, good readability'
},
'terrain': {
name: 'Terrain',
url: 'https://tiles.stadiamaps.com/tiles/stamen_terrain/{z}/{x}/{y}{r}.jpg',
attribution: '© Stamen Design © OpenStreetMap',
description: 'Natural terrain visualization'
},
'watercolor': {
name: 'Watercolor',
url: 'https://tiles.stadiamaps.com/tiles/stamen_watercolor/{z}/{x}/{y}.jpg',
attribution: '© Stamen Design © OpenStreetMap',
description: 'Artistic watercolor style'
},
// Default
'default': {
name: 'OpenStreetMap',
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '© OpenStreetMap contributors',
description: 'Standard OpenStreetMap'
},
// Satellite
'satellite': {
name: 'Satellite',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: '© Esri',
description: 'Satellite imagery'
},
};
const ContactMap: React.FC<ContactMapProps> = ({
latitude,
longitude,
zoom = 15,
address,
clubName,
mapStyle = 'default',
height = 400,
clubPrimaryColor,
clubSecondaryColor,
}) => {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<any>(null);
const [isLoaded, setIsLoaded] = React.useState(false);
const [loadError, setLoadError] = React.useState<string | null>(null);
useEffect(() => {
// Load Leaflet CSS and JS dynamically
const loadLeaflet = async () => {
try {
// Check if already loaded
if ((window as any).L) {
L = (window as any).L;
setIsLoaded(true);
return;
}
// Load CSS
if (!document.getElementById('leaflet-css')) {
const link = document.createElement('link');
link.id = 'leaflet-css';
link.rel = 'stylesheet';
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
link.integrity = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
link.crossOrigin = '';
document.head.appendChild(link);
}
// Load JS
if (!document.getElementById('leaflet-js')) {
const script = document.createElement('script');
script.id = 'leaflet-js';
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
script.integrity = 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=';
script.crossOrigin = '';
script.onload = () => {
L = (window as any).L;
setIsLoaded(true);
};
script.onerror = () => {
setLoadError('Failed to load map library');
};
document.head.appendChild(script);
}
} catch (error) {
setLoadError('Error loading map');
}
};
loadLeaflet();
}, []);
useEffect(() => {
if (!isLoaded || !L || !mapRef.current || mapInstanceRef.current) return;
try {
// Initialize map
const map = L.map(mapRef.current, {
center: [latitude, longitude],
zoom: zoom,
scrollWheelZoom: false, // Disable scroll zoom for better UX
});
mapInstanceRef.current = map;
// Get tile layer URL based on style
let tileUrl = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
let attribution = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
// Use predefined styles or custom URL
if (mapStyle && MAP_STYLES[mapStyle as keyof typeof MAP_STYLES]) {
const style = MAP_STYLES[mapStyle as keyof typeof MAP_STYLES];
tileUrl = style.url;
attribution = style.attribution;
} else if (mapStyle && mapStyle.startsWith('http')) {
// Custom tile URL
tileUrl = mapStyle;
}
// Add tile layer
const tileLayer = L.tileLayer(tileUrl, {
attribution: attribution,
maxZoom: 19,
}).addTo(map);
// Apply club color overlay if provided
if (clubPrimaryColor && clubPrimaryColor !== '') {
const colorFilter = createColorFilter(clubPrimaryColor);
if (colorFilter) {
const pane = map.createPane('colorOverlay');
pane.style.zIndex = '400';
pane.style.pointerEvents = 'none';
pane.style.mixBlendMode = 'multiply';
pane.style.backgroundColor = colorFilter;
pane.style.opacity = '0.15';
}
}
// Create custom marker icon with club colors
const markerColor = clubPrimaryColor || '#3388ff';
const customIcon = createCustomMarkerIcon(markerColor, L);
// Add marker
const marker = L.marker([latitude, longitude], { icon: customIcon }).addTo(map);
// Add popup if address is provided
if (clubName || address) {
let popupContent = '';
if (clubName) popupContent += `<b>${clubName}</b><br>`;
if (address) popupContent += address;
marker.bindPopup(popupContent);
}
// Enable scroll zoom on click
map.on('click', () => {
map.scrollWheelZoom.enable();
});
// Disable scroll zoom on mouseout
map.on('mouseout', () => {
map.scrollWheelZoom.disable();
});
} catch (error) {
console.error('Error initializing map:', error);
setLoadError('Failed to initialize map');
}
// Cleanup
return () => {
if (mapInstanceRef.current) {
mapInstanceRef.current.remove();
mapInstanceRef.current = null;
}
};
}, [isLoaded, latitude, longitude, zoom, address, clubName, mapStyle, clubPrimaryColor, clubSecondaryColor]);
// Helper function to create color filter
function createColorFilter(color: string): string | null {
try {
// Validate and normalize color
const tempDiv = document.createElement('div');
tempDiv.style.color = color;
document.body.appendChild(tempDiv);
const computedColor = window.getComputedStyle(tempDiv).color;
document.body.removeChild(tempDiv);
return computedColor;
} catch {
return null;
}
}
// Helper function to create custom marker with club colors
function createCustomMarkerIcon(color: string, leaflet: any) {
// Create SVG marker with custom color
const svgIcon = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 36" width="36" height="54">
<defs>
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="2" result="offsetblur"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.3"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<path fill="${color}" stroke="#fff" stroke-width="1.5" filter="url(#shadow)"
d="M12 0C7.03 0 3 4.03 3 9c0 7.5 9 18 9 18s9-10.5 9-18c0-4.97-4.03-9-9-9z"/>
<circle cx="12" cy="9" r="3" fill="#fff"/>
</svg>
`;
const iconUrl = 'data:image/svg+xml;base64,' + btoa(svgIcon);
return leaflet.icon({
iconUrl: iconUrl,
iconSize: [36, 54],
iconAnchor: [18, 54],
popupAnchor: [0, -54],
});
}
if (loadError) {
return (
<Box
ref={mapRef}
w="100%"
h={`${height}px`}
bg="gray.100"
display="flex"
alignItems="center"
justifyContent="center"
borderRadius="md"
>
{loadError}
</Box>
);
}
return (
<Box
ref={mapRef}
w="100%"
h={`${height}px`}
borderRadius="md"
overflow="hidden"
boxShadow="md"
/>
);
};
export default ContactMap;
@@ -0,0 +1,334 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Container,
Heading,
Text,
SimpleGrid,
VStack,
HStack,
Avatar,
Badge,
Link,
Icon,
useColorModeValue,
Divider,
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon,
} from '@chakra-ui/react';
import { FiMail, FiPhone, FiMapPin } from 'react-icons/fi';
import { getPublicContacts, GroupedContacts } from '../../services/contactInfo';
import { getPublicSettings } from '../../services/settings';
import ContactMap from './ContactMap';
import { getImageUrl } from '../../utils/imageUtils';
const ContactsSection: React.FC = () => {
const [contactsData, setContactsData] = useState<GroupedContacts | null>(null);
const [settings, setSettings] = useState<any>(null);
const [loading, setLoading] = useState(true);
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [contacts, settingsData] = await Promise.all([
getPublicContacts(),
getPublicSettings(),
]);
setContactsData(contacts);
setSettings(settingsData);
} catch (error) {
console.error('Failed to load contacts:', error);
} finally {
setLoading(false);
}
};
if (loading || !contactsData) {
return null;
}
// Check if there's any data to display
const hasContacts = Object.keys(contactsData.categories).length > 0 || contactsData.uncategorized.length > 0;
const hasLocation = settings?.location_latitude && settings?.location_longitude;
const hasContactInfo = settings?.contact_address || settings?.contact_phone || settings?.contact_email;
if (!hasContacts && !hasLocation && !hasContactInfo) {
return null; // Don't render if no data
}
return (
<Box py={16} bg={useColorModeValue('gray.50', 'gray.900')}>
<Container maxW="container.xl">
<VStack spacing={8} align="stretch">
<Box textAlign="center">
<Heading size="xl" mb={4}>Kontakt</Heading>
<Text fontSize="lg" color="gray.600">
Spojte se s námi
</Text>
</Box>
{/* Map and Address Section */}
{(hasLocation || hasContactInfo) && (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={8}>
{/* Map - always show if coordinates are set */}
{hasLocation && (
<Box>
<ContactMap
latitude={settings.location_latitude}
longitude={settings.location_longitude}
zoom={settings.map_zoom_level || 15}
address={settings.contact_address}
clubName={settings.club_name || settings.site_title}
mapStyle={settings.map_style || 'default'}
clubPrimaryColor={settings.primary_color}
clubSecondaryColor={settings.accent_color}
/>
</Box>
)}
{/* Contact Information */}
{hasContactInfo && (
<Box
bg={bgColor}
p={6}
borderRadius="lg"
boxShadow="md"
border="1px"
borderColor={borderColor}
>
<VStack align="stretch" spacing={4}>
<Heading size="md">Naše adresa</Heading>
{settings.contact_address && (
<HStack align="start">
<Icon as={FiMapPin} boxSize={5} color="blue.500" mt={1} />
<VStack align="start" spacing={0}>
<Text fontWeight="bold">Adresa</Text>
<Text>{settings.contact_address}</Text>
{settings.contact_city && (
<Text>
{settings.contact_zip && `${settings.contact_zip} `}
{settings.contact_city}
</Text>
)}
{settings.contact_country && <Text>{settings.contact_country}</Text>}
</VStack>
</HStack>
)}
{settings.contact_phone && (
<HStack align="start">
<Icon as={FiPhone} boxSize={5} color="blue.500" mt={1} />
<VStack align="start" spacing={0}>
<Text fontWeight="bold">Telefon</Text>
<Link href={`tel:${settings.contact_phone}`} color="blue.500">
{settings.contact_phone}
</Link>
</VStack>
</HStack>
)}
{settings.contact_email && (
<HStack align="start">
<Icon as={FiMail} boxSize={5} color="blue.500" mt={1} />
<VStack align="start" spacing={0}>
<Text fontWeight="bold">Email</Text>
<Link href={`mailto:${settings.contact_email}`} color="blue.500">
{settings.contact_email}
</Link>
</VStack>
</HStack>
)}
</VStack>
</Box>
)}
</SimpleGrid>
)}
{/* Contacts by Category */}
{hasContacts && (
<Box>
<Divider my={8} />
<Accordion allowMultiple defaultIndex={[0]}>
{Object.entries(contactsData.categories).map(([categoryName, contacts]) => (
<AccordionItem key={categoryName} border="none" mb={4}>
<AccordionButton
bg={bgColor}
borderRadius="lg"
p={4}
_hover={{ bg: useColorModeValue('gray.100', 'gray.700') }}
boxShadow="sm"
>
<Box flex="1" textAlign="left">
<Heading size="md">{categoryName}</Heading>
<Text fontSize="sm" color="gray.600">
{contacts.length} {contacts.length === 1 ? 'kontakt' : 'kontaktů'}
</Text>
</Box>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} pt={4}>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
{contacts.map((contact) => (
<Box
key={contact.id}
bg={bgColor}
p={6}
borderRadius="lg"
boxShadow="md"
border="1px"
borderColor={borderColor}
transition="transform 0.2s"
_hover={{ transform: 'translateY(-4px)', boxShadow: 'lg' }}
>
<VStack spacing={4} align="start">
{contact.image_url && (
<Avatar
src={getImageUrl(contact.image_url)}
name={contact.name}
size="xl"
alignSelf="center"
/>
)}
<Box textAlign={contact.image_url ? 'center' : 'left'} w="100%">
<Heading size="sm" mb={1}>
{contact.name}
</Heading>
{contact.position && (
<Badge colorScheme="blue" mb={2}>
{contact.position}
</Badge>
)}
</Box>
{contact.description && (
<Text fontSize="sm" color="gray.600">
{contact.description}
</Text>
)}
<VStack align="start" spacing={2} w="100%">
{contact.email && (
<HStack spacing={2}>
<Icon as={FiMail} color="blue.500" />
<Link href={`mailto:${contact.email}`} fontSize="sm" color="blue.500">
{contact.email}
</Link>
</HStack>
)}
{contact.phone && (
<HStack spacing={2}>
<Icon as={FiPhone} color="blue.500" />
<Link href={`tel:${contact.phone}`} fontSize="sm" color="blue.500">
{contact.phone}
</Link>
</HStack>
)}
</VStack>
</VStack>
</Box>
))}
</SimpleGrid>
</AccordionPanel>
</AccordionItem>
))}
{/* Uncategorized contacts */}
{contactsData.uncategorized.length > 0 && (
<AccordionItem border="none" mb={4}>
<AccordionButton
bg={bgColor}
borderRadius="lg"
p={4}
_hover={{ bg: useColorModeValue('gray.100', 'gray.700') }}
boxShadow="sm"
>
<Box flex="1" textAlign="left">
<Heading size="md">Ostatní kontakty</Heading>
<Text fontSize="sm" color="gray.600">
{contactsData.uncategorized.length} {contactsData.uncategorized.length === 1 ? 'kontakt' : 'kontaktů'}
</Text>
</Box>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} pt={4}>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
{contactsData.uncategorized.map((contact) => (
<Box
key={contact.id}
bg={bgColor}
p={6}
borderRadius="lg"
boxShadow="md"
border="1px"
borderColor={borderColor}
transition="transform 0.2s"
_hover={{ transform: 'translateY(-4px)', boxShadow: 'lg' }}
>
<VStack spacing={4} align="start">
{contact.image_url && (
<Avatar
src={getImageUrl(contact.image_url)}
name={contact.name}
size="xl"
alignSelf="center"
/>
)}
<Box textAlign={contact.image_url ? 'center' : 'left'} w="100%">
<Heading size="sm" mb={1}>
{contact.name}
</Heading>
{contact.position && (
<Badge colorScheme="blue" mb={2}>
{contact.position}
</Badge>
)}
</Box>
{contact.description && (
<Text fontSize="sm" color="gray.600">
{contact.description}
</Text>
)}
<VStack align="start" spacing={2} w="100%">
{contact.email && (
<HStack spacing={2}>
<Icon as={FiMail} color="blue.500" />
<Link href={`mailto:${contact.email}`} fontSize="sm" color="blue.500">
{contact.email}
</Link>
</HStack>
)}
{contact.phone && (
<HStack spacing={2}>
<Icon as={FiPhone} color="blue.500" />
<Link href={`tel:${contact.phone}`} fontSize="sm" color="blue.500">
{contact.phone}
</Link>
</HStack>
)}
</VStack>
</VStack>
</Box>
))}
</SimpleGrid>
</AccordionPanel>
</AccordionItem>
)}
</Accordion>
</Box>
)}
</VStack>
</Container>
</Box>
);
};
export default ContactsSection;
@@ -0,0 +1,92 @@
import { Box, Grid, GridItem, Heading, Image, Text, VStack, HStack, Button, Skeleton, Badge } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getArticles, Article } from '../../services/articles';
import { Link as RouterLink } from 'react-router-dom';
import { assetUrl } from '../../utils/url';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { Eye, Clock } from 'lucide-react';
const FeaturedBlog: React.FC = () => {
const { data, isLoading } = useQuery({
queryKey: ['articles', { page: 1, page_size: 3, published: true }],
queryFn: () => getArticles({ page: 1, page_size: 3, published: true }),
});
const theme = useClubTheme();
const articles = data?.data || [];
if (isLoading) return <Skeleton height="320px" />;
const [main, side1, side2] = [articles[0], articles[1], articles[2]];
return (
<Box>
<HStack justify="space-between" mb={3}>
<Heading size="lg">Aktuality</Heading>
<Button as={RouterLink} to="/blog" size="sm" variant="link">Všechny články</Button>
</HStack>
<Grid templateColumns={{ base: '1fr', md: '2fr 1fr' }} gap={4}>
<GridItem>
{main && (
<Box as={RouterLink} to={main.slug ? `/news/${main.slug}` : `/articles/${main.id}`} position="relative" overflow="hidden" borderRadius="xl">
<Image src={assetUrl(main.image_url) || '/logo192.png'} alt={main.title} w="100%" h={{ base: '220px', md: '320px' }} objectFit="cover" />
<Box position="absolute" inset={0} bgGradient="linear(to-t, rgba(0,0,0,0.7), rgba(0,0,0,0.1))" />
{/* Stats badges */}
{((main.read_time || main.estimated_read_minutes) || (main.view_count && main.view_count > 0)) && (
<HStack position="absolute" top={3} right={3} spacing={2}>
{(main.read_time || main.estimated_read_minutes) && (
<Badge display="flex" alignItems="center" gap={1} bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1}>
<Clock size={12} />
{main.read_time || main.estimated_read_minutes} min
</Badge>
)}
{main.view_count && main.view_count > 0 && (
<Badge display="flex" alignItems="center" gap={1} bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1}>
<Eye size={12} />
{main.view_count}
</Badge>
)}
</HStack>
)}
<VStack align="stretch" spacing={2} position="absolute" bottom={0} p={4} color="white">
<Text fontSize="xs" bg={theme.secondary} color="black" px={2} py={0.5} borderRadius="md" w="fit-content">Novinka</Text>
<Heading size="md">{main.title}</Heading>
</VStack>
</Box>
)}
</GridItem>
<GridItem>
<VStack spacing={4} align="stretch">
{[side1, side2].filter(Boolean).map((a) => (
<HStack key={(a as Article).id} align="stretch" spacing={3} as={RouterLink} to={(a as Article).slug ? `/news/${(a as Article).slug}` : `/articles/${(a as Article).id}`}>
<Image src={assetUrl((a as Article).image_url) || '/logo192.png'} alt={(a as Article).title} w="40%" h="120px" objectFit="cover" borderRadius="lg" />
<VStack align="stretch" spacing={2} flex={1}>
<HStack spacing={2} flexWrap="wrap">
{((a as Article).read_time || (a as Article).estimated_read_minutes) && (
<HStack spacing={1}>
<Clock size={12} color="gray" />
<Text fontSize="xs" color="gray.500">
{(a as Article).read_time || (a as Article).estimated_read_minutes} min
</Text>
</HStack>
)}
{(a as Article).view_count && (a as Article).view_count! > 0 && (
<HStack spacing={1}>
<Eye size={12} color="gray" />
<Text fontSize="xs" color="gray.500">
{(a as Article).view_count}
</Text>
</HStack>
)}
</HStack>
<Heading size="sm" noOfLines={3}>{(a as Article).title}</Heading>
</VStack>
</HStack>
))}
</VStack>
</GridItem>
</Grid>
</Box>
);
};
export default FeaturedBlog;
@@ -0,0 +1,287 @@
import React, { useEffect, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import {
Box,
Heading,
SimpleGrid,
Image,
Text,
VStack,
HStack,
Button,
Skeleton,
Badge,
useColorModeValue,
} from '@chakra-ui/react';
import { Calendar, Image as ImageIcon, ExternalLink, ArrowRight } from 'lucide-react';
interface Album {
id: string;
title: string;
url: string;
date: string;
photos_count: number;
views_count?: number;
photos: Array<{
id: string;
page_url: string;
image_1500: string;
}>;
}
const resolveBackendUrl = (path: string) => {
try {
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const b = new URL(base);
const abs = new URL(path, `${b.protocol}//${b.host}`);
return abs.toString();
}
return path;
} catch {
return path;
}
};
const GallerySection: React.FC = () => {
const [albums, setAlbums] = useState<Album[]>([]);
const [loading, setLoading] = useState(true);
// Dark mode colors
const cardBg = useColorModeValue('white', 'gray.800');
const headingColor = useColorModeValue('gray.800', 'gray.100');
const textColor = useColorModeValue('gray.600', 'gray.300');
const infoBg = useColorModeValue('blue.50', 'blue.900');
const infoBorder = useColorModeValue('blue.200', 'blue.700');
const infoText = useColorModeValue('blue.700', 'blue.200');
useEffect(() => {
const fetchAlbums = async () => {
setLoading(true);
try {
// Load from both sources and combine
const [profileRes, albumsRes] = await Promise.allSettled([
fetch(resolveBackendUrl('/cache/prefetch/zonerama_profile.json'), { cache: 'no-cache' }),
fetch(resolveBackendUrl('/cache/prefetch/zonerama_albums.json'), { cache: 'no-cache' })
]);
let combinedAlbums: Album[] = [];
// Get profile albums (newest/main source)
if (profileRes.status === 'fulfilled' && profileRes.value.ok) {
const profileData = await profileRes.value.json();
combinedAlbums = [...(profileData.albums || [])];
}
// Get blog-related albums (additional source)
if (albumsRes.status === 'fulfilled' && albumsRes.value.ok) {
const albumsData = await albumsRes.value.json();
const blogAlbums = Array.isArray(albumsData) ? albumsData : [];
// Filter out albums with empty/invalid data and avoid duplicates
const validBlogAlbums = blogAlbums.filter((album: any) =>
album.id &&
album.title &&
!combinedAlbums.some(existing => existing.id === album.id)
);
combinedAlbums = [...combinedAlbums, ...validBlogAlbums];
}
// Sort by date (newest first)
combinedAlbums.sort((a, b) => {
const parseDate = (dateStr: string) => {
if (!dateStr) return new Date(0);
const parts = dateStr.split(/[.\s]+/).filter(Boolean);
if (parts.length === 3) {
const [day, month, year] = parts;
return new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`);
}
return new Date(dateStr);
};
return parseDate(b.date).getTime() - parseDate(a.date).getTime();
});
// Get the 3 most recent albums
const recentAlbums = combinedAlbums.slice(0, 3);
setAlbums(recentAlbums);
} catch (err) {
console.error('Error loading albums:', err);
} finally {
setLoading(false);
}
};
fetchAlbums();
}, []);
if (loading) {
return (
<Box py={12}>
<VStack spacing={6} align="stretch">
<Heading size="xl">Galerie</Heading>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
{[1, 2, 3].map((i) => (
<Skeleton key={i} height="300px" borderRadius="lg" />
))}
</SimpleGrid>
</VStack>
</Box>
);
}
if (albums.length === 0) {
return null;
}
return (
<Box py={12}>
<VStack spacing={6} align="stretch">
{/* Header */}
<HStack justify="space-between" align="center" flexWrap="wrap">
<VStack align="start" spacing={1}>
<Heading size="xl" color={headingColor}>
Fotogalerie
</Heading>
<Text color={textColor} fontSize="sm">
Nejnovější alba z našich akcí
</Text>
</VStack>
<Button
as={RouterLink}
to="/galerie"
rightIcon={<ArrowRight size={18} />}
colorScheme="blue"
variant="outline"
size="md"
>
Zobrazit vše
</Button>
</HStack>
{/* Zonerama Attribution */}
<Box
bg={infoBg}
borderWidth="1px"
borderColor={infoBorder}
borderRadius="md"
px={4}
py={2}
>
<Text fontSize="xs" color={infoText}>
📸 Všechny fotografie jsou z platformy{' '}
<Text
as="a"
href="https://zonerama.com"
target="_blank"
rel="noopener noreferrer"
fontWeight="600"
color="blue.600"
_hover={{ textDecoration: 'underline' }}
>
Zonerama
</Text>
</Text>
</Box>
{/* Albums Grid */}
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
{albums.map((album) => {
const coverPhoto = album.photos && album.photos.length > 0
? album.photos[0]
: null;
return (
<Box
key={album.id}
as={RouterLink}
to={`/galerie/album/${album.id}`}
bg={cardBg}
borderRadius="lg"
overflow="hidden"
boxShadow="md"
transition="all 0.3s"
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'gray.700')}
_hover={{
transform: 'translateY(-8px)',
boxShadow: '2xl',
borderColor: useColorModeValue('gray.300', 'gray.600'),
}}
cursor="pointer"
>
{/* Cover Image */}
{coverPhoto ? (
<Image
src={coverPhoto.image_1500}
alt={album.title}
w="100%"
h="200px"
objectFit="cover"
loading="lazy"
/>
) : (
<Box
w="100%"
h="200px"
bg="gray.200"
display="flex"
alignItems="center"
justifyContent="center"
>
<ImageIcon size={48} color="gray" />
</Box>
)}
{/* Album Info */}
<VStack align="stretch" p={4} spacing={2}>
<Heading size="sm" color={headingColor} noOfLines={2} minH="40px">
{album.title}
</Heading>
<VStack spacing={2} fontSize="xs" color={textColor} align="stretch">
{album.date && (
<HStack spacing={1}>
<Calendar size={14} />
<Text>{album.date}</Text>
</HStack>
)}
<HStack spacing={1}>
<ImageIcon size={14} />
<Text>{album.photos_count} foto</Text>
</HStack>
</VStack>
{album.views_count !== undefined && album.views_count > 0 && (
<Badge colorScheme="purple" fontSize="2xs" alignSelf="flex-start">
{album.views_count} zhlédnutí
</Badge>
)}
</VStack>
</Box>
);
})}
</SimpleGrid>
{/* Bottom CTA */}
<Box textAlign="center" pt={4}>
<Button
as={RouterLink}
to="/galerie"
rightIcon={<ArrowRight size={18} />}
colorScheme="blue"
size="lg"
>
Zobrazit všechna alba
</Button>
</Box>
</VStack>
</Box>
);
};
export default GallerySection;
@@ -0,0 +1,199 @@
import React from 'react';
import { Box, Flex, HStack, Image, Text, Container, useColorModeValue } from '@chakra-ui/react';
import { Link as RouterLink } from 'react-router-dom';
interface HeaderVariantsProps {
variant: 'unified' | 'edge' | 'minimal' | 'modern';
clubName?: string;
clubLogo?: string;
clubId?: string;
}
const HeaderVariants: React.FC<HeaderVariantsProps> = ({
variant = 'unified',
clubName,
clubLogo,
clubId,
}) => {
const displayLogo = clubId
? `http://logoapi.sportcreative.eu/logos/${clubId}?format=svg`
: clubLogo || '/images/club-logo.png';
// Unified variant - classic header
if (variant === 'unified') {
return (
<Box
bg={useColorModeValue('white', 'gray.800')}
borderBottom="1px"
borderColor={useColorModeValue('gray.200', 'gray.700')}
py={4}
>
<Container maxW="7xl">
<Flex align="center" justify="space-between">
<HStack as={RouterLink} to="/" spacing={4}>
{displayLogo && (
<Image
src={displayLogo}
alt={clubName || 'Club'}
boxSize="48px"
objectFit="contain"
borderRadius="full"
borderWidth="2px"
borderColor="brand.primary"
style={{
padding: displayLogo.includes('logoapi.sportcreative.eu') ? '4px' : '0px',
boxSizing: 'border-box'
}}
/>
)}
<Box>
<Text fontSize="2xl" fontWeight="bold" color={useColorModeValue('gray.800', 'white')}>
{clubName || 'MyClub'}
</Text>
<Text fontSize="xs" color="gray.500">Official Website</Text>
</Box>
</HStack>
</Flex>
</Container>
</Box>
);
}
// Edge variant - modern with gradient
if (variant === 'edge') {
return (
<Box
bgGradient="linear(to-r, brand.primary, brand.secondary)"
position="relative"
overflow="hidden"
>
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
bg="blackAlpha.300"
backdropFilter="blur(8px)"
/>
<Container maxW="7xl" position="relative" py={6}>
<Flex align="center" justify="center" direction="column">
{displayLogo && (
<Image
src={displayLogo}
alt={clubName || 'Club'}
boxSize="80px"
objectFit="contain"
mb={3}
filter="drop-shadow(0 4px 6px rgba(0,0,0,0.3))"
style={{
padding: displayLogo.includes('logoapi.sportcreative.eu') ? '8px' : '0px',
boxSizing: 'border-box'
}}
/>
)}
<Text fontSize="3xl" fontWeight="bold" color="white" textShadow="0 2px 4px rgba(0,0,0,0.3)">
{clubName || 'Football Club'}
</Text>
<Text fontSize="sm" color="whiteAlpha.900" mt={1}>
Official Website
</Text>
</Flex>
</Container>
</Box>
);
}
// Minimal variant - clean and simple
if (variant === 'minimal') {
return (
<Box bg={useColorModeValue('gray.50', 'gray.900')} py={3}>
<Container maxW="7xl">
<Flex align="center" justify="center">
<HStack as={RouterLink} to="/" spacing={3}>
{displayLogo && (
<Image
src={displayLogo}
alt={clubName || 'Club'}
boxSize="36px"
objectFit="contain"
style={{
padding: displayLogo.includes('logoapi.sportcreative.eu') ? '3px' : '0px',
boxSizing: 'border-box'
}}
/>
)}
<Text fontSize="lg" fontWeight="600" color={useColorModeValue('gray.700', 'gray.200')}>
{clubName || 'FC'}
</Text>
</HStack>
</Flex>
</Container>
</Box>
);
}
// Modern variant - bold with accent
if (variant === 'modern') {
return (
<Box
bg={useColorModeValue('white', 'gray.800')}
borderBottom="4px"
borderColor="brand.primary"
boxShadow="sm"
>
<Container maxW="7xl" py={5}>
<Flex align="center" justify="space-between">
<HStack as={RouterLink} to="/" spacing={4}>
{displayLogo && (
<Box
position="relative"
_before={{
content: '""',
position: 'absolute',
top: '-4px',
left: '-4px',
right: '-4px',
bottom: '-4px',
bg: 'brand.primary',
opacity: 0.1,
borderRadius: 'full',
}}
>
<Image
src={displayLogo}
alt={clubName || 'Club'}
boxSize="56px"
objectFit="contain"
borderRadius="full"
style={{
padding: displayLogo.includes('logoapi.sportcreative.eu') ? '6px' : '0px',
boxSizing: 'border-box'
}}
/>
</Box>
)}
<Box>
<Text
fontSize="2xl"
fontWeight="900"
color="brand.primary"
letterSpacing="tight"
>
{clubName || 'FOOTBALL CLUB'}
</Text>
<Text fontSize="xs" color="gray.500" fontWeight="600" letterSpacing="wider" textTransform="uppercase">
Official Website
</Text>
</Box>
</HStack>
</Flex>
</Container>
</Box>
);
}
return null;
};
export default HeaderVariants;
@@ -0,0 +1,106 @@
import React from 'react';
import { Box, Container, Grid, GridItem, VStack, HStack, Image, Heading, Text, Icon, Button } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import BlogSwiper from './BlogSwiper';
import { getArticles, Article } from '../../services/articles';
import { Link as RouterLink } from 'react-router-dom';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import UpcomingSwitch from './UpcomingSwitch';
import { ChevronRightIcon } from '@chakra-ui/icons';
const RailItem: React.FC<{ a: Article }>=({ a })=>{
return (
<HStack
as={RouterLink}
to={`/articles/${a.id}`}
spacing={3}
align="center"
px={3}
py={2}
borderRadius="md"
_hover={{ bg: 'whiteAlpha.200' }}
transition="background 0.2s"
>
<Box position="relative" flexShrink={0}>
<Image src={a.image_url || '/stadium-placeholder.jpg'} alt={a.title} boxSize="64px" objectFit="cover" borderRadius="md" />
</Box>
<VStack spacing={0} align="start" minW={0}>
<Text fontSize="xs" color="whiteAlpha.700">{new Date(a.created_at || '').toLocaleDateString()}</Text>
<Text fontWeight={600} noOfLines={2} color="white">{a.title}</Text>
</VStack>
<ChevronRightIcon color="whiteAlpha.700" ml="auto" />
</HStack>
);
}
const HeroWithRail: React.FC = () => {
const theme = useClubTheme();
const { data, isLoading } = useQuery({
queryKey: ['hero-rail-articles', { page: 1, page_size: 8, published: true }],
queryFn: () => getArticles({ page: 1, page_size: 8, published: true }),
});
const articles = data?.data || [];
return (
<Box position="relative">
{/* Hero */}
<Grid templateColumns={{ base: '1fr', lg: '2fr 1fr' }} gap={6}>
<GridItem>
<BlogSwiper />
</GridItem>
{/* Right rail */}
<GridItem display={{ base: 'none', lg: 'block' }}>
<Box
h={{ base: 'auto', lg: '600px' }}
bg="blackAlpha.600"
borderRadius="xl"
overflow="hidden"
backdropFilter="auto"
backdropBlur="8px"
border="1px solid"
borderColor="whiteAlpha.200"
>
<VStack align="stretch" spacing={0} h="100%">
<HStack justify="space-between" px={4} py={3} borderBottom="1px solid" borderColor="whiteAlpha.200">
<Text fontWeight="700" color="white">Novinky</Text>
<Button as={RouterLink} to="/blog" size="sm" variant="ghost" color="whiteAlpha.800" _hover={{ bg: 'whiteAlpha.200' }}>Vše</Button>
</HStack>
<VStack spacing={1} align="stretch" px={2} py={2} overflowY="auto">
{isLoading && Array.from({length:5}).map((_,i)=> (
<Box key={i} h="72px" borderRadius="md" bg="whiteAlpha.200" />
))}
{!isLoading && articles.map((a) => (
<RailItem key={a.id} a={a} />
))}
</VStack>
</VStack>
</Box>
</GridItem>
</Grid>
{/* Glasmorphic upcoming panel */}
<Box
position="absolute"
left="50%"
bottom={{ base: 2, md: 4 }}
transform="translateX(-50%)"
w={{ base: '95%', md: '80%' }}
bg="whiteAlpha.200"
backdropFilter="auto"
backdropBlur="10px"
border="1px solid"
borderColor="whiteAlpha.300"
borderRadius="xl"
px={{ base: 3, md: 6 }}
py={{ base: 3, md: 4 }}
boxShadow="lg"
>
<UpcomingSwitch />
</Box>
</Box>
);
};
export default HeroWithRail;
@@ -0,0 +1,227 @@
import { Box, Tabs, TabList, TabPanels, Tab, TabPanel, Table, Thead, Tbody, Tr, Th, Td, Text, Badge, Heading, Flex, Tooltip } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { facrApi } from '../../services/facr/facrApi';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { useState } from 'react';
import ClubModal from './ClubModal';
import { TeamLogo } from '../common/TeamLogo';
const LeagueTablePro: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubId = settings?.club_id;
const clubType = settings?.club_type || 'football';
const theme = useClubTheme();
const { data } = useQuery({
queryKey: ['facr-table', clubId, clubType],
queryFn: () => facrApi.getClubTable(clubId!, clubType as any),
enabled: !!clubId,
});
const [selectedClub, setSelectedClub] = useState<any>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const handleClubClick = (row: any) => {
// Transform row data to match ClubModal interface
const clubData = {
team: row.team || row.team_name || '-',
team_id: row.team_id || '',
team_logo_url: row.team_logo_url,
rank: row.rank,
played: row.played,
wins: row.wins,
draws: row.draws,
losses: row.losses,
score: row.score,
points: row.points,
};
setSelectedClub(clubData);
setIsModalOpen(true);
};
if (!data) return null;
return (
<Box borderRadius="lg" overflow="hidden" bg="white" boxShadow="sm" borderWidth="1px" borderColor="gray.100">
<Box bg="primary.600" px={4} py={2} borderBottomWidth="1px" borderColor="primary.700">
<Heading size="md" color="white">Tabulka</Heading>
</Box>
<Tabs variant="enclosed" colorScheme="gray" size="sm">
<TabList px={2} pt={2} overflowX="auto" overflowY="hidden" css={{
'&::-webkit-scrollbar': { height: '4px' },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: 'gray.300', borderRadius: '4px' },
}}>
{data.competitions?.map((c) => (
<Tab
key={c.id}
_selected={{
color: 'primary.600',
borderBottom: '2px solid',
borderColor: 'primary.600',
fontWeight: 'bold'
}}
_focus={{ boxShadow: 'none' }}
fontSize="sm"
px={3}
py={3}
>
{c.name}
</Tab>
))}
</TabList>
<TabPanels>
{data.competitions?.map((c) => (
<TabPanel key={c.id} px={0}>
<Box maxH="420px" overflowY="auto">
<Table size="sm" variant="simple">
<Thead position="sticky" top={0} zIndex={1} bg="gray.50">
<Tr borderBottomWidth="1px" borderColor="gray.200">
<Th width="8" px={2} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">#</Th>
<Th px={3} color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Tým</Th>
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Z</Th>
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">V</Th>
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">R</Th>
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">P</Th>
<Th width="16" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Skóre</Th>
<Th width="14" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Body</Th>
</Tr>
</Thead>
<Tbody>
{c.table?.overall?.map((row, idx) => {
const isHighlighted = row.team_id === clubId;
return (
<Tr
key={`${row.team_id}-${idx}`}
bg={isHighlighted ? 'primary.50' : idx % 2 === 0 ? 'white' : 'gray.50'}
borderLeft={isHighlighted ? '3px solid' : '3px solid transparent'}
borderLeftColor={isHighlighted ? 'primary.500' : 'transparent'}
_hover={{ bg: isHighlighted ? 'primary.100' : 'gray.100', cursor: 'pointer' }}
onClick={() => handleClubClick(row)}
>
<Td px={2} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
<Flex align="center" justify="center">
{row.rank}
{idx < 3 && (
<Box as="span" ml={1} color={idx === 0 ? 'yellow.400' : idx === 1 ? 'gray.400' : 'yellow.700'}>
{idx === 0 ? '🥇' : idx === 1 ? '🥈' : '🥉'}
</Box>
)}
</Flex>
</Td>
<Td px={3} py={2}>
<Flex align="center" minW="180px">
<TeamLogo
teamId={row.team_id}
teamName={row.team}
facrLogo={row.team_logo_url}
size="small"
alt={row.team}
mr={2}
fallbackIcon={
<Box
w="20px"
h="20px"
bg="gray.200"
borderRadius="md"
display="flex"
alignItems="center"
justifyContent="center"
color="gray.400"
fontSize="xs"
fontWeight="bold"
>
{row.team.substring(0, 2).toUpperCase()}
</Box>
}
/>
<Text
as="span"
fontWeight={isHighlighted ? 'bold' : 'normal'}
color={isHighlighted ? 'primary.700' : 'gray.800'}
isTruncated
>
{row.team}
</Text>
</Flex>
</Td>
<Td isNumeric px={1} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
{row.played}
</Td>
<Td isNumeric px={1} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
{row.wins}
</Td>
<Td isNumeric px={1} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
{row.draws}
</Td>
<Td isNumeric px={1} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
{row.losses}
</Td>
<Td isNumeric px={1} textAlign="center" fontFamily="mono" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
{row.score}
</Td>
<Td
isNumeric
px={1}
textAlign="center"
fontWeight="bold"
color={isHighlighted ? 'white' : 'gray.800'}
bg={isHighlighted ? 'primary.500' : 'gray.100'}
borderLeftWidth="1px"
borderLeftColor="white"
>
{row.points}
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</Box>
{/* League Info Footer */}
<Box px={4} py={3} borderTopWidth="1px" borderColor="gray.100" bg="gray.50">
<Flex justify="space-between" fontSize="xs" color="gray.600">
<Box>
<Text as="span" mr={4} display="inline-flex" alignItems="center">
<Box as="span" display="inline-block" w="8px" h="8px" bg="green.500" borderRadius="full" mr={1} />
Postup
</Text>
<Text as="span" mr={4} display="inline-flex" alignItems="center">
<Box as="span" display="inline-block" w="8px" h="8px" bg="blue.500" borderRadius="full" mr={1} />
Evropské poháry
</Text>
<Text as="span" display="inline-flex" alignItems="center">
<Box as="span" display="inline-block" w="8px" h="8px" bg="red.500" borderRadius="full" mr={1} />
Sestup
</Text>
</Box>
<Box>
<Text as="span" display="inline-flex" alignItems="center">
<Box as="span" fontWeight="bold" mr={1}>Aktualizováno:</Box>
{new Date().toLocaleDateString('cs-CZ', {
day: '2-digit',
month: 'long',
year: 'numeric'
})}
</Text>
</Box>
</Flex>
</Box>
</TabPanel>
))}
</TabPanels>
</Tabs>
<ClubModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
club={selectedClub}
clubType={clubType as 'football' | 'futsal'}
/>
</Box>
);
};
export default LeagueTablePro;
+193
View File
@@ -0,0 +1,193 @@
import React, { useMemo } from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
Button,
HStack,
VStack,
Image,
Text,
Badge,
Link,
Divider,
} from '@chakra-ui/react';
import { useCountdown } from '../../hooks/useCountdown';
import { assetUrl } from '../../utils/url';
export type FacrMatchLike = {
id?: string | number;
date?: string; // yyyy-mm-dd
time?: string; // HH:MM
date_time?: string; // alternative combined format (dd.MM.yyyy HH:mm)
home?: string;
away?: string;
home_logo_url?: string;
away_logo_url?: string;
competition?: string;
competitionName?: string;
venue?: string;
score?: string | null;
facr_link?: string | null;
report_url?: string | null;
};
interface MatchModalProps {
isOpen: boolean;
match: FacrMatchLike | null;
onClose: () => void;
onTeamClick?: (teamName: string, teamLogoUrl?: string) => void;
}
const formatWhen = (m: FacrMatchLike | null) => {
if (!m) return '';
try {
if (m.date && m.time) {
const d = new Date(`${m.date}T${(m.time || '00:00')}:00`);
if (!isNaN(d.getTime())) return d.toLocaleString();
}
if (m.date_time) {
// Try to parse dd.MM.yyyy HH:mm quickly by reordering
const dt = String(m.date_time);
const [dPart, tPart] = dt.split(' ');
const [dd, MM, yyyy] = (dPart || '').split('.');
if (dd && MM && yyyy) {
const iso = `${yyyy}-${MM.padStart(2, '0')}-${dd.padStart(2, '0')}T${(tPart || '00:00')}:00`;
const d = new Date(iso);
if (!isNaN(d.getTime())) return d.toLocaleString();
}
return m.date_time;
}
} catch {}
return '';
};
export const MatchModal: React.FC<MatchModalProps> = ({ isOpen, match, onClose, onTeamClick }) => {
const kickoffIso = useMemo(() => {
if (!match) return null;
if (match.date && match.time) return `${match.date}T${(match.time || '00:00')}:00`;
if (match.date_time) {
const dt = String(match.date_time);
const [dPart, tPart] = dt.split(' ');
const [dd, MM, yyyy] = (dPart || '').split('.');
if (dd && MM && yyyy) return `${yyyy}-${MM.padStart(2, '0')}-${dd.padStart(2, '0')}T${(tPart || '00:00')}:00`;
return match.date_time; // fallback
}
return null;
}, [match]);
const { countdownString, isActive, timeRemaining } = useCountdown(kickoffIso, 1000);
const facrLink = match?.facr_link || match?.report_url || null;
const when = formatWhen(match);
// Determine if match has started (countdown finished) but no score yet
const matchStarted = kickoffIso ? new Date(kickoffIso).getTime() <= Date.now() : false;
const hasScore = match?.score && match.score.trim() !== '';
return (
<Modal isOpen={isOpen} onClose={onClose} isCentered size={{ base: 'md', md: 'lg' }}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
{match?.home || 'Domácí'} vs {match?.away || 'Hosté'}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
{match && (
<VStack align="stretch" spacing={4}>
<HStack justify="space-between" align="center">
<VStack
align="center"
spacing={2}
flex={1}
minW={0}
cursor={onTeamClick ? 'pointer' : 'default'}
onClick={() => onTeamClick && onTeamClick(match.home || '', match.home_logo_url)}
_hover={onTeamClick ? { opacity: 0.8, transform: 'scale(1.05)' } : {}}
transition="all 0.2s"
role={onTeamClick ? 'button' : undefined}
tabIndex={onTeamClick ? 0 : undefined}
>
<Image src={assetUrl(match.home_logo_url) || '/logo192.png'} alt={match.home || 'Domácí'} boxSize="56px" objectFit="contain" />
<Text fontWeight="semibold" noOfLines={1} textAlign="center">{match.home || 'Domácí'}</Text>
</VStack>
<VStack spacing={1} minW="120px">
{hasScore ? (
<>
<Text fontSize="2xl" fontWeight="bold">{match.score}</Text>
<Text fontSize="sm" color="gray.600">Skončeno</Text>
</>
) : matchStarted ? (
<>
<Text fontSize="2xl" fontWeight="bold">:</Text>
<Text fontSize="sm" color="green.600">Probíhá</Text>
</>
) : (
<>
<Text fontSize="lg" color="gray.600">Začátek za</Text>
<Text fontSize="2xl" fontWeight="bold">{countdownString || '—'}</Text>
</>
)}
{(match.competition || match.competitionName) && (
<Badge colorScheme="blue" variant="subtle">{match.competition || match.competitionName}</Badge>
)}
</VStack>
<VStack
align="center"
spacing={2}
flex={1}
minW={0}
cursor={onTeamClick ? 'pointer' : 'default'}
onClick={() => onTeamClick && onTeamClick(match.away || '', match.away_logo_url)}
_hover={onTeamClick ? { opacity: 0.8, transform: 'scale(1.05)' } : {}}
transition="all 0.2s"
role={onTeamClick ? 'button' : undefined}
tabIndex={onTeamClick ? 0 : undefined}
>
<Image src={assetUrl(match.away_logo_url) || '/logo192.png'} alt={match.away || 'Hosté'} boxSize="56px" objectFit="contain" />
<Text fontWeight="semibold" noOfLines={1} textAlign="center">{match.away || 'Hosté'}</Text>
</VStack>
</HStack>
<Divider />
<VStack align="stretch" spacing={1} color="gray.700">
{when && <Text><strong>Kdy:</strong> {when}</Text>}
{match.venue && <Text><strong>Kde:</strong> {match.venue}</Text>}
</VStack>
</VStack>
)}
</ModalBody>
<ModalFooter>
{facrLink && (
<Button
colorScheme="blue"
mr={3}
onClick={(e) => {
e.preventDefault();
// Open in background tab without switching focus
const link = document.createElement('a');
link.href = facrLink;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}
>
Detail na FAČR
</Button>
)}
<Button onClick={onClose}>Zavřít</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default MatchModal;
@@ -0,0 +1,118 @@
import { Box, Heading, Tabs, TabList, TabPanels, Tab, TabPanel, VStack, HStack, Image, Text, Link, Skeleton, Badge } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { facrApi } from '../../services/facr/facrApi';
import { FACR_CLUB_ID, FACR_CLUB_TYPE } from '../../config/facr';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { TeamLogo } from '../common/TeamLogo';
import '../../styles/logos.css';
const MatchRow: React.FC<{
date: string;
home: { name: string; logo?: string; id?: string };
away: { name: string; logo?: string; id?: string };
score?: string;
clubName?: string;
}> = ({ date, home, away, score, clubName }) => (
<HStack justify="space-between" borderWidth="1px" borderRadius="md" p={3} bg="white">
<Text w="140px" fontSize="sm" color="gray.600">{date}</Text>
<HStack flex={1} justify="flex-end">
<HStack minW="40%" justify="flex-end" spacing={2}>
<Text noOfLines={1} textAlign="right" flex={1}>{home.name}</Text>
<Box className="logo-container" w="28px" h="28px">
<TeamLogo
teamId={home.id}
teamName={home.name}
facrLogo={home.logo}
size="custom"
boxSize="28px"
/>
</Box>
</HStack>
<HStack w="auto" minW="60px" justify="center" spacing={2}>
<Text fontWeight="bold" textAlign="center">{score || '-:-'}</Text>
{(() => {
const sent = (() => {
if (!score || !clubName) return null;
const m = score.match(/^(\d+)\s*[:\-]\s*(\d+)$/);
if (!m) return null;
const h = parseInt(m[1], 10), a = parseInt(m[2], 10);
const norm = (s: string) => String(s||'').normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/\s+/g,' ').trim().toLowerCase();
const strip = (s: string) => norm(s).replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g,'').replace(/\s+/g,' ').trim();
const ourIsHome = (() => { const aName = strip(home.name); const bName = strip(clubName); return aName && bName && (aName===bName || aName.endsWith(bName) || bName.endsWith(aName)); })();
const ourIsAway = (() => { const aName = strip(away.name); const bName = strip(clubName); return aName && bName && (aName===bName || aName.endsWith(bName) || bName.endsWith(aName)); })();
if (!ourIsHome && !ourIsAway) return null;
if (h === a) return { label: 'Remíza', color: 'blue' } as const;
const our = ourIsHome ? h : a; const opp = ourIsHome ? a : h;
return our > opp ? ({ label: 'Výhra', color: 'green' } as const) : ({ label: 'Prohra', color: 'red' } as const);
})();
return sent ? <Badge colorScheme={sent.color} variant="subtle">{sent.label}</Badge> : null;
})()}
</HStack>
<HStack minW="40%" spacing={2}>
<Box className="logo-container" w="28px" h="28px">
<TeamLogo
teamId={away.id}
teamName={away.name}
facrLogo={away.logo}
size="custom"
boxSize="28px"
/>
</Box>
<Text noOfLines={1} flex={1}>{away.name}</Text>
</HStack>
</HStack>
</HStack>
);
const MatchesSection: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubId = settings?.club_id || FACR_CLUB_ID;
const clubType = settings?.club_type || FACR_CLUB_TYPE;
const { data, isLoading, isError } = useQuery({
queryKey: ['facr-club', clubId, clubType],
queryFn: () => facrApi.getClub(clubId, clubType),
enabled: Boolean(clubId),
});
return (
<Box>
<Heading size="lg" mb={4}>Zápasy</Heading>
{!clubId && (
<Text color="orange.500" mb={4}>Nastavte klub v Nastavení (Admin) nebo REACT_APP_FACR_CLUB_ID pro načtení zápasů z FAČR.</Text>
)}
{isLoading && <Skeleton height="200px" />}
{!isLoading && data && (
<Tabs variant="enclosed-colored" isFitted>
<TabList>
{data.competitions?.map((c) => (
<Tab key={c.id}>{c.name}</Tab>
))}
</TabList>
<TabPanels>
{data.competitions?.map((c) => (
<TabPanel key={c.id} px={0}>
<VStack align="stretch" spacing={3}>
{(c.matches || []).slice(0, 5).map((m, idx) => (
<MatchRow
key={m.match_id || idx}
date={m.date_time}
home={{ name: m.home, logo: m.home_logo_url, id: m.home_id }}
away={{ name: m.away, logo: m.away_logo_url, id: m.away_id }}
score={m.score}
clubName={data.name}
/>
))}
{(c.matches || []).length === 0 && (
<Text color="gray.500">Žádné zápasy k dispozici.</Text>
)}
</VStack>
</TabPanel>
))}
</TabPanels>
</Tabs>
)}
</Box>
);
};
export default MatchesSection;
@@ -0,0 +1,77 @@
import { Box, SimpleGrid, Heading, Text, useColorModeValue, HStack, Button, Link, Badge } from '@chakra-ui/react';
import { useEffect, useState } from 'react';
import { getClothing, ClothingItem } from '../../services/clothing';
import { Link as RouterLink } from 'react-router-dom';
const MerchSection: React.FC = () => {
const [items, setItems] = useState<ClothingItem[]>([]);
const [loading, setLoading] = useState(true);
const cardBg = useColorModeValue('white', 'gray.800');
useEffect(() => {
const fetchItems = async () => {
try {
const data = await getClothing();
// Show only 5 newest items on homepage
setItems(data.slice(0, 5));
} catch (e) {
console.error('Failed to fetch clothing items:', e);
} finally {
setLoading(false);
}
};
fetchItems();
}, []);
if (loading || items.length === 0) return null;
return (
<Box>
<HStack justify="space-between" mb={3}>
<Heading as="h3" size="md">Oblečení týmu</Heading>
<Link as={RouterLink} to="/obleceni">
<Button size="sm" variant="outline" colorScheme="blue">Zobrazit vše</Button>
</Link>
</HStack>
<SimpleGrid columns={{ base: 2, md: 3, lg: 5 }} spacing={4}>
{items.map((it) => (
<a
key={it.id}
href={it.url || '/obleceni'}
target={it.url ? "_blank" : undefined}
rel={it.url ? "noreferrer noopener" : undefined}
>
<Box
bg={cardBg}
borderRadius="xl"
overflow="hidden"
boxShadow="sm"
borderWidth="1px"
transition="all 0.2s"
_hover={{ transform: 'translateY(-4px)', boxShadow: 'md' }}
>
<Box
aria-hidden
height={{ base: 140, md: 180 }}
bgSize="cover"
bgPos="center"
style={{ backgroundImage: `url(${it.image_url})` }}
/>
<Box p={3} borderTopWidth="1px">
<Text noOfLines={1} fontWeight="semibold" fontSize="sm">{it.title}</Text>
{it.price && it.price > 0 && (
<Badge colorScheme="blue" mt={1} fontSize="xs">
{it.price} {it.currency || 'Kč'}
</Badge>
)}
</Box>
</Box>
</a>
))}
</SimpleGrid>
</Box>
);
};
export default MerchSection;
@@ -0,0 +1,168 @@
import { Box, Grid, GridItem, Heading, Image, Button, HStack, Text, VStack, Badge } from '@chakra-ui/react';
import React, { useEffect, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { Calendar, Image as ImageIcon } from 'lucide-react';
interface Album {
id: string;
title: string;
url: string;
date: string;
photos_count: number;
views_count?: number;
photos: Array<{
id: string;
page_url: string;
image_1500: string;
}>;
}
// Resolve backend-relative URLs against API origin
const resolveBackendUrl = (path: string) => {
try {
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
const base = (process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1');
const b = new URL(base);
const abs = new URL(path, `${b.protocol}//${b.host}`);
return abs.toString();
}
return path;
} catch { return path; }
};
const PhotosSection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl }) => {
const [albums, setAlbums] = useState<Album[]>([]);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
let active = true;
(async () => {
try {
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const response = await fetch(`${apiUrl}/gallery/albums`);
if (response.ok) {
const data = await response.json();
if (active) {
// Get 5 most recent albums
setAlbums((data.albums || []).slice(0, 5));
}
}
} catch {
if (active) setAlbums([]);
} finally {
if (active) setLoaded(true);
}
})();
return () => { active = false };
}, []);
const showSetupHint = loaded && albums.length === 0 && !zoneramaUrl;
return (
<Box>
<HStack justify="space-between" mb={3}>
<Heading size="lg">Fotogalerie</Heading>
<Button as={RouterLink} to="/galerie" size="sm" variant="outline">
Zobrazit vše
</Button>
</HStack>
{showSetupHint && (
<Box bg="yellow.50" borderWidth="1px" borderColor="yellow.200" color="yellow.800" p={3} borderRadius="md" mb={3}>
<Text>Žádné fotky nejsou k dispozici. Zadejte prosím odkaz na Zonerama v nastavení (Sociální sítě Fotogalerie) a my ji budeme automaticky načítat.</Text>
</Box>
)}
{/* Zonerama Attribution */}
{albums.length > 0 && (
<Box bg="blue.50" borderWidth="1px" borderColor="blue.200" color="blue.800" p={2} borderRadius="md" mb={3} fontSize="xs">
<Text>
📸 Fotografie z{' '}
<Text
as="a"
href="https://zonerama.com"
target="_blank"
rel="noopener noreferrer"
fontWeight="600"
color="blue.600"
_hover={{ textDecoration: 'underline' }}
>
Zonerama
</Text>
</Text>
</Box>
)}
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={4}>
{albums.map((album) => {
const coverPhoto = album.photos && album.photos.length > 0 ? album.photos[0] : null;
return (
<GridItem key={album.id}>
<Box
as={RouterLink}
to={`/galerie/album/${album.id}`}
bg="white"
borderRadius="md"
overflow="hidden"
boxShadow="sm"
transition="all 0.2s"
_hover={{
transform: 'translateY(-2px)',
boxShadow: 'md',
}}
cursor="pointer"
display="block"
>
{/* Cover Image */}
{coverPhoto ? (
<Image
src={resolveBackendUrl(coverPhoto.image_1500)}
alt={album.title}
h="180px"
w="100%"
objectFit="cover"
/>
) : (
<Box
h="180px"
w="100%"
bg="gray.200"
display="flex"
alignItems="center"
justifyContent="center"
>
<ImageIcon size={32} color="gray" />
</Box>
)}
{/* Album Info */}
<VStack align="stretch" p={3} spacing={2}>
<Heading size="sm" noOfLines={2} color="gray.800">
{album.title}
</Heading>
<HStack spacing={3} fontSize="xs" color="gray.600">
{album.date && (
<HStack spacing={1}>
<Calendar size={14} />
<Text>{album.date}</Text>
</HStack>
)}
<HStack spacing={1}>
<ImageIcon size={14} />
<Text>{album.photos_count} foto</Text>
</HStack>
</HStack>
</VStack>
</Box>
</GridItem>
);
})}
</Grid>
</Box>
);
};
export default PhotosSection;
@@ -0,0 +1,87 @@
import React from 'react';
import {
Box,
VStack,
Heading,
Text,
Spinner,
Alert,
AlertIcon,
useColorModeValue,
} from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getPolls, getPoll } from '../../services/polls';
import PollCard from '../polls/PollCard';
interface PollsWidgetProps {
featuredOnly?: boolean;
maxPolls?: number;
title?: string;
}
const PollsWidget: React.FC<PollsWidgetProps> = ({
featuredOnly = true,
maxPolls = 1,
title = 'Hlasování',
}) => {
const bgSection = useColorModeValue('gray.50', 'gray.900');
// Fetch polls list
const { data: polls, isLoading } = useQuery({
queryKey: ['polls', { featured: featuredOnly }],
queryFn: () => getPolls(featuredOnly ? { featured: true } : undefined),
staleTime: 2 * 60 * 1000, // 2 minutes
});
// Get full poll data for each featured poll
const pollsToDisplay = polls?.slice(0, maxPolls) || [];
const { data: pollsData, isLoading: isLoadingPolls } = useQuery({
queryKey: ['polls-details', pollsToDisplay.map((p) => p.id)],
queryFn: async () => {
const promises = pollsToDisplay.map((poll) => getPoll(poll.id));
return await Promise.all(promises);
},
enabled: pollsToDisplay.length > 0,
});
if (isLoading || isLoadingPolls) {
return (
<Box bg={bgSection} py={12} px={4}>
<VStack spacing={4}>
<Spinner size="lg" />
<Text>Načítání ankety...</Text>
</VStack>
</Box>
);
}
if (!pollsData || pollsData.length === 0) {
return null; // Don't show widget if no polls
}
return (
<Box bg={bgSection} py={12} px={4}>
<VStack spacing={8} maxW="4xl" mx="auto">
<Heading size="lg" textAlign="center">
{title}
</Heading>
<VStack spacing={6} w="full">
{pollsData.map((pollResponse) => (
<Box key={pollResponse.poll.id} w="full" maxW="600px">
<PollCard
poll={pollResponse.poll}
hasVoted={pollResponse.has_voted}
isActive={pollResponse.is_active}
canShowResults={pollResponse.can_show_results}
/>
</Box>
))}
</VStack>
</VStack>
</Box>
);
};
export default PollsWidget;
@@ -0,0 +1,121 @@
import React, { useMemo } from 'react';
import { usePublicSettings } from '../../hooks/usePublicSettings';
// Normalizes various social URL formats to a proper https URL
const normalizeSocialUrl = (network: 'facebook' | 'instagram' | 'youtube', raw?: string | null): string | null => {
let v = String(raw || '').trim();
if (!v) return null;
// Replace whitespace
v = v.replace(/\s+/g, '');
// Accept handle like @club
if (v.startsWith('@')) {
const handle = v.slice(1);
if (network === 'facebook') return `https://www.facebook.com/${handle}`;
if (network === 'instagram') return `https://www.instagram.com/${handle}`;
if (network === 'youtube') return `https://www.youtube.com/@${handle}`;
}
// If only a username without slashes
if (!/^https?:\/\//i.test(v) && !v.includes('/') && !v.includes('.')) {
if (network === 'facebook') return `https://www.facebook.com/${v}`;
if (network === 'instagram') return `https://www.instagram.com/${v}`;
if (network === 'youtube') return `https://www.youtube.com/@${v}`;
}
// If looks like domain without scheme
if (!/^https?:\/\//i.test(v)) {
if (/^facebook\.com\//i.test(v)) return `https://www.${v}`;
if (/^instagram\.com\//i.test(v)) return `https://www.${v}`;
if (/^youtube\.com\//i.test(v)) return `https://www.${v}`;
}
return v;
};
const SocialEmbeds: React.FC<{ variant?: 'unified' | 'magazine' | 'pro' | 'edge' }>
= ({ variant = 'unified' }) => {
const { data: settings } = usePublicSettings();
const facebookHref = useMemo(() => {
const raw = (settings as any)?.facebook_url
|| (settings as any)?.facebook
|| (settings as any)?.facebookPage
|| (settings as any)?.facebook_page
|| (settings as any)?.facebookPageUrl
|| (settings as any)?.facebook_page_url;
return normalizeSocialUrl('facebook', raw);
}, [settings]);
const instagramHref = useMemo(() => {
const raw = (settings as any)?.instagram_url
|| (settings as any)?.instagram
|| (settings as any)?.instagramProfile
|| (settings as any)?.instagram_profile
|| (settings as any)?.ig
|| (settings as any)?.ig_url;
return normalizeSocialUrl('instagram', raw);
}, [settings]);
const youtubeHref = useMemo(() => {
const raw = (settings as any)?.youtube_url
|| (settings as any)?.youtube
|| (settings as any)?.yt
|| (settings as any)?.youtube_channel;
return normalizeSocialUrl('youtube', raw);
}, [settings]);
if (!instagramHref && !youtubeHref) return null;
const outerStyle: React.CSSProperties = {
margin: variant === 'pro' ? '16px 0' : '8px 0',
};
const gridStyle: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: '1fr',
gap: 12,
};
const colStyle: React.CSSProperties = {
background: '#fff',
borderRadius: 10,
border: '1px solid var(--light-gray)',
padding: 8,
};
// Instagram profile embed is unofficial; try /embed fallback, else show CTA tile
const instagramEmbedSrc = instagramHref ? `${instagramHref.replace(/\/$/, '')}/embed` : null;
return (
<div className={`social-embeds ${variant}`} style={outerStyle}>
<div className="section-head" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
<h3 style={{ margin: 0 }}>Sledujte nás</h3>
<div className="links" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{facebookHref && (<a className="btn" href={facebookHref} target="_blank" rel="noreferrer noopener">Facebook</a>)}
{instagramHref && (<a className="btn" href={instagramHref} target="_blank" rel="noreferrer noopener">Instagram</a>)}
{youtubeHref && (<a className="btn" href={youtubeHref} target="_blank" rel="noreferrer noopener">YouTube</a>)}
</div>
</div>
<div className="grid" style={gridStyle}>
{instagramHref && (
<div className="col" style={colStyle}>
{instagramEmbedSrc ? (
<iframe
title="Instagram"
src={instagramEmbedSrc}
width="100%"
height={360}
style={{ border: 0, width: '100%' }}
frameBorder={0}
scrolling="no"
/>
) : (
<div style={{ padding: 16 }}>
<p>Sledujte nás na Instagramu.</p>
<a className="btn" href={instagramHref} target="_blank" rel="noreferrer noopener">Otevřít Instagram</a>
</div>
)}
</div>
)}
</div>
</div>
);
};
export default SocialEmbeds;
@@ -0,0 +1,266 @@
import React, { useEffect, useState } from 'react';
import { Box, Heading, Tabs, TabList, TabPanels, Tab, TabPanel, Table, Thead, Tbody, Tr, Th, Td, Skeleton, Text, Badge, HStack, useColorModeValue } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { facrApi } from '../../services/facr/facrApi';
import { FACR_CLUB_ID, FACR_CLUB_TYPE } from '../../config/facr';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import ClubModal from './ClubModal';
import { TeamLogo } from '../common/TeamLogo';
const TableSection: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubId = settings?.club_id || FACR_CLUB_ID;
const clubType = settings?.club_type || FACR_CLUB_TYPE;
// movement map: compKey -> teamKey -> delta (prevRank - currentRank)
const [movementMap, setMovementMap] = useState<Record<string, Record<string, number>>>({});
const [selectedClub, setSelectedClub] = useState<any>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const handleClubClick = (row: any) => {
// Transform row data to match ClubModal interface
const clubData = {
team: row.team || row.team_name || '-',
team_id: row.team_id || '',
team_logo_url: row.team_logo_url,
rank: row.rank,
played: row.played,
wins: row.wins,
draws: row.draws,
losses: row.losses,
score: row.score,
points: row.points,
};
setSelectedClub(clubData);
setIsModalOpen(true);
};
// Theme-aware movement colors (softer in dark mode)
const upColor = useColorModeValue('green.400', 'green.300');
const downColor = useColorModeValue('red.400', 'red.300');
const sameColor = useColorModeValue('gray.300', 'gray.600');
// Badge/background colors to avoid white-on-white
const badgeBg = useColorModeValue('gray.100', 'gray.700');
const badgeText = useColorModeValue('gray.800', 'whiteAlpha.900');
const rankTopBg = useColorModeValue('green.100', 'green.600');
const rankTopText = useColorModeValue('green.800', 'white');
const pointsBg = useColorModeValue('blue.600', 'blue.400');
const pointsText = 'white';
const { data, isLoading, isError, error } = useQuery({
queryKey: ['facr-table', clubId, clubType],
queryFn: () => facrApi.getClubTable(clubId, clubType),
enabled: Boolean(clubId),
staleTime: 1000 * 60 * 3, // 3 minutes
retry: 2,
retryDelay: attempt => Math.min(1000 * 2 ** attempt, 8000),
});
// After data loads, compare with previous snapshot stored in localStorage to compute movement
useEffect(() => {
try {
if (!data?.competitions?.length) return;
const storageKey = `facr_table_prev_${clubId || 'unknown'}_${clubType || 'football'}`;
const prevRaw = localStorage.getItem(storageKey);
const prev = prevRaw ? JSON.parse(prevRaw) : null;
const map: Record<string, Record<string, number>> = {};
data.competitions.forEach((c: any) => {
const compKey = String(c.id ?? c.code ?? c.name ?? 'comp');
const prevComp = prev?.competitions?.find((pc: any) => String(pc.id ?? pc.code ?? pc.name) === compKey);
const prevRanks: Record<string, number> = {};
(prevComp?.table?.overall || []).forEach((r: any, i: number) => {
const teamKey = String(r.team_id ?? r.team ?? r.team_name ?? i).toLowerCase();
const rank = Number(r.rank ?? (i + 1));
prevRanks[teamKey] = rank;
});
const compMov: Record<string, number> = {};
(c.table?.overall || []).forEach((r: any, i: number) => {
const teamKeyRaw = String(r.team_id ?? r.team ?? r.team_name ?? i);
const teamKey = teamKeyRaw.toLowerCase();
const currentRank = Number(r.rank ?? (i + 1));
const prevRank = prevRanks[teamKey];
if (typeof prevRank === 'number') {
compMov[teamKeyRaw] = prevRank - currentRank; // positive => moved up
}
});
map[compKey] = compMov;
});
setMovementMap(map);
// Save current snapshot for next comparison (trim to essentials)
const snapshot = {
competitions: (data.competitions || []).map((c: any) => ({
id: c.id,
code: c.code,
name: c.name,
table: { overall: (c.table?.overall || []).map((r: any, i: number) => ({
team_id: r.team_id,
team: r.team,
team_name: r.team_name,
rank: Number(r.rank ?? (i + 1)),
})) },
})),
};
localStorage.setItem(storageKey, JSON.stringify(snapshot));
} catch {}
}, [data, clubId, clubType]);
return (
<Box>
<Heading size="lg" mb={4}>Tabulka soutěží</Heading>
{!clubId && (
<Text color="orange.500" mb={4}>Nastavte klub v Nastavení (Admin) nebo REACT_APP_FACR_CLUB_ID pro načtení tabulek z FAČR.</Text>
)}
{isLoading && <Skeleton height="200px" />}
{isError && (
<Text color="red.500" mb={4}>
Nepodařilo se načíst tabulky z FAČR. Zkuste to prosím znovu později.
{process.env.NODE_ENV !== 'production' && error instanceof Error ? ` (${error.message})` : ''}
</Text>
)}
{/* Legend for movement */}
{!isLoading && !isError && (
<HStack spacing={4} mb={2} color="gray.600" fontSize="sm">
<HStack spacing={2}>
<Box w="10px" h="10px" borderRadius="2px" bg={upColor} />
<Text>Lepší pozice</Text>
</HStack>
<HStack spacing={2}>
<Box w="10px" h="10px" borderRadius="2px" bg={sameColor} />
<Text>Beze změny</Text>
</HStack>
<HStack spacing={2}>
<Box w="10px" h="10px" borderRadius="2px" bg={downColor} />
<Text>Horší pozice</Text>
</HStack>
</HStack>
)}
{!isLoading && !isError && data && data.competitions?.length > 0 && (
<Tabs variant="enclosed" colorScheme="blue" isFitted>
<TabList bg={useColorModeValue('white', 'gray.800')} borderRadius="md" borderWidth="1px" borderColor={useColorModeValue('gray.200', 'gray.700')}>
{data.competitions?.map((c) => (
<Tab
key={c.id}
_selected={{ bg: useColorModeValue('blue.50', 'blue.900'), color: useColorModeValue('blue.700', 'blue.200'), borderColor: useColorModeValue('blue.200', 'blue.600') }}
color={useColorModeValue('gray.800', 'gray.200')}
>
{c.name}
</Tab>
))}
</TabList>
<TabPanels>
{data.competitions?.map((c) => (
<TabPanel key={c.id} px={0}>
<Box maxH="420px" overflowY="auto" borderWidth="1px" borderRadius="md" bg={useColorModeValue('white', 'gray.800')} color={useColorModeValue('gray.800', 'gray.100')} borderColor={useColorModeValue('gray.200', 'gray.700')}>
<Table size="sm" variant="striped" colorScheme="gray">
<Thead position="sticky" top={0} zIndex={1} bg="brand.primary">
<Tr>
<Th color="text.onPrimary">#</Th>
<Th color="text.onPrimary">Tým</Th>
<Th isNumeric color="text.onPrimary">Z</Th>
<Th isNumeric color="text.onPrimary">V</Th>
<Th isNumeric color="text.onPrimary">R</Th>
<Th isNumeric color="text.onPrimary">P</Th>
<Th isNumeric color="text.onPrimary">Skóre</Th>
<Th isNumeric color="text.onPrimary">Body</Th>
</Tr>
</Thead>
<Tbody>
{c.table?.overall?.map((row, idx) => {
const compKey = String(c.id ?? c.code ?? c.name ?? 'comp');
const teamKeyRaw = String((row as any).team_id ?? (row as any).team ?? (row as any).team_name ?? idx);
const deltaStored = movementMap?.[compKey]?.[teamKeyRaw];
const movement: 'up' | 'same' | 'down' = typeof deltaStored === 'number' ? (deltaStored > 0 ? 'up' : (deltaStored < 0 ? 'down' : 'same')) : 'same';
const deltaVal = typeof deltaStored === 'number' ? deltaStored : 0;
const borderCol = movement === 'up' ? upColor : movement === 'down' ? downColor : sameColor;
const ourClubId = settings?.club_id;
const ourClubName = (settings?.club_name || '').toLowerCase();
const isOurClub = (ourClubId && row.team_id === ourClubId) || (!!ourClubName && String(row.team || '').toLowerCase() === ourClubName);
return (
<Tr
key={`${row.team_id}-${idx}`}
_hover={{ bg: useColorModeValue('gray.50', 'gray.700'), cursor: 'pointer' }}
bg={idx % 2 === 0 ? useColorModeValue('white', 'gray.800') : useColorModeValue('gray.50', 'gray.750')}
sx={{ borderLeftWidth: '4px', borderLeftStyle: 'solid', borderLeftColor: borderCol }}
onClick={() => handleClubClick(row)}
>
<Td>
<Badge
variant="subtle"
bg={idx <= 2 ? rankTopBg : badgeBg}
color={idx <= 2 ? rankTopText : badgeText}
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'whiteAlpha.300')}
>
{row.rank}
</Badge>
</Td>
<Td>
<HStack spacing={2} align="center">
<TeamLogo
teamId={row.team_id}
teamName={row.team}
facrLogo={row.team_logo_url}
size="small"
alt={row.team}
borderRadius="full"
bg="white"
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'whiteAlpha.300')}
/>
<Text as="span" color={isOurClub ? 'brand.primary' : useColorModeValue('gray.800', 'gray.100')} fontWeight={isOurClub ? 'bold' : 'normal'}>
{row.team}
</Text>
<Text as="span" fontSize="xs" color={movement === 'up' ? 'green.500' : movement === 'down' ? 'red.500' : 'gray.500'}>
{movement === 'up' ? '▲' : movement === 'down' ? '▼' : '•'}
</Text>
</HStack>
{deltaVal !== 0 && (
<Badge
ml={2}
variant="subtle"
bg={movement === 'up' ? 'green.100' : movement === 'down' ? 'red.100' : badgeBg}
color={movement === 'up' ? 'green.700' : movement === 'down' ? 'red.700' : badgeText}
borderWidth="1px"
borderColor={useColorModeValue('green.200', movement === 'down' ? 'red.300' : 'whiteAlpha.300')}
>
{movement === 'up' ? `+${deltaVal}` : `${deltaVal}`}
</Badge>
)}
</Td>
<Td isNumeric>{row.played}</Td>
<Td isNumeric>{row.wins}</Td>
<Td isNumeric>{row.draws}</Td>
<Td isNumeric>{row.losses}</Td>
<Td isNumeric>{row.score}</Td>
<Td isNumeric>
<Badge variant="solid" bg={pointsBg} color={pointsText}>{row.points}</Badge>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</Box>
</TabPanel>
))}
</TabPanels>
</Tabs>
)}
{!isLoading && !isError && data && (!data.competitions || data.competitions.length === 0) && (
<Text color="gray.500">Pro tento klub nejsou dostupné tabulky.</Text>
)}
<ClubModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
club={selectedClub}
clubType={clubType as 'football' | 'futsal'}
/>
</Box>
);
};
export default TableSection;
@@ -0,0 +1,26 @@
import { Box, Heading, HStack, VStack, Image, Text, useColorModeValue } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getPlayers, Player } from '../../services/players';
const TeamScroller: React.FC = () => {
const { data } = useQuery({ queryKey: ['players'], queryFn: getPlayers });
const players = (data || []).filter(p => p.is_active);
if (!players.length) return null;
return (
<Box>
<Heading size="lg" mb={4} textAlign="center">Náš tým</Heading>
<HStack spacing={6} overflowX="auto" py={2} className="hide-scrollbar">
{players.map((p: Player) => (
<VStack key={p.id} minW="160px" spacing={2} bg={useColorModeValue('white', 'gray.800')} borderRadius="xl" p={4} boxShadow="sm" borderWidth="1px" borderColor={useColorModeValue('gray.200', 'gray.700')}>
<Image src={p.image_url || '/logo192.png'} alt={p.first_name + ' ' + p.last_name} w="140px" h="140px" objectFit="cover" borderRadius="lg" />
<Text fontWeight="bold" textAlign="center">{p.first_name} {p.last_name}</Text>
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>{p.position}</Text>
</VStack>
))}
</HStack>
</Box>
);
};
export default TeamScroller;
@@ -0,0 +1,72 @@
import React from 'react';
import ContactMap from './ContactMap';
import VectorMap from './VectorMap';
interface UnifiedMapProps {
latitude: number;
longitude: number;
zoom?: number;
address?: string;
clubName?: string;
mapStyle?: string;
height?: number;
clubPrimaryColor?: string;
clubSecondaryColor?: string;
useVectorMaps?: boolean;
}
/**
* Unified Map Component
*
* Automatically chooses between raster (Leaflet) and vector (MapLibre GL) maps
* based on the useVectorMaps prop or environment configuration.
*
* Usage:
* <UnifiedMap
* latitude={50.0755}
* longitude={14.4378}
* useVectorMaps={true} // or from settings
* />
*/
const UnifiedMap: React.FC<UnifiedMapProps> = ({
useVectorMaps = false,
...props
}) => {
// Map style conversion: raster styles to vector equivalents
const getVectorStyle = (rasterStyle?: string): 'positron' | 'dark-matter' | 'osm-bright' | 'klokantech-basic' => {
const styleMap: Record<string, any> = {
'default': 'osm-bright',
'positron': 'positron',
'positron-no-labels': 'positron',
'dark': 'dark-matter',
'dark-no-labels': 'dark-matter',
'dark-matter': 'dark-matter',
'toner': 'klokantech-basic',
'toner-lite': 'klokantech-basic',
'voyager': 'osm-bright',
'osm-bright': 'osm-bright',
'klokantech-basic': 'klokantech-basic',
};
return styleMap[rasterStyle || 'default'] || 'positron';
};
if (useVectorMaps) {
// Use vector maps (MapLibre GL JS)
return (
<VectorMap
{...props}
mapStyle={getVectorStyle(props.mapStyle)}
/>
);
} else {
// Use raster maps (Leaflet)
return (
<ContactMap
{...props}
/>
);
}
};
export default UnifiedMap;
@@ -0,0 +1,70 @@
import { Box, Flex, Heading, Text, HStack, Image, Button } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { facrApi } from '../../services/facr/facrApi';
import { useClubTheme } from '../../contexts/ClubThemeContext';
function formatCountdown(dt: string) {
const target = new Date(dt).getTime();
const diff = target - Date.now();
if (isNaN(target) || diff <= 0) return '';
const d = Math.floor(diff / (1000 * 60 * 60 * 24));
const h = Math.floor((diff / (1000 * 60 * 60)) % 24);
const m = Math.floor((diff / (1000 * 60)) % 60);
return `${String(d).padStart(2,'0')}d ${String(h).padStart(2,'0')}h ${String(m).padStart(2,'0')}m`;
}
const UpcomingBanner: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubId = settings?.club_id;
const clubType = settings?.club_type || 'football';
const theme = useClubTheme();
const { data } = useQuery({
queryKey: ['facr-club', clubId, clubType],
queryFn: () => facrApi.getClub(clubId!, clubType as any),
enabled: !!clubId,
});
const allMatches = (data?.competitions || []).flatMap(c => c.matches || []);
const upcoming = allMatches
.map(m => ({ m, t: new Date(m.date_time).getTime() }))
.filter(x => !isNaN(x.t) && x.t > Date.now())
.sort((a, b) => a.t - b.t)[0]?.m;
if (!upcoming) return null;
return (
<Box bg={theme.primary} color="white" borderRadius="xl" p={{ base: 4, md: 6 }} shadow="md">
<Text fontSize="sm" opacity={0.9} fontWeight="600">Nadcházející zápas</Text>
<Flex align="center" justify="space-between" gap={4} mt={2} direction={{ base: 'column', md: 'row' }}>
<HStack spacing={4} flex={1} justify="center">
<HStack>
<Image src={upcoming.home_logo_url} alt={upcoming.home} boxSize={{ base: '36px', md: '48px' }} objectFit="contain" />
<Text fontWeight="600">{upcoming.home}</Text>
</HStack>
<Heading size="md">vs</Heading>
<HStack>
<Image src={upcoming.away_logo_url} alt={upcoming.away} boxSize={{ base: '36px', md: '48px' }} objectFit="contain" />
<Text fontWeight="600">{upcoming.away}</Text>
</HStack>
</HStack>
<HStack spacing={6}>
<Box textAlign="center">
<Text fontSize="xs" opacity={0.8}>KICKOFF</Text>
<Heading size="sm">{new Date(upcoming.date_time).toLocaleString()}</Heading>
</Box>
<Box textAlign="center">
<Text fontSize="xs" opacity={0.8}>ZAČÍNÁ ZA</Text>
<Heading size="sm">{formatCountdown(upcoming.date_time)}</Heading>
</Box>
</HStack>
{upcoming.report_url && (
<Button as="a" href={upcoming.report_url} target="_blank" colorScheme="red" variant="solid">Detail</Button>
)}
</Flex>
</Box>
);
};
export default UpcomingBanner;
@@ -0,0 +1,105 @@
import React from 'react';
import { Box, HStack, VStack, Text, Heading, Tabs, TabList, TabPanels, Tab, TabPanel, Image, Button } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { facrApi } from '../../services/facr/facrApi';
import { useClubTheme } from '../../contexts/ClubThemeContext';
function formatCountdown(dt?: string) {
if (!dt) return '';
const target = new Date(dt).getTime();
const diff = target - Date.now();
if (isNaN(target) || diff <= 0) return '';
const d = Math.floor(diff / (1000 * 60 * 60 * 24));
const h = Math.floor((diff / (1000 * 60 * 60)) % 24);
const m = Math.floor((diff / (1000 * 60)) % 60);
return `${String(d).padStart(2,'0')}d ${String(h).padStart(2,'0')}h ${String(m).padStart(2,'0')}m`;
}
const UpcomingSwitch: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubId = settings?.club_id;
const clubType = (settings?.club_type || 'football') as any;
const theme = useClubTheme();
const { data } = useQuery({
queryKey: ['facr-club', clubId, clubType],
queryFn: () => facrApi.getClub(clubId!, clubType),
enabled: !!clubId,
});
const comps = data?.competitions || [];
if (!comps.length) return null;
return (
<Tabs variant="unstyled" colorScheme="whiteAlpha">
<TabList gap={2} flexWrap={{ base: 'wrap', md: 'nowrap' }}>
{comps.map((c) => (
<Tab
key={c.id}
px={3}
py={2}
borderRadius="full"
bg="whiteAlpha.200"
color="white"
_selected={{ bg: 'white', color: 'black' }}
>
{c.name}
</Tab>
))}
</TabList>
<TabPanels>
{comps.map((c) => {
const upcoming = (c.matches || [])
.map((m) => ({ m, t: new Date(m.date_time).getTime() }))
.filter((x) => !isNaN(x.t) && x.t > Date.now())
.sort((a, b) => a.t - b.t)[0]?.m;
if (!upcoming) {
return (
<TabPanel key={c.id} px={0}>
<Box py={6} textAlign="center" color="whiteAlpha.800">Žádný nadcházející zápas.</Box>
</TabPanel>
);
}
return (
<TabPanel key={c.id} px={0}>
<HStack spacing={6} align="center" justify="space-between" flexWrap={{ base: 'wrap', md: 'nowrap' }}>
<HStack spacing={4} flex={1} minW={0} justify="center">
<HStack minW={0}>
<Image src={upcoming.home_logo_url} alt={upcoming.home} boxSize={{ base: '32px', md: '44px' }} objectFit="contain" />
<Text fontWeight={700} color="white" noOfLines={1}>{upcoming.home}</Text>
</HStack>
<Heading size="sm" color="white">vs</Heading>
<HStack minW={0}>
<Image src={upcoming.away_logo_url} alt={upcoming.away} boxSize={{ base: '32px', md: '44px' }} objectFit="contain" />
<Text fontWeight={700} color="white" noOfLines={1}>{upcoming.away}</Text>
</HStack>
</HStack>
<HStack spacing={6}>
<Box textAlign="center" color="white">
<Text fontSize="xs" opacity={0.85}>KICKOFF</Text>
<Heading size="sm">{new Date(upcoming.date_time).toLocaleString()}</Heading>
</Box>
<Box textAlign="center" color="white">
<Text fontSize="xs" opacity={0.85}>ZAČÍNÁ ZA</Text>
<Heading size="sm">{formatCountdown(upcoming.date_time)}</Heading>
</Box>
</HStack>
{upcoming.report_url && (
<Button as="a" href={upcoming.report_url} target="_blank" bg={theme.primary} color="white" _hover={{ bg: theme.accent }}>
Detail zápasu
</Button>
)}
</HStack>
</TabPanel>
);
})}
</TabPanels>
</Tabs>
);
};
export default UpcomingSwitch;
+323
View File
@@ -0,0 +1,323 @@
import React, { useEffect, useRef, useState } from 'react';
import { Box } from '@chakra-ui/react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
interface VectorMapProps {
latitude: number;
longitude: number;
zoom?: number;
address?: string;
clubName?: string;
mapStyle?: 'positron' | 'dark-matter' | 'osm-bright' | 'klokantech-basic';
height?: number;
clubPrimaryColor?: string;
clubSecondaryColor?: string;
customStyleUrl?: string;
}
// OpenMapTiles free demo server (for development/testing)
// For production, use your own tile server or a commercial provider
const MAPTILER_API_KEY = process.env.REACT_APP_MAPTILER_KEY || 'get_your_own_OpIi9ZULNHzrESv6T2vL';
// Vector tile style definitions
export const VECTOR_STYLES = {
'positron': {
name: 'Positron (Light)',
description: 'Clean light style, perfect for data visualization',
getStyleUrl: (apiKey: string) => `https://api.maptiler.com/maps/positron/style.json?key=${apiKey}`,
},
'dark-matter': {
name: 'Dark Matter',
description: 'Sleek dark theme for modern interfaces',
getStyleUrl: (apiKey: string) => `https://api.maptiler.com/maps/darkmatter/style.json?key=${apiKey}`,
},
'osm-bright': {
name: 'OSM Bright',
description: 'Colorful OpenStreetMap style',
getStyleUrl: (apiKey: string) => `https://api.maptiler.com/maps/bright/style.json?key=${apiKey}`,
},
'klokantech-basic': {
name: 'Basic',
description: 'Simple and clean base map',
getStyleUrl: (apiKey: string) => `https://api.maptiler.com/maps/basic/style.json?key=${apiKey}`,
},
};
// Custom Positron-like style with club colors (self-hosted tiles not required)
const createCustomPositronStyle = (primaryColor?: string, secondaryColor?: string): any => {
const mainColor = primaryColor || '#e11d48';
const accentColor = secondaryColor || '#3b82f6';
return {
version: 8,
name: 'Custom Positron',
sources: {
'openmaptiles': {
type: 'vector',
url: `https://api.maptiler.com/tiles/v3/tiles.json?key=${MAPTILER_API_KEY}`,
},
},
glyphs: 'https://api.maptiler.com/fonts/{fontstack}/{range}.pbf?key=' + MAPTILER_API_KEY,
layers: [
// Background
{
id: 'background',
type: 'background',
paint: { 'background-color': '#f8f8f8' },
},
// Water
{
id: 'water',
type: 'fill',
source: 'openmaptiles',
'source-layer': 'water',
paint: { 'fill-color': '#e3e8ed' },
},
// Parks
{
id: 'park',
type: 'fill',
source: 'openmaptiles',
'source-layer': 'park',
paint: { 'fill-color': '#e8f5e8' },
},
// Buildings
{
id: 'building',
type: 'fill',
source: 'openmaptiles',
'source-layer': 'building',
paint: {
'fill-color': '#ececec',
'fill-opacity': 0.6,
},
},
// Roads - major
{
id: 'road-major',
type: 'line',
source: 'openmaptiles',
'source-layer': 'transportation',
filter: ['in', 'class', 'motorway', 'trunk', 'primary'],
paint: {
'line-color': '#ffffff',
'line-width': {
base: 1.4,
stops: [[6, 0.5], [20, 30]],
},
},
},
// Roads - minor
{
id: 'road-minor',
type: 'line',
source: 'openmaptiles',
'source-layer': 'transportation',
filter: ['in', 'class', 'secondary', 'tertiary', 'minor'],
paint: {
'line-color': '#ffffff',
'line-width': {
base: 1.4,
stops: [[6, 0.25], [20, 20]],
},
},
},
// Place labels
{
id: 'place-label',
type: 'symbol',
source: 'openmaptiles',
'source-layer': 'place',
layout: {
'text-field': '{name}',
'text-font': ['Noto Sans Regular'],
'text-size': {
base: 1.2,
stops: [[7, 11], [15, 14]],
},
},
paint: {
'text-color': '#666666',
'text-halo-color': '#ffffff',
'text-halo-width': 1.5,
},
},
],
};
};
const VectorMap: React.FC<VectorMapProps> = ({
latitude,
longitude,
zoom = 15,
address,
clubName,
mapStyle = 'positron',
height = 400,
clubPrimaryColor,
clubSecondaryColor,
customStyleUrl,
}) => {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<maplibregl.Map | null>(null);
const marker = useRef<maplibregl.Marker | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!mapContainer.current || map.current) return;
try {
// Determine style URL
let styleUrl: string | any;
if (customStyleUrl) {
styleUrl = customStyleUrl;
} else if (mapStyle === 'positron' && clubPrimaryColor) {
// Use custom style with club colors
styleUrl = createCustomPositronStyle(clubPrimaryColor, clubSecondaryColor);
} else {
// Use predefined style
styleUrl = VECTOR_STYLES[mapStyle]?.getStyleUrl(MAPTILER_API_KEY) ||
VECTOR_STYLES.positron.getStyleUrl(MAPTILER_API_KEY);
}
// Initialize map
map.current = new maplibregl.Map({
container: mapContainer.current,
style: styleUrl,
center: [longitude, latitude],
zoom: zoom,
});
// Add navigation controls
map.current.addControl(new maplibregl.NavigationControl(), 'top-right');
// Create custom marker with club color
const markerColor = clubPrimaryColor || '#e11d48';
// Create marker element
const el = document.createElement('div');
el.style.width = '36px';
el.style.height = '54px';
el.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 36" width="36" height="54">
<defs>
<filter id="marker-shadow-${Date.now()}" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="2" result="offsetblur"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.3"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<path fill="${markerColor}" stroke="#fff" stroke-width="1.5"
filter="url(#marker-shadow-${Date.now()})"
d="M12 0C7.03 0 3 4.03 3 9c0 7.5 9 18 9 18s9-10.5 9-18c0-4.97-4.03-9-9-9z"/>
<circle cx="12" cy="9" r="3" fill="#fff"/>
</svg>
`;
// Add marker to map
marker.current = new maplibregl.Marker({ element: el })
.setLngLat([longitude, latitude])
.addTo(map.current);
// Add popup if there's content
if (clubName || address) {
let popupContent = '';
if (clubName) popupContent += `<strong>${clubName}</strong><br>`;
if (address) popupContent += address;
const popup = new maplibregl.Popup({ offset: 25 })
.setHTML(popupContent);
marker.current.setPopup(popup);
}
// Handle map load event for additional customization
map.current.on('load', () => {
if (!map.current) return;
// Apply club color tint to water features if primary color is set
if (clubPrimaryColor && map.current.getLayer('water')) {
map.current.setPaintProperty('water', 'fill-color',
adjustColorBrightness(clubPrimaryColor, 0.9));
}
});
} catch (err: any) {
console.error('Error initializing map:', err);
setError(err?.message || 'Failed to load map');
}
// Cleanup
return () => {
if (marker.current) {
marker.current.remove();
marker.current = null;
}
if (map.current) {
map.current.remove();
map.current = null;
}
};
}, [latitude, longitude, zoom, mapStyle, clubPrimaryColor, clubSecondaryColor, customStyleUrl]);
// Update marker and center when coordinates change
useEffect(() => {
if (!map.current || !marker.current) return;
const newCenter: [number, number] = [longitude, latitude];
marker.current.setLngLat(newCenter);
map.current.setCenter(newCenter);
}, [latitude, longitude]);
// Helper function to adjust color brightness
function adjustColorBrightness(color: string, factor: number): string {
try {
// Simple RGB adjustment
const hex = color.replace('#', '');
const r = Math.min(255, Math.floor(parseInt(hex.substring(0, 2), 16) * factor));
const g = Math.min(255, Math.floor(parseInt(hex.substring(2, 4), 16) * factor));
const b = Math.min(255, Math.floor(parseInt(hex.substring(4, 6), 16) * factor));
return `rgb(${r}, ${g}, ${b})`;
} catch {
return color;
}
}
if (error) {
return (
<Box
w="100%"
h={`${height}px`}
bg="gray.100"
display="flex"
alignItems="center"
justifyContent="center"
borderRadius="md"
p={4}
>
{error}
</Box>
);
}
return (
<Box
ref={mapContainer}
w="100%"
h={`${height}px`}
borderRadius="md"
overflow="hidden"
boxShadow="md"
/>
);
};
export default VectorMap;
@@ -0,0 +1,337 @@
import { Box, AspectRatio, Text, useColorModeValue, SimpleGrid, Heading, HStack, Badge, Button, Link, Modal, ModalOverlay, ModalContent, ModalBody, ModalCloseButton, useDisclosure, Icon, VStack } from '@chakra-ui/react';
import { Link as RouterLink } from 'react-router-dom';
import { FaYoutube, FaPlay } from 'react-icons/fa';
import HorizontalScroller from '../ui/HorizontalScroller';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
import { useEffect, useMemo, useState } from 'react';
type Props = {
// optional manual override
videos?: string[];
};
type RenderItem = {
key: string;
title: string;
embedUrl: string;
thumbnail?: string;
date?: string; // YYYY-MM-DD
videoId?: string;
};
const toEmbed = (idOrUrl: string): string => {
// If a full URL is passed, try to extract the id; otherwise assume it's already an id
// supports https://www.youtube.com/watch?v=ID or youtu.be/ID
try {
if (idOrUrl.includes('youtube.com') || idOrUrl.includes('youtu.be')) {
const u = new URL(idOrUrl);
if (u.hostname.includes('youtu.be')) {
const id = u.pathname.replace('/', '');
return `https://www.youtube.com/embed/${id}`;
}
const id = u.searchParams.get('v');
if (id) return `https://www.youtube.com/embed/${id}`;
}
} catch {}
// otherwise treat as id
return `https://www.youtube.com/embed/${idOrUrl}`;
};
const VideosSection: React.FC<Props> = ({ videos }) => {
const cardBg = useColorModeValue('white', 'gray.800');
const theme = useClubTheme();
const { data: settings } = usePublicSettings();
const [yt, setYt] = useState<YouTubeVideo[]>([]);
const { isOpen, onOpen, onClose } = useDisclosure();
const [selectedVideo, setSelectedVideo] = useState<RenderItem | null>(null);
// If admin explicitly disabled, respect it. Otherwise default to ON when there are manual videos configured
// or when a YouTube URL is present for auto mode.
const hasManualConfigured = Boolean((settings as any)?.videos_items?.length || (settings as any)?.videos?.length);
const hasAutoConfigured = Boolean((settings as any)?.youtube_url || (settings as any)?.social_youtube);
// Default enablement: if not explicitly set, enable when manual items exist or when a YouTube URL is configured (auto mode).
// This avoids flicker caused by toggling visibility while data is loading.
const enabled = (typeof (settings as any)?.videos_module_enabled === 'boolean')
? Boolean((settings as any)?.videos_module_enabled)
: (hasManualConfigured || ((settings?.videos_source || 'auto') === 'auto' && hasAutoConfigured));
const style = settings?.videos_style || 'slider';
const source = settings?.videos_source || 'auto';
// Default to 6 items on homepage unless overridden by settings (max 12)
const limit = Math.max(1, Math.min(12, settings?.videos_limit ?? 6));
const youtubeUrl = (settings as any)?.youtube_url || (settings as any)?.social_youtube || null;
useEffect(() => {
let canceled = false;
const run = async () => {
if (source !== 'auto') return;
const payload = await getCachedYouTube();
if (!payload) return;
// Sort by published_date descending (safety; service should already do this)
const vids = (payload.videos || []).slice().sort((a, b) => (Date.parse(b.published_date || '') || 0) - (Date.parse(a.published_date || '') || 0));
if (!canceled) setYt(vids);
};
run();
return () => { canceled = true; };
}, [source]);
const extractVideoId = (embedUrl: string): string | undefined => {
if (embedUrl?.includes('/embed/')) {
return embedUrl.split('/embed/')[1]?.split('?')[0];
}
return undefined;
};
const items: RenderItem[] = useMemo(() => {
if (source === 'auto') {
return (yt || []).slice(0, limit).map(v => ({
key: v.video_id,
title: v.title,
embedUrl: toEmbed(v.video_id),
thumbnail: v.thumbnail_url,
date: v.published_date,
videoId: v.video_id,
}));
}
// manual fallback from settings or prop
const manual = (settings?.videos_items || []).map((it, i) => {
const embedUrl = toEmbed(it.url);
return {
key: `${i}-${it.url}`,
title: it.title || `Video ${i+1}`,
embedUrl,
thumbnail: it.thumbnail_url,
date: it.uploaded_at,
videoId: extractVideoId(embedUrl),
};
});
const legacy = (videos || settings?.videos || []).map((url, i) => {
const embedUrl = toEmbed(url as any);
return {
key: `${i}-${url}`,
title: `Video ${i+1}`,
embedUrl,
videoId: extractVideoId(embedUrl),
};
});
return (manual.length ? manual : legacy).slice(0, limit);
}, [source, yt, settings?.videos_items, settings?.videos, videos, limit]);
if (!enabled || items.length === 0) return null;
const handlePlayClick = (it: RenderItem) => {
setSelectedVideo(it);
onOpen();
};
const Card: React.FC<{ it: RenderItem; idx: number }> = ({ it, idx }) => {
const thumb = it.thumbnail || (it.videoId ? `https://i.ytimg.com/vi/${it.videoId}/hqdefault.jpg` : undefined);
const borderColor = useColorModeValue('gray.200', 'gray.600');
const placeholderBg = useColorModeValue('gray.100', 'gray.700');
const placeholderIcon = useColorModeValue('gray.400', 'gray.500');
const videoPrimaryColor = theme.primary;
return (
<Box
bg={cardBg}
borderRadius="xl"
overflow="hidden"
boxShadow="sm"
borderWidth="2px"
borderColor={borderColor}
transition="all 0.3s"
position="relative"
sx={{
'&:hover': {
transform: 'translateY(-8px)',
boxShadow: '0 20px 40px rgba(0,0,0,0.15)',
borderColor: 'brand.primary',
},
'&:hover .play-overlay': {
opacity: 1,
},
'&:hover .play-overlay > div': {
transform: 'scale(1.05)',
},
}}
>
<AspectRatio ratio={16 / 9}>
<Box position="relative" cursor="pointer" onClick={() => handlePlayClick(it)}>
{/* Thumbnail */}
{thumb ? (
<Box
as="img"
src={thumb}
alt={it.title}
width="100%"
height="100%"
style={{ objectFit: 'cover' }}
/>
) : (
<Box bg={placeholderBg} display="flex" alignItems="center" justifyContent="center">
<Icon as={FaPlay} boxSize={12} color={placeholderIcon} />
</Box>
)}
{/* Play overlay */}
<Box
className="play-overlay"
position="absolute"
inset={0}
display="flex"
alignItems="center"
justifyContent="center"
bg="blackAlpha.700"
opacity={0}
transition="opacity 0.3s ease"
pointerEvents="none"
>
<Box
bg="white"
color="brand.primary"
borderRadius="full"
px={8}
py={4}
fontWeight="bold"
display="flex"
alignItems="center"
gap={2}
transform="scale(0.9)"
transition="transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
boxShadow="0 12px 32px rgba(0,0,0,0.4)"
>
<Icon as={FaPlay} boxSize={5} />
<Text fontSize="lg">Přehrát</Text>
</Box>
</Box>
</Box>
</AspectRatio>
<Box p={4} borderTopWidth="2px" borderTopColor={videoPrimaryColor}>
<VStack align="start" spacing={2}>
<Text fontWeight="bold" fontSize="md" color={videoPrimaryColor} noOfLines={2}>
{it.title}
</Text>
<HStack justify="space-between" width="100%">
{it.date && (
<Badge colorScheme="gray" fontSize="0.7rem">
{new Date(it.date).toLocaleDateString('cs-CZ')}
</Badge>
)}
{it.videoId && (
<Link href={`https://www.youtube.com/watch?v=${it.videoId}`} isExternal onClick={(e) => e.stopPropagation()}>
<Button
size="xs"
variant="ghost"
colorScheme="red"
leftIcon={<Icon as={FaYoutube} />}
>
YouTube
</Button>
</Link>
)}
</HStack>
</VStack>
</Box>
</Box>
);
};
if (style === 'slider') {
return (
<Box>
<Box className="section-head" style={{ marginTop: 8, marginBottom: 16 }}>
<HStack spacing={3}>
<Heading as="h3" size="lg" fontWeight="700">Videa</Heading>
</HStack>
<Link as={RouterLink} to="/videa">
<Button
size="md"
variant="solid"
bg={theme.primary}
color="white"
rightIcon={<Box as="span"></Box>}
_hover={{ opacity: 0.9, transform: 'translateX(4px)' }}
transition="all 0.2s"
>
Více videí
</Button>
</Link>
</Box>
<HorizontalScroller draggable>
{items.map((it, idx) => (
<Box
key={it.key}
minW={{ base: '85%', md: '60%', lg: '33%' }}
display="flex"
flexDirection="column"
>
<Card it={it} idx={idx} />
</Box>
))}
</HorizontalScroller>
{/* Video Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
<ModalOverlay bg="blackAlpha.800" />
<ModalContent bg="transparent" boxShadow="none" maxW="90vw">
<ModalCloseButton color="white" size="lg" bg="blackAlpha.600" _hover={{ bg: 'blackAlpha.700' }} borderRadius="full" zIndex={2} />
<ModalBody p={0}>
{selectedVideo && (
<AspectRatio ratio={16 / 9} maxH="90vh">
<iframe
src={`${selectedVideo.embedUrl}?autoplay=1&vq=hd1080&rel=0&modestbranding=1&playsinline=1`}
title={selectedVideo.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
referrerPolicy="strict-origin-when-cross-origin"
style={{ borderRadius: '8px' }}
/>
</AspectRatio>
)}
</ModalBody>
</ModalContent>
</Modal>
</Box>
);
}
const cols = style === 'grid3' ? { base: 1, md: 3 } : { base: 1, md: 2, lg: 3 };
return (
<Box>
<Box className="section-head">
<Heading as="h3" size="md">Videa</Heading>
<Link as={RouterLink} to="/videa">
<Button size="sm" variant="outline" colorScheme="blue">Více videí</Button>
</Link>
</Box>
<SimpleGrid columns={cols} spacing={4}>
{items.map((it, idx) => (
<Card key={it.key} it={it} idx={idx} />
))}
</SimpleGrid>
{/* Video Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
<ModalOverlay bg="blackAlpha.800" />
<ModalContent bg="transparent" boxShadow="none" maxW="90vw">
<ModalCloseButton color="white" size="lg" bg="blackAlpha.600" _hover={{ bg: 'blackAlpha.700' }} borderRadius="full" zIndex={2} />
<ModalBody p={0}>
{selectedVideo && (
<AspectRatio ratio={16 / 9} maxH="90vh">
<iframe
src={`${selectedVideo.embedUrl}?autoplay=1&vq=hd1080&rel=0&modestbranding=1&playsinline=1`}
title={selectedVideo.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
referrerPolicy="strict-origin-when-cross-origin"
style={{ borderRadius: '8px' }}
/>
</AspectRatio>
)}
</ModalBody>
</ModalContent>
</Modal>
</Box>
);
};
export default VideosSection;
@@ -0,0 +1,3 @@
// Deprecated: use `src/layouts/AdminLayout` instead.
// This file re-exports the new AdminLayout to avoid code duplication.
export { default } from '../../layouts/AdminLayout';
+304
View File
@@ -0,0 +1,304 @@
import { Box, Container, HStack, Link, Text, Stack, Wrap, WrapItem, Button, Image, VStack, IconButton, SimpleGrid, Heading } from '@chakra-ui/react';
import React, { useEffect, useState } from 'react';
import { FiArrowUpRight, FiMail } from 'react-icons/fi';
import { FaFacebook, FaInstagram, FaYoutube } from 'react-icons/fa';
import { trackNavigation } from '../../utils/umami';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { assetUrl } from '../../utils/url';
const resolveBackendUrl = (path: string) => {
try {
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/cache') || path.startsWith('/uploads')) {
const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const u = new URL(base);
u.pathname = path;
return u.toString();
}
return path;
} catch {
return path;
}
};
interface Sponsor {
id: number | string;
name: string;
logo_url?: string;
website_url?: string;
is_active?: boolean;
}
const Footer: React.FC = () => {
const currentYear = new Date().getFullYear();
const [clubName, setClubName] = useState<string>('Fotbal Club');
const [shopUrl, setShopUrl] = useState<string | null>(null);
const [sponsors, setSponsors] = useState<Sponsor[]>([]);
const theme = useClubTheme();
const { data: settings } = usePublicSettings();
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(resolveBackendUrl('/cache/prefetch/facr_club_info.json'), { cache: 'no-cache' });
if (!res.ok) return;
const json = await res.json();
if (cancelled) return;
if (json?.name) setClubName(String(json.name));
} catch {}
try {
const res = await fetch(resolveBackendUrl('/cache/prefetch/settings.json'), { cache: 'no-cache' });
if (res?.ok) {
const s = await res.json();
if (!cancelled && s) {
setShopUrl(s?.shop_url || s?.eshop_url || null);
}
}
} catch {}
// Fetch sponsors
try {
const apiUrl = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const sponsorsRes = await fetch(`${apiUrl}/public/sponsors`);
if (sponsorsRes.ok) {
const data = await sponsorsRes.json();
if (!cancelled && Array.isArray(data)) {
// Filter active sponsors only
const activeSponsors = data.filter((s: Sponsor) => s.is_active !== false);
setSponsors(activeSponsors);
}
}
} catch {}
})();
return () => { cancelled = true; };
}, []);
return (
<>
{/* Navigation Footer */}
<Box bg="gray.800" color="white" mt={12} py={8} borderTop="1px" borderColor="whiteAlpha.200">
<Container maxW="container.xl">
<Stack direction={{ base: 'column', lg: 'row' }} spacing={6} justify="space-between" align={{ base: 'flex-start', lg: 'center' }} w="100%">
{/* Brand */}
<HStack spacing={3} align="center">
<Text fontWeight="700" fontSize="lg">{clubName}</Text>
</HStack>
{/* Navigation links */}
<Wrap spacing={4} shouldWrapChildren>
<WrapItem><Link href="/blog" color="whiteAlpha.900" fontWeight="600" _hover={{ color: 'white', textDecoration: 'underline' }}>Články</Link></WrapItem>
<WrapItem><Link href="/kalendar" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Zápasy</Link></WrapItem>
<WrapItem><Link href="/tabulky" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Tabulka</Link></WrapItem>
<WrapItem><Link href="/sponzori" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Sponzoři</Link></WrapItem>
<WrapItem><Link href="/kontakt" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Kontakt</Link></WrapItem>
<WrapItem><Link href="/pravidla-cookies" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Cookies</Link></WrapItem>
<WrapItem><Link href="/obchodni-podminky" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Obchodní podmínky</Link></WrapItem>
<WrapItem><Link href="/zasady-ochrany-osobnich-udaju" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Zásady ochrany osobních údajů</Link></WrapItem>
{shopUrl && (
<WrapItem><Link href={shopUrl} color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }} isExternal display="inline-flex" alignItems="center" gap={1}>Eshop <FiArrowUpRight /></Link></WrapItem>
)}
</Wrap>
</Stack>
</Container>
</Box>
{/* Sponsors Section */}
{sponsors.length > 0 && (
<Box bg="gray.700" color="white" py={8} borderTop="1px" borderColor="whiteAlpha.200">
<Container maxW="container.xl">
<VStack spacing={6}>
<Heading size="md" color="whiteAlpha.900">
Naši partneři
</Heading>
<SimpleGrid
columns={{ base: 2, sm: 3, md: 4, lg: 6 }}
spacing={6}
w="full"
>
{sponsors.map((sponsor) => (
<Link
key={sponsor.id}
href={sponsor.website_url || '#'}
isExternal={!!sponsor.website_url}
target={sponsor.website_url ? '_blank' : undefined}
rel={sponsor.website_url ? 'noopener noreferrer' : undefined}
display="flex"
alignItems="center"
justifyContent="center"
p={3}
bg="whiteAlpha.100"
borderRadius="md"
_hover={{ bg: 'whiteAlpha.200', transform: 'translateY(-2px)' }}
transition="all 0.2s"
onClick={() => trackNavigation('footer', `sponsor_${sponsor.name}`)}
>
<Image
src={assetUrl(sponsor.logo_url) || '/logo192.png'}
alt={sponsor.name}
maxH="60px"
maxW="full"
objectFit="contain"
filter="brightness(0) invert(1)"
opacity={0.9}
_hover={{ opacity: 1 }}
/>
</Link>
))}
</SimpleGrid>
</VStack>
</Container>
</Box>
)}
{/* Social Media Section */}
{(settings?.facebook_url || settings?.instagram_url || settings?.youtube_url) && (
<Box bg="gray.600" color="white" py={6} borderTop="1px" borderColor="whiteAlpha.200">
<Container maxW="container.xl">
<VStack spacing={4}>
<Text fontSize="lg" fontWeight="600" color="whiteAlpha.900">
Sledujte nás
</Text>
<HStack spacing={4}>
{settings?.facebook_url && (
<IconButton
as="a"
href={settings.facebook_url}
target="_blank"
rel="noopener noreferrer"
aria-label="Facebook"
icon={<FaFacebook />}
size="lg"
colorScheme="facebook"
variant="ghost"
color="white"
_hover={{
bg: 'whiteAlpha.200',
transform: 'translateY(-2px)',
color: '#1877F2'
}}
transition="all 0.2s"
onClick={() => trackNavigation('footer', 'social_facebook')}
/>
)}
{settings?.instagram_url && (
<IconButton
as="a"
href={settings.instagram_url}
target="_blank"
rel="noopener noreferrer"
aria-label="Instagram"
icon={<FaInstagram />}
size="lg"
variant="ghost"
color="white"
_hover={{
bg: 'whiteAlpha.200',
transform: 'translateY(-2px)',
color: '#E4405F'
}}
transition="all 0.2s"
onClick={() => trackNavigation('footer', 'social_instagram')}
/>
)}
{settings?.youtube_url && (
<IconButton
as="a"
href={settings.youtube_url}
target="_blank"
rel="noopener noreferrer"
aria-label="YouTube"
icon={<FaYoutube />}
size="lg"
variant="ghost"
color="white"
_hover={{
bg: 'whiteAlpha.200',
transform: 'translateY(-2px)',
color: '#FF0000'
}}
transition="all 0.2s"
onClick={() => trackNavigation('footer', 'social_youtube')}
/>
)}
</HStack>
</VStack>
</Container>
</Box>
)}
{/* Copyright Bar */}
<Box bg="gray.900" color="whiteAlpha.900" py={4}>
<Container maxW="container.xl">
<Text fontSize="sm" textAlign="center">
© {currentYear} {clubName}. Všechna práva vyhrazena.
</Text>
</Container>
</Box>
{/* MyClub Watermark - Clean White Branding */}
<Box bg="white" borderTop="1px" borderColor="gray.200" py={6}>
<Container maxW="container.xl">
<Stack
direction={{ base: 'column', md: 'row' }}
spacing={6}
justify="space-between"
align="center"
>
{/* Left: MyClub Logo & Text */}
<HStack spacing={4} align="center">
<Image
src="https://myclub.sportcreative.eu/logo.svg"
alt="MyClub"
h={{ base: '32px', md: '40px' }}
w="auto"
fallbackSrc="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 50'%3E%3Ctext x='10' y='35' font-family='Arial' font-size='24' font-weight='bold' fill='%23000'%3EMyClub%3C/text%3E%3C/svg%3E"
/>
<VStack align="start" spacing={0}>
<Text fontSize={{ base: 'sm', md: 'md' }} fontWeight="600" color="gray.800">
Stránku provozuje MyClub
</Text>
<Text fontSize={{ base: 'xs', md: 'sm' }} color="gray.600">
Profesionální webové stránky pro sportovní kluby
</Text>
</VStack>
</HStack>
{/* Right: CTA Buttons */}
<HStack spacing={3}>
<Button
as="a"
href="https://myclub.sportcreative.eu/kontakt"
target="_blank"
rel="noopener noreferrer"
size={{ base: 'sm', md: 'md' }}
colorScheme="blue"
variant="solid"
leftIcon={<FiMail />}
_hover={{ transform: 'translateY(-2px)', boxShadow: 'lg' }}
transition="all 0.2s"
>
Objednat
</Button>
<Button
as="a"
href="https://myclub.sportcreative.eu"
target="_blank"
rel="noopener noreferrer"
size={{ base: 'sm', md: 'md' }}
variant="outline"
colorScheme="gray"
rightIcon={<FiArrowUpRight />}
_hover={{ bg: 'gray.50' }}
>
Více info
</Button>
</HStack>
</Stack>
</Container>
</Box>
</>
);
};
export default Footer;
@@ -0,0 +1,59 @@
import { Box, Container, IconButton } from '@chakra-ui/react';
import { ReactNode, useEffect, useState } from 'react';
import { FiChevronUp } from 'react-icons/fi';
import Navbar from '../Navbar';
import Footer from './Footer';
interface MainLayoutProps {
children: ReactNode;
}
export const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
const [showTop, setShowTop] = useState(false);
useEffect(() => {
const onScroll = () => {
try {
setShowTop(window.scrollY > 400);
} catch {}
};
window.addEventListener('scroll', onScroll, { passive: true } as any);
onScroll();
return () => window.removeEventListener('scroll', onScroll as any);
}, []);
const scrollToTop = () => {
try {
window.scrollTo({ top: 0, behavior: 'smooth' });
} catch {
window.scrollTo(0, 0);
}
};
return (
<Box minH="100vh" bg="bg.app" overflowX="hidden">
<Box id="top" position="absolute" top={0} left={0} />
<Navbar />
<Container maxW="container.xl" py={8}>
{children}
</Container>
<Footer />
{showTop && (
<IconButton
aria-label="Zpět nahoru"
icon={<FiChevronUp />}
position="fixed"
right={{ base: 4, md: 6 }}
bottom={{ base: 4, md: 6 }}
zIndex={1000}
colorScheme="blue"
onClick={scrollToTop}
borderRadius="full"
shadow="md"
/>
)}
</Box>
);
};
export default MainLayout;
@@ -0,0 +1,39 @@
import { Box, Container, Heading, Text, VStack } from '@chakra-ui/react';
import NewsletterSubscribe from './NewsletterSubscribe';
type NewsletterSectionProps = {
title?: string;
description?: string;
bgColor?: string;
py?: number | string;
};
export default function NewsletterSection({
title = 'Přihlaste se k odběru novinek',
description = 'Nenechte si ujít žádné novinky, zápasy a akce našeho klubu. Odebírejte náš newsletter a buďte v obraze.',
bgColor = 'gray.50',
py = 16,
}: NewsletterSectionProps) {
return (
<Box as="section" bg={bgColor} py={py}>
<Container maxW="container.lg">
<VStack spacing={6} align="center" textAlign="center" maxW="3xl" mx="auto">
<Heading as="h2" size="xl">
{title}
</Heading>
{description && (
<Text fontSize="lg" color="gray.600" maxW="2xl">
{description}
</Text>
)}
<Box w="100%" maxW="md" mt={4}>
<NewsletterSubscribe />
</Box>
<Text fontSize="sm" color="gray.500" mt={2}>
Vaši e-mailovou adresu budeme používat pouze pro zasílání novinek. Můžete se kdykoli odhlásit.
</Text>
</VStack>
</Container>
</Box>
);
}
@@ -0,0 +1,127 @@
import { useState } from 'react';
import {
Box,
Button,
Flex,
FormControl,
FormErrorMessage,
Input,
Text,
useToast,
VStack,
useColorModeValue
} from '@chakra-ui/react';
import { useForm } from 'react-hook-form';
import { subscribeToNewsletter } from '../../services/public';
import { trackNewsletterSubscribe, trackFormSubmit } from '../../utils/umami';
type FormData = {
email: string;
};
export default function NewsletterSubscribe() {
const [isLoading, setIsLoading] = useState(false);
const toast = useToast();
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<FormData>();
const onSubmit = async (data: FormData) => {
setIsLoading(true);
try {
await subscribeToNewsletter(data.email);
// Track successful newsletter subscription
trackNewsletterSubscribe(window.location.pathname);
trackFormSubmit('Newsletter Subscribe', true);
toast({
title: 'Přihlášení k odběru proběhlo úspěšně',
description: 'Děkujeme za přihlášení k odběru našeho newsletteru!',
status: 'success',
duration: 5000,
isClosable: true,
});
reset();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Nastala chyba při přihlašování k odběru';
// Track failed subscription
trackFormSubmit('Newsletter Subscribe', false);
toast({
title: 'Chyba',
description: errorMessage,
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setIsLoading(false);
}
};
const cardBg = useColorModeValue('white', 'transparent');
const cardBorder = useColorModeValue('gray.200', 'gray.600');
const headingColor = useColorModeValue('gray.800', 'white');
const textColor = useColorModeValue('gray.600', 'gray.300');
const disclaimerColor = useColorModeValue('gray.500', 'gray.400');
return (
<Box w="100%" maxW="xl" mx="auto" p={4} bg={cardBg} borderRadius="md" boxShadow="sm" borderWidth="1px" borderColor={cardBorder}>
<VStack spacing={3} align="stretch">
<Text fontSize="xl" fontWeight="bold" textAlign="center" color={headingColor}>
Přihlaste se k odběru novinek
</Text>
<Text textAlign="center" color={textColor} mb={2}>
Budeme vás informovat o novinkách, zápasech a akcích našeho klubu.
</Text>
<form onSubmit={handleSubmit(onSubmit)}>
<VStack spacing={3}>
<FormControl isInvalid={!!errors.email}>
<Input
id="email"
type="email"
placeholder="Váš e-mail"
{...register('email', {
required: 'E-mail je povinný',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Neplatná e-mailová adresa',
},
})}
size="md"
disabled={isLoading}
/>
<FormErrorMessage>
{errors.email && errors.email.message}
</FormErrorMessage>
</FormControl>
<Button
type="submit"
colorScheme="blue"
size="md"
width="100%"
isLoading={isLoading}
loadingText="Odesílám..."
data-umami-event="Newsletter Submit"
data-umami-event-location={window.location.pathname}
>
Odeslat
</Button>
</VStack>
</form>
<Text fontSize="xs" color={disclaimerColor} textAlign="center" mt={2}>
Odesláním formuláře souhlasíte se zpracováním osobních údajů.
Vaši e-mailovou adresu budeme používat pouze pro zasílání novinek.
</Text>
</VStack>
</Box>
);
}
@@ -0,0 +1,118 @@
import React from 'react';
import {
Box,
VStack,
Heading,
Spinner,
Text,
useColorModeValue,
} from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getPolls, getPoll } from '../../services/polls';
import PollCard from './PollCard';
interface EmbeddedPollProps {
articleId?: number;
eventId?: number;
videoUrl?: string;
title?: string;
showTitle?: boolean;
}
/**
* EmbeddedPoll component - displays polls related to specific content
* Use in article pages, event pages, or video pages
*/
const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
articleId,
eventId,
videoUrl,
title = 'Hlasování',
showTitle = true,
}) => {
const bgSection = useColorModeValue('gray.50', 'gray.900');
// Build query params based on what's provided
const queryParams: any = {};
if (articleId) queryParams.article_id = articleId;
if (eventId) queryParams.event_id = eventId;
if (videoUrl) queryParams.video_url = videoUrl;
// Fetch polls related to this content
const { data: polls, isLoading } = useQuery({
queryKey: ['embedded-polls', queryParams],
queryFn: () => getPolls(queryParams),
enabled: !!(articleId || eventId || videoUrl), // Only fetch if at least one param is provided
staleTime: 2 * 60 * 1000,
});
// Get full poll data for each
const pollsToDisplay = polls?.slice(0, 3) || []; // Max 3 polls per content
const { data: pollsData, isLoading: isLoadingPolls } = useQuery({
queryKey: ['embedded-polls-details', pollsToDisplay.map((p) => p.id)],
queryFn: async () => {
const promises = pollsToDisplay.map((poll) => getPoll(poll.id));
return await Promise.all(promises);
},
enabled: pollsToDisplay.length > 0,
});
// Don't render anything if no content identifier provided
if (!articleId && !eventId && !videoUrl) {
return null;
}
// Don't render if loading initially
if (isLoading) {
return (
<Box py={4}>
<VStack spacing={2}>
<Spinner size="sm" />
<Text fontSize="sm" color="gray.500">
Načítání hlasování...
</Text>
</VStack>
</Box>
);
}
// Don't render if no polls found
if (!polls || polls.length === 0 || !pollsData || pollsData.length === 0) {
return null;
}
return (
<Box bg={bgSection} py={8} px={4} borderRadius="xl" my={8}>
<VStack spacing={6} maxW="3xl" mx="auto">
{showTitle && (
<Heading size="md" textAlign="center">
{title}
</Heading>
)}
<VStack spacing={4} w="full">
{isLoadingPolls ? (
<VStack py={8}>
<Spinner />
<Text>Načítání...</Text>
</VStack>
) : (
pollsData.map((pollResponse) => (
<Box key={pollResponse.poll.id} w="full">
<PollCard
poll={pollResponse.poll}
hasVoted={pollResponse.has_voted}
isActive={pollResponse.is_active}
canShowResults={pollResponse.can_show_results}
/>
</Box>
))
)}
</VStack>
</VStack>
</Box>
);
};
export default EmbeddedPoll;
+370
View File
@@ -0,0 +1,370 @@
import React, { useState } from 'react';
import {
Box,
VStack,
HStack,
Text,
Button,
Radio,
RadioGroup,
Checkbox,
CheckboxGroup,
Progress,
Badge,
useToast,
Image,
Heading,
useColorModeValue,
} from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { CheckIcon } from '@chakra-ui/icons';
import {
Poll,
PollOption,
votePoll,
getPollResults,
generateSessionToken,
} from '../../services/polls';
interface PollCardProps {
poll: Poll;
hasVoted: boolean;
isActive: boolean;
canShowResults: boolean;
onVoteSuccess?: () => void;
}
const PollCard: React.FC<PollCardProps> = ({
poll,
hasVoted: initialHasVoted,
isActive,
canShowResults: initialCanShowResults,
onVoteSuccess,
}) => {
const toast = useToast();
const queryClient = useQueryClient();
const [selectedOptions, setSelectedOptions] = useState<number[]>([]);
const [hasVoted, setHasVoted] = useState(initialHasVoted);
const [canShowResults, setCanShowResults] = useState(initialCanShowResults);
const [results, setResults] = useState<any[]>([]);
const [showingResults, setShowingResults] = useState(initialCanShowResults);
const bgCard = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
// Vote mutation
const voteMutation = useMutation({
mutationFn: () => {
const sessionToken = generateSessionToken();
return votePoll(poll.id, {
option_ids: selectedOptions,
session_token: sessionToken,
});
},
onSuccess: async () => {
setHasVoted(true);
setCanShowResults(true);
setShowingResults(true);
// Fetch results
try {
const resultsData = await getPollResults(poll.id);
setResults(resultsData.results);
} catch (error) {
console.error('Failed to fetch results:', error);
}
queryClient.invalidateQueries({ queryKey: ['polls'] });
queryClient.invalidateQueries({ queryKey: ['poll', poll.id] });
toast({
title: 'Hlas zaznamenán!',
description: 'Děkujeme za vaši účast v anketě.',
status: 'success',
duration: 3000,
});
if (onVoteSuccess) {
onVoteSuccess();
}
},
onError: (error: any) => {
toast({
title: 'Chyba',
description: error.response?.data?.error || 'Nepodařilo se zaznamenat váš hlas',
status: 'error',
duration: 5000,
});
},
});
const handleVote = () => {
if (selectedOptions.length === 0) {
toast({
title: 'Vyberte možnost',
description: 'Před hlasováním vyberte alespoň jednu možnost.',
status: 'warning',
duration: 3000,
});
return;
}
if (poll.allow_multiple && selectedOptions.length > poll.max_choices) {
toast({
title: 'Příliš mnoho voleb',
description: `Můžete vybrat maximálně ${poll.max_choices} možností.`,
status: 'warning',
duration: 3000,
});
return;
}
voteMutation.mutate();
};
const handleSingleChoice = (value: string) => {
setSelectedOptions([parseInt(value)]);
};
const handleMultipleChoice = (values: (string | number)[]) => {
const numValues = values.map((v) => (typeof v === 'string' ? parseInt(v) : v));
if (numValues.length <= poll.max_choices) {
setSelectedOptions(numValues);
}
};
const loadResults = async () => {
try {
const resultsData = await getPollResults(poll.id);
setResults(resultsData.results);
setShowingResults(true);
} catch (error: any) {
toast({
title: 'Chyba',
description: 'Nepodařilo se načíst výsledky',
status: 'error',
duration: 3000,
});
}
};
const calculatePercentage = (voteCount: number) => {
if (poll.total_votes === 0) return 0;
return (voteCount / poll.total_votes) * 100;
};
// Show results if available
if (showingResults && canShowResults) {
const displayResults = results.length > 0 ? results : poll.options.map(opt => ({
option_id: opt.id,
text: opt.text,
vote_count: opt.vote_count,
percentage: calculatePercentage(opt.vote_count),
image_url: opt.image_url,
}));
return (
<Box
bg={bgCard}
borderWidth="1px"
borderColor={borderColor}
borderRadius="xl"
p={6}
boxShadow="md"
>
<VStack spacing={4} align="stretch">
{poll.image_url && (
<Image
src={poll.image_url}
alt={poll.title}
borderRadius="lg"
maxH="200px"
objectFit="cover"
/>
)}
<HStack justify="space-between" align="start">
<Heading size="md">{poll.title}</Heading>
{hasVoted && (
<Badge colorScheme="green" fontSize="sm">
<HStack spacing={1}>
<CheckIcon boxSize={3} />
<Text>Hlasováno</Text>
</HStack>
</Badge>
)}
</HStack>
{poll.description && (
<Text fontSize="sm" color="gray.600">
{poll.description}
</Text>
)}
<VStack spacing={3} align="stretch">
<Text fontWeight="bold" fontSize="sm" color="gray.500">
Výsledky ({poll.total_votes} hlasů)
</Text>
{displayResults.map((result) => (
<Box key={result.option_id}>
<HStack justify="space-between" mb={1}>
<Text fontWeight="medium">{result.text}</Text>
<Text fontSize="sm" color="gray.500">
{result.vote_count} ({result.percentage.toFixed(1)}%)
</Text>
</HStack>
<Progress
value={result.percentage}
colorScheme="blue"
borderRadius="full"
size="sm"
/>
</Box>
))}
</VStack>
</VStack>
</Box>
);
}
// Show voting form
return (
<Box
bg={bgCard}
borderWidth="1px"
borderColor={borderColor}
borderRadius="xl"
p={6}
boxShadow="md"
>
<VStack spacing={4} align="stretch">
{poll.image_url && (
<Image
src={poll.image_url}
alt={poll.title}
borderRadius="lg"
maxH="200px"
objectFit="cover"
/>
)}
<Heading size="md">{poll.title}</Heading>
{poll.description && (
<Text fontSize="sm" color="gray.600">
{poll.description}
</Text>
)}
{!isActive && (
<Badge colorScheme="orange">Anketa je momentálně uzavřena</Badge>
)}
{isActive && (
<>
{poll.allow_multiple ? (
<CheckboxGroup
value={selectedOptions.map(String)}
onChange={handleMultipleChoice}
>
<VStack spacing={2} align="stretch">
<Text fontSize="sm" color="gray.500">
Vyberte {poll.max_choices} možností
</Text>
{poll.options.map((option) => (
<Box
key={option.id}
p={3}
borderWidth="1px"
borderRadius="md"
_hover={{ bg: hoverBg }}
cursor="pointer"
>
<Checkbox value={String(option.id)}>
<VStack align="start" spacing={1}>
<Text>{option.text}</Text>
{option.description && (
<Text fontSize="xs" color="gray.500">
{option.description}
</Text>
)}
</VStack>
</Checkbox>
</Box>
))}
</VStack>
</CheckboxGroup>
) : (
<RadioGroup
value={selectedOptions[0]?.toString() || ''}
onChange={handleSingleChoice}
>
<VStack spacing={2} align="stretch">
{poll.options.map((option) => (
<Box
key={option.id}
p={3}
borderWidth="1px"
borderRadius="md"
_hover={{ bg: hoverBg }}
cursor="pointer"
>
<Radio value={String(option.id)}>
<VStack align="start" spacing={1}>
<Text>{option.text}</Text>
{option.description && (
<Text fontSize="xs" color="gray.500">
{option.description}
</Text>
)}
{option.player && (
<HStack spacing={2}>
{option.player.image_url && (
<Image
src={option.player.image_url}
alt={`${option.player.first_name} ${option.player.last_name}`}
boxSize="24px"
borderRadius="full"
/>
)}
<Text fontSize="xs" color="gray.500">
#{option.player.jersey_number} {option.player.first_name}{' '}
{option.player.last_name}
</Text>
</HStack>
)}
</VStack>
</Radio>
</Box>
))}
</VStack>
</RadioGroup>
)}
<Button
colorScheme="blue"
onClick={handleVote}
isLoading={voteMutation.isPending}
isDisabled={!isActive || selectedOptions.length === 0}
>
Hlasovat
</Button>
</>
)}
{canShowResults && !showingResults && (
<Button variant="outline" onClick={loadResults} size="sm">
Zobrazit výsledky
</Button>
)}
<Text fontSize="xs" color="gray.500" textAlign="center">
Celkem hlasů: {poll.total_votes}
</Text>
</VStack>
</Box>
);
};
export default PollCard;
@@ -0,0 +1,16 @@
import React from 'react';
import { Box } from '@chakra-ui/react';
import { ScoreboardState } from '@/services/scoreboard';
import ScoreboardPreview from './ScoreboardPreview';
// Full display component intended for public overlay usage.
// For now it reuses ScoreboardPreview visuals; can diverge later for larger sizing.
const ScoreboardDisplay: React.FC<{ state: ScoreboardState }> = ({ state }) => {
return (
<Box>
<ScoreboardPreview state={state} />
</Box>
);
};
export default ScoreboardDisplay;
@@ -0,0 +1,173 @@
import React from 'react';
import { Box, HStack, Text, Image } from '@chakra-ui/react';
import { ScoreboardState } from '@/services/scoreboard';
export const ScoreboardPreview: React.FC<{ state: ScoreboardState }> = ({ state }) => {
const theme = state.theme || 'pill';
switch (theme) {
case 'pill':
return (
<HStack spacing={2} px={1.5} py={1} borderRadius="full" bg="white" borderWidth="1px" borderColor="gray.200" boxShadow="sm" width="max-content">
<SegmentTeam colorA={state.primaryColor} left>
{state.homeLogo ? <Image src={state.homeLogo} alt="home" boxSize="16px" objectFit="contain" /> : null}
<Text textTransform="uppercase" fontSize="sm" lineHeight={1}>{state.homeShort || deriveShortLocal(state.homeName)}</Text>
</SegmentTeam>
<SegmentScore>{state.homeScore} {state.awayScore}</SegmentScore>
<SegmentTeam colorA={state.secondaryColor} right>
<Text textTransform="uppercase" fontSize="sm" lineHeight={1}>{state.awayShort || deriveShortLocal(state.awayName)}</Text>
{state.awayLogo ? <Image src={state.awayLogo} alt="away" boxSize="16px" objectFit="contain" /> : null}
</SegmentTeam>
</HStack>
);
case 'classic':
case 'var1':
return (
<HStack spacing={3} bgGradient="linear(to-b, #c8d4dc, #a8b8c4)" px={5} py={3} borderRadius="lg" boxShadow="md" width="max-content">
<Box bg="white" color="black" fontWeight="bold" px={3} py={1} borderRadius="md" fontSize="lg">{formatTimer(state.halfLength)}</Box>
<Box bg={state.primaryColor || '#34495e'} color="white" px={4} py={2} borderRadius="md" fontWeight="bold">{state.homeShort || deriveShortLocal(state.homeName)}</Box>
<Text fontWeight="bold" color="black">{state.homeScore}-{state.awayScore}</Text>
<Box bg={state.secondaryColor || '#2c3e50'} color="white" px={4} py={2} borderRadius="md" fontWeight="bold">{state.awayShort || deriveShortLocal(state.awayName)}</Box>
</HStack>
);
case 'var2':
return (
<HStack spacing={0} borderRadius="md" overflow="hidden" boxShadow="md" width="max-content">
<Box bgGradient="linear(135deg, #4a5568, #2d3748)" color="white" px={3} py={2} fontWeight="bold">{formatTimer(state.halfLength)}</Box>
<Box bgGradient="linear(135deg, #2c5282, #2a4365)" color="white" px={4} py={2} fontWeight="bold">{state.homeShort || deriveShortLocal(state.homeName)}</Box>
<Box bgGradient="linear(135deg, #2c5282, #2a4365)" color="white" px={3} py={2} fontWeight="bold">{state.homeScore}-{state.awayScore}</Box>
<Box bgGradient="linear(135deg, #2c5282, #2a4365)" color="white" px={4} py={2} fontWeight="bold">{state.awayShort || deriveShortLocal(state.awayName)}</Box>
</HStack>
);
case 'var3':
return (
<Box textAlign="center" fontFamily="Poppins, Arial, sans-serif">
<HStack spacing={0} justify="center">
<Box w="102px" h="38px" bg="#F6F6F6" lineHeight="41px" position="relative">
<Box position="absolute" left="-8px" top={0} w="6px" h="38px" bg={state.primaryColor || '#ea2212'} />
<Text>{state.homeShort || deriveShortLocal(state.homeName)}</Text>
</Box>
<Box w="102px" h="38px" bg="#F6F6F6" lineHeight="41px" zIndex={2} boxShadow="0 3px 10px rgba(0,0,0,0.7)">
<Text fontWeight="bold">{state.homeScore}-{state.awayScore}</Text>
</Box>
<Box w="102px" h="38px" bg="#F6F6F6" lineHeight="41px" position="relative">
<Box position="absolute" right="-8px" top={0} w="6px" h="38px" bg={state.secondaryColor || '#ea2212'} />
<Text>{state.awayShort || deriveShortLocal(state.awayName)}</Text>
</Box>
</HStack>
<Box mt={2} w="306px" mx="auto" bg="#F6F6F6">
<Text>{formatTimer(state.halfLength)}</Text>
</Box>
</Box>
);
case 'var4':
return (
<Box w="340px" borderWidth="1px" borderRadius="xl" boxShadow="xl" p={4} bg="white" color="gray.900">
<HStack>
<Text fontWeight="bold">{state.homeName}</Text>
<Text ml="auto" fontWeight="extrabold">{state.homeScore}</Text>
</HStack>
<Box textAlign="center" fontWeight="extrabold" py={1}>VS</Box>
<HStack>
<Text fontWeight="bold">{state.awayName}</Text>
<Text ml="auto" fontWeight="extrabold">{state.awayScore}</Text>
</HStack>
<HStack justify="flex-end" fontSize="sm" opacity={0.8} pt={2}>
<Text>{formatTimer(state.halfLength)}</Text>
</HStack>
</Box>
);
default:
return (
<HStack spacing={3} bg="gray.900" color="white" px={4} py={3} borderRadius="lg" boxShadow="lg" width="max-content">
<Text fontWeight="bold">{state.homeName}</Text>
<Text fontWeight="black">{state.homeScore} : {state.awayScore}</Text>
<Text fontWeight="bold">{state.awayName}</Text>
</HStack>
);
}
};
// Small presentational helpers for the pill theme
const SegmentTeam: React.FC<{ colorA?: string; left?: boolean; right?: boolean; children: React.ReactNode }> = ({ colorA = '#1e3a8a', left, right, children }) => {
return (
<HStack
px={2}
py={0.5}
borderRadius="full"
bgGradient={`linear(to-r, ${colorA}, ${shadeColor(colorA, 20)})`}
color="white"
spacing={1.5}
position="relative"
_before={left ? { content: '""', position: 'absolute', left: '-10px', top: 0, bottom: 0, width: '14px', bgGradient: `linear(to-r, ${colorA}, ${shadeColor(colorA, 20)})`, borderTopLeftRadius: '999px', borderBottomLeftRadius: '999px' } : undefined}
_after={right ? { content: '""', position: 'absolute', right: '-10px', top: 0, bottom: 0, width: '14px', bgGradient: `linear(to-r, ${colorA}, ${shadeColor(colorA, 20)})`, borderTopRightRadius: '999px', borderBottomRightRadius: '999px' } : undefined}
minW="46px"
>
{children}
</HStack>
);
};
const SegmentScore: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<Box px={2} py={0.5} borderRadius="md" bg="gray.50" borderWidth="1px" borderColor="gray.200" fontWeight="800" minW="58px" textAlign="center" fontSize="sm">
{children}
</Box>
);
};
function formatTimer(halfLength: number): string {
// Simple static mm:ss display using half length as baseline; real timer would come from backend
const min = Math.max(0, Math.min(halfLength, 99));
return `${String(min).padStart(2, '0')}:00`;
}
function deriveShortLocal(name?: string) {
if (!name) return '---';
const s = String(name).trim().toUpperCase();
if (!s) return '---';
const map: Record<string, string> = {
'Á':'A','Ä':'A','Å':'A','Â':'A','À':'A',
'Č':'C','Ć':'C','Ç':'C',
'Ď':'D',
'É':'E','Ě':'E','È':'E','Ë':'E','Ê':'E',
'Í':'I','Ì':'I','Ï':'I','Î':'I',
'Ň':'N','Ń':'N',
'Ó':'O','Ö':'O','Ô':'O','Ò':'O',
'Ř':'R',
'Š':'S','Ś':'S',
'Ť':'T',
'Ú':'U','Ů':'U','Ù':'U','Ü':'U','Û':'U',
'Ý':'Y',
'Ž':'Z',
};
let out = '';
for (const ch of s) {
const c = map[ch] || ch;
if (c >= 'A' && c <= 'Z') {
out += c;
if (out.length === 3) break;
}
}
while (out.length < 3) out += '-';
return out;
}
function shadeColor(hex: string, percent: number) {
// Simple hex shade function
try {
const n = hex.replace('#','');
const num = parseInt(n.length === 3 ? n.split('').map((c)=>c+c).join('') : n, 16);
let r = (num >> 16) & 0xff;
let g = (num >> 8) & 0xff;
let b = num & 0xff;
r = Math.min(255, Math.max(0, Math.round(r + (percent/100)*255)));
g = Math.min(255, Math.max(0, Math.round(g + (percent/100)*255)));
b = Math.min(255, Math.max(0, Math.round(b + (percent/100)*255)));
return `#${[r,g,b].map(v=>v.toString(16).padStart(2,'0')).join('')}`;
} catch {
return hex;
}
}
export default ScoreboardPreview;
+128
View File
@@ -0,0 +1,128 @@
import { Helmet } from 'react-helmet-async';
import { useEffect, useState } from 'react';
import api from '../../services/api';
import { assetUrl } from '../../utils/url';
import { usePublicSettings } from '../../hooks/usePublicSettings';
interface SEOData {
site_title?: string;
site_description?: string;
meta_keywords?: string;
default_og_image_url?: string;
twitter_handle?: string;
canonical_base_url?: string;
additional_meta?: string; // raw HTML strings not injected for safety here
enable_indexing?: boolean;
}
export default function DefaultSEO() {
const [seo, setSeo] = useState<SEOData | null>(null);
const [social, setSocial] = useState<{ facebook?: string; instagram?: string; youtube?: string } | null>(null);
const { data: publicSettings } = usePublicSettings();
useEffect(() => {
let mounted = true;
api.get<SEOData>('/seo')
.then(res => { if (mounted) setSeo(res.data as any); })
.catch(() => {});
// Socials can come from settings (hook). Set when available
// We still keep axios SEO fetch above for dedicated SEO fields
return () => { mounted = false; };
}, []);
// Keep socials in sync when settings load
useEffect(() => {
if (!publicSettings) return;
setSocial({
facebook: publicSettings.facebook_url,
instagram: publicSettings.instagram_url,
youtube: publicSettings.youtube_url,
});
}, [publicSettings]);
const fallbackClubName = publicSettings?.club_name;
const title = (seo?.site_title && seo.site_title.trim()) || (fallbackClubName && fallbackClubName.trim()) || 'MyClub';
const desc = (seo?.site_description && seo.site_description.trim()) || (fallbackClubName ? `Official ${fallbackClubName} Website` : 'Official MyClub Website');
const keywords = seo?.meta_keywords || '';
const rawOg = seo?.default_og_image_url || publicSettings?.club_logo_url || '/logo512.png';
const ogImg = assetUrl(rawOg) || rawOg;
const twitter = seo?.twitter_handle || '';
const origin = seo?.canonical_base_url || (typeof window !== 'undefined' ? window.location.origin : '');
const sameAs: string[] = [];
if (twitter) {
const handle = twitter.startsWith('@') ? twitter.slice(1) : twitter;
sameAs.push(`https://twitter.com/${handle}`);
}
if (social?.facebook) sameAs.push(social.facebook);
if (social?.instagram) sameAs.push(social.instagram);
if (social?.youtube) sameAs.push(social.youtube);
// robots
const robots = seo?.enable_indexing === false ? 'noindex, nofollow' : 'index, follow';
// Ensure document title updates as soon as we have a computed title
useEffect(() => {
if (typeof document !== 'undefined' && title) {
document.title = title;
}
}, [title]);
return (
<Helmet defaultTitle={title} titleTemplate={`%s | ${fallbackClubName || title}`}>
<title>{title}</title>
<meta name="description" content={desc} />
{keywords && <meta name="keywords" content={keywords} />}
<meta name="robots" content={robots} />
{/* Favicon and app icons based on club logo */}
{publicSettings?.club_logo_url && (
<>
<link rel="icon" href={assetUrl(publicSettings.club_logo_url)} />
<link rel="shortcut icon" href={assetUrl(publicSettings.club_logo_url)} />
<link rel="apple-touch-icon" href={assetUrl(publicSettings.club_logo_url)} />
</>
)}
{/* Open Graph */}
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={desc} />
<meta property="og:image" content={ogImg} />
{/* Twitter */}
<meta name="twitter:card" content="summary_large_image" />
{twitter && <meta name="twitter:site" content={twitter} />}
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={desc} />
<meta name="twitter:image" content={ogImg} />
{/* Canonical (best-effort: base only; pages can override) */}
{seo?.canonical_base_url && <link rel="canonical" href={seo.canonical_base_url} />}
{/* JSON-LD: WebSite + SearchAction */}
{origin && (
<script type="application/ld+json">
{JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebSite',
url: origin,
name: title,
potentialAction: {
'@type': 'SearchAction',
target: `${origin}/blog?q={search_term_string}`,
'query-input': 'required name=search_term_string'
}
})}
</script>
)}
{/* JSON-LD: Organization */}
{origin && (
<script type="application/ld+json">
{JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Organization',
url: origin,
name: title,
logo: ogImg,
sameAs: sameAs.length ? sameAs : undefined,
})}
</script>
)}
</Helmet>
);
}
@@ -0,0 +1,269 @@
import React, { useEffect, useRef, useState } from 'react';
import { Box, HStack, IconButton, Heading, useColorModeValue } from '@chakra-ui/react';
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
import { useClubTheme } from '../../contexts/ClubThemeContext';
interface HorizontalScrollerProps {
title?: string;
rightAction?: React.ReactNode;
children: React.ReactNode;
// Enhancements (all optional; defaults keep legacy behavior)
draggable?: boolean; // enable mouse/touch drag scrolling
autoScroll?: boolean; // enable continuous auto-scroll
autoSpeed?: number; // pixels per frame (~60fps). default 1.2
rewindLoop?: boolean; // if true, jump to start when reaching end (instead of stopping)
pauseOnHover?: boolean; // pause auto-scroll on hover
infiniteScroll?: boolean; // duplicate children for seamless infinite loop
}
const SCROLL_AMOUNT = 0.7; // 70% of viewport width per click
const HorizontalScroller: React.FC<HorizontalScrollerProps> = ({ title, rightAction, children, draggable = false, autoScroll = false, autoSpeed = 1.2, rewindLoop = true, pauseOnHover = true, infiniteScroll = false }) => {
const containerRef = useRef<HTMLDivElement>(null);
const theme = useClubTheme();
const cardBg = useColorModeValue('white', 'gray.800');
const [isHovering, setIsHovering] = useState(false);
const isPointerDownRef = useRef(false);
const startXRef = useRef(0);
const startScrollLeftRef = useRef(0);
const hasDraggedRef = useRef(false);
const rafIdRef = useRef<number | null>(null);
const scrollBy = (dir: 1 | -1) => {
const el = containerRef.current;
if (!el) return;
// Use the container width rather than window width for more accurate scrolling
const amount = Math.floor(el.clientWidth * SCROLL_AMOUNT) * dir;
el.scrollBy({ left: amount, behavior: 'smooth' });
};
// Mouse/touch drag handlers
const onPointerDown = (clientX: number) => {
const el = containerRef.current;
if (!el) return;
isPointerDownRef.current = true;
hasDraggedRef.current = false;
startXRef.current = clientX;
startScrollLeftRef.current = el.scrollLeft;
// while dragging, disable smooth scroll behavior and selection
el.style.scrollSnapType = 'none';
el.style.cursor = 'grabbing';
(document.body as any).style.userSelect = 'none';
};
const onPointerMove = (clientX: number) => {
const el = containerRef.current;
if (!el || !isPointerDownRef.current) return;
const dx = clientX - startXRef.current;
if (Math.abs(dx) > 3) hasDraggedRef.current = true;
el.scrollLeft = startScrollLeftRef.current - dx;
};
const onPointerUp = () => {
const el = containerRef.current;
isPointerDownRef.current = false;
if (el) {
el.style.cursor = 'grab';
}
(document.body as any).style.userSelect = '';
if (el) {
// restore snap a moment later for a natural feel
setTimeout(() => { if (el) el.style.scrollSnapType = 'x mandatory'; }, 100);
}
};
// Auto-scroll implementation with infinite scroll support
useEffect(() => {
const el = containerRef.current;
if (!autoScroll || !el) return;
// For infinite scroll, set initial position to middle of duplicated content
if (infiniteScroll) {
const halfWidth = el.scrollWidth / 2;
el.scrollLeft = halfWidth;
}
let running = true;
const step = () => {
if (!running || !el) return;
const scrollLeft = el.scrollLeft;
const scrollWidth = el.scrollWidth;
const clientWidth = el.clientWidth;
const shouldPause = (pauseOnHover && isHovering) || isPointerDownRef.current;
if (!shouldPause) {
if (infiniteScroll) {
// Seamless infinite loop by resetting when reaching halfway
const halfWidth = scrollWidth / 2;
if (scrollLeft >= halfWidth) {
el.scrollLeft = 0;
} else {
el.scrollLeft += autoSpeed;
}
} else {
// Original rewind behavior
const atEnd = scrollLeft + clientWidth >= scrollWidth - 2;
if (atEnd && rewindLoop) {
el.scrollLeft = 0;
} else if (!atEnd) {
el.scrollLeft += autoSpeed;
}
}
}
rafIdRef.current = requestAnimationFrame(step);
};
// Start the animation loop
rafIdRef.current = requestAnimationFrame(step);
return () => {
running = false;
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
};
}, [autoScroll, autoSpeed, pauseOnHover, isHovering, rewindLoop, infiniteScroll]);
return (
<Box position="relative">
{(title || rightAction) && (
<HStack justify="space-between" mb={3}>
{title && (
<Heading size="lg" letterSpacing="0.04em" style={{ textTransform: 'uppercase' }}>
{title}
</Heading>
)}
{rightAction}
</HStack>
)}
{/* gradient masks for fancy edges */}
<Box
pointerEvents="none"
position="absolute"
left={0}
top={0}
bottom={0}
w={{ base: 16, md: 24 }}
bgGradient={useColorModeValue(
'linear(to-r, white, rgba(255,255,255,0.9), rgba(255,255,255,0.4), transparent)',
'linear(to-r, gray.900, rgba(17,25,40,0.9), rgba(17,25,40,0.4), transparent)'
)}
zIndex={1}
/>
<Box
pointerEvents="none"
position="absolute"
right={0}
top={0}
bottom={0}
w={{ base: 16, md: 24 }}
bgGradient={useColorModeValue(
'linear(to-l, white, rgba(255,255,255,0.9), rgba(255,255,255,0.4), transparent)',
'linear(to-l, gray.900, rgba(17,25,40,0.9), rgba(17,25,40,0.4), transparent)'
)}
zIndex={1}
/>
{/* scroll area */}
<HStack
ref={containerRef}
spacing={4}
overflowX="auto"
py={2}
px={1}
cursor={draggable ? 'grab' : 'default'}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => { setIsHovering(false); if (draggable) onPointerUp(); }}
onMouseDown={(e) => { if (!draggable) return; e.preventDefault(); onPointerDown(e.clientX); }}
onMouseMove={(e) => { if (!draggable) return; onPointerMove(e.clientX); }}
onMouseUp={() => { if (!draggable) return; onPointerUp(); }}
onTouchStart={(e) => { if (!draggable) return; if (e.touches[0]) onPointerDown(e.touches[0].clientX); }}
onTouchMove={(e) => { if (!draggable) return; if (e.touches[0]) onPointerMove(e.touches[0].clientX); }}
onTouchEnd={() => { if (!draggable) return; onPointerUp(); }}
css={{
scrollSnapType: infiniteScroll ? 'none' : 'x proximity',
scrollBehavior: 'smooth',
WebkitOverflowScrolling: 'touch',
'&::-webkit-scrollbar': {
height: '6px',
},
'&::-webkit-scrollbar-track': {
background: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
background: useColorModeValue('rgba(0,0,0,0.15)', 'rgba(255,255,255,0.15)'),
borderRadius: '3px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: useColorModeValue('rgba(0,0,0,0.25)', 'rgba(255,255,255,0.25)'),
},
}}
>
{children}
{infiniteScroll && children}
</HStack>
{/* navigation buttons - must be above gradient masks */}
<IconButton
aria-label="scroll left"
icon={<ChevronLeftIcon boxSize={6} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
scrollBy(-1);
}}
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onTouchStart={(e) => { e.preventDefault(); e.stopPropagation(); }}
position="absolute"
top="50%"
transform="translateY(-50%)"
left={{ base: 1, md: 2 }}
size="lg"
colorScheme="blackAlpha"
bg={useColorModeValue('rgba(255,255,255,0.95)', 'rgba(45,55,72,0.95)')}
color={useColorModeValue('gray.800', 'white')}
boxShadow="xl"
_hover={{ bg: useColorModeValue('white', 'gray.600'), transform: 'translateY(-50%) scale(1.15)', boxShadow: '2xl' }}
_active={{ transform: 'translateY(-50%) scale(0.95)' }}
transition="all 0.2s"
zIndex={30}
borderRadius="full"
pointerEvents="auto"
/>
<IconButton
aria-label="scroll right"
icon={<ChevronRightIcon boxSize={6} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
scrollBy(1);
}}
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onTouchStart={(e) => { e.preventDefault(); e.stopPropagation(); }}
position="absolute"
top="50%"
transform="translateY(-50%)"
right={{ base: 1, md: 2 }}
size="lg"
colorScheme="blackAlpha"
bg={useColorModeValue('rgba(255,255,255,0.95)', 'rgba(45,55,72,0.95)')}
color={useColorModeValue('gray.800', 'white')}
boxShadow="xl"
_hover={{ bg: useColorModeValue('white', 'gray.600'), transform: 'translateY(-50%) scale(1.15)', boxShadow: '2xl' }}
_active={{ transform: 'translateY(-50%) scale(0.95)' }}
transition="all 0.2s"
zIndex={30}
borderRadius="full"
pointerEvents="auto"
/>
{/* bottom accent line */}
<Box mt={2} h="2px" bg={theme.primary} borderRadius="full" />
</Box>
);
};
export default HorizontalScroller;

Some files were not shown because too many files have changed in this diff Show More