This commit is contained in:
Tomas Dvorak
2026-01-26 08:13:18 +01:00
parent aa036b6550
commit dfc079288f
505 changed files with 95755 additions and 5712 deletions
+1
View File
@@ -17,6 +17,7 @@ REACT_APP_FACR_CACHE_TTL=3600000 # 1 hour in milliseconds
# Homepage Layout (sparta or classic)
REACT_APP_HOMEPAGE_LAYOUT=classic
REACT_APP_ESHOP_URL=http://localhost:3100
# OpenRouter (for AI blog generation)
# Get a key at https://openrouter.ai
+4
View File
@@ -6,6 +6,9 @@ REACT_APP_API_URL=/api/v1
REACT_APP_NAME=Fotbal Club Manager
REACT_APP_ENV=development
# Translation API Configuration
# Uses tdvorak.dev free translation service - no API key required
# FACR API Configuration - Local instance
# Backend exposes the FACR proxy under /api/v1/facr
REACT_APP_FACR_API_BASE_URL=/api/v1/facr
@@ -21,6 +24,7 @@ REACT_APP_FACR_CACHE_TTL=3600000 # 1 hour in milliseconds
# Homepage Layout Configuration
# Options: 'sparta' (premium layout) or 'classic' (default layout)
REACT_APP_HOMEPAGE_LAYOUT=classic
REACT_APP_ESHOP_URL=
# 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/
+11 -2
View File
@@ -18,12 +18,18 @@ COPY . .
ENV NODE_ENV=production
# Disable ESLint during build to avoid CRA/ESLint v9 plugin incompatibilities
ENV DISABLE_ESLINT_PLUGIN=true
# Skip CRA preflight checks (peer deps, eslint presence) to prevent extra work in CI builds
ENV SKIP_PREFLIGHT_CHECK=true
# Disable source maps to reduce memory usage
ENV GENERATE_SOURCEMAP=false
# Reduce memory footprint - use 1.5GB with smaller semi-space for better GC
ENV NODE_OPTIONS="--openssl-legacy-provider --max-old-space-size=1536"
# Reduce memory footprint - cap Node heap to ~1GB to avoid OOM in constrained builders
ENV NODE_OPTIONS="--openssl-legacy-provider --max-old-space-size=1024"
# Limit webpack parallelism to reduce memory usage
ENV CI=true
# Allow build to continue even if TypeScript diagnostics exist; avoids heavy TS checks from blocking
ENV TSC_COMPILE_ON_ERROR=true
# Disable ForkTsCheckerWebpackPlugin via craco filter to further reduce memory
ENV DISABLE_TS_TYPECHECK=true
# Clean npm cache before build to free up memory
RUN npm cache clean --force 2>/dev/null || true
@@ -32,6 +38,9 @@ RUN npm cache clean --force 2>/dev/null || true
RUN --mount=type=cache,target=/root/.npm \
npm run build
RUN ls -R /app/build
# Production stage
FROM nginx:alpine
Binary file not shown.
+103
View File
@@ -0,0 +1,103 @@
// Admin Sidebar Scroll Test - Run directly in browser console
// Copy and paste this entire script into the browser console on any admin page
(function adminScrollTest() {
console.clear();
console.log('=== ADMIN SIDEBAR SCROLL TEST ===\n');
// Find sidebar
const sidebar = document.querySelector('[data-sidebar="true"]');
if (!sidebar) {
console.error('❌ No sidebar found. Make sure you are on an admin page (/admin/*)');
return;
}
console.log('✅ Sidebar found');
console.log('📏 Current scroll:', sidebar.scrollTop);
console.log('📏 Scroll height:', sidebar.scrollHeight);
console.log('📏 Client height:', sidebar.clientHeight);
console.log('📏 Scrollable:', sidebar.scrollHeight > sidebar.clientHeight ? '✅ YES' : '❌ NO');
// Check if scrollable
if (sidebar.scrollHeight <= sidebar.clientHeight) {
console.log('\n⚠️ Sidebar is NOT scrollable!');
console.log(' To test scroll retention, the sidebar needs to be scrollable.');
console.log(' Try: 1) Reducing browser window height, or 2) Zoom in (Ctrl +)');
return;
}
console.log('\n🧪 Testing scroll retention...');
// Clear any existing scroll data
sessionStorage.removeItem('admin-sidebar-scroll');
sessionStorage.removeItem('admin-sidebar-scroll-emergency');
delete window.__adminSidebarScrollTarget;
// Scroll to a test position (middle of scrollable area)
const maxScroll = sidebar.scrollHeight - sidebar.clientHeight;
const testScroll = Math.floor(maxScroll * 0.5);
console.log('📍 Scrolling to test position:', testScroll, 'px');
sidebar.scrollTop = testScroll;
setTimeout(() => {
const actualScroll = sidebar.scrollTop;
console.log('📍 Current position after scroll:', actualScroll, 'px');
// Check if scroll was saved
const savedData = sessionStorage.getItem('admin-sidebar-scroll');
const emergencyData = sessionStorage.getItem('admin-sidebar-scroll-emergency');
const globalTarget = window.__adminSidebarScrollTarget;
console.log('\n💾 Storage check:');
console.log(' Main saved data:', savedData);
console.log(' Emergency saved:', emergencyData);
console.log(' Global target:', globalTarget);
console.log('\n🚀 NOW CLICK ANY ADMIN NAVIGATION LINK');
console.log(' The test will automatically detect navigation and check scroll preservation');
// Intercept navigation to test
let navDetected = false;
const originalPushState = history.pushState;
history.pushState = function(...args) {
if (!navDetected) {
navDetected = true;
const scrollBefore = sidebar.scrollTop;
console.log('\n🚦 Navigation detected!');
console.log(' Scroll position before navigation:', scrollBefore, 'px');
// Check scroll preservation at multiple intervals
const checkPoints = [100, 300, 600, 1000];
checkPoints.forEach((delay, index) => {
setTimeout(() => {
const scrollAfter = sidebar.scrollTop;
const difference = Math.abs(scrollAfter - scrollBefore);
console.log(` 📍 Check ${index + 1} (${delay}ms): ${scrollAfter}px (diff: ${difference}px)`);
if (delay === 1000) {
if (difference < 10) {
console.log('\n✅ SUCCESS: Scroll position preserved!');
console.log(' The admin sidebar scroll retention is working correctly.');
} else {
console.log('\n❌ FAILURE: Scroll position was reset');
console.log(' Difference:', difference, 'px');
console.log('\n🔍 Debugging info:');
console.log(' - Check browser console for any scroll-related errors');
console.log(' - Verify AdminScrollManager is loaded');
console.log(' - Check if CSS is interfering');
}
// Restore original history function
history.pushState = originalPushState;
}
}, delay);
});
}
return originalPushState.apply(this, args);
};
}, 500);
})();
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128px" height="128px"><path fill="#F7F7FB" d="M109.7,110H18.2c-5.1,0-9.2-4.1-9.2-9.2V22.6c0-2.5,2.1-4.6,4.6-4.6H96c2.5,0,4.6,2.1,4.6,4.6v23.1v55.2C100.6,105.9,104.7,110,109.7,110L109.7,110c5.1,0,9.2-4.1,9.2-9.2V50.2c0-2.5-2.1-4.6-4.6-4.6h-4.6"/><path fill="#DEDFE6" d="M23 31.9h63.1c1.7 0 3 1.3 3 3v16.9c0 1.7-1.3 3-3 3H23c-1.7 0-3-1.3-3-3V34.9C20 33.2 21.3 31.9 23 31.9zM109.7 110L109.7 110c5.1 0 9.2-4.1 9.2-9.2V50.2c0-2.5-2.1-4.6-4.6-4.6h-13.8v55.2C100.6 105.9 104.7 110 109.7 110z"/><path fill="#464C55" d="M109.7,113H18.2C11.5,113,6,107.5,6,100.8V22.6c0-4.2,3.4-7.6,7.6-7.6H96c4.2,0,7.6,3.4,7.6,7.6v78.3c0,3.4,2.8,6.2,6.2,6.2s6.2-2.8,6.2-6.2V50.2c0-0.9-0.7-1.6-1.6-1.6h-4.6c-1.7,0-3-1.3-3-3s1.3-3,3-3h4.6c4.2,0,7.6,3.4,7.6,7.6v50.6C121.9,107.6,116.4,113,109.7,113z M13.6,21c-0.9,0-1.6,0.7-1.6,1.6v78.3c0,3.4,2.8,6.2,6.2,6.2h81.1c-1.1-1.8-1.7-3.9-1.7-6.2V22.6c0-0.9-0.7-1.6-1.6-1.6L13.6,21z"/><path fill="#DEDFE6" d="M41.2 72.9H23c-1.7 0-3-1.3-3-3s1.3-3 3-3h18.2c1.7 0 3 1.3 3 3S42.9 72.9 41.2 72.9zM41.2 98.7H23c-1.7 0-3-1.3-3-3s1.3-3 3-3h18.2c1.7 0 3 1.3 3 3S42.9 98.7 41.2 98.7zM41.2 85.7H23c-1.7 0-3-1.3-3-3s1.3-3 3-3h18.2c1.7 0 3 1.3 3 3S42.9 85.7 41.2 85.7z"/><path fill="#464C55" d="M86,99.1H58c-1.7,0-3-1.3-3-3V68.6c0-1.7,1.3-3,3-3h28c1.7,0,3,1.3,3,3v27.5C89,97.7,87.7,99.1,86,99.1z M61,93.1h22V71.6H61V93.1z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

-24
View File
@@ -1,24 +0,0 @@
{
"files": {
"main.css": "/static/css/main.3ca1fc6e.css",
"main.js": "/static/js/main.8c4bb3a7.js",
"runtime.js": "/static/js/runtime.18a1ba68.js",
"static/js/453.54292a4b.chunk.js": "/static/js/453.54292a4b.chunk.js",
"static/css/290.1124e12e.css": "/static/css/290.1124e12e.css",
"static/js/290.0640644c.js": "/static/js/290.0640644c.js",
"index.html": "/index.html",
"main.3ca1fc6e.css.map": "/static/css/main.3ca1fc6e.css.map",
"main.8c4bb3a7.js.map": "/static/js/main.8c4bb3a7.js.map",
"runtime.18a1ba68.js.map": "/static/js/runtime.18a1ba68.js.map",
"453.54292a4b.chunk.js.map": "/static/js/453.54292a4b.chunk.js.map",
"290.1124e12e.css.map": "/static/css/290.1124e12e.css.map",
"290.0640644c.js.map": "/static/js/290.0640644c.js.map"
},
"entrypoints": [
"static/js/runtime.18a1ba68.js",
"static/css/290.1124e12e.css",
"static/js/290.0640644c.js",
"static/css/main.3ca1fc6e.css",
"static/js/main.8c4bb3a7.js"
]
}
@@ -0,0 +1,46 @@
// Quick debug to check if AdminScrollManager is working
// Run this in browser console
(function debugAdminScrollManager() {
console.clear();
console.log('=== DEBUGGING AdminScrollManager ===\n');
// Check if AdminScrollManager is loaded
console.log('🔍 Checking AdminScrollManager...');
// Look for AdminScrollManager logs
console.log('📋 Recent console logs:');
const logs = console.logs || [];
console.log(' - Look for "[AdminScrollManager] Component mounted" message');
console.log(' - Look for "[AdminScrollManager] Saved position" message');
console.log(' - Look for "[AdminScrollManager] Restoring position" message');
// Check if AdminLayout is being used
const adminLayout = document.querySelector('.admin-layout');
console.log('\n🏗️ AdminLayout check:');
console.log(' AdminLayout element found:', !!adminLayout);
// Check sidebar
const sidebar = document.querySelector('[data-sidebar="true"]');
console.log('\n📱 Sidebar check:');
console.log(' Sidebar element found:', !!sidebar);
console.log(' Sidebar current scroll:', sidebar?.scrollTop);
// Check if React DevTools shows AdminScrollManager
console.log('\n🔧 Manual test:');
console.log(' 1. Scroll the sidebar to any position');
console.log(' 2. Click an admin navigation link');
console.log(' 3. Watch console for AdminScrollManager messages');
console.log('\n💡 If you don\'t see AdminScrollManager logs, the component is not loading');
// Force a test
if (sidebar) {
console.log('\n🧪 Forcing a scroll test...');
sidebar.scrollTop = 300;
setTimeout(() => {
console.log(' Scroll set to:', sidebar.scrollTop);
console.log(' Now click a navigation link and watch for AdminScrollManager logs');
}, 100);
}
})();
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="144px" height="144px" baseProfile="basic"><path fill="#536dfe" d="M47.496,10.074c-0.508-0.249-0.727,0.226-1.025,0.467c-0.102,0.078-0.188,0.179-0.274,0.272 c-0.743,0.794-1.611,1.315-2.746,1.253c-1.658-0.093-3.074,0.428-4.326,1.696c-0.266-1.564-1.15-2.498-2.495-3.097 c-0.704-0.311-1.416-0.623-1.909-1.3c-0.344-0.482-0.438-1.019-0.61-1.548c-0.11-0.319-0.219-0.646-0.587-0.7 c-0.399-0.062-0.555,0.272-0.712,0.553c-0.626,1.144-0.868,2.405-0.845,3.681c0.055,2.871,1.267,5.159,3.676,6.785 c0.274,0.187,0.344,0.373,0.258,0.646c-0.164,0.56-0.36,1.105-0.532,1.665c-0.11,0.358-0.274,0.436-0.657,0.28 c-1.322-0.552-2.464-1.369-3.473-2.358c-1.713-1.657-3.262-3.486-5.194-4.918c-0.454-0.335-0.907-0.646-1.377-0.942 c-1.971-1.914,0.258-3.486,0.774-3.673c0.54-0.195,0.188-0.864-1.557-0.856c-1.744,0.008-3.34,0.591-5.374,1.369 c-0.297,0.117-0.61,0.202-0.931,0.272c-1.846-0.35-3.763-0.428-5.765-0.202c-3.77,0.42-6.782,2.202-8.996,5.245 c-2.66,3.657-3.285,7.812-2.519,12.147c0.806,4.568,3.137,8.349,6.719,11.306c3.716,3.066,7.994,4.568,12.876,4.28 c2.965-0.171,6.266-0.568,9.989-3.719c0.939,0.467,1.924,0.654,3.559,0.794c1.259,0.117,2.472-0.062,3.411-0.257 c1.471-0.311,1.369-1.673,0.837-1.922C34,36,33.471,35.441,33.471,35.441c2.19-2.591,5.491-5.284,6.782-14.007 c0.102-0.692,0.016-1.128,0-1.689c-0.008-0.342,0.07-0.475,0.462-0.514c1.079-0.125,2.128-0.42,3.09-0.949 c2.793-1.525,3.919-4.031,4.185-7.034C48.028,10.79,47.981,10.315,47.496,10.074z M23.161,37.107 c-4.177-3.284-6.203-4.365-7.04-4.319c-0.782,0.047-0.641,0.942-0.469,1.525c0.18,0.576,0.415,0.973,0.743,1.478 c0.227,0.335,0.383,0.833-0.227,1.206c-1.345,0.833-3.684-0.28-3.794-0.335c-2.722-1.603-4.998-3.72-6.602-6.614 c-1.549-2.786-2.448-5.774-2.597-8.964c-0.039-0.77,0.188-1.043,0.954-1.183c1.009-0.187,2.049-0.226,3.059-0.078 c4.263,0.623,7.893,2.529,10.936,5.548c1.737,1.72,3.051,3.774,4.404,5.782c1.439,2.132,2.988,4.163,4.959,5.828 c0.696,0.584,1.252,1.027,1.783,1.354C27.667,38.515,24.991,38.554,23.161,37.107L23.161,37.107z M25.164,24.228 c0-0.342,0.274-0.615,0.618-0.615c0.078,0,0.149,0.015,0.211,0.039c0.086,0.031,0.164,0.078,0.227,0.148 c0.11,0.109,0.172,0.265,0.172,0.428c0,0.342-0.274,0.615-0.618,0.615S25.164,24.571,25.164,24.228L25.164,24.228z M31.382,27.419 c-0.399,0.163-0.798,0.303-1.181,0.319c-0.595,0.031-1.244-0.21-1.596-0.506c-0.548-0.459-0.939-0.716-1.103-1.517 c-0.07-0.342-0.031-0.872,0.031-1.175c0.141-0.654-0.016-1.074-0.477-1.455c-0.376-0.311-0.853-0.397-1.377-0.397 c-0.196,0-0.375-0.086-0.508-0.156c-0.219-0.109-0.399-0.381-0.227-0.716c0.055-0.109,0.321-0.373,0.383-0.42 c0.712-0.405,1.533-0.272,2.292,0.031c0.704,0.288,1.236,0.817,2.003,1.564c0.782,0.903,0.923,1.152,1.369,1.829 c0.352,0.529,0.673,1.074,0.892,1.696C32.016,26.905,31.844,27.224,31.382,27.419L31.382,27.419z"/></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Player placeholder">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#EDF2F7"/>
<stop offset="1" stop-color="#E2E8F0"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="24" fill="url(#g)"/>
<circle cx="128" cy="92" r="44" fill="#CBD5E0"/>
<path d="M48 212c0-40 36-64 80-64s80 24 80 64v12H48v-12z" fill="#CBD5E0"/>
</svg>

After

Width:  |  Height:  |  Size: 552 B

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>

After

Width:  |  Height:  |  Size: 756 B

@@ -0,0 +1,4 @@
<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#f7fafc"/>
<text x="50%" y="50%" text-anchor="middle" dy=".3em" fill="#a0aec0" font-family="Arial, sans-serif" font-size="14">Club Logo</text>
</svg>

After

Width:  |  Height:  |  Size: 257 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 2.9 KiB

-1
View File
@@ -1 +0,0 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><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="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>Fotbal Club</title><script defer="defer" src="/static/js/runtime.18a1ba68.js"></script><script defer="defer" src="/static/js/290.0640644c.js"></script><script defer="defer" src="/static/js/main.8c4bb3a7.js"></script><link href="/static/css/290.1124e12e.css" rel="stylesheet"><link href="/static/css/main.3ca1fc6e.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
+36
View File
@@ -0,0 +1,36 @@
<svg width="192" height="192" viewBox="0 0 192 192" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Clean white background -->
<rect width="192" height="192" fill="#ffffff"/>
<!-- Enhanced Football/Soccer Ball Design -->
<g transform="translate(96,96)">
<!-- Outer circle with bold stroke -->
<circle cx="0" cy="0" r="48" fill="none" stroke="#000000" stroke-width="3"/>
<!-- Pentagon pattern - cleaner design -->
<g>
<!-- Center pentagon -->
<path d="M 0,-20 L 19,-6 L 12,16 L -12,16 L -19,-6 Z"
fill="#000000" stroke="#000000" stroke-width="2"/>
<!-- Surrounding hexagons with cleaner lines -->
<g stroke="#000000" stroke-width="2" fill="none">
<path d="M 0,-20 L 19,-6 L 19,-32 L 0,-45 L -19,-32 L -19,-6 Z"/>
<path d="M 19,-6 L 12,16 L 34,16 L 41,-6 L 19,-6 Z"/>
<path d="M 12,16 L -12,16 L -19,38 L 3,46 L 25,38 L 12,16 Z"/>
<path d="M -12,16 L -19,-6 L -41,-6 L -34,16 L -12,16 Z"/>
<path d="M -19,-6 L -19,-32 L -41,-32 L -34,-6 L -19,-6 Z"/>
</g>
</g>
<!-- Add subtle texture lines -->
<g stroke="#000000" stroke-width="0.5" opacity="0.3">
<circle cx="0" cy="0" r="35" fill="none"/>
<circle cx="0" cy="0" r="25" fill="none"/>
</g>
</g>
<!-- Enhanced text with better typography -->
<text x="96" y="165" font-family="Arial, sans-serif" font-size="16" font-weight="900"
text-anchor="middle" fill="#000000" letter-spacing="1">FC CLUB</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

+1 -25
View File
@@ -1,25 +1 @@
{
"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"
}
{"short_name": "Fotbal Club", "name": "Fotbal Club - Oficiální aplikace", "description": "Oficiální webové stránky fotbalového klubu - aktuality, zápasy, tabulky, hráči a fotogalerie", "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"}
Binary file not shown.

After

Width:  |  Height:  |  Size: 958 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+42
View File
@@ -0,0 +1,42 @@
<svg width="192" height="192" viewBox="0 0 192 192" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Clean white background -->
<rect width="192" height="192" fill="#ffffff"/>
<!-- Enhanced Player Silhouette -->
<g transform="translate(96,96)">
<!-- Athletic pose silhouette -->
<g fill="#000000">
<!-- Head -->
<circle cx="0" cy="-28" r="14"/>
<!-- Body with athletic build -->
<path d="M -10,-14 L -7,8 L -4,30 L 4,30 L 7,8 L 10,-14 Z"/>
<!-- Defined arms in athletic position -->
<path d="M -10,-10 L -22,-8 L -24,2 L -18,6 L -10,2 M 10,-10 L 22,-8 L 24,2 L 18,6 L 10,2"/>
<!-- Strong legs in running stance -->
<path d="M -4,30 L -8,52 L -14,52 M 4,30 L 8,52 L 14,52"/>
<!-- Muscle definition lines -->
<g stroke="#ffffff" stroke-width="1" fill="none">
<line x1="-7" y1="-5" x2="-5" y2="5"/>
<line x1="7" y1="-5" x2="5" y2="5"/>
<line x1="-4" y1="15" x2="-4" y2="25"/>
<line x1="4" y1="15" x2="4" y2="25"/>
</g>
</g>
<!-- Motion lines -->
<g stroke="#000000" stroke-width="2" opacity="0.3">
<line x1="-30" y1="-20" x2="-20" y2="-18"/>
<line x1="-32" y1="-10" x2="-22" y2="-8"/>
<line x1="30" y1="-20" x2="20" y2="-18"/>
<line x1="32" y1="-10" x2="22" y2="-8"/>
</g>
</g>
<!-- Enhanced typography -->
<text x="96" y="165" font-family="Arial, sans-serif" font-size="14" font-weight="900"
text-anchor="middle" fill="#000000" letter-spacing="2">PLAYER</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+76 -8
View File
@@ -1,19 +1,22 @@
/* eslint-disable no-restricted-globals */
// Service Worker for PWA support and offline functionality
const CACHE_VERSION = 'v1.0.0';
const CACHE_VERSION = 'v1.0.2';
const CACHE_NAME = `fotbal-club-cache-${CACHE_VERSION}`;
// Rate limiting for background updates
const BACKGROUND_UPDATE_INTERVAL = 5000; // 5 seconds minimum between updates
const lastBackgroundUpdates = new Map();
// 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',
'/robots.txt',
];
// API endpoints to cache
@@ -75,11 +78,27 @@ self.addEventListener('fetch', (event) => {
return;
}
// Only handle same-origin requests
if (url.origin !== self.location.origin) {
return;
}
// Skip admin routes
if (url.pathname.startsWith('/admin')) {
return;
}
// Skip background update requests to prevent infinite loops
if (request.headers.get('X-SW-Background-Update') === 'true') {
return;
}
// Handle SPA navigations with app shell fallback
if (request.mode === 'navigate') {
event.respondWith(handleNavigationRequest(request));
return;
}
// Handle API requests
if (url.pathname.startsWith('/api/')) {
event.respondWith(handleAPIRequest(request));
@@ -96,8 +115,16 @@ async function handleStaticRequest(request) {
// Try cache first
const cachedResponse = await caches.match(request);
if (cachedResponse) {
// Return cached response and update in background
fetchAndUpdateCache(request);
// For static assets with long cache headers, don't update in background
const url = new URL(request.url);
const isStaticAsset = url.pathname.match(/\.(png|jpg|jpeg|gif|svg|webp|ico|woff|woff2|ttf|eot)$/);
const hasLongCache = cachedResponse.headers.get('cache-control')?.includes('max-age=31536000');
// Only update in background if it's not a static asset with long cache
if (!isStaticAsset || !hasLongCache) {
fetchAndUpdateCache(request);
}
return cachedResponse;
}
@@ -114,8 +141,8 @@ async function handleStaticRequest(request) {
} catch (error) {
console.error('[SW] Fetch failed:', error);
// Return offline page if available
const cachedOffline = await caches.match('/offline.html');
// Return app shell (index.html) if available
const cachedOffline = await caches.match('/index.html');
if (cachedOffline) {
return cachedOffline;
}
@@ -129,6 +156,28 @@ async function handleStaticRequest(request) {
}
}
// Handle SPA navigation requests - Network First with index.html fallback
async function handleNavigationRequest(request) {
try {
const networkResponse = await fetch(request);
if (networkResponse && networkResponse.ok) {
return networkResponse;
}
} catch (error) {
console.error('[SW] Navigation fetch failed:', error);
}
// Fallback to cached index.html (app shell)
const cachedIndex = await caches.match('/index.html');
if (cachedIndex) {
return cachedIndex;
}
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 {
@@ -166,9 +215,28 @@ async function handleAPIRequest(request) {
// Update cache in background
async function fetchAndUpdateCache(request) {
try {
const response = await fetch(request);
// Skip if this is already a background update request to prevent infinite loops
if (request.headers.get('X-SW-Background-Update') === 'true') {
return;
}
// Rate limit background updates to prevent excessive requests
const now = Date.now();
const lastUpdate = lastBackgroundUpdates.get(request.url);
if (lastUpdate && (now - lastUpdate) < BACKGROUND_UPDATE_INTERVAL) {
return;
}
lastBackgroundUpdates.set(request.url, now);
// Use fetch with cache: 'no-store' to bypass service worker and avoid infinite loops
const response = await fetch(request.url, {
cache: 'no-store',
headers: { 'X-SW-Background-Update': 'true' }
});
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
// Use the original request as key, but the new response
cache.put(request, response);
}
} catch (error) {
+49
View File
@@ -0,0 +1,49 @@
<svg width="192" height="192" viewBox="0 0 192 192" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Clean white background -->
<rect width="192" height="192" fill="#ffffff"/>
<!-- Enhanced Sponsor/Partner placeholder -->
<g transform="translate(96,96)">
<!-- Premium shield shape with better proportions -->
<g>
<!-- Shadow -->
<path d="M 2,-47 L 37,-27 L 37,12 L 2,47 L -33,12 L -33,-27 Z"
fill="#000000" opacity="0.1"/>
<!-- Main shield -->
<path d="M 0,-45 L 35,-25 L 35,10 L 0,45 L -35,10 L -35,-25 Z"
fill="#ffffff" stroke="#000000" stroke-width="3"/>
<!-- Inner decorative border -->
<path d="M 0,-38 L 28,-20 L 28,8 L 0,38 L -28,8 L -28,-20 Z"
fill="none" stroke="#000000" stroke-width="1.5"/>
<!-- Enhanced star with better geometry -->
<g transform="scale(1.2)">
<path d="M 0,-18 L 5,-5 L 18,-3 L 9,6 L 11,19 L 0,13 L -11,19 L -9,6 L -18,-3 L -5,-5 Z"
fill="#000000"/>
<!-- Inner star detail -->
<path d="M 0,-10 L 2,-3 L 9,-2 L 4,3 L 5,10 L 0,7 L -5,10 L -4,3 L -9,-2 L -2,-3 Z"
fill="#ffffff"/>
</g>
<!-- Ribbon with better design -->
<g>
<path d="M -12,38 L 0,48 L 12,38 L 8,45 L 0,50 L -8,45 Z" fill="#000000"/>
<text x="0" y="44" font-family="Arial, sans-serif" font-size="8" font-weight="bold"
text-anchor="middle" fill="#ffffff">PREMIUM</text>
</g>
</g>
<!-- Decorative corner elements -->
<g stroke="#000000" stroke-width="1.5" fill="none">
<circle cx="-25" cy="-35" r="3"/>
<circle cx="25" cy="-35" r="3"/>
<circle cx="-25" cy="30" r="3"/>
<circle cx="25" cy="30" r="3"/>
</g>
</g>
<!-- Enhanced typography -->
<text x="96" y="165" font-family="Arial, sans-serif" font-size="14" font-weight="900"
text-anchor="middle" fill="#000000" letter-spacing="2">SPONSOR</text>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,121 +0,0 @@
/*!
* @kurkle/color v0.3.4
* https://github.com/kurkle/color#readme
* (c) 2024 Jukka Kurkela
* Released under the MIT License
*/
/*!
* Chart.js v4.4.1
* https://www.chartjs.org
* (c) 2023 Chart.js Contributors
* Released under the MIT License
*/
/*!
* Quill Editor v1.3.7
* https://quilljs.com/
* Copyright (c) 2014, Jason Chen
* Copyright (c) 2013, salesforce.com
*/
/*! @license DOMPurify 3.2.6 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.2.6/LICENSE */
/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* use-sync-external-store-shim.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license lucide-react v0.379.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/
/**
* @remix-run/router v1.23.0
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
/**
* React Router DOM v6.30.1
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
/**
* React Router v6.30.1
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
/** @license React v16.13.1
* react-is.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
File diff suppressed because one or more lines are too long
@@ -1,2 +0,0 @@
"use strict";(self.webpackChunkfrontend=self.webpackChunkfrontend||[]).push([[453],{6453:(e,t,n)=>{n.r(t),n.d(t,{getCLS:()=>y,getFCP:()=>g,getFID:()=>C,getLCP:()=>P,getTTFB:()=>D});var i,r,a,o,u=function(e,t){return{name:e,value:void 0===t?-1:t,delta:0,entries:[],id:"v2-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12)}},c=function(e,t){try{if(PerformanceObserver.supportedEntryTypes.includes(e)){if("first-input"===e&&!("PerformanceEventTiming"in self))return;var n=new PerformanceObserver(function(e){return e.getEntries().map(t)});return n.observe({type:e,buffered:!0}),n}}catch(e){}},f=function(e,t){var n=function n(i){"pagehide"!==i.type&&"hidden"!==document.visibilityState||(e(i),t&&(removeEventListener("visibilitychange",n,!0),removeEventListener("pagehide",n,!0)))};addEventListener("visibilitychange",n,!0),addEventListener("pagehide",n,!0)},s=function(e){addEventListener("pageshow",function(t){t.persisted&&e(t)},!0)},m=function(e,t,n){var i;return function(r){t.value>=0&&(r||n)&&(t.delta=t.value-(i||0),(t.delta||void 0===i)&&(i=t.value,e(t)))}},v=-1,d=function(){return"hidden"===document.visibilityState?0:1/0},p=function(){f(function(e){var t=e.timeStamp;v=t},!0)},l=function(){return v<0&&(v=d(),p(),s(function(){setTimeout(function(){v=d(),p()},0)})),{get firstHiddenTime(){return v}}},g=function(e,t){var n,i=l(),r=u("FCP"),a=function(e){"first-contentful-paint"===e.name&&(f&&f.disconnect(),e.startTime<i.firstHiddenTime&&(r.value=e.startTime,r.entries.push(e),n(!0)))},o=window.performance&&performance.getEntriesByName&&performance.getEntriesByName("first-contentful-paint")[0],f=o?null:c("paint",a);(o||f)&&(n=m(e,r,t),o&&a(o),s(function(i){r=u("FCP"),n=m(e,r,t),requestAnimationFrame(function(){requestAnimationFrame(function(){r.value=performance.now()-i.timeStamp,n(!0)})})}))},h=!1,T=-1,y=function(e,t){h||(g(function(e){T=e.value}),h=!0);var n,i=function(t){T>-1&&e(t)},r=u("CLS",0),a=0,o=[],v=function(e){if(!e.hadRecentInput){var t=o[0],i=o[o.length-1];a&&e.startTime-i.startTime<1e3&&e.startTime-t.startTime<5e3?(a+=e.value,o.push(e)):(a=e.value,o=[e]),a>r.value&&(r.value=a,r.entries=o,n())}},d=c("layout-shift",v);d&&(n=m(i,r,t),f(function(){d.takeRecords().map(v),n(!0)}),s(function(){a=0,T=-1,r=u("CLS",0),n=m(i,r,t)}))},E={passive:!0,capture:!0},w=new Date,L=function(e,t){i||(i=t,r=e,a=new Date,F(removeEventListener),S())},S=function(){if(r>=0&&r<a-w){var e={entryType:"first-input",name:i.type,target:i.target,cancelable:i.cancelable,startTime:i.timeStamp,processingStart:i.timeStamp+r};o.forEach(function(t){t(e)}),o=[]}},b=function(e){if(e.cancelable){var t=(e.timeStamp>1e12?new Date:performance.now())-e.timeStamp;"pointerdown"==e.type?function(e,t){var n=function(){L(e,t),r()},i=function(){r()},r=function(){removeEventListener("pointerup",n,E),removeEventListener("pointercancel",i,E)};addEventListener("pointerup",n,E),addEventListener("pointercancel",i,E)}(t,e):L(t,e)}},F=function(e){["mousedown","keydown","touchstart","pointerdown"].forEach(function(t){return e(t,b,E)})},C=function(e,t){var n,a=l(),v=u("FID"),d=function(e){e.startTime<a.firstHiddenTime&&(v.value=e.processingStart-e.startTime,v.entries.push(e),n(!0))},p=c("first-input",d);n=m(e,v,t),p&&f(function(){p.takeRecords().map(d),p.disconnect()},!0),p&&s(function(){var a;v=u("FID"),n=m(e,v,t),o=[],r=-1,i=null,F(addEventListener),a=d,o.push(a),S()})},k={},P=function(e,t){var n,i=l(),r=u("LCP"),a=function(e){var t=e.startTime;t<i.firstHiddenTime&&(r.value=t,r.entries.push(e),n())},o=c("largest-contentful-paint",a);if(o){n=m(e,r,t);var v=function(){k[r.id]||(o.takeRecords().map(a),o.disconnect(),k[r.id]=!0,n(!0))};["keydown","click"].forEach(function(e){addEventListener(e,v,{once:!0,capture:!0})}),f(v,!0),s(function(i){r=u("LCP"),n=m(e,r,t),requestAnimationFrame(function(){requestAnimationFrame(function(){r.value=performance.now()-i.timeStamp,k[r.id]=!0,n(!0)})})})}},D=function(e){var t,n=u("TTFB");t=function(){try{var t=performance.getEntriesByType("navigation")[0]||function(){var e=performance.timing,t={entryType:"navigation",startTime:0};for(var n in e)"navigationStart"!==n&&"toJSON"!==n&&(t[n]=Math.max(e[n]-e.navigationStart,0));return t}();if(n.value=n.delta=t.responseStart,n.value<0||n.value>performance.now())return;n.entries=[t],e(n)}catch(e){}},"complete"===document.readyState?setTimeout(t,0):addEventListener("load",function(){return setTimeout(t,0)})}}}]);
//# sourceMappingURL=453.54292a4b.chunk.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,2 +0,0 @@
(()=>{"use strict";var e={},t={};function r(o){var n=t[o];if(void 0!==n)return n.exports;var a=t[o]={id:o,loaded:!1,exports:{}};return e[o].call(a.exports,a,a.exports,r),a.loaded=!0,a.exports}r.m=e,(()=>{var e=[];r.O=(t,o,n,a)=>{if(!o){var i=1/0;for(d=0;d<e.length;d++){o=e[d][0],n=e[d][1],a=e[d][2];for(var l=!0,u=0;u<o.length;u++)(!1&a||i>=a)&&Object.keys(r.O).every(e=>r.O[e](o[u]))?o.splice(u--,1):(l=!1,a<i&&(i=a));if(l){e.splice(d--,1);var f=n();void 0!==f&&(t=f)}}return t}a=a||0;for(var d=e.length;d>0&&e[d-1][2]>a;d--)e[d]=e[d-1];e[d]=[o,n,a]}})(),r.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return r.d(t,{a:t}),t},(()=>{var e,t=Object.getPrototypeOf?e=>Object.getPrototypeOf(e):e=>e.__proto__;r.t=function(o,n){if(1&n&&(o=this(o)),8&n)return o;if("object"===typeof o&&o){if(4&n&&o.__esModule)return o;if(16&n&&"function"===typeof o.then)return o}var a=Object.create(null);r.r(a);var i={};e=e||[null,t({}),t([]),t(t)];for(var l=2&n&&o;("object"==typeof l||"function"==typeof l)&&!~e.indexOf(l);l=t(l))Object.getOwnPropertyNames(l).forEach(e=>i[e]=()=>o[e]);return i.default=()=>o,r.d(a,i),a}})(),r.d=(e,t)=>{for(var o in t)r.o(t,o)&&!r.o(e,o)&&Object.defineProperty(e,o,{enumerable:!0,get:t[o]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>"static/js/"+e+".54292a4b.chunk.js",r.miniCssF=e=>{},r.g=function(){if("object"===typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"===typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={},t="frontend:";r.l=(o,n,a,i)=>{if(e[o])e[o].push(n);else{var l,u;if(void 0!==a)for(var f=document.getElementsByTagName("script"),d=0;d<f.length;d++){var c=f[d];if(c.getAttribute("src")==o||c.getAttribute("data-webpack")==t+a){l=c;break}}l||(u=!0,(l=document.createElement("script")).charset="utf-8",l.timeout=120,r.nc&&l.setAttribute("nonce",r.nc),l.setAttribute("data-webpack",t+a),l.src=o),e[o]=[n];var s=(t,r)=>{l.onerror=l.onload=null,clearTimeout(p);var n=e[o];if(delete e[o],l.parentNode&&l.parentNode.removeChild(l),n&&n.forEach(e=>e(r)),t)return t(r)},p=setTimeout(s.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=s.bind(null,l.onerror),l.onload=s.bind(null,l.onload),u&&document.head.appendChild(l)}}})(),r.r=e=>{"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.nmd=e=>(e.paths=[],e.children||(e.children=[]),e),r.p="/",(()=>{var e={121:0};r.f.j=(t,o)=>{var n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else if(121!=t){var a=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=a);var i=r.p+r.u(t),l=new Error;r.l(i,o=>{if(r.o(e,t)&&(0!==(n=e[t])&&(e[t]=void 0),n)){var a=o&&("load"===o.type?"missing":o.type),i=o&&o.target&&o.target.src;l.message="Loading chunk "+t+" failed.\n("+a+": "+i+")",l.name="ChunkLoadError",l.type=a,l.request=i,n[1](l)}},"chunk-"+t,t)}else e[t]=0},r.O.j=t=>0===e[t];var t=(t,o)=>{var n,a,i=o[0],l=o[1],u=o[2],f=0;if(i.some(t=>0!==e[t])){for(n in l)r.o(l,n)&&(r.m[n]=l[n]);if(u)var d=u(r)}for(t&&t(o);f<i.length;f++)a=i[f],r.o(e,a)&&e[a]&&e[a][0](),e[a]=0;return r.O(d)},o=self.webpackChunkfrontend=self.webpackChunkfrontend||[];o.forEach(t.bind(null,0)),o.push=t.bind(null,o.push.bind(o))})(),r.nc=void 0})();
//# sourceMappingURL=runtime.18a1ba68.js.map
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+26
View File
@@ -6,6 +6,13 @@ module.exports = {
'@': path.resolve(__dirname, 'src/')
},
configure: (webpackConfig) => {
// Always remove ESLint for better performance
webpackConfig.plugins = (webpackConfig.plugins || []).filter((plugin) => {
const name = plugin && plugin.constructor && plugin.constructor.name;
if (name === 'ESLintWebpackPlugin') return false;
return true;
});
// Optimize for production builds
if (process.env.NODE_ENV === 'production') {
// Reduce memory usage during build
@@ -30,6 +37,25 @@ module.exports = {
runtimeChunk: 'single',
};
// Limit parallelism of existing minimizers to lower memory footprint
if (Array.isArray(webpackConfig.optimization.minimizer)) {
webpackConfig.optimization.minimizer = webpackConfig.optimization.minimizer.map((minimizer) => {
const name = minimizer && minimizer.constructor && minimizer.constructor.name;
if (name === 'TerserPlugin') {
if (minimizer.options) {
minimizer.options.parallel = false;
minimizer.options.extractComments = false;
}
}
if (name === 'CssMinimizerPlugin' || name === 'CssMinimizerWebpackPlugin') {
if (minimizer.options) {
minimizer.options.parallel = 1;
}
}
return minimizer;
});
}
// Disable source maps if env variable is set
if (process.env.GENERATE_SOURCEMAP === 'false') {
webpackConfig.devtool = false;
+124
View File
@@ -0,0 +1,124 @@
// Request debugging utility
// Add this to your browser console to track API requests
window.requestTracker = {
requests: [],
isTracking: false,
start() {
if (this.isTracking) return;
this.isTracking = true;
this.requests = [];
// Override fetch to track requests
const originalFetch = window.fetch;
window.fetch = async (...args) => {
const start = performance.now();
const url = args[0];
const method = args[1]?.method || 'GET';
try {
const response = await originalFetch(...args);
const end = performance.now();
const duration = Math.round(end - start);
this.requests.push({
url,
method,
status: response.status,
duration,
timestamp: new Date().toISOString(),
type: url.includes('/api/') ? 'API' : 'Other'
});
// Keep only last 100 requests
if (this.requests.length > 100) {
this.requests = this.requests.slice(-100);
}
return response;
} catch (error) {
const end = performance.now();
const duration = Math.round(end - start);
this.requests.push({
url,
method,
error: error.message,
duration,
timestamp: new Date().toISOString(),
type: url.includes('/api/') ? 'API' : 'Other'
});
throw error;
}
};
console.log('🔍 Request tracking started');
},
stop() {
this.isTracking = false;
// Restore original fetch (simplified - in production you'd store the reference)
console.log('🔍 Request tracking stopped');
},
stats() {
const apiRequests = this.requests.filter(r => r.type === 'API');
const lastMinute = apiRequests.filter(r => {
const requestTime = new Date(r.timestamp);
const oneMinuteAgo = new Date(Date.now() - 60000);
return requestTime > oneMinuteAgo;
});
const lastFiveMinutes = apiRequests.filter(r => {
const requestTime = new Date(r.timestamp);
const fiveMinutesAgo = new Date(Date.now() - 300000);
return requestTime > fiveMinutesAgo;
});
// Group by endpoint
const endpointCounts = {};
apiRequests.forEach(r => {
const endpoint = r.url.split('/').pop() || 'unknown';
endpointCounts[endpoint] = (endpointCounts[endpoint] || 0) + 1;
});
console.log('📊 Request Statistics:');
console.log(`Total API requests: ${apiRequests.length}`);
console.log(`Last 1 minute: ${lastMinute.length}`);
console.log(`Last 5 minutes: ${lastFiveMinutes.length}`);
console.log('Endpoints:', endpointCounts);
// Show suspicious patterns
if (lastMinute.length > 30) {
console.warn('⚠️ High request frequency detected!');
}
return {
total: apiRequests.length,
lastMinute: lastMinute.length,
lastFiveMinutes: lastFiveMinutes.length,
endpoints: endpointCounts
};
},
recent(limit = 20) {
console.log('📝 Recent requests:');
this.requests.slice(-limit).forEach((r, i) => {
const status = r.status || 'ERR';
const icon = status === 200 ? '✅' : status >= 400 ? '❌' : '⚠️';
console.log(`${icon} ${r.method} ${r.url} (${status}) - ${r.duration}ms`);
});
}
};
// Auto-start tracking
window.requestTracker.start();
// Show stats every 30 seconds
setInterval(() => {
if (window.requestTracker.isTracking) {
window.requestTracker.stats();
}
}, 30000);
+18 -18
View File
@@ -60,6 +60,24 @@ server {
add_header Cache-Control "public, max-age=3600";
}
# Short links and tracked redirect - must bypass SPA and hit backend (MUST be before location /)
location ^~ /s/ {
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;
}
location = /r {
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;
}
location / {
root /usr/share/nginx/html;
index index.html;
@@ -90,24 +108,6 @@ server {
proxy_busy_buffers_size 8k;
}
# Short links and tracked redirect - must bypass SPA and hit backend
location ^~ /s/ {
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;
}
location = /r {
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 backend-served assets so the frontend can use relative URLs
location /uploads/ {
proxy_pass http://backend:8080;
+423 -38
View File
@@ -14,6 +14,8 @@
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@hookform/resolvers": "^3.3.4",
"@lobehub/icons": "^1.10.1",
"@monaco-editor/react": "^4.6.0",
"@tanstack/react-query": "^4.36.1",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
@@ -21,6 +23,7 @@
"@tinymce/tinymce-react": "^6.3.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.126",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@types/react-frame-component": "^4.1.6",
@@ -29,9 +32,14 @@
"date-fns": "^4.1.0",
"dompurify": "^3.2.6",
"framer-motion": "^10.16.4",
"i18next": "^23.7.16",
"i18next-browser-languagedetector": "^7.2.0",
"i18next-http-backend": "^2.4.2",
"lucide-react": "^0.379.0",
"maplibre-gl": "^5.9.0",
"monaco-editor": "^0.49.0",
"popmotion": "^11.0.5",
"qrcode": "^1.5.4",
"quill": "^2.0.3",
"react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1",
@@ -41,6 +49,7 @@
"react-frame-component": "^5.2.7",
"react-helmet-async": "^2.0.5",
"react-hook-form": "^7.48.2",
"react-i18next": "^13.5.0",
"react-icons": "^4.12.0",
"react-image-crop": "^11.0.10",
"react-markdown": "^10.1.0",
@@ -64,6 +73,7 @@
"@types/react-image-crop": "^8.1.6",
"@types/react-syntax-highlighter": "^15.5.13",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react-hooks": "^7.0.1",
"http-proxy-middleware": "^3.0.5"
}
},
@@ -2238,7 +2248,6 @@
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@chakra-ui/styled-system/-/styled-system-2.12.0.tgz",
"integrity": "sha512-zoqLw1I2y4GlZ0LDoyw8o0JjoDOW6u0IwFPAoHuw0UMbP8glHUGvwEL1STug/i/GzBKw83yoF6ae41HIQvhMww==",
"license": "MIT",
"peer": true,
"dependencies": {
"@chakra-ui/utils": "2.2.2",
@@ -2303,7 +2312,6 @@
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@chakra-ui/utils/-/utils-2.2.2.tgz",
"integrity": "sha512-jUPLT0JzRMWxpdzH6c+t0YMJYrvc5CLericgITV3zDSXblkfx3DsYXqU11DJTSGZI9dUKzM1Wd0Wswn4eJwvFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/lodash.mergewith": "4.6.9",
@@ -2692,6 +2700,18 @@
"stylis": "4.2.0"
}
},
"node_modules/@emotion/css": {
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/css/-/css-11.13.5.tgz",
"integrity": "sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w==",
"dependencies": {
"@emotion/babel-plugin": "^11.13.5",
"@emotion/cache": "^11.13.5",
"@emotion/serialize": "^1.3.3",
"@emotion/sheet": "^1.4.0",
"@emotion/utils": "^1.4.2"
}
},
"node_modules/@emotion/hash": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
@@ -2863,10 +2883,9 @@
"license": "Python-2.0"
},
"node_modules/@eslint/eslintrc/node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"license": "MIT",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dependencies": {
"argparse": "^2.0.1"
},
@@ -3446,6 +3465,21 @@
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
"license": "MIT"
},
"node_modules/@lobehub/icons": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@lobehub/icons/-/icons-1.10.1.tgz",
"integrity": "sha512-JvKwdTTOSKzZIkjvH0T9Smsl2EhyKDt+ux7yVjji3ucbdyrg0KlvrHB4Dw8bqOjR7/Kpee9iKmIHLABZGndB1Q==",
"dependencies": {
"@babel/runtime": "^7",
"react-layout-kit": "^1"
},
"peerDependencies": {
"polished": ">=4",
"react": ">=18",
"react-dom": ">=18",
"react-layout-kit": ">=1"
}
},
"node_modules/@mapbox/geojson-rewind": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
@@ -3540,6 +3574,27 @@
"supercluster": "^8.0.1"
}
},
"node_modules/@monaco-editor/loader": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
"dependencies": {
"state-local": "^1.0.6"
}
},
"node_modules/@monaco-editor/react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
"dependencies": {
"@monaco-editor/loader": "^1.5.0"
},
"peerDependencies": {
"monaco-editor": ">= 0.25.0 < 1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
@@ -4072,7 +4127,6 @@
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
@@ -4092,7 +4146,6 @@
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"dequal": "^2.0.3"
@@ -4636,6 +4689,14 @@
"integrity": "sha512-hroOstUScF6zhIi+5+x0dzqrHA1EJi+Irri6b1fxolMTqqHIV/Cg77EtnQcZqZCu8hR3mX2BzIxN4/GzI68Kfw==",
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@@ -5887,10 +5948,9 @@
}
},
"node_modules/axios": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
"license": "MIT",
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
@@ -7136,6 +7196,14 @@
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"license": "MIT"
},
"node_modules/cross-fetch": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
"dependencies": {
"node-fetch": "^2.6.12"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -7742,6 +7810,14 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -7996,6 +8072,11 @@
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -8642,6 +8723,17 @@
"eslint": "^8.0.0"
}
},
"node_modules/eslint-config-react-app/node_modules/eslint-plugin-react-hooks": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz",
"integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0"
}
},
"node_modules/eslint-import-resolver-node": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
@@ -8865,15 +8957,22 @@
}
},
"node_modules/eslint-plugin-react-hooks": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz",
"integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==",
"license": "MIT",
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz",
"integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==",
"dev": true,
"dependencies": {
"@babel/core": "^7.24.4",
"@babel/parser": "^7.24.4",
"hermes-parser": "^0.25.1",
"zod": "^3.25.0 || ^4.0.0",
"zod-validation-error": "^3.5.0 || ^4.0.0"
},
"engines": {
"node": ">=10"
"node": ">=18"
},
"peerDependencies": {
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0"
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
}
},
"node_modules/eslint-plugin-react/node_modules/doctrine": {
@@ -9034,10 +9133,9 @@
}
},
"node_modules/eslint/node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"license": "MIT",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dependencies": {
"argparse": "^2.0.1"
},
@@ -10504,6 +10602,21 @@
"he": "bin/he"
}
},
"node_modules/hermes-estree": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
"integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
"dev": true
},
"node_modules/hermes-parser": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
"integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
"dev": true,
"dependencies": {
"hermes-estree": "0.25.1"
}
},
"node_modules/hey-listen": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz",
@@ -10652,6 +10765,14 @@
"node": ">=12"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -10817,6 +10938,44 @@
"node": ">=10.17.0"
}
},
"node_modules/i18next": {
"version": "23.7.16",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-23.7.16.tgz",
"integrity": "sha512-SrqFkMn9W6Wb43ZJ9qrO6U2U4S80RsFMA7VYFSqp7oc7RllQOYDCdRfsse6A7Cq/V8MnpxKvJCYgM8++27n4Fw==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-7.2.0.tgz",
"integrity": "sha512-U00DbDtFIYD3wkWsr2aVGfXGAj2TgnELzOX9qv8bT0aJtvPV9CRO77h+vgmHFBMe7LAxdwvT/7VkCWGya6L3tA==",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/i18next-http-backend": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-2.4.2.tgz",
"integrity": "sha512-wKrgGcaFQ4EPjfzBTjzMU0rbFTYpa0S5gv9N/d8WBmWS64+IgJb7cHddMvV+tUkse7vUfco3eVs2lB+nJhPo3w==",
"dependencies": {
"cross-fetch": "4.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -12667,10 +12826,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
"license": "MIT",
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
@@ -13305,10 +13463,9 @@
}
},
"node_modules/mdast-util-to-hast": {
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",
"integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==",
"license": "MIT",
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
"integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
@@ -14008,6 +14165,11 @@
"node": "*"
}
},
"node_modules/monaco-editor": {
"version": "0.49.0",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.49.0.tgz",
"integrity": "sha512-2I8/T3X/hLxB2oPHgqcNYUVdA/ZEFShT7IAujifIPMfKkNbLOqY8XCoyHCXrsdjb36dW9MwoTwBCFpXKMwNwaQ=="
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -14099,11 +14261,48 @@
"tslib": "^2.0.3"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/node-forge": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
"integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==",
"license": "(BSD-3-Clause OR GPL-2.0)",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz",
"integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==",
"engines": {
"node": ">= 6.13.0"
}
@@ -14787,6 +14986,26 @@
"node": ">=4"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/polished": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz",
"integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.17.8"
},
"engines": {
"node": ">=10"
}
},
"node_modules/popmotion": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/popmotion/-/popmotion-11.0.5.tgz",
@@ -16322,6 +16541,91 @@
"teleport": ">=0.2.0"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"engines": {
"node": ">=6"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
@@ -16738,6 +17042,27 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-i18next": {
"version": "13.5.0",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-13.5.0.tgz",
"integrity": "sha512-CFJ5NDGJ2MUyBohEHxljOq/39NQ972rh1ajnadG9BjTk+UXbHLq4z5DKEbEQBDoIhUmmbuS/fIMJKo6VOax1HA==",
"dependencies": {
"@babel/runtime": "^7.22.5",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.2.3",
"react": ">= 16.8.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/react-icons": {
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.12.0.tgz",
@@ -16762,6 +17087,18 @@
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"license": "MIT"
},
"node_modules/react-layout-kit": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/react-layout-kit/-/react-layout-kit-1.9.2.tgz",
"integrity": "sha512-fzmrwMBNGIAiDIrdFMV3NvJhUNl01QC9EMcI8SP7osg51N4j+z6w4tx9i2yWxEEXZ2armLV6EtkFd3KST8PYiA==",
"dependencies": {
"@babel/runtime": "^7",
"@emotion/css": "^11"
},
"peerDependencies": {
"react": ">=18"
}
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@@ -17480,6 +17817,11 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -18112,6 +18454,11 @@
"node": ">= 0.8.0"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -18470,6 +18817,11 @@
"integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==",
"license": "MIT"
},
"node_modules/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="
},
"node_modules/static-eval": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.0.2.tgz",
@@ -18971,10 +19323,9 @@
}
},
"node_modules/sucrase/node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"license": "ISC",
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
@@ -20289,6 +20640,14 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/w3c-hr-time": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
@@ -20763,6 +21122,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
},
"node_modules/which-typed-array": {
"version": "1.1.19",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
@@ -21314,6 +21678,27 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz",
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-validation-error": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
"integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
"dev": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+10
View File
@@ -15,6 +15,8 @@
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@hookform/resolvers": "^3.3.4",
"@lobehub/icons": "^1.10.1",
"@monaco-editor/react": "^4.6.0",
"@tanstack/react-query": "^4.36.1",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
@@ -22,6 +24,7 @@
"@tinymce/tinymce-react": "^6.3.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.126",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@types/react-frame-component": "^4.1.6",
@@ -30,9 +33,14 @@
"date-fns": "^4.1.0",
"dompurify": "^3.2.6",
"framer-motion": "^10.16.4",
"i18next": "^23.7.16",
"i18next-browser-languagedetector": "^7.2.0",
"i18next-http-backend": "^2.4.2",
"lucide-react": "^0.379.0",
"maplibre-gl": "^5.9.0",
"monaco-editor": "^0.49.0",
"popmotion": "^11.0.5",
"qrcode": "^1.5.4",
"quill": "^2.0.3",
"react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1",
@@ -42,6 +50,7 @@
"react-frame-component": "^5.2.7",
"react-helmet-async": "^2.0.5",
"react-hook-form": "^7.48.2",
"react-i18next": "^13.5.0",
"react-icons": "^4.12.0",
"react-image-crop": "^11.0.10",
"react-markdown": "^10.1.0",
@@ -65,6 +74,7 @@
"@types/react-image-crop": "^8.1.6",
"@types/react-syntax-highlighter": "^15.5.13",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react-hooks": "^7.0.1",
"http-proxy-middleware": "^3.0.5"
},
"browserslist": {
Binary file not shown.
+103
View File
@@ -0,0 +1,103 @@
// Admin Sidebar Scroll Test - Run directly in browser console
// Copy and paste this entire script into the browser console on any admin page
(function adminScrollTest() {
console.clear();
console.log('=== ADMIN SIDEBAR SCROLL TEST ===\n');
// Find sidebar
const sidebar = document.querySelector('[data-sidebar="true"]');
if (!sidebar) {
console.error('❌ No sidebar found. Make sure you are on an admin page (/admin/*)');
return;
}
console.log('✅ Sidebar found');
console.log('📏 Current scroll:', sidebar.scrollTop);
console.log('📏 Scroll height:', sidebar.scrollHeight);
console.log('📏 Client height:', sidebar.clientHeight);
console.log('📏 Scrollable:', sidebar.scrollHeight > sidebar.clientHeight ? '✅ YES' : '❌ NO');
// Check if scrollable
if (sidebar.scrollHeight <= sidebar.clientHeight) {
console.log('\n⚠️ Sidebar is NOT scrollable!');
console.log(' To test scroll retention, the sidebar needs to be scrollable.');
console.log(' Try: 1) Reducing browser window height, or 2) Zoom in (Ctrl +)');
return;
}
console.log('\n🧪 Testing scroll retention...');
// Clear any existing scroll data
sessionStorage.removeItem('admin-sidebar-scroll');
sessionStorage.removeItem('admin-sidebar-scroll-emergency');
delete window.__adminSidebarScrollTarget;
// Scroll to a test position (middle of scrollable area)
const maxScroll = sidebar.scrollHeight - sidebar.clientHeight;
const testScroll = Math.floor(maxScroll * 0.5);
console.log('📍 Scrolling to test position:', testScroll, 'px');
sidebar.scrollTop = testScroll;
setTimeout(() => {
const actualScroll = sidebar.scrollTop;
console.log('📍 Current position after scroll:', actualScroll, 'px');
// Check if scroll was saved
const savedData = sessionStorage.getItem('admin-sidebar-scroll');
const emergencyData = sessionStorage.getItem('admin-sidebar-scroll-emergency');
const globalTarget = window.__adminSidebarScrollTarget;
console.log('\n💾 Storage check:');
console.log(' Main saved data:', savedData);
console.log(' Emergency saved:', emergencyData);
console.log(' Global target:', globalTarget);
console.log('\n🚀 NOW CLICK ANY ADMIN NAVIGATION LINK');
console.log(' The test will automatically detect navigation and check scroll preservation');
// Intercept navigation to test
let navDetected = false;
const originalPushState = history.pushState;
history.pushState = function(...args) {
if (!navDetected) {
navDetected = true;
const scrollBefore = sidebar.scrollTop;
console.log('\n🚦 Navigation detected!');
console.log(' Scroll position before navigation:', scrollBefore, 'px');
// Check scroll preservation at multiple intervals
const checkPoints = [100, 300, 600, 1000];
checkPoints.forEach((delay, index) => {
setTimeout(() => {
const scrollAfter = sidebar.scrollTop;
const difference = Math.abs(scrollAfter - scrollBefore);
console.log(` 📍 Check ${index + 1} (${delay}ms): ${scrollAfter}px (diff: ${difference}px)`);
if (delay === 1000) {
if (difference < 10) {
console.log('\n✅ SUCCESS: Scroll position preserved!');
console.log(' The admin sidebar scroll retention is working correctly.');
} else {
console.log('\n❌ FAILURE: Scroll position was reset');
console.log(' Difference:', difference, 'px');
console.log('\n🔍 Debugging info:');
console.log(' - Check browser console for any scroll-related errors');
console.log(' - Verify AdminScrollManager is loaded');
console.log(' - Check if CSS is interfering');
}
// Restore original history function
history.pushState = originalPushState;
}
}, delay);
});
}
return originalPushState.apply(this, args);
};
}, 500);
})();
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128px" height="128px"><path fill="#F7F7FB" d="M109.7,110H18.2c-5.1,0-9.2-4.1-9.2-9.2V22.6c0-2.5,2.1-4.6,4.6-4.6H96c2.5,0,4.6,2.1,4.6,4.6v23.1v55.2C100.6,105.9,104.7,110,109.7,110L109.7,110c5.1,0,9.2-4.1,9.2-9.2V50.2c0-2.5-2.1-4.6-4.6-4.6h-4.6"/><path fill="#DEDFE6" d="M23 31.9h63.1c1.7 0 3 1.3 3 3v16.9c0 1.7-1.3 3-3 3H23c-1.7 0-3-1.3-3-3V34.9C20 33.2 21.3 31.9 23 31.9zM109.7 110L109.7 110c5.1 0 9.2-4.1 9.2-9.2V50.2c0-2.5-2.1-4.6-4.6-4.6h-13.8v55.2C100.6 105.9 104.7 110 109.7 110z"/><path fill="#464C55" d="M109.7,113H18.2C11.5,113,6,107.5,6,100.8V22.6c0-4.2,3.4-7.6,7.6-7.6H96c4.2,0,7.6,3.4,7.6,7.6v78.3c0,3.4,2.8,6.2,6.2,6.2s6.2-2.8,6.2-6.2V50.2c0-0.9-0.7-1.6-1.6-1.6h-4.6c-1.7,0-3-1.3-3-3s1.3-3,3-3h4.6c4.2,0,7.6,3.4,7.6,7.6v50.6C121.9,107.6,116.4,113,109.7,113z M13.6,21c-0.9,0-1.6,0.7-1.6,1.6v78.3c0,3.4,2.8,6.2,6.2,6.2h81.1c-1.1-1.8-1.7-3.9-1.7-6.2V22.6c0-0.9-0.7-1.6-1.6-1.6L13.6,21z"/><path fill="#DEDFE6" d="M41.2 72.9H23c-1.7 0-3-1.3-3-3s1.3-3 3-3h18.2c1.7 0 3 1.3 3 3S42.9 72.9 41.2 72.9zM41.2 98.7H23c-1.7 0-3-1.3-3-3s1.3-3 3-3h18.2c1.7 0 3 1.3 3 3S42.9 98.7 41.2 98.7zM41.2 85.7H23c-1.7 0-3-1.3-3-3s1.3-3 3-3h18.2c1.7 0 3 1.3 3 3S42.9 85.7 41.2 85.7z"/><path fill="#464C55" d="M86,99.1H58c-1.7,0-3-1.3-3-3V68.6c0-1.7,1.3-3,3-3h28c1.7,0,3,1.3,3,3v27.5C89,97.7,87.7,99.1,86,99.1z M61,93.1h22V71.6H61V93.1z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,46 @@
// Quick debug to check if AdminScrollManager is working
// Run this in browser console
(function debugAdminScrollManager() {
console.clear();
console.log('=== DEBUGGING AdminScrollManager ===\n');
// Check if AdminScrollManager is loaded
console.log('🔍 Checking AdminScrollManager...');
// Look for AdminScrollManager logs
console.log('📋 Recent console logs:');
const logs = console.logs || [];
console.log(' - Look for "[AdminScrollManager] Component mounted" message');
console.log(' - Look for "[AdminScrollManager] Saved position" message');
console.log(' - Look for "[AdminScrollManager] Restoring position" message');
// Check if AdminLayout is being used
const adminLayout = document.querySelector('.admin-layout');
console.log('\n🏗️ AdminLayout check:');
console.log(' AdminLayout element found:', !!adminLayout);
// Check sidebar
const sidebar = document.querySelector('[data-sidebar="true"]');
console.log('\n📱 Sidebar check:');
console.log(' Sidebar element found:', !!sidebar);
console.log(' Sidebar current scroll:', sidebar?.scrollTop);
// Check if React DevTools shows AdminScrollManager
console.log('\n🔧 Manual test:');
console.log(' 1. Scroll the sidebar to any position');
console.log(' 2. Click an admin navigation link');
console.log(' 3. Watch console for AdminScrollManager messages');
console.log('\n💡 If you don\'t see AdminScrollManager logs, the component is not loading');
// Force a test
if (sidebar) {
console.log('\n🧪 Forcing a scroll test...');
sidebar.scrollTop = 300;
setTimeout(() => {
console.log(' Scroll set to:', sidebar.scrollTop);
console.log(' Now click a navigation link and watch for AdminScrollManager logs');
}, 100);
}
})();
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="144px" height="144px" baseProfile="basic"><path fill="#536dfe" d="M47.496,10.074c-0.508-0.249-0.727,0.226-1.025,0.467c-0.102,0.078-0.188,0.179-0.274,0.272 c-0.743,0.794-1.611,1.315-2.746,1.253c-1.658-0.093-3.074,0.428-4.326,1.696c-0.266-1.564-1.15-2.498-2.495-3.097 c-0.704-0.311-1.416-0.623-1.909-1.3c-0.344-0.482-0.438-1.019-0.61-1.548c-0.11-0.319-0.219-0.646-0.587-0.7 c-0.399-0.062-0.555,0.272-0.712,0.553c-0.626,1.144-0.868,2.405-0.845,3.681c0.055,2.871,1.267,5.159,3.676,6.785 c0.274,0.187,0.344,0.373,0.258,0.646c-0.164,0.56-0.36,1.105-0.532,1.665c-0.11,0.358-0.274,0.436-0.657,0.28 c-1.322-0.552-2.464-1.369-3.473-2.358c-1.713-1.657-3.262-3.486-5.194-4.918c-0.454-0.335-0.907-0.646-1.377-0.942 c-1.971-1.914,0.258-3.486,0.774-3.673c0.54-0.195,0.188-0.864-1.557-0.856c-1.744,0.008-3.34,0.591-5.374,1.369 c-0.297,0.117-0.61,0.202-0.931,0.272c-1.846-0.35-3.763-0.428-5.765-0.202c-3.77,0.42-6.782,2.202-8.996,5.245 c-2.66,3.657-3.285,7.812-2.519,12.147c0.806,4.568,3.137,8.349,6.719,11.306c3.716,3.066,7.994,4.568,12.876,4.28 c2.965-0.171,6.266-0.568,9.989-3.719c0.939,0.467,1.924,0.654,3.559,0.794c1.259,0.117,2.472-0.062,3.411-0.257 c1.471-0.311,1.369-1.673,0.837-1.922C34,36,33.471,35.441,33.471,35.441c2.19-2.591,5.491-5.284,6.782-14.007 c0.102-0.692,0.016-1.128,0-1.689c-0.008-0.342,0.07-0.475,0.462-0.514c1.079-0.125,2.128-0.42,3.09-0.949 c2.793-1.525,3.919-4.031,4.185-7.034C48.028,10.79,47.981,10.315,47.496,10.074z M23.161,37.107 c-4.177-3.284-6.203-4.365-7.04-4.319c-0.782,0.047-0.641,0.942-0.469,1.525c0.18,0.576,0.415,0.973,0.743,1.478 c0.227,0.335,0.383,0.833-0.227,1.206c-1.345,0.833-3.684-0.28-3.794-0.335c-2.722-1.603-4.998-3.72-6.602-6.614 c-1.549-2.786-2.448-5.774-2.597-8.964c-0.039-0.77,0.188-1.043,0.954-1.183c1.009-0.187,2.049-0.226,3.059-0.078 c4.263,0.623,7.893,2.529,10.936,5.548c1.737,1.72,3.051,3.774,4.404,5.782c1.439,2.132,2.988,4.163,4.959,5.828 c0.696,0.584,1.252,1.027,1.783,1.354C27.667,38.515,24.991,38.554,23.161,37.107L23.161,37.107z M25.164,24.228 c0-0.342,0.274-0.615,0.618-0.615c0.078,0,0.149,0.015,0.211,0.039c0.086,0.031,0.164,0.078,0.227,0.148 c0.11,0.109,0.172,0.265,0.172,0.428c0,0.342-0.274,0.615-0.618,0.615S25.164,24.571,25.164,24.228L25.164,24.228z M31.382,27.419 c-0.399,0.163-0.798,0.303-1.181,0.319c-0.595,0.031-1.244-0.21-1.596-0.506c-0.548-0.459-0.939-0.716-1.103-1.517 c-0.07-0.342-0.031-0.872,0.031-1.175c0.141-0.654-0.016-1.074-0.477-1.455c-0.376-0.311-0.853-0.397-1.377-0.397 c-0.196,0-0.375-0.086-0.508-0.156c-0.219-0.109-0.399-0.381-0.227-0.716c0.055-0.109,0.321-0.373,0.383-0.42 c0.712-0.405,1.533-0.272,2.292,0.031c0.704,0.288,1.236,0.817,2.003,1.564c0.782,0.903,0.923,1.152,1.369,1.829 c0.352,0.529,0.673,1.074,0.892,1.696C32.016,26.905,31.844,27.224,31.382,27.419L31.382,27.419z"/></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>

After

Width:  |  Height:  |  Size: 756 B

@@ -0,0 +1,4 @@
<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#f7fafc"/>
<text x="50%" y="50%" text-anchor="middle" dy=".3em" fill="#a0aec0" font-family="Arial, sans-serif" font-size="14">Club Logo</text>
</svg>

After

Width:  |  Height:  |  Size: 257 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 2.9 KiB

+36
View File
@@ -0,0 +1,36 @@
<svg width="192" height="192" viewBox="0 0 192 192" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Clean white background -->
<rect width="192" height="192" fill="#ffffff"/>
<!-- Enhanced Football/Soccer Ball Design -->
<g transform="translate(96,96)">
<!-- Outer circle with bold stroke -->
<circle cx="0" cy="0" r="48" fill="none" stroke="#000000" stroke-width="3"/>
<!-- Pentagon pattern - cleaner design -->
<g>
<!-- Center pentagon -->
<path d="M 0,-20 L 19,-6 L 12,16 L -12,16 L -19,-6 Z"
fill="#000000" stroke="#000000" stroke-width="2"/>
<!-- Surrounding hexagons with cleaner lines -->
<g stroke="#000000" stroke-width="2" fill="none">
<path d="M 0,-20 L 19,-6 L 19,-32 L 0,-45 L -19,-32 L -19,-6 Z"/>
<path d="M 19,-6 L 12,16 L 34,16 L 41,-6 L 19,-6 Z"/>
<path d="M 12,16 L -12,16 L -19,38 L 3,46 L 25,38 L 12,16 Z"/>
<path d="M -12,16 L -19,-6 L -41,-6 L -34,16 L -12,16 Z"/>
<path d="M -19,-6 L -19,-32 L -41,-32 L -34,-6 L -19,-6 Z"/>
</g>
</g>
<!-- Add subtle texture lines -->
<g stroke="#000000" stroke-width="0.5" opacity="0.3">
<circle cx="0" cy="0" r="35" fill="none"/>
<circle cx="0" cy="0" r="25" fill="none"/>
</g>
</g>
<!-- Enhanced text with better typography -->
<text x="96" y="165" font-family="Arial, sans-serif" font-size="16" font-weight="900"
text-anchor="middle" fill="#000000" letter-spacing="1">FC CLUB</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

+1 -25
View File
@@ -1,25 +1 @@
{
"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"
}
{"short_name": "Fotbal Club", "name": "Fotbal Club - Oficiální aplikace", "description": "Oficiální webové stránky fotbalového klubu - aktuality, zápasy, tabulky, hráči a fotogalerie", "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"}
Binary file not shown.

After

Width:  |  Height:  |  Size: 958 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+42
View File
@@ -0,0 +1,42 @@
<svg width="192" height="192" viewBox="0 0 192 192" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Clean white background -->
<rect width="192" height="192" fill="#ffffff"/>
<!-- Enhanced Player Silhouette -->
<g transform="translate(96,96)">
<!-- Athletic pose silhouette -->
<g fill="#000000">
<!-- Head -->
<circle cx="0" cy="-28" r="14"/>
<!-- Body with athletic build -->
<path d="M -10,-14 L -7,8 L -4,30 L 4,30 L 7,8 L 10,-14 Z"/>
<!-- Defined arms in athletic position -->
<path d="M -10,-10 L -22,-8 L -24,2 L -18,6 L -10,2 M 10,-10 L 22,-8 L 24,2 L 18,6 L 10,2"/>
<!-- Strong legs in running stance -->
<path d="M -4,30 L -8,52 L -14,52 M 4,30 L 8,52 L 14,52"/>
<!-- Muscle definition lines -->
<g stroke="#ffffff" stroke-width="1" fill="none">
<line x1="-7" y1="-5" x2="-5" y2="5"/>
<line x1="7" y1="-5" x2="5" y2="5"/>
<line x1="-4" y1="15" x2="-4" y2="25"/>
<line x1="4" y1="15" x2="4" y2="25"/>
</g>
</g>
<!-- Motion lines -->
<g stroke="#000000" stroke-width="2" opacity="0.3">
<line x1="-30" y1="-20" x2="-20" y2="-18"/>
<line x1="-32" y1="-10" x2="-22" y2="-8"/>
<line x1="30" y1="-20" x2="20" y2="-18"/>
<line x1="32" y1="-10" x2="22" y2="-8"/>
</g>
</g>
<!-- Enhanced typography -->
<text x="96" y="165" font-family="Arial, sans-serif" font-size="14" font-weight="900"
text-anchor="middle" fill="#000000" letter-spacing="2">PLAYER</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+40 -4
View File
@@ -1,9 +1,13 @@
/* eslint-disable no-restricted-globals */
// Service Worker for PWA support and offline functionality
const CACHE_VERSION = 'v1.0.1';
const CACHE_VERSION = 'v1.0.2';
const CACHE_NAME = `fotbal-club-cache-${CACHE_VERSION}`;
// Rate limiting for background updates
const BACKGROUND_UPDATE_INTERVAL = 5000; // 5 seconds minimum between updates
const lastBackgroundUpdates = new Map();
// Assets to cache on install
const STATIC_ASSETS = [
'/',
@@ -84,6 +88,11 @@ self.addEventListener('fetch', (event) => {
return;
}
// Skip background update requests to prevent infinite loops
if (request.headers.get('X-SW-Background-Update') === 'true') {
return;
}
// Handle SPA navigations with app shell fallback
if (request.mode === 'navigate') {
event.respondWith(handleNavigationRequest(request));
@@ -106,8 +115,16 @@ async function handleStaticRequest(request) {
// Try cache first
const cachedResponse = await caches.match(request);
if (cachedResponse) {
// Return cached response and update in background
fetchAndUpdateCache(request);
// For static assets with long cache headers, don't update in background
const url = new URL(request.url);
const isStaticAsset = url.pathname.match(/\.(png|jpg|jpeg|gif|svg|webp|ico|woff|woff2|ttf|eot)$/);
const hasLongCache = cachedResponse.headers.get('cache-control')?.includes('max-age=31536000');
// Only update in background if it's not a static asset with long cache
if (!isStaticAsset || !hasLongCache) {
fetchAndUpdateCache(request);
}
return cachedResponse;
}
@@ -198,9 +215,28 @@ async function handleAPIRequest(request) {
// Update cache in background
async function fetchAndUpdateCache(request) {
try {
const response = await fetch(request);
// Skip if this is already a background update request to prevent infinite loops
if (request.headers.get('X-SW-Background-Update') === 'true') {
return;
}
// Rate limit background updates to prevent excessive requests
const now = Date.now();
const lastUpdate = lastBackgroundUpdates.get(request.url);
if (lastUpdate && (now - lastUpdate) < BACKGROUND_UPDATE_INTERVAL) {
return;
}
lastBackgroundUpdates.set(request.url, now);
// Use fetch with cache: 'no-store' to bypass service worker and avoid infinite loops
const response = await fetch(request.url, {
cache: 'no-store',
headers: { 'X-SW-Background-Update': 'true' }
});
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
// Use the original request as key, but the new response
cache.put(request, response);
}
} catch (error) {
+49
View File
@@ -0,0 +1,49 @@
<svg width="192" height="192" viewBox="0 0 192 192" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Clean white background -->
<rect width="192" height="192" fill="#ffffff"/>
<!-- Enhanced Sponsor/Partner placeholder -->
<g transform="translate(96,96)">
<!-- Premium shield shape with better proportions -->
<g>
<!-- Shadow -->
<path d="M 2,-47 L 37,-27 L 37,12 L 2,47 L -33,12 L -33,-27 Z"
fill="#000000" opacity="0.1"/>
<!-- Main shield -->
<path d="M 0,-45 L 35,-25 L 35,10 L 0,45 L -35,10 L -35,-25 Z"
fill="#ffffff" stroke="#000000" stroke-width="3"/>
<!-- Inner decorative border -->
<path d="M 0,-38 L 28,-20 L 28,8 L 0,38 L -28,8 L -28,-20 Z"
fill="none" stroke="#000000" stroke-width="1.5"/>
<!-- Enhanced star with better geometry -->
<g transform="scale(1.2)">
<path d="M 0,-18 L 5,-5 L 18,-3 L 9,6 L 11,19 L 0,13 L -11,19 L -9,6 L -18,-3 L -5,-5 Z"
fill="#000000"/>
<!-- Inner star detail -->
<path d="M 0,-10 L 2,-3 L 9,-2 L 4,3 L 5,10 L 0,7 L -5,10 L -4,3 L -9,-2 L -2,-3 Z"
fill="#ffffff"/>
</g>
<!-- Ribbon with better design -->
<g>
<path d="M -12,38 L 0,48 L 12,38 L 8,45 L 0,50 L -8,45 Z" fill="#000000"/>
<text x="0" y="44" font-family="Arial, sans-serif" font-size="8" font-weight="bold"
text-anchor="middle" fill="#ffffff">PREMIUM</text>
</g>
</g>
<!-- Decorative corner elements -->
<g stroke="#000000" stroke-width="1.5" fill="none">
<circle cx="-25" cy="-35" r="3"/>
<circle cx="25" cy="-35" r="3"/>
<circle cx="-25" cy="30" r="3"/>
<circle cx="25" cy="30" r="3"/>
</g>
</g>
<!-- Enhanced typography -->
<text x="96" y="165" font-family="Arial, sans-serif" font-size="14" font-weight="900"
text-anchor="middle" fill="#000000" letter-spacing="2">SPONSOR</text>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+40 -52
View File
@@ -10,11 +10,12 @@ import { useUmami } from './hooks/useUmami';
import { useFontLoader } from './hooks/useFontLoader';
import DefaultSEO from './components/seo/DefaultSEO';
import CookieBanner from './components/CookieBanner';
import { ConfirmDialogProvider } from './contexts/ConfirmDialogContext';
import ServiceWorkerUpdateListener from './components/common/ServiceWorkerUpdateListener';
import ProtectedRoute from './components/ProtectedRoute';
import { getSetupStatus } from './services/setup';
import { useState, useEffect } from 'react';
import { usePublicSettings } from './hooks/usePublicSettings';
import { getEditorAllowedAdminNav } from './services/navigation';
// Create a client
const queryClient = new QueryClient({
@@ -77,6 +78,7 @@ const OverlayScoreboardPage = lazy(() => import('./pages/OverlayScoreboardPage')
const OverlaySponsorsPage = lazy(() => import('./pages/OverlaySponsorsPage'));
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
const ForbiddenPage = lazy(() => import('./pages/ForbiddenPage'));
const I18nTestPage = lazy(() => import('./pages/I18nTestPage'));
// Legal pages
const CookiePolicyPage = lazy(() => import('./pages/legal/CookiePolicyPage'));
@@ -101,6 +103,7 @@ 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 AdminEshopProductsPage = lazy(() => import('./pages/admin/AdminEshopProductsPage'));
const AdminResetPasswordPage = lazy(() => import('./pages/admin/AdminResetPasswordPage'));
const AboutAdminPage = lazy(() => import('./pages/admin/AboutAdminPage'));
const AnalyticsAdminPage = lazy(() => import('./pages/admin/AnalyticsAdminPage'));
@@ -116,8 +119,17 @@ const ShortlinksAdminPage = lazy(() => import('./pages/admin/ShortlinksAdminPage
const EngagementAdminPage = lazy(() => import('./pages/admin/EngagementAdminPage'));
const SweepstakesAdminPage = lazy(() => import('./pages/admin/SweepstakesAdminPage'));
const SweepstakeVisualPage = lazy(() => import('./pages/admin/SweepstakeVisualPage'));
const I18nAdminPage = lazy(() => import('./pages/admin/I18nAdminPage'));
const SemiAdminPage = lazy(() => import('./pages/SemiAdminPage'));
const ErrorsAdminPage = lazy(() => import('./pages/admin/ErrorsAdminPage'));
const ManualFacrAdminPage = lazy(() => import('./pages/admin/ManualFacrAdminPage'));
const FinancialDashboard = lazy(() => import('./pages/admin/FinancialDashboard'));
const QRCodesAdminPage = lazy(() => import('./pages/admin/QRCodesAdminPage'));
const ExpensesPage = lazy(() => import('./pages/admin/ExpensesPage'));
const InvoicesPage = lazy(() => import('./pages/admin/InvoicesPage'));
const InvoiceSettingsPage = lazy(() => import('./pages/admin/InvoiceSettingsPage'));
const KontaktyPage = lazy(() => import('./pages/admin/KontaktyPage'));
const TicketAdminPage = lazy(() => import('./pages/admin/TicketAdminPage'));
// Analytics and font loader
const AnalyticsInitializer: React.FC = () => {
@@ -178,50 +190,12 @@ const AdminRoutesWrapper = () => {
return <Outlet />;
};
// Admin index: admins see dashboard; editors redirect to first allowed page
// Admin index: admins and editors see the main dashboard; others are forbidden
const AdminIndexRoute: React.FC = () => {
const { user } = useAuth();
const role = (user as any)?.role;
const [target, setTarget] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(role === 'editor');
useEffect(() => {
let mounted = true;
(async () => {
if (role === 'editor') {
try {
const items: any[] = await getEditorAllowedAdminNav();
let to = '/admin/clanky';
if (Array.isArray(items) && items.length > 0) {
const pickUrl = (it: any): string | null => {
if (it?.url) return it.url;
if (Array.isArray(it?.children) && it.children.length > 0) {
for (const ch of it.children) {
if (ch?.url) return ch.url;
}
}
return null;
};
for (const it of items) {
const u = pickUrl(it);
if (u) { to = u; break; }
}
}
if (mounted) setTarget(to);
} catch (_) {
if (mounted) setTarget('/admin/clanky');
} finally {
if (mounted) setLoading(false);
}
}
})();
return () => { mounted = false; };
}, [role]);
if (role === 'admin') return <AdminDashboardPage />;
if (role === 'editor') {
if (loading) return <PageLoader />;
return <Navigate to={target || '/admin/clanky'} replace />;
const role = String((user as any)?.role || '').toLowerCase();
if (role === 'admin' || role === 'editor') {
return <AdminDashboardPage />;
}
return <Navigate to="/403" replace />;
};
@@ -254,6 +228,8 @@ const AppLazy: React.FC = () => {
<AnalyticsInitializer />
<FontLoader />
<DefaultSEO />
<ConfirmDialogProvider>
<ServiceWorkerUpdateListener />
<Suspense fallback={<PageLoader />}>
<Routes>
{/* Public routes */}
@@ -262,6 +238,7 @@ const AppLazy: React.FC = () => {
<Route path="/search" element={<SearchPage />} />
<Route path="/overlay/scoreboard" element={<OverlayScoreboardPage />} />
<Route path="/overlay/sponsors" element={<OverlaySponsorsPage />} />
<Route path="/i18n-test" element={<I18nTestPage />} />
<Route path="/blog" element={<BlogRoute />} />
<Route path="/klub" element={<ClubPage />} />
<Route path="/o-klubu" element={<AboutPage />} />
@@ -327,30 +304,31 @@ const AppLazy: React.FC = () => {
<Route path="/admin/clanky" element={<ArticlesAdminPage />} />
<Route path="/admin/aktivity" element={<AdminActivitiesPage />} />
<Route path="/admin/shortlinks" element={<ShortlinksAdminPage />} />
<Route path="/admin/tymy" element={<TeamsAdminPage />} />
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
<Route path="/admin/aliasy-soutezi" element={<CompetitionAliasesAdminPage />} />
<Route path="/admin/o-klubu" element={<AboutAdminPage />} />
<Route path="/admin/videa" element={<AdminVideosPage />} />
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
<Route path="/admin/scoreboard" element={<ScoreboardAdminPage />} />
<Route path="/admin/scoreboard/remote" element={<MobileScoreboardControlPage />} />
</Route>
{/* Admin routes */}
<Route element={<ProtectedRoute requiredRole="admin"><AdminRoutesWrapper /></ProtectedRoute>}>
<Route path="/admin/docs" element={<AdminDocsPage />} />
<Route path="/admin/o-klubu" element={<AboutAdminPage />} />
<Route path="/admin/videa" element={<AdminVideosPage />} />
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
<Route path="/admin/eshop-produkty" element={<AdminEshopProductsPage />} />
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
<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/errors" element={<ErrorsAdminPage />} />
<Route path="/admin/soubory" element={<FilesAdminPage />} />
@@ -361,6 +339,15 @@ const AppLazy: React.FC = () => {
<Route path="/admin/engagement" element={<EngagementAdminPage />} />
<Route path="/admin/sweepstakes" element={<SweepstakesAdminPage />} />
<Route path="/admin/sweepstakes/:id/visual" element={<SweepstakeVisualPage />} />
<Route path="/admin/i18n" element={<I18nAdminPage />} />
<Route path="/admin/manual-data" element={<ManualFacrAdminPage />} />
<Route path="/admin/financial-dashboard" element={<FinancialDashboard />} />
<Route path="/admin/qr-codes" element={<QRCodesAdminPage />} />
<Route path="/admin/expenses" element={<ExpensesPage />} />
<Route path="/admin/invoices" element={<InvoicesPage />} />
<Route path="/admin/invoice-settings" element={<InvoiceSettingsPage />} />
<Route path="/admin/customers" element={<KontaktyPage />} />
<Route path="/admin/tickets" element={<TicketAdminPage />} />
</Route>
{/* Legacy admin routes */}
@@ -375,6 +362,7 @@ const AppLazy: React.FC = () => {
</Routes>
</Suspense>
<CookieBanner />
</ConfirmDialogProvider>
</HelmetProvider>
</ClubThemeProvider>
</AuthProvider>
+118 -13
View File
@@ -56,7 +56,10 @@ import ShortlinksAdminPage from './pages/admin/ShortlinksAdminPage';
import CommentsAdminPage from './pages/admin/CommentsAdminPage';
import EngagementAdminPage from './pages/admin/EngagementAdminPage';
import SweepstakesAdminPage from './pages/admin/SweepstakesAdminPage';
import FinancialDashboard from './pages/admin/FinancialDashboard';
import QRCodesAdminPage from './pages/admin/QRCodesAdminPage';
import SweepstakeVisualPage from './pages/admin/SweepstakeVisualPage';
import I18nAdminPage from './pages/admin/I18nAdminPage';
import SemiAdminPage from './pages/SemiAdminPage';
import PollsAdminPage from './pages/admin/PollsAdminPage';
// Admin pages render their own AdminLayout internally
@@ -64,12 +67,15 @@ import SetupPage from './pages/SetupPage';
import StylePreviewPage from './pages/StylePreviewPage';
import AboutPage from './pages/AboutPage';
import AdminDocsPage from './pages/admin/AdminDocsPage';
import ManualFacrAdminPage from './pages/admin/ManualFacrAdminPage';
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 { ConfirmDialogProvider } from './contexts/ConfirmDialogContext';
import ServiceWorkerUpdateListener from './components/common/ServiceWorkerUpdateListener';
import CookiePolicyPage from './pages/legal/CookiePolicyPage';
import OverlayScoreboardPage from './pages/OverlayScoreboardPage';
import OverlaySponsorsPage from './pages/OverlaySponsorsPage';
@@ -82,7 +88,6 @@ import ForbiddenPage from './pages/ForbiddenPage';
import NotFoundPage from './pages/NotFoundPage';
import VideosPage from './pages/VideosPage';
import SearchPage from './pages/SearchPage';
import ShortRedirectPage from './pages/ShortRedirectPage';
import ClothingPage from './pages/ClothingPage';
import PollsPage from './pages/PollsPage';
import { useUmami } from './hooks/useUmami';
@@ -277,7 +282,7 @@ const FontLoader: React.FC = () => {
return null;
};
// Component to trigger daily check-in for authenticated users (once per day per device)
// Component to trigger daily check-in for authenticated users (once per day)
const CheckinInitializer: React.FC = () => {
const { isAuthenticated } = useAuth();
useEffect(() => {
@@ -399,10 +404,12 @@ const App: React.FC = () => {
<Router>
<AuthProvider>
<ClubThemeProvider>
<ConfirmDialogProvider>
<AnalyticsInitializer />
<FontLoader />
<RouteLogger />
<CheckinInitializer />
<ServiceWorkerUpdateListener />
<DefaultSEO />
<Routes>
{/* Public routes */}
@@ -433,8 +440,7 @@ const App: React.FC = () => {
<Route path="/pravidla-cookies" element={<CookiePolicyPage />} />
<Route path="/obchodni-podminky" element={<TermsPage />} />
<Route path="/zasady-ochrany-osobnich-udaju" element={<PrivacyPolicyPage />} />
{/* Short links - forward to backend origin if frontend captured it */}
<Route path="/s/:code" element={<ShortRedirectPage />} />
{/* Short links are handled by nginx -> backend, not React Router */}
<Route path="/news" element={<NewsRedirect />} />
{/* Slug routes must precede id route to avoid conflicts */}
<Route path="/news/:slug" element={<ArticleDetailPage />} />
@@ -510,26 +516,21 @@ const App: React.FC = () => {
}>
<Route path="/admin/docs" element={<AdminDocsPage />} />
{/* moved to editor-accessible routes below */}
<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/sponzori" element={<SponsorsAdminPage />} />
{/* moved to editor-accessible routes below */}
<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 />} />
@@ -538,6 +539,7 @@ const App: React.FC = () => {
<Route path="/admin/engagement" element={<EngagementAdminPage />} />
<Route path="/admin/sweepstakes" element={<SweepstakesAdminPage />} />
<Route path="/admin/sweepstakes/:id/visual" element={<SweepstakeVisualPage />} />
<Route path="/admin/jazyky" element={<I18nAdminPage />} />
</Route>
{/* Remaining protected routes that don't use AdminLayout */}
@@ -604,11 +606,114 @@ const App: React.FC = () => {
}
/>
{/* Financial Management */}
<Route
path="/admin/financial-dashboard"
element={
<ProtectedRoute requiredRole="admin">
<FinancialDashboard />
</ProtectedRoute>
}
/>
{/* QR Codes */}
<Route
path="/admin/qr-codes"
element={
<ProtectedRoute requiredRole="admin">
<QRCodesAdminPage />
</ProtectedRoute>
}
/>
{/* Editor-level admin pages (also allow admin) */}
<Route
path="/admin/tymy"
element={
<ProtectedRoute requiredRole="editor">
<TeamsAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/zapasy"
element={
<ProtectedRoute requiredRole="editor">
<MatchesAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/hraci"
element={
<ProtectedRoute requiredRole="editor">
<PlayersAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/aliasy-soutezi"
element={
<ProtectedRoute requiredRole="editor">
<CompetitionAliasesAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/manual-data"
element={
<ProtectedRoute requiredRole="admin">
<ManualFacrAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/o-klubu"
element={
<ProtectedRoute requiredRole="editor">
<AboutAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/videa"
element={
<ProtectedRoute requiredRole="editor">
<AdminVideosPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/galerie"
element={
<ProtectedRoute requiredRole="editor">
<GalleryAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/scoreboard"
element={
<ProtectedRoute requiredRole="editor">
<ScoreboardAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/scoreboard/remote"
element={
<ProtectedRoute requiredRole="editor">
<MobileScoreboardControlPage />
</ProtectedRoute>
}
/>
{/* Not found route */}
<Route path="*" element={<NotFoundRoute />} />
</Routes>
{/* Cookie consent banner shown across the whole site */}
<CookieBanner />
</ConfirmDialogProvider>
</ClubThemeProvider>
</AuthProvider>
</Router>
+203 -307
View File
@@ -4,7 +4,6 @@ import {
Flex,
Button,
useColorModeValue,
useColorMode,
IconButton,
Avatar,
Menu,
@@ -36,7 +35,7 @@ import {
Input,
useToast,
} from '@chakra-ui/react';
import { MoonIcon, SunIcon, HamburgerIcon, EditIcon, ChevronDownIcon } from '@chakra-ui/icons';
import { 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';
@@ -49,6 +48,7 @@ import { getNavigationItems, NavigationItem, seedDefaultNavigation } from '../se
import { getEvents } from '../services/eventService';
import { getPlayers } from '../services/public';
import { getArticles } from '../services/articles';
import { LanguageSwitcher } from './common/LanguageSwitcher';
import { getCachedYouTube } from '../services/youtube';
import { getZoneramaManifestWithFallbacks } from '../services/zonerama';
import { getMyNewsletterToken } from '../services/public/newsletter';
@@ -57,6 +57,9 @@ import { assetUrl } from '../utils/url';
import { getProfile as getEngagementProfile, EngagementProfile } from '../services/engagement';
import AchievementsModal from './engagement/AchievementsModal';
import RewardsModal from './engagement/RewardsModal';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { useTranslation } from 'react-i18next';
import { useNavbarData } from '../hooks/useNavbarData';
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
@@ -85,7 +88,7 @@ const normalizeSocialUrl = (network: 'facebook' | 'instagram' | 'youtube', raw?:
};
// Mobile menu component
const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, dividerColor, settings, categories, galleryHref, galleryLabel, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, dynamicNavItems, navLoading }: {
const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, dividerColor, settings, categories, galleryHref, galleryLabel, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, dynamicNavItems, navLoading, t, i18n, handleSimpleLanguageChange }: {
isOpen: boolean;
onClose: () => void;
isAdmin: boolean;
@@ -96,25 +99,27 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
categories?: Category[] | null;
galleryHref?: string | null;
galleryLabel?: string;
hasTables?: boolean | null;
hasActivities?: boolean | null;
hasPlayers?: boolean | null;
hasArticles?: boolean | null;
hasVideos?: boolean | null;
hasGallery?: boolean | null;
dynamicNavItems: NavigationItem[];
navLoading: boolean;
hasTables?: boolean;
hasActivities?: boolean;
hasPlayers?: boolean;
hasArticles?: boolean;
hasVideos?: boolean;
hasGallery?: boolean;
dynamicNavItems?: NavigationItem[];
navLoading?: boolean;
t: (key: string) => string;
i18n: any;
handleSimpleLanguageChange: (lang: string) => void;
}) => (
<Drawer isOpen={isOpen} placement="left" onClose={onClose}>
<DrawerOverlay />
<DrawerContent bg={menuBg}>
<DrawerCloseButton />
<DrawerHeader borderBottomWidth="1px" borderColor="border.subtle">Menu</DrawerHeader>
<DrawerHeader borderBottomWidth="1px" borderColor="border.subtle">{t('action.open_menu')}</DrawerHeader>
<DrawerBody>
<VStack align="stretch" spacing={2}>
{/* Dynamic navigation items in mobile */}
{(!navLoading && dynamicNavItems.length > 0) ? (
// Use dynamic navigation
{(!navLoading && dynamicNavItems && dynamicNavItems.length > 0) ? (
dynamicNavItems.map((item, idx) => {
const linkIsExternal = item.type === 'external';
const hasChildren = item.type === 'dropdown' && item.children && item.children.length > 0;
@@ -138,7 +143,7 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
{item.label}
</Button>
<VStack align="stretch" pl={4} spacing={1}>
{categories.map((cat: any) => {
{categories.map((cat: any, catIdx: number) => {
const catIsExternal = typeof cat.url === 'string' && /^https?:\/\//i.test(cat.url);
const catHref = cat.url || (cat.id ? `/blog?category_id=${cat.id}` : (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog'));
const catLinkProps = catIsExternal ? { href: catHref } : { to: catHref };
@@ -196,20 +201,20 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
) : (
// Fallback to hardcoded navigation
<>
<Button as={RouterLink} to="/" variant="ghost" justifyContent="flex-start">Domů</Button>
<Button as={RouterLink} to="/" variant="ghost" justifyContent="flex-start">{t('nav.home')}</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="/o-klubu" variant="ghost" justifyContent="flex-start">{t('nav.club')}</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>
{hasActivities === true && (
<Button as={RouterLink} to="/aktivity" variant="ghost" justifyContent="flex-start">Aktivity</Button>
<Button as={RouterLink} to="/kalendar" variant="ghost" justifyContent="flex-start">{t('nav.calendar')}</Button>
<Button as={RouterLink} to="/zapasy" variant="ghost" justifyContent="flex-start">{t('nav.matches')}</Button>
{hasActivities && (
<Button as={RouterLink} to="/aktivity" variant="ghost" justifyContent="flex-start">{t('nav.activities')}</Button>
)}
{hasPlayers === true && (
<Button as={RouterLink} to="/hraci" variant="ghost" justifyContent="flex-start">Hráči</Button>
{hasPlayers && (
<Button as={RouterLink} to="/hraci" variant="ghost" justifyContent="flex-start">{t('nav.players')}</Button>
)}
{hasTables === true && (
<Button as={RouterLink} to="/tabulky" variant="ghost" justifyContent="flex-start">Tabulky</Button>
{hasTables && (
<Button as={RouterLink} to="/tabulky" variant="ghost" justifyContent="flex-start">{t('nav.tables')}</Button>
)}
{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);
@@ -225,17 +230,19 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
variant="ghost"
justifyContent="flex-start"
>
{item?.label || 'Stránka'}
{item?.label || t('common.page')}
</Button>
);
})}
{hasArticles === true && (
{hasArticles && (
<>
{Array.isArray(categories) && categories.length > 0 ? (
<>
<Button as={RouterLink} to="/blog" onClick={onClose} variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
<Button as={RouterLink} to="/blog" onClick={onClose} variant="ghost" justifyContent="flex-start" fontWeight="bold">
{t('nav.articles')}
</Button>
<VStack align="stretch" pl={4} spacing={1}>
{categories.map((cat: any) => {
{categories.map((cat: any, catIdx: number) => {
const catIsExternal = typeof cat.url === 'string' && /^https?:\/\//i.test(cat.url);
const catHref = cat.url || (cat.id ? `/blog?category_id=${cat.id}` : (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog'));
const catLinkProps = catIsExternal ? { href: catHref } : { to: catHref };
@@ -248,31 +255,33 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
</VStack>
</>
) : (
<Button as={RouterLink} to="/blog" onClick={onClose} variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
<Button as={RouterLink} to="/blog" onClick={onClose} variant="ghost" justifyContent="flex-start" fontWeight="bold">
{t('nav.articles')}
</Button>
)}
</>
)}
{hasVideos === true && (
<Button as={RouterLink} to="/videa" variant="ghost" justifyContent="flex-start">Videa</Button>
{hasVideos && (
<Button as={RouterLink} to="/videa" variant="ghost" justifyContent="flex-start">{t('nav.videos')}</Button>
)}
<Button as={RouterLink} to="/hledat" variant="ghost" justifyContent="flex-start">Hledat</Button>
{hasGallery === true && (
<Button as={RouterLink} to="/galerie" variant="ghost" justifyContent="flex-start">{galleryLabel || 'Fotogalerie'}</Button>
<Button as={RouterLink} to="/hledat" variant="ghost" justifyContent="flex-start">{t('action.search')}</Button>
{hasGallery && (
<Button as={RouterLink} to="/galerie" variant="ghost" justifyContent="flex-start">{galleryLabel || t('homepage.gallery')}</Button>
)}
{settings?.shop_url && (
<Button as="a" href={settings.shop_url} target="_blank" rel="noreferrer" variant="ghost" justifyContent="flex-start">Fanshop</Button>
<Button as="a" href={settings.shop_url} target="_blank" rel="noreferrer" variant="ghost" justifyContent="flex-start">{t('nav.shop')}</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>
<Button as={RouterLink} to="/sponzori" variant="ghost" justifyContent="flex-start">{t('nav.sponsors')}</Button>
<Button as={RouterLink} to="/kontakt" variant="ghost" justifyContent="flex-start">{t('nav.contact')}</Button>
</>
)}
{isAdmin && (
<>
<Divider my={2} borderColor={dividerColor} />
<Text fontWeight="bold" mt={2} color={dividerColor}>Administrace</Text>
<Text fontWeight="bold" mt={2} color={dividerColor}>{t('nav.admin')}</Text>
<Button as={RouterLink} to="/admin" variant="ghost" justifyContent="flex-start" colorScheme="blue">
Administrace
{t('nav.admin')}
</Button>
</>
)}
@@ -281,13 +290,16 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
<>
<Divider my={2} borderColor={dividerColor} />
<Button as={RouterLink} to="/login" colorScheme="blue" justifyContent="flex-start">
Přihlásit se
{t('action.login')}
</Button>
<Button as={RouterLink} to="/register" variant="outline" justifyContent="flex-start">
Registrovat se
{t('common.register')}
</Button>
</>
)}
{/* Language switcher for mobile */}
<LanguageSwitcher />
</VStack>
</DrawerBody>
</DrawerContent>
@@ -295,7 +307,7 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
);
const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth = false, variant = 'unified' }) => {
const { colorMode, toggleColorMode } = useColorMode();
const { t, i18n } = useTranslation();
const { isAuthenticated, logout, user } = useAuth();
const { isOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isSearchOpen, onOpen: onSearchOpen, onClose: onSearchClose } = useDisclosure();
@@ -314,20 +326,15 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
const navTextColor = useColorModeValue('gray.700', 'gray.200');
const topBarBg = useColorModeValue('gray.50', 'blackAlpha.500');
const [scrolled, setScrolled] = useState(false);
const [hasTables, setHasTables] = useState<boolean | null>(null);
const [hasActivities, setHasActivities] = useState<boolean | null>(null);
const [hasPlayers, setHasPlayers] = useState<boolean | null>(null);
const [hasArticles, setHasArticles] = useState<boolean | null>(null);
const [hasVideos, setHasVideos] = useState<boolean | null>(null);
const [hasGallery, setHasGallery] = useState<boolean | null>(null);
const [dynamicNavItems, setDynamicNavItems] = useState<NavigationItem[]>([]);
const [navLoading, setNavLoading] = useState(true);
const containerMaxW = fullWidth ? 'full' as const : '7xl' as const;
const [windowWidth, setWindowWidth] = useState<number>(typeof window !== 'undefined' ? window.innerWidth : 1920);
const [engProfile, setEngProfile] = useState<EngagementProfile | null>(null);
const { isOpen: isAchOpen, onOpen: onAchOpen, onClose: onAchClose } = useDisclosure();
const { isOpen: isRewOpen, onOpen: onRewOpen, onClose: onRewClose } = useDisclosure();
// Use the combined navbar data hook
const navbarData = useNavbarData(isAdmin, settings);
useEffect(() => {
const onResize = () => setWindowWidth(window.innerWidth);
window.addEventListener('resize', onResize);
@@ -379,6 +386,24 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
return () => window.removeEventListener('scroll', onScroll as any);
}, []);
// Simple language change handler for non-logged-in users
const handleSimpleLanguageChange = async (languageCode: string) => {
try {
// Change language in i18next
await i18n.changeLanguage(languageCode);
// Save to localStorage and cookie
try {
localStorage.setItem('language', languageCode);
} catch (e) {
// Ignore localStorage errors
}
document.cookie = `lang=${languageCode}; max-age=${365 * 24 * 60 * 60}; path=/`;
} catch (error) {
console.error('Failed to change language:', error);
}
};
// Open newsletter preferences for logged-in user (fetch token and redirect)
const openMyNewsletterPrefs = async () => {
try {
@@ -427,162 +452,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
// 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 origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').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; };
}, []);
// Determine if there are any activities/events available
useEffect(() => {
let disposed = false;
(async () => {
try {
const events = await getEvents();
if (!disposed) setHasActivities(Array.isArray(events) && events.length > 0);
} catch {
if (!disposed) setHasActivities(false);
}
})();
return () => { disposed = true; };
}, []);
// Determine if there are any players available
useEffect(() => {
let disposed = false;
(async () => {
try {
const players = await getPlayers();
if (!disposed) setHasPlayers(Array.isArray(players) && players.length > 0);
} catch {
if (!disposed) setHasPlayers(false);
}
})();
return () => { disposed = true; };
}, []);
// Determine if there are any articles available
useEffect(() => {
let disposed = false;
(async () => {
try {
const result = await getArticles({ page: 1, page_size: 1, published: true });
if (!disposed) setHasArticles(result.total > 0);
} catch {
if (!disposed) setHasArticles(false);
}
})();
return () => { disposed = true; };
}, []);
// Determine if there are any videos available
useEffect(() => {
let disposed = false;
(async () => {
try {
const youtube = await getCachedYouTube();
if (!disposed) setHasVideos(youtube && Array.isArray(youtube.videos) && youtube.videos.length > 0);
} catch {
if (!disposed) setHasVideos(false);
}
})();
return () => { disposed = true; };
}, []);
// Determine if there is any gallery content available
useEffect(() => {
let disposed = false;
(async () => {
try {
const manifest = await getZoneramaManifestWithFallbacks();
if (!disposed) setHasGallery(Array.isArray(manifest) && manifest.length > 0);
} catch {
if (!disposed) setHasGallery(false);
}
})();
return () => { disposed = true; };
}, []);
const galleryLabel = settings?.gallery_label || t('homepage.gallery');
const isPathActive = (to?: string) => {
if (!to) return false;
@@ -592,8 +462,30 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
// Convert NavigationItem to NavLink format
const convertToNavLink = (item: NavigationItem): NavLink => {
// Map known Czech labels to translation keys
const getTranslatedLabel = (label: string) => {
const labelMap: Record<string, string> = {
'Domů': 'nav.home',
'Aktuality': 'nav.news',
'Zápasy': 'nav.matches',
'Hráči': 'nav.players',
'Fotogalerie': 'nav.gallery',
'Videa': 'nav.videos',
'Kontakt': 'nav.contact',
'O klubu': 'nav.about',
'Aktivity': 'nav.activities',
'Sponzoři': 'nav.sponsors',
'Články': 'nav.news',
'Blog': 'nav.news',
'Kalendář': 'nav.calendar',
'Tabulky': 'nav.table'
};
const translationKey = labelMap[label];
return translationKey ? t(translationKey) : label;
};
const link: NavLink = {
label: item.label,
label: getTranslatedLabel(item.label),
to: item.url || '#',
external: item.type === 'external',
};
@@ -601,7 +493,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
// Add children for dropdown items
if (item.type === 'dropdown' && item.children && item.children.length > 0) {
link.items = item.children.map(child => ({
label: child.label,
label: getTranslatedLabel(child.label),
to: child.url || '#',
}));
}
@@ -611,34 +503,35 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
// Build categories as items for Články dropdown (fallback)
const categoryItems = useMemo(() => {
const source = Array.isArray(navCategories) && navCategories.length > 0 ? navCategories : [];
const source = Array.isArray(navbarData.categories) && navbarData.categories.length > 0 ? navbarData.categories : [];
return source.map((cat: any) => ({
label: cat.name,
to: cat.url || (cat.id ? `/blog?category_id=${cat.id}` : (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog'))
}));
}, [navCategories]);
}, [navbarData.categories]);
// Filter dynamic navigation items based on available data (only show when data exists)
const filteredDynamicNavItems = useMemo(() => {
const filterItem = (item: NavigationItem): NavigationItem | null => {
const url = item.url || '';
if (url.startsWith('/aktivity') && hasActivities !== true) return null;
if (url.startsWith('/hraci') && hasPlayers !== true) return null;
if (url.startsWith('/blog') && hasArticles !== true) return null;
if (url.startsWith('/videa') && hasVideos !== true) return null;
if (url.startsWith('/galerie') && hasGallery !== true) return null;
if (url.startsWith('/aktivity') && !navbarData.hasActivities) return null;
if (url.startsWith('/hraci') && !navbarData.hasPlayers) return null;
if (url.startsWith('/blog') && !navbarData.hasArticles) return null;
if (url.startsWith('/videa') && !navbarData.hasVideos) return null;
if (url.startsWith('/galerie') && !navbarData.hasGallery) return null;
if (item.type === 'dropdown' && Array.isArray(item.children)) {
const children = item.children.map(filterItem).filter(Boolean) as NavigationItem[];
return { ...item, children };
}
return item;
};
return dynamicNavItems.map(filterItem).filter(Boolean) as NavigationItem[];
}, [dynamicNavItems, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery]);
return navbarData.dynamicNavItems.map(filterItem).filter(Boolean) as NavigationItem[];
}, [navbarData.dynamicNavItems, navbarData.hasActivities, navbarData.hasPlayers, navbarData.hasArticles, navbarData.hasVideos, navbarData.hasGallery]);
// Use dynamic navigation if available, otherwise fallback to hardcoded
let NAV_LINKS: NavLink[] = useMemo(() => {
if (!navLoading && filteredDynamicNavItems.length > 0) {
if (!navbarData.navLoading && filteredDynamicNavItems.length > 0) {
console.log('Navbar: Using dynamic navigation, items:', filteredDynamicNavItems.length);
// Use dynamic navigation from API
const navLinks = filteredDynamicNavItems.map(convertToNavLink);
@@ -662,36 +555,74 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
// Ensure we only show sections when there is data
const filtered = navLinks.filter((link) => {
const to = link.to || '';
if (to.startsWith('/aktivity')) return hasActivities === true;
if (to.startsWith('/hraci')) return hasPlayers === true;
if (to.startsWith('/blog')) return hasArticles === true;
if (to.startsWith('/videa')) return hasVideos === true;
if (to.startsWith('/galerie')) return hasGallery === true;
if (to.startsWith('/aktivity')) return navbarData.hasActivities;
if (to.startsWith('/hraci')) return navbarData.hasPlayers;
if (to.startsWith('/blog')) return navbarData.hasArticles;
if (to.startsWith('/videa')) return navbarData.hasVideos;
if (to.startsWith('/galerie')) return navbarData.hasGallery;
return true;
});
return filtered;
}
// 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' },
console.log('Navbar: Using fallback hardcoded navigation, language:', i18n.language);
// Fallback to hardcoded navigation - modular based on language
const currentLanguage = i18n.language;
let links: NavLink[] = [];
// Base navigation items for all languages
const baseLinks: NavLink[] = [
{ label: t('nav.home'), to: '/' },
];
// Add club info if enabled
if (settings?.show_about_in_nav !== false) {
baseLinks.push({ label: t('nav.club'), to: '/o-klubu' });
}
// Add calendar and matches for all languages
baseLinks.push(
{ label: t('nav.calendar'), to: '/kalendar' },
{ label: t('nav.matches'), to: '/zapasy' }
);
// Add optional items only if data exists (for all languages)
if (navbarData.hasActivities) {
baseLinks.push({ label: t('nav.activities'), to: '/aktivity' });
}
if (navbarData.hasPlayers) {
baseLinks.push({ label: t('nav.players'), to: '/hraci' });
}
if (navbarData.hasTables) {
baseLinks.push({ label: t('nav.tables'), to: '/tabulky' });
}
if (navbarData.hasArticles) {
baseLinks.push(
categoryItems.length > 0
? { label: t('nav.articles'), to: '/blog', items: categoryItems }
: { label: t('nav.articles'), to: '/blog' }
);
}
if (navbarData.hasVideos) {
baseLinks.push({ label: t('nav.videos'), to: '/videa' });
}
if (navbarData.hasGallery) {
baseLinks.push({ label: galleryLabel, to: '/galerie' });
}
// Always show sponsors and contact
baseLinks.push(
{ label: t('nav.sponsors'), to: '/sponzori' },
{ label: t('nav.contact'), to: '/kontakt' }
);
links = baseLinks;
// Add shop if configured
if (settings?.shop_url) {
links.push({ label: t('nav.shop'), to: settings.shop_url, external: true } as NavLink);
}
// 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[]) : [];
@@ -704,39 +635,9 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
links = [...links, ...mapped];
}
}
// Hide Tabulky when there is no table data
if (hasTables === false) {
links = links.filter((n) => n.label !== 'Tabulky');
}
// Hide Aktivity unless there are activities
if (hasActivities !== true) {
links = links.filter((n) => n.label !== 'Aktivity');
}
// Hide Hráči unless there are players
if (hasPlayers !== true) {
links = links.filter((n) => n.label !== 'Hráči');
}
// Hide Články unless there are articles
if (hasArticles !== true) {
links = links.filter((n) => n.label !== 'Články');
}
// Hide Videa unless there are videos
if (hasVideos !== true) {
links = links.filter((n) => n.label !== 'Videa');
}
// Hide Fotogalerie unless there is gallery content
if (hasGallery !== true) {
links = links.filter((n) => n.to !== '/galerie');
}
return links;
}, [filteredDynamicNavItems, navLoading, settings, categoryItems, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, galleryLabel]);
}, [filteredDynamicNavItems, navbarData.navLoading, settings, categoryItems, navbarData.hasTables, navbarData.hasActivities, navbarData.hasPlayers, navbarData.hasArticles, navbarData.hasVideos, navbarData.hasGallery, galleryLabel, i18n.language, t]);
// Split navigation into visible and overflow for desktop
const navSplit = useMemo(() => {
@@ -749,7 +650,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
else if (w >= 1100) maxVisible = 7;
else maxVisible = 6;
if (links.length <= maxVisible) return { visible: links, overflow: [] as NavLink[] };
const visibleCount = Math.max(1, maxVisible - 1); // reserve one slot for "Další"
const visibleCount = Math.max(1, maxVisible - 1); // reserve one slot for "More"
return { visible: links.slice(0, visibleCount), overflow: links.slice(visibleCount) };
}, [NAV_LINKS, windowWidth]);
@@ -799,7 +700,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
<HStack spacing={2}>
{settings?.shop_url && (
<Button as="a" href={settings.shop_url} target="_blank" rel="noreferrer" variant="link" size="xs" leftIcon={<FaShoppingBag />}>
Fanshop
{t('nav.shop')}
</Button>
)}
</HStack>
@@ -828,7 +729,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
boxShadow={navBoxShadow}
transition="box-shadow 0.2s ease, background-color 0.2s ease, backdrop-filter 0.2s ease"
>
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} isAuthenticated={isAuthenticated} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navCategories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={hasTables} hasActivities={hasActivities} hasPlayers={hasPlayers} hasArticles={hasArticles} hasVideos={hasVideos} hasGallery={hasGallery} dynamicNavItems={filteredDynamicNavItems} navLoading={navLoading} />
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} isAuthenticated={isAuthenticated} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navbarData.categories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={navbarData.hasTables} hasActivities={navbarData.hasActivities} hasPlayers={navbarData.hasPlayers} hasArticles={navbarData.hasArticles} hasVideos={navbarData.hasVideos} hasGallery={navbarData.hasGallery} dynamicNavItems={filteredDynamicNavItems || []} navLoading={navbarData.navLoading} t={t} i18n={i18n} handleSimpleLanguageChange={handleSimpleLanguageChange} />
<Container maxW={containerMaxW} px={fullWidth ? 0 : undefined}>
<Flex h={16} alignItems="center" justifyContent="space-between">
<HStack spacing={4} alignItems="center">
@@ -885,7 +786,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
);
})}
{navSplit.overflow.length > 0 && (
<HoverMenu key="more" label="Další" items={moreItems} />
<HoverMenu key="more" label={t('action.more')} items={moreItems} />
)}
</HStack>
</HStack>
@@ -896,7 +797,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
display={{ base: 'flex', md: 'none' }}
onClick={onOpen}
icon={<HamburgerIcon />}
aria-label="Otevřít menu"
aria-label={t('action.open_menu')}
variant="ghost"
mr={2}
/>
@@ -905,9 +806,9 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
<Box display={{ base: 'none', md: 'flex' }} mr={2} />
{/* Search button */}
<Tooltip label="Hledat" hasArrow>
<Tooltip label={t('action.search')} hasArrow>
<IconButton
aria-label="Hledat"
aria-label={t('action.search')}
icon={<FaSearch />}
size="sm"
mr={2}
@@ -918,11 +819,11 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
{/* Admin edit button */}
{isAdmin && (
<Tooltip label="Správa obsahu" hasArrow>
<Tooltip label={t('action.content_admin')} hasArrow>
<IconButton
as={RouterLink}
to="/admin"
aria-label="Správa obsahu"
aria-label={t('action.content_admin')}
icon={<EditIcon />}
size="sm"
mr={2}
@@ -932,16 +833,11 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
</Tooltip>
)}
{/* Language switcher - compact dropdown for all users */}
<LanguageSwitcher />
{/* 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 />}
/>
<ThemeToggle />
{/* Auth buttons (desktop) */}
{!isAuthenticated && (
@@ -955,7 +851,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
ml={2}
mr={2}
>
Registrovat se
{t('auth.register')}
</Button>
<Button
as={RouterLink}
@@ -965,7 +861,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
display={{ base: 'none', md: 'inline-flex' }}
mr={2}
>
Přihlásit se
{t('auth.login')}
</Button>
</>
)}
@@ -980,25 +876,25 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
minW={0}
ml={2}
>
<Avatar size="sm" name={user?.name || 'Uživatel'} src={engProfile?.animated_avatar_url || engProfile?.avatar_url || undefined} />
<Avatar size="sm" name={user?.name || t('common.user')} src={engProfile?.animated_avatar_url || engProfile?.avatar_url || undefined} />
</MenuButton>
<MenuList>
<MenuItem isDisabled>{`Úroveň ${engProfile?.level ?? 1}${engProfile?.points ?? 0} bodů`}</MenuItem>
<MenuItem isDisabled>{`${t('engagement.level')} ${engProfile?.level ?? 1}${engProfile?.points ?? 0} ${t('engagement.points')}`}</MenuItem>
<Box px={3} py={2}>
<Text fontSize="xs" color="gray.500">Progres</Text>
<Text fontSize="xs" color="gray.500">{t('engagement.progress')}</Text>
<Progress value={levelProgress.pct} size="xs" colorScheme="blue" borderRadius="full" mt={1} />
</Box>
{!isAdmin && (
<>
<MenuItem onClick={onAchOpen}>Úspěchy</MenuItem>
<MenuItem onClick={onRewOpen}>Odměny</MenuItem>
<MenuItem onClick={onAchOpen}>{t('engagement.achievements')}</MenuItem>
<MenuItem onClick={onRewOpen}>{t('engagement.rewards')}</MenuItem>
</>
)}
<MenuItem as={RouterLink} to={accountPath}>Můj účet</MenuItem>
<MenuItem onClick={openMyNewsletterPrefs}>Emailové preference</MenuItem>
{isAdmin && <MenuItem as={RouterLink} to="/admin/nastaveni">Nastavení stránky</MenuItem>}
{isAdmin && <MenuItem as={RouterLink} to="/admin">Administrace</MenuItem>}
<MenuItem onClick={logout}>Odhlásit se</MenuItem>
<MenuItem as={RouterLink} to={accountPath}>{t('common.my_account')}</MenuItem>
<MenuItem onClick={openMyNewsletterPrefs}>{t('newsletter.email_preferences')}</MenuItem>
{isAdmin && <MenuItem as={RouterLink} to="/admin/nastaveni">{t('admin.page_settings')}</MenuItem>}
{isAdmin && <MenuItem as={RouterLink} to="/admin">{t('nav.admin')}</MenuItem>}
<MenuItem onClick={logout}>{t('auth.logout')}</MenuItem>
</MenuList>
</Menu>
)}
@@ -1011,7 +907,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
<Modal isOpen={isSearchOpen} onClose={onSearchClose} size="md" motionPreset="scale">
<ModalOverlay />
<ModalContent>
<ModalHeader>Vyhledávání</ModalHeader>
<ModalHeader>{t('search.title')}</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<form
@@ -1026,19 +922,19 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
<FaSearchIcon />
</InputLeftElement>
<Input
placeholder="Hledat kluby, zápasy, články, hráče..."
placeholder={t('search.placeholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
</InputGroup>
<Button type="submit" colorScheme="blue" size="lg" w="full" leftIcon={<FaSearchIcon />}>
Vyhledat
{t('search.search_button')}
</Button>
</VStack>
</form>
<Text fontSize="sm" color="gray.500" mt={4} textAlign="center">
Zadejte klíčová slova pro vyhledávání
{t('search.search_hint')}
</Text>
</ModalBody>
</ModalContent>
+1 -1
View File
@@ -23,7 +23,7 @@ const SponsorsStrip: React.FC = () => {
{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" />
<Image src={assetUrl(s.logo_url) || '/sponsor-placeholder.svg'} alt={s.name} height="50px" objectFit="contain" />
</Link>
))}
</HStack>
+269
View File
@@ -0,0 +1,269 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Card,
CardBody,
CardHeader,
Heading,
HStack,
VStack,
Text,
Badge,
Icon,
SimpleGrid,
Alert,
AlertIcon,
useColorModeValue,
Spinner,
Divider,
} from '@chakra-ui/react';
import {
FiSun,
FiCloud,
FiCloudRain,
FiWind,
FiDroplet,
FiThermometer,
FiAlertTriangle,
FiCheck,
} from 'react-icons/fi';
import { api } from '../services/api';
interface WeatherData {
date_time: string;
temperature: number;
humidity: number;
precipitation: number;
wind_speed: number;
wind_direction: number;
weather_code: string;
description: string;
is_suitable: boolean;
recommendations: string;
}
interface WeatherWidgetProps {
facilityId: number;
facilityName: string;
}
const WeatherWidget: React.FC<WeatherWidgetProps> = ({ facilityId, facilityName }) => {
const [weather, setWeather] = useState<WeatherData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
useEffect(() => {
fetchWeather();
}, [facilityId]);
const fetchWeather = async () => {
try {
setLoading(true);
setError(null);
const response = await api.get(`/facilities/${facilityId}/weather`);
setWeather(response.data.weather || []);
} catch (err: any) {
if (err.response?.status === 503) {
setError('Služba počasí není nakonfigurována');
} else if (err.response?.status === 400) {
setError('Počasí je dostupné pouze pro venkovní zařízení');
} else {
setError('Nepodařilo se načíst data o počasí');
}
} finally {
setLoading(false);
}
};
const getWeatherIcon = (code: string, isSuitable: boolean) => {
if (!isSuitable) {
return <FiCloudRain />;
}
switch (code) {
case '800': // Clear
return <FiSun />;
case '801':
case '802':
case '803':
case '804': // Clouds
return <FiCloud />;
default:
return <FiCloud />;
}
};
const getWindDirection = (degrees: number) => {
const directions = ['S', 'SV', 'V', 'JV', 'J', 'JZ', 'Z', 'SZ'];
const index = Math.round(degrees / 45) % 8;
return directions[index];
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('cs-CZ', {
weekday: 'short',
day: 'numeric',
month: 'numeric',
});
};
const formatTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleTimeString('cs-CZ', {
hour: '2-digit',
minute: '2-digit',
});
};
const getSuitabilityColor = (isSuitable: boolean) => {
return isSuitable ? 'green' : 'red';
};
const getSuitabilityText = (isSuitable: boolean) => {
return isSuitable ? 'Vhodné' : 'Nevhodné';
};
if (loading) {
return (
<Card bg={bgColor} borderWidth={1} borderColor={borderColor}>
<CardBody>
<VStack spacing={4}>
<Spinner />
<Text>Načítání počasí...</Text>
</VStack>
</CardBody>
</Card>
);
}
if (error) {
return (
<Card bg={bgColor} borderWidth={1} borderColor={borderColor}>
<CardBody>
<Alert status="warning">
<AlertIcon />
<Text>{error}</Text>
</Alert>
</CardBody>
</Card>
);
}
if (weather.length === 0) {
return (
<Card bg={bgColor} borderWidth={1} borderColor={borderColor}>
<CardBody>
<Text color="gray.500">Žádná data o počasí</Text>
</CardBody>
</Card>
);
}
// Group weather by date
const weatherByDate = weather.reduce((acc, item) => {
const date = formatDate(item.date_time);
if (!acc[date]) {
acc[date] = [];
}
acc[date].push(item);
return acc;
}, {} as Record<string, WeatherData[]>);
return (
<Card bg={bgColor} borderWidth={1} borderColor={borderColor}>
<CardHeader>
<Heading size="md">Počasí - {facilityName}</Heading>
</CardHeader>
<CardBody>
<VStack spacing={4} align="stretch">
{Object.entries(weatherByDate).map(([date, dayWeather]) => (
<Box key={date}>
<Heading size="sm" mb={3}>{date}</Heading>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={3}>
{dayWeather.map((item, index) => (
<Card
key={index}
variant="outline"
size="sm"
bg={item.is_suitable ? 'green.50' : 'red.50'}
borderColor={item.is_suitable ? 'green.200' : 'red.200'}
>
<CardBody p={3}>
<VStack spacing={2} align="start">
<HStack justify="space-between" w="full">
<Text fontSize="sm" fontWeight="bold">
{formatTime(item.date_time)}
</Text>
<Badge
colorScheme={getSuitabilityColor(item.is_suitable)}
fontSize="xs"
>
{getSuitabilityText(item.is_suitable)}
</Badge>
</HStack>
<HStack>
{item.is_suitable ? <FiSun /> : <FiCloudRain />}
<Text fontSize="sm">{item.description}</Text>
</HStack>
<SimpleGrid columns={2} spacing={2} w="full">
<HStack>
<FiThermometer />
<Text fontSize="xs">{item.temperature.toFixed(1)}°C</Text>
</HStack>
<HStack>
<FiDroplet />
<Text fontSize="xs">{item.humidity}%</Text>
</HStack>
<HStack>
<FiWind />
<Text fontSize="xs">
{item.wind_speed.toFixed(1)} km/h {getWindDirection(item.wind_direction)}
</Text>
</HStack>
{item.precipitation > 0 && (
<HStack>
<FiCloudRain />
<Text fontSize="xs">{item.precipitation} mm</Text>
</HStack>
)}
</SimpleGrid>
{item.recommendations && (
<>
<Divider />
<Text fontSize="xs" color="gray.600">
{item.recommendations}
</Text>
</>
)}
</VStack>
</CardBody>
</Card>
))}
</SimpleGrid>
</Box>
))}
<Alert status="info" fontSize="sm">
<AlertIcon />
<Text>
Počasí je aktualizováno každé 2 hodiny.
Doporučení jsou generována automaticky na základě povětrnostních podmínek.
</Text>
</Alert>
</VStack>
</CardBody>
</Card>
);
};
export default WeatherWidget;
+3 -10
View File
@@ -2,7 +2,6 @@ import {
Box,
Flex,
IconButton,
useColorMode,
Text,
Menu,
MenuButton,
@@ -15,11 +14,12 @@ import {
Tooltip,
Link as ChakraLink
} from '@chakra-ui/react';
import { FaBars, FaMoon, FaSun, FaSignOutAlt, FaUserCog, FaBook } from 'react-icons/fa';
import { FaBars, 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';
import { ThemeToggle } from '@/components/ui/theme-toggle';
interface AdminHeaderProps extends BoxProps {
onMenuToggle: () => void;
@@ -27,7 +27,6 @@ interface AdminHeaderProps extends BoxProps {
}
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)');
@@ -81,13 +80,7 @@ const AdminHeader = ({ onMenuToggle, rightContent, ...rest }: AdminHeaderProps)
/>
</ChakraLink>
</Tooltip>
<IconButton
aria-label="Přepnout barevné schéma"
icon={colorMode === 'light' ? <FaMoon /> : <FaSun />}
variant="ghost"
onClick={toggleColorMode}
size="sm"
/>
<ThemeToggle />
<Menu>
<MenuButton>
@@ -0,0 +1,105 @@
import { Box, Tooltip } from '@chakra-ui/react';
import { useEffect, useState } from 'react';
import api from '../../services/api';
interface HealthCheckResult {
status: string;
message?: string;
}
interface HealthResponse {
status: string;
checks?: Record<string, HealthCheckResult>;
}
type HealthStatus = 'loading' | 'healthy' | 'degraded' | 'unhealthy' | 'error';
const POLL_INTERVAL_MS = 60000; // 60 seconds
const AdminHealthIndicator = () => {
const [status, setStatus] = useState<HealthStatus>('loading');
const [lastError, setLastError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
let timer: number | undefined;
const fetchStatus = async () => {
try {
const res = await api.get<HealthResponse>('/health/ready');
if (cancelled) return;
const raw = (res.data?.status || '').toLowerCase();
let mapped: HealthStatus;
if (raw === 'healthy') mapped = 'healthy';
else if (raw === 'degraded') mapped = 'degraded';
else mapped = 'unhealthy';
setStatus(mapped);
setLastError(null);
} catch (err) {
if (cancelled) return;
// On error keep previous status if we already had one, otherwise mark as error
setStatus((prev) => (prev === 'loading' ? 'error' : prev));
setLastError('Nepodařilo se ověřit stav serveru');
} finally {
if (!cancelled) {
timer = window.setTimeout(fetchStatus, POLL_INTERVAL_MS);
}
}
};
fetchStatus();
return () => {
cancelled = true;
if (timer) {
window.clearTimeout(timer);
}
};
}, []);
let color: string;
switch (status) {
case 'healthy':
color = 'green.400';
break;
case 'degraded':
color = 'orange.400';
break;
case 'unhealthy':
color = 'red.400';
break;
case 'loading':
color = 'yellow.300';
break;
case 'error':
default:
color = 'gray.400';
break;
}
const label: string = (() => {
if (status === 'loading') return 'Ověřuji stav serveru…';
if (status === 'healthy') return 'Server v pořádku';
if (status === 'degraded') return 'Server běží, ale v omezeném režimu';
if (status === 'unhealthy') return 'Server není připraven (zdravotní kontrola selhala)';
if (status === 'error') return lastError || 'Nepodařilo se ověřit stav serveru';
return 'Stav serveru není známý';
})();
return (
<Tooltip label={label} hasArrow>
<Box
as="span"
w="10px"
h="10px"
borderRadius="full"
bg={color}
boxShadow="0 0 0 2px rgba(255, 255, 255, 0.6)"
/>
</Tooltip>
);
};
export default AdminHealthIndicator;
@@ -0,0 +1,78 @@
// Create a completely new approach - intercept navigation at router level
// This will be a replacement for the current scroll retention system
import { useEffect, useRef } from 'react';
import { useLocation, useNavigationType } from 'react-router-dom';
interface AdminScrollManagerProps {
children: React.ReactNode;
}
export const AdminScrollManager = ({ children }: AdminScrollManagerProps) => {
const location = useLocation();
const navigationType = useNavigationType();
const scrollPositionRef = useRef<number>(0);
const isNavigatingRef = useRef<boolean>(false);
// Log when component mounts
useEffect(() => {
console.log('[AdminScrollManager] Component mounted');
console.log('[AdminScrollManager] Current path:', location.pathname);
}, []);
// Save scroll position before navigation
useEffect(() => {
if (isNavigatingRef.current) return; // Don't save while navigating
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (sidebar && sidebar.scrollTop > 0) {
scrollPositionRef.current = sidebar.scrollTop;
console.log('[AdminScrollManager] Saved position:', scrollPositionRef.current);
}
}, [location.pathname]);
// Restore scroll position after navigation
useEffect(() => {
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (!sidebar) return;
// Restore on any navigation type, not just PUSH
if (scrollPositionRef.current > 0) {
isNavigatingRef.current = true;
console.log('[AdminScrollManager] Restoring position:', scrollPositionRef.current, 'Navigation type:', navigationType);
// Aggressive restoration
const targetScroll = scrollPositionRef.current;
// Immediate restore
sidebar.scrollTop = targetScroll;
// Multiple restoration attempts
const restoreAttempts = [
() => sidebar.scrollTop = targetScroll,
() => sidebar.scrollTo({ top: targetScroll, behavior: 'auto' }),
() => sidebar.scrollTop = targetScroll,
];
// Try restoration at different intervals
restoreAttempts.forEach((restore, index) => {
setTimeout(() => {
restore();
console.log(`[AdminScrollManager] Restore attempt ${index + 1}:`, sidebar.scrollTop);
}, index * 100);
});
// Final attempt after 1 second
setTimeout(() => {
if (sidebar.scrollTop !== targetScroll) {
sidebar.scrollTop = targetScroll;
console.log('[AdminScrollManager] Final restore:', sidebar.scrollTop);
}
isNavigatingRef.current = false;
}, 1000);
}
}, [location.pathname, navigationType]);
return <>{children}</>;
};
@@ -18,7 +18,7 @@ import {
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, FaFolderOpen } from 'react-icons/fa';
import { FaSearch, FaCog, FaNewspaper, FaUsers, FaImage, FaHandshake, FaEnvelope, FaAward, FaSyncAlt, FaVideo, FaCalendarAlt, FaPalette, FaCommentAlt, FaKey, FaChartLine, FaBook, FaTools, FaBell, FaBars, FaFolderOpen, FaFutbol, FaTachometerAlt, FaLightbulb, FaBug, FaAddressBook, FaChalkboard, FaMobileAlt, FaInfoCircle, FaListOl, FaBolt, FaEdit, FaPaintBrush, FaLifeRing, FaQrcode, FaPoll, FaHashtag, FaTicketAlt, FaTrash, FaExclamationTriangle, FaFlag, FaGavel, FaClipboardList, FaStar, FaTrophy, FaGift, FaShoppingCart, FaLink, FaArrowUp, FaPhotoVideo, FaTshirt, FaGlobe } from 'react-icons/fa';
export type AdminSearchItem = {
label: string;
@@ -29,30 +29,79 @@ export type AdminSearchItem = {
};
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: 'Soubory', path: '/admin/soubory', section: 'Média', keywords: ['files', 'uploads'], icon: FaFolderOpen },
{ 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: '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 },
// Core Admin Pages
{ label: 'Dashboard', path: '/admin', section: 'Základní', keywords: ['overview', 'stat', 'dashboard', 'přehled'], icon: FaTachometerAlt },
{ label: 'Články', path: '/admin/clanky', section: 'Obsah', keywords: ['articles', 'posts', 'blog', 'články'], icon: FaNewspaper },
{ label: 'Kategorie článků', path: '/admin/kategorie', section: 'Obsah', keywords: ['categories', 'kategorie'], icon: FaHashtag },
{ label: 'Aktivity', path: '/admin/aktivity', section: 'Obsah', keywords: ['activities', 'events', 'akce', 'události'], icon: FaCalendarAlt },
{ label: 'Komentáře', path: '/admin/komentare', section: 'Obsah', keywords: ['comments', 'diskuse'], icon: FaCommentAlt },
{ label: 'Hráči', path: '/admin/hraci', section: 'Sport', keywords: ['players', 'hráči'], icon: FaUsers },
{ label: 'Týmy', path: '/admin/tymy', section: 'Sport', keywords: ['teams', 'týmy'], icon: FaUsers },
{ label: 'Zápasy', path: '/admin/zapasy', section: 'Sport', keywords: ['matches', 'facr', 'zápasy'], icon: FaCalendarAlt },
{ label: 'Alias soutěží', path: '/admin/aliasy', section: 'Sport', keywords: ['aliases', 'competition', 'soutěže'], icon: FaAward },
{ label: 'Tabulky', path: '/admin/tabulky', section: 'Sport', keywords: ['standings', 'table', 'tabulka'], icon: FaChartLine },
{ label: 'Tabule (Scoreboard)', path: '/admin/scoreboard', section: 'Sport', keywords: ['scoreboard', 'tabule', 'výsledky'], icon: FaChalkboard },
{ label: 'Galerie', path: '/admin/galerie', section: 'Média', keywords: ['gallery', 'zonerama', 'fotky'], icon: FaImage },
{ label: 'Videa', path: '/admin/videa', section: 'Média', keywords: ['youtube', 'videos', 'videa'], icon: FaVideo },
{ label: 'Soubory', path: '/admin/soubory', section: 'Média', keywords: ['files', 'uploads', 'soubory'], icon: FaFolderOpen },
{ label: 'Sponzoři', path: '/admin/sponzori', section: 'Marketing', keywords: ['sponsors', 'partners', 'sponzoři'], icon: FaHandshake },
{ label: 'Bannery', path: '/admin/bannery', section: 'Marketing', keywords: ['banners', 'reklama'], icon: FaImage },
{ label: 'Oblečení', path: '/admin/obleceni', section: 'Marketing', keywords: ['clothing', 'merch', 'eshop', 'obleceni'], icon: FaTshirt },
{ label: 'Ankety', path: '/admin/ankety', section: 'Marketing', keywords: ['polls', 'ankety', 'hlasování'], icon: FaPoll },
{ label: 'Soutěže', path: '/admin/souteze', section: 'Marketing', keywords: ['sweepstakes', 'souteže', 'akce'], icon: FaTrophy },
{ label: 'Odměny & Úspěchy', path: '/admin/odmeny', section: 'Marketing', keywords: ['engagement', 'rewards', 'odmeny', 'úspěchy'], icon: FaTrophy },
{ label: 'Zkrácené odkazy', path: '/admin/shortlinks', section: 'Marketing', keywords: ['shortlinks', 'zkrácené', 'odkazy'], icon: FaLink },
{ label: 'QR kódy', path: '/admin/qr', section: 'Marketing', keywords: ['qr', 'kódy', 'qrcode'], icon: FaQrcode },
{ label: 'Vstupenky', path: '/admin/vstupenky', section: 'Marketing', keywords: ['tickets', 'vstupenky', 'prodej'], icon: FaTicketAlt },
{ label: 'Newsletter', path: '/admin/newsletter', section: 'Komunikace', keywords: ['email', 'campaign', 'newsletter'], icon: FaEnvelope },
{ label: 'Zprávy', path: '/admin/zpravy', section: 'Komunikace', keywords: ['messages', 'zprávy'], icon: FaCommentAlt },
{ label: 'Kontakty', path: '/admin/kontakty', section: 'Komunikace', keywords: ['contacts', 'kontakty', 'formulář'], icon: FaAddressBook },
{ label: 'Notifikace', path: '/admin/notifications', section: 'Komunikace', keywords: ['notifications', 'notifikace'], icon: FaBell },
{ 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 },
{ label: 'O klubu', path: '/admin/o-klubu', section: 'Obsah', keywords: ['about', 'klub'], icon: FaPalette },
{ label: 'Nastavení', path: '/admin/nastaveni', section: 'Nastavení', keywords: ['settings', 'config', 'nastavení'], icon: FaCog },
{ label: 'Uživatelé', path: '/admin/uzivatele', section: 'Nastavení', keywords: ['users', 'accounts', 'uživatelé'], icon: FaKey },
{ label: 'Navigace', path: '/admin/navigace', section: 'Nastavení', keywords: ['navigation', 'menu', 'sidebar'], icon: FaBars },
{ label: 'Prefetch & Cache', path: '/admin/prefetch', section: 'Nástroje', keywords: ['cache', 'fetch', 'prefetch'], icon: FaSyncAlt },
{ label: 'Chybová hlášení', path: '/admin/chyby', section: 'Nástroje', keywords: ['errors', 'chyby', 'hlášení', 'log'], icon: FaBug },
{ label: 'Překlady (I18n)', path: '/admin/i18n', section: 'Nástroje', keywords: ['i18n', 'překlady', 'jazyky', 'translations'], icon: FaGlobe },
{ label: 'FACR manuál', path: '/admin/facr-manual', section: 'Nástroje', keywords: ['facr', 'manuál', 'import'], icon: FaFutbol },
// Settings Sections (deep links)
{ label: 'Nastavení - Sociální sítě', path: '/admin/nastaveni#socialni-site', section: 'Nastavení', keywords: ['socialni', 'sítě', 'facebook', 'instagram', 'twitter'], icon: FaAddressBook },
{ label: 'Nastavení - Videa', path: '/admin/nastaveni#videa', section: 'Nastavení', keywords: ['videa', 'youtube', 'kanál'], icon: FaVideo },
{ label: 'Nastavení - SMTP', path: '/admin/nastaveni#smtp', section: 'Nastavení', keywords: ['smtp', 'email', 'odesílání'], icon: FaEnvelope },
{ label: 'Nastavení - Analytika', path: '/admin/nastaveni#analytika', section: 'Nastavení', keywords: ['umami', 'analytics', 'statistiky'], icon: FaChartLine },
{ label: 'Nastavení - SEO', path: '/admin/nastaveni#seo', section: 'Nastavení', keywords: ['seo', 'metadata', 'vyhledávače'], icon: FaSearch },
{ label: 'Nastavení - Obecné', path: '/admin/nastaveni#obecne', section: 'Nastavení', keywords: ['obecné', 'základní', 'klub'], icon: FaCog },
// Documentation Sections
{ label: 'Dokumentace - Úvod', path: '/admin/docs#uvod', section: 'Dokumentace', keywords: ['docs', 'documentation', 'úvod'], icon: FaBook },
{ label: 'Dokumentace - Nastavení klubu', path: '/admin/docs#nastaveni', section: 'Dokumentace', keywords: ['docs', 'nastavení', 'konfigurace'], icon: FaBook },
{ label: 'Dokumentace - Dashboard', path: '/admin/docs#dashboard', section: 'Dokumentace', keywords: ['docs', 'dashboard', 'přehledy'], icon: FaBook },
{ label: 'Dokumentace - Články', path: '/admin/docs#clanky', section: 'Dokumentace', keywords: ['docs', 'články', 'blog'], icon: FaBook },
{ label: 'Dokumentace - Zápasy', path: '/admin/docs#zapasy', section: 'Dokumentace', keywords: ['docs', 'zápasy', 'facr'], icon: FaBook },
{ label: 'Dokumentace - Hráči a týmy', path: '/admin/docs#hraci-tymy', section: 'Dokumentace', keywords: ['docs', 'hráči', 'týmy'], icon: FaBook },
{ label: 'Dokumentace - Média', path: '/admin/docs#media', section: 'Dokumentace', keywords: ['docs', 'média', 'soubory'], icon: FaBook },
{ label: 'Dokumentace - Galerie', path: '/admin/docs#gallery', section: 'Dokumentace', keywords: ['docs', 'galerie', 'fotky'], icon: FaBook },
{ label: 'Dokumentace - Soubory', path: '/admin/docs#files', section: 'Dokumentace', keywords: ['docs', 'soubory', 'upload'], icon: FaBook },
{ label: 'Dokumentace - Sponzoři a bannery', path: '/admin/docs#sponzori-bannery', section: 'Dokumentace', keywords: ['docs', 'sponzoři', 'bannery'], icon: FaBook },
{ label: 'Dokumentace - Newsletter', path: '/admin/docs#newsletter', section: 'Dokumentace', keywords: ['docs', 'newsletter', 'email'], icon: FaBook },
{ label: 'Dokumentace - Alias soutěží', path: '/admin/docs#aliasy', section: 'Dokumentace', keywords: ['docs', 'alias', 'soutěže'], icon: FaBook },
{ label: 'Dokumentace - Prefetch', path: '/admin/docs#prefetch', section: 'Dokumentace', keywords: ['docs', 'prefetch', 'cache'], icon: FaBook },
{ label: 'Dokumentace - Videa', path: '/admin/docs#videa', section: 'Dokumentace', keywords: ['docs', 'videa', 'youtube'], icon: FaBook },
{ label: 'Dokumentace - Aktivity', path: '/admin/docs#aktivity', section: 'Dokumentace', keywords: ['docs', 'aktivity', 'události'], icon: FaBook },
{ label: 'Dokumentace - Oblečení', path: '/admin/docs#merch', section: 'Dokumentace', keywords: ['docs', 'obleceni', 'merch'], icon: FaBook },
{ label: 'Dokumentace - Zprávy', path: '/admin/docs#zpravy', section: 'Dokumentace', keywords: ['docs', 'zprávy', 'komunikace'], icon: FaBook },
{ label: 'Dokumentace - Kontakty', path: '/admin/docs#contacts', section: 'Dokumentace', keywords: ['docs', 'kontakty', 'formuláře'], icon: FaBook },
{ label: 'Dokumentace - Analytics', path: '/admin/docs#analytics', section: 'Dokumentace', keywords: ['docs', 'analytics', 'statistiky'], icon: FaBook },
{ label: 'Dokumentace - Scoreboard', path: '/admin/docs#scoreboard', section: 'Dokumentace', keywords: ['docs', 'scoreboard', 'tabule'], icon: FaBook },
{ label: 'Dokumentace - Mobilní scoreboard', path: '/admin/docs#mobile-scoreboard', section: 'Dokumentace', keywords: ['docs', 'mobilní', 'scoreboard'], icon: FaBook },
{ label: 'Dokumentace - Uživatelé', path: '/admin/docs#uzivatele', section: 'Dokumentace', keywords: ['docs', 'uživatelé', 'přístupy'], icon: FaBook },
{ label: 'Dokumentace - Interní dokumentace', path: '/admin/docs#docs', section: 'Dokumentace', keywords: ['docs', 'interní', 'vývoj'], icon: FaBook },
{ label: 'Dokumentace - Checklisty', path: '/admin/docs#checklist', section: 'Dokumentace', keywords: ['docs', 'checklist', 'postupy'], icon: FaBook },
{ label: 'Dokumentace - SEO', path: '/admin/docs#seo', section: 'Dokumentace', keywords: ['docs', 'seo', 'metadata'], icon: FaBook },
{ label: 'Dokumentace - Řešení problémů', path: '/admin/docs#troubleshooting', section: 'Dokumentace', keywords: ['docs', 'troubleshooting', 'problémy'], icon: FaBook },
];
function highlight(text: string, q: string) {
@@ -83,8 +132,12 @@ function score(item: AdminSearchItem, q: string) {
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;
// Boost score for settings sections when searching for settings-related terms
if (item.section === 'Nastavení' && (b.includes('nastavení') || b.includes('settings') || b.includes('config'))) s += 30;
// Boost score for documentation when searching for help/docs
if (item.section === 'Dokumentace' && (b.includes('docs') || b.includes('dokumentace') || b.includes('help') || b.includes('návod'))) s += 30;
// Small preference for Docs when # present
if (item.section === 'Dokumentace' && item.path.includes('#')) s += 5;
return s;
}
@@ -153,7 +206,7 @@ export default function AdminSearchModal({ isOpen, onClose, onSelectPath }: { is
<Icon as={FaSearch} />
</InputLeftElement>
<Input
placeholder="Hledat v administraci (stránky, nastavení, dokumentace)"
placeholder="Hledat stránky, nastavení, dokumentaci... (např. 'sociální sítě', 'články', 'scoreboard')"
value={q}
onChange={(e) => { setQ(e.target.value); setIdx(-1); }}
onKeyDown={onKeyDown}
+180 -107
View File
@@ -1,6 +1,7 @@
import { Box, VStack, Text, useColorModeValue, Icon, Link as ChakraLink, Divider, Image, Flex, Spinner, Collapse } from '@chakra-ui/react';
import { Box, VStack, Text, useColorModeValue, Icon, Link as ChakraLink, Divider, Image, Flex, Spinner, Collapse, IconButton } from '@chakra-ui/react';
import { Link as RouterLink, useLocation } from 'react-router-dom';
import { useEffect, useRef, useCallback, useState, useMemo } from 'react';
import { useAdminNavScrollRetention } from '../../hooks/useAdminNavScrollRetention';
import {
FaTachometerAlt,
FaUsers,
@@ -33,7 +34,19 @@ import {
FaFileAlt,
FaLink,
FaComments,
FaGift
FaGift,
FaQrcode,
FaTools,
FaDollarSign,
FaFileInvoice,
FaReceipt,
FaUserFriends,
FaMoneyBillWave,
FaCogs,
FaDatabase,
FaRocket,
FaFileInvoiceDollar,
FaTimes
} from 'react-icons/fa';
import { ChevronDownIcon, ChevronRightIcon } from '@chakra-ui/icons';
import { useAuth } from '../../contexts/AuthContext';
@@ -52,9 +65,11 @@ interface NavItemProps {
const NavItem = ({ icon, to, children, onClick }: NavItemProps) => {
const location = useLocation();
const isActive = to ? location.pathname.startsWith(to) : false;
// Use exact matching for navigation items to prevent multiple active states
const isActive = to ? location.pathname === to : false;
const activeBg = useColorModeValue('blue.50', 'blue.900');
const activeColor = useColorModeValue('blue.600', 'blue.300');
const hoverBg = useColorModeValue('gray.100', 'gray.700');
const handleClick = (e: React.MouseEvent) => {
// Call the onClick handler first
@@ -65,7 +80,9 @@ const NavItem = ({ icon, to, children, onClick }: NavItemProps) => {
return;
}
}
// Allow RouterLink to handle navigation normally
// For external links or non-router navigation, allow default behavior
// For RouterLink, React Router will handle the navigation
};
// If onClick is provided without `to`, render as a button-like link
@@ -87,7 +104,7 @@ const NavItem = ({ icon, to, children, onClick }: NavItemProps) => {
fontSize="sm"
_hover={{
textDecoration: 'none',
bg: isActive ? activeBg : useColorModeValue('gray.100', 'gray.700'),
bg: isActive ? activeBg : hoverBg,
transform: 'translateX(2px)',
}}
transition="all 0.2s ease"
@@ -146,7 +163,7 @@ const getIconForPageType = (pageType?: string): any => {
polls: FaPoll,
navigation: FaBars,
competition_aliases: FaAward,
prefetch: FaSyncAlt,
prefetch: FaRocket, // Better icon for prefetch & cache
users: FaUserShield,
settings: FaPalette,
files: FaFolder,
@@ -156,6 +173,14 @@ const getIconForPageType = (pageType?: string): any => {
comments: FaComments,
engagement: FaAward,
sweepstakes: FaGift,
'manual-data': FaTools,
'financial-dashboard': FaMoneyBillWave, // Better finance icon
'qr-codes': FaQrcode,
'invoices': FaFileInvoiceDollar, // Enhanced invoice icon
'invoice-settings': FaFileInvoiceDollar, // Enhanced invoice settings icon
'customers': FaUserFriends, // Better customers icon
'expenses': FaMoneyBillWave, // Better expenses icon
'manual_facr': FaTools, // For manual FACR
};
return iconMap[pageType || ''] || FaFileAlt;
};
@@ -175,13 +200,39 @@ const AdminSidebar = ({
const textColor = useColorModeValue('gray.800', '#e2e8f0');
const bg = bgProp || defaultBg;
const borderColor = borderColorProp || defaultBorderColor;
// Check if e-shop is enabled (from backend config)
const isEshopEnabled = (publicSettings as any)?.eshop_enabled || false;
// Check if club data mode is manual (to show manual data tab)
const isManualClubDataMode = (publicSettings?.club_data_mode || '').toLowerCase() === 'manual';
// Hoisted color tokens to keep hook calls stable across renders
const dividerColor = useColorModeValue('gray.200', 'whiteAlpha.300');
const categoryTextColor = useColorModeValue('gray.600', 'gray.300');
const categoryIconColor = useColorModeValue('gray.500', 'gray.400');
const headerMutedColor = useColorModeValue('gray.500', 'gray.400');
const scrollThumb = useColorModeValue('gray.300', 'gray.600');
const scrollThumbHover = useColorModeValue('gray.400', 'gray.500');
const badgeBgGreen = useColorModeValue('green.100', 'green.900');
const badgeColorGreen = useColorModeValue('green.700', 'green.200');
const badgeBorderGreen = useColorModeValue('green.200', 'green.700');
const badgeBgGray = useColorModeValue('gray.100', 'whiteAlpha.200');
const badgeColorGray = useColorModeValue('gray.700', 'gray.300');
const badgeBorderGray = useColorModeValue('gray.200', 'whiteAlpha.300');
// 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 seedFixRef = useRef<{ about: boolean }>({ about: false });
const location = useLocation();
const STORAGE_KEY = 'admin-sidebar-scroll';
// Use the updated scroll retention hook
const { scrollToCurrentPage, isReady, debug } = useAdminNavScrollRetention({
scrollContainerSelector: '[data-sidebar="true"]',
enableDebug: process.env.NODE_ENV === 'development'
});
// Helper function to render NavItem
const renderNavItem = useCallback((props: NavItemProps) => {
return <NavItem {...props} />;
}, []);
// Dynamic navigation state
const [navItems, setNavItems] = useState<NavigationItem[]>([]);
@@ -203,6 +254,7 @@ const AdminSidebar = ({
const hasSweepstakes = useMemo(() => hasItemDeep(it => (it.page_type === 'sweepstakes') || (it.url === '/admin/sweepstakes')), [hasItemDeep]);
const hasCompetitionAliases = useMemo(() => hasItemDeep(it => (it.page_type === 'competition_aliases') || (it.url === '/admin/aliasy-soutezi')), [hasItemDeep]);
const hasClothing = useMemo(() => hasItemDeep(it => (it.page_type === 'clothing') || (it.url === '/admin/obleceni')), [hasItemDeep]);
const hasEshopProducts = useMemo(() => hasItemDeep(it => (it.page_type === 'eshop_products') || (it.url === '/admin/eshop-produkty')), [hasItemDeep]);
const hasAbout = useMemo(() => hasItemDeep(it => (it.page_type === 'about') || (it.url === '/admin/o-klubu')), [hasItemDeep]);
const hasVideos = useMemo(() => hasItemDeep(it => (it.page_type === 'videos') || (it.url === '/admin/videa')), [hasItemDeep]);
const hasGallery = useMemo(() => hasItemDeep(it => (it.page_type === 'gallery') || (it.url === '/admin/galerie')), [hasItemDeep]);
@@ -220,6 +272,7 @@ const AdminSidebar = ({
const hasSettingsPage = useMemo(() => hasItemDeep(it => (it.page_type === 'settings') || (it.url === '/admin/nastaveni')), [hasItemDeep]);
const hasAnalytics = useMemo(() => hasItemDeep(it => (it.page_type === 'analytics') || (it.url === '/admin/analytika')), [hasItemDeep]);
const hasPrefetch = useMemo(() => hasItemDeep(it => (it.page_type === 'prefetch') || (it.url === '/admin/prefetch')), [hasItemDeep]);
const hasManualData = useMemo(() => hasItemDeep(it => (it.page_type === 'manual_data') || (it.url === '/admin/manual-data')), [hasItemDeep]);
// Collapsed state for admin categories (dropdown items)
@@ -259,27 +312,7 @@ const AdminSidebar = ({
});
}, []);
// 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));
}, []);
// The scroll handling is now managed by the useAdminNavScrollRetention hook
// Load dynamic navigation from API
useEffect(() => {
@@ -360,25 +393,13 @@ const AdminSidebar = ({
return () => { active = false };
}, [isAdmin]);
// Keep active item in view upon route change - but only if it's not visible
// Auto-scroll to current page when navigation loads
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' });
}
if (isReady && !navLoading) {
scrollToCurrentPage();
debug('Auto-scroll to current page after navigation load');
}
}, [location.pathname]);
}, [isReady, navLoading, scrollToCurrentPage, debug]);
return (
<Box
@@ -387,60 +408,74 @@ const AdminSidebar = ({
left={0}
top={0}
bottom={0}
width="260px"
width={{ base: '320px', md: '260px' }}
bg={bg}
borderRightWidth={borderRight}
borderColor={borderColor}
pt={5}
pt={{ base: 16, md: 5 }}
display={{ base: isOpen ? 'block' : 'none', md: 'block' }}
zIndex={10}
zIndex={{ base: 11, md: 10 }}
overflowY="auto"
overflowX="hidden"
boxShadow="lg"
boxShadow={{ base: 'lg', md: 'none' }}
transform={{ base: isOpen ? 'translateX(0)' : 'translateX(-100%)', md: 'translateX(0)' }}
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
ref={scrollRef}
onScroll={handleScroll}
onScroll={undefined}
data-sidebar="true"
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') },
'&::-webkit-scrollbar-thumb': { background: scrollThumb, borderRadius: '2px' },
'&::-webkit-scrollbar-thumb:hover': { background: scrollThumbHover },
}}
>
<VStack align="stretch" spacing={1} px={3} pb={6}>
<Box px={3} mb={8}>
<Flex align="center" gap={3} mb={2}>
<VStack align="stretch" spacing={{ base: 2, md: 1 }} px={{ base: 6, md: 3 }} pb={6}>
{/* Close button for mobile */}
<Flex justify="flex-end" display={{ base: 'flex', md: 'none' }} w="100%" mb={2}>
<IconButton
aria-label="Zavřít menu"
icon={<FaTimes />}
variant="ghost"
onClick={onClose}
size="sm"
borderRadius="full"
_hover={{ bg: useColorModeValue('gray.100', 'gray.700') }}
/>
</Flex>
<Box px={{ base: 6, md: 3 }} mb={{ base: 6, md: 4 }}>
<Flex align="center" gap={{ base: 4, md: 3 }} mb={2}>
<Image
src={assetUrl(publicSettings?.club_logo_url) || publicSettings?.club_logo_url || '/dist/img/logo-club-empty.svg'}
alt="Club Logo"
boxSize="48px"
boxSize={{ base: '56px', md: '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"
fontSize={{ base: '2xl', md: '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">
<Text fontSize={{ base: 'sm', md: '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>
{renderNavItem({
icon: FaHome,
to: "/",
onClick: onClose,
children: "Zpět na web"
})}
<Divider my={2} />
@@ -452,51 +487,61 @@ const AdminSidebar = ({
) : navItems.length > 0 ? (
// Render dynamic navigation with collapsible categories
<>
{navItems.filter(item => item.visible).map((item, index) => {
{navItems.filter(item => {
// Hide E-shop category if e-shop is disabled
if (item.label === 'E-shop' && !isEshopEnabled) {
return false;
}
return item.visible;
}).map((item, index) => {
const isCategory = item.type === 'dropdown';
const hasChildren = Array.isArray(item.children) && item.children.length > 0;
const catCollapsed = !!(item.id && collapsed[item.id]);
const categoryHeader = (
<Box key={`cat-${item.id || index}`} px={2} py={2} onClick={() => toggleCollapsed(item.id)} cursor="pointer" role="button" aria-expanded={!catCollapsed}>
<Box px={2} py={2} onClick={() => toggleCollapsed(item.id)} cursor="pointer" role="button" aria-expanded={!catCollapsed}>
<Flex align="center" gap={2}>
<Box flex="1" height="1px" bg={useColorModeValue('gray.200','whiteAlpha.300')} />
<Text fontSize="xs" fontWeight="bold" textTransform="uppercase" letterSpacing="wider" color={useColorModeValue('gray.600','gray.300')}>
<Box flex="1" height="1px" bg={dividerColor} />
<Text fontSize="xs" fontWeight="bold" textTransform="uppercase" letterSpacing="wider" color={categoryTextColor}>
{item.label}
</Text>
<Icon as={catCollapsed ? ChevronRightIcon : ChevronDownIcon} boxSize={3.5} color={useColorModeValue('gray.500','gray.400')} />
<Box flex="1" height="1px" bg={useColorModeValue('gray.200','whiteAlpha.300')} />
<Icon as={catCollapsed ? ChevronRightIcon : ChevronDownIcon} boxSize={3.5} color={categoryIconColor} />
<Box flex="1" height="1px" bg={dividerColor} />
</Flex>
</Box>
);
if (isCategory) {
return (
<Box key={item.id || index}>
<Box>
{categoryHeader}
{hasChildren && (
<Collapse in={!catCollapsed} animateOpacity unmountOnExit>
<VStack align="stretch" spacing={1} px={1}>
{item.children!.filter(c => c.visible).map((child, cidx) => {
{item.children!.filter(c => {
// Hide manual FACR if not in manual club data mode
if (c.page_type === 'manual_facr' && !isManualClubDataMode) {
return false;
}
return c.visible;
}).map((child, cidx) => {
const childIcon = getIconForPageType(child.page_type);
const childUrl = child.url || '#';
const showBadge = child.page_type === 'activities' && upcomingCount > 0;
return (
<NavItem
key={child.id || `${item.id}-c-${cidx}`}
icon={childIcon}
to={childUrl}
onClick={onClose}
>
return renderNavItem({
icon: childIcon,
to: childUrl,
onClick: onClose,
children: (
<Text as="span">
{child.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')}>
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={badgeBgGreen} color={badgeColorGreen} borderWidth="1px" borderColor={badgeBorderGreen}>
{upcomingCount}
</Text>
)}
</Text>
</NavItem>
);
)
});
})}
</VStack>
</Collapse>
@@ -506,39 +551,38 @@ const AdminSidebar = ({
}
// Non-category top-level item
// Hide manual FACR if not in manual club data mode
if (item.page_type === 'manual_facr' && !isManualClubDataMode) {
return null;
}
const itemIcon = getIconForPageType(item.page_type);
const itemUrl = item.url || '#';
const isActivities = item.page_type === 'activities';
const showBadge = isActivities && upcomingCount > 0;
return (
<NavItem
key={item.id || index}
icon={itemIcon}
to={itemUrl}
onClick={onClose}
>
return renderNavItem({
icon: itemIcon,
to: itemUrl,
onClick: onClose,
children: (
<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')}>
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={badgeBgGreen} color={badgeColorGreen} borderWidth="1px" borderColor={badgeBorderGreen}>
{upcomingCount}
</Text>
)}
</Text>
</NavItem>
);
)
});
})}
{/* Ensure Shortlinks is present even if not configured in dynamic nav (admins only) */}
{isAdmin && !hasShortlinks && (
<NavItem
icon={FaLink}
to="/admin/shortlinks"
onClick={onClose}
>
Zkrácené odkazy
</NavItem>
)}
{isAdmin && !hasShortlinks && renderNavItem({
icon: FaLink,
to: "/admin/shortlinks",
onClick: onClose,
children: "Zkrácené odkazy"
})}
{/* Ensure Engagement page is present even if not configured in dynamic nav (admins only) */}
{isAdmin && !hasEngagement && (
@@ -593,6 +637,17 @@ const AdminSidebar = ({
</NavItem>
)}
{/* Ensure E-shop products are present even if not configured in dynamic nav (admins only) - ONLY if e-shop is enabled */}
{isAdmin && !hasEshopProducts && isEshopEnabled && (
<NavItem
icon={FaTshirt}
to="/admin/eshop-produkty"
onClick={onClose}
>
Eshop produkty
</NavItem>
)}
{/* Ensure About page (O klubu) and other core admin pages are present (admins only) */}
{isAdmin && !hasAbout && (
<NavItem
@@ -747,11 +802,20 @@ const AdminSidebar = ({
Prefetch & Cache
</NavItem>
)}
{isAdmin && !hasManualData && isManualClubDataMode && (
<NavItem
icon={FaFileAlt}
to="/admin/manual-data"
onClick={onClose}
>
Manuální data soutěží
</NavItem>
)}
</>
) : (
// Fallback to hardcoded navigation
<>
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={useColorModeValue('gray.500', 'gray.400')} textTransform="uppercase" letterSpacing="wider">
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={headerMutedColor} textTransform="uppercase" letterSpacing="wider">
Hlavní
</Text>
@@ -764,6 +828,15 @@ const AdminSidebar = ({
Nástěnka
</NavItem>
)}
{isAdmin && isManualClubDataMode && (
<NavItem
icon={FaFileAlt}
to="/admin/manual-data"
onClick={onClose}
>
Manuální data soutěží
</NavItem>
)}
{isAdmin && (
<NavItem
@@ -775,7 +848,7 @@ const AdminSidebar = ({
</NavItem>
)}
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={useColorModeValue('gray.500', 'gray.400')} textTransform="uppercase" letterSpacing="wider" mt={4}>
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={headerMutedColor} textTransform="uppercase" letterSpacing="wider" mt={4}>
Obsah
</Text>
{/* Core sports entities first */}
@@ -796,7 +869,7 @@ const AdminSidebar = ({
{/* 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')}>
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={badgeBgGray} color={badgeColorGray} borderWidth="1px" borderColor={badgeBorderGray}>
scroller
</Text>
</Text>
@@ -811,7 +884,7 @@ const AdminSidebar = ({
<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')}>
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={badgeBgGreen} color={badgeColorGreen} borderWidth="1px" borderColor={badgeBorderGreen}>
{upcomingCount}
</Text>
)}
@@ -947,7 +1020,7 @@ const AdminSidebar = ({
{isAdmin && (
<>
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={useColorModeValue('gray.500', 'gray.400')} textTransform="uppercase" letterSpacing="wider" mt={4}>
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={headerMutedColor} textTransform="uppercase" letterSpacing="wider" mt={4}>
Nastavení
</Text>
@@ -0,0 +1,58 @@
import React, { useState } from 'react';
import { BlogTranslator } from './BlogTranslator';
import { Box, Text, Divider, Alert, AlertIcon } from '@chakra-ui/react';
/**
* Example component showing how to integrate blog translation
* This can be added to ArticlesAdminPage or any blog editing interface
*/
export const BlogTranslationExample: React.FC = () => {
const [currentTitle, setCurrentTitle] = useState('Vítejte v našem fotbalovém klubu');
const [currentContent, setCurrentContent] = useState(`
<p>Vítáme vás na oficiálních stránkách našeho fotbalového klubu. Naše klub má dlouhou historii a tradici.</p>
<p><strong>Nadcházející zápas:</strong> Sparta Praha vs Slavia Praha</p>
<p><em>Přijďte nás podpořit!</em></p>
`);
const handleTranslationComplete = (translatedTitle: string, translatedContent: string) => {
setCurrentTitle(translatedTitle);
setCurrentContent(translatedContent);
};
return (
<Box p={4} borderWidth={1} borderRadius="md" bg="white">
<Text fontSize="lg" fontWeight="bold" mb={4}>
Blog Translation Integration Example
</Text>
<Box mb={4}>
<Text fontWeight="medium" mb={2}>Current Content:</Text>
<Box p={3} bg="gray.50" borderRadius="md">
<Text fontWeight="bold">{currentTitle}</Text>
<Box dangerouslySetInnerHTML={{ __html: currentContent }} mt={2} />
</Box>
</Box>
<Divider my={4} />
<BlogTranslator
title={currentTitle}
content={currentContent}
onTranslationComplete={handleTranslationComplete}
/>
<Alert status="info" mt={4}>
<AlertIcon />
<Box>
<Text fontWeight="bold">Integration Instructions:</Text>
<Text fontSize="sm">
1. Add the BlogTranslator component to your article editing interface<br/>
2. Pass the current title and content as props<br/>
3. Handle the onTranslationComplete callback to update your form state<br/>
4. The component automatically detects source language and translates to the opposite language
</Text>
</Box>
</Alert>
</Box>
);
};
@@ -0,0 +1,108 @@
import React from 'react';
import {
Button,
Box,
Text,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Spinner,
HStack,
VStack,
Divider,
} from '@chakra-ui/react';
import { useBlogTranslation } from '../../hooks/useBlogTranslation';
import { FaLanguage, FaCheck, FaExclamationTriangle } from 'react-icons/fa';
interface BlogTranslatorProps {
title: string;
content: string;
onTranslationComplete: (translatedTitle: string, translatedContent: string) => void;
disabled?: boolean;
}
export const BlogTranslator: React.FC<BlogTranslatorProps> = ({
title,
content,
onTranslationComplete,
disabled = false,
}) => {
const { translateBlog, isTranslating, translationError, detectSourceLanguage, getTargetLanguage } = useBlogTranslation();
const handleTranslate = async () => {
try {
const result = await translateBlog(title, content);
onTranslationComplete(result.title, result.content);
} catch (error) {
// Error is handled by the hook
}
};
const sourceLang = detectSourceLanguage(title + ' ' + content);
const targetLang = getTargetLanguage();
const shouldTranslate = sourceLang !== targetLang && title && content;
if (!shouldTranslate) {
return (
<Alert status="info" borderRadius="md">
<AlertIcon as={FaLanguage} />
<Box>
<AlertTitle>Translation not needed</AlertTitle>
<AlertDescription>
Content is already in {sourceLang === 'cs' ? 'Czech' : 'English'} or the target language is the same.
</AlertDescription>
</Box>
</Alert>
);
}
return (
<VStack spacing={3} align="stretch">
<Box>
<HStack spacing={2} mb={2}>
<FaLanguage />
<Text fontWeight="medium">
Translate from {sourceLang === 'cs' ? 'Czech' : 'English'} to {targetLang === 'cs' ? 'Czech' : 'English'}
</Text>
</HStack>
<Button
colorScheme="blue"
onClick={handleTranslate}
isLoading={isTranslating}
loadingText="Translating..."
leftIcon={<FaLanguage />}
disabled={disabled || isTranslating}
size="sm"
>
{isTranslating ? 'Translating...' : 'Translate Blog'}
</Button>
</Box>
{translationError && (
<Alert status="error" borderRadius="md">
<AlertIcon as={FaExclamationTriangle} />
<Box>
<AlertTitle>Translation Failed</AlertTitle>
<AlertDescription>{translationError}</AlertDescription>
</Box>
</Alert>
)}
{!isTranslating && !translationError && (
<Alert status="success" borderRadius="md">
<AlertIcon as={FaCheck} />
<Box>
<AlertTitle>Ready to Translate</AlertTitle>
<AlertDescription>
Click the translate button to convert this blog content to {targetLang === 'cs' ? 'Czech' : 'English'}.
</AlertDescription>
</Box>
</Alert>
)}
<Divider />
</VStack>
);
};
@@ -1,13 +1,14 @@
import React from 'react';
import { IconButton, Button, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, Textarea, useToast, Tooltip, Box, Menu, MenuButton, MenuList, MenuItem } from '@chakra-ui/react';
import { IconButton, Button, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, Textarea, useToast, Tooltip, Box, Menu, MenuButton, MenuList, MenuItem, SimpleGrid, Text, Image as ChakraImage } from '@chakra-ui/react';
import { Share2, Instagram, Twitter, Facebook, Copy } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import { createShortLink, createPublicShortLink } from '../../services/shortlinks';
import { Article, getArticleMatchLink } from '../../services/articles';
import { API_URL } from '../../services/api';
import { composeInstagramPostFromArticle, composeInstagramPostFromActivity, MatchSnapshot, stripHtml, formatDateTime, cleanVenue } from '../../services/instagram';
import { generateInstagramAI } from '../../services/ai';
import { generateInstagramAI, generateInstagramImagesAI } from '../../services/ai';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import AILoadingModal from '../common/AILoadingModal';
interface Props {
article?: Article;
@@ -49,6 +50,9 @@ const InstagramGeneratorButton: React.FC<Props> = ({
const [text, setText] = React.useState('');
const [shortUrl, setShortUrl] = React.useState('');
const [loading, setLoading] = React.useState(false);
const [aiProgress, setAiProgress] = React.useState<number | undefined>(undefined);
const [aiStartTime, setAiStartTime] = React.useState<number | null>(null);
const [images, setImages] = React.useState<string[]>([]);
// Build deterministic campaign id for UTM and shortlink code
const campaignId = React.useMemo(() => {
@@ -82,15 +86,29 @@ const InstagramGeneratorButton: React.FC<Props> = ({
}
try {
setLoading(true);
setAiProgress(0);
setAiStartTime(Date.now());
setImages([]);
// Start progress simulation
const progressInterval = setInterval(() => {
setAiProgress(prev => {
if (prev === undefined || prev >= 95) return prev;
return Math.min(prev + Math.random() * 20 + 10, 95);
});
}, 1500);
const fullUrl = withUtm(computeTarget());
if (!fullUrl) throw new Error('Nelze zjistit URL článku/aktivity');
// Deterministic shortlink code to keep link stable across generations
const platformCode = article?.id ? `ig-a${article.id}` : (activity?.id ? `ig-e${activity.id}` : 'ig-share');
const payload = {
target_url: fullUrl,
title: article?.title || activity?.title || 'Link',
source_type: article ? 'article' : (activity ? 'event' : 'other'),
source_id: article?.id || activity?.id,
code: platformCode,
} as any;
let sUrl = '';
try {
@@ -192,13 +210,50 @@ const InstagramGeneratorButton: React.FC<Props> = ({
} else {
composed = `${clubName || 'Náš klub'}\n\n🔗 ${sUrl || fullUrl}`;
}
try {
const primaryColor = (publicSettings as any)?.primary_color || '';
const secondaryColor = (publicSettings as any)?.secondary_color || '';
const imagePromptBase = article
? `Návrh instagramového obrázku k článku "${article.title}" pro oficiální profil fotbalového klubu${clubName ? ' ' + clubName : ''}.`
: activity
? `Návrh instagramového obrázku k aktivitě "${activity.title}" pro oficiální profil fotbalového klubu${clubName ? ' ' + clubName : ''}.`
: `Návrh univerzálního instagramového obrázku pro oficiální profil fotbalového klubu${clubName ? ' ' + clubName : ''}.`;
let colorHint = '';
if (primaryColor || secondaryColor) {
colorHint = ` Klubové barvy: ${primaryColor || ''}${primaryColor && secondaryColor ? ', ' : ''}${secondaryColor || ''}.`;
}
const imagePrompt = `${imagePromptBase} Zobraz stadion, hráče nebo fanoušky našeho klubu, žádné loga soupeře ani text v obrázku.${colorHint} Styl: realistický, moderní, sportovní, poměr stran 4:5, bez textu, vhodné jako hlavní vizuál příspěvku.`;
const imgResp = await generateInstagramImagesAI({
prompt: imagePrompt,
aspect: '4:5',
count: 2,
});
if (Array.isArray(imgResp?.urls) && imgResp.urls.length > 0) {
setImages(imgResp.urls);
}
} catch (imgErr) {
console.error('Instagram image generation failed', imgErr);
}
clearInterval(progressInterval);
setAiProgress(100);
setText(composed);
onGenerated?.(composed, sUrl || fullUrl);
onOpen();
setTimeout(() => {
onOpen();
}, 500); // Brief moment to show 100% completion
} catch (err: any) {
toast({ status: 'error', title: 'Nelze vygenerovat příspěvek', description: err?.message || 'Zkuste to prosím znovu.' });
} finally {
setLoading(false);
setTimeout(() => {
setLoading(false);
setAiProgress(undefined);
setAiStartTime(null);
}, 1000); // Brief moment to show 100% completion or clear on error
}
};
@@ -269,10 +324,10 @@ const InstagramGeneratorButton: React.FC<Props> = ({
const AdminButtonEl = (
<Tooltip label="Vygenerovat Instagram příspěvek" placement="right">
{variant === 'icon' ? (
<IconButton aria-label="IG post" icon={<Share2 size={18} />} colorScheme="brand" onClick={handleGenerate} isLoading={loading} size={size} />
<IconButton aria-label="Instagram příspěvek" icon={<Share2 size={18} />} colorScheme="brand" onClick={handleGenerate} isDisabled={loading} size={size} />
) : (
<Button leftIcon={<Share2 size={18} />} colorScheme="brand" onClick={handleGenerate} isLoading={loading} size={size}>
Instagram post
<Button leftIcon={<Share2 size={18} />} colorScheme="brand" onClick={handleGenerate} isDisabled={loading} size={size}>
Instagram příspěvek
</Button>
)}
</Tooltip>
@@ -280,7 +335,16 @@ const InstagramGeneratorButton: React.FC<Props> = ({
const VisitorShareEl = (
<Menu placement="top-start">
<MenuButton as={IconButton} aria-label="Sdílet" icon={<Share2 size={18} />} variant="solid" colorScheme="brand" />
<MenuButton
as={IconButton}
aria-label="Sdílet"
icon={<Share2 size={18} />}
variant="solid"
colorScheme="brand"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onTouchStart={(e) => { e.preventDefault(); e.stopPropagation(); }}
/>
<MenuList>
<MenuItem onClick={() => handleShareClick('instagram')} icon={<Instagram size={16} />}>Instagram</MenuItem>
<MenuItem onClick={() => handleShareClick('twitter')} icon={<Twitter size={16} />}>Twitter</MenuItem>
@@ -312,9 +376,29 @@ const InstagramGeneratorButton: React.FC<Props> = ({
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Instagram post</ModalHeader>
<ModalHeader>Instagram příspěvek</ModalHeader>
<ModalBody>
<Textarea value={text} onChange={(e) => setText(e.target.value)} rows={12} fontFamily="mono" />
{images.length > 0 && (
<Box mt={4}>
<Text fontSize="sm" mb={2}>Návrhy obrázků pro Instagram (Grok):</Text>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={3}>
{images.map((url, idx) => (
<Box key={idx}>
<ChakraImage src={url} alt={`Instagram obrázek ${idx + 1}`} borderRadius="md" w="100%" objectFit="cover" />
<Button
mt={2}
size="xs"
variant="outline"
onClick={() => { window.open(url, '_blank'); }}
>
Otevřít v novém okně
</Button>
</Box>
))}
</SimpleGrid>
</Box>
)}
</ModalBody>
<ModalFooter gap={3}>
<Button variant="outline" onClick={handleCopy}>Kopírovat</Button>
@@ -322,6 +406,15 @@ const InstagramGeneratorButton: React.FC<Props> = ({
</ModalFooter>
</ModalContent>
</Modal>
<AILoadingModal
isOpen={loading}
onClose={() => {}} // Can't close while AI is working
title="AI generuje Instagram příspěvek"
message="Pracuji na vytvoření příspěvku pro Instagram..."
progress={aiProgress}
estimatedTime={aiStartTime ? 20 : undefined} // 20 seconds estimated
/>
</>
);
};
@@ -263,6 +263,7 @@ const MapLinkImporter: React.FC<MapLinkImporterProps> = ({
overflow="hidden"
borderWidth="1px"
borderColor={borderColor}
minH="300px"
>
<ContactMap
latitude={previewCoords.latitude}
@@ -306,6 +307,41 @@ const MapLinkImporter: React.FC<MapLinkImporterProps> = ({
</>
)}
{/* Show map even without preview if coordinates exist */}
{!previewCoords && (currentLatitude && currentLongitude) && (
<>
<Divider />
<Box>
<Text fontWeight="semibold" mb={2}>
Náhled mapy
</Text>
<Box
borderRadius="md"
overflow="hidden"
borderWidth="1px"
borderColor={borderColor}
minH="300px"
>
<ContactMap
latitude={currentLatitude}
longitude={currentLongitude}
zoom={currentZoom || 15}
address={undefined}
clubName={clubName}
mapStyle={mapStyle || 'positron'}
clubPrimaryColor={clubPrimaryColor}
clubSecondaryColor={clubSecondaryColor}
height={300}
/>
</Box>
<Text fontSize="xs" color="gray.500" mt={2}>
Souřadnice: {currentLatitude.toFixed(6)}, {currentLongitude.toFixed(6)}
{currentZoom && ` | Zoom: ${currentZoom}`}
</Text>
</Box>
</>
)}
</VStack>
);
};
+33 -15
View File
@@ -29,7 +29,8 @@ import {
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AddIcon, DeleteIcon, ChevronDownIcon, ChevronUpIcon, CloseIcon } from '@chakra-ui/icons';
import { FiPlus } from 'react-icons/fi';
import { getPolls, createPoll, updatePoll, Poll, CreatePollRequest } from '../../services/polls';
import { getAdminPolls, getPolls, createPoll, updatePoll, Poll, CreatePollRequest } from '../../services/polls';
import { useConfirmDialog } from '../../contexts/ConfirmDialogContext';
interface PollLinkerProps {
articleId?: number;
@@ -47,6 +48,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
const [isExpanded, setIsExpanded] = useState(true);
const [selectedPollId, setSelectedPollId] = useState<string>('');
const [showCreateForm, setShowCreateForm] = useState(false);
const { confirm } = useConfirmDialog();
// Poll creation form state
const [newPollData, setNewPollData] = useState<CreatePollRequest>({
@@ -82,7 +84,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
// Query for all available polls
const { data: allPolls, isLoading: isLoadingAll } = useQuery({
queryKey: ['all-admin-polls'],
queryFn: () => getPolls({ status: 'active' }),
queryFn: () => getAdminPolls({ status: 'active' }),
});
// Mutation to link existing poll
@@ -189,10 +191,16 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
linkPollMutation.mutate(parseInt(selectedPollId));
};
const handleUnlinkPoll = (pollId: number) => {
if (window.confirm('Opravdu chcete odpojit tuto anketu?')) {
unlinkPollMutation.mutate(pollId);
}
const handleUnlinkPoll = async (pollId: number) => {
const ok = await confirm({
title: 'Odpojit anketu',
message: 'Opravdu chcete odpojit tuto anketu?',
confirmText: 'Odpojit',
cancelText: 'Zrušit',
isDanger: true,
});
if (!ok) return;
unlinkPollMutation.mutate(pollId);
};
const resetNewPollForm = () => {
@@ -272,13 +280,13 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
}));
};
// Filter out polls that are already linked elsewhere to avoid accidental reuse
const linkedPollIds = new Set(linkedPolls?.map(p => p.id) || []);
const availablePolls = allPolls?.filter(p => {
if (linkedPollIds.has(p.id)) return false; // already linked to this content, handled above
const linkedElsewhere = !!(p.related_article_id || p.related_event_id || p.related_match_id || p.related_video_url);
return !linkedElsewhere;
}) || [];
// Filter out polls that are already linked to THIS content to avoid duplicates
// But allow polls that are linked elsewhere (user can decide to reuse)
const linkedPollIds = new Set(linkedPolls?.map((p: Poll) => p.id) || []);
const availablePolls = allPolls?.filter((p: Poll) => !linkedPollIds.has(p.id)) || [];
// For debugging: also include all polls to see what's available
const allAvailablePolls = allPolls || [];
if (!articleId && !eventId) {
return null;
@@ -323,7 +331,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
<Text fontSize="xs" fontWeight="bold" color="gray.500">
Připojené ankety:
</Text>
{linkedPolls.map((poll) => (
{linkedPolls.map((poll: Poll) => (
<HStack
key={poll.id}
p={2}
@@ -390,7 +398,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
size="sm"
flex={1}
>
{availablePolls.map((poll) => (
{availablePolls.map((poll: Poll) => (
<option key={poll.id} value={poll.id}>
{poll.title} ({poll.status}) - {poll.total_votes} hlasů
</option>
@@ -408,6 +416,16 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
</Button>
</HStack>
</VStack>
) : allAvailablePolls.length > 0 ? (
<Alert status="warning" size="sm">
<AlertIcon />
<VStack align="start" spacing={2}>
<Text fontSize="sm">Všechny aktivní ankety jsou již propojeny s touto aktivitou.</Text>
<Text fontSize="xs" color="gray.600">
Dostupné ankety ({allAvailablePolls.length}): {allAvailablePolls.map(p => p.title).join(', ')}
</Text>
</VStack>
</Alert>
) : (
<Alert status="info" size="sm">
<AlertIcon />
@@ -0,0 +1,120 @@
import React from 'react';
import {
Button,
Box,
Text,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Spinner,
HStack,
VStack,
Divider,
} from '@chakra-ui/react';
import { useBlogTranslation } from '../../hooks/useBlogTranslation';
import { FaLanguage, FaCheck, FaExclamationTriangle } from 'react-icons/fa';
interface UniversalTranslatorProps {
title: string;
content: string;
onTranslationComplete: (translatedTitle: string, translatedContent: string) => void;
disabled?: boolean;
contentType?: 'article' | 'activity' | 'page' | 'sponsor' | 'player';
}
export const UniversalTranslator: React.FC<UniversalTranslatorProps> = ({
title,
content,
onTranslationComplete,
disabled = false,
contentType = 'article',
}) => {
const { translateBlog, isTranslating, translationError, detectSourceLanguage, getTargetLanguage } = useBlogTranslation();
const handleTranslate = async () => {
try {
const result = await translateBlog(title, content);
onTranslationComplete(result.title, result.content);
} catch (error) {
// Error is handled by the hook
}
};
const sourceLang = detectSourceLanguage(title + ' ' + content);
const targetLang = getTargetLanguage();
const shouldTranslate = sourceLang !== targetLang && title && content;
if (!shouldTranslate) {
return (
<Alert status="info" borderRadius="md">
<AlertIcon as={FaLanguage} />
<Box>
<AlertTitle>Translation not needed</AlertTitle>
<AlertDescription>
Content is already in {sourceLang === 'cs' ? 'Czech' : 'English'} or the target language is the same.
</AlertDescription>
</Box>
</Alert>
);
}
const getContentTypeLabel = () => {
switch (contentType) {
case 'activity': return 'Activity';
case 'page': return 'Page';
case 'sponsor': return 'Sponsor';
case 'player': return 'Player';
default: return 'Content';
}
};
return (
<VStack spacing={3} align="stretch">
<Box>
<HStack spacing={2} mb={2}>
<FaLanguage />
<Text fontWeight="medium">
Translate {getContentTypeLabel()} from {sourceLang === 'cs' ? 'Czech' : 'English'} to {targetLang === 'cs' ? 'Czech' : 'English'}
</Text>
</HStack>
<Button
colorScheme="blue"
onClick={handleTranslate}
isLoading={isTranslating}
loadingText="Translating..."
leftIcon={<FaLanguage />}
disabled={disabled || isTranslating}
size="sm"
>
{isTranslating ? 'Translating...' : `Translate ${getContentTypeLabel()}`}
</Button>
</Box>
{translationError && (
<Alert status="error" borderRadius="md">
<AlertIcon as={FaExclamationTriangle} />
<Box>
<AlertTitle>Translation Failed</AlertTitle>
<AlertDescription>{translationError}</AlertDescription>
</Box>
</Alert>
)}
{!isTranslating && !translationError && (
<Alert status="success" borderRadius="md">
<AlertIcon as={FaCheck} />
<Box>
<AlertTitle>Ready to Translate</AlertTitle>
<AlertDescription>
Click the translate button to convert this {getContentTypeLabel().toLowerCase()} to {targetLang === 'cs' ? 'Czech' : 'English'}.
</AlertDescription>
</Box>
</Alert>
)}
<Divider />
</VStack>
);
};
@@ -10,10 +10,8 @@ import {
Text,
useColorModeValue,
VStack,
Tooltip,
Icon,
} from '@chakra-ui/react';
import { FaInfoCircle } from 'react-icons/fa';
import { HelpTooltipCard } from '../common/HelpTooltipCard';
import { ComposableMap, Geographies, Geography } from 'react-simple-maps';
import type { Feature } from 'geojson';
import { useClubTheme } from '../../contexts/ClubThemeContext';
@@ -183,13 +181,14 @@ export const VisitorCountriesMap: React.FC<VisitorCountriesMapProps> = ({
<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>
<HelpTooltipCard
label="Jak pracovat s mapou návštěvníků"
title="Jak pracovat s mapou návštěvníků"
items={[
'Najetím myši na zemi zobrazíte počet návštěv z dané země.',
'Kliknutím na zemi zobrazíte detailnější analytická data podle nastavení nadřazené stránky.',
]}
/>
</Flex>
</CardHeader>
<CardBody>
@@ -0,0 +1,404 @@
import { Box, Text, HStack, VStack, Icon, Skeleton, Badge, useColorModeValue, Tooltip, Progress, Divider, Heading, SimpleGrid } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { FaCloud, FaCloudSun, FaSun, FaCloudRain, FaSnowflake, FaWind, FaTint, FaEye, FaThermometerHalf, FaMapMarkerAlt, FaSyncAlt, FaMoon } from 'react-icons/fa';
import api from '../../services/api';
interface WeatherData {
location: {
name: string;
region: string;
country: string;
lat: number;
lon: number;
tz_id: string;
localtime: string;
};
current: {
last_updated: string;
temp_c: number;
temp_f: number;
is_day: number;
condition: {
text: string;
icon: string;
code: number;
};
wind_mph: number;
wind_kph: number;
wind_degree: number;
wind_dir: string;
pressure_mb: number;
pressure_in: number;
precip_mm: number;
precip_in: number;
humidity: number;
cloud: number;
feelslike_c: number;
feelslike_f: number;
vis_km: number;
vis_miles: number;
uv: number;
gust_mph: number;
gust_kph: number;
};
forecast: {
forecastday: Array<{
date: string;
date_epoch: number;
day: {
maxtemp_c: number;
maxtemp_f: number;
mintemp_c: number;
mintemp_f: number;
avgtemp_c: number;
avgtemp_f: number;
maxwind_mph: number;
maxwind_kph: number;
totalprecip_mm: number;
totalprecip_in: number;
totalsnow_cm: number;
avgvis_km: number;
avgvis_miles: number;
avghumidity: number;
daily_will_it_rain: number;
daily_chance_of_rain: number;
daily_will_it_snow: number;
daily_chance_of_snow: number;
condition: {
text: string;
icon: string;
code: number;
};
uv: number;
};
astro: {
sunrise: string;
sunset: string;
moonrise: string;
moonset: string;
moon_phase: string;
moon_illumination: string;
is_moon_up: number;
is_sun_up: number;
};
hour: Array<{
time_epoch: number;
time: string;
temp_c: number;
temp_f: number;
is_day: number;
condition: {
text: string;
icon: string;
code: number;
};
wind_mph: number;
wind_kph: number;
wind_degree: number;
wind_dir: string;
pressure_mb: number;
pressure_in: number;
precip_mm: number;
precip_in: number;
humidity: number;
cloud: number;
feelslike_c: number;
feelslike_f: number;
windchill_c: number;
windchill_f: number;
heatindex_c: number;
heatindex_f: number;
dewpoint_c: number;
dewpoint_f: number;
will_it_rain: number;
chance_of_rain: number;
will_it_snow: number;
chance_of_snow: number;
vis_km: number;
vis_miles: number;
gust_mph: number;
gust_kph: number;
uv: number;
}>;
}>;
};
}
const getWeatherIcon = (condition: string, isDay: number) => {
const lowerCondition = condition.toLowerCase();
if (lowerCondition.includes('sunny') || lowerCondition.includes('clear')) {
return isDay ? FaSun : FaMoon;
}
if (lowerCondition.includes('partly cloudy') || lowerCondition.includes('partly sunny')) {
return FaCloudSun;
}
if (lowerCondition.includes('cloudy') || lowerCondition.includes('overcast')) {
return FaCloud;
}
if (lowerCondition.includes('rain') || lowerCondition.includes('drizzle') || lowerCondition.includes('shower')) {
return FaCloudRain;
}
if (lowerCondition.includes('snow') || lowerCondition.includes('sleet') || lowerCondition.includes('blizzard')) {
return FaSnowflake;
}
return FaCloud;
};
const getUVColor = (uv: number) => {
if (uv <= 2) return 'green';
if (uv <= 5) return 'yellow';
if (uv <= 7) return 'orange';
if (uv <= 10) return 'red';
return 'purple';
};
const getUVLabel = (uv: number) => {
if (uv <= 2) return 'Nízké';
if (uv <= 5) return 'Střední';
if (uv <= 7) return 'Vysoké';
if (uv <= 10) return 'Velmi vysoké';
return 'Extrémní';
};
const getWindDirection = (degree: number) => {
const directions = ['S', 'SV', 'V', 'JV', 'J', 'JZ', 'Z', 'SZ'];
const index = Math.round(degree / 45) % 8;
return directions[index];
};
const WeatherWidget = () => {
const { data: weather, isLoading, error, refetch } = useQuery<WeatherData>({
queryKey: ['weather', 'club'],
queryFn: async () => {
const response = await api.get('/weather/club');
return response.data;
},
staleTime: 10 * 60 * 1000, // 10 minutes
refetchInterval: 15 * 60 * 1000, // 15 minutes
});
const bgCard = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textColor = useColorModeValue('gray.600', 'gray.400');
if (isLoading) {
return (
<Box
bg={bgCard}
p={6}
borderRadius="xl"
boxShadow="md"
borderWidth="1px"
borderColor={borderColor}
>
<VStack spacing={4} align="stretch">
<Skeleton height="20px" width="60%" />
<Skeleton height="40px" width="80%" />
<Skeleton height="16px" width="40%" />
<Skeleton height="16px" width="50%" />
</VStack>
</Box>
);
}
if (error || !weather) {
return (
<Box
bg={bgCard}
p={6}
borderRadius="xl"
boxShadow="md"
borderWidth="1px"
borderColor={borderColor}
>
<VStack spacing={4} align="center">
<Icon as={FaCloud} boxSize={12} color="gray.400" />
<Text color="gray.500" textAlign="center">
Počasí není dostupné
</Text>
<Text fontSize="sm" color="gray.400" textAlign="center">
Zkuste to znovu později
</Text>
</VStack>
</Box>
);
}
const currentIcon = getWeatherIcon(weather.current.condition.text, weather.current.is_day);
const uvColor = getUVColor(weather.current.uv);
const uvLabel = getUVLabel(weather.current.uv);
return (
<Box
bg={bgCard}
p={6}
borderRadius="xl"
boxShadow="md"
borderWidth="1px"
borderColor={borderColor}
position="relative"
overflow="hidden"
>
{/* Refresh button */}
<Box position="absolute" top={4} right={4}>
<Tooltip label="Obnovit počasí">
<Box
as="button"
onClick={() => refetch()}
p={2}
borderRadius="md"
_hover={{ bg: useColorModeValue('gray.100', 'gray.700') }}
transition="all 0.2s"
>
<Icon as={FaSyncAlt} color={textColor} boxSize={4} />
</Box>
</Tooltip>
</Box>
<VStack spacing={4} align="stretch">
{/* Location */}
<HStack spacing={2} color={textColor}>
<Icon as={FaMapMarkerAlt} boxSize={3} />
<Text fontSize="sm" fontWeight="medium">
{weather.location.name}
{weather.location.region && `, ${weather.location.region}`}
</Text>
</HStack>
{/* Current Weather */}
<HStack spacing={4} align="center">
<VStack spacing={1} align="center" minW="80px">
<Icon as={currentIcon} boxSize={12} color="blue.400" />
<Text fontSize="sm" color={textColor} textAlign="center">
{weather.current.condition.text}
</Text>
</VStack>
<VStack spacing={1} align="start" flex={1}>
<Text fontSize="4xl" fontWeight="bold" lineHeight="1">
{Math.round(weather.current.temp_c)}°C
</Text>
<Text fontSize="sm" color={textColor}>
Pocitově {Math.round(weather.current.feelslike_c)}°C
</Text>
</VStack>
</HStack>
<Divider />
{/* Weather Details Grid */}
<SimpleGrid columns={2} spacing={3}>
<HStack spacing={2}>
<Icon as={FaWind} boxSize={4} color={textColor} />
<VStack spacing={0} align="start">
<Text fontSize="xs" color={textColor}>Vítr</Text>
<Text fontSize="sm" fontWeight="medium">
{weather.current.wind_kph} km/h {getWindDirection(weather.current.wind_degree)}
</Text>
</VStack>
</HStack>
<HStack spacing={2}>
<Icon as={FaTint} boxSize={4} color={textColor} />
<VStack spacing={0} align="start">
<Text fontSize="xs" color={textColor}>Vlhkost</Text>
<Text fontSize="sm" fontWeight="medium">{weather.current.humidity}%</Text>
</VStack>
</HStack>
<HStack spacing={2}>
<Icon as={FaEye} boxSize={4} color={textColor} />
<VStack spacing={0} align="start">
<Text fontSize="xs" color={textColor}>Viditelnost</Text>
<Text fontSize="sm" fontWeight="medium">{weather.current.vis_km} km</Text>
</VStack>
</HStack>
<HStack spacing={2}>
<Icon as={FaThermometerHalf} boxSize={4} color={textColor} />
<VStack spacing={0} align="start">
<Text fontSize="xs" color={textColor}>Tlak</Text>
<Text fontSize="sm" fontWeight="medium">{weather.current.pressure_mb} hPa</Text>
</VStack>
</HStack>
</SimpleGrid>
{/* UV Index */}
<Box>
<HStack justify="space-between" mb={1}>
<Text fontSize="xs" color={textColor}>UV Index</Text>
<Badge colorScheme={uvColor} fontSize="xs">
{uvLabel} ({weather.current.uv})
</Badge>
</HStack>
<Progress
value={Math.min(weather.current.uv * 10, 100)}
size="xs"
colorScheme={uvColor}
borderRadius="md"
/>
</Box>
{/* Forecast for next 2 days */}
{weather.forecast.forecastday.length > 1 && (
<>
<Divider />
<Text fontSize="sm" fontWeight="bold" mb={2}>
Předpověď na následující dny
</Text>
<SimpleGrid columns={2} spacing={3}>
{weather.forecast.forecastday.slice(1, 3).map((day, index) => {
const dayIcon = getWeatherIcon(day.day.condition.text, 1);
const date = new Date(day.date);
const dayName = date.toLocaleDateString('cs-CZ', { weekday: 'short' });
return (
<Box
key={index}
p={3}
bg={useColorModeValue('gray.50', 'gray.700')}
borderRadius="md"
border="1px solid"
borderColor={useColorModeValue('gray.200', 'gray.600')}
>
<HStack spacing={3} align="center">
<Icon as={dayIcon} boxSize={6} color="blue.400" />
<VStack spacing={1} align="start" flex={1}>
<Text fontSize="xs" fontWeight="medium">{dayName}</Text>
<HStack spacing={2}>
<Text fontSize="sm" fontWeight="bold">
{Math.round(day.day.maxtemp_c)}°
</Text>
<Text fontSize="sm" color={textColor}>
{Math.round(day.day.mintemp_c)}°
</Text>
</HStack>
{day.day.daily_chance_of_rain > 0 && (
<Badge size="xs" colorScheme="blue">
{day.day.daily_chance_of_rain}% déšť
</Badge>
)}
</VStack>
</HStack>
</Box>
);
})}
</SimpleGrid>
</>
)}
{/* Last updated */}
<Text fontSize="xs" color={textColor} textAlign="center">
Aktualizováno: {new Date(weather.current.last_updated).toLocaleTimeString('cs-CZ', {
hour: '2-digit',
minute: '2-digit'
})}
</Text>
</VStack>
</Box>
);
};
export default WeatherWidget;
@@ -5,6 +5,7 @@ import { listComments, createComment, updateComment, deleteComment, CommentItem,
import { useAuth } from '../../contexts/AuthContext';
import { Pencil, Trash2, Send, CheckCircle2 } from 'lucide-react';
import { Link as RouterLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
type Props = {
targetType: 'article' | 'event' | 'gallery_album' | 'youtube_video';
@@ -14,14 +15,18 @@ type Props = {
const PAGE_SIZE = 20;
const displayName = (u?: CommentItem['user']) => {
if (!u) return 'Anonym';
const { i18n } = useTranslation();
const useEnglish = i18n.language === 'en';
if (!u) return useEnglish ? 'Anonymous' : 'Anonym';
const uname = (u.username || '').trim();
if (uname) return uname;
const name = `${u.first_name || ''} ${u.last_name || ''}`.trim();
return name || 'Uživatel';
return name || (useEnglish ? 'User' : 'Uživatel');
};
const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
const { t, i18n } = useTranslation();
const cardBg = useColorModeValue('white', 'gray.800');
const border = useColorModeValue('gray.200', 'gray.700');
const muted = useColorModeValue('gray.600', 'gray.400');
@@ -29,6 +34,8 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
const adminLikedColor = useColorModeValue('blue.600','blue.300');
const queryClient = useQueryClient();
const { isAuthenticated, user } = useAuth();
const useEnglish = i18n.language === 'en';
const commentsQuery = useInfiniteQuery({
queryKey: ['comments', targetType, targetId],
@@ -48,7 +55,9 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
const [replyTo, setReplyTo] = React.useState<number | null>(null);
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
const [canRequestUnban, setCanRequestUnban] = React.useState<boolean>(false);
const [unbanMessage, setUnbanMessage] = React.useState<string>('Prosím o odblokování komentářů. Děkuji.');
const [unbanMessage, setUnbanMessage] = React.useState<string>('Please unblock my comments. Thank you.');
const [localReactions, setLocalReactions] = React.useState<Record<number, string | undefined>>({});
const [pendingReactions, setPendingReactions] = React.useState<Set<number>>(new Set());
const createMut = useMutation({
mutationFn: (body: { content: string; parent_id?: number | null }) => createComment({ target_type: targetType, target_id: targetId, content: body.content, parent_id: body.parent_id }),
@@ -58,12 +67,12 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
setErrorMsg(null);
await queryClient.invalidateQueries({ queryKey: ['comments', targetType, targetId] });
if ((created as any)?.status === 'hidden') {
setErrorMsg('Váš komentář čeká na schválení (automatická moderace).');
setErrorMsg(useEnglish ? 'Your comment is awaiting approval (automatic moderation).' : 'Váš komentář čeká na schválení (automatická moderace).');
}
try { window.dispatchEvent(new CustomEvent('engagement:refresh')); } catch {}
},
onError: (e: any) => {
const msg = e?.response?.data?.error || 'Nepodařilo se odeslat komentář';
const msg = e?.response?.data?.error || (useEnglish ? 'Failed to post comment' : 'Nepodařilo se odeslat komentář');
setErrorMsg(msg);
if ((e?.response?.status || 0) === 403) setCanRequestUnban(true);
}
@@ -88,21 +97,41 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
const reactMut = useMutation({
mutationFn: (args: { id: number; type: string }) => reactComment(args.id, args.type),
onMutate: async ({ id, type }) => {
setPendingReactions(prev => new Set(prev).add(id));
const qk = ['comments', targetType, targetId] as const;
await queryClient.cancelQueries({ queryKey: qk });
const previous = queryClient.getQueryData<any>(qk);
// Set optimistic state
setLocalReactions((m) => ({ ...m, [id]: type }));
queryClient.setQueryData(qk, (oldData: any) => {
if (!oldData) return oldData;
const pages = (oldData.pages || []).map((page: any) => {
const items = (page.items || []).map((it: any) => {
if (it.id !== id) return it;
const next = { ...it, reactions: { ...(it.reactions || {}) } };
const prevType = next.my_reaction as string | undefined;
if (prevType && typeof next.reactions[prevType] === 'number') {
next.reactions[prevType] = Math.max(0, (next.reactions[prevType] || 0) - 1);
const prevType = (next.my_reaction as string | undefined)?.trim();
// Remove previous reaction count if it existed
if (prevType && prevType !== type) {
const prevCount = (typeof next.reactions[prevType] === 'number' ? next.reactions[prevType] : 1) as number;
const newPrev = Math.max(0, prevCount - 1);
if (newPrev <= 0) {
delete (next.reactions as any)[prevType];
} else {
next.reactions[prevType] = newPrev;
}
}
next.reactions[type] = (next.reactions[type] || 0) + 1;
// Add new reaction count
next.reactions[type] = ((next.reactions[type] || 0) as number) + 1;
next.my_reaction = type;
if ((user as any)?.role === 'admin') {
next.admin_liked = (type === 'thumbs_up' || type === 'like');
}
return next;
});
return { ...page, items };
@@ -117,7 +146,23 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
queryClient.setQueryData(qk, (ctx as any).previous);
}
},
onSettled: async () => {
onSettled: async (_data, _error, variables) => {
if (variables?.id) {
setPendingReactions(prev => {
const next = new Set(prev);
next.delete(variables.id);
return next;
});
}
// Clear local optimistic state after a delay to allow server state to sync
setTimeout(() => {
setLocalReactions((m) => {
const cp = { ...m };
delete cp[variables?.id];
return cp;
});
}, 100);
await queryClient.invalidateQueries({ queryKey: ['comments', targetType, targetId] });
try { window.dispatchEvent(new CustomEvent('engagement:refresh')); } catch {}
},
@@ -126,20 +171,35 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
const unreactMut = useMutation({
mutationFn: (id: number) => unreactComment(id),
onMutate: async (id: number) => {
setPendingReactions(prev => new Set(prev).add(id));
const qk = ['comments', targetType, targetId] as const;
await queryClient.cancelQueries({ queryKey: qk });
const previous = queryClient.getQueryData<any>(qk);
// Set optimistic state to empty
setLocalReactions((m) => ({ ...m, [id]: '' }));
queryClient.setQueryData(qk, (oldData: any) => {
if (!oldData) return oldData;
const pages = (oldData.pages || []).map((page: any) => {
const items = (page.items || []).map((it: any) => {
if (it.id !== id) return it;
const next = { ...it, reactions: { ...(it.reactions || {}) } };
const prevType = next.my_reaction as string | undefined;
if (prevType && typeof next.reactions[prevType] === 'number') {
next.reactions[prevType] = Math.max(0, (next.reactions[prevType] || 0) - 1);
const prevType = (next.my_reaction as string | undefined)?.trim();
if (prevType) {
const prevCount = (typeof next.reactions[prevType] === 'number' ? next.reactions[prevType] : 1) as number;
const newPrev = Math.max(0, prevCount - 1);
if (newPrev <= 0) {
delete (next.reactions as any)[prevType];
} else {
next.reactions[prevType] = newPrev;
}
}
next.my_reaction = '';
if ((user as any)?.role === 'admin') {
next.admin_liked = false;
}
return next;
});
return { ...page, items };
@@ -154,7 +214,23 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
queryClient.setQueryData(qk, (ctx as any).previous);
}
},
onSettled: async () => {
onSettled: async (_data, _error, variables) => {
if (variables) {
setPendingReactions(prev => {
const next = new Set(prev);
next.delete(variables);
return next;
});
}
// Clear local optimistic state after a delay to allow server state to sync
setTimeout(() => {
setLocalReactions((m) => {
const cp = { ...m };
delete cp[variables];
return cp;
});
}, 100);
await queryClient.invalidateQueries({ queryKey: ['comments', targetType, targetId] });
try { window.dispatchEvent(new CustomEvent('engagement:refresh')); } catch {}
},
@@ -164,15 +240,15 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
mutationFn: (message: string) => requestUnban(message),
onSuccess: () => {
setCanRequestUnban(false);
setErrorMsg('Žádost o odblokování odeslána.');
setUnbanMessage('Prosím o odblokování komentářů. Děkuji.');
setErrorMsg(useEnglish ? 'Unban request sent.' : 'Žádost o odblokování odeslána.');
setUnbanMessage(useEnglish ? 'Please unblock my comments. Thank you.' : 'Prosím o odblokování komentářů. Děkuji.');
}
});
const reportMut = useMutation({
mutationFn: (args: { id: number; reason?: string }) => reportComment(args.id, args.reason),
onSuccess: async () => {
setErrorMsg('Děkujeme za nahlášení. Moderátor se na komentář podívá.');
setErrorMsg(useEnglish ? 'Thank you for reporting. A moderator will review the comment.' : 'Děkujeme za nahlášení. Moderátor se na komentář podívá.');
},
});
@@ -196,15 +272,54 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
const ReactionBar: React.FC<{ c: CommentItem }> = ({ c }) => {
const options: { key: string; label: string; color: string; name: string }[] = [
{ key: 'thumbs_up', label: '👍', color: 'green', name: 'Palec nahoru' },
{ key: 'heart', label: '❤️', color: 'pink', name: 'Srdíčko' },
{ key: 'smile', label: '😀', color: 'yellow', name: 'Úsměv' },
{ key: 'surprised', label: '😮', color: 'purple', name: 'Překvapení' },
{ key: 'thumbs_down', label: '👎', color: 'red', name: 'Palec dolů' },
{ key: 'thumbs_up', label: '👍', color: 'green', name: useEnglish ? 'Thumbs up' : 'Palec nahoru' },
{ key: 'heart', label: '❤️', color: 'pink', name: useEnglish ? 'Heart' : 'Srdíčko' },
{ key: 'smile', label: '😀', color: 'yellow', name: useEnglish ? 'Smile' : 'Úsměv' },
{ key: 'surprised', label: '😮', color: 'purple', name: useEnglish ? 'Surprised' : 'Překvapení' },
{ key: 'thumbs_down', label: '👎', color: 'red', name: useEnglish ? 'Thumbs down' : 'Palec dolů' },
];
const counts = c.reactions || {};
const active = c.my_reaction;
const isBusy = reactMut.isPending || unreactMut.isPending;
const active = React.useMemo(() => {
// Prioritize local optimistic state, fallback to server state
const localActive = (localReactions[c.id] || '').trim();
if (localActive !== '') {
return localActive;
}
return (c.my_reaction || '').trim();
}, [c.my_reaction, localReactions, c.id]);
const viewCounts = React.useMemo(() => {
const base: Record<string, number> = { ...(counts as any) };
const a = (active || '').trim();
const serverActive = (c.my_reaction || '').trim();
const localActive = (localReactions[c.id] || '').trim();
// If we have a local optimistic state that differs from server, adjust counts
if (localActive !== '' && localActive !== serverActive) {
// Remove old server reaction count if it existed
if (serverActive) {
const prev = typeof base[serverActive] === 'number' ? base[serverActive] : 0;
if (prev > 0) {
base[serverActive] = prev - 1;
if (base[serverActive] <= 0) delete base[serverActive];
}
}
// Add new local reaction count
base[localActive] = (base[localActive] || 0) + 1;
}
// Handle unreact optimistic state (empty string)
else if (localActive === '' && serverActive) {
// Remove the server reaction count optimistically
const prev = typeof base[serverActive] === 'number' ? base[serverActive] : 0;
if (prev > 0) {
base[serverActive] = prev - 1;
if (base[serverActive] <= 0) delete base[serverActive];
}
}
return base;
}, [counts, active, c.my_reaction, localReactions, c.id]);
const isBusy = reactMut.isPending || unreactMut.isPending || pendingReactions.has(c.id);
return (
<HStack spacing={2} mt={1}>
{options.map((o) => (
@@ -214,9 +329,9 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
colorScheme={o.color}
variant={active === o.key ? 'solid' : 'outline'}
isDisabled={!isAuthenticated || isBusy}
aria-pressed={active === o.key}
isActive={active === o.key}
onClick={() => {
if (!isAuthenticated) return;
if (!isAuthenticated || isBusy) return;
if (active === o.key) {
unreactMut.mutate(c.id);
} else {
@@ -226,7 +341,7 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
>
<HStack spacing={1}>
<Text as="span">{o.label}</Text>
<Text as="span" fontSize="xs">{counts[o.key] || 0}</Text>
<Text as="span" fontSize="xs">{viewCounts[o.key] || 0}</Text>
</HStack>
</Button>
</Tooltip>
@@ -246,14 +361,14 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
<HStack justify="space-between">
<HStack spacing={2}>
<Text fontWeight="600">{displayName(c.user)}</Text>
{c.user?.role === 'admin' && <Badge colorScheme="purple" variant="subtle">Admin</Badge>}
{c.user?.role === 'admin' && <Badge colorScheme="purple" variant="subtle">{useEnglish ? 'Admin' : 'Admin'}</Badge>}
<Text fontSize="sm" color={muted}>{new Date(c.created_at).toLocaleString()}</Text>
{c.is_edited && <Text fontSize="xs" color={muted}>(upraveno)</Text>}
{c.is_edited && <Text fontSize="xs" color={muted}>({useEnglish ? '(edited)' : '(upraveno)'})</Text>}
</HStack>
{canEdit(c) && (
<HStack spacing={1}>
<IconButton aria-label="Upravit" size="xs" variant="ghost" icon={<Pencil size={16} />} onClick={() => { setEditingId(c.id); setEditContent(c.content); }} />
<IconButton aria-label="Smazat" size="xs" variant="ghost" colorScheme="red" icon={<Trash2 size={16} />} onClick={() => deleteMut.mutate(c.id)} />
<IconButton aria-label={useEnglish ? 'Edit' : 'Upravit'} size="xs" variant="ghost" icon={<Pencil size={16} />} onClick={() => { setEditingId(c.id); setEditContent(c.content); }} />
<IconButton aria-label={useEnglish ? 'Delete' : 'Smazat'} size="xs" variant="ghost" colorScheme="red" icon={<Trash2 size={16} />} onClick={() => deleteMut.mutate(c.id)} />
</HStack>
)}
</HStack>
@@ -261,8 +376,8 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
<VStack align="stretch" spacing={2}>
<Textarea value={editContent} onChange={(e) => setEditContent(e.target.value)} rows={3} />
<HStack>
<Button size="sm" colorScheme="blue" onClick={() => updateMut.mutate({ id: c.id, content: editContent.trim() })} isLoading={updateMut.isPending}>Uložit</Button>
<Button size="sm" variant="ghost" onClick={() => { setEditingId(null); setEditContent(''); }}>Zrušit</Button>
<Button size="sm" colorScheme="blue" onClick={() => updateMut.mutate({ id: c.id, content: editContent.trim() })} isLoading={updateMut.isPending}>{useEnglish ? 'Save' : 'Uložit'}</Button>
<Button size="sm" variant="ghost" onClick={() => { setEditingId(null); setEditContent(''); }}>{useEnglish ? 'Cancel' : 'Zrušit'}</Button>
</HStack>
</VStack>
) : (
@@ -276,13 +391,13 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
{c.admin_liked && (
<HStack spacing={2} mt={1} color={adminLikedColor}>
<CheckCircle2 size={16} />
<Text fontSize="sm">Označeno administrátorem</Text>
<Text fontSize="sm">{useEnglish ? 'Marked by administrator' : 'Označeno administrátorem'}</Text>
</HStack>
)}
<HStack>
{isAuthenticated && <Button size="xs" variant="ghost" onClick={() => setReplyTo(c.id)}>Odpovědět</Button>}
{isAuthenticated && <Button size="xs" variant="ghost" colorScheme="red" onClick={() => reportMut.mutate({ id: c.id })}>Nahlásit</Button>}
{c.status === 'hidden' && <Badge colorScheme="yellow">Čeká na schválení</Badge>}
{isAuthenticated && <Button size="xs" variant="ghost" onClick={() => setReplyTo(c.id)}>{useEnglish ? 'Reply' : 'Odpovědět'}</Button>}
{isAuthenticated && <Button size="xs" variant="ghost" colorScheme="red" onClick={() => reportMut.mutate({ id: c.id })}>{useEnglish ? 'Report' : 'Nahlásit'}</Button>}
{c.status === 'hidden' && <Badge colorScheme="yellow">{useEnglish ? 'Awaiting approval' : 'Čeká na schválení'}</Badge>}
</HStack>
{/* Replies */}
{renderThread(c.id, depth + 1)}
@@ -295,18 +410,20 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
return (
<Box mt={6} borderWidth="1px" borderColor={border} borderRadius="lg" bg={cardBg} p={4}>
<Heading as="h3" size="md" mb={3}>Komentáře</Heading>
<HStack justify="space-between" align="center" mb={3}>
<Heading as="h3" size="md">{useEnglish ? 'Comments' : 'Komentáře'}</Heading>
</HStack>
{commentsQuery.isLoading ? (
<HStack><Spinner size="sm" /><Text>Načítám</Text></HStack>
<HStack><Spinner size="sm" /><Text>{useEnglish ? 'Loading...' : 'Načítám…'}</Text></HStack>
) : (
<VStack align="stretch" spacing={3}>
{allItems.length === 0 && (
<Text color={muted}>Zatím žádné komentáře.</Text>
<Text color={muted}>{useEnglish ? 'No comments yet.' : 'Zatím žádné komentáře.'}</Text>
)}
{renderThread(null)}
{commentsQuery.hasNextPage && (
<Button onClick={() => commentsQuery.fetchNextPage()} isLoading={commentsQuery.isFetchingNextPage} alignSelf="center" size="sm" variant="outline">Načíst další</Button>
<Button onClick={() => commentsQuery.fetchNextPage()} isLoading={commentsQuery.isFetchingNextPage} alignSelf="center" size="sm" variant="outline">{useEnglish ? 'Load more' : 'Načíst další'}</Button>
)}
</VStack>
)}
@@ -319,32 +436,37 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
<VStack align="stretch" spacing={2}>
{replyTo && (
<HStack justify="space-between">
<Text fontSize="sm" color={muted}>Odpověď na komentář #{replyTo}</Text>
<Button size="xs" variant="ghost" onClick={() => setReplyTo(null)}>Zrušit odpověď</Button>
<Text fontSize="sm" color={muted}>{useEnglish ? `Reply to comment #${replyTo}` : `Odpověď na komentář #${replyTo}`}</Text>
<Button size="xs" variant="ghost" onClick={() => setReplyTo(null)}>{useEnglish ? 'Cancel reply' : 'Zrušit odpověď'}</Button>
</HStack>
)}
<Textarea placeholder="Napište komentář…" value={newContent} onChange={(e) => setNewContent(e.target.value)} rows={3} />
<Textarea placeholder={useEnglish ? 'Write a comment...' : 'Napište komentář…'} value={newContent} onChange={(e) => setNewContent(e.target.value)} rows={3} />
<HStack>
<Button leftIcon={<Send size={16} />} colorScheme="blue" onClick={() => createMut.mutate({ content: newContent.trim(), parent_id: replyTo })} isLoading={createMut.isPending} isDisabled={newContent.trim().length < minChars}>Odeslat</Button>
<Text fontSize="sm" color={muted}>Respektujte prosím pravidla slušné diskuse.</Text>
<Button leftIcon={<Send size={16} />} colorScheme="blue" onClick={() => createMut.mutate({ content: newContent.trim(), parent_id: replyTo })} isLoading={createMut.isPending} isDisabled={newContent.trim().length < minChars}>{useEnglish ? 'Send' : 'Odeslat'}</Button>
<Text fontSize="sm" color={muted}>{useEnglish ? 'Please respect the rules of decent discussion.' : 'Respektujte prosím pravidla slušné diskuse.'}</Text>
</HStack>
{canRequestUnban && (
<VStack align="stretch" spacing={2} borderWidth="1px" borderColor={border} borderRadius="md" p={3} bg={appealBg}>
<Text fontSize="sm" color={muted}>Váš účet je dočasně zablokován pro komentování. Můžete odeslat žádost o odblokování s krátkým vysvětlením.</Text>
<Text fontSize="sm" color={muted}>{useEnglish ? 'Your account is temporarily blocked from commenting. You can send an unban request with a brief explanation.' : 'Váš účet je dočasně zablokován pro komentování. Můžete odeslat žádost o odblokování s krátkým vysvětlením.'}</Text>
<Textarea
placeholder="Vaše zpráva pro administrátory…"
placeholder={useEnglish ? 'Your message for administrators...' : 'Vaše zpráva pro administrátory…'}
value={unbanMessage}
onChange={(e) => setUnbanMessage(e.target.value)}
rows={3}
/>
<HStack>
<Button size="sm" variant="outline" onClick={() => unbanMut.mutate(unbanMessage.trim() || 'Prosím o odblokování komentářů. Děkuji.')} isLoading={unbanMut.isPending}>Odeslat žádost o odblokování</Button>
<Button size="sm" variant="outline" onClick={() => unbanMut.mutate(unbanMessage.trim() || (useEnglish ? 'Please unblock my comments. Thank you.' : 'Prosím o odblokování komentářů. Děkuji.'))} isLoading={unbanMut.isPending}>{useEnglish ? 'Send unban request' : 'Odeslat žádost o odblokování'}</Button>
</HStack>
</VStack>
)}
</VStack>
) : (
<Text color={muted}>Pro přidání komentáře se prosím <ChakraLink as={RouterLink} to="/login" color="blue.500">přihlaste</ChakraLink>.</Text>
<Text color={muted}>
{useEnglish ? 'To add a comment, please ' : 'Pro přidání komentáře se prosím '}
<ChakraLink as={RouterLink} to="/login" color="blue.500">
{useEnglish ? 'log in' : 'přihlaste'}
</ChakraLink>.
</Text>
)}
</Box>
</Box>
@@ -0,0 +1,157 @@
import React from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
VStack,
HStack,
Text,
Spinner,
Progress,
Box,
Icon,
useColorModeValue,
} from '@chakra-ui/react';
import { FiCpu, FiClock } from 'react-icons/fi';
interface AILoadingModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
message?: string;
progress?: number;
estimatedTime?: number; // in seconds
}
const AILoadingModal: React.FC<AILoadingModalProps> = ({
isOpen,
onClose,
title = 'AI generuje obsah',
message = 'Pracuji na vašem požadavku...',
progress,
estimatedTime,
}) => {
const bg = useColorModeValue('white', 'gray.800');
const textColor = useColorModeValue('gray.700', 'gray.200');
const progressColor = useColorModeValue('blue.500', 'blue.300');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const getProgressMessage = () => {
if (progress !== undefined) {
if (progress < 25) return 'AI analyzuje požadavek...';
if (progress < 50) return 'AI komunikuje s modelem...';
if (progress < 75) return 'AI zpracovává obsah...';
if (progress < 90) return 'AI optimalizuje výstup...';
return 'AI dokončuje...';
}
return message;
};
const getTimeRemaining = () => {
if (estimatedTime && progress !== undefined && progress > 0) {
const remaining = Math.ceil((estimatedTime * (100 - progress)) / 100);
if (remaining > 0) {
return `Předpokládaný čas: ${remaining}s`;
}
}
if (estimatedTime) {
return `Předpokládaný čas: ${estimatedTime}s`;
}
return '';
};
const getRoundedProgress = () => {
if (progress !== undefined) {
return Math.round(progress);
}
return 0;
};
return (
<Modal isOpen={isOpen} onClose={onClose} isCentered closeOnEsc={false} closeOnOverlayClick={false}>
<ModalOverlay bg="blackAlpha.400" backdropFilter="blur(6px)" />
<ModalContent bg={bg} borderRadius="2xl" boxShadow="2xl" border="1px solid" borderColor={borderColor}>
<ModalHeader borderBottom="1px solid" borderColor={borderColor} pb={4}>
<VStack spacing={3} align="center">
<HStack spacing={3}>
<Box bg="blue.50" p={2} borderRadius="full">
<Icon as={FiCpu} color="blue.500" boxSize={5} />
</Box>
<Text fontSize="lg" fontWeight="600" color={textColor}>
{title}
</Text>
</HStack>
</VStack>
</ModalHeader>
<ModalBody py={8}>
<VStack spacing={6} align="center">
{/* Single centered AI Animation with model logo */}
<VStack spacing={4}>
<Box bg="blue.50" p={4} borderRadius="full">
<Icon as={FiCpu} color="blue.500" boxSize={6} />
</Box>
<Spinner size="lg" color="blue.500" thickness="3px" />
</VStack>
{/* Status Message */}
<Text fontSize="md" color={textColor} textAlign="center" fontWeight="500">
{getProgressMessage()}
</Text>
{/* Progress Bar */}
{progress !== undefined && (
<Box w="100%" maxW="400px">
<Progress
value={progress}
size="lg"
colorScheme="blue"
borderRadius="full"
bg="gray.100"
sx={{
'& > div': {
bg: progressColor,
transition: 'width 0.4s ease-out',
},
}}
/>
<Text fontSize="sm" color="gray.600" mt={3} textAlign="center" fontWeight="500">
{getRoundedProgress()}% hotovo
</Text>
</Box>
)}
{/* Time Estimate */}
{getTimeRemaining() && (
<HStack spacing={2} color="gray.500">
<Icon as={FiClock} boxSize={4} />
<Text fontSize="sm">{getTimeRemaining()}</Text>
</HStack>
)}
{/* Additional Info */}
<VStack spacing={2} align="center" pt={2}>
<Text fontSize="sm" color="gray.400" fontStyle="italic">
AI pracuje na vašem obsahu
</Text>
<Text fontSize="xs" color="gray.400">
Vygenerovaný obsah bude automaticky vložen do formuláře
</Text>
</VStack>
</VStack>
</ModalBody>
<ModalFooter borderTop="1px solid" borderColor={borderColor}>
<Text fontSize="sm" color="gray.400">
Prosím čekejte, proces nelze přerušit
</Text>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default AILoadingModal;
@@ -0,0 +1,863 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Flex,
HStack,
IconButton,
Menu,
MenuButton,
MenuItem,
MenuList,
Text,
Textarea,
Tooltip,
useColorModeValue,
useDisclosure,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
Button,
Spinner,
useToast,
} from '@chakra-ui/react';
import { AnimatePresence, motion } from 'framer-motion';
import { FiChevronDown, FiCheck, FiSend, FiPaperclip, FiMic } from 'react-icons/fi';
import { Brain, Sparkles, Globe2 } from 'lucide-react';
import {
AITextModelId,
AITextModelOption,
getDefaultTextModelId,
getEnabledTextModels,
findTextModel,
} from '../../utils/aiModels';
import { getAIUsageStatus, formatUsageText, AIUsageStatus } from '../../services/aiUsage';
import UploadPanel, { UploadPanelFile } from './UploadPanel';
import { processOcrAI, transcribeAudioAI } from '../../services/ai';
import { uploadFile as uploadArticleFile } from '../../services/articles';
export interface AIPromptInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: (value: string, modelId: AITextModelId) => void | Promise<unknown>;
isSubmitting?: boolean;
placeholder?: string;
helperText?: string;
initialModelId?: AITextModelId;
onModelChange?: (id: AITextModelId) => void;
onAttachClick?: () => void;
onVoiceClick?: () => void;
}
const DAILY_LIMIT = 10;
const DAILY_LIMIT_REASONING = 5;
const DAILY_LIMIT_DEEPSEEK = Infinity; // Unlimited for DeepSeek
const MotionBox = motion(Box);
const getModelIcon = (model?: AITextModelOption, size = 14) => {
if (!model) return null;
const iconSize = size === 16 ? 20 : size; // Slightly larger for better visibility
if (model.provider === 'deepseek') {
return <img src="/deepseek.svg" alt="DeepSeek" width={iconSize} height={iconSize} />;
}
if (model.provider === 'mistral') {
if (model.id === 'mistral-small-latest') {
return <img src="/mistral_small.png" alt="Mistral" width={iconSize} height={iconSize} />;
}
return <img src="/ministral.png" alt="Ministral" width={iconSize} height={iconSize} />;
}
if (model.provider === 'openrouter') {
return <Globe2 size={size} />; // Keep Lucide icon for OpenRouter
}
if (model.provider === 'grok') {
return <img src="/grok.svg" alt="Grok" width={iconSize} height={iconSize} />;
}
return null;
};
const isReasoningModel = (modelId: AITextModelId): boolean => {
return modelId.includes('reasoning') || modelId.includes('reasoner');
};
const AIPromptInput: React.FC<AIPromptInputProps> = ({
value,
onChange,
onSubmit,
isSubmitting,
placeholder,
helperText,
initialModelId,
onModelChange,
onAttachClick,
onVoiceClick,
}) => {
const models = useMemo<AITextModelOption[]>(() => getEnabledTextModels(), []);
const defaultId = useMemo<AITextModelId>(() => initialModelId || getDefaultTextModelId(), [initialModelId]);
const [selectedModel, setSelectedModel] = useState<AITextModelId>(defaultId);
const [usageStatus, setUsageStatus] = useState<AIUsageStatus>({});
const [isLoadingUsage, setIsLoadingUsage] = useState(false);
// Define getRemainingToday before using it in useState
const getRemainingToday = useCallback((modelId: AITextModelId): number => {
const modelStatus = usageStatus[modelId];
if (!modelStatus) return 10; // Default fallback
return modelStatus.unlimited ? -1 : modelStatus.remaining;
}, [usageStatus]);
const [remaining, setRemaining] = useState<number>(() => getRemainingToday(defaultId));
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const minHeight = 96;
const maxHeight = 260;
// Clean white styling for AI input
const bg = useColorModeValue('white', 'gray.800');
const innerBg = useColorModeValue('gray.50', 'gray.700');
const border = useColorModeValue('gray.200', 'gray.600');
const placeholderColor = useColorModeValue('gray.500', 'gray.400');
const hoverBg = useColorModeValue('gray.100', 'gray.600');
const toast = useToast();
const {
isOpen: isAttachOpen,
onOpen: onAttachOpen,
onClose: onAttachClose,
} = useDisclosure();
const {
isOpen: isVoiceOpen,
onOpen: onVoiceOpen,
onClose: onVoiceClose,
} = useDisclosure();
const [ocrFiles, setOcrFiles] = useState<UploadPanelFile[]>([]);
const [ocrText, setOcrText] = useState('');
const [isOcrProcessing, setIsOcrProcessing] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const mediaStreamRef = useRef<MediaStream | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const animationFrameRef = useRef<number | null>(null);
const recordingTimerRef = useRef<number | null>(null);
const [isRecording, setIsRecording] = useState(false);
const [isVoiceProcessing, setIsVoiceProcessing] = useState(false);
const [hasRecording, setHasRecording] = useState(false);
const [recordingSeconds, setRecordingSeconds] = useState(0);
const [waveform, setWaveform] = useState<number[]>(() => new Array(32).fill(0));
const adjustHeight = useCallback((reset?: boolean) => {
const el = textareaRef.current;
if (!el) return;
if (reset) {
el.style.height = `${minHeight}px`;
return;
}
el.style.height = 'auto';
const next = Math.max(minHeight, Math.min(el.scrollHeight, maxHeight));
el.style.height = `${next}px`;
}, []);
useEffect(() => {
adjustHeight();
}, [value, adjustHeight]);
useEffect(() => {
const handleResize = () => adjustHeight();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [adjustHeight]);
// Fetch usage status on mount and when model changes
useEffect(() => {
const fetchUsageStatus = async () => {
setIsLoadingUsage(true);
try {
const status = await getAIUsageStatus();
setUsageStatus(status);
} catch (error) {
console.error('Failed to fetch AI usage status:', error);
} finally {
setIsLoadingUsage(false);
}
};
fetchUsageStatus();
}, []);
// Update remaining when usage status or selected model changes
useEffect(() => {
setRemaining(getRemainingToday(selectedModel));
}, [getRemainingToday, selectedModel]);
const getDailyLimit = (modelId: AITextModelId): number => {
const modelStatus = usageStatus[modelId];
if (!modelStatus) return 10; // Default fallback
return modelStatus.limit;
};
const handleModelChange = (id: AITextModelId) => {
setSelectedModel(id);
if (onModelChange) onModelChange(id);
};
const handleSubmit = async () => {
const trimmed = value.trim();
if (!trimmed) return;
// Check remaining requests before submitting
const modelStatus = usageStatus[selectedModel];
if (modelStatus && !modelStatus.unlimited && modelStatus.remaining <= 0) {
toast({
title: 'Denní limit vyčerpán',
description: 'Denní limit pro tento AI model byl vyčerpán. Zkuste to znovu zítra.',
status: 'warning',
duration: 5000,
isClosable: true,
});
return;
}
try {
const maybePromise = onSubmit(trimmed, selectedModel);
if (maybePromise && typeof (maybePromise as any).then === 'function') {
await (maybePromise as Promise<unknown>);
}
// Refresh usage status after successful submission
const status = await getAIUsageStatus();
setUsageStatus(status);
} catch {
// Do not decrement usage on error; parent is responsible for showing errors
}
};
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!isSubmitting) {
handleSubmit();
}
}
};
const selected = findTextModel(selectedModel);
const isDisabled = isSubmitting || !value.trim() || remaining <= 0;
const handleInsertText = (text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
const next = value ? `${value}\n\n${trimmed}` : trimmed;
onChange(next);
};
const handleOcrUploadFinished = async (uploaded: UploadPanelFile[]) => {
if (!uploaded || uploaded.length === 0) return;
const last = uploaded[uploaded.length - 1];
if (!last?.url) return;
setIsOcrProcessing(true);
try {
const isImage = (last.type || '').startsWith('image/');
const resp = await processOcrAI({
document_url: isImage ? '' : last.url,
image_url: isImage ? last.url : '',
model: 'mistral-ocr-latest',
});
setOcrText(resp.text || '');
if ((resp.text || '').trim()) {
handleInsertText(resp.text || '');
toast({
title: 'Text z dokumentu byl vložen do AI promptu',
status: 'success',
duration: 3000,
isClosable: true,
});
} else {
toast({
title: 'OCR nenašlo čitelný text',
status: 'info',
duration: 3000,
isClosable: true,
});
}
} catch (err: any) {
toast({
title: 'OCR selhalo',
description: err?.message || 'Zkuste to prosím znovu.',
status: 'error',
duration: 4000,
isClosable: true,
});
} finally {
setIsOcrProcessing(false);
}
};
const stopMediaStream = () => {
if (mediaStreamRef.current) {
mediaStreamRef.current.getTracks().forEach((t) => t.stop());
mediaStreamRef.current = null;
}
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (recordingTimerRef.current !== null) {
window.clearInterval(recordingTimerRef.current);
recordingTimerRef.current = null;
}
if (audioContextRef.current) {
try {
audioContextRef.current.close();
} catch {
// ignore
}
audioContextRef.current = null;
analyserRef.current = null;
}
};
const startRecording = async () => {
if (isRecording) return;
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
toast({
title: 'Prohlížeč nepodporuje nahrávání zvuku',
status: 'error',
duration: 4000,
isClosable: true,
});
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaStreamRef.current = stream;
audioChunksRef.current = [];
setRecordingSeconds(0);
try {
const AudioCtx =
typeof window !== 'undefined'
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
((window as any).AudioContext || (window as any).webkitAudioContext)
: null;
if (AudioCtx) {
const ctx: AudioContext = audioContextRef.current || new AudioCtx();
audioContextRef.current = ctx;
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
analyserRef.current = analyser;
source.connect(analyser);
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const tick = () => {
if (!analyserRef.current) return;
analyserRef.current.getByteTimeDomainData(dataArray);
let sum = 0;
for (let i = 0; i < bufferLength; i += 1) {
const v = (dataArray[i] - 128) / 128;
sum += v * v;
}
const rms = Math.sqrt(sum / bufferLength);
const level = Math.max(0, Math.min(1, rms * 4));
setWaveform((prev) =>
prev.map((_, idx) => {
const jitter = 0.2 * Math.sin(Date.now() / 120 + idx);
const value = level + jitter;
return Math.max(0.1, Math.min(1, value));
}),
);
animationFrameRef.current = requestAnimationFrame(tick);
};
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current);
}
animationFrameRef.current = requestAnimationFrame(tick);
}
} catch {
// Vizualizace je best-effort, případné chyby ignorujeme
}
const recorder = new MediaRecorder(stream);
mediaRecorderRef.current = recorder;
recorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) {
audioChunksRef.current.push(e.data);
}
};
recorder.onstop = () => {
stopMediaStream();
setIsRecording(false);
setHasRecording(audioChunksRef.current.length > 0);
};
recorder.start();
setIsRecording(true);
setHasRecording(false);
if (recordingTimerRef.current !== null) {
window.clearInterval(recordingTimerRef.current);
}
recordingTimerRef.current = window.setInterval(() => {
setRecordingSeconds((prev) => prev + 1);
}, 1000);
} catch (err: any) {
toast({
title: 'Nelze získat přístup k mikrofonu',
description: err?.message || 'Zkontrolujte oprávnění prohlížeče.',
status: 'error',
duration: 4000,
isClosable: true,
});
}
};
const stopRecording = () => {
if (!isRecording) return;
try {
mediaRecorderRef.current?.stop();
} catch {
stopMediaStream();
setIsRecording(false);
}
};
const handleTranscribe = async () => {
if (!audioChunksRef.current.length) {
toast({
title: 'Nejprve nahrajte hlasovou zprávu',
status: 'info',
duration: 3000,
isClosable: true,
});
return;
}
const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
const file = new File([blob], 'voice-message.webm', { type: 'audio/webm' });
setIsVoiceProcessing(true);
try {
const uploaded = await uploadArticleFile(file as any);
const resp = await transcribeAudioAI({
file_url: uploaded.url,
model: 'voxtral-mini-latest',
language: 'cs',
});
if ((resp.text || '').trim()) {
handleInsertText(resp.text || '');
toast({
title: 'Přepis hlasu vložen do AI promptu',
status: 'success',
duration: 3000,
isClosable: true,
});
onVoiceClose();
} else {
toast({
title: 'Přepis neobsahuje text',
status: 'info',
duration: 3000,
isClosable: true,
});
}
} catch (err: any) {
toast({
title: 'Přepis hlasu selhal',
description: err?.message || 'Zkuste to prosím znovu.',
status: 'error',
duration: 4000,
isClosable: true,
});
} finally {
setIsVoiceProcessing(false);
}
};
return (
<Box w="100%" py={2}>
<Box
bg={bg}
borderRadius="2xl"
p={3}
borderWidth="1px"
borderColor={border}
boxShadow={useColorModeValue('sm', 'dark-lg')}
_hover={{ borderColor: useColorModeValue('gray.300', 'gray.500') }}
transition="all 0.2s ease"
>
<Box position="relative">
<Flex direction="column">
<Box overflowY="auto" maxH="360px">
<Textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder || 'Napište, s čím má AI pomoci...'}
resize="none"
border="none"
bg={innerBg}
borderRadius="xl"
px={4}
py={3}
minH={`${minHeight}px`}
_focusVisible={{ boxShadow: 'none', bg: useColorModeValue('white', 'gray.700') }}
_placeholder={{ color: placeholderColor }}
fontSize="sm"
/>
</Box>
<Box
h="60px"
bg={innerBg}
borderBottomRadius="xl"
borderTop="1px solid"
borderColor={border}
mt={-1}
_hover={{ bg: hoverBg }}
transition="all 0.2s ease"
>
<Flex
position="absolute"
left={3}
right={3}
bottom={2}
align="center"
justify="space-between"
>
<HStack spacing={2} align="center">
<Menu>
<MenuButton
as={IconButton}
size="sm"
variant="ghost"
colorScheme="gray"
aria-label="Vybrat AI model"
px={2}
_hover={{ bg: hoverBg }}
>
<AnimatePresence mode="wait">
<MotionBox
key={selectedModel}
display="flex"
alignItems="center"
gap={2}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.15 }}
>
<Box bg="blue.50" p={1} borderRadius="md">
{getModelIcon(selected, 14)}
</Box>
<Text fontSize="xs" fontWeight="600" color="gray.700">
{selected?.label || 'Model'}
</Text>
<FiChevronDown size={12} color="gray.500" />
</MotionBox>
</AnimatePresence>
</MenuButton>
<MenuList fontSize="sm" zIndex={1500}>
{models.map((model) => {
const rem = getRemainingToday(model.id as AITextModelId);
return (
<MenuItem
key={model.id}
onClick={() => handleModelChange(model.id)}
>
<HStack justify="space-between" w="100%" spacing={3}>
<Box>
<HStack spacing={2} align="center">
<Box bg="gray.50" p={1} borderRadius="md">
{getModelIcon(model, 16)}
</Box>
<Text fontWeight="600" color="gray.800">{model.label}</Text>
</HStack>
{model.description && (
<Text fontSize="xs" color="gray.600" mt={1}>
{model.description}
</Text>
)}
{isReasoningModel(model.id) && (
<Text fontSize="xs" color="blue.600" mt={1} fontWeight="medium">
Reasoning model - přemýšlí před odpovědí
</Text>
)}
</Box>
<HStack spacing={2} align="center">
{isLoadingUsage ? (
<Spinner size="xs" color="gray.500" />
) : (
<Text fontSize="xs" color="gray.600" fontWeight="500">
{model.provider === 'deepseek' ? '∞' : `${Math.max(0, rem)}/${getDailyLimit(model.id)}`}
</Text>
)}
{selectedModel === model.id && (
<Box bg="green.100" p={1} borderRadius="full">
<FiCheck size={12} color="green.600" />
</Box>
)}
</HStack>
</HStack>
</MenuItem>
);
})}
</MenuList>
</Menu>
<Box h="4" w="1px" bg={border} mx={1} />
{/* Reasoning model warning */}
{isReasoningModel(selectedModel) && (
<Tooltip label="Reasoning modely přemýšlí nad odpovědí - poskytují hlubší analýzu, ale mohou trvat déle">
<Text fontSize="xs" color="blue.600" px={2} py={1} bg="blue.50" borderRadius="md" fontWeight="medium">
Reasoning
</Text>
</Tooltip>
)}
<Tooltip label="Přiložit soubor (obrázek / PDF)">
<IconButton
aria-label="Přiložit soubor"
icon={<FiPaperclip />}
size="sm"
variant="ghost"
colorScheme="gray"
_hover={{ bg: hoverBg }}
onClick={() => {
if (onAttachClick) {
onAttachClick();
} else {
setOcrFiles([]);
setOcrText('');
onAttachOpen();
}
}}
/>
</Tooltip>
<Tooltip label="Nahrát hlas pro přepis">
<IconButton
aria-label="Nahrát hlas"
icon={<FiMic />}
size="sm"
variant="ghost"
colorScheme="gray"
_hover={{ bg: hoverBg }}
onClick={() => {
if (onVoiceClick) {
onVoiceClick();
} else {
audioChunksRef.current = [];
setHasRecording(false);
setIsVoiceProcessing(false);
onVoiceOpen();
}
}}
/>
</Tooltip>
{helperText && (
<Text fontSize="xs" color="gray.500" ml={2} noOfLines={1}>
{helperText}
</Text>
)}
</HStack>
<HStack spacing={3} align="center">
{isLoadingUsage ? (
<Spinner size="xs" color="gray.500" />
) : (
<Text fontSize="xs" color="gray.600" fontWeight="500">
{selected?.provider === 'deepseek' ? '∞' : `Zbývá ${getDailyLimit(selectedModel) === Infinity ? '∞' : `${remaining}/${getDailyLimit(selectedModel)}`}`}
</Text>
)}
<Tooltip
label={
remaining <= 0
? 'Denní limit pro tento model byl vyčerpán.'
: 'Odeslat (Enter). Shift+Enter vloží nový řádek.'
}
>
<span>
<IconButton
aria-label="Odeslat AI požadavek"
icon={<FiSend />}
size="sm"
variant="ghost"
colorScheme="blue"
_hover={{ bg: 'blue.50' }}
isLoading={isSubmitting && !isLoadingUsage}
isDisabled={isDisabled}
onClick={handleSubmit}
/>
</span>
</Tooltip>
</HStack>
</Flex>
</Box>
</Flex>
</Box>
</Box>
<Modal isOpen={isAttachOpen} onClose={onAttachClose} isCentered size="lg">
<ModalOverlay />
<ModalContent>
<ModalHeader>Přiložit soubor pro OCR</ModalHeader>
<ModalCloseButton />
<ModalBody>
<UploadPanel
label="Soubor pro AI (PDF / obrázek)"
description="Nahrajte dokument nebo obrázek, ze kterého má AI přečíst text."
value={ocrFiles}
onChange={setOcrFiles}
accept="application/pdf,image/*"
maxFiles={1}
onUploadFinished={handleOcrUploadFinished}
/>
{isOcrProcessing && (
<HStack mt={4} spacing={2} align="center">
<Spinner size="sm" />
<Text fontSize="sm">Probíhá zpracování textu</Text>
</HStack>
)}
{!!ocrText && !isOcrProcessing && (
<Box mt={4}>
<Text fontSize="sm" fontWeight="semibold" mb={2}>
Rozpoznaný text
</Text>
<Box
borderWidth="1px"
borderRadius="md"
borderColor={border}
bg={innerBg}
p={3}
maxH="200px"
overflowY="auto"
fontSize="sm"
whiteSpace="pre-wrap"
>
{ocrText}
</Box>
</Box>
)}
</ModalBody>
<ModalFooter>
<HStack spacing={3}>
<Button variant="ghost" onClick={onAttachClose}>
Zavřít
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
<Modal isOpen={isVoiceOpen} onClose={() => {
if (isRecording) {
stopRecording();
}
onVoiceClose();
}} isCentered size="md">
<ModalOverlay />
<ModalContent>
<ModalHeader>Hlasový vstup pro AI</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Text fontSize="sm" mb={3}>
Nahrajte krátkou hlasovou zprávu, kterou AI přepíše do textu a vloží do promptu.
</Text>
<HStack spacing={4} align="center">
<Button
onClick={isRecording ? stopRecording : startRecording}
isDisabled={isVoiceProcessing}
bg={isRecording ? 'red.500' : 'blackAlpha.700'}
color="white"
_hover={{
bg: isRecording ? 'red.600' : 'blackAlpha.800',
}}
_active={{
bg: isRecording ? 'red.700' : 'blackAlpha.900',
}}
>
{isRecording ? 'Zastavit nahrávání' : 'Začít nahrávat'}
</Button>
{hasRecording && !isRecording && (
<Text fontSize="sm" color="green.500">
Nahrávka je připravena k přepisu.
</Text>
)}
</HStack>
{isRecording && (
<Box mt={4}>
<HStack justify="space-between" mb={1}>
<Text fontSize="xs" color="gray.500">
Nahrávání
</Text>
<Text fontSize="xs" fontFamily="mono" color="gray.600">
{`${String(Math.floor(recordingSeconds / 60)).padStart(2, '0')}:${String(
recordingSeconds % 60,
).padStart(2, '0')}`}
</Text>
</HStack>
<HStack spacing={1} align="flex-end" h="40px">
{waveform.map((value, idx) => (
<Box
key={idx}
w="2px"
borderRadius="full"
bg="green.400"
h={`${Math.max(8, value * 100)}%`}
/>
))}
</HStack>
</Box>
)}
{isVoiceProcessing && (
<HStack mt={4} spacing={2} align="center">
<Spinner size="sm" />
<Text fontSize="sm">Probíhá přepis hlasu</Text>
</HStack>
)}
</ModalBody>
<ModalFooter>
<HStack spacing={3}>
<Button
onClick={handleTranscribe}
isDisabled={!hasRecording || isRecording || isVoiceProcessing}
isLoading={isVoiceProcessing}
bg="blackAlpha.800"
color="white"
_hover={{ bg: 'blackAlpha.900' }}
_active={{ bg: 'blackAlpha.900' }}
>
Přepsat a vložit do promptu
</Button>
<Button
variant="ghost"
onClick={() => {
if (isRecording) {
stopRecording();
}
onVoiceClose();
}}
>
Zavřít
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
);
};
export default AIPromptInput;
@@ -0,0 +1,585 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Button,
HStack,
IconButton,
Input,
SimpleGrid,
Text,
Tooltip,
useColorModeValue,
VStack,
Slider,
SliderTrack,
SliderFilledTrack,
SliderThumb,
} from '@chakra-ui/react';
import { BsEyedropper } from 'react-icons/bs';
type Hsva = { h: number; s: number; v: number; a: number };
type ColorPickerProps = {
value: string;
onChange: (hex: string) => void;
onChangeComplete?: (hex: string) => void;
label?: React.ReactNode;
showAlpha?: boolean;
showEyeDropper?: boolean;
recentStorageKey?: string;
onInteractionStart?: () => void;
onInteractionEnd?: () => void;
compact?: boolean;
hideHistory?: boolean;
};
type ColorHistory = {
manual: string[];
recent: string[];
};
const DEFAULT_HISTORY: ColorHistory = { manual: [], recent: [] };
const GLOBAL_KEY = 'myclub-colorpicker-v1';
function clamp01(x: number) {
return Math.min(1, Math.max(0, x));
}
function parseHex(input: string): { r: number; g: number; b: number; a: number } | null {
if (!input) return null;
let h = input.trim();
if (!h.startsWith('#')) h = `#${h}`;
h = h.slice(1);
if (![3, 4, 6, 8].includes(h.length)) return null;
if (h.length === 3 || h.length === 4) {
h = h
.split('')
.map((c) => c + c)
.join('');
}
const hasAlpha = h.length === 8;
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
const a = hasAlpha ? parseInt(h.slice(6, 8), 16) / 255 : 1;
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b) || Number.isNaN(a)) return null;
return { r, g, b, a };
}
function rgbaToHex(r: number, g: number, b: number, a: number) {
const toHex = (n: number) => Math.round(Math.min(255, Math.max(0, n))).toString(16).padStart(2, '0');
const hr = toHex(r);
const hg = toHex(g);
const hb = toHex(b);
const ha = toHex(a * 255);
if (a >= 0.999) return `#${hr}${hg}${hb}`;
return `#${hr}${hg}${hb}${ha}`;
}
function hexToHsva(hex: string): Hsva {
const parsed = parseHex(hex) || { r: 255, g: 0, b: 0, a: 1 };
const r = parsed.r / 255;
const g = parsed.g / 255;
const b = parsed.b / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const d = max - min;
let h = 0;
const v = max;
const s = max === 0 ? 0 : d / max;
if (d !== 0) {
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
break;
case g:
h = ((b - r) / d + 2) * 60;
break;
case b:
default:
h = ((r - g) / d + 4) * 60;
break;
}
}
if (!Number.isFinite(h)) h = 0;
return { h, s, v, a: parsed.a };
}
function hsvaToHex(c: Hsva): string {
const h = ((c.h % 360) + 360) % 360;
const s = clamp01(c.s);
const v = clamp01(c.v);
const a = clamp01(c.a);
const C = v * s;
const X = C * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - C;
let r = 0;
let g = 0;
let b = 0;
if (h < 60) {
r = C;
g = X;
b = 0;
} else if (h < 120) {
r = X;
g = C;
b = 0;
} else if (h < 180) {
r = 0;
g = C;
b = X;
} else if (h < 240) {
r = 0;
g = X;
b = C;
} else if (h < 300) {
r = X;
g = 0;
b = C;
} else {
r = C;
g = 0;
b = X;
}
const rr = (r + m) * 255;
const gg = (g + m) * 255;
const bb = (b + m) * 255;
return rgbaToHex(rr, gg, bb, a);
}
function loadHistory(key?: string): ColorHistory {
if (typeof window === 'undefined') return DEFAULT_HISTORY;
try {
const raw = window.localStorage.getItem(key || GLOBAL_KEY);
if (!raw) return DEFAULT_HISTORY;
const parsed = JSON.parse(raw) as ColorHistory;
if (!parsed || typeof parsed !== 'object') return DEFAULT_HISTORY;
return {
manual: Array.isArray(parsed.manual) ? parsed.manual : [],
recent: Array.isArray(parsed.recent) ? parsed.recent : [],
};
} catch {
return DEFAULT_HISTORY;
}
}
function saveHistory(history: ColorHistory, key?: string) {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(key || GLOBAL_KEY, JSON.stringify(history));
} catch {
}
}
function pushRecentColor(history: ColorHistory, hex: string, limit = 10): ColorHistory {
const color = (hex || '').toLowerCase();
if (!color) return history;
const recent = [color, ...history.recent.filter((c) => c !== color)];
return { ...history, recent: recent.slice(0, limit) };
}
function pushManualColor(history: ColorHistory, hex: string): ColorHistory {
const color = (hex || '').toLowerCase();
if (!color) return history;
if (history.manual.includes(color)) return history;
return { ...history, manual: [...history.manual, color] };
}
export const ColorPicker: React.FC<ColorPickerProps> = ({
value,
onChange,
onChangeComplete,
label,
showAlpha = false,
showEyeDropper = true,
recentStorageKey,
onInteractionStart,
onInteractionEnd,
compact = false,
hideHistory = false,
}) => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textColor = useColorModeValue('gray.700', 'gray.100');
const subtleText = useColorModeValue('gray.500', 'gray.400');
const [hsva, setHsva] = useState<Hsva>(() => hexToHsva(value || '#ff0000'));
const [hex, setHex] = useState<string>(() => hsvaToHex(hsva));
const [hexInput, setHexInput] = useState<string>(hex.toUpperCase());
const [isEditingHex, setIsEditingHex] = useState(false);
const [history, setHistory] = useState<ColorHistory>(() => loadHistory(recentStorageKey));
const [eyeDropperAvailable, setEyeDropperAvailable] = useState(false);
const svRef = useRef<HTMLDivElement | null>(null);
const latestHsvaRef = useRef<Hsva>(hsva);
useEffect(() => {
if (typeof window !== 'undefined' && (window as any).EyeDropper) {
setEyeDropperAvailable(true);
}
}, []);
useEffect(() => {
const normalized = hsvaToHex(hexToHsva(value || hex));
setHsva(hexToHsva(normalized));
setHex(normalized);
if (!isEditingHex) setHexInput(normalized.toUpperCase());
}, [value]);
useEffect(() => {
latestHsvaRef.current = hsva;
}, [hsva]);
useEffect(() => {
setHistory(loadHistory(recentStorageKey));
}, [recentStorageKey]);
const emitChange = useCallback(
(nextHsva: Hsva, { complete, addToRecent }: { complete?: boolean; addToRecent?: boolean } = {}) => {
const nextHex = hsvaToHex(nextHsva);
setHsva(nextHsva);
setHex(nextHex);
if (!isEditingHex) setHexInput(nextHex.toUpperCase());
onChange(nextHex);
if (addToRecent) {
setHistory((prev) => {
const updated = pushRecentColor(prev, nextHex);
saveHistory(updated, recentStorageKey);
return updated;
});
}
if (complete && onChangeComplete) onChangeComplete(nextHex);
},
[isEditingHex, onChange, onChangeComplete, recentStorageKey],
);
const handleSvPointer = useCallback(
(clientX: number, clientY: number) => {
const rect = svRef.current?.getBoundingClientRect();
if (!rect) return;
const x = clamp01((clientX - rect.left) / rect.width);
const y = clamp01((clientY - rect.top) / rect.height);
const s = x;
const v = 1 - y;
const base = latestHsvaRef.current;
emitChange({ ...base, s, v }, { addToRecent: false });
},
[emitChange],
);
const handleSvPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
onInteractionStart?.();
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
handleSvPointer(e.clientX, e.clientY);
let rafId: number | undefined;
const move = (ev: PointerEvent) => {
// Use requestAnimationFrame for smooth 60fps updates
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
handleSvPointer(ev.clientX, ev.clientY);
});
};
const up = (ev: PointerEvent) => {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = undefined;
}
handleSvPointer(ev.clientX, ev.clientY);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
const latest = latestHsvaRef.current;
emitChange(latest, { complete: true, addToRecent: true });
onInteractionEnd?.();
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
},
[handleSvPointer, emitChange, hsva, onInteractionEnd, onInteractionStart],
);
const handleHueChange = useCallback((next: number) => {
emitChange({ ...hsva, h: next }, { addToRecent: false });
}, [hsva, emitChange]);
const handleHueChangeStart = () => {
onInteractionStart?.();
};
const handleHueChangeEnd = useCallback((next: number) => {
emitChange({ ...hsva, h: next }, { complete: true, addToRecent: true });
onInteractionEnd?.();
}, [hsva, emitChange, onInteractionEnd]);
const handleAlphaChange = useCallback((next: number) => {
emitChange({ ...hsva, a: clamp01(next / 100) }, { addToRecent: false });
}, [hsva, emitChange]);
const handleAlphaChangeEnd = useCallback((next: number) => {
emitChange({ ...hsva, a: clamp01(next / 100) }, { complete: true, addToRecent: true });
}, [hsva, emitChange]);
const handleHexInputBlur = () => {
setIsEditingHex(false);
const parsed = parseHex(hexInput);
if (!parsed) {
setHexInput(hex.toUpperCase());
return;
}
const nextHsva = hexToHsva(hexInput);
emitChange(nextHsva, { complete: true, addToRecent: true });
};
const handleSaveManual = () => {
setHistory((prev) => {
const updated = pushManualColor(prev, hex);
saveHistory(updated, recentStorageKey);
return updated;
});
};
const handlePickFromScreen = async () => {
if (typeof window === 'undefined') return;
if (!eyeDropperAvailable || !(window as any).EyeDropper) return;
try {
onInteractionStart?.();
const EyeDropperCtor = (window as any).EyeDropper;
const eyeDropper = new EyeDropperCtor();
const result = await eyeDropper.open();
const picked = result?.sRGBHex as string | undefined;
if (picked) {
const base = latestHsvaRef.current;
const pickedHsva = hexToHsva(picked);
const nextHsva = { ...pickedHsva, a: base.a };
emitChange(nextHsva, { complete: true, addToRecent: true });
}
} catch {
} finally {
onInteractionEnd?.();
}
};
const svBackground = useMemo(
() => `linear-gradient(to top, black, transparent), linear-gradient(to right, white, hsl(${hsva.h}, 100%, 50%))`,
[hsva.h],
);
const svPointerStyle = useMemo(() => {
const x = hsva.s * 100;
const y = (1 - hsva.v) * 100;
return { left: `${x}%`, top: `${y}%` };
}, [hsva.s, hsva.v]);
const alphaPercent = Math.round(hsva.a * 100);
const currentHex = hex;
return (
<Box
borderWidth="1px"
borderRadius="lg"
p={compact ? 2 : 3}
bg={cardBg}
borderColor={borderColor}
boxShadow="sm"
>
{label ? (
<Text fontSize="sm" fontWeight="medium" mb={2} color={textColor}>
{label}
</Text>
) : null}
<Box
ref={svRef}
position="relative"
borderRadius="lg"
overflow="hidden"
height={compact ? '110px' : '140px'}
cursor="crosshair"
backgroundImage={svBackground}
onPointerDown={handleSvPointerDown}
>
<Box
position="absolute"
width="16px"
height="16px"
borderRadius="full"
borderWidth="2px"
borderColor="white"
boxShadow="0 0 0 1px rgba(0,0,0,0.4)"
transform="translate(-50%, -50%)"
style={svPointerStyle}
/>
</Box>
<Box mt={3}>
<Slider
aria-label="Hue"
min={0}
max={360}
value={hsva.h}
onChange={handleHueChange}
onChangeStart={handleHueChangeStart}
onChangeEnd={handleHueChangeEnd}
size="sm"
>
<SliderTrack
bg="linear-gradient(to right, red, #ff0, #0f0, #0ff, #00f, #f0f, red)"
h="8px"
borderRadius="full"
>
<SliderFilledTrack bg="transparent" />
</SliderTrack>
<SliderThumb boxSize={4} borderWidth="2px" borderColor="white" />
</Slider>
</Box>
{showAlpha && (
<Box mt={3}>
<Slider
aria-label="Opacity"
min={0}
max={100}
value={alphaPercent}
onChange={handleAlphaChange}
onChangeEnd={handleAlphaChangeEnd}
size="sm"
>
<SliderTrack
bg={`linear-gradient(to right, transparent, ${currentHex})`}
h="8px"
borderRadius="full"
>
<SliderFilledTrack bg="transparent" />
</SliderTrack>
<SliderThumb boxSize={4} borderWidth="2px" borderColor="white" />
</Slider>
</Box>
)}
<HStack spacing={2} mt={3} align="center">
<Box
width="32px"
height="32px"
borderRadius="full"
borderWidth="1px"
borderColor={borderColor}
bg={currentHex}
/>
<Input
value={hexInput}
onChange={(e) => {
setIsEditingHex(true);
setHexInput(e.target.value);
}}
onBlur={handleHexInputBlur}
size="sm"
fontFamily="mono"
maxW="130px"
/>
{showAlpha && (
<Input
value={`${alphaPercent}%`}
onChange={(e) => {
const raw = e.target.value.replace(/[^0-9]/g, '');
const n = Math.max(0, Math.min(100, parseInt(raw || '0', 10)));
emitChange({ ...hsva, a: n / 100 }, { addToRecent: false });
}}
onBlur={() => handleAlphaChangeEnd(alphaPercent)}
size="sm"
maxW="80px"
/>
)}
{showEyeDropper && (
<Tooltip
label={
eyeDropperAvailable
? 'Vybrat barvu z obrazovky'
: 'Pipeta není v tomto prohlížeči podporována.'
}
>
<IconButton
aria-label="Vybrat barvu z obrazovky"
icon={<BsEyedropper />}
size="sm"
variant="outline"
onClick={eyeDropperAvailable ? handlePickFromScreen : undefined}
isDisabled={!eyeDropperAvailable}
/>
</Tooltip>
)}
</HStack>
{!hideHistory && (
<VStack align="stretch" spacing={2} mt={3}>
{history.manual.length > 0 && (
<Box>
<Text fontSize="xs" mb={1} color={subtleText}>
Uložené barvy
</Text>
<SimpleGrid columns={8} spacing={1}>
{history.manual.map((c) => (
<Box
key={`manual-${c}`}
as="button"
type="button"
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
emitChange(hexToHsva(c), { complete: true, addToRecent: true });
}}
width="18px"
height="18px"
borderRadius="full"
borderWidth={currentHex.toLowerCase() === c ? '2px' : '1px'}
borderColor={currentHex.toLowerCase() === c ? 'blue.500' : borderColor}
bg={c}
/>
))}
</SimpleGrid>
</Box>
)}
{history.recent.length > 0 && (
<Box>
<Text fontSize="xs" mb={1} color={subtleText}>
Poslední barvy
</Text>
<SimpleGrid columns={8} spacing={1}>
{history.recent.map((c) => (
<Box
key={`recent-${c}`}
as="button"
type="button"
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
emitChange(hexToHsva(c), { complete: true, addToRecent: true });
}}
width="16px"
height="16px"
borderRadius="full"
borderWidth={currentHex.toLowerCase() === c ? '2px' : '1px'}
borderColor={currentHex.toLowerCase() === c ? 'blue.500' : borderColor}
bg={c}
/>
))}
</SimpleGrid>
</Box>
)}
<Button
mt={1}
variant="ghost"
size="sm"
onClick={handleSaveManual}
>
+ Přidat barvu
</Button>
</VStack>
)}
</Box>
);
};
export default ColorPicker;
@@ -0,0 +1,73 @@
import React, { useEffect, useState } from 'react';
import { Box, HStack, Input, Popover, PopoverTrigger, PopoverContent, PopoverBody, useColorModeValue } from '@chakra-ui/react';
import ColorPicker from './ColorPicker';
export type ColorPickerPopoverProps = {
value: string;
onChange: (hex: string) => void;
recentStorageKey?: string;
};
const ColorPickerPopover: React.FC<ColorPickerPopoverProps> = ({ value, onChange, recentStorageKey }) => {
const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.800');
const [internal, setInternal] = useState<string>(value || '#000000');
useEffect(() => {
setInternal(value || '#000000');
}, [value]);
return (
<Popover
placement="bottom-start"
closeOnBlur
closeOnEsc
autoFocus={false}
returnFocusOnClose={false}
>
<PopoverTrigger>
<HStack
spacing={2}
cursor="pointer"
align="center"
>
<Box
width="32px"
height="32px"
borderRadius="full"
borderWidth="1px"
borderColor={borderColor}
bg={value || '#000000'}
/>
<Input
value={(value || '').toUpperCase()}
isReadOnly
size="sm"
maxW="120px"
fontFamily="mono"
bg={inputBg}
/>
</HStack>
</PopoverTrigger>
<PopoverContent w="auto" maxW="280px" _focus={{ boxShadow: 'none' }}>
<PopoverBody p={3}>
<ColorPicker
value={internal}
onChange={(hex) => {
setInternal(hex);
onChange(hex);
}}
onChangeComplete={(hex) => {
setInternal(hex);
onChange(hex);
}}
recentStorageKey={recentStorageKey}
compact
/>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default ColorPickerPopover;
@@ -21,6 +21,22 @@ import {
ButtonGroup,
IconButton,
Tooltip,
Drawer,
DrawerOverlay,
DrawerContent,
DrawerHeader,
DrawerCloseButton,
DrawerBody,
DrawerFooter,
Textarea,
AlertDialog,
AlertDialogOverlay,
AlertDialogContent,
AlertDialogHeader,
AlertDialogCloseButton,
AlertDialogBody,
AlertDialogFooter,
Checkbox,
} from '@chakra-ui/react';
import ReactQuill from 'react-quill';
import ReactCrop, { Crop } from 'react-image-crop';
@@ -76,6 +92,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const quillRef = useRef<ReactQuill | null>(null);
const toolbarRef = useRef<HTMLDivElement | null>(null);
const onChangeRef = useRef(onChange);
const changeDebounceRef = useRef<number | null>(null);
const lastContentRef = useRef<string>('');
const selectedImageIdRef = useRef<string | null>(null);
const selectImageByIdRef = useRef<(id: string) => void>(() => {});
const toolbarDragRef = useRef<{ active: boolean; startX: number; startY: number; startLeft: number; startTop: number }>({ active: false, startX: 0, startY: 0, startLeft: 0, startTop: 0 });
@@ -91,6 +109,16 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
// Cleanup debounced change handler on unmount
useEffect(() => {
return () => {
if (changeDebounceRef.current !== null) {
window.clearTimeout(changeDebounceRef.current);
changeDebounceRef.current = null;
}
};
}, []);
// Crop modal state
const [cropOpen, setCropOpen] = useState(false);
@@ -108,6 +136,18 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const [linkUrl, setLinkUrl] = useState('');
const linkRangeRef = useRef<{ index: number; length: number } | null>(null);
const [isHtmlEditorOpen, setIsHtmlEditorOpen] = useState(false);
const [htmlSource, setHtmlSource] = useState('');
const htmlRangeRef = useRef<{ index: number; length: number } | null>(null);
const htmlSwitchingRef = useRef(false);
const [MonacoComponent, setMonacoComponent] = useState<React.ComponentType<any> | null>(null);
const [htmlEditMode, setHtmlEditMode] = useState<'block' | 'document'>('block');
const [isFullHtmlConfirmOpen, setIsFullHtmlConfirmOpen] = useState(false);
const confirmCancelRef = useRef<HTMLButtonElement | null>(null);
const [confirmDontAsk, setConfirmDontAsk] = useState(false);
const pendingSanitizedRef = useRef<string>('');
const pendingRawRef = useRef<string>('');
// Force white mode for better readability in admin
const borderColor = 'gray.200';
const bgColor = 'white';
@@ -201,6 +241,32 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
}
}, []);
const handleChange = useCallback(
(content: string, _delta: any, source: string) => {
if (readOnly) return;
if (source !== 'user') return;
const cleaned = cleanEditorHTML(content || '');
if (cleaned === lastContentRef.current) return;
lastContentRef.current = cleaned;
if (changeDebounceRef.current !== null) {
window.clearTimeout(changeDebounceRef.current);
}
changeDebounceRef.current = window.setTimeout(() => {
changeDebounceRef.current = null;
try {
onChangeRef.current(cleaned);
} catch (e) {
console.error('CustomRichEditor onChange error', e);
}
}, 150);
},
[readOnly, cleanEditorHTML]
);
// Image upload handler
const handleImageUpload = useCallback(() => {
const input = document.createElement('input');
@@ -234,6 +300,84 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setIsLinkOpen(true);
}, []);
const openHtmlEditor = useCallback((mode: 'block' | 'document' = 'block') => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const range = quill.getSelection();
setHtmlEditMode(mode);
if (mode === 'document') {
try {
const currentHTML = (quill.root?.innerHTML as string) || '';
setHtmlSource(cleanEditorHTML(currentHTML));
htmlRangeRef.current = { index: 0, length: quill.getLength() };
} catch {
setHtmlSource('');
htmlRangeRef.current = { index: 0, length: 0 };
}
} else {
htmlRangeRef.current = range ? { index: range.index, length: range.length } : { index: quill.getLength(), length: 0 };
setHtmlSource('');
}
setIsHtmlEditorOpen(true);
}, [cleanEditorHTML]);
const handleInsertHtmlBlock = useCallback(() => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const html = htmlSource.trim();
if (!html) {
toast({ title: 'Prázdný HTML kód', description: 'Zadejte HTML, které chcete vložit.', status: 'warning', duration: 2000 });
return;
}
const sanitized = DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
ADD_TAGS: ['iframe'],
ADD_ATTR: ['class', 'target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id', 'data-bullets', 'data-list'],
});
if (htmlEditMode === 'document') {
const skip = typeof window !== 'undefined' && window.localStorage.getItem('rte_skip_full_html_confirm') === '1';
if (!skip) {
pendingSanitizedRef.current = sanitized;
pendingRawRef.current = html;
setConfirmDontAsk(false);
setIsFullHtmlConfirmOpen(true);
return;
}
const total = quill.getLength();
quill.focus();
try { quill.deleteText(0, total, 'api'); } catch {}
try {
quill.clipboard.dangerouslyPasteHTML(0, sanitized, 'user');
} catch {
const fallback = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
quill.insertText(0, fallback, 'user');
}
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
setIsHtmlEditorOpen(false);
setHtmlSource('');
toast({ title: 'Obsah aktualizován', status: 'success', duration: 2000 });
return;
}
const range = htmlRangeRef.current || quill.getSelection() || { index: quill.getLength(), length: 0 };
const index = range.index;
quill.focus();
try {
quill.clipboard.dangerouslyPasteHTML(index, sanitized, 'user');
} catch {
const fallback = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
quill.insertText(index, fallback, 'user');
}
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
setIsHtmlEditorOpen(false);
setHtmlSource('');
toast({ title: 'HTML vloženo', status: 'success', duration: 2000 });
}, [htmlSource, toast, cleanEditorHTML, htmlEditMode]);
const quillModules = useMemo(() => ({
toolbar: {
container: toolbarConfig,
@@ -247,6 +391,54 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
},
}), [toolbarConfig, onImageUpload, handleImageUpload, handleLinkToolbar]);
useEffect(() => {
if (!isMounted || readOnly) return;
const quill = quillRef.current?.getEditor();
if (!quill) return;
const onTextChange = (delta: any, _old: any, source: any) => {
if (source !== 'user' || htmlSwitchingRef.current || isHtmlEditorOpen) return;
let inserted = '';
if (Array.isArray(delta?.ops)) {
delta.ops.forEach((op: any) => {
if (typeof op.insert === 'string') inserted += op.insert;
});
}
if (!inserted || !(/[<>]/.test(inserted))) return;
const range = quill.getSelection();
if (!range) return;
const ctxStart = Math.max(0, range.index - 200);
const ctx = quill.getText(ctxStart, range.index - ctxStart);
const relStart = ctx.lastIndexOf('<');
if (relStart < 0) return;
let snippet = ctx.slice(relStart);
snippet = (snippet || '').trim();
if (!/^<\/?[A-Za-z]/.test(snippet)) return;
const absStart = ctxStart + relStart;
const len = range.index - absStart;
if (len <= 0 || len > 200) return;
htmlSwitchingRef.current = true;
try { quill.deleteText(absStart, len, 'api'); } catch {}
try { htmlRangeRef.current = { index: absStart, length: 0 }; } catch {}
setIsHtmlEditorOpen(true);
setHtmlSource(snippet);
setTimeout(() => { htmlSwitchingRef.current = false; }, 120);
};
quill.on('text-change', onTextChange);
return () => {
try { quill.off('text-change', onTextChange); } catch {}
};
}, [isMounted, readOnly, isHtmlEditorOpen]);
useEffect(() => {
let mounted = true;
if (isHtmlEditorOpen && !MonacoComponent) {
import('@monaco-editor/react')
.then((m) => { if (mounted) setMonacoComponent(() => (m as any).default || (m as any)); })
.catch(() => {});
}
return () => { mounted = false; };
}, [isHtmlEditorOpen, MonacoComponent]);
const quillFormats = useMemo(
() => [
'header',
@@ -1277,6 +1469,37 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
}
}, [selectedImageElement, imageFilters, toast]);
const deleteSelectedImage = useCallback(() => {
if (!selectedImageElement) return;
const editor = quillRef.current?.getEditor();
if (!editor) return;
try {
// Remove the image element from the DOM
selectedImageElement.remove();
} catch {}
try {
// Remove resize overlay container if present
const editorContainer = editor.root.parentElement as HTMLElement | null;
const resizeContainer = editorContainer?.querySelector('.custom-image-resize-container') as HTMLElement | null;
if (resizeContainer && resizeContainer.parentNode) {
resizeContainer.parentNode.removeChild(resizeContainer);
}
} catch {}
selectedImageIdRef.current = null;
setSelectedImageElement(null);
setShowImageToolbar(false);
setImageWidth(0);
setManualWidth('');
setWidthPercent(0);
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
toast({ title: 'Obrázek odstraněn', status: 'info', duration: 1500 });
}, [selectedImageElement, toast, cleanEditorHTML]);
// Align image
const alignImage = useCallback((alignment: 'left' | 'center' | 'right') => {
if (selectedImageElement) {
@@ -1304,7 +1527,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
toast({ title: `Obrázek zarovnán ${alignment === 'left' ? 'vlevo' : alignment === 'center' ? 'na střed' : 'vpravo'}`, status: 'success', duration: 1500 });
}
}, [selectedImageElement, toast]);
// Reselect helper after content updates (e.g., when value change triggers rerender)
const reselectAfterContentUpdate = useCallback(() => {
const id = selectedImageIdRef.current;
@@ -1385,33 +1607,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
}
}, [selectedImageElement, manualWidth, toast, applyWidthPx]);
// Delete selected image
const deleteSelectedImage = useCallback(() => {
if (selectedImageElement) {
selectedImageElement.remove();
setSelectedImageElement(null);
setShowImageToolbar(false);
const editor = quillRef.current?.getEditor();
if (editor) {
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
}
toast({ title: 'Obrázek odstraněn', status: 'info', duration: 1500 });
}
}, [selectedImageElement, toast]);
// Sanitize HTML on change and keep author-selected colors intact
const handleChange = (content: string) => {
// First sanitize
let cleaned = DOMPurify.sanitize(content, {
USE_PROFILES: { html: true },
ADD_TAGS: ['iframe'],
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id', 'data-bullets', 'data-list'],
});
onChangeRef.current(cleanEditorHTML(cleaned));
};
// Apply bullet style (disc | circle | square) to the current list
const applyBulletStyle = useCallback((style: 'disc' | 'circle' | 'square') => {
// Apply bullet style (disc | circle | square | none) to the current list
const applyBulletStyle = useCallback((style: 'disc' | 'circle' | 'square' | 'none') => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const range = quill.getSelection();
@@ -1426,11 +1623,45 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
}
if (el && el.tagName === 'UL') {
(el as HTMLElement).setAttribute('data-bullets', style);
try {
// Also set inline style for public rendering where Quill CSS is not present
(el as HTMLElement).style.listStyleType = style === 'none' ? 'none' : style;
} catch {}
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
}
}, [onChangeRef]);
}, [onChangeRef, cleanEditorHTML]);
// Enhance toolbar: add bullet-style popover and color reset buttons
const insertOrUpdateLink = useCallback(() => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const range = linkRangeRef.current || quill.getSelection();
if (!range) return;
const text = linkText?.trim() || linkUrl?.trim();
const url = linkUrl?.trim();
if (!url) {
toast({ title: 'Zadejte URL', status: 'warning', duration: 1500 });
return;
}
quill.focus();
if (range.length > 0) {
// Replace selected text with provided text and link
quill.deleteText(range.index, range.length, 'user');
quill.insertText(range.index, text || url, 'link', url, 'user');
quill.setSelection(range.index + (text || url).length, 0, 'user');
} else {
quill.insertText(range.index, text || url, 'link', url, 'user');
quill.setSelection(range.index + (text || url).length, 0, 'user');
}
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
setIsLinkOpen(false);
setLinkText('');
setLinkUrl('');
}, [linkText, linkUrl, toast, cleanEditorHTML]);
// Enhance toolbar: add color/background reset buttons next to pickers
useEffect(() => {
if (!isMounted) return;
const editor = quillRef.current?.getEditor();
@@ -1458,128 +1689,42 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
(picker.parentElement as HTMLElement)?.insertBefore(btn, picker.nextSibling);
}
};
addResetButton('.ql-color .ql-picker', 'ql-colorreset', 'color');
addResetButton('.ql-background .ql-picker', 'ql-bgreset', 'background');
// Create bullet styles popover and attach to bullet list button
const bulletBtn = toolbarEl.querySelector('button.ql-list[value="bullet"]') as HTMLButtonElement | null;
if (!bulletBtn) return;
let popover = toolbarEl.querySelector('.bullet-style-popover') as HTMLDivElement | null;
if (!popover) {
popover = document.createElement('div');
popover.className = 'bullet-style-popover';
popover.style.cssText = 'position:absolute;display:none;background:#fff;border:1px solid rgba(0,0,0,0.15);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.15);padding:6px;gap:6px;z-index:1000;';
const mk = (label: string, st: 'disc'|'circle'|'square') => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'ql-bulletstyle';
b.textContent = label;
b.style.cssText = 'min-width:32px;height:28px;padding:0 8px;border-radius:6px;border:1px solid #e2e8f0;background:#fff;cursor:pointer;';
b.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const q = quillRef.current?.getEditor();
if (!q) return;
const range = q.getSelection(true);
if (range) {
q.format('list', 'bullet', 'user');
applyBulletStyle(st);
}
if (popover) popover.style.display = 'none';
});
b.addEventListener('mouseenter', () => { b.style.background = '#f7fafc'; });
b.addEventListener('mouseleave', () => { b.style.background = '#fff'; });
return b;
};
popover.appendChild(mk('•', 'disc'));
popover.appendChild(mk('○', 'circle'));
popover.appendChild(mk('▪', 'square'));
toolbarEl.appendChild(popover);
}
let hideTimer: number | null = null;
const show = () => {
if (!popover) return;
const rect = bulletBtn.getBoundingClientRect();
const tRect = toolbarEl.getBoundingClientRect();
popover.style.left = `${rect.left - tRect.left}px`;
popover.style.top = `${rect.bottom - tRect.top + 6}px`;
popover.style.display = 'flex';
};
const toggle = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!popover) return;
if (popover.style.display === 'flex') {
popover.style.display = 'none';
} else {
show();
}
};
const scheduleHide = () => {
if (hideTimer) window.clearTimeout(hideTimer);
hideTimer = window.setTimeout(() => { if (popover) popover.style.display = 'none'; }, 200);
};
const cancelHide = () => { if (hideTimer) { window.clearTimeout(hideTimer); hideTimer = null; } };
bulletBtn.addEventListener('mouseenter', show);
bulletBtn.addEventListener('click', toggle);
bulletBtn.addEventListener('mouseleave', scheduleHide);
popover.addEventListener('mouseenter', cancelHide);
popover.addEventListener('mouseleave', scheduleHide);
return () => {
bulletBtn.removeEventListener('mouseenter', show);
bulletBtn.removeEventListener('click', toggle);
bulletBtn.removeEventListener('mouseleave', scheduleHide);
popover && popover.removeEventListener('mouseenter', cancelHide);
popover && popover.removeEventListener('mouseleave', scheduleHide);
};
}, [isMounted, applyBulletStyle]);
const insertOrUpdateLink = useCallback(() => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const range = linkRangeRef.current || quill.getSelection() || { index: quill.getLength(), length: 0 };
const text = linkText?.trim() || linkUrl?.trim();
const url = linkUrl?.trim();
if (!url) {
toast({ title: 'Zadejte URL', status: 'warning', duration: 1500 });
return;
}
quill.focus();
if (range.length > 0) {
// Replace selected text with provided text and link
quill.deleteText(range.index, range.length, 'user');
quill.insertText(range.index, text || url, 'link', url, 'user');
quill.setSelection(range.index + (text || url).length, 0, 'user');
} else {
quill.insertText(range.index, text || url, 'link', url, 'user');
quill.setSelection(range.index + (text || url).length, 0, 'user');
}
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
setIsLinkOpen(false);
setLinkText('');
setLinkUrl('');
}, [linkText, linkUrl, toast]);
addResetButton('.ql-color .ql-picker-label', 'ql-color-reset', 'color');
addResetButton('.ql-background .ql-picker-label', 'ql-background-reset', 'background');
}, [isMounted, cleanEditorHTML]);
return (
<Box>
{/* Editor Controls */}
{!readOnly && (
<VStack align="stretch" spacing={1} mb={2}>
{onImageUpload && (
<HStack spacing={2} justify="flex-start" flexWrap="wrap">
<Button
size="sm"
leftIcon={<ImageIcon size={16} />}
colorScheme="purple"
onClick={handleImageUpload}
>
Vložit obrázek
</Button>
<Text fontSize="xs" color="gray.500">
nebo použijte tlačítko obrázku v nástrojové liště
</Text>
</HStack>
)}
<HStack spacing={2} justify="flex-start" flexWrap="wrap">
{onImageUpload && (
<>
<Button
size="sm"
leftIcon={<ImageIcon size={16} />}
colorScheme="purple"
onClick={handleImageUpload}
>
Vložit obrázek
</Button>
<Text fontSize="xs" color="gray.500">
nebo použijte tlačítko obrázku v nástrojové liště
</Text>
</>
)}
<Button
size="sm"
leftIcon={<Code size={16} />}
colorScheme="blue"
variant="outline"
onClick={() => openHtmlEditor('document')}
>
Otevřít v HTML
</Button>
</HStack>
</VStack>
)}
@@ -2153,6 +2298,97 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
</ModalContent>
</Modal>
<Drawer isOpen={isHtmlEditorOpen} placement="right" size="lg" onClose={() => setIsHtmlEditorOpen(false)}>
<DrawerOverlay />
<DrawerContent>
<DrawerCloseButton />
<DrawerHeader>{htmlEditMode === 'document' ? 'Upravit HTML (celý obsah)' : 'Vložit vlastní HTML'}</DrawerHeader>
<DrawerBody>
<VStack align="stretch" spacing={4}>
<FormControl>
<FormLabel>HTML kód</FormLabel>
{MonacoComponent ? (
<MonacoComponent
height="60vh"
defaultLanguage="html"
language="html"
theme="vs-light"
value={htmlSource}
onChange={(v: string | undefined) => setHtmlSource(v || '')}
options={{
automaticLayout: true,
wordWrap: 'on',
minimap: { enabled: false },
tabSize: 2,
insertSpaces: true,
formatOnPaste: true,
formatOnType: true,
suggestOnTriggerCharacters: true,
}}
/>
) : (
<Textarea
value={htmlSource}
onChange={(e) => setHtmlSource(e.target.value)}
minH="200px"
fontFamily="monospace"
fontSize="sm"
/>
)}
<FormHelperText>
{htmlEditMode === 'document'
? 'Upravujete celý obsah. Po potvrzení se současný obsah editoru nahradí vaším HTML.'
: 'Vložte libovolný HTML kód (např. <h1>Nadpis</h1>, <p>odstavec</p>, <ul>...</ul>). Po vložení se převede na plně funkční formátování v editoru.'}
</FormHelperText>
</FormControl>
</VStack>
</DrawerBody>
<DrawerFooter>
<Button variant="ghost" mr={3} onClick={() => setIsHtmlEditorOpen(false)}>
Zrušit
</Button>
<Button colorScheme="blue" onClick={handleInsertHtmlBlock}>
{htmlEditMode === 'document' ? 'Použít HTML' : 'Vložit do textu'}
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
<AlertDialog isOpen={isFullHtmlConfirmOpen} leastDestructiveRef={confirmCancelRef as any} onClose={() => setIsFullHtmlConfirmOpen(false)} isCentered>
<AlertDialogOverlay />
<AlertDialogContent>
<AlertDialogHeader>Nahradit celý obsah HTML?</AlertDialogHeader>
<AlertDialogCloseButton />
<AlertDialogBody>
Tato akce nahradí aktuální obsah editoru vaším HTML. Chcete pokračovat?
<Box mt={3}>
<Checkbox isChecked={confirmDontAsk} onChange={(e) => setConfirmDontAsk(e.target.checked)}>
Neptat se příště
</Checkbox>
</Box>
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={confirmCancelRef as any} onClick={() => setIsFullHtmlConfirmOpen(false)} mr={3}>Zrušit</Button>
<Button colorScheme="blue" onClick={() => {
try { if (confirmDontAsk) window.localStorage.setItem('rte_skip_full_html_confirm', '1'); } catch {}
const quill = quillRef.current?.getEditor();
if (!quill) { setIsFullHtmlConfirmOpen(false); return; }
const sanitized = pendingSanitizedRef.current;
const raw = pendingRawRef.current;
const total = quill.getLength();
quill.focus();
try { quill.deleteText(0, total, 'api'); } catch {}
try { quill.clipboard.dangerouslyPasteHTML(0, sanitized, 'user'); } catch { const fb = DOMPurify.sanitize(raw, { USE_PROFILES: { html: true } }); quill.insertText(0, fb, 'user'); }
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
setIsHtmlEditorOpen(false);
setHtmlSource('');
setIsFullHtmlConfirmOpen(false);
toast({ title: 'Obsah aktualizován', status: 'success', duration: 2000 });
}}>Použít HTML</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Crop Modal */}
{/* Image Preview Modal */}
<Modal isOpen={isPreviewOpen} onClose={() => setIsPreviewOpen(false)} size="6xl" isCentered>
@@ -0,0 +1,632 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Box,
Button,
HStack,
VStack,
Text,
SimpleGrid,
Stack,
Divider,
useColorModeValue,
Popover,
PopoverTrigger,
PopoverContent,
PopoverArrow,
PopoverBody,
} from '@chakra-ui/react';
import { ChevronLeftIcon, ChevronRightIcon, CalendarIcon } from '@chakra-ui/icons';
import {
addDays,
addMonths,
endOfMonth,
endOfWeek,
format,
getDaysInMonth,
isSameDay,
isSameMonth,
isWithinInterval,
parseISO,
startOfMonth,
startOfWeek,
subMonths,
setYear,
setMonth,
} from 'date-fns';
import { cs } from 'date-fns/locale';
export type DateRangePickerProps = {
from?: string;
to?: string;
onChange: (from: string, to: string) => void;
size?: 'sm' | 'md';
};
function parseDate(value?: string | null): Date | null {
if (!value) return null;
try {
const d = parseISO(value);
if (!isNaN(d.getTime())) return d;
} catch {
// ignore
}
return null;
}
function toIsoDate(d: Date | null | undefined): string {
if (!d || isNaN(d.getTime())) return '';
return format(d, 'yyyy-MM-dd');
}
const monthNamesCs = [
'Leden',
'Únor',
'Březen',
'Duben',
'Květen',
'Červen',
'Červenec',
'Srpen',
'Září',
'Říjen',
'Listopad',
'Prosinec',
];
const weekdayLabelsCs = (() => {
const start = startOfWeek(new Date(), { weekStartsOn: 1 });
return Array.from({ length: 7 }).map((_, i) =>
format(addDays(start, i), 'EEEEE', { locale: cs }),
);
})();
const buildPresets = () => {
const today = new Date();
const todayIso = toIsoDate(today);
const lastNDays = (n: number) => {
const end = today;
const start = addDays(end, -n + 1);
return { from: toIsoDate(start), to: toIsoDate(end) };
};
const thisWeek = () => {
const monday = startOfWeek(today, { weekStartsOn: 1 });
const sunday = endOfWeek(today, { weekStartsOn: 1 });
return { from: toIsoDate(monday), to: toIsoDate(sunday) };
};
const next30Days = () => {
const start = today;
const end = addDays(start, 30);
return { from: toIsoDate(start), to: toIsoDate(end) };
};
const thisMonth = () => {
const start = startOfMonth(today);
const end = endOfMonth(today);
return { from: toIsoDate(start), to: toIsoDate(end) };
};
const last3Months = () => {
const end = today;
const start = addMonths(startOfMonth(end), -2);
return { from: toIsoDate(start), to: toIsoDate(end) };
};
const last12Months = () => {
const end = today;
const start = addMonths(startOfMonth(end), -11);
return { from: toIsoDate(start), to: toIsoDate(end) };
};
const thisYear = () => {
const year = today.getFullYear();
const start = new Date(year, 0, 1);
const end = new Date(year, 11, 31);
return { from: toIsoDate(start), to: toIsoDate(end) };
};
return [
{ key: 'today', label: 'Dnes', getRange: () => ({ from: todayIso, to: todayIso }) },
{ key: 'last7', label: 'Posledních 7 dní', getRange: () => lastNDays(7) },
{ key: 'last30', label: 'Posledních 30 dní', getRange: () => lastNDays(30) },
{ key: 'thisWeek', label: 'Tento týden', getRange: () => thisWeek() },
{ key: 'next30', label: 'Nadcházejících 30 dní', getRange: () => next30Days() },
{ key: 'thisMonth', label: 'Tento měsíc', getRange: () => thisMonth() },
{ key: 'last3months', label: 'Poslední 3 měsíce', getRange: () => last3Months() },
{ key: 'last12months', label: 'Posledních 12 měsíců', getRange: () => last12Months() },
{ key: 'thisYear', label: 'Tento rok', getRange: () => thisYear() },
];
};
const PRESETS = buildPresets();
export const DateRangePicker: React.FC<DateRangePickerProps> = ({ from, to, onChange, size = 'sm' }) => {
const [viewDate, setViewDate] = useState<Date>(() => parseDate(from) || parseDate(to) || new Date());
const [draftFrom, setDraftFrom] = useState<Date | null>(() => parseDate(from));
const [draftTo, setDraftTo] = useState<Date | null>(() => parseDate(to));
const [hoverDate, setHoverDate] = useState<Date | null>(null);
const [activePanel, setActivePanel] = useState<'calendar' | 'year' | 'month' | 'day'>('calendar');
const [yearPageStart, setYearPageStart] = useState<number>(() => {
const base = (parseDate(from) || parseDate(to) || new Date()).getFullYear();
return base - 6; // show 12 years around current
});
const cardBg = useColorModeValue('white', 'gray.800');
const sideBg = useColorModeValue('gray.50', 'gray.900');
const sideActiveBg = useColorModeValue('blue.50', 'blue.900');
const sideActiveColor = useColorModeValue('blue.700', 'blue.100');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const dayBgSelected = useColorModeValue('blue.500', 'blue.400');
const dayBgInRange = useColorModeValue('blue.50', 'blue.900');
const dayTextSelected = useColorModeValue('white', 'gray.900');
const dayTextSubtle = useColorModeValue('gray.400', 'gray.500');
const triggerBg = useColorModeValue('white', 'gray.800');
const triggerHoverBg = useColorModeValue('gray.50', 'gray.700');
const triggerBorderColor = useColorModeValue('gray.300', 'gray.600');
useEffect(() => {
const start = parseDate(from);
const end = parseDate(to);
setDraftFrom(start);
setDraftTo(end);
const base = start || end || new Date();
setViewDate(base);
setYearPageStart(base.getFullYear() - 6);
}, [from, to]);
const rangeLabel = useMemo(() => {
const start = parseDate(from);
const end = parseDate(to);
if (start && end) {
return `${format(start, 'd.M.yyyy', { locale: cs })} ${format(end, 'd.M.yyyy', { locale: cs })}`;
}
if (start) return `Od ${format(start, 'd.M.yyyy', { locale: cs })}`;
if (end) return `Do ${format(end, 'd.M.yyyy', { locale: cs })}`;
return 'Libovolné období';
}, [from, to]);
const handleDayClick = (day: Date) => {
if (!draftFrom || (draftFrom && draftTo)) {
setDraftFrom(day);
setDraftTo(null);
return;
}
if (draftFrom && !draftTo) {
if (day < draftFrom) {
setDraftTo(draftFrom);
setDraftFrom(day);
} else if (day.getTime() === draftFrom.getTime()) {
// Single day toggle
setDraftTo(day);
} else {
setDraftTo(day);
}
}
};
const currentRange = useMemo(() => {
if (!draftFrom && !draftTo) return null;
if (draftFrom && draftTo) {
return draftFrom <= draftTo
? { start: draftFrom, end: draftTo }
: { start: draftTo, end: draftFrom };
}
if (draftFrom && hoverDate) {
return draftFrom <= hoverDate
? { start: draftFrom, end: hoverDate }
: { start: hoverDate, end: draftFrom };
}
return null;
}, [draftFrom, draftTo, hoverDate]);
const monthStart = startOfMonth(viewDate);
const monthEnd = endOfMonth(viewDate);
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 });
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
const calendarDays: Date[] = [];
for (let d = calendarStart; d <= calendarEnd; d = addDays(d, 1)) {
calendarDays.push(d);
}
const weeks: Date[][] = [];
for (let i = 0; i < calendarDays.length; i += 7) {
weeks.push(calendarDays.slice(i, i + 7));
}
const yearOptions = useMemo(() => {
return Array.from({ length: 12 }).map((_, i) => yearPageStart + i);
}, [yearPageStart]);
const selectedReferenceDate = draftFrom || draftTo || viewDate;
const selectedYear = selectedReferenceDate.getFullYear();
const selectedMonth = selectedReferenceDate.getMonth();
const daysInSelectedMonth = getDaysInMonth(new Date(selectedYear, selectedMonth, 1));
const headerDayLabel = format(selectedReferenceDate, 'd.', { locale: cs });
const applyDraft = () => {
const nextFrom = toIsoDate(draftFrom || null);
const nextTo = toIsoDate(draftTo || draftFrom || null);
onChange(nextFrom, nextTo);
};
const clearDraft = () => {
setDraftFrom(null);
setDraftTo(null);
setHoverDate(null);
};
const renderCalendar = () => (
<VStack align="stretch" spacing={4}>
<SimpleGrid columns={7} spacing={2} fontSize="sm" textTransform="uppercase" color={dayTextSubtle}>
{weekdayLabelsCs.map((d) => (
<Box key={d} textAlign="center">{d}</Box>
))}
</SimpleGrid>
<VStack spacing={2} align="stretch">
{weeks.map((week, wi) => (
<HStack key={wi} spacing={2}>
{week.map((day) => {
const isOutside = !isSameMonth(day, monthStart);
const isSelectedStart = draftFrom && isSameDay(day, draftFrom);
const isSelectedEnd = draftTo && isSameDay(day, draftTo);
const inRange = currentRange ? isWithinInterval(day, currentRange) : false;
let bg: string | undefined;
let color: string | undefined;
let roundedLeft = false;
let roundedRight = false;
if (inRange) {
bg = dayBgInRange;
}
if (isSelectedStart || isSelectedEnd) {
bg = dayBgSelected;
color = dayTextSelected;
roundedLeft = true;
roundedRight = true;
}
const baseColor = isOutside ? dayTextSubtle : undefined;
return (
<Button
key={day.toISOString()}
size={size}
variant={bg ? 'solid' : 'ghost'}
flex="1"
borderRadius={bg ? (roundedLeft && roundedRight ? 'full' : 'md') : 'md'}
bg={bg}
color={color || baseColor}
_hover={{ bg: bg || dayBgInRange }}
onClick={() => handleDayClick(day)}
onMouseEnter={() => setHoverDate(day)}
onMouseLeave={() => setHoverDate(null)}
w="100%"
h="2.5rem"
px={0}
>
{format(day, 'd')}
</Button>
);
})}
</HStack>
))}
</VStack>
</VStack>
);
const renderYearPanel = () => (
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Button
size="xs"
variant="ghost"
leftIcon={<ChevronLeftIcon />}
onClick={() => setYearPageStart((prev) => prev - 12)}
>
Starší
</Button>
<Text fontSize="sm" fontWeight="semibold">
{yearOptions[0]} {yearOptions[yearOptions.length - 1]}
</Text>
<Button
size="xs"
variant="ghost"
rightIcon={<ChevronRightIcon />}
onClick={() => setYearPageStart((prev) => prev + 12)}
>
Novější
</Button>
</HStack>
<SimpleGrid columns={3} spacing={2}>
{yearOptions.map((year) => (
<Button
key={year}
size={size}
variant={year === selectedYear ? 'solid' : 'ghost'}
colorScheme={year === selectedYear ? 'blue' : undefined}
onClick={() => {
const next = setYear(viewDate, year);
setViewDate(next);
setActivePanel('month');
}}
>
{year}
</Button>
))}
</SimpleGrid>
</VStack>
);
const renderMonthPanel = () => (
<SimpleGrid columns={3} spacing={2}>
{monthNamesCs.map((name, idx) => (
<Button
key={name}
size={size}
variant={idx === selectedMonth ? 'solid' : 'ghost'}
colorScheme={idx === selectedMonth ? 'blue' : undefined}
onClick={() => {
const next = setMonth(viewDate, idx);
setViewDate(startOfMonth(next));
setActivePanel('calendar');
}}
>
{`${idx + 1}. ${name}`}
</Button>
))}
</SimpleGrid>
);
const renderDayPanel = () => (
<SimpleGrid columns={7} spacing={2}>
{Array.from({ length: daysInSelectedMonth }).map((_, i) => {
const dayNum = i + 1;
const d = new Date(selectedYear, selectedMonth, dayNum);
const isSelectedStart = draftFrom && isSameDay(d, draftFrom);
const isSelectedEnd = draftTo && isSameDay(d, draftTo);
const inRange = currentRange ? isWithinInterval(d, currentRange) : false;
let bg: string | undefined;
let color: string | undefined;
if (inRange) bg = dayBgInRange;
if (isSelectedStart || isSelectedEnd) {
bg = dayBgSelected;
color = dayTextSelected;
}
return (
<Button
key={dayNum}
size={size}
variant={bg ? 'solid' : 'ghost'}
bg={bg}
color={color}
_hover={{ bg: bg || dayBgInRange }}
onClick={() => handleDayClick(d)}
borderRadius="full"
w="100%"
h="2.5rem"
px={0}
>
{dayNum}
</Button>
);
})}
</SimpleGrid>
);
const headerYear = viewDate.getFullYear();
const headerMonthName = monthNamesCs[viewDate.getMonth()];
return (
<Popover
placement="bottom-start"
onOpen={() => {
const start = parseDate(from);
const end = parseDate(to);
setDraftFrom(start);
setDraftTo(end);
const base = start || end || new Date();
setViewDate(base);
setYearPageStart(base.getFullYear() - 6);
setActivePanel('calendar');
}}
>
<PopoverTrigger>
<Button
size={size}
variant="outline"
leftIcon={<CalendarIcon />}
borderRadius="full"
px={5}
py={3}
minW={{ base: '220px', md: '260px', lg: '300px' }}
bg={triggerBg}
borderColor={triggerBorderColor}
boxShadow="sm"
_hover={{ bg: triggerHoverBg, boxShadow: 'md' }}
overflow="hidden"
>
<HStack spacing={3} align="center" w="100%" justify="space-between">
<Text fontSize="sm" color="gray.600" flexShrink={0}>
Období:
</Text>
<Text
fontSize="sm"
fontWeight="medium"
flex="1"
minW={0}
textAlign="right"
isTruncated
noOfLines={1}
>
{rangeLabel}
</Text>
</HStack>
</Button>
</PopoverTrigger>
<PopoverContent
w={{ base: '100%', sm: '360px', md: '520px' }}
maxW="520px"
borderRadius="xl"
boxShadow="xl"
bg={cardBg}
borderColor={borderColor}
>
<PopoverArrow />
<PopoverBody p={0}>
<Stack
direction={{ base: 'column', md: 'row' }}
spacing={0}
divider={<Divider orientation="vertical" display={{ base: 'none', md: 'block' }} />}
>
<Box
w={{ base: '100%', md: '200px' }}
borderBottomWidth={{ base: '1px', md: 0 }}
bg={sideBg}
p={4}
>
<VStack align="stretch" spacing={2}>
{PRESETS.map((preset) => (
<Button
key={preset.key}
size="sm"
justifyContent="flex-start"
variant="ghost"
borderRadius="md"
px={3}
py={2}
fontSize="sm"
_hover={{ bg: sideActiveBg, color: sideActiveColor }}
onClick={() => {
const r = preset.getRange();
setDraftFrom(parseDate(r.from));
setDraftTo(parseDate(r.to));
}}
>
{preset.label}
</Button>
))}
<Button
size="sm"
variant="ghost"
borderRadius="md"
px={3}
py={2}
fontSize="sm"
onClick={clearDraft}
>
Vymazat
</Button>
</VStack>
</Box>
<Box flex="1" p={4}>
<VStack align="stretch" spacing={4}>
<HStack justify="space-between" align="center">
<HStack spacing={2}>
<Button
size="xs"
borderRadius="full"
variant={activePanel === 'year' ? 'solid' : 'outline'}
colorScheme={activePanel === 'year' ? 'blue' : undefined}
onClick={() => setActivePanel('year')}
>
{headerYear}
</Button>
<Button
size="xs"
borderRadius="full"
variant={activePanel === 'month' ? 'solid' : 'outline'}
colorScheme={activePanel === 'month' ? 'blue' : undefined}
onClick={() => setActivePanel('month')}
>
{headerMonthName}
</Button>
<Button
size="xs"
borderRadius="full"
variant={activePanel === 'day' ? 'solid' : 'outline'}
colorScheme={activePanel === 'day' ? 'blue' : undefined}
onClick={() => setActivePanel('day')}
>
{headerDayLabel}
</Button>
</HStack>
<HStack spacing={1}>
<Button
size="xs"
variant="ghost"
onClick={() => setViewDate((prev) => subMonths(prev, 1))}
>
<ChevronLeftIcon />
</Button>
<Button
size="xs"
variant="ghost"
onClick={() => setViewDate((prev) => addMonths(prev, 1))}
>
<ChevronRightIcon />
</Button>
</HStack>
</HStack>
{activePanel === 'calendar' && renderCalendar()}
{activePanel === 'year' && renderYearPanel()}
{activePanel === 'month' && renderMonthPanel()}
{activePanel === 'day' && renderDayPanel()}
{activePanel !== 'calendar' && (
<Button
size="xs"
alignSelf="flex-start"
variant="link"
onClick={() => setActivePanel('calendar')}
>
Zpět na kalendář
</Button>
)}
<HStack justify="space-between" pt={2}>
<Text fontSize="xs" color="gray.500">
Rozsah:{' '}
{draftFrom && draftTo
? `${format(draftFrom, 'd.M.yyyy', { locale: cs })} ${format(draftTo, 'd.M.yyyy', { locale: cs })}`
: draftFrom
? `Od ${format(draftFrom, 'd.M.yyyy', { locale: cs })}`
: draftTo
? `Do ${format(draftTo, 'd.M.yyyy', { locale: cs })}`
: 'nenastaveno'}
</Text>
<HStack spacing={2}>
<Button
size="sm"
variant="ghost"
onClick={clearDraft}
>
Zrušit
</Button>
<Button
size="sm"
colorScheme="blue"
onClick={applyDraft}
isDisabled={!draftFrom && !draftTo}
>
Použít
</Button>
</HStack>
</HStack>
</VStack>
</Box>
</Stack>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default DateRangePicker;
@@ -0,0 +1,123 @@
import React from 'react';
import {
Box,
Text,
HStack,
Stack,
Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
PopoverArrow,
useColorModeValue,
Portal,
Icon,
} from '@chakra-ui/react';
import { CheckCircleIcon, InfoOutlineIcon } from '@chakra-ui/icons';
export interface HelpTooltipCardProps {
label: string;
title: string;
items?: string[];
showStrengthBars?: boolean;
}
/**
* HelpTooltipCard
*
* Reusable rich tooltip/popover used for contextual help next to form fields.
* Triggered by a small pill with label text; shows a card with colored bars
* and a checklist inside, visually similar to password requirement examples.
*/
export const HelpTooltipCard: React.FC<HelpTooltipCardProps> = ({ label, title, items, showStrengthBars = false }) => {
const bg = useColorModeValue('white', 'gray.800');
const border = useColorModeValue('gray.200', 'gray.700');
const pillBg = useColorModeValue('white', 'gray.700');
const pillBorder = useColorModeValue('gray.200', 'gray.600');
const iconColor = useColorModeValue('gray.600', 'gray.200');
return (
<Popover trigger="hover" openDelay={150} closeDelay={100} placement="right-start">
<PopoverTrigger>
<Box
as="button"
type="button"
borderRadius="full"
bg={pillBg}
borderWidth="1px"
borderColor={pillBorder}
boxShadow="sm"
fontSize="xs"
fontWeight="medium"
_hover={{ boxShadow: 'md', transform: 'translateY(-1px)' }}
transition="all 0.15s ease-out"
display="inline-flex"
alignItems="center"
justifyContent="center"
w="18px"
h="18px"
aria-label={label}
>
<Icon as={InfoOutlineIcon} boxSize={3} color={iconColor} />
</Box>
</PopoverTrigger>
<Portal>
<PopoverContent
maxW="sm"
bg={bg}
borderColor={border}
boxShadow="2xl"
borderRadius="xl"
_focus={{ boxShadow: '2xl' }}
>
<PopoverArrow />
<PopoverBody p={4}>
<Stack spacing={3}>
{showStrengthBars && (
<HStack spacing={1.5}>
<Box flex="1" h="2px" borderRadius="full" bg="red.400" />
<Box flex="1" h="2px" borderRadius="full" bg="orange.400" />
<Box flex="1" h="2px" borderRadius="full" bg="green.400" />
</HStack>
)}
<Text fontSize="sm" fontWeight="semibold">
{title}
</Text>
{items && items.length > 0 && (
<Stack as="ul" spacing={1.5} pl={0} m={0} style={{ listStyle: 'none' }}>
{items.map((item) => (
<HStack as="li" key={item} align="flex-start" spacing={2}>
<Icon as={CheckCircleIcon} color="green.400" boxSize={3.5} mt={0.5} />
<Text fontSize="sm" color="gray.700" _dark={{ color: 'gray.200' }}>
{item}
</Text>
</HStack>
))}
</Stack>
)}
</Stack>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
);
};
export const PasswordHelpTooltip: React.FC<{ label?: string }> = ({ label = 'Požadavky na heslo' }) => {
return (
<HelpTooltipCard
label={label}
title="Silné heslo by mělo obsahovat:"
items={[
'Minimálně 8 znaků (povinné)',
'Kombinaci malých a velkých písmen',
'Čísla a ideálně speciální znaky',
]}
showStrengthBars
/>
);
};
export default HelpTooltipCard;
@@ -0,0 +1,150 @@
import { useTranslation } from 'react-i18next';
import { Button, Menu, MenuButton, MenuList, MenuItem, HStack, Text, Icon } from '@chakra-ui/react';
import { FaGlobe, FaChevronDown } from 'react-icons/fa';
import { useState, useEffect } from 'react';
import { api } from '../../services/api';
interface Language {
id: string;
name: string;
native_name: string;
code: string;
is_default: boolean;
is_active: boolean;
sort_order: number;
}
export const LanguageSwitcher = () => {
const { i18n } = useTranslation();
const [languages, setLanguages] = useState<Language[]>([]);
const [currentLanguage, setCurrentLanguage] = useState<string>('cs');
const [loading, setLoading] = useState(true);
// Fetch available languages from API
useEffect(() => {
const fetchLanguages = async () => {
try {
const response = await api.get('/i18n/languages');
const activeLanguages = response.data.languages.filter((lang: Language) => lang.is_active);
if (activeLanguages.length > 0) {
setLanguages(activeLanguages);
} else {
// Fallback to default languages if API returns empty
setLanguages([
{ id: 'cs', name: 'Czech', native_name: 'Čeština', code: 'cs', is_default: true, is_active: true, sort_order: 1 },
{ id: 'en', name: 'English', native_name: 'English', code: 'en', is_default: false, is_active: true, sort_order: 2 },
]);
}
// Set current language from i18n or default to Czech
const current = i18n.language || 'cs';
setCurrentLanguage(current);
} catch (error) {
// Fallback to default languages on error
setLanguages([
{ id: 'cs', name: 'Czech', native_name: 'Čeština', code: 'cs', is_default: true, is_active: true, sort_order: 1 },
{ id: 'en', name: 'English', native_name: 'English', code: 'en', is_default: false, is_active: true, sort_order: 2 },
]);
const current = i18n.language || 'cs';
setCurrentLanguage(current);
} finally {
setLoading(false);
}
};
fetchLanguages();
}, [i18n.language]);
// Change language
const changeLanguage = async (languageCode: string) => {
try {
// Change language in i18next
await i18n.changeLanguage(languageCode);
// Save preference to backend if user is authenticated
const token = localStorage.getItem('token');
if (token) {
try {
await api.post('/i18n/user-language', { language_code: languageCode });
} catch (error) {
console.warn('Failed to save language preference to backend:', error);
}
}
// Save to cookie
document.cookie = `lang=${languageCode}; max-age=${365 * 24 * 60 * 60}; path=/`;
setCurrentLanguage(languageCode);
// Refresh page to update navigation and all content
window.location.reload();
} catch (error) {
console.error('Failed to change language:', error);
}
};
// Get current language display name
const getCurrentLanguageDisplay = () => {
const lang = languages.find(l => l.code === currentLanguage);
return lang ? lang.native_name : 'Čeština';
};
if (loading || languages.length < 2) {
return null; // Don't show if less than 2 languages or still loading
}
return (
<Menu>
<MenuButton
as={Button}
variant="ghost"
size="sm"
minWidth="auto"
px={2}
py={1}
borderRadius="md"
_hover={{ bg: "gray.100" }}
_active={{ bg: "gray.200" }}
fontSize="sm"
fontWeight="medium"
>
<HStack spacing={1}>
<Icon as={FaGlobe} fontSize="sm" />
<Text fontSize="sm" fontWeight="medium" textTransform="uppercase">
{currentLanguage === 'cs' ? 'CZ' : 'EN'}
</Text>
<Icon as={FaChevronDown} fontSize="xs" />
</HStack>
</MenuButton>
<MenuList minWidth="100px" p={1} boxShadow="lg" border="1px" borderColor="gray.200">
{languages
.sort((a, b) => a.sort_order - b.sort_order)
.map((language) => (
<MenuItem
key={language.code}
onClick={() => changeLanguage(language.code)}
isDisabled={language.code === currentLanguage}
fontSize="sm"
borderRadius="md"
px={2}
py={1}
_hover={{ bg: "gray.100" }}
_selected={{ bg: "blue.50", color: "blue.600" }}
>
<HStack spacing={1} width="100%" justifyContent="space-between">
<Text fontSize="sm" fontWeight="medium" textTransform="uppercase">
{language.code === 'cs' ? 'CZ' : 'EN'}
</Text>
{language.code === currentLanguage && (
<Text fontSize="sm" color="blue.500" fontWeight="bold">
</Text>
)}
</HStack>
</MenuItem>
))}
</MenuList>
</Menu>
);
};
@@ -0,0 +1,463 @@
import React, { useMemo } from 'react';
import {
Box,
Text,
HStack,
VStack,
Icon,
Skeleton,
Badge,
useColorModeValue,
Tooltip,
Progress,
Divider,
SimpleGrid,
} from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import {
FaCloud,
FaCloudSun,
FaSun,
FaCloudRain,
FaSnowflake,
FaWind,
FaTint,
FaEye,
FaThermometerHalf,
FaExclamationTriangle,
FaMoon,
} from 'react-icons/fa';
import api from '../../services/api';
interface MatchWeatherData {
weather: {
location: {
name: string;
region: string;
country: string;
};
current: {
last_updated: string;
temp_c: number;
temp_f: number;
is_day: number;
condition: {
text: string;
icon: string;
code: number;
};
wind_mph: number;
wind_kph: number;
wind_degree: number;
wind_dir: string;
pressure_mb: number;
pressure_in: number;
precip_mm: number;
precip_in: number;
humidity: number;
cloud: number;
feelslike_c: number;
feelslike_f: number;
vis_km: number;
vis_miles: number;
uv: number;
gust_mph: number;
gust_kph: number;
};
forecast: {
forecastday: Array<{
date: string;
date_epoch: number;
day: {
maxtemp_c: number;
maxtemp_f: number;
mintemp_c: number;
mintemp_f: number;
avgtemp_c: number;
avgtemp_f: number;
maxwind_mph: number;
maxwind_kph: number;
totalprecip_mm: number;
totalprecip_in: number;
totalsnow_cm: number;
avgvis_km: number;
avgvis_miles: number;
avghumidity: number;
daily_will_it_rain: number;
daily_chance_of_rain: number;
daily_will_it_snow: number;
daily_chance_of_snow: number;
condition: {
text: string;
icon: string;
code: number;
};
uv: number;
};
astro: {
sunrise: string;
sunset: string;
moonrise: string;
moonset: string;
moon_phase: string;
moon_illumination: number;
is_moon_up: number;
is_sun_up: number;
};
hour: Array<{
time_epoch: number;
time: string;
temp_c: number;
temp_f: number;
is_day: number;
condition: {
text: string;
icon: string;
code: number;
};
wind_mph: number;
wind_kph: number;
wind_degree: number;
wind_dir: string;
pressure_mb: number;
pressure_in: number;
precip_mm: number;
precip_in: number;
humidity: number;
cloud: number;
feelslike_c: number;
feelslike_f: number;
windchill_c: number;
windchill_f: number;
heatindex_c: number;
heatindex_f: number;
dewpoint_c: number;
dewpoint_f: number;
will_it_rain: number;
chance_of_rain: number;
will_it_snow: number;
chance_of_snow: number;
vis_km: number;
vis_miles: number;
gust_mph: number;
gust_kph: number;
uv: number;
}>;
}>;
};
};
match_time: string;
closest_hour: {
time_epoch: number;
time: string;
temp_c: number;
temp_f: number;
is_day: number;
condition: {
text: string;
icon: string;
code: number;
};
wind_mph: number;
wind_kph: number;
wind_degree: number;
wind_dir: string;
pressure_mb: number;
pressure_in: number;
precip_mm: number;
precip_in: number;
humidity: number;
cloud: number;
feelslike_c: number;
feelslike_f: number;
windchill_c: number;
windchill_f: number;
heatindex_c: number;
heatindex_f: number;
dewpoint_c: number;
dewpoint_f: number;
will_it_rain: number;
chance_of_rain: number;
will_it_snow: number;
chance_of_snow: number;
vis_km: number;
vis_miles: number;
gust_mph: number;
gust_kph: number;
uv: number;
};
}
interface MatchWeatherProps {
matchDateTime: string;
venue?: string;
isHomeMatch: boolean;
matchHasStarted: boolean;
delayLoad?: boolean; // New prop to control when to load weather
}
// Czech and English translations
const translations = {
cs: {
weather: 'Počasí',
forecast: 'Předpověď počasí',
temperature: 'Teplota',
feelsLike: 'Pocitově',
wind: 'Vítr',
humidity: 'Vlhkost',
visibility: 'Viditelnost',
pressure: 'Tlak',
precipitation: 'Srážky',
chanceOfRain: 'Šance na déšť',
chanceOfSnow: 'Šance na sníh',
atMatchTime: 'V čase zápasu',
weatherUnavailable: 'Předpověď počasí není dostupná',
forecastNotAvailable: 'Předpověď není dostupná pro tento zápas',
tooFarInFuture: 'Zápas je příliš daleko v budoucnosti',
matchInPast: 'Zápas již proběhl',
loading: 'Načítám počasí...',
},
en: {
weather: 'Weather',
forecast: 'Weather Forecast',
temperature: 'Temperature',
feelsLike: 'Feels Like',
wind: 'Wind',
humidity: 'Humidity',
visibility: 'Visibility',
pressure: 'Pressure',
precipitation: 'Precipitation',
chanceOfRain: 'Rain Chance',
chanceOfSnow: 'Snow Chance',
atMatchTime: 'At Match Time',
weatherUnavailable: 'Weather forecast unavailable',
forecastNotAvailable: 'Forecast not available for this match',
tooFarInFuture: 'Match is too far in the future',
matchInPast: 'Match has already taken place',
loading: 'Loading weather...',
},
};
const getWeatherIcon = (condition: string, isDay: number) => {
const lowerCondition = condition.toLowerCase();
if (lowerCondition.includes('sunny') || lowerCondition.includes('clear')) {
return isDay ? FaSun : FaMoon;
}
if (lowerCondition.includes('partly cloudy') || lowerCondition.includes('partly sunny')) {
return FaCloudSun;
}
if (lowerCondition.includes('cloudy') || lowerCondition.includes('overcast')) {
return FaCloud;
}
if (lowerCondition.includes('rain') || lowerCondition.includes('drizzle') || lowerCondition.includes('shower')) {
return FaCloudRain;
}
if (lowerCondition.includes('snow') || lowerCondition.includes('sleet') || lowerCondition.includes('blizzard')) {
return FaSnowflake;
}
return FaCloud;
};
const getWindDirection = (degree: number) => {
const directions = ['S', 'SV', 'V', 'JV', 'J', 'JZ', 'Z', 'SZ'];
const index = Math.round(degree / 45) % 8;
return directions[index];
};
export const MatchWeather: React.FC<MatchWeatherProps> = ({
matchDateTime,
venue,
isHomeMatch,
matchHasStarted,
delayLoad = false
}) => {
const { i18n } = useTranslation();
const t = translations[i18n.language as keyof typeof translations] || translations.cs;
const bgCard = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textColor = useColorModeValue('gray.600', 'gray.400');
// Only show weather for home matches that haven't started yet
if (!isHomeMatch || matchHasStarted) {
return null;
}
const { data: weatherData, isLoading, error } = useQuery<MatchWeatherData>({
queryKey: ['matchWeather', matchDateTime, venue],
queryFn: async () => {
const params = new URLSearchParams({
match_datetime: matchDateTime,
// Only pass location parameter if it's not a home match (home matches use club location)
...(venue && { location: venue }),
});
const response = await api.get(`/weather/match?${params}`);
return response.data;
},
staleTime: 30 * 60 * 1000, // 30 minutes cache
enabled: isHomeMatch && !matchHasStarted && !delayLoad, // Disable if delayLoad is true
});
if (isLoading) {
return (
<Box
bg={bgCard}
p={4}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
mt={4}
>
<VStack spacing={3} align="stretch">
<Text fontSize="sm" fontWeight="bold" color={textColor}>
{t.weather}
</Text>
<Skeleton height="20px" width="60%" />
<Skeleton height="16px" width="40%" />
<Skeleton height="16px" width="50%" />
</VStack>
</Box>
);
}
if (error || !weatherData) {
// Check for specific error messages to provide better user feedback
let errorMessage = t.weatherUnavailable;
const errorString = String(error || '');
if (errorString.includes('too far in the future')) {
errorMessage = t.tooFarInFuture;
} else if (errorString.includes('match is in the past')) {
errorMessage = t.matchInPast;
} else if (errorString.includes('no location specified')) {
errorMessage = t.forecastNotAvailable;
}
return (
<Box
bg={bgCard}
p={4}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
mt={4}
>
<VStack spacing={2} align="center">
<Icon as={FaExclamationTriangle} boxSize={6} color="orange.400" />
<Text fontSize="sm" color="gray.500" textAlign="center">
{errorMessage}
</Text>
</VStack>
</Box>
);
}
const { weather, closest_hour } = weatherData;
const currentIcon = getWeatherIcon(weather.current.condition.text, weather.current.is_day);
const matchIcon = getWeatherIcon(closest_hour.condition.text, closest_hour.is_day);
return (
<Box
bg={bgCard}
p={4}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
mt={4}
>
<VStack spacing={4} align="stretch">
{/* Header */}
<HStack spacing={2} align="center">
<Icon as={FaCloud} boxSize={4} color="blue.500" />
<Text fontSize="sm" fontWeight="bold">
{t.forecast}
</Text>
<Badge fontSize="xs" colorScheme="blue" variant="subtle">
{weather.location.name}
</Badge>
</HStack>
<Divider />
{/* Current Weather */}
<HStack spacing={3} align="center">
<Icon as={currentIcon} boxSize={8} color="blue.400" />
<VStack spacing={1} align="start" flex={1}>
<Text fontSize="lg" fontWeight="bold">
{Math.round(weather.current.temp_c)}°C
</Text>
<Text fontSize="xs" color={textColor}>
{t.feelsLike} {Math.round(weather.current.feelslike_c)}°C
</Text>
</VStack>
<VStack spacing={1} align="end">
<Text fontSize="xs" color={textColor}>{weather.current.condition.text}</Text>
{(closest_hour.will_it_rain > 0 || closest_hour.chance_of_rain > 0) && (
<Badge size="xs" colorScheme="blue">
{t.chanceOfRain}: {closest_hour.chance_of_rain}%
</Badge>
)}
{(closest_hour.will_it_snow > 0 || closest_hour.chance_of_snow > 0) && (
<Badge size="xs" colorScheme="cyan">
{t.chanceOfSnow}: {closest_hour.chance_of_snow}%
</Badge>
)}
</VStack>
</HStack>
{/* Weather at Match Time */}
<Box>
<Text fontSize="xs" fontWeight="bold" color={textColor} mb={2}>
{t.atMatchTime}
</Text>
<HStack spacing={3} align="center" bg={useColorModeValue('gray.50', 'gray.700')} p={3} borderRadius="md">
<Icon as={matchIcon} boxSize={6} color="blue.400" />
<VStack spacing={1} align="start" flex={1}>
<Text fontSize="md" fontWeight="bold">
{Math.round(closest_hour.temp_c)}°C
</Text>
<Text fontSize="xs" color={textColor}>
{closest_hour.condition.text}
</Text>
</VStack>
<HStack spacing={4} fontSize="xs" color={textColor}>
<HStack spacing={1}>
<Icon as={FaWind} boxSize={3} />
<Text>{closest_hour.wind_kph} km/h</Text>
</HStack>
<HStack spacing={1}>
<Icon as={FaTint} boxSize={3} />
<Text>{closest_hour.humidity}%</Text>
</HStack>
</HStack>
</HStack>
</Box>
{/* Additional Details */}
<SimpleGrid columns={2} spacing={2} fontSize="xs">
<HStack spacing={2}>
<Icon as={FaWind} boxSize={3} color={textColor} />
<Text color={textColor}>{t.wind}: {weather.current.wind_kph} km/h {getWindDirection(weather.current.wind_degree)}</Text>
</HStack>
<HStack spacing={2}>
<Icon as={FaTint} boxSize={3} color={textColor} />
<Text color={textColor}>{t.humidity}: {weather.current.humidity}%</Text>
</HStack>
<HStack spacing={2}>
<Icon as={FaEye} boxSize={3} color={textColor} />
<Text color={textColor}>{t.visibility}: {weather.current.vis_km} km</Text>
</HStack>
<HStack spacing={2}>
<Icon as={FaThermometerHalf} boxSize={3} color={textColor} />
<Text color={textColor}>{t.pressure}: {weather.current.pressure_mb} hPa</Text>
</HStack>
</SimpleGrid>
</VStack>
</Box>
);
};
export default MatchWeather;
@@ -0,0 +1,65 @@
import React, { useState } from 'react';
import { Button, Box, VStack, Text, Icon } from '@chakra-ui/react';
import { FaCloud } from 'react-icons/fa';
import { MatchWeather } from './MatchWeather';
interface MatchWeatherLazyProps {
matchDateTime: string;
venue?: string;
isHomeMatch: boolean;
matchHasStarted: boolean;
}
export const MatchWeatherLazy: React.FC<MatchWeatherLazyProps> = ({
matchDateTime,
venue,
isHomeMatch,
matchHasStarted
}) => {
const [showWeather, setShowWeather] = useState(false);
// Only show weather for home matches that haven't started yet
if (!isHomeMatch || matchHasStarted) {
return null;
}
if (!showWeather) {
return (
<Box
bg="white"
p={4}
borderRadius="lg"
borderWidth="1px"
borderColor="gray.200"
mt={4}
>
<VStack spacing={3} align="center">
<Icon as={FaCloud} boxSize={8} color="blue.400" />
<Text fontSize="sm" color="gray.600" textAlign="center">
Zobrazit předpověď počasí
</Text>
<Button
size="sm"
colorScheme="blue"
variant="outline"
onClick={() => setShowWeather(true)}
>
Zobrazit počasí
</Button>
</VStack>
</Box>
);
}
return (
<MatchWeather
matchDateTime={matchDateTime}
venue={undefined} // Don't pass venue for home matches - let backend use club location
isHomeMatch={isHomeMatch}
matchHasStarted={matchHasStarted}
delayLoad={false}
/>
);
};
export default MatchWeatherLazy;
@@ -0,0 +1,34 @@
import React, { useEffect } from 'react';
import { useConfirmDialog } from '../../contexts/ConfirmDialogContext';
const ServiceWorkerUpdateListener: React.FC = () => {
const { confirm } = useConfirmDialog();
useEffect(() => {
const handler = (event: Event) => {
const ev = event as CustomEvent<{ registration?: ServiceWorkerRegistration }>;
const registration = ev.detail?.registration;
if (!registration) return;
(async () => {
const ok = await confirm({
title: 'Aktualizace aplikace',
message: 'Nová verze aplikace je k dispozici. Chcete aktualizovat?',
confirmText: 'Aktualizovat',
cancelText: 'Později',
});
if (ok && registration.waiting) {
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
}
})();
};
window.addEventListener('sw-update-available', handler as EventListener);
return () => {
window.removeEventListener('sw-update-available', handler as EventListener);
};
}, [confirm]);
return null;
};
export default ServiceWorkerUpdateListener;

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