This commit is contained in:
Tomas Dvorak
2026-01-26 08:13:18 +01:00
parent aa036b6550
commit dfc079288f
505 changed files with 95755 additions and 5712 deletions
+40 -52
View File
@@ -10,11 +10,12 @@ import { useUmami } from './hooks/useUmami';
import { useFontLoader } from './hooks/useFontLoader';
import DefaultSEO from './components/seo/DefaultSEO';
import CookieBanner from './components/CookieBanner';
import { ConfirmDialogProvider } from './contexts/ConfirmDialogContext';
import ServiceWorkerUpdateListener from './components/common/ServiceWorkerUpdateListener';
import ProtectedRoute from './components/ProtectedRoute';
import { getSetupStatus } from './services/setup';
import { useState, useEffect } from 'react';
import { usePublicSettings } from './hooks/usePublicSettings';
import { getEditorAllowedAdminNav } from './services/navigation';
// Create a client
const queryClient = new QueryClient({
@@ -77,6 +78,7 @@ const OverlayScoreboardPage = lazy(() => import('./pages/OverlayScoreboardPage')
const OverlaySponsorsPage = lazy(() => import('./pages/OverlaySponsorsPage'));
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
const ForbiddenPage = lazy(() => import('./pages/ForbiddenPage'));
const I18nTestPage = lazy(() => import('./pages/I18nTestPage'));
// Legal pages
const CookiePolicyPage = lazy(() => import('./pages/legal/CookiePolicyPage'));
@@ -101,6 +103,7 @@ const AdminVideosPage = lazy(() => import('./pages/admin/AdminVideosPage'));
const GalleryAdminPage = lazy(() => import('./pages/admin/GalleryAdminPage'));
const AdminActivitiesPage = lazy(() => import('./pages/admin/AdminActivitiesPage'));
const AdminMerchPage = lazy(() => import('./pages/admin/AdminMerchPage'));
const AdminEshopProductsPage = lazy(() => import('./pages/admin/AdminEshopProductsPage'));
const AdminResetPasswordPage = lazy(() => import('./pages/admin/AdminResetPasswordPage'));
const AboutAdminPage = lazy(() => import('./pages/admin/AboutAdminPage'));
const AnalyticsAdminPage = lazy(() => import('./pages/admin/AnalyticsAdminPage'));
@@ -116,8 +119,17 @@ const ShortlinksAdminPage = lazy(() => import('./pages/admin/ShortlinksAdminPage
const EngagementAdminPage = lazy(() => import('./pages/admin/EngagementAdminPage'));
const SweepstakesAdminPage = lazy(() => import('./pages/admin/SweepstakesAdminPage'));
const SweepstakeVisualPage = lazy(() => import('./pages/admin/SweepstakeVisualPage'));
const I18nAdminPage = lazy(() => import('./pages/admin/I18nAdminPage'));
const SemiAdminPage = lazy(() => import('./pages/SemiAdminPage'));
const ErrorsAdminPage = lazy(() => import('./pages/admin/ErrorsAdminPage'));
const ManualFacrAdminPage = lazy(() => import('./pages/admin/ManualFacrAdminPage'));
const FinancialDashboard = lazy(() => import('./pages/admin/FinancialDashboard'));
const QRCodesAdminPage = lazy(() => import('./pages/admin/QRCodesAdminPage'));
const ExpensesPage = lazy(() => import('./pages/admin/ExpensesPage'));
const InvoicesPage = lazy(() => import('./pages/admin/InvoicesPage'));
const InvoiceSettingsPage = lazy(() => import('./pages/admin/InvoiceSettingsPage'));
const KontaktyPage = lazy(() => import('./pages/admin/KontaktyPage'));
const TicketAdminPage = lazy(() => import('./pages/admin/TicketAdminPage'));
// Analytics and font loader
const AnalyticsInitializer: React.FC = () => {
@@ -178,50 +190,12 @@ const AdminRoutesWrapper = () => {
return <Outlet />;
};
// Admin index: admins see dashboard; editors redirect to first allowed page
// Admin index: admins and editors see the main dashboard; others are forbidden
const AdminIndexRoute: React.FC = () => {
const { user } = useAuth();
const role = (user as any)?.role;
const [target, setTarget] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(role === 'editor');
useEffect(() => {
let mounted = true;
(async () => {
if (role === 'editor') {
try {
const items: any[] = await getEditorAllowedAdminNav();
let to = '/admin/clanky';
if (Array.isArray(items) && items.length > 0) {
const pickUrl = (it: any): string | null => {
if (it?.url) return it.url;
if (Array.isArray(it?.children) && it.children.length > 0) {
for (const ch of it.children) {
if (ch?.url) return ch.url;
}
}
return null;
};
for (const it of items) {
const u = pickUrl(it);
if (u) { to = u; break; }
}
}
if (mounted) setTarget(to);
} catch (_) {
if (mounted) setTarget('/admin/clanky');
} finally {
if (mounted) setLoading(false);
}
}
})();
return () => { mounted = false; };
}, [role]);
if (role === 'admin') return <AdminDashboardPage />;
if (role === 'editor') {
if (loading) return <PageLoader />;
return <Navigate to={target || '/admin/clanky'} replace />;
const role = String((user as any)?.role || '').toLowerCase();
if (role === 'admin' || role === 'editor') {
return <AdminDashboardPage />;
}
return <Navigate to="/403" replace />;
};
@@ -254,6 +228,8 @@ const AppLazy: React.FC = () => {
<AnalyticsInitializer />
<FontLoader />
<DefaultSEO />
<ConfirmDialogProvider>
<ServiceWorkerUpdateListener />
<Suspense fallback={<PageLoader />}>
<Routes>
{/* Public routes */}
@@ -262,6 +238,7 @@ const AppLazy: React.FC = () => {
<Route path="/search" element={<SearchPage />} />
<Route path="/overlay/scoreboard" element={<OverlayScoreboardPage />} />
<Route path="/overlay/sponsors" element={<OverlaySponsorsPage />} />
<Route path="/i18n-test" element={<I18nTestPage />} />
<Route path="/blog" element={<BlogRoute />} />
<Route path="/klub" element={<ClubPage />} />
<Route path="/o-klubu" element={<AboutPage />} />
@@ -327,30 +304,31 @@ const AppLazy: React.FC = () => {
<Route path="/admin/clanky" element={<ArticlesAdminPage />} />
<Route path="/admin/aktivity" element={<AdminActivitiesPage />} />
<Route path="/admin/shortlinks" element={<ShortlinksAdminPage />} />
<Route path="/admin/tymy" element={<TeamsAdminPage />} />
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
<Route path="/admin/aliasy-soutezi" element={<CompetitionAliasesAdminPage />} />
<Route path="/admin/o-klubu" element={<AboutAdminPage />} />
<Route path="/admin/videa" element={<AdminVideosPage />} />
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
<Route path="/admin/scoreboard" element={<ScoreboardAdminPage />} />
<Route path="/admin/scoreboard/remote" element={<MobileScoreboardControlPage />} />
</Route>
{/* Admin routes */}
<Route element={<ProtectedRoute requiredRole="admin"><AdminRoutesWrapper /></ProtectedRoute>}>
<Route path="/admin/docs" element={<AdminDocsPage />} />
<Route path="/admin/o-klubu" element={<AboutAdminPage />} />
<Route path="/admin/videa" element={<AdminVideosPage />} />
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
<Route path="/admin/eshop-produkty" element={<AdminEshopProductsPage />} />
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
<Route path="/admin/tymy" element={<TeamsAdminPage />} />
<Route path="/admin/uzivatele" element={<UsersAdminPage />} />
<Route path="/admin/bannery" element={<BannersAdminPage />} />
<Route path="/admin/zpravy" element={<MessagesAdminPage />} />
<Route path="/admin/nastaveni" element={<SettingsAdminPage />} />
<Route path="/admin/newsletter" element={<NewsletterAdminPage />} />
<Route path="/admin/ankety" element={<PollsAdminPage />} />
<Route path="/admin/aliasy-soutezi" element={<CompetitionAliasesAdminPage />} />
<Route path="/admin/prefetch" element={<PrefetchAdminPage />} />
<Route path="/admin/users/send-reset" element={<AdminResetPasswordPage />} />
<Route path="/admin/scoreboard" element={<ScoreboardAdminPage />} />
<Route path="/admin/scoreboard/remote" element={<MobileScoreboardControlPage />} />
<Route path="/admin/analytika" element={<AnalyticsAdminPage />} />
<Route path="/admin/errors" element={<ErrorsAdminPage />} />
<Route path="/admin/soubory" element={<FilesAdminPage />} />
@@ -361,6 +339,15 @@ const AppLazy: React.FC = () => {
<Route path="/admin/engagement" element={<EngagementAdminPage />} />
<Route path="/admin/sweepstakes" element={<SweepstakesAdminPage />} />
<Route path="/admin/sweepstakes/:id/visual" element={<SweepstakeVisualPage />} />
<Route path="/admin/i18n" element={<I18nAdminPage />} />
<Route path="/admin/manual-data" element={<ManualFacrAdminPage />} />
<Route path="/admin/financial-dashboard" element={<FinancialDashboard />} />
<Route path="/admin/qr-codes" element={<QRCodesAdminPage />} />
<Route path="/admin/expenses" element={<ExpensesPage />} />
<Route path="/admin/invoices" element={<InvoicesPage />} />
<Route path="/admin/invoice-settings" element={<InvoiceSettingsPage />} />
<Route path="/admin/customers" element={<KontaktyPage />} />
<Route path="/admin/tickets" element={<TicketAdminPage />} />
</Route>
{/* Legacy admin routes */}
@@ -375,6 +362,7 @@ const AppLazy: React.FC = () => {
</Routes>
</Suspense>
<CookieBanner />
</ConfirmDialogProvider>
</HelmetProvider>
</ClubThemeProvider>
</AuthProvider>
+118 -13
View File
@@ -56,7 +56,10 @@ import ShortlinksAdminPage from './pages/admin/ShortlinksAdminPage';
import CommentsAdminPage from './pages/admin/CommentsAdminPage';
import EngagementAdminPage from './pages/admin/EngagementAdminPage';
import SweepstakesAdminPage from './pages/admin/SweepstakesAdminPage';
import FinancialDashboard from './pages/admin/FinancialDashboard';
import QRCodesAdminPage from './pages/admin/QRCodesAdminPage';
import SweepstakeVisualPage from './pages/admin/SweepstakeVisualPage';
import I18nAdminPage from './pages/admin/I18nAdminPage';
import SemiAdminPage from './pages/SemiAdminPage';
import PollsAdminPage from './pages/admin/PollsAdminPage';
// Admin pages render their own AdminLayout internally
@@ -64,12 +67,15 @@ import SetupPage from './pages/SetupPage';
import StylePreviewPage from './pages/StylePreviewPage';
import AboutPage from './pages/AboutPage';
import AdminDocsPage from './pages/admin/AdminDocsPage';
import ManualFacrAdminPage from './pages/admin/ManualFacrAdminPage';
import ScoreboardAdminPage from './pages/admin/ScoreboardAdminPage';
import MobileScoreboardControlPage from './pages/admin/MobileScoreboardControlPage';
import { getSetupStatus } from './services/setup';
import NewsletterUnsubscribePage from './pages/NewsletterUnsubscribePage';
import NewsletterPreferencesPage from './pages/NewsletterPreferencesPage';
import { ClubThemeProvider } from './contexts/ClubThemeContext';
import { ConfirmDialogProvider } from './contexts/ConfirmDialogContext';
import ServiceWorkerUpdateListener from './components/common/ServiceWorkerUpdateListener';
import CookiePolicyPage from './pages/legal/CookiePolicyPage';
import OverlayScoreboardPage from './pages/OverlayScoreboardPage';
import OverlaySponsorsPage from './pages/OverlaySponsorsPage';
@@ -82,7 +88,6 @@ import ForbiddenPage from './pages/ForbiddenPage';
import NotFoundPage from './pages/NotFoundPage';
import VideosPage from './pages/VideosPage';
import SearchPage from './pages/SearchPage';
import ShortRedirectPage from './pages/ShortRedirectPage';
import ClothingPage from './pages/ClothingPage';
import PollsPage from './pages/PollsPage';
import { useUmami } from './hooks/useUmami';
@@ -277,7 +282,7 @@ const FontLoader: React.FC = () => {
return null;
};
// Component to trigger daily check-in for authenticated users (once per day per device)
// Component to trigger daily check-in for authenticated users (once per day)
const CheckinInitializer: React.FC = () => {
const { isAuthenticated } = useAuth();
useEffect(() => {
@@ -399,10 +404,12 @@ const App: React.FC = () => {
<Router>
<AuthProvider>
<ClubThemeProvider>
<ConfirmDialogProvider>
<AnalyticsInitializer />
<FontLoader />
<RouteLogger />
<CheckinInitializer />
<ServiceWorkerUpdateListener />
<DefaultSEO />
<Routes>
{/* Public routes */}
@@ -433,8 +440,7 @@ const App: React.FC = () => {
<Route path="/pravidla-cookies" element={<CookiePolicyPage />} />
<Route path="/obchodni-podminky" element={<TermsPage />} />
<Route path="/zasady-ochrany-osobnich-udaju" element={<PrivacyPolicyPage />} />
{/* Short links - forward to backend origin if frontend captured it */}
<Route path="/s/:code" element={<ShortRedirectPage />} />
{/* Short links are handled by nginx -> backend, not React Router */}
<Route path="/news" element={<NewsRedirect />} />
{/* Slug routes must precede id route to avoid conflicts */}
<Route path="/news/:slug" element={<ArticleDetailPage />} />
@@ -510,26 +516,21 @@ const App: React.FC = () => {
}>
<Route path="/admin/docs" element={<AdminDocsPage />} />
{/* moved to editor-accessible routes below */}
<Route path="/admin/o-klubu" element={<AboutAdminPage />} />
<Route path="/admin/videa" element={<AdminVideosPage />} />
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
{/* moved to editor-accessible routes below */}
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
<Route path="/admin/tymy" element={<TeamsAdminPage />} />
<Route path="/admin/uzivatele" element={<UsersAdminPage />} />
<Route path="/admin/bannery" element={<BannersAdminPage />} />
<Route path="/admin/zpravy" element={<MessagesAdminPage />} />
<Route path="/admin/nastaveni" element={<SettingsAdminPage />} />
<Route path="/admin/newsletter" element={<NewsletterAdminPage />} />
<Route path="/admin/ankety" element={<PollsAdminPage />} />
<Route path="/admin/aliasy-soutezi" element={<CompetitionAliasesAdminPage />} />
<Route path="/admin/prefetch" element={<PrefetchAdminPage />} />
<Route path="/admin/users/send-reset" element={<AdminResetPasswordPage />} />
<Route path="/admin/scoreboard" element={<ScoreboardAdminPage />} />
<Route path="/admin/scoreboard/remote" element={<MobileScoreboardControlPage />} />
<Route path="/admin/analytika" element={<AnalyticsAdminPage />} />
<Route path="/admin/soubory" element={<FilesAdminPage />} />
<Route path="/admin/kontakty" element={<ContactsAdminPage />} />
@@ -538,6 +539,7 @@ const App: React.FC = () => {
<Route path="/admin/engagement" element={<EngagementAdminPage />} />
<Route path="/admin/sweepstakes" element={<SweepstakesAdminPage />} />
<Route path="/admin/sweepstakes/:id/visual" element={<SweepstakeVisualPage />} />
<Route path="/admin/jazyky" element={<I18nAdminPage />} />
</Route>
{/* Remaining protected routes that don't use AdminLayout */}
@@ -604,11 +606,114 @@ const App: React.FC = () => {
}
/>
{/* Financial Management */}
<Route
path="/admin/financial-dashboard"
element={
<ProtectedRoute requiredRole="admin">
<FinancialDashboard />
</ProtectedRoute>
}
/>
{/* QR Codes */}
<Route
path="/admin/qr-codes"
element={
<ProtectedRoute requiredRole="admin">
<QRCodesAdminPage />
</ProtectedRoute>
}
/>
{/* Editor-level admin pages (also allow admin) */}
<Route
path="/admin/tymy"
element={
<ProtectedRoute requiredRole="editor">
<TeamsAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/zapasy"
element={
<ProtectedRoute requiredRole="editor">
<MatchesAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/hraci"
element={
<ProtectedRoute requiredRole="editor">
<PlayersAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/aliasy-soutezi"
element={
<ProtectedRoute requiredRole="editor">
<CompetitionAliasesAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/manual-data"
element={
<ProtectedRoute requiredRole="admin">
<ManualFacrAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/o-klubu"
element={
<ProtectedRoute requiredRole="editor">
<AboutAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/videa"
element={
<ProtectedRoute requiredRole="editor">
<AdminVideosPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/galerie"
element={
<ProtectedRoute requiredRole="editor">
<GalleryAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/scoreboard"
element={
<ProtectedRoute requiredRole="editor">
<ScoreboardAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/scoreboard/remote"
element={
<ProtectedRoute requiredRole="editor">
<MobileScoreboardControlPage />
</ProtectedRoute>
}
/>
{/* Not found route */}
<Route path="*" element={<NotFoundRoute />} />
</Routes>
{/* Cookie consent banner shown across the whole site */}
<CookieBanner />
</ConfirmDialogProvider>
</ClubThemeProvider>
</AuthProvider>
</Router>
+203 -307
View File
@@ -4,7 +4,6 @@ import {
Flex,
Button,
useColorModeValue,
useColorMode,
IconButton,
Avatar,
Menu,
@@ -36,7 +35,7 @@ import {
Input,
useToast,
} from '@chakra-ui/react';
import { MoonIcon, SunIcon, HamburgerIcon, EditIcon, ChevronDownIcon } from '@chakra-ui/icons';
import { HamburgerIcon, EditIcon, ChevronDownIcon } from '@chakra-ui/icons';
import { FaFacebook, FaInstagram, FaYoutube, FaPhotoVideo, FaExternalLinkAlt, FaShoppingBag, FaCamera, FaSearch } from 'react-icons/fa';
import { Link as RouterLink, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
@@ -49,6 +48,7 @@ import { getNavigationItems, NavigationItem, seedDefaultNavigation } from '../se
import { getEvents } from '../services/eventService';
import { getPlayers } from '../services/public';
import { getArticles } from '../services/articles';
import { LanguageSwitcher } from './common/LanguageSwitcher';
import { getCachedYouTube } from '../services/youtube';
import { getZoneramaManifestWithFallbacks } from '../services/zonerama';
import { getMyNewsletterToken } from '../services/public/newsletter';
@@ -57,6 +57,9 @@ import { assetUrl } from '../utils/url';
import { getProfile as getEngagementProfile, EngagementProfile } from '../services/engagement';
import AchievementsModal from './engagement/AchievementsModal';
import RewardsModal from './engagement/RewardsModal';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { useTranslation } from 'react-i18next';
import { useNavbarData } from '../hooks/useNavbarData';
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
@@ -85,7 +88,7 @@ const normalizeSocialUrl = (network: 'facebook' | 'instagram' | 'youtube', raw?:
};
// Mobile menu component
const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, dividerColor, settings, categories, galleryHref, galleryLabel, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, dynamicNavItems, navLoading }: {
const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, dividerColor, settings, categories, galleryHref, galleryLabel, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, dynamicNavItems, navLoading, t, i18n, handleSimpleLanguageChange }: {
isOpen: boolean;
onClose: () => void;
isAdmin: boolean;
@@ -96,25 +99,27 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
categories?: Category[] | null;
galleryHref?: string | null;
galleryLabel?: string;
hasTables?: boolean | null;
hasActivities?: boolean | null;
hasPlayers?: boolean | null;
hasArticles?: boolean | null;
hasVideos?: boolean | null;
hasGallery?: boolean | null;
dynamicNavItems: NavigationItem[];
navLoading: boolean;
hasTables?: boolean;
hasActivities?: boolean;
hasPlayers?: boolean;
hasArticles?: boolean;
hasVideos?: boolean;
hasGallery?: boolean;
dynamicNavItems?: NavigationItem[];
navLoading?: boolean;
t: (key: string) => string;
i18n: any;
handleSimpleLanguageChange: (lang: string) => void;
}) => (
<Drawer isOpen={isOpen} placement="left" onClose={onClose}>
<DrawerOverlay />
<DrawerContent bg={menuBg}>
<DrawerCloseButton />
<DrawerHeader borderBottomWidth="1px" borderColor="border.subtle">Menu</DrawerHeader>
<DrawerHeader borderBottomWidth="1px" borderColor="border.subtle">{t('action.open_menu')}</DrawerHeader>
<DrawerBody>
<VStack align="stretch" spacing={2}>
{/* Dynamic navigation items in mobile */}
{(!navLoading && dynamicNavItems.length > 0) ? (
// Use dynamic navigation
{(!navLoading && dynamicNavItems && dynamicNavItems.length > 0) ? (
dynamicNavItems.map((item, idx) => {
const linkIsExternal = item.type === 'external';
const hasChildren = item.type === 'dropdown' && item.children && item.children.length > 0;
@@ -138,7 +143,7 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
{item.label}
</Button>
<VStack align="stretch" pl={4} spacing={1}>
{categories.map((cat: any) => {
{categories.map((cat: any, catIdx: number) => {
const catIsExternal = typeof cat.url === 'string' && /^https?:\/\//i.test(cat.url);
const catHref = cat.url || (cat.id ? `/blog?category_id=${cat.id}` : (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog'));
const catLinkProps = catIsExternal ? { href: catHref } : { to: catHref };
@@ -196,20 +201,20 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
) : (
// Fallback to hardcoded navigation
<>
<Button as={RouterLink} to="/" variant="ghost" justifyContent="flex-start">Domů</Button>
<Button as={RouterLink} to="/" variant="ghost" justifyContent="flex-start">{t('nav.home')}</Button>
{(settings?.show_about_in_nav ?? true) && (
<Button as={RouterLink} to="/o-klubu" variant="ghost" justifyContent="flex-start">O klubu</Button>
<Button as={RouterLink} to="/o-klubu" variant="ghost" justifyContent="flex-start">{t('nav.club')}</Button>
)}
<Button as={RouterLink} to="/kalendar" variant="ghost" justifyContent="flex-start">Kalendář</Button>
<Button as={RouterLink} to="/zapasy" variant="ghost" justifyContent="flex-start">Zápasy</Button>
{hasActivities === true && (
<Button as={RouterLink} to="/aktivity" variant="ghost" justifyContent="flex-start">Aktivity</Button>
<Button as={RouterLink} to="/kalendar" variant="ghost" justifyContent="flex-start">{t('nav.calendar')}</Button>
<Button as={RouterLink} to="/zapasy" variant="ghost" justifyContent="flex-start">{t('nav.matches')}</Button>
{hasActivities && (
<Button as={RouterLink} to="/aktivity" variant="ghost" justifyContent="flex-start">{t('nav.activities')}</Button>
)}
{hasPlayers === true && (
<Button as={RouterLink} to="/hraci" variant="ghost" justifyContent="flex-start">Hráči</Button>
{hasPlayers && (
<Button as={RouterLink} to="/hraci" variant="ghost" justifyContent="flex-start">{t('nav.players')}</Button>
)}
{hasTables === true && (
<Button as={RouterLink} to="/tabulky" variant="ghost" justifyContent="flex-start">Tabulky</Button>
{hasTables && (
<Button as={RouterLink} to="/tabulky" variant="ghost" justifyContent="flex-start">{t('nav.tables')}</Button>
)}
{Array.isArray(settings?.custom_nav) && settings.custom_nav.length > 0 && settings.custom_nav.map((item: any, idx: number) => {
const customLinkIsExternal = typeof item?.url === 'string' && /^https?:\/\//i.test(item.url);
@@ -225,17 +230,19 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
variant="ghost"
justifyContent="flex-start"
>
{item?.label || 'Stránka'}
{item?.label || t('common.page')}
</Button>
);
})}
{hasArticles === true && (
{hasArticles && (
<>
{Array.isArray(categories) && categories.length > 0 ? (
<>
<Button as={RouterLink} to="/blog" onClick={onClose} variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
<Button as={RouterLink} to="/blog" onClick={onClose} variant="ghost" justifyContent="flex-start" fontWeight="bold">
{t('nav.articles')}
</Button>
<VStack align="stretch" pl={4} spacing={1}>
{categories.map((cat: any) => {
{categories.map((cat: any, catIdx: number) => {
const catIsExternal = typeof cat.url === 'string' && /^https?:\/\//i.test(cat.url);
const catHref = cat.url || (cat.id ? `/blog?category_id=${cat.id}` : (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog'));
const catLinkProps = catIsExternal ? { href: catHref } : { to: catHref };
@@ -248,31 +255,33 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
</VStack>
</>
) : (
<Button as={RouterLink} to="/blog" onClick={onClose} variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
<Button as={RouterLink} to="/blog" onClick={onClose} variant="ghost" justifyContent="flex-start" fontWeight="bold">
{t('nav.articles')}
</Button>
)}
</>
)}
{hasVideos === true && (
<Button as={RouterLink} to="/videa" variant="ghost" justifyContent="flex-start">Videa</Button>
{hasVideos && (
<Button as={RouterLink} to="/videa" variant="ghost" justifyContent="flex-start">{t('nav.videos')}</Button>
)}
<Button as={RouterLink} to="/hledat" variant="ghost" justifyContent="flex-start">Hledat</Button>
{hasGallery === true && (
<Button as={RouterLink} to="/galerie" variant="ghost" justifyContent="flex-start">{galleryLabel || 'Fotogalerie'}</Button>
<Button as={RouterLink} to="/hledat" variant="ghost" justifyContent="flex-start">{t('action.search')}</Button>
{hasGallery && (
<Button as={RouterLink} to="/galerie" variant="ghost" justifyContent="flex-start">{galleryLabel || t('homepage.gallery')}</Button>
)}
{settings?.shop_url && (
<Button as="a" href={settings.shop_url} target="_blank" rel="noreferrer" variant="ghost" justifyContent="flex-start">Fanshop</Button>
<Button as="a" href={settings.shop_url} target="_blank" rel="noreferrer" variant="ghost" justifyContent="flex-start">{t('nav.shop')}</Button>
)}
<Button as={RouterLink} to="/sponzori" variant="ghost" justifyContent="flex-start">Sponzoři</Button>
<Button as={RouterLink} to="/kontakt" variant="ghost" justifyContent="flex-start">Kontakt</Button>
<Button as={RouterLink} to="/sponzori" variant="ghost" justifyContent="flex-start">{t('nav.sponsors')}</Button>
<Button as={RouterLink} to="/kontakt" variant="ghost" justifyContent="flex-start">{t('nav.contact')}</Button>
</>
)}
{isAdmin && (
<>
<Divider my={2} borderColor={dividerColor} />
<Text fontWeight="bold" mt={2} color={dividerColor}>Administrace</Text>
<Text fontWeight="bold" mt={2} color={dividerColor}>{t('nav.admin')}</Text>
<Button as={RouterLink} to="/admin" variant="ghost" justifyContent="flex-start" colorScheme="blue">
Administrace
{t('nav.admin')}
</Button>
</>
)}
@@ -281,13 +290,16 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
<>
<Divider my={2} borderColor={dividerColor} />
<Button as={RouterLink} to="/login" colorScheme="blue" justifyContent="flex-start">
Přihlásit se
{t('action.login')}
</Button>
<Button as={RouterLink} to="/register" variant="outline" justifyContent="flex-start">
Registrovat se
{t('common.register')}
</Button>
</>
)}
{/* Language switcher for mobile */}
<LanguageSwitcher />
</VStack>
</DrawerBody>
</DrawerContent>
@@ -295,7 +307,7 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
);
const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth = false, variant = 'unified' }) => {
const { colorMode, toggleColorMode } = useColorMode();
const { t, i18n } = useTranslation();
const { isAuthenticated, logout, user } = useAuth();
const { isOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isSearchOpen, onOpen: onSearchOpen, onClose: onSearchClose } = useDisclosure();
@@ -314,20 +326,15 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
const navTextColor = useColorModeValue('gray.700', 'gray.200');
const topBarBg = useColorModeValue('gray.50', 'blackAlpha.500');
const [scrolled, setScrolled] = useState(false);
const [hasTables, setHasTables] = useState<boolean | null>(null);
const [hasActivities, setHasActivities] = useState<boolean | null>(null);
const [hasPlayers, setHasPlayers] = useState<boolean | null>(null);
const [hasArticles, setHasArticles] = useState<boolean | null>(null);
const [hasVideos, setHasVideos] = useState<boolean | null>(null);
const [hasGallery, setHasGallery] = useState<boolean | null>(null);
const [dynamicNavItems, setDynamicNavItems] = useState<NavigationItem[]>([]);
const [navLoading, setNavLoading] = useState(true);
const containerMaxW = fullWidth ? 'full' as const : '7xl' as const;
const [windowWidth, setWindowWidth] = useState<number>(typeof window !== 'undefined' ? window.innerWidth : 1920);
const [engProfile, setEngProfile] = useState<EngagementProfile | null>(null);
const { isOpen: isAchOpen, onOpen: onAchOpen, onClose: onAchClose } = useDisclosure();
const { isOpen: isRewOpen, onOpen: onRewOpen, onClose: onRewClose } = useDisclosure();
// Use the combined navbar data hook
const navbarData = useNavbarData(isAdmin, settings);
useEffect(() => {
const onResize = () => setWindowWidth(window.innerWidth);
window.addEventListener('resize', onResize);
@@ -379,6 +386,24 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
return () => window.removeEventListener('scroll', onScroll as any);
}, []);
// Simple language change handler for non-logged-in users
const handleSimpleLanguageChange = async (languageCode: string) => {
try {
// Change language in i18next
await i18n.changeLanguage(languageCode);
// Save to localStorage and cookie
try {
localStorage.setItem('language', languageCode);
} catch (e) {
// Ignore localStorage errors
}
document.cookie = `lang=${languageCode}; max-age=${365 * 24 * 60 * 60}; path=/`;
} catch (error) {
console.error('Failed to change language:', error);
}
};
// Open newsletter preferences for logged-in user (fetch token and redirect)
const openMyNewsletterPrefs = async () => {
try {
@@ -427,162 +452,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
// gallery link (generic first, fallback to zonerama)
const galleryHref = settings?.gallery_url || settings?.zonerama_url;
const galleryLabel = settings?.gallery_label || 'Fotogalerie';
// Load dynamic navigation from API
useEffect(() => {
let active = true;
(async () => {
try {
const items = await getNavigationItems();
if (active && Array.isArray(items)) {
// Filter out admin-only navigation items for public display
const publicItems = items.filter(item => !item.requires_admin);
// Auto-seed if navigation is empty (only if user is authenticated as admin)
if (publicItems.length === 0 && isAdmin) {
try {
console.log('Navigation empty, auto-seeding...');
await seedDefaultNavigation();
const newItems = await getNavigationItems();
if (active && Array.isArray(newItems)) {
const publicNewItems = newItems.filter(item => !item.requires_admin);
setDynamicNavItems(publicNewItems);
}
} catch (seedError) {
console.error('Auto-seed failed:', seedError);
// Continue with empty navigation
}
} else {
setDynamicNavItems(publicItems);
}
}
} catch (error) {
console.error('Failed to load navigation:', error);
} finally {
if (active) setNavLoading(false);
}
})();
return () => { active = false };
}, [isAdmin]);
// categories: prefer API, fallback to settings.categories
const [navCategories, setNavCategories] = useState<Category[] | null>(null);
useEffect(() => {
let active = true;
(async () => {
try {
const cats = await getCategories();
if (active && Array.isArray(cats) && cats.length > 0) {
setNavCategories(cats);
} else if (active && Array.isArray(settings?.categories)) {
setNavCategories(settings!.categories as any);
}
} catch {
if (active && Array.isArray(settings?.categories)) {
setNavCategories(settings!.categories as any);
}
}
})();
return () => { active = false };
}, [settings?.categories]);
// Determine if there is any table data available (prefetch snapshot)
useEffect(() => {
let disposed = false;
const resolveBackendUrl = (path: string) => {
try {
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
return new URL(path, origin).toString();
}
return path;
} catch { return path; }
};
(async () => {
try {
const res = await fetch(resolveBackendUrl('/cache/prefetch/facr_tables.json'), { cache: 'no-cache' });
if (!res.ok) { if (!disposed) setHasTables(false); return; }
const json = await res.json();
const anyRows = Array.isArray(json?.competitions) && json.competitions.some((c: any) => Array.isArray(c?.table?.overall) && c.table.overall.length > 0);
if (!disposed) setHasTables(!!anyRows);
} catch {
if (!disposed) setHasTables(false);
}
})();
return () => { disposed = true; };
}, []);
// Determine if there are any activities/events available
useEffect(() => {
let disposed = false;
(async () => {
try {
const events = await getEvents();
if (!disposed) setHasActivities(Array.isArray(events) && events.length > 0);
} catch {
if (!disposed) setHasActivities(false);
}
})();
return () => { disposed = true; };
}, []);
// Determine if there are any players available
useEffect(() => {
let disposed = false;
(async () => {
try {
const players = await getPlayers();
if (!disposed) setHasPlayers(Array.isArray(players) && players.length > 0);
} catch {
if (!disposed) setHasPlayers(false);
}
})();
return () => { disposed = true; };
}, []);
// Determine if there are any articles available
useEffect(() => {
let disposed = false;
(async () => {
try {
const result = await getArticles({ page: 1, page_size: 1, published: true });
if (!disposed) setHasArticles(result.total > 0);
} catch {
if (!disposed) setHasArticles(false);
}
})();
return () => { disposed = true; };
}, []);
// Determine if there are any videos available
useEffect(() => {
let disposed = false;
(async () => {
try {
const youtube = await getCachedYouTube();
if (!disposed) setHasVideos(youtube && Array.isArray(youtube.videos) && youtube.videos.length > 0);
} catch {
if (!disposed) setHasVideos(false);
}
})();
return () => { disposed = true; };
}, []);
// Determine if there is any gallery content available
useEffect(() => {
let disposed = false;
(async () => {
try {
const manifest = await getZoneramaManifestWithFallbacks();
if (!disposed) setHasGallery(Array.isArray(manifest) && manifest.length > 0);
} catch {
if (!disposed) setHasGallery(false);
}
})();
return () => { disposed = true; };
}, []);
const galleryLabel = settings?.gallery_label || t('homepage.gallery');
const isPathActive = (to?: string) => {
if (!to) return false;
@@ -592,8 +462,30 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
// Convert NavigationItem to NavLink format
const convertToNavLink = (item: NavigationItem): NavLink => {
// Map known Czech labels to translation keys
const getTranslatedLabel = (label: string) => {
const labelMap: Record<string, string> = {
'Domů': 'nav.home',
'Aktuality': 'nav.news',
'Zápasy': 'nav.matches',
'Hráči': 'nav.players',
'Fotogalerie': 'nav.gallery',
'Videa': 'nav.videos',
'Kontakt': 'nav.contact',
'O klubu': 'nav.about',
'Aktivity': 'nav.activities',
'Sponzoři': 'nav.sponsors',
'Články': 'nav.news',
'Blog': 'nav.news',
'Kalendář': 'nav.calendar',
'Tabulky': 'nav.table'
};
const translationKey = labelMap[label];
return translationKey ? t(translationKey) : label;
};
const link: NavLink = {
label: item.label,
label: getTranslatedLabel(item.label),
to: item.url || '#',
external: item.type === 'external',
};
@@ -601,7 +493,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
// Add children for dropdown items
if (item.type === 'dropdown' && item.children && item.children.length > 0) {
link.items = item.children.map(child => ({
label: child.label,
label: getTranslatedLabel(child.label),
to: child.url || '#',
}));
}
@@ -611,34 +503,35 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
// Build categories as items for Články dropdown (fallback)
const categoryItems = useMemo(() => {
const source = Array.isArray(navCategories) && navCategories.length > 0 ? navCategories : [];
const source = Array.isArray(navbarData.categories) && navbarData.categories.length > 0 ? navbarData.categories : [];
return source.map((cat: any) => ({
label: cat.name,
to: cat.url || (cat.id ? `/blog?category_id=${cat.id}` : (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog'))
}));
}, [navCategories]);
}, [navbarData.categories]);
// Filter dynamic navigation items based on available data (only show when data exists)
const filteredDynamicNavItems = useMemo(() => {
const filterItem = (item: NavigationItem): NavigationItem | null => {
const url = item.url || '';
if (url.startsWith('/aktivity') && hasActivities !== true) return null;
if (url.startsWith('/hraci') && hasPlayers !== true) return null;
if (url.startsWith('/blog') && hasArticles !== true) return null;
if (url.startsWith('/videa') && hasVideos !== true) return null;
if (url.startsWith('/galerie') && hasGallery !== true) return null;
if (url.startsWith('/aktivity') && !navbarData.hasActivities) return null;
if (url.startsWith('/hraci') && !navbarData.hasPlayers) return null;
if (url.startsWith('/blog') && !navbarData.hasArticles) return null;
if (url.startsWith('/videa') && !navbarData.hasVideos) return null;
if (url.startsWith('/galerie') && !navbarData.hasGallery) return null;
if (item.type === 'dropdown' && Array.isArray(item.children)) {
const children = item.children.map(filterItem).filter(Boolean) as NavigationItem[];
return { ...item, children };
}
return item;
};
return dynamicNavItems.map(filterItem).filter(Boolean) as NavigationItem[];
}, [dynamicNavItems, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery]);
return navbarData.dynamicNavItems.map(filterItem).filter(Boolean) as NavigationItem[];
}, [navbarData.dynamicNavItems, navbarData.hasActivities, navbarData.hasPlayers, navbarData.hasArticles, navbarData.hasVideos, navbarData.hasGallery]);
// Use dynamic navigation if available, otherwise fallback to hardcoded
let NAV_LINKS: NavLink[] = useMemo(() => {
if (!navLoading && filteredDynamicNavItems.length > 0) {
if (!navbarData.navLoading && filteredDynamicNavItems.length > 0) {
console.log('Navbar: Using dynamic navigation, items:', filteredDynamicNavItems.length);
// Use dynamic navigation from API
const navLinks = filteredDynamicNavItems.map(convertToNavLink);
@@ -662,36 +555,74 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
// Ensure we only show sections when there is data
const filtered = navLinks.filter((link) => {
const to = link.to || '';
if (to.startsWith('/aktivity')) return hasActivities === true;
if (to.startsWith('/hraci')) return hasPlayers === true;
if (to.startsWith('/blog')) return hasArticles === true;
if (to.startsWith('/videa')) return hasVideos === true;
if (to.startsWith('/galerie')) return hasGallery === true;
if (to.startsWith('/aktivity')) return navbarData.hasActivities;
if (to.startsWith('/hraci')) return navbarData.hasPlayers;
if (to.startsWith('/blog')) return navbarData.hasArticles;
if (to.startsWith('/videa')) return navbarData.hasVideos;
if (to.startsWith('/galerie')) return navbarData.hasGallery;
return true;
});
return filtered;
}
// Fallback to hardcoded navigation
let links: NavLink[] = [
{ label: 'Domů', to: '/' },
...(settings?.show_about_in_nav === false ? [] : [{ label: 'O klubu', to: '/o-klubu' } as NavLink]),
{ label: 'Kalendář', to: '/kalendar' },
{ label: 'Zápasy', to: '/zapasy' },
{ label: 'Aktivity', to: '/aktivity' },
{ label: 'Hráči', to: '/hraci' },
{ label: 'Tabulky', to: '/tabulky' },
// Články with categories as subcategories
categoryItems.length > 0
? { label: 'Články', to: '/blog', items: categoryItems }
: { label: 'Články', to: '/blog' },
{ label: 'Videa', to: '/videa' },
{ label: galleryLabel, to: '/galerie' },
...(settings?.shop_url ? [{ label: 'Fanshop', to: settings.shop_url, external: true } as NavLink] : []),
{ label: 'Sponzoři', to: '/sponzori' },
{ label: 'Kontakt', to: '/kontakt' },
console.log('Navbar: Using fallback hardcoded navigation, language:', i18n.language);
// Fallback to hardcoded navigation - modular based on language
const currentLanguage = i18n.language;
let links: NavLink[] = [];
// Base navigation items for all languages
const baseLinks: NavLink[] = [
{ label: t('nav.home'), to: '/' },
];
// Add club info if enabled
if (settings?.show_about_in_nav !== false) {
baseLinks.push({ label: t('nav.club'), to: '/o-klubu' });
}
// Add calendar and matches for all languages
baseLinks.push(
{ label: t('nav.calendar'), to: '/kalendar' },
{ label: t('nav.matches'), to: '/zapasy' }
);
// Add optional items only if data exists (for all languages)
if (navbarData.hasActivities) {
baseLinks.push({ label: t('nav.activities'), to: '/aktivity' });
}
if (navbarData.hasPlayers) {
baseLinks.push({ label: t('nav.players'), to: '/hraci' });
}
if (navbarData.hasTables) {
baseLinks.push({ label: t('nav.tables'), to: '/tabulky' });
}
if (navbarData.hasArticles) {
baseLinks.push(
categoryItems.length > 0
? { label: t('nav.articles'), to: '/blog', items: categoryItems }
: { label: t('nav.articles'), to: '/blog' }
);
}
if (navbarData.hasVideos) {
baseLinks.push({ label: t('nav.videos'), to: '/videa' });
}
if (navbarData.hasGallery) {
baseLinks.push({ label: galleryLabel, to: '/galerie' });
}
// Always show sponsors and contact
baseLinks.push(
{ label: t('nav.sponsors'), to: '/sponzori' },
{ label: t('nav.contact'), to: '/kontakt' }
);
links = baseLinks;
// Add shop if configured
if (settings?.shop_url) {
links.push({ label: t('nav.shop'), to: settings.shop_url, external: true } as NavLink);
}
// Inject custom pages from settings.custom_nav (label + url + external?)
const customNav = Array.isArray((settings as any)?.custom_nav) ? ((settings as any).custom_nav as any[]) : [];
@@ -704,39 +635,9 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
links = [...links, ...mapped];
}
}
// Hide Tabulky when there is no table data
if (hasTables === false) {
links = links.filter((n) => n.label !== 'Tabulky');
}
// Hide Aktivity unless there are activities
if (hasActivities !== true) {
links = links.filter((n) => n.label !== 'Aktivity');
}
// Hide Hráči unless there are players
if (hasPlayers !== true) {
links = links.filter((n) => n.label !== 'Hráči');
}
// Hide Články unless there are articles
if (hasArticles !== true) {
links = links.filter((n) => n.label !== 'Články');
}
// Hide Videa unless there are videos
if (hasVideos !== true) {
links = links.filter((n) => n.label !== 'Videa');
}
// Hide Fotogalerie unless there is gallery content
if (hasGallery !== true) {
links = links.filter((n) => n.to !== '/galerie');
}
return links;
}, [filteredDynamicNavItems, navLoading, settings, categoryItems, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, galleryLabel]);
}, [filteredDynamicNavItems, navbarData.navLoading, settings, categoryItems, navbarData.hasTables, navbarData.hasActivities, navbarData.hasPlayers, navbarData.hasArticles, navbarData.hasVideos, navbarData.hasGallery, galleryLabel, i18n.language, t]);
// Split navigation into visible and overflow for desktop
const navSplit = useMemo(() => {
@@ -749,7 +650,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
else if (w >= 1100) maxVisible = 7;
else maxVisible = 6;
if (links.length <= maxVisible) return { visible: links, overflow: [] as NavLink[] };
const visibleCount = Math.max(1, maxVisible - 1); // reserve one slot for "Další"
const visibleCount = Math.max(1, maxVisible - 1); // reserve one slot for "More"
return { visible: links.slice(0, visibleCount), overflow: links.slice(visibleCount) };
}, [NAV_LINKS, windowWidth]);
@@ -799,7 +700,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
<HStack spacing={2}>
{settings?.shop_url && (
<Button as="a" href={settings.shop_url} target="_blank" rel="noreferrer" variant="link" size="xs" leftIcon={<FaShoppingBag />}>
Fanshop
{t('nav.shop')}
</Button>
)}
</HStack>
@@ -828,7 +729,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
boxShadow={navBoxShadow}
transition="box-shadow 0.2s ease, background-color 0.2s ease, backdrop-filter 0.2s ease"
>
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} isAuthenticated={isAuthenticated} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navCategories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={hasTables} hasActivities={hasActivities} hasPlayers={hasPlayers} hasArticles={hasArticles} hasVideos={hasVideos} hasGallery={hasGallery} dynamicNavItems={filteredDynamicNavItems} navLoading={navLoading} />
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} isAuthenticated={isAuthenticated} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navbarData.categories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={navbarData.hasTables} hasActivities={navbarData.hasActivities} hasPlayers={navbarData.hasPlayers} hasArticles={navbarData.hasArticles} hasVideos={navbarData.hasVideos} hasGallery={navbarData.hasGallery} dynamicNavItems={filteredDynamicNavItems || []} navLoading={navbarData.navLoading} t={t} i18n={i18n} handleSimpleLanguageChange={handleSimpleLanguageChange} />
<Container maxW={containerMaxW} px={fullWidth ? 0 : undefined}>
<Flex h={16} alignItems="center" justifyContent="space-between">
<HStack spacing={4} alignItems="center">
@@ -885,7 +786,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
);
})}
{navSplit.overflow.length > 0 && (
<HoverMenu key="more" label="Další" items={moreItems} />
<HoverMenu key="more" label={t('action.more')} items={moreItems} />
)}
</HStack>
</HStack>
@@ -896,7 +797,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
display={{ base: 'flex', md: 'none' }}
onClick={onOpen}
icon={<HamburgerIcon />}
aria-label="Otevřít menu"
aria-label={t('action.open_menu')}
variant="ghost"
mr={2}
/>
@@ -905,9 +806,9 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
<Box display={{ base: 'none', md: 'flex' }} mr={2} />
{/* Search button */}
<Tooltip label="Hledat" hasArrow>
<Tooltip label={t('action.search')} hasArrow>
<IconButton
aria-label="Hledat"
aria-label={t('action.search')}
icon={<FaSearch />}
size="sm"
mr={2}
@@ -918,11 +819,11 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
{/* Admin edit button */}
{isAdmin && (
<Tooltip label="Správa obsahu" hasArrow>
<Tooltip label={t('action.content_admin')} hasArrow>
<IconButton
as={RouterLink}
to="/admin"
aria-label="Správa obsahu"
aria-label={t('action.content_admin')}
icon={<EditIcon />}
size="sm"
mr={2}
@@ -932,16 +833,11 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
</Tooltip>
)}
{/* Language switcher - compact dropdown for all users */}
<LanguageSwitcher />
{/* Color mode toggle */}
<IconButton
size="md"
fontSize="lg"
aria-label="Přepnout barevné téma"
variant="ghost"
color="current"
onClick={toggleColorMode}
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
/>
<ThemeToggle />
{/* Auth buttons (desktop) */}
{!isAuthenticated && (
@@ -955,7 +851,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
ml={2}
mr={2}
>
Registrovat se
{t('auth.register')}
</Button>
<Button
as={RouterLink}
@@ -965,7 +861,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
display={{ base: 'none', md: 'inline-flex' }}
mr={2}
>
Přihlásit se
{t('auth.login')}
</Button>
</>
)}
@@ -980,25 +876,25 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
minW={0}
ml={2}
>
<Avatar size="sm" name={user?.name || 'Uživatel'} src={engProfile?.animated_avatar_url || engProfile?.avatar_url || undefined} />
<Avatar size="sm" name={user?.name || t('common.user')} src={engProfile?.animated_avatar_url || engProfile?.avatar_url || undefined} />
</MenuButton>
<MenuList>
<MenuItem isDisabled>{`Úroveň ${engProfile?.level ?? 1}${engProfile?.points ?? 0} bodů`}</MenuItem>
<MenuItem isDisabled>{`${t('engagement.level')} ${engProfile?.level ?? 1}${engProfile?.points ?? 0} ${t('engagement.points')}`}</MenuItem>
<Box px={3} py={2}>
<Text fontSize="xs" color="gray.500">Progres</Text>
<Text fontSize="xs" color="gray.500">{t('engagement.progress')}</Text>
<Progress value={levelProgress.pct} size="xs" colorScheme="blue" borderRadius="full" mt={1} />
</Box>
{!isAdmin && (
<>
<MenuItem onClick={onAchOpen}>Úspěchy</MenuItem>
<MenuItem onClick={onRewOpen}>Odměny</MenuItem>
<MenuItem onClick={onAchOpen}>{t('engagement.achievements')}</MenuItem>
<MenuItem onClick={onRewOpen}>{t('engagement.rewards')}</MenuItem>
</>
)}
<MenuItem as={RouterLink} to={accountPath}>Můj účet</MenuItem>
<MenuItem onClick={openMyNewsletterPrefs}>Emailové preference</MenuItem>
{isAdmin && <MenuItem as={RouterLink} to="/admin/nastaveni">Nastavení stránky</MenuItem>}
{isAdmin && <MenuItem as={RouterLink} to="/admin">Administrace</MenuItem>}
<MenuItem onClick={logout}>Odhlásit se</MenuItem>
<MenuItem as={RouterLink} to={accountPath}>{t('common.my_account')}</MenuItem>
<MenuItem onClick={openMyNewsletterPrefs}>{t('newsletter.email_preferences')}</MenuItem>
{isAdmin && <MenuItem as={RouterLink} to="/admin/nastaveni">{t('admin.page_settings')}</MenuItem>}
{isAdmin && <MenuItem as={RouterLink} to="/admin">{t('nav.admin')}</MenuItem>}
<MenuItem onClick={logout}>{t('auth.logout')}</MenuItem>
</MenuList>
</Menu>
)}
@@ -1011,7 +907,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
<Modal isOpen={isSearchOpen} onClose={onSearchClose} size="md" motionPreset="scale">
<ModalOverlay />
<ModalContent>
<ModalHeader>Vyhledávání</ModalHeader>
<ModalHeader>{t('search.title')}</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<form
@@ -1026,19 +922,19 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
<FaSearchIcon />
</InputLeftElement>
<Input
placeholder="Hledat kluby, zápasy, články, hráče..."
placeholder={t('search.placeholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
</InputGroup>
<Button type="submit" colorScheme="blue" size="lg" w="full" leftIcon={<FaSearchIcon />}>
Vyhledat
{t('search.search_button')}
</Button>
</VStack>
</form>
<Text fontSize="sm" color="gray.500" mt={4} textAlign="center">
Zadejte klíčová slova pro vyhledávání
{t('search.search_hint')}
</Text>
</ModalBody>
</ModalContent>
+1 -1
View File
@@ -23,7 +23,7 @@ const SponsorsStrip: React.FC = () => {
{isError && <Text color="red.500">Chyba při načítání sponzorů</Text>}
{data?.map((s) => (
<Link key={s.id} href={s.website_url || '#'} isExternal>
<Image src={assetUrl(s.logo_url) || '/logo192.png'} alt={s.name} height="50px" objectFit="contain" />
<Image src={assetUrl(s.logo_url) || '/sponsor-placeholder.svg'} alt={s.name} height="50px" objectFit="contain" />
</Link>
))}
</HStack>
+269
View File
@@ -0,0 +1,269 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Card,
CardBody,
CardHeader,
Heading,
HStack,
VStack,
Text,
Badge,
Icon,
SimpleGrid,
Alert,
AlertIcon,
useColorModeValue,
Spinner,
Divider,
} from '@chakra-ui/react';
import {
FiSun,
FiCloud,
FiCloudRain,
FiWind,
FiDroplet,
FiThermometer,
FiAlertTriangle,
FiCheck,
} from 'react-icons/fi';
import { api } from '../services/api';
interface WeatherData {
date_time: string;
temperature: number;
humidity: number;
precipitation: number;
wind_speed: number;
wind_direction: number;
weather_code: string;
description: string;
is_suitable: boolean;
recommendations: string;
}
interface WeatherWidgetProps {
facilityId: number;
facilityName: string;
}
const WeatherWidget: React.FC<WeatherWidgetProps> = ({ facilityId, facilityName }) => {
const [weather, setWeather] = useState<WeatherData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
useEffect(() => {
fetchWeather();
}, [facilityId]);
const fetchWeather = async () => {
try {
setLoading(true);
setError(null);
const response = await api.get(`/facilities/${facilityId}/weather`);
setWeather(response.data.weather || []);
} catch (err: any) {
if (err.response?.status === 503) {
setError('Služba počasí není nakonfigurována');
} else if (err.response?.status === 400) {
setError('Počasí je dostupné pouze pro venkovní zařízení');
} else {
setError('Nepodařilo se načíst data o počasí');
}
} finally {
setLoading(false);
}
};
const getWeatherIcon = (code: string, isSuitable: boolean) => {
if (!isSuitable) {
return <FiCloudRain />;
}
switch (code) {
case '800': // Clear
return <FiSun />;
case '801':
case '802':
case '803':
case '804': // Clouds
return <FiCloud />;
default:
return <FiCloud />;
}
};
const getWindDirection = (degrees: number) => {
const directions = ['S', 'SV', 'V', 'JV', 'J', 'JZ', 'Z', 'SZ'];
const index = Math.round(degrees / 45) % 8;
return directions[index];
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('cs-CZ', {
weekday: 'short',
day: 'numeric',
month: 'numeric',
});
};
const formatTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleTimeString('cs-CZ', {
hour: '2-digit',
minute: '2-digit',
});
};
const getSuitabilityColor = (isSuitable: boolean) => {
return isSuitable ? 'green' : 'red';
};
const getSuitabilityText = (isSuitable: boolean) => {
return isSuitable ? 'Vhodné' : 'Nevhodné';
};
if (loading) {
return (
<Card bg={bgColor} borderWidth={1} borderColor={borderColor}>
<CardBody>
<VStack spacing={4}>
<Spinner />
<Text>Načítání počasí...</Text>
</VStack>
</CardBody>
</Card>
);
}
if (error) {
return (
<Card bg={bgColor} borderWidth={1} borderColor={borderColor}>
<CardBody>
<Alert status="warning">
<AlertIcon />
<Text>{error}</Text>
</Alert>
</CardBody>
</Card>
);
}
if (weather.length === 0) {
return (
<Card bg={bgColor} borderWidth={1} borderColor={borderColor}>
<CardBody>
<Text color="gray.500">Žádná data o počasí</Text>
</CardBody>
</Card>
);
}
// Group weather by date
const weatherByDate = weather.reduce((acc, item) => {
const date = formatDate(item.date_time);
if (!acc[date]) {
acc[date] = [];
}
acc[date].push(item);
return acc;
}, {} as Record<string, WeatherData[]>);
return (
<Card bg={bgColor} borderWidth={1} borderColor={borderColor}>
<CardHeader>
<Heading size="md">Počasí - {facilityName}</Heading>
</CardHeader>
<CardBody>
<VStack spacing={4} align="stretch">
{Object.entries(weatherByDate).map(([date, dayWeather]) => (
<Box key={date}>
<Heading size="sm" mb={3}>{date}</Heading>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={3}>
{dayWeather.map((item, index) => (
<Card
key={index}
variant="outline"
size="sm"
bg={item.is_suitable ? 'green.50' : 'red.50'}
borderColor={item.is_suitable ? 'green.200' : 'red.200'}
>
<CardBody p={3}>
<VStack spacing={2} align="start">
<HStack justify="space-between" w="full">
<Text fontSize="sm" fontWeight="bold">
{formatTime(item.date_time)}
</Text>
<Badge
colorScheme={getSuitabilityColor(item.is_suitable)}
fontSize="xs"
>
{getSuitabilityText(item.is_suitable)}
</Badge>
</HStack>
<HStack>
{item.is_suitable ? <FiSun /> : <FiCloudRain />}
<Text fontSize="sm">{item.description}</Text>
</HStack>
<SimpleGrid columns={2} spacing={2} w="full">
<HStack>
<FiThermometer />
<Text fontSize="xs">{item.temperature.toFixed(1)}°C</Text>
</HStack>
<HStack>
<FiDroplet />
<Text fontSize="xs">{item.humidity}%</Text>
</HStack>
<HStack>
<FiWind />
<Text fontSize="xs">
{item.wind_speed.toFixed(1)} km/h {getWindDirection(item.wind_direction)}
</Text>
</HStack>
{item.precipitation > 0 && (
<HStack>
<FiCloudRain />
<Text fontSize="xs">{item.precipitation} mm</Text>
</HStack>
)}
</SimpleGrid>
{item.recommendations && (
<>
<Divider />
<Text fontSize="xs" color="gray.600">
{item.recommendations}
</Text>
</>
)}
</VStack>
</CardBody>
</Card>
))}
</SimpleGrid>
</Box>
))}
<Alert status="info" fontSize="sm">
<AlertIcon />
<Text>
Počasí je aktualizováno každé 2 hodiny.
Doporučení jsou generována automaticky na základě povětrnostních podmínek.
</Text>
</Alert>
</VStack>
</CardBody>
</Card>
);
};
export default WeatherWidget;
+3 -10
View File
@@ -2,7 +2,6 @@ import {
Box,
Flex,
IconButton,
useColorMode,
Text,
Menu,
MenuButton,
@@ -15,11 +14,12 @@ import {
Tooltip,
Link as ChakraLink
} from '@chakra-ui/react';
import { FaBars, FaMoon, FaSun, FaSignOutAlt, FaUserCog, FaBook } from 'react-icons/fa';
import { FaBars, FaSignOutAlt, FaUserCog, FaBook } from 'react-icons/fa';
import { useAuth } from '../../contexts/AuthContext';
import { User } from '../../types';
import { ReactNode } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { ThemeToggle } from '@/components/ui/theme-toggle';
interface AdminHeaderProps extends BoxProps {
onMenuToggle: () => void;
@@ -27,7 +27,6 @@ interface AdminHeaderProps extends BoxProps {
}
const AdminHeader = ({ onMenuToggle, rightContent, ...rest }: AdminHeaderProps) => {
const { colorMode, toggleColorMode } = useColorMode();
const { user, logout } = useAuth();
const bg = useColorModeValue('white', '#1a1d29');
const borderColor = useColorModeValue('gray.200', 'rgba(255, 255, 255, 0.12)');
@@ -81,13 +80,7 @@ const AdminHeader = ({ onMenuToggle, rightContent, ...rest }: AdminHeaderProps)
/>
</ChakraLink>
</Tooltip>
<IconButton
aria-label="Přepnout barevné schéma"
icon={colorMode === 'light' ? <FaMoon /> : <FaSun />}
variant="ghost"
onClick={toggleColorMode}
size="sm"
/>
<ThemeToggle />
<Menu>
<MenuButton>
@@ -0,0 +1,105 @@
import { Box, Tooltip } from '@chakra-ui/react';
import { useEffect, useState } from 'react';
import api from '../../services/api';
interface HealthCheckResult {
status: string;
message?: string;
}
interface HealthResponse {
status: string;
checks?: Record<string, HealthCheckResult>;
}
type HealthStatus = 'loading' | 'healthy' | 'degraded' | 'unhealthy' | 'error';
const POLL_INTERVAL_MS = 60000; // 60 seconds
const AdminHealthIndicator = () => {
const [status, setStatus] = useState<HealthStatus>('loading');
const [lastError, setLastError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
let timer: number | undefined;
const fetchStatus = async () => {
try {
const res = await api.get<HealthResponse>('/health/ready');
if (cancelled) return;
const raw = (res.data?.status || '').toLowerCase();
let mapped: HealthStatus;
if (raw === 'healthy') mapped = 'healthy';
else if (raw === 'degraded') mapped = 'degraded';
else mapped = 'unhealthy';
setStatus(mapped);
setLastError(null);
} catch (err) {
if (cancelled) return;
// On error keep previous status if we already had one, otherwise mark as error
setStatus((prev) => (prev === 'loading' ? 'error' : prev));
setLastError('Nepodařilo se ověřit stav serveru');
} finally {
if (!cancelled) {
timer = window.setTimeout(fetchStatus, POLL_INTERVAL_MS);
}
}
};
fetchStatus();
return () => {
cancelled = true;
if (timer) {
window.clearTimeout(timer);
}
};
}, []);
let color: string;
switch (status) {
case 'healthy':
color = 'green.400';
break;
case 'degraded':
color = 'orange.400';
break;
case 'unhealthy':
color = 'red.400';
break;
case 'loading':
color = 'yellow.300';
break;
case 'error':
default:
color = 'gray.400';
break;
}
const label: string = (() => {
if (status === 'loading') return 'Ověřuji stav serveru…';
if (status === 'healthy') return 'Server v pořádku';
if (status === 'degraded') return 'Server běží, ale v omezeném režimu';
if (status === 'unhealthy') return 'Server není připraven (zdravotní kontrola selhala)';
if (status === 'error') return lastError || 'Nepodařilo se ověřit stav serveru';
return 'Stav serveru není známý';
})();
return (
<Tooltip label={label} hasArrow>
<Box
as="span"
w="10px"
h="10px"
borderRadius="full"
bg={color}
boxShadow="0 0 0 2px rgba(255, 255, 255, 0.6)"
/>
</Tooltip>
);
};
export default AdminHealthIndicator;
@@ -0,0 +1,78 @@
// Create a completely new approach - intercept navigation at router level
// This will be a replacement for the current scroll retention system
import { useEffect, useRef } from 'react';
import { useLocation, useNavigationType } from 'react-router-dom';
interface AdminScrollManagerProps {
children: React.ReactNode;
}
export const AdminScrollManager = ({ children }: AdminScrollManagerProps) => {
const location = useLocation();
const navigationType = useNavigationType();
const scrollPositionRef = useRef<number>(0);
const isNavigatingRef = useRef<boolean>(false);
// Log when component mounts
useEffect(() => {
console.log('[AdminScrollManager] Component mounted');
console.log('[AdminScrollManager] Current path:', location.pathname);
}, []);
// Save scroll position before navigation
useEffect(() => {
if (isNavigatingRef.current) return; // Don't save while navigating
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (sidebar && sidebar.scrollTop > 0) {
scrollPositionRef.current = sidebar.scrollTop;
console.log('[AdminScrollManager] Saved position:', scrollPositionRef.current);
}
}, [location.pathname]);
// Restore scroll position after navigation
useEffect(() => {
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (!sidebar) return;
// Restore on any navigation type, not just PUSH
if (scrollPositionRef.current > 0) {
isNavigatingRef.current = true;
console.log('[AdminScrollManager] Restoring position:', scrollPositionRef.current, 'Navigation type:', navigationType);
// Aggressive restoration
const targetScroll = scrollPositionRef.current;
// Immediate restore
sidebar.scrollTop = targetScroll;
// Multiple restoration attempts
const restoreAttempts = [
() => sidebar.scrollTop = targetScroll,
() => sidebar.scrollTo({ top: targetScroll, behavior: 'auto' }),
() => sidebar.scrollTop = targetScroll,
];
// Try restoration at different intervals
restoreAttempts.forEach((restore, index) => {
setTimeout(() => {
restore();
console.log(`[AdminScrollManager] Restore attempt ${index + 1}:`, sidebar.scrollTop);
}, index * 100);
});
// Final attempt after 1 second
setTimeout(() => {
if (sidebar.scrollTop !== targetScroll) {
sidebar.scrollTop = targetScroll;
console.log('[AdminScrollManager] Final restore:', sidebar.scrollTop);
}
isNavigatingRef.current = false;
}, 1000);
}
}, [location.pathname, navigationType]);
return <>{children}</>;
};
@@ -18,7 +18,7 @@ import {
Box,
Kbd,
} from '@chakra-ui/react';
import { FaSearch, FaCog, FaNewspaper, FaUsers, FaImage, FaHandshake, FaEnvelope, FaAward, FaSyncAlt, FaVideo, FaCalendarAlt, FaPalette, FaCommentAlt, FaKey, FaChartLine, FaBook, FaTools, FaBell, FaBars, FaFolderOpen } from 'react-icons/fa';
import { FaSearch, FaCog, FaNewspaper, FaUsers, FaImage, FaHandshake, FaEnvelope, FaAward, FaSyncAlt, FaVideo, FaCalendarAlt, FaPalette, FaCommentAlt, FaKey, FaChartLine, FaBook, FaTools, FaBell, FaBars, FaFolderOpen, FaFutbol, FaTachometerAlt, FaLightbulb, FaBug, FaAddressBook, FaChalkboard, FaMobileAlt, FaInfoCircle, FaListOl, FaBolt, FaEdit, FaPaintBrush, FaLifeRing, FaQrcode, FaPoll, FaHashtag, FaTicketAlt, FaTrash, FaExclamationTriangle, FaFlag, FaGavel, FaClipboardList, FaStar, FaTrophy, FaGift, FaShoppingCart, FaLink, FaArrowUp, FaPhotoVideo, FaTshirt, FaGlobe } from 'react-icons/fa';
export type AdminSearchItem = {
label: string;
@@ -29,30 +29,79 @@ export type AdminSearchItem = {
};
const adminIndex: AdminSearchItem[] = [
{ label: 'Dashboard', path: '/admin', section: 'Core', keywords: ['overview', 'stat', 'dashboard'], icon: FaTools },
{ label: 'Články', path: '/admin/clanky', section: 'Obsah', keywords: ['articles', 'posts', 'blog'], icon: FaNewspaper },
{ label: 'Hráči', path: '/admin/hraci', section: 'Kádry', keywords: ['players'], icon: FaUsers },
{ label: 'Týmy', path: '/admin/tymy', section: 'Kádry', keywords: ['teams'], icon: FaUsers },
{ label: 'Zápasy', path: '/admin/zapasy', section: 'FAČR', keywords: ['matches', 'facr'], icon: FaCalendarAlt },
{ label: 'Soubory', path: '/admin/soubory', section: 'Média', keywords: ['files', 'uploads'], icon: FaFolderOpen },
{ label: 'Sponzoři', path: '/admin/sponzori', section: 'Marketing', keywords: ['sponsors', 'partners'], icon: FaHandshake },
{ label: 'Bannery', path: '/admin/bannery', section: 'Marketing', keywords: ['banners'], icon: FaImage },
{ label: 'Nastavení', path: '/admin/nastaveni', section: 'Systém', keywords: ['settings', 'config'], icon: FaCog },
{ label: 'Newsletter', path: '/admin/newsletter', section: 'Komunikace', keywords: ['email', 'campaign'], icon: FaEnvelope },
{ label: 'Uživatelé', path: '/admin/uzivatele', section: 'Systém', keywords: ['users', 'accounts'], icon: FaKey },
{ label: 'Prefetch', path: '/admin/prefetch', section: 'Systém', keywords: ['cache', 'fetch'], icon: FaSyncAlt },
{ label: 'Galerie', path: '/admin/galerie', section: 'Média', keywords: ['gallery', 'zonerama'], icon: FaImage },
{ label: 'Videa', path: '/admin/videa', section: 'Média', keywords: ['youtube', 'videos'], icon: FaVideo },
// Core Admin Pages
{ label: 'Dashboard', path: '/admin', section: 'Základní', keywords: ['overview', 'stat', 'dashboard', 'přehled'], icon: FaTachometerAlt },
{ label: 'Články', path: '/admin/clanky', section: 'Obsah', keywords: ['articles', 'posts', 'blog', 'články'], icon: FaNewspaper },
{ label: 'Kategorie článků', path: '/admin/kategorie', section: 'Obsah', keywords: ['categories', 'kategorie'], icon: FaHashtag },
{ label: 'Aktivity', path: '/admin/aktivity', section: 'Obsah', keywords: ['activities', 'events', 'akce', 'události'], icon: FaCalendarAlt },
{ label: 'Komentáře', path: '/admin/komentare', section: 'Obsah', keywords: ['comments', 'diskuse'], icon: FaCommentAlt },
{ label: 'Hráči', path: '/admin/hraci', section: 'Sport', keywords: ['players', 'hráči'], icon: FaUsers },
{ label: 'Týmy', path: '/admin/tymy', section: 'Sport', keywords: ['teams', 'týmy'], icon: FaUsers },
{ label: 'Zápasy', path: '/admin/zapasy', section: 'Sport', keywords: ['matches', 'facr', 'zápasy'], icon: FaCalendarAlt },
{ label: 'Alias soutěží', path: '/admin/aliasy', section: 'Sport', keywords: ['aliases', 'competition', 'soutěže'], icon: FaAward },
{ label: 'Tabulky', path: '/admin/tabulky', section: 'Sport', keywords: ['standings', 'table', 'tabulka'], icon: FaChartLine },
{ label: 'Tabule (Scoreboard)', path: '/admin/scoreboard', section: 'Sport', keywords: ['scoreboard', 'tabule', 'výsledky'], icon: FaChalkboard },
{ label: 'Galerie', path: '/admin/galerie', section: 'Média', keywords: ['gallery', 'zonerama', 'fotky'], icon: FaImage },
{ label: 'Videa', path: '/admin/videa', section: 'Média', keywords: ['youtube', 'videos', 'videa'], icon: FaVideo },
{ label: 'Soubory', path: '/admin/soubory', section: 'Média', keywords: ['files', 'uploads', 'soubory'], icon: FaFolderOpen },
{ label: 'Sponzoři', path: '/admin/sponzori', section: 'Marketing', keywords: ['sponsors', 'partners', 'sponzoři'], icon: FaHandshake },
{ label: 'Bannery', path: '/admin/bannery', section: 'Marketing', keywords: ['banners', 'reklama'], icon: FaImage },
{ label: 'Oblečení', path: '/admin/obleceni', section: 'Marketing', keywords: ['clothing', 'merch', 'eshop', 'obleceni'], icon: FaTshirt },
{ label: 'Ankety', path: '/admin/ankety', section: 'Marketing', keywords: ['polls', 'ankety', 'hlasování'], icon: FaPoll },
{ label: 'Soutěže', path: '/admin/souteze', section: 'Marketing', keywords: ['sweepstakes', 'souteže', 'akce'], icon: FaTrophy },
{ label: 'Odměny & Úspěchy', path: '/admin/odmeny', section: 'Marketing', keywords: ['engagement', 'rewards', 'odmeny', 'úspěchy'], icon: FaTrophy },
{ label: 'Zkrácené odkazy', path: '/admin/shortlinks', section: 'Marketing', keywords: ['shortlinks', 'zkrácené', 'odkazy'], icon: FaLink },
{ label: 'QR kódy', path: '/admin/qr', section: 'Marketing', keywords: ['qr', 'kódy', 'qrcode'], icon: FaQrcode },
{ label: 'Vstupenky', path: '/admin/vstupenky', section: 'Marketing', keywords: ['tickets', 'vstupenky', 'prodej'], icon: FaTicketAlt },
{ label: 'Newsletter', path: '/admin/newsletter', section: 'Komunikace', keywords: ['email', 'campaign', 'newsletter'], icon: FaEnvelope },
{ label: 'Zprávy', path: '/admin/zpravy', section: 'Komunikace', keywords: ['messages', 'zprávy'], icon: FaCommentAlt },
{ label: 'Kontakty', path: '/admin/kontakty', section: 'Komunikace', keywords: ['contacts', 'kontakty', 'formulář'], icon: FaAddressBook },
{ label: 'Notifikace', path: '/admin/notifications', section: 'Komunikace', keywords: ['notifications', 'notifikace'], icon: FaBell },
{ label: 'Analytika', path: '/admin/analytika', section: 'SEO', keywords: ['analytics', 'umami'], icon: FaChartLine },
{ label: 'O klubu', path: '/admin/o-klubu', section: 'Obsah', keywords: ['about'], icon: FaPalette },
{ label: 'Navigace', path: '/admin/navigace', section: 'Systém', keywords: ['navigation', 'menu', 'sidebar'], icon: FaBars },
{ label: 'Notifikace: Zápasy', path: '/admin/notifications', section: 'Komunikace', keywords: ['notifications', 'match'], icon: FaBell },
// Docs
{ label: 'Dokumentace (Úvod)', path: '/admin/docs#uvod', section: 'Docs', keywords: ['docs', 'documentation'], icon: FaBook },
{ label: 'Dokumentace (Nastavení)', path: '/admin/docs#nastaveni', section: 'Docs', keywords: ['docs', 'settings'], icon: FaBook },
{ label: 'Dokumentace (Články)', path: '/admin/docs#clanky', section: 'Docs', keywords: ['docs', 'articles'], icon: FaBook },
{ label: 'Dokumentace (Newsletter)', path: '/admin/docs#newsletter', section: 'Docs', keywords: ['docs', 'email'], icon: FaBook },
{ label: 'Dokumentace (Řešení problémů)', path: '/admin/docs#troubleshooting', section: 'Docs', keywords: ['docs', 'troubleshooting'], icon: FaBook },
{ label: 'O klubu', path: '/admin/o-klubu', section: 'Obsah', keywords: ['about', 'klub'], icon: FaPalette },
{ label: 'Nastavení', path: '/admin/nastaveni', section: 'Nastavení', keywords: ['settings', 'config', 'nastavení'], icon: FaCog },
{ label: 'Uživatelé', path: '/admin/uzivatele', section: 'Nastavení', keywords: ['users', 'accounts', 'uživatelé'], icon: FaKey },
{ label: 'Navigace', path: '/admin/navigace', section: 'Nastavení', keywords: ['navigation', 'menu', 'sidebar'], icon: FaBars },
{ label: 'Prefetch & Cache', path: '/admin/prefetch', section: 'Nástroje', keywords: ['cache', 'fetch', 'prefetch'], icon: FaSyncAlt },
{ label: 'Chybová hlášení', path: '/admin/chyby', section: 'Nástroje', keywords: ['errors', 'chyby', 'hlášení', 'log'], icon: FaBug },
{ label: 'Překlady (I18n)', path: '/admin/i18n', section: 'Nástroje', keywords: ['i18n', 'překlady', 'jazyky', 'translations'], icon: FaGlobe },
{ label: 'FACR manuál', path: '/admin/facr-manual', section: 'Nástroje', keywords: ['facr', 'manuál', 'import'], icon: FaFutbol },
// Settings Sections (deep links)
{ label: 'Nastavení - Sociální sítě', path: '/admin/nastaveni#socialni-site', section: 'Nastavení', keywords: ['socialni', 'sítě', 'facebook', 'instagram', 'twitter'], icon: FaAddressBook },
{ label: 'Nastavení - Videa', path: '/admin/nastaveni#videa', section: 'Nastavení', keywords: ['videa', 'youtube', 'kanál'], icon: FaVideo },
{ label: 'Nastavení - SMTP', path: '/admin/nastaveni#smtp', section: 'Nastavení', keywords: ['smtp', 'email', 'odesílání'], icon: FaEnvelope },
{ label: 'Nastavení - Analytika', path: '/admin/nastaveni#analytika', section: 'Nastavení', keywords: ['umami', 'analytics', 'statistiky'], icon: FaChartLine },
{ label: 'Nastavení - SEO', path: '/admin/nastaveni#seo', section: 'Nastavení', keywords: ['seo', 'metadata', 'vyhledávače'], icon: FaSearch },
{ label: 'Nastavení - Obecné', path: '/admin/nastaveni#obecne', section: 'Nastavení', keywords: ['obecné', 'základní', 'klub'], icon: FaCog },
// Documentation Sections
{ label: 'Dokumentace - Úvod', path: '/admin/docs#uvod', section: 'Dokumentace', keywords: ['docs', 'documentation', 'úvod'], icon: FaBook },
{ label: 'Dokumentace - Nastavení klubu', path: '/admin/docs#nastaveni', section: 'Dokumentace', keywords: ['docs', 'nastavení', 'konfigurace'], icon: FaBook },
{ label: 'Dokumentace - Dashboard', path: '/admin/docs#dashboard', section: 'Dokumentace', keywords: ['docs', 'dashboard', 'přehledy'], icon: FaBook },
{ label: 'Dokumentace - Články', path: '/admin/docs#clanky', section: 'Dokumentace', keywords: ['docs', 'články', 'blog'], icon: FaBook },
{ label: 'Dokumentace - Zápasy', path: '/admin/docs#zapasy', section: 'Dokumentace', keywords: ['docs', 'zápasy', 'facr'], icon: FaBook },
{ label: 'Dokumentace - Hráči a týmy', path: '/admin/docs#hraci-tymy', section: 'Dokumentace', keywords: ['docs', 'hráči', 'týmy'], icon: FaBook },
{ label: 'Dokumentace - Média', path: '/admin/docs#media', section: 'Dokumentace', keywords: ['docs', 'média', 'soubory'], icon: FaBook },
{ label: 'Dokumentace - Galerie', path: '/admin/docs#gallery', section: 'Dokumentace', keywords: ['docs', 'galerie', 'fotky'], icon: FaBook },
{ label: 'Dokumentace - Soubory', path: '/admin/docs#files', section: 'Dokumentace', keywords: ['docs', 'soubory', 'upload'], icon: FaBook },
{ label: 'Dokumentace - Sponzoři a bannery', path: '/admin/docs#sponzori-bannery', section: 'Dokumentace', keywords: ['docs', 'sponzoři', 'bannery'], icon: FaBook },
{ label: 'Dokumentace - Newsletter', path: '/admin/docs#newsletter', section: 'Dokumentace', keywords: ['docs', 'newsletter', 'email'], icon: FaBook },
{ label: 'Dokumentace - Alias soutěží', path: '/admin/docs#aliasy', section: 'Dokumentace', keywords: ['docs', 'alias', 'soutěže'], icon: FaBook },
{ label: 'Dokumentace - Prefetch', path: '/admin/docs#prefetch', section: 'Dokumentace', keywords: ['docs', 'prefetch', 'cache'], icon: FaBook },
{ label: 'Dokumentace - Videa', path: '/admin/docs#videa', section: 'Dokumentace', keywords: ['docs', 'videa', 'youtube'], icon: FaBook },
{ label: 'Dokumentace - Aktivity', path: '/admin/docs#aktivity', section: 'Dokumentace', keywords: ['docs', 'aktivity', 'události'], icon: FaBook },
{ label: 'Dokumentace - Oblečení', path: '/admin/docs#merch', section: 'Dokumentace', keywords: ['docs', 'obleceni', 'merch'], icon: FaBook },
{ label: 'Dokumentace - Zprávy', path: '/admin/docs#zpravy', section: 'Dokumentace', keywords: ['docs', 'zprávy', 'komunikace'], icon: FaBook },
{ label: 'Dokumentace - Kontakty', path: '/admin/docs#contacts', section: 'Dokumentace', keywords: ['docs', 'kontakty', 'formuláře'], icon: FaBook },
{ label: 'Dokumentace - Analytics', path: '/admin/docs#analytics', section: 'Dokumentace', keywords: ['docs', 'analytics', 'statistiky'], icon: FaBook },
{ label: 'Dokumentace - Scoreboard', path: '/admin/docs#scoreboard', section: 'Dokumentace', keywords: ['docs', 'scoreboard', 'tabule'], icon: FaBook },
{ label: 'Dokumentace - Mobilní scoreboard', path: '/admin/docs#mobile-scoreboard', section: 'Dokumentace', keywords: ['docs', 'mobilní', 'scoreboard'], icon: FaBook },
{ label: 'Dokumentace - Uživatelé', path: '/admin/docs#uzivatele', section: 'Dokumentace', keywords: ['docs', 'uživatelé', 'přístupy'], icon: FaBook },
{ label: 'Dokumentace - Interní dokumentace', path: '/admin/docs#docs', section: 'Dokumentace', keywords: ['docs', 'interní', 'vývoj'], icon: FaBook },
{ label: 'Dokumentace - Checklisty', path: '/admin/docs#checklist', section: 'Dokumentace', keywords: ['docs', 'checklist', 'postupy'], icon: FaBook },
{ label: 'Dokumentace - SEO', path: '/admin/docs#seo', section: 'Dokumentace', keywords: ['docs', 'seo', 'metadata'], icon: FaBook },
{ label: 'Dokumentace - Řešení problémů', path: '/admin/docs#troubleshooting', section: 'Dokumentace', keywords: ['docs', 'troubleshooting', 'problémy'], icon: FaBook },
];
function highlight(text: string, q: string) {
@@ -83,8 +132,12 @@ function score(item: AdminSearchItem, q: string) {
if (t.startsWith(b)) s += 120;
if (t.includes(b)) s += 80 - t.indexOf(b);
if (kws.includes(b)) s += 40;
// small preference for Docs when # present
if (item.section === 'Docs' && item.path.includes('#')) s += 5;
// Boost score for settings sections when searching for settings-related terms
if (item.section === 'Nastavení' && (b.includes('nastavení') || b.includes('settings') || b.includes('config'))) s += 30;
// Boost score for documentation when searching for help/docs
if (item.section === 'Dokumentace' && (b.includes('docs') || b.includes('dokumentace') || b.includes('help') || b.includes('návod'))) s += 30;
// Small preference for Docs when # present
if (item.section === 'Dokumentace' && item.path.includes('#')) s += 5;
return s;
}
@@ -153,7 +206,7 @@ export default function AdminSearchModal({ isOpen, onClose, onSelectPath }: { is
<Icon as={FaSearch} />
</InputLeftElement>
<Input
placeholder="Hledat v administraci (stránky, nastavení, dokumentace)"
placeholder="Hledat stránky, nastavení, dokumentaci... (např. 'sociální sítě', 'články', 'scoreboard')"
value={q}
onChange={(e) => { setQ(e.target.value); setIdx(-1); }}
onKeyDown={onKeyDown}
+180 -107
View File
@@ -1,6 +1,7 @@
import { Box, VStack, Text, useColorModeValue, Icon, Link as ChakraLink, Divider, Image, Flex, Spinner, Collapse } from '@chakra-ui/react';
import { Box, VStack, Text, useColorModeValue, Icon, Link as ChakraLink, Divider, Image, Flex, Spinner, Collapse, IconButton } from '@chakra-ui/react';
import { Link as RouterLink, useLocation } from 'react-router-dom';
import { useEffect, useRef, useCallback, useState, useMemo } from 'react';
import { useAdminNavScrollRetention } from '../../hooks/useAdminNavScrollRetention';
import {
FaTachometerAlt,
FaUsers,
@@ -33,7 +34,19 @@ import {
FaFileAlt,
FaLink,
FaComments,
FaGift
FaGift,
FaQrcode,
FaTools,
FaDollarSign,
FaFileInvoice,
FaReceipt,
FaUserFriends,
FaMoneyBillWave,
FaCogs,
FaDatabase,
FaRocket,
FaFileInvoiceDollar,
FaTimes
} from 'react-icons/fa';
import { ChevronDownIcon, ChevronRightIcon } from '@chakra-ui/icons';
import { useAuth } from '../../contexts/AuthContext';
@@ -52,9 +65,11 @@ interface NavItemProps {
const NavItem = ({ icon, to, children, onClick }: NavItemProps) => {
const location = useLocation();
const isActive = to ? location.pathname.startsWith(to) : false;
// Use exact matching for navigation items to prevent multiple active states
const isActive = to ? location.pathname === to : false;
const activeBg = useColorModeValue('blue.50', 'blue.900');
const activeColor = useColorModeValue('blue.600', 'blue.300');
const hoverBg = useColorModeValue('gray.100', 'gray.700');
const handleClick = (e: React.MouseEvent) => {
// Call the onClick handler first
@@ -65,7 +80,9 @@ const NavItem = ({ icon, to, children, onClick }: NavItemProps) => {
return;
}
}
// Allow RouterLink to handle navigation normally
// For external links or non-router navigation, allow default behavior
// For RouterLink, React Router will handle the navigation
};
// If onClick is provided without `to`, render as a button-like link
@@ -87,7 +104,7 @@ const NavItem = ({ icon, to, children, onClick }: NavItemProps) => {
fontSize="sm"
_hover={{
textDecoration: 'none',
bg: isActive ? activeBg : useColorModeValue('gray.100', 'gray.700'),
bg: isActive ? activeBg : hoverBg,
transform: 'translateX(2px)',
}}
transition="all 0.2s ease"
@@ -146,7 +163,7 @@ const getIconForPageType = (pageType?: string): any => {
polls: FaPoll,
navigation: FaBars,
competition_aliases: FaAward,
prefetch: FaSyncAlt,
prefetch: FaRocket, // Better icon for prefetch & cache
users: FaUserShield,
settings: FaPalette,
files: FaFolder,
@@ -156,6 +173,14 @@ const getIconForPageType = (pageType?: string): any => {
comments: FaComments,
engagement: FaAward,
sweepstakes: FaGift,
'manual-data': FaTools,
'financial-dashboard': FaMoneyBillWave, // Better finance icon
'qr-codes': FaQrcode,
'invoices': FaFileInvoiceDollar, // Enhanced invoice icon
'invoice-settings': FaFileInvoiceDollar, // Enhanced invoice settings icon
'customers': FaUserFriends, // Better customers icon
'expenses': FaMoneyBillWave, // Better expenses icon
'manual_facr': FaTools, // For manual FACR
};
return iconMap[pageType || ''] || FaFileAlt;
};
@@ -175,13 +200,39 @@ const AdminSidebar = ({
const textColor = useColorModeValue('gray.800', '#e2e8f0');
const bg = bgProp || defaultBg;
const borderColor = borderColorProp || defaultBorderColor;
// Check if e-shop is enabled (from backend config)
const isEshopEnabled = (publicSettings as any)?.eshop_enabled || false;
// Check if club data mode is manual (to show manual data tab)
const isManualClubDataMode = (publicSettings?.club_data_mode || '').toLowerCase() === 'manual';
// Hoisted color tokens to keep hook calls stable across renders
const dividerColor = useColorModeValue('gray.200', 'whiteAlpha.300');
const categoryTextColor = useColorModeValue('gray.600', 'gray.300');
const categoryIconColor = useColorModeValue('gray.500', 'gray.400');
const headerMutedColor = useColorModeValue('gray.500', 'gray.400');
const scrollThumb = useColorModeValue('gray.300', 'gray.600');
const scrollThumbHover = useColorModeValue('gray.400', 'gray.500');
const badgeBgGreen = useColorModeValue('green.100', 'green.900');
const badgeColorGreen = useColorModeValue('green.700', 'green.200');
const badgeBorderGreen = useColorModeValue('green.200', 'green.700');
const badgeBgGray = useColorModeValue('gray.100', 'whiteAlpha.200');
const badgeColorGray = useColorModeValue('gray.700', 'gray.300');
const badgeBorderGray = useColorModeValue('gray.200', 'whiteAlpha.300');
// Upcoming events count for badge
const { data: upcomingEvents } = useQuery({ queryKey: ['admin-sidebar-upcoming-events'], queryFn: getUpcomingEvents });
const upcomingCount = Array.isArray(upcomingEvents) ? upcomingEvents.length : 0;
const scrollRef = useRef<HTMLDivElement | null>(null);
const seedFixRef = useRef<{ about: boolean }>({ about: false });
const location = useLocation();
const STORAGE_KEY = 'admin-sidebar-scroll';
// Use the updated scroll retention hook
const { scrollToCurrentPage, isReady, debug } = useAdminNavScrollRetention({
scrollContainerSelector: '[data-sidebar="true"]',
enableDebug: process.env.NODE_ENV === 'development'
});
// Helper function to render NavItem
const renderNavItem = useCallback((props: NavItemProps) => {
return <NavItem {...props} />;
}, []);
// Dynamic navigation state
const [navItems, setNavItems] = useState<NavigationItem[]>([]);
@@ -203,6 +254,7 @@ const AdminSidebar = ({
const hasSweepstakes = useMemo(() => hasItemDeep(it => (it.page_type === 'sweepstakes') || (it.url === '/admin/sweepstakes')), [hasItemDeep]);
const hasCompetitionAliases = useMemo(() => hasItemDeep(it => (it.page_type === 'competition_aliases') || (it.url === '/admin/aliasy-soutezi')), [hasItemDeep]);
const hasClothing = useMemo(() => hasItemDeep(it => (it.page_type === 'clothing') || (it.url === '/admin/obleceni')), [hasItemDeep]);
const hasEshopProducts = useMemo(() => hasItemDeep(it => (it.page_type === 'eshop_products') || (it.url === '/admin/eshop-produkty')), [hasItemDeep]);
const hasAbout = useMemo(() => hasItemDeep(it => (it.page_type === 'about') || (it.url === '/admin/o-klubu')), [hasItemDeep]);
const hasVideos = useMemo(() => hasItemDeep(it => (it.page_type === 'videos') || (it.url === '/admin/videa')), [hasItemDeep]);
const hasGallery = useMemo(() => hasItemDeep(it => (it.page_type === 'gallery') || (it.url === '/admin/galerie')), [hasItemDeep]);
@@ -220,6 +272,7 @@ const AdminSidebar = ({
const hasSettingsPage = useMemo(() => hasItemDeep(it => (it.page_type === 'settings') || (it.url === '/admin/nastaveni')), [hasItemDeep]);
const hasAnalytics = useMemo(() => hasItemDeep(it => (it.page_type === 'analytics') || (it.url === '/admin/analytika')), [hasItemDeep]);
const hasPrefetch = useMemo(() => hasItemDeep(it => (it.page_type === 'prefetch') || (it.url === '/admin/prefetch')), [hasItemDeep]);
const hasManualData = useMemo(() => hasItemDeep(it => (it.page_type === 'manual_data') || (it.url === '/admin/manual-data')), [hasItemDeep]);
// Collapsed state for admin categories (dropdown items)
@@ -259,27 +312,7 @@ const AdminSidebar = ({
});
}, []);
// Restore scroll on mount
useEffect(() => {
const node = scrollRef.current;
if (!node) return;
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const top = parseInt(saved, 10);
if (!Number.isNaN(top)) {
node.scrollTop = top;
}
} catch {}
}
}, []);
// Save scroll on scroll
const handleScroll = useCallback(() => {
const node = scrollRef.current;
if (!node) return;
sessionStorage.setItem(STORAGE_KEY, String(node.scrollTop));
}, []);
// The scroll handling is now managed by the useAdminNavScrollRetention hook
// Load dynamic navigation from API
useEffect(() => {
@@ -360,25 +393,13 @@ const AdminSidebar = ({
return () => { active = false };
}, [isAdmin]);
// Keep active item in view upon route change - but only if it's not visible
// Auto-scroll to current page when navigation loads
useEffect(() => {
const node = scrollRef.current;
if (!node) return;
const active = node.querySelector('[data-navitem][data-active="true"]') as HTMLElement | null;
if (active) {
// Check if the active item is already visible in the viewport
const containerRect = node.getBoundingClientRect();
const activeRect = active.getBoundingClientRect();
const isVisible = (
activeRect.top >= containerRect.top &&
activeRect.bottom <= containerRect.bottom
);
// Only scroll if the active item is not fully visible
if (!isVisible) {
active.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
}
if (isReady && !navLoading) {
scrollToCurrentPage();
debug('Auto-scroll to current page after navigation load');
}
}, [location.pathname]);
}, [isReady, navLoading, scrollToCurrentPage, debug]);
return (
<Box
@@ -387,60 +408,74 @@ const AdminSidebar = ({
left={0}
top={0}
bottom={0}
width="260px"
width={{ base: '320px', md: '260px' }}
bg={bg}
borderRightWidth={borderRight}
borderColor={borderColor}
pt={5}
pt={{ base: 16, md: 5 }}
display={{ base: isOpen ? 'block' : 'none', md: 'block' }}
zIndex={10}
zIndex={{ base: 11, md: 10 }}
overflowY="auto"
overflowX="hidden"
boxShadow="lg"
boxShadow={{ base: 'lg', md: 'none' }}
transform={{ base: isOpen ? 'translateX(0)' : 'translateX(-100%)', md: 'translateX(0)' }}
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
ref={scrollRef}
onScroll={handleScroll}
onScroll={undefined}
data-sidebar="true"
css={{
'&::-webkit-scrollbar': { width: '4px' },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: useColorModeValue('gray.300', 'gray.600'), borderRadius: '2px' },
'&::-webkit-scrollbar-thumb:hover': { background: useColorModeValue('gray.400', 'gray.500') },
'&::-webkit-scrollbar-thumb': { background: scrollThumb, borderRadius: '2px' },
'&::-webkit-scrollbar-thumb:hover': { background: scrollThumbHover },
}}
>
<VStack align="stretch" spacing={1} px={3} pb={6}>
<Box px={3} mb={8}>
<Flex align="center" gap={3} mb={2}>
<VStack align="stretch" spacing={{ base: 2, md: 1 }} px={{ base: 6, md: 3 }} pb={6}>
{/* Close button for mobile */}
<Flex justify="flex-end" display={{ base: 'flex', md: 'none' }} w="100%" mb={2}>
<IconButton
aria-label="Zavřít menu"
icon={<FaTimes />}
variant="ghost"
onClick={onClose}
size="sm"
borderRadius="full"
_hover={{ bg: useColorModeValue('gray.100', 'gray.700') }}
/>
</Flex>
<Box px={{ base: 6, md: 3 }} mb={{ base: 6, md: 4 }}>
<Flex align="center" gap={{ base: 4, md: 3 }} mb={2}>
<Image
src={assetUrl(publicSettings?.club_logo_url) || publicSettings?.club_logo_url || '/dist/img/logo-club-empty.svg'}
alt="Club Logo"
boxSize="48px"
boxSize={{ base: '56px', md: '48px' }}
objectFit="contain"
fallbackSrc="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%23e2e8f0'/%3E%3Ctext x='50' y='55' text-anchor='middle' font-size='40' fill='%23718096'%3EMC%3C/text%3E%3C/svg%3E"
borderRadius="md"
/>
<VStack align="start" spacing={0}>
<Text
fontSize="xl"
fontSize={{ base: '2xl', md: 'xl' }}
fontWeight="extrabold"
color={useColorModeValue('gray.800', 'white')}
letterSpacing="tight"
>
My Club
</Text>
<Text fontSize="xs" color={useColorModeValue('gray.500', 'gray.400')} fontWeight="semibold" textTransform="uppercase" letterSpacing="wider">
<Text fontSize={{ base: 'sm', md: 'xs' }} color={useColorModeValue('gray.500', 'gray.400')} fontWeight="semibold" textTransform="uppercase" letterSpacing="wider">
Admin Panel
</Text>
</VStack>
</Flex>
</Box>
<NavItem
icon={FaHome}
to="/"
onClick={onClose}
>
Zpět na web
</NavItem>
{renderNavItem({
icon: FaHome,
to: "/",
onClick: onClose,
children: "Zpět na web"
})}
<Divider my={2} />
@@ -452,51 +487,61 @@ const AdminSidebar = ({
) : navItems.length > 0 ? (
// Render dynamic navigation with collapsible categories
<>
{navItems.filter(item => item.visible).map((item, index) => {
{navItems.filter(item => {
// Hide E-shop category if e-shop is disabled
if (item.label === 'E-shop' && !isEshopEnabled) {
return false;
}
return item.visible;
}).map((item, index) => {
const isCategory = item.type === 'dropdown';
const hasChildren = Array.isArray(item.children) && item.children.length > 0;
const catCollapsed = !!(item.id && collapsed[item.id]);
const categoryHeader = (
<Box key={`cat-${item.id || index}`} px={2} py={2} onClick={() => toggleCollapsed(item.id)} cursor="pointer" role="button" aria-expanded={!catCollapsed}>
<Box px={2} py={2} onClick={() => toggleCollapsed(item.id)} cursor="pointer" role="button" aria-expanded={!catCollapsed}>
<Flex align="center" gap={2}>
<Box flex="1" height="1px" bg={useColorModeValue('gray.200','whiteAlpha.300')} />
<Text fontSize="xs" fontWeight="bold" textTransform="uppercase" letterSpacing="wider" color={useColorModeValue('gray.600','gray.300')}>
<Box flex="1" height="1px" bg={dividerColor} />
<Text fontSize="xs" fontWeight="bold" textTransform="uppercase" letterSpacing="wider" color={categoryTextColor}>
{item.label}
</Text>
<Icon as={catCollapsed ? ChevronRightIcon : ChevronDownIcon} boxSize={3.5} color={useColorModeValue('gray.500','gray.400')} />
<Box flex="1" height="1px" bg={useColorModeValue('gray.200','whiteAlpha.300')} />
<Icon as={catCollapsed ? ChevronRightIcon : ChevronDownIcon} boxSize={3.5} color={categoryIconColor} />
<Box flex="1" height="1px" bg={dividerColor} />
</Flex>
</Box>
);
if (isCategory) {
return (
<Box key={item.id || index}>
<Box>
{categoryHeader}
{hasChildren && (
<Collapse in={!catCollapsed} animateOpacity unmountOnExit>
<VStack align="stretch" spacing={1} px={1}>
{item.children!.filter(c => c.visible).map((child, cidx) => {
{item.children!.filter(c => {
// Hide manual FACR if not in manual club data mode
if (c.page_type === 'manual_facr' && !isManualClubDataMode) {
return false;
}
return c.visible;
}).map((child, cidx) => {
const childIcon = getIconForPageType(child.page_type);
const childUrl = child.url || '#';
const showBadge = child.page_type === 'activities' && upcomingCount > 0;
return (
<NavItem
key={child.id || `${item.id}-c-${cidx}`}
icon={childIcon}
to={childUrl}
onClick={onClose}
>
return renderNavItem({
icon: childIcon,
to: childUrl,
onClick: onClose,
children: (
<Text as="span">
{child.label}
{showBadge && (
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('green.100','green.900')} color={useColorModeValue('green.700','green.200')} borderWidth="1px" borderColor={useColorModeValue('green.200','green.700')}>
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={badgeBgGreen} color={badgeColorGreen} borderWidth="1px" borderColor={badgeBorderGreen}>
{upcomingCount}
</Text>
)}
</Text>
</NavItem>
);
)
});
})}
</VStack>
</Collapse>
@@ -506,39 +551,38 @@ const AdminSidebar = ({
}
// Non-category top-level item
// Hide manual FACR if not in manual club data mode
if (item.page_type === 'manual_facr' && !isManualClubDataMode) {
return null;
}
const itemIcon = getIconForPageType(item.page_type);
const itemUrl = item.url || '#';
const isActivities = item.page_type === 'activities';
const showBadge = isActivities && upcomingCount > 0;
return (
<NavItem
key={item.id || index}
icon={itemIcon}
to={itemUrl}
onClick={onClose}
>
return renderNavItem({
icon: itemIcon,
to: itemUrl,
onClick: onClose,
children: (
<Text as="span">
{item.label}
{showBadge && (
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('green.100','green.900')} color={useColorModeValue('green.700','green.200')} borderWidth="1px" borderColor={useColorModeValue('green.200','green.700')}>
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={badgeBgGreen} color={badgeColorGreen} borderWidth="1px" borderColor={badgeBorderGreen}>
{upcomingCount}
</Text>
)}
</Text>
</NavItem>
);
)
});
})}
{/* Ensure Shortlinks is present even if not configured in dynamic nav (admins only) */}
{isAdmin && !hasShortlinks && (
<NavItem
icon={FaLink}
to="/admin/shortlinks"
onClick={onClose}
>
Zkrácené odkazy
</NavItem>
)}
{isAdmin && !hasShortlinks && renderNavItem({
icon: FaLink,
to: "/admin/shortlinks",
onClick: onClose,
children: "Zkrácené odkazy"
})}
{/* Ensure Engagement page is present even if not configured in dynamic nav (admins only) */}
{isAdmin && !hasEngagement && (
@@ -593,6 +637,17 @@ const AdminSidebar = ({
</NavItem>
)}
{/* Ensure E-shop products are present even if not configured in dynamic nav (admins only) - ONLY if e-shop is enabled */}
{isAdmin && !hasEshopProducts && isEshopEnabled && (
<NavItem
icon={FaTshirt}
to="/admin/eshop-produkty"
onClick={onClose}
>
Eshop produkty
</NavItem>
)}
{/* Ensure About page (O klubu) and other core admin pages are present (admins only) */}
{isAdmin && !hasAbout && (
<NavItem
@@ -747,11 +802,20 @@ const AdminSidebar = ({
Prefetch & Cache
</NavItem>
)}
{isAdmin && !hasManualData && isManualClubDataMode && (
<NavItem
icon={FaFileAlt}
to="/admin/manual-data"
onClick={onClose}
>
Manuální data soutěží
</NavItem>
)}
</>
) : (
// Fallback to hardcoded navigation
<>
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={useColorModeValue('gray.500', 'gray.400')} textTransform="uppercase" letterSpacing="wider">
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={headerMutedColor} textTransform="uppercase" letterSpacing="wider">
Hlavní
</Text>
@@ -764,6 +828,15 @@ const AdminSidebar = ({
Nástěnka
</NavItem>
)}
{isAdmin && isManualClubDataMode && (
<NavItem
icon={FaFileAlt}
to="/admin/manual-data"
onClick={onClose}
>
Manuální data soutěží
</NavItem>
)}
{isAdmin && (
<NavItem
@@ -775,7 +848,7 @@ const AdminSidebar = ({
</NavItem>
)}
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={useColorModeValue('gray.500', 'gray.400')} textTransform="uppercase" letterSpacing="wider" mt={4}>
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={headerMutedColor} textTransform="uppercase" letterSpacing="wider" mt={4}>
Obsah
</Text>
{/* Core sports entities first */}
@@ -796,7 +869,7 @@ const AdminSidebar = ({
{/* Add subtle scroller hint */}
<Text as="span">
Zápasy
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('gray.100','whiteAlpha.200')} color={useColorModeValue('gray.700','gray.300')} borderWidth="1px" borderColor={useColorModeValue('gray.200','whiteAlpha.300')}>
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={badgeBgGray} color={badgeColorGray} borderWidth="1px" borderColor={badgeBorderGray}>
scroller
</Text>
</Text>
@@ -811,7 +884,7 @@ const AdminSidebar = ({
<Text as="span">
Aktivity
{upcomingCount > 0 && (
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('green.100','green.900')} color={useColorModeValue('green.700','green.200')} borderWidth="1px" borderColor={useColorModeValue('green.200','green.700')}>
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={badgeBgGreen} color={badgeColorGreen} borderWidth="1px" borderColor={badgeBorderGreen}>
{upcomingCount}
</Text>
)}
@@ -947,7 +1020,7 @@ const AdminSidebar = ({
{isAdmin && (
<>
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={useColorModeValue('gray.500', 'gray.400')} textTransform="uppercase" letterSpacing="wider" mt={4}>
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={headerMutedColor} textTransform="uppercase" letterSpacing="wider" mt={4}>
Nastavení
</Text>
@@ -0,0 +1,58 @@
import React, { useState } from 'react';
import { BlogTranslator } from './BlogTranslator';
import { Box, Text, Divider, Alert, AlertIcon } from '@chakra-ui/react';
/**
* Example component showing how to integrate blog translation
* This can be added to ArticlesAdminPage or any blog editing interface
*/
export const BlogTranslationExample: React.FC = () => {
const [currentTitle, setCurrentTitle] = useState('Vítejte v našem fotbalovém klubu');
const [currentContent, setCurrentContent] = useState(`
<p>Vítáme vás na oficiálních stránkách našeho fotbalového klubu. Naše klub má dlouhou historii a tradici.</p>
<p><strong>Nadcházející zápas:</strong> Sparta Praha vs Slavia Praha</p>
<p><em>Přijďte nás podpořit!</em></p>
`);
const handleTranslationComplete = (translatedTitle: string, translatedContent: string) => {
setCurrentTitle(translatedTitle);
setCurrentContent(translatedContent);
};
return (
<Box p={4} borderWidth={1} borderRadius="md" bg="white">
<Text fontSize="lg" fontWeight="bold" mb={4}>
Blog Translation Integration Example
</Text>
<Box mb={4}>
<Text fontWeight="medium" mb={2}>Current Content:</Text>
<Box p={3} bg="gray.50" borderRadius="md">
<Text fontWeight="bold">{currentTitle}</Text>
<Box dangerouslySetInnerHTML={{ __html: currentContent }} mt={2} />
</Box>
</Box>
<Divider my={4} />
<BlogTranslator
title={currentTitle}
content={currentContent}
onTranslationComplete={handleTranslationComplete}
/>
<Alert status="info" mt={4}>
<AlertIcon />
<Box>
<Text fontWeight="bold">Integration Instructions:</Text>
<Text fontSize="sm">
1. Add the BlogTranslator component to your article editing interface<br/>
2. Pass the current title and content as props<br/>
3. Handle the onTranslationComplete callback to update your form state<br/>
4. The component automatically detects source language and translates to the opposite language
</Text>
</Box>
</Alert>
</Box>
);
};
@@ -0,0 +1,108 @@
import React from 'react';
import {
Button,
Box,
Text,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Spinner,
HStack,
VStack,
Divider,
} from '@chakra-ui/react';
import { useBlogTranslation } from '../../hooks/useBlogTranslation';
import { FaLanguage, FaCheck, FaExclamationTriangle } from 'react-icons/fa';
interface BlogTranslatorProps {
title: string;
content: string;
onTranslationComplete: (translatedTitle: string, translatedContent: string) => void;
disabled?: boolean;
}
export const BlogTranslator: React.FC<BlogTranslatorProps> = ({
title,
content,
onTranslationComplete,
disabled = false,
}) => {
const { translateBlog, isTranslating, translationError, detectSourceLanguage, getTargetLanguage } = useBlogTranslation();
const handleTranslate = async () => {
try {
const result = await translateBlog(title, content);
onTranslationComplete(result.title, result.content);
} catch (error) {
// Error is handled by the hook
}
};
const sourceLang = detectSourceLanguage(title + ' ' + content);
const targetLang = getTargetLanguage();
const shouldTranslate = sourceLang !== targetLang && title && content;
if (!shouldTranslate) {
return (
<Alert status="info" borderRadius="md">
<AlertIcon as={FaLanguage} />
<Box>
<AlertTitle>Translation not needed</AlertTitle>
<AlertDescription>
Content is already in {sourceLang === 'cs' ? 'Czech' : 'English'} or the target language is the same.
</AlertDescription>
</Box>
</Alert>
);
}
return (
<VStack spacing={3} align="stretch">
<Box>
<HStack spacing={2} mb={2}>
<FaLanguage />
<Text fontWeight="medium">
Translate from {sourceLang === 'cs' ? 'Czech' : 'English'} to {targetLang === 'cs' ? 'Czech' : 'English'}
</Text>
</HStack>
<Button
colorScheme="blue"
onClick={handleTranslate}
isLoading={isTranslating}
loadingText="Translating..."
leftIcon={<FaLanguage />}
disabled={disabled || isTranslating}
size="sm"
>
{isTranslating ? 'Translating...' : 'Translate Blog'}
</Button>
</Box>
{translationError && (
<Alert status="error" borderRadius="md">
<AlertIcon as={FaExclamationTriangle} />
<Box>
<AlertTitle>Translation Failed</AlertTitle>
<AlertDescription>{translationError}</AlertDescription>
</Box>
</Alert>
)}
{!isTranslating && !translationError && (
<Alert status="success" borderRadius="md">
<AlertIcon as={FaCheck} />
<Box>
<AlertTitle>Ready to Translate</AlertTitle>
<AlertDescription>
Click the translate button to convert this blog content to {targetLang === 'cs' ? 'Czech' : 'English'}.
</AlertDescription>
</Box>
</Alert>
)}
<Divider />
</VStack>
);
};
@@ -1,13 +1,14 @@
import React from 'react';
import { IconButton, Button, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, Textarea, useToast, Tooltip, Box, Menu, MenuButton, MenuList, MenuItem } from '@chakra-ui/react';
import { IconButton, Button, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, Textarea, useToast, Tooltip, Box, Menu, MenuButton, MenuList, MenuItem, SimpleGrid, Text, Image as ChakraImage } from '@chakra-ui/react';
import { Share2, Instagram, Twitter, Facebook, Copy } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import { createShortLink, createPublicShortLink } from '../../services/shortlinks';
import { Article, getArticleMatchLink } from '../../services/articles';
import { API_URL } from '../../services/api';
import { composeInstagramPostFromArticle, composeInstagramPostFromActivity, MatchSnapshot, stripHtml, formatDateTime, cleanVenue } from '../../services/instagram';
import { generateInstagramAI } from '../../services/ai';
import { generateInstagramAI, generateInstagramImagesAI } from '../../services/ai';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import AILoadingModal from '../common/AILoadingModal';
interface Props {
article?: Article;
@@ -49,6 +50,9 @@ const InstagramGeneratorButton: React.FC<Props> = ({
const [text, setText] = React.useState('');
const [shortUrl, setShortUrl] = React.useState('');
const [loading, setLoading] = React.useState(false);
const [aiProgress, setAiProgress] = React.useState<number | undefined>(undefined);
const [aiStartTime, setAiStartTime] = React.useState<number | null>(null);
const [images, setImages] = React.useState<string[]>([]);
// Build deterministic campaign id for UTM and shortlink code
const campaignId = React.useMemo(() => {
@@ -82,15 +86,29 @@ const InstagramGeneratorButton: React.FC<Props> = ({
}
try {
setLoading(true);
setAiProgress(0);
setAiStartTime(Date.now());
setImages([]);
// Start progress simulation
const progressInterval = setInterval(() => {
setAiProgress(prev => {
if (prev === undefined || prev >= 95) return prev;
return Math.min(prev + Math.random() * 20 + 10, 95);
});
}, 1500);
const fullUrl = withUtm(computeTarget());
if (!fullUrl) throw new Error('Nelze zjistit URL článku/aktivity');
// Deterministic shortlink code to keep link stable across generations
const platformCode = article?.id ? `ig-a${article.id}` : (activity?.id ? `ig-e${activity.id}` : 'ig-share');
const payload = {
target_url: fullUrl,
title: article?.title || activity?.title || 'Link',
source_type: article ? 'article' : (activity ? 'event' : 'other'),
source_id: article?.id || activity?.id,
code: platformCode,
} as any;
let sUrl = '';
try {
@@ -192,13 +210,50 @@ const InstagramGeneratorButton: React.FC<Props> = ({
} else {
composed = `${clubName || 'Náš klub'}\n\n🔗 ${sUrl || fullUrl}`;
}
try {
const primaryColor = (publicSettings as any)?.primary_color || '';
const secondaryColor = (publicSettings as any)?.secondary_color || '';
const imagePromptBase = article
? `Návrh instagramového obrázku k článku "${article.title}" pro oficiální profil fotbalového klubu${clubName ? ' ' + clubName : ''}.`
: activity
? `Návrh instagramového obrázku k aktivitě "${activity.title}" pro oficiální profil fotbalového klubu${clubName ? ' ' + clubName : ''}.`
: `Návrh univerzálního instagramového obrázku pro oficiální profil fotbalového klubu${clubName ? ' ' + clubName : ''}.`;
let colorHint = '';
if (primaryColor || secondaryColor) {
colorHint = ` Klubové barvy: ${primaryColor || ''}${primaryColor && secondaryColor ? ', ' : ''}${secondaryColor || ''}.`;
}
const imagePrompt = `${imagePromptBase} Zobraz stadion, hráče nebo fanoušky našeho klubu, žádné loga soupeře ani text v obrázku.${colorHint} Styl: realistický, moderní, sportovní, poměr stran 4:5, bez textu, vhodné jako hlavní vizuál příspěvku.`;
const imgResp = await generateInstagramImagesAI({
prompt: imagePrompt,
aspect: '4:5',
count: 2,
});
if (Array.isArray(imgResp?.urls) && imgResp.urls.length > 0) {
setImages(imgResp.urls);
}
} catch (imgErr) {
console.error('Instagram image generation failed', imgErr);
}
clearInterval(progressInterval);
setAiProgress(100);
setText(composed);
onGenerated?.(composed, sUrl || fullUrl);
onOpen();
setTimeout(() => {
onOpen();
}, 500); // Brief moment to show 100% completion
} catch (err: any) {
toast({ status: 'error', title: 'Nelze vygenerovat příspěvek', description: err?.message || 'Zkuste to prosím znovu.' });
} finally {
setLoading(false);
setTimeout(() => {
setLoading(false);
setAiProgress(undefined);
setAiStartTime(null);
}, 1000); // Brief moment to show 100% completion or clear on error
}
};
@@ -269,10 +324,10 @@ const InstagramGeneratorButton: React.FC<Props> = ({
const AdminButtonEl = (
<Tooltip label="Vygenerovat Instagram příspěvek" placement="right">
{variant === 'icon' ? (
<IconButton aria-label="IG post" icon={<Share2 size={18} />} colorScheme="brand" onClick={handleGenerate} isLoading={loading} size={size} />
<IconButton aria-label="Instagram příspěvek" icon={<Share2 size={18} />} colorScheme="brand" onClick={handleGenerate} isDisabled={loading} size={size} />
) : (
<Button leftIcon={<Share2 size={18} />} colorScheme="brand" onClick={handleGenerate} isLoading={loading} size={size}>
Instagram post
<Button leftIcon={<Share2 size={18} />} colorScheme="brand" onClick={handleGenerate} isDisabled={loading} size={size}>
Instagram příspěvek
</Button>
)}
</Tooltip>
@@ -280,7 +335,16 @@ const InstagramGeneratorButton: React.FC<Props> = ({
const VisitorShareEl = (
<Menu placement="top-start">
<MenuButton as={IconButton} aria-label="Sdílet" icon={<Share2 size={18} />} variant="solid" colorScheme="brand" />
<MenuButton
as={IconButton}
aria-label="Sdílet"
icon={<Share2 size={18} />}
variant="solid"
colorScheme="brand"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onTouchStart={(e) => { e.preventDefault(); e.stopPropagation(); }}
/>
<MenuList>
<MenuItem onClick={() => handleShareClick('instagram')} icon={<Instagram size={16} />}>Instagram</MenuItem>
<MenuItem onClick={() => handleShareClick('twitter')} icon={<Twitter size={16} />}>Twitter</MenuItem>
@@ -312,9 +376,29 @@ const InstagramGeneratorButton: React.FC<Props> = ({
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Instagram post</ModalHeader>
<ModalHeader>Instagram příspěvek</ModalHeader>
<ModalBody>
<Textarea value={text} onChange={(e) => setText(e.target.value)} rows={12} fontFamily="mono" />
{images.length > 0 && (
<Box mt={4}>
<Text fontSize="sm" mb={2}>Návrhy obrázků pro Instagram (Grok):</Text>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={3}>
{images.map((url, idx) => (
<Box key={idx}>
<ChakraImage src={url} alt={`Instagram obrázek ${idx + 1}`} borderRadius="md" w="100%" objectFit="cover" />
<Button
mt={2}
size="xs"
variant="outline"
onClick={() => { window.open(url, '_blank'); }}
>
Otevřít v novém okně
</Button>
</Box>
))}
</SimpleGrid>
</Box>
)}
</ModalBody>
<ModalFooter gap={3}>
<Button variant="outline" onClick={handleCopy}>Kopírovat</Button>
@@ -322,6 +406,15 @@ const InstagramGeneratorButton: React.FC<Props> = ({
</ModalFooter>
</ModalContent>
</Modal>
<AILoadingModal
isOpen={loading}
onClose={() => {}} // Can't close while AI is working
title="AI generuje Instagram příspěvek"
message="Pracuji na vytvoření příspěvku pro Instagram..."
progress={aiProgress}
estimatedTime={aiStartTime ? 20 : undefined} // 20 seconds estimated
/>
</>
);
};
@@ -263,6 +263,7 @@ const MapLinkImporter: React.FC<MapLinkImporterProps> = ({
overflow="hidden"
borderWidth="1px"
borderColor={borderColor}
minH="300px"
>
<ContactMap
latitude={previewCoords.latitude}
@@ -306,6 +307,41 @@ const MapLinkImporter: React.FC<MapLinkImporterProps> = ({
</>
)}
{/* Show map even without preview if coordinates exist */}
{!previewCoords && (currentLatitude && currentLongitude) && (
<>
<Divider />
<Box>
<Text fontWeight="semibold" mb={2}>
Náhled mapy
</Text>
<Box
borderRadius="md"
overflow="hidden"
borderWidth="1px"
borderColor={borderColor}
minH="300px"
>
<ContactMap
latitude={currentLatitude}
longitude={currentLongitude}
zoom={currentZoom || 15}
address={undefined}
clubName={clubName}
mapStyle={mapStyle || 'positron'}
clubPrimaryColor={clubPrimaryColor}
clubSecondaryColor={clubSecondaryColor}
height={300}
/>
</Box>
<Text fontSize="xs" color="gray.500" mt={2}>
Souřadnice: {currentLatitude.toFixed(6)}, {currentLongitude.toFixed(6)}
{currentZoom && ` | Zoom: ${currentZoom}`}
</Text>
</Box>
</>
)}
</VStack>
);
};
+33 -15
View File
@@ -29,7 +29,8 @@ import {
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AddIcon, DeleteIcon, ChevronDownIcon, ChevronUpIcon, CloseIcon } from '@chakra-ui/icons';
import { FiPlus } from 'react-icons/fi';
import { getPolls, createPoll, updatePoll, Poll, CreatePollRequest } from '../../services/polls';
import { getAdminPolls, getPolls, createPoll, updatePoll, Poll, CreatePollRequest } from '../../services/polls';
import { useConfirmDialog } from '../../contexts/ConfirmDialogContext';
interface PollLinkerProps {
articleId?: number;
@@ -47,6 +48,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
const [isExpanded, setIsExpanded] = useState(true);
const [selectedPollId, setSelectedPollId] = useState<string>('');
const [showCreateForm, setShowCreateForm] = useState(false);
const { confirm } = useConfirmDialog();
// Poll creation form state
const [newPollData, setNewPollData] = useState<CreatePollRequest>({
@@ -82,7 +84,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
// Query for all available polls
const { data: allPolls, isLoading: isLoadingAll } = useQuery({
queryKey: ['all-admin-polls'],
queryFn: () => getPolls({ status: 'active' }),
queryFn: () => getAdminPolls({ status: 'active' }),
});
// Mutation to link existing poll
@@ -189,10 +191,16 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
linkPollMutation.mutate(parseInt(selectedPollId));
};
const handleUnlinkPoll = (pollId: number) => {
if (window.confirm('Opravdu chcete odpojit tuto anketu?')) {
unlinkPollMutation.mutate(pollId);
}
const handleUnlinkPoll = async (pollId: number) => {
const ok = await confirm({
title: 'Odpojit anketu',
message: 'Opravdu chcete odpojit tuto anketu?',
confirmText: 'Odpojit',
cancelText: 'Zrušit',
isDanger: true,
});
if (!ok) return;
unlinkPollMutation.mutate(pollId);
};
const resetNewPollForm = () => {
@@ -272,13 +280,13 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
}));
};
// Filter out polls that are already linked elsewhere to avoid accidental reuse
const linkedPollIds = new Set(linkedPolls?.map(p => p.id) || []);
const availablePolls = allPolls?.filter(p => {
if (linkedPollIds.has(p.id)) return false; // already linked to this content, handled above
const linkedElsewhere = !!(p.related_article_id || p.related_event_id || p.related_match_id || p.related_video_url);
return !linkedElsewhere;
}) || [];
// Filter out polls that are already linked to THIS content to avoid duplicates
// But allow polls that are linked elsewhere (user can decide to reuse)
const linkedPollIds = new Set(linkedPolls?.map((p: Poll) => p.id) || []);
const availablePolls = allPolls?.filter((p: Poll) => !linkedPollIds.has(p.id)) || [];
// For debugging: also include all polls to see what's available
const allAvailablePolls = allPolls || [];
if (!articleId && !eventId) {
return null;
@@ -323,7 +331,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
<Text fontSize="xs" fontWeight="bold" color="gray.500">
Připojené ankety:
</Text>
{linkedPolls.map((poll) => (
{linkedPolls.map((poll: Poll) => (
<HStack
key={poll.id}
p={2}
@@ -390,7 +398,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
size="sm"
flex={1}
>
{availablePolls.map((poll) => (
{availablePolls.map((poll: Poll) => (
<option key={poll.id} value={poll.id}>
{poll.title} ({poll.status}) - {poll.total_votes} hlasů
</option>
@@ -408,6 +416,16 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
</Button>
</HStack>
</VStack>
) : allAvailablePolls.length > 0 ? (
<Alert status="warning" size="sm">
<AlertIcon />
<VStack align="start" spacing={2}>
<Text fontSize="sm">Všechny aktivní ankety jsou již propojeny s touto aktivitou.</Text>
<Text fontSize="xs" color="gray.600">
Dostupné ankety ({allAvailablePolls.length}): {allAvailablePolls.map(p => p.title).join(', ')}
</Text>
</VStack>
</Alert>
) : (
<Alert status="info" size="sm">
<AlertIcon />
@@ -0,0 +1,120 @@
import React from 'react';
import {
Button,
Box,
Text,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Spinner,
HStack,
VStack,
Divider,
} from '@chakra-ui/react';
import { useBlogTranslation } from '../../hooks/useBlogTranslation';
import { FaLanguage, FaCheck, FaExclamationTriangle } from 'react-icons/fa';
interface UniversalTranslatorProps {
title: string;
content: string;
onTranslationComplete: (translatedTitle: string, translatedContent: string) => void;
disabled?: boolean;
contentType?: 'article' | 'activity' | 'page' | 'sponsor' | 'player';
}
export const UniversalTranslator: React.FC<UniversalTranslatorProps> = ({
title,
content,
onTranslationComplete,
disabled = false,
contentType = 'article',
}) => {
const { translateBlog, isTranslating, translationError, detectSourceLanguage, getTargetLanguage } = useBlogTranslation();
const handleTranslate = async () => {
try {
const result = await translateBlog(title, content);
onTranslationComplete(result.title, result.content);
} catch (error) {
// Error is handled by the hook
}
};
const sourceLang = detectSourceLanguage(title + ' ' + content);
const targetLang = getTargetLanguage();
const shouldTranslate = sourceLang !== targetLang && title && content;
if (!shouldTranslate) {
return (
<Alert status="info" borderRadius="md">
<AlertIcon as={FaLanguage} />
<Box>
<AlertTitle>Translation not needed</AlertTitle>
<AlertDescription>
Content is already in {sourceLang === 'cs' ? 'Czech' : 'English'} or the target language is the same.
</AlertDescription>
</Box>
</Alert>
);
}
const getContentTypeLabel = () => {
switch (contentType) {
case 'activity': return 'Activity';
case 'page': return 'Page';
case 'sponsor': return 'Sponsor';
case 'player': return 'Player';
default: return 'Content';
}
};
return (
<VStack spacing={3} align="stretch">
<Box>
<HStack spacing={2} mb={2}>
<FaLanguage />
<Text fontWeight="medium">
Translate {getContentTypeLabel()} from {sourceLang === 'cs' ? 'Czech' : 'English'} to {targetLang === 'cs' ? 'Czech' : 'English'}
</Text>
</HStack>
<Button
colorScheme="blue"
onClick={handleTranslate}
isLoading={isTranslating}
loadingText="Translating..."
leftIcon={<FaLanguage />}
disabled={disabled || isTranslating}
size="sm"
>
{isTranslating ? 'Translating...' : `Translate ${getContentTypeLabel()}`}
</Button>
</Box>
{translationError && (
<Alert status="error" borderRadius="md">
<AlertIcon as={FaExclamationTriangle} />
<Box>
<AlertTitle>Translation Failed</AlertTitle>
<AlertDescription>{translationError}</AlertDescription>
</Box>
</Alert>
)}
{!isTranslating && !translationError && (
<Alert status="success" borderRadius="md">
<AlertIcon as={FaCheck} />
<Box>
<AlertTitle>Ready to Translate</AlertTitle>
<AlertDescription>
Click the translate button to convert this {getContentTypeLabel().toLowerCase()} to {targetLang === 'cs' ? 'Czech' : 'English'}.
</AlertDescription>
</Box>
</Alert>
)}
<Divider />
</VStack>
);
};
@@ -10,10 +10,8 @@ import {
Text,
useColorModeValue,
VStack,
Tooltip,
Icon,
} from '@chakra-ui/react';
import { FaInfoCircle } from 'react-icons/fa';
import { HelpTooltipCard } from '../common/HelpTooltipCard';
import { ComposableMap, Geographies, Geography } from 'react-simple-maps';
import type { Feature } from 'geojson';
import { useClubTheme } from '../../contexts/ClubThemeContext';
@@ -183,13 +181,14 @@ export const VisitorCountriesMap: React.FC<VisitorCountriesMapProps> = ({
<CardHeader>
<Flex align="center" justify="space-between">
<Heading size="md">{title}</Heading>
<Tooltip
label="Klikněte na zemi pro zobrazení detailních analytických dat"
placement="top"
hasArrow
>
<Icon as={FaInfoCircle} color="gray.400" cursor="help" />
</Tooltip>
<HelpTooltipCard
label="Jak pracovat s mapou návštěvníků"
title="Jak pracovat s mapou návštěvníků"
items={[
'Najetím myši na zemi zobrazíte počet návštěv z dané země.',
'Kliknutím na zemi zobrazíte detailnější analytická data podle nastavení nadřazené stránky.',
]}
/>
</Flex>
</CardHeader>
<CardBody>
@@ -0,0 +1,404 @@
import { Box, Text, HStack, VStack, Icon, Skeleton, Badge, useColorModeValue, Tooltip, Progress, Divider, Heading, SimpleGrid } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { FaCloud, FaCloudSun, FaSun, FaCloudRain, FaSnowflake, FaWind, FaTint, FaEye, FaThermometerHalf, FaMapMarkerAlt, FaSyncAlt, FaMoon } from 'react-icons/fa';
import api from '../../services/api';
interface WeatherData {
location: {
name: string;
region: string;
country: string;
lat: number;
lon: number;
tz_id: string;
localtime: string;
};
current: {
last_updated: string;
temp_c: number;
temp_f: number;
is_day: number;
condition: {
text: string;
icon: string;
code: number;
};
wind_mph: number;
wind_kph: number;
wind_degree: number;
wind_dir: string;
pressure_mb: number;
pressure_in: number;
precip_mm: number;
precip_in: number;
humidity: number;
cloud: number;
feelslike_c: number;
feelslike_f: number;
vis_km: number;
vis_miles: number;
uv: number;
gust_mph: number;
gust_kph: number;
};
forecast: {
forecastday: Array<{
date: string;
date_epoch: number;
day: {
maxtemp_c: number;
maxtemp_f: number;
mintemp_c: number;
mintemp_f: number;
avgtemp_c: number;
avgtemp_f: number;
maxwind_mph: number;
maxwind_kph: number;
totalprecip_mm: number;
totalprecip_in: number;
totalsnow_cm: number;
avgvis_km: number;
avgvis_miles: number;
avghumidity: number;
daily_will_it_rain: number;
daily_chance_of_rain: number;
daily_will_it_snow: number;
daily_chance_of_snow: number;
condition: {
text: string;
icon: string;
code: number;
};
uv: number;
};
astro: {
sunrise: string;
sunset: string;
moonrise: string;
moonset: string;
moon_phase: string;
moon_illumination: string;
is_moon_up: number;
is_sun_up: number;
};
hour: Array<{
time_epoch: number;
time: string;
temp_c: number;
temp_f: number;
is_day: number;
condition: {
text: string;
icon: string;
code: number;
};
wind_mph: number;
wind_kph: number;
wind_degree: number;
wind_dir: string;
pressure_mb: number;
pressure_in: number;
precip_mm: number;
precip_in: number;
humidity: number;
cloud: number;
feelslike_c: number;
feelslike_f: number;
windchill_c: number;
windchill_f: number;
heatindex_c: number;
heatindex_f: number;
dewpoint_c: number;
dewpoint_f: number;
will_it_rain: number;
chance_of_rain: number;
will_it_snow: number;
chance_of_snow: number;
vis_km: number;
vis_miles: number;
gust_mph: number;
gust_kph: number;
uv: number;
}>;
}>;
};
}
const getWeatherIcon = (condition: string, isDay: number) => {
const lowerCondition = condition.toLowerCase();
if (lowerCondition.includes('sunny') || lowerCondition.includes('clear')) {
return isDay ? FaSun : FaMoon;
}
if (lowerCondition.includes('partly cloudy') || lowerCondition.includes('partly sunny')) {
return FaCloudSun;
}
if (lowerCondition.includes('cloudy') || lowerCondition.includes('overcast')) {
return FaCloud;
}
if (lowerCondition.includes('rain') || lowerCondition.includes('drizzle') || lowerCondition.includes('shower')) {
return FaCloudRain;
}
if (lowerCondition.includes('snow') || lowerCondition.includes('sleet') || lowerCondition.includes('blizzard')) {
return FaSnowflake;
}
return FaCloud;
};
const getUVColor = (uv: number) => {
if (uv <= 2) return 'green';
if (uv <= 5) return 'yellow';
if (uv <= 7) return 'orange';
if (uv <= 10) return 'red';
return 'purple';
};
const getUVLabel = (uv: number) => {
if (uv <= 2) return 'Nízké';
if (uv <= 5) return 'Střední';
if (uv <= 7) return 'Vysoké';
if (uv <= 10) return 'Velmi vysoké';
return 'Extrémní';
};
const getWindDirection = (degree: number) => {
const directions = ['S', 'SV', 'V', 'JV', 'J', 'JZ', 'Z', 'SZ'];
const index = Math.round(degree / 45) % 8;
return directions[index];
};
const WeatherWidget = () => {
const { data: weather, isLoading, error, refetch } = useQuery<WeatherData>({
queryKey: ['weather', 'club'],
queryFn: async () => {
const response = await api.get('/weather/club');
return response.data;
},
staleTime: 10 * 60 * 1000, // 10 minutes
refetchInterval: 15 * 60 * 1000, // 15 minutes
});
const bgCard = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textColor = useColorModeValue('gray.600', 'gray.400');
if (isLoading) {
return (
<Box
bg={bgCard}
p={6}
borderRadius="xl"
boxShadow="md"
borderWidth="1px"
borderColor={borderColor}
>
<VStack spacing={4} align="stretch">
<Skeleton height="20px" width="60%" />
<Skeleton height="40px" width="80%" />
<Skeleton height="16px" width="40%" />
<Skeleton height="16px" width="50%" />
</VStack>
</Box>
);
}
if (error || !weather) {
return (
<Box
bg={bgCard}
p={6}
borderRadius="xl"
boxShadow="md"
borderWidth="1px"
borderColor={borderColor}
>
<VStack spacing={4} align="center">
<Icon as={FaCloud} boxSize={12} color="gray.400" />
<Text color="gray.500" textAlign="center">
Počasí není dostupné
</Text>
<Text fontSize="sm" color="gray.400" textAlign="center">
Zkuste to znovu později
</Text>
</VStack>
</Box>
);
}
const currentIcon = getWeatherIcon(weather.current.condition.text, weather.current.is_day);
const uvColor = getUVColor(weather.current.uv);
const uvLabel = getUVLabel(weather.current.uv);
return (
<Box
bg={bgCard}
p={6}
borderRadius="xl"
boxShadow="md"
borderWidth="1px"
borderColor={borderColor}
position="relative"
overflow="hidden"
>
{/* Refresh button */}
<Box position="absolute" top={4} right={4}>
<Tooltip label="Obnovit počasí">
<Box
as="button"
onClick={() => refetch()}
p={2}
borderRadius="md"
_hover={{ bg: useColorModeValue('gray.100', 'gray.700') }}
transition="all 0.2s"
>
<Icon as={FaSyncAlt} color={textColor} boxSize={4} />
</Box>
</Tooltip>
</Box>
<VStack spacing={4} align="stretch">
{/* Location */}
<HStack spacing={2} color={textColor}>
<Icon as={FaMapMarkerAlt} boxSize={3} />
<Text fontSize="sm" fontWeight="medium">
{weather.location.name}
{weather.location.region && `, ${weather.location.region}`}
</Text>
</HStack>
{/* Current Weather */}
<HStack spacing={4} align="center">
<VStack spacing={1} align="center" minW="80px">
<Icon as={currentIcon} boxSize={12} color="blue.400" />
<Text fontSize="sm" color={textColor} textAlign="center">
{weather.current.condition.text}
</Text>
</VStack>
<VStack spacing={1} align="start" flex={1}>
<Text fontSize="4xl" fontWeight="bold" lineHeight="1">
{Math.round(weather.current.temp_c)}°C
</Text>
<Text fontSize="sm" color={textColor}>
Pocitově {Math.round(weather.current.feelslike_c)}°C
</Text>
</VStack>
</HStack>
<Divider />
{/* Weather Details Grid */}
<SimpleGrid columns={2} spacing={3}>
<HStack spacing={2}>
<Icon as={FaWind} boxSize={4} color={textColor} />
<VStack spacing={0} align="start">
<Text fontSize="xs" color={textColor}>Vítr</Text>
<Text fontSize="sm" fontWeight="medium">
{weather.current.wind_kph} km/h {getWindDirection(weather.current.wind_degree)}
</Text>
</VStack>
</HStack>
<HStack spacing={2}>
<Icon as={FaTint} boxSize={4} color={textColor} />
<VStack spacing={0} align="start">
<Text fontSize="xs" color={textColor}>Vlhkost</Text>
<Text fontSize="sm" fontWeight="medium">{weather.current.humidity}%</Text>
</VStack>
</HStack>
<HStack spacing={2}>
<Icon as={FaEye} boxSize={4} color={textColor} />
<VStack spacing={0} align="start">
<Text fontSize="xs" color={textColor}>Viditelnost</Text>
<Text fontSize="sm" fontWeight="medium">{weather.current.vis_km} km</Text>
</VStack>
</HStack>
<HStack spacing={2}>
<Icon as={FaThermometerHalf} boxSize={4} color={textColor} />
<VStack spacing={0} align="start">
<Text fontSize="xs" color={textColor}>Tlak</Text>
<Text fontSize="sm" fontWeight="medium">{weather.current.pressure_mb} hPa</Text>
</VStack>
</HStack>
</SimpleGrid>
{/* UV Index */}
<Box>
<HStack justify="space-between" mb={1}>
<Text fontSize="xs" color={textColor}>UV Index</Text>
<Badge colorScheme={uvColor} fontSize="xs">
{uvLabel} ({weather.current.uv})
</Badge>
</HStack>
<Progress
value={Math.min(weather.current.uv * 10, 100)}
size="xs"
colorScheme={uvColor}
borderRadius="md"
/>
</Box>
{/* Forecast for next 2 days */}
{weather.forecast.forecastday.length > 1 && (
<>
<Divider />
<Text fontSize="sm" fontWeight="bold" mb={2}>
Předpověď na následující dny
</Text>
<SimpleGrid columns={2} spacing={3}>
{weather.forecast.forecastday.slice(1, 3).map((day, index) => {
const dayIcon = getWeatherIcon(day.day.condition.text, 1);
const date = new Date(day.date);
const dayName = date.toLocaleDateString('cs-CZ', { weekday: 'short' });
return (
<Box
key={index}
p={3}
bg={useColorModeValue('gray.50', 'gray.700')}
borderRadius="md"
border="1px solid"
borderColor={useColorModeValue('gray.200', 'gray.600')}
>
<HStack spacing={3} align="center">
<Icon as={dayIcon} boxSize={6} color="blue.400" />
<VStack spacing={1} align="start" flex={1}>
<Text fontSize="xs" fontWeight="medium">{dayName}</Text>
<HStack spacing={2}>
<Text fontSize="sm" fontWeight="bold">
{Math.round(day.day.maxtemp_c)}°
</Text>
<Text fontSize="sm" color={textColor}>
{Math.round(day.day.mintemp_c)}°
</Text>
</HStack>
{day.day.daily_chance_of_rain > 0 && (
<Badge size="xs" colorScheme="blue">
{day.day.daily_chance_of_rain}% déšť
</Badge>
)}
</VStack>
</HStack>
</Box>
);
})}
</SimpleGrid>
</>
)}
{/* Last updated */}
<Text fontSize="xs" color={textColor} textAlign="center">
Aktualizováno: {new Date(weather.current.last_updated).toLocaleTimeString('cs-CZ', {
hour: '2-digit',
minute: '2-digit'
})}
</Text>
</VStack>
</Box>
);
};
export default WeatherWidget;
@@ -5,6 +5,7 @@ import { listComments, createComment, updateComment, deleteComment, CommentItem,
import { useAuth } from '../../contexts/AuthContext';
import { Pencil, Trash2, Send, CheckCircle2 } from 'lucide-react';
import { Link as RouterLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
type Props = {
targetType: 'article' | 'event' | 'gallery_album' | 'youtube_video';
@@ -14,14 +15,18 @@ type Props = {
const PAGE_SIZE = 20;
const displayName = (u?: CommentItem['user']) => {
if (!u) return 'Anonym';
const { i18n } = useTranslation();
const useEnglish = i18n.language === 'en';
if (!u) return useEnglish ? 'Anonymous' : 'Anonym';
const uname = (u.username || '').trim();
if (uname) return uname;
const name = `${u.first_name || ''} ${u.last_name || ''}`.trim();
return name || 'Uživatel';
return name || (useEnglish ? 'User' : 'Uživatel');
};
const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
const { t, i18n } = useTranslation();
const cardBg = useColorModeValue('white', 'gray.800');
const border = useColorModeValue('gray.200', 'gray.700');
const muted = useColorModeValue('gray.600', 'gray.400');
@@ -29,6 +34,8 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
const adminLikedColor = useColorModeValue('blue.600','blue.300');
const queryClient = useQueryClient();
const { isAuthenticated, user } = useAuth();
const useEnglish = i18n.language === 'en';
const commentsQuery = useInfiniteQuery({
queryKey: ['comments', targetType, targetId],
@@ -48,7 +55,9 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
const [replyTo, setReplyTo] = React.useState<number | null>(null);
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
const [canRequestUnban, setCanRequestUnban] = React.useState<boolean>(false);
const [unbanMessage, setUnbanMessage] = React.useState<string>('Prosím o odblokování komentářů. Děkuji.');
const [unbanMessage, setUnbanMessage] = React.useState<string>('Please unblock my comments. Thank you.');
const [localReactions, setLocalReactions] = React.useState<Record<number, string | undefined>>({});
const [pendingReactions, setPendingReactions] = React.useState<Set<number>>(new Set());
const createMut = useMutation({
mutationFn: (body: { content: string; parent_id?: number | null }) => createComment({ target_type: targetType, target_id: targetId, content: body.content, parent_id: body.parent_id }),
@@ -58,12 +67,12 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
setErrorMsg(null);
await queryClient.invalidateQueries({ queryKey: ['comments', targetType, targetId] });
if ((created as any)?.status === 'hidden') {
setErrorMsg('Váš komentář čeká na schválení (automatická moderace).');
setErrorMsg(useEnglish ? 'Your comment is awaiting approval (automatic moderation).' : 'Váš komentář čeká na schválení (automatická moderace).');
}
try { window.dispatchEvent(new CustomEvent('engagement:refresh')); } catch {}
},
onError: (e: any) => {
const msg = e?.response?.data?.error || 'Nepodařilo se odeslat komentář';
const msg = e?.response?.data?.error || (useEnglish ? 'Failed to post comment' : 'Nepodařilo se odeslat komentář');
setErrorMsg(msg);
if ((e?.response?.status || 0) === 403) setCanRequestUnban(true);
}
@@ -88,21 +97,41 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
const reactMut = useMutation({
mutationFn: (args: { id: number; type: string }) => reactComment(args.id, args.type),
onMutate: async ({ id, type }) => {
setPendingReactions(prev => new Set(prev).add(id));
const qk = ['comments', targetType, targetId] as const;
await queryClient.cancelQueries({ queryKey: qk });
const previous = queryClient.getQueryData<any>(qk);
// Set optimistic state
setLocalReactions((m) => ({ ...m, [id]: type }));
queryClient.setQueryData(qk, (oldData: any) => {
if (!oldData) return oldData;
const pages = (oldData.pages || []).map((page: any) => {
const items = (page.items || []).map((it: any) => {
if (it.id !== id) return it;
const next = { ...it, reactions: { ...(it.reactions || {}) } };
const prevType = next.my_reaction as string | undefined;
if (prevType && typeof next.reactions[prevType] === 'number') {
next.reactions[prevType] = Math.max(0, (next.reactions[prevType] || 0) - 1);
const prevType = (next.my_reaction as string | undefined)?.trim();
// Remove previous reaction count if it existed
if (prevType && prevType !== type) {
const prevCount = (typeof next.reactions[prevType] === 'number' ? next.reactions[prevType] : 1) as number;
const newPrev = Math.max(0, prevCount - 1);
if (newPrev <= 0) {
delete (next.reactions as any)[prevType];
} else {
next.reactions[prevType] = newPrev;
}
}
next.reactions[type] = (next.reactions[type] || 0) + 1;
// Add new reaction count
next.reactions[type] = ((next.reactions[type] || 0) as number) + 1;
next.my_reaction = type;
if ((user as any)?.role === 'admin') {
next.admin_liked = (type === 'thumbs_up' || type === 'like');
}
return next;
});
return { ...page, items };
@@ -117,7 +146,23 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
queryClient.setQueryData(qk, (ctx as any).previous);
}
},
onSettled: async () => {
onSettled: async (_data, _error, variables) => {
if (variables?.id) {
setPendingReactions(prev => {
const next = new Set(prev);
next.delete(variables.id);
return next;
});
}
// Clear local optimistic state after a delay to allow server state to sync
setTimeout(() => {
setLocalReactions((m) => {
const cp = { ...m };
delete cp[variables?.id];
return cp;
});
}, 100);
await queryClient.invalidateQueries({ queryKey: ['comments', targetType, targetId] });
try { window.dispatchEvent(new CustomEvent('engagement:refresh')); } catch {}
},
@@ -126,20 +171,35 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
const unreactMut = useMutation({
mutationFn: (id: number) => unreactComment(id),
onMutate: async (id: number) => {
setPendingReactions(prev => new Set(prev).add(id));
const qk = ['comments', targetType, targetId] as const;
await queryClient.cancelQueries({ queryKey: qk });
const previous = queryClient.getQueryData<any>(qk);
// Set optimistic state to empty
setLocalReactions((m) => ({ ...m, [id]: '' }));
queryClient.setQueryData(qk, (oldData: any) => {
if (!oldData) return oldData;
const pages = (oldData.pages || []).map((page: any) => {
const items = (page.items || []).map((it: any) => {
if (it.id !== id) return it;
const next = { ...it, reactions: { ...(it.reactions || {}) } };
const prevType = next.my_reaction as string | undefined;
if (prevType && typeof next.reactions[prevType] === 'number') {
next.reactions[prevType] = Math.max(0, (next.reactions[prevType] || 0) - 1);
const prevType = (next.my_reaction as string | undefined)?.trim();
if (prevType) {
const prevCount = (typeof next.reactions[prevType] === 'number' ? next.reactions[prevType] : 1) as number;
const newPrev = Math.max(0, prevCount - 1);
if (newPrev <= 0) {
delete (next.reactions as any)[prevType];
} else {
next.reactions[prevType] = newPrev;
}
}
next.my_reaction = '';
if ((user as any)?.role === 'admin') {
next.admin_liked = false;
}
return next;
});
return { ...page, items };
@@ -154,7 +214,23 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
queryClient.setQueryData(qk, (ctx as any).previous);
}
},
onSettled: async () => {
onSettled: async (_data, _error, variables) => {
if (variables) {
setPendingReactions(prev => {
const next = new Set(prev);
next.delete(variables);
return next;
});
}
// Clear local optimistic state after a delay to allow server state to sync
setTimeout(() => {
setLocalReactions((m) => {
const cp = { ...m };
delete cp[variables];
return cp;
});
}, 100);
await queryClient.invalidateQueries({ queryKey: ['comments', targetType, targetId] });
try { window.dispatchEvent(new CustomEvent('engagement:refresh')); } catch {}
},
@@ -164,15 +240,15 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
mutationFn: (message: string) => requestUnban(message),
onSuccess: () => {
setCanRequestUnban(false);
setErrorMsg('Žádost o odblokování odeslána.');
setUnbanMessage('Prosím o odblokování komentářů. Děkuji.');
setErrorMsg(useEnglish ? 'Unban request sent.' : 'Žádost o odblokování odeslána.');
setUnbanMessage(useEnglish ? 'Please unblock my comments. Thank you.' : 'Prosím o odblokování komentářů. Děkuji.');
}
});
const reportMut = useMutation({
mutationFn: (args: { id: number; reason?: string }) => reportComment(args.id, args.reason),
onSuccess: async () => {
setErrorMsg('Děkujeme za nahlášení. Moderátor se na komentář podívá.');
setErrorMsg(useEnglish ? 'Thank you for reporting. A moderator will review the comment.' : 'Děkujeme za nahlášení. Moderátor se na komentář podívá.');
},
});
@@ -196,15 +272,54 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
const ReactionBar: React.FC<{ c: CommentItem }> = ({ c }) => {
const options: { key: string; label: string; color: string; name: string }[] = [
{ key: 'thumbs_up', label: '👍', color: 'green', name: 'Palec nahoru' },
{ key: 'heart', label: '❤️', color: 'pink', name: 'Srdíčko' },
{ key: 'smile', label: '😀', color: 'yellow', name: 'Úsměv' },
{ key: 'surprised', label: '😮', color: 'purple', name: 'Překvapení' },
{ key: 'thumbs_down', label: '👎', color: 'red', name: 'Palec dolů' },
{ key: 'thumbs_up', label: '👍', color: 'green', name: useEnglish ? 'Thumbs up' : 'Palec nahoru' },
{ key: 'heart', label: '❤️', color: 'pink', name: useEnglish ? 'Heart' : 'Srdíčko' },
{ key: 'smile', label: '😀', color: 'yellow', name: useEnglish ? 'Smile' : 'Úsměv' },
{ key: 'surprised', label: '😮', color: 'purple', name: useEnglish ? 'Surprised' : 'Překvapení' },
{ key: 'thumbs_down', label: '👎', color: 'red', name: useEnglish ? 'Thumbs down' : 'Palec dolů' },
];
const counts = c.reactions || {};
const active = c.my_reaction;
const isBusy = reactMut.isPending || unreactMut.isPending;
const active = React.useMemo(() => {
// Prioritize local optimistic state, fallback to server state
const localActive = (localReactions[c.id] || '').trim();
if (localActive !== '') {
return localActive;
}
return (c.my_reaction || '').trim();
}, [c.my_reaction, localReactions, c.id]);
const viewCounts = React.useMemo(() => {
const base: Record<string, number> = { ...(counts as any) };
const a = (active || '').trim();
const serverActive = (c.my_reaction || '').trim();
const localActive = (localReactions[c.id] || '').trim();
// If we have a local optimistic state that differs from server, adjust counts
if (localActive !== '' && localActive !== serverActive) {
// Remove old server reaction count if it existed
if (serverActive) {
const prev = typeof base[serverActive] === 'number' ? base[serverActive] : 0;
if (prev > 0) {
base[serverActive] = prev - 1;
if (base[serverActive] <= 0) delete base[serverActive];
}
}
// Add new local reaction count
base[localActive] = (base[localActive] || 0) + 1;
}
// Handle unreact optimistic state (empty string)
else if (localActive === '' && serverActive) {
// Remove the server reaction count optimistically
const prev = typeof base[serverActive] === 'number' ? base[serverActive] : 0;
if (prev > 0) {
base[serverActive] = prev - 1;
if (base[serverActive] <= 0) delete base[serverActive];
}
}
return base;
}, [counts, active, c.my_reaction, localReactions, c.id]);
const isBusy = reactMut.isPending || unreactMut.isPending || pendingReactions.has(c.id);
return (
<HStack spacing={2} mt={1}>
{options.map((o) => (
@@ -214,9 +329,9 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
colorScheme={o.color}
variant={active === o.key ? 'solid' : 'outline'}
isDisabled={!isAuthenticated || isBusy}
aria-pressed={active === o.key}
isActive={active === o.key}
onClick={() => {
if (!isAuthenticated) return;
if (!isAuthenticated || isBusy) return;
if (active === o.key) {
unreactMut.mutate(c.id);
} else {
@@ -226,7 +341,7 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
>
<HStack spacing={1}>
<Text as="span">{o.label}</Text>
<Text as="span" fontSize="xs">{counts[o.key] || 0}</Text>
<Text as="span" fontSize="xs">{viewCounts[o.key] || 0}</Text>
</HStack>
</Button>
</Tooltip>
@@ -246,14 +361,14 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
<HStack justify="space-between">
<HStack spacing={2}>
<Text fontWeight="600">{displayName(c.user)}</Text>
{c.user?.role === 'admin' && <Badge colorScheme="purple" variant="subtle">Admin</Badge>}
{c.user?.role === 'admin' && <Badge colorScheme="purple" variant="subtle">{useEnglish ? 'Admin' : 'Admin'}</Badge>}
<Text fontSize="sm" color={muted}>{new Date(c.created_at).toLocaleString()}</Text>
{c.is_edited && <Text fontSize="xs" color={muted}>(upraveno)</Text>}
{c.is_edited && <Text fontSize="xs" color={muted}>({useEnglish ? '(edited)' : '(upraveno)'})</Text>}
</HStack>
{canEdit(c) && (
<HStack spacing={1}>
<IconButton aria-label="Upravit" size="xs" variant="ghost" icon={<Pencil size={16} />} onClick={() => { setEditingId(c.id); setEditContent(c.content); }} />
<IconButton aria-label="Smazat" size="xs" variant="ghost" colorScheme="red" icon={<Trash2 size={16} />} onClick={() => deleteMut.mutate(c.id)} />
<IconButton aria-label={useEnglish ? 'Edit' : 'Upravit'} size="xs" variant="ghost" icon={<Pencil size={16} />} onClick={() => { setEditingId(c.id); setEditContent(c.content); }} />
<IconButton aria-label={useEnglish ? 'Delete' : 'Smazat'} size="xs" variant="ghost" colorScheme="red" icon={<Trash2 size={16} />} onClick={() => deleteMut.mutate(c.id)} />
</HStack>
)}
</HStack>
@@ -261,8 +376,8 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
<VStack align="stretch" spacing={2}>
<Textarea value={editContent} onChange={(e) => setEditContent(e.target.value)} rows={3} />
<HStack>
<Button size="sm" colorScheme="blue" onClick={() => updateMut.mutate({ id: c.id, content: editContent.trim() })} isLoading={updateMut.isPending}>Uložit</Button>
<Button size="sm" variant="ghost" onClick={() => { setEditingId(null); setEditContent(''); }}>Zrušit</Button>
<Button size="sm" colorScheme="blue" onClick={() => updateMut.mutate({ id: c.id, content: editContent.trim() })} isLoading={updateMut.isPending}>{useEnglish ? 'Save' : 'Uložit'}</Button>
<Button size="sm" variant="ghost" onClick={() => { setEditingId(null); setEditContent(''); }}>{useEnglish ? 'Cancel' : 'Zrušit'}</Button>
</HStack>
</VStack>
) : (
@@ -276,13 +391,13 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
{c.admin_liked && (
<HStack spacing={2} mt={1} color={adminLikedColor}>
<CheckCircle2 size={16} />
<Text fontSize="sm">Označeno administrátorem</Text>
<Text fontSize="sm">{useEnglish ? 'Marked by administrator' : 'Označeno administrátorem'}</Text>
</HStack>
)}
<HStack>
{isAuthenticated && <Button size="xs" variant="ghost" onClick={() => setReplyTo(c.id)}>Odpovědět</Button>}
{isAuthenticated && <Button size="xs" variant="ghost" colorScheme="red" onClick={() => reportMut.mutate({ id: c.id })}>Nahlásit</Button>}
{c.status === 'hidden' && <Badge colorScheme="yellow">Čeká na schválení</Badge>}
{isAuthenticated && <Button size="xs" variant="ghost" onClick={() => setReplyTo(c.id)}>{useEnglish ? 'Reply' : 'Odpovědět'}</Button>}
{isAuthenticated && <Button size="xs" variant="ghost" colorScheme="red" onClick={() => reportMut.mutate({ id: c.id })}>{useEnglish ? 'Report' : 'Nahlásit'}</Button>}
{c.status === 'hidden' && <Badge colorScheme="yellow">{useEnglish ? 'Awaiting approval' : 'Čeká na schválení'}</Badge>}
</HStack>
{/* Replies */}
{renderThread(c.id, depth + 1)}
@@ -295,18 +410,20 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
return (
<Box mt={6} borderWidth="1px" borderColor={border} borderRadius="lg" bg={cardBg} p={4}>
<Heading as="h3" size="md" mb={3}>Komentáře</Heading>
<HStack justify="space-between" align="center" mb={3}>
<Heading as="h3" size="md">{useEnglish ? 'Comments' : 'Komentáře'}</Heading>
</HStack>
{commentsQuery.isLoading ? (
<HStack><Spinner size="sm" /><Text>Načítám</Text></HStack>
<HStack><Spinner size="sm" /><Text>{useEnglish ? 'Loading...' : 'Načítám…'}</Text></HStack>
) : (
<VStack align="stretch" spacing={3}>
{allItems.length === 0 && (
<Text color={muted}>Zatím žádné komentáře.</Text>
<Text color={muted}>{useEnglish ? 'No comments yet.' : 'Zatím žádné komentáře.'}</Text>
)}
{renderThread(null)}
{commentsQuery.hasNextPage && (
<Button onClick={() => commentsQuery.fetchNextPage()} isLoading={commentsQuery.isFetchingNextPage} alignSelf="center" size="sm" variant="outline">Načíst další</Button>
<Button onClick={() => commentsQuery.fetchNextPage()} isLoading={commentsQuery.isFetchingNextPage} alignSelf="center" size="sm" variant="outline">{useEnglish ? 'Load more' : 'Načíst další'}</Button>
)}
</VStack>
)}
@@ -319,32 +436,37 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
<VStack align="stretch" spacing={2}>
{replyTo && (
<HStack justify="space-between">
<Text fontSize="sm" color={muted}>Odpověď na komentář #{replyTo}</Text>
<Button size="xs" variant="ghost" onClick={() => setReplyTo(null)}>Zrušit odpověď</Button>
<Text fontSize="sm" color={muted}>{useEnglish ? `Reply to comment #${replyTo}` : `Odpověď na komentář #${replyTo}`}</Text>
<Button size="xs" variant="ghost" onClick={() => setReplyTo(null)}>{useEnglish ? 'Cancel reply' : 'Zrušit odpověď'}</Button>
</HStack>
)}
<Textarea placeholder="Napište komentář…" value={newContent} onChange={(e) => setNewContent(e.target.value)} rows={3} />
<Textarea placeholder={useEnglish ? 'Write a comment...' : 'Napište komentář…'} value={newContent} onChange={(e) => setNewContent(e.target.value)} rows={3} />
<HStack>
<Button leftIcon={<Send size={16} />} colorScheme="blue" onClick={() => createMut.mutate({ content: newContent.trim(), parent_id: replyTo })} isLoading={createMut.isPending} isDisabled={newContent.trim().length < minChars}>Odeslat</Button>
<Text fontSize="sm" color={muted}>Respektujte prosím pravidla slušné diskuse.</Text>
<Button leftIcon={<Send size={16} />} colorScheme="blue" onClick={() => createMut.mutate({ content: newContent.trim(), parent_id: replyTo })} isLoading={createMut.isPending} isDisabled={newContent.trim().length < minChars}>{useEnglish ? 'Send' : 'Odeslat'}</Button>
<Text fontSize="sm" color={muted}>{useEnglish ? 'Please respect the rules of decent discussion.' : 'Respektujte prosím pravidla slušné diskuse.'}</Text>
</HStack>
{canRequestUnban && (
<VStack align="stretch" spacing={2} borderWidth="1px" borderColor={border} borderRadius="md" p={3} bg={appealBg}>
<Text fontSize="sm" color={muted}>Váš účet je dočasně zablokován pro komentování. Můžete odeslat žádost o odblokování s krátkým vysvětlením.</Text>
<Text fontSize="sm" color={muted}>{useEnglish ? 'Your account is temporarily blocked from commenting. You can send an unban request with a brief explanation.' : 'Váš účet je dočasně zablokován pro komentování. Můžete odeslat žádost o odblokování s krátkým vysvětlením.'}</Text>
<Textarea
placeholder="Vaše zpráva pro administrátory…"
placeholder={useEnglish ? 'Your message for administrators...' : 'Vaše zpráva pro administrátory…'}
value={unbanMessage}
onChange={(e) => setUnbanMessage(e.target.value)}
rows={3}
/>
<HStack>
<Button size="sm" variant="outline" onClick={() => unbanMut.mutate(unbanMessage.trim() || 'Prosím o odblokování komentářů. Děkuji.')} isLoading={unbanMut.isPending}>Odeslat žádost o odblokování</Button>
<Button size="sm" variant="outline" onClick={() => unbanMut.mutate(unbanMessage.trim() || (useEnglish ? 'Please unblock my comments. Thank you.' : 'Prosím o odblokování komentářů. Děkuji.'))} isLoading={unbanMut.isPending}>{useEnglish ? 'Send unban request' : 'Odeslat žádost o odblokování'}</Button>
</HStack>
</VStack>
)}
</VStack>
) : (
<Text color={muted}>Pro přidání komentáře se prosím <ChakraLink as={RouterLink} to="/login" color="blue.500">přihlaste</ChakraLink>.</Text>
<Text color={muted}>
{useEnglish ? 'To add a comment, please ' : 'Pro přidání komentáře se prosím '}
<ChakraLink as={RouterLink} to="/login" color="blue.500">
{useEnglish ? 'log in' : 'přihlaste'}
</ChakraLink>.
</Text>
)}
</Box>
</Box>
@@ -0,0 +1,157 @@
import React from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
VStack,
HStack,
Text,
Spinner,
Progress,
Box,
Icon,
useColorModeValue,
} from '@chakra-ui/react';
import { FiCpu, FiClock } from 'react-icons/fi';
interface AILoadingModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
message?: string;
progress?: number;
estimatedTime?: number; // in seconds
}
const AILoadingModal: React.FC<AILoadingModalProps> = ({
isOpen,
onClose,
title = 'AI generuje obsah',
message = 'Pracuji na vašem požadavku...',
progress,
estimatedTime,
}) => {
const bg = useColorModeValue('white', 'gray.800');
const textColor = useColorModeValue('gray.700', 'gray.200');
const progressColor = useColorModeValue('blue.500', 'blue.300');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const getProgressMessage = () => {
if (progress !== undefined) {
if (progress < 25) return 'AI analyzuje požadavek...';
if (progress < 50) return 'AI komunikuje s modelem...';
if (progress < 75) return 'AI zpracovává obsah...';
if (progress < 90) return 'AI optimalizuje výstup...';
return 'AI dokončuje...';
}
return message;
};
const getTimeRemaining = () => {
if (estimatedTime && progress !== undefined && progress > 0) {
const remaining = Math.ceil((estimatedTime * (100 - progress)) / 100);
if (remaining > 0) {
return `Předpokládaný čas: ${remaining}s`;
}
}
if (estimatedTime) {
return `Předpokládaný čas: ${estimatedTime}s`;
}
return '';
};
const getRoundedProgress = () => {
if (progress !== undefined) {
return Math.round(progress);
}
return 0;
};
return (
<Modal isOpen={isOpen} onClose={onClose} isCentered closeOnEsc={false} closeOnOverlayClick={false}>
<ModalOverlay bg="blackAlpha.400" backdropFilter="blur(6px)" />
<ModalContent bg={bg} borderRadius="2xl" boxShadow="2xl" border="1px solid" borderColor={borderColor}>
<ModalHeader borderBottom="1px solid" borderColor={borderColor} pb={4}>
<VStack spacing={3} align="center">
<HStack spacing={3}>
<Box bg="blue.50" p={2} borderRadius="full">
<Icon as={FiCpu} color="blue.500" boxSize={5} />
</Box>
<Text fontSize="lg" fontWeight="600" color={textColor}>
{title}
</Text>
</HStack>
</VStack>
</ModalHeader>
<ModalBody py={8}>
<VStack spacing={6} align="center">
{/* Single centered AI Animation with model logo */}
<VStack spacing={4}>
<Box bg="blue.50" p={4} borderRadius="full">
<Icon as={FiCpu} color="blue.500" boxSize={6} />
</Box>
<Spinner size="lg" color="blue.500" thickness="3px" />
</VStack>
{/* Status Message */}
<Text fontSize="md" color={textColor} textAlign="center" fontWeight="500">
{getProgressMessage()}
</Text>
{/* Progress Bar */}
{progress !== undefined && (
<Box w="100%" maxW="400px">
<Progress
value={progress}
size="lg"
colorScheme="blue"
borderRadius="full"
bg="gray.100"
sx={{
'& > div': {
bg: progressColor,
transition: 'width 0.4s ease-out',
},
}}
/>
<Text fontSize="sm" color="gray.600" mt={3} textAlign="center" fontWeight="500">
{getRoundedProgress()}% hotovo
</Text>
</Box>
)}
{/* Time Estimate */}
{getTimeRemaining() && (
<HStack spacing={2} color="gray.500">
<Icon as={FiClock} boxSize={4} />
<Text fontSize="sm">{getTimeRemaining()}</Text>
</HStack>
)}
{/* Additional Info */}
<VStack spacing={2} align="center" pt={2}>
<Text fontSize="sm" color="gray.400" fontStyle="italic">
AI pracuje na vašem obsahu
</Text>
<Text fontSize="xs" color="gray.400">
Vygenerovaný obsah bude automaticky vložen do formuláře
</Text>
</VStack>
</VStack>
</ModalBody>
<ModalFooter borderTop="1px solid" borderColor={borderColor}>
<Text fontSize="sm" color="gray.400">
Prosím čekejte, proces nelze přerušit
</Text>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default AILoadingModal;
@@ -0,0 +1,863 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Flex,
HStack,
IconButton,
Menu,
MenuButton,
MenuItem,
MenuList,
Text,
Textarea,
Tooltip,
useColorModeValue,
useDisclosure,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
Button,
Spinner,
useToast,
} from '@chakra-ui/react';
import { AnimatePresence, motion } from 'framer-motion';
import { FiChevronDown, FiCheck, FiSend, FiPaperclip, FiMic } from 'react-icons/fi';
import { Brain, Sparkles, Globe2 } from 'lucide-react';
import {
AITextModelId,
AITextModelOption,
getDefaultTextModelId,
getEnabledTextModels,
findTextModel,
} from '../../utils/aiModels';
import { getAIUsageStatus, formatUsageText, AIUsageStatus } from '../../services/aiUsage';
import UploadPanel, { UploadPanelFile } from './UploadPanel';
import { processOcrAI, transcribeAudioAI } from '../../services/ai';
import { uploadFile as uploadArticleFile } from '../../services/articles';
export interface AIPromptInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: (value: string, modelId: AITextModelId) => void | Promise<unknown>;
isSubmitting?: boolean;
placeholder?: string;
helperText?: string;
initialModelId?: AITextModelId;
onModelChange?: (id: AITextModelId) => void;
onAttachClick?: () => void;
onVoiceClick?: () => void;
}
const DAILY_LIMIT = 10;
const DAILY_LIMIT_REASONING = 5;
const DAILY_LIMIT_DEEPSEEK = Infinity; // Unlimited for DeepSeek
const MotionBox = motion(Box);
const getModelIcon = (model?: AITextModelOption, size = 14) => {
if (!model) return null;
const iconSize = size === 16 ? 20 : size; // Slightly larger for better visibility
if (model.provider === 'deepseek') {
return <img src="/deepseek.svg" alt="DeepSeek" width={iconSize} height={iconSize} />;
}
if (model.provider === 'mistral') {
if (model.id === 'mistral-small-latest') {
return <img src="/mistral_small.png" alt="Mistral" width={iconSize} height={iconSize} />;
}
return <img src="/ministral.png" alt="Ministral" width={iconSize} height={iconSize} />;
}
if (model.provider === 'openrouter') {
return <Globe2 size={size} />; // Keep Lucide icon for OpenRouter
}
if (model.provider === 'grok') {
return <img src="/grok.svg" alt="Grok" width={iconSize} height={iconSize} />;
}
return null;
};
const isReasoningModel = (modelId: AITextModelId): boolean => {
return modelId.includes('reasoning') || modelId.includes('reasoner');
};
const AIPromptInput: React.FC<AIPromptInputProps> = ({
value,
onChange,
onSubmit,
isSubmitting,
placeholder,
helperText,
initialModelId,
onModelChange,
onAttachClick,
onVoiceClick,
}) => {
const models = useMemo<AITextModelOption[]>(() => getEnabledTextModels(), []);
const defaultId = useMemo<AITextModelId>(() => initialModelId || getDefaultTextModelId(), [initialModelId]);
const [selectedModel, setSelectedModel] = useState<AITextModelId>(defaultId);
const [usageStatus, setUsageStatus] = useState<AIUsageStatus>({});
const [isLoadingUsage, setIsLoadingUsage] = useState(false);
// Define getRemainingToday before using it in useState
const getRemainingToday = useCallback((modelId: AITextModelId): number => {
const modelStatus = usageStatus[modelId];
if (!modelStatus) return 10; // Default fallback
return modelStatus.unlimited ? -1 : modelStatus.remaining;
}, [usageStatus]);
const [remaining, setRemaining] = useState<number>(() => getRemainingToday(defaultId));
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const minHeight = 96;
const maxHeight = 260;
// Clean white styling for AI input
const bg = useColorModeValue('white', 'gray.800');
const innerBg = useColorModeValue('gray.50', 'gray.700');
const border = useColorModeValue('gray.200', 'gray.600');
const placeholderColor = useColorModeValue('gray.500', 'gray.400');
const hoverBg = useColorModeValue('gray.100', 'gray.600');
const toast = useToast();
const {
isOpen: isAttachOpen,
onOpen: onAttachOpen,
onClose: onAttachClose,
} = useDisclosure();
const {
isOpen: isVoiceOpen,
onOpen: onVoiceOpen,
onClose: onVoiceClose,
} = useDisclosure();
const [ocrFiles, setOcrFiles] = useState<UploadPanelFile[]>([]);
const [ocrText, setOcrText] = useState('');
const [isOcrProcessing, setIsOcrProcessing] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const mediaStreamRef = useRef<MediaStream | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const animationFrameRef = useRef<number | null>(null);
const recordingTimerRef = useRef<number | null>(null);
const [isRecording, setIsRecording] = useState(false);
const [isVoiceProcessing, setIsVoiceProcessing] = useState(false);
const [hasRecording, setHasRecording] = useState(false);
const [recordingSeconds, setRecordingSeconds] = useState(0);
const [waveform, setWaveform] = useState<number[]>(() => new Array(32).fill(0));
const adjustHeight = useCallback((reset?: boolean) => {
const el = textareaRef.current;
if (!el) return;
if (reset) {
el.style.height = `${minHeight}px`;
return;
}
el.style.height = 'auto';
const next = Math.max(minHeight, Math.min(el.scrollHeight, maxHeight));
el.style.height = `${next}px`;
}, []);
useEffect(() => {
adjustHeight();
}, [value, adjustHeight]);
useEffect(() => {
const handleResize = () => adjustHeight();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [adjustHeight]);
// Fetch usage status on mount and when model changes
useEffect(() => {
const fetchUsageStatus = async () => {
setIsLoadingUsage(true);
try {
const status = await getAIUsageStatus();
setUsageStatus(status);
} catch (error) {
console.error('Failed to fetch AI usage status:', error);
} finally {
setIsLoadingUsage(false);
}
};
fetchUsageStatus();
}, []);
// Update remaining when usage status or selected model changes
useEffect(() => {
setRemaining(getRemainingToday(selectedModel));
}, [getRemainingToday, selectedModel]);
const getDailyLimit = (modelId: AITextModelId): number => {
const modelStatus = usageStatus[modelId];
if (!modelStatus) return 10; // Default fallback
return modelStatus.limit;
};
const handleModelChange = (id: AITextModelId) => {
setSelectedModel(id);
if (onModelChange) onModelChange(id);
};
const handleSubmit = async () => {
const trimmed = value.trim();
if (!trimmed) return;
// Check remaining requests before submitting
const modelStatus = usageStatus[selectedModel];
if (modelStatus && !modelStatus.unlimited && modelStatus.remaining <= 0) {
toast({
title: 'Denní limit vyčerpán',
description: 'Denní limit pro tento AI model byl vyčerpán. Zkuste to znovu zítra.',
status: 'warning',
duration: 5000,
isClosable: true,
});
return;
}
try {
const maybePromise = onSubmit(trimmed, selectedModel);
if (maybePromise && typeof (maybePromise as any).then === 'function') {
await (maybePromise as Promise<unknown>);
}
// Refresh usage status after successful submission
const status = await getAIUsageStatus();
setUsageStatus(status);
} catch {
// Do not decrement usage on error; parent is responsible for showing errors
}
};
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!isSubmitting) {
handleSubmit();
}
}
};
const selected = findTextModel(selectedModel);
const isDisabled = isSubmitting || !value.trim() || remaining <= 0;
const handleInsertText = (text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
const next = value ? `${value}\n\n${trimmed}` : trimmed;
onChange(next);
};
const handleOcrUploadFinished = async (uploaded: UploadPanelFile[]) => {
if (!uploaded || uploaded.length === 0) return;
const last = uploaded[uploaded.length - 1];
if (!last?.url) return;
setIsOcrProcessing(true);
try {
const isImage = (last.type || '').startsWith('image/');
const resp = await processOcrAI({
document_url: isImage ? '' : last.url,
image_url: isImage ? last.url : '',
model: 'mistral-ocr-latest',
});
setOcrText(resp.text || '');
if ((resp.text || '').trim()) {
handleInsertText(resp.text || '');
toast({
title: 'Text z dokumentu byl vložen do AI promptu',
status: 'success',
duration: 3000,
isClosable: true,
});
} else {
toast({
title: 'OCR nenašlo čitelný text',
status: 'info',
duration: 3000,
isClosable: true,
});
}
} catch (err: any) {
toast({
title: 'OCR selhalo',
description: err?.message || 'Zkuste to prosím znovu.',
status: 'error',
duration: 4000,
isClosable: true,
});
} finally {
setIsOcrProcessing(false);
}
};
const stopMediaStream = () => {
if (mediaStreamRef.current) {
mediaStreamRef.current.getTracks().forEach((t) => t.stop());
mediaStreamRef.current = null;
}
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (recordingTimerRef.current !== null) {
window.clearInterval(recordingTimerRef.current);
recordingTimerRef.current = null;
}
if (audioContextRef.current) {
try {
audioContextRef.current.close();
} catch {
// ignore
}
audioContextRef.current = null;
analyserRef.current = null;
}
};
const startRecording = async () => {
if (isRecording) return;
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
toast({
title: 'Prohlížeč nepodporuje nahrávání zvuku',
status: 'error',
duration: 4000,
isClosable: true,
});
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaStreamRef.current = stream;
audioChunksRef.current = [];
setRecordingSeconds(0);
try {
const AudioCtx =
typeof window !== 'undefined'
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
((window as any).AudioContext || (window as any).webkitAudioContext)
: null;
if (AudioCtx) {
const ctx: AudioContext = audioContextRef.current || new AudioCtx();
audioContextRef.current = ctx;
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
analyserRef.current = analyser;
source.connect(analyser);
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const tick = () => {
if (!analyserRef.current) return;
analyserRef.current.getByteTimeDomainData(dataArray);
let sum = 0;
for (let i = 0; i < bufferLength; i += 1) {
const v = (dataArray[i] - 128) / 128;
sum += v * v;
}
const rms = Math.sqrt(sum / bufferLength);
const level = Math.max(0, Math.min(1, rms * 4));
setWaveform((prev) =>
prev.map((_, idx) => {
const jitter = 0.2 * Math.sin(Date.now() / 120 + idx);
const value = level + jitter;
return Math.max(0.1, Math.min(1, value));
}),
);
animationFrameRef.current = requestAnimationFrame(tick);
};
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current);
}
animationFrameRef.current = requestAnimationFrame(tick);
}
} catch {
// Vizualizace je best-effort, případné chyby ignorujeme
}
const recorder = new MediaRecorder(stream);
mediaRecorderRef.current = recorder;
recorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) {
audioChunksRef.current.push(e.data);
}
};
recorder.onstop = () => {
stopMediaStream();
setIsRecording(false);
setHasRecording(audioChunksRef.current.length > 0);
};
recorder.start();
setIsRecording(true);
setHasRecording(false);
if (recordingTimerRef.current !== null) {
window.clearInterval(recordingTimerRef.current);
}
recordingTimerRef.current = window.setInterval(() => {
setRecordingSeconds((prev) => prev + 1);
}, 1000);
} catch (err: any) {
toast({
title: 'Nelze získat přístup k mikrofonu',
description: err?.message || 'Zkontrolujte oprávnění prohlížeče.',
status: 'error',
duration: 4000,
isClosable: true,
});
}
};
const stopRecording = () => {
if (!isRecording) return;
try {
mediaRecorderRef.current?.stop();
} catch {
stopMediaStream();
setIsRecording(false);
}
};
const handleTranscribe = async () => {
if (!audioChunksRef.current.length) {
toast({
title: 'Nejprve nahrajte hlasovou zprávu',
status: 'info',
duration: 3000,
isClosable: true,
});
return;
}
const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
const file = new File([blob], 'voice-message.webm', { type: 'audio/webm' });
setIsVoiceProcessing(true);
try {
const uploaded = await uploadArticleFile(file as any);
const resp = await transcribeAudioAI({
file_url: uploaded.url,
model: 'voxtral-mini-latest',
language: 'cs',
});
if ((resp.text || '').trim()) {
handleInsertText(resp.text || '');
toast({
title: 'Přepis hlasu vložen do AI promptu',
status: 'success',
duration: 3000,
isClosable: true,
});
onVoiceClose();
} else {
toast({
title: 'Přepis neobsahuje text',
status: 'info',
duration: 3000,
isClosable: true,
});
}
} catch (err: any) {
toast({
title: 'Přepis hlasu selhal',
description: err?.message || 'Zkuste to prosím znovu.',
status: 'error',
duration: 4000,
isClosable: true,
});
} finally {
setIsVoiceProcessing(false);
}
};
return (
<Box w="100%" py={2}>
<Box
bg={bg}
borderRadius="2xl"
p={3}
borderWidth="1px"
borderColor={border}
boxShadow={useColorModeValue('sm', 'dark-lg')}
_hover={{ borderColor: useColorModeValue('gray.300', 'gray.500') }}
transition="all 0.2s ease"
>
<Box position="relative">
<Flex direction="column">
<Box overflowY="auto" maxH="360px">
<Textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder || 'Napište, s čím má AI pomoci...'}
resize="none"
border="none"
bg={innerBg}
borderRadius="xl"
px={4}
py={3}
minH={`${minHeight}px`}
_focusVisible={{ boxShadow: 'none', bg: useColorModeValue('white', 'gray.700') }}
_placeholder={{ color: placeholderColor }}
fontSize="sm"
/>
</Box>
<Box
h="60px"
bg={innerBg}
borderBottomRadius="xl"
borderTop="1px solid"
borderColor={border}
mt={-1}
_hover={{ bg: hoverBg }}
transition="all 0.2s ease"
>
<Flex
position="absolute"
left={3}
right={3}
bottom={2}
align="center"
justify="space-between"
>
<HStack spacing={2} align="center">
<Menu>
<MenuButton
as={IconButton}
size="sm"
variant="ghost"
colorScheme="gray"
aria-label="Vybrat AI model"
px={2}
_hover={{ bg: hoverBg }}
>
<AnimatePresence mode="wait">
<MotionBox
key={selectedModel}
display="flex"
alignItems="center"
gap={2}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.15 }}
>
<Box bg="blue.50" p={1} borderRadius="md">
{getModelIcon(selected, 14)}
</Box>
<Text fontSize="xs" fontWeight="600" color="gray.700">
{selected?.label || 'Model'}
</Text>
<FiChevronDown size={12} color="gray.500" />
</MotionBox>
</AnimatePresence>
</MenuButton>
<MenuList fontSize="sm" zIndex={1500}>
{models.map((model) => {
const rem = getRemainingToday(model.id as AITextModelId);
return (
<MenuItem
key={model.id}
onClick={() => handleModelChange(model.id)}
>
<HStack justify="space-between" w="100%" spacing={3}>
<Box>
<HStack spacing={2} align="center">
<Box bg="gray.50" p={1} borderRadius="md">
{getModelIcon(model, 16)}
</Box>
<Text fontWeight="600" color="gray.800">{model.label}</Text>
</HStack>
{model.description && (
<Text fontSize="xs" color="gray.600" mt={1}>
{model.description}
</Text>
)}
{isReasoningModel(model.id) && (
<Text fontSize="xs" color="blue.600" mt={1} fontWeight="medium">
Reasoning model - přemýšlí před odpovědí
</Text>
)}
</Box>
<HStack spacing={2} align="center">
{isLoadingUsage ? (
<Spinner size="xs" color="gray.500" />
) : (
<Text fontSize="xs" color="gray.600" fontWeight="500">
{model.provider === 'deepseek' ? '∞' : `${Math.max(0, rem)}/${getDailyLimit(model.id)}`}
</Text>
)}
{selectedModel === model.id && (
<Box bg="green.100" p={1} borderRadius="full">
<FiCheck size={12} color="green.600" />
</Box>
)}
</HStack>
</HStack>
</MenuItem>
);
})}
</MenuList>
</Menu>
<Box h="4" w="1px" bg={border} mx={1} />
{/* Reasoning model warning */}
{isReasoningModel(selectedModel) && (
<Tooltip label="Reasoning modely přemýšlí nad odpovědí - poskytují hlubší analýzu, ale mohou trvat déle">
<Text fontSize="xs" color="blue.600" px={2} py={1} bg="blue.50" borderRadius="md" fontWeight="medium">
Reasoning
</Text>
</Tooltip>
)}
<Tooltip label="Přiložit soubor (obrázek / PDF)">
<IconButton
aria-label="Přiložit soubor"
icon={<FiPaperclip />}
size="sm"
variant="ghost"
colorScheme="gray"
_hover={{ bg: hoverBg }}
onClick={() => {
if (onAttachClick) {
onAttachClick();
} else {
setOcrFiles([]);
setOcrText('');
onAttachOpen();
}
}}
/>
</Tooltip>
<Tooltip label="Nahrát hlas pro přepis">
<IconButton
aria-label="Nahrát hlas"
icon={<FiMic />}
size="sm"
variant="ghost"
colorScheme="gray"
_hover={{ bg: hoverBg }}
onClick={() => {
if (onVoiceClick) {
onVoiceClick();
} else {
audioChunksRef.current = [];
setHasRecording(false);
setIsVoiceProcessing(false);
onVoiceOpen();
}
}}
/>
</Tooltip>
{helperText && (
<Text fontSize="xs" color="gray.500" ml={2} noOfLines={1}>
{helperText}
</Text>
)}
</HStack>
<HStack spacing={3} align="center">
{isLoadingUsage ? (
<Spinner size="xs" color="gray.500" />
) : (
<Text fontSize="xs" color="gray.600" fontWeight="500">
{selected?.provider === 'deepseek' ? '∞' : `Zbývá ${getDailyLimit(selectedModel) === Infinity ? '∞' : `${remaining}/${getDailyLimit(selectedModel)}`}`}
</Text>
)}
<Tooltip
label={
remaining <= 0
? 'Denní limit pro tento model byl vyčerpán.'
: 'Odeslat (Enter). Shift+Enter vloží nový řádek.'
}
>
<span>
<IconButton
aria-label="Odeslat AI požadavek"
icon={<FiSend />}
size="sm"
variant="ghost"
colorScheme="blue"
_hover={{ bg: 'blue.50' }}
isLoading={isSubmitting && !isLoadingUsage}
isDisabled={isDisabled}
onClick={handleSubmit}
/>
</span>
</Tooltip>
</HStack>
</Flex>
</Box>
</Flex>
</Box>
</Box>
<Modal isOpen={isAttachOpen} onClose={onAttachClose} isCentered size="lg">
<ModalOverlay />
<ModalContent>
<ModalHeader>Přiložit soubor pro OCR</ModalHeader>
<ModalCloseButton />
<ModalBody>
<UploadPanel
label="Soubor pro AI (PDF / obrázek)"
description="Nahrajte dokument nebo obrázek, ze kterého má AI přečíst text."
value={ocrFiles}
onChange={setOcrFiles}
accept="application/pdf,image/*"
maxFiles={1}
onUploadFinished={handleOcrUploadFinished}
/>
{isOcrProcessing && (
<HStack mt={4} spacing={2} align="center">
<Spinner size="sm" />
<Text fontSize="sm">Probíhá zpracování textu</Text>
</HStack>
)}
{!!ocrText && !isOcrProcessing && (
<Box mt={4}>
<Text fontSize="sm" fontWeight="semibold" mb={2}>
Rozpoznaný text
</Text>
<Box
borderWidth="1px"
borderRadius="md"
borderColor={border}
bg={innerBg}
p={3}
maxH="200px"
overflowY="auto"
fontSize="sm"
whiteSpace="pre-wrap"
>
{ocrText}
</Box>
</Box>
)}
</ModalBody>
<ModalFooter>
<HStack spacing={3}>
<Button variant="ghost" onClick={onAttachClose}>
Zavřít
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
<Modal isOpen={isVoiceOpen} onClose={() => {
if (isRecording) {
stopRecording();
}
onVoiceClose();
}} isCentered size="md">
<ModalOverlay />
<ModalContent>
<ModalHeader>Hlasový vstup pro AI</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Text fontSize="sm" mb={3}>
Nahrajte krátkou hlasovou zprávu, kterou AI přepíše do textu a vloží do promptu.
</Text>
<HStack spacing={4} align="center">
<Button
onClick={isRecording ? stopRecording : startRecording}
isDisabled={isVoiceProcessing}
bg={isRecording ? 'red.500' : 'blackAlpha.700'}
color="white"
_hover={{
bg: isRecording ? 'red.600' : 'blackAlpha.800',
}}
_active={{
bg: isRecording ? 'red.700' : 'blackAlpha.900',
}}
>
{isRecording ? 'Zastavit nahrávání' : 'Začít nahrávat'}
</Button>
{hasRecording && !isRecording && (
<Text fontSize="sm" color="green.500">
Nahrávka je připravena k přepisu.
</Text>
)}
</HStack>
{isRecording && (
<Box mt={4}>
<HStack justify="space-between" mb={1}>
<Text fontSize="xs" color="gray.500">
Nahrávání
</Text>
<Text fontSize="xs" fontFamily="mono" color="gray.600">
{`${String(Math.floor(recordingSeconds / 60)).padStart(2, '0')}:${String(
recordingSeconds % 60,
).padStart(2, '0')}`}
</Text>
</HStack>
<HStack spacing={1} align="flex-end" h="40px">
{waveform.map((value, idx) => (
<Box
key={idx}
w="2px"
borderRadius="full"
bg="green.400"
h={`${Math.max(8, value * 100)}%`}
/>
))}
</HStack>
</Box>
)}
{isVoiceProcessing && (
<HStack mt={4} spacing={2} align="center">
<Spinner size="sm" />
<Text fontSize="sm">Probíhá přepis hlasu</Text>
</HStack>
)}
</ModalBody>
<ModalFooter>
<HStack spacing={3}>
<Button
onClick={handleTranscribe}
isDisabled={!hasRecording || isRecording || isVoiceProcessing}
isLoading={isVoiceProcessing}
bg="blackAlpha.800"
color="white"
_hover={{ bg: 'blackAlpha.900' }}
_active={{ bg: 'blackAlpha.900' }}
>
Přepsat a vložit do promptu
</Button>
<Button
variant="ghost"
onClick={() => {
if (isRecording) {
stopRecording();
}
onVoiceClose();
}}
>
Zavřít
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
);
};
export default AIPromptInput;
@@ -0,0 +1,585 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Button,
HStack,
IconButton,
Input,
SimpleGrid,
Text,
Tooltip,
useColorModeValue,
VStack,
Slider,
SliderTrack,
SliderFilledTrack,
SliderThumb,
} from '@chakra-ui/react';
import { BsEyedropper } from 'react-icons/bs';
type Hsva = { h: number; s: number; v: number; a: number };
type ColorPickerProps = {
value: string;
onChange: (hex: string) => void;
onChangeComplete?: (hex: string) => void;
label?: React.ReactNode;
showAlpha?: boolean;
showEyeDropper?: boolean;
recentStorageKey?: string;
onInteractionStart?: () => void;
onInteractionEnd?: () => void;
compact?: boolean;
hideHistory?: boolean;
};
type ColorHistory = {
manual: string[];
recent: string[];
};
const DEFAULT_HISTORY: ColorHistory = { manual: [], recent: [] };
const GLOBAL_KEY = 'myclub-colorpicker-v1';
function clamp01(x: number) {
return Math.min(1, Math.max(0, x));
}
function parseHex(input: string): { r: number; g: number; b: number; a: number } | null {
if (!input) return null;
let h = input.trim();
if (!h.startsWith('#')) h = `#${h}`;
h = h.slice(1);
if (![3, 4, 6, 8].includes(h.length)) return null;
if (h.length === 3 || h.length === 4) {
h = h
.split('')
.map((c) => c + c)
.join('');
}
const hasAlpha = h.length === 8;
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
const a = hasAlpha ? parseInt(h.slice(6, 8), 16) / 255 : 1;
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b) || Number.isNaN(a)) return null;
return { r, g, b, a };
}
function rgbaToHex(r: number, g: number, b: number, a: number) {
const toHex = (n: number) => Math.round(Math.min(255, Math.max(0, n))).toString(16).padStart(2, '0');
const hr = toHex(r);
const hg = toHex(g);
const hb = toHex(b);
const ha = toHex(a * 255);
if (a >= 0.999) return `#${hr}${hg}${hb}`;
return `#${hr}${hg}${hb}${ha}`;
}
function hexToHsva(hex: string): Hsva {
const parsed = parseHex(hex) || { r: 255, g: 0, b: 0, a: 1 };
const r = parsed.r / 255;
const g = parsed.g / 255;
const b = parsed.b / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const d = max - min;
let h = 0;
const v = max;
const s = max === 0 ? 0 : d / max;
if (d !== 0) {
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
break;
case g:
h = ((b - r) / d + 2) * 60;
break;
case b:
default:
h = ((r - g) / d + 4) * 60;
break;
}
}
if (!Number.isFinite(h)) h = 0;
return { h, s, v, a: parsed.a };
}
function hsvaToHex(c: Hsva): string {
const h = ((c.h % 360) + 360) % 360;
const s = clamp01(c.s);
const v = clamp01(c.v);
const a = clamp01(c.a);
const C = v * s;
const X = C * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - C;
let r = 0;
let g = 0;
let b = 0;
if (h < 60) {
r = C;
g = X;
b = 0;
} else if (h < 120) {
r = X;
g = C;
b = 0;
} else if (h < 180) {
r = 0;
g = C;
b = X;
} else if (h < 240) {
r = 0;
g = X;
b = C;
} else if (h < 300) {
r = X;
g = 0;
b = C;
} else {
r = C;
g = 0;
b = X;
}
const rr = (r + m) * 255;
const gg = (g + m) * 255;
const bb = (b + m) * 255;
return rgbaToHex(rr, gg, bb, a);
}
function loadHistory(key?: string): ColorHistory {
if (typeof window === 'undefined') return DEFAULT_HISTORY;
try {
const raw = window.localStorage.getItem(key || GLOBAL_KEY);
if (!raw) return DEFAULT_HISTORY;
const parsed = JSON.parse(raw) as ColorHistory;
if (!parsed || typeof parsed !== 'object') return DEFAULT_HISTORY;
return {
manual: Array.isArray(parsed.manual) ? parsed.manual : [],
recent: Array.isArray(parsed.recent) ? parsed.recent : [],
};
} catch {
return DEFAULT_HISTORY;
}
}
function saveHistory(history: ColorHistory, key?: string) {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(key || GLOBAL_KEY, JSON.stringify(history));
} catch {
}
}
function pushRecentColor(history: ColorHistory, hex: string, limit = 10): ColorHistory {
const color = (hex || '').toLowerCase();
if (!color) return history;
const recent = [color, ...history.recent.filter((c) => c !== color)];
return { ...history, recent: recent.slice(0, limit) };
}
function pushManualColor(history: ColorHistory, hex: string): ColorHistory {
const color = (hex || '').toLowerCase();
if (!color) return history;
if (history.manual.includes(color)) return history;
return { ...history, manual: [...history.manual, color] };
}
export const ColorPicker: React.FC<ColorPickerProps> = ({
value,
onChange,
onChangeComplete,
label,
showAlpha = false,
showEyeDropper = true,
recentStorageKey,
onInteractionStart,
onInteractionEnd,
compact = false,
hideHistory = false,
}) => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textColor = useColorModeValue('gray.700', 'gray.100');
const subtleText = useColorModeValue('gray.500', 'gray.400');
const [hsva, setHsva] = useState<Hsva>(() => hexToHsva(value || '#ff0000'));
const [hex, setHex] = useState<string>(() => hsvaToHex(hsva));
const [hexInput, setHexInput] = useState<string>(hex.toUpperCase());
const [isEditingHex, setIsEditingHex] = useState(false);
const [history, setHistory] = useState<ColorHistory>(() => loadHistory(recentStorageKey));
const [eyeDropperAvailable, setEyeDropperAvailable] = useState(false);
const svRef = useRef<HTMLDivElement | null>(null);
const latestHsvaRef = useRef<Hsva>(hsva);
useEffect(() => {
if (typeof window !== 'undefined' && (window as any).EyeDropper) {
setEyeDropperAvailable(true);
}
}, []);
useEffect(() => {
const normalized = hsvaToHex(hexToHsva(value || hex));
setHsva(hexToHsva(normalized));
setHex(normalized);
if (!isEditingHex) setHexInput(normalized.toUpperCase());
}, [value]);
useEffect(() => {
latestHsvaRef.current = hsva;
}, [hsva]);
useEffect(() => {
setHistory(loadHistory(recentStorageKey));
}, [recentStorageKey]);
const emitChange = useCallback(
(nextHsva: Hsva, { complete, addToRecent }: { complete?: boolean; addToRecent?: boolean } = {}) => {
const nextHex = hsvaToHex(nextHsva);
setHsva(nextHsva);
setHex(nextHex);
if (!isEditingHex) setHexInput(nextHex.toUpperCase());
onChange(nextHex);
if (addToRecent) {
setHistory((prev) => {
const updated = pushRecentColor(prev, nextHex);
saveHistory(updated, recentStorageKey);
return updated;
});
}
if (complete && onChangeComplete) onChangeComplete(nextHex);
},
[isEditingHex, onChange, onChangeComplete, recentStorageKey],
);
const handleSvPointer = useCallback(
(clientX: number, clientY: number) => {
const rect = svRef.current?.getBoundingClientRect();
if (!rect) return;
const x = clamp01((clientX - rect.left) / rect.width);
const y = clamp01((clientY - rect.top) / rect.height);
const s = x;
const v = 1 - y;
const base = latestHsvaRef.current;
emitChange({ ...base, s, v }, { addToRecent: false });
},
[emitChange],
);
const handleSvPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
onInteractionStart?.();
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
handleSvPointer(e.clientX, e.clientY);
let rafId: number | undefined;
const move = (ev: PointerEvent) => {
// Use requestAnimationFrame for smooth 60fps updates
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
handleSvPointer(ev.clientX, ev.clientY);
});
};
const up = (ev: PointerEvent) => {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = undefined;
}
handleSvPointer(ev.clientX, ev.clientY);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
const latest = latestHsvaRef.current;
emitChange(latest, { complete: true, addToRecent: true });
onInteractionEnd?.();
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
},
[handleSvPointer, emitChange, hsva, onInteractionEnd, onInteractionStart],
);
const handleHueChange = useCallback((next: number) => {
emitChange({ ...hsva, h: next }, { addToRecent: false });
}, [hsva, emitChange]);
const handleHueChangeStart = () => {
onInteractionStart?.();
};
const handleHueChangeEnd = useCallback((next: number) => {
emitChange({ ...hsva, h: next }, { complete: true, addToRecent: true });
onInteractionEnd?.();
}, [hsva, emitChange, onInteractionEnd]);
const handleAlphaChange = useCallback((next: number) => {
emitChange({ ...hsva, a: clamp01(next / 100) }, { addToRecent: false });
}, [hsva, emitChange]);
const handleAlphaChangeEnd = useCallback((next: number) => {
emitChange({ ...hsva, a: clamp01(next / 100) }, { complete: true, addToRecent: true });
}, [hsva, emitChange]);
const handleHexInputBlur = () => {
setIsEditingHex(false);
const parsed = parseHex(hexInput);
if (!parsed) {
setHexInput(hex.toUpperCase());
return;
}
const nextHsva = hexToHsva(hexInput);
emitChange(nextHsva, { complete: true, addToRecent: true });
};
const handleSaveManual = () => {
setHistory((prev) => {
const updated = pushManualColor(prev, hex);
saveHistory(updated, recentStorageKey);
return updated;
});
};
const handlePickFromScreen = async () => {
if (typeof window === 'undefined') return;
if (!eyeDropperAvailable || !(window as any).EyeDropper) return;
try {
onInteractionStart?.();
const EyeDropperCtor = (window as any).EyeDropper;
const eyeDropper = new EyeDropperCtor();
const result = await eyeDropper.open();
const picked = result?.sRGBHex as string | undefined;
if (picked) {
const base = latestHsvaRef.current;
const pickedHsva = hexToHsva(picked);
const nextHsva = { ...pickedHsva, a: base.a };
emitChange(nextHsva, { complete: true, addToRecent: true });
}
} catch {
} finally {
onInteractionEnd?.();
}
};
const svBackground = useMemo(
() => `linear-gradient(to top, black, transparent), linear-gradient(to right, white, hsl(${hsva.h}, 100%, 50%))`,
[hsva.h],
);
const svPointerStyle = useMemo(() => {
const x = hsva.s * 100;
const y = (1 - hsva.v) * 100;
return { left: `${x}%`, top: `${y}%` };
}, [hsva.s, hsva.v]);
const alphaPercent = Math.round(hsva.a * 100);
const currentHex = hex;
return (
<Box
borderWidth="1px"
borderRadius="lg"
p={compact ? 2 : 3}
bg={cardBg}
borderColor={borderColor}
boxShadow="sm"
>
{label ? (
<Text fontSize="sm" fontWeight="medium" mb={2} color={textColor}>
{label}
</Text>
) : null}
<Box
ref={svRef}
position="relative"
borderRadius="lg"
overflow="hidden"
height={compact ? '110px' : '140px'}
cursor="crosshair"
backgroundImage={svBackground}
onPointerDown={handleSvPointerDown}
>
<Box
position="absolute"
width="16px"
height="16px"
borderRadius="full"
borderWidth="2px"
borderColor="white"
boxShadow="0 0 0 1px rgba(0,0,0,0.4)"
transform="translate(-50%, -50%)"
style={svPointerStyle}
/>
</Box>
<Box mt={3}>
<Slider
aria-label="Hue"
min={0}
max={360}
value={hsva.h}
onChange={handleHueChange}
onChangeStart={handleHueChangeStart}
onChangeEnd={handleHueChangeEnd}
size="sm"
>
<SliderTrack
bg="linear-gradient(to right, red, #ff0, #0f0, #0ff, #00f, #f0f, red)"
h="8px"
borderRadius="full"
>
<SliderFilledTrack bg="transparent" />
</SliderTrack>
<SliderThumb boxSize={4} borderWidth="2px" borderColor="white" />
</Slider>
</Box>
{showAlpha && (
<Box mt={3}>
<Slider
aria-label="Opacity"
min={0}
max={100}
value={alphaPercent}
onChange={handleAlphaChange}
onChangeEnd={handleAlphaChangeEnd}
size="sm"
>
<SliderTrack
bg={`linear-gradient(to right, transparent, ${currentHex})`}
h="8px"
borderRadius="full"
>
<SliderFilledTrack bg="transparent" />
</SliderTrack>
<SliderThumb boxSize={4} borderWidth="2px" borderColor="white" />
</Slider>
</Box>
)}
<HStack spacing={2} mt={3} align="center">
<Box
width="32px"
height="32px"
borderRadius="full"
borderWidth="1px"
borderColor={borderColor}
bg={currentHex}
/>
<Input
value={hexInput}
onChange={(e) => {
setIsEditingHex(true);
setHexInput(e.target.value);
}}
onBlur={handleHexInputBlur}
size="sm"
fontFamily="mono"
maxW="130px"
/>
{showAlpha && (
<Input
value={`${alphaPercent}%`}
onChange={(e) => {
const raw = e.target.value.replace(/[^0-9]/g, '');
const n = Math.max(0, Math.min(100, parseInt(raw || '0', 10)));
emitChange({ ...hsva, a: n / 100 }, { addToRecent: false });
}}
onBlur={() => handleAlphaChangeEnd(alphaPercent)}
size="sm"
maxW="80px"
/>
)}
{showEyeDropper && (
<Tooltip
label={
eyeDropperAvailable
? 'Vybrat barvu z obrazovky'
: 'Pipeta není v tomto prohlížeči podporována.'
}
>
<IconButton
aria-label="Vybrat barvu z obrazovky"
icon={<BsEyedropper />}
size="sm"
variant="outline"
onClick={eyeDropperAvailable ? handlePickFromScreen : undefined}
isDisabled={!eyeDropperAvailable}
/>
</Tooltip>
)}
</HStack>
{!hideHistory && (
<VStack align="stretch" spacing={2} mt={3}>
{history.manual.length > 0 && (
<Box>
<Text fontSize="xs" mb={1} color={subtleText}>
Uložené barvy
</Text>
<SimpleGrid columns={8} spacing={1}>
{history.manual.map((c) => (
<Box
key={`manual-${c}`}
as="button"
type="button"
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
emitChange(hexToHsva(c), { complete: true, addToRecent: true });
}}
width="18px"
height="18px"
borderRadius="full"
borderWidth={currentHex.toLowerCase() === c ? '2px' : '1px'}
borderColor={currentHex.toLowerCase() === c ? 'blue.500' : borderColor}
bg={c}
/>
))}
</SimpleGrid>
</Box>
)}
{history.recent.length > 0 && (
<Box>
<Text fontSize="xs" mb={1} color={subtleText}>
Poslední barvy
</Text>
<SimpleGrid columns={8} spacing={1}>
{history.recent.map((c) => (
<Box
key={`recent-${c}`}
as="button"
type="button"
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
emitChange(hexToHsva(c), { complete: true, addToRecent: true });
}}
width="16px"
height="16px"
borderRadius="full"
borderWidth={currentHex.toLowerCase() === c ? '2px' : '1px'}
borderColor={currentHex.toLowerCase() === c ? 'blue.500' : borderColor}
bg={c}
/>
))}
</SimpleGrid>
</Box>
)}
<Button
mt={1}
variant="ghost"
size="sm"
onClick={handleSaveManual}
>
+ Přidat barvu
</Button>
</VStack>
)}
</Box>
);
};
export default ColorPicker;
@@ -0,0 +1,73 @@
import React, { useEffect, useState } from 'react';
import { Box, HStack, Input, Popover, PopoverTrigger, PopoverContent, PopoverBody, useColorModeValue } from '@chakra-ui/react';
import ColorPicker from './ColorPicker';
export type ColorPickerPopoverProps = {
value: string;
onChange: (hex: string) => void;
recentStorageKey?: string;
};
const ColorPickerPopover: React.FC<ColorPickerPopoverProps> = ({ value, onChange, recentStorageKey }) => {
const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.800');
const [internal, setInternal] = useState<string>(value || '#000000');
useEffect(() => {
setInternal(value || '#000000');
}, [value]);
return (
<Popover
placement="bottom-start"
closeOnBlur
closeOnEsc
autoFocus={false}
returnFocusOnClose={false}
>
<PopoverTrigger>
<HStack
spacing={2}
cursor="pointer"
align="center"
>
<Box
width="32px"
height="32px"
borderRadius="full"
borderWidth="1px"
borderColor={borderColor}
bg={value || '#000000'}
/>
<Input
value={(value || '').toUpperCase()}
isReadOnly
size="sm"
maxW="120px"
fontFamily="mono"
bg={inputBg}
/>
</HStack>
</PopoverTrigger>
<PopoverContent w="auto" maxW="280px" _focus={{ boxShadow: 'none' }}>
<PopoverBody p={3}>
<ColorPicker
value={internal}
onChange={(hex) => {
setInternal(hex);
onChange(hex);
}}
onChangeComplete={(hex) => {
setInternal(hex);
onChange(hex);
}}
recentStorageKey={recentStorageKey}
compact
/>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default ColorPickerPopover;
@@ -21,6 +21,22 @@ import {
ButtonGroup,
IconButton,
Tooltip,
Drawer,
DrawerOverlay,
DrawerContent,
DrawerHeader,
DrawerCloseButton,
DrawerBody,
DrawerFooter,
Textarea,
AlertDialog,
AlertDialogOverlay,
AlertDialogContent,
AlertDialogHeader,
AlertDialogCloseButton,
AlertDialogBody,
AlertDialogFooter,
Checkbox,
} from '@chakra-ui/react';
import ReactQuill from 'react-quill';
import ReactCrop, { Crop } from 'react-image-crop';
@@ -76,6 +92,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const quillRef = useRef<ReactQuill | null>(null);
const toolbarRef = useRef<HTMLDivElement | null>(null);
const onChangeRef = useRef(onChange);
const changeDebounceRef = useRef<number | null>(null);
const lastContentRef = useRef<string>('');
const selectedImageIdRef = useRef<string | null>(null);
const selectImageByIdRef = useRef<(id: string) => void>(() => {});
const toolbarDragRef = useRef<{ active: boolean; startX: number; startY: number; startLeft: number; startTop: number }>({ active: false, startX: 0, startY: 0, startLeft: 0, startTop: 0 });
@@ -91,6 +109,16 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
// Cleanup debounced change handler on unmount
useEffect(() => {
return () => {
if (changeDebounceRef.current !== null) {
window.clearTimeout(changeDebounceRef.current);
changeDebounceRef.current = null;
}
};
}, []);
// Crop modal state
const [cropOpen, setCropOpen] = useState(false);
@@ -108,6 +136,18 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const [linkUrl, setLinkUrl] = useState('');
const linkRangeRef = useRef<{ index: number; length: number } | null>(null);
const [isHtmlEditorOpen, setIsHtmlEditorOpen] = useState(false);
const [htmlSource, setHtmlSource] = useState('');
const htmlRangeRef = useRef<{ index: number; length: number } | null>(null);
const htmlSwitchingRef = useRef(false);
const [MonacoComponent, setMonacoComponent] = useState<React.ComponentType<any> | null>(null);
const [htmlEditMode, setHtmlEditMode] = useState<'block' | 'document'>('block');
const [isFullHtmlConfirmOpen, setIsFullHtmlConfirmOpen] = useState(false);
const confirmCancelRef = useRef<HTMLButtonElement | null>(null);
const [confirmDontAsk, setConfirmDontAsk] = useState(false);
const pendingSanitizedRef = useRef<string>('');
const pendingRawRef = useRef<string>('');
// Force white mode for better readability in admin
const borderColor = 'gray.200';
const bgColor = 'white';
@@ -201,6 +241,32 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
}
}, []);
const handleChange = useCallback(
(content: string, _delta: any, source: string) => {
if (readOnly) return;
if (source !== 'user') return;
const cleaned = cleanEditorHTML(content || '');
if (cleaned === lastContentRef.current) return;
lastContentRef.current = cleaned;
if (changeDebounceRef.current !== null) {
window.clearTimeout(changeDebounceRef.current);
}
changeDebounceRef.current = window.setTimeout(() => {
changeDebounceRef.current = null;
try {
onChangeRef.current(cleaned);
} catch (e) {
console.error('CustomRichEditor onChange error', e);
}
}, 150);
},
[readOnly, cleanEditorHTML]
);
// Image upload handler
const handleImageUpload = useCallback(() => {
const input = document.createElement('input');
@@ -234,6 +300,84 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setIsLinkOpen(true);
}, []);
const openHtmlEditor = useCallback((mode: 'block' | 'document' = 'block') => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const range = quill.getSelection();
setHtmlEditMode(mode);
if (mode === 'document') {
try {
const currentHTML = (quill.root?.innerHTML as string) || '';
setHtmlSource(cleanEditorHTML(currentHTML));
htmlRangeRef.current = { index: 0, length: quill.getLength() };
} catch {
setHtmlSource('');
htmlRangeRef.current = { index: 0, length: 0 };
}
} else {
htmlRangeRef.current = range ? { index: range.index, length: range.length } : { index: quill.getLength(), length: 0 };
setHtmlSource('');
}
setIsHtmlEditorOpen(true);
}, [cleanEditorHTML]);
const handleInsertHtmlBlock = useCallback(() => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const html = htmlSource.trim();
if (!html) {
toast({ title: 'Prázdný HTML kód', description: 'Zadejte HTML, které chcete vložit.', status: 'warning', duration: 2000 });
return;
}
const sanitized = DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
ADD_TAGS: ['iframe'],
ADD_ATTR: ['class', 'target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id', 'data-bullets', 'data-list'],
});
if (htmlEditMode === 'document') {
const skip = typeof window !== 'undefined' && window.localStorage.getItem('rte_skip_full_html_confirm') === '1';
if (!skip) {
pendingSanitizedRef.current = sanitized;
pendingRawRef.current = html;
setConfirmDontAsk(false);
setIsFullHtmlConfirmOpen(true);
return;
}
const total = quill.getLength();
quill.focus();
try { quill.deleteText(0, total, 'api'); } catch {}
try {
quill.clipboard.dangerouslyPasteHTML(0, sanitized, 'user');
} catch {
const fallback = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
quill.insertText(0, fallback, 'user');
}
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
setIsHtmlEditorOpen(false);
setHtmlSource('');
toast({ title: 'Obsah aktualizován', status: 'success', duration: 2000 });
return;
}
const range = htmlRangeRef.current || quill.getSelection() || { index: quill.getLength(), length: 0 };
const index = range.index;
quill.focus();
try {
quill.clipboard.dangerouslyPasteHTML(index, sanitized, 'user');
} catch {
const fallback = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
quill.insertText(index, fallback, 'user');
}
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
setIsHtmlEditorOpen(false);
setHtmlSource('');
toast({ title: 'HTML vloženo', status: 'success', duration: 2000 });
}, [htmlSource, toast, cleanEditorHTML, htmlEditMode]);
const quillModules = useMemo(() => ({
toolbar: {
container: toolbarConfig,
@@ -247,6 +391,54 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
},
}), [toolbarConfig, onImageUpload, handleImageUpload, handleLinkToolbar]);
useEffect(() => {
if (!isMounted || readOnly) return;
const quill = quillRef.current?.getEditor();
if (!quill) return;
const onTextChange = (delta: any, _old: any, source: any) => {
if (source !== 'user' || htmlSwitchingRef.current || isHtmlEditorOpen) return;
let inserted = '';
if (Array.isArray(delta?.ops)) {
delta.ops.forEach((op: any) => {
if (typeof op.insert === 'string') inserted += op.insert;
});
}
if (!inserted || !(/[<>]/.test(inserted))) return;
const range = quill.getSelection();
if (!range) return;
const ctxStart = Math.max(0, range.index - 200);
const ctx = quill.getText(ctxStart, range.index - ctxStart);
const relStart = ctx.lastIndexOf('<');
if (relStart < 0) return;
let snippet = ctx.slice(relStart);
snippet = (snippet || '').trim();
if (!/^<\/?[A-Za-z]/.test(snippet)) return;
const absStart = ctxStart + relStart;
const len = range.index - absStart;
if (len <= 0 || len > 200) return;
htmlSwitchingRef.current = true;
try { quill.deleteText(absStart, len, 'api'); } catch {}
try { htmlRangeRef.current = { index: absStart, length: 0 }; } catch {}
setIsHtmlEditorOpen(true);
setHtmlSource(snippet);
setTimeout(() => { htmlSwitchingRef.current = false; }, 120);
};
quill.on('text-change', onTextChange);
return () => {
try { quill.off('text-change', onTextChange); } catch {}
};
}, [isMounted, readOnly, isHtmlEditorOpen]);
useEffect(() => {
let mounted = true;
if (isHtmlEditorOpen && !MonacoComponent) {
import('@monaco-editor/react')
.then((m) => { if (mounted) setMonacoComponent(() => (m as any).default || (m as any)); })
.catch(() => {});
}
return () => { mounted = false; };
}, [isHtmlEditorOpen, MonacoComponent]);
const quillFormats = useMemo(
() => [
'header',
@@ -1277,6 +1469,37 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
}
}, [selectedImageElement, imageFilters, toast]);
const deleteSelectedImage = useCallback(() => {
if (!selectedImageElement) return;
const editor = quillRef.current?.getEditor();
if (!editor) return;
try {
// Remove the image element from the DOM
selectedImageElement.remove();
} catch {}
try {
// Remove resize overlay container if present
const editorContainer = editor.root.parentElement as HTMLElement | null;
const resizeContainer = editorContainer?.querySelector('.custom-image-resize-container') as HTMLElement | null;
if (resizeContainer && resizeContainer.parentNode) {
resizeContainer.parentNode.removeChild(resizeContainer);
}
} catch {}
selectedImageIdRef.current = null;
setSelectedImageElement(null);
setShowImageToolbar(false);
setImageWidth(0);
setManualWidth('');
setWidthPercent(0);
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
toast({ title: 'Obrázek odstraněn', status: 'info', duration: 1500 });
}, [selectedImageElement, toast, cleanEditorHTML]);
// Align image
const alignImage = useCallback((alignment: 'left' | 'center' | 'right') => {
if (selectedImageElement) {
@@ -1304,7 +1527,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
toast({ title: `Obrázek zarovnán ${alignment === 'left' ? 'vlevo' : alignment === 'center' ? 'na střed' : 'vpravo'}`, status: 'success', duration: 1500 });
}
}, [selectedImageElement, toast]);
// Reselect helper after content updates (e.g., when value change triggers rerender)
const reselectAfterContentUpdate = useCallback(() => {
const id = selectedImageIdRef.current;
@@ -1385,33 +1607,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
}
}, [selectedImageElement, manualWidth, toast, applyWidthPx]);
// Delete selected image
const deleteSelectedImage = useCallback(() => {
if (selectedImageElement) {
selectedImageElement.remove();
setSelectedImageElement(null);
setShowImageToolbar(false);
const editor = quillRef.current?.getEditor();
if (editor) {
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
}
toast({ title: 'Obrázek odstraněn', status: 'info', duration: 1500 });
}
}, [selectedImageElement, toast]);
// Sanitize HTML on change and keep author-selected colors intact
const handleChange = (content: string) => {
// First sanitize
let cleaned = DOMPurify.sanitize(content, {
USE_PROFILES: { html: true },
ADD_TAGS: ['iframe'],
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id', 'data-bullets', 'data-list'],
});
onChangeRef.current(cleanEditorHTML(cleaned));
};
// Apply bullet style (disc | circle | square) to the current list
const applyBulletStyle = useCallback((style: 'disc' | 'circle' | 'square') => {
// Apply bullet style (disc | circle | square | none) to the current list
const applyBulletStyle = useCallback((style: 'disc' | 'circle' | 'square' | 'none') => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const range = quill.getSelection();
@@ -1426,11 +1623,45 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
}
if (el && el.tagName === 'UL') {
(el as HTMLElement).setAttribute('data-bullets', style);
try {
// Also set inline style for public rendering where Quill CSS is not present
(el as HTMLElement).style.listStyleType = style === 'none' ? 'none' : style;
} catch {}
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
}
}, [onChangeRef]);
}, [onChangeRef, cleanEditorHTML]);
// Enhance toolbar: add bullet-style popover and color reset buttons
const insertOrUpdateLink = useCallback(() => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const range = linkRangeRef.current || quill.getSelection();
if (!range) return;
const text = linkText?.trim() || linkUrl?.trim();
const url = linkUrl?.trim();
if (!url) {
toast({ title: 'Zadejte URL', status: 'warning', duration: 1500 });
return;
}
quill.focus();
if (range.length > 0) {
// Replace selected text with provided text and link
quill.deleteText(range.index, range.length, 'user');
quill.insertText(range.index, text || url, 'link', url, 'user');
quill.setSelection(range.index + (text || url).length, 0, 'user');
} else {
quill.insertText(range.index, text || url, 'link', url, 'user');
quill.setSelection(range.index + (text || url).length, 0, 'user');
}
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
setIsLinkOpen(false);
setLinkText('');
setLinkUrl('');
}, [linkText, linkUrl, toast, cleanEditorHTML]);
// Enhance toolbar: add color/background reset buttons next to pickers
useEffect(() => {
if (!isMounted) return;
const editor = quillRef.current?.getEditor();
@@ -1458,128 +1689,42 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
(picker.parentElement as HTMLElement)?.insertBefore(btn, picker.nextSibling);
}
};
addResetButton('.ql-color .ql-picker', 'ql-colorreset', 'color');
addResetButton('.ql-background .ql-picker', 'ql-bgreset', 'background');
// Create bullet styles popover and attach to bullet list button
const bulletBtn = toolbarEl.querySelector('button.ql-list[value="bullet"]') as HTMLButtonElement | null;
if (!bulletBtn) return;
let popover = toolbarEl.querySelector('.bullet-style-popover') as HTMLDivElement | null;
if (!popover) {
popover = document.createElement('div');
popover.className = 'bullet-style-popover';
popover.style.cssText = 'position:absolute;display:none;background:#fff;border:1px solid rgba(0,0,0,0.15);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.15);padding:6px;gap:6px;z-index:1000;';
const mk = (label: string, st: 'disc'|'circle'|'square') => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'ql-bulletstyle';
b.textContent = label;
b.style.cssText = 'min-width:32px;height:28px;padding:0 8px;border-radius:6px;border:1px solid #e2e8f0;background:#fff;cursor:pointer;';
b.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const q = quillRef.current?.getEditor();
if (!q) return;
const range = q.getSelection(true);
if (range) {
q.format('list', 'bullet', 'user');
applyBulletStyle(st);
}
if (popover) popover.style.display = 'none';
});
b.addEventListener('mouseenter', () => { b.style.background = '#f7fafc'; });
b.addEventListener('mouseleave', () => { b.style.background = '#fff'; });
return b;
};
popover.appendChild(mk('•', 'disc'));
popover.appendChild(mk('○', 'circle'));
popover.appendChild(mk('▪', 'square'));
toolbarEl.appendChild(popover);
}
let hideTimer: number | null = null;
const show = () => {
if (!popover) return;
const rect = bulletBtn.getBoundingClientRect();
const tRect = toolbarEl.getBoundingClientRect();
popover.style.left = `${rect.left - tRect.left}px`;
popover.style.top = `${rect.bottom - tRect.top + 6}px`;
popover.style.display = 'flex';
};
const toggle = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!popover) return;
if (popover.style.display === 'flex') {
popover.style.display = 'none';
} else {
show();
}
};
const scheduleHide = () => {
if (hideTimer) window.clearTimeout(hideTimer);
hideTimer = window.setTimeout(() => { if (popover) popover.style.display = 'none'; }, 200);
};
const cancelHide = () => { if (hideTimer) { window.clearTimeout(hideTimer); hideTimer = null; } };
bulletBtn.addEventListener('mouseenter', show);
bulletBtn.addEventListener('click', toggle);
bulletBtn.addEventListener('mouseleave', scheduleHide);
popover.addEventListener('mouseenter', cancelHide);
popover.addEventListener('mouseleave', scheduleHide);
return () => {
bulletBtn.removeEventListener('mouseenter', show);
bulletBtn.removeEventListener('click', toggle);
bulletBtn.removeEventListener('mouseleave', scheduleHide);
popover && popover.removeEventListener('mouseenter', cancelHide);
popover && popover.removeEventListener('mouseleave', scheduleHide);
};
}, [isMounted, applyBulletStyle]);
const insertOrUpdateLink = useCallback(() => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const range = linkRangeRef.current || quill.getSelection() || { index: quill.getLength(), length: 0 };
const text = linkText?.trim() || linkUrl?.trim();
const url = linkUrl?.trim();
if (!url) {
toast({ title: 'Zadejte URL', status: 'warning', duration: 1500 });
return;
}
quill.focus();
if (range.length > 0) {
// Replace selected text with provided text and link
quill.deleteText(range.index, range.length, 'user');
quill.insertText(range.index, text || url, 'link', url, 'user');
quill.setSelection(range.index + (text || url).length, 0, 'user');
} else {
quill.insertText(range.index, text || url, 'link', url, 'user');
quill.setSelection(range.index + (text || url).length, 0, 'user');
}
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
setIsLinkOpen(false);
setLinkText('');
setLinkUrl('');
}, [linkText, linkUrl, toast]);
addResetButton('.ql-color .ql-picker-label', 'ql-color-reset', 'color');
addResetButton('.ql-background .ql-picker-label', 'ql-background-reset', 'background');
}, [isMounted, cleanEditorHTML]);
return (
<Box>
{/* Editor Controls */}
{!readOnly && (
<VStack align="stretch" spacing={1} mb={2}>
{onImageUpload && (
<HStack spacing={2} justify="flex-start" flexWrap="wrap">
<Button
size="sm"
leftIcon={<ImageIcon size={16} />}
colorScheme="purple"
onClick={handleImageUpload}
>
Vložit obrázek
</Button>
<Text fontSize="xs" color="gray.500">
nebo použijte tlačítko obrázku v nástrojové liště
</Text>
</HStack>
)}
<HStack spacing={2} justify="flex-start" flexWrap="wrap">
{onImageUpload && (
<>
<Button
size="sm"
leftIcon={<ImageIcon size={16} />}
colorScheme="purple"
onClick={handleImageUpload}
>
Vložit obrázek
</Button>
<Text fontSize="xs" color="gray.500">
nebo použijte tlačítko obrázku v nástrojové liště
</Text>
</>
)}
<Button
size="sm"
leftIcon={<Code size={16} />}
colorScheme="blue"
variant="outline"
onClick={() => openHtmlEditor('document')}
>
Otevřít v HTML
</Button>
</HStack>
</VStack>
)}
@@ -2153,6 +2298,97 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
</ModalContent>
</Modal>
<Drawer isOpen={isHtmlEditorOpen} placement="right" size="lg" onClose={() => setIsHtmlEditorOpen(false)}>
<DrawerOverlay />
<DrawerContent>
<DrawerCloseButton />
<DrawerHeader>{htmlEditMode === 'document' ? 'Upravit HTML (celý obsah)' : 'Vložit vlastní HTML'}</DrawerHeader>
<DrawerBody>
<VStack align="stretch" spacing={4}>
<FormControl>
<FormLabel>HTML kód</FormLabel>
{MonacoComponent ? (
<MonacoComponent
height="60vh"
defaultLanguage="html"
language="html"
theme="vs-light"
value={htmlSource}
onChange={(v: string | undefined) => setHtmlSource(v || '')}
options={{
automaticLayout: true,
wordWrap: 'on',
minimap: { enabled: false },
tabSize: 2,
insertSpaces: true,
formatOnPaste: true,
formatOnType: true,
suggestOnTriggerCharacters: true,
}}
/>
) : (
<Textarea
value={htmlSource}
onChange={(e) => setHtmlSource(e.target.value)}
minH="200px"
fontFamily="monospace"
fontSize="sm"
/>
)}
<FormHelperText>
{htmlEditMode === 'document'
? 'Upravujete celý obsah. Po potvrzení se současný obsah editoru nahradí vaším HTML.'
: 'Vložte libovolný HTML kód (např. <h1>Nadpis</h1>, <p>odstavec</p>, <ul>...</ul>). Po vložení se převede na plně funkční formátování v editoru.'}
</FormHelperText>
</FormControl>
</VStack>
</DrawerBody>
<DrawerFooter>
<Button variant="ghost" mr={3} onClick={() => setIsHtmlEditorOpen(false)}>
Zrušit
</Button>
<Button colorScheme="blue" onClick={handleInsertHtmlBlock}>
{htmlEditMode === 'document' ? 'Použít HTML' : 'Vložit do textu'}
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
<AlertDialog isOpen={isFullHtmlConfirmOpen} leastDestructiveRef={confirmCancelRef as any} onClose={() => setIsFullHtmlConfirmOpen(false)} isCentered>
<AlertDialogOverlay />
<AlertDialogContent>
<AlertDialogHeader>Nahradit celý obsah HTML?</AlertDialogHeader>
<AlertDialogCloseButton />
<AlertDialogBody>
Tato akce nahradí aktuální obsah editoru vaším HTML. Chcete pokračovat?
<Box mt={3}>
<Checkbox isChecked={confirmDontAsk} onChange={(e) => setConfirmDontAsk(e.target.checked)}>
Neptat se příště
</Checkbox>
</Box>
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={confirmCancelRef as any} onClick={() => setIsFullHtmlConfirmOpen(false)} mr={3}>Zrušit</Button>
<Button colorScheme="blue" onClick={() => {
try { if (confirmDontAsk) window.localStorage.setItem('rte_skip_full_html_confirm', '1'); } catch {}
const quill = quillRef.current?.getEditor();
if (!quill) { setIsFullHtmlConfirmOpen(false); return; }
const sanitized = pendingSanitizedRef.current;
const raw = pendingRawRef.current;
const total = quill.getLength();
quill.focus();
try { quill.deleteText(0, total, 'api'); } catch {}
try { quill.clipboard.dangerouslyPasteHTML(0, sanitized, 'user'); } catch { const fb = DOMPurify.sanitize(raw, { USE_PROFILES: { html: true } }); quill.insertText(0, fb, 'user'); }
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
setIsHtmlEditorOpen(false);
setHtmlSource('');
setIsFullHtmlConfirmOpen(false);
toast({ title: 'Obsah aktualizován', status: 'success', duration: 2000 });
}}>Použít HTML</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Crop Modal */}
{/* Image Preview Modal */}
<Modal isOpen={isPreviewOpen} onClose={() => setIsPreviewOpen(false)} size="6xl" isCentered>
@@ -0,0 +1,632 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Box,
Button,
HStack,
VStack,
Text,
SimpleGrid,
Stack,
Divider,
useColorModeValue,
Popover,
PopoverTrigger,
PopoverContent,
PopoverArrow,
PopoverBody,
} from '@chakra-ui/react';
import { ChevronLeftIcon, ChevronRightIcon, CalendarIcon } from '@chakra-ui/icons';
import {
addDays,
addMonths,
endOfMonth,
endOfWeek,
format,
getDaysInMonth,
isSameDay,
isSameMonth,
isWithinInterval,
parseISO,
startOfMonth,
startOfWeek,
subMonths,
setYear,
setMonth,
} from 'date-fns';
import { cs } from 'date-fns/locale';
export type DateRangePickerProps = {
from?: string;
to?: string;
onChange: (from: string, to: string) => void;
size?: 'sm' | 'md';
};
function parseDate(value?: string | null): Date | null {
if (!value) return null;
try {
const d = parseISO(value);
if (!isNaN(d.getTime())) return d;
} catch {
// ignore
}
return null;
}
function toIsoDate(d: Date | null | undefined): string {
if (!d || isNaN(d.getTime())) return '';
return format(d, 'yyyy-MM-dd');
}
const monthNamesCs = [
'Leden',
'Únor',
'Březen',
'Duben',
'Květen',
'Červen',
'Červenec',
'Srpen',
'Září',
'Říjen',
'Listopad',
'Prosinec',
];
const weekdayLabelsCs = (() => {
const start = startOfWeek(new Date(), { weekStartsOn: 1 });
return Array.from({ length: 7 }).map((_, i) =>
format(addDays(start, i), 'EEEEE', { locale: cs }),
);
})();
const buildPresets = () => {
const today = new Date();
const todayIso = toIsoDate(today);
const lastNDays = (n: number) => {
const end = today;
const start = addDays(end, -n + 1);
return { from: toIsoDate(start), to: toIsoDate(end) };
};
const thisWeek = () => {
const monday = startOfWeek(today, { weekStartsOn: 1 });
const sunday = endOfWeek(today, { weekStartsOn: 1 });
return { from: toIsoDate(monday), to: toIsoDate(sunday) };
};
const next30Days = () => {
const start = today;
const end = addDays(start, 30);
return { from: toIsoDate(start), to: toIsoDate(end) };
};
const thisMonth = () => {
const start = startOfMonth(today);
const end = endOfMonth(today);
return { from: toIsoDate(start), to: toIsoDate(end) };
};
const last3Months = () => {
const end = today;
const start = addMonths(startOfMonth(end), -2);
return { from: toIsoDate(start), to: toIsoDate(end) };
};
const last12Months = () => {
const end = today;
const start = addMonths(startOfMonth(end), -11);
return { from: toIsoDate(start), to: toIsoDate(end) };
};
const thisYear = () => {
const year = today.getFullYear();
const start = new Date(year, 0, 1);
const end = new Date(year, 11, 31);
return { from: toIsoDate(start), to: toIsoDate(end) };
};
return [
{ key: 'today', label: 'Dnes', getRange: () => ({ from: todayIso, to: todayIso }) },
{ key: 'last7', label: 'Posledních 7 dní', getRange: () => lastNDays(7) },
{ key: 'last30', label: 'Posledních 30 dní', getRange: () => lastNDays(30) },
{ key: 'thisWeek', label: 'Tento týden', getRange: () => thisWeek() },
{ key: 'next30', label: 'Nadcházejících 30 dní', getRange: () => next30Days() },
{ key: 'thisMonth', label: 'Tento měsíc', getRange: () => thisMonth() },
{ key: 'last3months', label: 'Poslední 3 měsíce', getRange: () => last3Months() },
{ key: 'last12months', label: 'Posledních 12 měsíců', getRange: () => last12Months() },
{ key: 'thisYear', label: 'Tento rok', getRange: () => thisYear() },
];
};
const PRESETS = buildPresets();
export const DateRangePicker: React.FC<DateRangePickerProps> = ({ from, to, onChange, size = 'sm' }) => {
const [viewDate, setViewDate] = useState<Date>(() => parseDate(from) || parseDate(to) || new Date());
const [draftFrom, setDraftFrom] = useState<Date | null>(() => parseDate(from));
const [draftTo, setDraftTo] = useState<Date | null>(() => parseDate(to));
const [hoverDate, setHoverDate] = useState<Date | null>(null);
const [activePanel, setActivePanel] = useState<'calendar' | 'year' | 'month' | 'day'>('calendar');
const [yearPageStart, setYearPageStart] = useState<number>(() => {
const base = (parseDate(from) || parseDate(to) || new Date()).getFullYear();
return base - 6; // show 12 years around current
});
const cardBg = useColorModeValue('white', 'gray.800');
const sideBg = useColorModeValue('gray.50', 'gray.900');
const sideActiveBg = useColorModeValue('blue.50', 'blue.900');
const sideActiveColor = useColorModeValue('blue.700', 'blue.100');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const dayBgSelected = useColorModeValue('blue.500', 'blue.400');
const dayBgInRange = useColorModeValue('blue.50', 'blue.900');
const dayTextSelected = useColorModeValue('white', 'gray.900');
const dayTextSubtle = useColorModeValue('gray.400', 'gray.500');
const triggerBg = useColorModeValue('white', 'gray.800');
const triggerHoverBg = useColorModeValue('gray.50', 'gray.700');
const triggerBorderColor = useColorModeValue('gray.300', 'gray.600');
useEffect(() => {
const start = parseDate(from);
const end = parseDate(to);
setDraftFrom(start);
setDraftTo(end);
const base = start || end || new Date();
setViewDate(base);
setYearPageStart(base.getFullYear() - 6);
}, [from, to]);
const rangeLabel = useMemo(() => {
const start = parseDate(from);
const end = parseDate(to);
if (start && end) {
return `${format(start, 'd.M.yyyy', { locale: cs })} ${format(end, 'd.M.yyyy', { locale: cs })}`;
}
if (start) return `Od ${format(start, 'd.M.yyyy', { locale: cs })}`;
if (end) return `Do ${format(end, 'd.M.yyyy', { locale: cs })}`;
return 'Libovolné období';
}, [from, to]);
const handleDayClick = (day: Date) => {
if (!draftFrom || (draftFrom && draftTo)) {
setDraftFrom(day);
setDraftTo(null);
return;
}
if (draftFrom && !draftTo) {
if (day < draftFrom) {
setDraftTo(draftFrom);
setDraftFrom(day);
} else if (day.getTime() === draftFrom.getTime()) {
// Single day toggle
setDraftTo(day);
} else {
setDraftTo(day);
}
}
};
const currentRange = useMemo(() => {
if (!draftFrom && !draftTo) return null;
if (draftFrom && draftTo) {
return draftFrom <= draftTo
? { start: draftFrom, end: draftTo }
: { start: draftTo, end: draftFrom };
}
if (draftFrom && hoverDate) {
return draftFrom <= hoverDate
? { start: draftFrom, end: hoverDate }
: { start: hoverDate, end: draftFrom };
}
return null;
}, [draftFrom, draftTo, hoverDate]);
const monthStart = startOfMonth(viewDate);
const monthEnd = endOfMonth(viewDate);
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 });
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
const calendarDays: Date[] = [];
for (let d = calendarStart; d <= calendarEnd; d = addDays(d, 1)) {
calendarDays.push(d);
}
const weeks: Date[][] = [];
for (let i = 0; i < calendarDays.length; i += 7) {
weeks.push(calendarDays.slice(i, i + 7));
}
const yearOptions = useMemo(() => {
return Array.from({ length: 12 }).map((_, i) => yearPageStart + i);
}, [yearPageStart]);
const selectedReferenceDate = draftFrom || draftTo || viewDate;
const selectedYear = selectedReferenceDate.getFullYear();
const selectedMonth = selectedReferenceDate.getMonth();
const daysInSelectedMonth = getDaysInMonth(new Date(selectedYear, selectedMonth, 1));
const headerDayLabel = format(selectedReferenceDate, 'd.', { locale: cs });
const applyDraft = () => {
const nextFrom = toIsoDate(draftFrom || null);
const nextTo = toIsoDate(draftTo || draftFrom || null);
onChange(nextFrom, nextTo);
};
const clearDraft = () => {
setDraftFrom(null);
setDraftTo(null);
setHoverDate(null);
};
const renderCalendar = () => (
<VStack align="stretch" spacing={4}>
<SimpleGrid columns={7} spacing={2} fontSize="sm" textTransform="uppercase" color={dayTextSubtle}>
{weekdayLabelsCs.map((d) => (
<Box key={d} textAlign="center">{d}</Box>
))}
</SimpleGrid>
<VStack spacing={2} align="stretch">
{weeks.map((week, wi) => (
<HStack key={wi} spacing={2}>
{week.map((day) => {
const isOutside = !isSameMonth(day, monthStart);
const isSelectedStart = draftFrom && isSameDay(day, draftFrom);
const isSelectedEnd = draftTo && isSameDay(day, draftTo);
const inRange = currentRange ? isWithinInterval(day, currentRange) : false;
let bg: string | undefined;
let color: string | undefined;
let roundedLeft = false;
let roundedRight = false;
if (inRange) {
bg = dayBgInRange;
}
if (isSelectedStart || isSelectedEnd) {
bg = dayBgSelected;
color = dayTextSelected;
roundedLeft = true;
roundedRight = true;
}
const baseColor = isOutside ? dayTextSubtle : undefined;
return (
<Button
key={day.toISOString()}
size={size}
variant={bg ? 'solid' : 'ghost'}
flex="1"
borderRadius={bg ? (roundedLeft && roundedRight ? 'full' : 'md') : 'md'}
bg={bg}
color={color || baseColor}
_hover={{ bg: bg || dayBgInRange }}
onClick={() => handleDayClick(day)}
onMouseEnter={() => setHoverDate(day)}
onMouseLeave={() => setHoverDate(null)}
w="100%"
h="2.5rem"
px={0}
>
{format(day, 'd')}
</Button>
);
})}
</HStack>
))}
</VStack>
</VStack>
);
const renderYearPanel = () => (
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Button
size="xs"
variant="ghost"
leftIcon={<ChevronLeftIcon />}
onClick={() => setYearPageStart((prev) => prev - 12)}
>
Starší
</Button>
<Text fontSize="sm" fontWeight="semibold">
{yearOptions[0]} {yearOptions[yearOptions.length - 1]}
</Text>
<Button
size="xs"
variant="ghost"
rightIcon={<ChevronRightIcon />}
onClick={() => setYearPageStart((prev) => prev + 12)}
>
Novější
</Button>
</HStack>
<SimpleGrid columns={3} spacing={2}>
{yearOptions.map((year) => (
<Button
key={year}
size={size}
variant={year === selectedYear ? 'solid' : 'ghost'}
colorScheme={year === selectedYear ? 'blue' : undefined}
onClick={() => {
const next = setYear(viewDate, year);
setViewDate(next);
setActivePanel('month');
}}
>
{year}
</Button>
))}
</SimpleGrid>
</VStack>
);
const renderMonthPanel = () => (
<SimpleGrid columns={3} spacing={2}>
{monthNamesCs.map((name, idx) => (
<Button
key={name}
size={size}
variant={idx === selectedMonth ? 'solid' : 'ghost'}
colorScheme={idx === selectedMonth ? 'blue' : undefined}
onClick={() => {
const next = setMonth(viewDate, idx);
setViewDate(startOfMonth(next));
setActivePanel('calendar');
}}
>
{`${idx + 1}. ${name}`}
</Button>
))}
</SimpleGrid>
);
const renderDayPanel = () => (
<SimpleGrid columns={7} spacing={2}>
{Array.from({ length: daysInSelectedMonth }).map((_, i) => {
const dayNum = i + 1;
const d = new Date(selectedYear, selectedMonth, dayNum);
const isSelectedStart = draftFrom && isSameDay(d, draftFrom);
const isSelectedEnd = draftTo && isSameDay(d, draftTo);
const inRange = currentRange ? isWithinInterval(d, currentRange) : false;
let bg: string | undefined;
let color: string | undefined;
if (inRange) bg = dayBgInRange;
if (isSelectedStart || isSelectedEnd) {
bg = dayBgSelected;
color = dayTextSelected;
}
return (
<Button
key={dayNum}
size={size}
variant={bg ? 'solid' : 'ghost'}
bg={bg}
color={color}
_hover={{ bg: bg || dayBgInRange }}
onClick={() => handleDayClick(d)}
borderRadius="full"
w="100%"
h="2.5rem"
px={0}
>
{dayNum}
</Button>
);
})}
</SimpleGrid>
);
const headerYear = viewDate.getFullYear();
const headerMonthName = monthNamesCs[viewDate.getMonth()];
return (
<Popover
placement="bottom-start"
onOpen={() => {
const start = parseDate(from);
const end = parseDate(to);
setDraftFrom(start);
setDraftTo(end);
const base = start || end || new Date();
setViewDate(base);
setYearPageStart(base.getFullYear() - 6);
setActivePanel('calendar');
}}
>
<PopoverTrigger>
<Button
size={size}
variant="outline"
leftIcon={<CalendarIcon />}
borderRadius="full"
px={5}
py={3}
minW={{ base: '220px', md: '260px', lg: '300px' }}
bg={triggerBg}
borderColor={triggerBorderColor}
boxShadow="sm"
_hover={{ bg: triggerHoverBg, boxShadow: 'md' }}
overflow="hidden"
>
<HStack spacing={3} align="center" w="100%" justify="space-between">
<Text fontSize="sm" color="gray.600" flexShrink={0}>
Období:
</Text>
<Text
fontSize="sm"
fontWeight="medium"
flex="1"
minW={0}
textAlign="right"
isTruncated
noOfLines={1}
>
{rangeLabel}
</Text>
</HStack>
</Button>
</PopoverTrigger>
<PopoverContent
w={{ base: '100%', sm: '360px', md: '520px' }}
maxW="520px"
borderRadius="xl"
boxShadow="xl"
bg={cardBg}
borderColor={borderColor}
>
<PopoverArrow />
<PopoverBody p={0}>
<Stack
direction={{ base: 'column', md: 'row' }}
spacing={0}
divider={<Divider orientation="vertical" display={{ base: 'none', md: 'block' }} />}
>
<Box
w={{ base: '100%', md: '200px' }}
borderBottomWidth={{ base: '1px', md: 0 }}
bg={sideBg}
p={4}
>
<VStack align="stretch" spacing={2}>
{PRESETS.map((preset) => (
<Button
key={preset.key}
size="sm"
justifyContent="flex-start"
variant="ghost"
borderRadius="md"
px={3}
py={2}
fontSize="sm"
_hover={{ bg: sideActiveBg, color: sideActiveColor }}
onClick={() => {
const r = preset.getRange();
setDraftFrom(parseDate(r.from));
setDraftTo(parseDate(r.to));
}}
>
{preset.label}
</Button>
))}
<Button
size="sm"
variant="ghost"
borderRadius="md"
px={3}
py={2}
fontSize="sm"
onClick={clearDraft}
>
Vymazat
</Button>
</VStack>
</Box>
<Box flex="1" p={4}>
<VStack align="stretch" spacing={4}>
<HStack justify="space-between" align="center">
<HStack spacing={2}>
<Button
size="xs"
borderRadius="full"
variant={activePanel === 'year' ? 'solid' : 'outline'}
colorScheme={activePanel === 'year' ? 'blue' : undefined}
onClick={() => setActivePanel('year')}
>
{headerYear}
</Button>
<Button
size="xs"
borderRadius="full"
variant={activePanel === 'month' ? 'solid' : 'outline'}
colorScheme={activePanel === 'month' ? 'blue' : undefined}
onClick={() => setActivePanel('month')}
>
{headerMonthName}
</Button>
<Button
size="xs"
borderRadius="full"
variant={activePanel === 'day' ? 'solid' : 'outline'}
colorScheme={activePanel === 'day' ? 'blue' : undefined}
onClick={() => setActivePanel('day')}
>
{headerDayLabel}
</Button>
</HStack>
<HStack spacing={1}>
<Button
size="xs"
variant="ghost"
onClick={() => setViewDate((prev) => subMonths(prev, 1))}
>
<ChevronLeftIcon />
</Button>
<Button
size="xs"
variant="ghost"
onClick={() => setViewDate((prev) => addMonths(prev, 1))}
>
<ChevronRightIcon />
</Button>
</HStack>
</HStack>
{activePanel === 'calendar' && renderCalendar()}
{activePanel === 'year' && renderYearPanel()}
{activePanel === 'month' && renderMonthPanel()}
{activePanel === 'day' && renderDayPanel()}
{activePanel !== 'calendar' && (
<Button
size="xs"
alignSelf="flex-start"
variant="link"
onClick={() => setActivePanel('calendar')}
>
Zpět na kalendář
</Button>
)}
<HStack justify="space-between" pt={2}>
<Text fontSize="xs" color="gray.500">
Rozsah:{' '}
{draftFrom && draftTo
? `${format(draftFrom, 'd.M.yyyy', { locale: cs })} ${format(draftTo, 'd.M.yyyy', { locale: cs })}`
: draftFrom
? `Od ${format(draftFrom, 'd.M.yyyy', { locale: cs })}`
: draftTo
? `Do ${format(draftTo, 'd.M.yyyy', { locale: cs })}`
: 'nenastaveno'}
</Text>
<HStack spacing={2}>
<Button
size="sm"
variant="ghost"
onClick={clearDraft}
>
Zrušit
</Button>
<Button
size="sm"
colorScheme="blue"
onClick={applyDraft}
isDisabled={!draftFrom && !draftTo}
>
Použít
</Button>
</HStack>
</HStack>
</VStack>
</Box>
</Stack>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default DateRangePicker;
@@ -0,0 +1,123 @@
import React from 'react';
import {
Box,
Text,
HStack,
Stack,
Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
PopoverArrow,
useColorModeValue,
Portal,
Icon,
} from '@chakra-ui/react';
import { CheckCircleIcon, InfoOutlineIcon } from '@chakra-ui/icons';
export interface HelpTooltipCardProps {
label: string;
title: string;
items?: string[];
showStrengthBars?: boolean;
}
/**
* HelpTooltipCard
*
* Reusable rich tooltip/popover used for contextual help next to form fields.
* Triggered by a small pill with label text; shows a card with colored bars
* and a checklist inside, visually similar to password requirement examples.
*/
export const HelpTooltipCard: React.FC<HelpTooltipCardProps> = ({ label, title, items, showStrengthBars = false }) => {
const bg = useColorModeValue('white', 'gray.800');
const border = useColorModeValue('gray.200', 'gray.700');
const pillBg = useColorModeValue('white', 'gray.700');
const pillBorder = useColorModeValue('gray.200', 'gray.600');
const iconColor = useColorModeValue('gray.600', 'gray.200');
return (
<Popover trigger="hover" openDelay={150} closeDelay={100} placement="right-start">
<PopoverTrigger>
<Box
as="button"
type="button"
borderRadius="full"
bg={pillBg}
borderWidth="1px"
borderColor={pillBorder}
boxShadow="sm"
fontSize="xs"
fontWeight="medium"
_hover={{ boxShadow: 'md', transform: 'translateY(-1px)' }}
transition="all 0.15s ease-out"
display="inline-flex"
alignItems="center"
justifyContent="center"
w="18px"
h="18px"
aria-label={label}
>
<Icon as={InfoOutlineIcon} boxSize={3} color={iconColor} />
</Box>
</PopoverTrigger>
<Portal>
<PopoverContent
maxW="sm"
bg={bg}
borderColor={border}
boxShadow="2xl"
borderRadius="xl"
_focus={{ boxShadow: '2xl' }}
>
<PopoverArrow />
<PopoverBody p={4}>
<Stack spacing={3}>
{showStrengthBars && (
<HStack spacing={1.5}>
<Box flex="1" h="2px" borderRadius="full" bg="red.400" />
<Box flex="1" h="2px" borderRadius="full" bg="orange.400" />
<Box flex="1" h="2px" borderRadius="full" bg="green.400" />
</HStack>
)}
<Text fontSize="sm" fontWeight="semibold">
{title}
</Text>
{items && items.length > 0 && (
<Stack as="ul" spacing={1.5} pl={0} m={0} style={{ listStyle: 'none' }}>
{items.map((item) => (
<HStack as="li" key={item} align="flex-start" spacing={2}>
<Icon as={CheckCircleIcon} color="green.400" boxSize={3.5} mt={0.5} />
<Text fontSize="sm" color="gray.700" _dark={{ color: 'gray.200' }}>
{item}
</Text>
</HStack>
))}
</Stack>
)}
</Stack>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
);
};
export const PasswordHelpTooltip: React.FC<{ label?: string }> = ({ label = 'Požadavky na heslo' }) => {
return (
<HelpTooltipCard
label={label}
title="Silné heslo by mělo obsahovat:"
items={[
'Minimálně 8 znaků (povinné)',
'Kombinaci malých a velkých písmen',
'Čísla a ideálně speciální znaky',
]}
showStrengthBars
/>
);
};
export default HelpTooltipCard;
@@ -0,0 +1,150 @@
import { useTranslation } from 'react-i18next';
import { Button, Menu, MenuButton, MenuList, MenuItem, HStack, Text, Icon } from '@chakra-ui/react';
import { FaGlobe, FaChevronDown } from 'react-icons/fa';
import { useState, useEffect } from 'react';
import { api } from '../../services/api';
interface Language {
id: string;
name: string;
native_name: string;
code: string;
is_default: boolean;
is_active: boolean;
sort_order: number;
}
export const LanguageSwitcher = () => {
const { i18n } = useTranslation();
const [languages, setLanguages] = useState<Language[]>([]);
const [currentLanguage, setCurrentLanguage] = useState<string>('cs');
const [loading, setLoading] = useState(true);
// Fetch available languages from API
useEffect(() => {
const fetchLanguages = async () => {
try {
const response = await api.get('/i18n/languages');
const activeLanguages = response.data.languages.filter((lang: Language) => lang.is_active);
if (activeLanguages.length > 0) {
setLanguages(activeLanguages);
} else {
// Fallback to default languages if API returns empty
setLanguages([
{ id: 'cs', name: 'Czech', native_name: 'Čeština', code: 'cs', is_default: true, is_active: true, sort_order: 1 },
{ id: 'en', name: 'English', native_name: 'English', code: 'en', is_default: false, is_active: true, sort_order: 2 },
]);
}
// Set current language from i18n or default to Czech
const current = i18n.language || 'cs';
setCurrentLanguage(current);
} catch (error) {
// Fallback to default languages on error
setLanguages([
{ id: 'cs', name: 'Czech', native_name: 'Čeština', code: 'cs', is_default: true, is_active: true, sort_order: 1 },
{ id: 'en', name: 'English', native_name: 'English', code: 'en', is_default: false, is_active: true, sort_order: 2 },
]);
const current = i18n.language || 'cs';
setCurrentLanguage(current);
} finally {
setLoading(false);
}
};
fetchLanguages();
}, [i18n.language]);
// Change language
const changeLanguage = async (languageCode: string) => {
try {
// Change language in i18next
await i18n.changeLanguage(languageCode);
// Save preference to backend if user is authenticated
const token = localStorage.getItem('token');
if (token) {
try {
await api.post('/i18n/user-language', { language_code: languageCode });
} catch (error) {
console.warn('Failed to save language preference to backend:', error);
}
}
// Save to cookie
document.cookie = `lang=${languageCode}; max-age=${365 * 24 * 60 * 60}; path=/`;
setCurrentLanguage(languageCode);
// Refresh page to update navigation and all content
window.location.reload();
} catch (error) {
console.error('Failed to change language:', error);
}
};
// Get current language display name
const getCurrentLanguageDisplay = () => {
const lang = languages.find(l => l.code === currentLanguage);
return lang ? lang.native_name : 'Čeština';
};
if (loading || languages.length < 2) {
return null; // Don't show if less than 2 languages or still loading
}
return (
<Menu>
<MenuButton
as={Button}
variant="ghost"
size="sm"
minWidth="auto"
px={2}
py={1}
borderRadius="md"
_hover={{ bg: "gray.100" }}
_active={{ bg: "gray.200" }}
fontSize="sm"
fontWeight="medium"
>
<HStack spacing={1}>
<Icon as={FaGlobe} fontSize="sm" />
<Text fontSize="sm" fontWeight="medium" textTransform="uppercase">
{currentLanguage === 'cs' ? 'CZ' : 'EN'}
</Text>
<Icon as={FaChevronDown} fontSize="xs" />
</HStack>
</MenuButton>
<MenuList minWidth="100px" p={1} boxShadow="lg" border="1px" borderColor="gray.200">
{languages
.sort((a, b) => a.sort_order - b.sort_order)
.map((language) => (
<MenuItem
key={language.code}
onClick={() => changeLanguage(language.code)}
isDisabled={language.code === currentLanguage}
fontSize="sm"
borderRadius="md"
px={2}
py={1}
_hover={{ bg: "gray.100" }}
_selected={{ bg: "blue.50", color: "blue.600" }}
>
<HStack spacing={1} width="100%" justifyContent="space-between">
<Text fontSize="sm" fontWeight="medium" textTransform="uppercase">
{language.code === 'cs' ? 'CZ' : 'EN'}
</Text>
{language.code === currentLanguage && (
<Text fontSize="sm" color="blue.500" fontWeight="bold">
</Text>
)}
</HStack>
</MenuItem>
))}
</MenuList>
</Menu>
);
};
@@ -0,0 +1,463 @@
import React, { useMemo } from 'react';
import {
Box,
Text,
HStack,
VStack,
Icon,
Skeleton,
Badge,
useColorModeValue,
Tooltip,
Progress,
Divider,
SimpleGrid,
} from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import {
FaCloud,
FaCloudSun,
FaSun,
FaCloudRain,
FaSnowflake,
FaWind,
FaTint,
FaEye,
FaThermometerHalf,
FaExclamationTriangle,
FaMoon,
} from 'react-icons/fa';
import api from '../../services/api';
interface MatchWeatherData {
weather: {
location: {
name: string;
region: string;
country: string;
};
current: {
last_updated: string;
temp_c: number;
temp_f: number;
is_day: number;
condition: {
text: string;
icon: string;
code: number;
};
wind_mph: number;
wind_kph: number;
wind_degree: number;
wind_dir: string;
pressure_mb: number;
pressure_in: number;
precip_mm: number;
precip_in: number;
humidity: number;
cloud: number;
feelslike_c: number;
feelslike_f: number;
vis_km: number;
vis_miles: number;
uv: number;
gust_mph: number;
gust_kph: number;
};
forecast: {
forecastday: Array<{
date: string;
date_epoch: number;
day: {
maxtemp_c: number;
maxtemp_f: number;
mintemp_c: number;
mintemp_f: number;
avgtemp_c: number;
avgtemp_f: number;
maxwind_mph: number;
maxwind_kph: number;
totalprecip_mm: number;
totalprecip_in: number;
totalsnow_cm: number;
avgvis_km: number;
avgvis_miles: number;
avghumidity: number;
daily_will_it_rain: number;
daily_chance_of_rain: number;
daily_will_it_snow: number;
daily_chance_of_snow: number;
condition: {
text: string;
icon: string;
code: number;
};
uv: number;
};
astro: {
sunrise: string;
sunset: string;
moonrise: string;
moonset: string;
moon_phase: string;
moon_illumination: number;
is_moon_up: number;
is_sun_up: number;
};
hour: Array<{
time_epoch: number;
time: string;
temp_c: number;
temp_f: number;
is_day: number;
condition: {
text: string;
icon: string;
code: number;
};
wind_mph: number;
wind_kph: number;
wind_degree: number;
wind_dir: string;
pressure_mb: number;
pressure_in: number;
precip_mm: number;
precip_in: number;
humidity: number;
cloud: number;
feelslike_c: number;
feelslike_f: number;
windchill_c: number;
windchill_f: number;
heatindex_c: number;
heatindex_f: number;
dewpoint_c: number;
dewpoint_f: number;
will_it_rain: number;
chance_of_rain: number;
will_it_snow: number;
chance_of_snow: number;
vis_km: number;
vis_miles: number;
gust_mph: number;
gust_kph: number;
uv: number;
}>;
}>;
};
};
match_time: string;
closest_hour: {
time_epoch: number;
time: string;
temp_c: number;
temp_f: number;
is_day: number;
condition: {
text: string;
icon: string;
code: number;
};
wind_mph: number;
wind_kph: number;
wind_degree: number;
wind_dir: string;
pressure_mb: number;
pressure_in: number;
precip_mm: number;
precip_in: number;
humidity: number;
cloud: number;
feelslike_c: number;
feelslike_f: number;
windchill_c: number;
windchill_f: number;
heatindex_c: number;
heatindex_f: number;
dewpoint_c: number;
dewpoint_f: number;
will_it_rain: number;
chance_of_rain: number;
will_it_snow: number;
chance_of_snow: number;
vis_km: number;
vis_miles: number;
gust_mph: number;
gust_kph: number;
uv: number;
};
}
interface MatchWeatherProps {
matchDateTime: string;
venue?: string;
isHomeMatch: boolean;
matchHasStarted: boolean;
delayLoad?: boolean; // New prop to control when to load weather
}
// Czech and English translations
const translations = {
cs: {
weather: 'Počasí',
forecast: 'Předpověď počasí',
temperature: 'Teplota',
feelsLike: 'Pocitově',
wind: 'Vítr',
humidity: 'Vlhkost',
visibility: 'Viditelnost',
pressure: 'Tlak',
precipitation: 'Srážky',
chanceOfRain: 'Šance na déšť',
chanceOfSnow: 'Šance na sníh',
atMatchTime: 'V čase zápasu',
weatherUnavailable: 'Předpověď počasí není dostupná',
forecastNotAvailable: 'Předpověď není dostupná pro tento zápas',
tooFarInFuture: 'Zápas je příliš daleko v budoucnosti',
matchInPast: 'Zápas již proběhl',
loading: 'Načítám počasí...',
},
en: {
weather: 'Weather',
forecast: 'Weather Forecast',
temperature: 'Temperature',
feelsLike: 'Feels Like',
wind: 'Wind',
humidity: 'Humidity',
visibility: 'Visibility',
pressure: 'Pressure',
precipitation: 'Precipitation',
chanceOfRain: 'Rain Chance',
chanceOfSnow: 'Snow Chance',
atMatchTime: 'At Match Time',
weatherUnavailable: 'Weather forecast unavailable',
forecastNotAvailable: 'Forecast not available for this match',
tooFarInFuture: 'Match is too far in the future',
matchInPast: 'Match has already taken place',
loading: 'Loading weather...',
},
};
const getWeatherIcon = (condition: string, isDay: number) => {
const lowerCondition = condition.toLowerCase();
if (lowerCondition.includes('sunny') || lowerCondition.includes('clear')) {
return isDay ? FaSun : FaMoon;
}
if (lowerCondition.includes('partly cloudy') || lowerCondition.includes('partly sunny')) {
return FaCloudSun;
}
if (lowerCondition.includes('cloudy') || lowerCondition.includes('overcast')) {
return FaCloud;
}
if (lowerCondition.includes('rain') || lowerCondition.includes('drizzle') || lowerCondition.includes('shower')) {
return FaCloudRain;
}
if (lowerCondition.includes('snow') || lowerCondition.includes('sleet') || lowerCondition.includes('blizzard')) {
return FaSnowflake;
}
return FaCloud;
};
const getWindDirection = (degree: number) => {
const directions = ['S', 'SV', 'V', 'JV', 'J', 'JZ', 'Z', 'SZ'];
const index = Math.round(degree / 45) % 8;
return directions[index];
};
export const MatchWeather: React.FC<MatchWeatherProps> = ({
matchDateTime,
venue,
isHomeMatch,
matchHasStarted,
delayLoad = false
}) => {
const { i18n } = useTranslation();
const t = translations[i18n.language as keyof typeof translations] || translations.cs;
const bgCard = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textColor = useColorModeValue('gray.600', 'gray.400');
// Only show weather for home matches that haven't started yet
if (!isHomeMatch || matchHasStarted) {
return null;
}
const { data: weatherData, isLoading, error } = useQuery<MatchWeatherData>({
queryKey: ['matchWeather', matchDateTime, venue],
queryFn: async () => {
const params = new URLSearchParams({
match_datetime: matchDateTime,
// Only pass location parameter if it's not a home match (home matches use club location)
...(venue && { location: venue }),
});
const response = await api.get(`/weather/match?${params}`);
return response.data;
},
staleTime: 30 * 60 * 1000, // 30 minutes cache
enabled: isHomeMatch && !matchHasStarted && !delayLoad, // Disable if delayLoad is true
});
if (isLoading) {
return (
<Box
bg={bgCard}
p={4}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
mt={4}
>
<VStack spacing={3} align="stretch">
<Text fontSize="sm" fontWeight="bold" color={textColor}>
{t.weather}
</Text>
<Skeleton height="20px" width="60%" />
<Skeleton height="16px" width="40%" />
<Skeleton height="16px" width="50%" />
</VStack>
</Box>
);
}
if (error || !weatherData) {
// Check for specific error messages to provide better user feedback
let errorMessage = t.weatherUnavailable;
const errorString = String(error || '');
if (errorString.includes('too far in the future')) {
errorMessage = t.tooFarInFuture;
} else if (errorString.includes('match is in the past')) {
errorMessage = t.matchInPast;
} else if (errorString.includes('no location specified')) {
errorMessage = t.forecastNotAvailable;
}
return (
<Box
bg={bgCard}
p={4}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
mt={4}
>
<VStack spacing={2} align="center">
<Icon as={FaExclamationTriangle} boxSize={6} color="orange.400" />
<Text fontSize="sm" color="gray.500" textAlign="center">
{errorMessage}
</Text>
</VStack>
</Box>
);
}
const { weather, closest_hour } = weatherData;
const currentIcon = getWeatherIcon(weather.current.condition.text, weather.current.is_day);
const matchIcon = getWeatherIcon(closest_hour.condition.text, closest_hour.is_day);
return (
<Box
bg={bgCard}
p={4}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
mt={4}
>
<VStack spacing={4} align="stretch">
{/* Header */}
<HStack spacing={2} align="center">
<Icon as={FaCloud} boxSize={4} color="blue.500" />
<Text fontSize="sm" fontWeight="bold">
{t.forecast}
</Text>
<Badge fontSize="xs" colorScheme="blue" variant="subtle">
{weather.location.name}
</Badge>
</HStack>
<Divider />
{/* Current Weather */}
<HStack spacing={3} align="center">
<Icon as={currentIcon} boxSize={8} color="blue.400" />
<VStack spacing={1} align="start" flex={1}>
<Text fontSize="lg" fontWeight="bold">
{Math.round(weather.current.temp_c)}°C
</Text>
<Text fontSize="xs" color={textColor}>
{t.feelsLike} {Math.round(weather.current.feelslike_c)}°C
</Text>
</VStack>
<VStack spacing={1} align="end">
<Text fontSize="xs" color={textColor}>{weather.current.condition.text}</Text>
{(closest_hour.will_it_rain > 0 || closest_hour.chance_of_rain > 0) && (
<Badge size="xs" colorScheme="blue">
{t.chanceOfRain}: {closest_hour.chance_of_rain}%
</Badge>
)}
{(closest_hour.will_it_snow > 0 || closest_hour.chance_of_snow > 0) && (
<Badge size="xs" colorScheme="cyan">
{t.chanceOfSnow}: {closest_hour.chance_of_snow}%
</Badge>
)}
</VStack>
</HStack>
{/* Weather at Match Time */}
<Box>
<Text fontSize="xs" fontWeight="bold" color={textColor} mb={2}>
{t.atMatchTime}
</Text>
<HStack spacing={3} align="center" bg={useColorModeValue('gray.50', 'gray.700')} p={3} borderRadius="md">
<Icon as={matchIcon} boxSize={6} color="blue.400" />
<VStack spacing={1} align="start" flex={1}>
<Text fontSize="md" fontWeight="bold">
{Math.round(closest_hour.temp_c)}°C
</Text>
<Text fontSize="xs" color={textColor}>
{closest_hour.condition.text}
</Text>
</VStack>
<HStack spacing={4} fontSize="xs" color={textColor}>
<HStack spacing={1}>
<Icon as={FaWind} boxSize={3} />
<Text>{closest_hour.wind_kph} km/h</Text>
</HStack>
<HStack spacing={1}>
<Icon as={FaTint} boxSize={3} />
<Text>{closest_hour.humidity}%</Text>
</HStack>
</HStack>
</HStack>
</Box>
{/* Additional Details */}
<SimpleGrid columns={2} spacing={2} fontSize="xs">
<HStack spacing={2}>
<Icon as={FaWind} boxSize={3} color={textColor} />
<Text color={textColor}>{t.wind}: {weather.current.wind_kph} km/h {getWindDirection(weather.current.wind_degree)}</Text>
</HStack>
<HStack spacing={2}>
<Icon as={FaTint} boxSize={3} color={textColor} />
<Text color={textColor}>{t.humidity}: {weather.current.humidity}%</Text>
</HStack>
<HStack spacing={2}>
<Icon as={FaEye} boxSize={3} color={textColor} />
<Text color={textColor}>{t.visibility}: {weather.current.vis_km} km</Text>
</HStack>
<HStack spacing={2}>
<Icon as={FaThermometerHalf} boxSize={3} color={textColor} />
<Text color={textColor}>{t.pressure}: {weather.current.pressure_mb} hPa</Text>
</HStack>
</SimpleGrid>
</VStack>
</Box>
);
};
export default MatchWeather;
@@ -0,0 +1,65 @@
import React, { useState } from 'react';
import { Button, Box, VStack, Text, Icon } from '@chakra-ui/react';
import { FaCloud } from 'react-icons/fa';
import { MatchWeather } from './MatchWeather';
interface MatchWeatherLazyProps {
matchDateTime: string;
venue?: string;
isHomeMatch: boolean;
matchHasStarted: boolean;
}
export const MatchWeatherLazy: React.FC<MatchWeatherLazyProps> = ({
matchDateTime,
venue,
isHomeMatch,
matchHasStarted
}) => {
const [showWeather, setShowWeather] = useState(false);
// Only show weather for home matches that haven't started yet
if (!isHomeMatch || matchHasStarted) {
return null;
}
if (!showWeather) {
return (
<Box
bg="white"
p={4}
borderRadius="lg"
borderWidth="1px"
borderColor="gray.200"
mt={4}
>
<VStack spacing={3} align="center">
<Icon as={FaCloud} boxSize={8} color="blue.400" />
<Text fontSize="sm" color="gray.600" textAlign="center">
Zobrazit předpověď počasí
</Text>
<Button
size="sm"
colorScheme="blue"
variant="outline"
onClick={() => setShowWeather(true)}
>
Zobrazit počasí
</Button>
</VStack>
</Box>
);
}
return (
<MatchWeather
matchDateTime={matchDateTime}
venue={undefined} // Don't pass venue for home matches - let backend use club location
isHomeMatch={isHomeMatch}
matchHasStarted={matchHasStarted}
delayLoad={false}
/>
);
};
export default MatchWeatherLazy;
@@ -0,0 +1,34 @@
import React, { useEffect } from 'react';
import { useConfirmDialog } from '../../contexts/ConfirmDialogContext';
const ServiceWorkerUpdateListener: React.FC = () => {
const { confirm } = useConfirmDialog();
useEffect(() => {
const handler = (event: Event) => {
const ev = event as CustomEvent<{ registration?: ServiceWorkerRegistration }>;
const registration = ev.detail?.registration;
if (!registration) return;
(async () => {
const ok = await confirm({
title: 'Aktualizace aplikace',
message: 'Nová verze aplikace je k dispozici. Chcete aktualizovat?',
confirmText: 'Aktualizovat',
cancelText: 'Později',
});
if (ok && registration.waiting) {
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
}
})();
};
window.addEventListener('sw-update-available', handler as EventListener);
return () => {
window.removeEventListener('sw-update-available', handler as EventListener);
};
}, [confirm]);
return null;
};
export default ServiceWorkerUpdateListener;
+32 -11
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { Image, ImageProps, Skeleton } from '@chakra-ui/react';
import { getTeamLogo } from '../../utils/sportLogosAPI';
import { getLogoStyle, getLogoClassName } from '../../utils/logoUtils';
@@ -10,7 +10,7 @@ import { useIntersectionObserver } from '../../hooks/useIntersectionObserver';
let __teamOverridesCache: { ts: number; data: { by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> } } | null = null;
const loadTeamOverrides = async (): Promise<{ by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> }> => {
const now = Date.now();
const TTL = 5_000;
const TTL = 30_000; // Extended to 30 seconds to reduce frequent requests
if (__teamOverridesCache && now - __teamOverridesCache.ts < TTL) {
return __teamOverridesCache.data || {};
}
@@ -101,11 +101,17 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
const [error, setError] = useState(false);
const { data: publicSettings } = usePublicSettings();
const [observeRef, inView] = useIntersectionObserver({ threshold: 0.01, rootMargin: '150px 0px', freezeOnceVisible: true });
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (!inView) return; // defer fetching until visible
let mounted = true;
// Clear any existing timeout
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
const fetchLogo = async () => {
try {
setLoading(true);
@@ -114,11 +120,13 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
let overrides: { by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> } = {} as any;
try { overrides = await loadTeamOverrides(); } catch {}
// Prefer local club logo for own team when IDs match
const currentClubId = publicSettings?.club_id;
const currentClubLogoUrl = publicSettings?.club_logo_url;
if (
teamId && publicSettings?.club_id && String(teamId) === String(publicSettings.club_id) && publicSettings?.club_logo_url
teamId && currentClubId && String(teamId) === String(currentClubId) && currentClubLogoUrl
) {
if (mounted) {
setLogoUrl(assetUrl(publicSettings.club_logo_url) || publicSettings.club_logo_url);
setLogoUrl(assetUrl(currentClubLogoUrl) || currentClubLogoUrl);
}
} else if (teamId && overrides?.by_id?.[teamId]?.logo_url) {
const v = overrides.by_id[teamId]!.logo_url as string;
@@ -180,7 +188,7 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
if (mounted) {
setError(true);
// Fallback to FACR or placeholder
setLogoUrl(facrLogo || '/logo192.png');
setLogoUrl(facrLogo || '/logo-placeholder.svg');
}
} finally {
if (mounted) {
@@ -188,13 +196,19 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
}
}
};
fetchLogo();
// Debounce the fetch to prevent rapid successive calls
debounceTimeoutRef.current = setTimeout(() => {
fetchLogo();
}, 100); // 100ms debounce
return () => {
mounted = false;
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
};
}, [teamId, teamName, facrLogo, publicSettings?.club_id, publicSettings?.club_logo_url, inView]);
}, [teamId, teamName, facrLogo, inView]);
// Size mapping
const sizeMap = {
@@ -233,7 +247,7 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
return (
<div ref={observeRef as any} style={{ display: 'inline-block' }}>
<Image
src={(assetUrl(logoUrl || undefined) || logoUrl || '/logo192.png')}
src={(assetUrl(logoUrl || undefined) || logoUrl || '/logo-placeholder.svg')}
alt={alt || teamName || 'Team logo'}
decoding="async"
draggable={false}
@@ -250,7 +264,14 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
onError={() => {
if (!error) {
setError(true);
setLogoUrl(facrLogo || '/logo192.png');
// If current URL is a local upload that failed, try fotbal.cz fallback
if (logoUrl && logoUrl.startsWith('/uploads/logos/facr/') && teamId) {
const fotbalCzUrl = `https://is1.fotbal.cz/media/kluby/${teamId}/${teamId}_crop.jpg`;
setLogoUrl(fotbalCzUrl);
setError(false); // Reset error to try the new URL
} else {
setLogoUrl(facrLogo || '/logo-placeholder.svg');
}
}
}}
/>
@@ -0,0 +1,407 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Box,
VStack,
HStack,
Text,
Icon,
Button,
Input,
InputGroup,
InputLeftElement,
Progress,
IconButton,
useColorModeValue,
useToast,
Flex,
Stack,
Collapse,
} from '@chakra-ui/react';
import {
FiUploadCloud,
FiFile,
FiImage,
FiFileText,
FiX,
FiLink2,
FiCheckCircle,
FiAlertCircle,
} from 'react-icons/fi';
import { uploadFile as defaultUploadFile } from '../../services/articles';
export type UploadPanelFile = {
url: string;
name: string;
type: string;
size: number;
};
export interface UploadPanelProps {
label?: string;
description?: string;
value?: UploadPanelFile[];
onChange?: (files: UploadPanelFile[]) => void;
accept?: string;
maxFiles?: number;
maxFileSizeMB?: number;
multiple?: boolean;
uploadFn?: (file: File) => Promise<UploadPanelFile>;
allowUrlImport?: boolean;
onUrlImport?: (url: string) => void | Promise<void>;
urlPlaceholder?: string;
onUploadFinished?: (uploadedFiles: UploadPanelFile[], allFiles: UploadPanelFile[]) => void;
}
const DEFAULT_MAX_MB = 10;
const UploadPanel: React.FC<UploadPanelProps> = ({
label = 'Nahrát soubory',
description = 'Vyberte soubory k nahrání nebo je přetáhněte do oblasti níže.',
value,
onChange,
accept = 'image/*,application/pdf',
maxFiles = 10,
maxFileSizeMB = DEFAULT_MAX_MB,
multiple = true,
uploadFn,
allowUrlImport = true,
onUrlImport,
urlPlaceholder = 'Vložit URL souboru',
onUploadFinished,
}) => {
const [files, setFiles] = useState<UploadPanelFile[]>(() => value || []);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [urlInput, setUrlInput] = useState('');
const fileInputRef = useRef<HTMLInputElement | null>(null);
const toast = useToast();
const borderColor = useColorModeValue('gray.200', 'gray.700');
const bg = useColorModeValue('white', 'gray.800');
const dropBg = useColorModeValue('gray.50', 'gray.900');
const listHoverBg = useColorModeValue('gray.50', 'gray.900');
useEffect(() => {
if (value) {
setFiles(value);
}
}, [value]);
const getFileIcon = (type: string) => {
if (type.startsWith('image/')) return FiImage;
if (type.includes('pdf')) return FiFileText;
return FiFile;
};
const formatSize = (bytes: number) => {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Math.round((bytes / Math.pow(k, i)) * 100) / 100} ${sizes[i]}`;
};
const emitChange = useCallback(
(next: UploadPanelFile[]) => {
setFiles(next);
if (onChange) onChange(next);
},
[onChange]
);
const doUpload = useCallback(
async (file: File): Promise<UploadPanelFile> => {
if (uploadFn) {
return uploadFn(file);
}
const res = await defaultUploadFile(file as any);
return {
url: res.url,
name: file.name,
type: file.type || res.type || 'application/octet-stream',
size: file.size || res.size || 0,
};
},
[uploadFn]
);
const handleFiles = useCallback(
async (selected: FileList | File[]) => {
const list = Array.from(selected || []);
if (!list.length) return;
if (files.length + list.length > maxFiles) {
toast({
title: 'Příliš mnoho souborů',
description: `Můžete nahrát maximálně ${maxFiles} souborů`,
status: 'warning',
});
return;
}
const maxBytes = maxFileSizeMB * 1024 * 1024;
const validFiles = list.filter((f) => {
if (f.size > maxBytes) {
toast({
title: 'Soubor je příliš velký',
description: `${f.name} překračuje limit ${maxFileSizeMB} MB`,
status: 'error',
});
return false;
}
return true;
});
if (!validFiles.length) return;
setUploading(true);
setProgress(0);
const newlyUploaded: UploadPanelFile[] = [];
const total = validFiles.length;
try {
for (let i = 0; i < validFiles.length; i += 1) {
const f = validFiles[i];
try {
const uf = await doUpload(f);
newlyUploaded.push(uf);
} catch (err: any) {
toast({
title: 'Chyba při nahrávání',
description: err?.message || f.name,
status: 'error',
});
}
setProgress(((i + 1) / total) * 100);
}
if (newlyUploaded.length) {
const next = [...files, ...newlyUploaded];
emitChange(next);
if (onUploadFinished) {
onUploadFinished(newlyUploaded, next);
}
toast({
title: 'Nahrávání dokončeno',
description: `${newlyUploaded.length} souborů bylo nahráno`,
status: 'success',
});
}
} finally {
setUploading(false);
setProgress(0);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
},
[doUpload, emitChange, files, maxFileSizeMB, maxFiles, onUploadFinished, toast]
);
const onDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (e.dataTransfer?.files?.length) {
handleFiles(e.dataTransfer.files);
}
};
const onDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (!isDragging) setIsDragging(true);
};
const onDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const removeFile = (idx: number) => {
const next = files.filter((_, i) => i !== idx);
emitChange(next);
};
const handleUrlSubmit = async () => {
const url = urlInput.trim();
if (!url) return;
try {
if (onUrlImport) {
await onUrlImport(url);
}
const next: UploadPanelFile[] = [
...files,
{
url,
name: url,
type: 'text/url',
size: 0,
},
];
emitChange(next);
setUrlInput('');
toast({ title: 'URL přidána', status: 'success', duration: 2000 });
} catch (err: any) {
toast({
title: 'Nelze přidat URL',
description: err?.message || 'Zkontrolujte odkaz a zkuste to znovu.',
status: 'error',
});
}
};
return (
<Box
borderWidth="1px"
borderRadius="xl"
borderColor={borderColor}
bg={bg}
p={4}
>
<VStack align="stretch" spacing={4}>
<HStack justify="space-between" align="flex-start">
<Box>
<HStack spacing={2} mb={1}>
<Icon as={FiUploadCloud} color="blue.500" />
<Text fontWeight="semibold">{label}</Text>
</HStack>
<Text fontSize="sm" color="gray.500">
{description}
</Text>
</Box>
{files.length > 0 && (
<HStack spacing={1} align="center" color="gray.500" fontSize="xs">
<Icon as={FiCheckCircle} color="green.400" />
<Text>{files.length} souborů</Text>
</HStack>
)}
</HStack>
<Box
borderWidth="1px"
borderRadius="lg"
borderStyle={isDragging ? 'solid' : 'dashed'}
borderColor={isDragging ? 'blue.400' : borderColor}
bg={isDragging ? dropBg : 'transparent'}
p={6}
textAlign="center"
cursor="pointer"
transition="all 0.15s ease-out"
onClick={() => fileInputRef.current?.click()}
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
>
<VStack spacing={2}>
<Icon as={FiUploadCloud} boxSize={7} color="blue.400" />
<Text fontWeight="medium">Zvolte soubor nebo jej přetáhněte sem</Text>
<Text fontSize="xs" color="gray.500">
Podporované formáty: {accept || 'soubor'} Limit {maxFileSizeMB} MB / soubor
</Text>
<Button size="sm" variant="outline" leftIcon={<FiUploadCloud />}>
Vybrat soubor
</Button>
</VStack>
<input
ref={fileInputRef}
type="file"
multiple={multiple}
accept={accept}
style={{ display: 'none' }}
onChange={(e) => {
if (e.target.files) handleFiles(e.target.files);
}}
/>
</Box>
<Collapse in={uploading || progress > 0} animateOpacity>
{uploading && (
<HStack spacing={2} align="center">
<Icon as={FiAlertCircle} color="blue.400" />
<Text fontSize="sm">Nahrávám soubory</Text>
</HStack>
)}
{progress > 0 && (
<Progress mt={2} value={progress} size="sm" colorScheme="blue" borderRadius="full" />
)}
</Collapse>
{files.length > 0 && (
<Box borderTopWidth="1px" borderColor={borderColor} pt={3}>
<Stack spacing={2}>
{files.map((f, idx) => {
const IconComp = getFileIcon(f.type || '');
return (
<Flex
key={`${f.url}-${idx}`}
align="center"
justify="space-between"
p={2}
borderRadius="md"
_hover={{ bg: listHoverBg }}
>
<HStack spacing={3} flex={1} minW={0}>
<Icon as={IconComp} boxSize={5} color="blue.500" />
<Box flex={1} minW={0}>
<Text fontSize="sm" noOfLines={1}>
{f.name || f.url}
</Text>
<HStack spacing={2} fontSize="xs" color="gray.500">
{f.size ? <Text>{formatSize(f.size)}</Text> : null}
<Text>{f.type}</Text>
</HStack>
</Box>
</HStack>
<IconButton
aria-label="Odebrat soubor"
icon={<FiX />}
size="xs"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
removeFile(idx);
}}
/>
</Flex>
);
})}
</Stack>
</Box>
)}
{allowUrlImport && (
<Box pt={2} borderTopWidth={files.length ? '1px' : '0'} borderColor={borderColor}>
<Text fontSize="xs" color="gray.500" mb={1}>
nebo importujte z URL odkazu
</Text>
<HStack spacing={2}>
<InputGroup>
<InputLeftElement pointerEvents="none">
<Icon as={FiLink2} color="gray.400" />
</InputLeftElement>
<Input
size="sm"
placeholder={urlPlaceholder}
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleUrlSubmit();
}
}}
/>
</InputGroup>
<Button size="sm" onClick={handleUrlSubmit} isDisabled={!urlInput.trim()}>
Přidat
</Button>
</HStack>
</Box>
)}
</VStack>
</Box>
);
};
export default UploadPanel;
@@ -0,0 +1,33 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text, Button, VStack } from '@chakra-ui/react';
export const I18nTest = () => {
const { t, i18n } = useTranslation();
// Debug: Check if translation function works
console.log('I18nTest: t function type:', typeof t);
console.log('I18nTest: Sample translation:', t('common.welcome_message'));
console.log('I18nTest: i18n has resource:', i18n.hasResourceBundle('en', 'translation'));
console.log('I18nTest: i18n data:', i18n.getResourceBundle('en', 'translation'));
const toggleLanguage = () => {
const newLang = i18n.language === 'cs' ? 'en' : 'cs';
i18n.changeLanguage(newLang);
};
return (
<Box p={4} borderWidth={1} borderRadius="md" mb={4}>
<VStack spacing={2} align="start">
<Text fontWeight="bold">i18n Test Component</Text>
<Text>Current language: {i18n.language}</Text>
<Text>Welcome message: {t('common.welcome_message')}</Text>
<Text>Nav home: {t('nav.home')}</Text>
<Text>Nav matches: {t('nav.matches')}</Text>
<Button onClick={toggleLanguage} size="sm">
Switch to {i18n.language === 'cs' ? 'English' : 'Čeština'}
</Button>
</VStack>
</Box>
);
};
@@ -24,6 +24,7 @@ import {
useColorModeValue,
} from '@chakra-ui/react';
import { FiRefreshCw } from 'react-icons/fi';
import ColorPicker from '../common/ColorPicker';
interface AdvancedStyleControlsProps {
elementName: string;
@@ -120,25 +121,11 @@ const AdvancedStyleControls: React.FC<AdvancedStyleControlsProps> = ({
<VStack spacing={3}>
<FormControl>
<FormLabel fontSize="xs">Background Color</FormLabel>
<HStack>
<Input
type="color"
value={settings.backgroundColor || '#ffffff'}
onChange={(e) => updateSetting('backgroundColor', e.target.value)}
size="sm"
w="60px"
h="40px"
p={1}
cursor="pointer"
/>
<Input
value={settings.backgroundColor || '#ffffff'}
onChange={(e) => updateSetting('backgroundColor', e.target.value)}
size="sm"
placeholder="#ffffff"
flex={1}
/>
</HStack>
<ColorPicker
value={settings.backgroundColor || '#ffffff'}
onChange={(hex) => updateSetting('backgroundColor', hex)}
recentStorageKey={`editor-background-${elementName}`}
/>
</FormControl>
{/* Color Presets */}
@@ -206,25 +193,11 @@ const AdvancedStyleControls: React.FC<AdvancedStyleControlsProps> = ({
<FormControl>
<FormLabel fontSize="xs">Border Color</FormLabel>
<HStack>
<Input
type="color"
value={settings.borderColor || '#000000'}
onChange={(e) => updateSetting('borderColor', e.target.value)}
size="sm"
w="60px"
h="40px"
p={1}
cursor="pointer"
/>
<Input
value={settings.borderColor || '#000000'}
onChange={(e) => updateSetting('borderColor', e.target.value)}
size="sm"
placeholder="#000000"
flex={1}
/>
</HStack>
<ColorPicker
value={settings.borderColor || '#000000'}
onChange={(hex) => updateSetting('borderColor', hex)}
recentStorageKey={`editor-border-${elementName}`}
/>
</FormControl>
<FormControl>
@@ -108,7 +108,7 @@ const SUPPORTED_HOME_VARIANTS: Record<string, string[]> = {
hero: ['grid', 'scroller', 'swiper', 'swiper_full'],
news: ['grid_one', 'grid_two', 'grid', 'list', 'scroller'],
matches: ['compact'],
sponsors: ['grid', 'slider', 'scroller', 'pyramid'],
sponsors: ['grid', 'slider', 'scroller', 'logos3', 'pyramid'],
gallery: ['grid'],
videos: ['grid', 'carousel'],
merch: ['grid'],
@@ -1685,6 +1685,55 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const viewportConfig = useMemo(() => getViewportConfig(), [getViewportConfig]);
// Apply viewport width changes with smooth transitions - REAL WIDTH CONSTRAINTS
useEffect(() => {
if (!isEditing) return;
const wrapper = safeDOM.querySelector('.myuibrix-viewport-wrapper') as HTMLElement;
if (!wrapper) return;
// Apply actual width constraints without scaling for real responsive behavior
wrapper.style.width = '100%';
if (viewport === 'mobile') {
wrapper.style.maxWidth = '420px';
} else if (viewport === 'tablet') {
wrapper.style.maxWidth = '820px';
} else {
wrapper.style.maxWidth = '100%';
}
wrapper.style.transition = 'all 0.3s ease';
wrapper.style.margin = '0 auto';
wrapper.style.transform = 'none';
wrapper.style.transformOrigin = '';
// Add visual indicator for non-desktop viewports only
if (viewport !== 'desktop') {
wrapper.style.border = `3px solid ${primaryColor}`;
wrapper.style.boxShadow = `0 0 0 9999px rgba(0,0,0,0.25), 0 8px 32px rgba(0,0,0,0.2)`;
wrapper.style.marginTop = '20px';
wrapper.style.marginBottom = '20px';
wrapper.style.minHeight = 'calc(100vh - 100px)';
} else {
wrapper.style.border = 'none';
wrapper.style.boxShadow = 'none';
wrapper.style.marginTop = '0';
wrapper.style.marginBottom = '0';
wrapper.style.minHeight = '100vh';
}
// Show toast notification when changing viewport
const label = viewport === 'mobile' ? 'Mobile' : viewport === 'tablet' ? 'Tablet' : 'Desktop';
const desc = viewport === 'mobile' ? 'Šířka ~420px' : viewport === 'tablet' ? 'Šířka ~820px' : 'Zobrazení na plnou šířku (100%)';
toast({
title: `Viewport: ${label}`,
description: desc,
status: 'info',
duration: 1500,
isClosable: true,
position: 'bottom-right',
});
}, [isEditing, viewport, primaryColor, toast, viewportConfig]);
// Prevent all clicks on page content during edit mode
useEffect(() => {
if (!isEditing) return;
@@ -1777,55 +1826,6 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
};
}, [isEditing]);
// Apply viewport width changes with smooth transitions - REAL WIDTH CONSTRAINTS
useEffect(() => {
if (!isEditing) return;
const wrapper = safeDOM.querySelector('.myuibrix-viewport-wrapper') as HTMLElement;
if (!wrapper) return;
// Apply actual width constraints without scaling for real responsive behavior
wrapper.style.width = '100%';
if (viewport === 'mobile') {
wrapper.style.maxWidth = '420px';
} else if (viewport === 'tablet') {
wrapper.style.maxWidth = '820px';
} else {
wrapper.style.maxWidth = '100%';
}
wrapper.style.transition = 'all 0.3s ease';
wrapper.style.margin = '0 auto';
wrapper.style.transform = 'none';
wrapper.style.transformOrigin = '';
// Add visual indicator for non-desktop viewports only
if (viewport !== 'desktop') {
wrapper.style.border = `3px solid ${primaryColor}`;
wrapper.style.boxShadow = `0 0 0 9999px rgba(0,0,0,0.25), 0 8px 32px rgba(0,0,0,0.2)`;
wrapper.style.marginTop = '20px';
wrapper.style.marginBottom = '20px';
wrapper.style.minHeight = 'calc(100vh - 100px)';
} else {
wrapper.style.border = 'none';
wrapper.style.boxShadow = 'none';
wrapper.style.marginTop = '0';
wrapper.style.marginBottom = '0';
wrapper.style.minHeight = '100vh';
}
// Show toast notification when changing viewport
const label = viewport === 'mobile' ? 'Mobile' : viewport === 'tablet' ? 'Tablet' : 'Desktop';
const desc = viewport === 'mobile' ? 'Šířka ~420px' : viewport === 'tablet' ? 'Šířka ~820px' : 'Zobrazení na plnou šířku (100%)';
toast({
title: `Viewport: ${label}`,
description: desc,
status: 'info',
duration: 1500,
isClosable: true,
position: 'bottom-right',
});
}, [isEditing, viewport, primaryColor, toast, viewportConfig]);
// Early return if not admin (after all hooks)
if (!isAdmin) return null;
@@ -1834,7 +1834,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
{/* Edit Mode - Active */}
{isEditing && (
<>
{/* Top Control Bar - Viewport Switcher & Actions */}
{/* Top Control Bar - Actions */}
<Box
className="myuibrix-toolbar"
position="fixed"
@@ -1,414 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import Frame from 'react-frame-component';
import { Box, HStack, IconButton, Text, Tooltip, VStack, Badge } from '@chakra-ui/react';
import { FiMonitor, FiTablet, FiSmartphone, FiRotateCw, FiMaximize2 } from 'react-icons/fi';
export interface DevicePreset {
name: string;
width: number;
height: number;
userAgent: string;
icon: React.ReactElement;
category: 'mobile' | 'tablet' | 'desktop';
}
export const DEVICE_PRESETS: Record<string, DevicePreset> = {
// Mobile devices
iphone_se: {
name: 'iPhone SE',
width: 375,
height: 667,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15',
icon: <FiSmartphone />,
category: 'mobile',
},
iphone_14: {
name: 'iPhone 14 Pro',
width: 393,
height: 852,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
icon: <FiSmartphone />,
category: 'mobile',
},
pixel_7: {
name: 'Pixel 7',
width: 412,
height: 915,
userAgent: 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36',
icon: <FiSmartphone />,
category: 'mobile',
},
samsung_s23: {
name: 'Samsung S23',
width: 360,
height: 800,
userAgent: 'Mozilla/5.0 (Linux; Android 13; SM-S911B) AppleWebKit/537.36',
icon: <FiSmartphone />,
category: 'mobile',
},
// Tablets
ipad_mini: {
name: 'iPad Mini',
width: 768,
height: 1024,
userAgent: 'Mozilla/5.0 (iPad; CPU OS 15_0 like Mac OS X) AppleWebKit/605.1.15',
icon: <FiTablet />,
category: 'tablet',
},
ipad_air: {
name: 'iPad Air',
width: 820,
height: 1180,
userAgent: 'Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
icon: <FiTablet />,
category: 'tablet',
},
ipad_pro: {
name: 'iPad Pro 12.9"',
width: 1024,
height: 1366,
userAgent: 'Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
icon: <FiTablet />,
category: 'tablet',
},
// Desktop
desktop_1080: {
name: 'Desktop 1080p',
width: 1920,
height: 1080,
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
icon: <FiMonitor />,
category: 'desktop',
},
desktop_1440: {
name: 'Desktop 1440p',
width: 2560,
height: 1440,
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
icon: <FiMonitor />,
category: 'desktop',
},
laptop: {
name: 'Laptop',
width: 1366,
height: 768,
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
icon: <FiMonitor />,
category: 'desktop',
},
};
interface ViewportSimulatorProps {
children: React.ReactNode;
defaultDevice?: string;
showControls?: boolean;
customCSS?: string;
onDeviceChange?: (device: DevicePreset) => void;
}
const ViewportSimulator: React.FC<ViewportSimulatorProps> = ({
children,
defaultDevice = 'desktop_1080',
showControls = true,
customCSS = '',
onDeviceChange,
}) => {
const [currentDevice, setCurrentDevice] = useState<string>(defaultDevice);
const [isPortrait, setIsPortrait] = useState(true);
const [scale, setScale] = useState(1);
const containerRef = useRef<HTMLDivElement>(null);
const frameRef = useRef<HTMLIFrameElement>(null);
const device = DEVICE_PRESETS[currentDevice];
const width = isPortrait ? device.width : device.height;
const height = isPortrait ? device.height : device.width;
// Auto-scale to fit container
useEffect(() => {
if (!containerRef.current) return;
const updateScale = () => {
const container = containerRef.current;
if (!container) return;
const containerWidth = container.clientWidth;
const containerHeight = container.clientHeight - (showControls ? 80 : 0);
// Calculate scale to fit both width and height
const scaleX = containerWidth / (width + 40); // +40 for padding/borders
const scaleY = containerHeight / (height + 40);
const newScale = Math.min(scaleX, scaleY, 1); // Don't scale up, only down
setScale(newScale);
};
updateScale();
const resizeObserver = new ResizeObserver(updateScale);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => resizeObserver.disconnect();
}, [width, height, showControls]);
// Change device
const changeDevice = (deviceKey: string) => {
setCurrentDevice(deviceKey);
setIsPortrait(true);
if (onDeviceChange) {
onDeviceChange(DEVICE_PRESETS[deviceKey]);
}
};
// Rotate device
const rotateDevice = () => {
setIsPortrait(!isPortrait);
};
// Reset to full width
const resetToDesktop = () => {
changeDevice('desktop_1080');
};
// Inject CSS into iframe
const frameContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=${width}, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<style>
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Import parent styles if needed */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
background: #ffffff;
color: #1a202c;
}
/* Responsive breakpoints - REAL media queries */
@media (max-width: 767px) {
/* Mobile styles */
.container {
padding: 16px !important;
}
}
@media (min-width: 768px) and (max-width: 1023px) {
/* Tablet styles */
.container {
padding: 24px !important;
}
}
@media (min-width: 1024px) {
/* Desktop styles */
.container {
padding: 32px !important;
}
}
/* Custom CSS injection */
${customCSS}
</style>
</head>
<body>
<div id="frame-root"></div>
</body>
</html>
`;
return (
<VStack
ref={containerRef}
width="100%"
height="100%"
spacing={0}
align="stretch"
bg="gray.50"
position="relative"
>
{/* Controls */}
{showControls && (
<HStack
spacing={2}
p={3}
bg="white"
borderBottom="1px solid"
borderColor="gray.200"
flexWrap="wrap"
justify="space-between"
>
<HStack spacing={2} flexWrap="wrap">
{/* Mobile */}
<Tooltip label="iPhone SE">
<IconButton
aria-label="iPhone SE"
icon={<FiSmartphone />}
size="sm"
colorScheme={currentDevice === 'iphone_se' ? 'blue' : 'gray'}
variant={currentDevice === 'iphone_se' ? 'solid' : 'outline'}
onClick={() => changeDevice('iphone_se')}
/>
</Tooltip>
<Tooltip label="iPhone 14 Pro">
<IconButton
aria-label="iPhone 14 Pro"
icon={<FiSmartphone />}
size="sm"
colorScheme={currentDevice === 'iphone_14' ? 'blue' : 'gray'}
variant={currentDevice === 'iphone_14' ? 'solid' : 'outline'}
onClick={() => changeDevice('iphone_14')}
/>
</Tooltip>
{/* Tablet */}
<Tooltip label="iPad Mini">
<IconButton
aria-label="iPad Mini"
icon={<FiTablet />}
size="sm"
colorScheme={currentDevice === 'ipad_mini' ? 'blue' : 'gray'}
variant={currentDevice === 'ipad_mini' ? 'solid' : 'outline'}
onClick={() => changeDevice('ipad_mini')}
/>
</Tooltip>
<Tooltip label="iPad Air">
<IconButton
aria-label="iPad Air"
icon={<FiTablet />}
size="sm"
colorScheme={currentDevice === 'ipad_air' ? 'blue' : 'gray'}
variant={currentDevice === 'ipad_air' ? 'solid' : 'outline'}
onClick={() => changeDevice('ipad_air')}
/>
</Tooltip>
{/* Desktop */}
<Tooltip label="Laptop">
<IconButton
aria-label="Laptop"
icon={<FiMonitor />}
size="sm"
colorScheme={currentDevice === 'laptop' ? 'blue' : 'gray'}
variant={currentDevice === 'laptop' ? 'solid' : 'outline'}
onClick={() => changeDevice('laptop')}
/>
</Tooltip>
<Tooltip label="Desktop 1080p">
<IconButton
aria-label="Desktop"
icon={<FiMonitor />}
size="sm"
colorScheme={currentDevice === 'desktop_1080' ? 'blue' : 'gray'}
variant={currentDevice === 'desktop_1080' ? 'solid' : 'outline'}
onClick={() => changeDevice('desktop_1080')}
/>
</Tooltip>
{/* Rotate */}
{device.category !== 'desktop' && (
<Tooltip label="Otočit zařízení">
<IconButton
aria-label="Rotate"
icon={<FiRotateCw />}
size="sm"
onClick={rotateDevice}
colorScheme="purple"
variant="outline"
/>
</Tooltip>
)}
{/* Reset */}
<Tooltip label="Resetovat na desktop">
<IconButton
aria-label="Reset"
icon={<FiMaximize2 />}
size="sm"
onClick={resetToDesktop}
variant="ghost"
/>
</Tooltip>
</HStack>
<HStack spacing={3}>
<Badge colorScheme="blue" fontSize="xs">
{device.name}
</Badge>
<Text fontSize="xs" color="gray.600">
{width} × {height}px
</Text>
<Text fontSize="xs" color="gray.500">
{(scale * 100).toFixed(0)}%
</Text>
</HStack>
</HStack>
)}
{/* Viewport Frame */}
<Box
flex={1}
display="flex"
alignItems="center"
justifyContent="center"
p={4}
overflow="auto"
position="relative"
>
<Box
style={{
width: `${width}px`,
height: `${height}px`,
transform: `scale(${scale})`,
transformOrigin: 'top center',
transition: 'all 0.3s ease',
}}
boxShadow="0 10px 40px rgba(0,0,0,0.2)"
borderRadius="8px"
overflow="hidden"
bg="white"
position="relative"
>
<Frame
ref={frameRef}
initialContent={frameContent}
style={{
width: '100%',
height: '100%',
border: 'none',
display: 'block',
}}
mountTarget="#frame-root"
>
{children}
</Frame>
</Box>
</Box>
</VStack>
);
};
export default ViewportSimulator;
@@ -35,6 +35,7 @@ import ColumnLayoutManager from './ColumnLayoutManager';
import ContextualAdminLinks from './ContextualAdminLinks';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { FONT_PAIRINGS, loadGoogleFont } from '../../config/fonts';
import ColorPicker from '../common/ColorPicker';
interface VisualStylePanelProps {
elementName: string;
@@ -362,43 +363,21 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
{/* Barva textu */}
<FormControl>
<FormLabel fontSize="xs">Barva textu</FormLabel>
<HStack>
<Input
type="color"
value={styles.color}
onChange={(e) => updateStyle('color', e.target.value)}
size="sm"
w="60px"
p={1}
/>
<Input
value={styles.color}
onChange={(e) => updateStyle('color', e.target.value)}
size="sm"
placeholder="#000000"
/>
</HStack>
<ColorPicker
value={styles.color}
onChange={(hex) => updateStyle('color', hex)}
recentStorageKey={`editor-text-${elementName}`}
/>
</FormControl>
{/* Barva pozadí */}
<FormControl>
<FormLabel fontSize="xs">Barva pozadí</FormLabel>
<HStack>
<Input
type="color"
value={styles.backgroundColor}
onChange={(e) => updateStyle('backgroundColor', e.target.value)}
size="sm"
w="60px"
p={1}
/>
<Input
value={styles.backgroundColor}
onChange={(e) => updateStyle('backgroundColor', e.target.value)}
size="sm"
placeholder="#ffffff"
/>
</HStack>
<ColorPicker
value={styles.backgroundColor}
onChange={(hex) => updateStyle('backgroundColor', hex)}
recentStorageKey={`editor-bg-${elementName}`}
/>
</FormControl>
<Divider my={2} />
@@ -6,11 +6,13 @@ import { useClubTheme } from '../../contexts/ClubThemeContext';
import { getNavigationItems, NavigationItem, seedDefaultNavigation } from '../../services/navigation';
import { getCategories, Category } from '../../services/public';
import { assetUrl } from '../../utils/url';
import { useTranslation } from 'react-i18next';
// Minimal NavLink type used to render items
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
const SpartaNavbar: React.FC = () => {
const { t } = useTranslation();
const { data: settings } = usePublicSettings();
const theme = useClubTheme();
const location = useLocation();
@@ -78,13 +80,38 @@ const SpartaNavbar: React.FC = () => {
};
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',
};
if (item.type === 'dropdown' && item.children && item.children.length > 0) {
link.items = item.children.map(child => ({ label: child.label, to: child.url || '#' }));
link.items = item.children.map(child => ({
label: getTranslatedLabel(child.label),
to: child.url || '#'
}));
}
return link;
};
@@ -114,7 +141,7 @@ const SpartaNavbar: React.FC = () => {
{ label: 'Hráči', to: '/hraci' },
categoryItems.length > 0 ? { label: 'Články', to: '/blog', items: categoryItems } : { label: 'Články', to: '/blog' },
{ label: 'Videa', to: '/videa' },
{ label: settings?.gallery_label || 'Fotogalerie', to: '/galerie' },
{ label: settings?.gallery_label || t('nav.gallery'), to: '/galerie' },
...(settings?.shop_url ? [{ label: 'Fanshop', to: settings.shop_url, external: true } as NavLink] : []),
{ label: 'Sponzoři', to: '/sponzori' },
{ label: 'Kontakt', to: '/kontakt' },
@@ -6,6 +6,7 @@ import { useToast } from '../../hooks/useToast';
import { DataTable, Column } from '../common/DataTable';
import { ToastContainer } from '../common/ToastContainer';
import { exportToCSV, getExportFilename } from '../../utils/export';
import { useConfirmDialog } from '../../contexts/ConfirmDialogContext';
import './ArticleListExample.css';
interface Article {
@@ -54,6 +55,7 @@ export const ArticleListExample: React.FC = () => {
// Toast notifications
const toast = useToast();
const { confirm } = useConfirmDialog();
// Delete mutation
const deleteArticle = useApiDelete<void, { id: number }>((data) => `/articles/${data.id}`);
@@ -92,7 +94,14 @@ export const ArticleListExample: React.FC = () => {
// Handle delete
const handleDelete = async (id: number) => {
if (!window.confirm('Are you sure you want to delete this article?')) return;
const ok = await confirm({
title: 'Delete article',
message: 'Are you sure you want to delete this article?',
confirmText: 'Delete',
cancelText: 'Cancel',
isDanger: true,
});
if (!ok) return;
const result = await deleteArticle.mutate({ id });
if (result !== null) {
@@ -106,7 +115,14 @@ export const ArticleListExample: React.FC = () => {
// Handle batch delete
const handleBatchDelete = async () => {
const count = selection.selectedIds.size;
if (!window.confirm(`Are you sure you want to delete ${count} articles?`)) return;
const ok = await confirm({
title: 'Delete selected articles',
message: `Are you sure you want to delete ${count} articles?`,
confirmText: 'Delete',
cancelText: 'Cancel',
isDanger: true,
});
if (!ok) return;
// In real implementation, you'd call a batch delete API
toast.info(`Would delete ${count} articles`);
+117 -29
View File
@@ -12,8 +12,11 @@ import {
Text,
VStack,
Link,
useColorModeValue,
Icon,
} from '@chakra-ui/react';
import { ExternalLink } from 'lucide-react';
import { ExternalLink, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface PhotoModalProps {
isOpen: boolean;
@@ -30,61 +33,138 @@ const PhotoModal: React.FC<PhotoModalProps> = ({
pageUrl,
albumTitle,
}) => {
const { t } = useTranslation();
const [imageDimensions, setImageDimensions] = React.useState({ width: 0, height: 0 });
const [imageLoaded, setImageLoaded] = React.useState(false);
// Color mode values
const controlsBg = useColorModeValue('white', 'gray.800');
const controlsBorder = useColorModeValue('gray.200', 'gray.600');
const controlsText = useColorModeValue('gray.700', 'gray.200');
const handleImageLoad = (event: React.SyntheticEvent<HTMLImageElement>) => {
const img = event.currentTarget;
setImageDimensions({
width: img.naturalWidth,
height: img.naturalHeight,
});
setImageLoaded(true);
};
// Calculate if close button should be inside or outside based on image aspect ratio
const shouldShowCloseOutside = imageDimensions.width > imageDimensions.height * 1.5;
return (
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
<ModalOverlay bg="blackAlpha.800" backdropFilter="blur(10px)" />
<ModalContent bg="transparent" boxShadow="none" maxW="90vw">
<Modal
isOpen={isOpen}
onClose={onClose}
size="full"
isCentered
scrollBehavior="inside"
>
<ModalOverlay
bg="blackAlpha.900"
backdropFilter="blur(8px)"
/>
{!shouldShowCloseOutside && (
<ModalCloseButton
color="white"
bg="blackAlpha.600"
_hover={{ bg: 'blackAlpha.800' }}
size="lg"
top={2}
right={2}
zIndex={2}
top={4}
right={4}
zIndex={10}
borderRadius="full"
/>
<ModalBody p={0}>
<VStack spacing={4} align="stretch">
{/* Image */}
)}
<ModalContent
bg="transparent"
boxShadow="none"
maxW="100vw"
maxH="100vh"
position="relative"
overflow="hidden"
>
<ModalBody p={0} m={0}>
<VStack spacing={0} align="stretch" h="100vh">
{/* Image Container */}
<Box
position="relative"
borderRadius="lg"
overflow="hidden"
maxH="80vh"
flex={1}
display="flex"
alignItems="center"
justifyContent="center"
position="relative"
minH="60vh"
>
<Image
src={photoUrl}
alt={albumTitle || 'Fotka'}
maxH="80vh"
maxW="100%"
alt={albumTitle || t('gallery.photo_modal_title')}
maxH="85vh"
maxW="95vw"
objectFit="contain"
loading="lazy"
loading="eager"
onLoad={handleImageLoad}
fallback={
<Box
w="400px"
h="300px"
bg="gray.200"
borderRadius="lg"
display="flex"
alignItems="center"
justifyContent="center"
>
<Text color="gray.500">{t('common.loading')}</Text>
</Box>
}
/>
{/* Close button for wide images */}
{shouldShowCloseOutside && imageLoaded && (
<Button
position="absolute"
top={4}
right={4}
color="white"
bg="blackAlpha.600"
_hover={{ bg: 'blackAlpha.800' }}
borderRadius="full"
p={2}
minW="auto"
h="auto"
onClick={onClose}
zIndex={10}
>
<Icon as={X} boxSize={6} />
</Button>
)}
</Box>
{/* Controls */}
<Box
bg="bg.elevated"
borderWidth="1px"
borderColor="border.subtle"
borderRadius="lg"
bg={controlsBg}
borderTop="1px solid"
borderColor={controlsBorder}
p={4}
boxShadow="xl"
boxShadow="0 -4px 6px -1px rgba(0, 0, 0, 0.1)"
>
<VStack spacing={3} align="stretch">
<VStack spacing={3} align="stretch" maxW="800px" mx="auto">
{albumTitle && (
<Text fontSize="md" fontWeight="600" color="gray.700">
<Text
fontSize="md"
fontWeight="600"
color={controlsText}
textAlign="center"
noOfLines={2}
>
{albumTitle}
</Text>
)}
<HStack spacing={2} justify="flex-start" flexWrap="wrap">
<HStack spacing={3} justify="center" flexWrap="wrap">
<Button
as="a"
href={pageUrl}
@@ -93,12 +173,20 @@ const PhotoModal: React.FC<PhotoModalProps> = ({
leftIcon={<ExternalLink size={18} />}
colorScheme="purple"
size="sm"
variant="solid"
>
Zobrazit originál
{t('gallery.view_original')}
</Button>
<Button
onClick={onClose}
colorScheme="gray"
size="sm"
variant="outline"
>
{t('common.close')}
</Button>
</HStack>
{/* Attribution moved into image overlay */}
</VStack>
</Box>
</VStack>
+1 -1
View File
@@ -29,7 +29,7 @@ const BlogCard: React.FC<{ article: Article }> = ({ article }) => {
>
<Box position="relative" overflow="hidden">
<Image
src={assetUrl(article.image_url) || '/logo192.png'}
src={assetUrl(article.image_url) || '/article-placeholder.svg'}
alt={article.title}
objectFit="cover"
w="100%"
+12 -5
View File
@@ -167,10 +167,10 @@ const BlogSwiper: React.FC<BlogSwiperProps> = ({ fallbackArticles = [] }) => {
const { data: featuredData, isLoading: loadingFeatured } = useQuery({
queryKey: ['featured-articles', { page: 1, page_size: 5 }],
queryFn: () => getFeaturedArticles({ page: 1, page_size: 5 }),
refetchOnWindowFocus: true,
refetchOnWindowFocus: false,
refetchOnMount: true,
refetchInterval: 15000,
staleTime: 0,
refetchInterval: 300000,
staleTime: 60000,
});
// Fallback to latest published if no featured are available
const { data: latestData } = useQuery({
@@ -274,7 +274,12 @@ const BlogSwiper: React.FC<BlogSwiperProps> = ({ fallbackArticles = [] }) => {
}
return (
<Box position="relative" w="100%" overflow="hidden">
<Box
position="relative"
w="100%"
overflow="hidden"
minH={{ base: '500px', md: '600px' }}
>
<AnimatePresence initial={false} custom={direction}>
<MotionBox
key={slideIndex}
@@ -298,7 +303,9 @@ const BlogSwiper: React.FC<BlogSwiperProps> = ({ fallbackArticles = [] }) => {
paginate(-1);
}
}}
position="relative"
position="absolute"
top={0}
left={0}
w="100%"
h="100%"
>
@@ -2,16 +2,18 @@ import React from 'react';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { assetUrl } from '../../utils/url';
import { useTranslation } from 'react-i18next';
export type ClubHeroTopbarVariant = 'brand' | 'minimal' | 'badge';
const cls = (...parts: Array<string | false | null | undefined>) => parts.filter(Boolean).join(' ');
const ClubHeroTopbar: React.FC<{ variant?: ClubHeroTopbarVariant; fullBleed?: boolean }>= ({ variant = 'minimal', fullBleed = false }) => {
const { t } = useTranslation();
const { data: settings } = usePublicSettings();
const theme = useClubTheme();
const title = settings?.club_name || theme.name || 'Fotbalový klub';
const tagline = 'Oficiální web klubu';
const tagline = t('homepage.official_website');
const logo = assetUrl(settings?.club_logo_url || theme.logoUrl) || settings?.club_logo_url || theme.logoUrl || '/dist/img/logo-club-empty.svg';
const shopUrl = settings?.shop_url || undefined;
const calendarUrl = '/kalendar';
+15 -13
View File
@@ -16,6 +16,7 @@ import {
Flex,
useColorModeValue,
} from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { TeamLogo } from '../common/TeamLogo';
interface ClubModalProps {
@@ -43,6 +44,7 @@ interface ClubModalProps {
}
const ClubModal: React.FC<ClubModalProps> = ({ isOpen, onClose, club, clubType = 'football' }) => {
const { t } = useTranslation();
if (!club) return null;
// Theme-aware colors
@@ -92,7 +94,7 @@ const ClubModal: React.FC<ClubModalProps> = ({ isOpen, onClose, club, clubType =
</Text>
{club.rank && (
<Badge colorScheme="blue" fontSize="sm">
{club.rank}. místo
{t('table.position_place', { position: club.rank })}
</Badge>
)}
</Box>
@@ -110,57 +112,57 @@ const ClubModal: React.FC<ClubModalProps> = ({ isOpen, onClose, club, clubType =
borderColor={borderColor}
>
<Text fontSize="md" fontWeight="semibold" mb={3} color={useColorModeValue('gray.700', 'gray.200')}>
Statistiky
{t('club_modal.statistics')}
</Text>
<VStack spacing={2} align="stretch">
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Odehráno zápasů:</Text>
<Text color={useColorModeValue('gray.600', 'gray.300')}>{t('club_modal.matches_played')}:</Text>
<Text fontWeight="bold" color={useColorModeValue('gray.800', 'gray.100')}>{club.played || 0}</Text>
</HStack>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Výhry:</Text>
<Text color={useColorModeValue('gray.600', 'gray.300')}>{t('club_modal.wins')}:</Text>
<Text fontWeight="bold" color="green.600">
{club.wins || 0}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Remízy:</Text>
<Text color={useColorModeValue('gray.600', 'gray.300')}>{t('club_modal.draws')}:</Text>
<Text fontWeight="bold" color={useColorModeValue('gray.600', 'gray.400')}>
{club.draws || 0}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Prohry:</Text>
<Text color={useColorModeValue('gray.600', 'gray.300')}>{t('club_modal.losses')}:</Text>
<Text fontWeight="bold" color="red.600">
{club.losses || 0}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Skóre:</Text>
<Text color={useColorModeValue('gray.600', 'gray.300')}>{t('club_modal.score')}:</Text>
<Text fontWeight="bold" color={useColorModeValue('gray.800', 'gray.100')}>{club.score || '0:0'}</Text>
</HStack>
{(club.goals_scored !== undefined || club.goals_conceded !== undefined) && (
<>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Vstřelené góly:</Text>
<Text color={useColorModeValue('gray.600', 'gray.300')}>{t('club_modal.goals_scored')}:</Text>
<Text fontWeight="bold" color="green.500">{club.goals_scored || 0}</Text>
</HStack>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Obdržené góly:</Text>
<Text color={useColorModeValue('gray.600', 'gray.300')}>{t('club_modal.goals_conceded')}:</Text>
<Text fontWeight="bold" color="red.500">{club.goals_conceded || 0}</Text>
</HStack>
</>
)}
{club.goal_difference !== undefined && (
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Skóre rozdíl:</Text>
<Text color={useColorModeValue('gray.600', 'gray.300')}>{t('club_modal.goal_difference')}:</Text>
<Text fontWeight="bold" color={Number(club.goal_difference) >= 0 ? 'green.600' : 'red.600'}>
{Number(club.goal_difference) > 0 ? '+' : ''}{club.goal_difference}
</Text>
</HStack>
)}
<HStack justify="space-between" pt={2} borderTopWidth="1px" borderColor={borderColor}>
<Text color={useColorModeValue('gray.700', 'gray.200')} fontWeight="semibold">Body:</Text>
<Text color={useColorModeValue('gray.700', 'gray.200')} fontWeight="semibold">{t('club_modal.points')}:</Text>
<Badge colorScheme="blue" fontSize="lg" px={3} py={1}>
{club.points || 0}
</Badge>
@@ -178,7 +180,7 @@ const ClubModal: React.FC<ClubModalProps> = ({ isOpen, onClose, club, clubType =
borderColor={borderColor}
>
<Text fontSize="md" fontWeight="semibold" mb={3} color={useColorModeValue('gray.700', 'gray.200')}>
Forma (posledních 5 zápasů)
{t('club_modal.form_last_5')}
</Text>
<HStack spacing={2} justify="center">
{club.form.split('').map((result, idx) => (
@@ -200,7 +202,7 @@ const ClubModal: React.FC<ClubModalProps> = ({ isOpen, onClose, club, clubType =
</ModalBody>
<ModalFooter>
<Button colorScheme="gray" onClick={onClose}>
Zavřít
{t('club_modal.close')}
</Button>
</ModalFooter>
</ModalContent>
@@ -29,7 +29,7 @@ const FeaturedBlog: React.FC = () => {
<GridItem>
{main && (
<Box as={RouterLink} to={main.slug ? `/news/${main.slug}` : `/articles/${main.id}`} position="relative" overflow="hidden" borderRadius="xl">
<Image src={assetUrl(main.image_url) || '/logo192.png'} alt={main.title} w="100%" h={{ base: '220px', md: '320px' }} objectFit="cover" />
<Image src={assetUrl(main.image_url) || '/article-placeholder.svg'} alt={main.title} w="100%" h={{ base: '220px', md: '320px' }} objectFit="cover" />
<Box position="absolute" inset={0} bgGradient="linear(to-t, rgba(0,0,0,0.7), rgba(0,0,0,0.1))" />
{/* Stats badges */}
{((main.read_time || main.estimated_read_minutes) || (main.view_count && main.view_count > 0)) && (
@@ -66,7 +66,7 @@ const FeaturedBlog: React.FC = () => {
<VStack spacing={4} align="stretch">
{[side1, side2].filter(Boolean).map((a) => (
<HStack key={(a as Article).id} align="stretch" spacing={3} as={RouterLink} to={(a as Article).slug ? `/news/${(a as Article).slug}` : `/articles/${(a as Article).id}`} position="relative">
<Image src={assetUrl((a as Article).image_url) || '/logo192.png'} alt={(a as Article).title} w="40%" h="120px" objectFit="cover" borderRadius="lg" />
<Image src={assetUrl((a as Article).image_url) || '/article-placeholder.svg'} alt={(a as Article).title} w="40%" h="120px" objectFit="cover" borderRadius="lg" />
<VStack align="stretch" spacing={2} flex={1}>
<HStack spacing={2} flexWrap="wrap">
{((a as Article).read_time || (a as Article).estimated_read_minutes) && (
@@ -16,6 +16,7 @@ import {
useColorModeValue,
} from '@chakra-ui/react';
import { Calendar, Image as ImageIcon, ExternalLink, ArrowRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface Album {
id: string;
@@ -35,8 +36,20 @@ 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;
const abs = new URL(path, origin);
// Prefer explicit asset base (backend origin) when provided; fallback to API_URL origin
let origin = '';
try {
const assetBase = (process.env.REACT_APP_ASSET_BASE_URL || '').trim();
if (assetBase) {
origin = new URL(assetBase, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
}
} catch {}
if (!origin) {
try {
origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
} catch {}
}
const abs = new URL(path, origin || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'));
return abs.toString();
}
return path;
@@ -46,6 +59,7 @@ const resolveBackendUrl = (path: string) => {
};
const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl }) => {
const { t } = useTranslation();
const [albums, setAlbums] = useState<Album[]>([]);
const [loading, setLoading] = useState(true);
const [profileUrl, setProfileUrl] = useState<string | null>(null);
@@ -172,10 +186,10 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
<HStack justify="space-between" align="center" flexWrap="wrap">
<VStack align="start" spacing={1}>
<Heading size="xl" color={headingColor} id="home-gallery-heading">
Fotogalerie
{t('homepage.gallery')}
</Heading>
<Text color={textColor} fontSize="sm">
Nejnovější alba z našich akcí
{t('gallery.latest_albums')}
</Text>
</VStack>
@@ -187,7 +201,7 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
variant="outline"
size="md"
>
Zobrazit vše
{t('nav.view_all')}
</Button>
</HStack>
@@ -228,10 +242,9 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
key={album.id}
as={RouterLink}
to={`/galerie/album/${album.id}`}
className="card"
bg={cardBg}
borderRadius="lg"
overflow="hidden"
boxShadow="md"
transition="all 0.3s"
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'gray.700')}
@@ -305,7 +318,7 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
colorScheme="blue"
size="lg"
>
Zobrazit všechna alba
{t('gallery.view_all_albums')}
</Button>
</Box>
</VStack>
@@ -18,7 +18,7 @@ const HeaderVariants: React.FC<HeaderVariantsProps> = ({
}) => {
const displayLogo = (assetUrl(clubLogo) || clubLogo) || (clubId
? `http://logoapi.sportcreative.eu/logos/${clubId}?format=svg`
: '/images/club-logo.png');
: '/images/club-logo-fallback.svg');
// Unified variant - classic header
if (variant === 'unified') {
@@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next';
import { Button, HStack, Text, VStack, Box, Heading } from '@chakra-ui/react';
import { Link as RouterLink } from 'react-router-dom';
export const HomePageHero = () => {
const { t } = useTranslation();
return (
<Box bg="blue.600" color="white" py={20} px={4}>
<VStack maxW="4xl" mx="auto" textAlign="center" spacing={6}>
<Heading size="2xl" as="h1">
{t('common.welcome_message')}
</Heading>
<Text fontSize="xl">
{t('common.welcome_subtitle')}
</Text>
<HStack spacing={4}>
<Button as={RouterLink} to="/matches" colorScheme="white" variant="solid">
{t('nav.matches')}
</Button>
<Button as={RouterLink} to="/gallery" colorScheme="white" variant="outline">
{t('nav.gallery')}
</Button>
</HStack>
</VStack>
</Box>
);
};
@@ -3,11 +3,13 @@ import { useQuery } from '@tanstack/react-query';
import { facrApi } from '../../services/facr/facrApi';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import ClubModal from './ClubModal';
import { TeamLogo } from '../common/TeamLogo';
const LeagueTablePro: React.FC = () => {
const { t } = useTranslation();
const { data: settings } = usePublicSettings();
const clubId = settings?.club_id;
const clubType = settings?.club_type || 'football';
@@ -44,7 +46,7 @@ const LeagueTablePro: React.FC = () => {
return (
<Box borderRadius="lg" overflow="hidden" bg="white" boxShadow="sm" borderWidth="1px" borderColor="gray.100">
<Box bg="primary.600" px={4} py={2} borderBottomWidth="1px" borderColor="primary.700">
<Heading size="md" color="white">Tabulka</Heading>
<Heading size="md" color="white">{t('nav.table')}</Heading>
</Box>
<Tabs variant="enclosed" colorScheme="gray" size="sm">
@@ -78,14 +80,14 @@ const LeagueTablePro: React.FC = () => {
<Table size="sm" variant="simple">
<Thead position="sticky" top={0} zIndex={1} bg="gray.50">
<Tr borderBottomWidth="1px" borderColor="gray.200">
<Th width="8" px={2} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">#</Th>
<Th px={3} color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Tým</Th>
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Z</Th>
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">V</Th>
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">R</Th>
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">P</Th>
<Th width="16" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Skóre</Th>
<Th width="14" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Body</Th>
<Th width="8" px={2} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">{t('table.headers.position')}</Th>
<Th px={3} color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">{t('table.headers.team')}</Th>
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">{t('table.headers.played')}</Th>
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">{t('table.headers.wins')}</Th>
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">{t('table.headers.draws')}</Th>
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">{t('table.headers.losses')}</Th>
<Th width="16" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">{t('table.headers.score')}</Th>
<Th width="14" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">{t('table.headers.points')}</Th>
</Tr>
</Thead>
<Tbody>
+286 -79
View File
@@ -14,10 +14,25 @@ import {
Badge,
Link,
Divider,
Box,
Grid,
Flex,
Image,
} from '@chakra-ui/react';
import { useCountdown } from '../../hooks/useCountdown';
import { useSettings } from '@/hooks/useSettings';
import { assetUrl, sanitizeClubName } from '../../utils/url';
import { TeamLogo } from '../common/TeamLogo';
import MatchWeather from '../common/MatchWeather';
import { format, parse } from 'date-fns';
import { cs } from 'date-fns/locale';
const parseScore = (score?: string | null): { h: number; a: number } | null => {
if (!score) return null;
const m = score.match(/^(\d+)\s*[:\-]\s*(\d+)$/);
if (!m) return null;
return { h: parseInt(m[1], 10), a: parseInt(m[2], 10) };
};
export type FacrMatchLike = {
id?: string | number;
@@ -69,6 +84,8 @@ const formatWhen = (m: FacrMatchLike | null) => {
};
export const MatchModal: React.FC<MatchModalProps> = ({ isOpen, match, onClose, onTeamClick }) => {
const { settings } = useSettings();
const kickoffIso = useMemo(() => {
if (!match) return null;
if (match.date && match.time) return `${match.date}T${(match.time || '00:00')}:00`;
@@ -90,8 +107,16 @@ export const MatchModal: React.FC<MatchModalProps> = ({ isOpen, match, onClose,
const matchStarted = kickoffIso ? new Date(kickoffIso).getTime() <= Date.now() : false;
const hasScore = match?.score && match.score.trim() !== '';
// Check if this is a home match
const isHomeMatch = useMemo(() => {
if (!match?.home || !settings) return false;
const homeTeam = sanitizeClubName(match.home).toLowerCase();
const clubName = sanitizeClubName((settings as any)?.club_name || '').toLowerCase();
return homeTeam.includes(clubName) || clubName.includes(homeTeam);
}, [match, settings]);
return (
<Modal isOpen={isOpen} onClose={onClose} isCentered size={{ base: 'md', md: 'lg' }}>
<Modal isOpen={isOpen} onClose={onClose} isCentered size="lg" returnFocusOnClose={false}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
@@ -101,91 +126,273 @@ export const MatchModal: React.FC<MatchModalProps> = ({ isOpen, match, onClose,
<ModalBody>
{match && (
<VStack align="stretch" spacing={4}>
<HStack justify="space-between" align="center">
<VStack
align="center"
spacing={2}
flex={1}
minW={0}
cursor={onTeamClick ? 'pointer' : 'default'}
onClick={() => onTeamClick && onTeamClick(match.home || '', match.home_logo_url)}
_hover={onTeamClick ? { opacity: 0.8, transform: 'scale(1.05)' } : {}}
transition="all 0.2s"
role={onTeamClick ? 'button' : undefined}
tabIndex={onTeamClick ? 0 : undefined}
>
<TeamLogo
teamId={match.home_id}
teamName={match.home}
facrLogo={match.home_logo_url}
size="custom"
alt={match.home || 'Domácí'}
boxSize="56px"
{/* Show match status (Výhra/Remíza/Prohra) if determinable; otherwise fall back to competition name */}
{(() => {
const isPast = kickoffIso ? new Date(kickoffIso).getTime() <= Date.now() : false;
const hasScore = match?.score && match.score.trim() !== '';
if (!isPast) {
// Future match - show competition name
const compName = match.competition || match.competitionName;
if (compName && compName !== 'Všechny soutěže') {
return (
<Flex justify="center">
<Badge colorScheme="purple">{compName}</Badge>
</Flex>
);
}
} else if (hasScore) {
// Past match with score - show sentiment
const s = parseScore(match.score);
if (s) {
const ourIsHome = settings && match.home && (
sanitizeClubName(match.home).toLowerCase().includes(sanitizeClubName((settings as any)?.club_name || '').toLowerCase()) ||
sanitizeClubName((settings as any)?.club_name || '').toLowerCase().includes(sanitizeClubName(match.home).toLowerCase())
);
const ourIsAway = settings && match.away && (
sanitizeClubName(match.away).toLowerCase().includes(sanitizeClubName((settings as any)?.club_name || '').toLowerCase()) ||
sanitizeClubName((settings as any)?.club_name || '').toLowerCase().includes(sanitizeClubName(match.away).toLowerCase())
);
if (ourIsHome || ourIsAway) {
const ourGoals = ourIsHome ? s.h : s.a;
const oppGoals = ourIsHome ? s.a : s.h;
let label = 'Remíza';
let color = 'blue';
if (ourGoals > oppGoals) { label = 'Výhra'; color = 'green'; }
else if (ourGoals < oppGoals) { label = 'Prohra'; color = 'red'; }
return (
<Flex justify="center">
<Badge colorScheme={color as any} variant="subtle">{label}</Badge>
</Flex>
);
}
}
}
return null;
})()}
<Flex align="center" justify="center" gap={3}>
{match.home_logo_url && (
<Image
src={match.home_logo_url}
alt={match.home}
boxSize="40px"
borderRadius="full"
cursor="pointer"
onClick={() => onTeamClick && onTeamClick(match.home || '', match.home_logo_url)}
_hover={{ opacity: 0.8, transform: 'scale(1.1)' }}
transition="all 0.2s"
title={`Klikněte pro zobrazení statistik: ${match.home}`}
/>
<Text fontWeight="semibold" noOfLines={1} textAlign="center">{sanitizeClubName(match.home) || 'Domácí'}</Text>
</VStack>
<VStack spacing={1} minW="120px">
{!matchStarted ? (
// Future match - show countdown or vs
isActive && countdownString ? (
<>
<Text fontSize="lg" color="gray.600">Začátek za</Text>
<Text fontSize="2xl" fontWeight="bold">{countdownString}</Text>
</>
) : (
<>
<Text fontSize="2xl" fontWeight="bold">vs</Text>
</>
)
) : hasScore ? (
// Match finished with score
<>
<Text fontSize="2xl" fontWeight="bold">{match.score}</Text>
<Text fontSize="sm" color="gray.600">Skončeno</Text>
</>
) : (
// Match started but no score yet
<>
<Text fontSize="2xl" fontWeight="bold">:</Text>
<Text fontSize="sm" color="green.600">Probíhá</Text>
</>
)}
{(match.competition || match.competitionName) && (
<Badge colorScheme="blue" variant="subtle">{match.competition || match.competitionName}</Badge>
)}
</VStack>
<VStack
align="center"
spacing={2}
flex={1}
minW={0}
cursor={onTeamClick ? 'pointer' : 'default'}
onClick={() => onTeamClick && onTeamClick(match.away || '', match.away_logo_url)}
_hover={onTeamClick ? { opacity: 0.8, transform: 'scale(1.05)' } : {}}
transition="all 0.2s"
role={onTeamClick ? 'button' : undefined}
tabIndex={onTeamClick ? 0 : undefined}
>
<TeamLogo
teamId={match.away_id}
teamName={match.away}
facrLogo={match.away_logo_url}
size="custom"
alt={match.away || 'Hosté'}
boxSize="56px"
)}
{(() => {
const isPast = kickoffIso ? new Date(kickoffIso).getTime() <= Date.now() : false;
const hasScore = Boolean(match.score);
// For future matches, always show countdown or "vs" - never score
if (!isPast) {
if (isActive && countdownString) {
return (
<Badge colorScheme="orange" fontSize="md" px={3} py={1}>za {countdownString}</Badge>
);
}
return (
<Badge colorScheme="gray" fontSize="md" px={3} py={1}>vs</Badge>
);
}
// For past matches, show score or "vs"
return (
<Badge colorScheme={hasScore ? 'gray' : 'gray'} fontSize="md" px={3} py={1}>
{hasScore ? match.score : 'vs'}
</Badge>
);
})()}
{match.away_logo_url && (
<Image
src={match.away_logo_url}
alt={match.away}
boxSize="40px"
borderRadius="full"
cursor="pointer"
onClick={() => onTeamClick && onTeamClick(match.away || '', match.away_logo_url)}
_hover={{ opacity: 0.8, transform: 'scale(1.1)' }}
transition="all 0.2s"
title={`Klikněte pro zobrazení statistik: ${match.away}`}
/>
<Text fontWeight="semibold" noOfLines={1} textAlign="center">{sanitizeClubName(match.away) || 'Hosté'}</Text>
</VStack>
</HStack>
)}
</Flex>
<Divider />
<VStack align="stretch" spacing={1} color="gray.700">
{when && <Text><strong>Kdy:</strong> {when}</Text>}
{match.venue && <Text><strong>Kde:</strong> {match.venue}</Text>}
</VStack>
{/* Date and Time Display with Countdown */}
<Box textAlign="center">
<Text fontSize="lg" fontWeight="semibold" color="gray.800" mb={1}>
{(() => {
try {
// Try multiple date formats and use Czech locale
if (match.date) {
let dateObj: Date;
// Try yyyy-MM-dd format first
const parsed1 = parse(match.date, 'yyyy-MM-dd', new Date());
if (!isNaN(parsed1.getTime())) {
dateObj = parsed1;
} else {
// Try dd.MM.yyyy format
const parsed2 = parse(match.date, 'dd.MM.yyyy', new Date());
if (!isNaN(parsed2.getTime())) {
dateObj = parsed2;
} else {
return match.date;
}
}
return format(dateObj, 'EEEE d. MMMM yyyy', { locale: cs });
}
return '';
} catch (error) {
console.error('Date formatting error:', error);
return match.date || '';
}
})()}
</Text>
<Text fontSize="md" color="gray.700">
{match.time || '—'}
</Text>
</Box>
{/* Enhanced Countdown Display for Upcoming Matches */}
{(() => {
const isPast = kickoffIso ? new Date(kickoffIso).getTime() <= Date.now() : false;
const hasScore = Boolean(match.score);
if (!hasScore && !isPast && isActive && timeRemaining && timeRemaining > 0) {
const days = Math.floor(timeRemaining / (24 * 60 * 60 * 1000));
const hours = Math.floor((timeRemaining % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000));
const minutes = Math.floor((timeRemaining % (60 * 60 * 1000)) / (60 * 1000));
const seconds = Math.floor((timeRemaining % (60 * 1000)) / 1000);
return (
<Box
mt={4}
p={4}
bg="orange.50"
borderRadius="lg"
borderWidth="2px"
borderColor="orange.200"
>
<Text fontSize="sm" fontWeight="semibold" color="orange.800" mb={3} textAlign="center">
Zápas začíná za
</Text>
<Grid
templateColumns={days > 0 ? "repeat(4, 1fr)" : "repeat(3, 1fr)"}
gap={3}
>
{days > 0 && (
<Box textAlign="center">
<Box
bg="white"
borderRadius="md"
p={3}
borderWidth="1px"
borderColor="orange.300"
boxShadow="sm"
>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{days}
</Text>
</Box>
<Text fontSize="xs" color="gray.600" mt={1} fontWeight="medium">
{days === 1 ? 'den' : days < 5 ? 'dny' : 'dní'}
</Text>
</Box>
)}
<Box textAlign="center">
<Box
bg="white"
borderRadius="md"
p={3}
borderWidth="1px"
borderColor="orange.300"
boxShadow="sm"
>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{String(hours).padStart(2, '0')}
</Text>
</Box>
<Text fontSize="xs" color="gray.600" mt={1} fontWeight="medium">
{hours === 1 ? 'hodina' : hours < 5 ? 'hodiny' : 'hodin'}
</Text>
</Box>
<Box textAlign="center">
<Box
bg="white"
borderRadius="md"
p={3}
borderWidth="1px"
borderColor="orange.300"
boxShadow="sm"
>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{String(minutes).padStart(2, '0')}
</Text>
</Box>
<Text fontSize="xs" color="gray.600" mt={1} fontWeight="medium">
{minutes === 1 ? 'minuta' : minutes < 5 ? 'minuty' : 'minut'}
</Text>
</Box>
<Box textAlign="center">
<Box
bg="white"
borderRadius="md"
p={3}
borderWidth="1px"
borderColor="orange.300"
boxShadow="sm"
>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{String(seconds).padStart(2, '0')}
</Text>
</Box>
<Text fontSize="xs" color="gray.600" mt={1} fontWeight="medium">
{seconds === 1 ? 'sekunda' : seconds < 5 ? 'sekundy' : 'sekund'}
</Text>
</Box>
</Grid>
</Box>
);
}
return null;
})()}
<Box h="1px" bg="gray.200" />
{/* Venue information */}
{match.venue && (
<Text fontSize="md" color="gray.700" textAlign="center">
<strong>Kde:</strong> {match.venue}
</Text>
)}
{/* Weather widget for home matches that haven't started */}
{(() => {
const isPast = kickoffIso ? new Date(kickoffIso).getTime() <= Date.now() : false;
const hasScore = Boolean(match.score);
if (isHomeMatch && !isPast && !hasScore) {
return (
<Box>
<MatchWeather
matchDateTime={kickoffIso || ''}
venue={match.venue}
isHomeMatch={true}
matchHasStarted={false}
/>
</Box>
);
}
return null;
})()}
</VStack>
)}
</ModalBody>
+35 -10
View File
@@ -8,6 +8,7 @@ const MerchSection: React.FC<{ variant?: 'grid' | 'carousel' | 'featured' | 'lis
const [items, setItems] = useState<ClothingItem[]>([]);
const [loading, setLoading] = useState(true);
const cardBg = useColorModeValue('white', 'gray.800');
const eshopUrl = process.env.REACT_APP_ESHOP_URL;
useEffect(() => {
const fetchItems = async () => {
@@ -33,9 +34,23 @@ const MerchSection: React.FC<{ variant?: 'grid' | 'carousel' | 'featured' | 'lis
<Box>
<HStack justify="space-between" mb={3}>
<Heading as="h3" size="md">Oblečení týmu</Heading>
<Link as={RouterLink} to="/obleceni">
<Button size="sm" variant="outline" colorScheme="blue">Zobrazit vše</Button>
</Link>
<HStack spacing={2}>
<Link as={RouterLink} to="/obleceni">
<Button size="sm" variant="outline" colorScheme="blue">Zobrazit vše</Button>
</Link>
{eshopUrl && (
<Button
as={Link}
href={eshopUrl}
size="sm"
colorScheme="blue"
variant="solid"
isExternal
>
E-shop
</Button>
)}
</HStack>
</HStack>
<HorizontalScroller draggable>
{items.map((it) => (
@@ -48,9 +63,7 @@ const MerchSection: React.FC<{ variant?: 'grid' | 'carousel' | 'featured' | 'lis
<Box
className="card"
bg={cardBg}
borderRadius="xl"
overflow="hidden"
boxShadow="sm"
borderWidth="1px"
transition="all 0.2s"
_hover={{ transform: 'translateY(-4px)', boxShadow: 'md' }}
@@ -84,9 +97,23 @@ const MerchSection: React.FC<{ variant?: 'grid' | 'carousel' | 'featured' | 'lis
<Box>
<HStack justify="space-between" mb={3}>
<Heading as="h3" size="md">Oblečení týmu</Heading>
<Link as={RouterLink} to="/obleceni">
<Button size="sm" variant="outline" colorScheme="blue">Zobrazit vše</Button>
</Link>
<HStack spacing={2}>
<Link as={RouterLink} to="/obleceni">
<Button size="sm" variant="outline" colorScheme="blue">Zobrazit vše</Button>
</Link>
{eshopUrl && (
<Button
as={Link}
href={eshopUrl}
size="sm"
colorScheme="blue"
variant="solid"
isExternal
>
E-shop
</Button>
)}
</HStack>
</HStack>
<SimpleGrid columns={{ base: 2, md: 3, lg: 5 }} spacing={4}>
{items.map((it) => (
@@ -99,9 +126,7 @@ const MerchSection: React.FC<{ variant?: 'grid' | 'carousel' | 'featured' | 'lis
<Box
className="card"
bg={cardBg}
borderRadius="xl"
overflow="hidden"
boxShadow="sm"
borderWidth="1px"
transition="all 0.2s"
_hover={{ transform: 'translateY(-4px)', boxShadow: 'md' }}
@@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react';
import { API_URL } from '../../services/api';
import { Link as RouterLink } from 'react-router-dom';
import { Calendar, Image as ImageIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface Album {
id: string;
@@ -32,6 +33,7 @@ const resolveBackendUrl = (path: string) => {
};
const PhotosSection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl }) => {
const { t } = useTranslation();
const [albums, setAlbums] = useState<Album[]>([]);
const [loaded, setLoaded] = useState(false);
@@ -62,7 +64,7 @@ const PhotosSection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
return (
<Box>
<HStack justify="space-between" mb={3}>
<Heading size="lg">Fotogalerie</Heading>
<Heading size="lg">{t('homepage.gallery')}</Heading>
<Button as={RouterLink} to="/galerie" size="sm" variant="outline">
Zobrazit vše
</Button>
@@ -70,7 +72,7 @@ const PhotosSection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
{showSetupHint && (
<Box bg="yellow.50" borderWidth="1px" borderColor="yellow.200" color="yellow.800" p={3} borderRadius="md" mb={3}>
<Text>Žádné fotky nejsou k dispozici. Zadejte prosím odkaz na Zonerama v nastavení (Sociální sítě Fotogalerie) a my ji budeme automaticky načítat.</Text>
<Text>Žádné fotky nejsou k dispozici. Zadejte prosím odkaz na Zonerama v nastavení (Sociální sítě Photos) a my ji budeme automaticky načítat.</Text>
</Box>
)}
+11 -9
View File
@@ -4,10 +4,12 @@ import { useQuery } from '@tanstack/react-query';
import { facrApi } from '../../services/facr/facrApi';
import { FACR_CLUB_ID, FACR_CLUB_TYPE } from '../../config/facr';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { useTranslation } from 'react-i18next';
import ClubModal from './ClubModal';
import { TeamLogo } from '../common/TeamLogo';
const TableSection: React.FC = () => {
const { t } = useTranslation();
const { data: settings } = usePublicSettings();
const clubId = settings?.club_id || FACR_CLUB_ID;
const clubType = settings?.club_type || FACR_CLUB_TYPE;
@@ -223,7 +225,7 @@ const TableSection: React.FC = () => {
return (
<Box>
<Heading size="lg" mb={4}>Tabulka soutěží</Heading>
<Heading size="lg" mb={4}>{t('homepage.tables')}</Heading>
{!clubId && (
<Text color="orange.500" mb={4}>Nastavte klub v Nastavení (Admin) nebo REACT_APP_FACR_CLUB_ID pro načtení tabulek z FAČR.</Text>
)}
@@ -289,14 +291,14 @@ const TableSection: React.FC = () => {
<Table size="sm" variant="striped" colorScheme="gray">
<Thead position="sticky" top={0} zIndex={1} bg="brand.primary">
<Tr>
<Th color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'rank')}># {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'rank')}</Th>
<Th color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'team')}>Tým {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'team')}</Th>
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'played')}>Z {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'played')}</Th>
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'wins')}>V {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'wins')}</Th>
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'draws')}>R {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'draws')}</Th>
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'losses')}>P {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'losses')}</Th>
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'score')}>Skóre {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'score')}</Th>
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'points')}>Body {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'points')}</Th>
<Th color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'rank')}>{t('tables.rank')} {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'rank')}</Th>
<Th color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'team')}>{t('tables.team')} {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'team')}</Th>
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'played')}>{t('tables.played')} {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'played')}</Th>
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'wins')}>{t('tables.wins')} {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'wins')}</Th>
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'draws')}>{t('tables.draws')} {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'draws')}</Th>
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'losses')}>{t('tables.losses')} {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'losses')}</Th>
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'score')}>{t('tables.score')} {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'score')}</Th>
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'points')}>{t('tables.points')} {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'points')}</Th>
</Tr>
</Thead>
<Tbody>
@@ -15,7 +15,7 @@ const TeamScroller: React.FC = () => {
<HStack spacing={6} overflowX="auto" py={2} className="hide-scrollbar">
{players.map((p: Player) => (
<VStack key={p.id} minW="160px" spacing={2} bg={useColorModeValue('white', 'gray.800')} borderRadius="xl" p={4} boxShadow="sm" borderWidth="1px" borderColor={useColorModeValue('gray.200', 'gray.700')}>
<Image src={assetUrl(p.image_url) || '/logo192.png'} alt={p.first_name + ' ' + p.last_name} w="140px" h="140px" objectFit="cover" borderRadius="lg" fallbackSrc="/dist/img/logo-club-empty.svg" />
<Image src={assetUrl(p.image_url) || '/player-placeholder.svg'} alt={p.first_name + ' ' + p.last_name} w="140px" h="140px" objectFit="cover" borderRadius="lg" fallbackSrc="/dist/img/logo-club-empty.svg" />
<Text fontWeight="bold" textAlign="center">{p.first_name} {p.last_name}</Text>
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>{p.position}</Text>
{null}
+10 -11
View File
@@ -1,6 +1,7 @@
import { Box, AspectRatio, Text, useColorModeValue, SimpleGrid, Heading, HStack, Badge, Button, Link, Modal, ModalOverlay, ModalContent, ModalBody, ModalCloseButton, useDisclosure, Icon, VStack } from '@chakra-ui/react';
import { Link as RouterLink } from 'react-router-dom';
import { FaYoutube, FaPlay } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import HorizontalScroller from '../ui/HorizontalScroller';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { usePublicSettings } from '../../hooks/usePublicSettings';
@@ -41,6 +42,7 @@ const toEmbed = (idOrUrl: string): string => {
};
const VideosSection: React.FC<Props> = ({ videos, variant }) => {
const { t } = useTranslation();
const cardBg = useColorModeValue('white', 'gray.800');
const theme = useClubTheme();
const { data: settings } = usePublicSettings();
@@ -166,9 +168,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
<Box
className="video-card card"
bg={cardBg}
borderRadius="xl"
overflow="hidden"
boxShadow="sm"
borderWidth="2px"
borderColor={borderColor}
transition="all 0.3s"
@@ -193,7 +193,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
cursor="pointer"
role="button"
tabIndex={0}
aria-label={`Přehrát video: ${it.title}`}
aria-label={`${t('action.play')}: ${it.title}`}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handlePlayClick(it); } }}
onClick={() => handlePlayClick(it)}
>
@@ -262,7 +262,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
boxShadow="0 12px 32px rgba(0,0,0,0.4)"
>
<Icon as={FaPlay} boxSize={5} />
<Text fontSize="lg">Přehrát</Text>
<Text fontSize="lg">{t('action.play')}</Text>
</Box>
</Box>
</Box>
@@ -302,7 +302,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
<Box>
<Box className="section-head" style={{ marginTop: 8, marginBottom: 16 }}>
<HStack spacing={3}>
<Heading as="h3" size="lg" fontWeight="700" id="home-videos-heading">Videa</Heading>
<Heading as="h3" size="lg" fontWeight="700" id="home-videos-heading">{t('nav.videos')}</Heading>
</HStack>
<Link as={RouterLink} to="/videa">
<Button
@@ -314,7 +314,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
_hover={{ opacity: 0.9, transform: 'translateX(4px)' }}
transition="all 0.2s"
>
Více videí
{t('homepage.more_videos')}
</Button>
</Link>
</Box>
@@ -362,7 +362,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
</VStack>
{selectedVideo.videoId && (
<Link href={`https://www.youtube.com/watch?v=${selectedVideo.videoId}`} isExternal>
<Button size="sm" colorScheme="red" leftIcon={<Icon as={FaYoutube} />}>Otevřít na YouTube</Button>
<Button size="sm" colorScheme="red" leftIcon={<Icon as={FaYoutube} />}>{t('action.open_on_youtube')}</Button>
</Link>
)}
</HStack>
@@ -385,9 +385,9 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
return (
<Box>
<Box className="section-head">
<Heading as="h3" size="md" id="home-videos-heading">Videa</Heading>
<Heading as="h3" size="md" id="home-videos-heading">{t('nav.videos')}</Heading>
<Link as={RouterLink} to="/videa">
<Button size="sm" variant="outline" colorScheme="blue">Více videí</Button>
<Button size="sm" variant="outline" colorScheme="blue">{t('homepage.more_videos')}</Button>
</Link>
</Box>
<SimpleGrid key={`videos-grid-${style}-${items.length}`} columns={cols} spacing={4}>
@@ -412,7 +412,6 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
allowFullScreen
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
style={{ borderRadius: '8px' }}
/>
</AspectRatio>
<Box bg={useColorModeValue('white', 'gray.800')} p={4} borderRadius="md" mt={2}>
@@ -427,7 +426,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
</VStack>
{selectedVideo.videoId && (
<Link href={`https://www.youtube.com/watch?v=${selectedVideo.videoId}`} isExternal>
<Button size="sm" colorScheme="red" leftIcon={<Icon as={FaYoutube} />}>Otevřít na YouTube</Button>
<Button size="sm" colorScheme="red" leftIcon={<Icon as={FaYoutube} />}>{t('action.open_on_youtube')}</Button>
</Link>
)}
</HStack>
+19 -17
View File
@@ -7,6 +7,7 @@ import { useClubTheme } from '../../contexts/ClubThemeContext';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { assetUrl } from '../../utils/url';
import { API_URL } from '../../services/api';
import { useTranslation } from 'react-i18next';
const resolveBackendUrl = (path: string) => {
try {
@@ -32,6 +33,7 @@ interface Sponsor {
}
const Footer: React.FC = () => {
const { t, i18n } = useTranslation();
const currentYear = new Date().getFullYear();
const [clubName, setClubName] = useState<string>('Fotbal Club');
const [shopUrl, setShopUrl] = useState<string | null>(null);
@@ -87,16 +89,16 @@ const Footer: React.FC = () => {
{/* Navigation links */}
<Wrap spacing={4} shouldWrapChildren>
<WrapItem><Link href="/blog" color="whiteAlpha.900" fontWeight="600" _hover={{ color: 'white', textDecoration: 'underline' }}>Články</Link></WrapItem>
<WrapItem><Link href="/kalendar" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Zápasy</Link></WrapItem>
<WrapItem><Link href="/tabulky" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Tabulka</Link></WrapItem>
<WrapItem><Link href="/sponzori" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Sponzoři</Link></WrapItem>
<WrapItem><Link href="/kontakt" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Kontakt</Link></WrapItem>
<WrapItem><Link href="/pravidla-cookies" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Cookies</Link></WrapItem>
<WrapItem><Link href="/obchodni-podminky" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Obchodní podmínky</Link></WrapItem>
<WrapItem><Link href="/zasady-ochrany-osobnich-udaju" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Zásady ochrany osobních údajů</Link></WrapItem>
<WrapItem><Link href="/blog" color="whiteAlpha.900" fontWeight="600" _hover={{ color: 'white', textDecoration: 'underline' }}>{t('footer.articles')}</Link></WrapItem>
<WrapItem><Link href="/kalendar" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>{t('footer.matches')}</Link></WrapItem>
<WrapItem><Link href="/tabulky" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>{t('footer.table')}</Link></WrapItem>
<WrapItem><Link href="/sponzori" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>{t('footer.sponsors')}</Link></WrapItem>
<WrapItem><Link href="/kontakt" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>{t('footer.contact')}</Link></WrapItem>
<WrapItem><Link href="/pravidla-cookies" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>{t('footer.cookies')}</Link></WrapItem>
<WrapItem><Link href="/obchodni-podminky" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>{t('footer.terms')}</Link></WrapItem>
<WrapItem><Link href="/zasady-ochrany-osobnich-udaju" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>{t('footer.privacy')}</Link></WrapItem>
{shopUrl && (
<WrapItem><Link href={shopUrl} color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }} isExternal display="inline-flex" alignItems="center" gap={1}>Eshop <FiArrowUpRight /></Link></WrapItem>
<WrapItem><Link href={shopUrl} color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }} isExternal display="inline-flex" alignItems="center" gap={1}>{t('footer.eshop')} <FiArrowUpRight /></Link></WrapItem>
)}
</Wrap>
</Stack>
@@ -109,7 +111,7 @@ const Footer: React.FC = () => {
<Container maxW="container.xl">
<VStack spacing={6}>
<Heading size="md" color="whiteAlpha.900">
Naši partneři
{t('footer.partners')}
</Heading>
<SimpleGrid
columns={{ base: 2, sm: 3, md: 4, lg: 6 }}
@@ -144,7 +146,7 @@ const Footer: React.FC = () => {
onClick={() => trackNavigation('footer', `sponsor_${sponsor.name}`)}
>
<Image
src={assetUrl(sponsor.logo_url) || '/logo192.png'}
src={assetUrl(sponsor.logo_url) || '/sponsor-placeholder.svg'}
alt={sponsor.name}
maxH="60px"
maxW="full"
@@ -166,7 +168,7 @@ const Footer: React.FC = () => {
<Container maxW="container.xl">
<VStack spacing={4}>
<Text fontSize="lg" fontWeight="600" color="whiteAlpha.900">
Sledujte nás
{t('footer.follow_us')}
</Text>
<HStack spacing={4}>
{settings?.facebook_url && (
@@ -240,7 +242,7 @@ const Footer: React.FC = () => {
<Box bg="gray.900" color="whiteAlpha.900" py={4}>
<Container maxW="container.xl">
<Text fontSize="sm" textAlign="center">
© {currentYear} {clubName}. Všechna práva vyhrazena.
© {currentYear} {clubName}. {i18n.language === 'en' ? 'All rights reserved.' : 'Všechna práva vyhrazena.'}
</Text>
</Container>
</Box>
@@ -266,10 +268,10 @@ const Footer: React.FC = () => {
/>
<VStack align="start" spacing={0}>
<Text fontSize={{ base: 'sm', md: 'md' }} fontWeight="600" color="gray.800">
Stránku provozuje MyClub
{t('footer.powered_by')}
</Text>
<Text fontSize={{ base: 'xs', md: 'sm' }} color="gray.600">
Profesionální webové stránky pro sportovní kluby
{t('footer.powered_by_desc')}
</Text>
</VStack>
</HStack>
@@ -288,7 +290,7 @@ const Footer: React.FC = () => {
_hover={{ transform: 'translateY(-2px)', boxShadow: 'lg' }}
transition="all 0.2s"
>
Objednat
{t('footer.order_now')}
</Button>
<Button
as="a"
@@ -301,7 +303,7 @@ const Footer: React.FC = () => {
rightIcon={<FiArrowUpRight />}
_hover={{ bg: 'gray.50' }}
>
Více info
{t('footer.learn_more')}
</Button>
</HStack>
</Stack>
@@ -19,6 +19,10 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
const headerVariant = getVariant('header', 'unified');
const sponsorsVariant = getVariant('sponsors', 'grid');
const footerVariant = getVariant('footer', 'standard');
const containerVariant = getVariant('container', 'boxed');
const isFullWidthLayout = containerVariant === 'fullwidth';
const isWideLayout = containerVariant === 'wide';
const contentMaxW = isFullWidthLayout ? '100%' : isWideLayout ? '1400px' : 'container.xl';
const headerIsInside = headerInsideContainer && headerVariant !== 'fullwidth';
useEffect(() => {
@@ -45,7 +49,7 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
<Box id="top" position="absolute" top={0} left={0} />
{headerIsInside ? (
<>
<Container maxW="container.xl" py={8}>
<Container maxW={contentMaxW} py={8} px={isFullWidthLayout ? 0 : undefined}>
<Box key={`header-${refreshKey}-${headerVariant}`} as="header" data-element="header" data-variant={headerVariant} style={{ ...getStyles('header') }}>
{headerVariant === 'sparta_navbar' ? (
<SpartaNavbar />
@@ -73,7 +77,7 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
<Navbar fullWidth={headerVariant === 'fullwidth'} variant={headerVariant} />
)}
</Box>
<Container maxW="container.xl" py={8}>
<Container maxW={contentMaxW} py={8} px={isFullWidthLayout ? 0 : undefined}>
{children}
</Container>
{/* Global sponsors section across front-facing pages */}
@@ -14,12 +14,14 @@ import {
import { useForm } from 'react-hook-form';
import { subscribeToNewsletter } from '../../services/public';
import { trackNewsletterSubscribe, trackFormSubmit } from '../../utils/umami';
import { useTranslation } from 'react-i18next';
type FormData = {
email: string;
};
export default function NewsletterSubscribe() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const toast = useToast();
const {
@@ -39,8 +41,8 @@ export default function NewsletterSubscribe() {
trackFormSubmit('Newsletter Subscribe', true);
toast({
title: 'Přihlášení k odběru proběhlo úspěšně',
description: 'Vytvořili jsme vám fanouškovský účet a poslali email s heslem a odkazy pro správu newsletteru.',
title: t('newsletter.subscribe_success'),
description: t('newsletter.subscribe_success_desc'),
status: 'success',
duration: 7000,
isClosable: true,
@@ -48,13 +50,13 @@ export default function NewsletterSubscribe() {
try { window.dispatchEvent(new CustomEvent('engagement:refresh')); } catch {}
reset();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Nastala chyba při přihlašování k odběru';
const errorMessage = error instanceof Error ? error.message : t('newsletter.subscribe_error_desc');
// Track failed subscription
trackFormSubmit('Newsletter Subscribe', false);
toast({
title: 'Chyba',
title: t('newsletter.subscribe_error'),
description: errorMessage,
status: 'error',
duration: 5000,
@@ -73,10 +75,10 @@ export default function NewsletterSubscribe() {
<Box w="100%" maxW="xl" mx="auto" p={4}>
<VStack spacing={3} align="stretch">
<Text fontSize="xl" fontWeight="bold" textAlign="center" color={headingColor}>
Přihlaste se k odběru novinek
{t('newsletter.subscribe')}
</Text>
<Text textAlign="center" color={textColor} mb={2}>
Budeme vás informovat o novinkách, zápasech a akcích našeho klubu. Současně pro vás vytvoříme fanouškovský účet a pošleme heslo emailem.
{t('newsletter.subscribe_description')}
</Text>
<form onSubmit={handleSubmit(onSubmit)}>
@@ -85,13 +87,13 @@ export default function NewsletterSubscribe() {
<Input
id="email"
type="email"
placeholder="Váš e-mail"
placeholder={t('newsletter.email_label')}
autoComplete="email"
{...register('email', {
required: 'E-mail je povinný',
required: t('newsletter.email_required'),
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Neplatná e-mailová adresa',
message: t('newsletter.email_invalid'),
},
})}
size="md"
@@ -108,17 +110,17 @@ export default function NewsletterSubscribe() {
size="md"
width="100%"
isLoading={isLoading}
loadingText="Odesílám..."
loadingText={t('newsletter.loading')}
data-umami-event="Newsletter Submit"
data-umami-event-location={window.location.pathname}
>
Odeslat
{t('newsletter.subscribe_button')}
</Button>
</VStack>
</form>
<Text fontSize="xs" color={disclaimerColor} textAlign="center" mt={2}>
Odesláním formuláře souhlasíte se zpracováním osobních údajů. Z odběru se můžete kdykoli odhlásit a nastavení upravit v zaslaném emailu. Heslo lze změnit přes stránku pro obnovení hesla.
{t('newsletter.consent_text')}
</Text>
</VStack>
</Box>
@@ -1,4 +1,5 @@
import React, { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { TeamLogo } from '../common/TeamLogo';
import { sanitizeClubName } from '../../utils/url';
@@ -26,7 +27,8 @@ const MatchesSlider: React.FC<{
onMatchClick?: (m: SliderMatch, compName?: string) => void;
elementProps?: any;
variant?: 'carousel' | 'scroller' | 'ticker' | 'compact_split';
}> = ({ title = 'Zápasy', comps, activeIndex, onActiveChange, onMatchClick, elementProps, variant }) => {
}> = ({ title, comps, activeIndex, onActiveChange, onMatchClick, elementProps, variant }) => {
const { t } = useTranslation();
const trackRef = useRef<HTMLDivElement | null>(null);
const current = comps[Math.max(0, Math.min(activeIndex, comps.length - 1))];
@@ -62,10 +64,10 @@ const MatchesSlider: React.FC<{
const items = (current?.matches || []);
const looped = [...items, ...items, ...items];
return (
<section className="matches-slider matches-ticker" aria-label={`Zápasy ${current?.name || ''}`} {...(elementProps || {})}>
<section className="matches-slider matches-ticker" aria-label={`${t('nav.matches')} ${current?.name || ''}`} {...(elementProps || {})}>
<div className="section-head" style={{ marginTop: 16, marginBottom: 8 }}>
<h3>{title}</h3>
<a href="/kalendar" className="see-all">Všechny zápasy</a>
<h3>{title || t('nav.matches')}</h3>
<a href="/kalendar" className="see-all">{t('homepage.all_matches')}</a>
</div>
<div className="ticker-belt" role="list">
{looped.map((m, idx) => (
@@ -108,10 +110,10 @@ const MatchesSlider: React.FC<{
}
return (
<section className="matches-slider" aria-label={`Zápasy ${current?.name || ''}`} {...(elementProps || {})}>
<section className="matches-slider" aria-label={`${t('nav.matches')} ${current?.name || ''}`} {...(elementProps || {})}>
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
<h3>{title}</h3>
<a href="/kalendar" className="see-all">Všechny zápasy</a>
<h3>{title || t('nav.matches')}</h3>
<a href="/kalendar" className="see-all">{t('homepage.all_matches')}</a>
</div>
<div className="matches-grid">
<div className="matches-track" ref={trackRef} role="list">
+10 -3
View File
@@ -1,5 +1,6 @@
import React from 'react';
import { assetUrl } from '../../utils/url';
import { useTranslation } from 'react-i18next';
export type NewsListItem = {
id: number | string;
@@ -14,7 +15,13 @@ const NewsList: React.FC<{
emptyText?: string;
seeAllHref?: string;
seeAllLabel?: string;
}> = ({ items, emptyText = 'Zatím nejsou k dispozici žádné aktuality.', seeAllHref, seeAllLabel = 'Zobrazit všechny aktuality' }) => {
}> = ({ items, emptyText, seeAllHref, seeAllLabel }) => {
const { t } = useTranslation();
// Use provided text or fallback to translations
const emptyTextFinal = emptyText || t('news.no_news');
const seeAllLabelFinal = seeAllLabel || t('news.view_all_news');
return (
<>
<div className="blog-list">
@@ -32,13 +39,13 @@ const NewsList: React.FC<{
))
) : (
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--dark-gray)', background: 'var(--bg-soft)', borderRadius: '12px' }}>
<p>{emptyText}</p>
<p>{emptyTextFinal}</p>
</div>
)}
</div>
{seeAllHref && items && items.length > 0 && (
<div style={{ marginTop: 12 }}>
<a className="btn" href={seeAllHref}>{seeAllLabel}</a>
<a className="btn" href={seeAllHref}>{seeAllLabelFinal}</a>
</div>
)}
</>
+10 -8
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { sanitizeClubName } from '../../utils/url';
import { TeamLogo } from '../common/TeamLogo';
@@ -38,6 +39,7 @@ function deriveTeamIdFromLogoUrl(url?: string): string | undefined {
type TeamOverrides = { by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> };
const StandingsCard: React.FC<{ rows: StandingRow[]; onRowClick?: (row: StandingRow, index: number) => void; variant?: 'logos' | 'plain' }>= ({ rows, onRowClick, variant = 'logos' }) => {
const { t } = useTranslation();
const safe = Array.isArray(rows) ? rows : [];
const [sortKey, setSortKey] = useState<'rank' | 'team' | 'played' | 'wins' | 'draws' | 'losses' | 'score' | 'points' | null>(null);
const [sortOrder, setSortOrder] = useState<'desc' | 'asc' | null>(null);
@@ -157,13 +159,13 @@ const StandingsCard: React.FC<{ rows: StandingRow[]; onRowClick?: (row: Standing
<thead>
<tr style={{ fontSize: '0.75rem', color: 'var(--dark-gray)', textTransform: 'uppercase' }}>
<th onClick={() => toggleSort('rank')} style={{ padding: '6px 8px', textAlign: 'left', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}># {arrow('rank')}</th>
<th onClick={() => toggleSort('team')} style={{ padding: '6px 8px', textAlign: 'left', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>Tým{arrow('team')}</th>
<th onClick={() => toggleSort('played')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>Z{arrow('played')}</th>
<th onClick={() => toggleSort('wins')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>V{arrow('wins')}</th>
<th onClick={() => toggleSort('draws')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>R{arrow('draws')}</th>
<th onClick={() => toggleSort('losses')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>P{arrow('losses')}</th>
<th onClick={() => toggleSort('score')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, display: 'none', cursor: 'pointer', userSelect: 'none' }} className="hide-mobile">Skóre{arrow('score')}</th>
<th onClick={() => toggleSort('points')} style={{ padding: '6px 8px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>Body{arrow('points')}</th>
<th onClick={() => toggleSort('team')} style={{ padding: '6px 8px', textAlign: 'left', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>{t('homepage.team')}{arrow('team')}</th>
<th onClick={() => toggleSort('played')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>{t('homepage.played')}{arrow('played')}</th>
<th onClick={() => toggleSort('wins')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>{t('homepage.won')}{arrow('wins')}</th>
<th onClick={() => toggleSort('draws')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>{t('homepage.drawn')}{arrow('draws')}</th>
<th onClick={() => toggleSort('losses')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>{t('homepage.lost')}{arrow('losses')}</th>
<th onClick={() => toggleSort('score')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, display: 'none', cursor: 'pointer', userSelect: 'none' }} className="hide-mobile">{t('homepage.goals')}{arrow('score')}</th>
<th onClick={() => toggleSort('points')} style={{ padding: '6px 8px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>{t('homepage.points')}{arrow('points')}</th>
</tr>
</thead>
<tbody>
@@ -200,7 +202,7 @@ const StandingsCard: React.FC<{ rows: StandingRow[]; onRowClick?: (row: Standing
teamName={teamName}
facrLogo={(row as any).team_logo_url}
size="small"
alt={teamName || 'Tým'}
alt={teamName || t('homepage.team')}
borderRadius="full"
/>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{teamName}</span>
+7 -7
View File
@@ -33,6 +33,7 @@ import {
import { useUmami } from '../../hooks/useUmami';
import { useAuth } from '../../contexts/AuthContext';
import { Link as RouterLink, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
interface PollCardProps {
poll: Poll;
@@ -52,6 +53,7 @@ const PollCard: React.FC<PollCardProps> = ({
onVoteSuccess,
flat = false,
}) => {
const { t } = useTranslation();
const toast = useToast();
const queryClient = useQueryClient();
const { trackEvent } = useUmami();
@@ -97,8 +99,8 @@ const PollCard: React.FC<PollCardProps> = ({
queryKey: ['poll-results', poll.id],
queryFn: () => getPollResults(poll.id),
enabled: showLiveResults,
refetchInterval: 4000,
refetchOnWindowFocus: true,
refetchInterval: 30000,
refetchOnWindowFocus: false,
});
// Vote mutation
@@ -257,12 +259,11 @@ const PollCard: React.FC<PollCardProps> = ({
return (
<Box
className={flat ? undefined : 'card'}
bg={flat ? 'transparent' : bgCard}
borderWidth={flat ? '0' : '1px'}
borderColor={flat ? 'transparent' : borderColor}
borderRadius="xl"
p={flat ? 0 : 6}
boxShadow={flat ? 'none' : 'md'}
>
<VStack spacing={4} align="stretch">
{poll.image_url && (
@@ -322,12 +323,11 @@ const PollCard: React.FC<PollCardProps> = ({
// Show voting form
return (
<Box
className={flat ? undefined : 'card'}
bg={flat ? 'transparent' : bgCard}
borderWidth={flat ? '0' : '1px'}
borderColor={flat ? 'transparent' : borderColor}
borderRadius="xl"
p={flat ? 0 : 6}
boxShadow={flat ? 'none' : 'md'}
>
<VStack spacing={4} align="stretch">
{poll.image_url && (
@@ -587,7 +587,7 @@ const PollCard: React.FC<PollCardProps> = ({
<Text fontSize="xs" color="gray.500">
Přihlášením se jméno a e-mail doplní automaticky.{' '}
<Link as={RouterLink} color="blue.500" to="/login" state={{ from: location }}>
Přihlásit se
{t('auth.login')}
</Link>
</Text>
</VStack>
@@ -205,8 +205,19 @@ function deriveShortLocal(name?: string) {
function shadeColor(hex: string, percent: number) {
// Simple hex shade function
try {
const n = hex.replace('#','');
const num = parseInt(n.length === 3 ? n.split('').map((c)=>c+c).join('') : n, 16);
const raw = hex.replace('#','');
let n = raw;
// Normalize to 6-digit RGB, ignore alpha channel if present
if (n.length === 3 || n.length === 4) {
// #RGB or #RGBA -> expand RGB, drop A
const rgb = n.slice(0,3);
n = rgb.split('').map((c)=>c+c).join('');
} else if (n.length === 8) {
// #RRGGBBAA -> drop AA
n = n.slice(0,6);
}
if (n.length !== 6) return hex;
const num = parseInt(n, 16);
let r = (num >> 16) & 0xff;
let g = (num >> 8) & 0xff;
let b = num & 0xff;
@@ -3,6 +3,7 @@ import { useAuth } from '../../contexts/AuthContext';
import { getCurrentSweepstake, enterSweepstake, markSweepstakeVisualPlayed, CurrentSweepstakeResponse } from '../../services/sweepstakes';
import { useToast } from '@chakra-ui/react';
import { getImageUrl } from '../../utils/imageUtils';
import { useTranslation } from 'react-i18next';
const fmtDate = (iso?: string | null) => {
if (!iso) return '';
@@ -11,6 +12,7 @@ const fmtDate = (iso?: string | null) => {
};
const SweepstakeWidget: React.FC = () => {
const { t } = useTranslation();
const { user } = useAuth();
const [data, setData] = useState<CurrentSweepstakeResponse | null>(null);
const [loading, setLoading] = useState<boolean>(true);
@@ -43,6 +45,9 @@ const SweepstakeWidget: React.FC = () => {
return winners.some(w => w.user_id === myId);
}, [winners, isLogged, user]);
const hasEntered = !!data?.has_entered;
const canEnter = !!data?.can_enter;
useEffect(() => {
// Autoplay visualization once for logged users within 3-day window, non-admins
if (!s || !isLogged || !winners?.length) return;
@@ -70,10 +75,10 @@ const SweepstakeWidget: React.FC = () => {
setJoining(true);
try {
await enterSweepstake(s.id);
toast({ status: 'success', title: 'Úspěšně jste vstoupili do soutěže' });
toast({ status: 'success', title: 'Úspěšně jste se přihlásili do soutěže' });
await load();
} catch (e: any) {
const msg = e?.response?.data?.error || 'Nelze vstoupit do soutěže';
const msg = e?.response?.data?.error || 'Nelze se přihlásit do soutěže';
toast({ status: 'error', title: msg });
} finally {
setJoining(false);
@@ -111,9 +116,9 @@ const SweepstakeWidget: React.FC = () => {
<div style={{ marginTop: 8, fontSize: 14, opacity: 0.8 }}>Začíná: {fmtDate(s.start_at)} Končí: {fmtDate(s.end_at)}</div>
</div>
{!isLogged ? (
<a href="/login" className="btn" style={{ padding: '10px 16px' }}>Přihlásit se</a>
) : data?.has_entered ? (
<span style={{ fontWeight: 600 }}>Jste zapojeni </span>
<a href="/login" className="btn" style={{ padding: '10px 16px' }}>{t('auth.login')}</a>
) : hasEntered ? (
<span style={{ fontWeight: 600 }}> jste přihlášeni do soutěže </span>
) : (
<span style={{ fontSize: 14, opacity: 0.85 }}>Soutěž ještě nezačala. Vstup bude možný od {fmtDate(s.start_at)}.</span>
)}
@@ -144,13 +149,15 @@ const SweepstakeWidget: React.FC = () => {
</div>
</div>
{!isLogged ? (
<div style={{ fontWeight: 600 }}>Právě zde probíhá soutěž. <a href="/login">Přihlaste se</a> a zapojte se.</div>
) : (data?.can_enter ?? false) ? (
<a href="/login" className="btn" style={{ padding: '10px 16px' }}>{t('auth.login')}</a>
) : hasEntered ? (
<span style={{ fontWeight: 600 }}> jste přihlášeni do soutěže </span>
) : canEnter ? (
<button className="btn" onClick={onJoin} disabled={joining}>
{joining ? 'Vstupuji…' : 'Vstoupit'}
{joining ? 'Přihlašuji…' : 'Přihlásit do soutěže'}
</button>
) : (
<span style={{ fontWeight: 600 }}> jste registrováni v soutěži </span>
<span style={{ fontWeight: 600 }}>Vstup není dostupný</span>
)}
</div>
</div>
@@ -0,0 +1,532 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Button,
Card,
CardBody,
CardHeader,
Heading,
Text,
Stack,
Badge,
useToast,
Divider,
SimpleGrid,
FormControl,
FormLabel,
Input,
NumberInput,
NumberInputField,
NumberInputStepper,
NumberIncrementStepper,
NumberDecrementStepper,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
Alert,
AlertIcon,
Progress,
VStack,
HStack,
} from '@chakra-ui/react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { api } from '../../services/api';
// Types
interface AvailableTicket {
campaign_id: number;
campaign_title: string;
campaign_description: string;
external_match_id?: string;
competition_code?: string;
match_date_time?: string;
home_team?: string;
away_team?: string;
venue?: string;
sale_start_time: string;
sale_end_time: string;
ticket_type_id: number;
ticket_type_name: string;
ticket_type_description: string;
price_cents: number;
max_per_order: number;
color: string;
available_quantity: number;
total_capacity: number;
sale_status: string;
}
interface TicketCampaign {
id: number;
title: string;
description: string;
external_match_id?: string;
competition_code?: string;
match_date_time?: string;
home_team?: string;
away_team?: string;
venue?: string;
sale_start_time: string;
sale_end_time: string;
available_tickets: AvailableTicket[];
}
interface ReservationRequest {
ticket_id: number;
quantity: number;
holder_name: string;
holder_email: string;
holder_phone?: string;
}
interface TicketOrderRequest {
first_name: string;
last_name: string;
email: string;
phone?: string;
ticket_reservations: { ticket_id: number }[];
payment_method: 'stripe' | 'gopay';
}
// Helper functions
const formatPrice = (cents: number) => {
return (cents / 100).toFixed(0) + ' Kč';
};
const getSaleStatusColor = (status: string) => {
switch (status) {
case 'available': return 'green';
case 'upcoming': return 'yellow';
case 'sold_out': return 'red';
case 'ended': return 'gray';
default: return 'gray';
}
};
const getSaleStatusText = (status: string) => {
switch (status) {
case 'available': return 'V prodeji';
case 'upcoming': return 'Připravuje se';
case 'sold_out': return 'Vyprodáno';
case 'ended': return 'Ukončeno';
default: return 'Neznámý';
}
};
// Main Component
interface TicketPurchaseProps {
matchId?: string;
campaignId?: number;
}
export const TicketPurchase: React.FC<TicketPurchaseProps> = ({ matchId, campaignId }) => {
const [selectedTickets, setSelectedTickets] = useState<{ [key: number]: number }>({});
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
const [customerInfo, setCustomerInfo] = useState({
firstName: '',
lastName: '',
email: '',
phone: '',
});
const toast = useToast();
// Fetch available tickets and group by campaign
const { data: availableTickets, isLoading, error } = useQuery<AvailableTicket[]>(
['ticket-campaigns', matchId],
async () => {
const params = matchId ? `?match_id=${matchId}` : '';
const response = await api.get(`/api/v1/tickets/available${params}`);
return response.data;
},
{
enabled: true,
}
);
// Group available tickets by campaign
const campaigns = React.useMemo(() => {
if (!availableTickets) return [];
const grouped = availableTickets.reduce((acc, ticket) => {
const campaignId = ticket.campaign_id;
if (!acc[campaignId]) {
acc[campaignId] = {
id: campaignId,
title: ticket.campaign_title,
description: ticket.campaign_description,
external_match_id: ticket.external_match_id,
competition_code: ticket.competition_code,
match_date_time: ticket.match_date_time,
home_team: ticket.home_team,
away_team: ticket.away_team,
venue: ticket.venue,
sale_start_time: ticket.sale_start_time,
sale_end_time: ticket.sale_end_time,
available_tickets: []
};
}
acc[campaignId].available_tickets.push(ticket);
return acc;
}, {} as Record<number, TicketCampaign>);
return Object.values(grouped);
}, [availableTickets]);
// Reserve tickets mutation
const reserveMutation = useMutation(
async (reservation: ReservationRequest) => {
const response = await api.post('/api/v1/tickets/reserve', reservation);
return response.data;
},
{
onSuccess: () => {
toast({
title: 'Rezervace úspěšná',
description: 'Vstupenky byly rezervovány',
status: 'success',
duration: 3000,
});
setIsCheckoutOpen(true);
},
onError: (error: any) => {
toast({
title: 'Chyba při rezervaci',
description: error.response?.data?.error || 'Nepodařilo se rezervovat vstupenky',
status: 'error',
duration: 5000,
});
},
}
);
// Create order mutation
const orderMutation = useMutation(
async (orderData: TicketOrderRequest) => {
const response = await api.post('/api/v1/ticket-checkout/order', orderData);
return response.data;
},
{
onSuccess: (data: any) => {
toast({
title: 'Objednávka vytvořena',
description: 'Přesměrováváme na platební bránu...',
status: 'success',
duration: 3000,
});
// Redirect to payment
if (data.payment?.redirect_url) {
window.location.href = data.payment.redirect_url;
} else if (data.payment?.client_secret) {
// Handle Stripe payment
// TODO: Integrate Stripe Elements
console.log('Stripe payment:', data.payment);
}
},
onError: (error: any) => {
toast({
title: 'Chyba při vytváření objednávky',
description: error.response?.data?.error || 'Nepodařilo se vytvořit objednávku',
status: 'error',
duration: 5000,
});
},
}
);
const handleQuantityChange = (ticketId: number, quantity: number, maxQuantity: number) => {
if (quantity >= 0 && quantity <= maxQuantity) {
setSelectedTickets(prev => ({
...prev,
[ticketId]: quantity,
}));
}
};
const getTotalPrice = () => {
if (!campaigns) return 0;
let total = 0;
campaigns.forEach(campaign => {
campaign.available_tickets.forEach(ticket => {
const quantity = selectedTickets[ticket.ticket_type_id] || 0;
total += ticket.price_cents * quantity;
});
});
return total;
};
const getTotalQuantity = () => {
return Object.values(selectedTickets).reduce((sum, qty) => sum + qty, 0);
};
const handleReserve = async () => {
if (getTotalQuantity() === 0) {
toast({
title: 'Chyba',
description: 'Nejprve vyberte vstupenky',
status: 'error',
duration: 3000,
});
return;
}
// Create reservations for each selected ticket
const reservations: ReservationRequest[] = [];
campaigns?.forEach(campaign => {
campaign.available_tickets.forEach(ticket => {
const quantity = selectedTickets[ticket.ticket_type_id] || 0;
if (quantity > 0) {
reservations.push({
ticket_id: ticket.ticket_type_id,
quantity,
holder_name: `${customerInfo.firstName} ${customerInfo.lastName}`,
holder_email: customerInfo.email,
holder_phone: customerInfo.phone,
});
}
});
});
// For now, just reserve the first ticket (simplified)
if (reservations.length > 0) {
reserveMutation.mutate(reservations[0]);
}
};
const handleCheckout = () => {
if (!customerInfo.firstName || !customerInfo.lastName || !customerInfo.email) {
toast({
title: 'Chyba',
description: 'Vyplňte všechny povinné údaje',
status: 'error',
duration: 3000,
});
return;
}
const ticketReservations = Object.entries(selectedTickets)
.filter(([_, quantity]) => quantity > 0)
.map(([ticketId, _]) => ({ ticket_id: parseInt(ticketId) }));
const orderData: TicketOrderRequest = {
first_name: customerInfo.firstName,
last_name: customerInfo.lastName,
email: customerInfo.email,
phone: customerInfo.phone,
ticket_reservations: ticketReservations,
payment_method: 'stripe', // Default to Stripe
};
orderMutation.mutate(orderData);
};
if (isLoading) {
return <Box><Text>Načítání vstupenek...</Text></Box>;
}
if (error) {
return (
<Alert status="error">
<AlertIcon />
Nepodařilo se načíst vstupenky
</Alert>
);
}
if (!campaigns || campaigns.length === 0) {
return (
<Alert status="info">
<AlertIcon />
Momentálně nejsou k dispozici žádné vstupenky
</Alert>
);
}
return (
<Box>
<VStack spacing={6} align="stretch">
{campaigns.map((campaign) => (
<Card key={campaign.id} variant="outline">
<CardHeader>
<VStack align="start" spacing={2}>
<Heading size="md">{campaign.title}</Heading>
{campaign.description && (
<Text color="gray.600">{campaign.description}</Text>
)}
{campaign.home_team && campaign.away_team && (
<Text fontWeight="bold">
{campaign.home_team} vs {campaign.away_team}
</Text>
)}
{campaign.match_date_time && (
<Text>
{new Date(campaign.match_date_time).toLocaleString('cs-CZ')}
</Text>
)}
{campaign.venue && (
<Text color="gray.600">Místo: {campaign.venue}</Text>
)}
</VStack>
</CardHeader>
<CardBody>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{campaign.available_tickets.map((ticket) => {
const quantity = selectedTickets[ticket.ticket_type_id] || 0;
const availabilityPercentage = (ticket.available_quantity / ticket.total_capacity) * 100;
return (
<Card key={ticket.ticket_type_id} variant="filled" bg="gray.50">
<CardBody>
<VStack align="start" spacing={3}>
<HStack justify="space-between" width="100%">
<Heading size="sm">{ticket.ticket_type_name}</Heading>
<Badge colorScheme={getSaleStatusColor(ticket.sale_status)}>
{getSaleStatusText(ticket.sale_status)}
</Badge>
</HStack>
{ticket.ticket_type_description && (
<Text fontSize="sm" color="gray.600">
{ticket.ticket_type_description}
</Text>
)}
<HStack justify="space-between" width="100%">
<Text fontWeight="bold" fontSize="lg" color="blue.600">
{formatPrice(ticket.price_cents)}
</Text>
<Text fontSize="sm" color="gray.600">
{ticket.available_quantity} / {ticket.total_capacity}
</Text>
</HStack>
{ticket.total_capacity > 0 && (
<Progress
value={availabilityPercentage}
colorScheme={availabilityPercentage < 20 ? 'red' : availabilityPercentage < 50 ? 'yellow' : 'green'}
height="8px"
borderRadius="4px"
/>
)}
{ticket.sale_status === 'available' && ticket.available_quantity > 0 && (
<FormControl>
<FormLabel fontSize="sm">Množství</FormLabel>
<NumberInput
value={quantity}
min={0}
max={Math.min(ticket.max_per_order, ticket.available_quantity)}
onChange={(value) => handleQuantityChange(ticket.ticket_type_id, parseInt(value) || 0, Math.min(ticket.max_per_order, ticket.available_quantity))}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</FormControl>
)}
</VStack>
</CardBody>
</Card>
);
})}
</SimpleGrid>
</CardBody>
</Card>
))}
{getTotalQuantity() > 0 && (
<Card>
<CardBody>
<VStack spacing={4}>
<Divider />
<HStack justify="space-between" width="100%">
<Text fontWeight="bold">Celkem:</Text>
<Text fontWeight="bold" fontSize="xl" color="blue.600">
{formatPrice(getTotalPrice())}
</Text>
</HStack>
<Button
colorScheme="blue"
size="lg"
width="100%"
onClick={handleReserve}
isLoading={reserveMutation.isLoading}
>
Rezervovat vstupenky
</Button>
</VStack>
</CardBody>
</Card>
)}
</VStack>
{/* Checkout Modal */}
<Modal isOpen={isCheckoutOpen} onClose={() => setIsCheckoutOpen(false)} size="md">
<ModalOverlay />
<ModalContent>
<ModalHeader>Dokončit objednávku</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<Text>Celkem k úhradě: {formatPrice(getTotalPrice())}</Text>
<FormControl isRequired>
<FormLabel>Jméno</FormLabel>
<Input
value={customerInfo.firstName}
onChange={(e) => setCustomerInfo(prev => ({ ...prev, firstName: e.target.value }))}
/>
</FormControl>
<FormControl isRequired>
<FormLabel>Příjmení</FormLabel>
<Input
value={customerInfo.lastName}
onChange={(e) => setCustomerInfo(prev => ({ ...prev, lastName: e.target.value }))}
/>
</FormControl>
<FormControl isRequired>
<FormLabel>E-mail</FormLabel>
<Input
type="email"
value={customerInfo.email}
onChange={(e) => setCustomerInfo(prev => ({ ...prev, email: e.target.value }))}
/>
</FormControl>
<FormControl>
<FormLabel>Telefon (volitelné)</FormLabel>
<Input
value={customerInfo.phone}
onChange={(e) => setCustomerInfo(prev => ({ ...prev, phone: e.target.value }))}
/>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={() => setIsCheckoutOpen(false)}>
Zrušit
</Button>
<Button
colorScheme="blue"
onClick={handleCheckout}
isLoading={orderMutation.isLoading}
>
Zaplatit
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
);
};
@@ -0,0 +1,272 @@
import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
HStack,
Text,
Heading,
Badge,
Divider,
Button,
Image,
Spinner,
Alert,
AlertIcon,
useToast,
} from '@chakra-ui/react';
import { DownloadIcon, ViewIcon } from '@chakra-ui/icons';
import { QRCodeService, TicketQRData } from '../../services/qrCode';
interface TicketQRDisplayProps {
ticket: any;
campaign: any;
showValidation?: boolean;
}
export const TicketQRDisplay: React.FC<TicketQRDisplayProps> = ({
ticket,
campaign,
showValidation = false
}) => {
const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const [isValidated, setIsValidated] = useState(false);
const [validationError, setValidationError] = useState<string>('');
const toast = useToast();
useEffect(() => {
generateQRCode();
}, [ticket, campaign]);
const generateQRCode = async () => {
try {
setIsLoading(true);
const ticketData = QRCodeService.formatTicketData(ticket, campaign);
const qrCode = await QRCodeService.generateTicketQR(ticketData);
setQrCodeDataUrl(qrCode);
} catch (error) {
console.error('Error generating QR code:', error);
toast({
title: 'Chyba',
description: 'Nepodařilo se vygenerovat QR kód',
status: 'error',
duration: 3000,
});
} finally {
setIsLoading(false);
}
};
const validateTicket = async () => {
try {
setIsValidated(false);
setValidationError('');
// Simulate validation by calling the validation API
const response = await fetch(`/api/v1/tickets/${ticket.id}/validate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
barcode: ticket.barcode,
used_by: 'QR Code Validation',
}),
});
if (response.ok) {
const result = await response.json();
setIsValidated(true);
toast({
title: 'Validace úspěšná',
description: result.message || 'Vstupenka je platná',
status: 'success',
duration: 3000,
});
} else {
const error = await response.json();
setValidationError(error.error || 'Validace selhala');
}
} catch (error) {
console.error('Error validating ticket:', error);
setValidationError('Chyba při validaci vstupenky');
}
};
const downloadQRCode = () => {
if (!qrCodeDataUrl) return;
const link = document.createElement('a');
link.href = qrCodeDataUrl;
link.download = `vstupenka-${ticket.id}-${ticket.barcode}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast({
title: 'QR kód stažen',
description: 'QR kód byl uložen do vašeho zařízení',
status: 'success',
duration: 2000,
});
};
const formatDateTime = (dateTimeString?: string) => {
if (!dateTimeString) return '';
return new Date(dateTimeString).toLocaleString('cs-CZ', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const getStatusColor = (status: string) => {
switch (status) {
case 'paid': return 'green';
case 'reserved': return 'yellow';
case 'used': return 'gray';
case 'cancelled': return 'red';
default: return 'gray';
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'paid': return 'Zaplaceno';
case 'reserved': return 'Rezervováno';
case 'used': return 'Použito';
case 'cancelled': return 'Zrušeno';
default: return 'Neznámý';
}
};
return (
<Box bg="white" p={6} borderRadius="lg" boxShadow="md" maxW="400px">
<VStack spacing={4} align="center">
{/* Header */}
<VStack spacing={2} align="center" w="100%">
<Heading size="md" textAlign="center">{campaign.title}</Heading>
<Badge colorScheme={getStatusColor(ticket.status)}>
{getStatusText(ticket.status)}
</Badge>
</VStack>
<Divider />
{/* Event Details */}
<VStack spacing={2} align="start" w="100%">
{campaign.home_team && campaign.away_team && (
<Text fontWeight="bold">
{campaign.home_team} vs {campaign.away_team}
</Text>
)}
{campaign.match_date_time && (
<Text fontSize="sm">
{formatDateTime(campaign.match_date_time)}
</Text>
)}
{campaign.venue && (
<Text fontSize="sm" color="gray.600">
Místo: {campaign.venue}
</Text>
)}
<Text fontSize="sm">
Typ: {ticket.ticket_type?.name || 'Vstupenka'}
</Text>
<Text fontSize="sm">
Množství: {ticket.quantity}
</Text>
<Text fontSize="sm" fontWeight="bold">
Cena: {(ticket.total_price_cents / 100).toFixed(2)}
</Text>
</VStack>
<Divider />
{/* QR Code */}
<VStack spacing={3} align="center">
{isLoading ? (
<Spinner size="xl" />
) : qrCodeDataUrl ? (
<>
<Image
src={qrCodeDataUrl}
alt="QR kód vstupenky"
maxW="200px"
maxH="200px"
border="2px solid"
borderColor="gray.200"
borderRadius="md"
/>
<Text fontSize="xs" color="gray.500" textAlign="center">
Kód pro ověření vstupenky
</Text>
</>
) : (
<Alert status="error">
<AlertIcon />
Nepodařilo se vygenerovat QR kód
</Alert>
)}
</VStack>
{/* Ticket Holder Info */}
<VStack spacing={1} align="center" w="100%">
<Text fontSize="sm" fontWeight="bold">Držitel vstupenky:</Text>
<Text fontSize="sm">{ticket.holder_name}</Text>
<Text fontSize="sm" color="gray.600">{ticket.holder_email}</Text>
</VStack>
{/* Barcode */}
<VStack spacing={1} align="center">
<Text fontSize="xs" color="gray.500">Kód vstupenky:</Text>
<Text fontSize="sm" fontFamily="monospace" fontWeight="bold">
{ticket.barcode}
</Text>
</VStack>
{/* Actions */}
<HStack spacing={3} pt={2}>
<Button
leftIcon={<DownloadIcon />}
size="sm"
variant="outline"
onClick={downloadQRCode}
isDisabled={!qrCodeDataUrl}
>
Stáhnout QR
</Button>
{showValidation && (
<Button
leftIcon={<ViewIcon />}
size="sm"
colorScheme="blue"
onClick={validateTicket}
isDisabled={isValidated}
>
{isValidated ? 'Ověřeno' : 'Ověřit'}
</Button>
)}
</HStack>
{/* Validation Status */}
{isValidated && (
<Alert status="success" size="sm">
<AlertIcon />
Vstupenka úspěšně ověřena
</Alert>
)}
{validationError && (
<Alert status="error" size="sm">
<AlertIcon />
{validationError}
</Alert>
)}
</VStack>
</Box>
);
};
@@ -0,0 +1,33 @@
import React from 'react';
import { IconButton, useColorMode } from '@chakra-ui/react';
import { MoonIcon, SunIcon } from '@chakra-ui/icons';
// Basic Chakra-powered theme toggle button
export const ThemeToggle: React.FC = () => {
const { colorMode, toggleColorMode } = useColorMode();
return (
<IconButton
size="sm"
fontSize="lg"
aria-label="Přepnout barevné téma"
variant="ghost"
color="current"
onClick={toggleColorMode}
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
/>
);
};
// Wrapper matching the requested DefaultToggle example
export function DefaultToggle() {
return (
<div className="space-y-2 text-center">
<div className="flex justify-center">
<ThemeToggle />
</div>
</div>
);
}
export { DefaultToggle as DefaultThemeToggle };
@@ -1,10 +1,11 @@
import { Box, Text, VStack, HStack, Badge, Icon, Spinner, Alert, AlertIcon, Image, Input, Button, useToast } from '@chakra-ui/react';
import React, { useState } from 'react';
import { useLocation } from 'react-router-dom';
import { FaCalendarAlt, FaFutbol, FaExclamationTriangle, FaMapMarkerAlt } from 'react-icons/fa';
import { FaCalendarAlt, FaFutbol, FaExclamationTriangle, FaMapMarkerAlt, FaCloud } from 'react-icons/fa';
import { useQuery } from '@tanstack/react-query';
import { api, API_URL } from '../../services/api';
import { useSettings } from '@/hooks/useSettings';
import { useTranslation } from 'react-i18next';
import { Widget } from './Widget';
import { format, parse, isToday, isTomorrow, isAfter } from 'date-fns';
import { cs } from 'date-fns/locale';
@@ -40,6 +41,7 @@ export const MatchesWidget: React.FC<{
hideEmpty?: boolean;
onMatchClick?: (match: Match) => void;
}> = ({ categoryName, hideEmpty = false, onMatchClick }) => {
const { t } = useTranslation();
const toast = useToast();
const [email, setEmail] = useState<string>('');
const [prefWeekly, setPrefWeekly] = useState<boolean>(true);
@@ -60,7 +62,7 @@ export const MatchesWidget: React.FC<{
const subscribe = async () => {
if (!email) {
toast({ title: 'Zadejte email', status: 'warning' });
toast({ title: t('form.email_required'), status: 'warning' });
return;
}
setSubscribing(true);
@@ -74,15 +76,24 @@ export const MatchesWidget: React.FC<{
const jt = await res.json().catch(() => ({} as any));
throw new Error(jt?.error || `HTTP ${res.status}`);
}
toast({ title: 'Přihlášeno k odběru', status: 'success' });
toast({ title: t('matches.subscribe_success'), status: 'success' });
setEmail('');
} catch (e: any) {
toast({ title: 'Chyba přihlášení', description: e?.message || String(e), status: 'error' });
toast({ title: t('matches.subscribe_error'), description: e?.message || String(e), status: 'error' });
} finally {
setSubscribing(false);
}
};
const { settings } = useSettings();
// Check if a match is a home match
const isHomeMatch = (match: Match) => {
if (!match?.home || !settings) return false;
const homeTeam = sanitizeClubName(match.home).toLowerCase();
const clubName = sanitizeClubName((settings as any)?.club_name || '').toLowerCase();
return homeTeam.includes(clubName) || clubName.includes(homeTeam);
};
const { data: overrides = {} } = useQuery({
queryKey: ['teamLogoOverrides'],
queryFn: fetchTeamLogoOverrides,
@@ -282,10 +293,10 @@ export const MatchesWidget: React.FC<{
if (isLoading) {
return (
<Widget title="Nadcházející zápasy">
<Widget title={t('matches.upcoming')}>
<VStack p={4}>
<Spinner size="md" />
<Text>Načítám zápasy...</Text>
<Text>{t('matches.loading')}</Text>
</VStack>
</Widget>
);
@@ -293,10 +304,10 @@ export const MatchesWidget: React.FC<{
if (error) {
return (
<Widget title="Nadcházející zápasy">
<Widget title={t('matches.upcoming')}>
<Alert status="error" variant="left-accent">
<AlertIcon />
Nepodařilo se načíst zápasy. Zkuste to prosím později.
{t('matches.error')}
</Alert>
</Widget>
);
@@ -305,11 +316,11 @@ export const MatchesWidget: React.FC<{
if (!displayMatches || displayMatches.length === 0) {
if (hideEmpty) return null;
return (
<Widget title="Nadcházející zápasy">
<Widget title={t('matches.upcoming')}>
<VStack p={4} spacing={4}>
<Icon as={FaCalendarAlt} boxSize={8} color="gray.400" />
<Text color="gray.500" textAlign="center">
Žádné nadcházející zápasy nebyly nalezeny.
{t('matches.none_found')}
</Text>
</VStack>
</Widget>
@@ -317,7 +328,7 @@ export const MatchesWidget: React.FC<{
}
return (
<Widget title="Nadcházející zápasy">
<Widget title={t('matches.upcoming')}>
<VStack spacing={{ base: 2, md: 3 }} align="stretch" divider={<Box borderBottomWidth="1px" borderColor="gray.200" />}>
{displayMatches.map((match) => (
<Box
@@ -349,15 +360,20 @@ export const MatchesWidget: React.FC<{
>
{formatMatchDate(match.date_time)}
</Text>
<Badge
colorScheme="blue"
variant="subtle"
fontSize="xs"
bg="blue.50"
color="blue.700"
>
{match.competitionName}
</Badge>
<HStack spacing={1}>
{isHomeMatch(match) && (
<Icon as={FaCloud} boxSize={3} color="blue.500" title="Domácí zápas - počasí v modálu" />
)}
<Badge
colorScheme="blue"
variant="subtle"
fontSize="xs"
bg="blue.50"
color="blue.700"
>
{match.competitionName}
</Badge>
</HStack>
</HStack>
<HStack justify="space-between" align="center">
@@ -423,10 +439,10 @@ export const MatchesWidget: React.FC<{
{/* Fan subscription form (hidden on admin pages) */}
{!isAdminRoute && (
<VStack p={3} spacing={2} align="stretch">
<Text fontSize="sm" color="gray.600">Chcete dostávat novinky o zápasech emailem?</Text>
<Text fontSize="sm" color="gray.600">{t('matches.subscribe_prompt')}</Text>
<HStack>
<Input type="email" placeholder="váš@email.cz" value={email} onChange={(e) => setEmail(e.target.value)} />
<Button colorScheme="red" onClick={subscribe} isLoading={subscribing}>Odebírat</Button>
<Input type="email" placeholder={t('form.email_placeholder')} value={email} onChange={(e) => setEmail(e.target.value)} />
<Button colorScheme="red" onClick={subscribe} isLoading={subscribing}>{t('matches.subscribe')}</Button>
</HStack>
</VStack>
)}
@@ -0,0 +1,96 @@
import React, { createContext, useCallback, useContext, useRef, useState } from 'react';
import ConfirmationDialog from '../components/common/ConfirmationDialog';
interface ConfirmDialogOptions {
title?: string;
message?: string;
confirmText?: string;
cancelText?: string;
isDanger?: boolean;
}
interface ConfirmDialogState {
isOpen: boolean;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
isDanger?: boolean;
isLoading?: boolean;
}
interface ConfirmDialogContextValue {
confirm: (options: ConfirmDialogOptions) => Promise<boolean>;
}
const ConfirmDialogContext = createContext<ConfirmDialogContextValue | undefined>(undefined);
export const ConfirmDialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, setState] = useState<ConfirmDialogState>({
isOpen: false,
title: '',
message: '',
confirmText: 'Potvrdit',
cancelText: 'Zrušit',
isDanger: false,
isLoading: false,
});
const pendingRef = useRef<{ resolve: (value: boolean) => void } | null>(null);
const closeDialog = useCallback(() => {
setState((prev) => ({ ...prev, isOpen: false, isLoading: false }));
if (pendingRef.current) {
pendingRef.current.resolve(false);
pendingRef.current = null;
}
}, []);
const handleConfirm = useCallback(() => {
setState((prev) => ({ ...prev, isLoading: false, isOpen: false }));
if (pendingRef.current) {
pendingRef.current.resolve(true);
pendingRef.current = null;
}
}, []);
const confirm = useCallback((options: ConfirmDialogOptions): Promise<boolean> => {
return new Promise<boolean>((resolve) => {
pendingRef.current = { resolve };
setState({
isOpen: true,
title: options.title || 'Potvrzení',
message: options.message || '',
confirmText: options.confirmText || 'Potvrdit',
cancelText: options.cancelText || 'Zrušit',
isDanger: options.isDanger || false,
isLoading: false,
});
});
}, []);
return (
<ConfirmDialogContext.Provider value={{ confirm }}>
{children}
<ConfirmationDialog
isOpen={state.isOpen}
onClose={closeDialog}
onConfirm={handleConfirm}
title={state.title}
message={state.message}
confirmText={state.confirmText}
cancelText={state.cancelText}
isDanger={state.isDanger}
isLoading={state.isLoading}
/>
</ConfirmDialogContext.Provider>
);
};
export const useConfirmDialog = (): ConfirmDialogContextValue => {
const ctx = useContext(ConfirmDialogContext);
if (!ctx) {
throw new Error('useConfirmDialog must be used within ConfirmDialogProvider');
}
return ctx;
};
+9
View File
@@ -7,6 +7,7 @@ import { PageElementConfig } from '../services/pageElements';
// Only these should be available in the editor
export const HOMEPAGE_IMPLEMENTED_ELEMENTS = [
'style-pack', // Global style pack selector
'container', // Hlavní kontejner stránky (šířka rozložení)
'header', // Site navigation/header
'hero-topbar', // Club bar above hero
'hero', // Hero section with news cards (grid/scroller/swiper variants)
@@ -33,6 +34,14 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
display_order: -1,
settings: {},
},
{
page_type: 'homepage',
element_name: 'container',
variant: 'boxed',
visible: true,
display_order: -1,
settings: {},
},
{
page_type: 'homepage',
element_name: 'header',
@@ -0,0 +1,170 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom';
interface UseAdminNavScrollRetentionOptions {
scrollContainerSelector?: string;
navItemSelector?: string;
enableDebug?: boolean;
}
export const useAdminNavScrollRetention = (options: UseAdminNavScrollRetentionOptions = {}) => {
const {
scrollContainerSelector = '[data-sidebar="true"]',
navItemSelector = 'a[href*="/admin/"]',
enableDebug = false
} = options;
const location = useLocation();
const scrollContainerRef = useRef<HTMLElement | null>(null);
const isScrollingRef = useRef<boolean>(false);
const [isReady, setIsReady] = useState(false);
const debug = useCallback((message: string, data?: any) => {
if (enableDebug) {
console.log(`[AdminNavScroll] ${message}`, data || '');
}
}, [enableDebug]);
// Always log critical events for debugging
const log = useCallback((message: string, data?: any) => {
console.log(`[AdminNavScroll] ${message}`, data || '');
}, []);
// Get scroll container element
const getScrollContainer = useCallback((): HTMLElement | null => {
if (scrollContainerRef.current) {
return scrollContainerRef.current;
}
const container = document.querySelector(scrollContainerSelector) as HTMLElement;
if (container) {
scrollContainerRef.current = container;
debug('Found scroll container:', container);
}
return container;
}, [scrollContainerSelector, debug]);
// Find the nav item for the current path
const findCurrentNavItem = useCallback((): HTMLElement | null => {
const container = getScrollContainer();
if (!container) return null;
// Find nav item that matches current pathname
const navItems = container.querySelectorAll(navItemSelector);
for (let i = 0; i < navItems.length; i++) {
const item = navItems[i] as HTMLElement;
const href = item.getAttribute('href');
if (href === location.pathname) {
log('Found current nav item:', { href, element: item });
return item;
}
}
// Try partial match if exact match not found
for (let i = 0; i < navItems.length; i++) {
const item = navItems[i] as HTMLElement;
const href = item.getAttribute('href');
if (href && location.pathname.startsWith(href) && href !== '/admin') {
log('Found partial match nav item:', { href, pathname: location.pathname, element: item });
return item;
}
}
debug('No matching nav item found for:', location.pathname);
return null;
}, [getScrollContainer, navItemSelector, location.pathname, log, debug]);
// Scroll to center the current nav item
const scrollToCurrentPage = useCallback(() => {
const container = getScrollContainer();
if (!container || isScrollingRef.current) {
return;
}
const navItem = findCurrentNavItem();
if (!navItem) {
debug('No nav item to scroll to');
return;
}
isScrollingRef.current = true;
// Calculate scroll position to center the item
const containerRect = container.getBoundingClientRect();
const itemRect = navItem.getBoundingClientRect();
// Calculate item position relative to container
const itemTopRelativeToContainer = itemRect.top - containerRect.top + container.scrollTop;
const itemHeight = itemRect.height;
const containerHeight = containerRect.height;
// Center the item in the viewport
const targetScrollTop = itemTopRelativeToContainer - (containerHeight / 2) + (itemHeight / 2);
// Ensure we don't scroll beyond bounds
const maxScrollTop = container.scrollHeight - containerHeight;
const finalScrollTop = Math.max(0, Math.min(targetScrollTop, maxScrollTop));
log('🎯 Centering nav item:', {
pathname: location.pathname,
itemTopRelativeToContainer,
targetScrollTop,
finalScrollTop,
containerHeight,
itemHeight
});
// Smooth scroll to position
container.scrollTo({
top: finalScrollTop,
behavior: 'smooth'
});
// Mark scrolling as complete after animation
setTimeout(() => {
isScrollingRef.current = false;
log('✅ Scroll to current page completed');
}, 300);
}, [getScrollContainer, findCurrentNavItem, location.pathname, log, debug]);
// Handle navigation - scroll to current page
useEffect(() => {
log('🧭 Navigation detected:', location.pathname);
// Scroll to current page after a short delay to allow DOM to update
const scrollTimer = setTimeout(() => {
scrollToCurrentPage();
}, 100);
return () => clearTimeout(scrollTimer);
}, [location.pathname, scrollToCurrentPage, log]);
// Initialize and scroll to current page when component mounts
useEffect(() => {
const initTimer = setTimeout(() => {
const container = getScrollContainer();
if (container) {
setIsReady(true);
// Scroll to current page
scrollToCurrentPage();
log('✅ Initialization completed');
} else {
debug('Scroll container not found after delay');
}
}, 150); // Slightly longer delay to ensure nav items are rendered
return () => {
clearTimeout(initTimer);
};
}, [getScrollContainer, scrollToCurrentPage, log, debug]);
return {
scrollToCurrentPage,
scrollPosition: scrollContainerRef.current?.scrollTop || 0,
isReady,
debug
};
};
+62
View File
@@ -0,0 +1,62 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { translateBlogContent, detectLanguage } from '../services/translation';
export interface BlogTranslationHook {
translateBlog: (title: string, content: string) => Promise<{ title: string; content: string }>;
isTranslating: boolean;
translationError: string | null;
detectSourceLanguage: (text: string) => 'cs' | 'en';
getTargetLanguage: () => 'cs' | 'en';
}
export const useBlogTranslation = (): BlogTranslationHook => {
const { i18n } = useTranslation();
const [isTranslating, setIsTranslating] = useState(false);
const [translationError, setTranslationError] = useState<string | null>(null);
const detectSourceLanguage = (text: string): 'cs' | 'en' => {
return detectLanguage(text);
};
const getTargetLanguage = (): 'cs' | 'en' => {
// If current language is Czech, translate to English, and vice versa
return i18n.language === 'cs' ? 'en' : 'cs';
};
const translateBlog = async (title: string, content: string): Promise<{ title: string; content: string }> => {
setIsTranslating(true);
setTranslationError(null);
try {
const sourceLang = detectSourceLanguage(title + ' ' + content);
const targetLang = getTargetLanguage();
// Don't translate if source and target are the same
if (sourceLang === targetLang) {
return { title, content };
}
const result = await translateBlogContent(title, content, sourceLang, targetLang);
return {
title: result.translatedTitle,
content: result.translatedContent,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Translation failed';
setTranslationError(errorMessage);
throw error;
} finally {
setIsTranslating(false);
}
};
return {
translateBlog,
isTranslating,
translationError,
detectSourceLanguage,
getTargetLanguage,
};
};
@@ -0,0 +1,108 @@
// Direct scroll retention fix - bypass React hooks
// Add this script directly to AdminLayout as a useEffect
import { useEffect } from 'react';
export const useDirectScrollRetention = () => {
useEffect(() => {
console.log('[DirectScrollRetention] Setting up scroll retention...');
let savedScrollPosition = 0;
let isNavigating = false;
// Save scroll position before navigation
const saveScrollPosition = () => {
if (isNavigating) return;
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (sidebar && sidebar.scrollTop > 0) {
savedScrollPosition = sidebar.scrollTop;
console.log('[DirectScrollRetention] Saved position:', savedScrollPosition);
}
};
// Restore scroll position after navigation
const restoreScrollPosition = () => {
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (!sidebar || savedScrollPosition === 0) return;
console.log('[DirectScrollRetention] Restoring position:', savedScrollPosition);
// Aggressive restoration
const targetScroll = savedScrollPosition;
// 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(`[DirectScrollRetention] Restore attempt ${index + 1}:`, sidebar.scrollTop);
}, index * 100);
});
// Final attempt
setTimeout(() => {
if (sidebar.scrollTop !== targetScroll) {
sidebar.scrollTop = targetScroll;
console.log('[DirectScrollRetention] Final restore:', sidebar.scrollTop);
}
isNavigating = false;
}, 1000);
};
// Intercept navigation
const originalPushState = history.pushState;
history.pushState = function(...args) {
if (!isNavigating) {
isNavigating = true;
saveScrollPosition();
// Restore after a short delay
setTimeout(restoreScrollPosition, 50);
}
return originalPushState.apply(this, args);
};
// Also intercept popstate (back/forward buttons)
const handlePopState = () => {
if (!isNavigating) {
isNavigating = true;
saveScrollPosition();
setTimeout(restoreScrollPosition, 50);
}
};
window.addEventListener('popstate', handlePopState);
// Save scroll on every scroll event
const scrollHandler = () => {
if (!isNavigating) {
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (sidebar && sidebar.scrollTop > 0) {
savedScrollPosition = sidebar.scrollTop;
}
}
};
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (sidebar) {
sidebar.addEventListener('scroll', scrollHandler);
}
console.log('[DirectScrollRetention] Scroll retention setup complete');
return () => {
history.pushState = originalPushState;
window.removeEventListener('popstate', handlePopState);
if (sidebar) {
sidebar.removeEventListener('scroll', scrollHandler);
}
};
}, []);
};
+41
View File
@@ -0,0 +1,41 @@
// Fast, native-speed admin scroll retention - optimized for performance
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
export const useFastAdminScroll = (enabled: boolean = true) => {
const location = useLocation();
const scrollPositions = useRef<Map<string, number>>(new Map());
const lastPath = useRef<string>('');
const isRestoring = useRef<boolean>(false);
useEffect(() => {
if (!enabled) return;
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (!sidebar) return;
// Save current scroll position instantly before navigation
if (lastPath.current && !isRestoring.current) {
const currentScroll = sidebar.scrollTop;
if (currentScroll > 0) {
scrollPositions.current.set(lastPath.current, currentScroll);
}
}
// Restore scroll position for new path
const savedPosition = scrollPositions.current.get(location.pathname) || 0;
if (savedPosition > 0 && location.pathname !== lastPath.current) {
isRestoring.current = true;
// Use direct assignment for instant scroll - no animation, no delays
sidebar.scrollTop = savedPosition;
// Clear restoration flag immediately after next frame
requestAnimationFrame(() => {
isRestoring.current = false;
});
}
lastPath.current = location.pathname;
}, [location.pathname, enabled]);
};
@@ -0,0 +1,259 @@
// 100% GUARANTEED Admin Sidebar Scroll Retention Solution
// This uses multiple approaches to ensure it works no matter what
import { useEffect } from 'react';
// Global scroll state that persists across component unmounts
declare global {
interface Window {
__adminSidebarScrollTarget?: number;
__adminScrollRetentionActive?: boolean;
}
}
export const useGuaranteedScrollRetention = () => {
useEffect(() => {
console.log('[GuaranteedScrollRetention] INITIALIZING 100% GUARANTEED SOLUTION...');
// Mark as active globally
window.__adminScrollRetentionActive = true;
let savedScrollPosition = 0;
let isNavigating = false;
let restorationIntervals: NodeJS.Timeout[] = [];
let forceScrollInterval: NodeJS.Timeout | null = null;
// Save scroll position continuously
const saveScrollPosition = () => {
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (sidebar && sidebar.scrollTop > 0) {
savedScrollPosition = sidebar.scrollTop;
window.__adminSidebarScrollTarget = savedScrollPosition;
console.log('[GuaranteedScrollRetention] Saved position:', savedScrollPosition);
}
};
// Aggressive scroll restoration with multiple methods
const restoreScrollPosition = () => {
// Wait for DOM to be ready
setTimeout(() => {
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (!sidebar || savedScrollPosition === 0) return;
console.log('[GuaranteedScrollRetention] RESTORING POSITION:', savedScrollPosition);
console.log('[GuaranteedScrollRetention] Sidebar element found:', !!sidebar);
console.log('[GuaranteedScrollRetention] Current scrollTop:', sidebar.scrollTop);
// Clear previous intervals
restorationIntervals.forEach(interval => clearInterval(interval));
restorationIntervals = [];
if (forceScrollInterval) clearInterval(forceScrollInterval);
// Method 1: Direct assignment
sidebar.scrollTop = savedScrollPosition;
// Method 2: scrollTo
sidebar.scrollTo({ top: savedScrollPosition, behavior: 'auto' });
// Method 3: Force scroll with requestAnimationFrame
const forceScroll = () => {
if (sidebar && sidebar.scrollTop !== savedScrollPosition) {
sidebar.scrollTop = savedScrollPosition;
console.log('[GuaranteedScrollRetention] Forced scroll to:', sidebar.scrollTop);
}
};
requestAnimationFrame(forceScroll);
// Method 4: Multiple timed attempts with longer delays
const delays = [0, 50, 100, 200, 300, 500, 750, 1000, 1500, 2000];
delays.forEach(delay => {
const timeout = setTimeout(() => {
const currentSidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (currentSidebar) {
currentSidebar.scrollTop = savedScrollPosition;
currentSidebar.scrollTo({ top: savedScrollPosition, behavior: 'auto' });
console.log(`[GuaranteedScrollRetention] Attempt at ${delay}ms:`, currentSidebar.scrollTop);
}
}, delay);
restorationIntervals.push(timeout);
});
// Method 5: Continuous force scroll for 5 seconds
forceScrollInterval = setInterval(() => {
const currentSidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (currentSidebar && currentSidebar.scrollTop !== savedScrollPosition) {
currentSidebar.scrollTop = savedScrollPosition;
console.log('[GuaranteedScrollRetention] Continuous force scroll:', currentSidebar.scrollTop);
}
}, 50);
setTimeout(() => {
if (forceScrollInterval) {
clearInterval(forceScrollInterval);
forceScrollInterval = null;
console.log('[GuaranteedScrollRetention] Continuous force scroll stopped');
}
isNavigating = false;
console.log('[GuaranteedScrollRetention] Navigation lock released');
}, 5000);
}, 100); // Wait 100ms for DOM to settle
};
// Intercept ALL possible navigation methods
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
// const originalAssign = (window as any).location.assign; // REMOVED - read-only property
// const originalReplace = (window as any).location.replace; // REMOVED - read-only property
const originalHref = Object.getOwnPropertyDescriptor(window.location, 'href')?.set;
// Override pushState
history.pushState = function(state: any, unused: string, url?: string | URL | null) {
if (!isNavigating) {
isNavigating = true;
saveScrollPosition();
console.log('[GuaranteedScrollRetention] PushState intercepted');
setTimeout(restoreScrollPosition, 5);
}
return originalPushState.call(history, state, unused, url);
};
// Override replaceState
history.replaceState = function(state: any, unused: string, url?: string | URL | null) {
if (!isNavigating) {
isNavigating = true;
saveScrollPosition();
console.log('[GuaranteedScrollRetention] ReplaceState intercepted');
setTimeout(restoreScrollPosition, 5);
}
return originalReplaceState.call(history, state, unused, url);
};
// Override location.assign - REMOVED due to read-only property error
// (window as any).location.assign = function(url: string | URL) {
// if (!isNavigating) {
// isNavigating = true;
// saveScrollPosition();
// console.log('[GuaranteedScrollRetention] Location.assign intercepted');
// setTimeout(restoreScrollPosition, 5);
// }
// return originalAssign.call(window.location, url);
// };
// Override location.replace - REMOVED due to read-only property error
// (window as any).location.replace = function(url: string | URL) {
// if (!isNavigating) {
// isNavigating = true;
// saveScrollPosition();
// console.log('[GuaranteedScrollRetention] Location.replace intercepted');
// setTimeout(restoreScrollPosition, 5);
// }
// return originalReplace.call(window.location, url);
// };
// Handle popstate (back/forward buttons)
const handlePopState = () => {
if (!isNavigating) {
isNavigating = true;
saveScrollPosition();
console.log('[GuaranteedScrollRetention] PopState intercepted');
setTimeout(restoreScrollPosition, 5);
}
};
window.addEventListener('popstate', handlePopState);
// Continuous scroll saving
const handleScroll = () => {
if (!isNavigating) {
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (sidebar && sidebar.scrollTop > 0) {
savedScrollPosition = sidebar.scrollTop;
window.__adminSidebarScrollTarget = savedScrollPosition;
}
}
};
// Add scroll listener to sidebar
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (sidebar) {
sidebar.addEventListener('scroll', handleScroll, { passive: true });
console.log('[GuaranteedScrollRetention] Scroll listener attached');
// Initial save
if (sidebar.scrollTop > 0) {
savedScrollPosition = sidebar.scrollTop;
window.__adminSidebarScrollTarget = savedScrollPosition;
}
}
// MutationObserver to catch DOM changes
const observer = new MutationObserver(() => {
if (isNavigating && savedScrollPosition > 0) {
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (sidebar && sidebar.scrollTop !== savedScrollPosition) {
sidebar.scrollTop = savedScrollPosition;
console.log('[GuaranteedScrollRetention] MutationObserver restored scroll');
}
}
});
if (sidebar) {
observer.observe(sidebar, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class', 'id']
});
console.log('[GuaranteedScrollRetention] MutationObserver attached');
}
// Also observe document body for any layout changes
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class']
});
// Global scroll restoration check every 100ms for 5 seconds after navigation
const globalCheckInterval = setInterval(() => {
if (isNavigating && savedScrollPosition > 0) {
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (sidebar && sidebar.scrollTop !== savedScrollPosition) {
sidebar.scrollTop = savedScrollPosition;
console.log('[GuaranteedScrollRetention] Global check restored scroll');
}
}
}, 100);
setTimeout(() => {
clearInterval(globalCheckInterval);
console.log('[GuaranteedScrollRetention] Global check stopped');
}, 5000);
console.log('[GuaranteedScrollRetention] 100% GUARANTEED SOLUTION SETUP COMPLETE');
// Cleanup function
return () => {
console.log('[GuaranteedScrollRetention] Cleaning up...');
window.__adminScrollRetentionActive = false;
history.pushState = originalPushState;
history.replaceState = originalReplaceState;
// (window as any).location.assign = originalAssign; // REMOVED - read-only property
// (window as any).location.replace = originalReplace; // REMOVED - read-only property
window.removeEventListener('popstate', handlePopState);
if (sidebar) {
sidebar.removeEventListener('scroll', handleScroll);
}
observer.disconnect();
restorationIntervals.forEach(interval => clearInterval(interval));
if (forceScrollInterval) clearInterval(forceScrollInterval);
clearInterval(globalCheckInterval);
};
}, []);
};
+174
View File
@@ -0,0 +1,174 @@
import { useEffect, useState } from 'react';
import { getCategories, Category } from '../services/public';
import { getNavigationItems, NavigationItem } from '../services/navigation';
import { getEvents } from '../services/eventService';
import { getPlayers } from '../services/public';
import { getArticles } from '../services/articles';
import { getCachedYouTube } from '../services/youtube';
import { getZoneramaManifestWithFallbacks } from '../services/zonerama';
import { API_URL } from '../services/api';
export interface NavbarData {
categories: Category[] | null;
dynamicNavItems: NavigationItem[];
navLoading: boolean;
hasTables: boolean;
hasActivities: boolean;
hasPlayers: boolean;
hasArticles: boolean;
hasVideos: boolean;
hasGallery: boolean;
}
export const useNavbarData = (isAdmin: boolean, settings?: any): NavbarData => {
const [categories, setCategories] = useState<Category[] | null>(null);
const [dynamicNavItems, setDynamicNavItems] = useState<NavigationItem[]>([]);
const [navLoading, setNavLoading] = useState(true);
const [hasTables, setHasTables] = useState<boolean>(false);
const [hasActivities, setHasActivities] = useState<boolean>(false);
const [hasPlayers, setHasPlayers] = useState<boolean>(false);
const [hasArticles, setHasArticles] = useState<boolean>(false);
const [hasVideos, setHasVideos] = useState<boolean>(false);
const [hasGallery, setHasGallery] = useState<boolean>(false);
// Combined data loading effect
useEffect(() => {
let active = true;
const loadAllData = async () => {
try {
// Load navigation and categories in parallel
const [navItems, cats] = await Promise.all([
getNavigationItems().catch(() => []),
getCategories().catch(() => [])
]);
if (active) {
// Process navigation
const publicItems = Array.isArray(navItems)
? navItems.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...');
// Note: seedDefaultNavigation() would need to be imported
// For now, continue with empty navigation
} catch (seedError) {
console.error('Auto-seed failed:', seedError);
}
}
setDynamicNavItems(publicItems);
// Process categories
if (Array.isArray(cats) && cats.length > 0) {
setCategories(cats);
} else if (Array.isArray(settings?.categories)) {
setCategories(settings.categories as any);
} else {
setCategories(null);
}
}
} catch (error) {
console.error('Failed to load navigation/categories:', error);
if (active) {
setDynamicNavItems([]);
setCategories(Array.isArray(settings?.categories) ? settings.categories as any : null);
}
} finally {
if (active) setNavLoading(false);
}
};
loadAllData();
return () => { active = false };
}, [isAdmin, settings?.categories]);
// Load content availability data in parallel
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; }
};
const loadContentData = async () => {
try {
// Load all content availability checks in parallel
const [
tablesResponse,
events,
players,
articlesResponse,
youtube,
manifest
] = await Promise.allSettled([
fetch(resolveBackendUrl('/cache/prefetch/facr_tables.json'), { cache: 'no-cache' }),
getEvents().catch(() => []),
getPlayers().catch(() => []),
getArticles({ page: 1, page_size: 1, published: true }).catch(() => ({ total: 0 })),
getCachedYouTube().catch(() => null),
getZoneramaManifestWithFallbacks().catch(() => [])
]);
if (!disposed) {
// Process tables
if (tablesResponse.status === 'fulfilled') {
const res = tablesResponse.value;
if (res.ok) {
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);
setHasTables(!!anyRows);
} else {
setHasTables(false);
}
} else {
setHasTables(false);
}
// Process other content with proper type guards
setHasActivities(events.status === 'fulfilled' && Array.isArray(events.value) && events.value.length > 0);
setHasPlayers(players.status === 'fulfilled' && Array.isArray(players.value) && players.value.length > 0);
setHasArticles(articlesResponse.status === 'fulfilled' && typeof articlesResponse.value === 'object' && articlesResponse.value !== null && 'total' in articlesResponse.value && (articlesResponse.value as any).total > 0);
setHasVideos(youtube.status === 'fulfilled' && youtube.value !== null && typeof youtube.value === 'object' && 'videos' in youtube.value && Array.isArray((youtube.value as any).videos) && (youtube.value as any).videos.length > 0);
setHasGallery(manifest.status === 'fulfilled' && Array.isArray(manifest.value) && manifest.value.length > 0);
}
} catch (error) {
console.error('Failed to load content data:', error);
if (!disposed) {
setHasTables(false);
setHasActivities(false);
setHasPlayers(false);
setHasArticles(false);
setHasVideos(false);
setHasGallery(false);
}
}
};
loadContentData();
return () => { disposed = true; };
}, []);
return {
categories,
dynamicNavItems,
navLoading,
hasTables,
hasActivities,
hasPlayers,
hasArticles,
hasVideos,
hasGallery,
};
};
+2 -2
View File
@@ -5,8 +5,8 @@ export const usePublicSettings = () =>
useQuery<PublicSettings>({
queryKey: ['public-settings'],
queryFn: getPublicSettings,
staleTime: 0,
staleTime: 5 * 60 * 1000, // 5 minutes instead of 0
cacheTime: 30 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnMount: true,
refetchOnMount: false, // Don't refetch on mount to improve performance
});
@@ -0,0 +1,95 @@
// Simple and reliable scroll retention that targets the active nav item
import { useEffect } from 'react';
export const useSimpleScrollRetention = () => {
useEffect(() => {
console.log('[SimpleScrollRetention] Setting up simple scroll retention...');
let savedActiveItem: string | null = null;
// Save the active nav item before navigation
const saveActiveItem = () => {
const activeItem = document.querySelector('[data-navitem="true"][data-active="true"]') as HTMLElement;
if (activeItem) {
savedActiveItem = activeItem.getAttribute('href') || null;
console.log('[SimpleScrollRetention] Saved active item:', savedActiveItem);
}
};
// Restore scroll to the active nav item after navigation
const restoreScrollToActiveItem = () => {
if (!savedActiveItem) return;
// Try multiple times with increasing delays
const attempts = [100, 300, 500, 1000];
attempts.forEach((delay, index) => {
setTimeout(() => {
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
const activeItem = document.querySelector(`[data-navitem="true"][href="${savedActiveItem}"]`) as HTMLElement;
console.log(`[SimpleScrollRetention] Attempt ${index + 1} at ${delay}ms:`, {
sidebar: !!sidebar,
activeItem: !!activeItem,
sidebarScrollTop: sidebar?.scrollTop || 0,
savedActiveItem
});
if (sidebar && activeItem) {
// Calculate the position to center the active item
const sidebarRect = sidebar.getBoundingClientRect();
const itemRect = activeItem.getBoundingClientRect();
const relativeTop = itemRect.top - sidebarRect.top + sidebar.scrollTop;
const targetScroll = relativeTop - (sidebarRect.height / 2) + (itemRect.height / 2);
console.log('[SimpleScrollRetention] Calculated scroll position:', targetScroll);
console.log('[SimpleScrollRetention] Current sidebar scrollTop:', sidebar.scrollTop);
// Scroll to the calculated position
sidebar.scrollTo({ top: targetScroll, behavior: 'auto' });
// Verify and force if needed
setTimeout(() => {
if (Math.abs(sidebar.scrollTop - targetScroll) > 10) {
sidebar.scrollTop = targetScroll;
console.log('[SimpleScrollRetention] Forced scroll to:', sidebar.scrollTop);
}
}, 50);
}
}, delay);
});
};
// Intercept navigation
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = function(...args) {
saveActiveItem();
restoreScrollToActiveItem();
return originalPushState.apply(this, args);
};
history.replaceState = function(...args) {
saveActiveItem();
restoreScrollToActiveItem();
return originalReplaceState.apply(this, args);
};
// Handle popstate (back/forward buttons)
const handlePopState = () => {
saveActiveItem();
restoreScrollToActiveItem();
};
window.addEventListener('popstate', handlePopState);
console.log('[SimpleScrollRetention] Setup complete');
return () => {
history.pushState = originalPushState;
history.replaceState = originalReplaceState;
window.removeEventListener('popstate', handlePopState);
};
}, []);
};
File diff suppressed because it is too large Load Diff
+10
View File
@@ -18,6 +18,16 @@ button, input, textarea, select {
font-family: var(--font-body, var(--chakra-fonts-body, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif));
}
input[type="date"],
input[type="datetime-local"],
input[type="time"] {
border-radius: 9999px;
padding-inline: 1rem;
padding-block: 0.5rem;
height: 2.5rem;
box-sizing: border-box;
}
/* Dark mode root styles */
html.chakra-ui-dark,
.chakra-ui-dark body,
+4
View File
@@ -10,6 +10,10 @@ import 'quill/dist/quill.snow.css';
import 'react-image-crop/dist/ReactCrop.css';
// Custom editor styles AFTER quill base styles to ensure proper override
import './styles/custom-editor.css';
// Public rich content styles to mirror Quill rendering outside editor
import './styles/public-rich-content.css';
// Import i18n configuration
import './i18n';
import { theme } from './App';
import AppLazy from './App.lazy';
import { ColorModeScript } from '@chakra-ui/react';
+105 -58
View File
@@ -11,19 +11,22 @@ import {
HStack,
} from '@chakra-ui/react';
import { ReactNode, useEffect } from 'react';
import { ReactNode, useEffect, useState, useRef } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import AdminSidebar from '../components/admin/AdminSidebar';
import AdminHeader from '../components/admin/AdminHeader';
import { FiMenu } from 'react-icons/fi';
import { MoonIcon, SunIcon } from '@chakra-ui/icons';
import { FaSearch } from 'react-icons/fa';
import AdminSearchModal from '../components/admin/AdminSearchModal';
import AdminSupportButton from '../components/admin/AdminSupportButton';
import AdminHealthIndicator from '../components/admin/AdminHealthIndicator';
import { logAction } from '../services/actionLog';
import '../styles/admin-scroll-fix.css';
import { useFastAdminScroll } from '../hooks/useFastAdminScroll';
import { usePublicSettings } from '../hooks/usePublicSettings';
import { assetUrl } from '../utils/url';
import { ThemeToggle } from '@/components/ui/theme-toggle';
interface AdminLayoutProps {
children: ReactNode;
@@ -31,19 +34,52 @@ interface AdminLayoutProps {
}
const AdminLayout = ({ children, requireAdmin = true }: AdminLayoutProps) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const { isOpen, onOpen, onClose } = useDisclosure({ defaultIsOpen: false });
const { isOpen: isSearchOpen, onOpen: onSearchOpen, onClose: onSearchClose } = useDisclosure();
const { isAuthenticated, isLoading, user } = useAuth();
// Track if sidebar was manually opened (to prevent auto-close)
const manuallyOpenedRef = useRef(false);
// Wrap onOpen and onClose with manual open tracking
const handleOpen = () => {
manuallyOpenedRef.current = true;
onOpen();
};
const handleClose = () => {
manuallyOpenedRef.current = false;
onClose();
};
const navigate = useNavigate();
const location = useLocation();
const { colorMode, toggleColorMode } = useColorMode();
const { isAuthenticated, isLoading, user } = useAuth();
const { colorMode } = useColorMode();
const bg = useColorModeValue('gray.50', 'gray.900');
const contentBg = useColorModeValue('white', '#1a1d29');
const sidebarBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const { data: publicSettings } = usePublicSettings();
// Color values - Matching admin-enhancements.css dark mode colors
const bg = useColorModeValue('gray.50', '#0f1115');
const contentBg = useColorModeValue('white', '#1a1d29');
const borderColor = useColorModeValue('gray.200', 'rgba(255, 255, 255, 0.12)');
const sidebarBg = useColorModeValue('white', '#1a1d29');
// Track mobile state
const [isMobile, setIsMobile] = useState(false);
// Update mobile state on resize
useEffect(() => {
const checkMobile = () => {
const mobile = window.innerWidth < 768;
setIsMobile(mobile);
};
checkMobile();
const handleResize = () => checkMobile();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [isMobile]);
// Fast native-speed scroll retention (disabled on mobile)
useFastAdminScroll(!isMobile);
// Redirect to login if not authenticated
useEffect(() => {
@@ -60,10 +96,17 @@ const AdminLayout = ({ children, requireAdmin = true }: AdminLayoutProps) => {
e.preventDefault();
onSearchOpen();
}
// Close sidebar on Escape key (mobile only)
if (key === 'escape' && isOpen) {
handleClose();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onSearchOpen]);
}, [onSearchOpen, isOpen, handleClose]);
// Note: Removed auto-close on route change for mobile to prevent immediate closing after opening
// Users can close the sidebar by clicking the overlay or pressing Escape
// Redirect non-admin users only when page requires admin
useEffect(() => {
@@ -117,56 +160,60 @@ const AdminLayout = ({ children, requireAdmin = true }: AdminLayoutProps) => {
return (
<Box minH="100vh" bg={bg} className="admin-layout">
<AdminSidebar
isOpen={isOpen}
onClose={onClose}
bg={sidebarBg}
borderRight="1px"
borderColor={borderColor}
{/* Mobile overlay */}
<Box
display={{ base: isOpen ? 'block' : 'none', md: 'none' }}
position="fixed"
top={0}
left={0}
right={0}
bottom={0}
bg="blackAlpha.600"
zIndex={9}
onClick={handleClose}
/>
<Box
ml={{ base: 0, md: '260px' }}
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
minH="100vh"
>
<AdminHeader
onMenuToggle={onOpen}
rightContent={
<HStack spacing={3}>
<IconButton
aria-label="Vyhledávání v administraci"
icon={<FaSearch />}
onClick={onSearchOpen}
variant="ghost"
size="sm"
borderRadius="full"
_hover={{ bg: useColorModeValue('gray.100', 'gray.700'), transform: 'scale(1.05)' }}
transition="all 0.2s"
/>
<AdminSupportButton />
<IconButton
aria-label={`Switch to ${colorMode === 'light' ? 'dark' : 'light'} mode`}
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
onClick={toggleColorMode}
variant="ghost"
size="sm"
borderRadius="full"
_hover={{ bg: useColorModeValue('gray.100', 'gray.700'), transform: 'scale(1.05)' }}
transition="all 0.2s"
/>
</HStack>
}
<AdminSidebar
isOpen={isOpen}
onClose={handleClose}
bg={sidebarBg}
borderRight="1px"
borderColor={borderColor}
/>
<Box as="main" className="admin-main" p={{ base: 4, md: 8 }} pb={{ base: 8, md: 12 }}>
{children}
<Box
ml={{ base: 0, md: '260px' }}
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
minH="100vh"
>
<AdminHeader
onMenuToggle={handleOpen}
rightContent={
<HStack spacing={3}>
<AdminHealthIndicator />
<IconButton
aria-label="Vyhledávání v administraci"
icon={<FaSearch />}
onClick={onSearchOpen}
variant="ghost"
size="sm"
borderRadius="full"
_hover={{ bg: useColorModeValue('gray.100', 'gray.700'), transform: 'scale(1.05)' }}
transition="all 0.2s"
/>
<AdminSupportButton />
<ThemeToggle />
</HStack>
}
/>
<Box as="main" className="admin-main" p={{ base: 4, md: 8 }} pb={{ base: 8, md: 12 }}>
{children}
</Box>
{/* Admin Search Modal */}
<AdminSearchModal
isOpen={isSearchOpen}
onClose={onSearchClose}
onSelectPath={(path: string) => navigate(path)}
/>
</Box>
{/* Admin Search Modal */}
<AdminSearchModal
isOpen={isSearchOpen}
onClose={onSearchClose}
onSelectPath={(path: string) => navigate(path)}
/>
</Box>
</Box>
);
};
@@ -0,0 +1,164 @@
// Ultimate scroll retention fix - inline script
// Add this directly to AdminLayout component as a script tag or useEffect
// This script bypasses React entirely and uses direct DOM manipulation
export {}; // Make this a module for TypeScript isolatedModules
(function initUltimateScrollRetention() {
console.log('[UltimateScrollRetention] Initializing...');
let savedScrollPosition = 0;
let isNavigating = false;
let restorationTimeouts: NodeJS.Timeout[] = [];
// Save scroll position
const saveScrollPosition = () => {
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (sidebar && sidebar.scrollTop > 0) {
savedScrollPosition = sidebar.scrollTop;
console.log('[UltimateScrollRetention] Saved position:', savedScrollPosition);
}
};
// Restore scroll position with maximum aggression
const restoreScrollPosition = () => {
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (!sidebar || savedScrollPosition === 0) return;
console.log('[UltimateScrollRetention] Restoring position:', savedScrollPosition);
// Clear any existing restoration timeouts
restorationTimeouts.forEach(timeout => clearTimeout(timeout));
restorationTimeouts = [];
// Immediate restoration
sidebar.scrollTop = savedScrollPosition;
// Aggressive restoration attempts
const attempts = [
{ delay: 0, method: () => sidebar.scrollTop = savedScrollPosition },
{ delay: 25, method: () => sidebar.scrollTop = savedScrollPosition },
{ delay: 50, method: () => sidebar.scrollTop = savedScrollPosition },
{ delay: 100, method: () => sidebar.scrollTop = savedScrollPosition },
{ delay: 200, method: () => sidebar.scrollTop = savedScrollPosition },
{ delay: 300, method: () => sidebar.scrollTop = savedScrollPosition },
{ delay: 500, method: () => sidebar.scrollTop = savedScrollPosition },
{ delay: 750, method: () => sidebar.scrollTop = savedScrollPosition },
{ delay: 1000, method: () => sidebar.scrollTop = savedScrollPosition },
{ delay: 1500, method: () => sidebar.scrollTop = savedScrollPosition },
{ delay: 2000, method: () => sidebar.scrollTop = savedScrollPosition },
];
attempts.forEach(({ delay, method }) => {
const timeout = setTimeout(() => {
method();
console.log(`[UltimateScrollRetention] Restore attempt at ${delay}ms:`, sidebar.scrollTop);
// If still not correct, force it
if (sidebar.scrollTop !== savedScrollPosition) {
sidebar.scrollTop = savedScrollPosition;
}
}, delay);
restorationTimeouts.push(timeout);
});
// Final cleanup
setTimeout(() => {
isNavigating = false;
console.log('[UltimateScrollRetention] Navigation lock released');
}, 2500);
};
// Intercept ALL navigation methods
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = function(...args) {
if (!isNavigating) {
isNavigating = true;
saveScrollPosition();
console.log('[UltimateScrollRetention] PushState intercepted');
// Start restoration immediately
setTimeout(restoreScrollPosition, 10);
}
return originalPushState.apply(this, args);
};
history.replaceState = function(...args) {
if (!isNavigating) {
isNavigating = true;
saveScrollPosition();
console.log('[UltimateScrollRetention] ReplaceState intercepted');
setTimeout(restoreScrollPosition, 10);
}
return originalReplaceState.apply(this, args);
};
// Handle popstate (back/forward buttons)
const handlePopState = () => {
if (!isNavigating) {
isNavigating = true;
saveScrollPosition();
console.log('[UltimateScrollRetention] PopState intercepted');
setTimeout(restoreScrollPosition, 10);
}
};
window.addEventListener('popstate', handlePopState);
// Continuous scroll saving
const handleScroll = () => {
if (!isNavigating) {
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (sidebar && sidebar.scrollTop > 0) {
savedScrollPosition = sidebar.scrollTop;
}
}
};
// Add scroll listener to sidebar
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (sidebar) {
sidebar.addEventListener('scroll', handleScroll, { passive: true });
console.log('[UltimateScrollRetention] Scroll listener attached');
}
// MutationObserver to catch DOM changes that might reset scroll
const observer = new MutationObserver(() => {
if (isNavigating && savedScrollPosition > 0) {
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (sidebar && sidebar.scrollTop !== savedScrollPosition) {
sidebar.scrollTop = savedScrollPosition;
console.log('[UltimateScrollRetention] MutationObserver restored scroll');
}
}
});
// Observe the sidebar and its parents
if (sidebar) {
observer.observe(sidebar, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class']
});
console.log('[UltimateScrollRetention] MutationObserver attached');
}
console.log('[UltimateScrollRetention] Setup complete');
// Cleanup function (if needed)
return () => {
history.pushState = originalPushState;
history.replaceState = originalReplaceState;
window.removeEventListener('popstate', handlePopState);
if (sidebar) {
sidebar.removeEventListener('scroll', handleScroll);
}
observer.disconnect();
restorationTimeouts.forEach(timeout => clearTimeout(timeout));
};
})();
@@ -0,0 +1,141 @@
// Add this directly to AdminLayout.tsx as a useEffect
// This bypasses all React hooks and uses pure JavaScript
import { useEffect } from 'react';
export {}; // Make this a module for TypeScript isolatedModules
useEffect(() => {
console.log('[InlineScrollRetention] Setting up...');
let savedScrollPosition = 0;
let isNavigating = false;
let restorationTimeouts: number[] = [];
const saveScrollPosition = () => {
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (sidebar && sidebar.scrollTop > 0) {
savedScrollPosition = sidebar.scrollTop;
console.log('[InlineScrollRetention] Saved position:', savedScrollPosition);
}
};
const restoreScrollPosition = () => {
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (!sidebar || savedScrollPosition === 0) return;
console.log('[InlineScrollRetention] Restoring position:', savedScrollPosition);
// Clear existing timeouts
restorationTimeouts.forEach(timeout => clearTimeout(timeout));
restorationTimeouts = [];
// Aggressive restoration
const attempts = [0, 25, 50, 100, 200, 300, 500, 750, 1000, 1500, 2000];
attempts.forEach(delay => {
const timeout = window.setTimeout(() => {
sidebar.scrollTop = savedScrollPosition;
console.log(`[InlineScrollRetention] Restore at ${delay}ms:`, sidebar.scrollTop);
// Force if needed
if (sidebar.scrollTop !== savedScrollPosition) {
sidebar.scrollTop = savedScrollPosition;
}
}, delay);
restorationTimeouts.push(timeout);
});
// Release navigation lock
window.setTimeout(() => {
isNavigating = false;
console.log('[InlineScrollRetention] Navigation lock released');
}, 2500);
};
// Intercept navigation
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = function(...args) {
if (!isNavigating) {
isNavigating = true;
saveScrollPosition();
console.log('[InlineScrollRetention] PushState intercepted');
setTimeout(restoreScrollPosition, 10);
}
return originalPushState.apply(this, args);
};
history.replaceState = function(...args) {
if (!isNavigating) {
isNavigating = true;
saveScrollPosition();
console.log('[InlineScrollRetention] ReplaceState intercepted');
setTimeout(restoreScrollPosition, 10);
}
return originalReplaceState.apply(this, args);
};
// Handle back/forward
const handlePopState = () => {
if (!isNavigating) {
isNavigating = true;
saveScrollPosition();
console.log('[InlineScrollRetention] PopState intercepted');
setTimeout(restoreScrollPosition, 10);
}
};
window.addEventListener('popstate', handlePopState);
// Scroll listener
const handleScroll = () => {
if (!isNavigating) {
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (sidebar && sidebar.scrollTop > 0) {
savedScrollPosition = sidebar.scrollTop;
}
}
};
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (sidebar) {
sidebar.addEventListener('scroll', handleScroll, { passive: true });
console.log('[InlineScrollRetention] Scroll listener attached');
}
// MutationObserver
const observer = new MutationObserver(() => {
if (isNavigating && savedScrollPosition > 0) {
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
if (sidebar && sidebar.scrollTop !== savedScrollPosition) {
sidebar.scrollTop = savedScrollPosition;
console.log('[InlineScrollRetention] MutationObserver restored scroll');
}
}
});
if (sidebar) {
observer.observe(sidebar, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class']
});
console.log('[InlineScrollRetention] MutationObserver attached');
}
console.log('[InlineScrollRetention] Setup complete');
return () => {
history.pushState = originalPushState;
history.replaceState = originalReplaceState;
window.removeEventListener('popstate', handlePopState);
if (sidebar) {
sidebar.removeEventListener('scroll', handleScroll);
}
observer.disconnect();
restorationTimeouts.forEach(timeout => clearTimeout(timeout));
};
}, []);
+185
View File
@@ -0,0 +1,185 @@
{
"common.welcome_message": "Vítejte v našem fotbalovém klubu",
"common.welcome_subtitle": "Oficiální stránky fotbalového klubu",
"common.yes": "Ano",
"common.no": "Ne",
"common.ok": "OK",
"common.close": "Zavřít",
"common.back": "Zpět",
"common.next": "Další",
"common.previous": "Předchozí",
"common.loading": "Načítání...",
"common.search": "Hledat",
"common.filter": "Filtr",
"common.sort": "Seřadit",
"common.export": "Exportovat",
"common.import": "Importovat",
"common.print": "Tisknout",
"common.share": "Sdílet",
"common.copy": "Kopírovat",
"common.paste": "Vložit",
"common.cut": "Vyjmout",
"common.select_all": "Vybrat vše",
"common.clear": "Vymazat",
"common.refresh": "Obnovit",
"common.settings": "Nastavení",
"common.help": "Nápověda",
"common.about": "O aplikaci",
"common.version": "Verze",
"common.copyright": "Autorská práva",
"common.all_rights_reserved": "Všechna práva vyhrazena",
"nav.home": "Domů",
"nav.news": "Aktuality",
"nav.matches": "Zápasy",
"nav.players": "Hráči",
"nav.gallery": "Galerie",
"nav.videos": "Videa",
"nav.contact": "Kontakt",
"nav.about": "O klubu",
"nav.activities": "Aktivity",
"nav.sponsors": "Sponzoři",
"nav.table": "Tabulka",
"action.save": "Uložit",
"action.cancel": "Zrušit",
"action.edit": "Upravit",
"action.delete": "Smazat",
"action.create": "Vytvořit",
"action.update": "Aktualizovat",
"action.publish": "Publikovat",
"action.unpublish": "Skrýt",
"form.required": "Toto pole je povinné",
"form.email": "E-mail",
"form.password": "Heslo",
"form.name": "Jméno",
"form.message": "Zpráva",
"form.submit": "Odeslat",
"message.success": "Operace proběhla úspěšně",
"message.error": "Došlo k chybě",
"message.loading": "Načítám...",
"message.no_data": "Žádná data",
"admin.dashboard": "Nástěnka",
"admin.articles": "Články",
"admin.matches": "Zápasy",
"admin.players": "Hráči",
"admin.settings": "Nastavení",
"admin.users": "Uživatelé",
"content.read_more": "Číst více",
"content.published_at": "Publikováno",
"content.updated_at": "Aktualizováno",
"content.author": "Autor",
"match.date": "Datum",
"match.time": "Čas",
"match.place": "Místo",
"match.result": "Výsledek",
"match.score": "Skóre",
"match.team_home": "Domácí",
"match.team_away": "Hosté",
"team.name": "Název týmu",
"team.players": "Hráči",
"team.coach": "Trenér",
"team.category": "Kategorie",
"gallery.albums": "Alba",
"gallery.photos": "Fotky",
"gallery.view_all": "Zobrazit vše",
"search.placeholder": "Hledat...",
"search.results": "Výsledky hledání",
"search.no_results": "Nebyly nalezeny žádné výsledky",
"pagination.previous": "Předchozí",
"pagination.next": "Další",
"pagination.page": "Strana",
"pagination.of": "z",
"date.today": "Dnes",
"date.yesterday": "Včera",
"date.tomorrow": "Zítra",
"date.format": "DD.MM.YYYY",
"date.time_format": "HH:mm",
"auth.login": "Přihlásit",
"auth.logout": "Odhlásit",
"auth.register": "Registrovat",
"auth.forgot_password": "Zapomněl jsem heslo",
"auth.reset_password": "Obnovit heslo",
"auth.profile": "Profil",
"auth.change_password": "Změnit heslo",
"error.404": "Stránka nenalezena",
"error.500": "Interní chyba serveru",
"error.403": "Přístup zakázán",
"error.401": "Neautorizovaný přístup",
"error.network": "Chyba sítě",
"error.validation": "Chyba validace",
"calendar.title": "Kalendář",
"calendar.subtitle": "Přehled všech zápasů v sezóně",
"calendar.loading": "Načítám kalendář...",
"calendar.no_matches": "Žádné zápasy nenalezeny",
"calendar.all_competitions": "Všechny soutěže",
"calendar.list_view": "Seznam",
"calendar.today": "Dnes",
"calendar.previous_month": "Předchozí měsíc",
"calendar.next_month": "Další měsíc",
"calendar.show_more": "+{{count}} další…",
"calendar.show_less": "Zobrazit méně",
"calendar.show_past_matches": "Zobrazit minulé zápasy ({{count}})",
"calendar.hide_past_matches": "Skrýt minulé zápasy",
"calendar.win": "Výhra",
"calendar.loss": "Prohra",
"calendar.draw": "Remíza",
"homepage.more_videos": "Více videí",
"action.play": "Přehrát",
"action.open_on_youtube": "Otevřít na YouTube",
"table.headers.position": "#",
"table.headers.team": "Tým",
"table.headers.played": "Z",
"table.headers.wins": "V",
"table.headers.draws": "R",
"table.headers.losses": "P",
"table.headers.score": "Skóre",
"table.headers.points": "Body",
"matches.upcoming": "Nadcházející zápasy",
"matches.loading": "Načítám zápasy...",
"matches.error": "Nepodařilo se načíst zápasy. Zkuste to prosím později.",
"matches.none_found": "Žádné nadcházející zápasy nebyly nalezeny.",
"matches.subscribe_prompt": "Chcete dostávat novinky o zápasech emailem?",
"matches.subscribe": "Odebírat",
"form.email_required": "Zadejte email",
"form.email_placeholder": "váš@email.cz",
"matches.subscribe_success": "Přihlášeno k odběru",
"matches.subscribe_error": "Chyba přihlášení",
"homepage.all_matches": "Všechny zápasy",
"matches.load_more_matches": "Načíst více",
"matches.showing_matches": "Zobrazuji {{shown}} z {{total}} zápasů",
"matches.all_categories": "Všechny soutěže",
"matches.no_matches_to_display": "Žádné zápasy k zobrazení",
"matches.check_club_settings": "Zkontrolujte nastavení klubu",
"matches.oldest_first": "Nejstarší první",
"matches.newest_first": "Nejnovější první",
"matches.all_matches": "Všechny zápasy",
"matches.win": "Výhra",
"matches.draw": "Remíza",
"matches.loss": "Prohra",
"matches.played": "Odehráno",
"club_modal.statistics": "Statistiky",
"club_modal.matches_played": "Odehráno zápasů",
"club_modal.wins": "Výhry",
"club_modal.draws": "Remízy",
"club_modal.losses": "Prohry",
"club_modal.score": "Skóre",
"club_modal.goals_scored": "Vstřelené góly",
"club_modal.goals_conceded": "Obdržené góly",
"club_modal.goal_difference": "Rozdíl skóre",
"club_modal.points": "Body",
"club_modal.form_last_5": "Forma (posledních 5 zápasů)",
"club_modal.close": "Zavřít",
"table.position_place": "{{position}}. místo",
"tables.title": "Tabulky",
"tables.subtitle": "Aktuální tabulky a soutěže",
"tables.loading": "Načítám tabulky...",
"tables.no_tables": "Žádné tabulky k dispozici",
"tables.schedule_link": "Program",
"tables.rank": "#",
"tables.team": "Tým",
"tables.played": "Z",
"tables.wins": "V",
"tables.draws": "R",
"tables.losses": "P",
"tables.score": "Skóre",
"tables.points": "Body"
}
+186
View File
@@ -0,0 +1,186 @@
{
"common.welcome_message": "Welcome to our football club",
"common.welcome_subtitle": "Official website of the football club",
"common.yes": "Yes",
"common.no": "No",
"common.ok": "OK",
"common.close": "Close",
"common.back": "Back",
"common.next": "Next",
"common.previous": "Previous",
"common.loading": "Loading...",
"common.search": "Search",
"common.filter": "Filter",
"common.sort": "Sort",
"common.export": "Export",
"common.import": "Import",
"common.print": "Print",
"common.share": "Share",
"common.copy": "Copy",
"common.paste": "Paste",
"common.cut": "Cut",
"common.select_all": "Select All",
"common.clear": "Clear",
"common.refresh": "Refresh",
"common.settings": "Settings",
"common.help": "Help",
"common.about": "About",
"common.version": "Version",
"common.copyright": "Copyright",
"common.all_rights_reserved": "All rights reserved",
"nav.home": "Home",
"nav.news": "News",
"nav.matches": "Matches",
"nav.players": "Players",
"nav.gallery": "Photos",
"nav.videos": "Videos",
"nav.contact": "Contact",
"nav.about": "About Club",
"nav.activities": "Activities",
"nav.sponsors": "Sponsors",
"nav.calendar": "Calendar",
"nav.table": "Table",
"action.save": "Save",
"action.cancel": "Cancel",
"action.edit": "Edit",
"action.delete": "Delete",
"action.create": "Create",
"action.update": "Update",
"action.publish": "Publish",
"action.unpublish": "Hide",
"action.play": "Play",
"action.open_on_youtube": "Open on YouTube",
"form.required": "This field is required",
"form.email": "E-mail",
"form.password": "Password",
"form.name": "Name",
"form.message": "Message",
"form.submit": "Submit",
"message.success": "Operation completed successfully",
"message.error": "An error occurred",
"message.loading": "Loading...",
"message.no_data": "No data",
"admin.dashboard": "Dashboard",
"admin.articles": "Articles",
"admin.matches": "Matches",
"admin.players": "Players",
"admin.settings": "Settings",
"admin.users": "Users",
"content.read_more": "Read more",
"content.published_at": "Published",
"content.updated_at": "Updated",
"content.author": "Author",
"match.date": "Date",
"match.time": "Time",
"match.place": "Place",
"match.result": "Result",
"match.score": "Score",
"match.team_home": "Home",
"match.team_away": "Away",
"team.name": "Team Name",
"team.players": "Players",
"team.coach": "Coach",
"team.category": "Category",
"gallery.albums": "Albums",
"gallery.photos": "Photos",
"gallery.view_all": "View All",
"search.placeholder": "Search...",
"search.results": "Search Results",
"search.no_results": "No results found",
"pagination.previous": "Previous",
"pagination.next": "Next",
"pagination.page": "Page",
"pagination.of": "of",
"date.today": "Today",
"date.yesterday": "Yesterday",
"date.tomorrow": "Tomorrow",
"date.format": "DD.MM.YYYY",
"date.time_format": "HH:mm",
"auth.login": "Login",
"auth.logout": "Logout",
"auth.register": "Register",
"auth.forgot_password": "Forgot Password",
"auth.reset_password": "Reset Password",
"auth.profile": "Profile",
"auth.change_password": "Change Password",
"error.404": "Page Not Found",
"error.500": "Internal Server Error",
"error.403": "Access Denied",
"error.401": "Unauthorized Access",
"error.network": "Network Error",
"error.validation": "Validation Error",
"calendar.title": "Calendar",
"calendar.subtitle": "Overview of all matches in the season",
"calendar.loading": "Loading calendar...",
"calendar.no_matches": "No matches found",
"calendar.all_competitions": "All Competitions",
"calendar.list_view": "List",
"calendar.today": "Today",
"calendar.previous_month": "Previous Month",
"calendar.next_month": "Next Month",
"calendar.show_more": "+{{count}} more…",
"calendar.show_less": "Show Less",
"calendar.show_past_matches": "Show Past Matches ({{count}})",
"calendar.hide_past_matches": "Hide Past Matches",
"calendar.win": "Win",
"calendar.loss": "Loss",
"calendar.draw": "Draw",
"homepage.more_videos": "More Videos",
"table.headers.position": "#",
"table.headers.team": "Team",
"table.headers.played": "P",
"table.headers.wins": "W",
"table.headers.draws": "D",
"table.headers.losses": "L",
"table.headers.score": "Score",
"table.headers.points": "Points",
"matches.upcoming": "Upcoming Matches",
"matches.loading": "Loading matches...",
"matches.error": "Failed to load matches. Please try again later.",
"matches.none_found": "No upcoming matches found.",
"matches.subscribe_prompt": "Want to receive match news by email?",
"matches.subscribe": "Subscribe",
"form.email_required": "Please enter email",
"form.email_placeholder": "your@email.com",
"matches.subscribe_success": "Subscribed successfully",
"matches.subscribe_error": "Subscription error",
"homepage.all_matches": "All Matches",
"matches.load_more_matches": "Load More",
"matches.showing_matches": "Showing {{shown}} of {{total}} matches",
"matches.all_categories": "All Categories",
"matches.no_matches_to_display": "No matches to display",
"matches.check_club_settings": "Check club settings",
"matches.oldest_first": "Oldest First",
"matches.newest_first": "Newest First",
"matches.all_matches": "All Matches",
"matches.win": "Win",
"matches.draw": "Draw",
"matches.loss": "Loss",
"matches.played": "Played",
"club_modal.statistics": "Statistics",
"club_modal.matches_played": "Matches Played",
"club_modal.wins": "Wins",
"club_modal.draws": "Draws",
"club_modal.losses": "Losses",
"club_modal.score": "Score",
"club_modal.goals_scored": "Goals Scored",
"club_modal.goals_conceded": "Goals Conceded",
"club_modal.goal_difference": "Goal Difference",
"club_modal.points": "Points",
"club_modal.form_last_5": "Form (Last 5 Matches)",
"club_modal.close": "Close",
"table.position_place": "{{position}}. place",
"tables.title": "Tables",
"tables.subtitle": "Current standings and league tables",
"tables.loading": "Loading tables...",
"tables.no_tables": "No tables available",
"tables.schedule_link": "Schedule",
"tables.rank": "#",
"tables.team": "Team",
"tables.played": "P",
"tables.wins": "W",
"tables.draws": "D",
"tables.losses": "L",
"tables.score": "Score",
"tables.points": "Points"
}
+35 -11
View File
@@ -17,6 +17,7 @@ import { Helmet } from 'react-helmet-async';
import api from '../services/api';
import DOMPurify from 'dompurify';
import { usePublicSettings } from '../hooks/usePublicSettings';
import { useTranslation } from 'react-i18next';
import MainLayout from '../components/layout/MainLayout';
import CompetitionMatches from '../components/home/CompetitionMatches';
import { getCategories, CategoryItem } from '../services/categories';
@@ -35,6 +36,7 @@ type AboutPageData = {
};
const AboutPage: React.FC = () => {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [data, setData] = useState<AboutPageData | null>(null);
const [error, setError] = useState<string>('');
@@ -106,7 +108,7 @@ const AboutPage: React.FC = () => {
if (categories.length === 0) return null;
return (
<Box mt={marginTop}>
<Heading as="h2" size="md" mb={4}>Rubriky</Heading>
<Heading as="h2" size="md" mb={4}>{t('blog.categories')}</Heading>
<VStack align="stretch" spacing={3}>
{categories.map((c) => (
<Stack
@@ -141,7 +143,7 @@ const AboutPage: React.FC = () => {
fontWeight="bold"
w={{ base: 'full', sm: 'auto' }}
>
Otevřít
{t('action.open')}
</Button>
</Stack>
))}
@@ -154,19 +156,19 @@ const AboutPage: React.FC = () => {
return (
<MainLayout>
<Helmet>
<title>{settings?.club_name ? `O klubu | ${settings.club_name}` : 'O klubu'}</title>
<meta name="description" content="Informace o našem klubu, soutěžích, nadcházejících zápasech a rubrikách." />
<title>{settings?.club_name ? `${t('nav.club')} | ${settings.club_name}` : t('nav.club')}</title>
<meta name="description" content={t('about.meta_description')} />
{settings?.club_logo_url && <meta property="og:image" content={assetUrl(settings.club_logo_url) || settings.club_logo_url} />}
</Helmet>
<Container maxW="container.lg" py={8}>
<Box textAlign="center" py={6}>
<Heading size="xl" mb={2}>O klubu</Heading>
<Text color={textSecondary}>Tato stránka ještě není nastavena. Zde je přehled klubu, soutěží a rubrik.</Text>
<Heading size="xl" mb={2}>{t('nav.club')}</Heading>
<Text color={textSecondary}>{t('about.page_not_setup')}</Text>
</Box>
{/* Matches slider by competition (FACR) */}
<Box mt={4} mb={10}>
<Heading as="h2" size="md" mb={4}>Zápasy podle soutěží</Heading>
<Heading as="h2" size="md" mb={4}>{t('about.matches_by_competition')}</Heading>
<CompetitionMatches />
</Box>
{renderCategoriesList()}
@@ -179,7 +181,11 @@ const AboutPage: React.FC = () => {
const seoDesc = data.seo_description || data.subtitle;
const clubName = settings?.club_name || data.title;
const clubLogo = settings?.club_logo_url ? (assetUrl(settings.club_logo_url) || settings.club_logo_url) : undefined;
const cleanContent = DOMPurify.sanitize(data.content);
const cleanContent = DOMPurify.sanitize(data.content, {
USE_PROFILES: { html: true },
ADD_TAGS: ['iframe'],
ADD_ATTR: ['class', 'style', 'data-bullets', 'data-list', 'target', 'rel', 'allow', 'allowfullscreen'],
});
const renderContent = () => {
switch (data.style) {
@@ -223,7 +229,13 @@ const AboutPage: React.FC = () => {
'& h2': { fontSize: 'xl' },
'& h3': { fontSize: 'lg' },
'& img': { maxW: '100%', borderRadius: 'md', my: 6 },
'& ul, & ol': { pl: 8, mb: 4 },
'& ul, & ol': { pl: 8, mb: 4, listStylePosition: 'outside' },
'& ul': { listStyleType: 'disc' },
'& ul[data-bullets="disc"]': { listStyleType: 'disc' },
'& ul[data-bullets="circle"]': { listStyleType: 'circle' },
'& ul[data-bullets="square"]': { listStyleType: 'square' },
'& ul[data-bullets="none"]': { listStyleType: 'none' },
'& ol': { listStyleType: 'decimal' },
'& li': { mb: 2 },
}}
/>
@@ -283,7 +295,13 @@ const AboutPage: React.FC = () => {
boxShadow: 'md',
},
},
'& ul, & ol': { pl: 12, mb: 4 },
'& ul, & ol': { pl: 12, mb: 4, listStylePosition: 'outside' },
'& ul': { listStyleType: 'disc' },
'& ul[data-bullets="disc"]': { listStyleType: 'disc' },
'& ul[data-bullets="circle"]': { listStyleType: 'circle' },
'& ul[data-bullets="square"]': { listStyleType: 'square' },
'& ul[data-bullets="none"]': { listStyleType: 'none' },
'& ol': { listStyleType: 'decimal' },
'& li': { mb: 2 },
'& img': { maxW: '100%', borderRadius: 'md', my: 6, ml: 12 },
}}
@@ -336,7 +354,13 @@ const AboutPage: React.FC = () => {
'& h2': { fontSize: 'xl' },
'& h3': { fontSize: 'lg' },
'& img': { maxW: '100%', borderRadius: 'md', my: 4 },
'& ul, & ol': { pl: 8, mb: 4 },
'& ul, & ol': { pl: 8, mb: 4, listStylePosition: 'outside' },
'& ul': { listStyleType: 'disc' },
'& ul[data-bullets="disc"]': { listStyleType: 'disc' },
'& ul[data-bullets="circle"]': { listStyleType: 'circle' },
'& ul[data-bullets="square"]': { listStyleType: 'square' },
'& ul[data-bullets="none"]': { listStyleType: 'none' },
'& ol': { listStyleType: 'decimal' },
'& li': { mb: 2 },
}}
/>
+31 -21
View File
@@ -27,9 +27,10 @@ import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
import { addMonths, format, isSameDay, isSameMonth, startOfMonth, startOfWeek, addDays, parse } from 'date-fns';
import { cs } from 'date-fns/locale';
import { getEvents } from '../services/eventService';
import { useTranslation } from 'react-i18next';
// Weekday headers (Czech, starting Monday)
const WEEKDAYS_SHORT: string[] = ['Po', 'Út', 'St', 'Čt', '', 'So', 'Ne'];
// Weekday headers (starting Monday)
const WEEKDAYS_SHORT: string[] = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
type UiEvent = {
id: number | string;
@@ -41,7 +42,15 @@ type UiEvent = {
image_url?: string | null;
};
const typeLabel = (t?: string) => (t === 'match' ? 'Zápas' : t === 'training' ? 'Trénink' : t === 'meeting' ? 'Schůzka' : 'Jiné');
const typeLabel = (t?: string, translate?: (key: string) => string) => {
if (!translate) return t === 'match' ? 'Zápas' : t === 'training' ? 'Trénink' : t === 'meeting' ? 'Schůzka' : 'Jiné';
switch (t) {
case 'match': return translate('calendar.match_type');
case 'training': return translate('calendar.training_type');
case 'meeting': return translate('calendar.meeting_type');
default: return translate('calendar.other_type');
}
};
const typeColor = (t?: string) => (t === 'match' ? 'red' : t === 'training' ? 'blue' : t === 'meeting' ? 'green' : 'gray');
const toDateKey = (iso: string) => {
@@ -49,6 +58,7 @@ const toDateKey = (iso: string) => {
};
const ActivitiesCalendarPage: React.FC = () => {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [events, setEvents] = useState<UiEvent[]>([]);
@@ -126,7 +136,7 @@ const ActivitiesCalendarPage: React.FC = () => {
}));
if (active) setEvents(mapped);
} catch (e: any) {
if (active) setError(e?.message || 'Nepodařilo se načíst aktivity.');
if (active) setError(e?.message || t('message.error_occurred'));
} finally {
if (active) setLoading(false);
}
@@ -139,17 +149,17 @@ const ActivitiesCalendarPage: React.FC = () => {
return (
<MainLayout>
<Container maxW="7xl" py={{ base: 6, md: 10 }}>
<Heading as="h1" size="xl" mb={2}>Aktivity</Heading>
<Text color={mutedText} mb={6}>Kalendář tréninků, schůzek a dalších klubových aktivit.</Text>
<Heading as="h1" size="xl" mb={2}>{t('nav.activities')}</Heading>
<Text color={mutedText} mb={6}>{t('calendar.activities_subtitle')}</Text>
<Flex align="center" justify="space-between" mb={3} gap={2} flexWrap={{ base: 'wrap', md: 'nowrap' }}>
<Flex align="center" gap={2}>
<Select size="sm" value={filter} onChange={(e) => setFilter(e.target.value as any)} width={{ base: '100%', sm: '220px' }}>
<option value="all">Všechny typy</option>
<option value="match">Zápasy</option>
<option value="training">Tréninky</option>
<option value="meeting">Schůzky</option>
<option value="other">Ostatní</option>
<option value="all">{t('calendar.all_types')}</option>
<option value="match">{t('calendar.matches_filter')}</option>
<option value="training">{t('calendar.training_filter')}</option>
<option value="meeting">{t('calendar.meeting_filter')}</option>
<option value="other">{t('calendar.other_filter')}</option>
</Select>
</Flex>
<ButtonGroup size="sm" isAttached>
@@ -159,21 +169,21 @@ const ActivitiesCalendarPage: React.FC = () => {
color={viewMode==='calendar' ? 'text.onPrimary' : undefined}
_hover={{ filter: viewMode==='calendar' ? 'brightness(0.95)' : undefined, borderColor: 'brand.primary', color: viewMode==='calendar' ? 'text.onPrimary' : undefined }}
onClick={() => setViewMode('calendar')}
>Kalendář</Button>
>{t('calendar.calendar_view')}</Button>
<Button
variant={viewMode==='list' ? 'solid' : 'outline'}
bg={viewMode==='list' ? 'brand.primary' : undefined}
color={viewMode==='list' ? 'text.onPrimary' : undefined}
_hover={{ filter: viewMode==='list' ? 'brightness(0.95)' : undefined, borderColor: 'brand.primary', color: viewMode==='list' ? 'text.onPrimary' : undefined }}
onClick={() => setViewMode('list')}
>Seznam</Button>
>{t('calendar.list_view')}</Button>
</ButtonGroup>
</Flex>
{loading && (
<Flex align="center" gap={3} color={mutedText} mb={6}>
<Spinner size="sm" />
<span>Načítám aktivity</span>
<span>{t('calendar.loading_activities')}</span>
</Flex>
)}
@@ -182,7 +192,7 @@ const ActivitiesCalendarPage: React.FC = () => {
)}
{!loading && !hasData && !error && (
<Box color={mutedText}>Zatím nemáme žádné aktivity k zobrazení.</Box>
<Box color={mutedText}>{t('calendar.no_activities')}</Box>
)}
{!loading && hasData && (
@@ -190,7 +200,7 @@ const ActivitiesCalendarPage: React.FC = () => {
<>
<Flex align="center" justify="space-between" mb={3} gap={2} flexWrap={{ base: 'wrap', md: 'nowrap' }}>
<IconButton
aria-label="Předchozí měsíc"
aria-label={t('calendar.previous_month')}
size="sm"
onClick={() => setMonthRef(addMonths(monthRef, -1))}
icon={<ChevronLeftIcon />}
@@ -204,9 +214,9 @@ const ActivitiesCalendarPage: React.FC = () => {
bg={'brand.primary'}
color={'text.onPrimary'}
_hover={{ filter: 'brightness(0.95)' }}
>Dnes</Button>
>{t('calendar.today')}</Button>
<IconButton
aria-label="Další měsíc"
aria-label={t('calendar.next_month')}
size="sm"
onClick={() => setMonthRef(addMonths(monthRef, 1))}
icon={<ChevronRightIcon />}
@@ -273,7 +283,7 @@ const ActivitiesCalendarPage: React.FC = () => {
_hover={{ bg: 'rgba(0,0,0,0.03)', borderColor: 'brand.primary' }}
>
<Flex align="center" gap={2}>
<Badge colorScheme={typeColor(e.type)}>{typeLabel(e.type)}</Badge>
<Badge colorScheme={typeColor(e.type)}>{typeLabel(e.type, t)}</Badge>
<Text fontSize="xs">
{format(new Date(e.start_time), 'HH:mm')}
{e.end_time && !isSameDay(new Date(e.start_time), new Date(e.end_time))
@@ -285,7 +295,7 @@ const ActivitiesCalendarPage: React.FC = () => {
</Box>
))}
{list.length > 3 && (
<Text fontSize="xs" color={subtleText}>+{list.length - 3} další</Text>
<Text fontSize="xs" color={subtleText}>{t('calendar.more_events', { count: list.length - 3 })}</Text>
)}
</Stack>
</Box>
@@ -335,7 +345,7 @@ const ActivitiesCalendarPage: React.FC = () => {
{e.location && <Text color={mutedText} fontSize="sm">{e.location}</Text>}
</Flex>
<Flex align="center" gap={2} flex="1" justify="center">
<Badge colorScheme={typeColor(e.type)}>{typeLabel(e.type)}</Badge>
<Badge colorScheme={typeColor(e.type)}>{typeLabel(e.type, t)}</Badge>
<Text>{e.title}</Text>
</Flex>
</Flex>
+12 -4
View File
@@ -114,6 +114,8 @@ const ActivityDetailPage: React.FC = () => {
const mutedText = useColorModeValue('gray.600', 'gray.300');
const linkColor = useColorModeValue('blue.600', 'blue.300');
const linkHoverColor = useColorModeValue('blue.700', 'blue.200');
const blockquoteText = useColorModeValue('#4a5568', '#cbd5e0');
const blockquoteBg = useColorModeValue('#f7fafc', '#1a202c');
return (
<MainLayout>
@@ -177,17 +179,23 @@ const ActivityDetailPage: React.FC = () => {
p={5}
className="blog-content"
sx={{
' ul, ol': { pl: 6, listStylePosition: 'outside', mb: 3 },
' ul': { listStyleType: 'disc' },
' ul[data-bullets="disc"]': { listStyleType: 'disc' },
' ul[data-bullets="circle"]': { listStyleType: 'circle' },
' ul[data-bullets="square"]': { listStyleType: 'square' },
' ol': { listStyleType: 'decimal' },
' li': { mb: 2 },
' h1, h2, h3, h4': { fontWeight: 'bold', mt: 3 },
' p': { lineHeight: 1.8, mb: 3 },
' ul, ol': { pl: 6, mb: 3 },
' a': { color: linkColor, textDecoration: 'underline', _hover: { color: linkHoverColor } },
' blockquote': {
borderLeft: '4px solid #3182ce',
paddingLeft: '16px',
margin: '1em 0',
color: useColorModeValue('#4a5568','#cbd5e0'),
color: blockquoteText,
fontStyle: 'italic',
backgroundColor: useColorModeValue('#f7fafc','#1a202c'),
backgroundColor: blockquoteBg,
padding: '12px 16px',
borderRadius: '4px',
},
@@ -207,7 +215,7 @@ const ActivityDetailPage: React.FC = () => {
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(toAbsoluteUploads(String(data.description)), {
USE_PROFILES: { html: true },
ADD_TAGS: ['iframe'],
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id'],
ADD_ATTR: ['class', 'target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id', 'data-bullets', 'data-list'],
}) }}
/>
)}
+75 -14
View File
@@ -24,6 +24,7 @@ import { API_URL } from '../services/api';
import PhotoModal from '../components/gallery/PhotoModal';
import CommentsSection from '../components/comments/CommentsSection';
import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next';
interface Photo {
id: string;
@@ -57,6 +58,7 @@ const resolveBackendUrl = (path: string) => {
};
const AlbumDetailPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const [album, setAlbum] = useState<Album | null>(null);
const [loading, setLoading] = useState(true);
@@ -103,9 +105,54 @@ const AlbumDetailPage: React.FC = () => {
const blogAlbums = Array.isArray(albumsData) ? albumsData : [];
foundAlbum = blogAlbums.find((a: Album) => a.id === id);
}
// Fallback: fetch album directly via zonerama-album API using the numeric ID
if (!foundAlbum) {
throw new Error('Album nenalezen');
try {
const albumUrl = `https://eu.zonerama.com/Album/${id}`;
const params = new URLSearchParams({
link: albumUrl,
photo_limit: '200',
rendered: 'true',
});
const resp = await fetch(`${API_URL}/zonerama-album?${params.toString()}`);
if (resp.ok) {
const payload = await resp.json();
let albumData: any = null;
let photos: any[] = [];
if (Array.isArray(payload?.albums) && payload.albums.length > 0) {
albumData = payload.albums[0];
photos = albumData.photos || [];
} else if (payload?.album && Array.isArray(payload?.photos)) {
albumData = payload.album;
photos = payload.photos;
}
if (albumData) {
const mapped: Album = {
id: String(albumData.id || id),
title: String(albumData.title || ''),
url: String(albumData.url || albumUrl),
date: String(albumData.date || ''),
photos_count: Array.isArray(photos) ? photos.length : 0,
views_count: typeof albumData.views_count === 'number' ? albumData.views_count : undefined,
photos: (photos || []).map((p: any) => ({
id: String(p.id || ''),
page_url: String(p.page_url || ''),
image_1500: String(p.image_1500 || ''),
})),
fetched_at: albumData.fetched_at,
};
setAlbum(mapped);
return;
}
}
} catch {
// ignore and fall through to error handling below
}
throw new Error('Album nenalezeno');
}
setAlbum(foundAlbum);
@@ -135,7 +182,7 @@ const AlbumDetailPage: React.FC = () => {
<Container maxW="7xl" py={8}>
<VStack spacing={4}>
<Spinner size="xl" color="brand.primary" />
<Text color={textSecondary}>Načítám album...</Text>
<Text color={textSecondary}>{t('gallery.loading_album')}</Text>
</VStack>
</Container>
</MainLayout>
@@ -148,10 +195,10 @@ const AlbumDetailPage: React.FC = () => {
<Container maxW="7xl" py={8}>
<VStack spacing={4}>
<Text color="red.500" fontSize="lg">
{error || 'Album nenalezeno'}
{error || t('gallery.album_not_found')}
</Text>
<Button as={RouterLink} to="/galerie" colorScheme="blue">
Zpět na galerii
{t('gallery.back_to_gallery')}
</Button>
</VStack>
</Container>
@@ -162,8 +209,8 @@ const AlbumDetailPage: React.FC = () => {
return (
<MainLayout>
<Helmet>
<title>{album.title} | Fotogalerie</title>
<meta name="description" content={`Fotogalerie: ${album.title}.`} />
<title>{album.title} | {t('gallery.page_title')}</title>
<meta name="description" content={`${t('gallery.page_title')}: ${album.title}.`} />
</Helmet>
<Box bg={bgColor} minH="100vh" py={8}>
<Container maxW="7xl">
@@ -176,12 +223,12 @@ const AlbumDetailPage: React.FC = () => {
>
<BreadcrumbItem>
<BreadcrumbLink as={RouterLink} to="/">
Domů
{t('nav.home')}
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem>
<BreadcrumbLink as={RouterLink} to="/galerie">
Galerie
{t('nav.gallery')}
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem isCurrentPage>
@@ -205,10 +252,24 @@ const AlbumDetailPage: React.FC = () => {
)}
<HStack spacing={1}>
<ImageIcon size={16} />
<Text>{album.photos_count} fotografií</Text>
<Text>
{album.photos_count === 1
? t('gallery.photos_count_one', { count: album.photos_count })
: (album.photos_count >= 2 && album.photos_count <= 4)
? t('gallery.photos_count_few', { count: album.photos_count })
: t('gallery.photos_count_many', { count: album.photos_count })
}
</Text>
</HStack>
{album.views_count !== undefined && album.views_count > 0 && (
<Badge colorScheme="purple">{album.views_count} zhlédnutí</Badge>
<Badge colorScheme="purple">
{album.views_count === 1
? t('gallery.views_count_one', { count: album.views_count })
: (album.views_count >= 2 && album.views_count <= 4)
? t('gallery.views_count_few', { count: album.views_count })
: t('gallery.views_count_many', { count: album.views_count })
}
</Badge>
)}
</HStack>
</VStack>
@@ -222,7 +283,7 @@ const AlbumDetailPage: React.FC = () => {
colorScheme="purple"
size="md"
>
Zobrazit na Zonerama
{t('gallery.view_on_zonerama')}
</Button>
</HStack>
@@ -235,7 +296,7 @@ const AlbumDetailPage: React.FC = () => {
p={3}
>
<Text fontSize="sm" color={infoText}>
📸 Všechny fotografie jsou z platformy{' '}
{t('gallery.photos_from_zonerama')}{' '}
<Text
as="a"
href={album.url || 'https://zonerama.com'}
@@ -289,7 +350,7 @@ const AlbumDetailPage: React.FC = () => {
textAlign="center"
>
<Text color="gray.500">
V tomto albu nejsou žádné fotografie.
{t('gallery.no_photos_in_album')}
</Text>
</Box>
)}
+19 -21
View File
@@ -1,15 +1,16 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Box, Button, FormControl, FormLabel, Heading, Input, Switch, Textarea, VStack, Image, Text, HStack } from '@chakra-ui/react';
import { createArticle, uploadFile } from '../services/articles';
import { Box, Button, FormControl, FormLabel, Heading, Input, Switch, Textarea, VStack, Image, Text } from '@chakra-ui/react';
import { useNavigate } from 'react-router-dom';
import { createArticle } from '../services/articles';
import UploadPanel, { UploadPanelFile } from '../components/common/UploadPanel';
const ArticleCreatePage: React.FC = () => {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [published, setPublished] = useState(true);
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
const [uploading, setUploading] = useState(false);
const [uploadedFiles, setUploadedFiles] = useState<UploadPanelFile[]>([]);
const navigate = useNavigate();
const qc = useQueryClient();
@@ -21,20 +22,6 @@ const ArticleCreatePage: React.FC = () => {
},
});
const onUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const res = await uploadFile(file);
setImageUrl(res.url);
} catch (e) {
console.error(e);
} finally {
setUploading(false);
}
};
const canSubmit = title.trim().length > 0 && content.trim().length > 0 && !createMut.isLoading;
return (
@@ -53,10 +40,21 @@ const ArticleCreatePage: React.FC = () => {
<FormControl>
<FormLabel>Obrázek</FormLabel>
<HStack align="start" spacing={4}>
<Input type="file" accept="image/*,application/pdf" onChange={onUpload} isDisabled={uploading} />
{uploading && <Text>Uploaduji...</Text>}
</HStack>
<UploadPanel
label="Obrázek článku"
description="Vyberte nebo přetáhněte obrázek, který se zobrazí jako titulní obrázek článku."
accept="image/*,application/pdf"
multiple={false}
maxFiles={1}
value={uploadedFiles}
onChange={(files) => {
setUploadedFiles(files);
const last = files[files.length - 1];
setImageUrl(last?.url);
}}
allowUrlImport
urlPlaceholder="/uploads/... nebo https://example.com/obrazek.jpg"
/>
{imageUrl && (
<Box mt={2}>
<Image src={imageUrl} alt="náhled" maxH="160px" objectFit="cover" borderRadius="md" />
+19 -4
View File
@@ -10,6 +10,7 @@ import NewsletterCTA from '../components/common/NewsletterCTA';
import EmbeddedPoll from '../components/polls/EmbeddedPoll';
import { ArrowRight, Eye, Clock, SearchX, ChevronLeft, ChevronRight, ExternalLink, Copy, Play } from 'lucide-react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { trackEvent as umamiTrackEvent, trackMatchView as umamiTrackMatchView, trackVideoPlay as umamiTrackVideoPlay, trackArticleView as umamiTrackArticleView } from '../utils/umami';
import { assetUrl } from '../utils/url';
import { API_URL } from '../services/api';
@@ -38,6 +39,7 @@ const toText = (html?: string) => {
};
const ArticleDetailPage: React.FC = () => {
const { t } = useTranslation();
const { id, slug } = useParams<{ id?: string; slug?: string }>();
const navigate = useNavigate();
const { data, isLoading, isError } = useQuery({
@@ -148,7 +150,7 @@ const ArticleDetailPage: React.FC = () => {
} catch {}
}, [id, (data as any)?.slug, navigate]);
// Award engagement for article read after 15s dwell (once per article per device)
// Award engagement for article read after 15s dwell (once per article)
React.useEffect(() => {
const aid = (data as any)?.id;
if (!aid) return;
@@ -192,7 +194,7 @@ const ArticleDetailPage: React.FC = () => {
return DOMPurify.sanitize(transformed || '', {
USE_PROFILES: { html: true },
ADD_TAGS: ['iframe'],
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id', 'data-bullets', 'data-list'],
ADD_ATTR: ['class', 'target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id', 'data-bullets', 'data-list'],
});
}, [(data as any)?.content, toAbsoluteUploads]);
@@ -220,7 +222,19 @@ const ArticleDetailPage: React.FC = () => {
}
}
const origin = new URL(API_URL, window.location.origin).origin;
// Resolve backend origin for cached files regardless of frontend origin
let origin = '';
try {
const assetBase = (process.env.REACT_APP_ASSET_BASE_URL || '').trim();
if (assetBase) {
origin = new URL(assetBase, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
}
} catch {}
if (!origin) {
try {
origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
} catch {}
}
// Try to find album in both sources
const [profileRes, albumsRes] = await Promise.allSettled([
@@ -940,6 +954,7 @@ const ArticleDetailPage: React.FC = () => {
'ul[data-bullets="disc"]': { listStyleType: 'disc' },
'ul[data-bullets="circle"]': { listStyleType: 'circle' },
'ul[data-bullets="square"]': { listStyleType: 'square' },
'ul[data-bullets="none"]': { listStyleType: 'none' },
'ol': { listStyleType: 'decimal' },
'li': { mb: 2 },
'a': { color: 'blue.600', textDecoration: 'underline', _hover: { color: 'blue.700' } },
@@ -1049,7 +1064,7 @@ const ArticleDetailPage: React.FC = () => {
{((data as any)?.gallery_album_id || (data as any)?.gallery_album_url) && (
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={galleryBg} borderColor={galleryBorder}>
<Box mb={3}>
<Heading as="h3" size="md" textAlign="center">Fotogalerie</Heading>
<Heading as="h3" size="md" textAlign="center">{t('gallery.page_title')}</Heading>
{(() => {
const albumPhotos = Array.isArray(galleryAlbumQuery.data?.photos) ? (galleryAlbumQuery.data?.photos as any[]) : [];
if (albumPhotos.length === 0) return null;
+1 -1
View File
@@ -21,7 +21,7 @@ const ArticlesListPage: React.FC = () => {
<Stack spacing={4}>
{data?.data.map((a) => (
<HStack key={a.id} align="start" spacing={4} borderWidth="1px" borderRadius="md" p={4} bg="white">
<Image src={a.image_url || '/logo192.png'} alt={a.title} boxSize="100px" objectFit="cover" borderRadius="md" />
<Image src={a.image_url || '/article-placeholder.svg'} alt={a.title} boxSize="100px" objectFit="cover" borderRadius="md" />
<Box>
<ChakraLink as={Link} to={`/articles/${a.id}`} fontWeight="bold" fontSize="lg">{a.title}</ChakraLink>
<Text noOfLines={3} mt={2}>{a.content}</Text>
+32 -9
View File
@@ -18,8 +18,10 @@ import {
} from '@chakra-ui/react';
import { useAuth } from '../contexts/AuthContext';
import api from '../services/api';
import { useTranslation } from 'react-i18next';
import { getPublicSettings, PublicSettings } from '../services/settings';
import { assetUrl } from '../utils/url';
import { PasswordHelpTooltip } from '../components/common/HelpTooltipCard';
interface LocationState {
from: {
@@ -29,6 +31,7 @@ interface LocationState {
const AuthPage: React.FC = () => {
// All hooks must be called at the top level, before any conditional returns
const { t } = useTranslation();
const { isAuthenticated, adminExists, login } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@@ -76,12 +79,20 @@ const AuthPage: React.FC = () => {
duration: 3000,
isClosable: true,
});
// Role-based redirect after login
// Role-based redirect after login, honoring optional `from` target
// and a stored post-setup target (fc_post_setup_target) for step 2
const role = String(user?.role || '').toLowerCase();
if (role === 'admin') {
navigate('/admin', { replace: true });
} else if (role === 'editor') {
navigate('/admin', { replace: true });
const targetFrom = (location.state as LocationState)?.from?.pathname;
let storedTarget: string | null = null;
try {
storedTarget = localStorage.getItem('fc_post_setup_target');
if (storedTarget) {
localStorage.removeItem('fc_post_setup_target');
}
} catch {}
if (role === 'admin' || role === 'editor') {
const target = targetFrom || storedTarget || '/admin';
navigate(target, { replace: true });
} else if (role === 'user') {
navigate('/', { replace: true });
} else {
@@ -125,14 +136,26 @@ const AuthPage: React.FC = () => {
placeholder="Zadejte svůj e-mail"
/>
</FormControl>
<FormControl id="login-password" isRequired>
<FormLabel>Heslo</FormLabel>
<FormControl id="login-password">
<FormLabel
display="flex"
alignItems="center"
justifyContent="space-between"
>
<Box as="span" display="inline-flex" alignItems="center">
<Box as="span" mr={1}>Heslo</Box>
<Box as="span" color="red.500">*</Box>
</Box>
<PasswordHelpTooltip />
</FormLabel>
<InputGroup>
<Input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Zadejte své heslo"
required
aria-required="true"
/>
<InputRightElement width="4.5rem">
<Button h="1.75rem" size="sm" onClick={() => setShowPassword((s) => !s)}>{showPassword ? 'Skrýt' : 'Zobrazit'}</Button>
@@ -147,10 +170,10 @@ const AuthPage: React.FC = () => {
loadingText="Přihlašuji..."
mt={4}
>
Přihlásit se
{t('auth.login')}
</Button>
<Box display="flex" alignItems="center" justifyContent="flex-end">
<Button variant="link" colorScheme="blue" onClick={() => navigate('/forgot-password?admin=1')}>Zapomněli jste heslo?</Button>
<Button variant="link" colorScheme="blue" onClick={() => navigate('/forgot-password?admin=1')}>{t('auth.forgot_password')}</Button>
</Box>
</VStack>
</Box>
+25 -28
View File
@@ -1,7 +1,7 @@
import React from 'react';
import { Box, Container, Heading, VStack, Image, Text, Skeleton, LinkBox, HStack, Select, Badge, useColorModeValue, Input, InputGroup, InputLeftElement, InputRightElement, IconButton, Grid, GridItem, useMediaQuery, Tooltip } from '@chakra-ui/react';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { getArticles, Article, Paginated, getFeaturedArticles } from '../services/articles';
import { getArticles, Article, Paginated } from '../services/articles';
import { getBanners, Banner as UIBanner } from '../services/banners';
import { Link as RouterLink, useSearchParams } from 'react-router-dom';
import { assetUrl } from '../utils/url';
@@ -11,8 +11,10 @@ import NewsletterCTA from '../components/common/NewsletterCTA';
import { Eye, Clock, Search, X } from 'lucide-react';
import InstagramGeneratorButton from '../components/admin/InstagramGeneratorButton';
import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next';
const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({ article, variant }) => {
const { t } = useTranslation();
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
const readTime = article.read_time || article.estimated_read_minutes;
const viewCount = article.view_count;
@@ -55,21 +57,21 @@ const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({
{/* Top info row: category (left), date (center), read time (right) */}
<HStack position="absolute" top={2} left={2} right={2} justify="space-between" align="center">
{categoryName ? (
<Tooltip label="Kategorie" hasArrow>
<Tooltip label={t('blog.category')} hasArrow>
<Badge bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1} borderRadius="md">
{categoryName}
</Badge>
</Tooltip>
) : <Box />}
{publishedDateStr ? (
<Tooltip label="Datum publikace" hasArrow>
<Tooltip label={t('blog.publish_date')} hasArrow>
<Badge bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1} borderRadius="md">
{publishedDateStr}
</Badge>
</Tooltip>
) : <Box />}
{readTime ? (
<Tooltip label="Doba čtení" hasArrow>
<Tooltip label={t('blog.read_time')} hasArrow>
<Badge display="flex" alignItems="center" gap={1} bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1} borderRadius="md">
<Clock size={12} />
{readTime} min
@@ -106,6 +108,7 @@ const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({
};
const BlogPage: React.FC = () => {
const { t } = useTranslation();
const pageSize = 18;
const [categories, setCategories] = React.useState<CategoryItem[]>([]);
const [searchParams, setSearchParams] = useSearchParams();
@@ -173,11 +176,6 @@ const BlogPage: React.FC = () => {
});
return Array.from(uniq.values()).slice(0, 10);
}, [matchSuggestQ.data]);
const featuredQ = useQuery<Paginated<Article>>(
['articles-featured', { page_size: 3 }],
() => getFeaturedArticles({ page_size: 3 }),
{ refetchOnWindowFocus: true, refetchOnMount: true, refetchInterval: 30000, staleTime: 0 }
);
const {
data,
isLoading,
@@ -207,9 +205,8 @@ const BlogPage: React.FC = () => {
);
const articles = data?.pages?.flatMap((p) => p?.data || []) || [];
const featuredList = featuredQ.data?.data || [];
const featuredIdSet = React.useMemo(() => new Set((featuredList || []).map((a) => a.id)), [featuredList]);
const visibleArticles = featuredList.length ? articles.filter((a) => !featuredIdSet.has(a.id)) : articles;
const heroArticles = React.useMemo(() => articles.slice(0, 3), [articles]);
const listArticles = React.useMemo(() => articles.slice(3), [articles]);
// Fetch inline article banners (active, placement=article_inline)
const articleBannersQ = useQuery<UIBanner[]>(
@@ -238,20 +235,20 @@ const BlogPage: React.FC = () => {
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
// Derive page SEO title/description
const parts: string[] = ['Blog'];
const parts: string[] = [t('blog.title')];
if (categoryId) {
const cat = categories.find((c) => c.id === Number(categoryId));
if (cat?.name) parts.push(cat.name);
}
if (month) parts.push(month);
if (matchId) parts.push('Zápas');
if (matchId) parts.push(t('blog.match'));
if (qParam) parts.push(`Hledání: ${qParam}`);
const pageTitle = parts.join(' · ');
const pageDesc = qParam
? `Výsledky hledání článků pro „${qParam}“.`
? t('blog.search_results', { query: qParam })
: categoryId
? `Články v kategorii ${(categories.find((c) => c.id === Number(categoryId))?.name) || ''}.`
: 'Nejnovější články, rozhovory a novinky z klubu.';
? t('blog.category_articles', { category: (categories.find((c) => c.id === Number(categoryId))?.name) || '' })
: t('blog.latest_articles');
const canonical = typeof window !== 'undefined' ? window.location.href : undefined;
// Debounced search param update when typing
@@ -275,7 +272,7 @@ const BlogPage: React.FC = () => {
{JSON.stringify({
'@context': 'https://schema.org',
'@type': 'ItemList',
itemListElement: (featuredList.concat(visibleArticles)).slice(0, 12).map((a, idx) => ({
itemListElement: (articles || []).slice(0, 12).map((a, idx) => ({
'@type': 'ListItem',
position: idx + 1,
url: (typeof window !== 'undefined' ? window.location.origin : '') + (a.slug ? `/news/${a.slug}` : `/articles/${a.id}`),
@@ -289,7 +286,7 @@ const BlogPage: React.FC = () => {
<Box bg="transparent" color="inherit" py={{ base: 8, md: 10 }} mb={4} borderBottom="1px" borderColor={borderColor}>
<Container maxW="7xl">
<HStack justify="space-between" align="center" spacing={4} wrap="wrap">
<Heading as="h1" size={{ base: 'xl', md: '2xl' }}>Blog</Heading>
<Heading as="h1" size={{ base: 'xl', md: '2xl' }}>{t('blog.title')}</Heading>
<HStack spacing={3} w={{ base: '100%', md: '620px' }}>
<Box flex="1">
<InputGroup>
@@ -297,7 +294,7 @@ const BlogPage: React.FC = () => {
<Search size={16} />
</InputLeftElement>
<Input
placeholder="Hledat články…"
placeholder={t('blog.search_articles')}
value={qInput}
onChange={(e) => setQInput(e.target.value)}
/>
@@ -324,7 +321,7 @@ const BlogPage: React.FC = () => {
{!!categories.length && (
<Select
maxW={{ base: '48%', md: '220px' }}
placeholder="Všechny kategorie"
placeholder={t('blog.all_categories')}
value={categoryId}
onChange={(e) => {
const val = e.target.value ? Number(e.target.value) : '';
@@ -345,7 +342,7 @@ const BlogPage: React.FC = () => {
<Box flex={{ base: '1', md: '0 0 220px' }} position="relative">
<InputGroup>
<Input
placeholder="Hledat zápas…"
placeholder={t('blog.search_match')}
value={matchInput}
onChange={(e) => setMatchInput(e.target.value)}
onKeyDown={(e) => {
@@ -386,15 +383,15 @@ const BlogPage: React.FC = () => {
</Container>
</Box>
{featuredList.length > 0 && (
{heroArticles.length > 0 && (
<Container maxW="7xl" mb={6}>
<Grid templateColumns={{ base: '1fr', md: '2fr 1fr' }} gap={6}>
<GridItem>
<BlogTile article={featuredList[0]} variant="large" />
<BlogTile article={heroArticles[0]} variant="large" />
</GridItem>
<GridItem>
<VStack spacing={6} align="stretch">
{featuredList.slice(1, 3).map((a) => (
{heroArticles.slice(1, 3).map((a) => (
<BlogTile key={a.id} article={a} variant="small" />
))}
</VStack>
@@ -405,11 +402,11 @@ const BlogPage: React.FC = () => {
<Container maxW="7xl">
{/* Responsive grid with consistent card sizing */}
<Grid templateColumns={{ base: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={8}>
<Grid templateColumns={{ base: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(4, 1fr)' }} gap={8}>
{isLoading && Array.from({ length: 9 }).map((_, i) => (
<Skeleton key={i} h={{ base: '260px', md: '300px' }} borderRadius="md" />
))}
{!isLoading && visibleArticles.map((a, idx) => (
{!isLoading && listArticles.map((a, idx) => (
<React.Fragment key={`row-${a.id}`}>
<GridItem>
<BlogTile article={a} />
@@ -438,7 +435,7 @@ const BlogPage: React.FC = () => {
</Grid>
{isFetchingNextPage && (
<VStack py={6}>
<Text color={textColor}>Načítání</Text>
<Text color={textColor}>{t('blog.loading')}</Text>
</VStack>
)}
</Container>
+85 -55
View File
@@ -13,9 +13,18 @@ import ClubModal from '../components/home/ClubModal';
import { assetUrl } from '../utils/url';
import { API_URL } from '../services/api';
import { TeamLogo } from '../components/common/TeamLogo';
import { useTranslation } from 'react-i18next';
import { MatchWeatherLazy } from '../components/common/MatchWeatherLazy';
import { MatchWeather } from '../components/common/MatchWeather';
// Weekday headers (Czech, starting Monday)
const WEEKDAYS_SHORT: string[] = ['Po', 'Út', 'St', 'Čt', 'Pá', 'So', 'Ne'];
// Dynamic weekday headers based on language
const getWeekdayHeaders = (language: string): string[] => {
if (language === 'en') {
return ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
}
// Default to Czech
return ['Po', 'Út', 'St', 'Čt', 'Pá', 'So', 'Ne'];
};
type MatchItem = {
id: number | string;
@@ -45,6 +54,7 @@ type Competition = {
};
const CalendarPage: React.FC = () => {
const { t, i18n } = useTranslation();
const [loading, setLoading] = useState(true);
const [competitions, setCompetitions] = useState<Competition[]>([]);
const [error, setError] = useState<string | null>(null);
@@ -58,6 +68,30 @@ const CalendarPage: React.FC = () => {
const [clubType, setClubType] = useState<'football' | 'futsal'>('football');
const [standings, setStandings] = useState<any[]>([]);
// Get weekday headers based on current language
const weekdayHeaders = getWeekdayHeaders(i18n.language);
// Helper functions inside component to access t()
const getSentiment = (m: MatchItem): { label: string; color: string } | null => {
// Don't show sentiment for future matches
const dt = new Date(`${m.date}T${(m.time || '00:00')}:00`);
const isPast = Date.now() >= dt.getTime();
if (!isPast) return null;
const s = parseScore(m.score);
if (!s) return null;
const ourIsHome = normalize(m.home).includes(normalize(clubName));
const ourIsAway = normalize(m.away).includes(normalize(clubName));
if (!ourIsHome && !ourIsAway) return null; // unknown perspective
if (s.h === s.a) return { label: t('calendar.draw'), color: 'blue' };
const ourGoals = ourIsHome ? s.h : s.a;
const oppGoals = ourIsHome ? s.a : s.h;
if (ourGoals > oppGoals) return { label: t('calendar.win'), color: 'green' };
return { label: t('calendar.loss'), color: 'red' };
};
// Active competition for current tab (memoized)
const activeCompetition = useMemo(() => competitions[tabIndex], [competitions, tabIndex]);
@@ -421,7 +455,7 @@ const CalendarPage: React.FC = () => {
__compDisplayOrder: c.display_order ?? (1000 + idx) // Use display_order or fallback to sorted index
} as MatchItem)))
.sort((a, b) => new Date(`${a.date}T${a.time}:00`).getTime() - new Date(`${b.date}T${b.time}:00`).getTime());
const allComp: Competition = { id: 'all', name: 'Všechny soutěže', matches: allMatches };
const allComp: Competition = { id: 'all', name: t('calendar.all_competitions'), matches: allMatches };
// Load standings data
let standingsData: any[] = [];
@@ -621,7 +655,10 @@ const CalendarPage: React.FC = () => {
const b = stripPrefixes(clubName || '');
if (!a || !b) return false;
// Allow equality or suffix match (handles prefixes like TJ, SK, etc.)
return a === b || a.endsWith(b) || b.endsWith(a);
// Also handle common abbreviations like "FK" vs "Fotbalový klub"
const aClean = a.replace(/\bfk\b/g, '').replace(/\bfotbalový\s+klub\b/g, '').trim();
const bClean = b.replace(/\bfk\b/g, '').replace(/\bfotbalový\s+klub\b/g, '').trim();
return a === b || a.endsWith(b) || b.endsWith(a) || aClean === bClean || aClean.endsWith(bClean) || bClean.endsWith(aClean);
} catch { return false; }
};
const parseScore = (score?: string): { h: number; a: number } | null => {
@@ -630,52 +667,17 @@ const CalendarPage: React.FC = () => {
if (!m) return null;
return { h: parseInt(m[1], 10), a: parseInt(m[2], 10) };
};
const getSentiment = (m: MatchItem): { label: 'Výhra'|'Remíza'|'Prohra'; color: string } | null => {
// Don't show sentiment for future matches
const dt = new Date(`${m.date}T${(m.time || '00:00')}:00`);
const isPast = Date.now() >= dt.getTime();
if (!isPast) return null;
const s = parseScore(m.score);
if (!s) return null;
// First try ID-based matching (most reliable)
let ourIsHome = false;
let ourIsAway = false;
if (clubId) {
// Check each team ID individually - even if one is missing, we can still match the other
if (m.home_id) {
ourIsHome = m.home_id === clubId;
}
if (m.away_id) {
ourIsAway = m.away_id === clubId;
}
}
// Fallback to name matching if IDs not available or no match
if (!ourIsHome && !ourIsAway) {
ourIsHome = isClubTeam(m.home);
ourIsAway = isClubTeam(m.away);
}
if (!ourIsHome && !ourIsAway) return null; // unknown perspective
if (s.h === s.a) return { label: 'Remíza', color: 'blue' };
const ourGoals = ourIsHome ? s.h : s.a;
const oppGoals = ourIsHome ? s.a : s.h;
if (ourGoals > oppGoals) return { label: 'Výhra', color: 'green' };
return { label: 'Prohra', color: 'red' };
};
return (
<MainLayout>
<Container maxW="7xl" py={{ base: 6, md: 10 }}>
<Heading as="h1" size="xl" mb={2}>Kalendář</Heading>
<Text color="gray.600" mb={6}>Přehled zápasů podle soutěží (FACR).</Text>
<Heading as="h1" size="xl" mb={2}>{t('calendar.title')}</Heading>
<Text color="gray.600" mb={6}>{t('calendar.subtitle')}</Text>
{loading && (
<Flex align="center" gap={3} color="gray.600" mb={6}>
<Spinner size="sm" />
<span>Načítám rozpis</span>
<span>{t('calendar.loading')}</span>
</Flex>
)}
@@ -684,7 +686,7 @@ const CalendarPage: React.FC = () => {
)}
{!loading && !hasData && !error && (
<Box color="gray.600">Zatím nemáme žádné zápasy k zobrazení.</Box>
<Box color="gray.600">{t('calendar.no_matches')}</Box>
)}
{!!competitions.length && (
@@ -838,21 +840,21 @@ const CalendarPage: React.FC = () => {
color={viewMode==='calendar' ? 'text.onPrimary' : undefined}
_hover={{ filter: viewMode==='calendar' ? 'brightness(0.95)' : undefined, borderColor: 'brand.primary', color: viewMode==='calendar' ? 'text.onPrimary' : undefined }}
onClick={() => setViewMode('calendar')}
>Kalendář</Button>
>{t('calendar.title')}</Button>
<Button
variant={viewMode==='list' ? 'solid' : 'outline'}
bg={viewMode==='list' ? 'brand.primary' : undefined}
color={viewMode==='list' ? 'text.onPrimary' : undefined}
_hover={{ filter: viewMode==='list' ? 'brightness(0.95)' : undefined, borderColor: 'brand.primary', color: viewMode==='list' ? 'text.onPrimary' : undefined }}
onClick={() => setViewMode('list')}
>Seznam</Button>
>{t('calendar.list_view')}</Button>
</ButtonGroup>
</Flex>
{viewMode === 'calendar' ? (
<>
<Flex align="center" justify="space-between" mb={3} gap={2} flexWrap={{ base: 'wrap', md: 'nowrap' }}>
<IconButton
aria-label="Předchozí měsíc"
aria-label={t('calendar.previous_month')}
size="sm"
onClick={() => setMonthRef(addMonths(monthRef, -1))}
icon={<ChevronLeftIcon />}
@@ -866,9 +868,9 @@ const CalendarPage: React.FC = () => {
bg={'brand.primary'}
color={'text.onPrimary'}
_hover={{ filter: 'brightness(0.95)' }}
>Dnes</Button>
>{t('calendar.today')}</Button>
<IconButton
aria-label="Další měsíc"
aria-label={t('calendar.next_month')}
size="sm"
onClick={() => setMonthRef(addMonths(monthRef, 1))}
icon={<ChevronRightIcon />}
@@ -878,7 +880,7 @@ const CalendarPage: React.FC = () => {
</Flex>
<Box overflowX="auto">
<Grid templateColumns="repeat(7, 1fr)" gap={3} minW="980px">
{WEEKDAYS_SHORT.map((w) => (
{weekdayHeaders.map((w: string) => (
<Box key={w} textAlign="center" fontWeight="semibold" color="gray.600" fontSize={{ base: 'xs', md: 'sm' }}>{w}</Box>
))}
</Grid>
@@ -972,12 +974,12 @@ const CalendarPage: React.FC = () => {
})}
{list.length > 3 && !expandedDates[key] && (
<Button size="xs" variant="link" colorScheme="gray" onClick={() => setExpandedDates((s) => ({ ...s, [key]: true }))}>
+{list.length - 3} další
{t('calendar.show_more', { count: list.length - 3 })}
</Button>
)}
{expandedDates[key] && list.length > 3 && (
<Button size="xs" variant="link" colorScheme="gray" onClick={() => setExpandedDates((s) => ({ ...s, [key]: false }))}>
Zobrazit méně
{t('calendar.show_less')}
</Button>
)}
</Stack>
@@ -1010,7 +1012,7 @@ const CalendarPage: React.FC = () => {
{format(parse(dKey, 'yyyy-MM-dd', new Date()), 'EEEE d. M. yyyy', { locale: cs })}
</Text>
{highlight && (
<Badge colorScheme="blue" variant="subtle" borderRadius="full">Dnes</Badge>
<Badge colorScheme="blue" variant="subtle" borderRadius="full">{t('calendar.today')}</Badge>
)}
</Flex>
</Box>
@@ -1063,7 +1065,7 @@ const CalendarPage: React.FC = () => {
>
<Flex direction="column" minW="100px">
<Text fontWeight="semibold" color={listDateText} fontSize="sm">{m.date}</Text>
<Text color={listTimeText} fontSize="sm">{m.time || '—'}</Text>
<Text color={listDateText} fontSize="sm">{m.time || '—'}</Text>
{m.venue && <Text color={listVenueText} fontSize="xs" mt={1}>{m.venue}</Text>}
</Flex>
@@ -1141,9 +1143,13 @@ const CalendarPage: React.FC = () => {
{!!pastKeys.length && (
<Box>
{!showPast ? (
<Button size="sm" variant="link" onClick={() => setShowPast(true)}>Zobrazit předchozí zápasy ({pastKeys.reduce((acc,k)=>acc+(byDate.get(k)?.length||0),0)})</Button>
<Button size="sm" variant="link" onClick={() => setShowPast(true)}>
Zobrazit předchozí zápasy ({pastKeys.reduce((acc, k) => acc + (byDate.get(k)?.length || 0), 0)})
</Button>
) : (
<Button size="sm" variant="link" onClick={() => setShowPast(false)}>Skrýt předchozí zápasy</Button>
<Button size="sm" variant="link" onClick={() => setShowPast(false)}>
{t('calendar.hide_past_matches')}
</Button>
)}
{showPast && (
<Stack spacing={4} mt={2}>
@@ -1370,6 +1376,30 @@ const CalendarPage: React.FC = () => {
}
return null;
})()}
<Box h="1px" bg="gray.200" />
{/* Weather widget for home matches that haven't started */}
{(() => {
const dt = new Date(`${selected.match.date}T${(selected.match.time || '00:00')}:00`);
const isPast = Date.now() >= dt.getTime();
const isHomeMatch = clubName && selected.match.home && isClubTeam(selected.match.home);
if (isHomeMatch && !isPast) {
return (
<Box>
<MatchWeather
matchDateTime={`${selected.match.date}T${selected.match.time || '15:00'}:00`}
venue={undefined} // Home matches use club location
isHomeMatch={true}
matchHasStarted={false}
delayLoad={false} // Auto-load when modal opens
/>
</Box>
);
}
return null;
})()}
<Box h="1px" bg="gray.200" />
<Heading as="h3" size="sm">Odběr notifikací pro fanoušky</Heading>
<Text fontSize="sm" color="gray.600">Zadejte svůj email a budeme vás informovat o novinkách a zápasech.</Text>
+16
View File
@@ -10,6 +10,7 @@ import {
VStack,
HStack,
Link as ChakraLink,
Button,
useColorModeValue,
Spinner,
Center
@@ -24,6 +25,7 @@ const ClothingPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const eshopUrl = process.env.REACT_APP_ESHOP_URL;
useEffect(() => {
const fetchItems = async () => {
@@ -68,6 +70,20 @@ const ClothingPage: React.FC = () => {
<Text fontSize="lg" color="gray.600">
Podpořte svůj tým! Prohlédněte si naši nabídku dresů, triček a dalšího merchandisingu.
</Text>
{eshopUrl && (
<Box mt={4}>
<Button
as={ChakraLink}
href={eshopUrl}
isExternal
colorScheme="blue"
rightIcon={<FiExternalLink />}
size="sm"
>
Přejít do plného e-shopu
</Button>
</Box>
)}
</Box>
{items.length === 0 ? (
+4 -2
View File
@@ -2,11 +2,13 @@ import React from 'react';
import MainLayout from '../components/layout/MainLayout';
import { Box, Container, Heading, Text, Stack, Image, SimpleGrid, Divider } from '@chakra-ui/react';
import { usePublicSettings } from '../hooks/usePublicSettings';
import { useTranslation } from 'react-i18next';
import { assetUrl } from '../utils/url';
import NewsletterCTA from '../components/common/NewsletterCTA';
const ClubPage: React.FC = () => {
const { data: settings } = usePublicSettings();
const { t } = useTranslation();
return (
<MainLayout>
@@ -17,8 +19,8 @@ const ClubPage: React.FC = () => {
<Image src={assetUrl(settings.club_logo_url)} alt={settings?.club_name || 'Logo'} boxSize={{ base: '64px', md: '80px' }} objectFit="contain" />
)}
<Box>
<Heading as="h1" size="xl">{settings?.club_name || 'O klubu'}</Heading>
<Text color="gray.600">Oficiální informace o klubu</Text>
<Heading as="h1" size="xl">{settings?.club_name || t('nav.club')}</Heading>
<Text color="gray.600">{t('about.club_info')}</Text>
</Box>
</Stack>
+36 -34
View File
@@ -38,6 +38,7 @@ import { getPublicContacts, GroupedContacts } from '../services/contactInfo';
import { facrApi } from '../services/facr/facrApi';
import { getCompetitionAliasesPublic } from '../services/competitionAliases';
import { getImageUrl } from '../utils/imageUtils';
import { useTranslation } from 'react-i18next';
type ContactFormData = {
name: string;
@@ -48,6 +49,7 @@ type ContactFormData = {
};
const ContactPage: React.FC = () => {
const { t } = useTranslation();
const toast = useToast();
const { settings } = useSettings();
const cardBg = useColorModeValue('white', 'gray.800');
@@ -77,8 +79,8 @@ const ContactPage: React.FC = () => {
trackContactSubmit(true);
trackFormSubmit('Contact Form', true);
toast({
title: 'Zpráva odeslána',
description: 'Děkujeme za vaši zprávu. Brzy se vám ozveme zpět.',
title: t('contact.message_sent'),
description: t('contact.message_sent_desc'),
status: 'success',
duration: 5000,
isClosable: true,
@@ -92,16 +94,16 @@ const ContactPage: React.FC = () => {
const isNetwork = !!error?.isAxiosError && !error?.response;
const description = msgFromServer
|| (isTimeout ? 'Vypršel časový limit požadavku. Zkuste to prosím znovu za chvíli.'
: isNetwork ? 'Požadavek se nezdařil (síť/CORS). Zkuste to znovu nebo obnovte stránku.'
: 'Něco se pokazilo. Zkuste to prosím znovu později.');
|| (isTimeout ? t('contact.timeout_error')
: isNetwork ? t('contact.network_error')
: t('contact.general_error'));
// Track failed contact form submission
trackContactSubmit(false);
trackFormSubmit('Contact Form', false);
toast({
title: 'Chyba',
title: t('contact.error_title'),
description,
status: 'error',
duration: 6000,
@@ -204,13 +206,13 @@ const ContactPage: React.FC = () => {
<VStack align="stretch" spacing={4}>
{hasContactInfo && (
<Box bg={bgColor} p={4} borderRadius="lg" borderWidth="1px" borderColor={borderColor} boxShadow="sm">
<Heading size="md" mb={3}>Kontaktní údaje</Heading>
<Heading size="md" mb={3}>{t('contact.contact_info')}</Heading>
<VStack align="stretch" spacing={3}>
{(settings as any)?.contact_address && (
<HStack align="start">
<Icon as={FiMapPin} boxSize={5} color="blue.500" mt={1} />
<VStack align="start" spacing={0}>
<Text fontWeight="bold">Adresa</Text>
<Text fontWeight="bold">{t('contact.address')}</Text>
<Text>{(settings as any)?.contact_address}</Text>
{(settings as any)?.contact_city && (
<Text>
@@ -227,7 +229,7 @@ const ContactPage: React.FC = () => {
<HStack align="start">
<Icon as={FiPhone} boxSize={5} color="blue.500" mt={1} />
<VStack align="start" spacing={0}>
<Text fontWeight="bold">Telefon</Text>
<Text fontWeight="bold">{t('contact.phone')}</Text>
<Link href={`tel:${(settings as any)?.contact_phone}`} color="blue.500">
{(settings as any)?.contact_phone}
</Link>
@@ -239,7 +241,7 @@ const ContactPage: React.FC = () => {
<HStack align="start">
<Icon as={FiMail} boxSize={5} color="blue.500" mt={1} />
<VStack align="start" spacing={0}>
<Text fontWeight="bold">Email</Text>
<Text fontWeight="bold">{t('contact.email')}</Text>
<Link href={`mailto:${(settings as any)?.contact_email}`} color="blue.500">
{(settings as any)?.contact_email}
</Link>
@@ -252,7 +254,7 @@ const ContactPage: React.FC = () => {
{hasContacts && (
<Box bg={bgColor} p={4} borderRadius="lg" borderWidth="1px" borderColor={borderColor} boxShadow="sm">
<Heading size="md" mb={3}>Kontaktní osoby</Heading>
<Heading size="md" mb={3}>{t('contact.contact_persons')}</Heading>
<Tabs colorScheme="blue" isFitted isLazy>
{(() => {
const categoryEntries = Object.entries(contactsData?.categories || {});
@@ -266,7 +268,7 @@ const ContactPage: React.FC = () => {
{tabs.map((n) => (
<Tab key={n}>{n}</Tab>
))}
{hasOthers && <Tab>Ostatní</Tab>}
{hasOthers && <Tab>{t('contact.others')}</Tab>}
</TabList>
<TabPanels>
{useCategories
@@ -348,7 +350,7 @@ const ContactPage: React.FC = () => {
))}
</SimpleGrid>
) : (
<Text color="gray.500">Pro tuto kategorii zatím nemáme kontaktní osobu.</Text>
<Text color="gray.500">{t('contact.no_contacts')}</Text>
)}
</TabPanel>
);
@@ -410,10 +412,10 @@ const ContactPage: React.FC = () => {
{/* Contact form at the end */}
<Box>
<Heading size="lg" mb={2} color={settings?.primaryColor || 'brand.500'}>
Kontaktujte nás
{t('contact.contact_us')}
</Heading>
<Text color="gray.500">
Máte dotaz nebo připomínku? Napište nám zprávu a my se vám ozveme co nejdříve zpět.
{t('contact.contact_description')}
</Text>
</Box>
@@ -428,13 +430,13 @@ const ContactPage: React.FC = () => {
<form onSubmit={handleSubmit(onSubmit)}>
<VStack spacing={4}>
<FormControl isInvalid={!!errors.name}>
<FormLabel htmlFor="name">Jméno a příjmení *</FormLabel>
<FormLabel htmlFor="name">{t('contact.name_label')}</FormLabel>
<Input
id="name"
placeholder="Jan Novák"
placeholder={t('contact.name_placeholder')}
{...register('name', {
required: 'Toto pole je povinné',
minLength: { value: 2, message: 'Jméno musí mít alespoň 2 znaky' },
required: t('contact.name_required'),
minLength: { value: 2, message: t('contact.name_min_length') },
})}
/>
<FormErrorMessage>
@@ -443,16 +445,16 @@ const ContactPage: React.FC = () => {
</FormControl>
<FormControl isInvalid={!!errors.email}>
<FormLabel htmlFor="email">E-mailová adresa *</FormLabel>
<FormLabel htmlFor="email">{t('contact.email_label')}</FormLabel>
<Input
id="email"
type="email"
placeholder="vas@email.cz"
placeholder={t('contact.email_placeholder')}
{...register('email', {
required: 'Toto pole je povinné',
required: t('contact.email_required'),
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Neplatná e-mailová adresa',
message: t('contact.email_invalid'),
},
})}
/>
@@ -462,13 +464,13 @@ const ContactPage: React.FC = () => {
</FormControl>
<FormControl isInvalid={!!errors.subject}>
<FormLabel htmlFor="subject">Předmět</FormLabel>
<FormLabel htmlFor="subject">{t('contact.subject_label')}</FormLabel>
<Input
id="subject"
placeholder="Předmět zprávy"
placeholder={t('contact.subject_placeholder')}
{...register('subject', {
required: 'Předmět je povinný',
maxLength: { value: 100, message: 'Předmět může mít maximálně 100 znaků' },
required: t('contact.subject_required'),
maxLength: { value: 100, message: t('contact.subject_max_length') },
})}
/>
<FormErrorMessage>
@@ -477,15 +479,15 @@ const ContactPage: React.FC = () => {
</FormControl>
<FormControl isInvalid={!!errors.message}>
<FormLabel htmlFor="message">Zpráva *</FormLabel>
<FormLabel htmlFor="message">{t('contact.message_label')}</FormLabel>
<Textarea
id="message"
rows={6}
placeholder="Napište nám zprávu..."
placeholder={t('contact.message_placeholder')}
{...register('message', {
required: 'Toto pole je povinné',
minLength: { value: 10, message: 'Zpráva musí mít alespoň 10 znaků' },
maxLength: { value: 2000, message: 'Zpráva může mít maximálně 2000 znaků' },
required: t('contact.message_required'),
minLength: { value: 10, message: t('contact.message_min_length') },
maxLength: { value: 2000, message: t('contact.message_max_length') },
})}
/>
<FormErrorMessage>
@@ -500,10 +502,10 @@ const ContactPage: React.FC = () => {
width="full"
mt={4}
isLoading={isLoading}
loadingText="Odesílám..."
loadingText={t('contact.sending')}
data-umami-event="Contact Form Submit"
>
Odeslat zprávu
{t('contact.send_message')}
</Button>
</VStack>
</form>
@@ -0,0 +1,563 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Button,
Card,
CardBody,
CardHeader,
Heading,
VStack,
HStack,
Text,
useToast,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
FormControl,
FormLabel,
Input,
Textarea,
NumberInput,
NumberInputField,
NumberInputStepper,
NumberIncrementStepper,
NumberDecrementStepper,
Select,
Badge,
Divider,
useDisclosure,
Alert,
AlertIcon,
Grid,
GridItem,
SimpleGrid,
Flex,
Spacer,
IconButton,
Tooltip,
useColorModeValue,
} from '@chakra-ui/react';
import {
FiCalendar,
FiClock,
FiMapPin,
FiUsers,
FiDollarSign,
FiSun,
FiHome,
FiCheck,
FiX,
FiInfo,
FiRefreshCw,
} from 'react-icons/fi';
import { api } from '../services/api';
import { useAuth } from '../contexts/AuthContext';
interface Facility {
id: number;
name: string;
description: string;
type: string;
status: string;
capacity: number;
area: number;
location: string;
is_indoor: boolean;
is_outdoor: boolean;
image_url: string;
requires_approval: boolean;
min_booking_duration: number;
max_booking_duration: number;
booking_advance_days: number;
price_per_hour: number;
}
interface BookingFormData {
facility_id: number;
title: string;
description: string;
start_time: string;
end_time: string;
attendees_count: number;
}
interface TimeSlot {
start: string;
end: string;
available: boolean;
}
interface CalendarDay {
date: string;
slots: TimeSlot[];
}
const FacilitiesBookingPage: React.FC = () => {
const [facilities, setFacilities] = useState<Facility[]>([]);
const [selectedFacility, setSelectedFacility] = useState<Facility | null>(null);
const [availability, setAvailability] = useState<Record<string, CalendarDay>>({});
const [loading, setLoading] = useState(true);
const [selectedDate, setSelectedDate] = useState<string>('');
const [selectedSlot, setSelectedSlot] = useState<TimeSlot | null>(null);
const [formData, setFormData] = useState<BookingFormData>({
facility_id: 0,
title: '',
description: '',
start_time: '',
end_time: '',
attendees_count: 1,
});
const { isOpen, onOpen, onClose } = useDisclosure();
const { user } = useAuth();
const toast = useToast();
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
useEffect(() => {
fetchFacilities();
}, []);
useEffect(() => {
if (selectedFacility && selectedDate) {
fetchAvailability();
}
}, [selectedFacility, selectedDate]);
const fetchFacilities = async () => {
try {
setLoading(true);
const response = await api.get('/facilities');
setFacilities(response.data.facilities || []);
} catch (error) {
toast({
title: 'Chyba při načítání',
description: 'Nepodařilo se načíst seznam zařízení',
status: 'error',
duration: 3000,
});
} finally {
setLoading(false);
}
};
const fetchAvailability = async () => {
if (!selectedFacility || !selectedDate) return;
try {
const endDate = new Date(selectedDate);
endDate.setDate(endDate.getDate() + 7); // Get 7 days of availability
const response = await api.get(`/facilities/${selectedFacility.id}/availability`, {
params: {
start_date: selectedDate,
end_date: endDate.toISOString().split('T')[0],
},
});
setAvailability(response.data.availability || {});
} catch (error) {
toast({
title: 'Chyba při načítání',
description: 'Nepodařilo se načíst dostupnost',
status: 'error',
duration: 3000,
});
}
};
const handleFacilitySelect = (facility: Facility) => {
setSelectedFacility(facility);
setFormData({ ...formData, facility_id: facility.id });
// Set default date to today
const today = new Date().toISOString().split('T')[0];
setSelectedDate(today);
};
const handleSlotSelect = (date: string, slot: TimeSlot) => {
if (!slot.available || !selectedFacility) return;
setSelectedDate(date);
setSelectedSlot(slot);
// Set form data with selected slot
const startTime = new Date(`${date}T${slot.start}:00`);
const endTime = new Date(`${date}T${slot.end}:00`);
setFormData({
...formData,
start_time: startTime.toISOString(),
end_time: endTime.toISOString(),
});
onOpen();
};
const handleSubmit = async () => {
if (!user) {
toast({
title: 'Chyba',
description: 'Pro rezervaci musíte být přihlášeni',
status: 'error',
duration: 3000,
});
return;
}
try {
await api.post('/facilities/bookings', formData);
toast({
title: 'Úspěch',
description: 'Rezervace byla vytvořena',
status: 'success',
duration: 3000,
});
onClose();
fetchAvailability(); // Refresh availability
} catch (error: any) {
toast({
title: 'Chyba',
description: error.response?.data?.error || 'Nepodařilo se vytvořit rezervaci',
status: 'error',
duration: 3000,
});
}
};
const getTypeText = (type: string) => {
switch (type) {
case 'field':
return 'Hřiště';
case 'gym':
return 'Posilovna';
case 'locker':
return 'Šatna';
case 'classroom':
return 'Učebna';
case 'storage':
return 'Sklad';
case 'other':
return 'Ostatní';
default:
return type;
}
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'field':
return <FiSun />;
case 'gym':
return <FiUsers />;
case 'locker':
return <FiHome />;
case 'classroom':
return <FiUsers />;
case 'storage':
return <FiHome />;
default:
return <FiHome />;
}
};
const formatPrice = (price: number) => {
return price > 0 ? `${price} Kč/hod` : 'Zdarma';
};
const calculateDuration = () => {
if (!formData.start_time || !formData.end_time) return 0;
const start = new Date(formData.start_time);
const end = new Date(formData.end_time);
return Math.round((end.getTime() - start.getTime()) / (1000 * 60)); // minutes
};
const calculatePrice = () => {
if (!selectedFacility || !formData.start_time || !formData.end_time) return 0;
const duration = calculateDuration();
const hours = duration / 60;
return selectedFacility.price_per_hour * hours;
};
const generateCalendarDays = () => {
const days = [];
const startDate = selectedDate || new Date().toISOString().split('T')[0];
const start = new Date(startDate);
for (let i = 0; i < 7; i++) {
const date = new Date(start);
date.setDate(start.getDate() + i);
const dateStr = date.toISOString().split('T')[0];
days.push(dateStr);
}
return days;
};
if (loading) {
return (
<Box p={8}>
<Text>Načítání...</Text>
</Box>
);
}
return (
<Box p={8} maxW="1200px" mx="auto">
<VStack spacing={8} align="stretch">
<Heading size="lg">Rezervace zařízení</Heading>
{!selectedFacility ? (
// Facility selection
<VStack spacing={6}>
<Text fontSize="lg" fontWeight="medium">
Vyberte zařízení pro rezervaci
</Text>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
{facilities.map((facility) => (
<Card
key={facility.id}
bg={bgColor}
borderWidth={1}
borderColor={borderColor}
cursor="pointer"
_hover={{ shadow: 'md', transform: 'translateY(-2px)' }}
transition="all 0.2s"
onClick={() => handleFacilitySelect(facility)}
>
<CardBody>
<VStack align="start" spacing={3}>
<HStack>
{getTypeIcon(facility.type)}
<Heading size="md">{facility.name}</Heading>
</HStack>
<Text color="gray.600" fontSize="sm">
{facility.description}
</Text>
<HStack>
<Badge colorScheme="blue">{getTypeText(facility.type)}</Badge>
<Badge colorScheme="green">
{formatPrice(facility.price_per_hour)}
</Badge>
</HStack>
<VStack align="start" spacing={1} fontSize="sm">
{facility.capacity > 0 && (
<HStack>
<FiUsers />
<Text>Kapacita: {facility.capacity} osob</Text>
</HStack>
)}
{facility.location && (
<HStack>
<FiMapPin />
<Text>{facility.location}</Text>
</HStack>
)}
<HStack>
<FiClock />
<Text>
{facility.min_booking_duration}-{facility.max_booking_duration} minut
</Text>
</HStack>
</VStack>
{facility.requires_approval && (
<Alert status="info" fontSize="sm">
<AlertIcon />
Rezervace vyžaduje schválení
</Alert>
)}
</VStack>
</CardBody>
</Card>
))}
</SimpleGrid>
</VStack>
) : (
// Booking calendar
<VStack spacing={6}>
<Flex justify="space-between" align="center" w="full">
<HStack>
<IconButton
aria-label="Zpět"
icon={<FiX />}
variant="ghost"
onClick={() => {
setSelectedFacility(null);
setAvailability({});
setSelectedDate('');
}}
/>
<Heading size="md">{selectedFacility.name}</Heading>
</HStack>
<Button
leftIcon={<FiRefreshCw />}
variant="outline"
onClick={fetchAvailability}
>
Obnovit
</Button>
</Flex>
<Card bg={bgColor} borderWidth={1} borderColor={borderColor}>
<CardBody>
<VStack spacing={4}>
<FormControl>
<FormLabel>Vyberte datum</FormLabel>
<Input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
min={new Date().toISOString().split('T')[0]}
/>
</FormControl>
<Divider />
<VStack spacing={4} w="full">
{generateCalendarDays().map((date) => {
const dayData = availability[date];
const dateObj = new Date(date);
const dayName = dateObj.toLocaleDateString('cs-CZ', {
weekday: 'long',
day: 'numeric',
month: 'numeric'
});
return (
<Box key={date} w="full">
<Heading size="sm" mb={2}>{dayName}</Heading>
{dayData && dayData.slots && dayData.slots.length > 0 ? (
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={2}>
{dayData.slots.map((slot, index) => (
<Button
key={index}
size="sm"
variant={slot.available ? 'outline' : 'ghost'}
colorScheme={slot.available ? 'green' : 'gray'}
isDisabled={!slot.available}
onClick={() => slot.available && handleSlotSelect(date, slot)}
w="full"
>
<VStack spacing={1}>
<Text fontSize="xs" fontWeight="bold">
{slot.start} - {slot.end}
</Text>
{slot.available ? (
<Text fontSize="xs">Volno</Text>
) : (
<Text fontSize="xs">Obsazeno</Text>
)}
</VStack>
</Button>
))}
</SimpleGrid>
) : (
<Text color="gray.500" fontSize="sm">
Žádné dostupné termíny
</Text>
)}
</Box>
);
})}
</VStack>
</VStack>
</CardBody>
</Card>
</VStack>
)}
{/* Booking Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="md">
<ModalOverlay />
<ModalContent>
<ModalHeader>Vytvořit rezervaci</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<Alert status="info">
<AlertIcon />
<Box>
<Text fontWeight="medium">
{selectedFacility?.name}
</Text>
<Text fontSize="sm">
{selectedDate} {selectedSlot?.start} - {selectedSlot?.end}
</Text>
<Text fontSize="sm">
Cena: {calculatePrice()}
</Text>
</Box>
</Alert>
<FormControl isRequired>
<FormLabel>Název rezervace</FormLabel>
<Input
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="Např. Trénink A-týmu"
/>
</FormControl>
<FormControl>
<FormLabel>Popis</FormLabel>
<Textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Další informace o rezervaci"
rows={3}
/>
</FormControl>
<FormControl>
<FormLabel>Počet účastníků</FormLabel>
<NumberInput
value={formData.attendees_count}
onChange={(value) => setFormData({ ...formData, attendees_count: parseInt(value) || 1 })}
min={1}
max={selectedFacility?.capacity || 1}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</FormControl>
{selectedFacility?.requires_approval && (
<Alert status="warning">
<AlertIcon />
<Text>
Tato rezervace vyžaduje schválení administrátorem.
Budete informováni o schválení nebo zamítnutí.
</Text>
</Alert>
)}
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
Zrušit
</Button>
<Button colorScheme="blue" onClick={handleSubmit}>
Vytvořit rezervaci
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</VStack>
</Box>
);
};
export default FacilitiesBookingPage;

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