This commit is contained in:
Tomas Dvorak committed 2026-01-26 08:13:18 +01:00
1 parent aa036b6550
commit dfc079288f
505 files changed
+95754 -5711

No files matched your search

+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

@@ -1,7 +0,0 @@
/*!
* Quill Editor v1.3.7
* https://quilljs.com/
* Copyright (c) 2014, Jason Chen
* Copyright (c) 2013, salesforce.com
*/.ql-container{box-sizing:border-box;font-family:Helvetica,Arial,sans-serif;font-size:13px;height:100%;margin:0;position:relative}.ql-container.ql-disabled .ql-tooltip{visibility:hidden}.ql-container.ql-disabled .ql-editor ul[data-checked]>li:before{pointer-events:none}.ql-clipboard{height:1px;left:-100000px;overflow-y:hidden;position:absolute;top:50%}.ql-clipboard p{margin:0;padding:0}.ql-editor{word-wrap:break-word;box-sizing:border-box;height:100%;line-height:1.42;outline:none;overflow-y:auto;padding:12px 15px;tab-size:4;-moz-tab-size:4;text-align:left;white-space:pre-wrap}.ql-editor>*{cursor:text}.ql-editor blockquote,.ql-editor h1,.ql-editor h2,.ql-editor h3,.ql-editor h4,.ql-editor h5,.ql-editor h6,.ql-editor ol,.ql-editor p,.ql-editor pre,.ql-editor ul{counter-reset:list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;margin:0;padding:0}.ql-editor ol,.ql-editor ul{padding-left:1.5em}.ql-editor ol>li,.ql-editor ul>li{list-style-type:none}.ql-editor ul>li:before{content:"\2022"}.ql-editor ul[data-checked=false],.ql-editor ul[data-checked=true]{pointer-events:none}.ql-editor ul[data-checked=false]>li *,.ql-editor ul[data-checked=true]>li *{pointer-events:all}.ql-editor ul[data-checked=false]>li:before,.ql-editor ul[data-checked=true]>li:before{color:#777;cursor:pointer;pointer-events:all}.ql-editor ul[data-checked=true]>li:before{content:"\2611"}.ql-editor ul[data-checked=false]>li:before{content:"\2610"}.ql-editor li:before{display:inline-block;white-space:nowrap;width:1.2em}.ql-editor li:not(.ql-direction-rtl):before{margin-left:-1.5em;margin-right:.3em;text-align:right}.ql-editor li.ql-direction-rtl:before{margin-left:.3em;margin-right:-1.5em}.ql-editor ol li:not(.ql-direction-rtl),.ql-editor ul li:not(.ql-direction-rtl){padding-left:1.5em}.ql-editor ol li.ql-direction-rtl,.ql-editor ul li.ql-direction-rtl{padding-right:1.5em}.ql-editor ol li{counter-increment:list-0;counter-reset:list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}.ql-editor ol li:before{content:counter(list-0,decimal) ". "}.ql-editor ol li.ql-indent-1{counter-increment:list-1}.ql-editor ol li.ql-indent-1:before{content:counter(list-1,lower-alpha) ". "}.ql-editor ol li.ql-indent-1{counter-reset:list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-2{counter-increment:list-2}.ql-editor ol li.ql-indent-2:before{content:counter(list-2,lower-roman) ". "}.ql-editor ol li.ql-indent-2{counter-reset:list-3 list-4 list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-3{counter-increment:list-3}.ql-editor ol li.ql-indent-3:before{content:counter(list-3,decimal) ". "}.ql-editor ol li.ql-indent-3{counter-reset:list-4 list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-4{counter-increment:list-4}.ql-editor ol li.ql-indent-4:before{content:counter(list-4,lower-alpha) ". "}.ql-editor ol li.ql-indent-4{counter-reset:list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-5{counter-increment:list-5}.ql-editor ol li.ql-indent-5:before{content:counter(list-5,lower-roman) ". "}.ql-editor ol li.ql-indent-5{counter-reset:list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-6{counter-increment:list-6}.ql-editor ol li.ql-indent-6:before{content:counter(list-6,decimal) ". "}.ql-editor ol li.ql-indent-6{counter-reset:list-7 list-8 list-9}.ql-editor ol li.ql-indent-7{counter-increment:list-7}.ql-editor ol li.ql-indent-7:before{content:counter(list-7,lower-alpha) ". "}.ql-editor ol li.ql-indent-7{counter-reset:list-8 list-9}.ql-editor ol li.ql-indent-8{counter-increment:list-8}.ql-editor ol li.ql-indent-8:before{content:counter(list-8,lower-roman) ". "}.ql-editor ol li.ql-indent-8{counter-reset:list-9}.ql-editor ol li.ql-indent-9{counter-increment:list-9}.ql-editor ol li.ql-indent-9:before{content:counter(list-9,decimal) ". "}.ql-editor .ql-indent-1:not(.ql-direction-rtl){padding-left:3em}.ql-editor li.ql-indent-1:not(.ql-direction-rtl){padding-left:4.5em}.ql-editor .ql-indent-1.ql-direction-rtl.ql-align-right{padding-right:3em}.ql-editor li.ql-indent-1.ql-direction-rtl.ql-align-right{padding-right:4.5em}.ql-editor .ql-indent-2:not(.ql-direction-rtl){padding-left:6em}.ql-editor li.ql-indent-2:not(.ql-direction-rtl){padding-left:7.5em}.ql-editor .ql-indent-2.ql-direction-rtl.ql-align-right{padding-right:6em}.ql-editor li.ql-indent-2.ql-direction-rtl.ql-align-right{padding-right:7.5em}.ql-editor .ql-indent-3:not(.ql-direction-rtl){padding-left:9em}.ql-editor li.ql-indent-3:not(.ql-direction-rtl){padding-left:10.5em}.ql-editor .ql-indent-3.ql-direction-rtl.ql-align-right{padding-right:9em}.ql-editor li.ql-indent-3.ql-direction-rtl.ql-align-right{padding-right:10.5em}.ql-editor .ql-indent-4:not(.ql-direction-rtl){padding-left:12em}.ql-editor li.ql-indent-4:not(.ql-direction-rtl){padding-left:13.5em}.ql-editor .ql-indent-4.ql-direction-rtl.ql-align-right{padding-right:12em}.ql-editor li.ql-indent-4.ql-direction-rtl.ql-align-right{padding-right:13.5em}.ql-editor .ql-indent-5:not(.ql-direcLine truncated
/*# sourceMappingURL=290.1124e12e.css.map*/
@@ -1 +0,0 @@
{"version":3,"file":"static/css/290.1124e12e.css","mappings":"AAAA;;;;;EAKE,CACF,cACE,qBAAsB,CACtB,sCAAyC,CACzC,cAAe,CACf,WAAY,CACZ,QAAW,CACX,iBACF,CACA,sCACE,iBACF,CACA,gEACE,mBACF,CACA,cAEE,UAAW,CADX,cAAe,CAEf,iBAAkB,CAClB,iBAAkB,CAClB,OACF,CACA,gBACE,QAAS,CACT,SACF,CACA,WAWE,oBAAqB,CAVrB,qBAAsB,CAEtB,WAAY,CADZ,gBAAiB,CAEjB,YAAa,CACb,eAAgB,CAChB,iBAAkB,CAClB,UAAW,CACX,eAAgB,CAChB,eAAgB,CAChB,oBAEF,CACA,aACE,WACF,CACA,kKAaE,4EAA6E,CAF7E,QAAS,CACT,SAEF,CACA,4BAEE,kBACF,CACA,kCAEE,oBACF,CACA,wBACE,eACF,CACA,mEAEE,mBACF,CACA,6EAEE,kBACF,CACA,uFAEE,UAAW,CACX,cAAe,CACf,kBACF,CACA,2CACE,eACF,CACA,4CACE,eACF,CACA,qBACE,oBAAqB,CACrB,kBAAmB,CACnB,WACF,CACA,4CACE,kBAAmB,CACnB,iBAAmB,CACnB,gBACF,CACA,sCACE,gBAAkB,CAClB,mBACF,CACA,gFAEE,kBACF,CACA,oEAEE,mBACF,CACA,iBAEE,wBAAyB,CADzB,4EAEF,CACA,wBACE,oCACF,CACA,6BACE,wBACF,CACA,oCACE,wCACF,CACA,6BACE,qEACF,CACA,6BACE,wBACF,CACA,oCACE,wCACF,CACA,6BACE,8DACF,CACA,6BACE,wBACF,CACA,oCACE,oCACF,CACA,6BACE,uDACF,CACA,6BACE,wBACF,CACA,oCACE,wCACF,CACA,6BACE,gDACF,CACA,6BACE,wBACF,CACA,oCACE,wCACF,CACA,6BACE,yCACF,CACA,6BACE,wBACF,CACA,oCACE,oCACF,CACA,6BACE,kCACF,CACA,6BACE,wBACF,CACA,oCACE,wCACF,CACA,6BACE,2BACF,CACA,6BACE,wBACF,CACA,oCACE,wCACF,CACA,6BACE,oBACF,CACA,6BACE,wBACF,CACA,oCACE,oCACF,CACA,+CACE,gBACF,CACA,iDACE,kBACF,CACA,wDACE,iBACF,CACA,0DACE,mBACF,CACA,+CACE,gBACF,CACA,iDACE,kBACF,CACA,wDACE,iBACF,CACA,0DACE,mBACF,CACA,+CACE,gBACF,CACA,iDACE,mBACF,CACA,wDACE,iBACF,CACA,0DACE,oBACF,CACA,+CACE,iBACF,CACA,iDACE,mBACF,CACA,wDACE,kBACF,CACA,0DACE,oBACF,CACA,+CACE,iBACF,CACA,iDACE,mBACF,CACA,wDACE,kBACF,CACA,0DACE,oBACF,CACA,+CACE,iBACF,CACA,iDACE,mBACF,CACA,wDACE,kBACF,CACA,0DACE,oBACF,CACA,+CACE,iBACF,CACA,iDACE,mBACF,CACA,wDACE,kBACF,CACA,0DACE,oBACF,CACA,+CACE,iBACF,CACA,iDACE,mBACF,CACA,wDACE,kBACF,CACA,0DACE,oBACF,CACA,+CACE,iBACF,CACA,iDACE,mBACF,CACA,wDACE,kBACF,CACA,0DACE,oBACF,CACA,qBACE,aAAc,CACd,cACF,CACA,qCACE,aACF,CACA,oCACE,iBACF,CACA,wBACE,qBACF,CACA,sBACE,wBACF,CACA,yBACE,qBACF,CACA,yBACE,qBACF,CACA,wBACE,wBACF,CACA,uBACE,qBACF,CACA,yBACE,qBACF,CACA,2BACE,UACF,CACA,yBACE,aACF,CACA,4BACE,UACF,CACA,4BACE,UACF,CACA,2BACE,aACF,CACA,0BACE,UACF,CACA,4BACE,UACF,CACA,0BACE,yCACF,CACA,8BACE,wCACF,CACA,0BACE,eACF,CACA,0BACE,eACF,CACA,yBACE,eACF,CACA,6BACE,aAAc,CACd,kBACF,CACA,4BACE,iBACF,CACA,6BACE,kBACF,CACA,2BACE,gBACF,CACA,2BACE,WAAsB,CACtB,8BAA+B,CAC/B,iBAAkB,CAClB,SAAU,CACV,mBAAoB,CACpB,iBAAkB,CAClB,UACF,CACA,qDAEE,UAAW,CACX,UAAW,CACX,aACF,CACA,uDAEE,eAAgB,CAChB,WAAY,CACZ,cAAe,CACf,oBAAqB,CACrB,UAAW,CACX,WAAY,CACZ,eAAgB,CAChB,UACF,CACA,+DAEE,UAAW,CACX,WACF,CACA,iFAEE,YACF,CACA,6FAEE,YACF,CACA,6jBAcE,UACF,CACA,kgDA4BE,SACF,CACA,kgDA4BE,WACF,CACA,wBACE,mGAEE,UACF,CACA,8PAIE,SACF,CACA,8PAIE,WACF,CACF,CAIA,oBACE,qBACF,CACA,oBACE,YACF,CACA,6CAEE,iBACF,CACA,qBACE,iBAAkB,CAClB,0BACF,CACA,uBACE,cAAe,CACf,oBACF,CACA,6BACE,2BACF,CACA,qBACE,oBAAqB,CACrB,qBACF,CACA,2BACE,UAAW,CACX,UAAW,CACX,aACF,CACA,oBACE,SAAU,CACV,WAAY,CACZ,oBAAqB,CACrB,qBAAsB,CACtB,cACF,CACA,0BACE,SAAU,CACV,WAAY,CACZ,oBAAqB,CACrB,cACF,CACA,8CAEE,SACF,CACA,mBACE,SACF,CACA,kBACE,iBACF,CACA,8CAEE,cACF,CACA,yBACE,UACF,CACA,sCACE,YACF,CACA,gDACE,cACF,CACA,iDACE,YACF,CACA,uBACE,aACF,CACA,uBACE,eACF,CACA,uBACE,gBACF,CACA,uBACE,aACF,CACA,uBACE,eACF,CACA,uBACE,eACF,CACA,sBACE,yBACF,CACA,+BACE,0BAA2B,CAC3B,iBAAkB,CAClB,cAAe,CACf,iBACF,CACA,iDAEE,wBAAyB,CACzB,iBACF,CACA,wBAEE,iBAAkB,CAClB,cAAe,CACf,gBAAiB,CAHjB,oBAIF,CACA,yBACE,aAAc,CACd,eACF,CACA,kCACE,wBAAyB,CACzB,aAAc,CACd,gBACF,CACA,wBACE,cACF,CACA,oBACE,UAAW,CACX,oBAAqB,CACrB,UAAW,CACX,cAAe,CACf,eAAgB,CAChB,WAAY,CACZ,iBAAkB,CAClB,qBACF,CACA,0BACE,cAAe,CACf,oBAAqB,CACrB,WAAY,CACZ,gBAAiB,CACjB,iBAAkB,CAClB,iBAAkB,CAClB,UACF,CACA,iCACE,oBAAqB,CACrB,gBACF,CACA,4BACE,qBAAsB,CACtB,YAAa,CACb,cAAe,CACf,eAAgB,CAChB,iBAAkB,CAClB,kBACF,CACA,4CACE,cAAe,CACf,aAAc,CACd,kBAAmB,CACnB,eACF,CACA,iDACE,UAAW,CACX,SACF,CACA,0DACE,SACF,CACA,4DACE,WACF,CACA,mDACE,aAAc,CACd,eAAgB,CAChB,QAAS,CACT,SACF,CACA,mDAEE,UACF,CACA,qFAEE,eACF,CACA,6FAEE,SACF,CACA,4CACE,aACF,CACA,yCACE,WAAY,CAEZ,eAAgB,CADhB,UAEF,CACA,6CACE,eAAgB,CAChB,WACF,CACA,0CACE,sBAA6B,CAC7B,UAAW,CACX,WAAY,CACZ,UAAW,CACX,SAAY,CACZ,UACF,CACA,mEAEE,eAAgB,CADhB,iBAAkB,CAElB,OAAQ,CACR,OAAQ,CACR,UACF,CACA,+fAME,wBACF,CACA,8BACE,UACF,CACA,2GAEE,gBACF,CACA,2IAEE,mBACF,CACA,2IAEE,mBACF,CACA,2IAEE,mBACF,CACA,2IAEE,mBACF,CACA,2IAEE,mBACF,CACA,2IAEE,mBACF,CACA,qEACE,aACF,CACA,qEACE,eACF,CACA,qEACE,gBACF,CACA,qEACE,aACF,CACA,qEACE,eACF,CACA,qEACE,eACF,CACA,4BACE,WACF,CACA,uGAEE,oBACF,CACA,2IAEE,eACF,CACA,mJAEE,mBACF,CACA,qEACE,yCACF,CACA,yEACE,wCACF,CACA,4BACE,UACF,CACA,uGAEE,gBACF,CACA,2IAEE,eACF,CACA,2IAEE,eACF,CACA,yIAEE,cACF,CACA,qEACE,cACF,CACA,qEACE,cACF,CACA,oEACE,cACF,CACA,wDACE,qBACF,CACA,mDACE,qBACF,CACA,oBACE,qBAAsB,CACtB,qBAAsB,CACtB,qDAA+D,CAC/D,WACF,CACA,gCACE,iBACF,CACA,qCACE,sBACF,CACA,uCACE,sBAA6B,CAC7B,0BACF,CAIA,0HACE,iBACF,CACA,4HAEE,iBACF,CACA,0CACE,YACF,CACA,qBACE,qBAAsB,CACtB,qBAAsB,CACtB,uBAA4B,CAC5B,UAAW,CACX,gBAAiB,CACjB,kBACF,CACA,4BACE,oBAAqB,CACrB,gBAAiB,CACjB,gBACF,CACA,sCAEE,qBAAsB,CADtB,YAAa,CAELine truncated
@@ -1,2 +0,0 @@
body,html{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;font-family:var(--font-body,var(--chakra-fonts-body,-apple-system,BlinkMacSystemFont,"Segoe UI","Roboto","Oxygen","Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue",sans-serif));margin:0;padding:0}.chakra-ui-dark #root,.chakra-ui-dark body,html.chakra-ui-dark{background-color:#0f1115!important;color:#e8eaf0!important}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}*,body,html{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-family:var(--font-body,var(--chakra-fonts-body,-apple-system,BlinkMacSystemFont,"Segoe UI","Roboto",sans-serif))}h1,h2,h3,h4,h5,h6{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif!important;font-family:var(--font-heading,var(--chakra-fonts-heading,-apple-system,BlinkMacSystemFont,"Segoe UI","Roboto",sans-serif))!important}button,input,select,textarea{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-family:var(--font-body,var(--chakra-fonts-body,-apple-system,BlinkMacSystemFont,"Segoe UI","Roboto",sans-serif))}@media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,:after,:before{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-duration:.01ms!important}}:focus-visible{border-radius:4px;outline:3px solid #c53030;outline:3px solid var(--primary,#c53030);outline-offset:2px}:focus:not(:focus-visible){outline:none}a:focus-visible{outline:3px solid #c53030;outline:3px solid var(--primary,#c53030);outline-offset:3px}button:focus-visible{outline:3px solid #c53030;outline:3px solid var(--primary,#c53030);outline-offset:2px}input:focus-visible,select:focus-visible,textarea:focus-visible{border-color:#c53030;border-color:var(--primary,#c53030);outline:2px solid #c53030;outline:2px solid var(--primary,#c53030);outline-offset:0}::selection{background:#c53030;background:var(--primary,#c53030);color:#fff;text-shadow:none}::-moz-selection{background:#c53030;background:var(--primary,#c53030);color:#fff;text-shadow:none}::-webkit-scrollbar-track{background:#0000000d}::-webkit-scrollbar-thumb{background:#0003;background-clip:padding-box;border:2px solid #0000}::-webkit-scrollbar-thumb:hover{background:#0000004d;background-clip:padding-box}::-webkit-scrollbar-thumb:active{background:#0006;background-clip:padding-box}*{scrollbar-color:#0003 #0000000d}.chakra-ui-dark ::-webkit-scrollbar-track{background:#ffffff0d}.chakra-ui-dark ::-webkit-scrollbar-thumb{background:#fff3}.chakra-ui-dark ::-webkit-scrollbar-thumb:hover{background:#ffffff4d}.chakra-ui-dark *{scrollbar-color:#fff3 #ffffff0d}@keyframes globalFadeIn{0%{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}@keyframes globalFadeInUp{0%{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}@keyframes globalSlideInLeft{0%{opacity:0;transform:translateX(-30px)}to{opacity:1;transform:translateX(0)}}@keyframes globalSlideInRight{0%{opacity:0;transform:translateX(30px)}to{opacity:1;transform:translateX(0)}}.fade-in{animation:globalFadeIn .6s ease-out forwards}.fade-in-up{animation:globalFadeInUp .7s cubic-bezier(.4,0,.2,1) forwards}.slide-in-left{animation:globalSlideInLeft .6s ease-out forwards}.slide-in-right{animation:globalSlideInRight .6s ease-out forwards}.fade-in-visible{animation:globalFadeIn .6s ease-out forwards}.stagger-1{animation-delay:.1s}.stagger-2{animation-delay:.2s}.stagger-3{animation-delay:.3s}.stagger-4{animation-delay:.4s}.stagger-5{animation-delay:.5s}.skeleton{background:linear-gradient(90deg,#f0f0f0,#e0e0e0 50%,#f0f0f0);border-radius:4px}.chakra-ui-dark .skeleton{background:linear-gradient(90deg,#ffffff0d,#ffffff1a 50%,#ffffff0d)}@keyframes pulse{0%,to{opacity:1}50%{opacity:.5}}.pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.spin{animation:spin 1s linear infinite}.back-to-top{align-items:center;background:#c53030;background:var(--primary,#c53030);border:none;border-radius:50%;bottom:24px;box-shadow:0 4px 12px #00000026;color:#fff;cursor:pointer;display:flex;height:48px;justify-content:center;opacity:0;position:fixed;right:24px;transition:all .3s ease;visibility:hidden;width:48px;z-index:1000}.back-to-top.visible{opacity:1;visibility:visible}.back-to-top:hover{box-shadow:0 8px 24px #00000040;transform:translateY(-4px)}.back-to-top:active{transform:translateY(-2px)}.back-to-top:focus-visible{outline:3px solid #fff;outline-offset:2px}.skip-to-content{background:#c53030;background:var(--primary,#c53030);border-radius:0 0 4px 0;color:#fff;left:0;padding:8px 16px;position:absolute;text-decoration:none;top:-40px;transition:top .3s;z-index:10000}.skip-to-content:focus{outline:3px solid #fff;outline-offset:-3px;top:0}Line truncated
/*# sourceMappingURL=main.3ca1fc6e.css.map*/
@@ -1 +0,0 @@
{"version":3,"file":"static/css/main.3ca1fc6e.css","mappings":"AAAA,UAME,kCAAmC,CACnC,iCAAkC,CAJlC,mIAEc,CAFd,6LAEc,CAJd,QAAS,CACT,SAMF,CAaA,+DAGE,kCAAoC,CACpC,uBACF,CAEA,KACE,uEAEF,CClBA,YACE,uEAA4H,CAA5H,qHACF,CAEA,kBACE,iFAA6I,CAA7I,qIACF,CAEA,6BACE,uEAA4H,CAA5H,qHACF,CAWA,uCACE,KACE,oBACF,CAEA,iBACE,kCAAqC,CACrC,qCAAuC,CAEvC,8BAAgC,CADhC,mCAEF,CACF,CAKA,eAGE,iBAAkB,CAFlB,yBAA0C,CAA1C,wCAA0C,CAC1C,kBAEF,CAGA,2BACE,YACF,CAGA,gBACE,yBAA0C,CAA1C,wCAA0C,CAC1C,kBACF,CAGA,qBACE,yBAA0C,CAA1C,wCAA0C,CAC1C,kBACF,CAGA,gEAKE,oBAAqC,CAArC,mCAAqC,CAFrC,yBAA0C,CAA1C,wCAA0C,CAC1C,gBAEF,CAKA,YACE,kBAAmC,CAAnC,iCAAmC,CACnC,UAAY,CACZ,gBACF,CAEA,iBACE,kBAAmC,CAAnC,iCAAmC,CACnC,UAAY,CACZ,gBACF,CAWA,0BACE,oBAEF,CAEA,0BACE,gBAA8B,CAG9B,2BAA4B,CAD5B,sBAEF,CAEA,gCACE,oBAA8B,CAC9B,2BACF,CAEA,iCACE,gBAA8B,CAC9B,2BACF,CAGA,EAEE,+BACF,CAGA,0CACE,oBACF,CAEA,0CACE,gBACF,CAEA,gDACE,oBACF,CAEA,kBACE,+BACF,CAKA,wBACE,GACE,SAAU,CACV,0BACF,CACA,GACE,SAAU,CACV,uBACF,CACF,CAEA,0BACE,GACE,SAAU,CACV,0BACF,CACA,GACE,SAAU,CACV,uBACF,CACF,CAEA,6BACE,GACE,SAAU,CACV,2BACF,CACA,GACE,SAAU,CACV,uBACF,CACF,CAEA,8BACE,GACE,SAAU,CACV,0BACF,CACA,GACE,SAAU,CACV,uBACF,CACF,CAEA,SACE,4CACF,CAEA,YACE,6DACF,CAEA,eACE,iDACF,CAEA,gBACE,kDACF,CAEA,iBACE,4CACF,CAOA,WAAa,mBAAuB,CACpC,WAAa,mBAAuB,CACpC,WAAa,mBAAuB,CACpC,WAAa,mBAAuB,CACpC,WAAa,mBAAuB,CAcpC,UACE,6DAKC,CAGD,iBACF,CAEA,0BACE,mEAMF,CAKA,iBACE,MACE,SACF,CACA,IACE,UACF,CACF,CAEA,OACE,mDACF,CAKA,gBACE,GACE,sBACF,CACA,GACE,uBACF,CACF,CAEA,MACE,iCACF,CAKA,aAiBE,kBAAmB,CAVnB,kBAAmC,CAAnC,iCAAmC,CAEnC,WAAY,CAHZ,iBAAkB,CAJlB,WAAY,CAaZ,+BAA0C,CAP1C,UAAY,CAEZ,cAAe,CAMf,YAAa,CAXb,WAAY,CAaZ,sBAAuB,CAPvB,SAAU,CAVV,cAAe,CAEf,UAAW,CAUX,uBAAyB,CADzB,iBAAkB,CARlB,UAAW,CAUX,YAKF,CAEA,qBACE,SAAU,CACV,kBACF,CAEA,mBAEE,+BAA0C,CAD1C,0BAEF,CAEA,oBACE,0BACF,CAEA,2BACE,sBAAwB,CACxB,kBACF,CAKA,iBAIE,kBAAmC,CAAnC,iCAAmC,CAInC,uBAAwB,CAHxB,UAAY,CAFZ,MAAO,CAGP,gBAAiB,CALjB,iBAAkB,CAMlB,oBAAqB,CALrB,SAAU,CAQV,kBAAoB,CADpB,aAEF,CAEA,uBAEE,sBAAwB,CACxB,mBAAoB,CAFpB,KAGF,CAKA,iBAOE,kBAAsB,CAEtB,QAAS,CANT,UAAW,CACX,WAAY,CAEZ,eAAgB,CADhB,SAAU,CAJV,iBAAkB,CAOlB,kBAAmB,CANnB,SAQF,CAEA,WACE,eACF,CAEA,qBACE,mBACF,CAEA,qBACE,mBACF,CAMA,wBACE,wEAME,eAAgB,CAChB,cACF,CACF,CAKA,aACE,gEAOE,sBACF,CAEA,cACE,2BACF,CAEA,EACE,yBAA4B,CAC5B,oBACF,CACF,CAKA,+BACE,EACE,mCACF,CAEA,SAEE,yBACF,CACF,CAKA,uBACE,qBACF,CAEA,qBACE,mBACF,CAEA,iBAEE,kCAA2B,CAA3B,0BAA2B,CAD3B,uBAEF,CAKA,IAGE,aAAc,CADd,WAAY,CADZ,cAGF,CAEA,iBACE,gBAAiB,CACjB,qBACF,CAEA,gBACE,cACF,CAKA,KACE,kCAAmC,CACnC,iCAAkC,CAElC,uEAA4H,CAA5H,qHAA4H,CAD5H,iCAEF,CAKA,6CACE,KAIE,iHACF,CACF,CCreA,yHAQE,uEAA4H,CAA5H,qHACF,CAGA,0IASE,iFAA6I,CAA7I,qIACF,CAKA,cAEE,YAAa,CACb,qBAAsB,CAFtB,gBAGF,CAEA,YACE,QAAO,CAGP,aAAc,CADd,gBAAiB,CADjB,YAAa,CAGb,UACF,CAKA,YACE,eAAiB,CACjB,kBAAmB,CACnB,8BAAwC,CAExC,kBAAmB,CADnB,YAAa,CAEb,8BACF,CAEA,kBACE,+BACF,CAEA,4BACE,kBAAmB,CACnB,8BAAwC,CACxC,aACF,CAEA,mBAEE,kBAAmB,CAInB,iCAA4C,CAL5C,YAAa,CAEb,6BAA8B,CAC9B,kBAAmB,CACnB,mBAEF,CAEA,mCACE,6BACF,CAKA,qBAIE,eAAiB,CADjB,0BAAqC,CADrC,iBAAkB,CADlB,eAIF,CAEA,qCAEE,kBAAmB,CADnB,sBAEF,CAEA,aAEE,uBAAyB,CACzB,gBAAiB,CAFjB,UAGF,CAEA,mBAIE,kBAAmB,CAHnB,uBAAgB,CAAhB,eAAgB,CAChB,KAAM,CACN,UAEF,CAEA,mCACE,kBACF,CAEA,gBAQE,iCAA4C,CAD5C,aAAc,CAGd,sBAAyE,CAAzE,sEAAyE,CANzE,iBAAmB,CADnB,eAAgB,CAGhB,mBAAqB,CALrB,YAAa,CACb,eAAgB,CAGhB,wBAAyB,CAIzB,kBAEF,CAEA,gCAEE,6BAA8C,CAD9C,aAEF,CAEA,gBAEE,iCAA4C,CAD5C,YAAa,CAEb,qBACF,CAEA,gCACE,6BACF,CAEA,sBACE,oCACF,CAEA,4BACE,0BACF,CAEA,4CACE,0BACF,CAEA,+BACE,0BAA6D,CAA7D,uDACF,CAEA,mCACE,yBAA0C,CAA1C,wCAA0C,CAC1C,mBACF,CAKA,qBAGE,kBAAmB,CAFnB,YAAa,CACb,OAAQ,CAER,wBACF,CAEA,wBAOE,eAAiB,CADjB,0BAAoC,CAJpC,iBAAkB,CAMlB,cAAe,CALf,iBAAmB,CACnB,eAAgB,CAHhB,gBAAiB,CAIjB,uBAIF,CAEA,8BAEE,8BAAwC,CADxC,0BAEF,CAEA,sCACE,yBAA0C,CAA1C,wCAA0C,CAC1C,kBACF,CAKA,YAEE,aAAS,CADT,YAAa,CACb,QACF,CAEA,kBACE,YAAa,CACb,qBAAsB,CACtB,OACF,CAEA,kBAKE,kBAAmB,CAFnB,aAAc,CACd,YAAa,CAGb,sBAAmE,CAAnE,gEAAmE,CALnE,iBAAmB,CADnB,eAAgB,CAKhB,OAEF,CAEA,kCACE,aACF,CAEA,iCAEE,aAAc,CADd,WAAY,CAEZ,eACF,CAEA,kBAME,eAAiB,CAJjB,0BAAoC,CACpC,iBAAkB,CAClB,kBAAoB,CAHpB,iBAAkB,CAIlB,uBAEF,CAEA,kCACE,kBAAmB,CACnB,sBAAuC,CACvC,UACF,CAEA,wBACE,oBAAqC,CAArC,mCAAqC,CAErC,8BAAgE,CAAhE,0DAAgE,CADhE,YAEF,CAEA,wBACE,oBACF,CAEA,kBACE,aAAc,CACd,iBAAmB,CACnB,cACF,CAEA,iBACE,aAAc,CACd,iBAAmB,CACnB,cACF,CAKA,WAEE,kBAAmB,CASnB,WAAY,CALZ,iBAAkB,CAIlB,cAAe,CATf,mBAAoB,CAOpB,kBAAoB,CADpB,eAAgB,CAHhB,OAAQ,CADR,sBAAuB,CAEvB,iBAAkB,CAOlB,oBAAqB,CAHrB,uBAIF,CAEA,yBACE,yBAA0C,CAA1C,wCAA0C,CAC1C,kBACF,CAEA,mBACE,kDAAqE,CAArE,iEAAqE,CAErE,8BAA4C,CAD5C,UAEF,CAEA,yBAEE,+BAA6C,CAD7C,0BAEF,CAEA,qBACE,kBAAmB,CACnB,aACF,CAEA,2BACE,kBAAmB,CACnB,0BACF,CAEA,mBACE,kBAAmB,CACnB,UACF,CAEA,yBACE,kBAAmB,CACnB,0BACF,CAEA,kBACE,kBAAmB,CACnB,UACF,CAEA,wBACE,kBAAmB,CACnB,0BACF,CAEA,oBAEE,kBAAmB,CADnB,UAAY,CAEZ,wBACF,CAKA,kBAGE,aAAS,CAFT,YAAa,CAEb,QAAS,CADT,wDAA2D,CAE3D,kBACF,CAEA,iBACE,eAAiB,CAKjB,6BAA8C,CAA9C,4CAA8C,CAJ9C,kBAAmB,CAEnB,8BAAyC,CADzC,YAAa,CAEb,uBAEF,CAEA,iCACE,kBACF,CAEA,uBAEE,+BAA0C,CAD1C,0BAEF,CAEA,kBAEE,aAAc,CADd,iBAAmB,CAEnB,eAAgB,CAChB,iBACF,CAEA,kBAGE,aAAc,CAEd,sBAAyE,CAAzLine truncated
-3
View File
@@ -1,3 +0,0 @@
/*! For license information please see 290.0640644c.js.LICENSE.txt */
(self.webpackChunkfrontend=self.webpackChunkfrontend||[]).push([[290],{14:t=>{t.exports=function(){return!1}},45:(t,e,n)=>{"use strict";n.d(e,{A:()=>o});var r=n(8587);function o(t,e){if(null==t)return{};var n,o,i=(0,r.A)(t,e);if(Object.getOwnPropertySymbols){var a=Object.getOwnPropertySymbols(t);for(o=0;o<a.length;o++)n=a[o],-1===e.indexOf(n)&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}},66:(t,e,n)=>{var r=n(7937)(n(6552),"DataView");t.exports=r},67:(t,e,n)=>{"use strict";n.d(e,{A:()=>r});const r=(0,n(7784).A)("Image",[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",ry:"2",key:"1m3agn"}],["circle",{cx:"9",cy:"9",r:"2",key:"af1f0g"}],["path",{d:"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21",key:"1xmnt7"}]])},86:(t,e,n)=>{"use strict";n.d(e,{A:()=>r});const r=(0,n(7784).A)("RotateCw",[["path",{d:"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8",key:"1p45f6"}],["path",{d:"M21 3v5h-5",key:"1q7to0"}]])},116:(t,e,n)=>{"use strict";n.d(e,{$:()=>c,s:()=>l});var r=n(9379),o=n(685),i=n(9939),a=n(7264),s=n(7988);class l extends a.k{constructor(t){super(),this.defaultOptions=t.defaultOptions,this.mutationId=t.mutationId,this.mutationCache=t.mutationCache,this.logger=t.logger||o.U,this.observers=[],this.state=t.state||c(),this.setOptions(t.options),this.scheduleGc()}setOptions(t){this.options=(0,r.A)((0,r.A)({},this.defaultOptions),t),this.updateCacheTime(this.options.cacheTime)}get meta(){return this.options.meta}setState(t){this.dispatch({type:"setState",state:t})}addObserver(t){this.observers.includes(t)||(this.observers.push(t),this.clearGcTimeout(),this.mutationCache.notify({type:"observerAdded",mutation:this,observer:t}))}removeObserver(t){this.observers=this.observers.filter(e=>e!==t),this.scheduleGc(),this.mutationCache.notify({type:"observerRemoved",mutation:this,observer:t})}optionalRemove(){this.observers.length||("loading"===this.state.status?this.scheduleGc():this.mutationCache.remove(this))}continue(){var t,e;return null!=(t=null==(e=this.retryer)?void 0:e.continue())?t:this.execute()}async execute(){const t=()=>{var t;return this.retryer=(0,s.II)({fn:()=>this.options.mutationFn?this.options.mutationFn(this.state.variables):Promise.reject("No mutationFn found"),onFail:(t,e)=>{this.dispatch({type:"failed",failureCount:t,error:e})},onPause:()=>{this.dispatch({type:"pause"})},onContinue:()=>{this.dispatch({type:"continue"})},retry:null!=(t=this.options.retry)?t:0,retryDelay:this.options.retryDelay,networkMode:this.options.networkMode}),this.retryer.promise},e="loading"===this.state.status;try{var n,r,o,i,a,l,c,u;if(!e){var d,h,f,p;this.dispatch({type:"loading",variables:this.options.variables}),await(null==(d=(h=this.mutationCache.config).onMutate)?void 0:d.call(h,this.state.variables,this));const t=await(null==(f=(p=this.options).onMutate)?void 0:f.call(p,this.state.variables));t!==this.state.context&&this.dispatch({type:"loading",context:t,variables:this.state.variables})}const s=await t();return await(null==(n=(r=this.mutationCache.config).onSuccess)?void 0:n.call(r,s,this.state.variables,this.state.context,this)),await(null==(o=(i=this.options).onSuccess)?void 0:o.call(i,s,this.state.variables,this.state.context)),await(null==(a=(l=this.mutationCache.config).onSettled)?void 0:a.call(l,s,null,this.state.variables,this.state.context,this)),await(null==(c=(u=this.options).onSettled)?void 0:c.call(u,s,null,this.state.variables,this.state.context)),this.dispatch({type:"success",data:s}),s}catch(_){try{var m,g,v,y,b,x,w,k;throw await(null==(m=(g=this.mutationCache.config).onError)?void 0:m.call(g,_,this.state.variables,this.state.context,this)),await(null==(v=(y=this.options).onError)?void 0:v.call(y,_,this.state.variables,this.state.context)),await(null==(b=(x=this.mutationCache.config).onSettled)?void 0:b.call(x,void 0,_,this.state.variables,this.state.context,this)),await(null==(w=(k=this.options).onSettled)?void 0:w.call(k,void 0,_,this.state.variables,this.state.context)),_}finally{this.dispatch({type:"error",error:_})}}}dispatch(t){this.state=(e=>{switch(t.type){case"failed":return(0,r.A)((0,r.A)({},e),{},{failureCount:t.failureCount,failureReason:t.error});case"pause":return(0,r.A)((0,r.A)({},e),{},{isPaused:!0});case"continue":return(0,r.A)((0,r.A)({},e),{},{isPaused:!1});case"loading":return(0,r.A)((0,r.A)({},e),{},{context:t.context,data:void 0,failureCount:0,failureReason:null,error:null,isPaused:!(0,s.v_)(this.options.networkMode),status:"loading",variables:t.variables});case"success":return(0,r.A)((0,r.A)({},e),{},{data:t.data,failureCount:0,failureReason:null,error:null,status:"success",isPaused:!1});case"error":return(0,r.A)((0,r.A)({},e),{},{data:void 0,error:t.error,failureCount:e.failureCount+1,failureReason:t.error,isPaused:!1,status:"error"});case"setState":return(0,r.A)((0,r.A)({},e),t.state)}})(this.state),i.j.batch(()=>{this.observers.forEach(e=>{e.onMutationUpdate(t)}),this.mutationCache.notify({mutation:this,type:"updated",action:t})})}}function c(){return{context:void 0Line truncated
//# sourceMappingURL=290.0640644c.js.map
@@ -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.
*/
@@ -1 +0,0 @@
{"version":3,"file":"static/js/290.0640644c.js","mappings":"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 truncated
@@ -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
@@ -1 +0,0 @@
{"version":3,"file":"static/js/453.54292a4b.chunk.js","mappings":"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 truncated
@@ -1,2 +0,0 @@
"use strict";(self.webpackChunkfrontend=self.webpackChunkfrontend||[]).push([[792],{857:(e,t,n)=>{var a=n(5043),i=n(4391),o=(n(5884),n(9576),n(3309)),r=n(359),s=n(9043),l=n(9781),c=n(3216),d=n(5475),h=n(9379),u=n(1860),m=n(6291),p=n(579);const x=(0,a.createContext)(void 0),g=e=>{let{children:t}=e;const[n,i]=(0,a.useState)(null),[o,r]=(0,a.useState)(!1),[s,l]=(0,a.useState)(!0),[c,d]=(0,a.useState)(!1);(0,a.useEffect)(()=>{(async()=>{const e=await(0,u.Ec)();d(e);const t=(0,u.gf)();if(t&&(0,u.wR)())try{const n=m.API_URL,a=await fetch("".concat(n,"/auth/me"),{credentials:"include",headers:{Authorization:"Bearer ".concat(t)}});if(a.ok){const o=await a.json(),s=o.user||o;if(i(s),r(!0),!e&&!s.role){if((await fetch("".concat(n,"/auth/make-admin"),{method:"POST",headers:{Authorization:"Bearer ".concat(t),"Content-Type":"application/json"}})).ok){const e=(0,h.A)((0,h.A)({},s),{},{role:"admin"});i(e),d(!0)}}}else(0,u.L)()}catch(n){console.error("Error initializing auth:",n),(0,u.L)()}l(!1)})()},[]);return(0,p.jsx)(x.Provider,{value:{isAuthenticated:o,user:n,isLoading:s,adminExists:c,login:async function(e,t){let n=!(arguments.length>2&&void 0!==arguments[2])||arguments[2];if((0,u.WG)(e,n),"admin"===t.role)d(!0);else if(!c&&!t.role)try{const n=m.API_URL;(await fetch("".concat(n,"/auth/make-admin"),{method:"POST",headers:{Authorization:"Bearer ".concat(e),"Content-Type":"application/json"}})).ok&&(t.role="admin",d(!0))}catch(a){console.error("Error making user admin:",a)}i(t),r(!0)},logout:()=>{try{m.Ay.post("/auth/logout").catch(()=>{})}catch(e){}(0,u.L)(),i(null),r(!1)},updateUser:e=>{n&&i((0,h.A)((0,h.A)({},n),e))}},children:t})},v=()=>{const e=(0,a.useContext)(x);if(void 0===e)throw new Error("useAuth must be used within an AuthProvider");return e};var j=n(502),b=n(4093),y=n(2107),f=n(8659),k=n(3572),z=n(8846),w=n(202),_=n(3225),S=n(6668),C=n(5699),A=n(5973),E=n(9244),T=n(8662),N=n(4946);const R=async()=>(await m.Ay.get("/settings")).data,P=async()=>(await m.Ay.get("/admin/settings")).data,I=async e=>(await m.Ay.put("/admin/settings",e)).data;function W(e){if(e)try{if(/^(?:https?:)?\/\//i.test(e)||/^data:/i.test(e))return e;if(e.startsWith("/uploads")||e.startsWith("/dist")){const t="http://127.0.0.1:8080";if(t&&!t.startsWith("/")){const n=new URL(t,"undefined"!==typeof window?window.location.origin:void 0);return n.pathname="/",new URL(e,n).toString()}return e}return e}catch(t){return e||void 0}}function M(e){if(!e)return"";let t=e.trim();return t=t.replace(/^T\u011blov\xfdchovn\xe1 jednota\s+/i,"TJ "),t=t.replace(/^Sportovn\xed klub\s+/i,"SK "),t=t.replace(/^Fotbalov\xfd klub\s+/i,"FK "),t=t.replace(/^Fotbal klub\s+/i,"FK "),t=t.replace(/^Sokol\s+/i,"SK "),t=t.replace(/,\s*(spolek|z\.\s*s\.|o\.\s*s\.|z\s*s|o\s*s|spolkov\xe1\s+organizace|spolek\s+registrovan\xfd|z\.s\.|o\.s\.)$/i,""),t.trim()}const D=()=>{var e,t;const{isAuthenticated:n,adminExists:i,login:o}=v(),[r,s]=(0,a.useState)(""),[l,d]=(0,a.useState)(""),[h,u]=(0,a.useState)(!1),[x,g]=(0,a.useState)(!1),[P,I]=(0,a.useState)(!0),[M,D]=(0,a.useState)(null),U=(0,c.Zp)(),L=(0,c.zy)(),F=(0,j.d)(),O=(0,b.dU)("white","gray.800");if((0,a.useEffect)(()=>{let e=!0;return(async()=>{try{const t=await R();e&&D(t)}catch(t){}})(),()=>{e=!1}},[]),n)return(0,p.jsx)(c.C5,{to:"/admin",replace:!0});null===(e=L.state)||void 0===e||null===(t=e.from)||void 0===t||t.pathname;return(0,p.jsx)(y.a,{minH:"100vh",display:"flex",alignItems:"center",justifyContent:"center",bg:"gray.50",children:(0,p.jsxs)(y.a,{w:"100%",maxW:"md",p:8,bg:O,borderRadius:"lg",boxShadow:"lg",children:[(0,p.jsx)(f.o,{mb:4,children:null!==M&&void 0!==M&&M.club_logo_url?(0,p.jsx)(k._,{src:W(M.club_logo_url)||M.club_logo_url,alt:M.club_name||"Club logo",maxH:"64px",objectFit:"contain"}):(0,p.jsx)(z.D,{size:"md",children:"P\u0159ihl\xe1\u0161en\xed"})}),(0,p.jsxs)(w.T,{as:"form",onSubmit:async e=>{e.preventDefault(),g(!0);try{const e=await m.Ay.post("/auth/login",{email:r,password:l}),{token:t,user:n}=e.data;o(t,n,P),F({title:"P\u0159ihl\xe1\u0161en\xed prob\u011bhlo \xfasp\u011b\u0161n\u011b",status:"success",duration:3e3,isClosable:!0});const a=String((null===n||void 0===n?void 0:n.role)||"").toLowerCase();U("admin"===a||"editor"===a?"/admin":"/",{replace:!0})}catch(s){var t,n,a,i;F({title:"P\u0159ihl\xe1\u0161en\xed selhalo",description:(null===(t=s.response)||void 0===t||null===(n=t.data)||void 0===n?void 0:n.error)||(null===(a=s.response)||void 0===a||null===(i=a.data)||void 0===i?void 0:i.message)||"Neplatn\xfd e-mail nebo heslo",status:"error",duration:5e3,isClosable:!0})}finally{g(!1)}},spacing:4,align:"stretch",children:[(0,p.jsxs)(_.MJ,{id:"login-email",isRequired:!0,children:[(0,p.jsx)(S.l,{children:"E-mailov\xe1 adresa"}),(0,p.jsx)(C.p,{type:"email",value:r,onChange:e=>s(e.target.value),placeholder:"Zadejte sv\u016fj e-mail"})]}),(0,p.jsxs)(_.MJ,{id:"login-password",isRequired:!0,children:[(0,p.jsx)(S.l,{children:"Heslo"}),(0,p.jsxs)(A.M,{children:[(0,p.jsx)(C.p,{type:h?"text":"password",value:l,onChLine truncated
//# sourceMappingURL=main.8c4bb3a7.js.map
@@ -1 +0,0 @@
{"version":3,"file":"static/js/main.8c4bb3a7.js","mappings":"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 truncated
@@ -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
@@ -1 +0,0 @@
{"version":3,"file":"static/js/runtime.18a1ba68.js","mappings":"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 truncated
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}>E‑mailové 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}
>
E‑shop 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;
Loaded 100 of 505 files, more files were not shown because too many files have changed in this diff. Show more