Files
MyClub/frontend/src/App.tsx
T
Tomas Dvorak 087f30e82c dev day #80
2025-11-02 21:31:00 +01:00

574 lines
22 KiB
TypeScript

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, useLocation } from 'react-router-dom';
import './styles/custom-scrollbar.css';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import AuthPage from './pages/AuthPage';
import RegisterPage from './pages/RegisterPage';
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 ShortlinksAdminPage from './pages/admin/ShortlinksAdminPage';
import CommentsAdminPage from './pages/admin/CommentsAdminPage';
import EngagementAdminPage from './pages/admin/EngagementAdminPage';
import SweepstakesAdminPage from './pages/admin/SweepstakesAdminPage';
import SweepstakeVisualPage from './pages/admin/SweepstakeVisualPage';
import SemiAdminPage from './pages/SemiAdminPage';
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 OverlaySponsorsPage from './pages/OverlaySponsorsPage';
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 ShortRedirectPage from './pages/ShortRedirectPage';
import ClothingPage from './pages/ClothingPage';
import PollsPage from './pages/PollsPage';
import { useUmami } from './hooks/useUmami';
import { checkin } from './services/engagement';
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;
};
// Component to trigger daily check-in for authenticated users (once per day per device)
const CheckinInitializer: React.FC = () => {
const { isAuthenticated } = useAuth();
useEffect(() => {
if (!isAuthenticated) return;
let cancelled = false;
const todayKey = (() => {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `fc_checkin_${y}-${m}-${day}`;
})();
try {
if (localStorage.getItem(todayKey) === '1') return;
} catch {}
// Fire and forget; backend caps ensure idempotence server-side
(async () => {
try { await checkin(); if (!cancelled) { try { localStorage.setItem(todayKey, '1'); } catch {} } } catch {}
})();
return () => { cancelled = true; };
}, [isAuthenticated]);
return null;
};
// Redirect /news -> /blog while preserving query parameters
const NewsRedirect: React.FC = () => {
const loc = useLocation();
return <Navigate to={`/blog${loc.search || ''}`} replace />;
};
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, user } = 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) {
const role = String(user?.role || '').toLowerCase();
if (role === 'admin') {
return <Navigate to="/admin" replace />;
}
if (role === 'editor') {
return <Navigate to="/admin" replace />;
}
if (role === 'fan') {
return <Navigate to="/semiadmin" replace />;
}
// Default: regular users to frontpage
return <Navigate to="/" 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 />
<CheckinInitializer />
<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="/overlay/sponsors" element={<OverlaySponsorsPage />} />
<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 />} />
{/* Short links - forward to backend origin if frontend captured it */}
<Route path="/s/:code" element={<ShortRedirectPage />} />
<Route path="/news" element={<NewsRedirect />} />
{/* 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="/register"
element={
<PublicRoute>
<RegisterPage />
</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="/semiadmin"
element={
<ProtectedRoute>
<SemiAdminPage />
</ProtectedRoute>
}
/>
<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 />} />
{/* moved to editor-accessible routes below */}
<Route path="/admin/o-klubu" element={<AboutAdminPage />} />
<Route path="/admin/videa" element={<AdminVideosPage />} />
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
<Route path="/admin/kategorie" element={<CategoriesAdminPage />} />
{/* moved to editor-accessible routes below */}
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
<Route path="/admin/tymy" element={<TeamsAdminPage />} />
<Route path="/admin/uzivatele" element={<UsersAdminPage />} />
<Route path="/admin/bannery" element={<BannersAdminPage />} />
<Route path="/admin/zpravy" element={<MessagesAdminPage />} />
<Route path="/admin/nastaveni" element={<SettingsAdminPage />} />
<Route path="/admin/newsletter" element={<NewsletterAdminPage />} />
<Route path="/admin/ankety" element={<PollsAdminPage />} />
<Route path="/admin/aliasy-soutezi" element={<CompetitionAliasesAdminPage />} />
<Route path="/admin/prefetch" element={<PrefetchAdminPage />} />
<Route path="/admin/users/send-reset" element={<AdminResetPasswordPage />} />
<Route path="/admin/scoreboard" element={<ScoreboardAdminPage />} />
<Route path="/admin/scoreboard/remote" element={<MobileScoreboardControlPage />} />
<Route path="/admin/analytika" element={<AnalyticsAdminPage />} />
<Route path="/admin/shortlinks" element={<ShortlinksAdminPage />} />
<Route path="/admin/soubory" element={<FilesAdminPage />} />
<Route path="/admin/kontakty" element={<ContactsAdminPage />} />
<Route path="/admin/navigace" element={<NavigationAdminPage />} />
<Route path="/admin/komentare" element={<CommentsAdminPage />} />
<Route path="/admin/engagement" element={<EngagementAdminPage />} />
<Route path="/admin/sweepstakes" element={<SweepstakesAdminPage />} />
<Route path="/admin/sweepstakes/:id/visual" element={<SweepstakeVisualPage />} />
</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>
}
/>
{/* Editor-accessible content pages (also allow admin) */}
<Route
path="/admin/clanky"
element={
<ProtectedRoute requiredRole="editor">
<ArticlesAdminPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/aktivity"
element={
<ProtectedRoute requiredRole="editor">
<AdminActivitiesPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/media"
element={
<ProtectedRoute requiredRole="editor">
<MediaAdminPage />
</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;