hot fix #1
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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 |
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 2.9 KiB |
@@ -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>
|
||||
@@ -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 |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
@@ -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"}
|
||||
|
||||
|
After Width: | Height: | Size: 958 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -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 |
@@ -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) {
|
||||
|
||||
@@ -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,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,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,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
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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 |
@@ -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 |
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 2.9 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
@@ -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"}
|
||||
|
||||
|
After Width: | Height: | Size: 958 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -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 |
@@ -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) {
|
||||
|
||||
@@ -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 |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||