mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
upload
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { ChakraProvider, extendTheme, Spinner, Center, Box } from '@chakra-ui/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate, Outlet } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { ClubThemeProvider } from './contexts/ClubThemeContext';
|
||||
import { HelmetProvider } from 'react-helmet-async';
|
||||
import { theme } from './App';
|
||||
import { useUmami } from './hooks/useUmami';
|
||||
import { useFontLoader } from './hooks/useFontLoader';
|
||||
import DefaultSEO from './components/seo/DefaultSEO';
|
||||
import CookieBanner from './components/CookieBanner';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import { getSetupStatus } from './services/setup';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// Create a client
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
cacheTime: 10 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Loading component
|
||||
const PageLoader = () => (
|
||||
<Center h="100vh">
|
||||
<Box textAlign="center">
|
||||
<Spinner size="xl" color="brand.primary" thickness="4px" />
|
||||
<Box mt={4} fontSize="sm" color="gray.600">Načítání...</Box>
|
||||
</Box>
|
||||
</Center>
|
||||
);
|
||||
|
||||
// Lazy load pages for code splitting
|
||||
const HomePage = lazy(() => import('./pages/HomePage'));
|
||||
const BlogPage = lazy(() => import('./pages/BlogPage'));
|
||||
const ArticleDetailPage = lazy(() => import('./pages/ArticleDetailPage'));
|
||||
const ActivityDetailPage = lazy(() => import('./pages/ActivityDetailPage'));
|
||||
const MatchDetailPage = lazy(() => import('./pages/MatchDetailPage'));
|
||||
const ClubPage = lazy(() => import('./pages/ClubPage'));
|
||||
const CalendarPage = lazy(() => import('./pages/CalendarPage'));
|
||||
const TablesPage = lazy(() => import('./pages/TablesPage'));
|
||||
const MatchesPage = lazy(() => import('./pages/MatchesPage'));
|
||||
const PlayersPage = lazy(() => import('./pages/PlayersPage'));
|
||||
const PlayerDetailPage = lazy(() => import('./pages/PlayerDetailPage'));
|
||||
const SponsorsPage = lazy(() => import('./pages/SponsorsPage'));
|
||||
const ContactPage = lazy(() => import('./pages/ContactPage'));
|
||||
const GalleryPage = lazy(() => import('./pages/GalleryPage'));
|
||||
const AlbumDetailPage = lazy(() => import('./pages/AlbumDetailPage'));
|
||||
const AuthPage = lazy(() => import('./pages/AuthPage'));
|
||||
const ForgotPasswordPage = lazy(() => import('./pages/ForgotPasswordPage'));
|
||||
const ResetPasswordPage = lazy(() => import('./pages/ResetPasswordPage'));
|
||||
const ActivitiesCalendarPage = lazy(() => import('./pages/ActivitiesCalendarPage'));
|
||||
const AboutPage = lazy(() => import('./pages/AboutPage'));
|
||||
const SetupPage = lazy(() => import('./pages/SetupPage'));
|
||||
const StylePreviewPage = lazy(() => import('./pages/StylePreviewPage'));
|
||||
const NewsletterUnsubscribePage = lazy(() => import('./pages/NewsletterUnsubscribePage'));
|
||||
const NewsletterPreferencesPage = lazy(() => import('./pages/NewsletterPreferencesPage'));
|
||||
const VideosPage = lazy(() => import('./pages/VideosPage'));
|
||||
const SearchPage = lazy(() => import('./pages/SearchPage'));
|
||||
const ClothingPage = lazy(() => import('./pages/ClothingPage'));
|
||||
const PollsPage = lazy(() => import('./pages/PollsPage'));
|
||||
const OverlayScoreboardPage = lazy(() => import('./pages/OverlayScoreboardPage'));
|
||||
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
|
||||
const ForbiddenPage = lazy(() => import('./pages/ForbiddenPage'));
|
||||
|
||||
// Legal pages
|
||||
const CookiePolicyPage = lazy(() => import('./pages/legal/CookiePolicyPage'));
|
||||
const TermsPage = lazy(() => import('./pages/legal/TermsPage'));
|
||||
const PrivacyPolicyPage = lazy(() => import('./pages/legal/PrivacyPolicyPage'));
|
||||
|
||||
// Admin pages
|
||||
const AdminDashboardPage = lazy(() => import('./pages/admin/AdminDashboardPage'));
|
||||
const ArticlesAdminPage = lazy(() => import('./pages/admin/ArticlesAdminPage'));
|
||||
const SponsorsAdminPage = lazy(() => import('./pages/admin/SponsorsAdminPage'));
|
||||
const CategoriesAdminPage = lazy(() => import('./pages/admin/CategoriesAdminPage'));
|
||||
const MediaAdminPage = lazy(() => import('./pages/admin/MediaAdminPage'));
|
||||
const MatchesAdminPage = lazy(() => import('./pages/admin/MatchesAdminPage'));
|
||||
const PlayersAdminPage = lazy(() => import('./pages/admin/PlayersAdminPage'));
|
||||
const TeamsAdminPage = lazy(() => import('./pages/admin/TeamsAdminPage'));
|
||||
const BannersAdminPage = lazy(() => import('./pages/admin/BannersAdminPage'));
|
||||
const MessagesAdminPage = lazy(() => import('./pages/admin/MessagesAdminPage'));
|
||||
const SettingsAdminPage = lazy(() => import('./pages/admin/SettingsAdminPage'));
|
||||
const UsersAdminPage = lazy(() => import('./pages/admin/UsersAdminPage'));
|
||||
const NewsletterAdminPage = lazy(() => import('./pages/admin/NewsletterAdminPage'));
|
||||
const CompetitionAliasesAdminPage = lazy(() => import('./pages/admin/CompetitionAliasesAdminPage'));
|
||||
const PrefetchAdminPage = lazy(() => import('./pages/admin/PrefetchAdminPage'));
|
||||
const AdminVideosPage = lazy(() => import('./pages/admin/AdminVideosPage'));
|
||||
const GalleryAdminPage = lazy(() => import('./pages/admin/GalleryAdminPage'));
|
||||
const AdminActivitiesPage = lazy(() => import('./pages/admin/AdminActivitiesPage'));
|
||||
const AdminMerchPage = lazy(() => import('./pages/admin/AdminMerchPage'));
|
||||
const AdminResetPasswordPage = lazy(() => import('./pages/admin/AdminResetPasswordPage'));
|
||||
const AboutAdminPage = lazy(() => import('./pages/admin/AboutAdminPage'));
|
||||
const AnalyticsAdminPage = lazy(() => import('./pages/admin/AnalyticsAdminPage'));
|
||||
const FilesAdminPage = lazy(() => import('./pages/admin/FilesAdminPage'));
|
||||
const ContactsAdminPage = lazy(() => import('./pages/admin/ContactsAdminPage'));
|
||||
const NavigationAdminPage = lazy(() => import('./pages/admin/NavigationAdminPage'));
|
||||
const PollsAdminPage = lazy(() => import('./pages/admin/PollsAdminPage'));
|
||||
const AdminDocsPage = lazy(() => import('./pages/admin/AdminDocsPage'));
|
||||
const ScoreboardAdminPage = lazy(() => import('./pages/admin/ScoreboardAdminPage'));
|
||||
const MobileScoreboardControlPage = lazy(() => import('./pages/admin/MobileScoreboardControlPage'));
|
||||
|
||||
// Analytics and font loader
|
||||
const AnalyticsInitializer: React.FC = () => {
|
||||
useUmami();
|
||||
return null;
|
||||
};
|
||||
|
||||
const FontLoader: React.FC = () => {
|
||||
useFontLoader();
|
||||
return null;
|
||||
};
|
||||
|
||||
// Public route wrapper
|
||||
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const [checkingSetup, setCheckingSetup] = useState(true);
|
||||
const [requiresSetup, setRequiresSetup] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const s = await getSetupStatus();
|
||||
if (mounted) setRequiresSetup(!!s.requires_setup);
|
||||
} catch (_) {
|
||||
if (mounted) setRequiresSetup(false);
|
||||
} finally {
|
||||
if (mounted) setCheckingSetup(false);
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
if (isLoading || checkingSetup) {
|
||||
return <PageLoader />;
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/admin" replace />;
|
||||
}
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
if (requiresSetup && currentPath !== '/setup') {
|
||||
return <Navigate to="/setup" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const AdminRoutesWrapper = () => {
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
const AppLazy: React.FC = () => {
|
||||
return (
|
||||
<ChakraProvider theme={theme}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Router>
|
||||
<AuthProvider>
|
||||
<ClubThemeProvider>
|
||||
<HelmetProvider>
|
||||
<AnalyticsInitializer />
|
||||
<FontLoader />
|
||||
<DefaultSEO />
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/hledat" element={<SearchPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/overlay/scoreboard" element={<OverlayScoreboardPage />} />
|
||||
<Route path="/blog" element={<BlogPage />} />
|
||||
<Route path="/klub" element={<ClubPage />} />
|
||||
<Route path="/o-klubu" element={<AboutPage />} />
|
||||
<Route path="/kalendar" element={<CalendarPage />} />
|
||||
<Route path="/aktivity" element={<ActivitiesCalendarPage />} />
|
||||
<Route path="/tabulky" element={<TablesPage />} />
|
||||
<Route path="/zapasy" element={<MatchesPage />} />
|
||||
<Route path="/players" element={<PlayersPage />} />
|
||||
<Route path="/hraci" element={<PlayersPage />} />
|
||||
<Route path="/players/:id" element={<PlayerDetailPage />} />
|
||||
<Route path="/hraci/:id" element={<PlayerDetailPage />} />
|
||||
<Route path="/sponzori" element={<SponsorsPage />} />
|
||||
<Route path="/kontakt" element={<ContactPage />} />
|
||||
<Route path="/ankety" element={<PollsPage />} />
|
||||
<Route path="/galerie" element={<GalleryPage />} />
|
||||
<Route path="/galerie/album/:id" element={<AlbumDetailPage />} />
|
||||
<Route path="/videa" element={<VideosPage />} />
|
||||
<Route path="/obleceni" element={<ClothingPage />} />
|
||||
|
||||
{/* Legal pages */}
|
||||
<Route path="/pravidla-cookies" element={<CookiePolicyPage />} />
|
||||
<Route path="/obchodni-podminky" element={<TermsPage />} />
|
||||
<Route path="/zasady-ochrany-osobnich-udaju" element={<PrivacyPolicyPage />} />
|
||||
|
||||
{/* Article routes */}
|
||||
<Route path="/news" element={<Navigate to="/blog" replace />} />
|
||||
<Route path="/news/:slug" element={<ArticleDetailPage />} />
|
||||
<Route path="/articles/slug/:slug" element={<ArticleDetailPage />} />
|
||||
<Route path="/articles/:id" element={<ArticleDetailPage />} />
|
||||
<Route path="/zapas/:id" element={<MatchDetailPage />} />
|
||||
<Route path="/aktivita/:id" element={<ActivityDetailPage />} />
|
||||
|
||||
{/* Redirects */}
|
||||
<Route path="/clanky" element={<Navigate to="/blog" replace />} />
|
||||
<Route path="/aktuality" element={<Navigate to="/blog" replace />} />
|
||||
|
||||
{/* Setup */}
|
||||
<Route path="/setup" element={<PublicRoute><SetupPage /></PublicRoute>} />
|
||||
<Route path="/setup/styl" element={<PublicRoute><StylePreviewPage /></PublicRoute>} />
|
||||
|
||||
{/* Auth */}
|
||||
<Route path="/login" element={<PublicRoute><AuthPage /></PublicRoute>} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
<Route path="/newsletter/unsubscribe/:email" element={<NewsletterUnsubscribePage />} />
|
||||
<Route path="/newsletter/preferences" element={<NewsletterPreferencesPage />} />
|
||||
<Route path="/403" element={<ForbiddenPage />} />
|
||||
|
||||
{/* Admin routes */}
|
||||
<Route element={<ProtectedRoute requiredRole="admin"><AdminRoutesWrapper /></ProtectedRoute>}>
|
||||
<Route path="/admin" element={<AdminDashboardPage />} />
|
||||
<Route path="/admin/docs" element={<AdminDocsPage />} />
|
||||
<Route path="/admin/clanky" element={<ArticlesAdminPage />} />
|
||||
<Route path="/admin/o-klubu" element={<AboutAdminPage />} />
|
||||
<Route path="/admin/videa" element={<AdminVideosPage />} />
|
||||
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
|
||||
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
|
||||
<Route path="/admin/aktivity" element={<AdminActivitiesPage />} />
|
||||
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
|
||||
<Route path="/admin/kategorie" element={<CategoriesAdminPage />} />
|
||||
<Route path="/admin/media" element={<MediaAdminPage />} />
|
||||
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
|
||||
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
|
||||
<Route path="/admin/tymy" element={<TeamsAdminPage />} />
|
||||
<Route path="/admin/uzivatele" element={<UsersAdminPage />} />
|
||||
<Route path="/admin/bannery" element={<BannersAdminPage />} />
|
||||
<Route path="/admin/zpravy" element={<MessagesAdminPage />} />
|
||||
<Route path="/admin/nastaveni" element={<SettingsAdminPage />} />
|
||||
<Route path="/admin/newsletter" element={<NewsletterAdminPage />} />
|
||||
<Route path="/admin/ankety" element={<PollsAdminPage />} />
|
||||
<Route path="/admin/aliasy-soutezi" element={<CompetitionAliasesAdminPage />} />
|
||||
<Route path="/admin/prefetch" element={<PrefetchAdminPage />} />
|
||||
<Route path="/admin/users/send-reset" element={<AdminResetPasswordPage />} />
|
||||
<Route path="/admin/scoreboard" element={<ScoreboardAdminPage />} />
|
||||
<Route path="/admin/scoreboard/remote" element={<MobileScoreboardControlPage />} />
|
||||
<Route path="/admin/analytika" element={<AnalyticsAdminPage />} />
|
||||
<Route path="/admin/soubory" element={<FilesAdminPage />} />
|
||||
<Route path="/admin/kontakty" element={<ContactsAdminPage />} />
|
||||
<Route path="/admin/navigace" element={<NavigationAdminPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Legacy admin routes */}
|
||||
<Route path="/dashboard" element={<Navigate to="/admin" replace />} />
|
||||
<Route path="/admin/sponsors" element={<ProtectedRoute requiredRole="admin"><SponsorsAdminPage /></ProtectedRoute>} />
|
||||
<Route path="/admin/banners" element={<ProtectedRoute requiredRole="admin"><BannersAdminPage /></ProtectedRoute>} />
|
||||
<Route path="/admin/messages" element={<ProtectedRoute requiredRole="admin"><MessagesAdminPage /></ProtectedRoute>} />
|
||||
<Route path="/admin/settings" element={<ProtectedRoute requiredRole="admin"><SettingsAdminPage /></ProtectedRoute>} />
|
||||
|
||||
{/* 404 */}
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<CookieBanner />
|
||||
</HelmetProvider>
|
||||
</ClubThemeProvider>
|
||||
</AuthProvider>
|
||||
</Router>
|
||||
</QueryClientProvider>
|
||||
</ChakraProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLazy;
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,471 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ChakraProvider, extendTheme } from '@chakra-ui/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate, Outlet } from 'react-router-dom';
|
||||
import './styles/custom-scrollbar.css';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import AuthPage from './pages/AuthPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import ArticlesListPage from './pages/ArticlesListPage';
|
||||
import HomePage from './pages/HomePage';
|
||||
import BlogPage from './pages/BlogPage';
|
||||
import ArticleDetailPage from './pages/ArticleDetailPage';
|
||||
import ActivityDetailPage from './pages/ActivityDetailPage';
|
||||
import MatchDetailPage from './pages/MatchDetailPage';
|
||||
import ClubPage from './pages/ClubPage';
|
||||
import CalendarPage from './pages/CalendarPage';
|
||||
import TablesPage from './pages/TablesPage';
|
||||
import MatchesPage from './pages/MatchesPage';
|
||||
import PlayersPage from './pages/PlayersPage';
|
||||
import PlayerDetailPage from './pages/PlayerDetailPage';
|
||||
import SponsorsPage from './pages/SponsorsPage';
|
||||
import ContactPage from './pages/ContactPage';
|
||||
import GalleryPage from './pages/GalleryPage';
|
||||
import AlbumDetailPage from './pages/AlbumDetailPage';
|
||||
import ForgotPasswordPage from './pages/ForgotPasswordPage';
|
||||
import ResetPasswordPage from './pages/ResetPasswordPage';
|
||||
import ActivitiesCalendarPage from './pages/ActivitiesCalendarPage';
|
||||
import AdminDashboardPage from './pages/admin/AdminDashboardPage';
|
||||
import ArticlesAdminPage from './pages/admin/ArticlesAdminPage';
|
||||
import SponsorsAdminPage from './pages/admin/SponsorsAdminPage';
|
||||
import CategoriesAdminPage from './pages/admin/CategoriesAdminPage';
|
||||
import MediaAdminPage from './pages/admin/MediaAdminPage';
|
||||
import MatchesAdminPage from './pages/admin/MatchesAdminPage';
|
||||
import PlayersAdminPage from './pages/admin/PlayersAdminPage';
|
||||
import TeamsAdminPage from './pages/admin/TeamsAdminPage';
|
||||
import BannersAdminPage from './pages/admin/BannersAdminPage';
|
||||
import MessagesAdminPage from './pages/admin/MessagesAdminPage';
|
||||
import SettingsAdminPage from './pages/admin/SettingsAdminPage';
|
||||
import UsersAdminPage from './pages/admin/UsersAdminPage';
|
||||
import NewsletterAdminPage from './pages/admin/NewsletterAdminPage';
|
||||
import CompetitionAliasesAdminPage from './pages/admin/CompetitionAliasesAdminPage';
|
||||
import PrefetchAdminPage from './pages/admin/PrefetchAdminPage';
|
||||
import AdminVideosPage from './pages/admin/AdminVideosPage';
|
||||
import GalleryAdminPage from './pages/admin/GalleryAdminPage';
|
||||
import AdminActivitiesPage from './pages/admin/AdminActivitiesPage';
|
||||
import AdminMerchPage from './pages/admin/AdminMerchPage';
|
||||
import AdminResetPasswordPage from './pages/admin/AdminResetPasswordPage';
|
||||
import AboutAdminPage from './pages/admin/AboutAdminPage';
|
||||
import AnalyticsAdminPage from './pages/admin/AnalyticsAdminPage';
|
||||
import FilesAdminPage from './pages/admin/FilesAdminPage';
|
||||
import ContactsAdminPage from './pages/admin/ContactsAdminPage';
|
||||
import NavigationAdminPage from './pages/admin/NavigationAdminPage';
|
||||
import PollsAdminPage from './pages/admin/PollsAdminPage';
|
||||
// Admin pages render their own AdminLayout internally
|
||||
import SetupPage from './pages/SetupPage';
|
||||
import StylePreviewPage from './pages/StylePreviewPage';
|
||||
import AboutPage from './pages/AboutPage';
|
||||
import AdminDocsPage from './pages/admin/AdminDocsPage';
|
||||
import ScoreboardAdminPage from './pages/admin/ScoreboardAdminPage';
|
||||
import MobileScoreboardControlPage from './pages/admin/MobileScoreboardControlPage';
|
||||
import { getSetupStatus } from './services/setup';
|
||||
import NewsletterUnsubscribePage from './pages/NewsletterUnsubscribePage';
|
||||
import NewsletterPreferencesPage from './pages/NewsletterPreferencesPage';
|
||||
import { ClubThemeProvider } from './contexts/ClubThemeContext';
|
||||
import CookiePolicyPage from './pages/legal/CookiePolicyPage';
|
||||
import OverlayScoreboardPage from './pages/OverlayScoreboardPage';
|
||||
import CookieBanner from './components/CookieBanner';
|
||||
import DefaultSEO from './components/seo/DefaultSEO';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import TermsPage from './pages/legal/TermsPage';
|
||||
import PrivacyPolicyPage from './pages/legal/PrivacyPolicyPage';
|
||||
import ForbiddenPage from './pages/ForbiddenPage';
|
||||
import NotFoundPage from './pages/NotFoundPage';
|
||||
import VideosPage from './pages/VideosPage';
|
||||
import SearchPage from './pages/SearchPage';
|
||||
import ClothingPage from './pages/ClothingPage';
|
||||
import PollsPage from './pages/PollsPage';
|
||||
import { useUmami } from './hooks/useUmami';
|
||||
import { useFontLoader } from './hooks/useFontLoader';
|
||||
|
||||
// Create a client with better cache configuration
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
cacheTime: 10 * 60 * 1000, // 10 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Theme configuration drawing colors from ClubTheme CSS variables for personalization
|
||||
export const theme = extendTheme({
|
||||
config: {
|
||||
initialColorMode: 'light',
|
||||
useSystemColorMode: false,
|
||||
},
|
||||
// Provide a brand color scale so colorScheme="brand" components style correctly
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#e6f7ff',
|
||||
100: '#b3e0ff',
|
||||
200: '#80caff',
|
||||
300: '#4db3ff',
|
||||
400: '#1a9cff',
|
||||
500: 'var(--club-primary, #0b5cff)',
|
||||
600: '#0066cc',
|
||||
700: '#004d99',
|
||||
800: '#003366',
|
||||
900: '#001a33',
|
||||
},
|
||||
},
|
||||
// Semantic tokens allow live updates when ClubThemeContext changes CSS variables
|
||||
semanticTokens: {
|
||||
colors: {
|
||||
'brand.primary': {
|
||||
default: 'var(--club-primary, #0b5cff)',
|
||||
},
|
||||
'brand.secondary': {
|
||||
default: 'var(--club-secondary, #ffd200)',
|
||||
},
|
||||
'brand.accent': {
|
||||
default: 'var(--club-accent, #141414)',
|
||||
},
|
||||
'text.onPrimary': {
|
||||
default: 'var(--club-text-on-primary, #ffffff)',
|
||||
},
|
||||
'bg.app': {
|
||||
default: '#f8f9fb',
|
||||
_dark: '#0f1115',
|
||||
},
|
||||
'text.app': {
|
||||
default: '#1a1a1a',
|
||||
_dark: '#e8eaf0',
|
||||
},
|
||||
// Backdrop/outline shades
|
||||
'border.subtle': {
|
||||
default: 'rgba(0,0,0,0.06)',
|
||||
_dark: 'rgba(255,255,255,0.12)',
|
||||
},
|
||||
'bg.card': {
|
||||
default: '#ffffff',
|
||||
_dark: '#1a1d29',
|
||||
},
|
||||
'bg.elevated': {
|
||||
default: '#ffffff',
|
||||
_dark: '#242831',
|
||||
},
|
||||
},
|
||||
},
|
||||
styles: {
|
||||
global: {
|
||||
'html, body, #root': {
|
||||
height: '100%',
|
||||
fontFamily: 'var(--font-body, Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial)',
|
||||
},
|
||||
body: {
|
||||
bg: 'bg.app',
|
||||
color: 'text.app',
|
||||
lineHeight: 1.5,
|
||||
fontFamily: 'var(--font-body, Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial)',
|
||||
},
|
||||
'h1, h2, h3, h4, h5, h6': {
|
||||
fontFamily: 'var(--font-heading, Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial)',
|
||||
},
|
||||
a: {
|
||||
transition: 'color 0.2s ease',
|
||||
},
|
||||
'::selection': {
|
||||
background: 'brand.accent',
|
||||
color: 'black',
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
Container: {
|
||||
baseStyle: {
|
||||
px: { base: 4, md: 6 },
|
||||
},
|
||||
sizes: {
|
||||
'7xl': '88rem',
|
||||
},
|
||||
},
|
||||
Button: {
|
||||
baseStyle: {
|
||||
fontWeight: '700',
|
||||
borderRadius: 'md',
|
||||
letterSpacing: '0.4px',
|
||||
_hover: { transform: 'translateY(-1px)', boxShadow: 'md' },
|
||||
_active: { transform: 'translateY(0)' },
|
||||
},
|
||||
variants: {
|
||||
solid: {
|
||||
bg: 'brand.primary',
|
||||
color: 'text.onPrimary',
|
||||
_hover: { filter: 'brightness(0.95)' },
|
||||
},
|
||||
outline: {
|
||||
border: '2px solid',
|
||||
borderColor: 'brand.primary',
|
||||
color: 'brand.primary',
|
||||
_hover: { bg: 'rgba(0,0,0,0.02)' },
|
||||
},
|
||||
ghost: {
|
||||
color: 'brand.secondary',
|
||||
_hover: { bg: 'rgba(0,0,0,0.04)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
Card: {
|
||||
baseStyle: {
|
||||
container: {
|
||||
borderRadius: 'lg',
|
||||
boxShadow: 'sm',
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.2s',
|
||||
borderWidth: '1px',
|
||||
borderColor: 'border.subtle',
|
||||
_hover: { transform: 'translateY(-4px)', boxShadow: 'lg' },
|
||||
},
|
||||
},
|
||||
},
|
||||
Divider: {
|
||||
baseStyle: {
|
||||
borderColor: 'border.subtle',
|
||||
},
|
||||
},
|
||||
Heading: {
|
||||
baseStyle: {
|
||||
fontFamily: 'var(--font-heading, Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial)',
|
||||
},
|
||||
},
|
||||
Text: {
|
||||
baseStyle: {
|
||||
fontFamily: 'var(--font-body, Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial)',
|
||||
},
|
||||
},
|
||||
},
|
||||
fonts: {
|
||||
heading: 'var(--font-heading, Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial)',
|
||||
body: 'var(--font-body, Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial)',
|
||||
},
|
||||
});
|
||||
|
||||
// Component to initialize analytics inside Router context
|
||||
const AnalyticsInitializer: React.FC = () => {
|
||||
useUmami();
|
||||
return null;
|
||||
};
|
||||
|
||||
// Component to load and apply club fonts
|
||||
const FontLoader: React.FC = () => {
|
||||
useFontLoader();
|
||||
return null;
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
// Uses shared ProtectedRoute component for auth guard
|
||||
|
||||
// Public Route component - redirects to admin if already authenticated
|
||||
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const [checkingSetup, setCheckingSetup] = useState(true);
|
||||
const [requiresSetup, setRequiresSetup] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const s = await getSetupStatus();
|
||||
if (mounted) setRequiresSetup(!!s.requires_setup);
|
||||
} catch (_) {
|
||||
if (mounted) setRequiresSetup(false);
|
||||
} finally {
|
||||
if (mounted) setCheckingSetup(false);
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
if (isLoading || checkingSetup) {
|
||||
return <div>Načítání…</div>;
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/admin" replace />;
|
||||
}
|
||||
|
||||
// If setup is required, redirect to setup wizard unless already on setup
|
||||
const currentPath = window.location.pathname;
|
||||
if (requiresSetup && currentPath !== '/setup') {
|
||||
return <Navigate to="/setup" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// Admin routes group wrapper (no layout here; pages render their own AdminLayout)
|
||||
const AdminRoutesWrapper = () => {
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
return (
|
||||
<ChakraProvider theme={theme}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Router>
|
||||
<AuthProvider>
|
||||
<ClubThemeProvider>
|
||||
<AnalyticsInitializer />
|
||||
<FontLoader />
|
||||
<DefaultSEO />
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/hledat" element={<SearchPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/overlay/scoreboard" element={<OverlayScoreboardPage />} />
|
||||
<Route path="/blog" element={<BlogPage />} />
|
||||
<Route path="/klub" element={<ClubPage />} />
|
||||
<Route path="/o-klubu" element={<AboutPage />} />
|
||||
<Route path="/kalendar" element={<CalendarPage />} />
|
||||
<Route path="/aktivity" element={<ActivitiesCalendarPage />} />
|
||||
<Route path="/tabulky" element={<TablesPage />} />
|
||||
<Route path="/zapasy" element={<MatchesPage />} />
|
||||
<Route path="/players" element={<PlayersPage />} />
|
||||
<Route path="/hraci" element={<PlayersPage />} />
|
||||
<Route path="/players/:id" element={<PlayerDetailPage />} />
|
||||
<Route path="/hraci/:id" element={<PlayerDetailPage />} />
|
||||
<Route path="/sponzori" element={<SponsorsPage />} />
|
||||
<Route path="/kontakt" element={<ContactPage />} />
|
||||
<Route path="/ankety" element={<PollsPage />} />
|
||||
<Route path="/galerie" element={<GalleryPage />} />
|
||||
<Route path="/galerie/album/:id" element={<AlbumDetailPage />} />
|
||||
<Route path="/videa" element={<VideosPage />} />
|
||||
<Route path="/obleceni" element={<ClothingPage />} />
|
||||
{/* Legal pages */}
|
||||
<Route path="/pravidla-cookies" element={<CookiePolicyPage />} />
|
||||
<Route path="/obchodni-podminky" element={<TermsPage />} />
|
||||
<Route path="/zasady-ochrany-osobnich-udaju" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/news" element={<Navigate to="/blog" replace />} />
|
||||
{/* Slug routes must precede id route to avoid conflicts */}
|
||||
<Route path="/news/:slug" element={<ArticleDetailPage />} />
|
||||
<Route path="/articles/slug/:slug" element={<ArticleDetailPage />} />
|
||||
<Route path="/articles/:id" element={<ArticleDetailPage />} />
|
||||
{/* Internal match detail */}
|
||||
<Route path="/zapas/:id" element={<MatchDetailPage />} />
|
||||
<Route path="/aktivita/:id" element={<ActivityDetailPage />} />
|
||||
{/* Legacy redirects */}
|
||||
<Route path="/clanky" element={<Navigate to="/blog" replace />} />
|
||||
<Route path="/aktuality" element={<Navigate to="/blog" replace />} />
|
||||
<Route
|
||||
path="/setup"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<SetupPage />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/setup/styl"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<StylePreviewPage />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<AuthPage />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
<Route path="/newsletter/unsubscribe/:email" element={<NewsletterUnsubscribePage />} />
|
||||
<Route path="/newsletter/preferences" element={<NewsletterPreferencesPage />} />
|
||||
<Route path="/403" element={<ForbiddenPage />} />
|
||||
|
||||
{/* Admin area (pages include AdminLayout themselves) */}
|
||||
<Route element={
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<AdminRoutesWrapper />
|
||||
</ProtectedRoute>
|
||||
}>
|
||||
<Route path="/admin" element={<AdminDashboardPage />} />
|
||||
<Route path="/admin/docs" element={<AdminDocsPage />} />
|
||||
<Route path="/admin/clanky" element={<ArticlesAdminPage />} />
|
||||
<Route path="/admin/o-klubu" element={<AboutAdminPage />} />
|
||||
<Route path="/admin/videa" element={<AdminVideosPage />} />
|
||||
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
|
||||
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
|
||||
<Route path="/admin/aktivity" element={<AdminActivitiesPage />} />
|
||||
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
|
||||
<Route path="/admin/kategorie" element={<CategoriesAdminPage />} />
|
||||
<Route path="/admin/media" element={<MediaAdminPage />} />
|
||||
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
|
||||
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
|
||||
<Route path="/admin/tymy" element={<TeamsAdminPage />} />
|
||||
<Route path="/admin/uzivatele" element={<UsersAdminPage />} />
|
||||
<Route path="/admin/bannery" element={<BannersAdminPage />} />
|
||||
<Route path="/admin/zpravy" element={<MessagesAdminPage />} />
|
||||
<Route path="/admin/nastaveni" element={<SettingsAdminPage />} />
|
||||
<Route path="/admin/newsletter" element={<NewsletterAdminPage />} />
|
||||
<Route path="/admin/ankety" element={<PollsAdminPage />} />
|
||||
<Route path="/admin/aliasy-soutezi" element={<CompetitionAliasesAdminPage />} />
|
||||
<Route path="/admin/prefetch" element={<PrefetchAdminPage />} />
|
||||
<Route path="/admin/users/send-reset" element={<AdminResetPasswordPage />} />
|
||||
<Route path="/admin/scoreboard" element={<ScoreboardAdminPage />} />
|
||||
<Route path="/admin/scoreboard/remote" element={<MobileScoreboardControlPage />} />
|
||||
<Route path="/admin/analytika" element={<AnalyticsAdminPage />} />
|
||||
<Route path="/admin/soubory" element={<FilesAdminPage />} />
|
||||
<Route path="/admin/kontakty" element={<ContactsAdminPage />} />
|
||||
<Route path="/admin/navigace" element={<NavigationAdminPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Remaining protected routes that don't use AdminLayout */}
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={<Navigate to="/admin" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/sponsors"
|
||||
element={
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<SponsorsAdminPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/banners"
|
||||
element={
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<BannersAdminPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/messages"
|
||||
element={
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<MessagesAdminPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/settings"
|
||||
element={
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<SettingsAdminPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Not found route */}
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
{/* Cookie consent banner shown across the whole site */}
|
||||
<CookieBanner />
|
||||
</ClubThemeProvider>
|
||||
</AuthProvider>
|
||||
</Router>
|
||||
</QueryClientProvider>
|
||||
</ChakraProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Box, Button, Flex, Text, Link } from '@chakra-ui/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'cookie_consent';
|
||||
|
||||
type Consent = {
|
||||
version: number;
|
||||
necessary: true; // always true
|
||||
preferences: boolean;
|
||||
analytics: boolean;
|
||||
marketing: boolean;
|
||||
timestamp: string; // ISO
|
||||
};
|
||||
|
||||
const defaultConsent: Consent = {
|
||||
version: 1,
|
||||
necessary: true,
|
||||
preferences: false,
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const CookieBanner: React.FC = () => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [managing, setManaging] = useState(false);
|
||||
const [consent, setConsent] = useState<Consent>(defaultConsent);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as Consent;
|
||||
setConsent(parsed);
|
||||
setVisible(false);
|
||||
} else {
|
||||
setVisible(true);
|
||||
}
|
||||
} catch {
|
||||
setVisible(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const saveAndClose = (c: Consent) => {
|
||||
const payload = { ...c, timestamp: new Date().toISOString() };
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
||||
setConsent(payload);
|
||||
setVisible(false);
|
||||
setManaging(false);
|
||||
// Dispatch a small custom event so analytics loaders can react
|
||||
window.dispatchEvent(new CustomEvent('cookie-consent-change', { detail: payload }));
|
||||
};
|
||||
|
||||
const acceptAll = () => {
|
||||
saveAndClose({ ...defaultConsent, preferences: true, analytics: true, marketing: true });
|
||||
};
|
||||
|
||||
const rejectNonEssential = () => {
|
||||
saveAndClose({ ...defaultConsent });
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Box role="dialog" aria-live="polite" position="fixed" bottom={0} left={0} right={0} bg="gray.900" color="gray.100" zIndex={1000} py={4} px={4}>
|
||||
<Flex align="start" justify="space-between" gap={6} wrap="wrap">
|
||||
<Box maxW={{ base: '100%', md: '70%' }}>
|
||||
<Text fontSize="sm" mb={2}>
|
||||
Tento web používá soubory cookies pro zajištění správného fungování (nezbytné) a za účelem vylepšení obsahu.
|
||||
O vybraných kategoriích rozhodujete vy. Podrobnosti najdete v
|
||||
<Link href="/pravidla-cookies" color="blue.300" textDecoration="underline">Pravidlech cookies</Link>.
|
||||
</Text>
|
||||
{managing && (
|
||||
<Box mt={3} bg="gray.800" borderRadius="md" p={3} border="1px solid" borderColor="gray.700">
|
||||
<Text fontWeight="semibold" mb={2}>Nastavení preferencí</Text>
|
||||
<Flex direction="column" gap={2}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input type="checkbox" checked readOnly />
|
||||
<Text fontSize="sm">Nezbytné cookies (vždy aktivní)</Text>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!consent.preferences}
|
||||
onChange={(e) => setConsent((c) => ({ ...c, preferences: e.target.checked }))}
|
||||
/>
|
||||
<Text fontSize="sm">Preferenční cookies (např. zapamatování voleb)</Text>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!consent.analytics}
|
||||
onChange={(e) => setConsent((c) => ({ ...c, analytics: e.target.checked }))}
|
||||
/>
|
||||
<Text fontSize="sm">Analytické cookies (anonymní měření návštěvnosti)</Text>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!consent.marketing}
|
||||
onChange={(e) => setConsent((c) => ({ ...c, marketing: e.target.checked }))}
|
||||
/>
|
||||
<Text fontSize="sm">Marketingové cookies</Text>
|
||||
</label>
|
||||
<Flex gap={2} mt={2} wrap="wrap">
|
||||
<Button size="sm" colorScheme="blue" onClick={() => saveAndClose(consent)}>Uložit nastavení</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setManaging(false)}>Zpět</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Flex gap={2} align="center" wrap="wrap">
|
||||
<Button size="sm" onClick={() => setManaging((v) => !v)} variant="outline">Nastavit</Button>
|
||||
<Button size="sm" onClick={rejectNonEssential} variant="ghost">Odmítnout nepovinné</Button>
|
||||
<Button size="sm" colorScheme="blue" onClick={acceptAll}>Přijmout vše</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CookieBanner;
|
||||
@@ -0,0 +1,717 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Button,
|
||||
useColorModeValue,
|
||||
useColorMode,
|
||||
IconButton,
|
||||
Avatar,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Text,
|
||||
HStack,
|
||||
Tooltip,
|
||||
useDisclosure,
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
DrawerContent,
|
||||
DrawerCloseButton,
|
||||
VStack,
|
||||
Divider,
|
||||
Container,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
Input,
|
||||
} from '@chakra-ui/react';
|
||||
import { MoonIcon, SunIcon, HamburgerIcon, EditIcon, ChevronDownIcon } from '@chakra-ui/icons';
|
||||
import { FaFacebook, FaInstagram, FaYoutube, FaPhotoVideo, FaExternalLinkAlt, FaShoppingBag, FaCamera, FaSearch } from 'react-icons/fa';
|
||||
import { Link as RouterLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { usePublicSettings } from '../hooks/usePublicSettings';
|
||||
import { useClubTheme } from '../contexts/ClubThemeContext';
|
||||
import { Image } from '@chakra-ui/react';
|
||||
import { getCategories, Category } from '../services/public';
|
||||
import { FaSearch as FaSearchIcon } from 'react-icons/fa';
|
||||
import { getNavigationItems, NavigationItem, seedDefaultNavigation } from '../services/navigation';
|
||||
|
||||
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
|
||||
|
||||
// Minimal normalization for social URLs so admins can input @handle or domain-less usernames
|
||||
const normalizeSocialUrl = (network: 'facebook' | 'instagram' | 'youtube', raw?: string | null): string | null => {
|
||||
let v = String(raw || '').trim();
|
||||
if (!v) return null;
|
||||
v = v.replace(/\s+/g, '');
|
||||
if (v.startsWith('@')) {
|
||||
const handle = v.slice(1);
|
||||
if (network === 'facebook') return `https://www.facebook.com/${handle}`;
|
||||
if (network === 'instagram') return `https://www.instagram.com/${handle}`;
|
||||
if (network === 'youtube') return `https://www.youtube.com/@${handle}`;
|
||||
}
|
||||
if (!/^https?:\/\//i.test(v) && !v.includes('/') && !v.includes('.')) {
|
||||
if (network === 'facebook') return `https://www.facebook.com/${v}`;
|
||||
if (network === 'instagram') return `https://www.instagram.com/${v}`;
|
||||
if (network === 'youtube') return `https://www.youtube.com/@${v}`;
|
||||
}
|
||||
if (!/^https?:\/\//i.test(v)) {
|
||||
if (/^facebook\.com\//i.test(v)) return `https://www.${v}`;
|
||||
if (/^instagram\.com\//i.test(v)) return `https://www.${v}`;
|
||||
if (/^youtube\.com\//i.test(v)) return `https://www.${v}`;
|
||||
}
|
||||
return v;
|
||||
};
|
||||
|
||||
// Mobile menu component
|
||||
const MobileMenu = ({ isOpen, onClose, isAdmin, menuBg, dividerColor, settings, categories, galleryHref, galleryLabel, hasTables, dynamicNavItems, navLoading }: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
isAdmin: boolean;
|
||||
menuBg: string;
|
||||
dividerColor: string;
|
||||
settings?: any;
|
||||
categories?: Category[] | null;
|
||||
galleryHref?: string | null;
|
||||
galleryLabel?: string;
|
||||
hasTables?: boolean | null;
|
||||
dynamicNavItems: NavigationItem[];
|
||||
navLoading: boolean;
|
||||
}) => (
|
||||
<Drawer isOpen={isOpen} placement="left" onClose={onClose}>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent bg={menuBg}>
|
||||
<DrawerCloseButton />
|
||||
<DrawerHeader borderBottomWidth="1px" borderColor="border.subtle">Menu</DrawerHeader>
|
||||
<DrawerBody>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{/* Dynamic navigation items in mobile */}
|
||||
{(!navLoading && dynamicNavItems.length > 0) ? (
|
||||
// Use dynamic navigation
|
||||
dynamicNavItems.map((item, idx) => {
|
||||
const linkIsExternal = item.type === 'external';
|
||||
const hasChildren = item.type === 'dropdown' && item.children && item.children.length > 0;
|
||||
const linkProps = linkIsExternal ? { href: item.url } : { to: item.url || '/' };
|
||||
const Comp: any = linkIsExternal ? 'a' : RouterLink;
|
||||
|
||||
return (
|
||||
<React.Fragment key={item.id || idx}>
|
||||
<Button
|
||||
as={Comp}
|
||||
{...linkProps}
|
||||
target={linkIsExternal ? '_blank' : undefined}
|
||||
rel={linkIsExternal ? 'noreferrer' : undefined}
|
||||
variant="ghost"
|
||||
justifyContent="flex-start"
|
||||
fontWeight={hasChildren ? 'bold' : 'normal'}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
{/* Render children for dropdown items */}
|
||||
{hasChildren && (
|
||||
<VStack align="stretch" pl={4} spacing={1}>
|
||||
{item.children!.map((child) => {
|
||||
const childIsExternal = child.type === 'external';
|
||||
const childLinkProps = childIsExternal ? { href: child.url } : { to: child.url || '/' };
|
||||
const ChildComp: any = childIsExternal ? 'a' : RouterLink;
|
||||
return (
|
||||
<Button
|
||||
key={child.id}
|
||||
as={ChildComp}
|
||||
{...(childLinkProps as any)}
|
||||
variant="ghost"
|
||||
justifyContent="flex-start"
|
||||
fontWeight="normal"
|
||||
size="sm"
|
||||
>
|
||||
{child.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// Fallback to hardcoded navigation
|
||||
<>
|
||||
<Button as={RouterLink} to="/" variant="ghost" justifyContent="flex-start">Domů</Button>
|
||||
{(settings?.show_about_in_nav ?? true) && (
|
||||
<Button as={RouterLink} to="/o-klubu" variant="ghost" justifyContent="flex-start">O klubu</Button>
|
||||
)}
|
||||
<Button as={RouterLink} to="/kalendar" variant="ghost" justifyContent="flex-start">Kalendář</Button>
|
||||
<Button as={RouterLink} to="/zapasy" variant="ghost" justifyContent="flex-start">Zápasy</Button>
|
||||
<Button as={RouterLink} to="/aktivity" variant="ghost" justifyContent="flex-start">Aktivity</Button>
|
||||
<Button as={RouterLink} to="/hraci" variant="ghost" justifyContent="flex-start">Hráči</Button>
|
||||
{hasTables ? (
|
||||
<Button as={RouterLink} to="/tabulky" variant="ghost" justifyContent="flex-start">Tabulky</Button>
|
||||
) : null}
|
||||
{Array.isArray(settings?.custom_nav) && settings.custom_nav.length > 0 && settings.custom_nav.map((item: any, idx: number) => {
|
||||
const customLinkIsExternal = typeof item?.url === 'string' && /^https?:\/\//i.test(item.url);
|
||||
const linkProps = customLinkIsExternal ? { href: item.url } : { to: item.url || '/' };
|
||||
const Comp: any = customLinkIsExternal ? 'a' : RouterLink;
|
||||
return (
|
||||
<Button
|
||||
key={`custom-nav-${idx}-${item?.label || 'link'}`}
|
||||
as={Comp}
|
||||
{...linkProps}
|
||||
target={customLinkIsExternal ? '_blank' : undefined}
|
||||
rel={customLinkIsExternal ? 'noreferrer' : undefined}
|
||||
variant="ghost"
|
||||
justifyContent="flex-start"
|
||||
>
|
||||
{item?.label || 'Stránka'}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<Button as={RouterLink} to="/blog" variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
|
||||
{Array.isArray(categories) && categories.length > 0 && (
|
||||
<VStack align="stretch" pl={4} spacing={1}>
|
||||
{categories.map((cat: any) => {
|
||||
const catIsExternal = typeof cat.url === 'string' && /^https?:\/\//i.test(cat.url);
|
||||
const catHref = cat.url || (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog');
|
||||
const catLinkProps = catIsExternal ? { href: catHref } : { to: catHref };
|
||||
return (
|
||||
<Button key={cat.slug || cat.name} as={catIsExternal ? 'a' : RouterLink} {...(catLinkProps as any)} variant="ghost" justifyContent="flex-start" fontWeight="normal" size="sm">
|
||||
{cat.name}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
)}
|
||||
<Button as={RouterLink} to="/videa" variant="ghost" justifyContent="flex-start">Videa</Button>
|
||||
<Button as={RouterLink} to="/hledat" variant="ghost" justifyContent="flex-start">Hledat</Button>
|
||||
<Button as={RouterLink} to="/galerie" variant="ghost" justifyContent="flex-start">{galleryLabel || 'Fotogalerie'}</Button>
|
||||
{settings?.shop_url && (
|
||||
<Button as="a" href={settings.shop_url} target="_blank" rel="noreferrer" variant="ghost" justifyContent="flex-start">Fanshop</Button>
|
||||
)}
|
||||
<Button as={RouterLink} to="/sponzori" variant="ghost" justifyContent="flex-start">Sponzoři</Button>
|
||||
<Button as={RouterLink} to="/kontakt" variant="ghost" justifyContent="flex-start">Kontakt</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Divider my={2} borderColor={dividerColor} />
|
||||
<Text fontWeight="bold" mt={2} color={dividerColor}>Administrace</Text>
|
||||
<Button as={RouterLink} to="/admin" variant="ghost" justifyContent="flex-start" colorScheme="blue">
|
||||
Administrace
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
const Navbar = () => {
|
||||
const { colorMode, toggleColorMode } = useColorMode();
|
||||
const { isAuthenticated, logout, user } = useAuth();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { isOpen: isSearchOpen, onOpen: onSearchOpen, onClose: onSearchClose } = useDisclosure();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const { data: settings } = usePublicSettings();
|
||||
const theme = useClubTheme();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const menuBg = useColorModeValue('white', '#0f1115');
|
||||
const dividerColor = useColorModeValue('gray.600', 'gray.300');
|
||||
const hoverBg = useColorModeValue('blackAlpha.100', 'whiteAlpha.200');
|
||||
const activeBg = useColorModeValue('blackAlpha.50', 'whiteAlpha.100');
|
||||
const activeTextColor = useColorModeValue('brand.primary', 'brand.accent');
|
||||
const navTextColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [hasTables, setHasTables] = useState<boolean | null>(null);
|
||||
const [dynamicNavItems, setDynamicNavItems] = useState<NavigationItem[]>([]);
|
||||
const [navLoading, setNavLoading] = useState(true);
|
||||
|
||||
// Search modal state
|
||||
const [query, setQuery] = useState('');
|
||||
const submitSearch = () => {
|
||||
const text = query.trim();
|
||||
if (!text) return;
|
||||
onSearchClose();
|
||||
setQuery('');
|
||||
navigate(`/hledat?q=${encodeURIComponent(text)}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 8);
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true } as any);
|
||||
return () => window.removeEventListener('scroll', onScroll as any);
|
||||
}, []);
|
||||
|
||||
// Also set document title to club name ASAP (SEO component will refine further)
|
||||
useEffect(() => {
|
||||
const name = settings?.club_name || theme.name;
|
||||
if (name && typeof document !== 'undefined') {
|
||||
document.title = name;
|
||||
}
|
||||
}, [settings?.club_name, theme.name]);
|
||||
|
||||
// Set favicon/logo in head for fan pages (SPA)
|
||||
useEffect(() => {
|
||||
try {
|
||||
let url = settings?.club_logo_url || theme.logoUrl || '/dist/img/logo-club-empty.svg';
|
||||
if (!url) return;
|
||||
// Normalize relative upload paths to API origin so favicon resolves on all pages
|
||||
try {
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const apiOrigin = new URL(apiUrl).origin;
|
||||
if (/^\/.+/.test(url) && !/^https?:\/\//i.test(url)) {
|
||||
// If starts with /uploads or any absolute path, prefix API origin
|
||||
url = apiOrigin + url;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const setIcon = (rel: string) => {
|
||||
let link = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`);
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.rel = rel as any;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.href = url;
|
||||
// Try to hint type if svg
|
||||
if (url.endsWith('.svg')) link.type = 'image/svg+xml';
|
||||
};
|
||||
setIcon('icon');
|
||||
setIcon('shortcut icon');
|
||||
} catch {}
|
||||
}, [settings?.club_logo_url, theme.logoUrl]);
|
||||
|
||||
// gallery link (generic first, fallback to zonerama)
|
||||
const galleryHref = settings?.gallery_url || settings?.zonerama_url;
|
||||
const galleryLabel = settings?.gallery_label || 'Fotogalerie';
|
||||
|
||||
// Load dynamic navigation from API
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const items = await getNavigationItems();
|
||||
if (active && Array.isArray(items)) {
|
||||
// Filter out admin-only navigation items for public display
|
||||
const publicItems = items.filter(item => !item.requires_admin);
|
||||
|
||||
// Auto-seed if navigation is empty (only if user is authenticated as admin)
|
||||
if (publicItems.length === 0 && isAdmin) {
|
||||
try {
|
||||
console.log('Navigation empty, auto-seeding...');
|
||||
await seedDefaultNavigation();
|
||||
const newItems = await getNavigationItems();
|
||||
if (active && Array.isArray(newItems)) {
|
||||
const publicNewItems = newItems.filter(item => !item.requires_admin);
|
||||
setDynamicNavItems(publicNewItems);
|
||||
}
|
||||
} catch (seedError) {
|
||||
console.error('Auto-seed failed:', seedError);
|
||||
// Continue with empty navigation
|
||||
}
|
||||
} else {
|
||||
setDynamicNavItems(publicItems);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load navigation:', error);
|
||||
} finally {
|
||||
if (active) setNavLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { active = false };
|
||||
}, [isAdmin]);
|
||||
|
||||
// categories: prefer API, fallback to settings.categories
|
||||
const [navCategories, setNavCategories] = useState<Category[] | null>(null);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const cats = await getCategories();
|
||||
if (active && Array.isArray(cats) && cats.length > 0) {
|
||||
setNavCategories(cats);
|
||||
} else if (active && Array.isArray(settings?.categories)) {
|
||||
setNavCategories(settings!.categories as any);
|
||||
}
|
||||
} catch {
|
||||
if (active && Array.isArray(settings?.categories)) {
|
||||
setNavCategories(settings!.categories as any);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => { active = false };
|
||||
}, [settings?.categories]);
|
||||
|
||||
// Determine if there is any table data available (prefetch snapshot)
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
const resolveBackendUrl = (path: string) => {
|
||||
try {
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
|
||||
const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const origin = new URL(base).origin;
|
||||
return new URL(path, origin).toString();
|
||||
}
|
||||
return path;
|
||||
} catch { return path; }
|
||||
};
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(resolveBackendUrl('/cache/prefetch/facr_tables.json'), { cache: 'no-cache' });
|
||||
if (!res.ok) { if (!disposed) setHasTables(false); return; }
|
||||
const json = await res.json();
|
||||
const anyRows = Array.isArray(json?.competitions) && json.competitions.some((c: any) => Array.isArray(c?.table?.overall) && c.table.overall.length > 0);
|
||||
if (!disposed) setHasTables(!!anyRows);
|
||||
} catch {
|
||||
if (!disposed) setHasTables(false);
|
||||
}
|
||||
})();
|
||||
return () => { disposed = true; };
|
||||
}, []);
|
||||
|
||||
const isPathActive = (to?: string) => {
|
||||
if (!to) return false;
|
||||
// Active when current pathname starts with target (handles subroutes)
|
||||
return location.pathname === to || location.pathname.startsWith(to + '/');
|
||||
};
|
||||
|
||||
// Convert NavigationItem to NavLink format
|
||||
const convertToNavLink = (item: NavigationItem): NavLink => {
|
||||
const link: NavLink = {
|
||||
label: item.label,
|
||||
to: item.url || '#',
|
||||
external: item.type === 'external',
|
||||
};
|
||||
|
||||
// Add children for dropdown items
|
||||
if (item.type === 'dropdown' && item.children && item.children.length > 0) {
|
||||
link.items = item.children.map(child => ({
|
||||
label: child.label,
|
||||
to: child.url || '#',
|
||||
}));
|
||||
}
|
||||
|
||||
return link;
|
||||
};
|
||||
|
||||
// Build categories as items for Články dropdown (fallback)
|
||||
const categoryItems = useMemo(() => {
|
||||
const source = Array.isArray(navCategories) && navCategories.length > 0 ? navCategories : [];
|
||||
return source.map((cat: any) => ({
|
||||
label: cat.name,
|
||||
to: cat.url || (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog')
|
||||
}));
|
||||
}, [navCategories]);
|
||||
|
||||
// Use dynamic navigation if available, otherwise fallback to hardcoded
|
||||
let NAV_LINKS: NavLink[] = useMemo(() => {
|
||||
if (!navLoading && dynamicNavItems.length > 0) {
|
||||
// Use dynamic navigation from API
|
||||
return dynamicNavItems.map(convertToNavLink);
|
||||
}
|
||||
|
||||
// Fallback to hardcoded navigation
|
||||
let links: NavLink[] = [
|
||||
{ label: 'Domů', to: '/' },
|
||||
...(settings?.show_about_in_nav === false ? [] : [{ label: 'O klubu', to: '/o-klubu' } as NavLink]),
|
||||
{ label: 'Kalendář', to: '/kalendar' },
|
||||
{ label: 'Zápasy', to: '/zapasy' },
|
||||
{ label: 'Aktivity', to: '/aktivity' },
|
||||
{ label: 'Hráči', to: '/hraci' },
|
||||
{ label: 'Tabulky', to: '/tabulky' },
|
||||
// Články with categories as subcategories
|
||||
categoryItems.length > 0
|
||||
? { label: 'Články', to: '/blog', items: categoryItems }
|
||||
: { label: 'Články', to: '/blog' },
|
||||
{ label: 'Videa', to: '/videa' },
|
||||
{ label: galleryLabel, to: '/galerie' },
|
||||
...(settings?.shop_url ? [{ label: 'Fanshop', to: settings.shop_url, external: true } as NavLink] : []),
|
||||
{ label: 'Sponzoři', to: '/sponzori' },
|
||||
{ label: 'Kontakt', to: '/kontakt' },
|
||||
];
|
||||
|
||||
// Inject custom pages from settings.custom_nav (label + url + external?)
|
||||
const customNav = Array.isArray((settings as any)?.custom_nav) ? ((settings as any).custom_nav as any[]) : [];
|
||||
if (customNav.length > 0) {
|
||||
const mapped: NavLink[] = customNav.map((it) => ({ label: String(it.label || 'Stránka'), to: String(it.url || '#'), external: Boolean(it.external) }));
|
||||
const insertIdx = links.findIndex((n) => n.label === 'Tabulky');
|
||||
if (insertIdx >= 0) {
|
||||
links = [...links.slice(0, insertIdx + 1), ...mapped, ...links.slice(insertIdx + 1)];
|
||||
} else {
|
||||
links = [...links, ...mapped];
|
||||
}
|
||||
}
|
||||
|
||||
// Hide Tabulky when there is no table data
|
||||
if (hasTables === false) {
|
||||
links = links.filter((n) => n.label !== 'Tabulky');
|
||||
}
|
||||
|
||||
return links;
|
||||
}, [dynamicNavItems, navLoading, settings, categoryItems, hasTables, galleryLabel]);
|
||||
|
||||
return (
|
||||
<Box position="sticky" top={0} zIndex={1000}>
|
||||
{/* Top bar with socials and quick external links */}
|
||||
{(settings?.facebook_url || settings?.instagram_url || settings?.youtube_url || settings?.shop_url) && (
|
||||
<Box bg={useColorModeValue('gray.50', 'blackAlpha.500')} borderBottomWidth="1px" borderColor="border.subtle" py={1}>
|
||||
<Container maxW="7xl">
|
||||
<Flex align="center" justify="space-between" gap={2}>
|
||||
<HStack spacing={2}>
|
||||
{settings?.shop_url && (
|
||||
<Button as="a" href={settings.shop_url} target="_blank" rel="noreferrer" variant="link" size="xs" leftIcon={<FaShoppingBag />}>
|
||||
Fanshop
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
{normalizeSocialUrl('facebook', settings?.facebook_url) && (
|
||||
<IconButton as="a" href={normalizeSocialUrl('facebook', settings?.facebook_url) || undefined} target="_blank" rel="noreferrer" aria-label="Facebook" icon={<FaFacebook />} variant="ghost" size="xs" />
|
||||
)}
|
||||
{normalizeSocialUrl('instagram', settings?.instagram_url) && (
|
||||
<IconButton as="a" href={normalizeSocialUrl('instagram', settings?.instagram_url) || undefined} target="_blank" rel="noreferrer" aria-label="Instagram" icon={<FaInstagram />} variant="ghost" size="xs" />
|
||||
)}
|
||||
{normalizeSocialUrl('youtube', settings?.youtube_url) && (
|
||||
<IconButton as="a" href={normalizeSocialUrl('youtube', settings?.youtube_url) || undefined} target="_blank" rel="noreferrer" aria-label="YouTube" icon={<FaYoutube />} variant="ghost" size="xs" />
|
||||
)}
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Main Nav Bar */}
|
||||
<Box
|
||||
bg={useColorModeValue('rgba(255,255,255,0.9)', 'rgba(15,17,21,0.85)')}
|
||||
backdropFilter="saturate(180%) blur(10px)"
|
||||
borderBottomWidth="1px"
|
||||
borderColor="border.subtle"
|
||||
boxShadow={scrolled ? 'sm' : 'none'}
|
||||
transition="box-shadow 0.2s ease, background-color 0.2s ease, backdrop-filter 0.2s ease"
|
||||
>
|
||||
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navCategories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={hasTables} dynamicNavItems={dynamicNavItems} navLoading={navLoading} />
|
||||
<Container maxW="7xl">
|
||||
<Flex h={16} alignItems="center" justifyContent="space-between">
|
||||
<HStack spacing={4} alignItems="center">
|
||||
{/* Club Logo only */}
|
||||
<HStack as={RouterLink} to="/" spacing={3} align="center">
|
||||
{(settings?.club_logo_url || theme.logoUrl) && (
|
||||
<Image
|
||||
src={settings?.club_logo_url || theme.logoUrl}
|
||||
alt={settings?.club_name || theme.name || 'Logo'}
|
||||
boxSize={{ base: '36px', md: '40px' }}
|
||||
objectFit="contain"
|
||||
borderRadius="full"
|
||||
borderWidth="2px"
|
||||
borderColor="brand.primary"
|
||||
style={{
|
||||
padding: (settings?.club_logo_url || theme.logoUrl)?.includes('logoapi.sportcreative.eu') ? '4px' : '0px',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
{/* Desktop navigation with hover dropdowns */}
|
||||
<HStack as="nav" spacing={1} display={{ base: 'none', lg: 'flex' }} ml={4}>
|
||||
{NAV_LINKS.map((nav) => {
|
||||
const commonProps = {
|
||||
variant: 'ghost' as const,
|
||||
size: 'sm' as const,
|
||||
px: 3,
|
||||
_hover: { bg: hoverBg, transform: 'translateY(-1px)' },
|
||||
fontWeight: isPathActive(nav.to) ? '700' : '600',
|
||||
color: isPathActive(nav.to) ? activeTextColor : navTextColor,
|
||||
bg: isPathActive(nav.to) ? activeBg : 'transparent',
|
||||
transition: 'all 0.2s',
|
||||
};
|
||||
|
||||
// Handle items with dropdown (like Články with categories)
|
||||
if (nav.items && nav.items.length > 0) {
|
||||
return (
|
||||
<HoverMenu key={nav.label} label={nav.label} items={nav.items} isActive={isPathActive(nav.to)} />
|
||||
);
|
||||
}
|
||||
|
||||
if (nav.external && nav.to) {
|
||||
return (
|
||||
<Button key={nav.label} as="a" href={nav.to} target="_blank" rel="noreferrer" rightIcon={<FaExternalLinkAlt />} {...commonProps}>
|
||||
{nav.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button key={nav.label} as={RouterLink} to={nav.to || '#'} {...commonProps}>
|
||||
{nav.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<Flex alignItems="center">
|
||||
{/* Mobile menu button */}
|
||||
<IconButton
|
||||
display={{ base: 'flex', md: 'none' }}
|
||||
onClick={onOpen}
|
||||
icon={<HamburgerIcon />}
|
||||
aria-label="Otevřít menu"
|
||||
variant="ghost"
|
||||
mr={2}
|
||||
/>
|
||||
|
||||
{/* Space reserved (socials moved to top bar) */}
|
||||
<Box display={{ base: 'none', md: 'flex' }} mr={2} />
|
||||
|
||||
{/* Search button */}
|
||||
<Tooltip label="Hledat" hasArrow>
|
||||
<IconButton
|
||||
aria-label="Hledat"
|
||||
icon={<FaSearch />}
|
||||
size="sm"
|
||||
mr={2}
|
||||
variant="ghost"
|
||||
onClick={onSearchOpen}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* Admin edit button */}
|
||||
{isAdmin && (
|
||||
<Tooltip label="Správa obsahu" hasArrow>
|
||||
<IconButton
|
||||
as={RouterLink}
|
||||
to="/admin"
|
||||
aria-label="Správa obsahu"
|
||||
icon={<EditIcon />}
|
||||
size="sm"
|
||||
mr={2}
|
||||
colorScheme="blue"
|
||||
variant="ghost"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Color mode toggle */}
|
||||
<IconButton
|
||||
size="md"
|
||||
fontSize="lg"
|
||||
aria-label="Přepnout barevné téma"
|
||||
variant="ghost"
|
||||
color="current"
|
||||
onClick={toggleColorMode}
|
||||
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
|
||||
/>
|
||||
|
||||
{isAuthenticated && (
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
rounded="full"
|
||||
variant="link"
|
||||
cursor="pointer"
|
||||
minW={0}
|
||||
ml={2}
|
||||
>
|
||||
<Avatar size="sm" name={user?.name || 'Uživatel'} />
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
<MenuItem as={RouterLink} to="/admin/nastaveni">Můj účet</MenuItem>
|
||||
{isAdmin && <MenuItem as={RouterLink} to="/admin">Administrace</MenuItem>}
|
||||
<MenuItem onClick={logout}>Odhlásit se</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
)}
|
||||
</Flex>
|
||||
{/* Close outer Flex */}
|
||||
</Flex>
|
||||
</Container>
|
||||
|
||||
{/* Search Modal */}
|
||||
<Modal isOpen={isSearchOpen} onClose={onSearchClose} size="md" motionPreset="scale">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Vyhledávání</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitSearch();
|
||||
}}
|
||||
>
|
||||
<VStack spacing={4}>
|
||||
<InputGroup size="lg">
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<FaSearchIcon />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="Hledat kluby, zápasy, články, hráče..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</InputGroup>
|
||||
<Button type="submit" colorScheme="blue" size="lg" w="full" leftIcon={<FaSearchIcon />}>
|
||||
Vyhledat
|
||||
</Button>
|
||||
</VStack>
|
||||
</form>
|
||||
<Text fontSize="sm" color="gray.500" mt={4} textAlign="center">
|
||||
Zadejte klíčová slova pro vyhledávání
|
||||
</Text>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// HoverMenu component for desktop dropdown nav
|
||||
const HoverMenu = ({ label, items, isActive }: { label: string; items: { label: string; to: string }[]; isActive?: boolean }) => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
return (
|
||||
<Box onMouseEnter={onOpen} onMouseLeave={onClose}>
|
||||
<Menu isOpen={isOpen} placement="bottom-start" gutter={4}>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
rightIcon={<ChevronDownIcon />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
px={3}
|
||||
fontWeight={isActive ? '700' : '600'}
|
||||
color={useColorModeValue(isActive ? 'brand.primary' : 'gray.700', isActive ? 'brand.accent' : 'gray.200')}
|
||||
bg={isActive ? useColorModeValue('blackAlpha.50', 'whiteAlpha.100') : 'transparent'}
|
||||
_hover={{ bg: useColorModeValue('blackAlpha.100', 'whiteAlpha.200'), transform: 'translateY(-1px)' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
{label}
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
{items.map((it) => (
|
||||
<MenuItem as={RouterLink} to={it.to} key={it.to}>
|
||||
{it.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
|
||||
// Search Modal rendered alongside Navbar content
|
||||
// Note: We append the modal inside Navbar return to keep code compact
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getSetupStatus } from '../services/setup';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: JSX.Element;
|
||||
requiredRole?: string;
|
||||
}
|
||||
|
||||
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children, requiredRole }) => {
|
||||
const { isAuthenticated, isLoading, user } = useAuth();
|
||||
const location = useLocation();
|
||||
const [checkingSetup, setCheckingSetup] = useState(true);
|
||||
const [requiresSetup, setRequiresSetup] = useState<boolean>(false);
|
||||
|
||||
// Check if setup is required
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const s = await getSetupStatus();
|
||||
if (mounted) setRequiresSetup(!!s.requires_setup);
|
||||
} catch (_) {
|
||||
if (mounted) setRequiresSetup(false);
|
||||
} finally {
|
||||
if (mounted) setCheckingSetup(false);
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
if (isLoading || checkingSetup) {
|
||||
// Show loading spinner or skeleton
|
||||
return <div>Načítání…</div>;
|
||||
}
|
||||
|
||||
// If setup is required, redirect to setup page
|
||||
if (requiresSetup) {
|
||||
return <Navigate to="/setup" replace />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// Redirect to login page, but save the current location
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
// Role-based access control
|
||||
if (requiredRole && user && user.role && user.role !== requiredRole && user.role !== 'admin') {
|
||||
// Redirect to 403 Forbidden page
|
||||
return <Navigate to="/403" state={{ from: location.pathname }} replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
@@ -0,0 +1,11 @@
|
||||
// Deprecated: use `components/admin/AdminSidebar` via `layouts/AdminLayout`.
|
||||
// This thin wrapper keeps backward compatibility by rendering the new AdminSidebar.
|
||||
import AdminSidebar from './admin/AdminSidebar';
|
||||
|
||||
const Sidebar = () => {
|
||||
return (
|
||||
<AdminSidebar isOpen={true} onClose={() => {}} />
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Box, HStack, Image, Link, Spinner, Text, useColorModeValue } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '../services/api';
|
||||
import { assetUrl } from '../utils/url';
|
||||
|
||||
type Sponsor = { id: number; name: string; logo_url?: string; website_url?: string };
|
||||
|
||||
type SponsorsResponse = Sponsor[] | { data: Sponsor[] };
|
||||
|
||||
const fetchSponsors = async (): Promise<Sponsor[]> => {
|
||||
const res = await api.get<SponsorsResponse>('/sponsors');
|
||||
const data = Array.isArray(res.data) ? res.data : res.data.data;
|
||||
return data || [];
|
||||
};
|
||||
|
||||
const SponsorsStrip: React.FC = () => {
|
||||
const { data, isLoading, isError } = useQuery({ queryKey: ['sponsors'], queryFn: fetchSponsors });
|
||||
|
||||
return (
|
||||
<Box bg={useColorModeValue('white', 'gray.800')} borderTopWidth="1px" borderColor={useColorModeValue('gray.200', 'gray.700')} mt={8} py={4}>
|
||||
<HStack spacing={6} overflowX="auto" px={4}>
|
||||
{isLoading && <Spinner />}
|
||||
{isError && <Text color="red.500">Chyba při načítání sponzorů</Text>}
|
||||
{data?.map((s) => (
|
||||
<Link key={s.id} href={s.website_url || '#'} isExternal>
|
||||
<Image src={assetUrl(s.logo_url) || '/logo192.png'} alt={s.name} height="50px" objectFit="contain" />
|
||||
</Link>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SponsorsStrip;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Box, BoxProps, useColorModeValue } from '@chakra-ui/react';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface AdminCardProps extends BoxProps {
|
||||
children: ReactNode;
|
||||
variant?: 'outline' | 'filled' | 'unstyled';
|
||||
hoverEffect?: boolean;
|
||||
}
|
||||
|
||||
export const AdminCard = ({
|
||||
children,
|
||||
variant = 'outline',
|
||||
hoverEffect = false,
|
||||
...props
|
||||
}: AdminCardProps) => {
|
||||
const bg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const hoverBg = useColorModeValue('gray.50', 'gray.750');
|
||||
|
||||
const variants = {
|
||||
outline: {
|
||||
bg,
|
||||
border: '1px solid',
|
||||
borderColor,
|
||||
},
|
||||
filled: {
|
||||
bg: useColorModeValue('gray.50', 'gray.750'),
|
||||
},
|
||||
unstyled: {},
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderRadius="lg"
|
||||
p={6}
|
||||
boxShadow="sm"
|
||||
transition="all 0.2s"
|
||||
_hover={hoverEffect ? { transform: 'translateY(-2px)', boxShadow: 'md' } : {}}
|
||||
{...variants[variant]}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,321 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts';
|
||||
import { useLocalStorage } from '../../hooks/useLocalStorage';
|
||||
import { FiCommand, FiSave, FiRefreshCw, FiSearch } from 'react-icons/fi';
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* AdminEnhancer - Adds admin-specific functionality
|
||||
* - Keyboard shortcuts (Ctrl+S, Ctrl+K, etc.)
|
||||
* - Auto-save drafts
|
||||
* - Unsaved changes warning
|
||||
* - Quick search
|
||||
* - Keyboard shortcuts help
|
||||
*/
|
||||
|
||||
interface AdminEnhancerProps {
|
||||
children: React.ReactNode;
|
||||
onSave?: () => void | Promise<void>;
|
||||
onRefresh?: () => void | Promise<void>;
|
||||
onSearch?: () => void;
|
||||
hasUnsavedChanges?: boolean;
|
||||
}
|
||||
|
||||
const AdminEnhancer: React.FC<AdminEnhancerProps> = ({
|
||||
children,
|
||||
onSave,
|
||||
onRefresh,
|
||||
onSearch,
|
||||
hasUnsavedChanges = false,
|
||||
}) => {
|
||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||
const [lastSaved, setLastSaved] = useLocalStorage('admin-last-saved', '');
|
||||
const toast = useToast();
|
||||
|
||||
// Warn before leaving with unsaved changes
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedChanges) return;
|
||||
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
return '';
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, [hasUnsavedChanges]);
|
||||
|
||||
// Handle save
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!onSave) return;
|
||||
|
||||
try {
|
||||
await onSave();
|
||||
const now = new Date().toISOString();
|
||||
setLastSaved(now);
|
||||
toast({
|
||||
title: 'Uloženo',
|
||||
description: 'Změny byly úspěšně uloženy',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
position: 'bottom-right',
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: 'Nepodařilo se uložit změny',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
position: 'bottom-right',
|
||||
});
|
||||
}
|
||||
}, [onSave, setLastSaved, toast]);
|
||||
|
||||
// Handle refresh
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (!onRefresh) return;
|
||||
|
||||
try {
|
||||
await onRefresh();
|
||||
toast({
|
||||
title: 'Obnoveno',
|
||||
description: 'Data byla aktualizována',
|
||||
status: 'info',
|
||||
duration: 2000,
|
||||
position: 'bottom-right',
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: 'Nepodařilo se obnovit data',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
position: 'bottom-right',
|
||||
});
|
||||
}
|
||||
}, [onRefresh, toast]);
|
||||
|
||||
// Admin keyboard shortcuts
|
||||
useKeyboardShortcuts([
|
||||
{
|
||||
key: 's',
|
||||
ctrlKey: true,
|
||||
callback: () => {
|
||||
handleSave();
|
||||
},
|
||||
description: 'Uložit změny',
|
||||
},
|
||||
{
|
||||
key: 'k',
|
||||
ctrlKey: true,
|
||||
callback: () => {
|
||||
if (onSearch) onSearch();
|
||||
},
|
||||
description: 'Otevřít vyhledávání',
|
||||
},
|
||||
{
|
||||
key: 'r',
|
||||
ctrlKey: true,
|
||||
callback: () => {
|
||||
handleRefresh();
|
||||
},
|
||||
description: 'Obnovit data',
|
||||
},
|
||||
{
|
||||
key: '?',
|
||||
shiftKey: true,
|
||||
callback: () => setShowShortcuts(true),
|
||||
description: 'Zobrazit klávesové zkratky',
|
||||
},
|
||||
{
|
||||
key: 'Escape',
|
||||
callback: () => setShowShortcuts(false),
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
|
||||
{/* Unsaved changes indicator */}
|
||||
{hasUnsavedChanges && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 24,
|
||||
left: 24,
|
||||
padding: '12px 16px',
|
||||
background: '#f59e0b',
|
||||
color: 'white',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
zIndex: 9999,
|
||||
animation: 'pulse 2s infinite',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>
|
||||
Máte neuložené změny
|
||||
</span>
|
||||
{onSave && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
background: 'white',
|
||||
color: '#f59e0b',
|
||||
border: 'none',
|
||||
padding: '6px 12px',
|
||||
borderRadius: 4,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Uložit nyní
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyboard shortcuts modal */}
|
||||
{showShortcuts && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
zIndex: 10000,
|
||||
}}
|
||||
onClick={() => setShowShortcuts(false)}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
background: 'white',
|
||||
borderRadius: 12,
|
||||
padding: 24,
|
||||
maxWidth: 500,
|
||||
width: '90%',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
zIndex: 10001,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
|
||||
<FiCommand size={24} />
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>
|
||||
Klávesové zkratky
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<ShortcutItem keys={['Ctrl', 'S']} description="Uložit změny" icon={<FiSave />} />
|
||||
<ShortcutItem keys={['Ctrl', 'K']} description="Vyhledávání" icon={<FiSearch />} />
|
||||
<ShortcutItem keys={['Ctrl', 'R']} description="Obnovit data" icon={<FiRefreshCw />} />
|
||||
<ShortcutItem keys={['?']} description="Zobrazit zkratky" icon={<FiCommand />} />
|
||||
<ShortcutItem keys={['Esc']} description="Zavřít modál" />
|
||||
<ShortcutItem keys={['Home']} description="Na začátek stránky" />
|
||||
<ShortcutItem keys={['End']} description="Na konec stránky" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowShortcuts(false)}
|
||||
style={{
|
||||
marginTop: 20,
|
||||
width: '100%',
|
||||
padding: '10px 20px',
|
||||
background: 'var(--primary, #C53030)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Zavřít
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Shortcut hint button */}
|
||||
<button
|
||||
onClick={() => setShowShortcuts(true)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 24,
|
||||
right: 24,
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: '50%',
|
||||
background: 'white',
|
||||
border: '2px solid #e2e8f0',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
transition: 'all 0.3s ease',
|
||||
zIndex: 9998,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(0,0,0,0.15)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
|
||||
}}
|
||||
title="Klávesové zkratky (Shift + ?)"
|
||||
>
|
||||
<FiCommand size={24} color="#4a5568" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ShortcutItem: React.FC<{ keys: string[]; description: string; icon?: React.ReactNode }> = ({
|
||||
keys,
|
||||
description,
|
||||
icon,
|
||||
}) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 0' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{icon && <span style={{ color: '#718096' }}>{icon}</span>}
|
||||
<span style={{ fontSize: 14, color: '#2d3748' }}>{description}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{keys.map((key, i) => (
|
||||
<React.Fragment key={key}>
|
||||
<kbd
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
background: '#e2e8f0',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
color: '#4a5568',
|
||||
border: '1px solid #cbd5e0',
|
||||
}}
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
{i < keys.length - 1 && <span style={{ color: '#a0aec0' }}>+</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AdminEnhancer;
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
IconButton,
|
||||
useColorMode,
|
||||
Text,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Avatar,
|
||||
useColorModeValue,
|
||||
HStack,
|
||||
BoxProps,
|
||||
Tooltip,
|
||||
Link as ChakraLink
|
||||
} from '@chakra-ui/react';
|
||||
import { FaBars, FaMoon, FaSun, FaSignOutAlt, FaUserCog, FaBook } from 'react-icons/fa';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { User } from '../../types';
|
||||
import { ReactNode } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
interface AdminHeaderProps extends BoxProps {
|
||||
onMenuToggle: () => void;
|
||||
rightContent?: ReactNode;
|
||||
}
|
||||
|
||||
const AdminHeader = ({ onMenuToggle, rightContent, ...rest }: AdminHeaderProps) => {
|
||||
const { colorMode, toggleColorMode } = useColorMode();
|
||||
const { user, logout } = useAuth();
|
||||
const bg = useColorModeValue('white', '#1a1d29');
|
||||
const borderColor = useColorModeValue('gray.200', 'rgba(255, 255, 255, 0.12)');
|
||||
const textColor = useColorModeValue('gray.800', '#e2e8f0');
|
||||
const userData = user as User | null;
|
||||
const headerShadow = useColorModeValue('sm', 'none');
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="header"
|
||||
position="sticky"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bg={bg}
|
||||
borderBottomWidth="1px"
|
||||
borderColor={borderColor}
|
||||
zIndex={20}
|
||||
height="60px"
|
||||
px={{ base: 3, md: 6 }}
|
||||
boxShadow={headerShadow}
|
||||
transition="all 0.2s"
|
||||
{...rest}
|
||||
>
|
||||
<Flex h="100%" alignItems="center" justifyContent="space-between">
|
||||
<Flex alignItems="center">
|
||||
<IconButton
|
||||
display={{ base: 'flex', md: 'none' }}
|
||||
aria-label="Otevřít menu"
|
||||
icon={<FaBars />}
|
||||
variant="ghost"
|
||||
onClick={onMenuToggle}
|
||||
mr={2}
|
||||
/>
|
||||
<Text fontSize="xl" fontWeight="bold" display={{ base: 'none', md: 'block' }}>
|
||||
Fotbal Admin
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<HStack spacing={4}>
|
||||
{rightContent || (
|
||||
<>
|
||||
<Tooltip label="Dokumentace" hasArrow>
|
||||
<ChakraLink as={RouterLink} to="/admin/docs">
|
||||
<IconButton
|
||||
aria-label="Dokumentace"
|
||||
icon={<FaBook />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
mr={1}
|
||||
/>
|
||||
</ChakraLink>
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
aria-label="Přepnout barevné schéma"
|
||||
icon={colorMode === 'light' ? <FaMoon /> : <FaSun />}
|
||||
variant="ghost"
|
||||
onClick={toggleColorMode}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<Menu>
|
||||
<MenuButton>
|
||||
<Avatar
|
||||
size="sm"
|
||||
name={userData?.name || 'Uživatel'}
|
||||
src={userData?.avatar}
|
||||
cursor="pointer"
|
||||
border="2px solid"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.600')}
|
||||
_hover={{
|
||||
transform: 'scale(1.05)',
|
||||
transition: 'transform 0.2s'
|
||||
}}
|
||||
/>
|
||||
</MenuButton>
|
||||
<MenuList zIndex={30}>
|
||||
{userData?.name && (
|
||||
<Box px={3} py={2} borderBottomWidth="1px" borderColor={borderColor}>
|
||||
<Text fontWeight="medium">{userData.name}</Text>
|
||||
<Text fontSize="sm" color="gray.500">{userData.email}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<MenuItem
|
||||
icon={<FaUserCog />}
|
||||
_hover={{
|
||||
bg: useColorModeValue('gray.100', 'gray.700')
|
||||
}}
|
||||
>
|
||||
Můj účet
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<FaSignOutAlt />}
|
||||
color="red.500"
|
||||
_hover={{
|
||||
bg: useColorModeValue('red.50', 'red.900')
|
||||
}}
|
||||
onClick={logout}
|
||||
>
|
||||
Odhlásit se
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminHeader;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Box, Text, Link, Alert, AlertIcon } from '@chakra-ui/react';
|
||||
|
||||
const AdminHelp: React.FC = () => {
|
||||
return (
|
||||
<Box mt={6}>
|
||||
<Alert status="info" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<Text>
|
||||
Pro kompletní dokumentaci navštivte{' '}
|
||||
<Link href="/docs" color="blue.600" fontWeight="semibold" textDecoration="underline">
|
||||
dokumentaci administrace
|
||||
</Link>
|
||||
</Text>
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminHelp;
|
||||
@@ -0,0 +1,193 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
Input,
|
||||
List,
|
||||
ListItem,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Icon,
|
||||
Box,
|
||||
Kbd,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaSearch, FaCog, FaNewspaper, FaUsers, FaImage, FaHandshake, FaEnvelope, FaAward, FaSyncAlt, FaVideo, FaCalendarAlt, FaPalette, FaCommentAlt, FaKey, FaChartLine, FaBook, FaTools, FaBell, FaBars } from 'react-icons/fa';
|
||||
|
||||
export type AdminSearchItem = {
|
||||
label: string;
|
||||
path: string;
|
||||
section: string;
|
||||
keywords?: string[];
|
||||
icon?: any;
|
||||
};
|
||||
|
||||
const adminIndex: AdminSearchItem[] = [
|
||||
{ label: 'Dashboard', path: '/admin', section: 'Core', keywords: ['overview', 'stat', 'dashboard'], icon: FaTools },
|
||||
{ label: 'Články', path: '/admin/clanky', section: 'Obsah', keywords: ['articles', 'posts', 'blog'], icon: FaNewspaper },
|
||||
{ label: 'Hráči', path: '/admin/hraci', section: 'Kádry', keywords: ['players'], icon: FaUsers },
|
||||
{ label: 'Týmy', path: '/admin/tymy', section: 'Kádry', keywords: ['teams'], icon: FaUsers },
|
||||
{ label: 'Zápasy', path: '/admin/zapasy', section: 'FAČR', keywords: ['matches', 'facr'], icon: FaCalendarAlt },
|
||||
{ label: 'Média', path: '/admin/media', section: 'Obsah', keywords: ['uploads', 'images'], icon: FaImage },
|
||||
{ label: 'Sponzoři', path: '/admin/sponzori', section: 'Marketing', keywords: ['sponsors', 'partners'], icon: FaHandshake },
|
||||
{ label: 'Bannery', path: '/admin/bannery', section: 'Marketing', keywords: ['banners'], icon: FaImage },
|
||||
{ label: 'Kategorie', path: '/admin/kategorie', section: 'Obsah', keywords: ['categories'], icon: FaAward },
|
||||
{ label: 'Nastavení', path: '/admin/nastaveni', section: 'Systém', keywords: ['settings', 'config'], icon: FaCog },
|
||||
{ label: 'Newsletter', path: '/admin/newsletter', section: 'Komunikace', keywords: ['email', 'campaign'], icon: FaEnvelope },
|
||||
{ label: 'Uživatelé', path: '/admin/uzivatele', section: 'Systém', keywords: ['users', 'accounts'], icon: FaKey },
|
||||
{ label: 'Prefetch', path: '/admin/prefetch', section: 'Systém', keywords: ['cache', 'fetch'], icon: FaSyncAlt },
|
||||
{ label: 'Galerie', path: '/admin/galerie', section: 'Média', keywords: ['gallery', 'zonerama'], icon: FaImage },
|
||||
{ label: 'Videa', path: '/admin/videa', section: 'Média', keywords: ['youtube', 'videos'], icon: FaVideo },
|
||||
{ label: 'Analytika', path: '/admin/analytika', section: 'SEO', keywords: ['analytics', 'umami'], icon: FaChartLine },
|
||||
{ label: 'O klubu', path: '/admin/o-klubu', section: 'Obsah', keywords: ['about'], icon: FaPalette },
|
||||
{ label: 'Navigace', path: '/admin/navigace', section: 'Systém', keywords: ['navigation', 'menu', 'sidebar'], icon: FaBars },
|
||||
{ label: 'Notifikace: Zápasy', path: '/admin/notifications', section: 'Komunikace', keywords: ['notifications', 'match'], icon: FaBell },
|
||||
// Docs
|
||||
{ label: 'Dokumentace (Úvod)', path: '/admin/docs#uvod', section: 'Docs', keywords: ['docs', 'documentation'], icon: FaBook },
|
||||
{ label: 'Dokumentace (Nastavení)', path: '/admin/docs#nastaveni', section: 'Docs', keywords: ['docs', 'settings'], icon: FaBook },
|
||||
{ label: 'Dokumentace (Články)', path: '/admin/docs#clanky', section: 'Docs', keywords: ['docs', 'articles'], icon: FaBook },
|
||||
{ label: 'Dokumentace (Newsletter)', path: '/admin/docs#newsletter', section: 'Docs', keywords: ['docs', 'email'], icon: FaBook },
|
||||
{ label: 'Dokumentace (Řešení problémů)', path: '/admin/docs#troubleshooting', section: 'Docs', keywords: ['docs', 'troubleshooting'], icon: FaBook },
|
||||
];
|
||||
|
||||
function highlight(text: string, q: string) {
|
||||
if (!q) return text;
|
||||
try {
|
||||
const esc = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(esc, 'gi');
|
||||
const parts = text.split(re);
|
||||
const matches = text.match(re) || [];
|
||||
const out: any[] = [];
|
||||
parts.forEach((p, idx) => {
|
||||
out.push(p);
|
||||
if (idx < matches.length) out.push(<mark key={idx} style={{ backgroundColor: '#fde68a' }}>{matches[idx]}</mark>);
|
||||
});
|
||||
return <>{out}</>;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function score(item: AdminSearchItem, q: string) {
|
||||
const t = (item.label || '').toLowerCase();
|
||||
const b = q.toLowerCase();
|
||||
const kws = (item.keywords || []).join(' ').toLowerCase();
|
||||
let s = 0;
|
||||
if (!b) return s;
|
||||
if (t === b) s += 200;
|
||||
if (t.startsWith(b)) s += 120;
|
||||
if (t.includes(b)) s += 80 - t.indexOf(b);
|
||||
if (kws.includes(b)) s += 40;
|
||||
// small preference for Docs when # present
|
||||
if (item.section === 'Docs' && item.path.includes('#')) s += 5;
|
||||
return s;
|
||||
}
|
||||
|
||||
export default function AdminSearchModal({ isOpen, onClose, onSelectPath }: { isOpen: boolean; onClose: () => void; onSelectPath: (path: string) => void }) {
|
||||
const [q, setQ] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
const [idx, setIdx] = useState(-1);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(q.trim()), 250);
|
||||
return () => clearTimeout(id);
|
||||
}, [q]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setQ('');
|
||||
setDebounced('');
|
||||
setIdx(-1);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const results = useMemo(() => {
|
||||
const arr = adminIndex.map((it) => ({ it, s: score(it, debounced) }))
|
||||
.filter((r) => r.s > 0 || !debounced)
|
||||
.sort((a, b) => b.s - a.s || a.it.label.localeCompare(b.it.label))
|
||||
.slice(0, 12)
|
||||
.map((r) => r.it);
|
||||
return arr;
|
||||
}, [debounced]);
|
||||
|
||||
const onSelect = useCallback((path: string) => {
|
||||
onClose();
|
||||
onSelectPath(path);
|
||||
}, [onClose, onSelectPath]);
|
||||
|
||||
const onKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
|
||||
const n = results.length;
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setIdx((i) => Math.min(n - 1, i + 1)); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setIdx((i) => Math.max(-1, i - 1)); }
|
||||
else if (e.key === 'Enter') {
|
||||
const chosen = idx >= 0 ? results[idx] : results[0];
|
||||
if (chosen) onSelect(chosen.path);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault(); onClose();
|
||||
} else if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="lg" motionPreset="scale">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
Admin vyhledávání
|
||||
<Box as="span" ml={3} color="gray.500" fontSize="sm">
|
||||
<Kbd>Ctrl</Kbd>+<Kbd>K</Kbd>
|
||||
</Box>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={4}>
|
||||
<InputGroup size="lg">
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<Icon as={FaSearch} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="Hledat v administraci (stránky, nastavení, dokumentace)"
|
||||
value={q}
|
||||
onChange={(e) => { setQ(e.target.value); setIdx(-1); }}
|
||||
onKeyDown={onKeyDown}
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<List mt={4} spacing={1}>
|
||||
{results.map((r, i) => (
|
||||
<ListItem
|
||||
key={r.path}
|
||||
px={3}
|
||||
py={2}
|
||||
borderRadius="md"
|
||||
cursor="pointer"
|
||||
bg={i === idx ? 'blackAlpha.50' : 'transparent'}
|
||||
_hover={{ bg: 'blackAlpha.50' }}
|
||||
onClick={() => onSelect(r.path)}
|
||||
>
|
||||
<HStack>
|
||||
{r.icon ? <Icon as={r.icon} color="blue.500" /> : null}
|
||||
<Text fontWeight="semibold">{highlight(r.label, debounced)}</Text>
|
||||
<Badge ml="auto" colorScheme="gray">{r.section}</Badge>
|
||||
</HStack>
|
||||
</ListItem>
|
||||
))}
|
||||
{results.length === 0 && (
|
||||
<Box color="gray.500" fontSize="sm" px={1} py={2}>Žádné výsledky</Box>
|
||||
)}
|
||||
</List>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
import { Box, VStack, Text, useColorModeValue, Icon, Link as ChakraLink, Divider, Image, Flex, Spinner } from '@chakra-ui/react';
|
||||
import { Link as RouterLink, useLocation } from 'react-router-dom';
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import {
|
||||
FaTachometerAlt,
|
||||
FaUsers,
|
||||
FaFutbol,
|
||||
FaCalendarAlt,
|
||||
FaNewspaper,
|
||||
FaHandshake,
|
||||
FaImage,
|
||||
FaEnvelope,
|
||||
FaCog,
|
||||
FaPalette,
|
||||
FaHome,
|
||||
FaSignOutAlt,
|
||||
FaPaperPlane,
|
||||
FaAward,
|
||||
FaSyncAlt,
|
||||
FaBook,
|
||||
FaMobileAlt,
|
||||
FaChartBar,
|
||||
FaFolder,
|
||||
FaAddressBook,
|
||||
FaBars,
|
||||
FaPoll,
|
||||
FaPaintBrush,
|
||||
FaVideo,
|
||||
FaCamera,
|
||||
FaTshirt,
|
||||
FaBullhorn,
|
||||
FaUserShield,
|
||||
FaFileAlt
|
||||
} from 'react-icons/fa';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getUpcomingEvents } from '../../services/eventService';
|
||||
import { getAllNavigationItems, NavigationItem, seedDefaultNavigation } from '../../services/navigation';
|
||||
|
||||
interface NavItemProps {
|
||||
icon: any;
|
||||
to?: string;
|
||||
children: React.ReactNode;
|
||||
onClick?: (e?: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const NavItem = ({ icon, to, children, onClick }: NavItemProps) => {
|
||||
const location = useLocation();
|
||||
const isActive = to ? location.pathname.startsWith(to) : false;
|
||||
const activeBg = useColorModeValue('blue.50', 'blue.900');
|
||||
const activeColor = useColorModeValue('blue.600', 'blue.300');
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// Call the onClick handler first
|
||||
if (onClick) {
|
||||
onClick(e);
|
||||
// If onClick called preventDefault, respect it
|
||||
if (e.isDefaultPrevented()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Allow RouterLink to handle navigation normally
|
||||
};
|
||||
|
||||
// If onClick is provided without `to`, render as a button-like link
|
||||
const LinkComponent = to ? RouterLink : 'a';
|
||||
const linkProps = to ? { to } : { href: '#' };
|
||||
|
||||
return (
|
||||
<ChakraLink
|
||||
as={LinkComponent}
|
||||
{...linkProps}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
px={3}
|
||||
py={2.5}
|
||||
borderRadius="lg"
|
||||
bg={isActive ? activeBg : 'transparent'}
|
||||
color={isActive ? activeColor : 'inherit'}
|
||||
fontWeight={isActive ? 'semibold' : 'medium'}
|
||||
fontSize="sm"
|
||||
_hover={{
|
||||
textDecoration: 'none',
|
||||
bg: isActive ? activeBg : useColorModeValue('gray.100', 'gray.700'),
|
||||
transform: 'translateX(2px)',
|
||||
}}
|
||||
transition="all 0.2s ease"
|
||||
onClick={handleClick}
|
||||
data-navitem="true"
|
||||
data-active={isActive ? 'true' : undefined}
|
||||
position="relative"
|
||||
_before={isActive ? {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: '3px',
|
||||
height: '60%',
|
||||
bg: activeColor,
|
||||
borderRadius: 'full',
|
||||
} : {}}
|
||||
>
|
||||
<Icon as={icon} mr={3} boxSize={4} />
|
||||
<Text flex={1}>{children}</Text>
|
||||
</ChakraLink>
|
||||
);
|
||||
};
|
||||
|
||||
interface AdminSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
bg?: string;
|
||||
borderRight?: string;
|
||||
borderColor?: string;
|
||||
}
|
||||
|
||||
// Icon mapping for navigation items
|
||||
const getIconForPageType = (pageType?: string): any => {
|
||||
const iconMap: Record<string, any> = {
|
||||
dashboard: FaTachometerAlt,
|
||||
analytics: FaChartBar,
|
||||
teams: FaUsers,
|
||||
matches: FaCalendarAlt,
|
||||
activities: FaCalendarAlt,
|
||||
players: FaFutbol,
|
||||
articles: FaNewspaper,
|
||||
categories: FaFileAlt,
|
||||
about: FaBook,
|
||||
videos: FaVideo,
|
||||
gallery: FaImage,
|
||||
scoreboard: FaTachometerAlt,
|
||||
scoreboard_remote: FaMobileAlt,
|
||||
clothing: FaTshirt,
|
||||
sponsors: FaHandshake,
|
||||
banners: FaBullhorn,
|
||||
messages: FaEnvelope,
|
||||
contacts: FaAddressBook,
|
||||
newsletter: FaPaperPlane,
|
||||
polls: FaPoll,
|
||||
navigation: FaBars,
|
||||
competition_aliases: FaAward,
|
||||
prefetch: FaSyncAlt,
|
||||
users: FaUserShield,
|
||||
settings: FaPalette,
|
||||
files: FaFolder,
|
||||
docs: FaBook,
|
||||
};
|
||||
return iconMap[pageType || ''] || FaFileAlt;
|
||||
};
|
||||
|
||||
const AdminSidebar = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
bg: bgProp,
|
||||
borderRight = '1px',
|
||||
borderColor: borderColorProp
|
||||
}: AdminSidebarProps) => {
|
||||
const { logout, user } = useAuth();
|
||||
const isAdmin = (user as any)?.role === 'admin';
|
||||
const defaultBg = useColorModeValue('white', '#1a1d29');
|
||||
const defaultBorderColor = useColorModeValue('gray.200', 'rgba(255, 255, 255, 0.12)');
|
||||
const textColor = useColorModeValue('gray.800', '#e2e8f0');
|
||||
const bg = bgProp || defaultBg;
|
||||
const borderColor = borderColorProp || defaultBorderColor;
|
||||
// Upcoming events count for badge
|
||||
const { data: upcomingEvents } = useQuery({ queryKey: ['admin-sidebar-upcoming-events'], queryFn: getUpcomingEvents });
|
||||
const upcomingCount = Array.isArray(upcomingEvents) ? upcomingEvents.length : 0;
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const location = useLocation();
|
||||
const STORAGE_KEY = 'admin-sidebar-scroll';
|
||||
|
||||
// Dynamic navigation state
|
||||
const [navItems, setNavItems] = useState<NavigationItem[]>([]);
|
||||
const [navLoading, setNavLoading] = useState(true);
|
||||
|
||||
// Restore scroll on mount
|
||||
useEffect(() => {
|
||||
const node = scrollRef.current;
|
||||
if (!node) return;
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
const top = parseInt(saved, 10);
|
||||
if (!Number.isNaN(top)) {
|
||||
node.scrollTop = top;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save scroll on scroll
|
||||
const handleScroll = useCallback(() => {
|
||||
const node = scrollRef.current;
|
||||
if (!node) return;
|
||||
sessionStorage.setItem(STORAGE_KEY, String(node.scrollTop));
|
||||
}, []);
|
||||
|
||||
// Load dynamic navigation from API
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const items = await getAllNavigationItems();
|
||||
if (active && Array.isArray(items)) {
|
||||
// Filter only admin navigation items
|
||||
const adminItems = items.filter(item => item.requires_admin);
|
||||
|
||||
// Auto-seed if admin navigation is empty and user is admin
|
||||
if (adminItems.length === 0 && isAdmin) {
|
||||
try {
|
||||
console.log('Admin navigation empty, auto-seeding...');
|
||||
await seedDefaultNavigation();
|
||||
const newItems = await getAllNavigationItems();
|
||||
if (active && Array.isArray(newItems)) {
|
||||
const newAdminItems = newItems.filter(item => item.requires_admin);
|
||||
setNavItems(newAdminItems);
|
||||
}
|
||||
} catch (seedError) {
|
||||
console.error('Auto-seed failed:', seedError);
|
||||
// Continue with empty navigation (will show fallback)
|
||||
setNavItems(adminItems);
|
||||
}
|
||||
} else {
|
||||
setNavItems(adminItems);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load admin navigation:', error);
|
||||
} finally {
|
||||
if (active) setNavLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { active = false };
|
||||
}, [isAdmin]);
|
||||
|
||||
// Keep active item in view upon route change - but only if it's not visible
|
||||
useEffect(() => {
|
||||
const node = scrollRef.current;
|
||||
if (!node) return;
|
||||
const active = node.querySelector('[data-navitem][data-active="true"]') as HTMLElement | null;
|
||||
if (active) {
|
||||
// Check if the active item is already visible in the viewport
|
||||
const containerRect = node.getBoundingClientRect();
|
||||
const activeRect = active.getBoundingClientRect();
|
||||
const isVisible = (
|
||||
activeRect.top >= containerRect.top &&
|
||||
activeRect.bottom <= containerRect.bottom
|
||||
);
|
||||
// Only scroll if the active item is not fully visible
|
||||
if (!isVisible) {
|
||||
active.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="nav"
|
||||
position="fixed"
|
||||
left={0}
|
||||
top={0}
|
||||
bottom={0}
|
||||
width="260px"
|
||||
bg={bg}
|
||||
borderRightWidth={borderRight}
|
||||
borderColor={borderColor}
|
||||
pt={5}
|
||||
display={{ base: isOpen ? 'block' : 'none', md: 'block' }}
|
||||
zIndex={10}
|
||||
overflowY="auto"
|
||||
overflowX="hidden"
|
||||
boxShadow="lg"
|
||||
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
css={{
|
||||
'&::-webkit-scrollbar': { width: '4px' },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': { background: useColorModeValue('gray.300', 'gray.600'), borderRadius: '2px' },
|
||||
'&::-webkit-scrollbar-thumb:hover': { background: useColorModeValue('gray.400', 'gray.500') },
|
||||
}}
|
||||
>
|
||||
<VStack align="stretch" spacing={1} px={3} pb={6}>
|
||||
<Box px={3} mb={8}>
|
||||
<Flex align="center" gap={3} mb={2}>
|
||||
<Image
|
||||
src="/api/logo"
|
||||
alt="Club Logo"
|
||||
boxSize="48px"
|
||||
objectFit="contain"
|
||||
fallbackSrc="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%23e2e8f0'/%3E%3Ctext x='50' y='55' text-anchor='middle' font-size='40' fill='%23718096'%3EMC%3C/text%3E%3C/svg%3E"
|
||||
borderRadius="md"
|
||||
/>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text
|
||||
fontSize="xl"
|
||||
fontWeight="extrabold"
|
||||
color={useColorModeValue('gray.800', 'white')}
|
||||
letterSpacing="tight"
|
||||
>
|
||||
My Club
|
||||
</Text>
|
||||
<Text fontSize="xs" color={useColorModeValue('gray.500', 'gray.400')} fontWeight="semibold" textTransform="uppercase" letterSpacing="wider">
|
||||
Admin Panel
|
||||
</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
<NavItem
|
||||
icon={FaHome}
|
||||
to="/"
|
||||
onClick={onClose}
|
||||
>
|
||||
Zpět na web
|
||||
</NavItem>
|
||||
|
||||
<Divider my={2} />
|
||||
|
||||
{/* Dynamic Navigation */}
|
||||
{navLoading ? (
|
||||
<Flex justify="center" py={8}>
|
||||
<Spinner size="sm" />
|
||||
</Flex>
|
||||
) : navItems.length > 0 ? (
|
||||
// Render dynamic navigation
|
||||
<>
|
||||
{navItems.filter(item => item.visible).map((item, index) => {
|
||||
const itemIcon = getIconForPageType(item.page_type);
|
||||
const itemUrl = item.url || '#';
|
||||
|
||||
// Add badge for activities showing upcoming count
|
||||
const isActivities = item.page_type === 'activities';
|
||||
const showBadge = isActivities && upcomingCount > 0;
|
||||
|
||||
return (
|
||||
<NavItem
|
||||
key={item.id || index}
|
||||
icon={itemIcon}
|
||||
to={itemUrl}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Text as="span">
|
||||
{item.label}
|
||||
{showBadge && (
|
||||
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('green.100','green.900')} color={useColorModeValue('green.700','green.200')} borderWidth="1px" borderColor={useColorModeValue('green.200','green.700')}>
|
||||
{upcomingCount}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</NavItem>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* MyUIbrix Editor - Special item */}
|
||||
<NavItem
|
||||
icon={FaPaintBrush}
|
||||
onClick={(e) => {
|
||||
e?.preventDefault();
|
||||
window.open('/?myuibrix=edit', '_blank');
|
||||
}}
|
||||
>
|
||||
MyUIbrix Editor
|
||||
</NavItem>
|
||||
</>
|
||||
) : (
|
||||
// Fallback to hardcoded navigation
|
||||
<>
|
||||
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={useColorModeValue('gray.500', 'gray.400')} textTransform="uppercase" letterSpacing="wider">
|
||||
Hlavní
|
||||
</Text>
|
||||
|
||||
<NavItem
|
||||
icon={FaTachometerAlt}
|
||||
to="/admin"
|
||||
onClick={onClose}
|
||||
>
|
||||
Nástěnka
|
||||
</NavItem>
|
||||
|
||||
{isAdmin && (
|
||||
<NavItem
|
||||
icon={FaChartBar}
|
||||
to="/admin/analytika"
|
||||
onClick={onClose}
|
||||
>
|
||||
Analytika
|
||||
</NavItem>
|
||||
)}
|
||||
|
||||
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={useColorModeValue('gray.500', 'gray.400')} textTransform="uppercase" letterSpacing="wider" mt={4}>
|
||||
Obsah
|
||||
</Text>
|
||||
{/* Core sports entities first */}
|
||||
<NavItem
|
||||
icon={FaUsers}
|
||||
to="/admin/tymy"
|
||||
onClick={onClose}
|
||||
>
|
||||
Týmy
|
||||
</NavItem>
|
||||
<NavItem
|
||||
icon={FaCalendarAlt}
|
||||
to="/admin/zapasy"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Add subtle scroller hint */}
|
||||
<Text as="span">
|
||||
Zápasy
|
||||
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('gray.100','whiteAlpha.200')} color={useColorModeValue('gray.700','gray.300')} borderWidth="1px" borderColor={useColorModeValue('gray.200','whiteAlpha.300')}>
|
||||
scroller
|
||||
</Text>
|
||||
</Text>
|
||||
</NavItem>
|
||||
<NavItem
|
||||
icon={FaCalendarAlt}
|
||||
to="/admin/aktivity"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Text as="span">
|
||||
Aktivity
|
||||
{upcomingCount > 0 && (
|
||||
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('green.100','green.900')} color={useColorModeValue('green.700','green.200')} borderWidth="1px" borderColor={useColorModeValue('green.200','green.700')}>
|
||||
{upcomingCount}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</NavItem>
|
||||
<NavItem
|
||||
icon={FaFutbol}
|
||||
to="/admin/hraci"
|
||||
onClick={onClose}
|
||||
>
|
||||
Hráči
|
||||
</NavItem>
|
||||
{/* Other content */}
|
||||
<NavItem
|
||||
icon={FaNewspaper}
|
||||
to="/admin/clanky"
|
||||
onClick={onClose}
|
||||
>
|
||||
Články
|
||||
</NavItem>
|
||||
<NavItem
|
||||
icon={FaFileAlt}
|
||||
to="/admin/kategorie"
|
||||
onClick={onClose}
|
||||
>
|
||||
Kategorie
|
||||
</NavItem>
|
||||
<NavItem
|
||||
icon={FaBook}
|
||||
to="/admin/o-klubu"
|
||||
onClick={onClose}
|
||||
>
|
||||
O klubu
|
||||
</NavItem>
|
||||
<NavItem
|
||||
icon={FaImage}
|
||||
to="/admin/videa"
|
||||
onClick={onClose}
|
||||
>
|
||||
Videa
|
||||
</NavItem>
|
||||
<NavItem
|
||||
icon={FaImage}
|
||||
to="/admin/galerie"
|
||||
onClick={onClose}
|
||||
>
|
||||
Galerie (Zonerama)
|
||||
</NavItem>
|
||||
<NavItem
|
||||
icon={FaTachometerAlt}
|
||||
to="/admin/scoreboard"
|
||||
onClick={onClose}
|
||||
>
|
||||
Tabule (Scoreboard)
|
||||
</NavItem>
|
||||
<NavItem
|
||||
icon={FaMobileAlt}
|
||||
to="/admin/scoreboard/remote"
|
||||
onClick={onClose}
|
||||
>
|
||||
Scoreboard Remote
|
||||
</NavItem>
|
||||
<NavItem
|
||||
icon={FaPalette}
|
||||
to="/admin/obleceni"
|
||||
onClick={onClose}
|
||||
>
|
||||
Oblečení
|
||||
</NavItem>
|
||||
<NavItem
|
||||
icon={FaHandshake}
|
||||
to="/admin/sponzori"
|
||||
onClick={onClose}
|
||||
>
|
||||
Sponzoři
|
||||
</NavItem>
|
||||
<NavItem
|
||||
icon={FaImage}
|
||||
to="/admin/bannery"
|
||||
onClick={onClose}
|
||||
>
|
||||
Bannery
|
||||
</NavItem>
|
||||
<NavItem
|
||||
icon={FaEnvelope}
|
||||
to="/admin/zpravy"
|
||||
onClick={onClose}
|
||||
>
|
||||
Zprávy
|
||||
</NavItem>
|
||||
<NavItem
|
||||
icon={FaAddressBook}
|
||||
to="/admin/kontakty"
|
||||
onClick={onClose}
|
||||
>
|
||||
Kontakty
|
||||
</NavItem>
|
||||
<NavItem
|
||||
icon={FaPaperPlane}
|
||||
to="/admin/newsletter"
|
||||
onClick={onClose}
|
||||
>
|
||||
Zpravodaj
|
||||
</NavItem>
|
||||
<NavItem
|
||||
icon={FaPoll}
|
||||
to="/admin/ankety"
|
||||
onClick={onClose}
|
||||
>
|
||||
Ankety
|
||||
</NavItem>
|
||||
|
||||
<Divider my={2} />
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={useColorModeValue('gray.500', 'gray.400')} textTransform="uppercase" letterSpacing="wider" mt={4}>
|
||||
Nastavení
|
||||
</Text>
|
||||
|
||||
<NavItem
|
||||
icon={FaPaintBrush}
|
||||
onClick={(e) => {
|
||||
e?.preventDefault();
|
||||
window.open('/?myuibrix=edit', '_blank');
|
||||
}}
|
||||
>
|
||||
MyUIbrix Editor
|
||||
</NavItem>
|
||||
|
||||
<NavItem
|
||||
icon={FaBars}
|
||||
to="/admin/navigace"
|
||||
onClick={onClose}
|
||||
>
|
||||
Navigace
|
||||
</NavItem>
|
||||
|
||||
<NavItem
|
||||
icon={FaAward}
|
||||
to="/admin/aliasy-soutezi"
|
||||
onClick={onClose}
|
||||
>
|
||||
Alias soutěží
|
||||
</NavItem>
|
||||
|
||||
<NavItem
|
||||
icon={FaSyncAlt}
|
||||
to="/admin/prefetch"
|
||||
onClick={onClose}
|
||||
>
|
||||
Prefetch & Cache
|
||||
</NavItem>
|
||||
|
||||
<NavItem
|
||||
icon={FaUsers}
|
||||
to="/admin/uzivatele"
|
||||
onClick={onClose}
|
||||
>
|
||||
Uživatelé
|
||||
</NavItem>
|
||||
<NavItem
|
||||
icon={FaPalette}
|
||||
to="/admin/nastaveni"
|
||||
onClick={onClose}
|
||||
>
|
||||
Nastavení
|
||||
</NavItem>
|
||||
<NavItem
|
||||
icon={FaFolder}
|
||||
to="/admin/soubory"
|
||||
onClick={onClose}
|
||||
>
|
||||
Soubory
|
||||
</NavItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box mt="auto" mb={4} px={2}>
|
||||
<ChakraLink
|
||||
as="button"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
w="100%"
|
||||
px={4}
|
||||
py={2}
|
||||
borderRadius="md"
|
||||
_hover={{
|
||||
textDecoration: 'none',
|
||||
bg: useColorModeValue('red.50', 'red.900'),
|
||||
color: 'red.500',
|
||||
}}
|
||||
onClick={logout}
|
||||
color={useColorModeValue('red.500', 'red.300')}
|
||||
>
|
||||
<Icon as={FaSignOutAlt} mr={3} />
|
||||
<Text>Odhlásit se</Text>
|
||||
</ChakraLink>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminSidebar;
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
Table as ChakraTable,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableProps as ChakraTableProps,
|
||||
TableContainer,
|
||||
Text,
|
||||
Skeleton,
|
||||
useColorModeValue,
|
||||
Box,
|
||||
} from '@chakra-ui/react';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export interface Column<T> {
|
||||
header: string;
|
||||
accessor: keyof T | ((item: T) => ReactNode);
|
||||
isNumeric?: boolean;
|
||||
width?: string | number;
|
||||
cellProps?: (item: T) => Record<string, any>;
|
||||
}
|
||||
|
||||
interface AdminTableProps<T> extends ChakraTableProps {
|
||||
columns: Column<T>[];
|
||||
data: T[] | undefined;
|
||||
isLoading?: boolean;
|
||||
emptyMessage?: string;
|
||||
onRowClick?: (item: T) => void;
|
||||
rowHoverEffect?: boolean;
|
||||
}
|
||||
|
||||
export function AdminTable<T>({
|
||||
columns,
|
||||
data,
|
||||
isLoading = false,
|
||||
emptyMessage = 'No data available',
|
||||
onRowClick,
|
||||
rowHoverEffect = true,
|
||||
...props
|
||||
}: AdminTableProps<T>) {
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const headerBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const headerColor = useColorModeValue('gray.600', 'gray.300');
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<TableContainer>
|
||||
<ChakraTable variant="simple" {...props}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
{columns.map((column, index) => (
|
||||
<Th key={index} bg={headerBg} color={headerColor}>
|
||||
{column.header}
|
||||
</Th>
|
||||
))}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{[1, 2, 3].map((row) => (
|
||||
<Tr key={row}>
|
||||
{columns.map((column, colIndex) => (
|
||||
<Td key={colIndex}>
|
||||
<Skeleton height="20px" />
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</ChakraTable>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<Box
|
||||
p={8}
|
||||
textAlign="center"
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Text color="gray.500">{emptyMessage}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableContainer
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
borderColor={borderColor}
|
||||
overflowX="auto"
|
||||
>
|
||||
<ChakraTable variant="simple" {...props}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
{columns.map((column, index) => (
|
||||
<Th
|
||||
key={index}
|
||||
isNumeric={column.isNumeric}
|
||||
width={column.width}
|
||||
bg={headerBg}
|
||||
color={headerColor}
|
||||
textTransform="uppercase"
|
||||
fontSize="xs"
|
||||
letterSpacing="wider"
|
||||
borderBottomWidth="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
{column.header}
|
||||
</Th>
|
||||
))}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{data.map((item, rowIndex) => (
|
||||
<Tr
|
||||
key={rowIndex}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
cursor={onRowClick ? 'pointer' : 'default'}
|
||||
_hover={rowHoverEffect ? { bg: hoverBg } : {}}
|
||||
transition="background-color 0.2s"
|
||||
>
|
||||
{columns.map((column, colIndex) => {
|
||||
const cellProps = column.cellProps?.(item) || {};
|
||||
return (
|
||||
<Td
|
||||
key={colIndex}
|
||||
isNumeric={column.isNumeric}
|
||||
borderColor={borderColor}
|
||||
{...cellProps}
|
||||
>
|
||||
{typeof column.accessor === 'function'
|
||||
? column.accessor(item)
|
||||
: (item[column.accessor] as ReactNode)}
|
||||
</Td>
|
||||
);
|
||||
})}
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</ChakraTable>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
VStack,
|
||||
HStack,
|
||||
Input,
|
||||
Text,
|
||||
SimpleGrid,
|
||||
Image,
|
||||
Checkbox,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalCloseButton,
|
||||
useToast,
|
||||
Spinner,
|
||||
Badge,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
FormHelperText,
|
||||
} from '@chakra-ui/react';
|
||||
import { ExternalLink, Download } from 'lucide-react';
|
||||
import { getZoneramaAlbum } from '../../services/zonerama';
|
||||
|
||||
interface Photo {
|
||||
id: string;
|
||||
page_url: string;
|
||||
image_1500: string;
|
||||
}
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
date: string;
|
||||
photos_count: number;
|
||||
photos: Photo[];
|
||||
}
|
||||
|
||||
interface AlbumPhotoPickerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onPhotosSelected: (photos: Photo[], albumInfo: Album) => void;
|
||||
}
|
||||
|
||||
const AlbumPhotoPicker: React.FC<AlbumPhotoPickerProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onPhotosSelected,
|
||||
}) => {
|
||||
const [albumLink, setAlbumLink] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [album, setAlbum] = useState<Album | null>(null);
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<string>>(new Set());
|
||||
const toast = useToast();
|
||||
|
||||
const handleFetchAlbum = async () => {
|
||||
if (!albumLink.trim()) {
|
||||
toast({
|
||||
title: 'Zadejte URL alba',
|
||||
status: 'warning',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!albumLink.includes('/Album/')) {
|
||||
toast({
|
||||
title: 'Neplatný odkaz',
|
||||
description: 'URL musí obsahovat "/Album/"',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getZoneramaAlbum(albumLink, { photo_limit: 100 }) as any;
|
||||
|
||||
// Handle both response formats: { album, photos } and { albums: [{ photos }] }
|
||||
let albumData: any = null;
|
||||
let photos: any[] = [];
|
||||
|
||||
if (result.albums && Array.isArray(result.albums) && result.albums.length > 0) {
|
||||
// New format: { albums: [{ id, title, url, date, photos }] }
|
||||
albumData = result.albums[0];
|
||||
photos = albumData.photos || [];
|
||||
} else if (result.album && result.photos) {
|
||||
// Old format: { album: {...}, photos: [...] }
|
||||
albumData = result.album;
|
||||
photos = result.photos;
|
||||
} else {
|
||||
throw new Error('Album nenalezeno - neplatná odpověď ze serveru');
|
||||
}
|
||||
|
||||
if (!albumData) {
|
||||
throw new Error('Album nenalezeno');
|
||||
}
|
||||
|
||||
const mappedPhotos = photos.map(p => ({
|
||||
id: p.id,
|
||||
page_url: p.page_url,
|
||||
image_1500: p.image_1500 || '',
|
||||
}));
|
||||
|
||||
setAlbum({
|
||||
id: albumData.id || '',
|
||||
title: albumData.title || '',
|
||||
url: albumData.url || albumLink,
|
||||
date: albumData.date || '', // Now properly extracting date
|
||||
photos_count: mappedPhotos.length,
|
||||
photos: mappedPhotos,
|
||||
});
|
||||
setSelectedPhotos(new Set());
|
||||
|
||||
toast({
|
||||
title: 'Album načteno',
|
||||
description: `${mappedPhotos.length} fotografií`,
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Album fetch error:', error);
|
||||
toast({
|
||||
title: 'Chyba načítání alba',
|
||||
description: error.message || 'Nepodařilo se načíst album',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePhoto = (photoId: string) => {
|
||||
const newSelected = new Set(selectedPhotos);
|
||||
if (newSelected.has(photoId)) {
|
||||
newSelected.delete(photoId);
|
||||
} else {
|
||||
newSelected.add(photoId);
|
||||
}
|
||||
setSelectedPhotos(newSelected);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (album) {
|
||||
if (selectedPhotos.size === album.photos.length) {
|
||||
setSelectedPhotos(new Set());
|
||||
} else {
|
||||
setSelectedPhotos(new Set(album.photos.map(p => p.id)));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!album || selectedPhotos.size === 0) {
|
||||
toast({
|
||||
title: 'Vyberte fotografie',
|
||||
status: 'warning',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = album.photos.filter(p => selectedPhotos.has(p.id));
|
||||
onPhotosSelected(selected, album);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAlbumLink('');
|
||||
setAlbum(null);
|
||||
setSelectedPhotos(new Set());
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="6xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxH="90vh">
|
||||
<ModalHeader>Vybrat fotografie z alba</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody overflowY="auto">
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{/* Album URL Input */}
|
||||
<FormControl>
|
||||
<FormLabel>URL Zonerama alba</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={albumLink}
|
||||
onChange={(e) => setAlbumLink(e.target.value)}
|
||||
placeholder="https://eu.zonerama.com/Account/Album/12345"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleFetchAlbum()}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleFetchAlbum}
|
||||
isLoading={loading}
|
||||
colorScheme="blue"
|
||||
leftIcon={<Download size={18} />}
|
||||
>
|
||||
Načíst
|
||||
</Button>
|
||||
</HStack>
|
||||
<FormHelperText>
|
||||
Vložte odkaz na Zonerama album (musí obsahovat /Album/)
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<VStack py={8}>
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
<Text color="gray.600">Načítám album...</Text>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* Album Info & Photos */}
|
||||
{album && !loading && (
|
||||
<>
|
||||
{/* Album Header */}
|
||||
<Box
|
||||
p={4}
|
||||
bg="blue.50"
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor="blue.200"
|
||||
>
|
||||
<VStack align="start" spacing={2}>
|
||||
<HStack justify="space-between" w="full">
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{album.title}
|
||||
</Text>
|
||||
<Button
|
||||
as="a"
|
||||
href={album.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
rightIcon={<ExternalLink size={14} />}
|
||||
>
|
||||
Zonerama
|
||||
</Button>
|
||||
</HStack>
|
||||
<HStack spacing={4} fontSize="sm" color="gray.700">
|
||||
{album.date && <Text>📅 {album.date}</Text>}
|
||||
<Badge colorScheme="blue">{album.photos.length} fotografií</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* Select All */}
|
||||
<HStack justify="space-between">
|
||||
<Checkbox
|
||||
isChecked={selectedPhotos.size === album.photos.length}
|
||||
isIndeterminate={
|
||||
selectedPhotos.size > 0 && selectedPhotos.size < album.photos.length
|
||||
}
|
||||
onChange={handleSelectAll}
|
||||
>
|
||||
Vybrat vše ({selectedPhotos.size}/{album.photos.length})
|
||||
</Checkbox>
|
||||
</HStack>
|
||||
|
||||
{/* Photos Grid */}
|
||||
<SimpleGrid columns={{ base: 3, md: 4, lg: 5 }} spacing={3}>
|
||||
{album.photos.map((photo) => (
|
||||
<Box
|
||||
key={photo.id}
|
||||
position="relative"
|
||||
cursor="pointer"
|
||||
onClick={() => togglePhoto(photo.id)}
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
borderWidth="2px"
|
||||
borderColor={selectedPhotos.has(photo.id) ? 'blue.500' : 'transparent'}
|
||||
transition="all 0.2s"
|
||||
_hover={{ transform: 'scale(1.05)' }}
|
||||
>
|
||||
<Image
|
||||
src={photo.image_1500}
|
||||
alt={photo.id}
|
||||
w="100%"
|
||||
h="150px"
|
||||
objectFit="cover"
|
||||
/>
|
||||
<Checkbox
|
||||
position="absolute"
|
||||
top={2}
|
||||
right={2}
|
||||
isChecked={selectedPhotos.has(photo.id)}
|
||||
pointerEvents="none"
|
||||
bg="white"
|
||||
borderRadius="sm"
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack spacing={3}>
|
||||
<Button variant="ghost" onClick={handleClose}>
|
||||
Zrušit
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={handleConfirm}
|
||||
isDisabled={!album || selectedPhotos.size === 0}
|
||||
>
|
||||
Vybrat ({selectedPhotos.size})
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlbumPhotoPicker;
|
||||
@@ -0,0 +1,277 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
SimpleGrid,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
StatArrow,
|
||||
Heading,
|
||||
Text,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Badge,
|
||||
VStack,
|
||||
HStack,
|
||||
Spinner,
|
||||
Icon,
|
||||
Card,
|
||||
CardBody,
|
||||
Link,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiEye, FiTrendingUp, FiFileText, FiUsers } from 'react-icons/fi';
|
||||
import api from '../../services/api';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
type AnalyticsStats = {
|
||||
total_page_views: number;
|
||||
unique_visitors: number;
|
||||
total_articles: number;
|
||||
published_articles: number;
|
||||
page_views_today: number;
|
||||
page_views_week: number;
|
||||
unique_visitors_week: number;
|
||||
avg_time_on_site: number;
|
||||
};
|
||||
|
||||
type TopPage = {
|
||||
page_path: string;
|
||||
page_name: string;
|
||||
view_count: number;
|
||||
unique_visitors: number;
|
||||
};
|
||||
|
||||
type TopArticle = {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
view_count: number;
|
||||
published_at: string;
|
||||
};
|
||||
|
||||
const AnalyticsDashboard: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stats, setStats] = useState<AnalyticsStats | null>(null);
|
||||
const [topPages, setTopPages] = useState<TopPage[]>([]);
|
||||
const [topArticles, setTopArticles] = useState<TopArticle[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAnalytics();
|
||||
}, []);
|
||||
|
||||
const loadAnalytics = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Load overview stats
|
||||
const statsRes = await api.get('/admin/analytics/overview');
|
||||
setStats(statsRes.data);
|
||||
|
||||
// Load top pages
|
||||
const pagesRes = await api.get('/admin/analytics/top-pages?limit=10');
|
||||
setTopPages(pagesRes.data || []);
|
||||
|
||||
// Load top articles
|
||||
const articlesRes = await api.get('/admin/analytics/top-articles?limit=10');
|
||||
setTopArticles(articlesRes.data || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load analytics:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box textAlign="center" py={10}>
|
||||
<Spinner size="xl" />
|
||||
<Text mt={4} color="gray.600">Načítám statistiky...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={6}>
|
||||
<Heading size="lg">Analytika a statistiky</Heading>
|
||||
|
||||
{/* Overview Stats */}
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 4 }} spacing={4}>
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Stat>
|
||||
<HStack>
|
||||
<Icon as={FiEye} boxSize={6} color="blue.500" />
|
||||
<StatLabel>Celkem zobrazení</StatLabel>
|
||||
</HStack>
|
||||
<StatNumber>{stats?.total_page_views || 0}</StatNumber>
|
||||
<StatHelpText>
|
||||
<StatArrow type="increase" />
|
||||
{stats?.page_views_today || 0} dnes
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Stat>
|
||||
<HStack>
|
||||
<Icon as={FiUsers} boxSize={6} color="green.500" />
|
||||
<StatLabel>Unikátní návštěvníci</StatLabel>
|
||||
</HStack>
|
||||
<StatNumber>{stats?.unique_visitors || 0}</StatNumber>
|
||||
<StatHelpText>
|
||||
{stats?.unique_visitors_week || 0} tento týden
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Stat>
|
||||
<HStack>
|
||||
<Icon as={FiFileText} boxSize={6} color="purple.500" />
|
||||
<StatLabel>Publikované články</StatLabel>
|
||||
</HStack>
|
||||
<StatNumber>{stats?.published_articles || 0}</StatNumber>
|
||||
<StatHelpText>
|
||||
z {stats?.total_articles || 0} celkem
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Stat>
|
||||
<HStack>
|
||||
<Icon as={FiTrendingUp} boxSize={6} color="orange.500" />
|
||||
<StatLabel>Zobrazení (týden)</StatLabel>
|
||||
</HStack>
|
||||
<StatNumber>{stats?.page_views_week || 0}</StatNumber>
|
||||
<StatHelpText>
|
||||
Ø {Math.round((stats?.page_views_week || 0) / 7)} / den
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Top Articles */}
|
||||
<Box>
|
||||
<Heading size="md" mb={4}>Nejčtenější články</Heading>
|
||||
<Card>
|
||||
<CardBody>
|
||||
{topArticles.length === 0 ? (
|
||||
<Text color="gray.600">Zatím žádná data</Text>
|
||||
) : (
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Článek</Th>
|
||||
<Th isNumeric>Zobrazení</Th>
|
||||
<Th>Datum publikace</Th>
|
||||
<Th>Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{topArticles.map((article, index) => (
|
||||
<Tr key={article.id}>
|
||||
<Td>
|
||||
<HStack>
|
||||
<Badge colorScheme="blue">{index + 1}</Badge>
|
||||
<Text fontWeight="medium">{article.title}</Text>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td isNumeric>
|
||||
<Badge colorScheme="green">{article.view_count}</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{new Date(article.published_at).toLocaleDateString('cs-CZ')}
|
||||
</Text>
|
||||
</Td>
|
||||
<Td>
|
||||
<HStack spacing={2}>
|
||||
<Link
|
||||
as={RouterLink}
|
||||
to={`/blog/${article.slug}`}
|
||||
fontSize="sm"
|
||||
color="blue.600"
|
||||
>
|
||||
Zobrazit
|
||||
</Link>
|
||||
<Link
|
||||
as={RouterLink}
|
||||
to={`/admin/clanky?edit=${article.id}`}
|
||||
fontSize="sm"
|
||||
color="purple.600"
|
||||
>
|
||||
Upravit
|
||||
</Link>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
{/* Top Pages */}
|
||||
<Box>
|
||||
<Heading size="md" mb={4}>Nejnavštěvovanější stránky</Heading>
|
||||
<Card>
|
||||
<CardBody>
|
||||
{topPages.length === 0 ? (
|
||||
<Text color="gray.600">Zatím žádná data</Text>
|
||||
) : (
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Stránka</Th>
|
||||
<Th>Cesta</Th>
|
||||
<Th isNumeric>Zobrazení</Th>
|
||||
<Th isNumeric>Unikátní</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{topPages.map((page, index) => (
|
||||
<Tr key={page.page_path}>
|
||||
<Td>
|
||||
<HStack>
|
||||
<Badge colorScheme="purple">{index + 1}</Badge>
|
||||
<Text>{page.page_name || page.page_path}</Text>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td>
|
||||
<Text fontSize="sm" color="gray.600" fontFamily="mono">
|
||||
{page.page_path}
|
||||
</Text>
|
||||
</Td>
|
||||
<Td isNumeric>
|
||||
<Badge colorScheme="blue">{page.view_count}</Badge>
|
||||
</Td>
|
||||
<Td isNumeric>
|
||||
<Badge colorScheme="green">{page.unique_visitors}</Badge>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Box>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalyticsDashboard;
|
||||
@@ -0,0 +1,339 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
FormHelperText,
|
||||
Input,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
Badge,
|
||||
Divider,
|
||||
Link,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import MapStyleSelector from './MapStyleSelector';
|
||||
import { FiMapPin, FiCheck, FiX, FiExternalLink } from 'react-icons/fi';
|
||||
import { parseMapUrl, MapCoordinates, validateCoordinates, reverseGeocode } from '../../utils/mapUrlParser';
|
||||
import ContactMap from '../home/ContactMap';
|
||||
|
||||
interface MapLinkImporterProps {
|
||||
onImport: (coordinates: MapCoordinates) => void;
|
||||
currentLatitude?: number;
|
||||
currentLongitude?: number;
|
||||
currentZoom?: number;
|
||||
mapStyle?: string;
|
||||
onMapStyleChange?: (style: string) => void;
|
||||
clubPrimaryColor?: string;
|
||||
clubSecondaryColor?: string;
|
||||
clubName?: string;
|
||||
}
|
||||
|
||||
const MapLinkImporter: React.FC<MapLinkImporterProps> = ({
|
||||
onImport,
|
||||
currentLatitude,
|
||||
currentLongitude,
|
||||
currentZoom,
|
||||
mapStyle,
|
||||
onMapStyleChange,
|
||||
clubPrimaryColor,
|
||||
clubSecondaryColor,
|
||||
clubName,
|
||||
}) => {
|
||||
const [urlInput, setUrlInput] = useState('');
|
||||
const [parsedData, setParsedData] = useState<MapCoordinates | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [previewCoords, setPreviewCoords] = useState<MapCoordinates | null>(null);
|
||||
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize preview with current coordinates if available
|
||||
if (currentLatitude && currentLongitude) {
|
||||
setPreviewCoords({
|
||||
latitude: currentLatitude,
|
||||
longitude: currentLongitude,
|
||||
zoom: currentZoom,
|
||||
source: 'unknown',
|
||||
});
|
||||
}
|
||||
}, [currentLatitude, currentLongitude, currentZoom]);
|
||||
|
||||
const handleUrlChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setUrlInput(value);
|
||||
setError(null);
|
||||
setParsedData(null);
|
||||
|
||||
if (!value.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to parse the URL
|
||||
const result = parseMapUrl(value);
|
||||
if (result) {
|
||||
if (validateCoordinates(result.latitude, result.longitude)) {
|
||||
// Perform reverse geocoding to get detailed address
|
||||
try {
|
||||
const addressDetails = await reverseGeocode(result.latitude, result.longitude);
|
||||
const enrichedResult = { ...result, ...addressDetails };
|
||||
setParsedData(enrichedResult);
|
||||
setPreviewCoords(enrichedResult);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
// If geocoding fails, still use the basic data
|
||||
setParsedData(result);
|
||||
setPreviewCoords(result);
|
||||
setError(null);
|
||||
}
|
||||
} else {
|
||||
setError('Souřadnice jsou mimo platný rozsah');
|
||||
setParsedData(null);
|
||||
}
|
||||
} else {
|
||||
setError('Nepodařilo se rozpoznat URL mapy. Podporované: mapy.cz, Google Maps');
|
||||
setParsedData(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
if (parsedData) {
|
||||
onImport(parsedData);
|
||||
setUrlInput('');
|
||||
setParsedData(null);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setUrlInput('');
|
||||
setParsedData(null);
|
||||
setError(null);
|
||||
// Reset preview to current coordinates
|
||||
if (currentLatitude && currentLongitude) {
|
||||
setPreviewCoords({
|
||||
latitude: currentLatitude,
|
||||
longitude: currentLongitude,
|
||||
zoom: currentZoom,
|
||||
source: 'unknown',
|
||||
});
|
||||
} else {
|
||||
setPreviewCoords(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Box>
|
||||
<FormControl>
|
||||
<FormLabel display="flex" alignItems="center" gap={2}>
|
||||
<FiMapPin /> Importovat z URL mapy
|
||||
</FormLabel>
|
||||
<Input
|
||||
placeholder="Vložte URL z mapy.cz nebo Google Maps..."
|
||||
value={urlInput}
|
||||
onChange={handleUrlChange}
|
||||
size="md"
|
||||
/>
|
||||
<FormHelperText>
|
||||
Podporované formáty:
|
||||
<Text as="span" fontWeight="semibold" ml={1}>mapy.cz</Text> (mapy.com/en/letecka?x=...&y=...),
|
||||
<Text as="span" fontWeight="semibold" ml={1}>Google Maps</Text> (google.com/maps/place/@lat,lng,zoom)
|
||||
</FormHelperText>
|
||||
<HStack mt={2} spacing={3} fontSize="sm">
|
||||
<Text color="gray.600">Quick links:</Text>
|
||||
<Link
|
||||
href="https://mapy.com/cs/"
|
||||
isExternal
|
||||
color="blue.500"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
_hover={{ color: 'blue.600', textDecoration: 'underline' }}
|
||||
>
|
||||
Mapy.cz <FiExternalLink size={12} />
|
||||
</Link>
|
||||
<Text color="gray.400">•</Text>
|
||||
<Link
|
||||
href="https://www.google.com/maps/"
|
||||
isExternal
|
||||
color="blue.500"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
_hover={{ color: 'blue.600', textDecoration: 'underline' }}
|
||||
>
|
||||
Google Maps <FiExternalLink size={12} />
|
||||
</Link>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
{parsedData && (
|
||||
<Alert status="success" mt={3} borderRadius="md">
|
||||
<AlertIcon />
|
||||
<Box flex="1">
|
||||
<AlertTitle>Úspěšně rozpoznáno!</AlertTitle>
|
||||
<AlertDescription display="block">
|
||||
<VStack align="start" spacing={1} mt={2}>
|
||||
<HStack>
|
||||
<Badge colorScheme="green">
|
||||
{parsedData.source === 'mapy.cz' ? 'Mapy.cz' : 'Google Maps'}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="sm">
|
||||
<strong>Šířka:</strong> {parsedData.latitude.toFixed(7)}
|
||||
</Text>
|
||||
<Text fontSize="sm">
|
||||
<strong>Délka:</strong> {parsedData.longitude.toFixed(7)}
|
||||
</Text>
|
||||
{parsedData.zoom && (
|
||||
<Text fontSize="sm">
|
||||
<strong>Zoom:</strong> {parsedData.zoom}
|
||||
</Text>
|
||||
)}
|
||||
{parsedData.street && (
|
||||
<Text fontSize="sm">
|
||||
<strong>Ulice:</strong> {parsedData.street}
|
||||
</Text>
|
||||
)}
|
||||
{parsedData.city && (
|
||||
<Text fontSize="sm">
|
||||
<strong>Město:</strong> {parsedData.city}
|
||||
</Text>
|
||||
)}
|
||||
{parsedData.zip && (
|
||||
<Text fontSize="sm">
|
||||
<strong>PSČ:</strong> {parsedData.zip}
|
||||
</Text>
|
||||
)}
|
||||
{parsedData.country && (
|
||||
<Text fontSize="sm">
|
||||
<strong>Země:</strong> {parsedData.country}
|
||||
</Text>
|
||||
)}
|
||||
{parsedData.address && (
|
||||
<Text fontSize="sm">
|
||||
<strong>Celá adresa:</strong> {parsedData.address}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
<HStack ml={2}>
|
||||
<Button
|
||||
leftIcon={<FiCheck />}
|
||||
colorScheme="green"
|
||||
size="sm"
|
||||
onClick={handleImport}
|
||||
>
|
||||
Importovat
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<FiX />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
>
|
||||
Zrušit
|
||||
</Button>
|
||||
</HStack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert status="error" mt={3} borderRadius="md">
|
||||
<AlertIcon />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Map Preview */}
|
||||
{previewCoords && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box>
|
||||
<Text fontWeight="semibold" mb={2}>
|
||||
Náhled mapy
|
||||
</Text>
|
||||
<Box
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<ContactMap
|
||||
latitude={previewCoords.latitude}
|
||||
longitude={previewCoords.longitude}
|
||||
zoom={previewCoords.zoom || 15}
|
||||
address={previewCoords.address}
|
||||
clubName={clubName}
|
||||
mapStyle={mapStyle || 'positron'}
|
||||
clubPrimaryColor={clubPrimaryColor}
|
||||
clubSecondaryColor={clubSecondaryColor}
|
||||
height={300}
|
||||
/>
|
||||
</Box>
|
||||
<Text fontSize="xs" color="gray.500" mt={2}>
|
||||
Souřadnice: {previewCoords.latitude.toFixed(6)}, {previewCoords.longitude.toFixed(6)}
|
||||
{previewCoords.zoom && ` | Zoom: ${previewCoords.zoom}`}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Map Style Selector */}
|
||||
{onMapStyleChange && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box>
|
||||
<Text fontWeight="semibold" mb={2}>
|
||||
Styl mapy
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.600" mb={3}>
|
||||
Vyberte vzhled mapy, který se zobrazí na vašem webu.
|
||||
</Text>
|
||||
<MapStyleSelector
|
||||
value={mapStyle || 'positron'}
|
||||
onChange={onMapStyleChange}
|
||||
clubPrimaryColor={clubPrimaryColor}
|
||||
clubSecondaryColor={clubSecondaryColor}
|
||||
showPreview={false}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Example URLs */}
|
||||
<Box
|
||||
bg={bgColor}
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
fontSize="sm"
|
||||
>
|
||||
<Text fontWeight="semibold" mb={2}>Příklady podporovaných URL:</Text>
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
<strong>Mapy.cz:</strong><br />
|
||||
mapy.cz/en/letecka?x=17.6996859&y=50.0947150&z=19
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
<strong>Google Maps:</strong><br />
|
||||
google.com/maps/place/@50.0948669,17.7001456,226m
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapLinkImporter;
|
||||
@@ -0,0 +1,178 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
VStack,
|
||||
Badge,
|
||||
Image,
|
||||
HStack,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { MAP_STYLES } from '../home/ContactMap';
|
||||
import ContactMap from '../home/ContactMap';
|
||||
|
||||
interface MapStyleSelectorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
clubPrimaryColor?: string;
|
||||
clubSecondaryColor?: string;
|
||||
showPreview?: boolean;
|
||||
}
|
||||
|
||||
const MapStyleSelector: React.FC<MapStyleSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
clubPrimaryColor,
|
||||
clubSecondaryColor,
|
||||
showPreview = true,
|
||||
}) => {
|
||||
const previewBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const tipsBg = useColorModeValue('blue.50', 'blue.900');
|
||||
const tipsBorder = useColorModeValue('blue.200', 'blue.700');
|
||||
const textColor = useColorModeValue('gray.700', 'gray.300');
|
||||
const secondaryText = useColorModeValue('gray.600', 'gray.400');
|
||||
const selectBg = useColorModeValue('white', 'gray.700');
|
||||
const styleCategories = {
|
||||
'Light & Minimal': ['positron', 'positron-no-labels', 'default'],
|
||||
'Dark Themes': ['dark', 'dark-no-labels'],
|
||||
'Black & White': ['toner', 'toner-lite'],
|
||||
'Colorful': ['voyager', 'terrain', 'watercolor'],
|
||||
'Satellite': ['satellite'],
|
||||
};
|
||||
|
||||
const selectedStyle = MAP_STYLES[value as keyof typeof MAP_STYLES] || MAP_STYLES.default;
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Styl mapy</FormLabel>
|
||||
<Select value={value} onChange={(e) => onChange(e.target.value)} bg={selectBg}>
|
||||
{Object.entries(styleCategories).map(([category, styles]) => (
|
||||
<optgroup key={category} label={category}>
|
||||
{styles.map((styleKey) => {
|
||||
const style = MAP_STYLES[styleKey as keyof typeof MAP_STYLES];
|
||||
return (
|
||||
<option key={styleKey} value={styleKey}>
|
||||
{style.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</optgroup>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{showPreview && (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Box
|
||||
p={4}
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
bg={previewBg}
|
||||
>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="semibold">{selectedStyle.name}</Text>
|
||||
<Badge colorScheme="blue">Náhled stylu</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{selectedStyle.description}
|
||||
</Text>
|
||||
|
||||
{clubPrimaryColor && (
|
||||
<Box>
|
||||
<Text fontSize="sm" fontWeight="medium" mb={2}>
|
||||
Barvy klubu:
|
||||
</Text>
|
||||
<HStack>
|
||||
<Box
|
||||
w="40px"
|
||||
h="40px"
|
||||
borderRadius="md"
|
||||
bg={clubPrimaryColor}
|
||||
borderWidth="1px"
|
||||
borderColor="gray.300"
|
||||
/>
|
||||
{clubSecondaryColor && (
|
||||
<Box
|
||||
w="40px"
|
||||
h="40px"
|
||||
borderRadius="md"
|
||||
bg={clubSecondaryColor}
|
||||
borderWidth="1px"
|
||||
borderColor="gray.300"
|
||||
/>
|
||||
)}
|
||||
<Text fontSize="xs" color={secondaryText}>
|
||||
Použity pro marker a overlay
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* Interactive Map Preview */}
|
||||
<Box
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
boxShadow="sm"
|
||||
>
|
||||
<ContactMap
|
||||
latitude={50.0755}
|
||||
longitude={14.4378}
|
||||
zoom={13}
|
||||
address="Praha, Česká republika"
|
||||
clubName="Náhled mapy"
|
||||
mapStyle={value}
|
||||
clubPrimaryColor={clubPrimaryColor}
|
||||
clubSecondaryColor={clubSecondaryColor}
|
||||
height={300}
|
||||
/>
|
||||
</Box>
|
||||
<Text fontSize="xs" color={secondaryText} textAlign="center">
|
||||
Náhled interaktivní mapy se zvoleným stylem
|
||||
</Text>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
<Box
|
||||
p={3}
|
||||
bg={tipsBg}
|
||||
borderWidth="1px"
|
||||
borderColor={tipsBorder}
|
||||
borderRadius="md"
|
||||
>
|
||||
<Text fontSize="sm" fontWeight="semibold" mb={1}>
|
||||
💡 Tipy pro výběr stylu:
|
||||
</Text>
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={2}>
|
||||
<Text fontSize="xs" color={textColor}>
|
||||
• <strong>Positron/Toner Lite</strong> - nejlepší pro barevné markery
|
||||
</Text>
|
||||
<Text fontSize="xs" color={textColor}>
|
||||
• <strong>Dark Matter</strong> - skvělé pro tmavý design
|
||||
</Text>
|
||||
<Text fontSize="xs" color={textColor}>
|
||||
• <strong>Toner B&W</strong> - vysoký kontrast, elegantní
|
||||
</Text>
|
||||
<Text fontSize="xs" color={textColor}>
|
||||
• <strong>Voyager</strong> - vyváženě pro všechny případy
|
||||
</Text>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Text fontSize="xs" color={secondaryText}>
|
||||
Všechny mapy jsou open-source a bezplatné.
|
||||
{clubPrimaryColor && ' Mapa bude automaticky obarvena barvami klubu.'}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapStyleSelector;
|
||||
@@ -0,0 +1,276 @@
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Button,
|
||||
VStack,
|
||||
Text,
|
||||
Divider,
|
||||
HStack,
|
||||
Badge,
|
||||
useClipboard,
|
||||
useToast,
|
||||
Box,
|
||||
Input,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
useDisclosure,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverHeader,
|
||||
PopoverBody,
|
||||
PopoverArrow,
|
||||
PopoverCloseButton,
|
||||
} from '@chakra-ui/react';
|
||||
import { EmailIcon, CopyIcon, CheckIcon, DeleteIcon, ArrowForwardIcon } from '@chakra-ui/icons';
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { forwardMessage } from '../../services/admin/contactMessages';
|
||||
import { format } from 'date-fns';
|
||||
import { cs } from 'date-fns/locale';
|
||||
|
||||
interface MessageDetailModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
message: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
subject?: string;
|
||||
message: string;
|
||||
source?: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
isRead: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
onDelete: () => void;
|
||||
onMarkAsRead: () => void;
|
||||
}
|
||||
|
||||
export default function MessageDetailModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
message,
|
||||
onDelete,
|
||||
onMarkAsRead,
|
||||
}: MessageDetailModalProps) {
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const { hasCopied, onCopy } = useClipboard(message.email);
|
||||
const { isOpen: isPopoverOpen, onOpen: onPopoverOpen, onClose: onPopoverClose } = useDisclosure();
|
||||
const [forwardEmail, setForwardEmail] = useState('');
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return format(new Date(dateString), 'd. M. yyyy HH:mm', { locale: cs });
|
||||
};
|
||||
|
||||
const handleCopyEmail = () => {
|
||||
onCopy();
|
||||
toast({
|
||||
title: 'E-mail zkopírován do schránky',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const forwardMutation = useMutation({
|
||||
mutationFn: (toEmail: string) => forwardMessage(message.id, toEmail),
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Zpráva přeposílána',
|
||||
description: `Zpráva bude odeslána na ${forwardEmail}`,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
setForwardEmail('');
|
||||
onPopoverClose();
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: 'Nepodařilo se přeposlat zprávu',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleForward = () => {
|
||||
if (!forwardEmail || !forwardEmail.includes('@')) {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: 'Zadejte platnou e-mailovou adresu',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
forwardMutation.mutate(forwardEmail);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<Box>
|
||||
<Text fontSize="lg" fontWeight="bold">
|
||||
{message.subject || 'Bez předmětu'}
|
||||
</Text>
|
||||
<HStack mt={1} fontSize="sm" color="gray.500">
|
||||
<Text>{formatDate(message.createdAt)}</Text>
|
||||
{!message.isRead && (
|
||||
<Badge colorScheme="blue">
|
||||
Nová zpráva
|
||||
</Badge>
|
||||
)}
|
||||
{message.source && (
|
||||
<Badge colorScheme={message.source === 'sponsor' ? 'purple' : 'gray'}>
|
||||
{message.source === 'sponsor' ? 'Sponzor' : 'Kontakt'}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={1}>
|
||||
Od:
|
||||
</Text>
|
||||
<HStack>
|
||||
<Text>{message.name}</Text>
|
||||
<HStack
|
||||
as="button"
|
||||
onClick={handleCopyEmail}
|
||||
color="blue.500"
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
spacing={1}
|
||||
>
|
||||
<Text><{message.email}></Text>
|
||||
{hasCopied ? (
|
||||
<CheckIcon boxSize={3} />
|
||||
) : (
|
||||
<CopyIcon boxSize={3} />
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2}>
|
||||
Zpráva:
|
||||
</Text>
|
||||
<Text whiteSpace="pre-wrap" p={3} bg="gray.50" borderRadius="md">
|
||||
{message.message}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{(message.ipAddress || message.userAgent) && (
|
||||
<Box mt={4} fontSize="sm" color="gray.500">
|
||||
<Text fontWeight="bold" mb={1}>
|
||||
Technické informace:
|
||||
</Text>
|
||||
{message.ipAddress && (
|
||||
<Text>
|
||||
<Text as="span" fontWeight="medium">IP adresa:</Text>{' '}
|
||||
{message.ipAddress}
|
||||
</Text>
|
||||
)}
|
||||
{message.userAgent && (
|
||||
<Text mt={1} isTruncated title={message.userAgent}>
|
||||
<Text as="span" fontWeight="medium">Prohlížeč:</Text>{' '}
|
||||
{message.userAgent.length > 50
|
||||
? `${message.userAgent.substring(0, 47)}...`
|
||||
: message.userAgent}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<HStack spacing={2} flexWrap="wrap" justify="flex-end" w="full">
|
||||
<Button
|
||||
variant="outline"
|
||||
colorScheme="red"
|
||||
leftIcon={<DeleteIcon />}
|
||||
onClick={onDelete}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
>
|
||||
Smazat
|
||||
</Button>
|
||||
|
||||
<Popover isOpen={isPopoverOpen} onClose={onPopoverClose}>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
colorScheme="teal"
|
||||
leftIcon={<ArrowForwardIcon />}
|
||||
onClick={onPopoverOpen}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
>
|
||||
Přeposlat
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverArrow />
|
||||
<PopoverCloseButton />
|
||||
<PopoverHeader>Přeposlat zprávu</PopoverHeader>
|
||||
<PopoverBody>
|
||||
<VStack spacing={3}>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">E-mailová adresa</FormLabel>
|
||||
<Input
|
||||
placeholder="prijemce@email.cz"
|
||||
value={forwardEmail}
|
||||
onChange={(e) => setForwardEmail(e.target.value)}
|
||||
type="email"
|
||||
size="sm"
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="teal"
|
||||
width="full"
|
||||
onClick={handleForward}
|
||||
isLoading={forwardMutation.isLoading}
|
||||
>
|
||||
Odeslat
|
||||
</Button>
|
||||
</VStack>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{!message.isRead && (
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
leftIcon={<EmailIcon />}
|
||||
onClick={onMarkAsRead}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
>
|
||||
Označit jako přečtené
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" onClick={onClose} size={{ base: "sm", md: "md" }}>
|
||||
Zavřít
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
IconButton,
|
||||
Progress,
|
||||
Badge,
|
||||
List,
|
||||
ListItem,
|
||||
Icon,
|
||||
useToast,
|
||||
Flex,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiUpload, FiX, FiFile, FiImage, FiFileText } from 'react-icons/fi';
|
||||
import { uploadFile } from '../../services/articles';
|
||||
|
||||
export type UploadedFile = {
|
||||
url: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
interface MultiFileUploadProps {
|
||||
onFilesUploaded: (files: UploadedFile[]) => void;
|
||||
existingFiles?: UploadedFile[];
|
||||
acceptedTypes?: string;
|
||||
maxFiles?: number;
|
||||
}
|
||||
|
||||
const MultiFileUpload: React.FC<MultiFileUploadProps> = ({
|
||||
onFilesUploaded,
|
||||
existingFiles = [],
|
||||
acceptedTypes = 'image/*,application/pdf,.doc,.docx,.xls,.xlsx',
|
||||
maxFiles = 10,
|
||||
}) => {
|
||||
const [files, setFiles] = useState<UploadedFile[]>(existingFiles);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const toast = useToast();
|
||||
|
||||
const getFileIcon = (type: string) => {
|
||||
if (type.startsWith('image/')) return FiImage;
|
||||
if (type.includes('pdf')) return FiFileText;
|
||||
return FiFile;
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = e.target.files;
|
||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||
|
||||
if (files.length + selectedFiles.length > maxFiles) {
|
||||
toast({
|
||||
title: 'Příliš mnoho souborů',
|
||||
description: `Můžete nahrát maximálně ${maxFiles} souborů`,
|
||||
status: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
const uploadedFiles: UploadedFile[] = [];
|
||||
const totalFiles = selectedFiles.length;
|
||||
|
||||
try {
|
||||
for (let i = 0; i < selectedFiles.length; i++) {
|
||||
const file = selectedFiles[i];
|
||||
|
||||
// Check file size (max 10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast({
|
||||
title: 'Soubor je příliš velký',
|
||||
description: `${file.name} překračuje limit 10MB`,
|
||||
status: 'error',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await uploadFile(file);
|
||||
uploadedFiles.push({
|
||||
url: result.url,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
});
|
||||
setUploadProgress(((i + 1) / totalFiles) * 100);
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: 'Chyba při nahrávání',
|
||||
description: `${file.name}: ${error.message}`,
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updatedFiles = [...files, ...uploadedFiles];
|
||||
setFiles(updatedFiles);
|
||||
onFilesUploaded(updatedFiles);
|
||||
|
||||
if (uploadedFiles.length > 0) {
|
||||
toast({
|
||||
title: 'Úspěšně nahráno',
|
||||
description: `${uploadedFiles.length} souborů bylo nahráno`,
|
||||
status: 'success',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFile = (index: number) => {
|
||||
const updatedFiles = files.filter((_, i) => i !== index);
|
||||
setFiles(updatedFiles);
|
||||
onFilesUploaded(updatedFiles);
|
||||
};
|
||||
|
||||
const handleCopyUrl = (url: string) => {
|
||||
navigator.clipboard.writeText(url);
|
||||
toast({
|
||||
title: 'Zkopírováno',
|
||||
description: 'URL byla zkopírována do schránky',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={acceptedTypes}
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
|
||||
<Button
|
||||
leftIcon={<FiUpload />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
isLoading={uploading}
|
||||
isDisabled={files.length >= maxFiles}
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Nahrát soubory ({files.length}/{maxFiles})
|
||||
</Button>
|
||||
|
||||
{uploading && (
|
||||
<Progress value={uploadProgress} size="sm" colorScheme="blue" mt={2} />
|
||||
)}
|
||||
|
||||
{files.length > 0 && (
|
||||
<List spacing={2} mt={4}>
|
||||
{files.map((file, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
p={2}
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
bg="gray.50"
|
||||
_hover={{ bg: 'gray.100' }}
|
||||
>
|
||||
<Flex align="center" justify="space-between">
|
||||
<HStack spacing={3} flex={1}>
|
||||
<Icon as={getFileIcon(file.type)} boxSize={5} color="blue.500" />
|
||||
<VStack align="start" spacing={0} flex={1}>
|
||||
<Text fontSize="sm" fontWeight="medium" noOfLines={1}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<HStack spacing={2}>
|
||||
<Badge fontSize="xs" colorScheme="gray">
|
||||
{formatFileSize(file.size)}
|
||||
</Badge>
|
||||
{file.type.startsWith('image/') && (
|
||||
<Badge fontSize="xs" colorScheme="blue">Obrázek</Badge>
|
||||
)}
|
||||
{file.type.includes('pdf') && (
|
||||
<Badge fontSize="xs" colorScheme="red">PDF</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => handleCopyUrl(file.url)}
|
||||
>
|
||||
Kopírovat URL
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label="Odstranit"
|
||||
icon={<FiX />}
|
||||
size="xs"
|
||||
colorScheme="red"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveFile(index)}
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
<Text fontSize="xs" color="gray.600" mt={2}>
|
||||
Podporované formáty: obrázky, PDF, Word, Excel (max 10MB na soubor)
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiFileUpload;
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Box, Heading, Text, HStack, Button, ButtonProps, useColorModeValue, VStack, Icon, Flex, Badge } from '@chakra-ui/react';
|
||||
import { ReactElement, ReactNode } from 'react';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: any; // Icon component
|
||||
badge?: {
|
||||
label: string;
|
||||
colorScheme?: string;
|
||||
};
|
||||
action?: {
|
||||
label: string;
|
||||
icon?: ReactElement;
|
||||
onClick: () => void;
|
||||
colorScheme?: ButtonProps['colorScheme'];
|
||||
isLoading?: boolean;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const PageHeader = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
badge,
|
||||
action,
|
||||
children,
|
||||
}: PageHeaderProps) => {
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const iconBg = useColorModeValue('blue.50', 'blue.900');
|
||||
const iconColor = useColorModeValue('blue.600', 'blue.300');
|
||||
|
||||
return (
|
||||
<Box
|
||||
mb={8}
|
||||
pb={6}
|
||||
borderBottomWidth="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Flex justify="space-between" align="flex-start" wrap="wrap" gap={4}>
|
||||
<HStack spacing={4} align="flex-start" flex={1}>
|
||||
{icon && (
|
||||
<Box
|
||||
p={3}
|
||||
bg={iconBg}
|
||||
borderRadius="xl"
|
||||
display={{ base: 'none', md: 'block' }}
|
||||
>
|
||||
<Icon as={icon} boxSize={6} color={iconColor} />
|
||||
</Box>
|
||||
)}
|
||||
<VStack align="flex-start" spacing={2} flex={1}>
|
||||
<HStack spacing={3} wrap="wrap">
|
||||
<Heading
|
||||
size="xl"
|
||||
fontWeight="extrabold"
|
||||
bgGradient={useColorModeValue(
|
||||
'linear(to-r, gray.800, gray.600)',
|
||||
'linear(to-r, white, gray.300)'
|
||||
)}
|
||||
bgClip="text"
|
||||
>
|
||||
{title}
|
||||
</Heading>
|
||||
{badge && (
|
||||
<Badge
|
||||
colorScheme={badge.colorScheme || 'blue'}
|
||||
fontSize="sm"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
>
|
||||
{badge.label}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
{description && (
|
||||
<Text
|
||||
color={useColorModeValue('gray.600', 'gray.400')}
|
||||
fontSize="md"
|
||||
maxW="2xl"
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
{action && (
|
||||
<Button
|
||||
leftIcon={action.icon}
|
||||
onClick={action.onClick}
|
||||
colorScheme={action.colorScheme || 'blue'}
|
||||
isLoading={action.isLoading}
|
||||
isDisabled={action.isDisabled}
|
||||
size="lg"
|
||||
shadow="sm"
|
||||
_hover={{ shadow: 'md', transform: 'translateY(-1px)' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,292 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Button,
|
||||
Badge,
|
||||
Text,
|
||||
useToast,
|
||||
Select,
|
||||
Spinner,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
IconButton,
|
||||
useColorModeValue,
|
||||
Collapse,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AddIcon, DeleteIcon, ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
||||
import { getPolls, createPoll, updatePoll, Poll, CreatePollRequest } from '../../services/polls';
|
||||
|
||||
interface PollLinkerProps {
|
||||
articleId?: number;
|
||||
eventId?: number;
|
||||
onPollsChanged?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* PollLinker - Component to manage poll associations with articles/events
|
||||
* Can be embedded in article and activity admin pages
|
||||
*/
|
||||
const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChanged }) => {
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [selectedPollId, setSelectedPollId] = useState<string>('');
|
||||
|
||||
const bgBox = useColorModeValue('gray.50', 'gray.700');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
// Query for existing polls
|
||||
const queryParams = articleId ? { article_id: articleId } : eventId ? { event_id: eventId } : {};
|
||||
|
||||
const { data: linkedPolls, isLoading: isLoadingLinked } = useQuery({
|
||||
queryKey: ['linked-polls', queryParams],
|
||||
queryFn: () => getPolls(queryParams),
|
||||
enabled: !!(articleId || eventId),
|
||||
});
|
||||
|
||||
// Query for all available polls
|
||||
const { data: allPolls, isLoading: isLoadingAll } = useQuery({
|
||||
queryKey: ['all-admin-polls'],
|
||||
queryFn: () => getPolls({ status: 'active' }),
|
||||
});
|
||||
|
||||
// Mutation to link existing poll
|
||||
const linkPollMutation = useMutation({
|
||||
mutationFn: async (pollId: number) => {
|
||||
const updateData: any = {};
|
||||
if (articleId) updateData.related_article_id = articleId;
|
||||
if (eventId) updateData.related_event_id = eventId;
|
||||
|
||||
return updatePoll(pollId, updateData);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['linked-polls'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['all-admin-polls'] });
|
||||
toast({
|
||||
title: 'Anketa propojena',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
setSelectedPollId('');
|
||||
if (onPollsChanged) onPollsChanged();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: error.response?.data?.error || 'Nepodařilo se propojit anketu',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Mutation to unlink poll
|
||||
const unlinkPollMutation = useMutation({
|
||||
mutationFn: async (pollId: number) => {
|
||||
const updateData: any = {};
|
||||
if (articleId) updateData.related_article_id = null;
|
||||
if (eventId) updateData.related_event_id = null;
|
||||
|
||||
return updatePoll(pollId, updateData);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['linked-polls'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['all-admin-polls'] });
|
||||
toast({
|
||||
title: 'Anketa odpojena',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
if (onPollsChanged) onPollsChanged();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: error.response?.data?.error || 'Nepodařilo se odpojit anketu',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleLinkPoll = () => {
|
||||
if (!selectedPollId) {
|
||||
toast({
|
||||
title: 'Vyberte anketu',
|
||||
description: 'Prosím vyberte anketu ze seznamu',
|
||||
status: 'warning',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
linkPollMutation.mutate(parseInt(selectedPollId));
|
||||
};
|
||||
|
||||
const handleUnlinkPoll = (pollId: number) => {
|
||||
if (window.confirm('Opravdu chcete odpojit tuto anketu?')) {
|
||||
unlinkPollMutation.mutate(pollId);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter out polls that are already linked
|
||||
const linkedPollIds = new Set(linkedPolls?.map(p => p.id) || []);
|
||||
const availablePolls = allPolls?.filter(p => !linkedPollIds.has(p.id)) || [];
|
||||
|
||||
if (!articleId && !eventId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
p={4}
|
||||
bg={bgBox}
|
||||
>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<HStack justify="space-between">
|
||||
<HStack>
|
||||
<Text fontWeight="bold" fontSize="sm">
|
||||
Ankety ({linkedPolls?.length || 0})
|
||||
</Text>
|
||||
{(linkedPolls?.length || 0) > 0 && (
|
||||
<Badge colorScheme="blue">{linkedPolls!.length} připojeno</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<IconButton
|
||||
aria-label={isExpanded ? 'Skrýt' : 'Zobrazit'}
|
||||
icon={isExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<Collapse in={isExpanded}>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{isLoadingLinked ? (
|
||||
<HStack justify="center" py={4}>
|
||||
<Spinner size="sm" />
|
||||
<Text fontSize="sm">Načítání anket...</Text>
|
||||
</HStack>
|
||||
) : linkedPolls && linkedPolls.length > 0 ? (
|
||||
<VStack spacing={2} align="stretch">
|
||||
<Text fontSize="xs" fontWeight="bold" color="gray.500">
|
||||
Připojené ankety:
|
||||
</Text>
|
||||
{linkedPolls.map((poll) => (
|
||||
<HStack
|
||||
key={poll.id}
|
||||
p={2}
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
justify="space-between"
|
||||
bg="white"
|
||||
_dark={{ bg: 'gray.800' }}
|
||||
>
|
||||
<VStack align="start" spacing={0} flex={1}>
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{poll.title}
|
||||
</Text>
|
||||
<HStack spacing={2}>
|
||||
<Badge size="sm" colorScheme={poll.status === 'active' ? 'green' : 'gray'}>
|
||||
{poll.status}
|
||||
</Badge>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{poll.total_votes} hlasů
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<IconButton
|
||||
aria-label="Odpojit anketu"
|
||||
icon={<DeleteIcon />}
|
||||
size="sm"
|
||||
colorScheme="red"
|
||||
variant="ghost"
|
||||
onClick={() => handleUnlinkPoll(poll.id)}
|
||||
isLoading={unlinkPollMutation.isPending}
|
||||
/>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
) : (
|
||||
<Alert status="info" size="sm">
|
||||
<AlertIcon />
|
||||
<Text fontSize="sm">Žádné ankety nejsou připojeny</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{isLoadingAll ? (
|
||||
<HStack justify="center" py={4}>
|
||||
<Spinner size="sm" />
|
||||
</HStack>
|
||||
) : availablePolls.length > 0 ? (
|
||||
<VStack spacing={3} align="stretch">
|
||||
<Text fontSize="xs" fontWeight="bold" color="gray.500">
|
||||
Připojit existující anketu:
|
||||
</Text>
|
||||
<HStack>
|
||||
<Select
|
||||
value={selectedPollId}
|
||||
onChange={(e) => setSelectedPollId(e.target.value)}
|
||||
placeholder="Vyberte anketu..."
|
||||
size="sm"
|
||||
flex={1}
|
||||
>
|
||||
{availablePolls.map((poll) => (
|
||||
<option key={poll.id} value={poll.id}>
|
||||
{poll.title} ({poll.status}) - {poll.total_votes} hlasů
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
leftIcon={<AddIcon />}
|
||||
onClick={handleLinkPoll}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
isLoading={linkPollMutation.isPending}
|
||||
isDisabled={!selectedPollId}
|
||||
>
|
||||
Připojit
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
) : (
|
||||
<Alert status="warning" size="sm">
|
||||
<AlertIcon />
|
||||
<Text fontSize="sm">
|
||||
Žádné dostupné ankety. Vytvořte novou v{' '}
|
||||
<Button
|
||||
as="a"
|
||||
href="/admin/ankety"
|
||||
target="_blank"
|
||||
variant="link"
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
>
|
||||
správě anket
|
||||
</Button>
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
💡 Tip: Ankety se zobrazí automaticky na konci {articleId ? 'článku' : 'aktivity'}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Collapse>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PollLinker;
|
||||
@@ -0,0 +1,207 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
VStack,
|
||||
Badge,
|
||||
HStack,
|
||||
Switch,
|
||||
Code,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Link,
|
||||
} from '@chakra-ui/react';
|
||||
import { VECTOR_STYLES } from '../home/VectorMap';
|
||||
import { ExternalLinkIcon } from '@chakra-ui/icons';
|
||||
|
||||
interface VectorMapStyleSelectorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
clubPrimaryColor?: string;
|
||||
clubSecondaryColor?: string;
|
||||
showPreview?: boolean;
|
||||
useVectorMaps?: boolean;
|
||||
onToggleVectorMaps?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const VectorMapStyleSelector: React.FC<VectorMapStyleSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
clubPrimaryColor,
|
||||
clubSecondaryColor,
|
||||
showPreview = true,
|
||||
useVectorMaps = false,
|
||||
onToggleVectorMaps,
|
||||
}) => {
|
||||
const selectedStyle = VECTOR_STYLES[value as keyof typeof VECTOR_STYLES] || VECTOR_STYLES.positron;
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{onToggleVectorMaps && (
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb={0} htmlFor="vector-maps-toggle">
|
||||
Použít vektorové mapy (MapLibre GL)
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="vector-maps-toggle"
|
||||
isChecked={useVectorMaps}
|
||||
onChange={(e) => onToggleVectorMaps(e.target.checked)}
|
||||
colorScheme="purple"
|
||||
/>
|
||||
<Badge ml={2} colorScheme="purple">Experimentální</Badge>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{useVectorMaps && (
|
||||
<Alert status="info" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<Box fontSize="sm">
|
||||
<Text fontWeight="bold" mb={1}>Vektorové mapy aktivovány!</Text>
|
||||
<Text>
|
||||
Využíváte MapLibre GL s OpenMapTiles. Výhody: lepší výkon, ostřejší zobrazení,
|
||||
možnost plné customizace stylů přes JSON.
|
||||
</Text>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Styl mapy</FormLabel>
|
||||
<Select value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="positron">Positron (Light)</option>
|
||||
<option value="dark-matter">Dark Matter</option>
|
||||
<option value="osm-bright">OSM Bright</option>
|
||||
<option value="klokantech-basic">Basic</option>
|
||||
</Select>
|
||||
<Text fontSize="sm" color="gray.600" mt={1}>
|
||||
{selectedStyle.description}
|
||||
</Text>
|
||||
</FormControl>
|
||||
|
||||
{showPreview && useVectorMaps && (
|
||||
<Box
|
||||
p={4}
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
bg="gray.50"
|
||||
>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="semibold">{selectedStyle.name}</Text>
|
||||
<Badge colorScheme="purple">Vector Tiles</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{selectedStyle.description}
|
||||
</Text>
|
||||
|
||||
{clubPrimaryColor && (
|
||||
<Box>
|
||||
<Text fontSize="sm" fontWeight="medium" mb={2}>
|
||||
Barvy klubu (aplikovány dynamicky):
|
||||
</Text>
|
||||
<HStack>
|
||||
<Box
|
||||
w="40px"
|
||||
h="40px"
|
||||
borderRadius="md"
|
||||
bg={clubPrimaryColor}
|
||||
borderWidth="1px"
|
||||
borderColor="gray.300"
|
||||
/>
|
||||
{clubSecondaryColor && (
|
||||
<Box
|
||||
w="40px"
|
||||
h="40px"
|
||||
borderRadius="md"
|
||||
bg={clubSecondaryColor}
|
||||
borderWidth="1px"
|
||||
borderColor="gray.300"
|
||||
/>
|
||||
)}
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
Marker + vodní plochy
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
p={3}
|
||||
bg="purple.50"
|
||||
borderWidth="1px"
|
||||
borderColor="purple.200"
|
||||
borderRadius="md"
|
||||
>
|
||||
<Text fontSize="sm" fontWeight="semibold" mb={2}>
|
||||
🚀 Výhody vektorových map:
|
||||
</Text>
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={2}>
|
||||
<Text fontSize="xs" color="gray.700">
|
||||
✓ <strong>Lepší výkon</strong> - rychlejší načítání
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.700">
|
||||
✓ <strong>Ostrý obraz</strong> - perfektní na Retina
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.700">
|
||||
✓ <strong>Dynamické styly</strong> - změna za běhu
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.700">
|
||||
✓ <strong>Club colors</strong> - automatická integrace
|
||||
</Text>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
p={3}
|
||||
bg="blue.50"
|
||||
borderWidth="1px"
|
||||
borderColor="blue.200"
|
||||
borderRadius="md"
|
||||
>
|
||||
<Text fontSize="sm" fontWeight="semibold" mb={1}>
|
||||
📚 Vlastní styly:
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.700" mb={2}>
|
||||
Pro pokročilé použití můžete vytvořit vlastní style JSON podle{' '}
|
||||
<Link
|
||||
href="https://github.com/openmaptiles/positron-gl-style"
|
||||
isExternal
|
||||
color="blue.600"
|
||||
>
|
||||
Positron GL Style <ExternalLinkIcon mx="2px" />
|
||||
</Link>
|
||||
</Text>
|
||||
<Code fontSize="xs" p={2} borderRadius="md" display="block">
|
||||
customStyleUrl: "https://your-server.com/style.json"
|
||||
</Code>
|
||||
</Box>
|
||||
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
Používáme MapLibre GL JS (open-source) s MapTiler tiles.
|
||||
{clubPrimaryColor && ' Barvy klubu jsou aplikovány automaticky na markery a vybrané prvky mapy.'}
|
||||
</Text>
|
||||
|
||||
{useVectorMaps && (
|
||||
<Alert status="warning" borderRadius="md" fontSize="sm">
|
||||
<AlertIcon />
|
||||
<Text>
|
||||
<strong>API Key:</strong> Nastavte <Code>REACT_APP_MAPTILER_KEY</Code> v <Code>.env</Code> souboru
|
||||
pro produkční použití. Získejte zdarma na{' '}
|
||||
<Link href="https://www.maptiler.com/" isExternal color="blue.600">
|
||||
maptiler.com <ExternalLinkIcon mx="2px" />
|
||||
</Link>
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default VectorMapStyleSelector;
|
||||
@@ -0,0 +1,385 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Flex,
|
||||
Heading,
|
||||
Skeleton,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
VStack,
|
||||
Tooltip,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaInfoCircle } from 'react-icons/fa';
|
||||
import { ComposableMap, Geographies, Geography } from 'react-simple-maps';
|
||||
import type { Feature } from 'geojson';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
|
||||
const GEO_URL = 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json';
|
||||
|
||||
type CountryDatum = {
|
||||
code: string;
|
||||
value: number;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
type VisitorCountriesMapProps = {
|
||||
title?: string;
|
||||
metrics: CountryDatum[];
|
||||
isLoading?: boolean;
|
||||
height?: number;
|
||||
onCountryClick?: (countryCode: string, countryName: string, value: number) => void;
|
||||
clearSelection?: boolean;
|
||||
};
|
||||
|
||||
type HoverState = {
|
||||
name: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
type ClickedState = {
|
||||
name: string;
|
||||
value: number;
|
||||
code: string;
|
||||
};
|
||||
|
||||
const mixChannel = (start: number, end: number, factor: number) => {
|
||||
return Math.round(start + (end - start) * factor);
|
||||
};
|
||||
|
||||
const hexToRgb = (hex: string) => {
|
||||
const normalized = hex.replace('#', '');
|
||||
const bigint = parseInt(normalized, 16);
|
||||
return {
|
||||
r: (bigint >> 16) & 255,
|
||||
g: (bigint >> 8) & 255,
|
||||
b: bigint & 255,
|
||||
};
|
||||
};
|
||||
|
||||
const interpolateColor = (startHex: string, endHex: string, factor: number) => {
|
||||
const start = hexToRgb(startHex);
|
||||
const end = hexToRgb(endHex);
|
||||
const clamped = Math.min(Math.max(factor, 0), 1);
|
||||
|
||||
const r = mixChannel(start.r, end.r, clamped);
|
||||
const g = mixChannel(start.g, end.g, clamped);
|
||||
const b = mixChannel(start.b, end.b, clamped);
|
||||
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
};
|
||||
|
||||
const getDisplayNames = () => {
|
||||
if (typeof Intl !== 'undefined' && typeof (Intl as any).DisplayNames === 'function') {
|
||||
try {
|
||||
return new Intl.DisplayNames(['cs', 'en'], { type: 'region' });
|
||||
} catch (error) {
|
||||
// Some browsers might throw when the locale is unsupported
|
||||
return new Intl.DisplayNames(['en'], { type: 'region' });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const VisitorCountriesMap: React.FC<VisitorCountriesMapProps> = ({
|
||||
title = 'Mapa návštěvníků podle země',
|
||||
metrics = [],
|
||||
isLoading = false,
|
||||
height = 400,
|
||||
onCountryClick,
|
||||
clearSelection = false,
|
||||
}) => {
|
||||
const [hovered, setHovered] = useState<HoverState | null>(null);
|
||||
const [clicked, setClicked] = useState<ClickedState | null>(null);
|
||||
const displayNames = useMemo(getDisplayNames, []);
|
||||
const numberFormatter = useMemo(() => new Intl.NumberFormat('cs-CZ'), []);
|
||||
const clubTheme = useClubTheme();
|
||||
|
||||
// Clear selection when clearSelection prop changes
|
||||
useEffect(() => {
|
||||
if (clearSelection) {
|
||||
setClicked(null);
|
||||
}
|
||||
}, [clearSelection]);
|
||||
|
||||
const dataMap = useMemo(() => {
|
||||
const map = new Map<string, { value: number; name: string }>();
|
||||
|
||||
metrics.forEach((item) => {
|
||||
if (!item?.code || typeof item.value !== 'number') return;
|
||||
const normalizedCode = item.code.toUpperCase();
|
||||
const fallbackName = item.name ||
|
||||
(normalizedCode.length === 2 ? displayNames?.of(normalizedCode) ?? normalizedCode : normalizedCode);
|
||||
|
||||
map.set(normalizedCode, {
|
||||
value: item.value,
|
||||
name: fallbackName || normalizedCode,
|
||||
});
|
||||
});
|
||||
|
||||
return map;
|
||||
}, [metrics, displayNames]);
|
||||
|
||||
const maxValue = useMemo(() => {
|
||||
let max = 0;
|
||||
dataMap.forEach(({ value }) => {
|
||||
if (value > max) max = value;
|
||||
});
|
||||
return max;
|
||||
}, [dataMap]);
|
||||
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const defaultFill = useColorModeValue('#EDF2F7', '#2D3748');
|
||||
|
||||
// Use club colors for the map gradient
|
||||
const startFill = useColorModeValue(
|
||||
hexToRgb(clubTheme.primary).r > 200 ? clubTheme.secondary : clubTheme.primary,
|
||||
clubTheme.primary
|
||||
);
|
||||
const endFill = useColorModeValue(
|
||||
clubTheme.accent || clubTheme.primary,
|
||||
clubTheme.secondary || clubTheme.primary
|
||||
);
|
||||
|
||||
const tooltipBg = useColorModeValue('white', 'gray.800');
|
||||
const tooltipBorder = useColorModeValue('gray.200', 'gray.600');
|
||||
// Enhanced border color for better visibility
|
||||
const countryBorderColor = useColorModeValue('#CBD5E0', '#4A5568');
|
||||
const hoveredBorderColor = clubTheme.secondary || '#F6AD55';
|
||||
|
||||
const getDatumForGeo = (geo: Feature) => {
|
||||
const properties = (geo.properties || {}) as Record<string, any>;
|
||||
const iso2 = (properties.ISO_A2 || properties.iso_a2 || '').toUpperCase();
|
||||
const iso3 = (properties.ISO_A3 || properties.iso_a3 || '').toUpperCase();
|
||||
return (iso2 && dataMap.get(iso2)) || (iso3 && dataMap.get(iso3)) || null;
|
||||
};
|
||||
|
||||
const getFillForDatum = (datum: { value: number } | null, isHovered: boolean = false, isClicked: boolean = false) => {
|
||||
if (!datum || maxValue <= 0) return defaultFill;
|
||||
const ratio = datum.value / maxValue;
|
||||
const baseColor = interpolateColor(startFill, endFill, ratio);
|
||||
|
||||
// Enhanced visual feedback
|
||||
if (isClicked) {
|
||||
// Make clicked country more prominent
|
||||
const rgb = hexToRgb(baseColor.startsWith('#') ? baseColor : '#000000');
|
||||
return `rgb(${Math.min(rgb.r + 50, 255)}, ${Math.min(rgb.g + 50, 255)}, ${Math.min(rgb.b + 50, 255)})`;
|
||||
} else if (isHovered) {
|
||||
// Brighten on hover
|
||||
const rgb = hexToRgb(baseColor.startsWith('#') ? baseColor : '#000000');
|
||||
return `rgb(${Math.min(rgb.r + 30, 255)}, ${Math.min(rgb.g + 30, 255)}, ${Math.min(rgb.b + 30, 255)})`;
|
||||
}
|
||||
|
||||
return baseColor;
|
||||
};
|
||||
|
||||
const hasData = metrics?.some((item) => item.value > 0);
|
||||
|
||||
return (
|
||||
<Card borderColor={borderColor} overflow="hidden">
|
||||
<CardHeader>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Heading size="md">{title}</Heading>
|
||||
<Tooltip
|
||||
label="Klikněte na zemi pro zobrazení detailních analytických dat"
|
||||
placement="top"
|
||||
hasArrow
|
||||
>
|
||||
<Icon as={FaInfoCircle} color="gray.400" cursor="help" />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{isLoading ? (
|
||||
<Skeleton height={`${height}px`} borderRadius="md" />
|
||||
) : !hasData ? (
|
||||
<Box textAlign="center" py={12} color="gray.500">
|
||||
<Text>Pro vybraný rozsah nejsou k dispozici data o zemích návštěvníků.</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
position="relative"
|
||||
width="100%"
|
||||
overflow="hidden"
|
||||
borderRadius="md"
|
||||
boxShadow="sm"
|
||||
transition="all 0.3s ease-in-out"
|
||||
_hover={{
|
||||
boxShadow: 'md',
|
||||
}}
|
||||
>
|
||||
<ComposableMap
|
||||
projectionConfig={{
|
||||
scale: 150,
|
||||
center: [0, 20],
|
||||
}}
|
||||
height={height}
|
||||
width={800}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))',
|
||||
}}
|
||||
>
|
||||
<Geographies geography={GEO_URL}>
|
||||
{({ geographies }: { geographies: Feature[] }) =>
|
||||
geographies.map((geo: Feature) => {
|
||||
const datum = getDatumForGeo(geo);
|
||||
const properties = (geo.properties || {}) as Record<string, any>;
|
||||
const countryCode = (properties.ISO_A2 || properties.iso_a2 || '').toUpperCase();
|
||||
const isHovered = hovered?.name === datum?.name;
|
||||
const isClicked = clicked?.code === countryCode;
|
||||
const hasData = datum !== null;
|
||||
|
||||
return (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
stroke={isClicked ? clubTheme.primary : (isHovered ? hoveredBorderColor : countryBorderColor)}
|
||||
strokeWidth={isClicked ? 2.5 : (isHovered ? 1.5 : 0.7)}
|
||||
fill={getFillForDatum(datum, isHovered, isClicked)}
|
||||
style={{
|
||||
default: {
|
||||
outline: 'none',
|
||||
cursor: hasData ? 'pointer' : 'default',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
},
|
||||
hover: {
|
||||
outline: 'none',
|
||||
cursor: hasData ? 'pointer' : 'default',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
},
|
||||
pressed: {
|
||||
outline: 'none',
|
||||
cursor: hasData ? 'pointer' : 'default',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
},
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (!datum) {
|
||||
setHovered(null);
|
||||
return;
|
||||
}
|
||||
setHovered({
|
||||
name: datum.name,
|
||||
value: datum.value,
|
||||
});
|
||||
}}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
onClick={() => {
|
||||
if (datum && onCountryClick) {
|
||||
setClicked({
|
||||
name: datum.name,
|
||||
value: datum.value,
|
||||
code: countryCode,
|
||||
});
|
||||
onCountryClick(countryCode, datum.name, datum.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</Geographies>
|
||||
</ComposableMap>
|
||||
|
||||
{hovered && (
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom={4}
|
||||
left={4}
|
||||
bg={tooltipBg}
|
||||
border="1px solid"
|
||||
borderColor={tooltipBorder}
|
||||
borderRadius="md"
|
||||
px={3}
|
||||
py={2}
|
||||
boxShadow="lg"
|
||||
zIndex={10}
|
||||
>
|
||||
<VStack spacing={1} align="start">
|
||||
<Text fontWeight="semibold">{hovered.name}</Text>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
{numberFormatter.format(hovered.value)} návštěv
|
||||
</Text>
|
||||
<Text fontSize="xs" color="blue.500" fontWeight="medium">
|
||||
Klikněte pro detaily
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{clicked && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top={4}
|
||||
right={4}
|
||||
bg={clubTheme.primary}
|
||||
color="white"
|
||||
borderRadius="md"
|
||||
px={3}
|
||||
py={2}
|
||||
boxShadow="lg"
|
||||
zIndex={10}
|
||||
>
|
||||
<VStack spacing={1} align="start">
|
||||
<Text fontWeight="semibold" fontSize="sm">
|
||||
Vybraná země
|
||||
</Text>
|
||||
<Text fontSize="sm">{clicked.name}</Text>
|
||||
<Text fontSize="xs" opacity={0.9}>
|
||||
{numberFormatter.format(clicked.value)} návštěv
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{hasData && !isLoading && (
|
||||
<VStack spacing={3} mt={4} align="stretch">
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text fontSize="sm" color="gray.500" fontWeight="medium">
|
||||
Méně návštěv
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.400" textAlign="center">
|
||||
Intenzita návštěvnosti
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.500" fontWeight="medium">
|
||||
Více návštěv
|
||||
</Text>
|
||||
</Flex>
|
||||
<Box
|
||||
height="12px"
|
||||
borderRadius="full"
|
||||
bgGradient={`linear(to-r, ${defaultFill}, ${startFill}, ${endFill})`}
|
||||
boxShadow="inset 0 1px 2px rgba(0,0,0,0.1)"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
bgGradient="linear(to-r, transparent, rgba(255,255,255,0.2), transparent)"
|
||||
borderRadius="full"
|
||||
/>
|
||||
</Box>
|
||||
{clicked && (
|
||||
<Text fontSize="xs" color="blue.500" textAlign="center" fontWeight="medium">
|
||||
💡 Klikněte na jinou zemi pro porovnání dat
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisitorCountriesMap;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>; // Or a loading spinner
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// Redirect to login page, saving the current location they were trying to go to
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
@@ -0,0 +1,135 @@
|
||||
import React from 'react';
|
||||
import { Box, Link as ChakraLink } from '@chakra-ui/react';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
export interface Banner {
|
||||
id: number | string;
|
||||
name: string;
|
||||
image: string;
|
||||
url?: string;
|
||||
placement?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
interface BannerDisplayProps {
|
||||
banners: Banner[];
|
||||
placement: 'homepage_top' | 'homepage_middle' | 'homepage_sidebar' | 'homepage_footer' | 'article_inline';
|
||||
containerStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const BannerDisplay: React.FC<BannerDisplayProps> = ({ banners, placement, containerStyle }) => {
|
||||
// Filter active banners for this placement
|
||||
const activeBanners = (banners || []).filter(
|
||||
b => b.placement === placement && (b.is_active !== false)
|
||||
);
|
||||
|
||||
if (activeBanners.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getContainerClass = () => {
|
||||
switch (placement) {
|
||||
case 'homepage_top':
|
||||
return 'banner-top';
|
||||
case 'homepage_middle':
|
||||
return 'banner-middle';
|
||||
case 'homepage_sidebar':
|
||||
return 'banner-sidebar';
|
||||
case 'homepage_footer':
|
||||
return 'banner-footer';
|
||||
case 'article_inline':
|
||||
return 'banner-article';
|
||||
default:
|
||||
return 'banner';
|
||||
}
|
||||
};
|
||||
|
||||
const getDefaultContainerStyle = (): React.CSSProperties => {
|
||||
const base: React.CSSProperties = {
|
||||
margin: '24px 0',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
flexWrap: 'wrap',
|
||||
};
|
||||
|
||||
switch (placement) {
|
||||
case 'homepage_top':
|
||||
return {
|
||||
...base,
|
||||
width: '100vw',
|
||||
position: 'relative',
|
||||
left: '50%',
|
||||
right: '50%',
|
||||
marginLeft: '-50vw',
|
||||
marginRight: '-50vw',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.02)',
|
||||
padding: '16px',
|
||||
borderTop: '1px solid rgba(0, 0, 0, 0.05)',
|
||||
borderBottom: '1px solid rgba(0, 0, 0, 0.05)',
|
||||
};
|
||||
case 'homepage_footer':
|
||||
return {
|
||||
...base,
|
||||
width: '100vw',
|
||||
position: 'relative',
|
||||
left: '50%',
|
||||
right: '50%',
|
||||
marginLeft: '-50vw',
|
||||
marginRight: '-50vw',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.02)',
|
||||
padding: '24px 16px',
|
||||
borderTop: '1px solid rgba(0, 0, 0, 0.05)',
|
||||
};
|
||||
case 'homepage_sidebar':
|
||||
return {
|
||||
display: 'block',
|
||||
margin: '24px 0',
|
||||
};
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
};
|
||||
|
||||
const finalContainerStyle = { ...getDefaultContainerStyle(), ...containerStyle };
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="section"
|
||||
className={getContainerClass()}
|
||||
sx={finalContainerStyle}
|
||||
>
|
||||
{activeBanners.map((banner) => (
|
||||
<ChakraLink
|
||||
key={banner.id}
|
||||
href={banner.url || '#'}
|
||||
isExternal={!!banner.url}
|
||||
target={banner.url ? '_blank' : undefined}
|
||||
rel={banner.url ? 'noopener noreferrer' : undefined}
|
||||
display="inline-block"
|
||||
_hover={{ opacity: 0.9, transform: 'translateY(-2px)' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<img
|
||||
src={assetUrl(banner.image) || banner.image}
|
||||
alt={banner.name}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
width: banner.width ? `${banner.width}px` : 'auto',
|
||||
height: banner.height ? `${banner.height}px` : 'auto',
|
||||
objectFit: 'contain',
|
||||
borderRadius: placement === 'homepage_sidebar' ? '8px' : '4px',
|
||||
boxShadow: placement === 'homepage_sidebar' ? '0 2px 8px rgba(0,0,0,0.1)' : 'none',
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
</ChakraLink>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BannerDisplay;
|
||||
@@ -0,0 +1,199 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { format, startOfWeek, addDays, isSameDay, parseISO, isBefore } from 'date-fns';
|
||||
import { cs } from 'date-fns/locale';
|
||||
import { Event } from '../../types/event';
|
||||
import { getEvents } from '../../services/eventService';
|
||||
import { getMatches } from '../../services/public';
|
||||
|
||||
interface CalendarProps {
|
||||
onEventClick?: (event: Event) => void;
|
||||
}
|
||||
|
||||
const Calendar: React.FC<CalendarProps> = ({ onEventClick }) => {
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [latestResults, setLatestResults] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
const data = await getEvents();
|
||||
setEvents(data);
|
||||
} catch (err) {
|
||||
setError('Nepodařilo se načíst události');
|
||||
console.error('Error fetching events:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchEvents();
|
||||
}, []);
|
||||
|
||||
// Fetch latest results (small sidebar)
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const matches = await getMatches();
|
||||
// Expecting items with date or date_time and score/result
|
||||
const now = new Date();
|
||||
const normalized = (Array.isArray(matches) ? matches : []).map((m: any) => {
|
||||
const dt = parseISO(m.date_time || m.date || m.match_date || m.datetime || '');
|
||||
return { ...m, __dt: isNaN(dt as any) ? null : dt };
|
||||
}).filter((m: any) => m.__dt && isBefore(m.__dt, now));
|
||||
normalized.sort((a: any, b: any) => (b.__dt as any) - (a.__dt as any));
|
||||
const recent = normalized.slice(0, 6);
|
||||
if (active) setLatestResults(recent);
|
||||
} catch (e) {
|
||||
// silent fail for sidebar
|
||||
}
|
||||
})();
|
||||
return () => { active = false };
|
||||
}, []);
|
||||
|
||||
const startDate = startOfWeek(currentDate, { weekStartsOn: 1 }); // Start on Monday
|
||||
const days = [];
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = addDays(startDate, i);
|
||||
days.push(day);
|
||||
}
|
||||
|
||||
const getEventsForDay = (day: Date) => {
|
||||
return events.filter(event => {
|
||||
const eventDate = parseISO(event.start_time);
|
||||
return isSameDay(eventDate, day);
|
||||
});
|
||||
};
|
||||
|
||||
const getEventTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'match':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'training':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'meeting':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div>Načítám kalendář...</div>;
|
||||
if (error) return <div className="text-red-500">{error}</div>;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Main Calendar (more prominent) */}
|
||||
<div className="lg:col-span-3">
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{/* Week header */}
|
||||
<div className="grid grid-cols-7 gap-px bg-gray-200">
|
||||
{days.map((day, i) => {
|
||||
const isToday = isSameDay(day, new Date());
|
||||
return (
|
||||
<div key={i} className={`bg-white p-3 text-center ${isToday ? 'ring-2 ring-blue-500 ring-offset-2 ring-offset-white' : ''}`}>
|
||||
<div className="font-semibold text-gray-900 uppercase tracking-wide text-xs">
|
||||
{format(day, 'EEEE', { locale: cs })}
|
||||
</div>
|
||||
<div className={`mt-1 text-xl font-bold ${isToday ? 'text-blue-600' : 'text-gray-900'}`}>
|
||||
{format(day, 'd')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Grouped events by day */}
|
||||
<div className="p-4">
|
||||
{days.map((day, i) => {
|
||||
const dayEvents = getEventsForDay(day);
|
||||
const isToday = isSameDay(day, new Date());
|
||||
return (
|
||||
<div key={i} className="mb-5">
|
||||
<div className={`sticky top-0 z-10 px-3 py-2 ${isToday ? 'bg-blue-50 border-l-4 border-blue-500' : 'bg-gray-50 border-l-4 border-gray-300'}`}>
|
||||
<h3 className={`text-sm md:text-base font-semibold tracking-wide ${isToday ? 'text-blue-700' : 'text-gray-800'}`}>
|
||||
{format(day, 'EEEE d. M. yyyy', { locale: cs })}
|
||||
{isToday && <span className="ml-2 text-[10px] md:text-xs font-bold text-blue-700 uppercase bg-blue-100 px-2 py-0.5 rounded-full">Dnes</span>}
|
||||
</h3>
|
||||
</div>
|
||||
{dayEvents.length > 0 ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
{dayEvents.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
onClick={() => onEventClick?.(event)}
|
||||
className={`p-3 rounded-md border cursor-pointer transition-colors ${getEventTypeColor(event.type)} hover:opacity-95`}
|
||||
style={{ borderColor: 'rgba(0,0,0,0.08)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium truncate pr-2">{event.title}</div>
|
||||
<div className="text-[10px] md:text-xs px-2 py-0.5 rounded-full bg-white/70 border border-black/10 text-gray-700">
|
||||
{event.type}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-700">
|
||||
{format(parseISO(event.start_time), 'H:mm', { locale: cs })}
|
||||
{event.location && ` • ${event.location}`}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-gray-500 text-sm">Žádné události</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar: Latest Results (compact, space-saving) */}
|
||||
<aside className="lg:col-span-1">
|
||||
<div className="bg-white rounded-lg shadow p-2 lg:sticky lg:top-2">
|
||||
<h3 className="text-xs font-semibold text-gray-800 mb-2 tracking-wide uppercase">Nejnovější výsledky</h3>
|
||||
{latestResults.length === 0 ? (
|
||||
<p className="text-gray-500 text-xs">Zatím žádné výsledky</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{latestResults.slice(0,6).map((m: any, idx: number) => {
|
||||
const nameHome = (m.home || m.home_team) || 'Domácí';
|
||||
const nameAway = (m.away || m.away_team) || 'Hosté';
|
||||
const score = m.score || (typeof m.result_home === 'number' && typeof m.result_away === 'number' ? `${m.result_home}:${m.result_away}` : '-');
|
||||
const dtRaw = (m.date_time || m.date || m.match_date || m.datetime || '') as string;
|
||||
let shortDate = '';
|
||||
try {
|
||||
const parsed = parseISO(dtRaw);
|
||||
if (!isNaN((parsed as any))) {
|
||||
shortDate = format(parsed as any, 'd.M.', { locale: cs });
|
||||
}
|
||||
} catch {}
|
||||
return (
|
||||
<li key={idx} className="flex items-center justify-between gap-2 p-1.5 rounded border border-gray-200/70 hover:bg-gray-50">
|
||||
<div className="flex-1 min-w-0 text-[11px] font-medium text-gray-900 truncate">
|
||||
<span className="truncate inline-block max-w-[46%] align-bottom">{nameHome}</span>
|
||||
<span className="mx-1 text-gray-500">vs</span>
|
||||
<span className="truncate inline-block max-w-[46%] align-bottom">{nameAway}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{shortDate && (
|
||||
<span className="hidden sm:inline-block text-[10px] text-gray-600 bg-gray-100 border border-gray-200 rounded px-1 py-0.5">{shortDate}</span>
|
||||
)}
|
||||
<span className="text-[10px] font-extrabold bg-gray-800 text-white rounded px-1.5 py-0.5">{score}</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Calendar;
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
Button,
|
||||
useDisclosure,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { useRef } from 'react';
|
||||
|
||||
interface ConfirmationDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
isDanger?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function ConfirmationDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Potvrdit',
|
||||
cancelText = 'Zrušit',
|
||||
isDanger = false,
|
||||
isLoading = false,
|
||||
}: ConfirmationDialogProps) {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
isOpen={isOpen}
|
||||
leastDestructiveRef={cancelRef}
|
||||
onClose={onClose}
|
||||
isCentered
|
||||
>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader fontSize="lg" fontWeight="bold">
|
||||
{title}
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogBody>{message}</AlertDialogBody>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<Button
|
||||
ref={cancelRef}
|
||||
onClick={onClose}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme={isDanger ? 'red' : 'blue'}
|
||||
onClick={handleConfirm}
|
||||
ml={3}
|
||||
isLoading={isLoading}
|
||||
loadingText="Zpracovávám..."
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,956 @@
|
||||
import React, { useRef, useCallback, useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
HStack,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
FormHelperText,
|
||||
Input,
|
||||
Text,
|
||||
SimpleGrid,
|
||||
useToast,
|
||||
VStack,
|
||||
useColorModeValue,
|
||||
ButtonGroup,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react';
|
||||
import ReactQuill from 'react-quill';
|
||||
import ReactCrop, { Crop } from 'react-image-crop';
|
||||
import DOMPurify from 'dompurify';
|
||||
import 'react-quill/dist/quill.snow.css';
|
||||
import 'react-image-crop/dist/ReactCrop.css';
|
||||
import '../../styles/custom-editor.css';
|
||||
import {
|
||||
Image as ImageIcon, Code, Type, Trash2, AlignLeft, AlignCenter, AlignRight,
|
||||
RotateCw, RotateCcw, FlipHorizontal, FlipVertical, Sun, Droplets, Eye,
|
||||
Sparkles, Contrast, ZoomIn, ZoomOut, Move, Maximize2, Settings,
|
||||
Circle, Square, X, Check, Filter
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ImageFilters {
|
||||
brightness: number;
|
||||
contrast: number;
|
||||
saturation: number;
|
||||
blur: number;
|
||||
grayscale: number;
|
||||
sepia: number;
|
||||
hueRotate: number;
|
||||
rotation: number;
|
||||
flipH: boolean;
|
||||
flipV: boolean;
|
||||
}
|
||||
|
||||
interface CustomRichEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
height?: string;
|
||||
readOnly?: boolean;
|
||||
onImageUpload?: (file: File) => Promise<{ url: string }>;
|
||||
showImageResize?: boolean;
|
||||
toolbar?: 'full' | 'basic' | 'minimal';
|
||||
}
|
||||
|
||||
const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Začněte psát...',
|
||||
height = '400px',
|
||||
readOnly = false,
|
||||
onImageUpload,
|
||||
showImageResize = true,
|
||||
toolbar = 'full',
|
||||
}) => {
|
||||
const toast = useToast();
|
||||
const quillRef = useRef<ReactQuill | null>(null);
|
||||
const [editorMode, setEditorMode] = useState<'rich' | 'html'>('rich');
|
||||
|
||||
// Crop modal state
|
||||
const [cropOpen, setCropOpen] = useState(false);
|
||||
const [cropSrc, setCropSrc] = useState<string | null>(null);
|
||||
const [crop, setCrop] = useState<Crop>({ unit: '%', width: 80, height: 80, x: 10, y: 10 });
|
||||
const [cropQuality, setCropQuality] = useState<number>(85);
|
||||
const [cropMaxWidth, setCropMaxWidth] = useState<number>(1500);
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const toolbarBg = useColorModeValue('white', 'gray.800');
|
||||
const toolbarBorder = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
// Image editing state
|
||||
const [selectedImageElement, setSelectedImageElement] = useState<HTMLImageElement | null>(null);
|
||||
const [imageFilters, setImageFilters] = useState<ImageFilters>({
|
||||
brightness: 100,
|
||||
contrast: 100,
|
||||
saturation: 100,
|
||||
blur: 0,
|
||||
grayscale: 0,
|
||||
sepia: 0,
|
||||
hueRotate: 0,
|
||||
rotation: 0,
|
||||
flipH: false,
|
||||
flipV: false,
|
||||
});
|
||||
const [showImageToolbar, setShowImageToolbar] = useState(false);
|
||||
const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 });
|
||||
|
||||
// Define toolbar configurations
|
||||
const toolbarConfigs = {
|
||||
full: [
|
||||
[{ header: [1, 2, 3, false] }],
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
[{ color: [] }, { background: [] }],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||
[{ align: [] }],
|
||||
['link', 'image', 'video'],
|
||||
['blockquote', 'code-block'],
|
||||
['clean'],
|
||||
],
|
||||
basic: [
|
||||
[{ header: [1, 2, 3, false] }],
|
||||
['bold', 'italic', 'underline'],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||
[{ align: [] }],
|
||||
['link', 'image'],
|
||||
['clean'],
|
||||
],
|
||||
minimal: [
|
||||
['bold', 'italic', 'underline'],
|
||||
[{ list: 'bullet' }],
|
||||
['link'],
|
||||
['clean'],
|
||||
],
|
||||
};
|
||||
|
||||
const getToolbarConfig = () => {
|
||||
return toolbarConfigs[toolbar] || toolbarConfigs.full;
|
||||
};
|
||||
|
||||
// Image upload handler
|
||||
const handleImageUpload = useCallback(() => {
|
||||
const input = document.createElement('input');
|
||||
input.setAttribute('type', 'file');
|
||||
input.setAttribute('accept', 'image/*');
|
||||
input.onchange = async () => {
|
||||
const file = (input.files || [])[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setCropSrc(reader.result as string);
|
||||
setCropOpen(true);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
input.click();
|
||||
}, []);
|
||||
|
||||
// Get cropped blob
|
||||
const getCroppedBlob = (image: HTMLImageElement, cropPixels: { x: number; y: number; width: number; height: number }): Promise<Blob> => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const scaleX = image.naturalWidth / image.width;
|
||||
const scaleY = image.naturalHeight / image.height;
|
||||
|
||||
let outputWidth = Math.max(1, Math.round(cropPixels.width * scaleX));
|
||||
let outputHeight = Math.max(1, Math.round(cropPixels.height * scaleY));
|
||||
|
||||
if (outputWidth > cropMaxWidth) {
|
||||
const scale = cropMaxWidth / outputWidth;
|
||||
outputWidth = cropMaxWidth;
|
||||
outputHeight = Math.round(outputHeight * scale);
|
||||
}
|
||||
|
||||
canvas.width = outputWidth;
|
||||
canvas.height = outputHeight;
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
if (!ctx) throw new Error('Canvas not supported');
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
ctx.drawImage(
|
||||
image,
|
||||
Math.round(cropPixels.x * scaleX),
|
||||
Math.round(cropPixels.y * scaleY),
|
||||
Math.round(cropPixels.width * scaleX),
|
||||
Math.round(cropPixels.height * scaleY),
|
||||
0,
|
||||
0,
|
||||
outputWidth,
|
||||
outputHeight
|
||||
);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
canvas.toBlob((blob) => resolve(blob as Blob), 'image/jpeg', cropQuality / 100);
|
||||
});
|
||||
};
|
||||
|
||||
// Confirm crop and insert
|
||||
const confirmCropAndInsert = async () => {
|
||||
try {
|
||||
if (!imgRef.current) {
|
||||
toast({ title: 'Chyba', description: 'Obrázek není načten', status: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!crop.width || !crop.height || crop.width <= 0 || crop.height <= 0) {
|
||||
toast({ title: 'Chyba', description: 'Vyberte oblast k oříznutí', status: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
const img = imgRef.current;
|
||||
const percToPx = (val: number, size: number) => (crop.unit === '%' ? (val / 100) * size : val);
|
||||
const cropPx = {
|
||||
x: Math.max(0, percToPx(crop.x || 0, img.width)),
|
||||
y: Math.max(0, percToPx(crop.y || 0, img.height)),
|
||||
width: Math.min(img.width, percToPx(crop.width || img.width, img.width)),
|
||||
height: Math.min(img.height, percToPx(crop.height || img.height, img.height)),
|
||||
};
|
||||
|
||||
if (cropPx.x + cropPx.width > img.width) {
|
||||
cropPx.width = img.width - cropPx.x;
|
||||
}
|
||||
if (cropPx.y + cropPx.height > img.height) {
|
||||
cropPx.height = img.height - cropPx.y;
|
||||
}
|
||||
|
||||
const blob = await getCroppedBlob(img, cropPx);
|
||||
const file = new File([blob], 'cropped-image.jpg', { type: 'image/jpeg' });
|
||||
|
||||
if (onImageUpload) {
|
||||
toast({ title: 'Nahrávám obrázek...', status: 'info', duration: 2000 });
|
||||
const res = await onImageUpload(file);
|
||||
|
||||
if (!res.url) {
|
||||
throw new Error('Upload failed - no URL returned');
|
||||
}
|
||||
|
||||
const quill = quillRef.current?.getEditor();
|
||||
if (quill) {
|
||||
const range = quill.getSelection(true);
|
||||
const index = range ? range.index : quill.getLength();
|
||||
quill.insertEmbed(index, 'image', res.url, 'user');
|
||||
quill.setSelection(index + 1, 0);
|
||||
toast({ title: 'Obrázek vložen', status: 'success', duration: 2000 });
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Crop and insert error:', e);
|
||||
toast({ title: 'Zpracování obrázku selhalo', description: e?.message || 'Chyba', status: 'error' });
|
||||
} finally {
|
||||
setCropOpen(false);
|
||||
setCropSrc(null);
|
||||
setCrop({ unit: '%', width: 80, height: 80, x: 10, y: 10 });
|
||||
setCropQuality(85);
|
||||
setCropMaxWidth(1500);
|
||||
}
|
||||
};
|
||||
|
||||
// Make images draggable and resizable
|
||||
useEffect(() => {
|
||||
const editor = quillRef.current?.getEditor();
|
||||
if (!editor || readOnly) return;
|
||||
|
||||
let selectedImage: HTMLImageElement | null = null;
|
||||
let resizeHandle: HTMLDivElement | null = null;
|
||||
let isResizing = false;
|
||||
let isDragging = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startWidth = 0;
|
||||
|
||||
const createResizeHandle = (img: HTMLImageElement) => {
|
||||
removeResizeHandle();
|
||||
|
||||
const handle = document.createElement('div');
|
||||
handle.className = 'custom-image-resize-handle';
|
||||
handle.style.cssText = `
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: linear-gradient(135deg, #3182ce 0%, #2c5aa0 100%);
|
||||
border: 2px solid white;
|
||||
border-radius: 50%;
|
||||
cursor: nwse-resize;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
transition: transform 0.2s;
|
||||
`;
|
||||
|
||||
const updateHandlePosition = () => {
|
||||
const rect = img.getBoundingClientRect();
|
||||
const editorRect = editor.root.getBoundingClientRect();
|
||||
handle.style.left = `${rect.right - editorRect.left - 7}px`;
|
||||
handle.style.top = `${rect.bottom - editorRect.top - 7}px`;
|
||||
};
|
||||
|
||||
updateHandlePosition();
|
||||
editor.root.style.position = 'relative';
|
||||
editor.root.appendChild(handle);
|
||||
resizeHandle = handle;
|
||||
|
||||
handle.addEventListener('mouseenter', () => {
|
||||
handle.style.transform = 'scale(1.2)';
|
||||
});
|
||||
|
||||
handle.addEventListener('mouseleave', () => {
|
||||
handle.style.transform = 'scale(1)';
|
||||
});
|
||||
|
||||
handle.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isResizing = true;
|
||||
startX = e.clientX;
|
||||
startWidth = img.offsetWidth;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!isResizing) return;
|
||||
const deltaX = e.clientX - startX;
|
||||
const newWidth = Math.max(50, startWidth + deltaX);
|
||||
img.style.width = `${newWidth}px`;
|
||||
img.style.maxWidth = '100%';
|
||||
updateHandlePosition();
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
isResizing = false;
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
onChange(editor.root.innerHTML);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
});
|
||||
|
||||
return handle;
|
||||
};
|
||||
|
||||
const removeResizeHandle = () => {
|
||||
if (resizeHandle && resizeHandle.parentNode) {
|
||||
resizeHandle.parentNode.removeChild(resizeHandle);
|
||||
resizeHandle = null;
|
||||
}
|
||||
};
|
||||
|
||||
const selectImage = (img: HTMLImageElement) => {
|
||||
if (selectedImage) {
|
||||
selectedImage.style.outline = '';
|
||||
selectedImage.style.cursor = '';
|
||||
selectedImage.style.boxShadow = '';
|
||||
}
|
||||
|
||||
selectedImage = img;
|
||||
img.style.outline = '3px solid #3182ce';
|
||||
img.style.cursor = 'move';
|
||||
img.style.boxShadow = '0 4px 12px rgba(49, 130, 206, 0.3)';
|
||||
createResizeHandle(img);
|
||||
|
||||
// Set selected image state and load filters
|
||||
setSelectedImageElement(img);
|
||||
const filtersData = img.getAttribute('data-filters');
|
||||
if (filtersData) {
|
||||
try {
|
||||
const savedFilters = JSON.parse(filtersData);
|
||||
setImageFilters(savedFilters);
|
||||
} catch {
|
||||
// If parsing fails, use defaults
|
||||
}
|
||||
}
|
||||
|
||||
// Show toolbar and position it
|
||||
const rect = img.getBoundingClientRect();
|
||||
const editorRect = editor.root.getBoundingClientRect();
|
||||
setToolbarPosition({
|
||||
top: rect.top - editorRect.top - 50,
|
||||
left: rect.left - editorRect.left,
|
||||
});
|
||||
setShowImageToolbar(true);
|
||||
};
|
||||
|
||||
const deselectImage = () => {
|
||||
if (selectedImage) {
|
||||
selectedImage.style.outline = '';
|
||||
selectedImage.style.cursor = '';
|
||||
selectedImage.style.boxShadow = '';
|
||||
selectedImage = null;
|
||||
}
|
||||
removeResizeHandle();
|
||||
setSelectedImageElement(null);
|
||||
setShowImageToolbar(false);
|
||||
};
|
||||
|
||||
const handleImageClick = (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'IMG') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectImage(target as HTMLImageElement);
|
||||
} else if (!target.classList.contains('custom-image-resize-handle')) {
|
||||
deselectImage();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'IMG' && selectedImage === target) {
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging || !selectedImage) return;
|
||||
|
||||
const deltaX = e.clientX - startX;
|
||||
|
||||
if (Math.abs(deltaX) > 20) {
|
||||
if (deltaX > 0) {
|
||||
selectedImage.style.display = 'block';
|
||||
selectedImage.style.marginLeft = 'auto';
|
||||
selectedImage.style.marginRight = '0';
|
||||
} else {
|
||||
selectedImage.style.display = 'block';
|
||||
selectedImage.style.marginLeft = '0';
|
||||
selectedImage.style.marginRight = 'auto';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
isDragging = false;
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
if (selectedImage) {
|
||||
onChange(editor.root.innerHTML);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete selected image on Delete key
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (selectedImage && (e.key === 'Delete' || e.key === 'Backspace')) {
|
||||
e.preventDefault();
|
||||
selectedImage.remove();
|
||||
deselectImage();
|
||||
onChange(editor.root.innerHTML);
|
||||
toast({ title: 'Obrázek odstraněn', status: 'info', duration: 1500 });
|
||||
}
|
||||
};
|
||||
|
||||
editor.root.addEventListener('click', handleImageClick);
|
||||
editor.root.addEventListener('mousedown', handleMouseDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
editor.root.removeEventListener('click', handleImageClick);
|
||||
editor.root.removeEventListener('mousedown', handleMouseDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
removeResizeHandle();
|
||||
deselectImage();
|
||||
};
|
||||
}, [value, onChange, readOnly, toast]);
|
||||
|
||||
// Apply filters to selected image
|
||||
const applyFiltersToImage = useCallback((img: HTMLImageElement, filters: ImageFilters) => {
|
||||
const filterString = `
|
||||
brightness(${filters.brightness}%)
|
||||
contrast(${filters.contrast}%)
|
||||
saturate(${filters.saturation}%)
|
||||
blur(${filters.blur}px)
|
||||
grayscale(${filters.grayscale}%)
|
||||
sepia(${filters.sepia}%)
|
||||
hue-rotate(${filters.hueRotate}deg)
|
||||
`.trim();
|
||||
|
||||
const transform = `
|
||||
rotate(${filters.rotation}deg)
|
||||
scaleX(${filters.flipH ? -1 : 1})
|
||||
scaleY(${filters.flipV ? -1 : 1})
|
||||
`.trim();
|
||||
|
||||
img.style.filter = filterString;
|
||||
img.style.transform = transform;
|
||||
img.setAttribute('data-filters', JSON.stringify(filters));
|
||||
}, []);
|
||||
|
||||
// Reset filters
|
||||
const resetFilters = useCallback(() => {
|
||||
const defaultFilters: ImageFilters = {
|
||||
brightness: 100,
|
||||
contrast: 100,
|
||||
saturation: 100,
|
||||
blur: 0,
|
||||
grayscale: 0,
|
||||
sepia: 0,
|
||||
hueRotate: 0,
|
||||
rotation: 0,
|
||||
flipH: false,
|
||||
flipV: false,
|
||||
};
|
||||
setImageFilters(defaultFilters);
|
||||
if (selectedImageElement) {
|
||||
applyFiltersToImage(selectedImageElement, defaultFilters);
|
||||
}
|
||||
}, [selectedImageElement, applyFiltersToImage]);
|
||||
|
||||
// Update filter and apply to image
|
||||
const updateFilter = useCallback((key: keyof ImageFilters, value: any) => {
|
||||
setImageFilters(prev => {
|
||||
const newFilters = { ...prev, [key]: value };
|
||||
if (selectedImageElement) {
|
||||
applyFiltersToImage(selectedImageElement, newFilters);
|
||||
}
|
||||
return newFilters;
|
||||
});
|
||||
}, [selectedImageElement, applyFiltersToImage]);
|
||||
|
||||
// Sanitize HTML on change
|
||||
const handleChange = (content: string) => {
|
||||
const cleaned = DOMPurify.sanitize(content, {
|
||||
USE_PROFILES: { html: true },
|
||||
ADD_TAGS: ['iframe'],
|
||||
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters'],
|
||||
});
|
||||
onChange(cleaned);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Editor Controls */}
|
||||
{!readOnly && (
|
||||
<HStack mb={2} spacing={2} justify="space-between" flexWrap="wrap">
|
||||
<ButtonGroup size="sm" isAttached variant="outline">
|
||||
<Button
|
||||
leftIcon={<Type size={16} />}
|
||||
variant={editorMode === 'rich' ? 'solid' : 'outline'}
|
||||
colorScheme={editorMode === 'rich' ? 'blue' : 'gray'}
|
||||
onClick={() => setEditorMode('rich')}
|
||||
>
|
||||
Editor
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<Code size={16} />}
|
||||
variant={editorMode === 'html' ? 'solid' : 'outline'}
|
||||
colorScheme={editorMode === 'html' ? 'blue' : 'gray'}
|
||||
onClick={() => setEditorMode('html')}
|
||||
>
|
||||
HTML
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
|
||||
{editorMode === 'rich' && onImageUpload && (
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={<ImageIcon size={16} />}
|
||||
colorScheme="purple"
|
||||
onClick={handleImageUpload}
|
||||
>
|
||||
Vložit obrázek
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{editorMode === 'rich' ? (
|
||||
<Box
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
bg={bgColor}
|
||||
sx={{
|
||||
'.ql-toolbar': {
|
||||
borderBottom: '1px solid',
|
||||
borderColor: borderColor,
|
||||
bg: hoverBg,
|
||||
},
|
||||
'.ql-container': {
|
||||
fontSize: '16px',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
|
||||
},
|
||||
'.ql-editor': {
|
||||
minHeight: height,
|
||||
maxHeight: '70vh',
|
||||
overflowY: 'auto',
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '8px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
bg: 'gray.100',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
bg: 'gray.400',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
img: {
|
||||
cursor: 'pointer',
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
margin: '12px 0',
|
||||
transition: 'all 0.2s ease',
|
||||
borderRadius: '4px',
|
||||
userSelect: 'none',
|
||||
'&:hover': {
|
||||
opacity: 0.95,
|
||||
transform: 'scale(1.01)',
|
||||
},
|
||||
},
|
||||
},
|
||||
'.ql-editor.ql-blank::before': {
|
||||
color: 'gray.400',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ReactQuill
|
||||
theme="snow"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
readOnly={readOnly}
|
||||
placeholder={placeholder}
|
||||
ref={quillRef}
|
||||
modules={{
|
||||
toolbar: {
|
||||
container: getToolbarConfig(),
|
||||
handlers: {
|
||||
image: onImageUpload ? handleImageUpload : undefined,
|
||||
},
|
||||
},
|
||||
clipboard: {
|
||||
matchVisual: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
as="textarea"
|
||||
value={value}
|
||||
onChange={(e: any) => onChange(e.target.value)}
|
||||
fontFamily="mono"
|
||||
fontSize="sm"
|
||||
p={4}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
bg={bgColor}
|
||||
resize="vertical"
|
||||
minH={height}
|
||||
maxH="70vh"
|
||||
width="100%"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!readOnly && editorMode === 'rich' && (
|
||||
<Text fontSize="xs" color="gray.500" mt={2}>
|
||||
💡 Tip: Klikněte na obrázek pro výběr a úpravu. Používejte nástrojovou lištu pro filtry a transformace.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Floating Image Editing Toolbar */}
|
||||
{showImageToolbar && selectedImageElement && !readOnly && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top={`${toolbarPosition.top}px`}
|
||||
left={`${toolbarPosition.left}px`}
|
||||
bg={toolbarBg}
|
||||
borderWidth="1px"
|
||||
borderColor={toolbarBorder}
|
||||
borderRadius="lg"
|
||||
boxShadow="lg"
|
||||
p={3}
|
||||
zIndex={1500}
|
||||
minW="320px"
|
||||
maxW="400px"
|
||||
>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* Toolbar Header */}
|
||||
<HStack justify="space-between">
|
||||
<HStack spacing={2}>
|
||||
<Settings size={16} />
|
||||
<Text fontWeight="bold" fontSize="sm">Úprava obrázku</Text>
|
||||
</HStack>
|
||||
<IconButton
|
||||
aria-label="Close"
|
||||
icon={<X size={16} />}
|
||||
size="xs"
|
||||
onClick={() => setShowImageToolbar(false)}
|
||||
variant="ghost"
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* Transform Buttons */}
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Tooltip label="Otočit doleva">
|
||||
<IconButton
|
||||
aria-label="Rotate left"
|
||||
icon={<RotateCcw size={16} />}
|
||||
size="sm"
|
||||
onClick={() => updateFilter('rotation', (imageFilters.rotation - 90) % 360)}
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="Otočit doprava">
|
||||
<IconButton
|
||||
aria-label="Rotate right"
|
||||
icon={<RotateCw size={16} />}
|
||||
size="sm"
|
||||
onClick={() => updateFilter('rotation', (imageFilters.rotation + 90) % 360)}
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="Převrátit horizontálně">
|
||||
<IconButton
|
||||
aria-label="Flip horizontal"
|
||||
icon={<FlipHorizontal size={16} />}
|
||||
size="sm"
|
||||
onClick={() => updateFilter('flipH', !imageFilters.flipH)}
|
||||
colorScheme="blue"
|
||||
variant={imageFilters.flipH ? 'solid' : 'outline'}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="Převrátit vertikálně">
|
||||
<IconButton
|
||||
aria-label="Flip vertical"
|
||||
icon={<FlipVertical size={16} />}
|
||||
size="sm"
|
||||
onClick={() => updateFilter('flipV', !imageFilters.flipV)}
|
||||
colorScheme="blue"
|
||||
variant={imageFilters.flipV ? 'solid' : 'outline'}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="Resetovat vše">
|
||||
<IconButton
|
||||
aria-label="Reset filters"
|
||||
icon={<RotateCcw size={16} />}
|
||||
size="sm"
|
||||
onClick={resetFilters}
|
||||
colorScheme="red"
|
||||
variant="outline"
|
||||
/>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
|
||||
{/* Filter Sliders */}
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<FormControl>
|
||||
<HStack justify="space-between">
|
||||
<HStack spacing={1}>
|
||||
<Sun size={14} />
|
||||
<FormLabel fontSize="xs" mb={0}>Jas</FormLabel>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">{imageFilters.brightness}%</Text>
|
||||
</HStack>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={imageFilters.brightness}
|
||||
onChange={(e) => updateFilter('brightness', Number(e.target.value))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<HStack justify="space-between">
|
||||
<HStack spacing={1}>
|
||||
<Contrast size={14} />
|
||||
<FormLabel fontSize="xs" mb={0}>Kontrast</FormLabel>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">{imageFilters.contrast}%</Text>
|
||||
</HStack>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={imageFilters.contrast}
|
||||
onChange={(e) => updateFilter('contrast', Number(e.target.value))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<HStack justify="space-between">
|
||||
<HStack spacing={1}>
|
||||
<Droplets size={14} />
|
||||
<FormLabel fontSize="xs" mb={0}>Sytost</FormLabel>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">{imageFilters.saturation}%</Text>
|
||||
</HStack>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={imageFilters.saturation}
|
||||
onChange={(e) => updateFilter('saturation', Number(e.target.value))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<HStack justify="space-between">
|
||||
<HStack spacing={1}>
|
||||
<Eye size={14} />
|
||||
<FormLabel fontSize="xs" mb={0}>Rozostření</FormLabel>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">{imageFilters.blur}px</Text>
|
||||
</HStack>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.5"
|
||||
value={imageFilters.blur}
|
||||
onChange={(e) => updateFilter('blur', Number(e.target.value))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
|
||||
{/* Quick Filters */}
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
updateFilter('grayscale', imageFilters.grayscale === 100 ? 0 : 100);
|
||||
}}
|
||||
colorScheme={imageFilters.grayscale === 100 ? 'purple' : 'gray'}
|
||||
variant={imageFilters.grayscale === 100 ? 'solid' : 'outline'}
|
||||
leftIcon={<Filter size={12} />}
|
||||
>
|
||||
Černobílá
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
updateFilter('sepia', imageFilters.sepia === 100 ? 0 : 100);
|
||||
}}
|
||||
colorScheme={imageFilters.sepia === 100 ? 'orange' : 'gray'}
|
||||
variant={imageFilters.sepia === 100 ? 'solid' : 'outline'}
|
||||
leftIcon={<Sparkles size={12} />}
|
||||
>
|
||||
Sepia
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Crop Modal */}
|
||||
<Modal isOpen={cropOpen} onClose={() => { setCropOpen(false); setCropSrc(null); }} size="6xl">
|
||||
<ModalOverlay bg="blackAlpha.700" backdropFilter="blur(4px)" />
|
||||
<ModalContent maxW="90vw" maxH="90vh">
|
||||
<ModalHeader>Oříznout a upravit obrázek</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody maxH="calc(90vh - 140px)" overflowY="auto" overflowX="hidden">
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{cropSrc && (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
p={4}
|
||||
bg={useColorModeValue('gray.50', 'gray.900')}
|
||||
borderRadius="md"
|
||||
>
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={(c: Crop) => setCrop(c)}
|
||||
minWidth={50}
|
||||
minHeight={50}
|
||||
keepSelection
|
||||
>
|
||||
<img
|
||||
ref={imgRef as any}
|
||||
src={cropSrc}
|
||||
alt="Crop preview"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '60vh',
|
||||
display: 'block',
|
||||
margin: 'auto'
|
||||
}}
|
||||
/>
|
||||
</ReactCrop>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">Max. šířka (px)</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
type="number"
|
||||
value={cropMaxWidth}
|
||||
onChange={(e) => setCropMaxWidth(Math.max(100, Math.min(3000, Number(e.target.value))))}
|
||||
min={100}
|
||||
max={3000}
|
||||
step={100}
|
||||
size="sm"
|
||||
/>
|
||||
<Text fontSize="sm" color="gray.600" whiteSpace="nowrap">px</Text>
|
||||
</HStack>
|
||||
<FormHelperText fontSize="xs">
|
||||
Větší obrázky budou zmenšeny (optimalizace výkonu)
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">Kvalita JPEG</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
type="number"
|
||||
value={cropQuality}
|
||||
onChange={(e) => setCropQuality(Math.max(1, Math.min(100, Number(e.target.value))))}
|
||||
min={1}
|
||||
max={100}
|
||||
step={5}
|
||||
size="sm"
|
||||
/>
|
||||
<Text fontSize="sm" color="gray.600" whiteSpace="nowrap">%</Text>
|
||||
</HStack>
|
||||
<FormHelperText fontSize="xs">
|
||||
85% je doporučená hodnota (menší velikost souboru)
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
💡 Přetáhněte rohy a hrany modré oblasti pro výběr části obrázku k oříznutí.
|
||||
</Text>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={() => { setCropOpen(false); setCropSrc(null); }}>
|
||||
Zrušit
|
||||
</Button>
|
||||
<Button colorScheme="blue" onClick={confirmCropAndInsert}>
|
||||
Oříznout a vložit
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomRichEditor;
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import NewsletterSubscribe from '../newsletter/NewsletterSubscribe';
|
||||
|
||||
const NewsletterCTA: React.FC = () => {
|
||||
return (
|
||||
<section className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24 }}>
|
||||
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
|
||||
<NewsletterSubscribe />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsletterCTA;
|
||||
@@ -0,0 +1,97 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useSmoothScroll } from '../../hooks/useSmoothScroll';
|
||||
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts';
|
||||
import { FiArrowUp } from 'react-icons/fi';
|
||||
|
||||
/**
|
||||
* PageEnhancer - Adds universal functionality to all pages
|
||||
* - Back to top button
|
||||
* - Keyboard shortcuts
|
||||
* - Scroll to top on route change
|
||||
* - Skip to content link
|
||||
*/
|
||||
|
||||
interface PageEnhancerProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const PageEnhancer: React.FC<PageEnhancerProps> = ({ children }) => {
|
||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
const { scrollToTop, scrollToElement } = useSmoothScroll();
|
||||
const location = useLocation();
|
||||
|
||||
// Scroll to top when route changes
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [location.pathname]);
|
||||
|
||||
// Show/hide back to top button
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setShowBackToTop(window.scrollY > 300);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// Handle back to top click
|
||||
const handleBackToTop = useCallback(() => {
|
||||
scrollToTop();
|
||||
}, [scrollToTop]);
|
||||
|
||||
// Global keyboard shortcuts
|
||||
useKeyboardShortcuts([
|
||||
{
|
||||
key: 'Home',
|
||||
callback: scrollToTop,
|
||||
description: 'Scroll to top',
|
||||
},
|
||||
{
|
||||
key: 'End',
|
||||
callback: () => {
|
||||
window.scrollTo({
|
||||
top: document.body.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
},
|
||||
description: 'Scroll to bottom',
|
||||
},
|
||||
{
|
||||
key: 'Escape',
|
||||
callback: () => {
|
||||
// Close any open modals (implement based on your modal system)
|
||||
const event = new CustomEvent('closeAllModals');
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
description: 'Close modals',
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Skip to main content link for accessibility */}
|
||||
<a href="#main-content" className="skip-to-content">
|
||||
Přeskočit na hlavní obsah
|
||||
</a>
|
||||
|
||||
{/* Main content with ID for skip link */}
|
||||
<div id="main-content">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Back to top button */}
|
||||
<button
|
||||
className={`back-to-top ${showBackToTop ? 'visible' : ''}`}
|
||||
onClick={handleBackToTop}
|
||||
aria-label="Zpět nahoru"
|
||||
title="Zpět nahoru"
|
||||
>
|
||||
<FiArrowUp size={24} />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageEnhancer;
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Button, HStack, Text } from '@chakra-ui/react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
maxVisiblePages?: number;
|
||||
}
|
||||
|
||||
export default function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
maxVisiblePages = 5,
|
||||
}: PaginationProps) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const getPageNumbers = () => {
|
||||
const pages = [];
|
||||
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
|
||||
const endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
|
||||
|
||||
if (endPage - startPage + 1 < maxVisiblePages) {
|
||||
startPage = Math.max(1, endPage - maxVisiblePages + 1);
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
const pages = getPageNumbers();
|
||||
|
||||
return (
|
||||
<HStack spacing={1}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
isDisabled={currentPage === 1}
|
||||
aria-label="Předchozí stránka"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
|
||||
{!pages.includes(1) && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={currentPage === 1 ? 'solid' : 'outline'}
|
||||
onClick={() => onPageChange(1)}
|
||||
>
|
||||
1
|
||||
</Button>
|
||||
{!pages.includes(2) && <Text>...</Text>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{pages.map((page) => (
|
||||
<Button
|
||||
key={page}
|
||||
size="sm"
|
||||
variant={currentPage === page ? 'solid' : 'outline'}
|
||||
colorScheme={currentPage === page ? 'blue' : 'gray'}
|
||||
onClick={() => onPageChange(page)}
|
||||
aria-current={currentPage === page ? 'page' : undefined}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{!pages.includes(totalPages) && (
|
||||
<>
|
||||
{!pages.includes(totalPages - 1) && <Text>...</Text>}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={currentPage === totalPages ? 'solid' : 'outline'}
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
>
|
||||
{totalPages}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
isDisabled={currentPage === totalPages}
|
||||
aria-label="Další stránka"
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import CustomRichEditor from './CustomRichEditor';
|
||||
import { uploadFile } from '../../services/articles';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
interface RichTextEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
height?: string;
|
||||
readOnly?: boolean;
|
||||
onImageUpload?: (file: File) => Promise<{ url: string }>;
|
||||
showImageResize?: boolean;
|
||||
toolbar?: 'full' | 'basic' | 'minimal' | string;
|
||||
}
|
||||
|
||||
const RichTextEditor: React.FC<RichTextEditorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Začněte psát...',
|
||||
height = '400px',
|
||||
readOnly = false,
|
||||
onImageUpload = uploadFile,
|
||||
showImageResize = true,
|
||||
toolbar = 'full',
|
||||
}) => {
|
||||
// Wrapper function to handle URL transformation
|
||||
const handleImageUpload = async (file: File) => {
|
||||
const res = await onImageUpload(file);
|
||||
// Transform URL if needed
|
||||
const url = assetUrl(res.url) || res.url;
|
||||
return { url };
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomRichEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
height={height}
|
||||
readOnly={readOnly}
|
||||
onImageUpload={handleImageUpload}
|
||||
showImageResize={showImageResize}
|
||||
toolbar={toolbar as 'full' | 'basic' | 'minimal'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RichTextEditor;
|
||||
@@ -0,0 +1,153 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
interface Sponsor {
|
||||
id: number | string;
|
||||
name: string;
|
||||
logo: string;
|
||||
url?: string;
|
||||
tier?: string;
|
||||
}
|
||||
|
||||
interface SponsorsSectionProps {
|
||||
layout?: 'grid' | 'slider' | 'scroller' | 'pyramid';
|
||||
theme?: 'dark' | 'light';
|
||||
}
|
||||
|
||||
const resolveBackendUrl = (path: string) => {
|
||||
try {
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
|
||||
const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const b = new URL(base);
|
||||
const abs = new URL(path, `${b.protocol}//${b.host}`);
|
||||
return abs.toString();
|
||||
}
|
||||
return path;
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
};
|
||||
|
||||
const SponsorsSection: React.FC<SponsorsSectionProps> = ({
|
||||
layout = 'grid',
|
||||
theme = 'light'
|
||||
}) => {
|
||||
const [sponsors, setSponsors] = useState<Sponsor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const fetchSponsors = async () => {
|
||||
try {
|
||||
// Try API first
|
||||
const apiRes = await fetch(`${process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1'}/public/sponsors`);
|
||||
if (apiRes.ok) {
|
||||
const data = await apiRes.json();
|
||||
if (!cancelled && Array.isArray(data)) {
|
||||
const mapped = data.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
logo: assetUrl(s.logo_url) || '/images/sponsors/placeholder.png',
|
||||
url: s.website_url || undefined,
|
||||
tier: s.tier,
|
||||
}));
|
||||
setSponsors(mapped);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Fallback to cache
|
||||
try {
|
||||
const cacheRes = await fetch(resolveBackendUrl('/cache/prefetch/settings.json'), { cache: 'no-cache' });
|
||||
if (cacheRes.ok) {
|
||||
const settings = await cacheRes.json();
|
||||
if (!cancelled) {
|
||||
const sponsorsData = settings?.sponsors || settings?.partners || [];
|
||||
if (Array.isArray(sponsorsData) && sponsorsData.length) {
|
||||
setSponsors(
|
||||
sponsorsData.map((s: any, i: number) => ({
|
||||
id: s.id ?? i + 1,
|
||||
name: s.name || 'Sponsor',
|
||||
logo: s.logo_url || s.logoUrl || s.logo || '/images/sponsors/placeholder.png',
|
||||
url: s.url || s.website || s.link || '#',
|
||||
tier: s.tier,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSponsors();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (loading || sponsors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = sponsors.find((s: any) => s.tier === 'title') || sponsors[0];
|
||||
const others = sponsors.filter((s) => s !== title);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`sponsors ${theme === 'dark' ? 'dark' : ''}`}
|
||||
style={{
|
||||
width: '100vw',
|
||||
position: 'relative',
|
||||
left: '50%',
|
||||
right: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
paddingLeft: 'max(16px, calc((100vw - 1200px) / 2))',
|
||||
paddingRight: 'max(16px, calc((100vw - 1200px) / 2))',
|
||||
boxSizing: 'border-box',
|
||||
marginTop: '32px',
|
||||
marginBottom: '32px',
|
||||
}}
|
||||
>
|
||||
<div className="section-head">
|
||||
<h3>Sponzoři</h3>
|
||||
</div>
|
||||
{layout === 'grid' ? (
|
||||
<>
|
||||
{title && (
|
||||
<div className="title-sponsor">
|
||||
<a className="sponsor-tile" href={title.url || '#'} target="_blank" rel="noreferrer noopener">
|
||||
<img src={title.logo} alt={title.name} />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="divider" aria-hidden />
|
||||
<div className="sponsors-grid">
|
||||
{others.map((s) => (
|
||||
<a key={s.id} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
|
||||
<img src={s.logo} alt={s.name} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="sponsors-slider">
|
||||
<div className="track">
|
||||
{[...sponsors, ...sponsors].map((s, idx) => (
|
||||
<a key={`${s.id}-${idx}`} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
|
||||
<img src={s.logo} alt={s.name} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default SponsorsSection;
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Image, ImageProps, Skeleton } from '@chakra-ui/react';
|
||||
import { getTeamLogo } from '../../utils/sportLogosAPI';
|
||||
import { getLogoStyle, getLogoClassName } from '../../utils/logoUtils';
|
||||
import '../../styles/logos.css';
|
||||
|
||||
interface TeamLogoProps extends Omit<ImageProps, 'src'> {
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
facrLogo?: string;
|
||||
size?: 'small' | 'medium' | 'large' | 'custom';
|
||||
fallbackIcon?: React.ReactElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* TeamLogo component with automatic logoapi.sportcreative.eu integration
|
||||
* Features:
|
||||
* - Fetches from logoapi first (with local caching)
|
||||
* - Falls back to FACR logo if logoapi doesn't have it
|
||||
* - Properly centers and formats logos
|
||||
* - Handles SVG optimization
|
||||
*/
|
||||
export const TeamLogo: React.FC<TeamLogoProps> = ({
|
||||
teamId,
|
||||
teamName,
|
||||
facrLogo,
|
||||
size = 'medium',
|
||||
fallbackIcon,
|
||||
alt,
|
||||
...imageProps
|
||||
}) => {
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const fetchLogo = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
|
||||
const url = await getTeamLogo(teamId, teamName, facrLogo);
|
||||
|
||||
if (mounted) {
|
||||
setLogoUrl(url);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch logo:', e);
|
||||
if (mounted) {
|
||||
setError(true);
|
||||
// Fallback to FACR or placeholder
|
||||
setLogoUrl(facrLogo || '/logo192.png');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchLogo();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [teamId, teamName, facrLogo]);
|
||||
|
||||
// Size mapping
|
||||
const sizeMap = {
|
||||
small: { boxSize: '24px' },
|
||||
medium: { boxSize: '32px' },
|
||||
large: { boxSize: '48px' },
|
||||
custom: {},
|
||||
};
|
||||
|
||||
const sizeProps = size !== 'custom' ? sizeMap[size] : {};
|
||||
|
||||
// Class name based on size
|
||||
const className = `match-logo-${size} ${imageProps.className || ''}`.trim();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Skeleton
|
||||
{...sizeProps}
|
||||
borderRadius="4px"
|
||||
className="logo-loading"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this is a circular container
|
||||
const isCircular = imageProps.borderRadius === 'full' || imageProps.style?.borderRadius === '50%';
|
||||
|
||||
// Get appropriate styling and className using utility functions
|
||||
// Only pass size to utils if it's not 'custom' (utils only accept standard sizes)
|
||||
const utilSize = size !== 'custom' ? size : 'medium';
|
||||
const logoStyle = getLogoStyle(logoUrl, isCircular, utilSize);
|
||||
const logoClassName = getLogoClassName(logoUrl, isCircular, utilSize);
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={logoUrl || '/logo192.png'}
|
||||
alt={alt || teamName || 'Team logo'}
|
||||
{...sizeProps}
|
||||
{...imageProps}
|
||||
className={`${className} ${logoClassName}`}
|
||||
objectFit="contain"
|
||||
loading="lazy"
|
||||
fallback={fallbackIcon}
|
||||
style={{
|
||||
...imageProps.style,
|
||||
...logoStyle
|
||||
}}
|
||||
onError={() => {
|
||||
if (!error) {
|
||||
setError(true);
|
||||
setLogoUrl(facrLogo || '/logo192.png');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamLogo;
|
||||
@@ -0,0 +1,333 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
VStack,
|
||||
HStack,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Select,
|
||||
Slider,
|
||||
SliderTrack,
|
||||
SliderFilledTrack,
|
||||
SliderThumb,
|
||||
Text,
|
||||
Box,
|
||||
Divider,
|
||||
Button,
|
||||
IconButton,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverBody,
|
||||
PopoverArrow,
|
||||
SimpleGrid,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiRefreshCw } from 'react-icons/fi';
|
||||
|
||||
interface AdvancedStyleControlsProps {
|
||||
elementName: string;
|
||||
settings?: Record<string, any>;
|
||||
onChange?: (settings: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
const AdvancedStyleControls: React.FC<AdvancedStyleControlsProps> = ({
|
||||
elementName,
|
||||
settings = {},
|
||||
onChange,
|
||||
}) => {
|
||||
const updateSetting = (key: string, value: any) => {
|
||||
if (onChange) {
|
||||
onChange({ ...settings, [key]: value });
|
||||
}
|
||||
};
|
||||
|
||||
const presetColors = [
|
||||
'#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8',
|
||||
'#F7DC6F', '#BB8FCE', '#85C1E2', '#F8B739', '#52B788',
|
||||
];
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{/* Spacing Controls */}
|
||||
<Box>
|
||||
<Text fontWeight="bold" fontSize="sm" mb={2}>Spacing</Text>
|
||||
<VStack spacing={3}>
|
||||
<FormControl>
|
||||
<HStack justify="space-between">
|
||||
<FormLabel fontSize="xs" mb={0}>Margin Top</FormLabel>
|
||||
<Text fontSize="xs" color="gray.500">{settings.marginTop || 0}px</Text>
|
||||
</HStack>
|
||||
<Slider
|
||||
value={settings.marginTop || 0}
|
||||
min={0}
|
||||
max={100}
|
||||
step={4}
|
||||
onChange={(val) => updateSetting('marginTop', val)}
|
||||
>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<HStack justify="space-between">
|
||||
<FormLabel fontSize="xs" mb={0}>Margin Bottom</FormLabel>
|
||||
<Text fontSize="xs" color="gray.500">{settings.marginBottom || 0}px</Text>
|
||||
</HStack>
|
||||
<Slider
|
||||
value={settings.marginBottom || 0}
|
||||
min={0}
|
||||
max={100}
|
||||
step={4}
|
||||
onChange={(val) => updateSetting('marginBottom', val)}
|
||||
>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<HStack justify="space-between">
|
||||
<FormLabel fontSize="xs" mb={0}>Padding</FormLabel>
|
||||
<Text fontSize="xs" color="gray.500">{settings.padding || 0}px</Text>
|
||||
</HStack>
|
||||
<Slider
|
||||
value={settings.padding || 0}
|
||||
min={0}
|
||||
max={100}
|
||||
step={4}
|
||||
onChange={(val) => updateSetting('padding', val)}
|
||||
>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Background Controls */}
|
||||
<Box>
|
||||
<Text fontWeight="bold" fontSize="sm" mb={2}>Background</Text>
|
||||
<VStack spacing={3}>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Background Color</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
type="color"
|
||||
value={settings.backgroundColor || '#ffffff'}
|
||||
onChange={(e) => updateSetting('backgroundColor', e.target.value)}
|
||||
size="sm"
|
||||
w="60px"
|
||||
h="40px"
|
||||
p={1}
|
||||
cursor="pointer"
|
||||
/>
|
||||
<Input
|
||||
value={settings.backgroundColor || '#ffffff'}
|
||||
onChange={(e) => updateSetting('backgroundColor', e.target.value)}
|
||||
size="sm"
|
||||
placeholder="#ffffff"
|
||||
flex={1}
|
||||
/>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
{/* Color Presets */}
|
||||
<SimpleGrid columns={5} spacing={2}>
|
||||
{presetColors.map((color) => (
|
||||
<Box
|
||||
key={color}
|
||||
w="100%"
|
||||
h="30px"
|
||||
bg={color}
|
||||
borderRadius="md"
|
||||
cursor="pointer"
|
||||
border="2px"
|
||||
borderColor={settings.backgroundColor === color ? 'blue.500' : 'transparent'}
|
||||
_hover={{ transform: 'scale(1.1)' }}
|
||||
transition="all 0.2s"
|
||||
onClick={() => updateSetting('backgroundColor', color)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<FormControl>
|
||||
<HStack justify="space-between">
|
||||
<FormLabel fontSize="xs" mb={0}>Background Opacity</FormLabel>
|
||||
<Text fontSize="xs" color="gray.500">{settings.backgroundOpacity || 100}%</Text>
|
||||
</HStack>
|
||||
<Slider
|
||||
value={settings.backgroundOpacity || 100}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(val) => updateSetting('backgroundOpacity', val)}
|
||||
>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Border Controls */}
|
||||
<Box>
|
||||
<Text fontWeight="bold" fontSize="sm" mb={2}>Border</Text>
|
||||
<VStack spacing={3}>
|
||||
<FormControl>
|
||||
<HStack justify="space-between">
|
||||
<FormLabel fontSize="xs" mb={0}>Border Width</FormLabel>
|
||||
<Text fontSize="xs" color="gray.500">{settings.borderWidth || 0}px</Text>
|
||||
</HStack>
|
||||
<Slider
|
||||
value={settings.borderWidth || 0}
|
||||
min={0}
|
||||
max={10}
|
||||
onChange={(val) => updateSetting('borderWidth', val)}
|
||||
>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Border Color</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
type="color"
|
||||
value={settings.borderColor || '#000000'}
|
||||
onChange={(e) => updateSetting('borderColor', e.target.value)}
|
||||
size="sm"
|
||||
w="60px"
|
||||
h="40px"
|
||||
p={1}
|
||||
cursor="pointer"
|
||||
/>
|
||||
<Input
|
||||
value={settings.borderColor || '#000000'}
|
||||
onChange={(e) => updateSetting('borderColor', e.target.value)}
|
||||
size="sm"
|
||||
placeholder="#000000"
|
||||
flex={1}
|
||||
/>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<HStack justify="space-between">
|
||||
<FormLabel fontSize="xs" mb={0}>Border Radius</FormLabel>
|
||||
<Text fontSize="xs" color="gray.500">{settings.borderRadius || 0}px</Text>
|
||||
</HStack>
|
||||
<Slider
|
||||
value={settings.borderRadius || 0}
|
||||
min={0}
|
||||
max={50}
|
||||
onChange={(val) => updateSetting('borderRadius', val)}
|
||||
>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Shadow Controls */}
|
||||
<Box>
|
||||
<Text fontWeight="bold" fontSize="sm" mb={2}>Shadow</Text>
|
||||
<VStack spacing={3}>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Shadow Type</FormLabel>
|
||||
<Select
|
||||
value={settings.boxShadow || 'none'}
|
||||
onChange={(e) => updateSetting('boxShadow', e.target.value)}
|
||||
size="sm"
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="sm">Small</option>
|
||||
<option value="md">Medium</option>
|
||||
<option value="lg">Large</option>
|
||||
<option value="xl">Extra Large</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Animation Controls */}
|
||||
<Box>
|
||||
<Text fontWeight="bold" fontSize="sm" mb={2}>Animation</Text>
|
||||
<VStack spacing={3}>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Entrance Animation</FormLabel>
|
||||
<Select
|
||||
value={settings.animation || 'none'}
|
||||
onChange={(e) => updateSetting('animation', e.target.value)}
|
||||
size="sm"
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="fadeIn">Fade In</option>
|
||||
<option value="slideInUp">Slide In Up</option>
|
||||
<option value="slideInDown">Slide In Down</option>
|
||||
<option value="slideInLeft">Slide In Left</option>
|
||||
<option value="slideInRight">Slide In Right</option>
|
||||
<option value="zoomIn">Zoom In</option>
|
||||
<option value="bounceIn">Bounce In</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<HStack justify="space-between">
|
||||
<FormLabel fontSize="xs" mb={0}>Animation Duration</FormLabel>
|
||||
<Text fontSize="xs" color="gray.500">{settings.animationDuration || 1000}ms</Text>
|
||||
</HStack>
|
||||
<Slider
|
||||
value={settings.animationDuration || 1000}
|
||||
min={200}
|
||||
max={3000}
|
||||
step={100}
|
||||
onChange={(val) => updateSetting('animationDuration', val)}
|
||||
>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Reset Button */}
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={<FiRefreshCw />}
|
||||
variant="outline"
|
||||
onClick={() => onChange && onChange({})}
|
||||
>
|
||||
Reset to Default
|
||||
</Button>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedStyleControls;
|
||||
@@ -0,0 +1,17 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
interface ConditionalElementProps {
|
||||
visible: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper that conditionally renders children based on visibility
|
||||
* Used with Elementor editor to show/hide elements
|
||||
*/
|
||||
const ConditionalElement: React.FC<ConditionalElementProps> = ({ visible, children }) => {
|
||||
if (!visible) return null;
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default ConditionalElement;
|
||||
@@ -0,0 +1,32 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
interface EditableElementProps {
|
||||
elementName: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper component that marks an element as editable in the visual editor
|
||||
*/
|
||||
const EditableElement: React.FC<EditableElementProps> = ({
|
||||
elementName,
|
||||
children,
|
||||
className,
|
||||
style
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
data-element={elementName}
|
||||
className={className}
|
||||
style={style}
|
||||
position="relative"
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditableElement;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,293 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
IconButton,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Select,
|
||||
Button,
|
||||
useToast,
|
||||
Tooltip,
|
||||
Badge,
|
||||
Collapse,
|
||||
useDisclosure,
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
DrawerContent,
|
||||
DrawerCloseButton,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Switch,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiEdit, FiSave, FiX, FiEye, FiEyeOff, FiSettings } from 'react-icons/fi';
|
||||
import {
|
||||
PageElementConfig,
|
||||
getPageElementConfigs,
|
||||
batchUpdatePageElementConfigs,
|
||||
ELEMENT_VARIANTS
|
||||
} from '../../services/pageElements';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
interface VisualPageEditorProps {
|
||||
pageType: string; // e.g., 'homepage', 'about'
|
||||
onConfigChange?: (configs: PageElementConfig[]) => void;
|
||||
}
|
||||
|
||||
const VisualPageEditor: React.FC<VisualPageEditorProps> = ({ pageType, onConfigChange }) => {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [configs, setConfigs] = useState<PageElementConfig[]>([]);
|
||||
const [localChanges, setLocalChanges] = useState<Record<string, string>>({});
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const toast = useToast();
|
||||
|
||||
// Load configurations
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return;
|
||||
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
const data = await getPageElementConfigs(pageType);
|
||||
setConfigs(data);
|
||||
|
||||
// Initialize local changes from existing configs
|
||||
const changes: Record<string, string> = {};
|
||||
data.forEach(cfg => {
|
||||
changes[cfg.element_name] = cfg.variant;
|
||||
});
|
||||
setLocalChanges(changes);
|
||||
} catch (error) {
|
||||
console.error('Failed to load page element configs:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadConfigs();
|
||||
}, [pageType, isAdmin]);
|
||||
|
||||
// Notify parent of config changes
|
||||
useEffect(() => {
|
||||
if (onConfigChange) {
|
||||
onConfigChange(configs);
|
||||
}
|
||||
}, [configs, onConfigChange]);
|
||||
|
||||
const handleVariantChange = (elementName: string, variant: string) => {
|
||||
setLocalChanges(prev => ({
|
||||
...prev,
|
||||
[elementName]: variant,
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// Build configs array from local changes
|
||||
const configsToSave: PageElementConfig[] = Object.entries(localChanges).map(([elementName, variant]) => ({
|
||||
page_type: pageType,
|
||||
element_name: elementName,
|
||||
variant,
|
||||
}));
|
||||
|
||||
const result = await batchUpdatePageElementConfigs(configsToSave);
|
||||
|
||||
toast({
|
||||
title: 'Changes saved',
|
||||
description: `Updated ${result.updated} configs, created ${result.created} new`,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
// Reload configs
|
||||
const updated = await getPageElementConfigs(pageType);
|
||||
setConfigs(updated);
|
||||
setHasChanges(false);
|
||||
|
||||
// Reload the page to apply changes
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to save changes',
|
||||
description: 'An error occurred while saving',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
// Reset local changes to match configs
|
||||
const changes: Record<string, string> = {};
|
||||
configs.forEach(cfg => {
|
||||
changes[cfg.element_name] = cfg.variant;
|
||||
});
|
||||
setLocalChanges(changes);
|
||||
setHasChanges(false);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating control button */}
|
||||
<Box
|
||||
position="fixed"
|
||||
right={4}
|
||||
bottom={20}
|
||||
zIndex={9999}
|
||||
display={isVisible ? 'block' : 'none'}
|
||||
>
|
||||
<Tooltip label="Visual Page Editor" hasArrow placement="left">
|
||||
<IconButton
|
||||
aria-label="Open visual editor"
|
||||
icon={<FiSettings />}
|
||||
colorScheme="purple"
|
||||
size="lg"
|
||||
borderRadius="full"
|
||||
boxShadow="lg"
|
||||
onClick={onOpen}
|
||||
_hover={{ transform: 'scale(1.1)' }}
|
||||
transition="all 0.2s"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Editor Drawer */}
|
||||
<Drawer isOpen={isOpen} placement="right" onClose={onClose} size="md">
|
||||
<DrawerOverlay />
|
||||
<DrawerContent>
|
||||
<DrawerCloseButton />
|
||||
<DrawerHeader borderBottomWidth="1px">
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack justify="space-between">
|
||||
<Text>Visual Page Editor</Text>
|
||||
<Badge colorScheme="purple">Admin</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="sm" fontWeight="normal" color="gray.500">
|
||||
Configure visual variants for page elements
|
||||
</Text>
|
||||
</VStack>
|
||||
</DrawerHeader>
|
||||
|
||||
<DrawerBody>
|
||||
<VStack align="stretch" spacing={6} py={4}>
|
||||
{/* Editor Status */}
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
Page: <strong>{pageType}</strong>
|
||||
</Text>
|
||||
<HStack>
|
||||
<Switch
|
||||
size="sm"
|
||||
isChecked={isEditing}
|
||||
onChange={(e) => setIsEditing(e.target.checked)}
|
||||
/>
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
{isEditing ? 'Editing' : 'Preview'}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Element Controls */}
|
||||
{Object.entries(ELEMENT_VARIANTS).map(([elementName, variants]) => (
|
||||
<Box key={elementName}>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm" fontWeight="bold" textTransform="capitalize">
|
||||
{elementName}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={localChanges[elementName] || variants[0].value}
|
||||
onChange={(e) => handleVariantChange(elementName, e.target.value)}
|
||||
size="md"
|
||||
isDisabled={!isEditing}
|
||||
>
|
||||
{variants.map((variant) => (
|
||||
<option key={variant.value} value={variant.value}>
|
||||
{variant.label} - {variant.description}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{/* Show current variant info */}
|
||||
{localChanges[elementName] && (
|
||||
<Text fontSize="xs" color="gray.500" mt={1}>
|
||||
Current: {variants.find(v => v.value === localChanges[elementName])?.description || localChanges[elementName]}
|
||||
</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Action Buttons */}
|
||||
<VStack spacing={3}>
|
||||
{hasChanges && (
|
||||
<Badge colorScheme="orange" fontSize="sm" p={2} borderRadius="md" w="full" textAlign="center">
|
||||
You have unsaved changes
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<HStack spacing={2} w="full">
|
||||
<Button
|
||||
leftIcon={<FiSave />}
|
||||
colorScheme="green"
|
||||
onClick={handleSave}
|
||||
isDisabled={!hasChanges || !isEditing}
|
||||
flex={1}
|
||||
>
|
||||
Save & Reload
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<FiX />}
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
isDisabled={!hasChanges}
|
||||
flex={1}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setIsVisible(!isVisible)}
|
||||
leftIcon={isVisible ? <FiEyeOff /> : <FiEye />}
|
||||
w="full"
|
||||
>
|
||||
{isVisible ? 'Hide' : 'Show'} Editor Button
|
||||
</Button>
|
||||
</VStack>
|
||||
|
||||
{/* Help Text */}
|
||||
<Box bg="blue.50" p={3} borderRadius="md" fontSize="sm">
|
||||
<Text fontWeight="bold" mb={1}>How to use:</Text>
|
||||
<VStack align="stretch" spacing={1} fontSize="xs">
|
||||
<Text>1. Toggle editing mode ON</Text>
|
||||
<Text>2. Select variants for each element</Text>
|
||||
<Text>3. Click "Save & Reload" to apply</Text>
|
||||
<Text>4. Page will reload with new styles</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisualPageEditor;
|
||||
@@ -0,0 +1,713 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Slider,
|
||||
SliderTrack,
|
||||
SliderFilledTrack,
|
||||
SliderThumb,
|
||||
Select,
|
||||
Switch,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
IconButton,
|
||||
Divider,
|
||||
Button,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiType, FiLayout, FiBox, FiDroplet, FiGrid, FiSmartphone, FiBarChart2, FiSidebar } from 'react-icons/fi';
|
||||
import { FaRegNewspaper, FaRegSquare, FaColumns } from 'react-icons/fa';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
|
||||
interface VisualStylePanelProps {
|
||||
elementName: string;
|
||||
onStyleChange: (styles: Record<string, any>) => void;
|
||||
currentStyles?: Record<string, any>;
|
||||
}
|
||||
|
||||
const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
||||
elementName,
|
||||
onStyleChange,
|
||||
currentStyles = {},
|
||||
}) => {
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const clubTheme = useClubTheme();
|
||||
const primaryColor = clubTheme.primary || '#0b5cff';
|
||||
|
||||
const [styles, setStyles] = useState({
|
||||
// Typography
|
||||
fontFamily: currentStyles.fontFamily || 'Inter',
|
||||
fontSize: currentStyles.fontSize || 16,
|
||||
fontWeight: currentStyles.fontWeight || 400,
|
||||
lineHeight: currentStyles.lineHeight || 1.5,
|
||||
letterSpacing: currentStyles.letterSpacing || 0,
|
||||
textTransform: currentStyles.textTransform || 'none',
|
||||
|
||||
// Colors
|
||||
color: currentStyles.color || '#000000',
|
||||
backgroundColor: currentStyles.backgroundColor || '#ffffff',
|
||||
|
||||
// Spacing
|
||||
paddingTop: currentStyles.paddingTop || 0,
|
||||
paddingRight: currentStyles.paddingRight || 0,
|
||||
paddingBottom: currentStyles.paddingBottom || 0,
|
||||
paddingLeft: currentStyles.paddingLeft || 0,
|
||||
marginTop: currentStyles.marginTop || 0,
|
||||
marginRight: currentStyles.marginRight || 0,
|
||||
marginBottom: currentStyles.marginBottom || 0,
|
||||
marginLeft: currentStyles.marginLeft || 0,
|
||||
|
||||
// Layout
|
||||
width: currentStyles.width || 'auto',
|
||||
height: currentStyles.height || 'auto',
|
||||
display: currentStyles.display || 'block',
|
||||
|
||||
// Grid Layout
|
||||
gridTemplateColumns: currentStyles.gridTemplateColumns || 'repeat(3, 1fr)',
|
||||
gridTemplateRows: currentStyles.gridTemplateRows || 'auto',
|
||||
gridColumnGap: currentStyles.gridColumnGap || 16,
|
||||
gridRowGap: currentStyles.gridRowGap || 16,
|
||||
gridAutoFlow: currentStyles.gridAutoFlow || 'row',
|
||||
alignItems: currentStyles.alignItems || 'stretch',
|
||||
justifyItems: currentStyles.justifyItems || 'stretch',
|
||||
|
||||
...currentStyles,
|
||||
});
|
||||
|
||||
const updateStyle = (key: string, value: any) => {
|
||||
const newStyles = { ...styles, [key]: value };
|
||||
setStyles(newStyles);
|
||||
onStyleChange(newStyles);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
width="280px"
|
||||
bg={bgColor}
|
||||
borderRight="1px"
|
||||
borderColor={primaryColor}
|
||||
height="100vh"
|
||||
overflowY="auto"
|
||||
pt="60px"
|
||||
>
|
||||
<Tabs size="sm" colorScheme="blue">
|
||||
<TabList px={2}>
|
||||
<Tab><FiType /> <Text ml={1}>Content</Text></Tab>
|
||||
<Tab><FiLayout /> <Text ml={1}>Style</Text></Tab>
|
||||
<Tab><FiGrid /> <Text ml={1}>Grid</Text></Tab>
|
||||
<Tab><FiBox /> <Text ml={1}>Advanced</Text></Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* Content Tab */}
|
||||
<TabPanel>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
|
||||
Typography
|
||||
</Text>
|
||||
|
||||
{/* Font Family */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Font Family</FormLabel>
|
||||
<Select
|
||||
size="sm"
|
||||
value={styles.fontFamily}
|
||||
onChange={(e) => updateStyle('fontFamily', e.target.value)}
|
||||
>
|
||||
<option value="Inter">Inter</option>
|
||||
<option value="Roboto">Roboto</option>
|
||||
<option value="Open Sans">Open Sans</option>
|
||||
<option value="Lato">Lato</option>
|
||||
<option value="Montserrat">Montserrat</option>
|
||||
<option value="Poppins">Poppins</option>
|
||||
<option value="Georgia">Georgia</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Font Size */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Size (px)</FormLabel>
|
||||
<HStack>
|
||||
<NumberInput
|
||||
size="sm"
|
||||
value={styles.fontSize}
|
||||
min={8}
|
||||
max={128}
|
||||
onChange={(_, val) => updateStyle('fontSize', val)}
|
||||
flex={1}
|
||||
>
|
||||
<NumberInputField />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
{/* Font Weight */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Weight</FormLabel>
|
||||
<HStack spacing={2}>
|
||||
<Slider
|
||||
value={styles.fontWeight}
|
||||
min={100}
|
||||
max={900}
|
||||
step={100}
|
||||
onChange={(val) => updateStyle('fontWeight', val)}
|
||||
flex={1}
|
||||
>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack bg={primaryColor} />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
<Text fontSize="xs" minW="40px">{styles.fontWeight}</Text>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
{/* Line Height */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Line Height</FormLabel>
|
||||
<HStack spacing={2}>
|
||||
<Slider
|
||||
value={styles.lineHeight}
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
onChange={(val) => updateStyle('lineHeight', val)}
|
||||
flex={1}
|
||||
>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack bg={primaryColor} />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
<Text fontSize="xs" minW="40px">{styles.lineHeight.toFixed(1)}</Text>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
{/* Letter Spacing */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Letter Spacing (px)</FormLabel>
|
||||
<HStack spacing={2}>
|
||||
<Slider
|
||||
value={styles.letterSpacing}
|
||||
min={-5}
|
||||
max={10}
|
||||
step={0.1}
|
||||
onChange={(val) => updateStyle('letterSpacing', val)}
|
||||
flex={1}
|
||||
>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack bg={primaryColor} />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
<Text fontSize="xs" minW="40px">{styles.letterSpacing.toFixed(1)}</Text>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
{/* Text Transform */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Transform</FormLabel>
|
||||
<Select
|
||||
size="sm"
|
||||
value={styles.textTransform}
|
||||
onChange={(e) => updateStyle('textTransform', e.target.value)}
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="uppercase">UPPERCASE</option>
|
||||
<option value="lowercase">lowercase</option>
|
||||
<option value="capitalize">Capitalize</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
{/* Style Tab */}
|
||||
<TabPanel>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
|
||||
Colors
|
||||
</Text>
|
||||
|
||||
{/* Text Color */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Text Color</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
type="color"
|
||||
value={styles.color}
|
||||
onChange={(e) => updateStyle('color', e.target.value)}
|
||||
size="sm"
|
||||
w="60px"
|
||||
p={1}
|
||||
/>
|
||||
<Input
|
||||
value={styles.color}
|
||||
onChange={(e) => updateStyle('color', e.target.value)}
|
||||
size="sm"
|
||||
placeholder="#000000"
|
||||
/>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
{/* Background Color */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Background Color</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
type="color"
|
||||
value={styles.backgroundColor}
|
||||
onChange={(e) => updateStyle('backgroundColor', e.target.value)}
|
||||
size="sm"
|
||||
w="60px"
|
||||
p={1}
|
||||
/>
|
||||
<Input
|
||||
value={styles.backgroundColor}
|
||||
onChange={(e) => updateStyle('backgroundColor', e.target.value)}
|
||||
size="sm"
|
||||
placeholder="#ffffff"
|
||||
/>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<Divider my={2} />
|
||||
|
||||
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
|
||||
Spacing
|
||||
</Text>
|
||||
|
||||
{/* Padding */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Padding (px)</FormLabel>
|
||||
<VStack spacing={2}>
|
||||
<HStack width="100%">
|
||||
<Text fontSize="xs" minW="20px">T</Text>
|
||||
<NumberInput
|
||||
size="xs"
|
||||
value={styles.paddingTop}
|
||||
min={0}
|
||||
onChange={(_, val) => updateStyle('paddingTop', val)}
|
||||
flex={1}
|
||||
>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</HStack>
|
||||
<HStack width="100%">
|
||||
<Text fontSize="xs" minW="20px">R</Text>
|
||||
<NumberInput
|
||||
size="xs"
|
||||
value={styles.paddingRight}
|
||||
min={0}
|
||||
onChange={(_, val) => updateStyle('paddingRight', val)}
|
||||
flex={1}
|
||||
>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</HStack>
|
||||
<HStack width="100%">
|
||||
<Text fontSize="xs" minW="20px">B</Text>
|
||||
<NumberInput
|
||||
size="xs"
|
||||
value={styles.paddingBottom}
|
||||
min={0}
|
||||
onChange={(_, val) => updateStyle('paddingBottom', val)}
|
||||
flex={1}
|
||||
>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</HStack>
|
||||
<HStack width="100%">
|
||||
<Text fontSize="xs" minW="20px">L</Text>
|
||||
<NumberInput
|
||||
size="xs"
|
||||
value={styles.paddingLeft}
|
||||
min={0}
|
||||
onChange={(_, val) => updateStyle('paddingLeft', val)}
|
||||
flex={1}
|
||||
>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</FormControl>
|
||||
|
||||
{/* Margin */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Margin (px)</FormLabel>
|
||||
<VStack spacing={2}>
|
||||
<HStack width="100%">
|
||||
<Text fontSize="xs" minW="20px">T</Text>
|
||||
<NumberInput
|
||||
size="xs"
|
||||
value={styles.marginTop}
|
||||
onChange={(_, val) => updateStyle('marginTop', val)}
|
||||
flex={1}
|
||||
>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</HStack>
|
||||
<HStack width="100%">
|
||||
<Text fontSize="xs" minW="20px">R</Text>
|
||||
<NumberInput
|
||||
size="xs"
|
||||
value={styles.marginRight}
|
||||
onChange={(_, val) => updateStyle('marginRight', val)}
|
||||
flex={1}
|
||||
>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</HStack>
|
||||
<HStack width="100%">
|
||||
<Text fontSize="xs" minW="20px">B</Text>
|
||||
<NumberInput
|
||||
size="xs"
|
||||
value={styles.marginBottom}
|
||||
onChange={(_, val) => updateStyle('marginBottom', val)}
|
||||
flex={1}
|
||||
>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</HStack>
|
||||
<HStack width="100%">
|
||||
<Text fontSize="xs" minW="20px">L</Text>
|
||||
<NumberInput
|
||||
size="xs"
|
||||
value={styles.marginLeft}
|
||||
onChange={(_, val) => updateStyle('marginLeft', val)}
|
||||
flex={1}
|
||||
>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
{/* Grid Tab */}
|
||||
<TabPanel>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
|
||||
Grid Layout
|
||||
</Text>
|
||||
|
||||
{/* Enable Grid */}
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel fontSize="xs" mb={0} flex={1}>Enable Grid Layout</FormLabel>
|
||||
<Switch
|
||||
size="sm"
|
||||
isChecked={styles.display === 'grid'}
|
||||
onChange={(e) => updateStyle('display', e.target.checked ? 'grid' : 'block')}
|
||||
sx={{
|
||||
'span[data-checked]': {
|
||||
bg: primaryColor,
|
||||
borderColor: primaryColor,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{styles.display === 'grid' && (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
{/* Quick Templates */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs" fontWeight="bold">Quick Templates</FormLabel>
|
||||
<VStack spacing={2}>
|
||||
<Button
|
||||
size="xs"
|
||||
width="100%"
|
||||
variant="outline"
|
||||
onClick={() => updateStyle('gridTemplateColumns', '1fr')}
|
||||
justifyContent="flex-start"
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<FiSmartphone />
|
||||
<Text>Single Column</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
width="100%"
|
||||
variant="outline"
|
||||
onClick={() => updateStyle('gridTemplateColumns', '1fr 1fr')}
|
||||
justifyContent="flex-start"
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<FaColumns />
|
||||
<Text>Two Equal (50% / 50%)</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
width="100%"
|
||||
variant="outline"
|
||||
onClick={() => updateStyle('gridTemplateColumns', '2fr 1fr')}
|
||||
justifyContent="flex-start"
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<FiBarChart2 />
|
||||
<Text>Left Larger (66% / 33%)</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
width="100%"
|
||||
variant="outline"
|
||||
onClick={() => updateStyle('gridTemplateColumns', '1fr 2fr')}
|
||||
justifyContent="flex-start"
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<FiBarChart2 style={{ transform: 'scaleX(-1)' }} />
|
||||
<Text>Right Larger (33% / 66%)</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
width="100%"
|
||||
variant="outline"
|
||||
onClick={() => updateStyle('gridTemplateColumns', '1fr 1fr 1fr')}
|
||||
justifyContent="flex-start"
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<FiGrid />
|
||||
<Text>Three Equal (33% / 33% / 33%)</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
width="100%"
|
||||
variant="outline"
|
||||
onClick={() => updateStyle('gridTemplateColumns', '2fr 1fr 1fr')}
|
||||
justifyContent="flex-start"
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<FaRegNewspaper />
|
||||
<Text>Featured + Two (50% / 25% / 25%)</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
width="100%"
|
||||
variant="outline"
|
||||
onClick={() => updateStyle('gridTemplateColumns', 'repeat(4, 1fr)')}
|
||||
justifyContent="flex-start"
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<FaRegSquare />
|
||||
<Text>Four Equal (25% each)</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
width="100%"
|
||||
variant="outline"
|
||||
onClick={() => updateStyle('gridTemplateColumns', '3fr 1fr')}
|
||||
justifyContent="flex-start"
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<FiSidebar />
|
||||
<Text>Main + Sidebar (75% / 25%)</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
</VStack>
|
||||
</FormControl>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Custom Columns */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Grid Template Columns</FormLabel>
|
||||
<Input
|
||||
size="sm"
|
||||
value={styles.gridTemplateColumns}
|
||||
onChange={(e) => updateStyle('gridTemplateColumns', e.target.value)}
|
||||
placeholder="e.g. 1fr 2fr or 300px 1fr"
|
||||
fontFamily="monospace"
|
||||
fontSize="xs"
|
||||
/>
|
||||
<Text fontSize="10px" color="gray.500" mt={1}>
|
||||
Examples: 1fr 1fr | 2fr 1fr | 200px 1fr | repeat(3, 1fr)
|
||||
</Text>
|
||||
</FormControl>
|
||||
|
||||
{/* Grid Template Rows */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Grid Template Rows</FormLabel>
|
||||
<Input
|
||||
size="sm"
|
||||
value={styles.gridTemplateRows}
|
||||
onChange={(e) => updateStyle('gridTemplateRows', e.target.value)}
|
||||
placeholder="auto or 200px 1fr"
|
||||
fontFamily="monospace"
|
||||
fontSize="xs"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* Column Gap */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Column Gap (px)</FormLabel>
|
||||
<HStack spacing={2}>
|
||||
<Slider
|
||||
value={styles.gridColumnGap}
|
||||
min={0}
|
||||
max={100}
|
||||
step={4}
|
||||
onChange={(val) => updateStyle('gridColumnGap', val)}
|
||||
flex={1}
|
||||
>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack bg="purple.500" />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
<Text fontSize="xs" minW="40px">{styles.gridColumnGap}px</Text>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
{/* Row Gap */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Row Gap (px)</FormLabel>
|
||||
<HStack spacing={2}>
|
||||
<Slider
|
||||
value={styles.gridRowGap}
|
||||
min={0}
|
||||
max={100}
|
||||
step={4}
|
||||
onChange={(val) => updateStyle('gridRowGap', val)}
|
||||
flex={1}
|
||||
>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack bg="purple.500" />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
<Text fontSize="xs" minW="40px">{styles.gridRowGap}px</Text>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Grid Auto Flow */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Auto Flow</FormLabel>
|
||||
<Select
|
||||
size="sm"
|
||||
value={styles.gridAutoFlow}
|
||||
onChange={(e) => updateStyle('gridAutoFlow', e.target.value)}
|
||||
>
|
||||
<option value="row">Row (horizontal)</option>
|
||||
<option value="column">Column (vertical)</option>
|
||||
<option value="row dense">Row Dense</option>
|
||||
<option value="column dense">Column Dense</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Align Items */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Align Items (vertical)</FormLabel>
|
||||
<Select
|
||||
size="sm"
|
||||
value={styles.alignItems}
|
||||
onChange={(e) => updateStyle('alignItems', e.target.value)}
|
||||
>
|
||||
<option value="stretch">Stretch</option>
|
||||
<option value="start">Start</option>
|
||||
<option value="center">Center</option>
|
||||
<option value="end">End</option>
|
||||
<option value="baseline">Baseline</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Justify Items */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Justify Items (horizontal)</FormLabel>
|
||||
<Select
|
||||
size="sm"
|
||||
value={styles.justifyItems}
|
||||
onChange={(e) => updateStyle('justifyItems', e.target.value)}
|
||||
>
|
||||
<option value="stretch">Stretch</option>
|
||||
<option value="start">Start</option>
|
||||
<option value="center">Center</option>
|
||||
<option value="end">End</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
{/* Advanced Tab */}
|
||||
<TabPanel>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
|
||||
Layout
|
||||
</Text>
|
||||
|
||||
{/* Display */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Display</FormLabel>
|
||||
<Select
|
||||
size="sm"
|
||||
value={styles.display}
|
||||
onChange={(e) => updateStyle('display', e.target.value)}
|
||||
>
|
||||
<option value="block">Block</option>
|
||||
<option value="inline-block">Inline Block</option>
|
||||
<option value="flex">Flex</option>
|
||||
<option value="grid">Grid</option>
|
||||
<option value="none">None</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Width */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Width</FormLabel>
|
||||
<Input
|
||||
size="sm"
|
||||
value={styles.width}
|
||||
onChange={(e) => updateStyle('width', e.target.value)}
|
||||
placeholder="auto, 100%, 500px"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* Height */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Height</FormLabel>
|
||||
<Input
|
||||
size="sm"
|
||||
value={styles.height}
|
||||
onChange={(e) => updateStyle('height', e.target.value)}
|
||||
placeholder="auto, 100%, 500px"
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisualStylePanel;
|
||||
@@ -0,0 +1,271 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Box, IconButton, Flex, Heading, Link as ChakraLink } from '@chakra-ui/react';
|
||||
import { FiChevronLeft, FiChevronRight, FiArrowRight } from 'react-icons/fi';
|
||||
import '../../styles/sparta-styles.css';
|
||||
|
||||
interface Article {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
image: string;
|
||||
categories: string[];
|
||||
date: string;
|
||||
duration?: string;
|
||||
isVideo?: boolean;
|
||||
unlimited?: boolean;
|
||||
}
|
||||
|
||||
interface SpartaHorizontalSliderProps {
|
||||
title: string;
|
||||
titleLink?: string;
|
||||
articles: Article[];
|
||||
itemsPerView?: {
|
||||
mobile: number;
|
||||
tablet: number;
|
||||
desktop: number;
|
||||
};
|
||||
gap?: number;
|
||||
showControls?: boolean;
|
||||
enableDrag?: boolean;
|
||||
showUnlimitedBadge?: boolean;
|
||||
showCategories?: boolean;
|
||||
showDuration?: boolean;
|
||||
}
|
||||
|
||||
const SpartaHorizontalSlider: React.FC<SpartaHorizontalSliderProps> = ({
|
||||
title,
|
||||
titleLink = '#',
|
||||
articles,
|
||||
itemsPerView = { mobile: 1, tablet: 2, desktop: 3 },
|
||||
gap = 16,
|
||||
showControls = true,
|
||||
enableDrag = true,
|
||||
showUnlimitedBadge = true,
|
||||
showCategories = true,
|
||||
showDuration = true,
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [startX, setStartX] = useState(0);
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Calculate how many items are visible based on viewport
|
||||
const getItemsPerView = () => {
|
||||
if (typeof window === 'undefined') return itemsPerView.desktop;
|
||||
|
||||
const width = window.innerWidth;
|
||||
if (width < 768) return itemsPerView.mobile;
|
||||
if (width < 1024) return itemsPerView.tablet;
|
||||
return itemsPerView.desktop;
|
||||
};
|
||||
|
||||
const [visibleItems, setVisibleItems] = useState(getItemsPerView());
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setVisibleItems(getItemsPerView());
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
const maxIndex = Math.max(0, articles.length - visibleItems);
|
||||
const canGoNext = currentIndex < maxIndex;
|
||||
const canGoPrev = currentIndex > 0;
|
||||
|
||||
const handleNext = () => {
|
||||
if (canGoNext) {
|
||||
setCurrentIndex(prev => Math.min(prev + 1, maxIndex));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (canGoPrev) {
|
||||
setCurrentIndex(prev => Math.max(prev - 1, 0));
|
||||
}
|
||||
};
|
||||
|
||||
// Drag functionality
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (!enableDrag || !trackRef.current) return;
|
||||
setIsDragging(true);
|
||||
setStartX(e.pageX - trackRef.current.offsetLeft);
|
||||
setScrollLeft(trackRef.current.scrollLeft);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isDragging || !trackRef.current) return;
|
||||
e.preventDefault();
|
||||
const x = e.pageX - trackRef.current.offsetLeft;
|
||||
const walk = (x - startX) * 2;
|
||||
trackRef.current.scrollLeft = scrollLeft - walk;
|
||||
};
|
||||
|
||||
// Calculate transform based on current index
|
||||
const slideWidth = trackRef.current?.children[0]?.clientWidth || 0;
|
||||
const transformValue = -(currentIndex * (slideWidth + gap));
|
||||
|
||||
return (
|
||||
<Box className="sparta-slider-container sparta-container sparta-section">
|
||||
{/* Header with title and controls */}
|
||||
<Flex className="sparta-slider-header" justifyContent="space-between" alignItems="center" mb={4}>
|
||||
<Heading className="sparta-slider-title" as="h2">
|
||||
<ChakraLink href={titleLink} display="inline-flex" alignItems="center" gap={2}>
|
||||
{title}
|
||||
<Box as={FiArrowRight} />
|
||||
</ChakraLink>
|
||||
</Heading>
|
||||
|
||||
{showControls && (
|
||||
<Flex className="sparta-slider-controls" gap={2}>
|
||||
<IconButton
|
||||
aria-label="Previous"
|
||||
icon={<FiChevronLeft />}
|
||||
onClick={handlePrev}
|
||||
isDisabled={!canGoPrev}
|
||||
className="sparta-slider-button"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Next"
|
||||
icon={<FiChevronRight />}
|
||||
onClick={handleNext}
|
||||
isDisabled={!canGoNext}
|
||||
className="sparta-slider-button"
|
||||
variant="ghost"
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* Slider viewport */}
|
||||
<Box className="sparta-slider-viewport" overflow="hidden">
|
||||
<Flex
|
||||
ref={trackRef}
|
||||
className="sparta-slider-track"
|
||||
gap={`${gap}px`}
|
||||
transition="transform 0.3s cubic-bezier(0.4, 0, 0.6, 1)"
|
||||
transform={`translate3d(${transformValue}px, 0, 0)`}
|
||||
cursor={enableDrag ? (isDragging ? 'grabbing' : 'grab') : 'default'}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
{articles.map((article) => (
|
||||
<Box
|
||||
key={article.id}
|
||||
className="sparta-slider-slide"
|
||||
flexShrink={0}
|
||||
data-dragging={isDragging}
|
||||
>
|
||||
<ChakraLink
|
||||
href={`/articles/${article.slug}`}
|
||||
className="sparta-article-card"
|
||||
textDecoration="none"
|
||||
_hover={{ textDecoration: 'none' }}
|
||||
>
|
||||
{/* Article Image */}
|
||||
<Box className="sparta-article-image" position="relative">
|
||||
<img
|
||||
src={article.image}
|
||||
alt={article.title}
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* Meta info overlay */}
|
||||
{(showUnlimitedBadge && article.unlimited) && (
|
||||
<Box className="sparta-article-meta">
|
||||
<Box className="sparta-article-badge">
|
||||
UNLIMITED
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Video duration */}
|
||||
{showDuration && article.duration && (
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom="8px"
|
||||
right="8px"
|
||||
padding="4px 8px"
|
||||
background="rgba(0, 0, 0, 0.8)"
|
||||
borderRadius="4px"
|
||||
fontSize="0.75rem"
|
||||
fontWeight="500"
|
||||
>
|
||||
{article.duration}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Article Details */}
|
||||
<Box className="sparta-article-details">
|
||||
{showCategories && article.categories.length > 0 && (
|
||||
<Flex className="sparta-article-categories">
|
||||
{article.categories.map((cat, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
<span>{cat}</span>
|
||||
{idx < article.categories.length - 1 && (
|
||||
<Box className="sparta-hero-separator" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Heading className="sparta-article-title" as="h3" size="sm">
|
||||
{article.title}
|
||||
</Heading>
|
||||
|
||||
<Box className="sparta-article-date" mt="auto">
|
||||
{new Date(article.date).toLocaleDateString('cs-CZ', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</ChakraLink>
|
||||
</Box>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpartaHorizontalSlider;
|
||||
|
||||
// Example usage:
|
||||
/*
|
||||
<SpartaHorizontalSlider
|
||||
title="Videa"
|
||||
titleLink="/sparta-tv"
|
||||
articles={[
|
||||
{
|
||||
id: '1',
|
||||
title: 'HIGHLIGHTS: Sparta - Slavia',
|
||||
slug: 'highlights-sparta-slavia',
|
||||
image: 'https://example.com/image.jpg',
|
||||
categories: ['Match content', 'Highlights'],
|
||||
date: '2025-10-05',
|
||||
duration: '4:32',
|
||||
isVideo: true,
|
||||
unlimited: true,
|
||||
},
|
||||
// ... more articles
|
||||
]}
|
||||
itemsPerView={{ mobile: 1, tablet: 2, desktop: 3 }}
|
||||
showUnlimitedBadge={true}
|
||||
showCategories={true}
|
||||
showDuration={true}
|
||||
/>
|
||||
*/
|
||||
@@ -0,0 +1,191 @@
|
||||
import React from 'react';
|
||||
import { Alert, AlertIcon, Box, Link, Spinner, Text, VStack } from '@chakra-ui/react';
|
||||
import ContactMap from '../home/ContactMap';
|
||||
import { getPublicSettings } from '../../services/settings';
|
||||
|
||||
interface EventLocationMapProps {
|
||||
location: string;
|
||||
title?: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
}
|
||||
|
||||
type GeocodeResult = {
|
||||
lat: number;
|
||||
lon: number;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
const NOMINATIM_BASE_URL = process.env.REACT_APP_NOMINATIM_URL || 'https://nominatim.openstreetmap.org';
|
||||
const NOMINATIM_EMAIL = process.env.REACT_APP_NOMINATIM_EMAIL;
|
||||
|
||||
const geocodeCache = new Map<string, GeocodeResult>();
|
||||
|
||||
async function geocodeLocation(query: string, signal: AbortSignal): Promise<GeocodeResult> {
|
||||
const cacheKey = query.trim().toLowerCase();
|
||||
if (geocodeCache.has(cacheKey)) {
|
||||
return geocodeCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
format: 'jsonv2',
|
||||
limit: '1',
|
||||
q: query,
|
||||
'accept-language': 'cs',
|
||||
});
|
||||
|
||||
if (NOMINATIM_EMAIL) {
|
||||
params.append('email', NOMINATIM_EMAIL);
|
||||
}
|
||||
|
||||
const endpoint = `${NOMINATIM_BASE_URL}/search?${params.toString()}`;
|
||||
const response = await fetch(endpoint, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Nepodařilo se načíst mapová data.');
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
if (!Array.isArray(json) || json.length === 0) {
|
||||
throw new Error('Poloha nebyla nalezena.');
|
||||
}
|
||||
|
||||
const first = json[0];
|
||||
const lat = Number(first.lat);
|
||||
const lon = Number(first.lon);
|
||||
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
throw new Error('Neplatné souřadnice.');
|
||||
}
|
||||
|
||||
const result: GeocodeResult = {
|
||||
lat,
|
||||
lon,
|
||||
displayName: String(first.display_name || query),
|
||||
};
|
||||
geocodeCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const EventLocationMap: React.FC<EventLocationMapProps> = ({ location, title, latitude, longitude }) => {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [coords, setCoords] = React.useState<GeocodeResult | null>(null);
|
||||
const [settings, setSettings] = React.useState<any>(null);
|
||||
|
||||
// Load settings for club colors
|
||||
React.useEffect(() => {
|
||||
getPublicSettings()
|
||||
.then(setSettings)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const trimmed = (location || '').trim();
|
||||
if (!trimmed) {
|
||||
setCoords(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// If coordinates are provided, use them directly
|
||||
if (latitude != null && longitude != null && Number.isFinite(latitude) && Number.isFinite(longitude)) {
|
||||
setCoords({
|
||||
lat: latitude,
|
||||
lon: longitude,
|
||||
displayName: trimmed,
|
||||
});
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, geocode the location
|
||||
let active = true;
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
geocodeLocation(trimmed, controller.signal)
|
||||
.then((result) => {
|
||||
if (!active) return;
|
||||
setCoords(result);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
if (!active) return;
|
||||
setCoords(null);
|
||||
setError(err?.message || 'Mapu se nepodařilo načíst.');
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [location, latitude, longitude]);
|
||||
|
||||
if (!location?.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const openStreetMapUrl = `https://www.openstreetmap.org/search?query=${encodeURIComponent(location.trim())}`;
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={3} mt={4} data-testid="event-location-map">
|
||||
<Text fontWeight="semibold" fontSize="lg">Mapa místa</Text>
|
||||
|
||||
{loading && (
|
||||
<HStackWithSpinner />
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<Alert status="error" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<Box>
|
||||
<Text mb={1}>{error}</Text>
|
||||
<Link href={openStreetMapUrl} isExternal color="blue.400">
|
||||
Otevřít v OpenStreetMap
|
||||
</Link>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !error && coords && (
|
||||
<Box borderWidth="1px" borderRadius="lg" overflow="hidden" borderColor="border.subtle">
|
||||
<ContactMap
|
||||
latitude={coords.lat}
|
||||
longitude={coords.lon}
|
||||
zoom={15}
|
||||
address={coords.displayName}
|
||||
clubName={title}
|
||||
height={320}
|
||||
mapStyle={settings?.map_style || 'default'}
|
||||
clubPrimaryColor={settings?.primary_color}
|
||||
clubSecondaryColor={settings?.accent_color}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
Přesnost určena pomocí otevřených mapových dat.{' '}
|
||||
<Link href={openStreetMapUrl} isExternal color="blue.400">
|
||||
Zobrazit v OpenStreetMap
|
||||
</Link>
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
const HStackWithSpinner: React.FC = () => (
|
||||
<Box display="flex" alignItems="center" gap={2} color="gray.500">
|
||||
<Spinner size="sm" />
|
||||
<Text>Načítám mapu…</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default EventLocationMap;
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
VStack,
|
||||
Box,
|
||||
Text,
|
||||
Spinner,
|
||||
Icon,
|
||||
useToast,
|
||||
Image,
|
||||
Flex,
|
||||
Badge,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaSearch, FaFutbol, FaFutbol as FaFutsal } from 'react-icons/fa';
|
||||
import { useFacrApi } from '../../hooks/useFacrApi';
|
||||
|
||||
export const ClubSearch = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
const { searchClubs, searchResults, searchLoading, searchError } = useFacrApi();
|
||||
const toast = useToast();
|
||||
|
||||
// Debounce search input
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedQuery(searchQuery);
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
// Trigger search when debounced query changes
|
||||
useEffect(() => {
|
||||
if (debouncedQuery) {
|
||||
searchClubs(debouncedQuery).catch(() => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to search for clubs',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [debouncedQuery, searchClubs, toast]);
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box width="100%" maxW="800px" mx="auto" p={4}>
|
||||
<InputGroup size="lg" mb={6}>
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<Icon as={FaSearch} color="gray.400" />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search for a club..."
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
bg="white"
|
||||
borderColor="gray.200"
|
||||
_hover={{ borderColor: 'gray.300' }}
|
||||
_focus={{
|
||||
borderColor: 'blue.500',
|
||||
boxShadow: '0 0 0 1px #3182ce',
|
||||
}}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
{searchLoading && (
|
||||
<Flex justify="center" my={8}>
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{searchError && (
|
||||
<Text color="red.500" textAlign="center" my={4}>
|
||||
Error: {searchError.message}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!searchLoading && searchResults.length > 0 && (
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Text fontSize="lg" fontWeight="bold" mb={2}>
|
||||
Search Results:
|
||||
</Text>
|
||||
{searchResults.map((club) => (
|
||||
<Box
|
||||
key={`${club.club_id}-${club.name}`}
|
||||
p={4}
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
bg="white"
|
||||
_hover={{
|
||||
shadow: 'md',
|
||||
transform: 'translateY(-2px)',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
<Flex align="center">
|
||||
{club.logo_url ? (
|
||||
<Image
|
||||
src={club.logo_url}
|
||||
alt={`${club.name} logo`}
|
||||
boxSize="50px"
|
||||
objectFit="contain"
|
||||
mr={4}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
boxSize="50px"
|
||||
bg="gray.100"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
borderRadius="md"
|
||||
mr={4}
|
||||
>
|
||||
<Icon
|
||||
as={club.club_type === 'football' ? FaFutbol : FaFutsal}
|
||||
color="gray.400"
|
||||
boxSize={6}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box flex={1}>
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{club.name}
|
||||
</Text>
|
||||
<Flex mt={1} alignItems="center">
|
||||
<Badge
|
||||
colorScheme={club.club_type === 'football' ? 'blue' : 'green'}
|
||||
mr={2}
|
||||
>
|
||||
{club.club_type === 'football' ? 'Football' : 'Futsal'}
|
||||
</Badge>
|
||||
<Text color="gray.600" fontSize="sm">
|
||||
{club.category}
|
||||
</Text>
|
||||
</Flex>
|
||||
{club.address && (
|
||||
<Text color="gray.600" fontSize="sm" mt={1}>
|
||||
{club.address}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{!searchLoading && searchQuery && searchResults.length === 0 && (
|
||||
<Text textAlign="center" color="gray.500" my={8}>
|
||||
No clubs found matching "{searchQuery}"
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClubSearch;
|
||||
@@ -0,0 +1,184 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Image,
|
||||
Box,
|
||||
HStack,
|
||||
Button,
|
||||
Text,
|
||||
useToast,
|
||||
IconButton,
|
||||
VStack,
|
||||
} from '@chakra-ui/react';
|
||||
import { Download, ExternalLink } from 'lucide-react';
|
||||
|
||||
interface PhotoModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
photoUrl: string;
|
||||
pageUrl: string;
|
||||
albumTitle?: string;
|
||||
}
|
||||
|
||||
const PhotoModal: React.FC<PhotoModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
photoUrl,
|
||||
pageUrl,
|
||||
albumTitle,
|
||||
}) => {
|
||||
const toast = useToast();
|
||||
|
||||
const getProxyUrl = (url: string) => {
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080';
|
||||
return `${apiUrl}/api/v1/gallery/proxy-image?url=${encodeURIComponent(url)}`;
|
||||
};
|
||||
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const response = await fetch(getProxyUrl(photoUrl));
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch image');
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `fotka-${Date.now()}.jpg`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
toast({
|
||||
title: 'Stahování zahájeno',
|
||||
description: 'Fotka se stahuje',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to download image:', error);
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: 'Nepodařilo se stáhnout obrázek',
|
||||
status: 'error',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
|
||||
<ModalOverlay bg="blackAlpha.800" backdropFilter="blur(10px)" />
|
||||
<ModalContent bg="transparent" boxShadow="none" maxW="90vw">
|
||||
<ModalCloseButton
|
||||
color="white"
|
||||
bg="blackAlpha.600"
|
||||
_hover={{ bg: 'blackAlpha.800' }}
|
||||
size="lg"
|
||||
top={2}
|
||||
right={2}
|
||||
zIndex={2}
|
||||
/>
|
||||
<ModalBody p={0}>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* Image */}
|
||||
<Box
|
||||
position="relative"
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
maxH="80vh"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Image
|
||||
src={photoUrl}
|
||||
alt={albumTitle || 'Fotka'}
|
||||
maxH="80vh"
|
||||
maxW="100%"
|
||||
objectFit="contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Controls */}
|
||||
<Box
|
||||
bg="bg.elevated"
|
||||
borderWidth="1px"
|
||||
borderColor="border.subtle"
|
||||
borderRadius="lg"
|
||||
p={4}
|
||||
boxShadow="xl"
|
||||
>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{albumTitle && (
|
||||
<Text fontSize="md" fontWeight="600" color="gray.700">
|
||||
{albumTitle}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack spacing={2} justify="space-between" flexWrap="wrap">
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
leftIcon={<Download size={18} />}
|
||||
onClick={handleDownload}
|
||||
colorScheme="green"
|
||||
size="sm"
|
||||
>
|
||||
Stáhnout
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href={pageUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
leftIcon={<ExternalLink size={18} />}
|
||||
colorScheme="purple"
|
||||
size="sm"
|
||||
>
|
||||
Zobrazit originál
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* Zonerama Copyright */}
|
||||
<Box
|
||||
pt={2}
|
||||
borderTopWidth="1px"
|
||||
borderColor="gray.200"
|
||||
>
|
||||
<HStack spacing={2} fontSize="xs" color="gray.500">
|
||||
<Text>
|
||||
© Fotografie z{' '}
|
||||
<Text
|
||||
as="a"
|
||||
href="https://zonerama.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color="blue.500"
|
||||
fontWeight="600"
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
Zonerama
|
||||
</Text>
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhotoModal;
|
||||
@@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
import { Box, Image, Heading, Text, VStack, HStack, Badge, Skeleton, useColorModeValue, Button } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getArticles, Article } from '../../services/articles';
|
||||
import HorizontalScroller from '../ui/HorizontalScroller';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import { Eye, Clock } from 'lucide-react';
|
||||
|
||||
const Card: React.FC<{ a: Article }> = ({ a }) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const border = useColorModeValue('gray.200', 'whiteAlpha.300');
|
||||
const theme = useClubTheme();
|
||||
const link = a.slug ? `/news/${a.slug}` : `/articles/${a.id}`;
|
||||
const categoryBadgeColor = useColorModeValue('gray.100', 'whiteAlpha.200');
|
||||
const categoryName = (a as any)?.category?.name || '';
|
||||
|
||||
return (
|
||||
<Box
|
||||
as={RouterLink}
|
||||
to={link}
|
||||
minW={{ base: '85%', md: '60%', lg: '33%' }}
|
||||
scrollSnapAlign="start"
|
||||
bg={cardBg}
|
||||
borderRadius="xl"
|
||||
overflow="hidden"
|
||||
boxShadow="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={border}
|
||||
_hover={{ transform: 'translateY(-4px)', boxShadow: '2xl' }}
|
||||
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||
position="relative"
|
||||
>
|
||||
<Box position="relative" overflow="hidden">
|
||||
<Image
|
||||
src={assetUrl(a.image_url) || '/stadium-placeholder.jpg'}
|
||||
alt={a.title}
|
||||
w="100%"
|
||||
h={{ base: '200px', md: '240px' }}
|
||||
objectFit="cover"
|
||||
transition="transform 0.3s ease"
|
||||
_groupHover={{ transform: 'scale(1.05)' }}
|
||||
/>
|
||||
{categoryName && (
|
||||
<Badge
|
||||
position="absolute"
|
||||
top={3}
|
||||
left={3}
|
||||
colorScheme="blue"
|
||||
fontSize="xs"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
textTransform="uppercase"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{categoryName}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
<VStack align="stretch" spacing={3} p={5}>
|
||||
<Heading size="md" noOfLines={2} lineHeight="1.3">{a.title}</Heading>
|
||||
{a.content && (
|
||||
<Text fontSize="sm" color="gray.600" noOfLines={3} lineHeight="1.5">
|
||||
{a.content.replace(/<[^>]*>/g, '').trim()}
|
||||
</Text>
|
||||
)}
|
||||
<HStack spacing={3} pt={2} borderTopWidth="1px" borderColor={border} flexWrap="wrap">
|
||||
{(a.read_time || a.estimated_read_minutes) && (
|
||||
<HStack spacing={1}>
|
||||
<Clock size={14} color="gray" />
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{a.read_time || a.estimated_read_minutes} min
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
{a.view_count !== undefined && a.view_count > 0 && (
|
||||
<HStack spacing={1}>
|
||||
<Eye size={14} color="gray" />
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{a.view_count}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
{a.published_at && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{new Date(a.published_at).toLocaleDateString('cs-CZ')}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const BlogCardsScroller: React.FC = () => {
|
||||
const theme = useClubTheme();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['articles', { page: 1, page_size: 12, published: true }],
|
||||
queryFn: () => getArticles({ page: 1, page_size: 12, published: true }),
|
||||
});
|
||||
|
||||
const list: Article[] = data?.data || [];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<HorizontalScroller
|
||||
title="Novinky"
|
||||
rightAction={<Button as={RouterLink} to="/blog" variant="link" color="brand.primary">Více</Button>}
|
||||
>
|
||||
{isLoading && Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} minW={{ base: '85%', md: '60%', lg: '33%' }} h={{ base: '260px', md: '300px' }} borderRadius="xl" />
|
||||
))}
|
||||
{!isLoading && list.map((a) => (
|
||||
<Card key={a.id} a={a} />
|
||||
))}
|
||||
</HorizontalScroller>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogCardsScroller;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Box, Heading, SimpleGrid, Image, Text, VStack, HStack, Button, Skeleton, Badge, useColorModeValue } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getArticles, Article } from '../../services/articles';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
const BlogCard: React.FC<{ article: Article }> = ({ article }) => {
|
||||
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const border = useColorModeValue('gray.200', 'whiteAlpha.300');
|
||||
const categoryName = (article as any)?.category?.name || '';
|
||||
|
||||
return (
|
||||
<VStack
|
||||
as={RouterLink}
|
||||
to={link}
|
||||
align="stretch"
|
||||
spacing={0}
|
||||
borderWidth="1px"
|
||||
borderRadius="xl"
|
||||
bg={cardBg}
|
||||
overflow="hidden"
|
||||
boxShadow="lg"
|
||||
borderColor={border}
|
||||
_hover={{ boxShadow: '2xl', transform: 'translateY(-4px)' }}
|
||||
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||
>
|
||||
<Box position="relative" overflow="hidden">
|
||||
<Image
|
||||
src={assetUrl(article.image_url) || '/logo192.png'}
|
||||
alt={article.title}
|
||||
objectFit="cover"
|
||||
w="100%"
|
||||
h="200px"
|
||||
transition="transform 0.3s ease"
|
||||
_groupHover={{ transform: 'scale(1.05)' }}
|
||||
/>
|
||||
{categoryName && (
|
||||
<Badge
|
||||
position="absolute"
|
||||
top={3}
|
||||
left={3}
|
||||
colorScheme="blue"
|
||||
fontSize="xs"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
textTransform="uppercase"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{categoryName}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
<VStack align="stretch" spacing={3} p={5}>
|
||||
<Heading size="md" noOfLines={2} lineHeight="1.3">{article.title}</Heading>
|
||||
<Text noOfLines={3} color="gray.600" fontSize="sm" lineHeight="1.5">
|
||||
{article.content?.replace(/<[^>]*>/g, '').slice(0, 160)}
|
||||
</Text>
|
||||
<HStack spacing={2} pt={2} borderTopWidth="1px" borderColor={border}>
|
||||
{article.estimated_read_minutes && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{article.estimated_read_minutes} min čtení
|
||||
</Text>
|
||||
)}
|
||||
{article.published_at && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
• {new Date(article.published_at).toLocaleDateString('cs-CZ')}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
const BlogGrid: React.FC = () => {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['articles', { page: 1, page_size: 10, published: true }],
|
||||
queryFn: () => getArticles({ page: 1, page_size: 10, published: true }),
|
||||
});
|
||||
|
||||
const articles = data?.data || [];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<Heading size="lg">Aktuality</Heading>
|
||||
<Button as={RouterLink} to="/blog" size="sm" variant="link">Zobrazit všechny</Button>
|
||||
</HStack>
|
||||
<SimpleGrid columns={{ base: 1, sm: 2, md: 3 }} spacing={6}>
|
||||
{isLoading && Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} height="240px" />
|
||||
))}
|
||||
{!isLoading && articles.map((a) => (
|
||||
<BlogCard key={a.id} article={a} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogGrid;
|
||||
@@ -0,0 +1,302 @@
|
||||
import React, { useRef, useState, useCallback } from 'react';
|
||||
import { Box, Image, Heading, Text, VStack, HStack, Skeleton, Button, IconButton, Flex, useBreakpointValue, Container } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getArticles, getFeaturedArticles, Article } from '../../services/articles';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { wrap } from 'popmotion';
|
||||
|
||||
const MotionBox = motion(Box);
|
||||
const MotionImage = motion(Image);
|
||||
|
||||
const variants = {
|
||||
enter: (direction: number) => ({
|
||||
x: direction > 0 ? 1000 : -1000,
|
||||
opacity: 0
|
||||
}),
|
||||
center: {
|
||||
zIndex: 1,
|
||||
x: 0,
|
||||
opacity: 1
|
||||
},
|
||||
exit: (direction: number) => ({
|
||||
zIndex: 0,
|
||||
x: direction < 0 ? 1000 : -1000,
|
||||
opacity: 0
|
||||
})
|
||||
};
|
||||
|
||||
const swipeConfidenceThreshold = 10000;
|
||||
const swipePower = (offset: number, velocity: number) => {
|
||||
return Math.abs(offset) * velocity;
|
||||
};
|
||||
|
||||
const HeroSlide: React.FC<{ article: Article }> = ({ article }) => {
|
||||
const theme = useClubTheme();
|
||||
const excerpt = (article.content || '').replace(/<[^>]*>/g, '').slice(0, 200) + '...';
|
||||
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="relative"
|
||||
w="100%"
|
||||
h={{ base: '500px', md: '600px' }}
|
||||
overflow="hidden"
|
||||
borderRadius={{ base: 'none', md: 'xl' }}
|
||||
boxShadow="lg"
|
||||
>
|
||||
<MotionImage
|
||||
src={assetUrl(article.image_url) || '/stadium-placeholder.jpg'}
|
||||
alt={article.title}
|
||||
w="100%"
|
||||
h="100%"
|
||||
objectFit="cover"
|
||||
initial={{ opacity: 0.7 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
/>
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p={{ base: 6, md: 10 }}
|
||||
bgGradient="linear(to-t, blackAlpha.900, blackAlpha.700, transparent)"
|
||||
color="white"
|
||||
>
|
||||
<Container maxW="7xl" px={{ base: 4, md: 6 }}>
|
||||
{/* Top-left BLOG link badge */}
|
||||
<HStack spacing={3} mb={4}>
|
||||
<Button
|
||||
as={RouterLink}
|
||||
to="/blog"
|
||||
size="sm"
|
||||
px={3}
|
||||
height="28px"
|
||||
borderRadius="full"
|
||||
bg={theme.primary}
|
||||
color="white"
|
||||
_hover={{ bg: theme.accent }}
|
||||
>
|
||||
BLOG
|
||||
</Button>
|
||||
<Text fontSize={{ base: 'xs', md: 'sm' }} opacity={0.85}>•</Text>
|
||||
<Text fontSize={{ base: 'xs', md: 'sm' }} opacity={0.85}>Klubové aktuality</Text>
|
||||
</HStack>
|
||||
|
||||
<Box maxW={{ base: '100%', md: '70%', lg: '55%' }}>
|
||||
<Text
|
||||
fontSize={{ base: 'sm', md: 'md' }}
|
||||
fontWeight="bold"
|
||||
color={theme.accent}
|
||||
textTransform="uppercase"
|
||||
letterSpacing="0.1em"
|
||||
mb={2}
|
||||
>
|
||||
Nejnovější aktualita
|
||||
</Text>
|
||||
<Heading
|
||||
as="h2"
|
||||
size={{ base: 'xl', md: '2xl', lg: '3xl' }}
|
||||
mb={4}
|
||||
lineHeight="1.2"
|
||||
textShadow="0 2px 4px rgba(0,0,0,0.5)"
|
||||
>
|
||||
{article.title}
|
||||
</Heading>
|
||||
<Text
|
||||
fontSize={{ base: 'sm', md: 'md' }}
|
||||
noOfLines={3}
|
||||
mb={6}
|
||||
textShadow="0 1px 2px rgba(0,0,0,0.5)"
|
||||
>
|
||||
{excerpt}
|
||||
</Text>
|
||||
<HStack spacing={4}>
|
||||
<Button
|
||||
as={RouterLink}
|
||||
to={link}
|
||||
size="lg"
|
||||
bg={theme.primary}
|
||||
color="white"
|
||||
rightIcon={<ChevronRightIcon />}
|
||||
_hover={{
|
||||
bg: theme.accent,
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 'lg',
|
||||
}}
|
||||
>
|
||||
Číst více
|
||||
</Button>
|
||||
<Button
|
||||
as={RouterLink}
|
||||
to="/blog"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
borderColor="whiteAlpha.700"
|
||||
color="white"
|
||||
_hover={{ bg: 'whiteAlpha.200' }}
|
||||
>
|
||||
Všechny články
|
||||
</Button>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const BlogSwiper: React.FC = () => {
|
||||
const [page, setPage] = useState(0);
|
||||
const [[slideIndex, direction], setSlideIndex] = useState([0, 0]);
|
||||
const { data: featuredData, isLoading: loadingFeatured } = useQuery({
|
||||
queryKey: ['featured-articles', { page: 1, page_size: 5 }],
|
||||
queryFn: () => getFeaturedArticles({ page: 1, page_size: 5 }),
|
||||
});
|
||||
// Fallback to latest published if no featured are available
|
||||
const { data: latestData } = useQuery({
|
||||
queryKey: ['latest-articles', { page: 1, page_size: 5, published: true }],
|
||||
queryFn: () => getArticles({ page: 1, page_size: 5, published: true }),
|
||||
enabled: Boolean(!loadingFeatured && !(featuredData?.data?.length)),
|
||||
});
|
||||
|
||||
const articles = (featuredData?.data?.length ? featuredData.data : (latestData?.data || []));
|
||||
const articleIndex = wrap(0, articles.length, slideIndex);
|
||||
const paginate = useCallback(
|
||||
(newDirection: number) => {
|
||||
setSlideIndex([slideIndex + newDirection, newDirection]);
|
||||
},
|
||||
[slideIndex]
|
||||
);
|
||||
|
||||
// Auto-advance slides
|
||||
React.useEffect(() => {
|
||||
if (articles.length <= 1) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
paginate(1);
|
||||
}, 8000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [articles.length, paginate]);
|
||||
|
||||
if (loadingFeatured) {
|
||||
return (
|
||||
<Skeleton
|
||||
w="100%"
|
||||
h={{ base: '500px', md: '600px' }}
|
||||
borderRadius={{ base: 'none', md: 'xl' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!articles.length) return null;
|
||||
|
||||
const currentArticle = articles[articleIndex];
|
||||
if (!currentArticle) return null;
|
||||
|
||||
return (
|
||||
<Box position="relative" w="100%" overflow="hidden">
|
||||
<AnimatePresence initial={false} custom={direction}>
|
||||
<MotionBox
|
||||
key={slideIndex}
|
||||
custom={direction}
|
||||
variants={variants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{
|
||||
x: { type: 'spring', stiffness: 300, damping: 30 },
|
||||
opacity: { duration: 0.2 }
|
||||
}}
|
||||
drag="x"
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={1}
|
||||
onDragEnd={(e, { offset, velocity }) => {
|
||||
const swipe = swipePower(offset.x, velocity.x);
|
||||
if (swipe < -swipeConfidenceThreshold) {
|
||||
paginate(1);
|
||||
} else if (swipe > swipeConfidenceThreshold) {
|
||||
paginate(-1);
|
||||
}
|
||||
}}
|
||||
position="relative"
|
||||
w="100%"
|
||||
h="100%"
|
||||
>
|
||||
<HeroSlide article={currentArticle} />
|
||||
</MotionBox>
|
||||
</AnimatePresence>
|
||||
|
||||
{articles.length > 1 && (
|
||||
<>
|
||||
<IconButton
|
||||
aria-label="Předchozí slide"
|
||||
icon={<ChevronLeftIcon />}
|
||||
position="absolute"
|
||||
left={4}
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
zIndex={2}
|
||||
borderRadius="full"
|
||||
colorScheme="blackAlpha"
|
||||
onClick={() => paginate(-1)}
|
||||
size="lg"
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Další slide"
|
||||
icon={<ChevronRightIcon />}
|
||||
position="absolute"
|
||||
right={4}
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
zIndex={2}
|
||||
borderRadius="full"
|
||||
colorScheme="blackAlpha"
|
||||
onClick={() => paginate(1)}
|
||||
size="lg"
|
||||
/>
|
||||
<Flex
|
||||
position="absolute"
|
||||
bottom={8}
|
||||
left="50%"
|
||||
transform="translateX(-50%)"
|
||||
zIndex={2}
|
||||
gap={2}
|
||||
>
|
||||
{articles.map((_, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
as="button"
|
||||
px={2}
|
||||
h="20px"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
fontSize="xs"
|
||||
fontWeight="700"
|
||||
color={index === articleIndex ? 'black' : 'white'}
|
||||
bg={index === articleIndex ? 'white' : 'whiteAlpha.500'}
|
||||
borderRadius="sm"
|
||||
onClick={() => setSlideIndex([index, index > articleIndex ? 1 : -1])}
|
||||
transition="all 0.3s"
|
||||
_hover={{
|
||||
bg: 'white',
|
||||
color: 'black',
|
||||
}}
|
||||
>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</Box>
|
||||
))}
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogSwiper;
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { Box, HStack, Image, Skeleton, useBreakpointValue, Tooltip } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getArticles, Article } from '../../services/articles';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
const modulo = (n: number, m: number) => ((n % m) + m) % m;
|
||||
|
||||
const BlogThumbStrip: React.FC = () => {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['thumb-articles', { page: 1, page_size: 12, published: true }],
|
||||
queryFn: () => getArticles({ page: 1, page_size: 12, published: true }),
|
||||
});
|
||||
|
||||
const articles = data?.data?.filter(a => !!a.image_url) || [];
|
||||
const visible = useBreakpointValue({ base: 2, md: 3, lg: 5 }) || 5;
|
||||
|
||||
const [index, setIndex] = React.useState(0);
|
||||
const [paused, setPaused] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (articles.length <= visible) return;
|
||||
const id = setInterval(() => {
|
||||
if (!paused) setIndex((i) => i + 1);
|
||||
}, 3000);
|
||||
return () => clearInterval(id);
|
||||
}, [articles.length, visible, paused]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<HStack spacing={3}>
|
||||
{Array.from({ length: visible }).map((_, i) => (
|
||||
<Skeleton key={i} w={{ base: '50%', md: `${100/visible}%` }} h={{ base: '90px', md: '120px', lg: '140px' }} borderRadius="md" />
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!articles.length) return null;
|
||||
|
||||
const items = Array.from({ length: visible }).map((_, i) => {
|
||||
const idx = modulo(index + i, articles.length);
|
||||
return articles[idx];
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
>
|
||||
<HStack spacing={3}>
|
||||
{items.map((a: Article) => (
|
||||
<Box
|
||||
key={a.id}
|
||||
as={RouterLink}
|
||||
to={a.slug ? `/news/${a.slug}` : `/articles/${a.id}`}
|
||||
flex={`0 0 ${100/visible}%`}
|
||||
position="relative"
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
_hover={{ transform: 'translateY(-2px)', boxShadow: 'lg' }}
|
||||
transition="all 0.25s ease"
|
||||
>
|
||||
<Image
|
||||
src={assetUrl(a.image_url) || '/stadium-placeholder.jpg'}
|
||||
alt={a.title}
|
||||
w="100%"
|
||||
h={{ base: '90px', md: '120px', lg: '140px' }}
|
||||
objectFit="cover"
|
||||
/>
|
||||
<Box position="absolute" bottom={0} left={0} right={0} h="36px" bgGradient="linear(to-t, blackAlpha.700, transparent)" />
|
||||
<Tooltip label={a.title} openDelay={300}>
|
||||
<Box position="absolute" bottom={1} left={2} right={2} color="white" fontSize="xs" noOfLines={1}>
|
||||
{a.title}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogThumbStrip;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Box, Flex, Heading, Image, HStack, Button, Text } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { facrApi } from '../../services/facr/facrApi';
|
||||
import { FACR_CLUB_ID, FACR_CLUB_TYPE } from '../../config/facr';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
|
||||
const ClubHeader: React.FC = () => {
|
||||
const { data: settings } = usePublicSettings();
|
||||
const clubId = settings?.club_id || FACR_CLUB_ID;
|
||||
const clubType = settings?.club_type || FACR_CLUB_TYPE;
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['facr-club', clubId, clubType],
|
||||
queryFn: () => facrApi.getClub(clubId, clubType),
|
||||
enabled: Boolean(clubId),
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex align="center" justify="space-between" bg="white" borderWidth="1px" borderRadius="lg" p={4}>
|
||||
<HStack spacing={4}>
|
||||
<Image src={data?.logo_url || '/logo192.png'} alt={data?.name || 'Club'} boxSize="64px" objectFit="contain" />
|
||||
<Box>
|
||||
<Heading size="lg">{data?.name || 'Club Name'}</Heading>
|
||||
<Text color="gray.600" fontSize="sm">
|
||||
{data?.address || (!clubId ? 'Nastavte klub v Nastavení (Admin) nebo REACT_APP_FACR_CLUB_ID' : '')}
|
||||
</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Button as="a" href="https://facebook.com" target="_blank" size="sm" variant="ghost">FB</Button>
|
||||
<Button as="a" href="https://instagram.com" target="_blank" size="sm" variant="ghost">IG</Button>
|
||||
<Button as="a" href="https://youtube.com" target="_blank" size="sm" variant="ghost">YT</Button>
|
||||
{data?.url && (
|
||||
<Button as="a" href={data.url} target="_blank" size="sm" colorScheme="blue">FAČR profil</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClubHeader;
|
||||
@@ -0,0 +1,211 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Button,
|
||||
Box,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Badge,
|
||||
Flex,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { TeamLogo } from '../common/TeamLogo';
|
||||
|
||||
interface ClubModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
club: {
|
||||
team: string;
|
||||
team_id?: string;
|
||||
team_logo_url?: string;
|
||||
rank?: string | number;
|
||||
played?: string | number;
|
||||
wins?: string | number;
|
||||
draws?: string | number;
|
||||
losses?: string | number;
|
||||
score?: string;
|
||||
points?: string | number;
|
||||
// Additional fields from FACR
|
||||
goals_scored?: string | number;
|
||||
goals_conceded?: string | number;
|
||||
goal_difference?: string | number;
|
||||
form?: string; // Last 5 matches form (e.g., "WWDWL")
|
||||
position_change?: number; // +/- change in position
|
||||
} | null;
|
||||
clubType?: 'football' | 'futsal';
|
||||
}
|
||||
|
||||
const ClubModal: React.FC<ClubModalProps> = ({ isOpen, onClose, club, clubType = 'football' }) => {
|
||||
if (!club) return null;
|
||||
|
||||
// Theme-aware colors
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'whiteAlpha.300');
|
||||
const fallbackBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const fallbackText = useColorModeValue('gray.600', 'gray.300');
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="lg" isCentered>
|
||||
<ModalOverlay bg="blackAlpha.600" backdropFilter="blur(4px)" />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<Flex align="center" gap={3}>
|
||||
<TeamLogo
|
||||
teamId={club.team_id}
|
||||
teamName={club.team}
|
||||
facrLogo={club.team_logo_url}
|
||||
size="large"
|
||||
alt={club.team}
|
||||
borderRadius="full"
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
fallbackIcon={
|
||||
<Box
|
||||
w="48px"
|
||||
h="48px"
|
||||
bg={fallbackBg}
|
||||
borderRadius="full"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
color={fallbackText}
|
||||
fontSize="lg"
|
||||
fontWeight="bold"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
{club.team.substring(0, 2).toUpperCase()}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold">
|
||||
{club.team}
|
||||
</Text>
|
||||
{club.rank && (
|
||||
<Badge colorScheme="blue" fontSize="sm">
|
||||
{club.rank}. místo
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* Statistics */}
|
||||
<Box
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
p={4}
|
||||
bg={useColorModeValue('gray.50', 'gray.700')}
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Text fontSize="md" fontWeight="semibold" mb={3} color={useColorModeValue('gray.700', 'gray.200')}>
|
||||
Statistiky
|
||||
</Text>
|
||||
<VStack spacing={2} align="stretch">
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Odehráno zápasů:</Text>
|
||||
<Text fontWeight="bold" color={useColorModeValue('gray.800', 'gray.100')}>{club.played || 0}</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Výhry:</Text>
|
||||
<Text fontWeight="bold" color="green.600">
|
||||
{club.wins || 0}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Remízy:</Text>
|
||||
<Text fontWeight="bold" color={useColorModeValue('gray.600', 'gray.400')}>
|
||||
{club.draws || 0}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Prohry:</Text>
|
||||
<Text fontWeight="bold" color="red.600">
|
||||
{club.losses || 0}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Skóre:</Text>
|
||||
<Text fontWeight="bold" color={useColorModeValue('gray.800', 'gray.100')}>{club.score || '0:0'}</Text>
|
||||
</HStack>
|
||||
{(club.goals_scored !== undefined || club.goals_conceded !== undefined) && (
|
||||
<>
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Vstřelené góly:</Text>
|
||||
<Text fontWeight="bold" color="green.500">{club.goals_scored || 0}</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Obdržené góly:</Text>
|
||||
<Text fontWeight="bold" color="red.500">{club.goals_conceded || 0}</Text>
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
{club.goal_difference !== undefined && (
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Skóre rozdíl:</Text>
|
||||
<Text fontWeight="bold" color={Number(club.goal_difference) >= 0 ? 'green.600' : 'red.600'}>
|
||||
{Number(club.goal_difference) > 0 ? '+' : ''}{club.goal_difference}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
<HStack justify="space-between" pt={2} borderTopWidth="1px" borderColor={borderColor}>
|
||||
<Text color={useColorModeValue('gray.700', 'gray.200')} fontWeight="semibold">Body:</Text>
|
||||
<Badge colorScheme="blue" fontSize="lg" px={3} py={1}>
|
||||
{club.points || 0}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* Form (Last 5 matches) */}
|
||||
{club.form && (
|
||||
<Box
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
p={4}
|
||||
bg={useColorModeValue('gray.50', 'gray.700')}
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Text fontSize="md" fontWeight="semibold" mb={3} color={useColorModeValue('gray.700', 'gray.200')}>
|
||||
Forma (posledních 5 zápasů)
|
||||
</Text>
|
||||
<HStack spacing={2} justify="center">
|
||||
{club.form.split('').map((result, idx) => (
|
||||
<Badge
|
||||
key={idx}
|
||||
colorScheme={result === 'W' ? 'green' : result === 'D' ? 'yellow' : 'red'}
|
||||
fontSize="md"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
>
|
||||
{result === 'W' ? 'V' : result === 'D' ? 'R' : 'P'}
|
||||
</Badge>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button colorScheme="gray" onClick={onClose}>
|
||||
Zavřít
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClubModal;
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Box, Tabs, TabList, TabPanels, Tab, TabPanel, VStack, HStack, Image, Text, Skeleton, Badge } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
import { facrApi } from '../../services/facr/facrApi';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { getCompetitionAliasesPublic, CompetitionAlias } from '../../services/competitionAliases';
|
||||
import { TeamLogo } from '../common/TeamLogo';
|
||||
import { sortCategoriesWithOrder } from '../../utils/categorySort';
|
||||
import '../../styles/logos.css';
|
||||
|
||||
const Row: React.FC<{ d: string; h: string; hid?: string; hl?: string; a: string; aid?: string; al?: string; s?: string; clubName?: string }> = ({ d, h, hid, hl, a, aid, al, s, clubName }) => (
|
||||
<HStack justify="space-between" borderRadius="lg" p={3} bg="white" boxShadow="sm">
|
||||
<Text w="140px" fontSize="sm" color="gray.600">{d}</Text>
|
||||
<HStack flex={1} justify="flex-end" spacing={4}>
|
||||
<HStack minW="40%" justify="flex-end" spacing={2}>
|
||||
<Text noOfLines={1} textAlign="right" flex={1}>{h}</Text>
|
||||
<Box className="logo-container" w="28px" h="28px">
|
||||
<TeamLogo
|
||||
teamId={hid}
|
||||
teamName={h}
|
||||
facrLogo={hl}
|
||||
size="custom"
|
||||
boxSize="28px"
|
||||
/>
|
||||
</Box>
|
||||
</HStack>
|
||||
<HStack minW="60px" justify="center" spacing={2}>
|
||||
<Text fontWeight="bold" textAlign="center">{s || '-:-'}</Text>
|
||||
{(() => {
|
||||
if (!s || !clubName) return null;
|
||||
const m = s.match(/^(\d+)\s*[:\-]\s*(\d+)$/);
|
||||
if (!m) return null;
|
||||
const hG = parseInt(m[1], 10), aG = parseInt(m[2], 10);
|
||||
const norm = (x: string) => String(x||'').normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/\s+/g,' ').trim().toLowerCase();
|
||||
const strip = (x: string) => norm(x).replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g,'').replace(/\s+/g,' ').trim();
|
||||
const ourHome = (() => { const A = strip(h); const B = strip(clubName); return A && B && (A===B || A.endsWith(B) || B.endsWith(A)); })();
|
||||
const ourAway = (() => { const A = strip(a); const B = strip(clubName); return A && B && (A===B || A.endsWith(B) || B.endsWith(A)); })();
|
||||
if (!ourHome && !ourAway) return null;
|
||||
if (hG === aG) return <Badge colorScheme="blue" variant="subtle">Remíza</Badge>;
|
||||
const our = ourHome ? hG : aG; const opp = ourHome ? aG : hG;
|
||||
return our > opp ? <Badge colorScheme="green" variant="subtle">Výhra</Badge> : <Badge colorScheme="red" variant="subtle">Prohra</Badge>;
|
||||
})()}
|
||||
</HStack>
|
||||
<HStack minW="40%" spacing={2}>
|
||||
<Box className="logo-container" w="28px" h="28px">
|
||||
<TeamLogo
|
||||
teamId={aid}
|
||||
teamName={a}
|
||||
facrLogo={al}
|
||||
size="custom"
|
||||
boxSize="28px"
|
||||
/>
|
||||
</Box>
|
||||
<Text noOfLines={1} flex={1}>{a}</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
|
||||
const CompetitionMatches: React.FC = () => {
|
||||
const { data: settings } = usePublicSettings();
|
||||
const clubId = settings?.club_id;
|
||||
const clubType = settings?.club_type || 'football';
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['facr-club', clubId, clubType],
|
||||
queryFn: () => facrApi.getClub(clubId!, clubType as any),
|
||||
enabled: !!clubId,
|
||||
});
|
||||
// Load competition aliases
|
||||
const [aliases, setAliases] = React.useState<Record<string, { alias: string; original_name?: string; display_order?: number }>>({});
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const list: CompetitionAlias[] = await getCompetitionAliasesPublic();
|
||||
if (!mounted) return;
|
||||
const map: Record<string, { alias: string; original_name?: string; display_order?: number }> = {};
|
||||
(list || []).forEach((a) => { if (a?.code && a?.alias) map[a.code] = { alias: a.alias, original_name: a.original_name, display_order: a.display_order }; });
|
||||
setAliases(map);
|
||||
} catch {}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
// Precompute sorted competitions safely (must be before any early returns to keep hooks order stable)
|
||||
const competitions = data?.competitions ?? [];
|
||||
const sortedCompetitions = React.useMemo(() => {
|
||||
const arr = Array.isArray(competitions) ? competitions : [];
|
||||
return sortCategoriesWithOrder(
|
||||
arr.map(c => ({
|
||||
...c,
|
||||
name: aliases[c.code]?.alias || aliases[c.id]?.alias || c.name,
|
||||
alias: aliases[c.code]?.alias || aliases[c.id]?.alias,
|
||||
display_order: (aliases[c.code]?.display_order) ?? (aliases[c.id]?.display_order),
|
||||
}))
|
||||
);
|
||||
}, [competitions, aliases]);
|
||||
|
||||
if (isLoading) return <Skeleton height="200px" />;
|
||||
|
||||
if (!clubId) {
|
||||
return (
|
||||
<Box p={4} bg="yellow.50" borderRadius="md" borderWidth="1px" borderColor="yellow.200">
|
||||
<Text color="gray.700">
|
||||
Pro zobrazení zápasů je potřeba nastavit klub v administraci (Nastavení → Základní údaje).
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || !data.competitions || data.competitions.length === 0) {
|
||||
return (
|
||||
<Box p={4} bg="gray.50" borderRadius="md" borderWidth="1px" borderColor="gray.200">
|
||||
<Text color="gray.600">
|
||||
Žádné soutěže ani zápasy nejsou k dispozici pro vybraný klub.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Sort competitions by age (Muži first, then U19, U17, etc.) and respect custom order (computed above)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Tabs variant="soft-rounded" colorScheme="blue" isFitted>
|
||||
<TabList>
|
||||
{sortedCompetitions.map((c) => {
|
||||
const label = c.alias || c.name;
|
||||
return <Tab key={c.id}>{label}</Tab>;
|
||||
})}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{sortedCompetitions.map((c) => (
|
||||
<TabPanel key={c.id} px={0}>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{(c.matches || []).slice(0, 6).map((m, idx) => (
|
||||
<Row key={m.match_id || idx} d={m.date_time} h={m.home} hid={m.home_id} hl={m.home_logo_url} a={m.away} aid={m.away_id} al={m.away_logo_url} s={m.score} clubName={data.name} />
|
||||
))}
|
||||
{(c.matches || []).length === 0 && (
|
||||
<Text color="gray.500">Žádné zápasy k dispozici.</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompetitionMatches;
|
||||
@@ -0,0 +1,328 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
// Dynamically load Leaflet
|
||||
let L: any = null;
|
||||
|
||||
interface ContactMapProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zoom?: number;
|
||||
address?: string;
|
||||
clubName?: string;
|
||||
mapStyle?: string;
|
||||
height?: number;
|
||||
clubPrimaryColor?: string;
|
||||
clubSecondaryColor?: string;
|
||||
}
|
||||
|
||||
// Available map styles
|
||||
export const MAP_STYLES = {
|
||||
// Clean & Minimal
|
||||
'positron': {
|
||||
name: 'Positron (Light)',
|
||||
url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
description: 'Clean light map, perfect for overlays'
|
||||
},
|
||||
'positron-no-labels': {
|
||||
name: 'Positron No Labels',
|
||||
url: 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
description: 'Minimal light map without labels'
|
||||
},
|
||||
|
||||
// Dark Themes
|
||||
'dark': {
|
||||
name: 'Dark Matter',
|
||||
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
description: 'Dark theme, great for night mode'
|
||||
},
|
||||
'dark-no-labels': {
|
||||
name: 'Dark No Labels',
|
||||
url: 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
description: 'Dark map without labels'
|
||||
},
|
||||
|
||||
// Black & White
|
||||
'toner': {
|
||||
name: 'Toner (B&W)',
|
||||
url: 'https://tiles.stadiamaps.com/tiles/stamen_toner/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© Stamen Design © OpenStreetMap',
|
||||
description: 'High contrast black and white'
|
||||
},
|
||||
'toner-lite': {
|
||||
name: 'Toner Lite (B&W)',
|
||||
url: 'https://tiles.stadiamaps.com/tiles/stamen_toner_lite/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© Stamen Design © OpenStreetMap',
|
||||
description: 'Subtle black and white'
|
||||
},
|
||||
|
||||
// Colorful Options
|
||||
'voyager': {
|
||||
name: 'Voyager',
|
||||
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
description: 'Balanced colors, good readability'
|
||||
},
|
||||
'terrain': {
|
||||
name: 'Terrain',
|
||||
url: 'https://tiles.stadiamaps.com/tiles/stamen_terrain/{z}/{x}/{y}{r}.jpg',
|
||||
attribution: '© Stamen Design © OpenStreetMap',
|
||||
description: 'Natural terrain visualization'
|
||||
},
|
||||
'watercolor': {
|
||||
name: 'Watercolor',
|
||||
url: 'https://tiles.stadiamaps.com/tiles/stamen_watercolor/{z}/{x}/{y}.jpg',
|
||||
attribution: '© Stamen Design © OpenStreetMap',
|
||||
description: 'Artistic watercolor style'
|
||||
},
|
||||
|
||||
// Default
|
||||
'default': {
|
||||
name: 'OpenStreetMap',
|
||||
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
description: 'Standard OpenStreetMap'
|
||||
},
|
||||
|
||||
// Satellite
|
||||
'satellite': {
|
||||
name: 'Satellite',
|
||||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
attribution: '© Esri',
|
||||
description: 'Satellite imagery'
|
||||
},
|
||||
};
|
||||
|
||||
const ContactMap: React.FC<ContactMapProps> = ({
|
||||
latitude,
|
||||
longitude,
|
||||
zoom = 15,
|
||||
address,
|
||||
clubName,
|
||||
mapStyle = 'default',
|
||||
height = 400,
|
||||
clubPrimaryColor,
|
||||
clubSecondaryColor,
|
||||
}) => {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<any>(null);
|
||||
const [isLoaded, setIsLoaded] = React.useState(false);
|
||||
const [loadError, setLoadError] = React.useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Load Leaflet CSS and JS dynamically
|
||||
const loadLeaflet = async () => {
|
||||
try {
|
||||
// Check if already loaded
|
||||
if ((window as any).L) {
|
||||
L = (window as any).L;
|
||||
setIsLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load CSS
|
||||
if (!document.getElementById('leaflet-css')) {
|
||||
const link = document.createElement('link');
|
||||
link.id = 'leaflet-css';
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||
link.integrity = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
|
||||
link.crossOrigin = '';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
// Load JS
|
||||
if (!document.getElementById('leaflet-js')) {
|
||||
const script = document.createElement('script');
|
||||
script.id = 'leaflet-js';
|
||||
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
|
||||
script.integrity = 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=';
|
||||
script.crossOrigin = '';
|
||||
|
||||
script.onload = () => {
|
||||
L = (window as any).L;
|
||||
setIsLoaded(true);
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
setLoadError('Failed to load map library');
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
} catch (error) {
|
||||
setLoadError('Error loading map');
|
||||
}
|
||||
};
|
||||
|
||||
loadLeaflet();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoaded || !L || !mapRef.current || mapInstanceRef.current) return;
|
||||
|
||||
try {
|
||||
// Initialize map
|
||||
const map = L.map(mapRef.current, {
|
||||
center: [latitude, longitude],
|
||||
zoom: zoom,
|
||||
scrollWheelZoom: false, // Disable scroll zoom for better UX
|
||||
});
|
||||
|
||||
mapInstanceRef.current = map;
|
||||
|
||||
// Get tile layer URL based on style
|
||||
let tileUrl = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
let attribution = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
||||
|
||||
// Use predefined styles or custom URL
|
||||
if (mapStyle && MAP_STYLES[mapStyle as keyof typeof MAP_STYLES]) {
|
||||
const style = MAP_STYLES[mapStyle as keyof typeof MAP_STYLES];
|
||||
tileUrl = style.url;
|
||||
attribution = style.attribution;
|
||||
} else if (mapStyle && mapStyle.startsWith('http')) {
|
||||
// Custom tile URL
|
||||
tileUrl = mapStyle;
|
||||
}
|
||||
|
||||
// Add tile layer
|
||||
const tileLayer = L.tileLayer(tileUrl, {
|
||||
attribution: attribution,
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
// Apply club color overlay if provided
|
||||
if (clubPrimaryColor && clubPrimaryColor !== '') {
|
||||
const colorFilter = createColorFilter(clubPrimaryColor);
|
||||
if (colorFilter) {
|
||||
const pane = map.createPane('colorOverlay');
|
||||
pane.style.zIndex = '400';
|
||||
pane.style.pointerEvents = 'none';
|
||||
pane.style.mixBlendMode = 'multiply';
|
||||
pane.style.backgroundColor = colorFilter;
|
||||
pane.style.opacity = '0.15';
|
||||
}
|
||||
}
|
||||
|
||||
// Create custom marker icon with club colors
|
||||
const markerColor = clubPrimaryColor || '#3388ff';
|
||||
const customIcon = createCustomMarkerIcon(markerColor, L);
|
||||
|
||||
// Add marker
|
||||
const marker = L.marker([latitude, longitude], { icon: customIcon }).addTo(map);
|
||||
|
||||
// Add popup if address is provided
|
||||
if (clubName || address) {
|
||||
let popupContent = '';
|
||||
if (clubName) popupContent += `<b>${clubName}</b><br>`;
|
||||
if (address) popupContent += address;
|
||||
marker.bindPopup(popupContent);
|
||||
}
|
||||
|
||||
// Enable scroll zoom on click
|
||||
map.on('click', () => {
|
||||
map.scrollWheelZoom.enable();
|
||||
});
|
||||
|
||||
// Disable scroll zoom on mouseout
|
||||
map.on('mouseout', () => {
|
||||
map.scrollWheelZoom.disable();
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing map:', error);
|
||||
setLoadError('Failed to initialize map');
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.remove();
|
||||
mapInstanceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isLoaded, latitude, longitude, zoom, address, clubName, mapStyle, clubPrimaryColor, clubSecondaryColor]);
|
||||
|
||||
// Helper function to create color filter
|
||||
function createColorFilter(color: string): string | null {
|
||||
try {
|
||||
// Validate and normalize color
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.style.color = color;
|
||||
document.body.appendChild(tempDiv);
|
||||
const computedColor = window.getComputedStyle(tempDiv).color;
|
||||
document.body.removeChild(tempDiv);
|
||||
return computedColor;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create custom marker with club colors
|
||||
function createCustomMarkerIcon(color: string, leaflet: any) {
|
||||
// Create SVG marker with custom color
|
||||
const svgIcon = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 36" width="36" height="54">
|
||||
<defs>
|
||||
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
|
||||
<feOffset dx="0" dy="2" result="offsetblur"/>
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.3"/>
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<path fill="${color}" stroke="#fff" stroke-width="1.5" filter="url(#shadow)"
|
||||
d="M12 0C7.03 0 3 4.03 3 9c0 7.5 9 18 9 18s9-10.5 9-18c0-4.97-4.03-9-9-9z"/>
|
||||
<circle cx="12" cy="9" r="3" fill="#fff"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const iconUrl = 'data:image/svg+xml;base64,' + btoa(svgIcon);
|
||||
|
||||
return leaflet.icon({
|
||||
iconUrl: iconUrl,
|
||||
iconSize: [36, 54],
|
||||
iconAnchor: [18, 54],
|
||||
popupAnchor: [0, -54],
|
||||
});
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<Box
|
||||
ref={mapRef}
|
||||
w="100%"
|
||||
h={`${height}px`}
|
||||
bg="gray.100"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
borderRadius="md"
|
||||
>
|
||||
{loadError}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={mapRef}
|
||||
w="100%"
|
||||
h={`${height}px`}
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
boxShadow="md"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactMap;
|
||||
@@ -0,0 +1,334 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Heading,
|
||||
Text,
|
||||
SimpleGrid,
|
||||
VStack,
|
||||
HStack,
|
||||
Avatar,
|
||||
Badge,
|
||||
Link,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionButton,
|
||||
AccordionPanel,
|
||||
AccordionIcon,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiMail, FiPhone, FiMapPin } from 'react-icons/fi';
|
||||
import { getPublicContacts, GroupedContacts } from '../../services/contactInfo';
|
||||
import { getPublicSettings } from '../../services/settings';
|
||||
import ContactMap from './ContactMap';
|
||||
import { getImageUrl } from '../../utils/imageUtils';
|
||||
|
||||
const ContactsSection: React.FC = () => {
|
||||
const [contactsData, setContactsData] = useState<GroupedContacts | null>(null);
|
||||
const [settings, setSettings] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [contacts, settingsData] = await Promise.all([
|
||||
getPublicContacts(),
|
||||
getPublicSettings(),
|
||||
]);
|
||||
setContactsData(contacts);
|
||||
setSettings(settingsData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load contacts:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !contactsData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if there's any data to display
|
||||
const hasContacts = Object.keys(contactsData.categories).length > 0 || contactsData.uncategorized.length > 0;
|
||||
const hasLocation = settings?.location_latitude && settings?.location_longitude;
|
||||
const hasContactInfo = settings?.contact_address || settings?.contact_phone || settings?.contact_email;
|
||||
|
||||
if (!hasContacts && !hasLocation && !hasContactInfo) {
|
||||
return null; // Don't render if no data
|
||||
}
|
||||
|
||||
return (
|
||||
<Box py={16} bg={useColorModeValue('gray.50', 'gray.900')}>
|
||||
<Container maxW="container.xl">
|
||||
<VStack spacing={8} align="stretch">
|
||||
<Box textAlign="center">
|
||||
<Heading size="xl" mb={4}>Kontakt</Heading>
|
||||
<Text fontSize="lg" color="gray.600">
|
||||
Spojte se s námi
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Map and Address Section */}
|
||||
{(hasLocation || hasContactInfo) && (
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={8}>
|
||||
{/* Map - always show if coordinates are set */}
|
||||
{hasLocation && (
|
||||
<Box>
|
||||
<ContactMap
|
||||
latitude={settings.location_latitude}
|
||||
longitude={settings.location_longitude}
|
||||
zoom={settings.map_zoom_level || 15}
|
||||
address={settings.contact_address}
|
||||
clubName={settings.club_name || settings.site_title}
|
||||
mapStyle={settings.map_style || 'default'}
|
||||
clubPrimaryColor={settings.primary_color}
|
||||
clubSecondaryColor={settings.accent_color}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Contact Information */}
|
||||
{hasContactInfo && (
|
||||
<Box
|
||||
bg={bgColor}
|
||||
p={6}
|
||||
borderRadius="lg"
|
||||
boxShadow="md"
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Heading size="md">Naše adresa</Heading>
|
||||
|
||||
{settings.contact_address && (
|
||||
<HStack align="start">
|
||||
<Icon as={FiMapPin} boxSize={5} color="blue.500" mt={1} />
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="bold">Adresa</Text>
|
||||
<Text>{settings.contact_address}</Text>
|
||||
{settings.contact_city && (
|
||||
<Text>
|
||||
{settings.contact_zip && `${settings.contact_zip} `}
|
||||
{settings.contact_city}
|
||||
</Text>
|
||||
)}
|
||||
{settings.contact_country && <Text>{settings.contact_country}</Text>}
|
||||
</VStack>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{settings.contact_phone && (
|
||||
<HStack align="start">
|
||||
<Icon as={FiPhone} boxSize={5} color="blue.500" mt={1} />
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="bold">Telefon</Text>
|
||||
<Link href={`tel:${settings.contact_phone}`} color="blue.500">
|
||||
{settings.contact_phone}
|
||||
</Link>
|
||||
</VStack>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{settings.contact_email && (
|
||||
<HStack align="start">
|
||||
<Icon as={FiMail} boxSize={5} color="blue.500" mt={1} />
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="bold">Email</Text>
|
||||
<Link href={`mailto:${settings.contact_email}`} color="blue.500">
|
||||
{settings.contact_email}
|
||||
</Link>
|
||||
</VStack>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* Contacts by Category */}
|
||||
{hasContacts && (
|
||||
<Box>
|
||||
<Divider my={8} />
|
||||
|
||||
<Accordion allowMultiple defaultIndex={[0]}>
|
||||
{Object.entries(contactsData.categories).map(([categoryName, contacts]) => (
|
||||
<AccordionItem key={categoryName} border="none" mb={4}>
|
||||
<AccordionButton
|
||||
bg={bgColor}
|
||||
borderRadius="lg"
|
||||
p={4}
|
||||
_hover={{ bg: useColorModeValue('gray.100', 'gray.700') }}
|
||||
boxShadow="sm"
|
||||
>
|
||||
<Box flex="1" textAlign="left">
|
||||
<Heading size="md">{categoryName}</Heading>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{contacts.length} {contacts.length === 1 ? 'kontakt' : 'kontaktů'}
|
||||
</Text>
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel pb={4} pt={4}>
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
|
||||
{contacts.map((contact) => (
|
||||
<Box
|
||||
key={contact.id}
|
||||
bg={bgColor}
|
||||
p={6}
|
||||
borderRadius="lg"
|
||||
boxShadow="md"
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
transition="transform 0.2s"
|
||||
_hover={{ transform: 'translateY(-4px)', boxShadow: 'lg' }}
|
||||
>
|
||||
<VStack spacing={4} align="start">
|
||||
{contact.image_url && (
|
||||
<Avatar
|
||||
src={getImageUrl(contact.image_url)}
|
||||
name={contact.name}
|
||||
size="xl"
|
||||
alignSelf="center"
|
||||
/>
|
||||
)}
|
||||
<Box textAlign={contact.image_url ? 'center' : 'left'} w="100%">
|
||||
<Heading size="sm" mb={1}>
|
||||
{contact.name}
|
||||
</Heading>
|
||||
{contact.position && (
|
||||
<Badge colorScheme="blue" mb={2}>
|
||||
{contact.position}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
{contact.description && (
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{contact.description}
|
||||
</Text>
|
||||
)}
|
||||
<VStack align="start" spacing={2} w="100%">
|
||||
{contact.email && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiMail} color="blue.500" />
|
||||
<Link href={`mailto:${contact.email}`} fontSize="sm" color="blue.500">
|
||||
{contact.email}
|
||||
</Link>
|
||||
</HStack>
|
||||
)}
|
||||
{contact.phone && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiPhone} color="blue.500" />
|
||||
<Link href={`tel:${contact.phone}`} fontSize="sm" color="blue.500">
|
||||
{contact.phone}
|
||||
</Link>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
|
||||
{/* Uncategorized contacts */}
|
||||
{contactsData.uncategorized.length > 0 && (
|
||||
<AccordionItem border="none" mb={4}>
|
||||
<AccordionButton
|
||||
bg={bgColor}
|
||||
borderRadius="lg"
|
||||
p={4}
|
||||
_hover={{ bg: useColorModeValue('gray.100', 'gray.700') }}
|
||||
boxShadow="sm"
|
||||
>
|
||||
<Box flex="1" textAlign="left">
|
||||
<Heading size="md">Ostatní kontakty</Heading>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{contactsData.uncategorized.length} {contactsData.uncategorized.length === 1 ? 'kontakt' : 'kontaktů'}
|
||||
</Text>
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel pb={4} pt={4}>
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
|
||||
{contactsData.uncategorized.map((contact) => (
|
||||
<Box
|
||||
key={contact.id}
|
||||
bg={bgColor}
|
||||
p={6}
|
||||
borderRadius="lg"
|
||||
boxShadow="md"
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
transition="transform 0.2s"
|
||||
_hover={{ transform: 'translateY(-4px)', boxShadow: 'lg' }}
|
||||
>
|
||||
<VStack spacing={4} align="start">
|
||||
{contact.image_url && (
|
||||
<Avatar
|
||||
src={getImageUrl(contact.image_url)}
|
||||
name={contact.name}
|
||||
size="xl"
|
||||
alignSelf="center"
|
||||
/>
|
||||
)}
|
||||
<Box textAlign={contact.image_url ? 'center' : 'left'} w="100%">
|
||||
<Heading size="sm" mb={1}>
|
||||
{contact.name}
|
||||
</Heading>
|
||||
{contact.position && (
|
||||
<Badge colorScheme="blue" mb={2}>
|
||||
{contact.position}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
{contact.description && (
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{contact.description}
|
||||
</Text>
|
||||
)}
|
||||
<VStack align="start" spacing={2} w="100%">
|
||||
{contact.email && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiMail} color="blue.500" />
|
||||
<Link href={`mailto:${contact.email}`} fontSize="sm" color="blue.500">
|
||||
{contact.email}
|
||||
</Link>
|
||||
</HStack>
|
||||
)}
|
||||
{contact.phone && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiPhone} color="blue.500" />
|
||||
<Link href={`tel:${contact.phone}`} fontSize="sm" color="blue.500">
|
||||
{contact.phone}
|
||||
</Link>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
)}
|
||||
</Accordion>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactsSection;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Box, Grid, GridItem, Heading, Image, Text, VStack, HStack, Button, Skeleton, Badge } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getArticles, Article } from '../../services/articles';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import { Eye, Clock } from 'lucide-react';
|
||||
|
||||
const FeaturedBlog: React.FC = () => {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['articles', { page: 1, page_size: 3, published: true }],
|
||||
queryFn: () => getArticles({ page: 1, page_size: 3, published: true }),
|
||||
});
|
||||
const theme = useClubTheme();
|
||||
const articles = data?.data || [];
|
||||
|
||||
if (isLoading) return <Skeleton height="320px" />;
|
||||
|
||||
const [main, side1, side2] = [articles[0], articles[1], articles[2]];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={3}>
|
||||
<Heading size="lg">Aktuality</Heading>
|
||||
<Button as={RouterLink} to="/blog" size="sm" variant="link">Všechny články</Button>
|
||||
</HStack>
|
||||
<Grid templateColumns={{ base: '1fr', md: '2fr 1fr' }} gap={4}>
|
||||
<GridItem>
|
||||
{main && (
|
||||
<Box as={RouterLink} to={main.slug ? `/news/${main.slug}` : `/articles/${main.id}`} position="relative" overflow="hidden" borderRadius="xl">
|
||||
<Image src={assetUrl(main.image_url) || '/logo192.png'} alt={main.title} w="100%" h={{ base: '220px', md: '320px' }} objectFit="cover" />
|
||||
<Box position="absolute" inset={0} bgGradient="linear(to-t, rgba(0,0,0,0.7), rgba(0,0,0,0.1))" />
|
||||
{/* Stats badges */}
|
||||
{((main.read_time || main.estimated_read_minutes) || (main.view_count && main.view_count > 0)) && (
|
||||
<HStack position="absolute" top={3} right={3} spacing={2}>
|
||||
{(main.read_time || main.estimated_read_minutes) && (
|
||||
<Badge display="flex" alignItems="center" gap={1} bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1}>
|
||||
<Clock size={12} />
|
||||
{main.read_time || main.estimated_read_minutes} min
|
||||
</Badge>
|
||||
)}
|
||||
{main.view_count && main.view_count > 0 && (
|
||||
<Badge display="flex" alignItems="center" gap={1} bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1}>
|
||||
<Eye size={12} />
|
||||
{main.view_count}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
<VStack align="stretch" spacing={2} position="absolute" bottom={0} p={4} color="white">
|
||||
<Text fontSize="xs" bg={theme.secondary} color="black" px={2} py={0.5} borderRadius="md" w="fit-content">Novinka</Text>
|
||||
<Heading size="md">{main.title}</Heading>
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{[side1, side2].filter(Boolean).map((a) => (
|
||||
<HStack key={(a as Article).id} align="stretch" spacing={3} as={RouterLink} to={(a as Article).slug ? `/news/${(a as Article).slug}` : `/articles/${(a as Article).id}`}>
|
||||
<Image src={assetUrl((a as Article).image_url) || '/logo192.png'} alt={(a as Article).title} w="40%" h="120px" objectFit="cover" borderRadius="lg" />
|
||||
<VStack align="stretch" spacing={2} flex={1}>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{((a as Article).read_time || (a as Article).estimated_read_minutes) && (
|
||||
<HStack spacing={1}>
|
||||
<Clock size={12} color="gray" />
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{(a as Article).read_time || (a as Article).estimated_read_minutes} min
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
{(a as Article).view_count && (a as Article).view_count! > 0 && (
|
||||
<HStack spacing={1}>
|
||||
<Eye size={12} color="gray" />
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{(a as Article).view_count}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
<Heading size="sm" noOfLines={3}>{(a as Article).title}</Heading>
|
||||
</VStack>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturedBlog;
|
||||
@@ -0,0 +1,287 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
SimpleGrid,
|
||||
Image,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Button,
|
||||
Skeleton,
|
||||
Badge,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { Calendar, Image as ImageIcon, ExternalLink, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
date: string;
|
||||
photos_count: number;
|
||||
views_count?: number;
|
||||
photos: Array<{
|
||||
id: string;
|
||||
page_url: string;
|
||||
image_1500: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const resolveBackendUrl = (path: string) => {
|
||||
try {
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
|
||||
const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const b = new URL(base);
|
||||
const abs = new URL(path, `${b.protocol}//${b.host}`);
|
||||
return abs.toString();
|
||||
}
|
||||
return path;
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
};
|
||||
|
||||
const GallerySection: React.FC = () => {
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Dark mode colors
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const headingColor = useColorModeValue('gray.800', 'gray.100');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.300');
|
||||
const infoBg = useColorModeValue('blue.50', 'blue.900');
|
||||
const infoBorder = useColorModeValue('blue.200', 'blue.700');
|
||||
const infoText = useColorModeValue('blue.700', 'blue.200');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAlbums = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Load from both sources and combine
|
||||
const [profileRes, albumsRes] = await Promise.allSettled([
|
||||
fetch(resolveBackendUrl('/cache/prefetch/zonerama_profile.json'), { cache: 'no-cache' }),
|
||||
fetch(resolveBackendUrl('/cache/prefetch/zonerama_albums.json'), { cache: 'no-cache' })
|
||||
]);
|
||||
|
||||
let combinedAlbums: Album[] = [];
|
||||
|
||||
// Get profile albums (newest/main source)
|
||||
if (profileRes.status === 'fulfilled' && profileRes.value.ok) {
|
||||
const profileData = await profileRes.value.json();
|
||||
combinedAlbums = [...(profileData.albums || [])];
|
||||
}
|
||||
|
||||
// Get blog-related albums (additional source)
|
||||
if (albumsRes.status === 'fulfilled' && albumsRes.value.ok) {
|
||||
const albumsData = await albumsRes.value.json();
|
||||
const blogAlbums = Array.isArray(albumsData) ? albumsData : [];
|
||||
|
||||
// Filter out albums with empty/invalid data and avoid duplicates
|
||||
const validBlogAlbums = blogAlbums.filter((album: any) =>
|
||||
album.id &&
|
||||
album.title &&
|
||||
!combinedAlbums.some(existing => existing.id === album.id)
|
||||
);
|
||||
|
||||
combinedAlbums = [...combinedAlbums, ...validBlogAlbums];
|
||||
}
|
||||
|
||||
// Sort by date (newest first)
|
||||
combinedAlbums.sort((a, b) => {
|
||||
const parseDate = (dateStr: string) => {
|
||||
if (!dateStr) return new Date(0);
|
||||
const parts = dateStr.split(/[.\s]+/).filter(Boolean);
|
||||
if (parts.length === 3) {
|
||||
const [day, month, year] = parts;
|
||||
return new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`);
|
||||
}
|
||||
return new Date(dateStr);
|
||||
};
|
||||
|
||||
return parseDate(b.date).getTime() - parseDate(a.date).getTime();
|
||||
});
|
||||
|
||||
// Get the 3 most recent albums
|
||||
const recentAlbums = combinedAlbums.slice(0, 3);
|
||||
setAlbums(recentAlbums);
|
||||
} catch (err) {
|
||||
console.error('Error loading albums:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAlbums();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box py={12}>
|
||||
<VStack spacing={6} align="stretch">
|
||||
<Heading size="xl">Galerie</Heading>
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} height="300px" borderRadius="lg" />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (albums.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box py={12}>
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* Header */}
|
||||
<HStack justify="space-between" align="center" flexWrap="wrap">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Heading size="xl" color={headingColor}>
|
||||
Fotogalerie
|
||||
</Heading>
|
||||
<Text color={textColor} fontSize="sm">
|
||||
Nejnovější alba z našich akcí
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<Button
|
||||
as={RouterLink}
|
||||
to="/galerie"
|
||||
rightIcon={<ArrowRight size={18} />}
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
size="md"
|
||||
>
|
||||
Zobrazit vše
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{/* Zonerama Attribution */}
|
||||
<Box
|
||||
bg={infoBg}
|
||||
borderWidth="1px"
|
||||
borderColor={infoBorder}
|
||||
borderRadius="md"
|
||||
px={4}
|
||||
py={2}
|
||||
>
|
||||
<Text fontSize="xs" color={infoText}>
|
||||
📸 Všechny fotografie jsou z platformy{' '}
|
||||
<Text
|
||||
as="a"
|
||||
href="https://zonerama.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
fontWeight="600"
|
||||
color="blue.600"
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
Zonerama
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Albums Grid */}
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
|
||||
{albums.map((album) => {
|
||||
const coverPhoto = album.photos && album.photos.length > 0
|
||||
? album.photos[0]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={album.id}
|
||||
as={RouterLink}
|
||||
to={`/galerie/album/${album.id}`}
|
||||
bg={cardBg}
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
boxShadow="md"
|
||||
transition="all 0.3s"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
_hover={{
|
||||
transform: 'translateY(-8px)',
|
||||
boxShadow: '2xl',
|
||||
borderColor: useColorModeValue('gray.300', 'gray.600'),
|
||||
}}
|
||||
cursor="pointer"
|
||||
>
|
||||
{/* Cover Image */}
|
||||
{coverPhoto ? (
|
||||
<Image
|
||||
src={coverPhoto.image_1500}
|
||||
alt={album.title}
|
||||
w="100%"
|
||||
h="200px"
|
||||
objectFit="cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
w="100%"
|
||||
h="200px"
|
||||
bg="gray.200"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<ImageIcon size={48} color="gray" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Album Info */}
|
||||
<VStack align="stretch" p={4} spacing={2}>
|
||||
<Heading size="sm" color={headingColor} noOfLines={2} minH="40px">
|
||||
{album.title}
|
||||
</Heading>
|
||||
|
||||
<VStack spacing={2} fontSize="xs" color={textColor} align="stretch">
|
||||
{album.date && (
|
||||
<HStack spacing={1}>
|
||||
<Calendar size={14} />
|
||||
<Text>{album.date}</Text>
|
||||
</HStack>
|
||||
)}
|
||||
<HStack spacing={1}>
|
||||
<ImageIcon size={14} />
|
||||
<Text>{album.photos_count} foto</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{album.views_count !== undefined && album.views_count > 0 && (
|
||||
<Badge colorScheme="purple" fontSize="2xs" alignSelf="flex-start">
|
||||
{album.views_count} zhlédnutí
|
||||
</Badge>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Bottom CTA */}
|
||||
<Box textAlign="center" pt={4}>
|
||||
<Button
|
||||
as={RouterLink}
|
||||
to="/galerie"
|
||||
rightIcon={<ArrowRight size={18} />}
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
>
|
||||
Zobrazit všechna alba
|
||||
</Button>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GallerySection;
|
||||
@@ -0,0 +1,199 @@
|
||||
import React from 'react';
|
||||
import { Box, Flex, HStack, Image, Text, Container, useColorModeValue } from '@chakra-ui/react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
interface HeaderVariantsProps {
|
||||
variant: 'unified' | 'edge' | 'minimal' | 'modern';
|
||||
clubName?: string;
|
||||
clubLogo?: string;
|
||||
clubId?: string;
|
||||
}
|
||||
|
||||
const HeaderVariants: React.FC<HeaderVariantsProps> = ({
|
||||
variant = 'unified',
|
||||
clubName,
|
||||
clubLogo,
|
||||
clubId,
|
||||
}) => {
|
||||
const displayLogo = clubId
|
||||
? `http://logoapi.sportcreative.eu/logos/${clubId}?format=svg`
|
||||
: clubLogo || '/images/club-logo.png';
|
||||
|
||||
// Unified variant - classic header
|
||||
if (variant === 'unified') {
|
||||
return (
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderBottom="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
py={4}
|
||||
>
|
||||
<Container maxW="7xl">
|
||||
<Flex align="center" justify="space-between">
|
||||
<HStack as={RouterLink} to="/" spacing={4}>
|
||||
{displayLogo && (
|
||||
<Image
|
||||
src={displayLogo}
|
||||
alt={clubName || 'Club'}
|
||||
boxSize="48px"
|
||||
objectFit="contain"
|
||||
borderRadius="full"
|
||||
borderWidth="2px"
|
||||
borderColor="brand.primary"
|
||||
style={{
|
||||
padding: displayLogo.includes('logoapi.sportcreative.eu') ? '4px' : '0px',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box>
|
||||
<Text fontSize="2xl" fontWeight="bold" color={useColorModeValue('gray.800', 'white')}>
|
||||
{clubName || 'MyClub'}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.500">Official Website</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Edge variant - modern with gradient
|
||||
if (variant === 'edge') {
|
||||
return (
|
||||
<Box
|
||||
bgGradient="linear(to-r, brand.primary, brand.secondary)"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
bg="blackAlpha.300"
|
||||
backdropFilter="blur(8px)"
|
||||
/>
|
||||
<Container maxW="7xl" position="relative" py={6}>
|
||||
<Flex align="center" justify="center" direction="column">
|
||||
{displayLogo && (
|
||||
<Image
|
||||
src={displayLogo}
|
||||
alt={clubName || 'Club'}
|
||||
boxSize="80px"
|
||||
objectFit="contain"
|
||||
mb={3}
|
||||
filter="drop-shadow(0 4px 6px rgba(0,0,0,0.3))"
|
||||
style={{
|
||||
padding: displayLogo.includes('logoapi.sportcreative.eu') ? '8px' : '0px',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Text fontSize="3xl" fontWeight="bold" color="white" textShadow="0 2px 4px rgba(0,0,0,0.3)">
|
||||
{clubName || 'Football Club'}
|
||||
</Text>
|
||||
<Text fontSize="sm" color="whiteAlpha.900" mt={1}>
|
||||
Official Website
|
||||
</Text>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Minimal variant - clean and simple
|
||||
if (variant === 'minimal') {
|
||||
return (
|
||||
<Box bg={useColorModeValue('gray.50', 'gray.900')} py={3}>
|
||||
<Container maxW="7xl">
|
||||
<Flex align="center" justify="center">
|
||||
<HStack as={RouterLink} to="/" spacing={3}>
|
||||
{displayLogo && (
|
||||
<Image
|
||||
src={displayLogo}
|
||||
alt={clubName || 'Club'}
|
||||
boxSize="36px"
|
||||
objectFit="contain"
|
||||
style={{
|
||||
padding: displayLogo.includes('logoapi.sportcreative.eu') ? '3px' : '0px',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Text fontSize="lg" fontWeight="600" color={useColorModeValue('gray.700', 'gray.200')}>
|
||||
{clubName || 'FC'}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Modern variant - bold with accent
|
||||
if (variant === 'modern') {
|
||||
return (
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderBottom="4px"
|
||||
borderColor="brand.primary"
|
||||
boxShadow="sm"
|
||||
>
|
||||
<Container maxW="7xl" py={5}>
|
||||
<Flex align="center" justify="space-between">
|
||||
<HStack as={RouterLink} to="/" spacing={4}>
|
||||
{displayLogo && (
|
||||
<Box
|
||||
position="relative"
|
||||
_before={{
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '-4px',
|
||||
left: '-4px',
|
||||
right: '-4px',
|
||||
bottom: '-4px',
|
||||
bg: 'brand.primary',
|
||||
opacity: 0.1,
|
||||
borderRadius: 'full',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={displayLogo}
|
||||
alt={clubName || 'Club'}
|
||||
boxSize="56px"
|
||||
objectFit="contain"
|
||||
borderRadius="full"
|
||||
style={{
|
||||
padding: displayLogo.includes('logoapi.sportcreative.eu') ? '6px' : '0px',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Text
|
||||
fontSize="2xl"
|
||||
fontWeight="900"
|
||||
color="brand.primary"
|
||||
letterSpacing="tight"
|
||||
>
|
||||
{clubName || 'FOOTBALL CLUB'}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.500" fontWeight="600" letterSpacing="wider" textTransform="uppercase">
|
||||
Official Website
|
||||
</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default HeaderVariants;
|
||||
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import { Box, Container, Grid, GridItem, VStack, HStack, Image, Heading, Text, Icon, Button } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import BlogSwiper from './BlogSwiper';
|
||||
import { getArticles, Article } from '../../services/articles';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import UpcomingSwitch from './UpcomingSwitch';
|
||||
import { ChevronRightIcon } from '@chakra-ui/icons';
|
||||
|
||||
const RailItem: React.FC<{ a: Article }>=({ a })=>{
|
||||
return (
|
||||
<HStack
|
||||
as={RouterLink}
|
||||
to={`/articles/${a.id}`}
|
||||
spacing={3}
|
||||
align="center"
|
||||
px={3}
|
||||
py={2}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'whiteAlpha.200' }}
|
||||
transition="background 0.2s"
|
||||
>
|
||||
<Box position="relative" flexShrink={0}>
|
||||
<Image src={a.image_url || '/stadium-placeholder.jpg'} alt={a.title} boxSize="64px" objectFit="cover" borderRadius="md" />
|
||||
</Box>
|
||||
<VStack spacing={0} align="start" minW={0}>
|
||||
<Text fontSize="xs" color="whiteAlpha.700">{new Date(a.created_at || '').toLocaleDateString()}</Text>
|
||||
<Text fontWeight={600} noOfLines={2} color="white">{a.title}</Text>
|
||||
</VStack>
|
||||
<ChevronRightIcon color="whiteAlpha.700" ml="auto" />
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
const HeroWithRail: React.FC = () => {
|
||||
const theme = useClubTheme();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['hero-rail-articles', { page: 1, page_size: 8, published: true }],
|
||||
queryFn: () => getArticles({ page: 1, page_size: 8, published: true }),
|
||||
});
|
||||
|
||||
const articles = data?.data || [];
|
||||
|
||||
return (
|
||||
<Box position="relative">
|
||||
{/* Hero */}
|
||||
<Grid templateColumns={{ base: '1fr', lg: '2fr 1fr' }} gap={6}>
|
||||
<GridItem>
|
||||
<BlogSwiper />
|
||||
</GridItem>
|
||||
{/* Right rail */}
|
||||
<GridItem display={{ base: 'none', lg: 'block' }}>
|
||||
<Box
|
||||
h={{ base: 'auto', lg: '600px' }}
|
||||
bg="blackAlpha.600"
|
||||
borderRadius="xl"
|
||||
overflow="hidden"
|
||||
backdropFilter="auto"
|
||||
backdropBlur="8px"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.200"
|
||||
>
|
||||
<VStack align="stretch" spacing={0} h="100%">
|
||||
<HStack justify="space-between" px={4} py={3} borderBottom="1px solid" borderColor="whiteAlpha.200">
|
||||
<Text fontWeight="700" color="white">Novinky</Text>
|
||||
<Button as={RouterLink} to="/blog" size="sm" variant="ghost" color="whiteAlpha.800" _hover={{ bg: 'whiteAlpha.200' }}>Vše</Button>
|
||||
</HStack>
|
||||
<VStack spacing={1} align="stretch" px={2} py={2} overflowY="auto">
|
||||
{isLoading && Array.from({length:5}).map((_,i)=> (
|
||||
<Box key={i} h="72px" borderRadius="md" bg="whiteAlpha.200" />
|
||||
))}
|
||||
{!isLoading && articles.map((a) => (
|
||||
<RailItem key={a.id} a={a} />
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
|
||||
{/* Glasmorphic upcoming panel */}
|
||||
<Box
|
||||
position="absolute"
|
||||
left="50%"
|
||||
bottom={{ base: 2, md: 4 }}
|
||||
transform="translateX(-50%)"
|
||||
w={{ base: '95%', md: '80%' }}
|
||||
bg="whiteAlpha.200"
|
||||
backdropFilter="auto"
|
||||
backdropBlur="10px"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.300"
|
||||
borderRadius="xl"
|
||||
px={{ base: 3, md: 6 }}
|
||||
py={{ base: 3, md: 4 }}
|
||||
boxShadow="lg"
|
||||
>
|
||||
<UpcomingSwitch />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroWithRail;
|
||||
@@ -0,0 +1,227 @@
|
||||
import { Box, Tabs, TabList, TabPanels, Tab, TabPanel, Table, Thead, Tbody, Tr, Th, Td, Text, Badge, Heading, Flex, Tooltip } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { facrApi } from '../../services/facr/facrApi';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import { useState } from 'react';
|
||||
import ClubModal from './ClubModal';
|
||||
import { TeamLogo } from '../common/TeamLogo';
|
||||
|
||||
const LeagueTablePro: React.FC = () => {
|
||||
const { data: settings } = usePublicSettings();
|
||||
const clubId = settings?.club_id;
|
||||
const clubType = settings?.club_type || 'football';
|
||||
const theme = useClubTheme();
|
||||
const { data } = useQuery({
|
||||
queryKey: ['facr-table', clubId, clubType],
|
||||
queryFn: () => facrApi.getClubTable(clubId!, clubType as any),
|
||||
enabled: !!clubId,
|
||||
});
|
||||
|
||||
const [selectedClub, setSelectedClub] = useState<any>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const handleClubClick = (row: any) => {
|
||||
// Transform row data to match ClubModal interface
|
||||
const clubData = {
|
||||
team: row.team || row.team_name || '-',
|
||||
team_id: row.team_id || '',
|
||||
team_logo_url: row.team_logo_url,
|
||||
rank: row.rank,
|
||||
played: row.played,
|
||||
wins: row.wins,
|
||||
draws: row.draws,
|
||||
losses: row.losses,
|
||||
score: row.score,
|
||||
points: row.points,
|
||||
};
|
||||
setSelectedClub(clubData);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<Box borderRadius="lg" overflow="hidden" bg="white" boxShadow="sm" borderWidth="1px" borderColor="gray.100">
|
||||
<Box bg="primary.600" px={4} py={2} borderBottomWidth="1px" borderColor="primary.700">
|
||||
<Heading size="md" color="white">Tabulka</Heading>
|
||||
</Box>
|
||||
|
||||
<Tabs variant="enclosed" colorScheme="gray" size="sm">
|
||||
<TabList px={2} pt={2} overflowX="auto" overflowY="hidden" css={{
|
||||
'&::-webkit-scrollbar': { height: '4px' },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': { background: 'gray.300', borderRadius: '4px' },
|
||||
}}>
|
||||
{data.competitions?.map((c) => (
|
||||
<Tab
|
||||
key={c.id}
|
||||
_selected={{
|
||||
color: 'primary.600',
|
||||
borderBottom: '2px solid',
|
||||
borderColor: 'primary.600',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
_focus={{ boxShadow: 'none' }}
|
||||
fontSize="sm"
|
||||
px={3}
|
||||
py={3}
|
||||
>
|
||||
{c.name}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{data.competitions?.map((c) => (
|
||||
<TabPanel key={c.id} px={0}>
|
||||
<Box maxH="420px" overflowY="auto">
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead position="sticky" top={0} zIndex={1} bg="gray.50">
|
||||
<Tr borderBottomWidth="1px" borderColor="gray.200">
|
||||
<Th width="8" px={2} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">#</Th>
|
||||
<Th px={3} color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Tým</Th>
|
||||
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Z</Th>
|
||||
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">V</Th>
|
||||
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">R</Th>
|
||||
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">P</Th>
|
||||
<Th width="16" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Skóre</Th>
|
||||
<Th width="14" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Body</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{c.table?.overall?.map((row, idx) => {
|
||||
const isHighlighted = row.team_id === clubId;
|
||||
return (
|
||||
<Tr
|
||||
key={`${row.team_id}-${idx}`}
|
||||
bg={isHighlighted ? 'primary.50' : idx % 2 === 0 ? 'white' : 'gray.50'}
|
||||
borderLeft={isHighlighted ? '3px solid' : '3px solid transparent'}
|
||||
borderLeftColor={isHighlighted ? 'primary.500' : 'transparent'}
|
||||
_hover={{ bg: isHighlighted ? 'primary.100' : 'gray.100', cursor: 'pointer' }}
|
||||
onClick={() => handleClubClick(row)}
|
||||
>
|
||||
<Td px={2} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
|
||||
<Flex align="center" justify="center">
|
||||
{row.rank}
|
||||
{idx < 3 && (
|
||||
<Box as="span" ml={1} color={idx === 0 ? 'yellow.400' : idx === 1 ? 'gray.400' : 'yellow.700'}>
|
||||
{idx === 0 ? '🥇' : idx === 1 ? '🥈' : '🥉'}
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
</Td>
|
||||
<Td px={3} py={2}>
|
||||
<Flex align="center" minW="180px">
|
||||
<TeamLogo
|
||||
teamId={row.team_id}
|
||||
teamName={row.team}
|
||||
facrLogo={row.team_logo_url}
|
||||
size="small"
|
||||
alt={row.team}
|
||||
mr={2}
|
||||
fallbackIcon={
|
||||
<Box
|
||||
w="20px"
|
||||
h="20px"
|
||||
bg="gray.200"
|
||||
borderRadius="md"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
color="gray.400"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{row.team.substring(0, 2).toUpperCase()}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
as="span"
|
||||
fontWeight={isHighlighted ? 'bold' : 'normal'}
|
||||
color={isHighlighted ? 'primary.700' : 'gray.800'}
|
||||
isTruncated
|
||||
>
|
||||
{row.team}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Td>
|
||||
<Td isNumeric px={1} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
|
||||
{row.played}
|
||||
</Td>
|
||||
<Td isNumeric px={1} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
|
||||
{row.wins}
|
||||
</Td>
|
||||
<Td isNumeric px={1} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
|
||||
{row.draws}
|
||||
</Td>
|
||||
<Td isNumeric px={1} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
|
||||
{row.losses}
|
||||
</Td>
|
||||
<Td isNumeric px={1} textAlign="center" fontFamily="mono" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
|
||||
{row.score}
|
||||
</Td>
|
||||
<Td
|
||||
isNumeric
|
||||
px={1}
|
||||
textAlign="center"
|
||||
fontWeight="bold"
|
||||
color={isHighlighted ? 'white' : 'gray.800'}
|
||||
bg={isHighlighted ? 'primary.500' : 'gray.100'}
|
||||
borderLeftWidth="1px"
|
||||
borderLeftColor="white"
|
||||
>
|
||||
{row.points}
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* League Info Footer */}
|
||||
<Box px={4} py={3} borderTopWidth="1px" borderColor="gray.100" bg="gray.50">
|
||||
<Flex justify="space-between" fontSize="xs" color="gray.600">
|
||||
<Box>
|
||||
<Text as="span" mr={4} display="inline-flex" alignItems="center">
|
||||
<Box as="span" display="inline-block" w="8px" h="8px" bg="green.500" borderRadius="full" mr={1} />
|
||||
Postup
|
||||
</Text>
|
||||
<Text as="span" mr={4} display="inline-flex" alignItems="center">
|
||||
<Box as="span" display="inline-block" w="8px" h="8px" bg="blue.500" borderRadius="full" mr={1} />
|
||||
Evropské poháry
|
||||
</Text>
|
||||
<Text as="span" display="inline-flex" alignItems="center">
|
||||
<Box as="span" display="inline-block" w="8px" h="8px" bg="red.500" borderRadius="full" mr={1} />
|
||||
Sestup
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text as="span" display="inline-flex" alignItems="center">
|
||||
<Box as="span" fontWeight="bold" mr={1}>Aktualizováno:</Box>
|
||||
{new Date().toLocaleDateString('cs-CZ', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
<ClubModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
club={selectedClub}
|
||||
clubType={clubType as 'football' | 'futsal'}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeagueTablePro;
|
||||
@@ -0,0 +1,193 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Button,
|
||||
HStack,
|
||||
VStack,
|
||||
Image,
|
||||
Text,
|
||||
Badge,
|
||||
Link,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import { useCountdown } from '../../hooks/useCountdown';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
export type FacrMatchLike = {
|
||||
id?: string | number;
|
||||
date?: string; // yyyy-mm-dd
|
||||
time?: string; // HH:MM
|
||||
date_time?: string; // alternative combined format (dd.MM.yyyy HH:mm)
|
||||
home?: string;
|
||||
away?: string;
|
||||
home_logo_url?: string;
|
||||
away_logo_url?: string;
|
||||
competition?: string;
|
||||
competitionName?: string;
|
||||
venue?: string;
|
||||
score?: string | null;
|
||||
facr_link?: string | null;
|
||||
report_url?: string | null;
|
||||
};
|
||||
|
||||
interface MatchModalProps {
|
||||
isOpen: boolean;
|
||||
match: FacrMatchLike | null;
|
||||
onClose: () => void;
|
||||
onTeamClick?: (teamName: string, teamLogoUrl?: string) => void;
|
||||
}
|
||||
|
||||
const formatWhen = (m: FacrMatchLike | null) => {
|
||||
if (!m) return '';
|
||||
try {
|
||||
if (m.date && m.time) {
|
||||
const d = new Date(`${m.date}T${(m.time || '00:00')}:00`);
|
||||
if (!isNaN(d.getTime())) return d.toLocaleString();
|
||||
}
|
||||
if (m.date_time) {
|
||||
// Try to parse dd.MM.yyyy HH:mm quickly by reordering
|
||||
const dt = String(m.date_time);
|
||||
const [dPart, tPart] = dt.split(' ');
|
||||
const [dd, MM, yyyy] = (dPart || '').split('.');
|
||||
if (dd && MM && yyyy) {
|
||||
const iso = `${yyyy}-${MM.padStart(2, '0')}-${dd.padStart(2, '0')}T${(tPart || '00:00')}:00`;
|
||||
const d = new Date(iso);
|
||||
if (!isNaN(d.getTime())) return d.toLocaleString();
|
||||
}
|
||||
return m.date_time;
|
||||
}
|
||||
} catch {}
|
||||
return '';
|
||||
};
|
||||
|
||||
export const MatchModal: React.FC<MatchModalProps> = ({ isOpen, match, onClose, onTeamClick }) => {
|
||||
const kickoffIso = useMemo(() => {
|
||||
if (!match) return null;
|
||||
if (match.date && match.time) return `${match.date}T${(match.time || '00:00')}:00`;
|
||||
if (match.date_time) {
|
||||
const dt = String(match.date_time);
|
||||
const [dPart, tPart] = dt.split(' ');
|
||||
const [dd, MM, yyyy] = (dPart || '').split('.');
|
||||
if (dd && MM && yyyy) return `${yyyy}-${MM.padStart(2, '0')}-${dd.padStart(2, '0')}T${(tPart || '00:00')}:00`;
|
||||
return match.date_time; // fallback
|
||||
}
|
||||
return null;
|
||||
}, [match]);
|
||||
|
||||
const { countdownString, isActive, timeRemaining } = useCountdown(kickoffIso, 1000);
|
||||
const facrLink = match?.facr_link || match?.report_url || null;
|
||||
const when = formatWhen(match);
|
||||
|
||||
// Determine if match has started (countdown finished) but no score yet
|
||||
const matchStarted = kickoffIso ? new Date(kickoffIso).getTime() <= Date.now() : false;
|
||||
const hasScore = match?.score && match.score.trim() !== '';
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} isCentered size={{ base: 'md', md: 'lg' }}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{match?.home || 'Domácí'} vs {match?.away || 'Hosté'}
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{match && (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<HStack justify="space-between" align="center">
|
||||
<VStack
|
||||
align="center"
|
||||
spacing={2}
|
||||
flex={1}
|
||||
minW={0}
|
||||
cursor={onTeamClick ? 'pointer' : 'default'}
|
||||
onClick={() => onTeamClick && onTeamClick(match.home || '', match.home_logo_url)}
|
||||
_hover={onTeamClick ? { opacity: 0.8, transform: 'scale(1.05)' } : {}}
|
||||
transition="all 0.2s"
|
||||
role={onTeamClick ? 'button' : undefined}
|
||||
tabIndex={onTeamClick ? 0 : undefined}
|
||||
>
|
||||
<Image src={assetUrl(match.home_logo_url) || '/logo192.png'} alt={match.home || 'Domácí'} boxSize="56px" objectFit="contain" />
|
||||
<Text fontWeight="semibold" noOfLines={1} textAlign="center">{match.home || 'Domácí'}</Text>
|
||||
</VStack>
|
||||
<VStack spacing={1} minW="120px">
|
||||
{hasScore ? (
|
||||
<>
|
||||
<Text fontSize="2xl" fontWeight="bold">{match.score}</Text>
|
||||
<Text fontSize="sm" color="gray.600">Skončeno</Text>
|
||||
</>
|
||||
) : matchStarted ? (
|
||||
<>
|
||||
<Text fontSize="2xl" fontWeight="bold">—:—</Text>
|
||||
<Text fontSize="sm" color="green.600">Probíhá</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text fontSize="lg" color="gray.600">Začátek za</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold">{countdownString || '—'}</Text>
|
||||
</>
|
||||
)}
|
||||
{(match.competition || match.competitionName) && (
|
||||
<Badge colorScheme="blue" variant="subtle">{match.competition || match.competitionName}</Badge>
|
||||
)}
|
||||
</VStack>
|
||||
<VStack
|
||||
align="center"
|
||||
spacing={2}
|
||||
flex={1}
|
||||
minW={0}
|
||||
cursor={onTeamClick ? 'pointer' : 'default'}
|
||||
onClick={() => onTeamClick && onTeamClick(match.away || '', match.away_logo_url)}
|
||||
_hover={onTeamClick ? { opacity: 0.8, transform: 'scale(1.05)' } : {}}
|
||||
transition="all 0.2s"
|
||||
role={onTeamClick ? 'button' : undefined}
|
||||
tabIndex={onTeamClick ? 0 : undefined}
|
||||
>
|
||||
<Image src={assetUrl(match.away_logo_url) || '/logo192.png'} alt={match.away || 'Hosté'} boxSize="56px" objectFit="contain" />
|
||||
<Text fontWeight="semibold" noOfLines={1} textAlign="center">{match.away || 'Hosté'}</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<VStack align="stretch" spacing={1} color="gray.700">
|
||||
{when && <Text><strong>Kdy:</strong> {when}</Text>}
|
||||
{match.venue && <Text><strong>Kde:</strong> {match.venue}</Text>}
|
||||
</VStack>
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
{facrLink && (
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
mr={3}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// Open in background tab without switching focus
|
||||
const link = document.createElement('a');
|
||||
link.href = facrLink;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}}
|
||||
>
|
||||
Detail na FAČR
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onClose}>Zavřít</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchModal;
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Box, Heading, Tabs, TabList, TabPanels, Tab, TabPanel, VStack, HStack, Image, Text, Link, Skeleton, Badge } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { facrApi } from '../../services/facr/facrApi';
|
||||
import { FACR_CLUB_ID, FACR_CLUB_TYPE } from '../../config/facr';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { TeamLogo } from '../common/TeamLogo';
|
||||
import '../../styles/logos.css';
|
||||
|
||||
const MatchRow: React.FC<{
|
||||
date: string;
|
||||
home: { name: string; logo?: string; id?: string };
|
||||
away: { name: string; logo?: string; id?: string };
|
||||
score?: string;
|
||||
clubName?: string;
|
||||
}> = ({ date, home, away, score, clubName }) => (
|
||||
<HStack justify="space-between" borderWidth="1px" borderRadius="md" p={3} bg="white">
|
||||
<Text w="140px" fontSize="sm" color="gray.600">{date}</Text>
|
||||
<HStack flex={1} justify="flex-end">
|
||||
<HStack minW="40%" justify="flex-end" spacing={2}>
|
||||
<Text noOfLines={1} textAlign="right" flex={1}>{home.name}</Text>
|
||||
<Box className="logo-container" w="28px" h="28px">
|
||||
<TeamLogo
|
||||
teamId={home.id}
|
||||
teamName={home.name}
|
||||
facrLogo={home.logo}
|
||||
size="custom"
|
||||
boxSize="28px"
|
||||
/>
|
||||
</Box>
|
||||
</HStack>
|
||||
<HStack w="auto" minW="60px" justify="center" spacing={2}>
|
||||
<Text fontWeight="bold" textAlign="center">{score || '-:-'}</Text>
|
||||
{(() => {
|
||||
const sent = (() => {
|
||||
if (!score || !clubName) return null;
|
||||
const m = score.match(/^(\d+)\s*[:\-]\s*(\d+)$/);
|
||||
if (!m) return null;
|
||||
const h = parseInt(m[1], 10), a = parseInt(m[2], 10);
|
||||
const norm = (s: string) => String(s||'').normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/\s+/g,' ').trim().toLowerCase();
|
||||
const strip = (s: string) => norm(s).replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g,'').replace(/\s+/g,' ').trim();
|
||||
const ourIsHome = (() => { const aName = strip(home.name); const bName = strip(clubName); return aName && bName && (aName===bName || aName.endsWith(bName) || bName.endsWith(aName)); })();
|
||||
const ourIsAway = (() => { const aName = strip(away.name); const bName = strip(clubName); return aName && bName && (aName===bName || aName.endsWith(bName) || bName.endsWith(aName)); })();
|
||||
if (!ourIsHome && !ourIsAway) return null;
|
||||
if (h === a) return { label: 'Remíza', color: 'blue' } as const;
|
||||
const our = ourIsHome ? h : a; const opp = ourIsHome ? a : h;
|
||||
return our > opp ? ({ label: 'Výhra', color: 'green' } as const) : ({ label: 'Prohra', color: 'red' } as const);
|
||||
})();
|
||||
return sent ? <Badge colorScheme={sent.color} variant="subtle">{sent.label}</Badge> : null;
|
||||
})()}
|
||||
</HStack>
|
||||
<HStack minW="40%" spacing={2}>
|
||||
<Box className="logo-container" w="28px" h="28px">
|
||||
<TeamLogo
|
||||
teamId={away.id}
|
||||
teamName={away.name}
|
||||
facrLogo={away.logo}
|
||||
size="custom"
|
||||
boxSize="28px"
|
||||
/>
|
||||
</Box>
|
||||
<Text noOfLines={1} flex={1}>{away.name}</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
|
||||
const MatchesSection: React.FC = () => {
|
||||
const { data: settings } = usePublicSettings();
|
||||
const clubId = settings?.club_id || FACR_CLUB_ID;
|
||||
const clubType = settings?.club_type || FACR_CLUB_TYPE;
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['facr-club', clubId, clubType],
|
||||
queryFn: () => facrApi.getClub(clubId, clubType),
|
||||
enabled: Boolean(clubId),
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Heading size="lg" mb={4}>Zápasy</Heading>
|
||||
{!clubId && (
|
||||
<Text color="orange.500" mb={4}>Nastavte klub v Nastavení (Admin) nebo REACT_APP_FACR_CLUB_ID pro načtení zápasů z FAČR.</Text>
|
||||
)}
|
||||
{isLoading && <Skeleton height="200px" />}
|
||||
{!isLoading && data && (
|
||||
<Tabs variant="enclosed-colored" isFitted>
|
||||
<TabList>
|
||||
{data.competitions?.map((c) => (
|
||||
<Tab key={c.id}>{c.name}</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{data.competitions?.map((c) => (
|
||||
<TabPanel key={c.id} px={0}>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{(c.matches || []).slice(0, 5).map((m, idx) => (
|
||||
<MatchRow
|
||||
key={m.match_id || idx}
|
||||
date={m.date_time}
|
||||
home={{ name: m.home, logo: m.home_logo_url, id: m.home_id }}
|
||||
away={{ name: m.away, logo: m.away_logo_url, id: m.away_id }}
|
||||
score={m.score}
|
||||
clubName={data.name}
|
||||
/>
|
||||
))}
|
||||
{(c.matches || []).length === 0 && (
|
||||
<Text color="gray.500">Žádné zápasy k dispozici.</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchesSection;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Box, SimpleGrid, Heading, Text, useColorModeValue, HStack, Button, Link, Badge } from '@chakra-ui/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getClothing, ClothingItem } from '../../services/clothing';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
const MerchSection: React.FC = () => {
|
||||
const [items, setItems] = useState<ClothingItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchItems = async () => {
|
||||
try {
|
||||
const data = await getClothing();
|
||||
// Show only 5 newest items on homepage
|
||||
setItems(data.slice(0, 5));
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch clothing items:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchItems();
|
||||
}, []);
|
||||
|
||||
if (loading || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={3}>
|
||||
<Heading as="h3" size="md">Oblečení týmu</Heading>
|
||||
<Link as={RouterLink} to="/obleceni">
|
||||
<Button size="sm" variant="outline" colorScheme="blue">Zobrazit vše</Button>
|
||||
</Link>
|
||||
</HStack>
|
||||
<SimpleGrid columns={{ base: 2, md: 3, lg: 5 }} spacing={4}>
|
||||
{items.map((it) => (
|
||||
<a
|
||||
key={it.id}
|
||||
href={it.url || '/obleceni'}
|
||||
target={it.url ? "_blank" : undefined}
|
||||
rel={it.url ? "noreferrer noopener" : undefined}
|
||||
>
|
||||
<Box
|
||||
bg={cardBg}
|
||||
borderRadius="xl"
|
||||
overflow="hidden"
|
||||
boxShadow="sm"
|
||||
borderWidth="1px"
|
||||
transition="all 0.2s"
|
||||
_hover={{ transform: 'translateY(-4px)', boxShadow: 'md' }}
|
||||
>
|
||||
<Box
|
||||
aria-hidden
|
||||
height={{ base: 140, md: 180 }}
|
||||
bgSize="cover"
|
||||
bgPos="center"
|
||||
style={{ backgroundImage: `url(${it.image_url})` }}
|
||||
/>
|
||||
<Box p={3} borderTopWidth="1px">
|
||||
<Text noOfLines={1} fontWeight="semibold" fontSize="sm">{it.title}</Text>
|
||||
{it.price && it.price > 0 && (
|
||||
<Badge colorScheme="blue" mt={1} fontSize="xs">
|
||||
{it.price} {it.currency || 'Kč'}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</a>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MerchSection;
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Box, Grid, GridItem, Heading, Image, Button, HStack, Text, VStack, Badge } from '@chakra-ui/react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { Calendar, Image as ImageIcon } from 'lucide-react';
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
date: string;
|
||||
photos_count: number;
|
||||
views_count?: number;
|
||||
photos: Array<{
|
||||
id: string;
|
||||
page_url: string;
|
||||
image_1500: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Resolve backend-relative URLs against API origin
|
||||
const resolveBackendUrl = (path: string) => {
|
||||
try {
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
|
||||
const base = (process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1');
|
||||
const b = new URL(base);
|
||||
const abs = new URL(path, `${b.protocol}//${b.host}`);
|
||||
return abs.toString();
|
||||
}
|
||||
return path;
|
||||
} catch { return path; }
|
||||
};
|
||||
|
||||
const PhotosSection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl }) => {
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const response = await fetch(`${apiUrl}/gallery/albums`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (active) {
|
||||
// Get 5 most recent albums
|
||||
setAlbums((data.albums || []).slice(0, 5));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (active) setAlbums([]);
|
||||
} finally {
|
||||
if (active) setLoaded(true);
|
||||
}
|
||||
})();
|
||||
return () => { active = false };
|
||||
}, []);
|
||||
|
||||
const showSetupHint = loaded && albums.length === 0 && !zoneramaUrl;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={3}>
|
||||
<Heading size="lg">Fotogalerie</Heading>
|
||||
<Button as={RouterLink} to="/galerie" size="sm" variant="outline">
|
||||
Zobrazit vše
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{showSetupHint && (
|
||||
<Box bg="yellow.50" borderWidth="1px" borderColor="yellow.200" color="yellow.800" p={3} borderRadius="md" mb={3}>
|
||||
<Text>Žádné fotky nejsou k dispozici. Zadejte prosím odkaz na Zonerama v nastavení (Sociální sítě → Fotogalerie) a my ji budeme automaticky načítat.</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Zonerama Attribution */}
|
||||
{albums.length > 0 && (
|
||||
<Box bg="blue.50" borderWidth="1px" borderColor="blue.200" color="blue.800" p={2} borderRadius="md" mb={3} fontSize="xs">
|
||||
<Text>
|
||||
📸 Fotografie z{' '}
|
||||
<Text
|
||||
as="a"
|
||||
href="https://zonerama.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
fontWeight="600"
|
||||
color="blue.600"
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
Zonerama
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={4}>
|
||||
{albums.map((album) => {
|
||||
const coverPhoto = album.photos && album.photos.length > 0 ? album.photos[0] : null;
|
||||
|
||||
return (
|
||||
<GridItem key={album.id}>
|
||||
<Box
|
||||
as={RouterLink}
|
||||
to={`/galerie/album/${album.id}`}
|
||||
bg="white"
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
boxShadow="sm"
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 'md',
|
||||
}}
|
||||
cursor="pointer"
|
||||
display="block"
|
||||
>
|
||||
{/* Cover Image */}
|
||||
{coverPhoto ? (
|
||||
<Image
|
||||
src={resolveBackendUrl(coverPhoto.image_1500)}
|
||||
alt={album.title}
|
||||
h="180px"
|
||||
w="100%"
|
||||
objectFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
h="180px"
|
||||
w="100%"
|
||||
bg="gray.200"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<ImageIcon size={32} color="gray" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Album Info */}
|
||||
<VStack align="stretch" p={3} spacing={2}>
|
||||
<Heading size="sm" noOfLines={2} color="gray.800">
|
||||
{album.title}
|
||||
</Heading>
|
||||
<HStack spacing={3} fontSize="xs" color="gray.600">
|
||||
{album.date && (
|
||||
<HStack spacing={1}>
|
||||
<Calendar size={14} />
|
||||
<Text>{album.date}</Text>
|
||||
</HStack>
|
||||
)}
|
||||
<HStack spacing={1}>
|
||||
<ImageIcon size={14} />
|
||||
<Text>{album.photos_count} foto</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
</GridItem>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhotosSection;
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
Heading,
|
||||
Text,
|
||||
Spinner,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getPolls, getPoll } from '../../services/polls';
|
||||
import PollCard from '../polls/PollCard';
|
||||
|
||||
interface PollsWidgetProps {
|
||||
featuredOnly?: boolean;
|
||||
maxPolls?: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const PollsWidget: React.FC<PollsWidgetProps> = ({
|
||||
featuredOnly = true,
|
||||
maxPolls = 1,
|
||||
title = 'Hlasování',
|
||||
}) => {
|
||||
const bgSection = useColorModeValue('gray.50', 'gray.900');
|
||||
|
||||
// Fetch polls list
|
||||
const { data: polls, isLoading } = useQuery({
|
||||
queryKey: ['polls', { featured: featuredOnly }],
|
||||
queryFn: () => getPolls(featuredOnly ? { featured: true } : undefined),
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
});
|
||||
|
||||
// Get full poll data for each featured poll
|
||||
const pollsToDisplay = polls?.slice(0, maxPolls) || [];
|
||||
|
||||
const { data: pollsData, isLoading: isLoadingPolls } = useQuery({
|
||||
queryKey: ['polls-details', pollsToDisplay.map((p) => p.id)],
|
||||
queryFn: async () => {
|
||||
const promises = pollsToDisplay.map((poll) => getPoll(poll.id));
|
||||
return await Promise.all(promises);
|
||||
},
|
||||
enabled: pollsToDisplay.length > 0,
|
||||
});
|
||||
|
||||
if (isLoading || isLoadingPolls) {
|
||||
return (
|
||||
<Box bg={bgSection} py={12} px={4}>
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="lg" />
|
||||
<Text>Načítání ankety...</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pollsData || pollsData.length === 0) {
|
||||
return null; // Don't show widget if no polls
|
||||
}
|
||||
|
||||
return (
|
||||
<Box bg={bgSection} py={12} px={4}>
|
||||
<VStack spacing={8} maxW="4xl" mx="auto">
|
||||
<Heading size="lg" textAlign="center">
|
||||
{title}
|
||||
</Heading>
|
||||
|
||||
<VStack spacing={6} w="full">
|
||||
{pollsData.map((pollResponse) => (
|
||||
<Box key={pollResponse.poll.id} w="full" maxW="600px">
|
||||
<PollCard
|
||||
poll={pollResponse.poll}
|
||||
hasVoted={pollResponse.has_voted}
|
||||
isActive={pollResponse.is_active}
|
||||
canShowResults={pollResponse.can_show_results}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PollsWidget;
|
||||
@@ -0,0 +1,121 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
|
||||
// Normalizes various social URL formats to a proper https URL
|
||||
const normalizeSocialUrl = (network: 'facebook' | 'instagram' | 'youtube', raw?: string | null): string | null => {
|
||||
let v = String(raw || '').trim();
|
||||
if (!v) return null;
|
||||
// Replace whitespace
|
||||
v = v.replace(/\s+/g, '');
|
||||
// Accept handle like @club
|
||||
if (v.startsWith('@')) {
|
||||
const handle = v.slice(1);
|
||||
if (network === 'facebook') return `https://www.facebook.com/${handle}`;
|
||||
if (network === 'instagram') return `https://www.instagram.com/${handle}`;
|
||||
if (network === 'youtube') return `https://www.youtube.com/@${handle}`;
|
||||
}
|
||||
// If only a username without slashes
|
||||
if (!/^https?:\/\//i.test(v) && !v.includes('/') && !v.includes('.')) {
|
||||
if (network === 'facebook') return `https://www.facebook.com/${v}`;
|
||||
if (network === 'instagram') return `https://www.instagram.com/${v}`;
|
||||
if (network === 'youtube') return `https://www.youtube.com/@${v}`;
|
||||
}
|
||||
// If looks like domain without scheme
|
||||
if (!/^https?:\/\//i.test(v)) {
|
||||
if (/^facebook\.com\//i.test(v)) return `https://www.${v}`;
|
||||
if (/^instagram\.com\//i.test(v)) return `https://www.${v}`;
|
||||
if (/^youtube\.com\//i.test(v)) return `https://www.${v}`;
|
||||
}
|
||||
return v;
|
||||
};
|
||||
|
||||
const SocialEmbeds: React.FC<{ variant?: 'unified' | 'magazine' | 'pro' | 'edge' }>
|
||||
= ({ variant = 'unified' }) => {
|
||||
const { data: settings } = usePublicSettings();
|
||||
|
||||
const facebookHref = useMemo(() => {
|
||||
const raw = (settings as any)?.facebook_url
|
||||
|| (settings as any)?.facebook
|
||||
|| (settings as any)?.facebookPage
|
||||
|| (settings as any)?.facebook_page
|
||||
|| (settings as any)?.facebookPageUrl
|
||||
|| (settings as any)?.facebook_page_url;
|
||||
return normalizeSocialUrl('facebook', raw);
|
||||
}, [settings]);
|
||||
|
||||
const instagramHref = useMemo(() => {
|
||||
const raw = (settings as any)?.instagram_url
|
||||
|| (settings as any)?.instagram
|
||||
|| (settings as any)?.instagramProfile
|
||||
|| (settings as any)?.instagram_profile
|
||||
|| (settings as any)?.ig
|
||||
|| (settings as any)?.ig_url;
|
||||
return normalizeSocialUrl('instagram', raw);
|
||||
}, [settings]);
|
||||
|
||||
const youtubeHref = useMemo(() => {
|
||||
const raw = (settings as any)?.youtube_url
|
||||
|| (settings as any)?.youtube
|
||||
|| (settings as any)?.yt
|
||||
|| (settings as any)?.youtube_channel;
|
||||
return normalizeSocialUrl('youtube', raw);
|
||||
}, [settings]);
|
||||
|
||||
if (!instagramHref && !youtubeHref) return null;
|
||||
|
||||
const outerStyle: React.CSSProperties = {
|
||||
margin: variant === 'pro' ? '16px 0' : '8px 0',
|
||||
};
|
||||
const gridStyle: React.CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr',
|
||||
gap: 12,
|
||||
};
|
||||
const colStyle: React.CSSProperties = {
|
||||
background: '#fff',
|
||||
borderRadius: 10,
|
||||
border: '1px solid var(--light-gray)',
|
||||
padding: 8,
|
||||
};
|
||||
|
||||
// Instagram profile embed is unofficial; try /embed fallback, else show CTA tile
|
||||
const instagramEmbedSrc = instagramHref ? `${instagramHref.replace(/\/$/, '')}/embed` : null;
|
||||
|
||||
return (
|
||||
<div className={`social-embeds ${variant}`} style={outerStyle}>
|
||||
<div className="section-head" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
|
||||
<h3 style={{ margin: 0 }}>Sledujte nás</h3>
|
||||
<div className="links" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{facebookHref && (<a className="btn" href={facebookHref} target="_blank" rel="noreferrer noopener">Facebook</a>)}
|
||||
{instagramHref && (<a className="btn" href={instagramHref} target="_blank" rel="noreferrer noopener">Instagram</a>)}
|
||||
{youtubeHref && (<a className="btn" href={youtubeHref} target="_blank" rel="noreferrer noopener">YouTube</a>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid" style={gridStyle}>
|
||||
{instagramHref && (
|
||||
<div className="col" style={colStyle}>
|
||||
{instagramEmbedSrc ? (
|
||||
<iframe
|
||||
title="Instagram"
|
||||
src={instagramEmbedSrc}
|
||||
width="100%"
|
||||
height={360}
|
||||
style={{ border: 0, width: '100%' }}
|
||||
frameBorder={0}
|
||||
scrolling="no"
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: 16 }}>
|
||||
<p>Sledujte nás na Instagramu.</p>
|
||||
<a className="btn" href={instagramHref} target="_blank" rel="noreferrer noopener">Otevřít Instagram</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SocialEmbeds;
|
||||
@@ -0,0 +1,266 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Heading, Tabs, TabList, TabPanels, Tab, TabPanel, Table, Thead, Tbody, Tr, Th, Td, Skeleton, Text, Badge, HStack, useColorModeValue } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { facrApi } from '../../services/facr/facrApi';
|
||||
import { FACR_CLUB_ID, FACR_CLUB_TYPE } from '../../config/facr';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import ClubModal from './ClubModal';
|
||||
import { TeamLogo } from '../common/TeamLogo';
|
||||
|
||||
const TableSection: React.FC = () => {
|
||||
const { data: settings } = usePublicSettings();
|
||||
const clubId = settings?.club_id || FACR_CLUB_ID;
|
||||
const clubType = settings?.club_type || FACR_CLUB_TYPE;
|
||||
// movement map: compKey -> teamKey -> delta (prevRank - currentRank)
|
||||
const [movementMap, setMovementMap] = useState<Record<string, Record<string, number>>>({});
|
||||
const [selectedClub, setSelectedClub] = useState<any>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const handleClubClick = (row: any) => {
|
||||
// Transform row data to match ClubModal interface
|
||||
const clubData = {
|
||||
team: row.team || row.team_name || '-',
|
||||
team_id: row.team_id || '',
|
||||
team_logo_url: row.team_logo_url,
|
||||
rank: row.rank,
|
||||
played: row.played,
|
||||
wins: row.wins,
|
||||
draws: row.draws,
|
||||
losses: row.losses,
|
||||
score: row.score,
|
||||
points: row.points,
|
||||
};
|
||||
setSelectedClub(clubData);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
// Theme-aware movement colors (softer in dark mode)
|
||||
const upColor = useColorModeValue('green.400', 'green.300');
|
||||
const downColor = useColorModeValue('red.400', 'red.300');
|
||||
const sameColor = useColorModeValue('gray.300', 'gray.600');
|
||||
// Badge/background colors to avoid white-on-white
|
||||
const badgeBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const badgeText = useColorModeValue('gray.800', 'whiteAlpha.900');
|
||||
const rankTopBg = useColorModeValue('green.100', 'green.600');
|
||||
const rankTopText = useColorModeValue('green.800', 'white');
|
||||
const pointsBg = useColorModeValue('blue.600', 'blue.400');
|
||||
const pointsText = 'white';
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['facr-table', clubId, clubType],
|
||||
queryFn: () => facrApi.getClubTable(clubId, clubType),
|
||||
enabled: Boolean(clubId),
|
||||
staleTime: 1000 * 60 * 3, // 3 minutes
|
||||
retry: 2,
|
||||
retryDelay: attempt => Math.min(1000 * 2 ** attempt, 8000),
|
||||
});
|
||||
|
||||
// After data loads, compare with previous snapshot stored in localStorage to compute movement
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (!data?.competitions?.length) return;
|
||||
const storageKey = `facr_table_prev_${clubId || 'unknown'}_${clubType || 'football'}`;
|
||||
const prevRaw = localStorage.getItem(storageKey);
|
||||
const prev = prevRaw ? JSON.parse(prevRaw) : null;
|
||||
const map: Record<string, Record<string, number>> = {};
|
||||
|
||||
data.competitions.forEach((c: any) => {
|
||||
const compKey = String(c.id ?? c.code ?? c.name ?? 'comp');
|
||||
const prevComp = prev?.competitions?.find((pc: any) => String(pc.id ?? pc.code ?? pc.name) === compKey);
|
||||
const prevRanks: Record<string, number> = {};
|
||||
(prevComp?.table?.overall || []).forEach((r: any, i: number) => {
|
||||
const teamKey = String(r.team_id ?? r.team ?? r.team_name ?? i).toLowerCase();
|
||||
const rank = Number(r.rank ?? (i + 1));
|
||||
prevRanks[teamKey] = rank;
|
||||
});
|
||||
const compMov: Record<string, number> = {};
|
||||
(c.table?.overall || []).forEach((r: any, i: number) => {
|
||||
const teamKeyRaw = String(r.team_id ?? r.team ?? r.team_name ?? i);
|
||||
const teamKey = teamKeyRaw.toLowerCase();
|
||||
const currentRank = Number(r.rank ?? (i + 1));
|
||||
const prevRank = prevRanks[teamKey];
|
||||
if (typeof prevRank === 'number') {
|
||||
compMov[teamKeyRaw] = prevRank - currentRank; // positive => moved up
|
||||
}
|
||||
});
|
||||
map[compKey] = compMov;
|
||||
});
|
||||
|
||||
setMovementMap(map);
|
||||
|
||||
// Save current snapshot for next comparison (trim to essentials)
|
||||
const snapshot = {
|
||||
competitions: (data.competitions || []).map((c: any) => ({
|
||||
id: c.id,
|
||||
code: c.code,
|
||||
name: c.name,
|
||||
table: { overall: (c.table?.overall || []).map((r: any, i: number) => ({
|
||||
team_id: r.team_id,
|
||||
team: r.team,
|
||||
team_name: r.team_name,
|
||||
rank: Number(r.rank ?? (i + 1)),
|
||||
})) },
|
||||
})),
|
||||
};
|
||||
localStorage.setItem(storageKey, JSON.stringify(snapshot));
|
||||
} catch {}
|
||||
}, [data, clubId, clubType]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Heading size="lg" mb={4}>Tabulka soutěží</Heading>
|
||||
{!clubId && (
|
||||
<Text color="orange.500" mb={4}>Nastavte klub v Nastavení (Admin) nebo REACT_APP_FACR_CLUB_ID pro načtení tabulek z FAČR.</Text>
|
||||
)}
|
||||
{isLoading && <Skeleton height="200px" />}
|
||||
{isError && (
|
||||
<Text color="red.500" mb={4}>
|
||||
Nepodařilo se načíst tabulky z FAČR. Zkuste to prosím znovu později.
|
||||
{process.env.NODE_ENV !== 'production' && error instanceof Error ? ` (${error.message})` : ''}
|
||||
</Text>
|
||||
)}
|
||||
{/* Legend for movement */}
|
||||
{!isLoading && !isError && (
|
||||
<HStack spacing={4} mb={2} color="gray.600" fontSize="sm">
|
||||
<HStack spacing={2}>
|
||||
<Box w="10px" h="10px" borderRadius="2px" bg={upColor} />
|
||||
<Text>Lepší pozice</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Box w="10px" h="10px" borderRadius="2px" bg={sameColor} />
|
||||
<Text>Beze změny</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Box w="10px" h="10px" borderRadius="2px" bg={downColor} />
|
||||
<Text>Horší pozice</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
)}
|
||||
{!isLoading && !isError && data && data.competitions?.length > 0 && (
|
||||
<Tabs variant="enclosed" colorScheme="blue" isFitted>
|
||||
<TabList bg={useColorModeValue('white', 'gray.800')} borderRadius="md" borderWidth="1px" borderColor={useColorModeValue('gray.200', 'gray.700')}>
|
||||
{data.competitions?.map((c) => (
|
||||
<Tab
|
||||
key={c.id}
|
||||
_selected={{ bg: useColorModeValue('blue.50', 'blue.900'), color: useColorModeValue('blue.700', 'blue.200'), borderColor: useColorModeValue('blue.200', 'blue.600') }}
|
||||
color={useColorModeValue('gray.800', 'gray.200')}
|
||||
>
|
||||
{c.name}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{data.competitions?.map((c) => (
|
||||
<TabPanel key={c.id} px={0}>
|
||||
<Box maxH="420px" overflowY="auto" borderWidth="1px" borderRadius="md" bg={useColorModeValue('white', 'gray.800')} color={useColorModeValue('gray.800', 'gray.100')} borderColor={useColorModeValue('gray.200', 'gray.700')}>
|
||||
<Table size="sm" variant="striped" colorScheme="gray">
|
||||
<Thead position="sticky" top={0} zIndex={1} bg="brand.primary">
|
||||
<Tr>
|
||||
<Th color="text.onPrimary">#</Th>
|
||||
<Th color="text.onPrimary">Tým</Th>
|
||||
<Th isNumeric color="text.onPrimary">Z</Th>
|
||||
<Th isNumeric color="text.onPrimary">V</Th>
|
||||
<Th isNumeric color="text.onPrimary">R</Th>
|
||||
<Th isNumeric color="text.onPrimary">P</Th>
|
||||
<Th isNumeric color="text.onPrimary">Skóre</Th>
|
||||
<Th isNumeric color="text.onPrimary">Body</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{c.table?.overall?.map((row, idx) => {
|
||||
const compKey = String(c.id ?? c.code ?? c.name ?? 'comp');
|
||||
const teamKeyRaw = String((row as any).team_id ?? (row as any).team ?? (row as any).team_name ?? idx);
|
||||
const deltaStored = movementMap?.[compKey]?.[teamKeyRaw];
|
||||
const movement: 'up' | 'same' | 'down' = typeof deltaStored === 'number' ? (deltaStored > 0 ? 'up' : (deltaStored < 0 ? 'down' : 'same')) : 'same';
|
||||
const deltaVal = typeof deltaStored === 'number' ? deltaStored : 0;
|
||||
const borderCol = movement === 'up' ? upColor : movement === 'down' ? downColor : sameColor;
|
||||
|
||||
const ourClubId = settings?.club_id;
|
||||
const ourClubName = (settings?.club_name || '').toLowerCase();
|
||||
const isOurClub = (ourClubId && row.team_id === ourClubId) || (!!ourClubName && String(row.team || '').toLowerCase() === ourClubName);
|
||||
|
||||
return (
|
||||
<Tr
|
||||
key={`${row.team_id}-${idx}`}
|
||||
_hover={{ bg: useColorModeValue('gray.50', 'gray.700'), cursor: 'pointer' }}
|
||||
bg={idx % 2 === 0 ? useColorModeValue('white', 'gray.800') : useColorModeValue('gray.50', 'gray.750')}
|
||||
sx={{ borderLeftWidth: '4px', borderLeftStyle: 'solid', borderLeftColor: borderCol }}
|
||||
onClick={() => handleClubClick(row)}
|
||||
>
|
||||
<Td>
|
||||
<Badge
|
||||
variant="subtle"
|
||||
bg={idx <= 2 ? rankTopBg : badgeBg}
|
||||
color={idx <= 2 ? rankTopText : badgeText}
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'whiteAlpha.300')}
|
||||
>
|
||||
{row.rank}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<HStack spacing={2} align="center">
|
||||
<TeamLogo
|
||||
teamId={row.team_id}
|
||||
teamName={row.team}
|
||||
facrLogo={row.team_logo_url}
|
||||
size="small"
|
||||
alt={row.team}
|
||||
borderRadius="full"
|
||||
bg="white"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'whiteAlpha.300')}
|
||||
/>
|
||||
<Text as="span" color={isOurClub ? 'brand.primary' : useColorModeValue('gray.800', 'gray.100')} fontWeight={isOurClub ? 'bold' : 'normal'}>
|
||||
{row.team}
|
||||
</Text>
|
||||
<Text as="span" fontSize="xs" color={movement === 'up' ? 'green.500' : movement === 'down' ? 'red.500' : 'gray.500'}>
|
||||
{movement === 'up' ? '▲' : movement === 'down' ? '▼' : '•'}
|
||||
</Text>
|
||||
</HStack>
|
||||
{deltaVal !== 0 && (
|
||||
<Badge
|
||||
ml={2}
|
||||
variant="subtle"
|
||||
bg={movement === 'up' ? 'green.100' : movement === 'down' ? 'red.100' : badgeBg}
|
||||
color={movement === 'up' ? 'green.700' : movement === 'down' ? 'red.700' : badgeText}
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('green.200', movement === 'down' ? 'red.300' : 'whiteAlpha.300')}
|
||||
>
|
||||
{movement === 'up' ? `+${deltaVal}` : `${deltaVal}`}
|
||||
</Badge>
|
||||
)}
|
||||
</Td>
|
||||
<Td isNumeric>{row.played}</Td>
|
||||
<Td isNumeric>{row.wins}</Td>
|
||||
<Td isNumeric>{row.draws}</Td>
|
||||
<Td isNumeric>{row.losses}</Td>
|
||||
<Td isNumeric>{row.score}</Td>
|
||||
<Td isNumeric>
|
||||
<Badge variant="solid" bg={pointsBg} color={pointsText}>{row.points}</Badge>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
)}
|
||||
{!isLoading && !isError && data && (!data.competitions || data.competitions.length === 0) && (
|
||||
<Text color="gray.500">Pro tento klub nejsou dostupné tabulky.</Text>
|
||||
)}
|
||||
|
||||
<ClubModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
club={selectedClub}
|
||||
clubType={clubType as 'football' | 'futsal'}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableSection;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Box, Heading, HStack, VStack, Image, Text, useColorModeValue } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getPlayers, Player } from '../../services/players';
|
||||
|
||||
const TeamScroller: React.FC = () => {
|
||||
const { data } = useQuery({ queryKey: ['players'], queryFn: getPlayers });
|
||||
const players = (data || []).filter(p => p.is_active);
|
||||
if (!players.length) return null;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Heading size="lg" mb={4} textAlign="center">Náš tým</Heading>
|
||||
<HStack spacing={6} overflowX="auto" py={2} className="hide-scrollbar">
|
||||
{players.map((p: Player) => (
|
||||
<VStack key={p.id} minW="160px" spacing={2} bg={useColorModeValue('white', 'gray.800')} borderRadius="xl" p={4} boxShadow="sm" borderWidth="1px" borderColor={useColorModeValue('gray.200', 'gray.700')}>
|
||||
<Image src={p.image_url || '/logo192.png'} alt={p.first_name + ' ' + p.last_name} w="140px" h="140px" objectFit="cover" borderRadius="lg" />
|
||||
<Text fontWeight="bold" textAlign="center">{p.first_name} {p.last_name}</Text>
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>{p.position}</Text>
|
||||
</VStack>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamScroller;
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import ContactMap from './ContactMap';
|
||||
import VectorMap from './VectorMap';
|
||||
|
||||
interface UnifiedMapProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zoom?: number;
|
||||
address?: string;
|
||||
clubName?: string;
|
||||
mapStyle?: string;
|
||||
height?: number;
|
||||
clubPrimaryColor?: string;
|
||||
clubSecondaryColor?: string;
|
||||
useVectorMaps?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified Map Component
|
||||
*
|
||||
* Automatically chooses between raster (Leaflet) and vector (MapLibre GL) maps
|
||||
* based on the useVectorMaps prop or environment configuration.
|
||||
*
|
||||
* Usage:
|
||||
* <UnifiedMap
|
||||
* latitude={50.0755}
|
||||
* longitude={14.4378}
|
||||
* useVectorMaps={true} // or from settings
|
||||
* />
|
||||
*/
|
||||
const UnifiedMap: React.FC<UnifiedMapProps> = ({
|
||||
useVectorMaps = false,
|
||||
...props
|
||||
}) => {
|
||||
// Map style conversion: raster styles to vector equivalents
|
||||
const getVectorStyle = (rasterStyle?: string): 'positron' | 'dark-matter' | 'osm-bright' | 'klokantech-basic' => {
|
||||
const styleMap: Record<string, any> = {
|
||||
'default': 'osm-bright',
|
||||
'positron': 'positron',
|
||||
'positron-no-labels': 'positron',
|
||||
'dark': 'dark-matter',
|
||||
'dark-no-labels': 'dark-matter',
|
||||
'dark-matter': 'dark-matter',
|
||||
'toner': 'klokantech-basic',
|
||||
'toner-lite': 'klokantech-basic',
|
||||
'voyager': 'osm-bright',
|
||||
'osm-bright': 'osm-bright',
|
||||
'klokantech-basic': 'klokantech-basic',
|
||||
};
|
||||
|
||||
return styleMap[rasterStyle || 'default'] || 'positron';
|
||||
};
|
||||
|
||||
if (useVectorMaps) {
|
||||
// Use vector maps (MapLibre GL JS)
|
||||
return (
|
||||
<VectorMap
|
||||
{...props}
|
||||
mapStyle={getVectorStyle(props.mapStyle)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// Use raster maps (Leaflet)
|
||||
return (
|
||||
<ContactMap
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default UnifiedMap;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Box, Flex, Heading, Text, HStack, Image, Button } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { facrApi } from '../../services/facr/facrApi';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
|
||||
function formatCountdown(dt: string) {
|
||||
const target = new Date(dt).getTime();
|
||||
const diff = target - Date.now();
|
||||
if (isNaN(target) || diff <= 0) return '';
|
||||
const d = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
const h = Math.floor((diff / (1000 * 60 * 60)) % 24);
|
||||
const m = Math.floor((diff / (1000 * 60)) % 60);
|
||||
return `${String(d).padStart(2,'0')}d ${String(h).padStart(2,'0')}h ${String(m).padStart(2,'0')}m`;
|
||||
}
|
||||
|
||||
const UpcomingBanner: React.FC = () => {
|
||||
const { data: settings } = usePublicSettings();
|
||||
const clubId = settings?.club_id;
|
||||
const clubType = settings?.club_type || 'football';
|
||||
const theme = useClubTheme();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['facr-club', clubId, clubType],
|
||||
queryFn: () => facrApi.getClub(clubId!, clubType as any),
|
||||
enabled: !!clubId,
|
||||
});
|
||||
|
||||
const allMatches = (data?.competitions || []).flatMap(c => c.matches || []);
|
||||
const upcoming = allMatches
|
||||
.map(m => ({ m, t: new Date(m.date_time).getTime() }))
|
||||
.filter(x => !isNaN(x.t) && x.t > Date.now())
|
||||
.sort((a, b) => a.t - b.t)[0]?.m;
|
||||
|
||||
if (!upcoming) return null;
|
||||
|
||||
return (
|
||||
<Box bg={theme.primary} color="white" borderRadius="xl" p={{ base: 4, md: 6 }} shadow="md">
|
||||
<Text fontSize="sm" opacity={0.9} fontWeight="600">Nadcházející zápas</Text>
|
||||
<Flex align="center" justify="space-between" gap={4} mt={2} direction={{ base: 'column', md: 'row' }}>
|
||||
<HStack spacing={4} flex={1} justify="center">
|
||||
<HStack>
|
||||
<Image src={upcoming.home_logo_url} alt={upcoming.home} boxSize={{ base: '36px', md: '48px' }} objectFit="contain" />
|
||||
<Text fontWeight="600">{upcoming.home}</Text>
|
||||
</HStack>
|
||||
<Heading size="md">vs</Heading>
|
||||
<HStack>
|
||||
<Image src={upcoming.away_logo_url} alt={upcoming.away} boxSize={{ base: '36px', md: '48px' }} objectFit="contain" />
|
||||
<Text fontWeight="600">{upcoming.away}</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<HStack spacing={6}>
|
||||
<Box textAlign="center">
|
||||
<Text fontSize="xs" opacity={0.8}>KICKOFF</Text>
|
||||
<Heading size="sm">{new Date(upcoming.date_time).toLocaleString()}</Heading>
|
||||
</Box>
|
||||
<Box textAlign="center">
|
||||
<Text fontSize="xs" opacity={0.8}>ZAČÍNÁ ZA</Text>
|
||||
<Heading size="sm">{formatCountdown(upcoming.date_time)}</Heading>
|
||||
</Box>
|
||||
</HStack>
|
||||
{upcoming.report_url && (
|
||||
<Button as="a" href={upcoming.report_url} target="_blank" colorScheme="red" variant="solid">Detail</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpcomingBanner;
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { Box, HStack, VStack, Text, Heading, Tabs, TabList, TabPanels, Tab, TabPanel, Image, Button } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { facrApi } from '../../services/facr/facrApi';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
|
||||
function formatCountdown(dt?: string) {
|
||||
if (!dt) return '';
|
||||
const target = new Date(dt).getTime();
|
||||
const diff = target - Date.now();
|
||||
if (isNaN(target) || diff <= 0) return '';
|
||||
const d = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
const h = Math.floor((diff / (1000 * 60 * 60)) % 24);
|
||||
const m = Math.floor((diff / (1000 * 60)) % 60);
|
||||
return `${String(d).padStart(2,'0')}d ${String(h).padStart(2,'0')}h ${String(m).padStart(2,'0')}m`;
|
||||
}
|
||||
|
||||
const UpcomingSwitch: React.FC = () => {
|
||||
const { data: settings } = usePublicSettings();
|
||||
const clubId = settings?.club_id;
|
||||
const clubType = (settings?.club_type || 'football') as any;
|
||||
const theme = useClubTheme();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['facr-club', clubId, clubType],
|
||||
queryFn: () => facrApi.getClub(clubId!, clubType),
|
||||
enabled: !!clubId,
|
||||
});
|
||||
|
||||
const comps = data?.competitions || [];
|
||||
|
||||
if (!comps.length) return null;
|
||||
|
||||
return (
|
||||
<Tabs variant="unstyled" colorScheme="whiteAlpha">
|
||||
<TabList gap={2} flexWrap={{ base: 'wrap', md: 'nowrap' }}>
|
||||
{comps.map((c) => (
|
||||
<Tab
|
||||
key={c.id}
|
||||
px={3}
|
||||
py={2}
|
||||
borderRadius="full"
|
||||
bg="whiteAlpha.200"
|
||||
color="white"
|
||||
_selected={{ bg: 'white', color: 'black' }}
|
||||
>
|
||||
{c.name}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{comps.map((c) => {
|
||||
const upcoming = (c.matches || [])
|
||||
.map((m) => ({ m, t: new Date(m.date_time).getTime() }))
|
||||
.filter((x) => !isNaN(x.t) && x.t > Date.now())
|
||||
.sort((a, b) => a.t - b.t)[0]?.m;
|
||||
|
||||
if (!upcoming) {
|
||||
return (
|
||||
<TabPanel key={c.id} px={0}>
|
||||
<Box py={6} textAlign="center" color="whiteAlpha.800">Žádný nadcházející zápas.</Box>
|
||||
</TabPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TabPanel key={c.id} px={0}>
|
||||
<HStack spacing={6} align="center" justify="space-between" flexWrap={{ base: 'wrap', md: 'nowrap' }}>
|
||||
<HStack spacing={4} flex={1} minW={0} justify="center">
|
||||
<HStack minW={0}>
|
||||
<Image src={upcoming.home_logo_url} alt={upcoming.home} boxSize={{ base: '32px', md: '44px' }} objectFit="contain" />
|
||||
<Text fontWeight={700} color="white" noOfLines={1}>{upcoming.home}</Text>
|
||||
</HStack>
|
||||
<Heading size="sm" color="white">vs</Heading>
|
||||
<HStack minW={0}>
|
||||
<Image src={upcoming.away_logo_url} alt={upcoming.away} boxSize={{ base: '32px', md: '44px' }} objectFit="contain" />
|
||||
<Text fontWeight={700} color="white" noOfLines={1}>{upcoming.away}</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<HStack spacing={6}>
|
||||
<Box textAlign="center" color="white">
|
||||
<Text fontSize="xs" opacity={0.85}>KICKOFF</Text>
|
||||
<Heading size="sm">{new Date(upcoming.date_time).toLocaleString()}</Heading>
|
||||
</Box>
|
||||
<Box textAlign="center" color="white">
|
||||
<Text fontSize="xs" opacity={0.85}>ZAČÍNÁ ZA</Text>
|
||||
<Heading size="sm">{formatCountdown(upcoming.date_time)}</Heading>
|
||||
</Box>
|
||||
</HStack>
|
||||
{upcoming.report_url && (
|
||||
<Button as="a" href={upcoming.report_url} target="_blank" bg={theme.primary} color="white" _hover={{ bg: theme.accent }}>
|
||||
Detail zápasu
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</TabPanel>
|
||||
);
|
||||
})}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpcomingSwitch;
|
||||
@@ -0,0 +1,323 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
|
||||
interface VectorMapProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zoom?: number;
|
||||
address?: string;
|
||||
clubName?: string;
|
||||
mapStyle?: 'positron' | 'dark-matter' | 'osm-bright' | 'klokantech-basic';
|
||||
height?: number;
|
||||
clubPrimaryColor?: string;
|
||||
clubSecondaryColor?: string;
|
||||
customStyleUrl?: string;
|
||||
}
|
||||
|
||||
// OpenMapTiles free demo server (for development/testing)
|
||||
// For production, use your own tile server or a commercial provider
|
||||
const MAPTILER_API_KEY = process.env.REACT_APP_MAPTILER_KEY || 'get_your_own_OpIi9ZULNHzrESv6T2vL';
|
||||
|
||||
// Vector tile style definitions
|
||||
export const VECTOR_STYLES = {
|
||||
'positron': {
|
||||
name: 'Positron (Light)',
|
||||
description: 'Clean light style, perfect for data visualization',
|
||||
getStyleUrl: (apiKey: string) => `https://api.maptiler.com/maps/positron/style.json?key=${apiKey}`,
|
||||
},
|
||||
'dark-matter': {
|
||||
name: 'Dark Matter',
|
||||
description: 'Sleek dark theme for modern interfaces',
|
||||
getStyleUrl: (apiKey: string) => `https://api.maptiler.com/maps/darkmatter/style.json?key=${apiKey}`,
|
||||
},
|
||||
'osm-bright': {
|
||||
name: 'OSM Bright',
|
||||
description: 'Colorful OpenStreetMap style',
|
||||
getStyleUrl: (apiKey: string) => `https://api.maptiler.com/maps/bright/style.json?key=${apiKey}`,
|
||||
},
|
||||
'klokantech-basic': {
|
||||
name: 'Basic',
|
||||
description: 'Simple and clean base map',
|
||||
getStyleUrl: (apiKey: string) => `https://api.maptiler.com/maps/basic/style.json?key=${apiKey}`,
|
||||
},
|
||||
};
|
||||
|
||||
// Custom Positron-like style with club colors (self-hosted tiles not required)
|
||||
const createCustomPositronStyle = (primaryColor?: string, secondaryColor?: string): any => {
|
||||
const mainColor = primaryColor || '#e11d48';
|
||||
const accentColor = secondaryColor || '#3b82f6';
|
||||
|
||||
return {
|
||||
version: 8,
|
||||
name: 'Custom Positron',
|
||||
sources: {
|
||||
'openmaptiles': {
|
||||
type: 'vector',
|
||||
url: `https://api.maptiler.com/tiles/v3/tiles.json?key=${MAPTILER_API_KEY}`,
|
||||
},
|
||||
},
|
||||
glyphs: 'https://api.maptiler.com/fonts/{fontstack}/{range}.pbf?key=' + MAPTILER_API_KEY,
|
||||
layers: [
|
||||
// Background
|
||||
{
|
||||
id: 'background',
|
||||
type: 'background',
|
||||
paint: { 'background-color': '#f8f8f8' },
|
||||
},
|
||||
// Water
|
||||
{
|
||||
id: 'water',
|
||||
type: 'fill',
|
||||
source: 'openmaptiles',
|
||||
'source-layer': 'water',
|
||||
paint: { 'fill-color': '#e3e8ed' },
|
||||
},
|
||||
// Parks
|
||||
{
|
||||
id: 'park',
|
||||
type: 'fill',
|
||||
source: 'openmaptiles',
|
||||
'source-layer': 'park',
|
||||
paint: { 'fill-color': '#e8f5e8' },
|
||||
},
|
||||
// Buildings
|
||||
{
|
||||
id: 'building',
|
||||
type: 'fill',
|
||||
source: 'openmaptiles',
|
||||
'source-layer': 'building',
|
||||
paint: {
|
||||
'fill-color': '#ececec',
|
||||
'fill-opacity': 0.6,
|
||||
},
|
||||
},
|
||||
// Roads - major
|
||||
{
|
||||
id: 'road-major',
|
||||
type: 'line',
|
||||
source: 'openmaptiles',
|
||||
'source-layer': 'transportation',
|
||||
filter: ['in', 'class', 'motorway', 'trunk', 'primary'],
|
||||
paint: {
|
||||
'line-color': '#ffffff',
|
||||
'line-width': {
|
||||
base: 1.4,
|
||||
stops: [[6, 0.5], [20, 30]],
|
||||
},
|
||||
},
|
||||
},
|
||||
// Roads - minor
|
||||
{
|
||||
id: 'road-minor',
|
||||
type: 'line',
|
||||
source: 'openmaptiles',
|
||||
'source-layer': 'transportation',
|
||||
filter: ['in', 'class', 'secondary', 'tertiary', 'minor'],
|
||||
paint: {
|
||||
'line-color': '#ffffff',
|
||||
'line-width': {
|
||||
base: 1.4,
|
||||
stops: [[6, 0.25], [20, 20]],
|
||||
},
|
||||
},
|
||||
},
|
||||
// Place labels
|
||||
{
|
||||
id: 'place-label',
|
||||
type: 'symbol',
|
||||
source: 'openmaptiles',
|
||||
'source-layer': 'place',
|
||||
layout: {
|
||||
'text-field': '{name}',
|
||||
'text-font': ['Noto Sans Regular'],
|
||||
'text-size': {
|
||||
base: 1.2,
|
||||
stops: [[7, 11], [15, 14]],
|
||||
},
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#666666',
|
||||
'text-halo-color': '#ffffff',
|
||||
'text-halo-width': 1.5,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const VectorMap: React.FC<VectorMapProps> = ({
|
||||
latitude,
|
||||
longitude,
|
||||
zoom = 15,
|
||||
address,
|
||||
clubName,
|
||||
mapStyle = 'positron',
|
||||
height = 400,
|
||||
clubPrimaryColor,
|
||||
clubSecondaryColor,
|
||||
customStyleUrl,
|
||||
}) => {
|
||||
const mapContainer = useRef<HTMLDivElement>(null);
|
||||
const map = useRef<maplibregl.Map | null>(null);
|
||||
const marker = useRef<maplibregl.Marker | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapContainer.current || map.current) return;
|
||||
|
||||
try {
|
||||
// Determine style URL
|
||||
let styleUrl: string | any;
|
||||
|
||||
if (customStyleUrl) {
|
||||
styleUrl = customStyleUrl;
|
||||
} else if (mapStyle === 'positron' && clubPrimaryColor) {
|
||||
// Use custom style with club colors
|
||||
styleUrl = createCustomPositronStyle(clubPrimaryColor, clubSecondaryColor);
|
||||
} else {
|
||||
// Use predefined style
|
||||
styleUrl = VECTOR_STYLES[mapStyle]?.getStyleUrl(MAPTILER_API_KEY) ||
|
||||
VECTOR_STYLES.positron.getStyleUrl(MAPTILER_API_KEY);
|
||||
}
|
||||
|
||||
// Initialize map
|
||||
map.current = new maplibregl.Map({
|
||||
container: mapContainer.current,
|
||||
style: styleUrl,
|
||||
center: [longitude, latitude],
|
||||
zoom: zoom,
|
||||
});
|
||||
|
||||
// Add navigation controls
|
||||
map.current.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||||
|
||||
// Create custom marker with club color
|
||||
const markerColor = clubPrimaryColor || '#e11d48';
|
||||
|
||||
// Create marker element
|
||||
const el = document.createElement('div');
|
||||
el.style.width = '36px';
|
||||
el.style.height = '54px';
|
||||
el.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 36" width="36" height="54">
|
||||
<defs>
|
||||
<filter id="marker-shadow-${Date.now()}" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
|
||||
<feOffset dx="0" dy="2" result="offsetblur"/>
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.3"/>
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<path fill="${markerColor}" stroke="#fff" stroke-width="1.5"
|
||||
filter="url(#marker-shadow-${Date.now()})"
|
||||
d="M12 0C7.03 0 3 4.03 3 9c0 7.5 9 18 9 18s9-10.5 9-18c0-4.97-4.03-9-9-9z"/>
|
||||
<circle cx="12" cy="9" r="3" fill="#fff"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// Add marker to map
|
||||
marker.current = new maplibregl.Marker({ element: el })
|
||||
.setLngLat([longitude, latitude])
|
||||
.addTo(map.current);
|
||||
|
||||
// Add popup if there's content
|
||||
if (clubName || address) {
|
||||
let popupContent = '';
|
||||
if (clubName) popupContent += `<strong>${clubName}</strong><br>`;
|
||||
if (address) popupContent += address;
|
||||
|
||||
const popup = new maplibregl.Popup({ offset: 25 })
|
||||
.setHTML(popupContent);
|
||||
|
||||
marker.current.setPopup(popup);
|
||||
}
|
||||
|
||||
// Handle map load event for additional customization
|
||||
map.current.on('load', () => {
|
||||
if (!map.current) return;
|
||||
|
||||
// Apply club color tint to water features if primary color is set
|
||||
if (clubPrimaryColor && map.current.getLayer('water')) {
|
||||
map.current.setPaintProperty('water', 'fill-color',
|
||||
adjustColorBrightness(clubPrimaryColor, 0.9));
|
||||
}
|
||||
});
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('Error initializing map:', err);
|
||||
setError(err?.message || 'Failed to load map');
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (marker.current) {
|
||||
marker.current.remove();
|
||||
marker.current = null;
|
||||
}
|
||||
if (map.current) {
|
||||
map.current.remove();
|
||||
map.current = null;
|
||||
}
|
||||
};
|
||||
}, [latitude, longitude, zoom, mapStyle, clubPrimaryColor, clubSecondaryColor, customStyleUrl]);
|
||||
|
||||
// Update marker and center when coordinates change
|
||||
useEffect(() => {
|
||||
if (!map.current || !marker.current) return;
|
||||
|
||||
const newCenter: [number, number] = [longitude, latitude];
|
||||
marker.current.setLngLat(newCenter);
|
||||
map.current.setCenter(newCenter);
|
||||
}, [latitude, longitude]);
|
||||
|
||||
// Helper function to adjust color brightness
|
||||
function adjustColorBrightness(color: string, factor: number): string {
|
||||
try {
|
||||
// Simple RGB adjustment
|
||||
const hex = color.replace('#', '');
|
||||
const r = Math.min(255, Math.floor(parseInt(hex.substring(0, 2), 16) * factor));
|
||||
const g = Math.min(255, Math.floor(parseInt(hex.substring(2, 4), 16) * factor));
|
||||
const b = Math.min(255, Math.floor(parseInt(hex.substring(4, 6), 16) * factor));
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
} catch {
|
||||
return color;
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box
|
||||
w="100%"
|
||||
h={`${height}px`}
|
||||
bg="gray.100"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
borderRadius="md"
|
||||
p={4}
|
||||
>
|
||||
{error}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={mapContainer}
|
||||
w="100%"
|
||||
h={`${height}px`}
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
boxShadow="md"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default VectorMap;
|
||||
@@ -0,0 +1,337 @@
|
||||
import { Box, AspectRatio, Text, useColorModeValue, SimpleGrid, Heading, HStack, Badge, Button, Link, Modal, ModalOverlay, ModalContent, ModalBody, ModalCloseButton, useDisclosure, Icon, VStack } from '@chakra-ui/react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { FaYoutube, FaPlay } from 'react-icons/fa';
|
||||
import HorizontalScroller from '../ui/HorizontalScroller';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
// optional manual override
|
||||
videos?: string[];
|
||||
};
|
||||
|
||||
type RenderItem = {
|
||||
key: string;
|
||||
title: string;
|
||||
embedUrl: string;
|
||||
thumbnail?: string;
|
||||
date?: string; // YYYY-MM-DD
|
||||
videoId?: string;
|
||||
};
|
||||
|
||||
const toEmbed = (idOrUrl: string): string => {
|
||||
// If a full URL is passed, try to extract the id; otherwise assume it's already an id
|
||||
// supports https://www.youtube.com/watch?v=ID or youtu.be/ID
|
||||
try {
|
||||
if (idOrUrl.includes('youtube.com') || idOrUrl.includes('youtu.be')) {
|
||||
const u = new URL(idOrUrl);
|
||||
if (u.hostname.includes('youtu.be')) {
|
||||
const id = u.pathname.replace('/', '');
|
||||
return `https://www.youtube.com/embed/${id}`;
|
||||
}
|
||||
const id = u.searchParams.get('v');
|
||||
if (id) return `https://www.youtube.com/embed/${id}`;
|
||||
}
|
||||
} catch {}
|
||||
// otherwise treat as id
|
||||
return `https://www.youtube.com/embed/${idOrUrl}`;
|
||||
};
|
||||
|
||||
const VideosSection: React.FC<Props> = ({ videos }) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const theme = useClubTheme();
|
||||
const { data: settings } = usePublicSettings();
|
||||
const [yt, setYt] = useState<YouTubeVideo[]>([]);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [selectedVideo, setSelectedVideo] = useState<RenderItem | null>(null);
|
||||
// If admin explicitly disabled, respect it. Otherwise default to ON when there are manual videos configured
|
||||
// or when a YouTube URL is present for auto mode.
|
||||
const hasManualConfigured = Boolean((settings as any)?.videos_items?.length || (settings as any)?.videos?.length);
|
||||
const hasAutoConfigured = Boolean((settings as any)?.youtube_url || (settings as any)?.social_youtube);
|
||||
// Default enablement: if not explicitly set, enable when manual items exist or when a YouTube URL is configured (auto mode).
|
||||
// This avoids flicker caused by toggling visibility while data is loading.
|
||||
const enabled = (typeof (settings as any)?.videos_module_enabled === 'boolean')
|
||||
? Boolean((settings as any)?.videos_module_enabled)
|
||||
: (hasManualConfigured || ((settings?.videos_source || 'auto') === 'auto' && hasAutoConfigured));
|
||||
const style = settings?.videos_style || 'slider';
|
||||
const source = settings?.videos_source || 'auto';
|
||||
// Default to 6 items on homepage unless overridden by settings (max 12)
|
||||
const limit = Math.max(1, Math.min(12, settings?.videos_limit ?? 6));
|
||||
const youtubeUrl = (settings as any)?.youtube_url || (settings as any)?.social_youtube || null;
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
const run = async () => {
|
||||
if (source !== 'auto') return;
|
||||
const payload = await getCachedYouTube();
|
||||
if (!payload) return;
|
||||
// Sort by published_date descending (safety; service should already do this)
|
||||
const vids = (payload.videos || []).slice().sort((a, b) => (Date.parse(b.published_date || '') || 0) - (Date.parse(a.published_date || '') || 0));
|
||||
if (!canceled) setYt(vids);
|
||||
};
|
||||
run();
|
||||
return () => { canceled = true; };
|
||||
}, [source]);
|
||||
|
||||
const extractVideoId = (embedUrl: string): string | undefined => {
|
||||
if (embedUrl?.includes('/embed/')) {
|
||||
return embedUrl.split('/embed/')[1]?.split('?')[0];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const items: RenderItem[] = useMemo(() => {
|
||||
if (source === 'auto') {
|
||||
return (yt || []).slice(0, limit).map(v => ({
|
||||
key: v.video_id,
|
||||
title: v.title,
|
||||
embedUrl: toEmbed(v.video_id),
|
||||
thumbnail: v.thumbnail_url,
|
||||
date: v.published_date,
|
||||
videoId: v.video_id,
|
||||
}));
|
||||
}
|
||||
// manual fallback from settings or prop
|
||||
const manual = (settings?.videos_items || []).map((it, i) => {
|
||||
const embedUrl = toEmbed(it.url);
|
||||
return {
|
||||
key: `${i}-${it.url}`,
|
||||
title: it.title || `Video ${i+1}`,
|
||||
embedUrl,
|
||||
thumbnail: it.thumbnail_url,
|
||||
date: it.uploaded_at,
|
||||
videoId: extractVideoId(embedUrl),
|
||||
};
|
||||
});
|
||||
const legacy = (videos || settings?.videos || []).map((url, i) => {
|
||||
const embedUrl = toEmbed(url as any);
|
||||
return {
|
||||
key: `${i}-${url}`,
|
||||
title: `Video ${i+1}`,
|
||||
embedUrl,
|
||||
videoId: extractVideoId(embedUrl),
|
||||
};
|
||||
});
|
||||
return (manual.length ? manual : legacy).slice(0, limit);
|
||||
}, [source, yt, settings?.videos_items, settings?.videos, videos, limit]);
|
||||
|
||||
if (!enabled || items.length === 0) return null;
|
||||
|
||||
const handlePlayClick = (it: RenderItem) => {
|
||||
setSelectedVideo(it);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const Card: React.FC<{ it: RenderItem; idx: number }> = ({ it, idx }) => {
|
||||
const thumb = it.thumbnail || (it.videoId ? `https://i.ytimg.com/vi/${it.videoId}/hqdefault.jpg` : undefined);
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const placeholderBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const placeholderIcon = useColorModeValue('gray.400', 'gray.500');
|
||||
const videoPrimaryColor = theme.primary;
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={cardBg}
|
||||
borderRadius="xl"
|
||||
overflow="hidden"
|
||||
boxShadow="sm"
|
||||
borderWidth="2px"
|
||||
borderColor={borderColor}
|
||||
transition="all 0.3s"
|
||||
position="relative"
|
||||
sx={{
|
||||
'&:hover': {
|
||||
transform: 'translateY(-8px)',
|
||||
boxShadow: '0 20px 40px rgba(0,0,0,0.15)',
|
||||
borderColor: 'brand.primary',
|
||||
},
|
||||
'&:hover .play-overlay': {
|
||||
opacity: 1,
|
||||
},
|
||||
'&:hover .play-overlay > div': {
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<Box position="relative" cursor="pointer" onClick={() => handlePlayClick(it)}>
|
||||
{/* Thumbnail */}
|
||||
{thumb ? (
|
||||
<Box
|
||||
as="img"
|
||||
src={thumb}
|
||||
alt={it.title}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<Box bg={placeholderBg} display="flex" alignItems="center" justifyContent="center">
|
||||
<Icon as={FaPlay} boxSize={12} color={placeholderIcon} />
|
||||
</Box>
|
||||
)}
|
||||
{/* Play overlay */}
|
||||
<Box
|
||||
className="play-overlay"
|
||||
position="absolute"
|
||||
inset={0}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="blackAlpha.700"
|
||||
opacity={0}
|
||||
transition="opacity 0.3s ease"
|
||||
pointerEvents="none"
|
||||
>
|
||||
<Box
|
||||
bg="white"
|
||||
color="brand.primary"
|
||||
borderRadius="full"
|
||||
px={8}
|
||||
py={4}
|
||||
fontWeight="bold"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
transform="scale(0.9)"
|
||||
transition="transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||
boxShadow="0 12px 32px rgba(0,0,0,0.4)"
|
||||
>
|
||||
<Icon as={FaPlay} boxSize={5} />
|
||||
<Text fontSize="lg">Přehrát</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</AspectRatio>
|
||||
<Box p={4} borderTopWidth="2px" borderTopColor={videoPrimaryColor}>
|
||||
<VStack align="start" spacing={2}>
|
||||
<Text fontWeight="bold" fontSize="md" color={videoPrimaryColor} noOfLines={2}>
|
||||
{it.title}
|
||||
</Text>
|
||||
<HStack justify="space-between" width="100%">
|
||||
{it.date && (
|
||||
<Badge colorScheme="gray" fontSize="0.7rem">
|
||||
{new Date(it.date).toLocaleDateString('cs-CZ')}
|
||||
</Badge>
|
||||
)}
|
||||
{it.videoId && (
|
||||
<Link href={`https://www.youtube.com/watch?v=${it.videoId}`} isExternal onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
leftIcon={<Icon as={FaYoutube} />}
|
||||
>
|
||||
YouTube
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
if (style === 'slider') {
|
||||
return (
|
||||
<Box>
|
||||
<Box className="section-head" style={{ marginTop: 8, marginBottom: 16 }}>
|
||||
<HStack spacing={3}>
|
||||
<Heading as="h3" size="lg" fontWeight="700">Videa</Heading>
|
||||
</HStack>
|
||||
<Link as={RouterLink} to="/videa">
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
bg={theme.primary}
|
||||
color="white"
|
||||
rightIcon={<Box as="span">→</Box>}
|
||||
_hover={{ opacity: 0.9, transform: 'translateX(4px)' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
Více videí
|
||||
</Button>
|
||||
</Link>
|
||||
</Box>
|
||||
<HorizontalScroller draggable>
|
||||
{items.map((it, idx) => (
|
||||
<Box
|
||||
key={it.key}
|
||||
minW={{ base: '85%', md: '60%', lg: '33%' }}
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
>
|
||||
<Card it={it} idx={idx} />
|
||||
</Box>
|
||||
))}
|
||||
</HorizontalScroller>
|
||||
|
||||
{/* Video Modal */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
|
||||
<ModalOverlay bg="blackAlpha.800" />
|
||||
<ModalContent bg="transparent" boxShadow="none" maxW="90vw">
|
||||
<ModalCloseButton color="white" size="lg" bg="blackAlpha.600" _hover={{ bg: 'blackAlpha.700' }} borderRadius="full" zIndex={2} />
|
||||
<ModalBody p={0}>
|
||||
{selectedVideo && (
|
||||
<AspectRatio ratio={16 / 9} maxH="90vh">
|
||||
<iframe
|
||||
src={`${selectedVideo.embedUrl}?autoplay=1&vq=hd1080&rel=0&modestbranding=1&playsinline=1`}
|
||||
title={selectedVideo.title}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
style={{ borderRadius: '8px' }}
|
||||
/>
|
||||
</AspectRatio>
|
||||
)}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const cols = style === 'grid3' ? { base: 1, md: 3 } : { base: 1, md: 2, lg: 3 };
|
||||
return (
|
||||
<Box>
|
||||
<Box className="section-head">
|
||||
<Heading as="h3" size="md">Videa</Heading>
|
||||
<Link as={RouterLink} to="/videa">
|
||||
<Button size="sm" variant="outline" colorScheme="blue">Více videí</Button>
|
||||
</Link>
|
||||
</Box>
|
||||
<SimpleGrid columns={cols} spacing={4}>
|
||||
{items.map((it, idx) => (
|
||||
<Card key={it.key} it={it} idx={idx} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Video Modal */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
|
||||
<ModalOverlay bg="blackAlpha.800" />
|
||||
<ModalContent bg="transparent" boxShadow="none" maxW="90vw">
|
||||
<ModalCloseButton color="white" size="lg" bg="blackAlpha.600" _hover={{ bg: 'blackAlpha.700' }} borderRadius="full" zIndex={2} />
|
||||
<ModalBody p={0}>
|
||||
{selectedVideo && (
|
||||
<AspectRatio ratio={16 / 9} maxH="90vh">
|
||||
<iframe
|
||||
src={`${selectedVideo.embedUrl}?autoplay=1&vq=hd1080&rel=0&modestbranding=1&playsinline=1`}
|
||||
title={selectedVideo.title}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
style={{ borderRadius: '8px' }}
|
||||
/>
|
||||
</AspectRatio>
|
||||
)}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideosSection;
|
||||
@@ -0,0 +1,3 @@
|
||||
// Deprecated: use `src/layouts/AdminLayout` instead.
|
||||
// This file re-exports the new AdminLayout to avoid code duplication.
|
||||
export { default } from '../../layouts/AdminLayout';
|
||||
@@ -0,0 +1,304 @@
|
||||
import { Box, Container, HStack, Link, Text, Stack, Wrap, WrapItem, Button, Image, VStack, IconButton, SimpleGrid, Heading } from '@chakra-ui/react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { FiArrowUpRight, FiMail } from 'react-icons/fi';
|
||||
import { FaFacebook, FaInstagram, FaYoutube } from 'react-icons/fa';
|
||||
import { trackNavigation } from '../../utils/umami';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
const resolveBackendUrl = (path: string) => {
|
||||
try {
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith('/cache') || path.startsWith('/uploads')) {
|
||||
const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const u = new URL(base);
|
||||
u.pathname = path;
|
||||
return u.toString();
|
||||
}
|
||||
return path;
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
};
|
||||
|
||||
interface Sponsor {
|
||||
id: number | string;
|
||||
name: string;
|
||||
logo_url?: string;
|
||||
website_url?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const [clubName, setClubName] = useState<string>('Fotbal Club');
|
||||
const [shopUrl, setShopUrl] = useState<string | null>(null);
|
||||
const [sponsors, setSponsors] = useState<Sponsor[]>([]);
|
||||
const theme = useClubTheme();
|
||||
const { data: settings } = usePublicSettings();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(resolveBackendUrl('/cache/prefetch/facr_club_info.json'), { cache: 'no-cache' });
|
||||
if (!res.ok) return;
|
||||
const json = await res.json();
|
||||
if (cancelled) return;
|
||||
if (json?.name) setClubName(String(json.name));
|
||||
} catch {}
|
||||
try {
|
||||
const res = await fetch(resolveBackendUrl('/cache/prefetch/settings.json'), { cache: 'no-cache' });
|
||||
if (res?.ok) {
|
||||
const s = await res.json();
|
||||
if (!cancelled && s) {
|
||||
setShopUrl(s?.shop_url || s?.eshop_url || null);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
// Fetch sponsors
|
||||
try {
|
||||
const apiUrl = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const sponsorsRes = await fetch(`${apiUrl}/public/sponsors`);
|
||||
if (sponsorsRes.ok) {
|
||||
const data = await sponsorsRes.json();
|
||||
if (!cancelled && Array.isArray(data)) {
|
||||
// Filter active sponsors only
|
||||
const activeSponsors = data.filter((s: Sponsor) => s.is_active !== false);
|
||||
setSponsors(activeSponsors);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Navigation Footer */}
|
||||
<Box bg="gray.800" color="white" mt={12} py={8} borderTop="1px" borderColor="whiteAlpha.200">
|
||||
<Container maxW="container.xl">
|
||||
<Stack direction={{ base: 'column', lg: 'row' }} spacing={6} justify="space-between" align={{ base: 'flex-start', lg: 'center' }} w="100%">
|
||||
{/* Brand */}
|
||||
<HStack spacing={3} align="center">
|
||||
<Text fontWeight="700" fontSize="lg">{clubName}</Text>
|
||||
</HStack>
|
||||
|
||||
{/* Navigation links */}
|
||||
<Wrap spacing={4} shouldWrapChildren>
|
||||
<WrapItem><Link href="/blog" color="whiteAlpha.900" fontWeight="600" _hover={{ color: 'white', textDecoration: 'underline' }}>Články</Link></WrapItem>
|
||||
<WrapItem><Link href="/kalendar" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Zápasy</Link></WrapItem>
|
||||
<WrapItem><Link href="/tabulky" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Tabulka</Link></WrapItem>
|
||||
<WrapItem><Link href="/sponzori" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Sponzoři</Link></WrapItem>
|
||||
<WrapItem><Link href="/kontakt" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Kontakt</Link></WrapItem>
|
||||
<WrapItem><Link href="/pravidla-cookies" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Cookies</Link></WrapItem>
|
||||
<WrapItem><Link href="/obchodni-podminky" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Obchodní podmínky</Link></WrapItem>
|
||||
<WrapItem><Link href="/zasady-ochrany-osobnich-udaju" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Zásady ochrany osobních údajů</Link></WrapItem>
|
||||
{shopUrl && (
|
||||
<WrapItem><Link href={shopUrl} color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }} isExternal display="inline-flex" alignItems="center" gap={1}>E‑shop <FiArrowUpRight /></Link></WrapItem>
|
||||
)}
|
||||
</Wrap>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* Sponsors Section */}
|
||||
{sponsors.length > 0 && (
|
||||
<Box bg="gray.700" color="white" py={8} borderTop="1px" borderColor="whiteAlpha.200">
|
||||
<Container maxW="container.xl">
|
||||
<VStack spacing={6}>
|
||||
<Heading size="md" color="whiteAlpha.900">
|
||||
Naši partneři
|
||||
</Heading>
|
||||
<SimpleGrid
|
||||
columns={{ base: 2, sm: 3, md: 4, lg: 6 }}
|
||||
spacing={6}
|
||||
w="full"
|
||||
>
|
||||
{sponsors.map((sponsor) => (
|
||||
<Link
|
||||
key={sponsor.id}
|
||||
href={sponsor.website_url || '#'}
|
||||
isExternal={!!sponsor.website_url}
|
||||
target={sponsor.website_url ? '_blank' : undefined}
|
||||
rel={sponsor.website_url ? 'noopener noreferrer' : undefined}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
p={3}
|
||||
bg="whiteAlpha.100"
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'whiteAlpha.200', transform: 'translateY(-2px)' }}
|
||||
transition="all 0.2s"
|
||||
onClick={() => trackNavigation('footer', `sponsor_${sponsor.name}`)}
|
||||
>
|
||||
<Image
|
||||
src={assetUrl(sponsor.logo_url) || '/logo192.png'}
|
||||
alt={sponsor.name}
|
||||
maxH="60px"
|
||||
maxW="full"
|
||||
objectFit="contain"
|
||||
filter="brightness(0) invert(1)"
|
||||
opacity={0.9}
|
||||
_hover={{ opacity: 1 }}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Social Media Section */}
|
||||
{(settings?.facebook_url || settings?.instagram_url || settings?.youtube_url) && (
|
||||
<Box bg="gray.600" color="white" py={6} borderTop="1px" borderColor="whiteAlpha.200">
|
||||
<Container maxW="container.xl">
|
||||
<VStack spacing={4}>
|
||||
<Text fontSize="lg" fontWeight="600" color="whiteAlpha.900">
|
||||
Sledujte nás
|
||||
</Text>
|
||||
<HStack spacing={4}>
|
||||
{settings?.facebook_url && (
|
||||
<IconButton
|
||||
as="a"
|
||||
href={settings.facebook_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Facebook"
|
||||
icon={<FaFacebook />}
|
||||
size="lg"
|
||||
colorScheme="facebook"
|
||||
variant="ghost"
|
||||
color="white"
|
||||
_hover={{
|
||||
bg: 'whiteAlpha.200',
|
||||
transform: 'translateY(-2px)',
|
||||
color: '#1877F2'
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
onClick={() => trackNavigation('footer', 'social_facebook')}
|
||||
/>
|
||||
)}
|
||||
{settings?.instagram_url && (
|
||||
<IconButton
|
||||
as="a"
|
||||
href={settings.instagram_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Instagram"
|
||||
icon={<FaInstagram />}
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
color="white"
|
||||
_hover={{
|
||||
bg: 'whiteAlpha.200',
|
||||
transform: 'translateY(-2px)',
|
||||
color: '#E4405F'
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
onClick={() => trackNavigation('footer', 'social_instagram')}
|
||||
/>
|
||||
)}
|
||||
{settings?.youtube_url && (
|
||||
<IconButton
|
||||
as="a"
|
||||
href={settings.youtube_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="YouTube"
|
||||
icon={<FaYoutube />}
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
color="white"
|
||||
_hover={{
|
||||
bg: 'whiteAlpha.200',
|
||||
transform: 'translateY(-2px)',
|
||||
color: '#FF0000'
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
onClick={() => trackNavigation('footer', 'social_youtube')}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Copyright Bar */}
|
||||
<Box bg="gray.900" color="whiteAlpha.900" py={4}>
|
||||
<Container maxW="container.xl">
|
||||
<Text fontSize="sm" textAlign="center">
|
||||
© {currentYear} {clubName}. Všechna práva vyhrazena.
|
||||
</Text>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* MyClub Watermark - Clean White Branding */}
|
||||
<Box bg="white" borderTop="1px" borderColor="gray.200" py={6}>
|
||||
<Container maxW="container.xl">
|
||||
<Stack
|
||||
direction={{ base: 'column', md: 'row' }}
|
||||
spacing={6}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
>
|
||||
{/* Left: MyClub Logo & Text */}
|
||||
<HStack spacing={4} align="center">
|
||||
<Image
|
||||
src="https://myclub.sportcreative.eu/logo.svg"
|
||||
alt="MyClub"
|
||||
h={{ base: '32px', md: '40px' }}
|
||||
w="auto"
|
||||
fallbackSrc="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 50'%3E%3Ctext x='10' y='35' font-family='Arial' font-size='24' font-weight='bold' fill='%23000'%3EMyClub%3C/text%3E%3C/svg%3E"
|
||||
/>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontSize={{ base: 'sm', md: 'md' }} fontWeight="600" color="gray.800">
|
||||
Stránku provozuje MyClub
|
||||
</Text>
|
||||
<Text fontSize={{ base: 'xs', md: 'sm' }} color="gray.600">
|
||||
Profesionální webové stránky pro sportovní kluby
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
{/* Right: CTA Buttons */}
|
||||
<HStack spacing={3}>
|
||||
<Button
|
||||
as="a"
|
||||
href="https://myclub.sportcreative.eu/kontakt"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size={{ base: 'sm', md: 'md' }}
|
||||
colorScheme="blue"
|
||||
variant="solid"
|
||||
leftIcon={<FiMail />}
|
||||
_hover={{ transform: 'translateY(-2px)', boxShadow: 'lg' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
Objednat
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href="https://myclub.sportcreative.eu"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size={{ base: 'sm', md: 'md' }}
|
||||
variant="outline"
|
||||
colorScheme="gray"
|
||||
rightIcon={<FiArrowUpRight />}
|
||||
_hover={{ bg: 'gray.50' }}
|
||||
>
|
||||
Více info
|
||||
</Button>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Box, Container, IconButton } from '@chakra-ui/react';
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import { FiChevronUp } from 'react-icons/fi';
|
||||
import Navbar from '../Navbar';
|
||||
import Footer from './Footer';
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
|
||||
const [showTop, setShowTop] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
try {
|
||||
setShowTop(window.scrollY > 400);
|
||||
} catch {}
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true } as any);
|
||||
onScroll();
|
||||
return () => window.removeEventListener('scroll', onScroll as any);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
try {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} catch {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg="bg.app" overflowX="hidden">
|
||||
<Box id="top" position="absolute" top={0} left={0} />
|
||||
<Navbar />
|
||||
<Container maxW="container.xl" py={8}>
|
||||
{children}
|
||||
</Container>
|
||||
<Footer />
|
||||
{showTop && (
|
||||
<IconButton
|
||||
aria-label="Zpět nahoru"
|
||||
icon={<FiChevronUp />}
|
||||
position="fixed"
|
||||
right={{ base: 4, md: 6 }}
|
||||
bottom={{ base: 4, md: 6 }}
|
||||
zIndex={1000}
|
||||
colorScheme="blue"
|
||||
onClick={scrollToTop}
|
||||
borderRadius="full"
|
||||
shadow="md"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainLayout;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Box, Container, Heading, Text, VStack } from '@chakra-ui/react';
|
||||
import NewsletterSubscribe from './NewsletterSubscribe';
|
||||
|
||||
type NewsletterSectionProps = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
bgColor?: string;
|
||||
py?: number | string;
|
||||
};
|
||||
|
||||
export default function NewsletterSection({
|
||||
title = 'Přihlaste se k odběru novinek',
|
||||
description = 'Nenechte si ujít žádné novinky, zápasy a akce našeho klubu. Odebírejte náš newsletter a buďte v obraze.',
|
||||
bgColor = 'gray.50',
|
||||
py = 16,
|
||||
}: NewsletterSectionProps) {
|
||||
return (
|
||||
<Box as="section" bg={bgColor} py={py}>
|
||||
<Container maxW="container.lg">
|
||||
<VStack spacing={6} align="center" textAlign="center" maxW="3xl" mx="auto">
|
||||
<Heading as="h2" size="xl">
|
||||
{title}
|
||||
</Heading>
|
||||
{description && (
|
||||
<Text fontSize="lg" color="gray.600" maxW="2xl">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
<Box w="100%" maxW="md" mt={4}>
|
||||
<NewsletterSubscribe />
|
||||
</Box>
|
||||
<Text fontSize="sm" color="gray.500" mt={2}>
|
||||
Vaši e-mailovou adresu budeme používat pouze pro zasílání novinek. Můžete se kdykoli odhlásit.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
Input,
|
||||
Text,
|
||||
useToast,
|
||||
VStack,
|
||||
useColorModeValue
|
||||
} from '@chakra-ui/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { subscribeToNewsletter } from '../../services/public';
|
||||
import { trackNewsletterSubscribe, trackFormSubmit } from '../../utils/umami';
|
||||
|
||||
type FormData = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
export default function NewsletterSubscribe() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const toast = useToast();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>();
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await subscribeToNewsletter(data.email);
|
||||
|
||||
// Track successful newsletter subscription
|
||||
trackNewsletterSubscribe(window.location.pathname);
|
||||
trackFormSubmit('Newsletter Subscribe', true);
|
||||
|
||||
toast({
|
||||
title: 'Přihlášení k odběru proběhlo úspěšně',
|
||||
description: 'Děkujeme za přihlášení k odběru našeho newsletteru!',
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
reset();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Nastala chyba při přihlašování k odběru';
|
||||
|
||||
// Track failed subscription
|
||||
trackFormSubmit('Newsletter Subscribe', false);
|
||||
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: errorMessage,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cardBg = useColorModeValue('white', 'transparent');
|
||||
const cardBorder = useColorModeValue('gray.200', 'gray.600');
|
||||
const headingColor = useColorModeValue('gray.800', 'white');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.300');
|
||||
const disclaimerColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
return (
|
||||
<Box w="100%" maxW="xl" mx="auto" p={4} bg={cardBg} borderRadius="md" boxShadow="sm" borderWidth="1px" borderColor={cardBorder}>
|
||||
<VStack spacing={3} align="stretch">
|
||||
<Text fontSize="xl" fontWeight="bold" textAlign="center" color={headingColor}>
|
||||
Přihlaste se k odběru novinek
|
||||
</Text>
|
||||
<Text textAlign="center" color={textColor} mb={2}>
|
||||
Budeme vás informovat o novinkách, zápasech a akcích našeho klubu.
|
||||
</Text>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<VStack spacing={3}>
|
||||
<FormControl isInvalid={!!errors.email}>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Váš e-mail"
|
||||
{...register('email', {
|
||||
required: 'E-mail je povinný',
|
||||
pattern: {
|
||||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||
message: 'Neplatná e-mailová adresa',
|
||||
},
|
||||
})}
|
||||
size="md"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{errors.email && errors.email.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
colorScheme="blue"
|
||||
size="md"
|
||||
width="100%"
|
||||
isLoading={isLoading}
|
||||
loadingText="Odesílám..."
|
||||
data-umami-event="Newsletter Submit"
|
||||
data-umami-event-location={window.location.pathname}
|
||||
>
|
||||
Odeslat
|
||||
</Button>
|
||||
</VStack>
|
||||
</form>
|
||||
|
||||
<Text fontSize="xs" color={disclaimerColor} textAlign="center" mt={2}>
|
||||
Odesláním formuláře souhlasíte se zpracováním osobních údajů.
|
||||
Vaši e-mailovou adresu budeme používat pouze pro zasílání novinek.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
Heading,
|
||||
Spinner,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getPolls, getPoll } from '../../services/polls';
|
||||
import PollCard from './PollCard';
|
||||
|
||||
interface EmbeddedPollProps {
|
||||
articleId?: number;
|
||||
eventId?: number;
|
||||
videoUrl?: string;
|
||||
title?: string;
|
||||
showTitle?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* EmbeddedPoll component - displays polls related to specific content
|
||||
* Use in article pages, event pages, or video pages
|
||||
*/
|
||||
const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
|
||||
articleId,
|
||||
eventId,
|
||||
videoUrl,
|
||||
title = 'Hlasování',
|
||||
showTitle = true,
|
||||
}) => {
|
||||
const bgSection = useColorModeValue('gray.50', 'gray.900');
|
||||
|
||||
// Build query params based on what's provided
|
||||
const queryParams: any = {};
|
||||
if (articleId) queryParams.article_id = articleId;
|
||||
if (eventId) queryParams.event_id = eventId;
|
||||
if (videoUrl) queryParams.video_url = videoUrl;
|
||||
|
||||
// Fetch polls related to this content
|
||||
const { data: polls, isLoading } = useQuery({
|
||||
queryKey: ['embedded-polls', queryParams],
|
||||
queryFn: () => getPolls(queryParams),
|
||||
enabled: !!(articleId || eventId || videoUrl), // Only fetch if at least one param is provided
|
||||
staleTime: 2 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Get full poll data for each
|
||||
const pollsToDisplay = polls?.slice(0, 3) || []; // Max 3 polls per content
|
||||
|
||||
const { data: pollsData, isLoading: isLoadingPolls } = useQuery({
|
||||
queryKey: ['embedded-polls-details', pollsToDisplay.map((p) => p.id)],
|
||||
queryFn: async () => {
|
||||
const promises = pollsToDisplay.map((poll) => getPoll(poll.id));
|
||||
return await Promise.all(promises);
|
||||
},
|
||||
enabled: pollsToDisplay.length > 0,
|
||||
});
|
||||
|
||||
// Don't render anything if no content identifier provided
|
||||
if (!articleId && !eventId && !videoUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't render if loading initially
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box py={4}>
|
||||
<VStack spacing={2}>
|
||||
<Spinner size="sm" />
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
Načítání hlasování...
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't render if no polls found
|
||||
if (!polls || polls.length === 0 || !pollsData || pollsData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box bg={bgSection} py={8} px={4} borderRadius="xl" my={8}>
|
||||
<VStack spacing={6} maxW="3xl" mx="auto">
|
||||
{showTitle && (
|
||||
<Heading size="md" textAlign="center">
|
||||
{title}
|
||||
</Heading>
|
||||
)}
|
||||
|
||||
<VStack spacing={4} w="full">
|
||||
{isLoadingPolls ? (
|
||||
<VStack py={8}>
|
||||
<Spinner />
|
||||
<Text>Načítání...</Text>
|
||||
</VStack>
|
||||
) : (
|
||||
pollsData.map((pollResponse) => (
|
||||
<Box key={pollResponse.poll.id} w="full">
|
||||
<PollCard
|
||||
poll={pollResponse.poll}
|
||||
hasVoted={pollResponse.has_voted}
|
||||
isActive={pollResponse.is_active}
|
||||
canShowResults={pollResponse.can_show_results}
|
||||
/>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmbeddedPoll;
|
||||
@@ -0,0 +1,370 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
Progress,
|
||||
Badge,
|
||||
useToast,
|
||||
Image,
|
||||
Heading,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { CheckIcon } from '@chakra-ui/icons';
|
||||
import {
|
||||
Poll,
|
||||
PollOption,
|
||||
votePoll,
|
||||
getPollResults,
|
||||
generateSessionToken,
|
||||
} from '../../services/polls';
|
||||
|
||||
interface PollCardProps {
|
||||
poll: Poll;
|
||||
hasVoted: boolean;
|
||||
isActive: boolean;
|
||||
canShowResults: boolean;
|
||||
onVoteSuccess?: () => void;
|
||||
}
|
||||
|
||||
const PollCard: React.FC<PollCardProps> = ({
|
||||
poll,
|
||||
hasVoted: initialHasVoted,
|
||||
isActive,
|
||||
canShowResults: initialCanShowResults,
|
||||
onVoteSuccess,
|
||||
}) => {
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedOptions, setSelectedOptions] = useState<number[]>([]);
|
||||
const [hasVoted, setHasVoted] = useState(initialHasVoted);
|
||||
const [canShowResults, setCanShowResults] = useState(initialCanShowResults);
|
||||
const [results, setResults] = useState<any[]>([]);
|
||||
const [showingResults, setShowingResults] = useState(initialCanShowResults);
|
||||
|
||||
const bgCard = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
// Vote mutation
|
||||
const voteMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const sessionToken = generateSessionToken();
|
||||
return votePoll(poll.id, {
|
||||
option_ids: selectedOptions,
|
||||
session_token: sessionToken,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setHasVoted(true);
|
||||
setCanShowResults(true);
|
||||
setShowingResults(true);
|
||||
|
||||
// Fetch results
|
||||
try {
|
||||
const resultsData = await getPollResults(poll.id);
|
||||
setResults(resultsData.results);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch results:', error);
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['polls'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['poll', poll.id] });
|
||||
|
||||
toast({
|
||||
title: 'Hlas zaznamenán!',
|
||||
description: 'Děkujeme za vaši účast v anketě.',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
if (onVoteSuccess) {
|
||||
onVoteSuccess();
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: error.response?.data?.error || 'Nepodařilo se zaznamenat váš hlas',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleVote = () => {
|
||||
if (selectedOptions.length === 0) {
|
||||
toast({
|
||||
title: 'Vyberte možnost',
|
||||
description: 'Před hlasováním vyberte alespoň jednu možnost.',
|
||||
status: 'warning',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (poll.allow_multiple && selectedOptions.length > poll.max_choices) {
|
||||
toast({
|
||||
title: 'Příliš mnoho voleb',
|
||||
description: `Můžete vybrat maximálně ${poll.max_choices} možností.`,
|
||||
status: 'warning',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
voteMutation.mutate();
|
||||
};
|
||||
|
||||
const handleSingleChoice = (value: string) => {
|
||||
setSelectedOptions([parseInt(value)]);
|
||||
};
|
||||
|
||||
const handleMultipleChoice = (values: (string | number)[]) => {
|
||||
const numValues = values.map((v) => (typeof v === 'string' ? parseInt(v) : v));
|
||||
if (numValues.length <= poll.max_choices) {
|
||||
setSelectedOptions(numValues);
|
||||
}
|
||||
};
|
||||
|
||||
const loadResults = async () => {
|
||||
try {
|
||||
const resultsData = await getPollResults(poll.id);
|
||||
setResults(resultsData.results);
|
||||
setShowingResults(true);
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: 'Nepodařilo se načíst výsledky',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const calculatePercentage = (voteCount: number) => {
|
||||
if (poll.total_votes === 0) return 0;
|
||||
return (voteCount / poll.total_votes) * 100;
|
||||
};
|
||||
|
||||
// Show results if available
|
||||
if (showingResults && canShowResults) {
|
||||
const displayResults = results.length > 0 ? results : poll.options.map(opt => ({
|
||||
option_id: opt.id,
|
||||
text: opt.text,
|
||||
vote_count: opt.vote_count,
|
||||
percentage: calculatePercentage(opt.vote_count),
|
||||
image_url: opt.image_url,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bgCard}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
p={6}
|
||||
boxShadow="md"
|
||||
>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{poll.image_url && (
|
||||
<Image
|
||||
src={poll.image_url}
|
||||
alt={poll.title}
|
||||
borderRadius="lg"
|
||||
maxH="200px"
|
||||
objectFit="cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
<HStack justify="space-between" align="start">
|
||||
<Heading size="md">{poll.title}</Heading>
|
||||
{hasVoted && (
|
||||
<Badge colorScheme="green" fontSize="sm">
|
||||
<HStack spacing={1}>
|
||||
<CheckIcon boxSize={3} />
|
||||
<Text>Hlasováno</Text>
|
||||
</HStack>
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{poll.description && (
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{poll.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<VStack spacing={3} align="stretch">
|
||||
<Text fontWeight="bold" fontSize="sm" color="gray.500">
|
||||
Výsledky ({poll.total_votes} hlasů)
|
||||
</Text>
|
||||
{displayResults.map((result) => (
|
||||
<Box key={result.option_id}>
|
||||
<HStack justify="space-between" mb={1}>
|
||||
<Text fontWeight="medium">{result.text}</Text>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
{result.vote_count} ({result.percentage.toFixed(1)}%)
|
||||
</Text>
|
||||
</HStack>
|
||||
<Progress
|
||||
value={result.percentage}
|
||||
colorScheme="blue"
|
||||
borderRadius="full"
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Show voting form
|
||||
return (
|
||||
<Box
|
||||
bg={bgCard}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="xl"
|
||||
p={6}
|
||||
boxShadow="md"
|
||||
>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{poll.image_url && (
|
||||
<Image
|
||||
src={poll.image_url}
|
||||
alt={poll.title}
|
||||
borderRadius="lg"
|
||||
maxH="200px"
|
||||
objectFit="cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Heading size="md">{poll.title}</Heading>
|
||||
|
||||
{poll.description && (
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{poll.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!isActive && (
|
||||
<Badge colorScheme="orange">Anketa je momentálně uzavřena</Badge>
|
||||
)}
|
||||
|
||||
{isActive && (
|
||||
<>
|
||||
{poll.allow_multiple ? (
|
||||
<CheckboxGroup
|
||||
value={selectedOptions.map(String)}
|
||||
onChange={handleMultipleChoice}
|
||||
>
|
||||
<VStack spacing={2} align="stretch">
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
Vyberte až {poll.max_choices} možností
|
||||
</Text>
|
||||
{poll.options.map((option) => (
|
||||
<Box
|
||||
key={option.id}
|
||||
p={3}
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
_hover={{ bg: hoverBg }}
|
||||
cursor="pointer"
|
||||
>
|
||||
<Checkbox value={String(option.id)}>
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text>{option.text}</Text>
|
||||
{option.description && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{option.description}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Checkbox>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</CheckboxGroup>
|
||||
) : (
|
||||
<RadioGroup
|
||||
value={selectedOptions[0]?.toString() || ''}
|
||||
onChange={handleSingleChoice}
|
||||
>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{poll.options.map((option) => (
|
||||
<Box
|
||||
key={option.id}
|
||||
p={3}
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
_hover={{ bg: hoverBg }}
|
||||
cursor="pointer"
|
||||
>
|
||||
<Radio value={String(option.id)}>
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text>{option.text}</Text>
|
||||
{option.description && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{option.description}
|
||||
</Text>
|
||||
)}
|
||||
{option.player && (
|
||||
<HStack spacing={2}>
|
||||
{option.player.image_url && (
|
||||
<Image
|
||||
src={option.player.image_url}
|
||||
alt={`${option.player.first_name} ${option.player.last_name}`}
|
||||
boxSize="24px"
|
||||
borderRadius="full"
|
||||
/>
|
||||
)}
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
#{option.player.jersey_number} {option.player.first_name}{' '}
|
||||
{option.player.last_name}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</Radio>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</RadioGroup>
|
||||
)}
|
||||
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={handleVote}
|
||||
isLoading={voteMutation.isPending}
|
||||
isDisabled={!isActive || selectedOptions.length === 0}
|
||||
>
|
||||
Hlasovat
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canShowResults && !showingResults && (
|
||||
<Button variant="outline" onClick={loadResults} size="sm">
|
||||
Zobrazit výsledky
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Text fontSize="xs" color="gray.500" textAlign="center">
|
||||
Celkem hlasů: {poll.total_votes}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PollCard;
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { ScoreboardState } from '@/services/scoreboard';
|
||||
import ScoreboardPreview from './ScoreboardPreview';
|
||||
|
||||
// Full display component intended for public overlay usage.
|
||||
// For now it reuses ScoreboardPreview visuals; can diverge later for larger sizing.
|
||||
const ScoreboardDisplay: React.FC<{ state: ScoreboardState }> = ({ state }) => {
|
||||
return (
|
||||
<Box>
|
||||
<ScoreboardPreview state={state} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScoreboardDisplay;
|
||||
@@ -0,0 +1,173 @@
|
||||
import React from 'react';
|
||||
import { Box, HStack, Text, Image } from '@chakra-ui/react';
|
||||
import { ScoreboardState } from '@/services/scoreboard';
|
||||
|
||||
export const ScoreboardPreview: React.FC<{ state: ScoreboardState }> = ({ state }) => {
|
||||
const theme = state.theme || 'pill';
|
||||
|
||||
switch (theme) {
|
||||
case 'pill':
|
||||
return (
|
||||
<HStack spacing={2} px={1.5} py={1} borderRadius="full" bg="white" borderWidth="1px" borderColor="gray.200" boxShadow="sm" width="max-content">
|
||||
<SegmentTeam colorA={state.primaryColor} left>
|
||||
{state.homeLogo ? <Image src={state.homeLogo} alt="home" boxSize="16px" objectFit="contain" /> : null}
|
||||
<Text textTransform="uppercase" fontSize="sm" lineHeight={1}>{state.homeShort || deriveShortLocal(state.homeName)}</Text>
|
||||
</SegmentTeam>
|
||||
<SegmentScore>{state.homeScore} – {state.awayScore}</SegmentScore>
|
||||
<SegmentTeam colorA={state.secondaryColor} right>
|
||||
<Text textTransform="uppercase" fontSize="sm" lineHeight={1}>{state.awayShort || deriveShortLocal(state.awayName)}</Text>
|
||||
{state.awayLogo ? <Image src={state.awayLogo} alt="away" boxSize="16px" objectFit="contain" /> : null}
|
||||
</SegmentTeam>
|
||||
</HStack>
|
||||
);
|
||||
case 'classic':
|
||||
case 'var1':
|
||||
return (
|
||||
<HStack spacing={3} bgGradient="linear(to-b, #c8d4dc, #a8b8c4)" px={5} py={3} borderRadius="lg" boxShadow="md" width="max-content">
|
||||
<Box bg="white" color="black" fontWeight="bold" px={3} py={1} borderRadius="md" fontSize="lg">{formatTimer(state.halfLength)}</Box>
|
||||
<Box bg={state.primaryColor || '#34495e'} color="white" px={4} py={2} borderRadius="md" fontWeight="bold">{state.homeShort || deriveShortLocal(state.homeName)}</Box>
|
||||
<Text fontWeight="bold" color="black">{state.homeScore}-{state.awayScore}</Text>
|
||||
<Box bg={state.secondaryColor || '#2c3e50'} color="white" px={4} py={2} borderRadius="md" fontWeight="bold">{state.awayShort || deriveShortLocal(state.awayName)}</Box>
|
||||
</HStack>
|
||||
);
|
||||
case 'var2':
|
||||
return (
|
||||
<HStack spacing={0} borderRadius="md" overflow="hidden" boxShadow="md" width="max-content">
|
||||
<Box bgGradient="linear(135deg, #4a5568, #2d3748)" color="white" px={3} py={2} fontWeight="bold">{formatTimer(state.halfLength)}</Box>
|
||||
<Box bgGradient="linear(135deg, #2c5282, #2a4365)" color="white" px={4} py={2} fontWeight="bold">{state.homeShort || deriveShortLocal(state.homeName)}</Box>
|
||||
<Box bgGradient="linear(135deg, #2c5282, #2a4365)" color="white" px={3} py={2} fontWeight="bold">{state.homeScore}-{state.awayScore}</Box>
|
||||
<Box bgGradient="linear(135deg, #2c5282, #2a4365)" color="white" px={4} py={2} fontWeight="bold">{state.awayShort || deriveShortLocal(state.awayName)}</Box>
|
||||
</HStack>
|
||||
);
|
||||
case 'var3':
|
||||
return (
|
||||
<Box textAlign="center" fontFamily="Poppins, Arial, sans-serif">
|
||||
<HStack spacing={0} justify="center">
|
||||
<Box w="102px" h="38px" bg="#F6F6F6" lineHeight="41px" position="relative">
|
||||
<Box position="absolute" left="-8px" top={0} w="6px" h="38px" bg={state.primaryColor || '#ea2212'} />
|
||||
<Text>{state.homeShort || deriveShortLocal(state.homeName)}</Text>
|
||||
</Box>
|
||||
<Box w="102px" h="38px" bg="#F6F6F6" lineHeight="41px" zIndex={2} boxShadow="0 3px 10px rgba(0,0,0,0.7)">
|
||||
<Text fontWeight="bold">{state.homeScore}-{state.awayScore}</Text>
|
||||
</Box>
|
||||
<Box w="102px" h="38px" bg="#F6F6F6" lineHeight="41px" position="relative">
|
||||
<Box position="absolute" right="-8px" top={0} w="6px" h="38px" bg={state.secondaryColor || '#ea2212'} />
|
||||
<Text>{state.awayShort || deriveShortLocal(state.awayName)}</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
<Box mt={2} w="306px" mx="auto" bg="#F6F6F6">
|
||||
<Text>{formatTimer(state.halfLength)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
case 'var4':
|
||||
return (
|
||||
<Box w="340px" borderWidth="1px" borderRadius="xl" boxShadow="xl" p={4} bg="white" color="gray.900">
|
||||
<HStack>
|
||||
<Text fontWeight="bold">{state.homeName}</Text>
|
||||
<Text ml="auto" fontWeight="extrabold">{state.homeScore}</Text>
|
||||
</HStack>
|
||||
<Box textAlign="center" fontWeight="extrabold" py={1}>VS</Box>
|
||||
<HStack>
|
||||
<Text fontWeight="bold">{state.awayName}</Text>
|
||||
<Text ml="auto" fontWeight="extrabold">{state.awayScore}</Text>
|
||||
</HStack>
|
||||
<HStack justify="flex-end" fontSize="sm" opacity={0.8} pt={2}>
|
||||
<Text>{formatTimer(state.halfLength)}</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<HStack spacing={3} bg="gray.900" color="white" px={4} py={3} borderRadius="lg" boxShadow="lg" width="max-content">
|
||||
<Text fontWeight="bold">{state.homeName}</Text>
|
||||
<Text fontWeight="black">{state.homeScore} : {state.awayScore}</Text>
|
||||
<Text fontWeight="bold">{state.awayName}</Text>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Small presentational helpers for the pill theme
|
||||
const SegmentTeam: React.FC<{ colorA?: string; left?: boolean; right?: boolean; children: React.ReactNode }> = ({ colorA = '#1e3a8a', left, right, children }) => {
|
||||
return (
|
||||
<HStack
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="full"
|
||||
bgGradient={`linear(to-r, ${colorA}, ${shadeColor(colorA, 20)})`}
|
||||
color="white"
|
||||
spacing={1.5}
|
||||
position="relative"
|
||||
_before={left ? { content: '""', position: 'absolute', left: '-10px', top: 0, bottom: 0, width: '14px', bgGradient: `linear(to-r, ${colorA}, ${shadeColor(colorA, 20)})`, borderTopLeftRadius: '999px', borderBottomLeftRadius: '999px' } : undefined}
|
||||
_after={right ? { content: '""', position: 'absolute', right: '-10px', top: 0, bottom: 0, width: '14px', bgGradient: `linear(to-r, ${colorA}, ${shadeColor(colorA, 20)})`, borderTopRightRadius: '999px', borderBottomRightRadius: '999px' } : undefined}
|
||||
minW="46px"
|
||||
>
|
||||
{children}
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
const SegmentScore: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<Box px={2} py={0.5} borderRadius="md" bg="gray.50" borderWidth="1px" borderColor="gray.200" fontWeight="800" minW="58px" textAlign="center" fontSize="sm">
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
function formatTimer(halfLength: number): string {
|
||||
// Simple static mm:ss display using half length as baseline; real timer would come from backend
|
||||
const min = Math.max(0, Math.min(halfLength, 99));
|
||||
return `${String(min).padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
function deriveShortLocal(name?: string) {
|
||||
if (!name) return '---';
|
||||
const s = String(name).trim().toUpperCase();
|
||||
if (!s) return '---';
|
||||
const map: Record<string, string> = {
|
||||
'Á':'A','Ä':'A','Å':'A','Â':'A','À':'A',
|
||||
'Č':'C','Ć':'C','Ç':'C',
|
||||
'Ď':'D',
|
||||
'É':'E','Ě':'E','È':'E','Ë':'E','Ê':'E',
|
||||
'Í':'I','Ì':'I','Ï':'I','Î':'I',
|
||||
'Ň':'N','Ń':'N',
|
||||
'Ó':'O','Ö':'O','Ô':'O','Ò':'O',
|
||||
'Ř':'R',
|
||||
'Š':'S','Ś':'S',
|
||||
'Ť':'T',
|
||||
'Ú':'U','Ů':'U','Ù':'U','Ü':'U','Û':'U',
|
||||
'Ý':'Y',
|
||||
'Ž':'Z',
|
||||
};
|
||||
let out = '';
|
||||
for (const ch of s) {
|
||||
const c = map[ch] || ch;
|
||||
if (c >= 'A' && c <= 'Z') {
|
||||
out += c;
|
||||
if (out.length === 3) break;
|
||||
}
|
||||
}
|
||||
while (out.length < 3) out += '-';
|
||||
return out;
|
||||
}
|
||||
|
||||
function shadeColor(hex: string, percent: number) {
|
||||
// Simple hex shade function
|
||||
try {
|
||||
const n = hex.replace('#','');
|
||||
const num = parseInt(n.length === 3 ? n.split('').map((c)=>c+c).join('') : n, 16);
|
||||
let r = (num >> 16) & 0xff;
|
||||
let g = (num >> 8) & 0xff;
|
||||
let b = num & 0xff;
|
||||
r = Math.min(255, Math.max(0, Math.round(r + (percent/100)*255)));
|
||||
g = Math.min(255, Math.max(0, Math.round(g + (percent/100)*255)));
|
||||
b = Math.min(255, Math.max(0, Math.round(b + (percent/100)*255)));
|
||||
return `#${[r,g,b].map(v=>v.toString(16).padStart(2,'0')).join('')}`;
|
||||
} catch {
|
||||
return hex;
|
||||
}
|
||||
}
|
||||
|
||||
export default ScoreboardPreview;
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../../services/api';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
|
||||
interface SEOData {
|
||||
site_title?: string;
|
||||
site_description?: string;
|
||||
meta_keywords?: string;
|
||||
default_og_image_url?: string;
|
||||
twitter_handle?: string;
|
||||
canonical_base_url?: string;
|
||||
additional_meta?: string; // raw HTML strings not injected for safety here
|
||||
enable_indexing?: boolean;
|
||||
}
|
||||
|
||||
export default function DefaultSEO() {
|
||||
const [seo, setSeo] = useState<SEOData | null>(null);
|
||||
const [social, setSocial] = useState<{ facebook?: string; instagram?: string; youtube?: string } | null>(null);
|
||||
const { data: publicSettings } = usePublicSettings();
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
api.get<SEOData>('/seo')
|
||||
.then(res => { if (mounted) setSeo(res.data as any); })
|
||||
.catch(() => {});
|
||||
// Socials can come from settings (hook). Set when available
|
||||
// We still keep axios SEO fetch above for dedicated SEO fields
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
// Keep socials in sync when settings load
|
||||
useEffect(() => {
|
||||
if (!publicSettings) return;
|
||||
setSocial({
|
||||
facebook: publicSettings.facebook_url,
|
||||
instagram: publicSettings.instagram_url,
|
||||
youtube: publicSettings.youtube_url,
|
||||
});
|
||||
}, [publicSettings]);
|
||||
|
||||
const fallbackClubName = publicSettings?.club_name;
|
||||
const title = (seo?.site_title && seo.site_title.trim()) || (fallbackClubName && fallbackClubName.trim()) || 'MyClub';
|
||||
const desc = (seo?.site_description && seo.site_description.trim()) || (fallbackClubName ? `Official ${fallbackClubName} Website` : 'Official MyClub Website');
|
||||
const keywords = seo?.meta_keywords || '';
|
||||
const rawOg = seo?.default_og_image_url || publicSettings?.club_logo_url || '/logo512.png';
|
||||
const ogImg = assetUrl(rawOg) || rawOg;
|
||||
const twitter = seo?.twitter_handle || '';
|
||||
const origin = seo?.canonical_base_url || (typeof window !== 'undefined' ? window.location.origin : '');
|
||||
const sameAs: string[] = [];
|
||||
if (twitter) {
|
||||
const handle = twitter.startsWith('@') ? twitter.slice(1) : twitter;
|
||||
sameAs.push(`https://twitter.com/${handle}`);
|
||||
}
|
||||
if (social?.facebook) sameAs.push(social.facebook);
|
||||
if (social?.instagram) sameAs.push(social.instagram);
|
||||
if (social?.youtube) sameAs.push(social.youtube);
|
||||
|
||||
// robots
|
||||
const robots = seo?.enable_indexing === false ? 'noindex, nofollow' : 'index, follow';
|
||||
|
||||
// Ensure document title updates as soon as we have a computed title
|
||||
useEffect(() => {
|
||||
if (typeof document !== 'undefined' && title) {
|
||||
document.title = title;
|
||||
}
|
||||
}, [title]);
|
||||
|
||||
return (
|
||||
<Helmet defaultTitle={title} titleTemplate={`%s | ${fallbackClubName || title}`}>
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={desc} />
|
||||
{keywords && <meta name="keywords" content={keywords} />}
|
||||
<meta name="robots" content={robots} />
|
||||
{/* Favicon and app icons based on club logo */}
|
||||
{publicSettings?.club_logo_url && (
|
||||
<>
|
||||
<link rel="icon" href={assetUrl(publicSettings.club_logo_url)} />
|
||||
<link rel="shortcut icon" href={assetUrl(publicSettings.club_logo_url)} />
|
||||
<link rel="apple-touch-icon" href={assetUrl(publicSettings.club_logo_url)} />
|
||||
</>
|
||||
)}
|
||||
{/* Open Graph */}
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={desc} />
|
||||
<meta property="og:image" content={ogImg} />
|
||||
{/* Twitter */}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
{twitter && <meta name="twitter:site" content={twitter} />}
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={desc} />
|
||||
<meta name="twitter:image" content={ogImg} />
|
||||
{/* Canonical (best-effort: base only; pages can override) */}
|
||||
{seo?.canonical_base_url && <link rel="canonical" href={seo.canonical_base_url} />}
|
||||
{/* JSON-LD: WebSite + SearchAction */}
|
||||
{origin && (
|
||||
<script type="application/ld+json">
|
||||
{JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
url: origin,
|
||||
name: title,
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: `${origin}/blog?q={search_term_string}`,
|
||||
'query-input': 'required name=search_term_string'
|
||||
}
|
||||
})}
|
||||
</script>
|
||||
)}
|
||||
{/* JSON-LD: Organization */}
|
||||
{origin && (
|
||||
<script type="application/ld+json">
|
||||
{JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
url: origin,
|
||||
name: title,
|
||||
logo: ogImg,
|
||||
sameAs: sameAs.length ? sameAs : undefined,
|
||||
})}
|
||||
</script>
|
||||
)}
|
||||
</Helmet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Box, HStack, IconButton, Heading, useColorModeValue } from '@chakra-ui/react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
|
||||
interface HorizontalScrollerProps {
|
||||
title?: string;
|
||||
rightAction?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
// Enhancements (all optional; defaults keep legacy behavior)
|
||||
draggable?: boolean; // enable mouse/touch drag scrolling
|
||||
autoScroll?: boolean; // enable continuous auto-scroll
|
||||
autoSpeed?: number; // pixels per frame (~60fps). default 1.2
|
||||
rewindLoop?: boolean; // if true, jump to start when reaching end (instead of stopping)
|
||||
pauseOnHover?: boolean; // pause auto-scroll on hover
|
||||
infiniteScroll?: boolean; // duplicate children for seamless infinite loop
|
||||
}
|
||||
|
||||
const SCROLL_AMOUNT = 0.7; // 70% of viewport width per click
|
||||
|
||||
const HorizontalScroller: React.FC<HorizontalScrollerProps> = ({ title, rightAction, children, draggable = false, autoScroll = false, autoSpeed = 1.2, rewindLoop = true, pauseOnHover = true, infiniteScroll = false }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const theme = useClubTheme();
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const isPointerDownRef = useRef(false);
|
||||
const startXRef = useRef(0);
|
||||
const startScrollLeftRef = useRef(0);
|
||||
const hasDraggedRef = useRef(false);
|
||||
const rafIdRef = useRef<number | null>(null);
|
||||
|
||||
const scrollBy = (dir: 1 | -1) => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
// Use the container width rather than window width for more accurate scrolling
|
||||
const amount = Math.floor(el.clientWidth * SCROLL_AMOUNT) * dir;
|
||||
el.scrollBy({ left: amount, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// Mouse/touch drag handlers
|
||||
const onPointerDown = (clientX: number) => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
isPointerDownRef.current = true;
|
||||
hasDraggedRef.current = false;
|
||||
startXRef.current = clientX;
|
||||
startScrollLeftRef.current = el.scrollLeft;
|
||||
// while dragging, disable smooth scroll behavior and selection
|
||||
el.style.scrollSnapType = 'none';
|
||||
el.style.cursor = 'grabbing';
|
||||
(document.body as any).style.userSelect = 'none';
|
||||
};
|
||||
const onPointerMove = (clientX: number) => {
|
||||
const el = containerRef.current;
|
||||
if (!el || !isPointerDownRef.current) return;
|
||||
const dx = clientX - startXRef.current;
|
||||
if (Math.abs(dx) > 3) hasDraggedRef.current = true;
|
||||
el.scrollLeft = startScrollLeftRef.current - dx;
|
||||
};
|
||||
const onPointerUp = () => {
|
||||
const el = containerRef.current;
|
||||
isPointerDownRef.current = false;
|
||||
if (el) {
|
||||
el.style.cursor = 'grab';
|
||||
}
|
||||
(document.body as any).style.userSelect = '';
|
||||
if (el) {
|
||||
// restore snap a moment later for a natural feel
|
||||
setTimeout(() => { if (el) el.style.scrollSnapType = 'x mandatory'; }, 100);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-scroll implementation with infinite scroll support
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!autoScroll || !el) return;
|
||||
|
||||
// For infinite scroll, set initial position to middle of duplicated content
|
||||
if (infiniteScroll) {
|
||||
const halfWidth = el.scrollWidth / 2;
|
||||
el.scrollLeft = halfWidth;
|
||||
}
|
||||
|
||||
let running = true;
|
||||
const step = () => {
|
||||
if (!running || !el) return;
|
||||
|
||||
const scrollLeft = el.scrollLeft;
|
||||
const scrollWidth = el.scrollWidth;
|
||||
const clientWidth = el.clientWidth;
|
||||
|
||||
const shouldPause = (pauseOnHover && isHovering) || isPointerDownRef.current;
|
||||
|
||||
if (!shouldPause) {
|
||||
if (infiniteScroll) {
|
||||
// Seamless infinite loop by resetting when reaching halfway
|
||||
const halfWidth = scrollWidth / 2;
|
||||
if (scrollLeft >= halfWidth) {
|
||||
el.scrollLeft = 0;
|
||||
} else {
|
||||
el.scrollLeft += autoSpeed;
|
||||
}
|
||||
} else {
|
||||
// Original rewind behavior
|
||||
const atEnd = scrollLeft + clientWidth >= scrollWidth - 2;
|
||||
if (atEnd && rewindLoop) {
|
||||
el.scrollLeft = 0;
|
||||
} else if (!atEnd) {
|
||||
el.scrollLeft += autoSpeed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rafIdRef.current = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
// Start the animation loop
|
||||
rafIdRef.current = requestAnimationFrame(step);
|
||||
|
||||
return () => {
|
||||
running = false;
|
||||
if (rafIdRef.current !== null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoScroll, autoSpeed, pauseOnHover, isHovering, rewindLoop, infiniteScroll]);
|
||||
|
||||
return (
|
||||
<Box position="relative">
|
||||
{(title || rightAction) && (
|
||||
<HStack justify="space-between" mb={3}>
|
||||
{title && (
|
||||
<Heading size="lg" letterSpacing="0.04em" style={{ textTransform: 'uppercase' }}>
|
||||
{title}
|
||||
</Heading>
|
||||
)}
|
||||
{rightAction}
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* gradient masks for fancy edges */}
|
||||
<Box
|
||||
pointerEvents="none"
|
||||
position="absolute"
|
||||
left={0}
|
||||
top={0}
|
||||
bottom={0}
|
||||
w={{ base: 16, md: 24 }}
|
||||
bgGradient={useColorModeValue(
|
||||
'linear(to-r, white, rgba(255,255,255,0.9), rgba(255,255,255,0.4), transparent)',
|
||||
'linear(to-r, gray.900, rgba(17,25,40,0.9), rgba(17,25,40,0.4), transparent)'
|
||||
)}
|
||||
zIndex={1}
|
||||
/>
|
||||
<Box
|
||||
pointerEvents="none"
|
||||
position="absolute"
|
||||
right={0}
|
||||
top={0}
|
||||
bottom={0}
|
||||
w={{ base: 16, md: 24 }}
|
||||
bgGradient={useColorModeValue(
|
||||
'linear(to-l, white, rgba(255,255,255,0.9), rgba(255,255,255,0.4), transparent)',
|
||||
'linear(to-l, gray.900, rgba(17,25,40,0.9), rgba(17,25,40,0.4), transparent)'
|
||||
)}
|
||||
zIndex={1}
|
||||
/>
|
||||
|
||||
{/* scroll area */}
|
||||
<HStack
|
||||
ref={containerRef}
|
||||
spacing={4}
|
||||
overflowX="auto"
|
||||
py={2}
|
||||
px={1}
|
||||
cursor={draggable ? 'grab' : 'default'}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => { setIsHovering(false); if (draggable) onPointerUp(); }}
|
||||
onMouseDown={(e) => { if (!draggable) return; e.preventDefault(); onPointerDown(e.clientX); }}
|
||||
onMouseMove={(e) => { if (!draggable) return; onPointerMove(e.clientX); }}
|
||||
onMouseUp={() => { if (!draggable) return; onPointerUp(); }}
|
||||
onTouchStart={(e) => { if (!draggable) return; if (e.touches[0]) onPointerDown(e.touches[0].clientX); }}
|
||||
onTouchMove={(e) => { if (!draggable) return; if (e.touches[0]) onPointerMove(e.touches[0].clientX); }}
|
||||
onTouchEnd={() => { if (!draggable) return; onPointerUp(); }}
|
||||
css={{
|
||||
scrollSnapType: infiniteScroll ? 'none' : 'x proximity',
|
||||
scrollBehavior: 'smooth',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
'&::-webkit-scrollbar': {
|
||||
height: '6px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: 'transparent',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: useColorModeValue('rgba(0,0,0,0.15)', 'rgba(255,255,255,0.15)'),
|
||||
borderRadius: '3px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: useColorModeValue('rgba(0,0,0,0.25)', 'rgba(255,255,255,0.25)'),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{infiniteScroll && children}
|
||||
</HStack>
|
||||
|
||||
{/* navigation buttons - must be above gradient masks */}
|
||||
<IconButton
|
||||
aria-label="scroll left"
|
||||
icon={<ChevronLeftIcon boxSize={6} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
scrollBy(-1);
|
||||
}}
|
||||
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
onTouchStart={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
position="absolute"
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
left={{ base: 1, md: 2 }}
|
||||
size="lg"
|
||||
colorScheme="blackAlpha"
|
||||
bg={useColorModeValue('rgba(255,255,255,0.95)', 'rgba(45,55,72,0.95)')}
|
||||
color={useColorModeValue('gray.800', 'white')}
|
||||
boxShadow="xl"
|
||||
_hover={{ bg: useColorModeValue('white', 'gray.600'), transform: 'translateY(-50%) scale(1.15)', boxShadow: '2xl' }}
|
||||
_active={{ transform: 'translateY(-50%) scale(0.95)' }}
|
||||
transition="all 0.2s"
|
||||
zIndex={30}
|
||||
borderRadius="full"
|
||||
pointerEvents="auto"
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="scroll right"
|
||||
icon={<ChevronRightIcon boxSize={6} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
scrollBy(1);
|
||||
}}
|
||||
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
onTouchStart={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
position="absolute"
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
right={{ base: 1, md: 2 }}
|
||||
size="lg"
|
||||
colorScheme="blackAlpha"
|
||||
bg={useColorModeValue('rgba(255,255,255,0.95)', 'rgba(45,55,72,0.95)')}
|
||||
color={useColorModeValue('gray.800', 'white')}
|
||||
boxShadow="xl"
|
||||
_hover={{ bg: useColorModeValue('white', 'gray.600'), transform: 'translateY(-50%) scale(1.15)', boxShadow: '2xl' }}
|
||||
_active={{ transform: 'translateY(-50%) scale(0.95)' }}
|
||||
transition="all 0.2s"
|
||||
zIndex={30}
|
||||
borderRadius="full"
|
||||
pointerEvents="auto"
|
||||
/>
|
||||
|
||||
{/* bottom accent line */}
|
||||
<Box mt={2} h="2px" bg={theme.primary} borderRadius="full" />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default HorizontalScroller;
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
export interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
export interface CardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
|
||||
export interface CardContentProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const Card: React.FC<CardProps> = ({ className = '', children, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border bg-white text-slate-950 shadow ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardHeader: React.FC<CardHeaderProps> = ({ className = '', children, ...props }) => {
|
||||
return (
|
||||
<div className={`flex flex-col space-y-1.5 p-6 ${className}`} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardTitle: React.FC<CardTitleProps> = ({ className = '', children, ...props }) => {
|
||||
return (
|
||||
<h3 className={`text-2xl font-semibold leading-none tracking-tight ${className}`} {...props}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardContent: React.FC<CardContentProps> = ({ className = '', children, ...props }) => {
|
||||
return (
|
||||
<div className={`p-6 pt-0 ${className}`} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Box, Text, VStack, HStack, Image, Skeleton, Link as ChakraLink, Icon } from '@chakra-ui/react';
|
||||
import { FaNewspaper, FaUser, FaCalendarAlt } from 'react-icons/fa';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../../services/api';
|
||||
import { Widget } from './Widget';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { cs } from 'date-fns/locale';
|
||||
import { Article } from '../../types';
|
||||
|
||||
export const ArticlesWidget = () => {
|
||||
const { data: articles = [], isLoading, error } = useQuery<Article[]>({
|
||||
queryKey: ['recentArticles'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const { data } = await api.get('/articles', {
|
||||
params: {
|
||||
limit: 3,
|
||||
include: 'author',
|
||||
sort: '-createdAt',
|
||||
published: true
|
||||
}
|
||||
});
|
||||
return data.data || [];
|
||||
} catch (err) {
|
||||
console.error('Error fetching articles:', err);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Widget title="Poslední články" icon={FaNewspaper}>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Box key={i}>
|
||||
<Skeleton height="120px" mb={2} borderRadius="md" />
|
||||
<Skeleton height="20px" mb={2} width="80%" />
|
||||
<Skeleton height="16px" width="60%" />
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !articles.length) {
|
||||
return (
|
||||
<Widget title="Poslední články" icon={FaNewspaper}>
|
||||
<VStack p={4} spacing={4}>
|
||||
<Box p={4} bg="gray.50" borderRadius="md" textAlign="center" w="full">
|
||||
<Icon as={FaNewspaper} boxSize={6} color="gray.400" mb={2} />
|
||||
<Text color="gray.500">Žádné články nebyly nalezeny</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Widget title="Poslední články" icon={FaNewspaper}>
|
||||
<VStack spacing={3} align="stretch" divider={<Box borderBottomWidth="1px" borderColor="gray.100" />}>
|
||||
{articles.map((article) => (
|
||||
<ChakraLink
|
||||
key={article.id}
|
||||
as={RouterLink}
|
||||
to={`/clanky/${article.slug}`}
|
||||
_hover={{ textDecoration: 'none' }}
|
||||
display="block"
|
||||
>
|
||||
<Box _hover={{ bg: 'gray.50' }} borderRadius="md" p={2}>
|
||||
<HStack align="flex-start" spacing={3}>
|
||||
<Box
|
||||
flexShrink={0}
|
||||
width="60px"
|
||||
height="60px"
|
||||
bg="gray.100"
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
position="relative"
|
||||
>
|
||||
{article.imageUrl ? (
|
||||
<Image
|
||||
src={article.imageUrl}
|
||||
alt={article.title}
|
||||
width="100%"
|
||||
height="100%"
|
||||
objectFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
width="100%"
|
||||
height="100%"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="gray.200"
|
||||
>
|
||||
<Icon as={FaNewspaper} color="gray.400" boxSize={5} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box flex={1} minW={0}>
|
||||
<Text fontWeight="medium" fontSize="sm" noOfLines={2} mb={1}>
|
||||
{article.title}
|
||||
</Text>
|
||||
<HStack spacing={3} fontSize="xs" color="gray.500">
|
||||
<HStack spacing={1}>
|
||||
<Icon as={FaUser} boxSize={3} />
|
||||
<Text>{article.author.name}</Text>
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={FaCalendarAlt} boxSize={3} />
|
||||
<Text>
|
||||
{format(parseISO(article.createdAt), 'd. M. yyyy', {
|
||||
locale: cs,
|
||||
})}
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Box>
|
||||
</ChakraLink>
|
||||
))}
|
||||
<Box textAlign="right" mt={2}>
|
||||
<ChakraLink
|
||||
as={RouterLink}
|
||||
to="/admin/clanky"
|
||||
color="blue.500"
|
||||
fontWeight="medium"
|
||||
fontSize="sm"
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
Zobrazit všechny články →
|
||||
</ChakraLink>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,306 @@
|
||||
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 { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../../services/api';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { Widget } from './Widget';
|
||||
import { format, parse, isToday, isTomorrow, isAfter } from 'date-fns';
|
||||
import { cs } from 'date-fns/locale';
|
||||
import { Match } from '../../types';
|
||||
import { fetchTeamLogoOverrides } from '@/services/adminMatches';
|
||||
import { assetUrl } from '@/utils/url';
|
||||
import { TeamLogo } from '../common/TeamLogo';
|
||||
import '../../styles/logos.css';
|
||||
|
||||
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm';
|
||||
|
||||
const formatMatchDate = (dateString: string) => {
|
||||
try {
|
||||
// Parse FACR date format from cache JSON (e.g., "17.08.2025 15:00")
|
||||
const date = parse(dateString, FACR_DATE_FMT, new Date());
|
||||
|
||||
if (isToday(date)) {
|
||||
return format(date, 'HH:mm');
|
||||
} else if (isTomorrow(date)) {
|
||||
return format(date, "HH:mm '(zítra)'");
|
||||
} else {
|
||||
return format(date, 'dd.MM. HH:mm');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error formatting date:', error, dateString);
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
export const MatchesWidget = () => {
|
||||
const toast = useToast();
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [prefWeekly, setPrefWeekly] = useState<boolean>(true);
|
||||
const [prefMatches, setPrefMatches] = useState<boolean>(true);
|
||||
const [subscribing, setSubscribing] = useState<boolean>(false);
|
||||
const location = useLocation();
|
||||
const isAdminRoute = String(location.pathname || '').startsWith('/admin');
|
||||
|
||||
const resolveUrl = (path: string) => {
|
||||
try {
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const origin = new URL(apiUrl).origin;
|
||||
return origin + path;
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
};
|
||||
|
||||
const subscribe = async () => {
|
||||
if (!email) {
|
||||
toast({ title: 'Zadejte email', status: 'warning' });
|
||||
return;
|
||||
}
|
||||
setSubscribing(true);
|
||||
try {
|
||||
const res = await fetch(resolveUrl('/api/v1/newsletter/subscribe'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, preferences: { weekly: prefWeekly, matches: prefMatches } })
|
||||
});
|
||||
if (!res.ok) {
|
||||
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' });
|
||||
setEmail('');
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Chyba přihlášení', description: e?.message || String(e), status: 'error' });
|
||||
} finally {
|
||||
setSubscribing(false);
|
||||
}
|
||||
};
|
||||
const { settings } = useSettings();
|
||||
const { data: overrides = {} } = useQuery({
|
||||
queryKey: ['teamLogoOverrides'],
|
||||
queryFn: fetchTeamLogoOverrides,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const getLogo = (teamName?: string, original?: string) => {
|
||||
const byName = (overrides as any)?.by_name || {} as Record<string, string>;
|
||||
const norm = (s: string) => String(s || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const stripPrefixes = (s: string) => {
|
||||
let x = norm(s);
|
||||
x = x.replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g, '');
|
||||
return x.replace(/\s+/g, ' ').trim();
|
||||
};
|
||||
const byNameNorm: Record<string, string> = Object.keys(byName || {}).reduce((acc: Record<string, string>, k) => { acc[norm(k)] = byName[k]; return acc; }, {});
|
||||
const strippedPairs = Object.keys(byName || {}).map((k) => ({ key: stripPrefixes(k), url: byName[k] }));
|
||||
const pick = (name?: string, orig?: string) => {
|
||||
if (!name) return orig;
|
||||
const exact = byName[name];
|
||||
let candidate = exact || byNameNorm[norm(name)];
|
||||
if (!candidate) {
|
||||
const s = stripPrefixes(name);
|
||||
for (const { key, url } of strippedPairs) { if (key && (s.endsWith(key) || key.endsWith(s))) { candidate = url; break; } }
|
||||
}
|
||||
const chosen = candidate || orig;
|
||||
if (typeof chosen === 'string' && chosen.startsWith('/')) {
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const origin = new URL(apiUrl).origin;
|
||||
return origin + chosen;
|
||||
}
|
||||
return chosen || (assetUrl('/dist/img/logo-club-empty.svg') as string);
|
||||
};
|
||||
return pick(teamName, original);
|
||||
};
|
||||
|
||||
const { data: matches = [], isLoading, error } = useQuery<Match[]>({
|
||||
queryKey: ['upcomingMatchesCache'],
|
||||
queryFn: async () => {
|
||||
// Build absolute origin from API URL env (which may include /api/v1)
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const origin = new URL(apiUrl).origin;
|
||||
const url = `${origin}/cache/prefetch/facr_club_info.json`;
|
||||
|
||||
const res = await fetch(url, { headers: { 'Cache-Control': 'no-cache' } });
|
||||
if (!res.ok) throw new Error(`Failed to load cache: ${res.status}`);
|
||||
const json = await res.json();
|
||||
|
||||
// Flatten competitions -> matches, enrich with competition name
|
||||
const comps = Array.isArray(json?.competitions) ? json.competitions : [];
|
||||
const items: any[] = comps.flatMap((c: any) =>
|
||||
(Array.isArray(c.matches) ? c.matches : []).map((m: any) => ({ ...m, competitionName: c.name, competition_id: c.id }))
|
||||
);
|
||||
|
||||
// Parse, filter for future, sort ascending, take next 5
|
||||
const now = new Date();
|
||||
const upcoming = items
|
||||
.map((m) => ({
|
||||
...m,
|
||||
__dt: parse(String(m.date_time || m.date), FACR_DATE_FMT, new Date()),
|
||||
}))
|
||||
.filter((m) => isAfter(m.__dt, now))
|
||||
.sort((a, b) => a.__dt.getTime() - b.__dt.getTime())
|
||||
.slice(0, 5)
|
||||
.map((m) => ({
|
||||
id: m.match_id,
|
||||
date_time: m.date_time || m.date,
|
||||
competitionName: m.competitionName,
|
||||
home: m.home || m.home_team,
|
||||
away: m.away || m.away_team,
|
||||
score: m.score,
|
||||
venue: m.venue,
|
||||
home_logo_url: getLogo(m.home || m.home_team, m.home_logo_url),
|
||||
away_logo_url: getLogo(m.away || m.away_team, m.away_logo_url),
|
||||
})) as Match[];
|
||||
|
||||
return upcoming;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Widget title="Nadcházející zápasy">
|
||||
<VStack p={4}>
|
||||
<Spinner size="md" />
|
||||
<Text>Načítám zápasy...</Text>
|
||||
</VStack>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Widget title="Nadcházející zápasy">
|
||||
<Alert status="error" variant="left-accent">
|
||||
<AlertIcon />
|
||||
Nepodařilo se načíst zápasy. Zkuste to prosím později.
|
||||
</Alert>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
if (!matches || matches.length === 0) {
|
||||
return (
|
||||
<Widget title="Nadcházející zápasy">
|
||||
<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.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Widget title="Nadcházející zápasy">
|
||||
<VStack spacing={{ base: 2, md: 3 }} align="stretch" divider={<Box borderBottomWidth="1px" borderColor="gray.200" />}>
|
||||
{matches.map((match) => (
|
||||
<Box
|
||||
key={match.id}
|
||||
p={{ base: 3, md: 4 }}
|
||||
bg="gray.50"
|
||||
_hover={{ bg: 'gray.100' }}
|
||||
borderRadius="lg"
|
||||
transition="background-color 0.2s"
|
||||
shadow="sm"
|
||||
>
|
||||
<HStack justify="space-between" mb={1} spacing={2} flexWrap="wrap">
|
||||
<Text
|
||||
fontSize={{ base: 'xs', sm: 'sm' }}
|
||||
color="gray.700"
|
||||
fontWeight="medium"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{formatMatchDate(match.date_time)}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme="blue"
|
||||
variant="subtle"
|
||||
fontSize="xs"
|
||||
bg="blue.50"
|
||||
color="blue.700"
|
||||
>
|
||||
{match.competitionName}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
<HStack justify="space-between" align="center">
|
||||
<HStack flex={1} minW={0} spacing={2}>
|
||||
<Box flexShrink={0} className="match-widget-logo">
|
||||
<TeamLogo
|
||||
teamId={(match as any).home_id}
|
||||
teamName={match.home}
|
||||
facrLogo={match.home_logo_url}
|
||||
size="small"
|
||||
fallbackIcon={<Icon as={FaFutbol} color="gray.400" boxSize={{ base: 4, md: 5 }} />}
|
||||
/>
|
||||
</Box>
|
||||
<Text
|
||||
fontSize={{ base: 'xs', sm: 'sm' }}
|
||||
fontWeight="medium"
|
||||
isTruncated
|
||||
color="gray.800"
|
||||
>
|
||||
{match.home}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text
|
||||
fontSize={{ base: 'xs', sm: 'sm' }}
|
||||
fontWeight="bold"
|
||||
minW={{ base: '32px', sm: '40px' }}
|
||||
textAlign="center"
|
||||
color="gray.900"
|
||||
flexShrink={0}
|
||||
>
|
||||
{match.score || 'vs'}
|
||||
</Text>
|
||||
<HStack flex={1} justify="flex-end" spacing={2} minW={0}>
|
||||
<Text
|
||||
fontSize={{ base: 'xs', sm: 'sm' }}
|
||||
fontWeight="medium"
|
||||
isTruncated
|
||||
textAlign="right"
|
||||
color="gray.800"
|
||||
>
|
||||
{match.away}
|
||||
</Text>
|
||||
<Box flexShrink={0} className="match-widget-logo">
|
||||
<TeamLogo
|
||||
teamId={(match as any).away_id}
|
||||
teamName={match.away}
|
||||
facrLogo={match.away_logo_url}
|
||||
size="small"
|
||||
fallbackIcon={<Icon as={FaFutbol} color="gray.400" boxSize={{ base: 4, md: 5 }} />}
|
||||
/>
|
||||
</Box>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{match.venue && (
|
||||
<HStack mt={2} spacing={2} color="gray.500" fontSize="sm">
|
||||
<Icon as={FaMapMarkerAlt} boxSize={3} />
|
||||
<Text isTruncated>{match.venue}</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
{/* 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 e‑mailem?</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>
|
||||
</HStack>
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Box, Text, VStack, HStack, Image, Skeleton, Link, SimpleGrid, Tooltip, Icon } from '@chakra-ui/react';
|
||||
import { FaHandshake, FaExternalLinkAlt } from 'react-icons/fa';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../../services/api';
|
||||
import { Widget } from './Widget';
|
||||
import { Sponsor } from '../../types';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
export const SponsorsWidget = () => {
|
||||
const { data: sponsors = [], isLoading, error } = useQuery<Sponsor[]>({
|
||||
queryKey: ['sponsors'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const { data } = await api.get('/sponsors');
|
||||
const raw = Array.isArray(data) ? data : (data?.data || []);
|
||||
// Normalize fields and resolve logo URL against backend origin
|
||||
return (raw || []).map((s: any) => ({
|
||||
...s,
|
||||
logoUrl: assetUrl(s.logo_url) || s.logo_url,
|
||||
websiteUrl: s.website_url,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('Error fetching sponsors:', err);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
staleTime: 15 * 60 * 1000, // 15 minutes
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Widget title="Partneři klubu" icon={FaHandshake}>
|
||||
<SimpleGrid columns={2} spacing={4}>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} height="80px" borderRadius="md" />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !sponsors.length) {
|
||||
return (
|
||||
<Widget title="Partneři klubu" icon={FaHandshake}>
|
||||
<VStack p={4} spacing={4}>
|
||||
<Box p={4} bg="gray.50" borderRadius="md" textAlign="center" w="full">
|
||||
<Icon as={FaHandshake} boxSize={6} color="gray.400" mb={2} />
|
||||
<Text color="gray.500">Žádní partneři k zobrazení</Text>
|
||||
<Text fontSize="sm" color="gray.400" mt={2}>
|
||||
Buďte první, kdo nás podpoří
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Widget title="Partneři klubu" icon={FaHandshake}>
|
||||
<SimpleGrid columns={2} spacing={3}>
|
||||
{sponsors.map((sponsor) => (
|
||||
<Tooltip key={sponsor.id} label={sponsor.websiteUrl ? `Navštívit ${sponsor.name}` : sponsor.name} placement="top">
|
||||
<Link
|
||||
href={sponsor.websiteUrl || '#'}
|
||||
isExternal={!!sponsor.websiteUrl}
|
||||
_hover={{ textDecoration: 'none' }}
|
||||
display="block"
|
||||
>
|
||||
<Box
|
||||
p={3}
|
||||
borderWidth="1px"
|
||||
borderColor="gray.200"
|
||||
borderRadius="md"
|
||||
height="100%"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="white"
|
||||
_hover={{ shadow: 'sm', borderColor: 'blue.200' }}
|
||||
transition="all 0.2s"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
{sponsor.websiteUrl && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top={1}
|
||||
right={1}
|
||||
color="blue.400"
|
||||
fontSize="xs"
|
||||
opacity={0.7}
|
||||
>
|
||||
<FaExternalLinkAlt />
|
||||
</Box>
|
||||
)}
|
||||
<Image
|
||||
src={sponsor.logoUrl || assetUrl((sponsor as any).logo_url) || '/images/sponsors/placeholder.png'}
|
||||
alt={sponsor.name}
|
||||
maxH="50px"
|
||||
maxW="100px"
|
||||
objectFit="contain"
|
||||
fallback={
|
||||
<Box
|
||||
width="100px"
|
||||
height="50px"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="gray.50"
|
||||
borderRadius="md"
|
||||
p={2}
|
||||
textAlign="center"
|
||||
>
|
||||
<Text fontSize="xs" color="gray.500" noOfLines={2}>
|
||||
{sponsor.name}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Box mt={3} textAlign="center">
|
||||
<Link
|
||||
as={RouterLink}
|
||||
to="/partneri"
|
||||
fontSize="sm"
|
||||
color="blue.500"
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
>
|
||||
Zobrazit všechny partnery <FaExternalLinkAlt size={10} />
|
||||
</Link>
|
||||
</Box>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Box, Text, VStack, HStack, Avatar, Skeleton, Icon, Alert, AlertIcon } from '@chakra-ui/react';
|
||||
import { FaUsers, FaFutbol } from 'react-icons/fa';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Widget } from './Widget';
|
||||
import { getFacrTablesCache } from '../../services/facr/cache';
|
||||
import { getPublicSettings, PublicSettings } from '../../services/settings';
|
||||
|
||||
export const TeamsWidget = () => {
|
||||
// Load settings for primary club name (for exclusion)
|
||||
const { data: settings, isLoading: settingsLoading } = useQuery<PublicSettings>({
|
||||
queryKey: ['public-settings'],
|
||||
queryFn: getPublicSettings as any,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Load FACR tables cache
|
||||
const { data: facrTables, isLoading, error } = useQuery<any>({
|
||||
queryKey: ['facr-tables-cache'],
|
||||
queryFn: getFacrTablesCache,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const normalize = (s?: string) => {
|
||||
let out = String(s || '');
|
||||
// Normalize diacritics and case
|
||||
out = out
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase();
|
||||
// Unify various dash characters to a simple hyphen
|
||||
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
|
||||
// Remove legal suffixes like ", z.s." / ", z. s." / " z.s." / "o.s." at end
|
||||
out = out.replace(/[,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
|
||||
// Remove organization phrases/prefixes anywhere (keep core locality/name)
|
||||
const orgPhrases = [
|
||||
'fotbalovy klub',
|
||||
'sportovni klub',
|
||||
'telovychovna jednota',
|
||||
'skolni sportovni klub',
|
||||
'fotbal',
|
||||
'futsal',
|
||||
];
|
||||
for (const phrase of orgPhrases) {
|
||||
const re = new RegExp(`(^|\\b)${phrase}(\\b|$)`, 'g');
|
||||
out = out.replace(re, ' ');
|
||||
}
|
||||
// Remove common short prefixes (tokens) like FC, FK, MFK, TJ, SK, SFC, AFK at word boundaries
|
||||
out = out.replace(/\b(1\.)?\s*(sfc|afc|fc|fk|mfk|tj|sk|afk)\b\.?/g, ' ');
|
||||
// Remove punctuation except hyphen
|
||||
out = out.replace(/[\.,!;:()\[\]{}]/g, ' ');
|
||||
// Collapse multiple spaces and trim
|
||||
out = out.replace(/\s+/g, ' ').trim();
|
||||
return out;
|
||||
};
|
||||
|
||||
const primaryKey = normalize((settings as any)?.club_name);
|
||||
const teams = (() => {
|
||||
const map: Record<string, { id: string; name: string; logoUrl?: string }> = {};
|
||||
const comps = Array.isArray(facrTables?.competitions) ? facrTables.competitions : [];
|
||||
for (const c of comps) {
|
||||
const rows = Array.isArray(c?.table?.overall) ? c.table.overall : [];
|
||||
for (const r of rows) {
|
||||
const name = (r?.team || '').trim();
|
||||
if (!name) continue;
|
||||
const key = normalize(name);
|
||||
if (key && key === primaryKey) continue; // exclude primary club from listing
|
||||
if (!map[key]) {
|
||||
map[key] = { id: key, name, logoUrl: r?.team_logo_url };
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.values(map).slice(0, 8); // show up to 8
|
||||
})();
|
||||
|
||||
// If application settings are still loading, show skeletons
|
||||
if (settingsLoading || isLoading) {
|
||||
return (
|
||||
<Widget title="Týmy klubu" icon={FaUsers}>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<HStack key={i} p={2} spacing={3}>
|
||||
<Skeleton boxSize="40px" borderRadius="md" />
|
||||
<Box flex={1}>
|
||||
<Skeleton height="16px" width="80%" mb={1} />
|
||||
<Skeleton height="12px" width="60%" />
|
||||
</Box>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Widget title="Týmy klubu" icon={FaUsers}>
|
||||
<Alert status="info" variant="left-accent">
|
||||
<AlertIcon />
|
||||
Nepodařilo se načíst seznam týmů z cache.
|
||||
</Alert>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
if (teams.length === 0) {
|
||||
return (
|
||||
<Widget title="Týmy klubu" icon={FaUsers}>
|
||||
<VStack p={4} spacing={4}>
|
||||
<Box p={4} bg="gray.50" borderRadius="md" textAlign="center" w="full">
|
||||
<Icon as={FaFutbol} boxSize={6} color="gray.400" mb={2} />
|
||||
<Text color="gray.500">Žádné týmy nebyly nalezeny</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Widget title="Týmy klubu" icon={FaUsers}>
|
||||
<VStack spacing={3} align="stretch" divider={<Box borderBottomWidth="1px" borderColor="gray.100" />}>
|
||||
{teams.map((team) => (
|
||||
<Box
|
||||
key={team.id}
|
||||
p={3}
|
||||
_hover={{ bg: 'gray.50', cursor: 'pointer' }}
|
||||
borderRadius="md"
|
||||
>
|
||||
<HStack spacing={3}>
|
||||
<Avatar
|
||||
name={team.name}
|
||||
src={team.logoUrl}
|
||||
size="sm"
|
||||
bg="blue.100"
|
||||
color="blue.700"
|
||||
icon={<FaFutbol />}
|
||||
/>
|
||||
<Box flex={1} minW={0}>
|
||||
<Text fontWeight="medium" isTruncated>{team.name}</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { cs } from 'date-fns/locale';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Skeleton,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
useColorModeValue,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaChartLine, FaArrowUp, FaArrowDown } from 'react-icons/fa';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
} from 'chart.js';
|
||||
import { api } from '@/services/api';
|
||||
import { Widget } from './Widget';
|
||||
|
||||
// Register Chart.js components
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
);
|
||||
|
||||
|
||||
|
||||
interface ChartDataset {
|
||||
label: string;
|
||||
data: number[];
|
||||
borderColor: string;
|
||||
backgroundColor: string;
|
||||
tension: number;
|
||||
fill: boolean;
|
||||
}
|
||||
|
||||
interface VisitorStats {
|
||||
totalVisitors: number;
|
||||
changePercentage: number;
|
||||
chartData: {
|
||||
labels: string[];
|
||||
datasets: ChartDataset[];
|
||||
};
|
||||
}
|
||||
|
||||
export const VisitorsWidget = () => {
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const bgColor = useColorModeValue('white', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.300');
|
||||
|
||||
const { data: stats, isLoading, error } = useQuery<VisitorStats>({
|
||||
queryKey: ['analytics', 'visitors'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const { data } = await api.get('/analytics/visitors', {
|
||||
params: {
|
||||
days: 30,
|
||||
groupBy: 'day'
|
||||
}
|
||||
});
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('Error fetching visitor stats:', err);
|
||||
// Return mock data in case of error
|
||||
return generateMockData();
|
||||
}
|
||||
},
|
||||
staleTime: 15 * 60 * 1000, // 15 minutes
|
||||
});
|
||||
|
||||
const changePercentage = stats?.changePercentage ?? 0;
|
||||
const isPositiveChange = changePercentage >= 0;
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index' as const,
|
||||
intersect: false,
|
||||
backgroundColor: useColorModeValue('white', 'gray.800'),
|
||||
titleColor: textColor,
|
||||
bodyColor: textColor,
|
||||
borderColor: borderColor,
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
boxShadow: 'lg',
|
||||
callbacks: {
|
||||
label: (context: { parsed: { y: number } }) => {
|
||||
return `${context.parsed.y} návštěv`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
color: textColor,
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 7,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
borderDash: [3, 3],
|
||||
color: borderColor,
|
||||
},
|
||||
ticks: {
|
||||
color: textColor,
|
||||
precision: 0,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
radius: 0,
|
||||
hoverRadius: 6,
|
||||
hoverBorderWidth: 2,
|
||||
},
|
||||
line: {
|
||||
borderWidth: 2,
|
||||
tension: 0.3,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Generate mock data for the chart
|
||||
const generateMockData = (): VisitorStats => {
|
||||
const labels = Array.from({ length: 30 }, (_, i) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - (29 - i));
|
||||
return format(date, 'd. M.', { locale: cs });
|
||||
});
|
||||
|
||||
const baseData = Array.from({ length: 30 }, (_, i) => {
|
||||
const base = 50 + Math.floor(Math.random() * 50);
|
||||
const dayOfWeek = new Date().getDay();
|
||||
const dayFactor = dayOfWeek >= 5 ? 0.7 : 1.0;
|
||||
return Math.floor(base * dayFactor);
|
||||
});
|
||||
|
||||
return {
|
||||
totalVisitors: baseData.reduce((sum, val) => sum + val, 0),
|
||||
changePercentage: 12.5,
|
||||
chartData: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Návštěvníci',
|
||||
data: baseData,
|
||||
borderColor: 'rgba(66, 153, 225, 1)',
|
||||
backgroundColor: 'rgba(66, 153, 225, 0.5)',
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Widget title="Návštěvnost" icon={FaChartLine}>
|
||||
<VStack spacing={4}>
|
||||
<Skeleton height="200px" width="100%" borderRadius="md" />
|
||||
<HStack width="100%" justify="space-between">
|
||||
<Skeleton height="60px" flex={1} mr={2} />
|
||||
<Skeleton height="60px" flex={1} />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Use mock data if stats is not available
|
||||
const displayStats = stats || generateMockData();
|
||||
|
||||
return (
|
||||
<Widget title="Návštěvnost" icon={FaChartLine}>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Stat>
|
||||
<HStack justify="space-between">
|
||||
<Box>
|
||||
<StatLabel color={textColor}>Celkem návštěv</StatLabel>
|
||||
<StatNumber fontSize="2xl">
|
||||
{new Intl.NumberFormat('cs-CZ').format(displayStats.totalVisitors)}
|
||||
</StatNumber>
|
||||
</Box>
|
||||
<Box
|
||||
bg={isPositiveChange ? 'green.50' : 'red.50'}
|
||||
_dark={{
|
||||
bg: isPositiveChange ? 'green.900' : 'red.900',
|
||||
color: isPositiveChange ? 'green.200' : 'red.200',
|
||||
}}
|
||||
color={isPositiveChange ? 'green.600' : 'red.600'}
|
||||
px={3}
|
||||
py={1.5}
|
||||
borderRadius="full"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
>
|
||||
{isPositiveChange ? (
|
||||
<Icon as={FaArrowUp} mr={1} />
|
||||
) : (
|
||||
<Icon as={FaArrowDown} mr={1} />
|
||||
)}
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{Math.abs(changePercentage)}%
|
||||
</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
<StatHelpText color={textColor}>Za posledních 30 dní</StatHelpText>
|
||||
</Stat>
|
||||
|
||||
<Box height="200px" mt={4}>
|
||||
<Line data={displayStats.chartData} options={chartOptions} />
|
||||
</Box>
|
||||
|
||||
{!stats && (
|
||||
<Box width="100%" textAlign="center" mt={2}>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
<Icon as={FaChartLine} mr={1} />
|
||||
Zobrazují se ukázková data
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Box, BoxProps, Heading, Icon, useColorModeValue } from '@chakra-ui/react';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface WidgetProps extends BoxProps {
|
||||
title: string;
|
||||
icon?: any;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const Widget = ({ title, icon, children, ...rest }: WidgetProps) => {
|
||||
return (
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
_hover={{ boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
{...rest}
|
||||
>
|
||||
<Heading size="md" mb={4} display="flex" alignItems="center">
|
||||
{icon && <Icon as={icon} mr={2} />}
|
||||
{title}
|
||||
</Heading>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
// API configuration
|
||||
export const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
// App configuration
|
||||
export const APP_NAME = 'Fotbal Club';
|
||||
export const APP_DESCRIPTION = 'Oficiální web fotbalového klubu';
|
||||
|
||||
// Feature flags
|
||||
export const FEATURES = {
|
||||
ENABLE_REGISTRATION: process.env.REACT_APP_ENABLE_REGISTRATION !== 'false',
|
||||
ENABLE_NEWSLETTER: true,
|
||||
ENABLE_CONTACT_FORM: true,
|
||||
};
|
||||
|
||||
// Default settings
|
||||
export const DEFAULTS = {
|
||||
PAGE_SIZE: 10,
|
||||
DATE_FORMAT: 'dd. MM. yyyy',
|
||||
DATE_TIME_FORMAT: 'dd. MM. yyyy HH:mm',
|
||||
};
|
||||
|
||||
// Local storage keys
|
||||
export const STORAGE_KEYS = {
|
||||
AUTH_TOKEN: 'auth_token',
|
||||
USER_DATA: 'user_data',
|
||||
THEME_PREFERENCE: 'theme_preference',
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export const FACR_CLUB_ID = process.env.REACT_APP_FACR_CLUB_ID || '';
|
||||
export const FACR_CLUB_TYPE: 'football' | 'futsal' = (process.env.REACT_APP_FACR_CLUB_TYPE as any) || 'football';
|
||||
|
||||
export const isFacrConfigured = (): boolean => !!FACR_CLUB_ID;
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Typography System - Google Fonts Configuration
|
||||
* Provides curated font pairings with preview support
|
||||
*/
|
||||
|
||||
export interface FontPairing {
|
||||
id: string;
|
||||
name: string;
|
||||
heading: string;
|
||||
body: string;
|
||||
googleFontsUrl: string;
|
||||
cssHeading: string;
|
||||
cssBody: string;
|
||||
description: string;
|
||||
style: 'modern' | 'classic' | 'elegant' | 'bold' | 'playful' | 'tech';
|
||||
}
|
||||
|
||||
export const FONT_PAIRINGS: FontPairing[] = [
|
||||
{
|
||||
id: 'inter-inter',
|
||||
name: 'Inter (Modern Clean)',
|
||||
heading: 'Inter',
|
||||
body: 'Inter',
|
||||
googleFontsUrl: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap',
|
||||
cssHeading: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
cssBody: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
description: 'Moderní univerzální font navržený pro digitální čtení',
|
||||
style: 'modern',
|
||||
},
|
||||
{
|
||||
id: 'montserrat-opensans',
|
||||
name: 'Montserrat & Open Sans (Klasika)',
|
||||
heading: 'Montserrat',
|
||||
body: 'Open Sans',
|
||||
googleFontsUrl: 'https://fonts.googleapis.com/css2?family=Montserrat:wght@600;700;800;900&family=Open+Sans:wght@400;500;600;700&display=swap',
|
||||
cssHeading: "'Montserrat', -apple-system, sans-serif",
|
||||
cssBody: "'Open Sans', -apple-system, sans-serif",
|
||||
description: 'Oblíbená kombinace pro čitelnost a profesionalitu',
|
||||
style: 'classic',
|
||||
},
|
||||
{
|
||||
id: 'poppins-roboto',
|
||||
name: 'Poppins & Roboto (Sportovní)',
|
||||
heading: 'Poppins',
|
||||
body: 'Roboto',
|
||||
googleFontsUrl: 'https://fonts.googleapis.com/css2?family=Poppins:wght@600;700;800;900&family=Roboto:wght@400;500;600;700&display=swap',
|
||||
cssHeading: "'Poppins', -apple-system, sans-serif",
|
||||
cssBody: "'Roboto', -apple-system, sans-serif",
|
||||
description: 'Dynamická kombinace vhodná pro sportovní weby',
|
||||
style: 'bold',
|
||||
},
|
||||
{
|
||||
id: 'raleway-lato',
|
||||
name: 'Raleway & Lato (Elegantní)',
|
||||
heading: 'Raleway',
|
||||
body: 'Lato',
|
||||
googleFontsUrl: 'https://fonts.googleapis.com/css2?family=Raleway:wght@600;700;800;900&family=Lato:wght@400;500;600;700&display=swap',
|
||||
cssHeading: "'Raleway', -apple-system, sans-serif",
|
||||
cssBody: "'Lato', -apple-system, sans-serif",
|
||||
description: 'Elegantní a lehká kombinace pro prémiový vzhled',
|
||||
style: 'elegant',
|
||||
},
|
||||
{
|
||||
id: 'oswald-sourcesan',
|
||||
name: 'Oswald & Source Sans (Silné)',
|
||||
heading: 'Oswald',
|
||||
body: 'Source Sans 3',
|
||||
googleFontsUrl: 'https://fonts.googleapis.com/css2?family=Oswald:wght@600;700&family=Source+Sans+3:wght@400;500;600;700&display=swap',
|
||||
cssHeading: "'Oswald', -apple-system, sans-serif",
|
||||
cssBody: "'Source Sans 3', -apple-system, sans-serif",
|
||||
description: 'Výrazné nadpisy s čitelným textem',
|
||||
style: 'bold',
|
||||
},
|
||||
{
|
||||
id: 'nunito-nunito',
|
||||
name: 'Nunito (Přátelské)',
|
||||
heading: 'Nunito',
|
||||
body: 'Nunito',
|
||||
googleFontsUrl: 'https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800;900&display=swap',
|
||||
cssHeading: "'Nunito', -apple-system, sans-serif",
|
||||
cssBody: "'Nunito', -apple-system, sans-serif",
|
||||
description: 'Přátelský a přístupný vzhled pro komunitní weby',
|
||||
style: 'playful',
|
||||
},
|
||||
{
|
||||
id: 'worksans-worksans',
|
||||
name: 'Work Sans (Tech)',
|
||||
heading: 'Work Sans',
|
||||
body: 'Work Sans',
|
||||
googleFontsUrl: 'https://fonts.googleapis.com/css2?family=Work+Sans:wght@400;500;600;700;800;900&display=swap',
|
||||
cssHeading: "'Work Sans', -apple-system, sans-serif",
|
||||
cssBody: "'Work Sans', -apple-system, sans-serif",
|
||||
description: 'Moderní technický vzhled s výbornou čitelností',
|
||||
style: 'tech',
|
||||
},
|
||||
{
|
||||
id: 'rubik-rubik',
|
||||
name: 'Rubik (Zaoblené)',
|
||||
heading: 'Rubik',
|
||||
body: 'Rubik',
|
||||
googleFontsUrl: 'https://fonts.googleapis.com/css2?family=Rubik:wght@400;500;600;700;800;900&display=swap',
|
||||
cssHeading: "'Rubik', -apple-system, sans-serif",
|
||||
cssBody: "'Rubik', -apple-system, sans-serif",
|
||||
description: 'Zaoblený a moderní design s přátelským dojmem',
|
||||
style: 'playful',
|
||||
},
|
||||
{
|
||||
id: 'playfair-sourcesans',
|
||||
name: 'Playfair & Source Sans (Prestižní)',
|
||||
heading: 'Playfair Display',
|
||||
body: 'Source Sans 3',
|
||||
googleFontsUrl: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700;800;900&family=Source+Sans+3:wght@400;500;600;700&display=swap',
|
||||
cssHeading: "'Playfair Display', Georgia, serif",
|
||||
cssBody: "'Source Sans 3', -apple-system, sans-serif",
|
||||
description: 'Prestižní serifový nadpis s moderním textem',
|
||||
style: 'elegant',
|
||||
},
|
||||
{
|
||||
id: 'bebas-opensans',
|
||||
name: 'Bebas Neue & Open Sans (Impact)',
|
||||
heading: 'Bebas Neue',
|
||||
body: 'Open Sans',
|
||||
googleFontsUrl: 'https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Open+Sans:wght@400;500;600;700&display=swap',
|
||||
cssHeading: "'Bebas Neue', -apple-system, sans-serif",
|
||||
cssBody: "'Open Sans', -apple-system, sans-serif",
|
||||
description: 'Masivní nadpisy pro maximální dojem',
|
||||
style: 'bold',
|
||||
},
|
||||
{
|
||||
id: 'archivo-archivo',
|
||||
name: 'Archivo (Kompaktní)',
|
||||
heading: 'Archivo',
|
||||
body: 'Archivo',
|
||||
googleFontsUrl: 'https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800;900&display=swap',
|
||||
cssHeading: "'Archivo', -apple-system, sans-serif",
|
||||
cssBody: "'Archivo', -apple-system, sans-serif",
|
||||
description: 'Kompaktní a efektivní pro hustší obsah',
|
||||
style: 'tech',
|
||||
},
|
||||
{
|
||||
id: 'exo-exo',
|
||||
name: 'Exo 2 (Futuristické)',
|
||||
heading: 'Exo 2',
|
||||
body: 'Exo 2',
|
||||
googleFontsUrl: 'https://fonts.googleapis.com/css2?family=Exo+2:wght@400;500;600;700;800;900&display=swap',
|
||||
cssHeading: "'Exo 2', -apple-system, sans-serif",
|
||||
cssBody: "'Exo 2', -apple-system, sans-serif",
|
||||
description: 'Futuristický a technologický vzhled',
|
||||
style: 'tech',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get font pairing by ID
|
||||
*/
|
||||
export const getFontPairing = (id: string): FontPairing | undefined => {
|
||||
return FONT_PAIRINGS.find((f) => f.id === id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get default font pairing
|
||||
*/
|
||||
export const getDefaultFontPairing = (): FontPairing => {
|
||||
return FONT_PAIRINGS[0]; // Inter by default
|
||||
};
|
||||
|
||||
/**
|
||||
* Load Google Font dynamically
|
||||
*/
|
||||
export const loadGoogleFont = (googleFontsUrl: string): void => {
|
||||
const existingLink = document.querySelector(`link[href="${googleFontsUrl}"]`);
|
||||
if (existingLink) return; // Already loaded
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.href = googleFontsUrl;
|
||||
link.rel = 'stylesheet';
|
||||
link.crossOrigin = 'anonymous';
|
||||
document.head.appendChild(link);
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply font pairing to document
|
||||
*/
|
||||
export const applyFontPairing = (pairing: FontPairing): void => {
|
||||
loadGoogleFont(pairing.googleFontsUrl);
|
||||
|
||||
// Set CSS custom properties
|
||||
document.documentElement.style.setProperty('--font-heading', pairing.cssHeading);
|
||||
document.documentElement.style.setProperty('--font-body', pairing.cssBody);
|
||||
document.documentElement.style.setProperty('--chakra-fonts-heading', pairing.cssHeading);
|
||||
document.documentElement.style.setProperty('--chakra-fonts-body', pairing.cssBody);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get font style badge color
|
||||
*/
|
||||
export const getFontStyleColor = (style: FontPairing['style']): string => {
|
||||
const colors: Record<FontPairing['style'], string> = {
|
||||
modern: 'blue',
|
||||
classic: 'gray',
|
||||
elegant: 'purple',
|
||||
bold: 'orange',
|
||||
playful: 'pink',
|
||||
tech: 'cyan',
|
||||
};
|
||||
return colors[style] || 'gray';
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { isAuthenticated, checkAdminExists, getToken as getStoredToken, clearToken, setToken as storeToken } from '../utils/auth';
|
||||
import api, { API_URL } from '../services/api';
|
||||
import { User } from '../types';
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
adminExists: boolean;
|
||||
login: (token: string, userData: User, remember?: boolean) => Promise<void>;
|
||||
logout: () => void;
|
||||
updateUser: (userData: Partial<User>) => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [authenticated, setAuthenticated] = useState<boolean>(false);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [adminExists, setAdminExists] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeAuth = async () => {
|
||||
// Check if admin exists
|
||||
const adminCheck = await checkAdminExists();
|
||||
setAdminExists(adminCheck);
|
||||
|
||||
// Check authentication
|
||||
const token = getStoredToken();
|
||||
if (token && isAuthenticated()) {
|
||||
try {
|
||||
// Fetch user data from API
|
||||
const base = API_URL;
|
||||
const response = await fetch(`${base}/auth/me`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const userData = data.user || data;
|
||||
setUser(userData);
|
||||
setAuthenticated(true);
|
||||
|
||||
// If this is the first user, they become admin
|
||||
if (!adminCheck && !userData.role) {
|
||||
const makeAdminResponse = await fetch(`${base}/auth/make-admin`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (makeAdminResponse.ok) {
|
||||
const updatedUser = { ...userData, role: 'admin' };
|
||||
setUser(updatedUser);
|
||||
setAdminExists(true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
clearToken();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing auth:', error);
|
||||
clearToken();
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
initializeAuth();
|
||||
}, []);
|
||||
|
||||
const login = async (token: string, userData: User, remember: boolean = true) => {
|
||||
storeToken(token, remember);
|
||||
|
||||
// If the user already has admin role, reflect it immediately
|
||||
if (userData.role === 'admin') {
|
||||
setAdminExists(true);
|
||||
}
|
||||
// Otherwise, try to promote if no admin exists
|
||||
else if (!adminExists && !userData.role) {
|
||||
try {
|
||||
const base = API_URL;
|
||||
const response = await fetch(`${base}/auth/make-admin`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
userData.role = 'admin';
|
||||
setAdminExists(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error making user admin:', error);
|
||||
}
|
||||
}
|
||||
setUser(userData);
|
||||
setAuthenticated(true);
|
||||
};
|
||||
|
||||
const updateUser = (userData: Partial<User>) => {
|
||||
if (user) {
|
||||
setUser({ ...user, ...userData });
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
// Attempt to clear server-side HttpOnly cookie (if used)
|
||||
try {
|
||||
// Use axios instance with credentials enabled
|
||||
api.post('/auth/logout').catch(() => {});
|
||||
} catch {}
|
||||
clearToken();
|
||||
setUser(null);
|
||||
setAuthenticated(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
isAuthenticated: authenticated,
|
||||
user,
|
||||
isLoading: loading,
|
||||
adminExists,
|
||||
login,
|
||||
logout,
|
||||
updateUser
|
||||
}}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = (): AuthContextType => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { usePublicSettings } from '../hooks/usePublicSettings';
|
||||
import { facrApi } from '../services/facr/facrApi';
|
||||
import { extractPalette, pickTextColor, isContrastAccessible } from '../utils/colors';
|
||||
import { fetchLogoFromLogoAPI } from '../utils/sportLogosAPI';
|
||||
|
||||
export interface ClubTheme {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
accent: string;
|
||||
textOnPrimary: string;
|
||||
textOnSecondary?: string;
|
||||
textOnAccent?: string;
|
||||
background?: string;
|
||||
text?: string;
|
||||
logoUrl?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const defaultTheme: ClubTheme = {
|
||||
primary: '#0b5cff',
|
||||
secondary: '#ffd200',
|
||||
accent: '#141414',
|
||||
textOnPrimary: '#ffffff',
|
||||
};
|
||||
|
||||
const ClubThemeContext = createContext<ClubTheme>(defaultTheme);
|
||||
|
||||
export const useClubTheme = () => useContext(ClubThemeContext);
|
||||
|
||||
export const ClubThemeProvider: React.FC<{ children: React.ReactNode }>= ({ children }) => {
|
||||
const { data: settings } = usePublicSettings();
|
||||
const clubId = settings?.club_id;
|
||||
const clubType = settings?.club_type || 'football';
|
||||
|
||||
const [theme, setTheme] = useState<ClubTheme>(defaultTheme);
|
||||
|
||||
// Create a stable settings key to prevent unnecessary re-runs
|
||||
const settingsKey = useMemo(() => {
|
||||
if (!settings) return null;
|
||||
const key = JSON.stringify({
|
||||
primary_color: settings.primary_color,
|
||||
secondary_color: settings.secondary_color,
|
||||
accent_color: settings.accent_color,
|
||||
text_color: settings.text_color,
|
||||
background_color: settings.background_color,
|
||||
club_logo_url: settings.club_logo_url,
|
||||
club_id: settings.club_id,
|
||||
club_type: settings.club_type,
|
||||
club_name: settings.club_name,
|
||||
});
|
||||
return key;
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsKey) return; // Don't run if no settings available
|
||||
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
// Prefer explicit settings from Admin if available
|
||||
const explicitPrimary = settings?.primary_color && settings.primary_color.trim() !== '' ? settings.primary_color : undefined;
|
||||
const explicitSecondary = settings?.secondary_color && settings.secondary_color.trim() !== '' ? settings.secondary_color : undefined;
|
||||
const explicitAccent = settings?.accent_color && settings.accent_color.trim() !== '' ? settings.accent_color : undefined;
|
||||
const explicitText = settings?.text_color && settings.text_color.trim() !== '' ? settings.text_color : undefined;
|
||||
const explicitLogo = settings?.club_logo_url;
|
||||
const explicitName = settings?.club_name;
|
||||
const explicitBackground = settings?.background_color && settings.background_color.trim() !== '' ? settings.background_color : undefined;
|
||||
|
||||
let primary = explicitPrimary;
|
||||
let secondary = explicitSecondary;
|
||||
let accent = explicitAccent;
|
||||
let textOnPrimary = explicitText;
|
||||
let textOnSecondary: string | undefined;
|
||||
let textOnAccent: string | undefined;
|
||||
let logoUrl = explicitLogo;
|
||||
let name = explicitName;
|
||||
let background = explicitBackground;
|
||||
let text = explicitText;
|
||||
|
||||
// Strategy:
|
||||
// 1) If we have a logo URL, we can always extract colors from it even when clubId is missing.
|
||||
// 2) Only reach to FACR when logo or name are missing AND we do have a clubId.
|
||||
if (!primary || !secondary || !accent || !logoUrl || !name) {
|
||||
// If logo/name missing but we have clubId, fetch club basics
|
||||
if ((!logoUrl || !name) && clubId) {
|
||||
try {
|
||||
const club = await facrApi.getClub(clubId, clubType as any);
|
||||
// Prefer FACR temporarily; final preference will try logoapi below
|
||||
logoUrl = logoUrl || club.logo_url;
|
||||
name = name || club.name;
|
||||
} catch {
|
||||
// ignore fetch errors; we may still proceed with defaults/palette
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a logo at this point, try to extract palette regardless of clubId
|
||||
let colors: string[] = [];
|
||||
try {
|
||||
colors = logoUrl ? await extractPalette(logoUrl, 5) : [];
|
||||
} catch {
|
||||
colors = [];
|
||||
}
|
||||
primary = primary || colors[0] || defaultTheme.primary;
|
||||
secondary = secondary || colors[1] || defaultTheme.secondary;
|
||||
accent = accent || colors.find(c => pickTextColor(c) === '#ffffff') || '#1a1a1a';
|
||||
|
||||
// If after all this we still lack logo/name and also have no clubId, do not hard fail; continue with colors and defaults
|
||||
if (!logoUrl && !clubId) {
|
||||
// keep logoUrl undefined; theme will still apply colors
|
||||
}
|
||||
}
|
||||
|
||||
// Derive text colors for contrast
|
||||
textOnPrimary = textOnPrimary || pickTextColor(primary!);
|
||||
textOnSecondary = pickTextColor(secondary!);
|
||||
textOnAccent = pickTextColor(accent!);
|
||||
|
||||
// Background/text fallbacks
|
||||
background = background || '#ffffff';
|
||||
// Choose readable text color directly based on background contrast
|
||||
// If background is light (e.g., white), this will return a dark color (e.g., black)
|
||||
// If background is dark, it will return white for readability
|
||||
text = text || pickTextColor(background!);
|
||||
|
||||
// Ensure WCAG AA contrast for primary text if possible
|
||||
if (!isContrastAccessible(primary!, textOnPrimary)) {
|
||||
textOnPrimary = pickTextColor(primary!);
|
||||
}
|
||||
if (!isContrastAccessible(secondary!, textOnSecondary)) {
|
||||
textOnSecondary = pickTextColor(secondary!);
|
||||
}
|
||||
if (!isContrastAccessible(accent!, textOnAccent)) {
|
||||
textOnAccent = pickTextColor(accent!);
|
||||
}
|
||||
|
||||
// Prefer logo from logoapi.sportcreative.eu when club ID is known
|
||||
if (clubId) {
|
||||
try {
|
||||
const apiLogo = await fetchLogoFromLogoAPI(String(clubId), name);
|
||||
if (apiLogo) {
|
||||
logoUrl = apiLogo;
|
||||
}
|
||||
} catch {
|
||||
// best-effort only
|
||||
}
|
||||
}
|
||||
|
||||
const next: ClubTheme = {
|
||||
primary: primary!,
|
||||
secondary: secondary!,
|
||||
accent: accent!,
|
||||
textOnPrimary: textOnPrimary!,
|
||||
textOnSecondary,
|
||||
textOnAccent,
|
||||
background,
|
||||
text,
|
||||
logoUrl,
|
||||
name,
|
||||
};
|
||||
|
||||
// Only update theme if it has actually changed
|
||||
if (mounted && (
|
||||
theme.primary !== next.primary ||
|
||||
theme.secondary !== next.secondary ||
|
||||
theme.accent !== next.accent ||
|
||||
theme.textOnPrimary !== next.textOnPrimary ||
|
||||
theme.logoUrl !== next.logoUrl ||
|
||||
theme.name !== next.name
|
||||
)) {
|
||||
setTheme(next);
|
||||
// expose CSS variables for raw CSS usage
|
||||
// NOTE: Do NOT set --club-bg or --club-text as they interfere with Chakra's dark mode
|
||||
const r = document.documentElement;
|
||||
r.style.setProperty('--club-primary', next.primary);
|
||||
r.style.setProperty('--club-secondary', next.secondary);
|
||||
r.style.setProperty('--club-accent', next.accent);
|
||||
r.style.setProperty('--club-text-on-primary', next.textOnPrimary);
|
||||
if (next.textOnSecondary) r.style.setProperty('--club-text-on-secondary', next.textOnSecondary);
|
||||
if (next.textOnAccent) r.style.setProperty('--club-text-on-accent', next.textOnAccent);
|
||||
// Only set these as fallbacks, they should not override dark mode
|
||||
r.style.setProperty('--club-bg-light', background!);
|
||||
r.style.setProperty('--club-text-light', text!);
|
||||
|
||||
// Cache colors in localStorage for instant loading on next visit
|
||||
try {
|
||||
localStorage.setItem('club_theme_cache', JSON.stringify({
|
||||
primary: next.primary,
|
||||
secondary: next.secondary,
|
||||
accent: next.accent,
|
||||
textOnPrimary: next.textOnPrimary,
|
||||
textOnSecondary: next.textOnSecondary,
|
||||
textOnAccent: next.textOnAccent,
|
||||
background,
|
||||
text,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
} catch (e) {
|
||||
// localStorage may fail in private browsing
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('ClubTheme: Error updating theme:', error);
|
||||
// Don't reset to default theme on error - keep current theme
|
||||
// Only set default if this is the first load and we have no theme yet
|
||||
if (mounted && theme === defaultTheme) {
|
||||
setTheme(defaultTheme);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, [settingsKey]);
|
||||
|
||||
const value = useMemo(() => theme, [theme]);
|
||||
return (
|
||||
<ClubThemeContext.Provider value={value}>{children}</ClubThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
// Default element configuration for homepage
|
||||
// This provides the initial intended order and elements
|
||||
|
||||
import { PageElementConfig } from '../services/pageElements';
|
||||
|
||||
export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
|
||||
{
|
||||
page_type: 'homepage',
|
||||
element_name: 'header',
|
||||
variant: 'unified',
|
||||
visible: true,
|
||||
display_order: 0,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
page_type: 'homepage',
|
||||
element_name: 'hero',
|
||||
variant: 'grid',
|
||||
visible: true,
|
||||
display_order: 1,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
page_type: 'homepage',
|
||||
element_name: 'news',
|
||||
variant: 'grid',
|
||||
visible: true,
|
||||
display_order: 2,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
page_type: 'homepage',
|
||||
element_name: 'matches',
|
||||
variant: 'compact',
|
||||
visible: true,
|
||||
display_order: 3,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
page_type: 'homepage',
|
||||
element_name: 'sponsors',
|
||||
variant: 'grid',
|
||||
visible: true,
|
||||
display_order: 4,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
page_type: 'homepage',
|
||||
element_name: 'gallery',
|
||||
variant: 'grid',
|
||||
visible: false,
|
||||
display_order: 5,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
page_type: 'homepage',
|
||||
element_name: 'videos',
|
||||
variant: 'grid',
|
||||
visible: false,
|
||||
display_order: 6,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
page_type: 'homepage',
|
||||
element_name: 'team',
|
||||
variant: 'grid',
|
||||
visible: false,
|
||||
display_order: 7,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
page_type: 'homepage',
|
||||
element_name: 'activities',
|
||||
variant: 'list',
|
||||
visible: false,
|
||||
display_order: 8,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
page_type: 'homepage',
|
||||
element_name: 'newsletter',
|
||||
variant: 'default',
|
||||
visible: false,
|
||||
display_order: 9,
|
||||
settings: {},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,205 @@
|
||||
// Sparta-style element configurations
|
||||
// Based on AC Sparta Praha design patterns from rec directory
|
||||
|
||||
import { PageElementConfig } from '../services/pageElements';
|
||||
|
||||
export const SPARTA_STYLE_ELEMENTS: PageElementConfig[] = [
|
||||
{
|
||||
page_type: 'homepage',
|
||||
element_name: 'header',
|
||||
variant: 'sparta_navbar',
|
||||
visible: true,
|
||||
display_order: 0,
|
||||
settings: {
|
||||
logo: '/images/sparta-logo.svg',
|
||||
logoWidth: 48,
|
||||
logoHeight: 48,
|
||||
searchEnabled: true,
|
||||
authEnabled: true,
|
||||
burgerMenu: true,
|
||||
links: [
|
||||
{ label: 'Vstupenky', url: '/vstupenky', variant: 'tertiary' },
|
||||
{ label: 'Fanzone', url: '/fanzone', variant: 'tertiary' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
page_type: 'homepage',
|
||||
element_name: 'hero',
|
||||
variant: 'sparta_featured_carousel',
|
||||
visible: true,
|
||||
display_order: 1,
|
||||
settings: {
|
||||
autoSwap: true,
|
||||
swapInterval: 5000,
|
||||
showThumbnails: true,
|
||||
thumbnailCount: 4,
|
||||
showCategories: true,
|
||||
buttonLabel: 'Přehrát',
|
||||
buttonVariant: 'primary',
|
||||
height: {
|
||||
mobile: '42.375rem',
|
||||
tablet: '50rem',
|
||||
desktop: '53rem',
|
||||
},
|
||||
gradientOverlay: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
page_type: 'homepage',
|
||||
element_name: 'videos',
|
||||
variant: 'sparta_horizontal_slider',
|
||||
visible: true,
|
||||
display_order: 2,
|
||||
settings: {
|
||||
title: 'Videa',
|
||||
titleLink: '/sparta-tv',
|
||||
itemsPerView: {
|
||||
mobile: 1,
|
||||
tablet: 2,
|
||||
desktop: 3,
|
||||
},
|
||||
gap: 16,
|
||||
showControls: true,
|
||||
enableDrag: true,
|
||||
showUnlimitedBadge: true,
|
||||
showCategories: true,
|
||||
showDuration: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
page_type: 'homepage',
|
||||
element_name: 'team',
|
||||
variant: 'sparta_tabs_stats',
|
||||
visible: true,
|
||||
display_order: 3,
|
||||
settings: {
|
||||
title: 'Týmy',
|
||||
titleLink: '/tymy',
|
||||
tabs: [
|
||||
{ id: '1', label: 'Muži A', value: '1' },
|
||||
{ id: '3', label: 'Ženy A', value: '3' },
|
||||
{ id: '25', label: 'Muži B', value: '25' },
|
||||
],
|
||||
showBuyJersey: true,
|
||||
buyJerseyUrl: 'https://www.fnshp.cz/ac-sparta-praha',
|
||||
showTeamDetails: true,
|
||||
backgroundColor: 'var(--colorBgSecondary, #1e1e1e)',
|
||||
padding: {
|
||||
mobile: '1.625rem 1.25rem',
|
||||
desktop: '2.5rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
page_type: 'homepage',
|
||||
element_name: 'merch',
|
||||
variant: 'sparta_product_slider',
|
||||
visible: true,
|
||||
display_order: 4,
|
||||
settings: {
|
||||
title: 'Fanshop',
|
||||
titleLink: 'https://www.fnshp.cz/ac-sparta-praha',
|
||||
externalLink: true,
|
||||
itemsPerView: {
|
||||
mobile: 1,
|
||||
tablet: 2,
|
||||
desktop: 4,
|
||||
},
|
||||
gap: 16,
|
||||
showControls: true,
|
||||
enableDrag: true,
|
||||
currency: 'Kč',
|
||||
showPrices: true,
|
||||
buyButtonLabel: 'Koupit',
|
||||
},
|
||||
},
|
||||
{
|
||||
page_type: 'homepage',
|
||||
element_name: 'sponsors',
|
||||
variant: 'sparta_partners_pyramid',
|
||||
visible: true,
|
||||
display_order: 5,
|
||||
settings: {
|
||||
pyramidTiers: 3,
|
||||
topTierCount: 1,
|
||||
middleTierCount: 4,
|
||||
bottomTierCount: 8,
|
||||
imageFormat: 'svg',
|
||||
partners: {
|
||||
top: [
|
||||
{ name: 'Betano', logo: '/images/svg/footer/betano.svg', url: 'https://www.betano.cz/' },
|
||||
],
|
||||
middle: [
|
||||
{ name: 'EPET', logo: '/images/svg/footer/epet.svg', url: 'https://www.epet.cz/' },
|
||||
{ name: 'Adidas', logo: '/images/svg/footer/adidas.svg', url: 'https://www.adidas.cz/obuv-fotbal' },
|
||||
{ name: 'T-Mobile', logo: '/images/svg/footer/t-mobile.svg', url: 'https://www.t-mobile.cz/' },
|
||||
{ name: 'Chance Liga', logo: '/images/svg/footer/chance.svg', url: 'https://www.chanceliga.cz/' },
|
||||
],
|
||||
bottom: [
|
||||
{ name: 'ČSOB', logo: '/images/svg/footer/csob.svg', url: 'https://www.csob.cz/' },
|
||||
{ name: 'Renomia', logo: '/images/svg/footer/renomia.svg', url: 'https://www.renomia.cz/' },
|
||||
// Add more partners as needed
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
page_type: 'homepage',
|
||||
element_name: 'footer',
|
||||
variant: 'sparta_extended',
|
||||
visible: true,
|
||||
display_order: 6,
|
||||
settings: {
|
||||
showPartners: true,
|
||||
showNewsletter: true,
|
||||
showSocial: true,
|
||||
showNavigation: true,
|
||||
columns: [
|
||||
{
|
||||
title: 'Klub',
|
||||
links: [
|
||||
{ label: 'O klubu', url: '/o-klubu' },
|
||||
{ label: 'Historie', url: '/historie' },
|
||||
{ label: 'Kontakt', url: '/kontakt' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Týmy',
|
||||
links: [
|
||||
{ label: 'Muži A', url: '/tymy/1' },
|
||||
{ label: 'Ženy A', url: '/tymy/3' },
|
||||
{ label: 'Mládež', url: '/tymy/mladez' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Fanshop',
|
||||
links: [
|
||||
{ label: 'Dresy', url: 'https://www.fnshp.cz/dresy' },
|
||||
{ label: 'Oblečení', url: 'https://www.fnshp.cz/obleceni' },
|
||||
{ label: 'Doplňky', url: 'https://www.fnshp.cz/doplnky' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Additional utility function to merge Sparta elements with defaults
|
||||
export const mergeSpartaElements = (defaultElements: PageElementConfig[]): PageElementConfig[] => {
|
||||
const spartaElementMap = new Map(
|
||||
SPARTA_STYLE_ELEMENTS.map(el => [el.element_name, el])
|
||||
);
|
||||
|
||||
return defaultElements.map(defaultEl => {
|
||||
const spartaEl = spartaElementMap.get(defaultEl.element_name);
|
||||
if (spartaEl) {
|
||||
return {
|
||||
...defaultEl,
|
||||
variant: spartaEl.variant,
|
||||
settings: { ...defaultEl.settings, ...spartaEl.settings },
|
||||
};
|
||||
}
|
||||
return defaultEl;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useToast, UseToastOptions } from '@chakra-ui/react';
|
||||
|
||||
interface UseAdminPageOptions {
|
||||
successMessage?: string;
|
||||
errorMessage?: string;
|
||||
toastPosition?: UseToastOptions['position'];
|
||||
}
|
||||
|
||||
export const useAdminPage = (options: UseAdminPageOptions = {}) => {
|
||||
const {
|
||||
successMessage = 'Operation completed successfully',
|
||||
errorMessage = 'An error occurred',
|
||||
toastPosition = 'top-right',
|
||||
} = options;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const toast = useToast();
|
||||
|
||||
const handleAsyncOperation = useCallback(
|
||||
async <T,>(
|
||||
operation: () => Promise<T>,
|
||||
customMessages?: {
|
||||
success?: string;
|
||||
error?: string;
|
||||
}
|
||||
): Promise<T | undefined> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await operation();
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: customMessages?.success || successMessage,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
position: toastPosition,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(errorMessage);
|
||||
setError(error);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: customMessages?.error || error.message || errorMessage,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
position: toastPosition,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[successMessage, errorMessage, toast, toastPosition]
|
||||
);
|
||||
|
||||
const resetError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
handleAsyncOperation,
|
||||
resetError,
|
||||
};
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user