mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
upload
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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;"]
|
||||
@@ -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 can’t go back!**
|
||||
|
||||
If you aren’t 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 you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t 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.
|
||||
@@ -0,0 +1,9 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
webpack: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src/')
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+22574
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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
|
||||
<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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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><{message.email}></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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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}>E‑shop <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;
|
||||
@@ -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 až {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;
|
||||
@@ -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
Reference in New Issue
Block a user