mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
hot fix #1
This commit is contained in:
+40
-52
@@ -10,11 +10,12 @@ import { useUmami } from './hooks/useUmami';
|
||||
import { useFontLoader } from './hooks/useFontLoader';
|
||||
import DefaultSEO from './components/seo/DefaultSEO';
|
||||
import CookieBanner from './components/CookieBanner';
|
||||
import { ConfirmDialogProvider } from './contexts/ConfirmDialogContext';
|
||||
import ServiceWorkerUpdateListener from './components/common/ServiceWorkerUpdateListener';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import { getSetupStatus } from './services/setup';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { usePublicSettings } from './hooks/usePublicSettings';
|
||||
import { getEditorAllowedAdminNav } from './services/navigation';
|
||||
|
||||
// Create a client
|
||||
const queryClient = new QueryClient({
|
||||
@@ -77,6 +78,7 @@ const OverlayScoreboardPage = lazy(() => import('./pages/OverlayScoreboardPage')
|
||||
const OverlaySponsorsPage = lazy(() => import('./pages/OverlaySponsorsPage'));
|
||||
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
|
||||
const ForbiddenPage = lazy(() => import('./pages/ForbiddenPage'));
|
||||
const I18nTestPage = lazy(() => import('./pages/I18nTestPage'));
|
||||
|
||||
// Legal pages
|
||||
const CookiePolicyPage = lazy(() => import('./pages/legal/CookiePolicyPage'));
|
||||
@@ -101,6 +103,7 @@ const AdminVideosPage = lazy(() => import('./pages/admin/AdminVideosPage'));
|
||||
const GalleryAdminPage = lazy(() => import('./pages/admin/GalleryAdminPage'));
|
||||
const AdminActivitiesPage = lazy(() => import('./pages/admin/AdminActivitiesPage'));
|
||||
const AdminMerchPage = lazy(() => import('./pages/admin/AdminMerchPage'));
|
||||
const AdminEshopProductsPage = lazy(() => import('./pages/admin/AdminEshopProductsPage'));
|
||||
const AdminResetPasswordPage = lazy(() => import('./pages/admin/AdminResetPasswordPage'));
|
||||
const AboutAdminPage = lazy(() => import('./pages/admin/AboutAdminPage'));
|
||||
const AnalyticsAdminPage = lazy(() => import('./pages/admin/AnalyticsAdminPage'));
|
||||
@@ -116,8 +119,17 @@ const ShortlinksAdminPage = lazy(() => import('./pages/admin/ShortlinksAdminPage
|
||||
const EngagementAdminPage = lazy(() => import('./pages/admin/EngagementAdminPage'));
|
||||
const SweepstakesAdminPage = lazy(() => import('./pages/admin/SweepstakesAdminPage'));
|
||||
const SweepstakeVisualPage = lazy(() => import('./pages/admin/SweepstakeVisualPage'));
|
||||
const I18nAdminPage = lazy(() => import('./pages/admin/I18nAdminPage'));
|
||||
const SemiAdminPage = lazy(() => import('./pages/SemiAdminPage'));
|
||||
const ErrorsAdminPage = lazy(() => import('./pages/admin/ErrorsAdminPage'));
|
||||
const ManualFacrAdminPage = lazy(() => import('./pages/admin/ManualFacrAdminPage'));
|
||||
const FinancialDashboard = lazy(() => import('./pages/admin/FinancialDashboard'));
|
||||
const QRCodesAdminPage = lazy(() => import('./pages/admin/QRCodesAdminPage'));
|
||||
const ExpensesPage = lazy(() => import('./pages/admin/ExpensesPage'));
|
||||
const InvoicesPage = lazy(() => import('./pages/admin/InvoicesPage'));
|
||||
const InvoiceSettingsPage = lazy(() => import('./pages/admin/InvoiceSettingsPage'));
|
||||
const KontaktyPage = lazy(() => import('./pages/admin/KontaktyPage'));
|
||||
const TicketAdminPage = lazy(() => import('./pages/admin/TicketAdminPage'));
|
||||
|
||||
// Analytics and font loader
|
||||
const AnalyticsInitializer: React.FC = () => {
|
||||
@@ -178,50 +190,12 @@ const AdminRoutesWrapper = () => {
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
// Admin index: admins see dashboard; editors redirect to first allowed page
|
||||
// Admin index: admins and editors see the main dashboard; others are forbidden
|
||||
const AdminIndexRoute: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const role = (user as any)?.role;
|
||||
const [target, setTarget] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(role === 'editor');
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
if (role === 'editor') {
|
||||
try {
|
||||
const items: any[] = await getEditorAllowedAdminNav();
|
||||
let to = '/admin/clanky';
|
||||
if (Array.isArray(items) && items.length > 0) {
|
||||
const pickUrl = (it: any): string | null => {
|
||||
if (it?.url) return it.url;
|
||||
if (Array.isArray(it?.children) && it.children.length > 0) {
|
||||
for (const ch of it.children) {
|
||||
if (ch?.url) return ch.url;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
for (const it of items) {
|
||||
const u = pickUrl(it);
|
||||
if (u) { to = u; break; }
|
||||
}
|
||||
}
|
||||
if (mounted) setTarget(to);
|
||||
} catch (_) {
|
||||
if (mounted) setTarget('/admin/clanky');
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, [role]);
|
||||
|
||||
if (role === 'admin') return <AdminDashboardPage />;
|
||||
if (role === 'editor') {
|
||||
if (loading) return <PageLoader />;
|
||||
return <Navigate to={target || '/admin/clanky'} replace />;
|
||||
const role = String((user as any)?.role || '').toLowerCase();
|
||||
if (role === 'admin' || role === 'editor') {
|
||||
return <AdminDashboardPage />;
|
||||
}
|
||||
return <Navigate to="/403" replace />;
|
||||
};
|
||||
@@ -254,6 +228,8 @@ const AppLazy: React.FC = () => {
|
||||
<AnalyticsInitializer />
|
||||
<FontLoader />
|
||||
<DefaultSEO />
|
||||
<ConfirmDialogProvider>
|
||||
<ServiceWorkerUpdateListener />
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
@@ -262,6 +238,7 @@ const AppLazy: React.FC = () => {
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/overlay/scoreboard" element={<OverlayScoreboardPage />} />
|
||||
<Route path="/overlay/sponsors" element={<OverlaySponsorsPage />} />
|
||||
<Route path="/i18n-test" element={<I18nTestPage />} />
|
||||
<Route path="/blog" element={<BlogRoute />} />
|
||||
<Route path="/klub" element={<ClubPage />} />
|
||||
<Route path="/o-klubu" element={<AboutPage />} />
|
||||
@@ -327,30 +304,31 @@ const AppLazy: React.FC = () => {
|
||||
<Route path="/admin/clanky" element={<ArticlesAdminPage />} />
|
||||
<Route path="/admin/aktivity" element={<AdminActivitiesPage />} />
|
||||
<Route path="/admin/shortlinks" element={<ShortlinksAdminPage />} />
|
||||
<Route path="/admin/tymy" element={<TeamsAdminPage />} />
|
||||
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
|
||||
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
|
||||
<Route path="/admin/aliasy-soutezi" element={<CompetitionAliasesAdminPage />} />
|
||||
<Route path="/admin/o-klubu" element={<AboutAdminPage />} />
|
||||
<Route path="/admin/videa" element={<AdminVideosPage />} />
|
||||
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
|
||||
<Route path="/admin/scoreboard" element={<ScoreboardAdminPage />} />
|
||||
<Route path="/admin/scoreboard/remote" element={<MobileScoreboardControlPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Admin routes */}
|
||||
<Route element={<ProtectedRoute requiredRole="admin"><AdminRoutesWrapper /></ProtectedRoute>}>
|
||||
<Route path="/admin/docs" element={<AdminDocsPage />} />
|
||||
<Route path="/admin/o-klubu" element={<AboutAdminPage />} />
|
||||
<Route path="/admin/videa" element={<AdminVideosPage />} />
|
||||
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
|
||||
<Route path="/admin/eshop-produkty" element={<AdminEshopProductsPage />} />
|
||||
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
|
||||
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
|
||||
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
|
||||
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
|
||||
<Route path="/admin/tymy" element={<TeamsAdminPage />} />
|
||||
<Route path="/admin/uzivatele" element={<UsersAdminPage />} />
|
||||
<Route path="/admin/bannery" element={<BannersAdminPage />} />
|
||||
<Route path="/admin/zpravy" element={<MessagesAdminPage />} />
|
||||
<Route path="/admin/nastaveni" element={<SettingsAdminPage />} />
|
||||
<Route path="/admin/newsletter" element={<NewsletterAdminPage />} />
|
||||
<Route path="/admin/ankety" element={<PollsAdminPage />} />
|
||||
<Route path="/admin/aliasy-soutezi" element={<CompetitionAliasesAdminPage />} />
|
||||
<Route path="/admin/prefetch" element={<PrefetchAdminPage />} />
|
||||
<Route path="/admin/users/send-reset" element={<AdminResetPasswordPage />} />
|
||||
<Route path="/admin/scoreboard" element={<ScoreboardAdminPage />} />
|
||||
<Route path="/admin/scoreboard/remote" element={<MobileScoreboardControlPage />} />
|
||||
<Route path="/admin/analytika" element={<AnalyticsAdminPage />} />
|
||||
<Route path="/admin/errors" element={<ErrorsAdminPage />} />
|
||||
<Route path="/admin/soubory" element={<FilesAdminPage />} />
|
||||
@@ -361,6 +339,15 @@ const AppLazy: React.FC = () => {
|
||||
<Route path="/admin/engagement" element={<EngagementAdminPage />} />
|
||||
<Route path="/admin/sweepstakes" element={<SweepstakesAdminPage />} />
|
||||
<Route path="/admin/sweepstakes/:id/visual" element={<SweepstakeVisualPage />} />
|
||||
<Route path="/admin/i18n" element={<I18nAdminPage />} />
|
||||
<Route path="/admin/manual-data" element={<ManualFacrAdminPage />} />
|
||||
<Route path="/admin/financial-dashboard" element={<FinancialDashboard />} />
|
||||
<Route path="/admin/qr-codes" element={<QRCodesAdminPage />} />
|
||||
<Route path="/admin/expenses" element={<ExpensesPage />} />
|
||||
<Route path="/admin/invoices" element={<InvoicesPage />} />
|
||||
<Route path="/admin/invoice-settings" element={<InvoiceSettingsPage />} />
|
||||
<Route path="/admin/customers" element={<KontaktyPage />} />
|
||||
<Route path="/admin/tickets" element={<TicketAdminPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Legacy admin routes */}
|
||||
@@ -375,6 +362,7 @@ const AppLazy: React.FC = () => {
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<CookieBanner />
|
||||
</ConfirmDialogProvider>
|
||||
</HelmetProvider>
|
||||
</ClubThemeProvider>
|
||||
</AuthProvider>
|
||||
|
||||
+118
-13
@@ -56,7 +56,10 @@ import ShortlinksAdminPage from './pages/admin/ShortlinksAdminPage';
|
||||
import CommentsAdminPage from './pages/admin/CommentsAdminPage';
|
||||
import EngagementAdminPage from './pages/admin/EngagementAdminPage';
|
||||
import SweepstakesAdminPage from './pages/admin/SweepstakesAdminPage';
|
||||
import FinancialDashboard from './pages/admin/FinancialDashboard';
|
||||
import QRCodesAdminPage from './pages/admin/QRCodesAdminPage';
|
||||
import SweepstakeVisualPage from './pages/admin/SweepstakeVisualPage';
|
||||
import I18nAdminPage from './pages/admin/I18nAdminPage';
|
||||
import SemiAdminPage from './pages/SemiAdminPage';
|
||||
import PollsAdminPage from './pages/admin/PollsAdminPage';
|
||||
// Admin pages render their own AdminLayout internally
|
||||
@@ -64,12 +67,15 @@ import SetupPage from './pages/SetupPage';
|
||||
import StylePreviewPage from './pages/StylePreviewPage';
|
||||
import AboutPage from './pages/AboutPage';
|
||||
import AdminDocsPage from './pages/admin/AdminDocsPage';
|
||||
import ManualFacrAdminPage from './pages/admin/ManualFacrAdminPage';
|
||||
import ScoreboardAdminPage from './pages/admin/ScoreboardAdminPage';
|
||||
import MobileScoreboardControlPage from './pages/admin/MobileScoreboardControlPage';
|
||||
import { getSetupStatus } from './services/setup';
|
||||
import NewsletterUnsubscribePage from './pages/NewsletterUnsubscribePage';
|
||||
import NewsletterPreferencesPage from './pages/NewsletterPreferencesPage';
|
||||
import { ClubThemeProvider } from './contexts/ClubThemeContext';
|
||||
import { ConfirmDialogProvider } from './contexts/ConfirmDialogContext';
|
||||
import ServiceWorkerUpdateListener from './components/common/ServiceWorkerUpdateListener';
|
||||
import CookiePolicyPage from './pages/legal/CookiePolicyPage';
|
||||
import OverlayScoreboardPage from './pages/OverlayScoreboardPage';
|
||||
import OverlaySponsorsPage from './pages/OverlaySponsorsPage';
|
||||
@@ -82,7 +88,6 @@ import ForbiddenPage from './pages/ForbiddenPage';
|
||||
import NotFoundPage from './pages/NotFoundPage';
|
||||
import VideosPage from './pages/VideosPage';
|
||||
import SearchPage from './pages/SearchPage';
|
||||
import ShortRedirectPage from './pages/ShortRedirectPage';
|
||||
import ClothingPage from './pages/ClothingPage';
|
||||
import PollsPage from './pages/PollsPage';
|
||||
import { useUmami } from './hooks/useUmami';
|
||||
@@ -277,7 +282,7 @@ const FontLoader: React.FC = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// Component to trigger daily check-in for authenticated users (once per day per device)
|
||||
// Component to trigger daily check-in for authenticated users (once per day)
|
||||
const CheckinInitializer: React.FC = () => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
useEffect(() => {
|
||||
@@ -399,10 +404,12 @@ const App: React.FC = () => {
|
||||
<Router>
|
||||
<AuthProvider>
|
||||
<ClubThemeProvider>
|
||||
<ConfirmDialogProvider>
|
||||
<AnalyticsInitializer />
|
||||
<FontLoader />
|
||||
<RouteLogger />
|
||||
<CheckinInitializer />
|
||||
<ServiceWorkerUpdateListener />
|
||||
<DefaultSEO />
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
@@ -433,8 +440,7 @@ const App: React.FC = () => {
|
||||
<Route path="/pravidla-cookies" element={<CookiePolicyPage />} />
|
||||
<Route path="/obchodni-podminky" element={<TermsPage />} />
|
||||
<Route path="/zasady-ochrany-osobnich-udaju" element={<PrivacyPolicyPage />} />
|
||||
{/* Short links - forward to backend origin if frontend captured it */}
|
||||
<Route path="/s/:code" element={<ShortRedirectPage />} />
|
||||
{/* Short links are handled by nginx -> backend, not React Router */}
|
||||
<Route path="/news" element={<NewsRedirect />} />
|
||||
{/* Slug routes must precede id route to avoid conflicts */}
|
||||
<Route path="/news/:slug" element={<ArticleDetailPage />} />
|
||||
@@ -510,26 +516,21 @@ const App: React.FC = () => {
|
||||
}>
|
||||
<Route path="/admin/docs" element={<AdminDocsPage />} />
|
||||
{/* moved to editor-accessible routes below */}
|
||||
<Route path="/admin/o-klubu" element={<AboutAdminPage />} />
|
||||
<Route path="/admin/videa" element={<AdminVideosPage />} />
|
||||
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
|
||||
|
||||
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
|
||||
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
|
||||
{/* moved to editor-accessible routes below */}
|
||||
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
|
||||
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
|
||||
<Route path="/admin/tymy" element={<TeamsAdminPage />} />
|
||||
|
||||
<Route path="/admin/uzivatele" element={<UsersAdminPage />} />
|
||||
<Route path="/admin/bannery" element={<BannersAdminPage />} />
|
||||
<Route path="/admin/zpravy" element={<MessagesAdminPage />} />
|
||||
<Route path="/admin/nastaveni" element={<SettingsAdminPage />} />
|
||||
<Route path="/admin/newsletter" element={<NewsletterAdminPage />} />
|
||||
<Route path="/admin/ankety" element={<PollsAdminPage />} />
|
||||
<Route path="/admin/aliasy-soutezi" element={<CompetitionAliasesAdminPage />} />
|
||||
|
||||
<Route path="/admin/prefetch" element={<PrefetchAdminPage />} />
|
||||
<Route path="/admin/users/send-reset" element={<AdminResetPasswordPage />} />
|
||||
<Route path="/admin/scoreboard" element={<ScoreboardAdminPage />} />
|
||||
<Route path="/admin/scoreboard/remote" element={<MobileScoreboardControlPage />} />
|
||||
|
||||
<Route path="/admin/analytika" element={<AnalyticsAdminPage />} />
|
||||
<Route path="/admin/soubory" element={<FilesAdminPage />} />
|
||||
<Route path="/admin/kontakty" element={<ContactsAdminPage />} />
|
||||
@@ -538,6 +539,7 @@ const App: React.FC = () => {
|
||||
<Route path="/admin/engagement" element={<EngagementAdminPage />} />
|
||||
<Route path="/admin/sweepstakes" element={<SweepstakesAdminPage />} />
|
||||
<Route path="/admin/sweepstakes/:id/visual" element={<SweepstakeVisualPage />} />
|
||||
<Route path="/admin/jazyky" element={<I18nAdminPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Remaining protected routes that don't use AdminLayout */}
|
||||
@@ -604,11 +606,114 @@ const App: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Financial Management */}
|
||||
<Route
|
||||
path="/admin/financial-dashboard"
|
||||
element={
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<FinancialDashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* QR Codes */}
|
||||
<Route
|
||||
path="/admin/qr-codes"
|
||||
element={
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<QRCodesAdminPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Editor-level admin pages (also allow admin) */}
|
||||
<Route
|
||||
path="/admin/tymy"
|
||||
element={
|
||||
<ProtectedRoute requiredRole="editor">
|
||||
<TeamsAdminPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/zapasy"
|
||||
element={
|
||||
<ProtectedRoute requiredRole="editor">
|
||||
<MatchesAdminPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/hraci"
|
||||
element={
|
||||
<ProtectedRoute requiredRole="editor">
|
||||
<PlayersAdminPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/aliasy-soutezi"
|
||||
element={
|
||||
<ProtectedRoute requiredRole="editor">
|
||||
<CompetitionAliasesAdminPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/manual-data"
|
||||
element={
|
||||
<ProtectedRoute requiredRole="admin">
|
||||
<ManualFacrAdminPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/o-klubu"
|
||||
element={
|
||||
<ProtectedRoute requiredRole="editor">
|
||||
<AboutAdminPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/videa"
|
||||
element={
|
||||
<ProtectedRoute requiredRole="editor">
|
||||
<AdminVideosPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/galerie"
|
||||
element={
|
||||
<ProtectedRoute requiredRole="editor">
|
||||
<GalleryAdminPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/scoreboard"
|
||||
element={
|
||||
<ProtectedRoute requiredRole="editor">
|
||||
<ScoreboardAdminPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/scoreboard/remote"
|
||||
element={
|
||||
<ProtectedRoute requiredRole="editor">
|
||||
<MobileScoreboardControlPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Not found route */}
|
||||
<Route path="*" element={<NotFoundRoute />} />
|
||||
</Routes>
|
||||
{/* Cookie consent banner shown across the whole site */}
|
||||
<CookieBanner />
|
||||
</ConfirmDialogProvider>
|
||||
</ClubThemeProvider>
|
||||
</AuthProvider>
|
||||
</Router>
|
||||
|
||||
+203
-307
@@ -4,7 +4,6 @@ import {
|
||||
Flex,
|
||||
Button,
|
||||
useColorModeValue,
|
||||
useColorMode,
|
||||
IconButton,
|
||||
Avatar,
|
||||
Menu,
|
||||
@@ -36,7 +35,7 @@ import {
|
||||
Input,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { MoonIcon, SunIcon, HamburgerIcon, EditIcon, ChevronDownIcon } from '@chakra-ui/icons';
|
||||
import { HamburgerIcon, EditIcon, ChevronDownIcon } from '@chakra-ui/icons';
|
||||
import { FaFacebook, FaInstagram, FaYoutube, FaPhotoVideo, FaExternalLinkAlt, FaShoppingBag, FaCamera, FaSearch } from 'react-icons/fa';
|
||||
import { Link as RouterLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
@@ -49,6 +48,7 @@ import { getNavigationItems, NavigationItem, seedDefaultNavigation } from '../se
|
||||
import { getEvents } from '../services/eventService';
|
||||
import { getPlayers } from '../services/public';
|
||||
import { getArticles } from '../services/articles';
|
||||
import { LanguageSwitcher } from './common/LanguageSwitcher';
|
||||
import { getCachedYouTube } from '../services/youtube';
|
||||
import { getZoneramaManifestWithFallbacks } from '../services/zonerama';
|
||||
import { getMyNewsletterToken } from '../services/public/newsletter';
|
||||
@@ -57,6 +57,9 @@ import { assetUrl } from '../utils/url';
|
||||
import { getProfile as getEngagementProfile, EngagementProfile } from '../services/engagement';
|
||||
import AchievementsModal from './engagement/AchievementsModal';
|
||||
import RewardsModal from './engagement/RewardsModal';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavbarData } from '../hooks/useNavbarData';
|
||||
|
||||
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
|
||||
|
||||
@@ -85,7 +88,7 @@ const normalizeSocialUrl = (network: 'facebook' | 'instagram' | 'youtube', raw?:
|
||||
};
|
||||
|
||||
// Mobile menu component
|
||||
const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, dividerColor, settings, categories, galleryHref, galleryLabel, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, dynamicNavItems, navLoading }: {
|
||||
const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, dividerColor, settings, categories, galleryHref, galleryLabel, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, dynamicNavItems, navLoading, t, i18n, handleSimpleLanguageChange }: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
isAdmin: boolean;
|
||||
@@ -96,25 +99,27 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
|
||||
categories?: Category[] | null;
|
||||
galleryHref?: string | null;
|
||||
galleryLabel?: string;
|
||||
hasTables?: boolean | null;
|
||||
hasActivities?: boolean | null;
|
||||
hasPlayers?: boolean | null;
|
||||
hasArticles?: boolean | null;
|
||||
hasVideos?: boolean | null;
|
||||
hasGallery?: boolean | null;
|
||||
dynamicNavItems: NavigationItem[];
|
||||
navLoading: boolean;
|
||||
hasTables?: boolean;
|
||||
hasActivities?: boolean;
|
||||
hasPlayers?: boolean;
|
||||
hasArticles?: boolean;
|
||||
hasVideos?: boolean;
|
||||
hasGallery?: boolean;
|
||||
dynamicNavItems?: NavigationItem[];
|
||||
navLoading?: boolean;
|
||||
t: (key: string) => string;
|
||||
i18n: any;
|
||||
handleSimpleLanguageChange: (lang: string) => void;
|
||||
}) => (
|
||||
<Drawer isOpen={isOpen} placement="left" onClose={onClose}>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent bg={menuBg}>
|
||||
<DrawerCloseButton />
|
||||
<DrawerHeader borderBottomWidth="1px" borderColor="border.subtle">Menu</DrawerHeader>
|
||||
<DrawerHeader borderBottomWidth="1px" borderColor="border.subtle">{t('action.open_menu')}</DrawerHeader>
|
||||
<DrawerBody>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{/* Dynamic navigation items in mobile */}
|
||||
{(!navLoading && dynamicNavItems.length > 0) ? (
|
||||
// Use dynamic navigation
|
||||
{(!navLoading && dynamicNavItems && dynamicNavItems.length > 0) ? (
|
||||
dynamicNavItems.map((item, idx) => {
|
||||
const linkIsExternal = item.type === 'external';
|
||||
const hasChildren = item.type === 'dropdown' && item.children && item.children.length > 0;
|
||||
@@ -138,7 +143,7 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
|
||||
{item.label}
|
||||
</Button>
|
||||
<VStack align="stretch" pl={4} spacing={1}>
|
||||
{categories.map((cat: any) => {
|
||||
{categories.map((cat: any, catIdx: number) => {
|
||||
const catIsExternal = typeof cat.url === 'string' && /^https?:\/\//i.test(cat.url);
|
||||
const catHref = cat.url || (cat.id ? `/blog?category_id=${cat.id}` : (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog'));
|
||||
const catLinkProps = catIsExternal ? { href: catHref } : { to: catHref };
|
||||
@@ -196,20 +201,20 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
|
||||
) : (
|
||||
// Fallback to hardcoded navigation
|
||||
<>
|
||||
<Button as={RouterLink} to="/" variant="ghost" justifyContent="flex-start">Domů</Button>
|
||||
<Button as={RouterLink} to="/" variant="ghost" justifyContent="flex-start">{t('nav.home')}</Button>
|
||||
{(settings?.show_about_in_nav ?? true) && (
|
||||
<Button as={RouterLink} to="/o-klubu" variant="ghost" justifyContent="flex-start">O klubu</Button>
|
||||
<Button as={RouterLink} to="/o-klubu" variant="ghost" justifyContent="flex-start">{t('nav.club')}</Button>
|
||||
)}
|
||||
<Button as={RouterLink} to="/kalendar" variant="ghost" justifyContent="flex-start">Kalendář</Button>
|
||||
<Button as={RouterLink} to="/zapasy" variant="ghost" justifyContent="flex-start">Zápasy</Button>
|
||||
{hasActivities === true && (
|
||||
<Button as={RouterLink} to="/aktivity" variant="ghost" justifyContent="flex-start">Aktivity</Button>
|
||||
<Button as={RouterLink} to="/kalendar" variant="ghost" justifyContent="flex-start">{t('nav.calendar')}</Button>
|
||||
<Button as={RouterLink} to="/zapasy" variant="ghost" justifyContent="flex-start">{t('nav.matches')}</Button>
|
||||
{hasActivities && (
|
||||
<Button as={RouterLink} to="/aktivity" variant="ghost" justifyContent="flex-start">{t('nav.activities')}</Button>
|
||||
)}
|
||||
{hasPlayers === true && (
|
||||
<Button as={RouterLink} to="/hraci" variant="ghost" justifyContent="flex-start">Hráči</Button>
|
||||
{hasPlayers && (
|
||||
<Button as={RouterLink} to="/hraci" variant="ghost" justifyContent="flex-start">{t('nav.players')}</Button>
|
||||
)}
|
||||
{hasTables === true && (
|
||||
<Button as={RouterLink} to="/tabulky" variant="ghost" justifyContent="flex-start">Tabulky</Button>
|
||||
{hasTables && (
|
||||
<Button as={RouterLink} to="/tabulky" variant="ghost" justifyContent="flex-start">{t('nav.tables')}</Button>
|
||||
)}
|
||||
{Array.isArray(settings?.custom_nav) && settings.custom_nav.length > 0 && settings.custom_nav.map((item: any, idx: number) => {
|
||||
const customLinkIsExternal = typeof item?.url === 'string' && /^https?:\/\//i.test(item.url);
|
||||
@@ -225,17 +230,19 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
|
||||
variant="ghost"
|
||||
justifyContent="flex-start"
|
||||
>
|
||||
{item?.label || 'Stránka'}
|
||||
{item?.label || t('common.page')}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{hasArticles === true && (
|
||||
{hasArticles && (
|
||||
<>
|
||||
{Array.isArray(categories) && categories.length > 0 ? (
|
||||
<>
|
||||
<Button as={RouterLink} to="/blog" onClick={onClose} variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
|
||||
<Button as={RouterLink} to="/blog" onClick={onClose} variant="ghost" justifyContent="flex-start" fontWeight="bold">
|
||||
{t('nav.articles')}
|
||||
</Button>
|
||||
<VStack align="stretch" pl={4} spacing={1}>
|
||||
{categories.map((cat: any) => {
|
||||
{categories.map((cat: any, catIdx: number) => {
|
||||
const catIsExternal = typeof cat.url === 'string' && /^https?:\/\//i.test(cat.url);
|
||||
const catHref = cat.url || (cat.id ? `/blog?category_id=${cat.id}` : (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog'));
|
||||
const catLinkProps = catIsExternal ? { href: catHref } : { to: catHref };
|
||||
@@ -248,31 +255,33 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
|
||||
</VStack>
|
||||
</>
|
||||
) : (
|
||||
<Button as={RouterLink} to="/blog" onClick={onClose} variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
|
||||
<Button as={RouterLink} to="/blog" onClick={onClose} variant="ghost" justifyContent="flex-start" fontWeight="bold">
|
||||
{t('nav.articles')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{hasVideos === true && (
|
||||
<Button as={RouterLink} to="/videa" variant="ghost" justifyContent="flex-start">Videa</Button>
|
||||
{hasVideos && (
|
||||
<Button as={RouterLink} to="/videa" variant="ghost" justifyContent="flex-start">{t('nav.videos')}</Button>
|
||||
)}
|
||||
<Button as={RouterLink} to="/hledat" variant="ghost" justifyContent="flex-start">Hledat</Button>
|
||||
{hasGallery === true && (
|
||||
<Button as={RouterLink} to="/galerie" variant="ghost" justifyContent="flex-start">{galleryLabel || 'Fotogalerie'}</Button>
|
||||
<Button as={RouterLink} to="/hledat" variant="ghost" justifyContent="flex-start">{t('action.search')}</Button>
|
||||
{hasGallery && (
|
||||
<Button as={RouterLink} to="/galerie" variant="ghost" justifyContent="flex-start">{galleryLabel || t('homepage.gallery')}</Button>
|
||||
)}
|
||||
{settings?.shop_url && (
|
||||
<Button as="a" href={settings.shop_url} target="_blank" rel="noreferrer" variant="ghost" justifyContent="flex-start">Fanshop</Button>
|
||||
<Button as="a" href={settings.shop_url} target="_blank" rel="noreferrer" variant="ghost" justifyContent="flex-start">{t('nav.shop')}</Button>
|
||||
)}
|
||||
<Button as={RouterLink} to="/sponzori" variant="ghost" justifyContent="flex-start">Sponzoři</Button>
|
||||
<Button as={RouterLink} to="/kontakt" variant="ghost" justifyContent="flex-start">Kontakt</Button>
|
||||
<Button as={RouterLink} to="/sponzori" variant="ghost" justifyContent="flex-start">{t('nav.sponsors')}</Button>
|
||||
<Button as={RouterLink} to="/kontakt" variant="ghost" justifyContent="flex-start">{t('nav.contact')}</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Divider my={2} borderColor={dividerColor} />
|
||||
<Text fontWeight="bold" mt={2} color={dividerColor}>Administrace</Text>
|
||||
<Text fontWeight="bold" mt={2} color={dividerColor}>{t('nav.admin')}</Text>
|
||||
<Button as={RouterLink} to="/admin" variant="ghost" justifyContent="flex-start" colorScheme="blue">
|
||||
Administrace
|
||||
{t('nav.admin')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -281,13 +290,16 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
|
||||
<>
|
||||
<Divider my={2} borderColor={dividerColor} />
|
||||
<Button as={RouterLink} to="/login" colorScheme="blue" justifyContent="flex-start">
|
||||
Přihlásit se
|
||||
{t('action.login')}
|
||||
</Button>
|
||||
<Button as={RouterLink} to="/register" variant="outline" justifyContent="flex-start">
|
||||
Registrovat se
|
||||
{t('common.register')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Language switcher for mobile */}
|
||||
<LanguageSwitcher />
|
||||
</VStack>
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
@@ -295,7 +307,7 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
|
||||
);
|
||||
|
||||
const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth = false, variant = 'unified' }) => {
|
||||
const { colorMode, toggleColorMode } = useColorMode();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { isAuthenticated, logout, user } = useAuth();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { isOpen: isSearchOpen, onOpen: onSearchOpen, onClose: onSearchClose } = useDisclosure();
|
||||
@@ -314,20 +326,15 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
|
||||
const navTextColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const topBarBg = useColorModeValue('gray.50', 'blackAlpha.500');
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [hasTables, setHasTables] = useState<boolean | null>(null);
|
||||
const [hasActivities, setHasActivities] = useState<boolean | null>(null);
|
||||
const [hasPlayers, setHasPlayers] = useState<boolean | null>(null);
|
||||
const [hasArticles, setHasArticles] = useState<boolean | null>(null);
|
||||
const [hasVideos, setHasVideos] = useState<boolean | null>(null);
|
||||
const [hasGallery, setHasGallery] = useState<boolean | null>(null);
|
||||
const [dynamicNavItems, setDynamicNavItems] = useState<NavigationItem[]>([]);
|
||||
const [navLoading, setNavLoading] = useState(true);
|
||||
const containerMaxW = fullWidth ? 'full' as const : '7xl' as const;
|
||||
const [windowWidth, setWindowWidth] = useState<number>(typeof window !== 'undefined' ? window.innerWidth : 1920);
|
||||
const [engProfile, setEngProfile] = useState<EngagementProfile | null>(null);
|
||||
const { isOpen: isAchOpen, onOpen: onAchOpen, onClose: onAchClose } = useDisclosure();
|
||||
const { isOpen: isRewOpen, onOpen: onRewOpen, onClose: onRewClose } = useDisclosure();
|
||||
|
||||
// Use the combined navbar data hook
|
||||
const navbarData = useNavbarData(isAdmin, settings);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setWindowWidth(window.innerWidth);
|
||||
window.addEventListener('resize', onResize);
|
||||
@@ -379,6 +386,24 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
|
||||
return () => window.removeEventListener('scroll', onScroll as any);
|
||||
}, []);
|
||||
|
||||
// Simple language change handler for non-logged-in users
|
||||
const handleSimpleLanguageChange = async (languageCode: string) => {
|
||||
try {
|
||||
// Change language in i18next
|
||||
await i18n.changeLanguage(languageCode);
|
||||
|
||||
// Save to localStorage and cookie
|
||||
try {
|
||||
localStorage.setItem('language', languageCode);
|
||||
} catch (e) {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
document.cookie = `lang=${languageCode}; max-age=${365 * 24 * 60 * 60}; path=/`;
|
||||
} catch (error) {
|
||||
console.error('Failed to change language:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Open newsletter preferences for logged-in user (fetch token and redirect)
|
||||
const openMyNewsletterPrefs = async () => {
|
||||
try {
|
||||
@@ -427,162 +452,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
|
||||
|
||||
// gallery link (generic first, fallback to zonerama)
|
||||
const galleryHref = settings?.gallery_url || settings?.zonerama_url;
|
||||
const galleryLabel = settings?.gallery_label || 'Fotogalerie';
|
||||
|
||||
// Load dynamic navigation from API
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const items = await getNavigationItems();
|
||||
if (active && Array.isArray(items)) {
|
||||
// Filter out admin-only navigation items for public display
|
||||
const publicItems = items.filter(item => !item.requires_admin);
|
||||
|
||||
// Auto-seed if navigation is empty (only if user is authenticated as admin)
|
||||
if (publicItems.length === 0 && isAdmin) {
|
||||
try {
|
||||
console.log('Navigation empty, auto-seeding...');
|
||||
await seedDefaultNavigation();
|
||||
const newItems = await getNavigationItems();
|
||||
if (active && Array.isArray(newItems)) {
|
||||
const publicNewItems = newItems.filter(item => !item.requires_admin);
|
||||
setDynamicNavItems(publicNewItems);
|
||||
}
|
||||
} catch (seedError) {
|
||||
console.error('Auto-seed failed:', seedError);
|
||||
// Continue with empty navigation
|
||||
}
|
||||
} else {
|
||||
setDynamicNavItems(publicItems);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load navigation:', error);
|
||||
} finally {
|
||||
if (active) setNavLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { active = false };
|
||||
}, [isAdmin]);
|
||||
|
||||
// categories: prefer API, fallback to settings.categories
|
||||
const [navCategories, setNavCategories] = useState<Category[] | null>(null);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const cats = await getCategories();
|
||||
if (active && Array.isArray(cats) && cats.length > 0) {
|
||||
setNavCategories(cats);
|
||||
} else if (active && Array.isArray(settings?.categories)) {
|
||||
setNavCategories(settings!.categories as any);
|
||||
}
|
||||
} catch {
|
||||
if (active && Array.isArray(settings?.categories)) {
|
||||
setNavCategories(settings!.categories as any);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => { active = false };
|
||||
}, [settings?.categories]);
|
||||
|
||||
// Determine if there is any table data available (prefetch snapshot)
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
const resolveBackendUrl = (path: string) => {
|
||||
try {
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
|
||||
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
|
||||
return new URL(path, origin).toString();
|
||||
}
|
||||
return path;
|
||||
} catch { return path; }
|
||||
};
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(resolveBackendUrl('/cache/prefetch/facr_tables.json'), { cache: 'no-cache' });
|
||||
if (!res.ok) { if (!disposed) setHasTables(false); return; }
|
||||
const json = await res.json();
|
||||
const anyRows = Array.isArray(json?.competitions) && json.competitions.some((c: any) => Array.isArray(c?.table?.overall) && c.table.overall.length > 0);
|
||||
if (!disposed) setHasTables(!!anyRows);
|
||||
} catch {
|
||||
if (!disposed) setHasTables(false);
|
||||
}
|
||||
})();
|
||||
return () => { disposed = true; };
|
||||
}, []);
|
||||
|
||||
// Determine if there are any activities/events available
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
(async () => {
|
||||
try {
|
||||
const events = await getEvents();
|
||||
if (!disposed) setHasActivities(Array.isArray(events) && events.length > 0);
|
||||
} catch {
|
||||
if (!disposed) setHasActivities(false);
|
||||
}
|
||||
})();
|
||||
return () => { disposed = true; };
|
||||
}, []);
|
||||
|
||||
// Determine if there are any players available
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
(async () => {
|
||||
try {
|
||||
const players = await getPlayers();
|
||||
if (!disposed) setHasPlayers(Array.isArray(players) && players.length > 0);
|
||||
} catch {
|
||||
if (!disposed) setHasPlayers(false);
|
||||
}
|
||||
})();
|
||||
return () => { disposed = true; };
|
||||
}, []);
|
||||
|
||||
// Determine if there are any articles available
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
(async () => {
|
||||
try {
|
||||
const result = await getArticles({ page: 1, page_size: 1, published: true });
|
||||
if (!disposed) setHasArticles(result.total > 0);
|
||||
} catch {
|
||||
if (!disposed) setHasArticles(false);
|
||||
}
|
||||
})();
|
||||
return () => { disposed = true; };
|
||||
}, []);
|
||||
|
||||
// Determine if there are any videos available
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
(async () => {
|
||||
try {
|
||||
const youtube = await getCachedYouTube();
|
||||
if (!disposed) setHasVideos(youtube && Array.isArray(youtube.videos) && youtube.videos.length > 0);
|
||||
} catch {
|
||||
if (!disposed) setHasVideos(false);
|
||||
}
|
||||
})();
|
||||
return () => { disposed = true; };
|
||||
}, []);
|
||||
|
||||
// Determine if there is any gallery content available
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
(async () => {
|
||||
try {
|
||||
const manifest = await getZoneramaManifestWithFallbacks();
|
||||
if (!disposed) setHasGallery(Array.isArray(manifest) && manifest.length > 0);
|
||||
} catch {
|
||||
if (!disposed) setHasGallery(false);
|
||||
}
|
||||
})();
|
||||
return () => { disposed = true; };
|
||||
}, []);
|
||||
const galleryLabel = settings?.gallery_label || t('homepage.gallery');
|
||||
|
||||
const isPathActive = (to?: string) => {
|
||||
if (!to) return false;
|
||||
@@ -592,8 +462,30 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
|
||||
|
||||
// Convert NavigationItem to NavLink format
|
||||
const convertToNavLink = (item: NavigationItem): NavLink => {
|
||||
// Map known Czech labels to translation keys
|
||||
const getTranslatedLabel = (label: string) => {
|
||||
const labelMap: Record<string, string> = {
|
||||
'Domů': 'nav.home',
|
||||
'Aktuality': 'nav.news',
|
||||
'Zápasy': 'nav.matches',
|
||||
'Hráči': 'nav.players',
|
||||
'Fotogalerie': 'nav.gallery',
|
||||
'Videa': 'nav.videos',
|
||||
'Kontakt': 'nav.contact',
|
||||
'O klubu': 'nav.about',
|
||||
'Aktivity': 'nav.activities',
|
||||
'Sponzoři': 'nav.sponsors',
|
||||
'Články': 'nav.news',
|
||||
'Blog': 'nav.news',
|
||||
'Kalendář': 'nav.calendar',
|
||||
'Tabulky': 'nav.table'
|
||||
};
|
||||
const translationKey = labelMap[label];
|
||||
return translationKey ? t(translationKey) : label;
|
||||
};
|
||||
|
||||
const link: NavLink = {
|
||||
label: item.label,
|
||||
label: getTranslatedLabel(item.label),
|
||||
to: item.url || '#',
|
||||
external: item.type === 'external',
|
||||
};
|
||||
@@ -601,7 +493,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
|
||||
// Add children for dropdown items
|
||||
if (item.type === 'dropdown' && item.children && item.children.length > 0) {
|
||||
link.items = item.children.map(child => ({
|
||||
label: child.label,
|
||||
label: getTranslatedLabel(child.label),
|
||||
to: child.url || '#',
|
||||
}));
|
||||
}
|
||||
@@ -611,34 +503,35 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
|
||||
|
||||
// Build categories as items for Články dropdown (fallback)
|
||||
const categoryItems = useMemo(() => {
|
||||
const source = Array.isArray(navCategories) && navCategories.length > 0 ? navCategories : [];
|
||||
const source = Array.isArray(navbarData.categories) && navbarData.categories.length > 0 ? navbarData.categories : [];
|
||||
return source.map((cat: any) => ({
|
||||
label: cat.name,
|
||||
to: cat.url || (cat.id ? `/blog?category_id=${cat.id}` : (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog'))
|
||||
}));
|
||||
}, [navCategories]);
|
||||
}, [navbarData.categories]);
|
||||
|
||||
// Filter dynamic navigation items based on available data (only show when data exists)
|
||||
const filteredDynamicNavItems = useMemo(() => {
|
||||
const filterItem = (item: NavigationItem): NavigationItem | null => {
|
||||
const url = item.url || '';
|
||||
if (url.startsWith('/aktivity') && hasActivities !== true) return null;
|
||||
if (url.startsWith('/hraci') && hasPlayers !== true) return null;
|
||||
if (url.startsWith('/blog') && hasArticles !== true) return null;
|
||||
if (url.startsWith('/videa') && hasVideos !== true) return null;
|
||||
if (url.startsWith('/galerie') && hasGallery !== true) return null;
|
||||
if (url.startsWith('/aktivity') && !navbarData.hasActivities) return null;
|
||||
if (url.startsWith('/hraci') && !navbarData.hasPlayers) return null;
|
||||
if (url.startsWith('/blog') && !navbarData.hasArticles) return null;
|
||||
if (url.startsWith('/videa') && !navbarData.hasVideos) return null;
|
||||
if (url.startsWith('/galerie') && !navbarData.hasGallery) return null;
|
||||
if (item.type === 'dropdown' && Array.isArray(item.children)) {
|
||||
const children = item.children.map(filterItem).filter(Boolean) as NavigationItem[];
|
||||
return { ...item, children };
|
||||
}
|
||||
return item;
|
||||
};
|
||||
return dynamicNavItems.map(filterItem).filter(Boolean) as NavigationItem[];
|
||||
}, [dynamicNavItems, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery]);
|
||||
return navbarData.dynamicNavItems.map(filterItem).filter(Boolean) as NavigationItem[];
|
||||
}, [navbarData.dynamicNavItems, navbarData.hasActivities, navbarData.hasPlayers, navbarData.hasArticles, navbarData.hasVideos, navbarData.hasGallery]);
|
||||
|
||||
// Use dynamic navigation if available, otherwise fallback to hardcoded
|
||||
let NAV_LINKS: NavLink[] = useMemo(() => {
|
||||
if (!navLoading && filteredDynamicNavItems.length > 0) {
|
||||
if (!navbarData.navLoading && filteredDynamicNavItems.length > 0) {
|
||||
console.log('Navbar: Using dynamic navigation, items:', filteredDynamicNavItems.length);
|
||||
// Use dynamic navigation from API
|
||||
const navLinks = filteredDynamicNavItems.map(convertToNavLink);
|
||||
|
||||
@@ -662,36 +555,74 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
|
||||
// Ensure we only show sections when there is data
|
||||
const filtered = navLinks.filter((link) => {
|
||||
const to = link.to || '';
|
||||
if (to.startsWith('/aktivity')) return hasActivities === true;
|
||||
if (to.startsWith('/hraci')) return hasPlayers === true;
|
||||
if (to.startsWith('/blog')) return hasArticles === true;
|
||||
if (to.startsWith('/videa')) return hasVideos === true;
|
||||
if (to.startsWith('/galerie')) return hasGallery === true;
|
||||
if (to.startsWith('/aktivity')) return navbarData.hasActivities;
|
||||
if (to.startsWith('/hraci')) return navbarData.hasPlayers;
|
||||
if (to.startsWith('/blog')) return navbarData.hasArticles;
|
||||
if (to.startsWith('/videa')) return navbarData.hasVideos;
|
||||
if (to.startsWith('/galerie')) return navbarData.hasGallery;
|
||||
return true;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// Fallback to hardcoded navigation
|
||||
let links: NavLink[] = [
|
||||
{ label: 'Domů', to: '/' },
|
||||
...(settings?.show_about_in_nav === false ? [] : [{ label: 'O klubu', to: '/o-klubu' } as NavLink]),
|
||||
{ label: 'Kalendář', to: '/kalendar' },
|
||||
{ label: 'Zápasy', to: '/zapasy' },
|
||||
{ label: 'Aktivity', to: '/aktivity' },
|
||||
{ label: 'Hráči', to: '/hraci' },
|
||||
{ label: 'Tabulky', to: '/tabulky' },
|
||||
// Články with categories as subcategories
|
||||
categoryItems.length > 0
|
||||
? { label: 'Články', to: '/blog', items: categoryItems }
|
||||
: { label: 'Články', to: '/blog' },
|
||||
{ label: 'Videa', to: '/videa' },
|
||||
{ label: galleryLabel, to: '/galerie' },
|
||||
...(settings?.shop_url ? [{ label: 'Fanshop', to: settings.shop_url, external: true } as NavLink] : []),
|
||||
{ label: 'Sponzoři', to: '/sponzori' },
|
||||
{ label: 'Kontakt', to: '/kontakt' },
|
||||
console.log('Navbar: Using fallback hardcoded navigation, language:', i18n.language);
|
||||
// Fallback to hardcoded navigation - modular based on language
|
||||
const currentLanguage = i18n.language;
|
||||
let links: NavLink[] = [];
|
||||
|
||||
// Base navigation items for all languages
|
||||
const baseLinks: NavLink[] = [
|
||||
{ label: t('nav.home'), to: '/' },
|
||||
];
|
||||
|
||||
// Add club info if enabled
|
||||
if (settings?.show_about_in_nav !== false) {
|
||||
baseLinks.push({ label: t('nav.club'), to: '/o-klubu' });
|
||||
}
|
||||
|
||||
// Add calendar and matches for all languages
|
||||
baseLinks.push(
|
||||
{ label: t('nav.calendar'), to: '/kalendar' },
|
||||
{ label: t('nav.matches'), to: '/zapasy' }
|
||||
);
|
||||
|
||||
// Add optional items only if data exists (for all languages)
|
||||
if (navbarData.hasActivities) {
|
||||
baseLinks.push({ label: t('nav.activities'), to: '/aktivity' });
|
||||
}
|
||||
if (navbarData.hasPlayers) {
|
||||
baseLinks.push({ label: t('nav.players'), to: '/hraci' });
|
||||
}
|
||||
if (navbarData.hasTables) {
|
||||
baseLinks.push({ label: t('nav.tables'), to: '/tabulky' });
|
||||
}
|
||||
if (navbarData.hasArticles) {
|
||||
baseLinks.push(
|
||||
categoryItems.length > 0
|
||||
? { label: t('nav.articles'), to: '/blog', items: categoryItems }
|
||||
: { label: t('nav.articles'), to: '/blog' }
|
||||
);
|
||||
}
|
||||
if (navbarData.hasVideos) {
|
||||
baseLinks.push({ label: t('nav.videos'), to: '/videa' });
|
||||
}
|
||||
if (navbarData.hasGallery) {
|
||||
baseLinks.push({ label: galleryLabel, to: '/galerie' });
|
||||
}
|
||||
|
||||
// Always show sponsors and contact
|
||||
baseLinks.push(
|
||||
{ label: t('nav.sponsors'), to: '/sponzori' },
|
||||
{ label: t('nav.contact'), to: '/kontakt' }
|
||||
);
|
||||
|
||||
links = baseLinks;
|
||||
|
||||
// Add shop if configured
|
||||
if (settings?.shop_url) {
|
||||
links.push({ label: t('nav.shop'), to: settings.shop_url, external: true } as NavLink);
|
||||
}
|
||||
|
||||
// Inject custom pages from settings.custom_nav (label + url + external?)
|
||||
const customNav = Array.isArray((settings as any)?.custom_nav) ? ((settings as any).custom_nav as any[]) : [];
|
||||
@@ -704,39 +635,9 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
|
||||
links = [...links, ...mapped];
|
||||
}
|
||||
}
|
||||
|
||||
// Hide Tabulky when there is no table data
|
||||
if (hasTables === false) {
|
||||
links = links.filter((n) => n.label !== 'Tabulky');
|
||||
}
|
||||
|
||||
// Hide Aktivity unless there are activities
|
||||
if (hasActivities !== true) {
|
||||
links = links.filter((n) => n.label !== 'Aktivity');
|
||||
}
|
||||
|
||||
// Hide Hráči unless there are players
|
||||
if (hasPlayers !== true) {
|
||||
links = links.filter((n) => n.label !== 'Hráči');
|
||||
}
|
||||
|
||||
// Hide Články unless there are articles
|
||||
if (hasArticles !== true) {
|
||||
links = links.filter((n) => n.label !== 'Články');
|
||||
}
|
||||
|
||||
// Hide Videa unless there are videos
|
||||
if (hasVideos !== true) {
|
||||
links = links.filter((n) => n.label !== 'Videa');
|
||||
}
|
||||
|
||||
// Hide Fotogalerie unless there is gallery content
|
||||
if (hasGallery !== true) {
|
||||
links = links.filter((n) => n.to !== '/galerie');
|
||||
}
|
||||
|
||||
return links;
|
||||
}, [filteredDynamicNavItems, navLoading, settings, categoryItems, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, galleryLabel]);
|
||||
}, [filteredDynamicNavItems, navbarData.navLoading, settings, categoryItems, navbarData.hasTables, navbarData.hasActivities, navbarData.hasPlayers, navbarData.hasArticles, navbarData.hasVideos, navbarData.hasGallery, galleryLabel, i18n.language, t]);
|
||||
|
||||
// Split navigation into visible and overflow for desktop
|
||||
const navSplit = useMemo(() => {
|
||||
@@ -749,7 +650,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
|
||||
else if (w >= 1100) maxVisible = 7;
|
||||
else maxVisible = 6;
|
||||
if (links.length <= maxVisible) return { visible: links, overflow: [] as NavLink[] };
|
||||
const visibleCount = Math.max(1, maxVisible - 1); // reserve one slot for "Další"
|
||||
const visibleCount = Math.max(1, maxVisible - 1); // reserve one slot for "More"
|
||||
return { visible: links.slice(0, visibleCount), overflow: links.slice(visibleCount) };
|
||||
}, [NAV_LINKS, windowWidth]);
|
||||
|
||||
@@ -799,7 +700,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
|
||||
<HStack spacing={2}>
|
||||
{settings?.shop_url && (
|
||||
<Button as="a" href={settings.shop_url} target="_blank" rel="noreferrer" variant="link" size="xs" leftIcon={<FaShoppingBag />}>
|
||||
Fanshop
|
||||
{t('nav.shop')}
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
@@ -828,7 +729,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
|
||||
boxShadow={navBoxShadow}
|
||||
transition="box-shadow 0.2s ease, background-color 0.2s ease, backdrop-filter 0.2s ease"
|
||||
>
|
||||
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} isAuthenticated={isAuthenticated} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navCategories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={hasTables} hasActivities={hasActivities} hasPlayers={hasPlayers} hasArticles={hasArticles} hasVideos={hasVideos} hasGallery={hasGallery} dynamicNavItems={filteredDynamicNavItems} navLoading={navLoading} />
|
||||
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} isAuthenticated={isAuthenticated} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navbarData.categories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={navbarData.hasTables} hasActivities={navbarData.hasActivities} hasPlayers={navbarData.hasPlayers} hasArticles={navbarData.hasArticles} hasVideos={navbarData.hasVideos} hasGallery={navbarData.hasGallery} dynamicNavItems={filteredDynamicNavItems || []} navLoading={navbarData.navLoading} t={t} i18n={i18n} handleSimpleLanguageChange={handleSimpleLanguageChange} />
|
||||
<Container maxW={containerMaxW} px={fullWidth ? 0 : undefined}>
|
||||
<Flex h={16} alignItems="center" justifyContent="space-between">
|
||||
<HStack spacing={4} alignItems="center">
|
||||
@@ -885,7 +786,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
|
||||
);
|
||||
})}
|
||||
{navSplit.overflow.length > 0 && (
|
||||
<HoverMenu key="more" label="Další" items={moreItems} />
|
||||
<HoverMenu key="more" label={t('action.more')} items={moreItems} />
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
@@ -896,7 +797,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
|
||||
display={{ base: 'flex', md: 'none' }}
|
||||
onClick={onOpen}
|
||||
icon={<HamburgerIcon />}
|
||||
aria-label="Otevřít menu"
|
||||
aria-label={t('action.open_menu')}
|
||||
variant="ghost"
|
||||
mr={2}
|
||||
/>
|
||||
@@ -905,9 +806,9 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
|
||||
<Box display={{ base: 'none', md: 'flex' }} mr={2} />
|
||||
|
||||
{/* Search button */}
|
||||
<Tooltip label="Hledat" hasArrow>
|
||||
<Tooltip label={t('action.search')} hasArrow>
|
||||
<IconButton
|
||||
aria-label="Hledat"
|
||||
aria-label={t('action.search')}
|
||||
icon={<FaSearch />}
|
||||
size="sm"
|
||||
mr={2}
|
||||
@@ -918,11 +819,11 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
|
||||
|
||||
{/* Admin edit button */}
|
||||
{isAdmin && (
|
||||
<Tooltip label="Správa obsahu" hasArrow>
|
||||
<Tooltip label={t('action.content_admin')} hasArrow>
|
||||
<IconButton
|
||||
as={RouterLink}
|
||||
to="/admin"
|
||||
aria-label="Správa obsahu"
|
||||
aria-label={t('action.content_admin')}
|
||||
icon={<EditIcon />}
|
||||
size="sm"
|
||||
mr={2}
|
||||
@@ -932,16 +833,11 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Language switcher - compact dropdown for all users */}
|
||||
<LanguageSwitcher />
|
||||
|
||||
{/* Color mode toggle */}
|
||||
<IconButton
|
||||
size="md"
|
||||
fontSize="lg"
|
||||
aria-label="Přepnout barevné téma"
|
||||
variant="ghost"
|
||||
color="current"
|
||||
onClick={toggleColorMode}
|
||||
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
|
||||
/>
|
||||
<ThemeToggle />
|
||||
|
||||
{/* Auth buttons (desktop) */}
|
||||
{!isAuthenticated && (
|
||||
@@ -955,7 +851,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
|
||||
ml={2}
|
||||
mr={2}
|
||||
>
|
||||
Registrovat se
|
||||
{t('auth.register')}
|
||||
</Button>
|
||||
<Button
|
||||
as={RouterLink}
|
||||
@@ -965,7 +861,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
|
||||
display={{ base: 'none', md: 'inline-flex' }}
|
||||
mr={2}
|
||||
>
|
||||
Přihlásit se
|
||||
{t('auth.login')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -980,25 +876,25 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
|
||||
minW={0}
|
||||
ml={2}
|
||||
>
|
||||
<Avatar size="sm" name={user?.name || 'Uživatel'} src={engProfile?.animated_avatar_url || engProfile?.avatar_url || undefined} />
|
||||
<Avatar size="sm" name={user?.name || t('common.user')} src={engProfile?.animated_avatar_url || engProfile?.avatar_url || undefined} />
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
<MenuItem isDisabled>{`Úroveň ${engProfile?.level ?? 1} • ${engProfile?.points ?? 0} bodů`}</MenuItem>
|
||||
<MenuItem isDisabled>{`${t('engagement.level')} ${engProfile?.level ?? 1} • ${engProfile?.points ?? 0} ${t('engagement.points')}`}</MenuItem>
|
||||
<Box px={3} py={2}>
|
||||
<Text fontSize="xs" color="gray.500">Progres</Text>
|
||||
<Text fontSize="xs" color="gray.500">{t('engagement.progress')}</Text>
|
||||
<Progress value={levelProgress.pct} size="xs" colorScheme="blue" borderRadius="full" mt={1} />
|
||||
</Box>
|
||||
{!isAdmin && (
|
||||
<>
|
||||
<MenuItem onClick={onAchOpen}>Úspěchy</MenuItem>
|
||||
<MenuItem onClick={onRewOpen}>Odměny</MenuItem>
|
||||
<MenuItem onClick={onAchOpen}>{t('engagement.achievements')}</MenuItem>
|
||||
<MenuItem onClick={onRewOpen}>{t('engagement.rewards')}</MenuItem>
|
||||
</>
|
||||
)}
|
||||
<MenuItem as={RouterLink} to={accountPath}>Můj účet</MenuItem>
|
||||
<MenuItem onClick={openMyNewsletterPrefs}>E‑mailové preference</MenuItem>
|
||||
{isAdmin && <MenuItem as={RouterLink} to="/admin/nastaveni">Nastavení stránky</MenuItem>}
|
||||
{isAdmin && <MenuItem as={RouterLink} to="/admin">Administrace</MenuItem>}
|
||||
<MenuItem onClick={logout}>Odhlásit se</MenuItem>
|
||||
<MenuItem as={RouterLink} to={accountPath}>{t('common.my_account')}</MenuItem>
|
||||
<MenuItem onClick={openMyNewsletterPrefs}>{t('newsletter.email_preferences')}</MenuItem>
|
||||
{isAdmin && <MenuItem as={RouterLink} to="/admin/nastaveni">{t('admin.page_settings')}</MenuItem>}
|
||||
{isAdmin && <MenuItem as={RouterLink} to="/admin">{t('nav.admin')}</MenuItem>}
|
||||
<MenuItem onClick={logout}>{t('auth.logout')}</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
)}
|
||||
@@ -1011,7 +907,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
|
||||
<Modal isOpen={isSearchOpen} onClose={onSearchClose} size="md" motionPreset="scale">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Vyhledávání</ModalHeader>
|
||||
<ModalHeader>{t('search.title')}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<form
|
||||
@@ -1026,19 +922,19 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
|
||||
<FaSearchIcon />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="Hledat kluby, zápasy, články, hráče..."
|
||||
placeholder={t('search.placeholder')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</InputGroup>
|
||||
<Button type="submit" colorScheme="blue" size="lg" w="full" leftIcon={<FaSearchIcon />}>
|
||||
Vyhledat
|
||||
{t('search.search_button')}
|
||||
</Button>
|
||||
</VStack>
|
||||
</form>
|
||||
<Text fontSize="sm" color="gray.500" mt={4} textAlign="center">
|
||||
Zadejte klíčová slova pro vyhledávání
|
||||
{t('search.search_hint')}
|
||||
</Text>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
|
||||
@@ -23,7 +23,7 @@ const SponsorsStrip: React.FC = () => {
|
||||
{isError && <Text color="red.500">Chyba při načítání sponzorů</Text>}
|
||||
{data?.map((s) => (
|
||||
<Link key={s.id} href={s.website_url || '#'} isExternal>
|
||||
<Image src={assetUrl(s.logo_url) || '/logo192.png'} alt={s.name} height="50px" objectFit="contain" />
|
||||
<Image src={assetUrl(s.logo_url) || '/sponsor-placeholder.svg'} alt={s.name} height="50px" objectFit="contain" />
|
||||
</Link>
|
||||
))}
|
||||
</HStack>
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
HStack,
|
||||
VStack,
|
||||
Text,
|
||||
Badge,
|
||||
Icon,
|
||||
SimpleGrid,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
useColorModeValue,
|
||||
Spinner,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiSun,
|
||||
FiCloud,
|
||||
FiCloudRain,
|
||||
FiWind,
|
||||
FiDroplet,
|
||||
FiThermometer,
|
||||
FiAlertTriangle,
|
||||
FiCheck,
|
||||
} from 'react-icons/fi';
|
||||
import { api } from '../services/api';
|
||||
|
||||
interface WeatherData {
|
||||
date_time: string;
|
||||
temperature: number;
|
||||
humidity: number;
|
||||
precipitation: number;
|
||||
wind_speed: number;
|
||||
wind_direction: number;
|
||||
weather_code: string;
|
||||
description: string;
|
||||
is_suitable: boolean;
|
||||
recommendations: string;
|
||||
}
|
||||
|
||||
interface WeatherWidgetProps {
|
||||
facilityId: number;
|
||||
facilityName: string;
|
||||
}
|
||||
|
||||
const WeatherWidget: React.FC<WeatherWidgetProps> = ({ facilityId, facilityName }) => {
|
||||
const [weather, setWeather] = useState<WeatherData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
useEffect(() => {
|
||||
fetchWeather();
|
||||
}, [facilityId]);
|
||||
|
||||
const fetchWeather = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await api.get(`/facilities/${facilityId}/weather`);
|
||||
setWeather(response.data.weather || []);
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 503) {
|
||||
setError('Služba počasí není nakonfigurována');
|
||||
} else if (err.response?.status === 400) {
|
||||
setError('Počasí je dostupné pouze pro venkovní zařízení');
|
||||
} else {
|
||||
setError('Nepodařilo se načíst data o počasí');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getWeatherIcon = (code: string, isSuitable: boolean) => {
|
||||
if (!isSuitable) {
|
||||
return <FiCloudRain />;
|
||||
}
|
||||
|
||||
switch (code) {
|
||||
case '800': // Clear
|
||||
return <FiSun />;
|
||||
case '801':
|
||||
case '802':
|
||||
case '803':
|
||||
case '804': // Clouds
|
||||
return <FiCloud />;
|
||||
default:
|
||||
return <FiCloud />;
|
||||
}
|
||||
};
|
||||
|
||||
const getWindDirection = (degrees: number) => {
|
||||
const directions = ['S', 'SV', 'V', 'JV', 'J', 'JZ', 'Z', 'SZ'];
|
||||
const index = Math.round(degrees / 45) % 8;
|
||||
return directions[index];
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('cs-CZ', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString('cs-CZ', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const getSuitabilityColor = (isSuitable: boolean) => {
|
||||
return isSuitable ? 'green' : 'red';
|
||||
};
|
||||
|
||||
const getSuitabilityText = (isSuitable: boolean) => {
|
||||
return isSuitable ? 'Vhodné' : 'Nevhodné';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card bg={bgColor} borderWidth={1} borderColor={borderColor}>
|
||||
<CardBody>
|
||||
<VStack spacing={4}>
|
||||
<Spinner />
|
||||
<Text>Načítání počasí...</Text>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card bg={bgColor} borderWidth={1} borderColor={borderColor}>
|
||||
<CardBody>
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
<Text>{error}</Text>
|
||||
</Alert>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (weather.length === 0) {
|
||||
return (
|
||||
<Card bg={bgColor} borderWidth={1} borderColor={borderColor}>
|
||||
<CardBody>
|
||||
<Text color="gray.500">Žádná data o počasí</Text>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Group weather by date
|
||||
const weatherByDate = weather.reduce((acc, item) => {
|
||||
const date = formatDate(item.date_time);
|
||||
if (!acc[date]) {
|
||||
acc[date] = [];
|
||||
}
|
||||
acc[date].push(item);
|
||||
return acc;
|
||||
}, {} as Record<string, WeatherData[]>);
|
||||
|
||||
return (
|
||||
<Card bg={bgColor} borderWidth={1} borderColor={borderColor}>
|
||||
<CardHeader>
|
||||
<Heading size="md">Počasí - {facilityName}</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{Object.entries(weatherByDate).map(([date, dayWeather]) => (
|
||||
<Box key={date}>
|
||||
<Heading size="sm" mb={3}>{date}</Heading>
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={3}>
|
||||
{dayWeather.map((item, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
bg={item.is_suitable ? 'green.50' : 'red.50'}
|
||||
borderColor={item.is_suitable ? 'green.200' : 'red.200'}
|
||||
>
|
||||
<CardBody p={3}>
|
||||
<VStack spacing={2} align="start">
|
||||
<HStack justify="space-between" w="full">
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
{formatTime(item.date_time)}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={getSuitabilityColor(item.is_suitable)}
|
||||
fontSize="xs"
|
||||
>
|
||||
{getSuitabilityText(item.is_suitable)}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
<HStack>
|
||||
{item.is_suitable ? <FiSun /> : <FiCloudRain />}
|
||||
<Text fontSize="sm">{item.description}</Text>
|
||||
</HStack>
|
||||
|
||||
<SimpleGrid columns={2} spacing={2} w="full">
|
||||
<HStack>
|
||||
<FiThermometer />
|
||||
<Text fontSize="xs">{item.temperature.toFixed(1)}°C</Text>
|
||||
</HStack>
|
||||
|
||||
<HStack>
|
||||
<FiDroplet />
|
||||
<Text fontSize="xs">{item.humidity}%</Text>
|
||||
</HStack>
|
||||
|
||||
<HStack>
|
||||
<FiWind />
|
||||
<Text fontSize="xs">
|
||||
{item.wind_speed.toFixed(1)} km/h {getWindDirection(item.wind_direction)}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{item.precipitation > 0 && (
|
||||
<HStack>
|
||||
<FiCloudRain />
|
||||
<Text fontSize="xs">{item.precipitation} mm</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
{item.recommendations && (
|
||||
<>
|
||||
<Divider />
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
{item.recommendations}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Alert status="info" fontSize="sm">
|
||||
<AlertIcon />
|
||||
<Text>
|
||||
Počasí je aktualizováno každé 2 hodiny.
|
||||
Doporučení jsou generována automaticky na základě povětrnostních podmínek.
|
||||
</Text>
|
||||
</Alert>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default WeatherWidget;
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
Box,
|
||||
Flex,
|
||||
IconButton,
|
||||
useColorMode,
|
||||
Text,
|
||||
Menu,
|
||||
MenuButton,
|
||||
@@ -15,11 +14,12 @@ import {
|
||||
Tooltip,
|
||||
Link as ChakraLink
|
||||
} from '@chakra-ui/react';
|
||||
import { FaBars, FaMoon, FaSun, FaSignOutAlt, FaUserCog, FaBook } from 'react-icons/fa';
|
||||
import { FaBars, FaSignOutAlt, FaUserCog, FaBook } from 'react-icons/fa';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { User } from '../../types';
|
||||
import { ReactNode } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
|
||||
interface AdminHeaderProps extends BoxProps {
|
||||
onMenuToggle: () => void;
|
||||
@@ -27,7 +27,6 @@ interface AdminHeaderProps extends BoxProps {
|
||||
}
|
||||
|
||||
const AdminHeader = ({ onMenuToggle, rightContent, ...rest }: AdminHeaderProps) => {
|
||||
const { colorMode, toggleColorMode } = useColorMode();
|
||||
const { user, logout } = useAuth();
|
||||
const bg = useColorModeValue('white', '#1a1d29');
|
||||
const borderColor = useColorModeValue('gray.200', 'rgba(255, 255, 255, 0.12)');
|
||||
@@ -81,13 +80,7 @@ const AdminHeader = ({ onMenuToggle, rightContent, ...rest }: AdminHeaderProps)
|
||||
/>
|
||||
</ChakraLink>
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
aria-label="Přepnout barevné schéma"
|
||||
icon={colorMode === 'light' ? <FaMoon /> : <FaSun />}
|
||||
variant="ghost"
|
||||
onClick={toggleColorMode}
|
||||
size="sm"
|
||||
/>
|
||||
<ThemeToggle />
|
||||
|
||||
<Menu>
|
||||
<MenuButton>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Box, Tooltip } from '@chakra-ui/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface HealthCheckResult {
|
||||
status: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface HealthResponse {
|
||||
status: string;
|
||||
checks?: Record<string, HealthCheckResult>;
|
||||
}
|
||||
|
||||
type HealthStatus = 'loading' | 'healthy' | 'degraded' | 'unhealthy' | 'error';
|
||||
|
||||
const POLL_INTERVAL_MS = 60000; // 60 seconds
|
||||
|
||||
const AdminHealthIndicator = () => {
|
||||
const [status, setStatus] = useState<HealthStatus>('loading');
|
||||
const [lastError, setLastError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: number | undefined;
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await api.get<HealthResponse>('/health/ready');
|
||||
if (cancelled) return;
|
||||
|
||||
const raw = (res.data?.status || '').toLowerCase();
|
||||
let mapped: HealthStatus;
|
||||
if (raw === 'healthy') mapped = 'healthy';
|
||||
else if (raw === 'degraded') mapped = 'degraded';
|
||||
else mapped = 'unhealthy';
|
||||
|
||||
setStatus(mapped);
|
||||
setLastError(null);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
// On error keep previous status if we already had one, otherwise mark as error
|
||||
setStatus((prev) => (prev === 'loading' ? 'error' : prev));
|
||||
setLastError('Nepodařilo se ověřit stav serveru');
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
timer = window.setTimeout(fetchStatus, POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchStatus();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
let color: string;
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
color = 'green.400';
|
||||
break;
|
||||
case 'degraded':
|
||||
color = 'orange.400';
|
||||
break;
|
||||
case 'unhealthy':
|
||||
color = 'red.400';
|
||||
break;
|
||||
case 'loading':
|
||||
color = 'yellow.300';
|
||||
break;
|
||||
case 'error':
|
||||
default:
|
||||
color = 'gray.400';
|
||||
break;
|
||||
}
|
||||
|
||||
const label: string = (() => {
|
||||
if (status === 'loading') return 'Ověřuji stav serveru…';
|
||||
if (status === 'healthy') return 'Server v pořádku';
|
||||
if (status === 'degraded') return 'Server běží, ale v omezeném režimu';
|
||||
if (status === 'unhealthy') return 'Server není připraven (zdravotní kontrola selhala)';
|
||||
if (status === 'error') return lastError || 'Nepodařilo se ověřit stav serveru';
|
||||
return 'Stav serveru není známý';
|
||||
})();
|
||||
|
||||
return (
|
||||
<Tooltip label={label} hasArrow>
|
||||
<Box
|
||||
as="span"
|
||||
w="10px"
|
||||
h="10px"
|
||||
borderRadius="full"
|
||||
bg={color}
|
||||
boxShadow="0 0 0 2px rgba(255, 255, 255, 0.6)"
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminHealthIndicator;
|
||||
@@ -0,0 +1,78 @@
|
||||
// Create a completely new approach - intercept navigation at router level
|
||||
// This will be a replacement for the current scroll retention system
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLocation, useNavigationType } from 'react-router-dom';
|
||||
|
||||
interface AdminScrollManagerProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AdminScrollManager = ({ children }: AdminScrollManagerProps) => {
|
||||
const location = useLocation();
|
||||
const navigationType = useNavigationType();
|
||||
const scrollPositionRef = useRef<number>(0);
|
||||
const isNavigatingRef = useRef<boolean>(false);
|
||||
|
||||
// Log when component mounts
|
||||
useEffect(() => {
|
||||
console.log('[AdminScrollManager] Component mounted');
|
||||
console.log('[AdminScrollManager] Current path:', location.pathname);
|
||||
}, []);
|
||||
|
||||
// Save scroll position before navigation
|
||||
useEffect(() => {
|
||||
if (isNavigatingRef.current) return; // Don't save while navigating
|
||||
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (sidebar && sidebar.scrollTop > 0) {
|
||||
scrollPositionRef.current = sidebar.scrollTop;
|
||||
console.log('[AdminScrollManager] Saved position:', scrollPositionRef.current);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
// Restore scroll position after navigation
|
||||
useEffect(() => {
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (!sidebar) return;
|
||||
|
||||
// Restore on any navigation type, not just PUSH
|
||||
if (scrollPositionRef.current > 0) {
|
||||
isNavigatingRef.current = true;
|
||||
|
||||
console.log('[AdminScrollManager] Restoring position:', scrollPositionRef.current, 'Navigation type:', navigationType);
|
||||
|
||||
// Aggressive restoration
|
||||
const targetScroll = scrollPositionRef.current;
|
||||
|
||||
// Immediate restore
|
||||
sidebar.scrollTop = targetScroll;
|
||||
|
||||
// Multiple restoration attempts
|
||||
const restoreAttempts = [
|
||||
() => sidebar.scrollTop = targetScroll,
|
||||
() => sidebar.scrollTo({ top: targetScroll, behavior: 'auto' }),
|
||||
() => sidebar.scrollTop = targetScroll,
|
||||
];
|
||||
|
||||
// Try restoration at different intervals
|
||||
restoreAttempts.forEach((restore, index) => {
|
||||
setTimeout(() => {
|
||||
restore();
|
||||
console.log(`[AdminScrollManager] Restore attempt ${index + 1}:`, sidebar.scrollTop);
|
||||
}, index * 100);
|
||||
});
|
||||
|
||||
// Final attempt after 1 second
|
||||
setTimeout(() => {
|
||||
if (sidebar.scrollTop !== targetScroll) {
|
||||
sidebar.scrollTop = targetScroll;
|
||||
console.log('[AdminScrollManager] Final restore:', sidebar.scrollTop);
|
||||
}
|
||||
isNavigatingRef.current = false;
|
||||
}, 1000);
|
||||
}
|
||||
}, [location.pathname, navigationType]);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
Box,
|
||||
Kbd,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaSearch, FaCog, FaNewspaper, FaUsers, FaImage, FaHandshake, FaEnvelope, FaAward, FaSyncAlt, FaVideo, FaCalendarAlt, FaPalette, FaCommentAlt, FaKey, FaChartLine, FaBook, FaTools, FaBell, FaBars, FaFolderOpen } from 'react-icons/fa';
|
||||
import { FaSearch, FaCog, FaNewspaper, FaUsers, FaImage, FaHandshake, FaEnvelope, FaAward, FaSyncAlt, FaVideo, FaCalendarAlt, FaPalette, FaCommentAlt, FaKey, FaChartLine, FaBook, FaTools, FaBell, FaBars, FaFolderOpen, FaFutbol, FaTachometerAlt, FaLightbulb, FaBug, FaAddressBook, FaChalkboard, FaMobileAlt, FaInfoCircle, FaListOl, FaBolt, FaEdit, FaPaintBrush, FaLifeRing, FaQrcode, FaPoll, FaHashtag, FaTicketAlt, FaTrash, FaExclamationTriangle, FaFlag, FaGavel, FaClipboardList, FaStar, FaTrophy, FaGift, FaShoppingCart, FaLink, FaArrowUp, FaPhotoVideo, FaTshirt, FaGlobe } from 'react-icons/fa';
|
||||
|
||||
export type AdminSearchItem = {
|
||||
label: string;
|
||||
@@ -29,30 +29,79 @@ export type AdminSearchItem = {
|
||||
};
|
||||
|
||||
const adminIndex: AdminSearchItem[] = [
|
||||
{ label: 'Dashboard', path: '/admin', section: 'Core', keywords: ['overview', 'stat', 'dashboard'], icon: FaTools },
|
||||
{ label: 'Články', path: '/admin/clanky', section: 'Obsah', keywords: ['articles', 'posts', 'blog'], icon: FaNewspaper },
|
||||
{ label: 'Hráči', path: '/admin/hraci', section: 'Kádry', keywords: ['players'], icon: FaUsers },
|
||||
{ label: 'Týmy', path: '/admin/tymy', section: 'Kádry', keywords: ['teams'], icon: FaUsers },
|
||||
{ label: 'Zápasy', path: '/admin/zapasy', section: 'FAČR', keywords: ['matches', 'facr'], icon: FaCalendarAlt },
|
||||
{ label: 'Soubory', path: '/admin/soubory', section: 'Média', keywords: ['files', 'uploads'], icon: FaFolderOpen },
|
||||
{ label: 'Sponzoři', path: '/admin/sponzori', section: 'Marketing', keywords: ['sponsors', 'partners'], icon: FaHandshake },
|
||||
{ label: 'Bannery', path: '/admin/bannery', section: 'Marketing', keywords: ['banners'], icon: FaImage },
|
||||
{ label: 'Nastavení', path: '/admin/nastaveni', section: 'Systém', keywords: ['settings', 'config'], icon: FaCog },
|
||||
{ label: 'Newsletter', path: '/admin/newsletter', section: 'Komunikace', keywords: ['email', 'campaign'], icon: FaEnvelope },
|
||||
{ label: 'Uživatelé', path: '/admin/uzivatele', section: 'Systém', keywords: ['users', 'accounts'], icon: FaKey },
|
||||
{ label: 'Prefetch', path: '/admin/prefetch', section: 'Systém', keywords: ['cache', 'fetch'], icon: FaSyncAlt },
|
||||
{ label: 'Galerie', path: '/admin/galerie', section: 'Média', keywords: ['gallery', 'zonerama'], icon: FaImage },
|
||||
{ label: 'Videa', path: '/admin/videa', section: 'Média', keywords: ['youtube', 'videos'], icon: FaVideo },
|
||||
// Core Admin Pages
|
||||
{ label: 'Dashboard', path: '/admin', section: 'Základní', keywords: ['overview', 'stat', 'dashboard', 'přehled'], icon: FaTachometerAlt },
|
||||
{ label: 'Články', path: '/admin/clanky', section: 'Obsah', keywords: ['articles', 'posts', 'blog', 'články'], icon: FaNewspaper },
|
||||
{ label: 'Kategorie článků', path: '/admin/kategorie', section: 'Obsah', keywords: ['categories', 'kategorie'], icon: FaHashtag },
|
||||
{ label: 'Aktivity', path: '/admin/aktivity', section: 'Obsah', keywords: ['activities', 'events', 'akce', 'události'], icon: FaCalendarAlt },
|
||||
{ label: 'Komentáře', path: '/admin/komentare', section: 'Obsah', keywords: ['comments', 'diskuse'], icon: FaCommentAlt },
|
||||
{ label: 'Hráči', path: '/admin/hraci', section: 'Sport', keywords: ['players', 'hráči'], icon: FaUsers },
|
||||
{ label: 'Týmy', path: '/admin/tymy', section: 'Sport', keywords: ['teams', 'týmy'], icon: FaUsers },
|
||||
{ label: 'Zápasy', path: '/admin/zapasy', section: 'Sport', keywords: ['matches', 'facr', 'zápasy'], icon: FaCalendarAlt },
|
||||
{ label: 'Alias soutěží', path: '/admin/aliasy', section: 'Sport', keywords: ['aliases', 'competition', 'soutěže'], icon: FaAward },
|
||||
{ label: 'Tabulky', path: '/admin/tabulky', section: 'Sport', keywords: ['standings', 'table', 'tabulka'], icon: FaChartLine },
|
||||
{ label: 'Tabule (Scoreboard)', path: '/admin/scoreboard', section: 'Sport', keywords: ['scoreboard', 'tabule', 'výsledky'], icon: FaChalkboard },
|
||||
{ label: 'Galerie', path: '/admin/galerie', section: 'Média', keywords: ['gallery', 'zonerama', 'fotky'], icon: FaImage },
|
||||
{ label: 'Videa', path: '/admin/videa', section: 'Média', keywords: ['youtube', 'videos', 'videa'], icon: FaVideo },
|
||||
{ label: 'Soubory', path: '/admin/soubory', section: 'Média', keywords: ['files', 'uploads', 'soubory'], icon: FaFolderOpen },
|
||||
{ label: 'Sponzoři', path: '/admin/sponzori', section: 'Marketing', keywords: ['sponsors', 'partners', 'sponzoři'], icon: FaHandshake },
|
||||
{ label: 'Bannery', path: '/admin/bannery', section: 'Marketing', keywords: ['banners', 'reklama'], icon: FaImage },
|
||||
{ label: 'Oblečení', path: '/admin/obleceni', section: 'Marketing', keywords: ['clothing', 'merch', 'eshop', 'obleceni'], icon: FaTshirt },
|
||||
{ label: 'Ankety', path: '/admin/ankety', section: 'Marketing', keywords: ['polls', 'ankety', 'hlasování'], icon: FaPoll },
|
||||
{ label: 'Soutěže', path: '/admin/souteze', section: 'Marketing', keywords: ['sweepstakes', 'souteže', 'akce'], icon: FaTrophy },
|
||||
{ label: 'Odměny & Úspěchy', path: '/admin/odmeny', section: 'Marketing', keywords: ['engagement', 'rewards', 'odmeny', 'úspěchy'], icon: FaTrophy },
|
||||
{ label: 'Zkrácené odkazy', path: '/admin/shortlinks', section: 'Marketing', keywords: ['shortlinks', 'zkrácené', 'odkazy'], icon: FaLink },
|
||||
{ label: 'QR kódy', path: '/admin/qr', section: 'Marketing', keywords: ['qr', 'kódy', 'qrcode'], icon: FaQrcode },
|
||||
{ label: 'Vstupenky', path: '/admin/vstupenky', section: 'Marketing', keywords: ['tickets', 'vstupenky', 'prodej'], icon: FaTicketAlt },
|
||||
{ label: 'Newsletter', path: '/admin/newsletter', section: 'Komunikace', keywords: ['email', 'campaign', 'newsletter'], icon: FaEnvelope },
|
||||
{ label: 'Zprávy', path: '/admin/zpravy', section: 'Komunikace', keywords: ['messages', 'zprávy'], icon: FaCommentAlt },
|
||||
{ label: 'Kontakty', path: '/admin/kontakty', section: 'Komunikace', keywords: ['contacts', 'kontakty', 'formulář'], icon: FaAddressBook },
|
||||
{ label: 'Notifikace', path: '/admin/notifications', section: 'Komunikace', keywords: ['notifications', 'notifikace'], icon: FaBell },
|
||||
{ label: 'Analytika', path: '/admin/analytika', section: 'SEO', keywords: ['analytics', 'umami'], icon: FaChartLine },
|
||||
{ label: 'O klubu', path: '/admin/o-klubu', section: 'Obsah', keywords: ['about'], icon: FaPalette },
|
||||
{ label: 'Navigace', path: '/admin/navigace', section: 'Systém', keywords: ['navigation', 'menu', 'sidebar'], icon: FaBars },
|
||||
{ label: 'Notifikace: Zápasy', path: '/admin/notifications', section: 'Komunikace', keywords: ['notifications', 'match'], icon: FaBell },
|
||||
// Docs
|
||||
{ label: 'Dokumentace (Úvod)', path: '/admin/docs#uvod', section: 'Docs', keywords: ['docs', 'documentation'], icon: FaBook },
|
||||
{ label: 'Dokumentace (Nastavení)', path: '/admin/docs#nastaveni', section: 'Docs', keywords: ['docs', 'settings'], icon: FaBook },
|
||||
{ label: 'Dokumentace (Články)', path: '/admin/docs#clanky', section: 'Docs', keywords: ['docs', 'articles'], icon: FaBook },
|
||||
{ label: 'Dokumentace (Newsletter)', path: '/admin/docs#newsletter', section: 'Docs', keywords: ['docs', 'email'], icon: FaBook },
|
||||
{ label: 'Dokumentace (Řešení problémů)', path: '/admin/docs#troubleshooting', section: 'Docs', keywords: ['docs', 'troubleshooting'], icon: FaBook },
|
||||
{ label: 'O klubu', path: '/admin/o-klubu', section: 'Obsah', keywords: ['about', 'klub'], icon: FaPalette },
|
||||
{ label: 'Nastavení', path: '/admin/nastaveni', section: 'Nastavení', keywords: ['settings', 'config', 'nastavení'], icon: FaCog },
|
||||
{ label: 'Uživatelé', path: '/admin/uzivatele', section: 'Nastavení', keywords: ['users', 'accounts', 'uživatelé'], icon: FaKey },
|
||||
{ label: 'Navigace', path: '/admin/navigace', section: 'Nastavení', keywords: ['navigation', 'menu', 'sidebar'], icon: FaBars },
|
||||
{ label: 'Prefetch & Cache', path: '/admin/prefetch', section: 'Nástroje', keywords: ['cache', 'fetch', 'prefetch'], icon: FaSyncAlt },
|
||||
{ label: 'Chybová hlášení', path: '/admin/chyby', section: 'Nástroje', keywords: ['errors', 'chyby', 'hlášení', 'log'], icon: FaBug },
|
||||
{ label: 'Překlady (I18n)', path: '/admin/i18n', section: 'Nástroje', keywords: ['i18n', 'překlady', 'jazyky', 'translations'], icon: FaGlobe },
|
||||
{ label: 'FACR manuál', path: '/admin/facr-manual', section: 'Nástroje', keywords: ['facr', 'manuál', 'import'], icon: FaFutbol },
|
||||
|
||||
// Settings Sections (deep links)
|
||||
{ label: 'Nastavení - Sociální sítě', path: '/admin/nastaveni#socialni-site', section: 'Nastavení', keywords: ['socialni', 'sítě', 'facebook', 'instagram', 'twitter'], icon: FaAddressBook },
|
||||
{ label: 'Nastavení - Videa', path: '/admin/nastaveni#videa', section: 'Nastavení', keywords: ['videa', 'youtube', 'kanál'], icon: FaVideo },
|
||||
{ label: 'Nastavení - SMTP', path: '/admin/nastaveni#smtp', section: 'Nastavení', keywords: ['smtp', 'email', 'odesílání'], icon: FaEnvelope },
|
||||
{ label: 'Nastavení - Analytika', path: '/admin/nastaveni#analytika', section: 'Nastavení', keywords: ['umami', 'analytics', 'statistiky'], icon: FaChartLine },
|
||||
{ label: 'Nastavení - SEO', path: '/admin/nastaveni#seo', section: 'Nastavení', keywords: ['seo', 'metadata', 'vyhledávače'], icon: FaSearch },
|
||||
{ label: 'Nastavení - Obecné', path: '/admin/nastaveni#obecne', section: 'Nastavení', keywords: ['obecné', 'základní', 'klub'], icon: FaCog },
|
||||
|
||||
// Documentation Sections
|
||||
{ label: 'Dokumentace - Úvod', path: '/admin/docs#uvod', section: 'Dokumentace', keywords: ['docs', 'documentation', 'úvod'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Nastavení klubu', path: '/admin/docs#nastaveni', section: 'Dokumentace', keywords: ['docs', 'nastavení', 'konfigurace'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Dashboard', path: '/admin/docs#dashboard', section: 'Dokumentace', keywords: ['docs', 'dashboard', 'přehledy'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Články', path: '/admin/docs#clanky', section: 'Dokumentace', keywords: ['docs', 'články', 'blog'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Zápasy', path: '/admin/docs#zapasy', section: 'Dokumentace', keywords: ['docs', 'zápasy', 'facr'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Hráči a týmy', path: '/admin/docs#hraci-tymy', section: 'Dokumentace', keywords: ['docs', 'hráči', 'týmy'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Média', path: '/admin/docs#media', section: 'Dokumentace', keywords: ['docs', 'média', 'soubory'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Galerie', path: '/admin/docs#gallery', section: 'Dokumentace', keywords: ['docs', 'galerie', 'fotky'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Soubory', path: '/admin/docs#files', section: 'Dokumentace', keywords: ['docs', 'soubory', 'upload'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Sponzoři a bannery', path: '/admin/docs#sponzori-bannery', section: 'Dokumentace', keywords: ['docs', 'sponzoři', 'bannery'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Newsletter', path: '/admin/docs#newsletter', section: 'Dokumentace', keywords: ['docs', 'newsletter', 'email'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Alias soutěží', path: '/admin/docs#aliasy', section: 'Dokumentace', keywords: ['docs', 'alias', 'soutěže'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Prefetch', path: '/admin/docs#prefetch', section: 'Dokumentace', keywords: ['docs', 'prefetch', 'cache'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Videa', path: '/admin/docs#videa', section: 'Dokumentace', keywords: ['docs', 'videa', 'youtube'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Aktivity', path: '/admin/docs#aktivity', section: 'Dokumentace', keywords: ['docs', 'aktivity', 'události'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Oblečení', path: '/admin/docs#merch', section: 'Dokumentace', keywords: ['docs', 'obleceni', 'merch'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Zprávy', path: '/admin/docs#zpravy', section: 'Dokumentace', keywords: ['docs', 'zprávy', 'komunikace'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Kontakty', path: '/admin/docs#contacts', section: 'Dokumentace', keywords: ['docs', 'kontakty', 'formuláře'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Analytics', path: '/admin/docs#analytics', section: 'Dokumentace', keywords: ['docs', 'analytics', 'statistiky'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Scoreboard', path: '/admin/docs#scoreboard', section: 'Dokumentace', keywords: ['docs', 'scoreboard', 'tabule'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Mobilní scoreboard', path: '/admin/docs#mobile-scoreboard', section: 'Dokumentace', keywords: ['docs', 'mobilní', 'scoreboard'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Uživatelé', path: '/admin/docs#uzivatele', section: 'Dokumentace', keywords: ['docs', 'uživatelé', 'přístupy'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Interní dokumentace', path: '/admin/docs#docs', section: 'Dokumentace', keywords: ['docs', 'interní', 'vývoj'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Checklisty', path: '/admin/docs#checklist', section: 'Dokumentace', keywords: ['docs', 'checklist', 'postupy'], icon: FaBook },
|
||||
{ label: 'Dokumentace - SEO', path: '/admin/docs#seo', section: 'Dokumentace', keywords: ['docs', 'seo', 'metadata'], icon: FaBook },
|
||||
{ label: 'Dokumentace - Řešení problémů', path: '/admin/docs#troubleshooting', section: 'Dokumentace', keywords: ['docs', 'troubleshooting', 'problémy'], icon: FaBook },
|
||||
];
|
||||
|
||||
function highlight(text: string, q: string) {
|
||||
@@ -83,8 +132,12 @@ function score(item: AdminSearchItem, q: string) {
|
||||
if (t.startsWith(b)) s += 120;
|
||||
if (t.includes(b)) s += 80 - t.indexOf(b);
|
||||
if (kws.includes(b)) s += 40;
|
||||
// small preference for Docs when # present
|
||||
if (item.section === 'Docs' && item.path.includes('#')) s += 5;
|
||||
// Boost score for settings sections when searching for settings-related terms
|
||||
if (item.section === 'Nastavení' && (b.includes('nastavení') || b.includes('settings') || b.includes('config'))) s += 30;
|
||||
// Boost score for documentation when searching for help/docs
|
||||
if (item.section === 'Dokumentace' && (b.includes('docs') || b.includes('dokumentace') || b.includes('help') || b.includes('návod'))) s += 30;
|
||||
// Small preference for Docs when # present
|
||||
if (item.section === 'Dokumentace' && item.path.includes('#')) s += 5;
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -153,7 +206,7 @@ export default function AdminSearchModal({ isOpen, onClose, onSelectPath }: { is
|
||||
<Icon as={FaSearch} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="Hledat v administraci (stránky, nastavení, dokumentace)"
|
||||
placeholder="Hledat stránky, nastavení, dokumentaci... (např. 'sociální sítě', 'články', 'scoreboard')"
|
||||
value={q}
|
||||
onChange={(e) => { setQ(e.target.value); setIdx(-1); }}
|
||||
onKeyDown={onKeyDown}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, VStack, Text, useColorModeValue, Icon, Link as ChakraLink, Divider, Image, Flex, Spinner, Collapse } from '@chakra-ui/react';
|
||||
import { Box, VStack, Text, useColorModeValue, Icon, Link as ChakraLink, Divider, Image, Flex, Spinner, Collapse, IconButton } from '@chakra-ui/react';
|
||||
import { Link as RouterLink, useLocation } from 'react-router-dom';
|
||||
import { useEffect, useRef, useCallback, useState, useMemo } from 'react';
|
||||
import { useAdminNavScrollRetention } from '../../hooks/useAdminNavScrollRetention';
|
||||
import {
|
||||
FaTachometerAlt,
|
||||
FaUsers,
|
||||
@@ -33,7 +34,19 @@ import {
|
||||
FaFileAlt,
|
||||
FaLink,
|
||||
FaComments,
|
||||
FaGift
|
||||
FaGift,
|
||||
FaQrcode,
|
||||
FaTools,
|
||||
FaDollarSign,
|
||||
FaFileInvoice,
|
||||
FaReceipt,
|
||||
FaUserFriends,
|
||||
FaMoneyBillWave,
|
||||
FaCogs,
|
||||
FaDatabase,
|
||||
FaRocket,
|
||||
FaFileInvoiceDollar,
|
||||
FaTimes
|
||||
} from 'react-icons/fa';
|
||||
import { ChevronDownIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
@@ -52,9 +65,11 @@ interface NavItemProps {
|
||||
|
||||
const NavItem = ({ icon, to, children, onClick }: NavItemProps) => {
|
||||
const location = useLocation();
|
||||
const isActive = to ? location.pathname.startsWith(to) : false;
|
||||
// Use exact matching for navigation items to prevent multiple active states
|
||||
const isActive = to ? location.pathname === to : false;
|
||||
const activeBg = useColorModeValue('blue.50', 'blue.900');
|
||||
const activeColor = useColorModeValue('blue.600', 'blue.300');
|
||||
const hoverBg = useColorModeValue('gray.100', 'gray.700');
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// Call the onClick handler first
|
||||
@@ -65,7 +80,9 @@ const NavItem = ({ icon, to, children, onClick }: NavItemProps) => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Allow RouterLink to handle navigation normally
|
||||
|
||||
// For external links or non-router navigation, allow default behavior
|
||||
// For RouterLink, React Router will handle the navigation
|
||||
};
|
||||
|
||||
// If onClick is provided without `to`, render as a button-like link
|
||||
@@ -87,7 +104,7 @@ const NavItem = ({ icon, to, children, onClick }: NavItemProps) => {
|
||||
fontSize="sm"
|
||||
_hover={{
|
||||
textDecoration: 'none',
|
||||
bg: isActive ? activeBg : useColorModeValue('gray.100', 'gray.700'),
|
||||
bg: isActive ? activeBg : hoverBg,
|
||||
transform: 'translateX(2px)',
|
||||
}}
|
||||
transition="all 0.2s ease"
|
||||
@@ -146,7 +163,7 @@ const getIconForPageType = (pageType?: string): any => {
|
||||
polls: FaPoll,
|
||||
navigation: FaBars,
|
||||
competition_aliases: FaAward,
|
||||
prefetch: FaSyncAlt,
|
||||
prefetch: FaRocket, // Better icon for prefetch & cache
|
||||
users: FaUserShield,
|
||||
settings: FaPalette,
|
||||
files: FaFolder,
|
||||
@@ -156,6 +173,14 @@ const getIconForPageType = (pageType?: string): any => {
|
||||
comments: FaComments,
|
||||
engagement: FaAward,
|
||||
sweepstakes: FaGift,
|
||||
'manual-data': FaTools,
|
||||
'financial-dashboard': FaMoneyBillWave, // Better finance icon
|
||||
'qr-codes': FaQrcode,
|
||||
'invoices': FaFileInvoiceDollar, // Enhanced invoice icon
|
||||
'invoice-settings': FaFileInvoiceDollar, // Enhanced invoice settings icon
|
||||
'customers': FaUserFriends, // Better customers icon
|
||||
'expenses': FaMoneyBillWave, // Better expenses icon
|
||||
'manual_facr': FaTools, // For manual FACR
|
||||
};
|
||||
return iconMap[pageType || ''] || FaFileAlt;
|
||||
};
|
||||
@@ -175,13 +200,39 @@ const AdminSidebar = ({
|
||||
const textColor = useColorModeValue('gray.800', '#e2e8f0');
|
||||
const bg = bgProp || defaultBg;
|
||||
const borderColor = borderColorProp || defaultBorderColor;
|
||||
// Check if e-shop is enabled (from backend config)
|
||||
const isEshopEnabled = (publicSettings as any)?.eshop_enabled || false;
|
||||
// Check if club data mode is manual (to show manual data tab)
|
||||
const isManualClubDataMode = (publicSettings?.club_data_mode || '').toLowerCase() === 'manual';
|
||||
// Hoisted color tokens to keep hook calls stable across renders
|
||||
const dividerColor = useColorModeValue('gray.200', 'whiteAlpha.300');
|
||||
const categoryTextColor = useColorModeValue('gray.600', 'gray.300');
|
||||
const categoryIconColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const headerMutedColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const scrollThumb = useColorModeValue('gray.300', 'gray.600');
|
||||
const scrollThumbHover = useColorModeValue('gray.400', 'gray.500');
|
||||
const badgeBgGreen = useColorModeValue('green.100', 'green.900');
|
||||
const badgeColorGreen = useColorModeValue('green.700', 'green.200');
|
||||
const badgeBorderGreen = useColorModeValue('green.200', 'green.700');
|
||||
const badgeBgGray = useColorModeValue('gray.100', 'whiteAlpha.200');
|
||||
const badgeColorGray = useColorModeValue('gray.700', 'gray.300');
|
||||
const badgeBorderGray = useColorModeValue('gray.200', 'whiteAlpha.300');
|
||||
// Upcoming events count for badge
|
||||
const { data: upcomingEvents } = useQuery({ queryKey: ['admin-sidebar-upcoming-events'], queryFn: getUpcomingEvents });
|
||||
const upcomingCount = Array.isArray(upcomingEvents) ? upcomingEvents.length : 0;
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const seedFixRef = useRef<{ about: boolean }>({ about: false });
|
||||
const location = useLocation();
|
||||
const STORAGE_KEY = 'admin-sidebar-scroll';
|
||||
// Use the updated scroll retention hook
|
||||
const { scrollToCurrentPage, isReady, debug } = useAdminNavScrollRetention({
|
||||
scrollContainerSelector: '[data-sidebar="true"]',
|
||||
enableDebug: process.env.NODE_ENV === 'development'
|
||||
});
|
||||
|
||||
// Helper function to render NavItem
|
||||
const renderNavItem = useCallback((props: NavItemProps) => {
|
||||
return <NavItem {...props} />;
|
||||
}, []);
|
||||
|
||||
// Dynamic navigation state
|
||||
const [navItems, setNavItems] = useState<NavigationItem[]>([]);
|
||||
@@ -203,6 +254,7 @@ const AdminSidebar = ({
|
||||
const hasSweepstakes = useMemo(() => hasItemDeep(it => (it.page_type === 'sweepstakes') || (it.url === '/admin/sweepstakes')), [hasItemDeep]);
|
||||
const hasCompetitionAliases = useMemo(() => hasItemDeep(it => (it.page_type === 'competition_aliases') || (it.url === '/admin/aliasy-soutezi')), [hasItemDeep]);
|
||||
const hasClothing = useMemo(() => hasItemDeep(it => (it.page_type === 'clothing') || (it.url === '/admin/obleceni')), [hasItemDeep]);
|
||||
const hasEshopProducts = useMemo(() => hasItemDeep(it => (it.page_type === 'eshop_products') || (it.url === '/admin/eshop-produkty')), [hasItemDeep]);
|
||||
const hasAbout = useMemo(() => hasItemDeep(it => (it.page_type === 'about') || (it.url === '/admin/o-klubu')), [hasItemDeep]);
|
||||
const hasVideos = useMemo(() => hasItemDeep(it => (it.page_type === 'videos') || (it.url === '/admin/videa')), [hasItemDeep]);
|
||||
const hasGallery = useMemo(() => hasItemDeep(it => (it.page_type === 'gallery') || (it.url === '/admin/galerie')), [hasItemDeep]);
|
||||
@@ -220,6 +272,7 @@ const AdminSidebar = ({
|
||||
const hasSettingsPage = useMemo(() => hasItemDeep(it => (it.page_type === 'settings') || (it.url === '/admin/nastaveni')), [hasItemDeep]);
|
||||
const hasAnalytics = useMemo(() => hasItemDeep(it => (it.page_type === 'analytics') || (it.url === '/admin/analytika')), [hasItemDeep]);
|
||||
const hasPrefetch = useMemo(() => hasItemDeep(it => (it.page_type === 'prefetch') || (it.url === '/admin/prefetch')), [hasItemDeep]);
|
||||
const hasManualData = useMemo(() => hasItemDeep(it => (it.page_type === 'manual_data') || (it.url === '/admin/manual-data')), [hasItemDeep]);
|
||||
|
||||
|
||||
// Collapsed state for admin categories (dropdown items)
|
||||
@@ -259,27 +312,7 @@ const AdminSidebar = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Restore scroll on mount
|
||||
useEffect(() => {
|
||||
const node = scrollRef.current;
|
||||
if (!node) return;
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
const top = parseInt(saved, 10);
|
||||
if (!Number.isNaN(top)) {
|
||||
node.scrollTop = top;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save scroll on scroll
|
||||
const handleScroll = useCallback(() => {
|
||||
const node = scrollRef.current;
|
||||
if (!node) return;
|
||||
sessionStorage.setItem(STORAGE_KEY, String(node.scrollTop));
|
||||
}, []);
|
||||
// The scroll handling is now managed by the useAdminNavScrollRetention hook
|
||||
|
||||
// Load dynamic navigation from API
|
||||
useEffect(() => {
|
||||
@@ -360,25 +393,13 @@ const AdminSidebar = ({
|
||||
return () => { active = false };
|
||||
}, [isAdmin]);
|
||||
|
||||
// Keep active item in view upon route change - but only if it's not visible
|
||||
// Auto-scroll to current page when navigation loads
|
||||
useEffect(() => {
|
||||
const node = scrollRef.current;
|
||||
if (!node) return;
|
||||
const active = node.querySelector('[data-navitem][data-active="true"]') as HTMLElement | null;
|
||||
if (active) {
|
||||
// Check if the active item is already visible in the viewport
|
||||
const containerRect = node.getBoundingClientRect();
|
||||
const activeRect = active.getBoundingClientRect();
|
||||
const isVisible = (
|
||||
activeRect.top >= containerRect.top &&
|
||||
activeRect.bottom <= containerRect.bottom
|
||||
);
|
||||
// Only scroll if the active item is not fully visible
|
||||
if (!isVisible) {
|
||||
active.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
if (isReady && !navLoading) {
|
||||
scrollToCurrentPage();
|
||||
debug('Auto-scroll to current page after navigation load');
|
||||
}
|
||||
}, [location.pathname]);
|
||||
}, [isReady, navLoading, scrollToCurrentPage, debug]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -387,60 +408,74 @@ const AdminSidebar = ({
|
||||
left={0}
|
||||
top={0}
|
||||
bottom={0}
|
||||
width="260px"
|
||||
width={{ base: '320px', md: '260px' }}
|
||||
bg={bg}
|
||||
borderRightWidth={borderRight}
|
||||
borderColor={borderColor}
|
||||
pt={5}
|
||||
pt={{ base: 16, md: 5 }}
|
||||
display={{ base: isOpen ? 'block' : 'none', md: 'block' }}
|
||||
zIndex={10}
|
||||
zIndex={{ base: 11, md: 10 }}
|
||||
overflowY="auto"
|
||||
overflowX="hidden"
|
||||
boxShadow="lg"
|
||||
boxShadow={{ base: 'lg', md: 'none' }}
|
||||
transform={{ base: isOpen ? 'translateX(0)' : 'translateX(-100%)', md: 'translateX(0)' }}
|
||||
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
onScroll={undefined}
|
||||
data-sidebar="true"
|
||||
css={{
|
||||
'&::-webkit-scrollbar': { width: '4px' },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': { background: useColorModeValue('gray.300', 'gray.600'), borderRadius: '2px' },
|
||||
'&::-webkit-scrollbar-thumb:hover': { background: useColorModeValue('gray.400', 'gray.500') },
|
||||
'&::-webkit-scrollbar-thumb': { background: scrollThumb, borderRadius: '2px' },
|
||||
'&::-webkit-scrollbar-thumb:hover': { background: scrollThumbHover },
|
||||
}}
|
||||
>
|
||||
<VStack align="stretch" spacing={1} px={3} pb={6}>
|
||||
<Box px={3} mb={8}>
|
||||
<Flex align="center" gap={3} mb={2}>
|
||||
<VStack align="stretch" spacing={{ base: 2, md: 1 }} px={{ base: 6, md: 3 }} pb={6}>
|
||||
{/* Close button for mobile */}
|
||||
<Flex justify="flex-end" display={{ base: 'flex', md: 'none' }} w="100%" mb={2}>
|
||||
<IconButton
|
||||
aria-label="Zavřít menu"
|
||||
icon={<FaTimes />}
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
size="sm"
|
||||
borderRadius="full"
|
||||
_hover={{ bg: useColorModeValue('gray.100', 'gray.700') }}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Box px={{ base: 6, md: 3 }} mb={{ base: 6, md: 4 }}>
|
||||
<Flex align="center" gap={{ base: 4, md: 3 }} mb={2}>
|
||||
<Image
|
||||
src={assetUrl(publicSettings?.club_logo_url) || publicSettings?.club_logo_url || '/dist/img/logo-club-empty.svg'}
|
||||
alt="Club Logo"
|
||||
boxSize="48px"
|
||||
boxSize={{ base: '56px', md: '48px' }}
|
||||
objectFit="contain"
|
||||
fallbackSrc="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%23e2e8f0'/%3E%3Ctext x='50' y='55' text-anchor='middle' font-size='40' fill='%23718096'%3EMC%3C/text%3E%3C/svg%3E"
|
||||
borderRadius="md"
|
||||
/>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text
|
||||
fontSize="xl"
|
||||
fontSize={{ base: '2xl', md: 'xl' }}
|
||||
fontWeight="extrabold"
|
||||
color={useColorModeValue('gray.800', 'white')}
|
||||
letterSpacing="tight"
|
||||
>
|
||||
My Club
|
||||
</Text>
|
||||
<Text fontSize="xs" color={useColorModeValue('gray.500', 'gray.400')} fontWeight="semibold" textTransform="uppercase" letterSpacing="wider">
|
||||
<Text fontSize={{ base: 'sm', md: 'xs' }} color={useColorModeValue('gray.500', 'gray.400')} fontWeight="semibold" textTransform="uppercase" letterSpacing="wider">
|
||||
Admin Panel
|
||||
</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
<NavItem
|
||||
icon={FaHome}
|
||||
to="/"
|
||||
onClick={onClose}
|
||||
>
|
||||
Zpět na web
|
||||
</NavItem>
|
||||
{renderNavItem({
|
||||
icon: FaHome,
|
||||
to: "/",
|
||||
onClick: onClose,
|
||||
children: "Zpět na web"
|
||||
})}
|
||||
|
||||
<Divider my={2} />
|
||||
|
||||
@@ -452,51 +487,61 @@ const AdminSidebar = ({
|
||||
) : navItems.length > 0 ? (
|
||||
// Render dynamic navigation with collapsible categories
|
||||
<>
|
||||
{navItems.filter(item => item.visible).map((item, index) => {
|
||||
{navItems.filter(item => {
|
||||
// Hide E-shop category if e-shop is disabled
|
||||
if (item.label === 'E-shop' && !isEshopEnabled) {
|
||||
return false;
|
||||
}
|
||||
return item.visible;
|
||||
}).map((item, index) => {
|
||||
const isCategory = item.type === 'dropdown';
|
||||
const hasChildren = Array.isArray(item.children) && item.children.length > 0;
|
||||
const catCollapsed = !!(item.id && collapsed[item.id]);
|
||||
const categoryHeader = (
|
||||
<Box key={`cat-${item.id || index}`} px={2} py={2} onClick={() => toggleCollapsed(item.id)} cursor="pointer" role="button" aria-expanded={!catCollapsed}>
|
||||
<Box px={2} py={2} onClick={() => toggleCollapsed(item.id)} cursor="pointer" role="button" aria-expanded={!catCollapsed}>
|
||||
<Flex align="center" gap={2}>
|
||||
<Box flex="1" height="1px" bg={useColorModeValue('gray.200','whiteAlpha.300')} />
|
||||
<Text fontSize="xs" fontWeight="bold" textTransform="uppercase" letterSpacing="wider" color={useColorModeValue('gray.600','gray.300')}>
|
||||
<Box flex="1" height="1px" bg={dividerColor} />
|
||||
<Text fontSize="xs" fontWeight="bold" textTransform="uppercase" letterSpacing="wider" color={categoryTextColor}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Icon as={catCollapsed ? ChevronRightIcon : ChevronDownIcon} boxSize={3.5} color={useColorModeValue('gray.500','gray.400')} />
|
||||
<Box flex="1" height="1px" bg={useColorModeValue('gray.200','whiteAlpha.300')} />
|
||||
<Icon as={catCollapsed ? ChevronRightIcon : ChevronDownIcon} boxSize={3.5} color={categoryIconColor} />
|
||||
<Box flex="1" height="1px" bg={dividerColor} />
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (isCategory) {
|
||||
return (
|
||||
<Box key={item.id || index}>
|
||||
<Box>
|
||||
{categoryHeader}
|
||||
{hasChildren && (
|
||||
<Collapse in={!catCollapsed} animateOpacity unmountOnExit>
|
||||
<VStack align="stretch" spacing={1} px={1}>
|
||||
{item.children!.filter(c => c.visible).map((child, cidx) => {
|
||||
{item.children!.filter(c => {
|
||||
// Hide manual FACR if not in manual club data mode
|
||||
if (c.page_type === 'manual_facr' && !isManualClubDataMode) {
|
||||
return false;
|
||||
}
|
||||
return c.visible;
|
||||
}).map((child, cidx) => {
|
||||
const childIcon = getIconForPageType(child.page_type);
|
||||
const childUrl = child.url || '#';
|
||||
const showBadge = child.page_type === 'activities' && upcomingCount > 0;
|
||||
return (
|
||||
<NavItem
|
||||
key={child.id || `${item.id}-c-${cidx}`}
|
||||
icon={childIcon}
|
||||
to={childUrl}
|
||||
onClick={onClose}
|
||||
>
|
||||
return renderNavItem({
|
||||
icon: childIcon,
|
||||
to: childUrl,
|
||||
onClick: onClose,
|
||||
children: (
|
||||
<Text as="span">
|
||||
{child.label}
|
||||
{showBadge && (
|
||||
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('green.100','green.900')} color={useColorModeValue('green.700','green.200')} borderWidth="1px" borderColor={useColorModeValue('green.200','green.700')}>
|
||||
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={badgeBgGreen} color={badgeColorGreen} borderWidth="1px" borderColor={badgeBorderGreen}>
|
||||
{upcomingCount}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</NavItem>
|
||||
);
|
||||
)
|
||||
});
|
||||
})}
|
||||
</VStack>
|
||||
</Collapse>
|
||||
@@ -506,39 +551,38 @@ const AdminSidebar = ({
|
||||
}
|
||||
|
||||
// Non-category top-level item
|
||||
// Hide manual FACR if not in manual club data mode
|
||||
if (item.page_type === 'manual_facr' && !isManualClubDataMode) {
|
||||
return null;
|
||||
}
|
||||
const itemIcon = getIconForPageType(item.page_type);
|
||||
const itemUrl = item.url || '#';
|
||||
const isActivities = item.page_type === 'activities';
|
||||
const showBadge = isActivities && upcomingCount > 0;
|
||||
return (
|
||||
<NavItem
|
||||
key={item.id || index}
|
||||
icon={itemIcon}
|
||||
to={itemUrl}
|
||||
onClick={onClose}
|
||||
>
|
||||
return renderNavItem({
|
||||
icon: itemIcon,
|
||||
to: itemUrl,
|
||||
onClick: onClose,
|
||||
children: (
|
||||
<Text as="span">
|
||||
{item.label}
|
||||
{showBadge && (
|
||||
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('green.100','green.900')} color={useColorModeValue('green.700','green.200')} borderWidth="1px" borderColor={useColorModeValue('green.200','green.700')}>
|
||||
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={badgeBgGreen} color={badgeColorGreen} borderWidth="1px" borderColor={badgeBorderGreen}>
|
||||
{upcomingCount}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</NavItem>
|
||||
);
|
||||
)
|
||||
});
|
||||
})}
|
||||
|
||||
{/* Ensure Shortlinks is present even if not configured in dynamic nav (admins only) */}
|
||||
{isAdmin && !hasShortlinks && (
|
||||
<NavItem
|
||||
icon={FaLink}
|
||||
to="/admin/shortlinks"
|
||||
onClick={onClose}
|
||||
>
|
||||
Zkrácené odkazy
|
||||
</NavItem>
|
||||
)}
|
||||
{isAdmin && !hasShortlinks && renderNavItem({
|
||||
icon: FaLink,
|
||||
to: "/admin/shortlinks",
|
||||
onClick: onClose,
|
||||
children: "Zkrácené odkazy"
|
||||
})}
|
||||
|
||||
{/* Ensure Engagement page is present even if not configured in dynamic nav (admins only) */}
|
||||
{isAdmin && !hasEngagement && (
|
||||
@@ -593,6 +637,17 @@ const AdminSidebar = ({
|
||||
</NavItem>
|
||||
)}
|
||||
|
||||
{/* Ensure E-shop products are present even if not configured in dynamic nav (admins only) - ONLY if e-shop is enabled */}
|
||||
{isAdmin && !hasEshopProducts && isEshopEnabled && (
|
||||
<NavItem
|
||||
icon={FaTshirt}
|
||||
to="/admin/eshop-produkty"
|
||||
onClick={onClose}
|
||||
>
|
||||
E‑shop produkty
|
||||
</NavItem>
|
||||
)}
|
||||
|
||||
{/* Ensure About page (O klubu) and other core admin pages are present (admins only) */}
|
||||
{isAdmin && !hasAbout && (
|
||||
<NavItem
|
||||
@@ -747,11 +802,20 @@ const AdminSidebar = ({
|
||||
Prefetch & Cache
|
||||
</NavItem>
|
||||
)}
|
||||
{isAdmin && !hasManualData && isManualClubDataMode && (
|
||||
<NavItem
|
||||
icon={FaFileAlt}
|
||||
to="/admin/manual-data"
|
||||
onClick={onClose}
|
||||
>
|
||||
Manuální data soutěží
|
||||
</NavItem>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Fallback to hardcoded navigation
|
||||
<>
|
||||
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={useColorModeValue('gray.500', 'gray.400')} textTransform="uppercase" letterSpacing="wider">
|
||||
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={headerMutedColor} textTransform="uppercase" letterSpacing="wider">
|
||||
Hlavní
|
||||
</Text>
|
||||
|
||||
@@ -764,6 +828,15 @@ const AdminSidebar = ({
|
||||
Nástěnka
|
||||
</NavItem>
|
||||
)}
|
||||
{isAdmin && isManualClubDataMode && (
|
||||
<NavItem
|
||||
icon={FaFileAlt}
|
||||
to="/admin/manual-data"
|
||||
onClick={onClose}
|
||||
>
|
||||
Manuální data soutěží
|
||||
</NavItem>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<NavItem
|
||||
@@ -775,7 +848,7 @@ const AdminSidebar = ({
|
||||
</NavItem>
|
||||
)}
|
||||
|
||||
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={useColorModeValue('gray.500', 'gray.400')} textTransform="uppercase" letterSpacing="wider" mt={4}>
|
||||
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={headerMutedColor} textTransform="uppercase" letterSpacing="wider" mt={4}>
|
||||
Obsah
|
||||
</Text>
|
||||
{/* Core sports entities first */}
|
||||
@@ -796,7 +869,7 @@ const AdminSidebar = ({
|
||||
{/* Add subtle scroller hint */}
|
||||
<Text as="span">
|
||||
Zápasy
|
||||
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('gray.100','whiteAlpha.200')} color={useColorModeValue('gray.700','gray.300')} borderWidth="1px" borderColor={useColorModeValue('gray.200','whiteAlpha.300')}>
|
||||
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={badgeBgGray} color={badgeColorGray} borderWidth="1px" borderColor={badgeBorderGray}>
|
||||
scroller
|
||||
</Text>
|
||||
</Text>
|
||||
@@ -811,7 +884,7 @@ const AdminSidebar = ({
|
||||
<Text as="span">
|
||||
Aktivity
|
||||
{upcomingCount > 0 && (
|
||||
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('green.100','green.900')} color={useColorModeValue('green.700','green.200')} borderWidth="1px" borderColor={useColorModeValue('green.200','green.700')}>
|
||||
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={badgeBgGreen} color={badgeColorGreen} borderWidth="1px" borderColor={badgeBorderGreen}>
|
||||
{upcomingCount}
|
||||
</Text>
|
||||
)}
|
||||
@@ -947,7 +1020,7 @@ const AdminSidebar = ({
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={useColorModeValue('gray.500', 'gray.400')} textTransform="uppercase" letterSpacing="wider" mt={4}>
|
||||
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={headerMutedColor} textTransform="uppercase" letterSpacing="wider" mt={4}>
|
||||
Nastavení
|
||||
</Text>
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BlogTranslator } from './BlogTranslator';
|
||||
import { Box, Text, Divider, Alert, AlertIcon } from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* Example component showing how to integrate blog translation
|
||||
* This can be added to ArticlesAdminPage or any blog editing interface
|
||||
*/
|
||||
export const BlogTranslationExample: React.FC = () => {
|
||||
const [currentTitle, setCurrentTitle] = useState('Vítejte v našem fotbalovém klubu');
|
||||
const [currentContent, setCurrentContent] = useState(`
|
||||
<p>Vítáme vás na oficiálních stránkách našeho fotbalového klubu. Naše klub má dlouhou historii a tradici.</p>
|
||||
<p><strong>Nadcházející zápas:</strong> Sparta Praha vs Slavia Praha</p>
|
||||
<p><em>Přijďte nás podpořit!</em></p>
|
||||
`);
|
||||
|
||||
const handleTranslationComplete = (translatedTitle: string, translatedContent: string) => {
|
||||
setCurrentTitle(translatedTitle);
|
||||
setCurrentContent(translatedContent);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box p={4} borderWidth={1} borderRadius="md" bg="white">
|
||||
<Text fontSize="lg" fontWeight="bold" mb={4}>
|
||||
Blog Translation Integration Example
|
||||
</Text>
|
||||
|
||||
<Box mb={4}>
|
||||
<Text fontWeight="medium" mb={2}>Current Content:</Text>
|
||||
<Box p={3} bg="gray.50" borderRadius="md">
|
||||
<Text fontWeight="bold">{currentTitle}</Text>
|
||||
<Box dangerouslySetInnerHTML={{ __html: currentContent }} mt={2} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider my={4} />
|
||||
|
||||
<BlogTranslator
|
||||
title={currentTitle}
|
||||
content={currentContent}
|
||||
onTranslationComplete={handleTranslationComplete}
|
||||
/>
|
||||
|
||||
<Alert status="info" mt={4}>
|
||||
<AlertIcon />
|
||||
<Box>
|
||||
<Text fontWeight="bold">Integration Instructions:</Text>
|
||||
<Text fontSize="sm">
|
||||
1. Add the BlogTranslator component to your article editing interface<br/>
|
||||
2. Pass the current title and content as props<br/>
|
||||
3. Handle the onTranslationComplete callback to update your form state<br/>
|
||||
4. The component automatically detects source language and translates to the opposite language
|
||||
</Text>
|
||||
</Box>
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Button,
|
||||
Box,
|
||||
Text,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
Spinner,
|
||||
HStack,
|
||||
VStack,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import { useBlogTranslation } from '../../hooks/useBlogTranslation';
|
||||
import { FaLanguage, FaCheck, FaExclamationTriangle } from 'react-icons/fa';
|
||||
|
||||
interface BlogTranslatorProps {
|
||||
title: string;
|
||||
content: string;
|
||||
onTranslationComplete: (translatedTitle: string, translatedContent: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const BlogTranslator: React.FC<BlogTranslatorProps> = ({
|
||||
title,
|
||||
content,
|
||||
onTranslationComplete,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { translateBlog, isTranslating, translationError, detectSourceLanguage, getTargetLanguage } = useBlogTranslation();
|
||||
|
||||
const handleTranslate = async () => {
|
||||
try {
|
||||
const result = await translateBlog(title, content);
|
||||
onTranslationComplete(result.title, result.content);
|
||||
} catch (error) {
|
||||
// Error is handled by the hook
|
||||
}
|
||||
};
|
||||
|
||||
const sourceLang = detectSourceLanguage(title + ' ' + content);
|
||||
const targetLang = getTargetLanguage();
|
||||
const shouldTranslate = sourceLang !== targetLang && title && content;
|
||||
|
||||
if (!shouldTranslate) {
|
||||
return (
|
||||
<Alert status="info" borderRadius="md">
|
||||
<AlertIcon as={FaLanguage} />
|
||||
<Box>
|
||||
<AlertTitle>Translation not needed</AlertTitle>
|
||||
<AlertDescription>
|
||||
Content is already in {sourceLang === 'cs' ? 'Czech' : 'English'} or the target language is the same.
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={3} align="stretch">
|
||||
<Box>
|
||||
<HStack spacing={2} mb={2}>
|
||||
<FaLanguage />
|
||||
<Text fontWeight="medium">
|
||||
Translate from {sourceLang === 'cs' ? 'Czech' : 'English'} to {targetLang === 'cs' ? 'Czech' : 'English'}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={handleTranslate}
|
||||
isLoading={isTranslating}
|
||||
loadingText="Translating..."
|
||||
leftIcon={<FaLanguage />}
|
||||
disabled={disabled || isTranslating}
|
||||
size="sm"
|
||||
>
|
||||
{isTranslating ? 'Translating...' : 'Translate Blog'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{translationError && (
|
||||
<Alert status="error" borderRadius="md">
|
||||
<AlertIcon as={FaExclamationTriangle} />
|
||||
<Box>
|
||||
<AlertTitle>Translation Failed</AlertTitle>
|
||||
<AlertDescription>{translationError}</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isTranslating && !translationError && (
|
||||
<Alert status="success" borderRadius="md">
|
||||
<AlertIcon as={FaCheck} />
|
||||
<Box>
|
||||
<AlertTitle>Ready to Translate</AlertTitle>
|
||||
<AlertDescription>
|
||||
Click the translate button to convert this blog content to {targetLang === 'cs' ? 'Czech' : 'English'}.
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,14 @@
|
||||
import React from 'react';
|
||||
import { IconButton, Button, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, Textarea, useToast, Tooltip, Box, Menu, MenuButton, MenuList, MenuItem } from '@chakra-ui/react';
|
||||
import { IconButton, Button, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, Textarea, useToast, Tooltip, Box, Menu, MenuButton, MenuList, MenuItem, SimpleGrid, Text, Image as ChakraImage } from '@chakra-ui/react';
|
||||
import { Share2, Instagram, Twitter, Facebook, Copy } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { createShortLink, createPublicShortLink } from '../../services/shortlinks';
|
||||
import { Article, getArticleMatchLink } from '../../services/articles';
|
||||
import { API_URL } from '../../services/api';
|
||||
import { composeInstagramPostFromArticle, composeInstagramPostFromActivity, MatchSnapshot, stripHtml, formatDateTime, cleanVenue } from '../../services/instagram';
|
||||
import { generateInstagramAI } from '../../services/ai';
|
||||
import { generateInstagramAI, generateInstagramImagesAI } from '../../services/ai';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import AILoadingModal from '../common/AILoadingModal';
|
||||
|
||||
interface Props {
|
||||
article?: Article;
|
||||
@@ -49,6 +50,9 @@ const InstagramGeneratorButton: React.FC<Props> = ({
|
||||
const [text, setText] = React.useState('');
|
||||
const [shortUrl, setShortUrl] = React.useState('');
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [aiProgress, setAiProgress] = React.useState<number | undefined>(undefined);
|
||||
const [aiStartTime, setAiStartTime] = React.useState<number | null>(null);
|
||||
const [images, setImages] = React.useState<string[]>([]);
|
||||
|
||||
// Build deterministic campaign id for UTM and shortlink code
|
||||
const campaignId = React.useMemo(() => {
|
||||
@@ -82,15 +86,29 @@ const InstagramGeneratorButton: React.FC<Props> = ({
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
setAiProgress(0);
|
||||
setAiStartTime(Date.now());
|
||||
setImages([]);
|
||||
|
||||
// Start progress simulation
|
||||
const progressInterval = setInterval(() => {
|
||||
setAiProgress(prev => {
|
||||
if (prev === undefined || prev >= 95) return prev;
|
||||
return Math.min(prev + Math.random() * 20 + 10, 95);
|
||||
});
|
||||
}, 1500);
|
||||
|
||||
const fullUrl = withUtm(computeTarget());
|
||||
if (!fullUrl) throw new Error('Nelze zjistit URL článku/aktivity');
|
||||
|
||||
// Deterministic shortlink code to keep link stable across generations
|
||||
const platformCode = article?.id ? `ig-a${article.id}` : (activity?.id ? `ig-e${activity.id}` : 'ig-share');
|
||||
const payload = {
|
||||
target_url: fullUrl,
|
||||
title: article?.title || activity?.title || 'Link',
|
||||
source_type: article ? 'article' : (activity ? 'event' : 'other'),
|
||||
source_id: article?.id || activity?.id,
|
||||
code: platformCode,
|
||||
} as any;
|
||||
let sUrl = '';
|
||||
try {
|
||||
@@ -192,13 +210,50 @@ const InstagramGeneratorButton: React.FC<Props> = ({
|
||||
} else {
|
||||
composed = `${clubName || 'Náš klub'}\n\n🔗 ${sUrl || fullUrl}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const primaryColor = (publicSettings as any)?.primary_color || '';
|
||||
const secondaryColor = (publicSettings as any)?.secondary_color || '';
|
||||
const imagePromptBase = article
|
||||
? `Návrh instagramového obrázku k článku "${article.title}" pro oficiální profil fotbalového klubu${clubName ? ' ' + clubName : ''}.`
|
||||
: activity
|
||||
? `Návrh instagramového obrázku k aktivitě "${activity.title}" pro oficiální profil fotbalového klubu${clubName ? ' ' + clubName : ''}.`
|
||||
: `Návrh univerzálního instagramového obrázku pro oficiální profil fotbalového klubu${clubName ? ' ' + clubName : ''}.`;
|
||||
|
||||
let colorHint = '';
|
||||
if (primaryColor || secondaryColor) {
|
||||
colorHint = ` Klubové barvy: ${primaryColor || ''}${primaryColor && secondaryColor ? ', ' : ''}${secondaryColor || ''}.`;
|
||||
}
|
||||
|
||||
const imagePrompt = `${imagePromptBase} Zobraz stadion, hráče nebo fanoušky našeho klubu, žádné loga soupeře ani text v obrázku.${colorHint} Styl: realistický, moderní, sportovní, poměr stran 4:5, bez textu, vhodné jako hlavní vizuál příspěvku.`;
|
||||
const imgResp = await generateInstagramImagesAI({
|
||||
prompt: imagePrompt,
|
||||
aspect: '4:5',
|
||||
count: 2,
|
||||
});
|
||||
if (Array.isArray(imgResp?.urls) && imgResp.urls.length > 0) {
|
||||
setImages(imgResp.urls);
|
||||
}
|
||||
} catch (imgErr) {
|
||||
console.error('Instagram image generation failed', imgErr);
|
||||
}
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setAiProgress(100);
|
||||
setText(composed);
|
||||
onGenerated?.(composed, sUrl || fullUrl);
|
||||
onOpen();
|
||||
|
||||
setTimeout(() => {
|
||||
onOpen();
|
||||
}, 500); // Brief moment to show 100% completion
|
||||
} catch (err: any) {
|
||||
toast({ status: 'error', title: 'Nelze vygenerovat příspěvek', description: err?.message || 'Zkuste to prosím znovu.' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
setAiProgress(undefined);
|
||||
setAiStartTime(null);
|
||||
}, 1000); // Brief moment to show 100% completion or clear on error
|
||||
}
|
||||
};
|
||||
|
||||
@@ -269,10 +324,10 @@ const InstagramGeneratorButton: React.FC<Props> = ({
|
||||
const AdminButtonEl = (
|
||||
<Tooltip label="Vygenerovat Instagram příspěvek" placement="right">
|
||||
{variant === 'icon' ? (
|
||||
<IconButton aria-label="IG post" icon={<Share2 size={18} />} colorScheme="brand" onClick={handleGenerate} isLoading={loading} size={size} />
|
||||
<IconButton aria-label="Instagram příspěvek" icon={<Share2 size={18} />} colorScheme="brand" onClick={handleGenerate} isDisabled={loading} size={size} />
|
||||
) : (
|
||||
<Button leftIcon={<Share2 size={18} />} colorScheme="brand" onClick={handleGenerate} isLoading={loading} size={size}>
|
||||
Instagram post
|
||||
<Button leftIcon={<Share2 size={18} />} colorScheme="brand" onClick={handleGenerate} isDisabled={loading} size={size}>
|
||||
Instagram příspěvek
|
||||
</Button>
|
||||
)}
|
||||
</Tooltip>
|
||||
@@ -280,7 +335,16 @@ const InstagramGeneratorButton: React.FC<Props> = ({
|
||||
|
||||
const VisitorShareEl = (
|
||||
<Menu placement="top-start">
|
||||
<MenuButton as={IconButton} aria-label="Sdílet" icon={<Share2 size={18} />} variant="solid" colorScheme="brand" />
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
aria-label="Sdílet"
|
||||
icon={<Share2 size={18} />}
|
||||
variant="solid"
|
||||
colorScheme="brand"
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
onTouchStart={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem onClick={() => handleShareClick('instagram')} icon={<Instagram size={16} />}>Instagram</MenuItem>
|
||||
<MenuItem onClick={() => handleShareClick('twitter')} icon={<Twitter size={16} />}>Twitter</MenuItem>
|
||||
@@ -312,9 +376,29 @@ const InstagramGeneratorButton: React.FC<Props> = ({
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Instagram post</ModalHeader>
|
||||
<ModalHeader>Instagram příspěvek</ModalHeader>
|
||||
<ModalBody>
|
||||
<Textarea value={text} onChange={(e) => setText(e.target.value)} rows={12} fontFamily="mono" />
|
||||
{images.length > 0 && (
|
||||
<Box mt={4}>
|
||||
<Text fontSize="sm" mb={2}>Návrhy obrázků pro Instagram (Grok):</Text>
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={3}>
|
||||
{images.map((url, idx) => (
|
||||
<Box key={idx}>
|
||||
<ChakraImage src={url} alt={`Instagram obrázek ${idx + 1}`} borderRadius="md" w="100%" objectFit="cover" />
|
||||
<Button
|
||||
mt={2}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => { window.open(url, '_blank'); }}
|
||||
>
|
||||
Otevřít v novém okně
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter gap={3}>
|
||||
<Button variant="outline" onClick={handleCopy}>Kopírovat</Button>
|
||||
@@ -322,6 +406,15 @@ const InstagramGeneratorButton: React.FC<Props> = ({
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<AILoadingModal
|
||||
isOpen={loading}
|
||||
onClose={() => {}} // Can't close while AI is working
|
||||
title="AI generuje Instagram příspěvek"
|
||||
message="Pracuji na vytvoření příspěvku pro Instagram..."
|
||||
progress={aiProgress}
|
||||
estimatedTime={aiStartTime ? 20 : undefined} // 20 seconds estimated
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -263,6 +263,7 @@ const MapLinkImporter: React.FC<MapLinkImporterProps> = ({
|
||||
overflow="hidden"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
minH="300px"
|
||||
>
|
||||
<ContactMap
|
||||
latitude={previewCoords.latitude}
|
||||
@@ -306,6 +307,41 @@ const MapLinkImporter: React.FC<MapLinkImporterProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Show map even without preview if coordinates exist */}
|
||||
{!previewCoords && (currentLatitude && currentLongitude) && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box>
|
||||
<Text fontWeight="semibold" mb={2}>
|
||||
Náhled mapy
|
||||
</Text>
|
||||
<Box
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
minH="300px"
|
||||
>
|
||||
<ContactMap
|
||||
latitude={currentLatitude}
|
||||
longitude={currentLongitude}
|
||||
zoom={currentZoom || 15}
|
||||
address={undefined}
|
||||
clubName={clubName}
|
||||
mapStyle={mapStyle || 'positron'}
|
||||
clubPrimaryColor={clubPrimaryColor}
|
||||
clubSecondaryColor={clubSecondaryColor}
|
||||
height={300}
|
||||
/>
|
||||
</Box>
|
||||
<Text fontSize="xs" color="gray.500" mt={2}>
|
||||
Souřadnice: {currentLatitude.toFixed(6)}, {currentLongitude.toFixed(6)}
|
||||
{currentZoom && ` | Zoom: ${currentZoom}`}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -29,7 +29,8 @@ import {
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AddIcon, DeleteIcon, ChevronDownIcon, ChevronUpIcon, CloseIcon } from '@chakra-ui/icons';
|
||||
import { FiPlus } from 'react-icons/fi';
|
||||
import { getPolls, createPoll, updatePoll, Poll, CreatePollRequest } from '../../services/polls';
|
||||
import { getAdminPolls, getPolls, createPoll, updatePoll, Poll, CreatePollRequest } from '../../services/polls';
|
||||
import { useConfirmDialog } from '../../contexts/ConfirmDialogContext';
|
||||
|
||||
interface PollLinkerProps {
|
||||
articleId?: number;
|
||||
@@ -47,6 +48,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [selectedPollId, setSelectedPollId] = useState<string>('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const { confirm } = useConfirmDialog();
|
||||
|
||||
// Poll creation form state
|
||||
const [newPollData, setNewPollData] = useState<CreatePollRequest>({
|
||||
@@ -82,7 +84,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
|
||||
// Query for all available polls
|
||||
const { data: allPolls, isLoading: isLoadingAll } = useQuery({
|
||||
queryKey: ['all-admin-polls'],
|
||||
queryFn: () => getPolls({ status: 'active' }),
|
||||
queryFn: () => getAdminPolls({ status: 'active' }),
|
||||
});
|
||||
|
||||
// Mutation to link existing poll
|
||||
@@ -189,10 +191,16 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
|
||||
linkPollMutation.mutate(parseInt(selectedPollId));
|
||||
};
|
||||
|
||||
const handleUnlinkPoll = (pollId: number) => {
|
||||
if (window.confirm('Opravdu chcete odpojit tuto anketu?')) {
|
||||
unlinkPollMutation.mutate(pollId);
|
||||
}
|
||||
const handleUnlinkPoll = async (pollId: number) => {
|
||||
const ok = await confirm({
|
||||
title: 'Odpojit anketu',
|
||||
message: 'Opravdu chcete odpojit tuto anketu?',
|
||||
confirmText: 'Odpojit',
|
||||
cancelText: 'Zrušit',
|
||||
isDanger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
unlinkPollMutation.mutate(pollId);
|
||||
};
|
||||
|
||||
const resetNewPollForm = () => {
|
||||
@@ -272,13 +280,13 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
|
||||
}));
|
||||
};
|
||||
|
||||
// Filter out polls that are already linked elsewhere to avoid accidental reuse
|
||||
const linkedPollIds = new Set(linkedPolls?.map(p => p.id) || []);
|
||||
const availablePolls = allPolls?.filter(p => {
|
||||
if (linkedPollIds.has(p.id)) return false; // already linked to this content, handled above
|
||||
const linkedElsewhere = !!(p.related_article_id || p.related_event_id || p.related_match_id || p.related_video_url);
|
||||
return !linkedElsewhere;
|
||||
}) || [];
|
||||
// Filter out polls that are already linked to THIS content to avoid duplicates
|
||||
// But allow polls that are linked elsewhere (user can decide to reuse)
|
||||
const linkedPollIds = new Set(linkedPolls?.map((p: Poll) => p.id) || []);
|
||||
const availablePolls = allPolls?.filter((p: Poll) => !linkedPollIds.has(p.id)) || [];
|
||||
|
||||
// For debugging: also include all polls to see what's available
|
||||
const allAvailablePolls = allPolls || [];
|
||||
|
||||
if (!articleId && !eventId) {
|
||||
return null;
|
||||
@@ -323,7 +331,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
|
||||
<Text fontSize="xs" fontWeight="bold" color="gray.500">
|
||||
Připojené ankety:
|
||||
</Text>
|
||||
{linkedPolls.map((poll) => (
|
||||
{linkedPolls.map((poll: Poll) => (
|
||||
<HStack
|
||||
key={poll.id}
|
||||
p={2}
|
||||
@@ -390,7 +398,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
|
||||
size="sm"
|
||||
flex={1}
|
||||
>
|
||||
{availablePolls.map((poll) => (
|
||||
{availablePolls.map((poll: Poll) => (
|
||||
<option key={poll.id} value={poll.id}>
|
||||
{poll.title} ({poll.status}) - {poll.total_votes} hlasů
|
||||
</option>
|
||||
@@ -408,6 +416,16 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
) : allAvailablePolls.length > 0 ? (
|
||||
<Alert status="warning" size="sm">
|
||||
<AlertIcon />
|
||||
<VStack align="start" spacing={2}>
|
||||
<Text fontSize="sm">Všechny aktivní ankety jsou již propojeny s touto aktivitou.</Text>
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
Dostupné ankety ({allAvailablePolls.length}): {allAvailablePolls.map(p => p.title).join(', ')}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert status="info" size="sm">
|
||||
<AlertIcon />
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Button,
|
||||
Box,
|
||||
Text,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
Spinner,
|
||||
HStack,
|
||||
VStack,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import { useBlogTranslation } from '../../hooks/useBlogTranslation';
|
||||
import { FaLanguage, FaCheck, FaExclamationTriangle } from 'react-icons/fa';
|
||||
|
||||
interface UniversalTranslatorProps {
|
||||
title: string;
|
||||
content: string;
|
||||
onTranslationComplete: (translatedTitle: string, translatedContent: string) => void;
|
||||
disabled?: boolean;
|
||||
contentType?: 'article' | 'activity' | 'page' | 'sponsor' | 'player';
|
||||
}
|
||||
|
||||
export const UniversalTranslator: React.FC<UniversalTranslatorProps> = ({
|
||||
title,
|
||||
content,
|
||||
onTranslationComplete,
|
||||
disabled = false,
|
||||
contentType = 'article',
|
||||
}) => {
|
||||
const { translateBlog, isTranslating, translationError, detectSourceLanguage, getTargetLanguage } = useBlogTranslation();
|
||||
|
||||
const handleTranslate = async () => {
|
||||
try {
|
||||
const result = await translateBlog(title, content);
|
||||
onTranslationComplete(result.title, result.content);
|
||||
} catch (error) {
|
||||
// Error is handled by the hook
|
||||
}
|
||||
};
|
||||
|
||||
const sourceLang = detectSourceLanguage(title + ' ' + content);
|
||||
const targetLang = getTargetLanguage();
|
||||
const shouldTranslate = sourceLang !== targetLang && title && content;
|
||||
|
||||
if (!shouldTranslate) {
|
||||
return (
|
||||
<Alert status="info" borderRadius="md">
|
||||
<AlertIcon as={FaLanguage} />
|
||||
<Box>
|
||||
<AlertTitle>Translation not needed</AlertTitle>
|
||||
<AlertDescription>
|
||||
Content is already in {sourceLang === 'cs' ? 'Czech' : 'English'} or the target language is the same.
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const getContentTypeLabel = () => {
|
||||
switch (contentType) {
|
||||
case 'activity': return 'Activity';
|
||||
case 'page': return 'Page';
|
||||
case 'sponsor': return 'Sponsor';
|
||||
case 'player': return 'Player';
|
||||
default: return 'Content';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack spacing={3} align="stretch">
|
||||
<Box>
|
||||
<HStack spacing={2} mb={2}>
|
||||
<FaLanguage />
|
||||
<Text fontWeight="medium">
|
||||
Translate {getContentTypeLabel()} from {sourceLang === 'cs' ? 'Czech' : 'English'} to {targetLang === 'cs' ? 'Czech' : 'English'}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={handleTranslate}
|
||||
isLoading={isTranslating}
|
||||
loadingText="Translating..."
|
||||
leftIcon={<FaLanguage />}
|
||||
disabled={disabled || isTranslating}
|
||||
size="sm"
|
||||
>
|
||||
{isTranslating ? 'Translating...' : `Translate ${getContentTypeLabel()}`}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{translationError && (
|
||||
<Alert status="error" borderRadius="md">
|
||||
<AlertIcon as={FaExclamationTriangle} />
|
||||
<Box>
|
||||
<AlertTitle>Translation Failed</AlertTitle>
|
||||
<AlertDescription>{translationError}</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isTranslating && !translationError && (
|
||||
<Alert status="success" borderRadius="md">
|
||||
<AlertIcon as={FaCheck} />
|
||||
<Box>
|
||||
<AlertTitle>Ready to Translate</AlertTitle>
|
||||
<AlertDescription>
|
||||
Click the translate button to convert this {getContentTypeLabel().toLowerCase()} to {targetLang === 'cs' ? 'Czech' : 'English'}.
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
@@ -10,10 +10,8 @@ import {
|
||||
Text,
|
||||
useColorModeValue,
|
||||
VStack,
|
||||
Tooltip,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaInfoCircle } from 'react-icons/fa';
|
||||
import { HelpTooltipCard } from '../common/HelpTooltipCard';
|
||||
import { ComposableMap, Geographies, Geography } from 'react-simple-maps';
|
||||
import type { Feature } from 'geojson';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
@@ -183,13 +181,14 @@ export const VisitorCountriesMap: React.FC<VisitorCountriesMapProps> = ({
|
||||
<CardHeader>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Heading size="md">{title}</Heading>
|
||||
<Tooltip
|
||||
label="Klikněte na zemi pro zobrazení detailních analytických dat"
|
||||
placement="top"
|
||||
hasArrow
|
||||
>
|
||||
<Icon as={FaInfoCircle} color="gray.400" cursor="help" />
|
||||
</Tooltip>
|
||||
<HelpTooltipCard
|
||||
label="Jak pracovat s mapou návštěvníků"
|
||||
title="Jak pracovat s mapou návštěvníků"
|
||||
items={[
|
||||
'Najetím myši na zemi zobrazíte počet návštěv z dané země.',
|
||||
'Kliknutím na zemi zobrazíte detailnější analytická data podle nastavení nadřazené stránky.',
|
||||
]}
|
||||
/>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
import { Box, Text, HStack, VStack, Icon, Skeleton, Badge, useColorModeValue, Tooltip, Progress, Divider, Heading, SimpleGrid } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { FaCloud, FaCloudSun, FaSun, FaCloudRain, FaSnowflake, FaWind, FaTint, FaEye, FaThermometerHalf, FaMapMarkerAlt, FaSyncAlt, FaMoon } from 'react-icons/fa';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface WeatherData {
|
||||
location: {
|
||||
name: string;
|
||||
region: string;
|
||||
country: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
tz_id: string;
|
||||
localtime: string;
|
||||
};
|
||||
current: {
|
||||
last_updated: string;
|
||||
temp_c: number;
|
||||
temp_f: number;
|
||||
is_day: number;
|
||||
condition: {
|
||||
text: string;
|
||||
icon: string;
|
||||
code: number;
|
||||
};
|
||||
wind_mph: number;
|
||||
wind_kph: number;
|
||||
wind_degree: number;
|
||||
wind_dir: string;
|
||||
pressure_mb: number;
|
||||
pressure_in: number;
|
||||
precip_mm: number;
|
||||
precip_in: number;
|
||||
humidity: number;
|
||||
cloud: number;
|
||||
feelslike_c: number;
|
||||
feelslike_f: number;
|
||||
vis_km: number;
|
||||
vis_miles: number;
|
||||
uv: number;
|
||||
gust_mph: number;
|
||||
gust_kph: number;
|
||||
};
|
||||
forecast: {
|
||||
forecastday: Array<{
|
||||
date: string;
|
||||
date_epoch: number;
|
||||
day: {
|
||||
maxtemp_c: number;
|
||||
maxtemp_f: number;
|
||||
mintemp_c: number;
|
||||
mintemp_f: number;
|
||||
avgtemp_c: number;
|
||||
avgtemp_f: number;
|
||||
maxwind_mph: number;
|
||||
maxwind_kph: number;
|
||||
totalprecip_mm: number;
|
||||
totalprecip_in: number;
|
||||
totalsnow_cm: number;
|
||||
avgvis_km: number;
|
||||
avgvis_miles: number;
|
||||
avghumidity: number;
|
||||
daily_will_it_rain: number;
|
||||
daily_chance_of_rain: number;
|
||||
daily_will_it_snow: number;
|
||||
daily_chance_of_snow: number;
|
||||
condition: {
|
||||
text: string;
|
||||
icon: string;
|
||||
code: number;
|
||||
};
|
||||
uv: number;
|
||||
};
|
||||
astro: {
|
||||
sunrise: string;
|
||||
sunset: string;
|
||||
moonrise: string;
|
||||
moonset: string;
|
||||
moon_phase: string;
|
||||
moon_illumination: string;
|
||||
is_moon_up: number;
|
||||
is_sun_up: number;
|
||||
};
|
||||
hour: Array<{
|
||||
time_epoch: number;
|
||||
time: string;
|
||||
temp_c: number;
|
||||
temp_f: number;
|
||||
is_day: number;
|
||||
condition: {
|
||||
text: string;
|
||||
icon: string;
|
||||
code: number;
|
||||
};
|
||||
wind_mph: number;
|
||||
wind_kph: number;
|
||||
wind_degree: number;
|
||||
wind_dir: string;
|
||||
pressure_mb: number;
|
||||
pressure_in: number;
|
||||
precip_mm: number;
|
||||
precip_in: number;
|
||||
humidity: number;
|
||||
cloud: number;
|
||||
feelslike_c: number;
|
||||
feelslike_f: number;
|
||||
windchill_c: number;
|
||||
windchill_f: number;
|
||||
heatindex_c: number;
|
||||
heatindex_f: number;
|
||||
dewpoint_c: number;
|
||||
dewpoint_f: number;
|
||||
will_it_rain: number;
|
||||
chance_of_rain: number;
|
||||
will_it_snow: number;
|
||||
chance_of_snow: number;
|
||||
vis_km: number;
|
||||
vis_miles: number;
|
||||
gust_mph: number;
|
||||
gust_kph: number;
|
||||
uv: number;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
const getWeatherIcon = (condition: string, isDay: number) => {
|
||||
const lowerCondition = condition.toLowerCase();
|
||||
|
||||
if (lowerCondition.includes('sunny') || lowerCondition.includes('clear')) {
|
||||
return isDay ? FaSun : FaMoon;
|
||||
}
|
||||
if (lowerCondition.includes('partly cloudy') || lowerCondition.includes('partly sunny')) {
|
||||
return FaCloudSun;
|
||||
}
|
||||
if (lowerCondition.includes('cloudy') || lowerCondition.includes('overcast')) {
|
||||
return FaCloud;
|
||||
}
|
||||
if (lowerCondition.includes('rain') || lowerCondition.includes('drizzle') || lowerCondition.includes('shower')) {
|
||||
return FaCloudRain;
|
||||
}
|
||||
if (lowerCondition.includes('snow') || lowerCondition.includes('sleet') || lowerCondition.includes('blizzard')) {
|
||||
return FaSnowflake;
|
||||
}
|
||||
|
||||
return FaCloud;
|
||||
};
|
||||
|
||||
const getUVColor = (uv: number) => {
|
||||
if (uv <= 2) return 'green';
|
||||
if (uv <= 5) return 'yellow';
|
||||
if (uv <= 7) return 'orange';
|
||||
if (uv <= 10) return 'red';
|
||||
return 'purple';
|
||||
};
|
||||
|
||||
const getUVLabel = (uv: number) => {
|
||||
if (uv <= 2) return 'Nízké';
|
||||
if (uv <= 5) return 'Střední';
|
||||
if (uv <= 7) return 'Vysoké';
|
||||
if (uv <= 10) return 'Velmi vysoké';
|
||||
return 'Extrémní';
|
||||
};
|
||||
|
||||
const getWindDirection = (degree: number) => {
|
||||
const directions = ['S', 'SV', 'V', 'JV', 'J', 'JZ', 'Z', 'SZ'];
|
||||
const index = Math.round(degree / 45) % 8;
|
||||
return directions[index];
|
||||
};
|
||||
|
||||
const WeatherWidget = () => {
|
||||
const { data: weather, isLoading, error, refetch } = useQuery<WeatherData>({
|
||||
queryKey: ['weather', 'club'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/weather/club');
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
refetchInterval: 15 * 60 * 1000, // 15 minutes
|
||||
});
|
||||
|
||||
const bgCard = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box
|
||||
bg={bgCard}
|
||||
p={6}
|
||||
borderRadius="xl"
|
||||
boxShadow="md"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Skeleton height="20px" width="60%" />
|
||||
<Skeleton height="40px" width="80%" />
|
||||
<Skeleton height="16px" width="40%" />
|
||||
<Skeleton height="16px" width="50%" />
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !weather) {
|
||||
return (
|
||||
<Box
|
||||
bg={bgCard}
|
||||
p={6}
|
||||
borderRadius="xl"
|
||||
boxShadow="md"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<VStack spacing={4} align="center">
|
||||
<Icon as={FaCloud} boxSize={12} color="gray.400" />
|
||||
<Text color="gray.500" textAlign="center">
|
||||
Počasí není dostupné
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.400" textAlign="center">
|
||||
Zkuste to znovu později
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const currentIcon = getWeatherIcon(weather.current.condition.text, weather.current.is_day);
|
||||
const uvColor = getUVColor(weather.current.uv);
|
||||
const uvLabel = getUVLabel(weather.current.uv);
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bgCard}
|
||||
p={6}
|
||||
borderRadius="xl"
|
||||
boxShadow="md"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Refresh button */}
|
||||
<Box position="absolute" top={4} right={4}>
|
||||
<Tooltip label="Obnovit počasí">
|
||||
<Box
|
||||
as="button"
|
||||
onClick={() => refetch()}
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: useColorModeValue('gray.100', 'gray.700') }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Icon as={FaSyncAlt} color={textColor} boxSize={4} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* Location */}
|
||||
<HStack spacing={2} color={textColor}>
|
||||
<Icon as={FaMapMarkerAlt} boxSize={3} />
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{weather.location.name}
|
||||
{weather.location.region && `, ${weather.location.region}`}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* Current Weather */}
|
||||
<HStack spacing={4} align="center">
|
||||
<VStack spacing={1} align="center" minW="80px">
|
||||
<Icon as={currentIcon} boxSize={12} color="blue.400" />
|
||||
<Text fontSize="sm" color={textColor} textAlign="center">
|
||||
{weather.current.condition.text}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack spacing={1} align="start" flex={1}>
|
||||
<Text fontSize="4xl" fontWeight="bold" lineHeight="1">
|
||||
{Math.round(weather.current.temp_c)}°C
|
||||
</Text>
|
||||
<Text fontSize="sm" color={textColor}>
|
||||
Pocitově {Math.round(weather.current.feelslike_c)}°C
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Weather Details Grid */}
|
||||
<SimpleGrid columns={2} spacing={3}>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FaWind} boxSize={4} color={textColor} />
|
||||
<VStack spacing={0} align="start">
|
||||
<Text fontSize="xs" color={textColor}>Vítr</Text>
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{weather.current.wind_kph} km/h {getWindDirection(weather.current.wind_degree)}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FaTint} boxSize={4} color={textColor} />
|
||||
<VStack spacing={0} align="start">
|
||||
<Text fontSize="xs" color={textColor}>Vlhkost</Text>
|
||||
<Text fontSize="sm" fontWeight="medium">{weather.current.humidity}%</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FaEye} boxSize={4} color={textColor} />
|
||||
<VStack spacing={0} align="start">
|
||||
<Text fontSize="xs" color={textColor}>Viditelnost</Text>
|
||||
<Text fontSize="sm" fontWeight="medium">{weather.current.vis_km} km</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FaThermometerHalf} boxSize={4} color={textColor} />
|
||||
<VStack spacing={0} align="start">
|
||||
<Text fontSize="xs" color={textColor}>Tlak</Text>
|
||||
<Text fontSize="sm" fontWeight="medium">{weather.current.pressure_mb} hPa</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* UV Index */}
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={1}>
|
||||
<Text fontSize="xs" color={textColor}>UV Index</Text>
|
||||
<Badge colorScheme={uvColor} fontSize="xs">
|
||||
{uvLabel} ({weather.current.uv})
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Progress
|
||||
value={Math.min(weather.current.uv * 10, 100)}
|
||||
size="xs"
|
||||
colorScheme={uvColor}
|
||||
borderRadius="md"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Forecast for next 2 days */}
|
||||
{weather.forecast.forecastday.length > 1 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Text fontSize="sm" fontWeight="bold" mb={2}>
|
||||
Předpověď na následující dny
|
||||
</Text>
|
||||
<SimpleGrid columns={2} spacing={3}>
|
||||
{weather.forecast.forecastday.slice(1, 3).map((day, index) => {
|
||||
const dayIcon = getWeatherIcon(day.day.condition.text, 1);
|
||||
const date = new Date(day.date);
|
||||
const dayName = date.toLocaleDateString('cs-CZ', { weekday: 'short' });
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={index}
|
||||
p={3}
|
||||
bg={useColorModeValue('gray.50', 'gray.700')}
|
||||
borderRadius="md"
|
||||
border="1px solid"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.600')}
|
||||
>
|
||||
<HStack spacing={3} align="center">
|
||||
<Icon as={dayIcon} boxSize={6} color="blue.400" />
|
||||
<VStack spacing={1} align="start" flex={1}>
|
||||
<Text fontSize="xs" fontWeight="medium">{dayName}</Text>
|
||||
<HStack spacing={2}>
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
{Math.round(day.day.maxtemp_c)}°
|
||||
</Text>
|
||||
<Text fontSize="sm" color={textColor}>
|
||||
{Math.round(day.day.mintemp_c)}°
|
||||
</Text>
|
||||
</HStack>
|
||||
{day.day.daily_chance_of_rain > 0 && (
|
||||
<Badge size="xs" colorScheme="blue">
|
||||
{day.day.daily_chance_of_rain}% déšť
|
||||
</Badge>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Last updated */}
|
||||
<Text fontSize="xs" color={textColor} textAlign="center">
|
||||
Aktualizováno: {new Date(weather.current.last_updated).toLocaleTimeString('cs-CZ', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default WeatherWidget;
|
||||
@@ -5,6 +5,7 @@ import { listComments, createComment, updateComment, deleteComment, CommentItem,
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Pencil, Trash2, Send, CheckCircle2 } from 'lucide-react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type Props = {
|
||||
targetType: 'article' | 'event' | 'gallery_album' | 'youtube_video';
|
||||
@@ -14,14 +15,18 @@ type Props = {
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const displayName = (u?: CommentItem['user']) => {
|
||||
if (!u) return 'Anonym';
|
||||
const { i18n } = useTranslation();
|
||||
const useEnglish = i18n.language === 'en';
|
||||
|
||||
if (!u) return useEnglish ? 'Anonymous' : 'Anonym';
|
||||
const uname = (u.username || '').trim();
|
||||
if (uname) return uname;
|
||||
const name = `${u.first_name || ''} ${u.last_name || ''}`.trim();
|
||||
return name || 'Uživatel';
|
||||
return name || (useEnglish ? 'User' : 'Uživatel');
|
||||
};
|
||||
|
||||
const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const border = useColorModeValue('gray.200', 'gray.700');
|
||||
const muted = useColorModeValue('gray.600', 'gray.400');
|
||||
@@ -29,6 +34,8 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
|
||||
const adminLikedColor = useColorModeValue('blue.600','blue.300');
|
||||
const queryClient = useQueryClient();
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
|
||||
const useEnglish = i18n.language === 'en';
|
||||
|
||||
const commentsQuery = useInfiniteQuery({
|
||||
queryKey: ['comments', targetType, targetId],
|
||||
@@ -48,7 +55,9 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
|
||||
const [replyTo, setReplyTo] = React.useState<number | null>(null);
|
||||
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
|
||||
const [canRequestUnban, setCanRequestUnban] = React.useState<boolean>(false);
|
||||
const [unbanMessage, setUnbanMessage] = React.useState<string>('Prosím o odblokování komentářů. Děkuji.');
|
||||
const [unbanMessage, setUnbanMessage] = React.useState<string>('Please unblock my comments. Thank you.');
|
||||
const [localReactions, setLocalReactions] = React.useState<Record<number, string | undefined>>({});
|
||||
const [pendingReactions, setPendingReactions] = React.useState<Set<number>>(new Set());
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (body: { content: string; parent_id?: number | null }) => createComment({ target_type: targetType, target_id: targetId, content: body.content, parent_id: body.parent_id }),
|
||||
@@ -58,12 +67,12 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
|
||||
setErrorMsg(null);
|
||||
await queryClient.invalidateQueries({ queryKey: ['comments', targetType, targetId] });
|
||||
if ((created as any)?.status === 'hidden') {
|
||||
setErrorMsg('Váš komentář čeká na schválení (automatická moderace).');
|
||||
setErrorMsg(useEnglish ? 'Your comment is awaiting approval (automatic moderation).' : 'Váš komentář čeká na schválení (automatická moderace).');
|
||||
}
|
||||
try { window.dispatchEvent(new CustomEvent('engagement:refresh')); } catch {}
|
||||
},
|
||||
onError: (e: any) => {
|
||||
const msg = e?.response?.data?.error || 'Nepodařilo se odeslat komentář';
|
||||
const msg = e?.response?.data?.error || (useEnglish ? 'Failed to post comment' : 'Nepodařilo se odeslat komentář');
|
||||
setErrorMsg(msg);
|
||||
if ((e?.response?.status || 0) === 403) setCanRequestUnban(true);
|
||||
}
|
||||
@@ -88,21 +97,41 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
|
||||
const reactMut = useMutation({
|
||||
mutationFn: (args: { id: number; type: string }) => reactComment(args.id, args.type),
|
||||
onMutate: async ({ id, type }) => {
|
||||
setPendingReactions(prev => new Set(prev).add(id));
|
||||
|
||||
const qk = ['comments', targetType, targetId] as const;
|
||||
await queryClient.cancelQueries({ queryKey: qk });
|
||||
const previous = queryClient.getQueryData<any>(qk);
|
||||
|
||||
// Set optimistic state
|
||||
setLocalReactions((m) => ({ ...m, [id]: type }));
|
||||
|
||||
queryClient.setQueryData(qk, (oldData: any) => {
|
||||
if (!oldData) return oldData;
|
||||
const pages = (oldData.pages || []).map((page: any) => {
|
||||
const items = (page.items || []).map((it: any) => {
|
||||
if (it.id !== id) return it;
|
||||
const next = { ...it, reactions: { ...(it.reactions || {}) } };
|
||||
const prevType = next.my_reaction as string | undefined;
|
||||
if (prevType && typeof next.reactions[prevType] === 'number') {
|
||||
next.reactions[prevType] = Math.max(0, (next.reactions[prevType] || 0) - 1);
|
||||
const prevType = (next.my_reaction as string | undefined)?.trim();
|
||||
|
||||
// Remove previous reaction count if it existed
|
||||
if (prevType && prevType !== type) {
|
||||
const prevCount = (typeof next.reactions[prevType] === 'number' ? next.reactions[prevType] : 1) as number;
|
||||
const newPrev = Math.max(0, prevCount - 1);
|
||||
if (newPrev <= 0) {
|
||||
delete (next.reactions as any)[prevType];
|
||||
} else {
|
||||
next.reactions[prevType] = newPrev;
|
||||
}
|
||||
}
|
||||
next.reactions[type] = (next.reactions[type] || 0) + 1;
|
||||
|
||||
// Add new reaction count
|
||||
next.reactions[type] = ((next.reactions[type] || 0) as number) + 1;
|
||||
next.my_reaction = type;
|
||||
|
||||
if ((user as any)?.role === 'admin') {
|
||||
next.admin_liked = (type === 'thumbs_up' || type === 'like');
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return { ...page, items };
|
||||
@@ -117,7 +146,23 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
|
||||
queryClient.setQueryData(qk, (ctx as any).previous);
|
||||
}
|
||||
},
|
||||
onSettled: async () => {
|
||||
onSettled: async (_data, _error, variables) => {
|
||||
if (variables?.id) {
|
||||
setPendingReactions(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(variables.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
// Clear local optimistic state after a delay to allow server state to sync
|
||||
setTimeout(() => {
|
||||
setLocalReactions((m) => {
|
||||
const cp = { ...m };
|
||||
delete cp[variables?.id];
|
||||
return cp;
|
||||
});
|
||||
}, 100);
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ['comments', targetType, targetId] });
|
||||
try { window.dispatchEvent(new CustomEvent('engagement:refresh')); } catch {}
|
||||
},
|
||||
@@ -126,20 +171,35 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
|
||||
const unreactMut = useMutation({
|
||||
mutationFn: (id: number) => unreactComment(id),
|
||||
onMutate: async (id: number) => {
|
||||
setPendingReactions(prev => new Set(prev).add(id));
|
||||
|
||||
const qk = ['comments', targetType, targetId] as const;
|
||||
await queryClient.cancelQueries({ queryKey: qk });
|
||||
const previous = queryClient.getQueryData<any>(qk);
|
||||
|
||||
// Set optimistic state to empty
|
||||
setLocalReactions((m) => ({ ...m, [id]: '' }));
|
||||
|
||||
queryClient.setQueryData(qk, (oldData: any) => {
|
||||
if (!oldData) return oldData;
|
||||
const pages = (oldData.pages || []).map((page: any) => {
|
||||
const items = (page.items || []).map((it: any) => {
|
||||
if (it.id !== id) return it;
|
||||
const next = { ...it, reactions: { ...(it.reactions || {}) } };
|
||||
const prevType = next.my_reaction as string | undefined;
|
||||
if (prevType && typeof next.reactions[prevType] === 'number') {
|
||||
next.reactions[prevType] = Math.max(0, (next.reactions[prevType] || 0) - 1);
|
||||
const prevType = (next.my_reaction as string | undefined)?.trim();
|
||||
if (prevType) {
|
||||
const prevCount = (typeof next.reactions[prevType] === 'number' ? next.reactions[prevType] : 1) as number;
|
||||
const newPrev = Math.max(0, prevCount - 1);
|
||||
if (newPrev <= 0) {
|
||||
delete (next.reactions as any)[prevType];
|
||||
} else {
|
||||
next.reactions[prevType] = newPrev;
|
||||
}
|
||||
}
|
||||
next.my_reaction = '';
|
||||
if ((user as any)?.role === 'admin') {
|
||||
next.admin_liked = false;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return { ...page, items };
|
||||
@@ -154,7 +214,23 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
|
||||
queryClient.setQueryData(qk, (ctx as any).previous);
|
||||
}
|
||||
},
|
||||
onSettled: async () => {
|
||||
onSettled: async (_data, _error, variables) => {
|
||||
if (variables) {
|
||||
setPendingReactions(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(variables);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
// Clear local optimistic state after a delay to allow server state to sync
|
||||
setTimeout(() => {
|
||||
setLocalReactions((m) => {
|
||||
const cp = { ...m };
|
||||
delete cp[variables];
|
||||
return cp;
|
||||
});
|
||||
}, 100);
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ['comments', targetType, targetId] });
|
||||
try { window.dispatchEvent(new CustomEvent('engagement:refresh')); } catch {}
|
||||
},
|
||||
@@ -164,15 +240,15 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
|
||||
mutationFn: (message: string) => requestUnban(message),
|
||||
onSuccess: () => {
|
||||
setCanRequestUnban(false);
|
||||
setErrorMsg('Žádost o odblokování odeslána.');
|
||||
setUnbanMessage('Prosím o odblokování komentářů. Děkuji.');
|
||||
setErrorMsg(useEnglish ? 'Unban request sent.' : 'Žádost o odblokování odeslána.');
|
||||
setUnbanMessage(useEnglish ? 'Please unblock my comments. Thank you.' : 'Prosím o odblokování komentářů. Děkuji.');
|
||||
}
|
||||
});
|
||||
|
||||
const reportMut = useMutation({
|
||||
mutationFn: (args: { id: number; reason?: string }) => reportComment(args.id, args.reason),
|
||||
onSuccess: async () => {
|
||||
setErrorMsg('Děkujeme za nahlášení. Moderátor se na komentář podívá.');
|
||||
setErrorMsg(useEnglish ? 'Thank you for reporting. A moderator will review the comment.' : 'Děkujeme za nahlášení. Moderátor se na komentář podívá.');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -196,15 +272,54 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
|
||||
|
||||
const ReactionBar: React.FC<{ c: CommentItem }> = ({ c }) => {
|
||||
const options: { key: string; label: string; color: string; name: string }[] = [
|
||||
{ key: 'thumbs_up', label: '👍', color: 'green', name: 'Palec nahoru' },
|
||||
{ key: 'heart', label: '❤️', color: 'pink', name: 'Srdíčko' },
|
||||
{ key: 'smile', label: '😀', color: 'yellow', name: 'Úsměv' },
|
||||
{ key: 'surprised', label: '😮', color: 'purple', name: 'Překvapení' },
|
||||
{ key: 'thumbs_down', label: '👎', color: 'red', name: 'Palec dolů' },
|
||||
{ key: 'thumbs_up', label: '👍', color: 'green', name: useEnglish ? 'Thumbs up' : 'Palec nahoru' },
|
||||
{ key: 'heart', label: '❤️', color: 'pink', name: useEnglish ? 'Heart' : 'Srdíčko' },
|
||||
{ key: 'smile', label: '😀', color: 'yellow', name: useEnglish ? 'Smile' : 'Úsměv' },
|
||||
{ key: 'surprised', label: '😮', color: 'purple', name: useEnglish ? 'Surprised' : 'Překvapení' },
|
||||
{ key: 'thumbs_down', label: '👎', color: 'red', name: useEnglish ? 'Thumbs down' : 'Palec dolů' },
|
||||
];
|
||||
const counts = c.reactions || {};
|
||||
const active = c.my_reaction;
|
||||
const isBusy = reactMut.isPending || unreactMut.isPending;
|
||||
const active = React.useMemo(() => {
|
||||
// Prioritize local optimistic state, fallback to server state
|
||||
const localActive = (localReactions[c.id] || '').trim();
|
||||
if (localActive !== '') {
|
||||
return localActive;
|
||||
}
|
||||
return (c.my_reaction || '').trim();
|
||||
}, [c.my_reaction, localReactions, c.id]);
|
||||
|
||||
const viewCounts = React.useMemo(() => {
|
||||
const base: Record<string, number> = { ...(counts as any) };
|
||||
const a = (active || '').trim();
|
||||
const serverActive = (c.my_reaction || '').trim();
|
||||
const localActive = (localReactions[c.id] || '').trim();
|
||||
|
||||
// If we have a local optimistic state that differs from server, adjust counts
|
||||
if (localActive !== '' && localActive !== serverActive) {
|
||||
// Remove old server reaction count if it existed
|
||||
if (serverActive) {
|
||||
const prev = typeof base[serverActive] === 'number' ? base[serverActive] : 0;
|
||||
if (prev > 0) {
|
||||
base[serverActive] = prev - 1;
|
||||
if (base[serverActive] <= 0) delete base[serverActive];
|
||||
}
|
||||
}
|
||||
// Add new local reaction count
|
||||
base[localActive] = (base[localActive] || 0) + 1;
|
||||
}
|
||||
// Handle unreact optimistic state (empty string)
|
||||
else if (localActive === '' && serverActive) {
|
||||
// Remove the server reaction count optimistically
|
||||
const prev = typeof base[serverActive] === 'number' ? base[serverActive] : 0;
|
||||
if (prev > 0) {
|
||||
base[serverActive] = prev - 1;
|
||||
if (base[serverActive] <= 0) delete base[serverActive];
|
||||
}
|
||||
}
|
||||
|
||||
return base;
|
||||
}, [counts, active, c.my_reaction, localReactions, c.id]);
|
||||
const isBusy = reactMut.isPending || unreactMut.isPending || pendingReactions.has(c.id);
|
||||
return (
|
||||
<HStack spacing={2} mt={1}>
|
||||
{options.map((o) => (
|
||||
@@ -214,9 +329,9 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
|
||||
colorScheme={o.color}
|
||||
variant={active === o.key ? 'solid' : 'outline'}
|
||||
isDisabled={!isAuthenticated || isBusy}
|
||||
aria-pressed={active === o.key}
|
||||
isActive={active === o.key}
|
||||
onClick={() => {
|
||||
if (!isAuthenticated) return;
|
||||
if (!isAuthenticated || isBusy) return;
|
||||
if (active === o.key) {
|
||||
unreactMut.mutate(c.id);
|
||||
} else {
|
||||
@@ -226,7 +341,7 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
|
||||
>
|
||||
<HStack spacing={1}>
|
||||
<Text as="span">{o.label}</Text>
|
||||
<Text as="span" fontSize="xs">{counts[o.key] || 0}</Text>
|
||||
<Text as="span" fontSize="xs">{viewCounts[o.key] || 0}</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -246,14 +361,14 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
|
||||
<HStack justify="space-between">
|
||||
<HStack spacing={2}>
|
||||
<Text fontWeight="600">{displayName(c.user)}</Text>
|
||||
{c.user?.role === 'admin' && <Badge colorScheme="purple" variant="subtle">Admin</Badge>}
|
||||
{c.user?.role === 'admin' && <Badge colorScheme="purple" variant="subtle">{useEnglish ? 'Admin' : 'Admin'}</Badge>}
|
||||
<Text fontSize="sm" color={muted}>{new Date(c.created_at).toLocaleString()}</Text>
|
||||
{c.is_edited && <Text fontSize="xs" color={muted}>(upraveno)</Text>}
|
||||
{c.is_edited && <Text fontSize="xs" color={muted}>({useEnglish ? '(edited)' : '(upraveno)'})</Text>}
|
||||
</HStack>
|
||||
{canEdit(c) && (
|
||||
<HStack spacing={1}>
|
||||
<IconButton aria-label="Upravit" size="xs" variant="ghost" icon={<Pencil size={16} />} onClick={() => { setEditingId(c.id); setEditContent(c.content); }} />
|
||||
<IconButton aria-label="Smazat" size="xs" variant="ghost" colorScheme="red" icon={<Trash2 size={16} />} onClick={() => deleteMut.mutate(c.id)} />
|
||||
<IconButton aria-label={useEnglish ? 'Edit' : 'Upravit'} size="xs" variant="ghost" icon={<Pencil size={16} />} onClick={() => { setEditingId(c.id); setEditContent(c.content); }} />
|
||||
<IconButton aria-label={useEnglish ? 'Delete' : 'Smazat'} size="xs" variant="ghost" colorScheme="red" icon={<Trash2 size={16} />} onClick={() => deleteMut.mutate(c.id)} />
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
@@ -261,8 +376,8 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Textarea value={editContent} onChange={(e) => setEditContent(e.target.value)} rows={3} />
|
||||
<HStack>
|
||||
<Button size="sm" colorScheme="blue" onClick={() => updateMut.mutate({ id: c.id, content: editContent.trim() })} isLoading={updateMut.isPending}>Uložit</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setEditingId(null); setEditContent(''); }}>Zrušit</Button>
|
||||
<Button size="sm" colorScheme="blue" onClick={() => updateMut.mutate({ id: c.id, content: editContent.trim() })} isLoading={updateMut.isPending}>{useEnglish ? 'Save' : 'Uložit'}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setEditingId(null); setEditContent(''); }}>{useEnglish ? 'Cancel' : 'Zrušit'}</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
) : (
|
||||
@@ -276,13 +391,13 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
|
||||
{c.admin_liked && (
|
||||
<HStack spacing={2} mt={1} color={adminLikedColor}>
|
||||
<CheckCircle2 size={16} />
|
||||
<Text fontSize="sm">Označeno administrátorem</Text>
|
||||
<Text fontSize="sm">{useEnglish ? 'Marked by administrator' : 'Označeno administrátorem'}</Text>
|
||||
</HStack>
|
||||
)}
|
||||
<HStack>
|
||||
{isAuthenticated && <Button size="xs" variant="ghost" onClick={() => setReplyTo(c.id)}>Odpovědět</Button>}
|
||||
{isAuthenticated && <Button size="xs" variant="ghost" colorScheme="red" onClick={() => reportMut.mutate({ id: c.id })}>Nahlásit</Button>}
|
||||
{c.status === 'hidden' && <Badge colorScheme="yellow">Čeká na schválení</Badge>}
|
||||
{isAuthenticated && <Button size="xs" variant="ghost" onClick={() => setReplyTo(c.id)}>{useEnglish ? 'Reply' : 'Odpovědět'}</Button>}
|
||||
{isAuthenticated && <Button size="xs" variant="ghost" colorScheme="red" onClick={() => reportMut.mutate({ id: c.id })}>{useEnglish ? 'Report' : 'Nahlásit'}</Button>}
|
||||
{c.status === 'hidden' && <Badge colorScheme="yellow">{useEnglish ? 'Awaiting approval' : 'Čeká na schválení'}</Badge>}
|
||||
</HStack>
|
||||
{/* Replies */}
|
||||
{renderThread(c.id, depth + 1)}
|
||||
@@ -295,18 +410,20 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
|
||||
|
||||
return (
|
||||
<Box mt={6} borderWidth="1px" borderColor={border} borderRadius="lg" bg={cardBg} p={4}>
|
||||
<Heading as="h3" size="md" mb={3}>Komentáře</Heading>
|
||||
<HStack justify="space-between" align="center" mb={3}>
|
||||
<Heading as="h3" size="md">{useEnglish ? 'Comments' : 'Komentáře'}</Heading>
|
||||
</HStack>
|
||||
|
||||
{commentsQuery.isLoading ? (
|
||||
<HStack><Spinner size="sm" /><Text>Načítám…</Text></HStack>
|
||||
<HStack><Spinner size="sm" /><Text>{useEnglish ? 'Loading...' : 'Načítám…'}</Text></HStack>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{allItems.length === 0 && (
|
||||
<Text color={muted}>Zatím žádné komentáře.</Text>
|
||||
<Text color={muted}>{useEnglish ? 'No comments yet.' : 'Zatím žádné komentáře.'}</Text>
|
||||
)}
|
||||
{renderThread(null)}
|
||||
{commentsQuery.hasNextPage && (
|
||||
<Button onClick={() => commentsQuery.fetchNextPage()} isLoading={commentsQuery.isFetchingNextPage} alignSelf="center" size="sm" variant="outline">Načíst další</Button>
|
||||
<Button onClick={() => commentsQuery.fetchNextPage()} isLoading={commentsQuery.isFetchingNextPage} alignSelf="center" size="sm" variant="outline">{useEnglish ? 'Load more' : 'Načíst další'}</Button>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
@@ -319,32 +436,37 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{replyTo && (
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm" color={muted}>Odpověď na komentář #{replyTo}</Text>
|
||||
<Button size="xs" variant="ghost" onClick={() => setReplyTo(null)}>Zrušit odpověď</Button>
|
||||
<Text fontSize="sm" color={muted}>{useEnglish ? `Reply to comment #${replyTo}` : `Odpověď na komentář #${replyTo}`}</Text>
|
||||
<Button size="xs" variant="ghost" onClick={() => setReplyTo(null)}>{useEnglish ? 'Cancel reply' : 'Zrušit odpověď'}</Button>
|
||||
</HStack>
|
||||
)}
|
||||
<Textarea placeholder="Napište komentář…" value={newContent} onChange={(e) => setNewContent(e.target.value)} rows={3} />
|
||||
<Textarea placeholder={useEnglish ? 'Write a comment...' : 'Napište komentář…'} value={newContent} onChange={(e) => setNewContent(e.target.value)} rows={3} />
|
||||
<HStack>
|
||||
<Button leftIcon={<Send size={16} />} colorScheme="blue" onClick={() => createMut.mutate({ content: newContent.trim(), parent_id: replyTo })} isLoading={createMut.isPending} isDisabled={newContent.trim().length < minChars}>Odeslat</Button>
|
||||
<Text fontSize="sm" color={muted}>Respektujte prosím pravidla slušné diskuse.</Text>
|
||||
<Button leftIcon={<Send size={16} />} colorScheme="blue" onClick={() => createMut.mutate({ content: newContent.trim(), parent_id: replyTo })} isLoading={createMut.isPending} isDisabled={newContent.trim().length < minChars}>{useEnglish ? 'Send' : 'Odeslat'}</Button>
|
||||
<Text fontSize="sm" color={muted}>{useEnglish ? 'Please respect the rules of decent discussion.' : 'Respektujte prosím pravidla slušné diskuse.'}</Text>
|
||||
</HStack>
|
||||
{canRequestUnban && (
|
||||
<VStack align="stretch" spacing={2} borderWidth="1px" borderColor={border} borderRadius="md" p={3} bg={appealBg}>
|
||||
<Text fontSize="sm" color={muted}>Váš účet je dočasně zablokován pro komentování. Můžete odeslat žádost o odblokování s krátkým vysvětlením.</Text>
|
||||
<Text fontSize="sm" color={muted}>{useEnglish ? 'Your account is temporarily blocked from commenting. You can send an unban request with a brief explanation.' : 'Váš účet je dočasně zablokován pro komentování. Můžete odeslat žádost o odblokování s krátkým vysvětlením.'}</Text>
|
||||
<Textarea
|
||||
placeholder="Vaše zpráva pro administrátory…"
|
||||
placeholder={useEnglish ? 'Your message for administrators...' : 'Vaše zpráva pro administrátory…'}
|
||||
value={unbanMessage}
|
||||
onChange={(e) => setUnbanMessage(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<HStack>
|
||||
<Button size="sm" variant="outline" onClick={() => unbanMut.mutate(unbanMessage.trim() || 'Prosím o odblokování komentářů. Děkuji.')} isLoading={unbanMut.isPending}>Odeslat žádost o odblokování</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => unbanMut.mutate(unbanMessage.trim() || (useEnglish ? 'Please unblock my comments. Thank you.' : 'Prosím o odblokování komentářů. Děkuji.'))} isLoading={unbanMut.isPending}>{useEnglish ? 'Send unban request' : 'Odeslat žádost o odblokování'}</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
) : (
|
||||
<Text color={muted}>Pro přidání komentáře se prosím <ChakraLink as={RouterLink} to="/login" color="blue.500">přihlaste</ChakraLink>.</Text>
|
||||
<Text color={muted}>
|
||||
{useEnglish ? 'To add a comment, please ' : 'Pro přidání komentáře se prosím '}
|
||||
<ChakraLink as={RouterLink} to="/login" color="blue.500">
|
||||
{useEnglish ? 'log in' : 'přihlaste'}
|
||||
</ChakraLink>.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Spinner,
|
||||
Progress,
|
||||
Box,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiCpu, FiClock } from 'react-icons/fi';
|
||||
|
||||
interface AILoadingModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
message?: string;
|
||||
progress?: number;
|
||||
estimatedTime?: number; // in seconds
|
||||
}
|
||||
|
||||
const AILoadingModal: React.FC<AILoadingModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title = 'AI generuje obsah',
|
||||
message = 'Pracuji na vašem požadavku...',
|
||||
progress,
|
||||
estimatedTime,
|
||||
}) => {
|
||||
const bg = useColorModeValue('white', 'gray.800');
|
||||
const textColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const progressColor = useColorModeValue('blue.500', 'blue.300');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
const getProgressMessage = () => {
|
||||
if (progress !== undefined) {
|
||||
if (progress < 25) return 'AI analyzuje požadavek...';
|
||||
if (progress < 50) return 'AI komunikuje s modelem...';
|
||||
if (progress < 75) return 'AI zpracovává obsah...';
|
||||
if (progress < 90) return 'AI optimalizuje výstup...';
|
||||
return 'AI dokončuje...';
|
||||
}
|
||||
return message;
|
||||
};
|
||||
|
||||
const getTimeRemaining = () => {
|
||||
if (estimatedTime && progress !== undefined && progress > 0) {
|
||||
const remaining = Math.ceil((estimatedTime * (100 - progress)) / 100);
|
||||
if (remaining > 0) {
|
||||
return `Předpokládaný čas: ${remaining}s`;
|
||||
}
|
||||
}
|
||||
if (estimatedTime) {
|
||||
return `Předpokládaný čas: ${estimatedTime}s`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const getRoundedProgress = () => {
|
||||
if (progress !== undefined) {
|
||||
return Math.round(progress);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} isCentered closeOnEsc={false} closeOnOverlayClick={false}>
|
||||
<ModalOverlay bg="blackAlpha.400" backdropFilter="blur(6px)" />
|
||||
<ModalContent bg={bg} borderRadius="2xl" boxShadow="2xl" border="1px solid" borderColor={borderColor}>
|
||||
<ModalHeader borderBottom="1px solid" borderColor={borderColor} pb={4}>
|
||||
<VStack spacing={3} align="center">
|
||||
<HStack spacing={3}>
|
||||
<Box bg="blue.50" p={2} borderRadius="full">
|
||||
<Icon as={FiCpu} color="blue.500" boxSize={5} />
|
||||
</Box>
|
||||
<Text fontSize="lg" fontWeight="600" color={textColor}>
|
||||
{title}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody py={8}>
|
||||
<VStack spacing={6} align="center">
|
||||
{/* Single centered AI Animation with model logo */}
|
||||
<VStack spacing={4}>
|
||||
<Box bg="blue.50" p={4} borderRadius="full">
|
||||
<Icon as={FiCpu} color="blue.500" boxSize={6} />
|
||||
</Box>
|
||||
<Spinner size="lg" color="blue.500" thickness="3px" />
|
||||
</VStack>
|
||||
|
||||
{/* Status Message */}
|
||||
<Text fontSize="md" color={textColor} textAlign="center" fontWeight="500">
|
||||
{getProgressMessage()}
|
||||
</Text>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{progress !== undefined && (
|
||||
<Box w="100%" maxW="400px">
|
||||
<Progress
|
||||
value={progress}
|
||||
size="lg"
|
||||
colorScheme="blue"
|
||||
borderRadius="full"
|
||||
bg="gray.100"
|
||||
sx={{
|
||||
'& > div': {
|
||||
bg: progressColor,
|
||||
transition: 'width 0.4s ease-out',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Text fontSize="sm" color="gray.600" mt={3} textAlign="center" fontWeight="500">
|
||||
{getRoundedProgress()}% hotovo
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Time Estimate */}
|
||||
{getTimeRemaining() && (
|
||||
<HStack spacing={2} color="gray.500">
|
||||
<Icon as={FiClock} boxSize={4} />
|
||||
<Text fontSize="sm">{getTimeRemaining()}</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* Additional Info */}
|
||||
<VStack spacing={2} align="center" pt={2}>
|
||||
<Text fontSize="sm" color="gray.400" fontStyle="italic">
|
||||
AI pracuje na vašem obsahu
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.400">
|
||||
Vygenerovaný obsah bude automaticky vložen do formuláře
|
||||
</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter borderTop="1px solid" borderColor={borderColor}>
|
||||
<Text fontSize="sm" color="gray.400">
|
||||
Prosím čekejte, proces nelze přerušit
|
||||
</Text>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AILoadingModal;
|
||||
@@ -0,0 +1,863 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
HStack,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
useDisclosure,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalCloseButton,
|
||||
Button,
|
||||
Spinner,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { FiChevronDown, FiCheck, FiSend, FiPaperclip, FiMic } from 'react-icons/fi';
|
||||
import { Brain, Sparkles, Globe2 } from 'lucide-react';
|
||||
import {
|
||||
AITextModelId,
|
||||
AITextModelOption,
|
||||
getDefaultTextModelId,
|
||||
getEnabledTextModels,
|
||||
findTextModel,
|
||||
} from '../../utils/aiModels';
|
||||
import { getAIUsageStatus, formatUsageText, AIUsageStatus } from '../../services/aiUsage';
|
||||
import UploadPanel, { UploadPanelFile } from './UploadPanel';
|
||||
import { processOcrAI, transcribeAudioAI } from '../../services/ai';
|
||||
import { uploadFile as uploadArticleFile } from '../../services/articles';
|
||||
|
||||
export interface AIPromptInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: (value: string, modelId: AITextModelId) => void | Promise<unknown>;
|
||||
isSubmitting?: boolean;
|
||||
placeholder?: string;
|
||||
helperText?: string;
|
||||
initialModelId?: AITextModelId;
|
||||
onModelChange?: (id: AITextModelId) => void;
|
||||
onAttachClick?: () => void;
|
||||
onVoiceClick?: () => void;
|
||||
}
|
||||
|
||||
const DAILY_LIMIT = 10;
|
||||
const DAILY_LIMIT_REASONING = 5;
|
||||
const DAILY_LIMIT_DEEPSEEK = Infinity; // Unlimited for DeepSeek
|
||||
|
||||
const MotionBox = motion(Box);
|
||||
|
||||
const getModelIcon = (model?: AITextModelOption, size = 14) => {
|
||||
if (!model) return null;
|
||||
const iconSize = size === 16 ? 20 : size; // Slightly larger for better visibility
|
||||
|
||||
if (model.provider === 'deepseek') {
|
||||
return <img src="/deepseek.svg" alt="DeepSeek" width={iconSize} height={iconSize} />;
|
||||
}
|
||||
if (model.provider === 'mistral') {
|
||||
if (model.id === 'mistral-small-latest') {
|
||||
return <img src="/mistral_small.png" alt="Mistral" width={iconSize} height={iconSize} />;
|
||||
}
|
||||
return <img src="/ministral.png" alt="Ministral" width={iconSize} height={iconSize} />;
|
||||
}
|
||||
if (model.provider === 'openrouter') {
|
||||
return <Globe2 size={size} />; // Keep Lucide icon for OpenRouter
|
||||
}
|
||||
if (model.provider === 'grok') {
|
||||
return <img src="/grok.svg" alt="Grok" width={iconSize} height={iconSize} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const isReasoningModel = (modelId: AITextModelId): boolean => {
|
||||
return modelId.includes('reasoning') || modelId.includes('reasoner');
|
||||
};
|
||||
|
||||
const AIPromptInput: React.FC<AIPromptInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
placeholder,
|
||||
helperText,
|
||||
initialModelId,
|
||||
onModelChange,
|
||||
onAttachClick,
|
||||
onVoiceClick,
|
||||
}) => {
|
||||
const models = useMemo<AITextModelOption[]>(() => getEnabledTextModels(), []);
|
||||
const defaultId = useMemo<AITextModelId>(() => initialModelId || getDefaultTextModelId(), [initialModelId]);
|
||||
const [selectedModel, setSelectedModel] = useState<AITextModelId>(defaultId);
|
||||
const [usageStatus, setUsageStatus] = useState<AIUsageStatus>({});
|
||||
const [isLoadingUsage, setIsLoadingUsage] = useState(false);
|
||||
|
||||
// Define getRemainingToday before using it in useState
|
||||
const getRemainingToday = useCallback((modelId: AITextModelId): number => {
|
||||
const modelStatus = usageStatus[modelId];
|
||||
if (!modelStatus) return 10; // Default fallback
|
||||
return modelStatus.unlimited ? -1 : modelStatus.remaining;
|
||||
}, [usageStatus]);
|
||||
|
||||
const [remaining, setRemaining] = useState<number>(() => getRemainingToday(defaultId));
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const minHeight = 96;
|
||||
const maxHeight = 260;
|
||||
|
||||
// Clean white styling for AI input
|
||||
const bg = useColorModeValue('white', 'gray.800');
|
||||
const innerBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const border = useColorModeValue('gray.200', 'gray.600');
|
||||
const placeholderColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const hoverBg = useColorModeValue('gray.100', 'gray.600');
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const {
|
||||
isOpen: isAttachOpen,
|
||||
onOpen: onAttachOpen,
|
||||
onClose: onAttachClose,
|
||||
} = useDisclosure();
|
||||
|
||||
const {
|
||||
isOpen: isVoiceOpen,
|
||||
onOpen: onVoiceOpen,
|
||||
onClose: onVoiceClose,
|
||||
} = useDisclosure();
|
||||
|
||||
const [ocrFiles, setOcrFiles] = useState<UploadPanelFile[]>([]);
|
||||
const [ocrText, setOcrText] = useState('');
|
||||
const [isOcrProcessing, setIsOcrProcessing] = useState(false);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const mediaStreamRef = useRef<MediaStream | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const recordingTimerRef = useRef<number | null>(null);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isVoiceProcessing, setIsVoiceProcessing] = useState(false);
|
||||
const [hasRecording, setHasRecording] = useState(false);
|
||||
const [recordingSeconds, setRecordingSeconds] = useState(0);
|
||||
const [waveform, setWaveform] = useState<number[]>(() => new Array(32).fill(0));
|
||||
|
||||
const adjustHeight = useCallback((reset?: boolean) => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
if (reset) {
|
||||
el.style.height = `${minHeight}px`;
|
||||
return;
|
||||
}
|
||||
el.style.height = 'auto';
|
||||
const next = Math.max(minHeight, Math.min(el.scrollHeight, maxHeight));
|
||||
el.style.height = `${next}px`;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
adjustHeight();
|
||||
}, [value, adjustHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => adjustHeight();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [adjustHeight]);
|
||||
|
||||
// Fetch usage status on mount and when model changes
|
||||
useEffect(() => {
|
||||
const fetchUsageStatus = async () => {
|
||||
setIsLoadingUsage(true);
|
||||
try {
|
||||
const status = await getAIUsageStatus();
|
||||
setUsageStatus(status);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch AI usage status:', error);
|
||||
} finally {
|
||||
setIsLoadingUsage(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUsageStatus();
|
||||
}, []);
|
||||
|
||||
// Update remaining when usage status or selected model changes
|
||||
useEffect(() => {
|
||||
setRemaining(getRemainingToday(selectedModel));
|
||||
}, [getRemainingToday, selectedModel]);
|
||||
|
||||
const getDailyLimit = (modelId: AITextModelId): number => {
|
||||
const modelStatus = usageStatus[modelId];
|
||||
if (!modelStatus) return 10; // Default fallback
|
||||
return modelStatus.limit;
|
||||
};
|
||||
|
||||
const handleModelChange = (id: AITextModelId) => {
|
||||
setSelectedModel(id);
|
||||
if (onModelChange) onModelChange(id);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
// Check remaining requests before submitting
|
||||
const modelStatus = usageStatus[selectedModel];
|
||||
if (modelStatus && !modelStatus.unlimited && modelStatus.remaining <= 0) {
|
||||
toast({
|
||||
title: 'Denní limit vyčerpán',
|
||||
description: 'Denní limit pro tento AI model byl vyčerpán. Zkuste to znovu zítra.',
|
||||
status: 'warning',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const maybePromise = onSubmit(trimmed, selectedModel);
|
||||
if (maybePromise && typeof (maybePromise as any).then === 'function') {
|
||||
await (maybePromise as Promise<unknown>);
|
||||
}
|
||||
// Refresh usage status after successful submission
|
||||
const status = await getAIUsageStatus();
|
||||
setUsageStatus(status);
|
||||
} catch {
|
||||
// Do not decrement usage on error; parent is responsible for showing errors
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (!isSubmitting) {
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const selected = findTextModel(selectedModel);
|
||||
|
||||
const isDisabled = isSubmitting || !value.trim() || remaining <= 0;
|
||||
|
||||
const handleInsertText = (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
const next = value ? `${value}\n\n${trimmed}` : trimmed;
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const handleOcrUploadFinished = async (uploaded: UploadPanelFile[]) => {
|
||||
if (!uploaded || uploaded.length === 0) return;
|
||||
const last = uploaded[uploaded.length - 1];
|
||||
if (!last?.url) return;
|
||||
setIsOcrProcessing(true);
|
||||
try {
|
||||
const isImage = (last.type || '').startsWith('image/');
|
||||
const resp = await processOcrAI({
|
||||
document_url: isImage ? '' : last.url,
|
||||
image_url: isImage ? last.url : '',
|
||||
model: 'mistral-ocr-latest',
|
||||
});
|
||||
setOcrText(resp.text || '');
|
||||
if ((resp.text || '').trim()) {
|
||||
handleInsertText(resp.text || '');
|
||||
toast({
|
||||
title: 'Text z dokumentu byl vložen do AI promptu',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'OCR nenašlo čitelný text',
|
||||
status: 'info',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: 'OCR selhalo',
|
||||
description: err?.message || 'Zkuste to prosím znovu.',
|
||||
status: 'error',
|
||||
duration: 4000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setIsOcrProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stopMediaStream = () => {
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getTracks().forEach((t) => t.stop());
|
||||
mediaStreamRef.current = null;
|
||||
}
|
||||
if (animationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
if (recordingTimerRef.current !== null) {
|
||||
window.clearInterval(recordingTimerRef.current);
|
||||
recordingTimerRef.current = null;
|
||||
}
|
||||
if (audioContextRef.current) {
|
||||
try {
|
||||
audioContextRef.current.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
audioContextRef.current = null;
|
||||
analyserRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
if (isRecording) return;
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
|
||||
toast({
|
||||
title: 'Prohlížeč nepodporuje nahrávání zvuku',
|
||||
status: 'error',
|
||||
duration: 4000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
mediaStreamRef.current = stream;
|
||||
audioChunksRef.current = [];
|
||||
setRecordingSeconds(0);
|
||||
|
||||
try {
|
||||
const AudioCtx =
|
||||
typeof window !== 'undefined'
|
||||
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
((window as any).AudioContext || (window as any).webkitAudioContext)
|
||||
: null;
|
||||
if (AudioCtx) {
|
||||
const ctx: AudioContext = audioContextRef.current || new AudioCtx();
|
||||
audioContextRef.current = ctx;
|
||||
const source = ctx.createMediaStreamSource(stream);
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
analyserRef.current = analyser;
|
||||
source.connect(analyser);
|
||||
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
|
||||
const tick = () => {
|
||||
if (!analyserRef.current) return;
|
||||
analyserRef.current.getByteTimeDomainData(dataArray);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < bufferLength; i += 1) {
|
||||
const v = (dataArray[i] - 128) / 128;
|
||||
sum += v * v;
|
||||
}
|
||||
const rms = Math.sqrt(sum / bufferLength);
|
||||
const level = Math.max(0, Math.min(1, rms * 4));
|
||||
setWaveform((prev) =>
|
||||
prev.map((_, idx) => {
|
||||
const jitter = 0.2 * Math.sin(Date.now() / 120 + idx);
|
||||
const value = level + jitter;
|
||||
return Math.max(0.1, Math.min(1, value));
|
||||
}),
|
||||
);
|
||||
animationFrameRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
if (animationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
animationFrameRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
} catch {
|
||||
// Vizualizace je best-effort, případné chyby ignorujeme
|
||||
}
|
||||
const recorder = new MediaRecorder(stream);
|
||||
mediaRecorderRef.current = recorder;
|
||||
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data && e.data.size > 0) {
|
||||
audioChunksRef.current.push(e.data);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
stopMediaStream();
|
||||
setIsRecording(false);
|
||||
setHasRecording(audioChunksRef.current.length > 0);
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
setIsRecording(true);
|
||||
setHasRecording(false);
|
||||
if (recordingTimerRef.current !== null) {
|
||||
window.clearInterval(recordingTimerRef.current);
|
||||
}
|
||||
recordingTimerRef.current = window.setInterval(() => {
|
||||
setRecordingSeconds((prev) => prev + 1);
|
||||
}, 1000);
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: 'Nelze získat přístup k mikrofonu',
|
||||
description: err?.message || 'Zkontrolujte oprávnění prohlížeče.',
|
||||
status: 'error',
|
||||
duration: 4000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (!isRecording) return;
|
||||
try {
|
||||
mediaRecorderRef.current?.stop();
|
||||
} catch {
|
||||
stopMediaStream();
|
||||
setIsRecording(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTranscribe = async () => {
|
||||
if (!audioChunksRef.current.length) {
|
||||
toast({
|
||||
title: 'Nejprve nahrajte hlasovou zprávu',
|
||||
status: 'info',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
|
||||
const file = new File([blob], 'voice-message.webm', { type: 'audio/webm' });
|
||||
setIsVoiceProcessing(true);
|
||||
try {
|
||||
const uploaded = await uploadArticleFile(file as any);
|
||||
const resp = await transcribeAudioAI({
|
||||
file_url: uploaded.url,
|
||||
model: 'voxtral-mini-latest',
|
||||
language: 'cs',
|
||||
});
|
||||
if ((resp.text || '').trim()) {
|
||||
handleInsertText(resp.text || '');
|
||||
toast({
|
||||
title: 'Přepis hlasu vložen do AI promptu',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
onVoiceClose();
|
||||
} else {
|
||||
toast({
|
||||
title: 'Přepis neobsahuje text',
|
||||
status: 'info',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: 'Přepis hlasu selhal',
|
||||
description: err?.message || 'Zkuste to prosím znovu.',
|
||||
status: 'error',
|
||||
duration: 4000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setIsVoiceProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box w="100%" py={2}>
|
||||
<Box
|
||||
bg={bg}
|
||||
borderRadius="2xl"
|
||||
p={3}
|
||||
borderWidth="1px"
|
||||
borderColor={border}
|
||||
boxShadow={useColorModeValue('sm', 'dark-lg')}
|
||||
_hover={{ borderColor: useColorModeValue('gray.300', 'gray.500') }}
|
||||
transition="all 0.2s ease"
|
||||
>
|
||||
<Box position="relative">
|
||||
<Flex direction="column">
|
||||
<Box overflowY="auto" maxH="360px">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder || 'Napište, s čím má AI pomoci...'}
|
||||
resize="none"
|
||||
border="none"
|
||||
bg={innerBg}
|
||||
borderRadius="xl"
|
||||
px={4}
|
||||
py={3}
|
||||
minH={`${minHeight}px`}
|
||||
_focusVisible={{ boxShadow: 'none', bg: useColorModeValue('white', 'gray.700') }}
|
||||
_placeholder={{ color: placeholderColor }}
|
||||
fontSize="sm"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
h="60px"
|
||||
bg={innerBg}
|
||||
borderBottomRadius="xl"
|
||||
borderTop="1px solid"
|
||||
borderColor={border}
|
||||
mt={-1}
|
||||
_hover={{ bg: hoverBg }}
|
||||
transition="all 0.2s ease"
|
||||
>
|
||||
<Flex
|
||||
position="absolute"
|
||||
left={3}
|
||||
right={3}
|
||||
bottom={2}
|
||||
align="center"
|
||||
justify="space-between"
|
||||
>
|
||||
<HStack spacing={2} align="center">
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="gray"
|
||||
aria-label="Vybrat AI model"
|
||||
px={2}
|
||||
_hover={{ bg: hoverBg }}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
<MotionBox
|
||||
key={selectedModel}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 4 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Box bg="blue.50" p={1} borderRadius="md">
|
||||
{getModelIcon(selected, 14)}
|
||||
</Box>
|
||||
<Text fontSize="xs" fontWeight="600" color="gray.700">
|
||||
{selected?.label || 'Model'}
|
||||
</Text>
|
||||
<FiChevronDown size={12} color="gray.500" />
|
||||
</MotionBox>
|
||||
</AnimatePresence>
|
||||
</MenuButton>
|
||||
<MenuList fontSize="sm" zIndex={1500}>
|
||||
{models.map((model) => {
|
||||
const rem = getRemainingToday(model.id as AITextModelId);
|
||||
return (
|
||||
<MenuItem
|
||||
key={model.id}
|
||||
onClick={() => handleModelChange(model.id)}
|
||||
>
|
||||
<HStack justify="space-between" w="100%" spacing={3}>
|
||||
<Box>
|
||||
<HStack spacing={2} align="center">
|
||||
<Box bg="gray.50" p={1} borderRadius="md">
|
||||
{getModelIcon(model, 16)}
|
||||
</Box>
|
||||
<Text fontWeight="600" color="gray.800">{model.label}</Text>
|
||||
</HStack>
|
||||
{model.description && (
|
||||
<Text fontSize="xs" color="gray.600" mt={1}>
|
||||
{model.description}
|
||||
</Text>
|
||||
)}
|
||||
{isReasoningModel(model.id) && (
|
||||
<Text fontSize="xs" color="blue.600" mt={1} fontWeight="medium">
|
||||
Reasoning model - přemýšlí před odpovědí
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<HStack spacing={2} align="center">
|
||||
{isLoadingUsage ? (
|
||||
<Spinner size="xs" color="gray.500" />
|
||||
) : (
|
||||
<Text fontSize="xs" color="gray.600" fontWeight="500">
|
||||
{model.provider === 'deepseek' ? '∞' : `${Math.max(0, rem)}/${getDailyLimit(model.id)}`}
|
||||
</Text>
|
||||
)}
|
||||
{selectedModel === model.id && (
|
||||
<Box bg="green.100" p={1} borderRadius="full">
|
||||
<FiCheck size={12} color="green.600" />
|
||||
</Box>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
|
||||
<Box h="4" w="1px" bg={border} mx={1} />
|
||||
|
||||
{/* Reasoning model warning */}
|
||||
{isReasoningModel(selectedModel) && (
|
||||
<Tooltip label="Reasoning modely přemýšlí nad odpovědí - poskytují hlubší analýzu, ale mohou trvat déle">
|
||||
<Text fontSize="xs" color="blue.600" px={2} py={1} bg="blue.50" borderRadius="md" fontWeight="medium">
|
||||
Reasoning
|
||||
</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip label="Přiložit soubor (obrázek / PDF)">
|
||||
<IconButton
|
||||
aria-label="Přiložit soubor"
|
||||
icon={<FiPaperclip />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="gray"
|
||||
_hover={{ bg: hoverBg }}
|
||||
onClick={() => {
|
||||
if (onAttachClick) {
|
||||
onAttachClick();
|
||||
} else {
|
||||
setOcrFiles([]);
|
||||
setOcrText('');
|
||||
onAttachOpen();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Nahrát hlas pro přepis">
|
||||
<IconButton
|
||||
aria-label="Nahrát hlas"
|
||||
icon={<FiMic />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="gray"
|
||||
_hover={{ bg: hoverBg }}
|
||||
onClick={() => {
|
||||
if (onVoiceClick) {
|
||||
onVoiceClick();
|
||||
} else {
|
||||
audioChunksRef.current = [];
|
||||
setHasRecording(false);
|
||||
setIsVoiceProcessing(false);
|
||||
onVoiceOpen();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{helperText && (
|
||||
<Text fontSize="xs" color="gray.500" ml={2} noOfLines={1}>
|
||||
{helperText}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<HStack spacing={3} align="center">
|
||||
{isLoadingUsage ? (
|
||||
<Spinner size="xs" color="gray.500" />
|
||||
) : (
|
||||
<Text fontSize="xs" color="gray.600" fontWeight="500">
|
||||
{selected?.provider === 'deepseek' ? '∞' : `Zbývá ${getDailyLimit(selectedModel) === Infinity ? '∞' : `${remaining}/${getDailyLimit(selectedModel)}`}`}
|
||||
</Text>
|
||||
)}
|
||||
<Tooltip
|
||||
label={
|
||||
remaining <= 0
|
||||
? 'Denní limit pro tento model byl vyčerpán.'
|
||||
: 'Odeslat (Enter). Shift+Enter vloží nový řádek.'
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<IconButton
|
||||
aria-label="Odeslat AI požadavek"
|
||||
icon={<FiSend />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
_hover={{ bg: 'blue.50' }}
|
||||
isLoading={isSubmitting && !isLoadingUsage}
|
||||
isDisabled={isDisabled}
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Modal isOpen={isAttachOpen} onClose={onAttachClose} isCentered size="lg">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Přiložit soubor pro OCR</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<UploadPanel
|
||||
label="Soubor pro AI (PDF / obrázek)"
|
||||
description="Nahrajte dokument nebo obrázek, ze kterého má AI přečíst text."
|
||||
value={ocrFiles}
|
||||
onChange={setOcrFiles}
|
||||
accept="application/pdf,image/*"
|
||||
maxFiles={1}
|
||||
onUploadFinished={handleOcrUploadFinished}
|
||||
/>
|
||||
{isOcrProcessing && (
|
||||
<HStack mt={4} spacing={2} align="center">
|
||||
<Spinner size="sm" />
|
||||
<Text fontSize="sm">Probíhá zpracování textu…</Text>
|
||||
</HStack>
|
||||
)}
|
||||
{!!ocrText && !isOcrProcessing && (
|
||||
<Box mt={4}>
|
||||
<Text fontSize="sm" fontWeight="semibold" mb={2}>
|
||||
Rozpoznaný text
|
||||
</Text>
|
||||
<Box
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
borderColor={border}
|
||||
bg={innerBg}
|
||||
p={3}
|
||||
maxH="200px"
|
||||
overflowY="auto"
|
||||
fontSize="sm"
|
||||
whiteSpace="pre-wrap"
|
||||
>
|
||||
{ocrText}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack spacing={3}>
|
||||
<Button variant="ghost" onClick={onAttachClose}>
|
||||
Zavřít
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<Modal isOpen={isVoiceOpen} onClose={() => {
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
}
|
||||
onVoiceClose();
|
||||
}} isCentered size="md">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Hlasový vstup pro AI</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<Text fontSize="sm" mb={3}>
|
||||
Nahrajte krátkou hlasovou zprávu, kterou AI přepíše do textu a vloží do promptu.
|
||||
</Text>
|
||||
<HStack spacing={4} align="center">
|
||||
<Button
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
isDisabled={isVoiceProcessing}
|
||||
bg={isRecording ? 'red.500' : 'blackAlpha.700'}
|
||||
color="white"
|
||||
_hover={{
|
||||
bg: isRecording ? 'red.600' : 'blackAlpha.800',
|
||||
}}
|
||||
_active={{
|
||||
bg: isRecording ? 'red.700' : 'blackAlpha.900',
|
||||
}}
|
||||
>
|
||||
{isRecording ? 'Zastavit nahrávání' : 'Začít nahrávat'}
|
||||
</Button>
|
||||
{hasRecording && !isRecording && (
|
||||
<Text fontSize="sm" color="green.500">
|
||||
Nahrávka je připravena k přepisu.
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
{isRecording && (
|
||||
<Box mt={4}>
|
||||
<HStack justify="space-between" mb={1}>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
Nahrávání…
|
||||
</Text>
|
||||
<Text fontSize="xs" fontFamily="mono" color="gray.600">
|
||||
{`${String(Math.floor(recordingSeconds / 60)).padStart(2, '0')}:${String(
|
||||
recordingSeconds % 60,
|
||||
).padStart(2, '0')}`}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={1} align="flex-end" h="40px">
|
||||
{waveform.map((value, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
w="2px"
|
||||
borderRadius="full"
|
||||
bg="green.400"
|
||||
h={`${Math.max(8, value * 100)}%`}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
{isVoiceProcessing && (
|
||||
<HStack mt={4} spacing={2} align="center">
|
||||
<Spinner size="sm" />
|
||||
<Text fontSize="sm">Probíhá přepis hlasu…</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack spacing={3}>
|
||||
<Button
|
||||
onClick={handleTranscribe}
|
||||
isDisabled={!hasRecording || isRecording || isVoiceProcessing}
|
||||
isLoading={isVoiceProcessing}
|
||||
bg="blackAlpha.800"
|
||||
color="white"
|
||||
_hover={{ bg: 'blackAlpha.900' }}
|
||||
_active={{ bg: 'blackAlpha.900' }}
|
||||
>
|
||||
Přepsat a vložit do promptu
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
}
|
||||
onVoiceClose();
|
||||
}}
|
||||
>
|
||||
Zavřít
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIPromptInput;
|
||||
@@ -0,0 +1,585 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
HStack,
|
||||
IconButton,
|
||||
Input,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
VStack,
|
||||
Slider,
|
||||
SliderTrack,
|
||||
SliderFilledTrack,
|
||||
SliderThumb,
|
||||
} from '@chakra-ui/react';
|
||||
import { BsEyedropper } from 'react-icons/bs';
|
||||
|
||||
type Hsva = { h: number; s: number; v: number; a: number };
|
||||
|
||||
type ColorPickerProps = {
|
||||
value: string;
|
||||
onChange: (hex: string) => void;
|
||||
onChangeComplete?: (hex: string) => void;
|
||||
label?: React.ReactNode;
|
||||
showAlpha?: boolean;
|
||||
showEyeDropper?: boolean;
|
||||
recentStorageKey?: string;
|
||||
onInteractionStart?: () => void;
|
||||
onInteractionEnd?: () => void;
|
||||
compact?: boolean;
|
||||
hideHistory?: boolean;
|
||||
};
|
||||
|
||||
type ColorHistory = {
|
||||
manual: string[];
|
||||
recent: string[];
|
||||
};
|
||||
|
||||
const DEFAULT_HISTORY: ColorHistory = { manual: [], recent: [] };
|
||||
const GLOBAL_KEY = 'myclub-colorpicker-v1';
|
||||
|
||||
function clamp01(x: number) {
|
||||
return Math.min(1, Math.max(0, x));
|
||||
}
|
||||
|
||||
function parseHex(input: string): { r: number; g: number; b: number; a: number } | null {
|
||||
if (!input) return null;
|
||||
let h = input.trim();
|
||||
if (!h.startsWith('#')) h = `#${h}`;
|
||||
h = h.slice(1);
|
||||
if (![3, 4, 6, 8].includes(h.length)) return null;
|
||||
if (h.length === 3 || h.length === 4) {
|
||||
h = h
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('');
|
||||
}
|
||||
const hasAlpha = h.length === 8;
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
const a = hasAlpha ? parseInt(h.slice(6, 8), 16) / 255 : 1;
|
||||
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b) || Number.isNaN(a)) return null;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
function rgbaToHex(r: number, g: number, b: number, a: number) {
|
||||
const toHex = (n: number) => Math.round(Math.min(255, Math.max(0, n))).toString(16).padStart(2, '0');
|
||||
const hr = toHex(r);
|
||||
const hg = toHex(g);
|
||||
const hb = toHex(b);
|
||||
const ha = toHex(a * 255);
|
||||
if (a >= 0.999) return `#${hr}${hg}${hb}`;
|
||||
return `#${hr}${hg}${hb}${ha}`;
|
||||
}
|
||||
|
||||
function hexToHsva(hex: string): Hsva {
|
||||
const parsed = parseHex(hex) || { r: 255, g: 0, b: 0, a: 1 };
|
||||
const r = parsed.r / 255;
|
||||
const g = parsed.g / 255;
|
||||
const b = parsed.b / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const d = max - min;
|
||||
let h = 0;
|
||||
const v = max;
|
||||
const s = max === 0 ? 0 : d / max;
|
||||
if (d !== 0) {
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) * 60;
|
||||
break;
|
||||
case b:
|
||||
default:
|
||||
h = ((r - g) / d + 4) * 60;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(h)) h = 0;
|
||||
return { h, s, v, a: parsed.a };
|
||||
}
|
||||
|
||||
function hsvaToHex(c: Hsva): string {
|
||||
const h = ((c.h % 360) + 360) % 360;
|
||||
const s = clamp01(c.s);
|
||||
const v = clamp01(c.v);
|
||||
const a = clamp01(c.a);
|
||||
const C = v * s;
|
||||
const X = C * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = v - C;
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
if (h < 60) {
|
||||
r = C;
|
||||
g = X;
|
||||
b = 0;
|
||||
} else if (h < 120) {
|
||||
r = X;
|
||||
g = C;
|
||||
b = 0;
|
||||
} else if (h < 180) {
|
||||
r = 0;
|
||||
g = C;
|
||||
b = X;
|
||||
} else if (h < 240) {
|
||||
r = 0;
|
||||
g = X;
|
||||
b = C;
|
||||
} else if (h < 300) {
|
||||
r = X;
|
||||
g = 0;
|
||||
b = C;
|
||||
} else {
|
||||
r = C;
|
||||
g = 0;
|
||||
b = X;
|
||||
}
|
||||
const rr = (r + m) * 255;
|
||||
const gg = (g + m) * 255;
|
||||
const bb = (b + m) * 255;
|
||||
return rgbaToHex(rr, gg, bb, a);
|
||||
}
|
||||
|
||||
function loadHistory(key?: string): ColorHistory {
|
||||
if (typeof window === 'undefined') return DEFAULT_HISTORY;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key || GLOBAL_KEY);
|
||||
if (!raw) return DEFAULT_HISTORY;
|
||||
const parsed = JSON.parse(raw) as ColorHistory;
|
||||
if (!parsed || typeof parsed !== 'object') return DEFAULT_HISTORY;
|
||||
return {
|
||||
manual: Array.isArray(parsed.manual) ? parsed.manual : [],
|
||||
recent: Array.isArray(parsed.recent) ? parsed.recent : [],
|
||||
};
|
||||
} catch {
|
||||
return DEFAULT_HISTORY;
|
||||
}
|
||||
}
|
||||
|
||||
function saveHistory(history: ColorHistory, key?: string) {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(key || GLOBAL_KEY, JSON.stringify(history));
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function pushRecentColor(history: ColorHistory, hex: string, limit = 10): ColorHistory {
|
||||
const color = (hex || '').toLowerCase();
|
||||
if (!color) return history;
|
||||
const recent = [color, ...history.recent.filter((c) => c !== color)];
|
||||
return { ...history, recent: recent.slice(0, limit) };
|
||||
}
|
||||
|
||||
function pushManualColor(history: ColorHistory, hex: string): ColorHistory {
|
||||
const color = (hex || '').toLowerCase();
|
||||
if (!color) return history;
|
||||
if (history.manual.includes(color)) return history;
|
||||
return { ...history, manual: [...history.manual, color] };
|
||||
}
|
||||
|
||||
export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onChangeComplete,
|
||||
label,
|
||||
showAlpha = false,
|
||||
showEyeDropper = true,
|
||||
recentStorageKey,
|
||||
onInteractionStart,
|
||||
onInteractionEnd,
|
||||
compact = false,
|
||||
hideHistory = false,
|
||||
}) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.700', 'gray.100');
|
||||
const subtleText = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
const [hsva, setHsva] = useState<Hsva>(() => hexToHsva(value || '#ff0000'));
|
||||
const [hex, setHex] = useState<string>(() => hsvaToHex(hsva));
|
||||
const [hexInput, setHexInput] = useState<string>(hex.toUpperCase());
|
||||
const [isEditingHex, setIsEditingHex] = useState(false);
|
||||
const [history, setHistory] = useState<ColorHistory>(() => loadHistory(recentStorageKey));
|
||||
const [eyeDropperAvailable, setEyeDropperAvailable] = useState(false);
|
||||
|
||||
const svRef = useRef<HTMLDivElement | null>(null);
|
||||
const latestHsvaRef = useRef<Hsva>(hsva);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && (window as any).EyeDropper) {
|
||||
setEyeDropperAvailable(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const normalized = hsvaToHex(hexToHsva(value || hex));
|
||||
setHsva(hexToHsva(normalized));
|
||||
setHex(normalized);
|
||||
if (!isEditingHex) setHexInput(normalized.toUpperCase());
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
latestHsvaRef.current = hsva;
|
||||
}, [hsva]);
|
||||
|
||||
useEffect(() => {
|
||||
setHistory(loadHistory(recentStorageKey));
|
||||
}, [recentStorageKey]);
|
||||
|
||||
const emitChange = useCallback(
|
||||
(nextHsva: Hsva, { complete, addToRecent }: { complete?: boolean; addToRecent?: boolean } = {}) => {
|
||||
const nextHex = hsvaToHex(nextHsva);
|
||||
setHsva(nextHsva);
|
||||
setHex(nextHex);
|
||||
if (!isEditingHex) setHexInput(nextHex.toUpperCase());
|
||||
onChange(nextHex);
|
||||
if (addToRecent) {
|
||||
setHistory((prev) => {
|
||||
const updated = pushRecentColor(prev, nextHex);
|
||||
saveHistory(updated, recentStorageKey);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
if (complete && onChangeComplete) onChangeComplete(nextHex);
|
||||
},
|
||||
[isEditingHex, onChange, onChangeComplete, recentStorageKey],
|
||||
);
|
||||
|
||||
const handleSvPointer = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
const rect = svRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const x = clamp01((clientX - rect.left) / rect.width);
|
||||
const y = clamp01((clientY - rect.top) / rect.height);
|
||||
const s = x;
|
||||
const v = 1 - y;
|
||||
const base = latestHsvaRef.current;
|
||||
emitChange({ ...base, s, v }, { addToRecent: false });
|
||||
},
|
||||
[emitChange],
|
||||
);
|
||||
|
||||
const handleSvPointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
onInteractionStart?.();
|
||||
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||
handleSvPointer(e.clientX, e.clientY);
|
||||
|
||||
let rafId: number | undefined;
|
||||
const move = (ev: PointerEvent) => {
|
||||
// Use requestAnimationFrame for smooth 60fps updates
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(() => {
|
||||
handleSvPointer(ev.clientX, ev.clientY);
|
||||
});
|
||||
};
|
||||
const up = (ev: PointerEvent) => {
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = undefined;
|
||||
}
|
||||
handleSvPointer(ev.clientX, ev.clientY);
|
||||
window.removeEventListener('pointermove', move);
|
||||
window.removeEventListener('pointerup', up);
|
||||
const latest = latestHsvaRef.current;
|
||||
emitChange(latest, { complete: true, addToRecent: true });
|
||||
onInteractionEnd?.();
|
||||
};
|
||||
window.addEventListener('pointermove', move);
|
||||
window.addEventListener('pointerup', up);
|
||||
},
|
||||
[handleSvPointer, emitChange, hsva, onInteractionEnd, onInteractionStart],
|
||||
);
|
||||
|
||||
const handleHueChange = useCallback((next: number) => {
|
||||
emitChange({ ...hsva, h: next }, { addToRecent: false });
|
||||
}, [hsva, emitChange]);
|
||||
|
||||
const handleHueChangeStart = () => {
|
||||
onInteractionStart?.();
|
||||
};
|
||||
|
||||
const handleHueChangeEnd = useCallback((next: number) => {
|
||||
emitChange({ ...hsva, h: next }, { complete: true, addToRecent: true });
|
||||
onInteractionEnd?.();
|
||||
}, [hsva, emitChange, onInteractionEnd]);
|
||||
|
||||
const handleAlphaChange = useCallback((next: number) => {
|
||||
emitChange({ ...hsva, a: clamp01(next / 100) }, { addToRecent: false });
|
||||
}, [hsva, emitChange]);
|
||||
|
||||
const handleAlphaChangeEnd = useCallback((next: number) => {
|
||||
emitChange({ ...hsva, a: clamp01(next / 100) }, { complete: true, addToRecent: true });
|
||||
}, [hsva, emitChange]);
|
||||
|
||||
const handleHexInputBlur = () => {
|
||||
setIsEditingHex(false);
|
||||
const parsed = parseHex(hexInput);
|
||||
if (!parsed) {
|
||||
setHexInput(hex.toUpperCase());
|
||||
return;
|
||||
}
|
||||
const nextHsva = hexToHsva(hexInput);
|
||||
emitChange(nextHsva, { complete: true, addToRecent: true });
|
||||
};
|
||||
|
||||
const handleSaveManual = () => {
|
||||
setHistory((prev) => {
|
||||
const updated = pushManualColor(prev, hex);
|
||||
saveHistory(updated, recentStorageKey);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handlePickFromScreen = async () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!eyeDropperAvailable || !(window as any).EyeDropper) return;
|
||||
try {
|
||||
onInteractionStart?.();
|
||||
const EyeDropperCtor = (window as any).EyeDropper;
|
||||
const eyeDropper = new EyeDropperCtor();
|
||||
const result = await eyeDropper.open();
|
||||
const picked = result?.sRGBHex as string | undefined;
|
||||
if (picked) {
|
||||
const base = latestHsvaRef.current;
|
||||
const pickedHsva = hexToHsva(picked);
|
||||
const nextHsva = { ...pickedHsva, a: base.a };
|
||||
emitChange(nextHsva, { complete: true, addToRecent: true });
|
||||
}
|
||||
} catch {
|
||||
} finally {
|
||||
onInteractionEnd?.();
|
||||
}
|
||||
};
|
||||
|
||||
const svBackground = useMemo(
|
||||
() => `linear-gradient(to top, black, transparent), linear-gradient(to right, white, hsl(${hsva.h}, 100%, 50%))`,
|
||||
[hsva.h],
|
||||
);
|
||||
|
||||
const svPointerStyle = useMemo(() => {
|
||||
const x = hsva.s * 100;
|
||||
const y = (1 - hsva.v) * 100;
|
||||
return { left: `${x}%`, top: `${y}%` };
|
||||
}, [hsva.s, hsva.v]);
|
||||
|
||||
const alphaPercent = Math.round(hsva.a * 100);
|
||||
|
||||
const currentHex = hex;
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
p={compact ? 2 : 3}
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
boxShadow="sm"
|
||||
>
|
||||
{label ? (
|
||||
<Text fontSize="sm" fontWeight="medium" mb={2} color={textColor}>
|
||||
{label}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Box
|
||||
ref={svRef}
|
||||
position="relative"
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
height={compact ? '110px' : '140px'}
|
||||
cursor="crosshair"
|
||||
backgroundImage={svBackground}
|
||||
onPointerDown={handleSvPointerDown}
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
width="16px"
|
||||
height="16px"
|
||||
borderRadius="full"
|
||||
borderWidth="2px"
|
||||
borderColor="white"
|
||||
boxShadow="0 0 0 1px rgba(0,0,0,0.4)"
|
||||
transform="translate(-50%, -50%)"
|
||||
style={svPointerStyle}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box mt={3}>
|
||||
<Slider
|
||||
aria-label="Hue"
|
||||
min={0}
|
||||
max={360}
|
||||
value={hsva.h}
|
||||
onChange={handleHueChange}
|
||||
onChangeStart={handleHueChangeStart}
|
||||
onChangeEnd={handleHueChangeEnd}
|
||||
size="sm"
|
||||
>
|
||||
<SliderTrack
|
||||
bg="linear-gradient(to right, red, #ff0, #0f0, #0ff, #00f, #f0f, red)"
|
||||
h="8px"
|
||||
borderRadius="full"
|
||||
>
|
||||
<SliderFilledTrack bg="transparent" />
|
||||
</SliderTrack>
|
||||
<SliderThumb boxSize={4} borderWidth="2px" borderColor="white" />
|
||||
</Slider>
|
||||
</Box>
|
||||
|
||||
{showAlpha && (
|
||||
<Box mt={3}>
|
||||
<Slider
|
||||
aria-label="Opacity"
|
||||
min={0}
|
||||
max={100}
|
||||
value={alphaPercent}
|
||||
onChange={handleAlphaChange}
|
||||
onChangeEnd={handleAlphaChangeEnd}
|
||||
size="sm"
|
||||
>
|
||||
<SliderTrack
|
||||
bg={`linear-gradient(to right, transparent, ${currentHex})`}
|
||||
h="8px"
|
||||
borderRadius="full"
|
||||
>
|
||||
<SliderFilledTrack bg="transparent" />
|
||||
</SliderTrack>
|
||||
<SliderThumb boxSize={4} borderWidth="2px" borderColor="white" />
|
||||
</Slider>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<HStack spacing={2} mt={3} align="center">
|
||||
<Box
|
||||
width="32px"
|
||||
height="32px"
|
||||
borderRadius="full"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
bg={currentHex}
|
||||
/>
|
||||
<Input
|
||||
value={hexInput}
|
||||
onChange={(e) => {
|
||||
setIsEditingHex(true);
|
||||
setHexInput(e.target.value);
|
||||
}}
|
||||
onBlur={handleHexInputBlur}
|
||||
size="sm"
|
||||
fontFamily="mono"
|
||||
maxW="130px"
|
||||
/>
|
||||
{showAlpha && (
|
||||
<Input
|
||||
value={`${alphaPercent}%`}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.replace(/[^0-9]/g, '');
|
||||
const n = Math.max(0, Math.min(100, parseInt(raw || '0', 10)));
|
||||
emitChange({ ...hsva, a: n / 100 }, { addToRecent: false });
|
||||
}}
|
||||
onBlur={() => handleAlphaChangeEnd(alphaPercent)}
|
||||
size="sm"
|
||||
maxW="80px"
|
||||
/>
|
||||
)}
|
||||
{showEyeDropper && (
|
||||
<Tooltip
|
||||
label={
|
||||
eyeDropperAvailable
|
||||
? 'Vybrat barvu z obrazovky'
|
||||
: 'Pipeta není v tomto prohlížeči podporována.'
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
aria-label="Vybrat barvu z obrazovky"
|
||||
icon={<BsEyedropper />}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={eyeDropperAvailable ? handlePickFromScreen : undefined}
|
||||
isDisabled={!eyeDropperAvailable}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{!hideHistory && (
|
||||
<VStack align="stretch" spacing={2} mt={3}>
|
||||
{history.manual.length > 0 && (
|
||||
<Box>
|
||||
<Text fontSize="xs" mb={1} color={subtleText}>
|
||||
Uložené barvy
|
||||
</Text>
|
||||
<SimpleGrid columns={8} spacing={1}>
|
||||
{history.manual.map((c) => (
|
||||
<Box
|
||||
key={`manual-${c}`}
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
emitChange(hexToHsva(c), { complete: true, addToRecent: true });
|
||||
}}
|
||||
width="18px"
|
||||
height="18px"
|
||||
borderRadius="full"
|
||||
borderWidth={currentHex.toLowerCase() === c ? '2px' : '1px'}
|
||||
borderColor={currentHex.toLowerCase() === c ? 'blue.500' : borderColor}
|
||||
bg={c}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{history.recent.length > 0 && (
|
||||
<Box>
|
||||
<Text fontSize="xs" mb={1} color={subtleText}>
|
||||
Poslední barvy
|
||||
</Text>
|
||||
<SimpleGrid columns={8} spacing={1}>
|
||||
{history.recent.map((c) => (
|
||||
<Box
|
||||
key={`recent-${c}`}
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
emitChange(hexToHsva(c), { complete: true, addToRecent: true });
|
||||
}}
|
||||
width="16px"
|
||||
height="16px"
|
||||
borderRadius="full"
|
||||
borderWidth={currentHex.toLowerCase() === c ? '2px' : '1px'}
|
||||
borderColor={currentHex.toLowerCase() === c ? 'blue.500' : borderColor}
|
||||
bg={c}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
mt={1}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSaveManual}
|
||||
>
|
||||
+ Přidat barvu
|
||||
</Button>
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorPicker;
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, HStack, Input, Popover, PopoverTrigger, PopoverContent, PopoverBody, useColorModeValue } from '@chakra-ui/react';
|
||||
import ColorPicker from './ColorPicker';
|
||||
|
||||
export type ColorPickerPopoverProps = {
|
||||
value: string;
|
||||
onChange: (hex: string) => void;
|
||||
recentStorageKey?: string;
|
||||
};
|
||||
|
||||
const ColorPickerPopover: React.FC<ColorPickerPopoverProps> = ({ value, onChange, recentStorageKey }) => {
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const inputBg = useColorModeValue('white', 'gray.800');
|
||||
const [internal, setInternal] = useState<string>(value || '#000000');
|
||||
|
||||
useEffect(() => {
|
||||
setInternal(value || '#000000');
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
placement="bottom-start"
|
||||
closeOnBlur
|
||||
closeOnEsc
|
||||
autoFocus={false}
|
||||
returnFocusOnClose={false}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<HStack
|
||||
spacing={2}
|
||||
cursor="pointer"
|
||||
align="center"
|
||||
>
|
||||
<Box
|
||||
width="32px"
|
||||
height="32px"
|
||||
borderRadius="full"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
bg={value || '#000000'}
|
||||
/>
|
||||
<Input
|
||||
value={(value || '').toUpperCase()}
|
||||
isReadOnly
|
||||
size="sm"
|
||||
maxW="120px"
|
||||
fontFamily="mono"
|
||||
bg={inputBg}
|
||||
/>
|
||||
</HStack>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent w="auto" maxW="280px" _focus={{ boxShadow: 'none' }}>
|
||||
<PopoverBody p={3}>
|
||||
<ColorPicker
|
||||
value={internal}
|
||||
onChange={(hex) => {
|
||||
setInternal(hex);
|
||||
onChange(hex);
|
||||
}}
|
||||
onChangeComplete={(hex) => {
|
||||
setInternal(hex);
|
||||
onChange(hex);
|
||||
}}
|
||||
recentStorageKey={recentStorageKey}
|
||||
compact
|
||||
/>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorPickerPopover;
|
||||
@@ -21,6 +21,22 @@ import {
|
||||
ButtonGroup,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Drawer,
|
||||
DrawerOverlay,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerCloseButton,
|
||||
DrawerBody,
|
||||
DrawerFooter,
|
||||
Textarea,
|
||||
AlertDialog,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogCloseButton,
|
||||
AlertDialogBody,
|
||||
AlertDialogFooter,
|
||||
Checkbox,
|
||||
} from '@chakra-ui/react';
|
||||
import ReactQuill from 'react-quill';
|
||||
import ReactCrop, { Crop } from 'react-image-crop';
|
||||
@@ -76,6 +92,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
const quillRef = useRef<ReactQuill | null>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement | null>(null);
|
||||
const onChangeRef = useRef(onChange);
|
||||
const changeDebounceRef = useRef<number | null>(null);
|
||||
const lastContentRef = useRef<string>('');
|
||||
const selectedImageIdRef = useRef<string | null>(null);
|
||||
const selectImageByIdRef = useRef<(id: string) => void>(() => {});
|
||||
const toolbarDragRef = useRef<{ active: boolean; startX: number; startY: number; startLeft: number; startTop: number }>({ active: false, startX: 0, startY: 0, startLeft: 0, startTop: 0 });
|
||||
@@ -91,6 +109,16 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
// Cleanup debounced change handler on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (changeDebounceRef.current !== null) {
|
||||
window.clearTimeout(changeDebounceRef.current);
|
||||
changeDebounceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Crop modal state
|
||||
const [cropOpen, setCropOpen] = useState(false);
|
||||
@@ -108,6 +136,18 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
const linkRangeRef = useRef<{ index: number; length: number } | null>(null);
|
||||
|
||||
const [isHtmlEditorOpen, setIsHtmlEditorOpen] = useState(false);
|
||||
const [htmlSource, setHtmlSource] = useState('');
|
||||
const htmlRangeRef = useRef<{ index: number; length: number } | null>(null);
|
||||
const htmlSwitchingRef = useRef(false);
|
||||
const [MonacoComponent, setMonacoComponent] = useState<React.ComponentType<any> | null>(null);
|
||||
const [htmlEditMode, setHtmlEditMode] = useState<'block' | 'document'>('block');
|
||||
const [isFullHtmlConfirmOpen, setIsFullHtmlConfirmOpen] = useState(false);
|
||||
const confirmCancelRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [confirmDontAsk, setConfirmDontAsk] = useState(false);
|
||||
const pendingSanitizedRef = useRef<string>('');
|
||||
const pendingRawRef = useRef<string>('');
|
||||
|
||||
// Force white mode for better readability in admin
|
||||
const borderColor = 'gray.200';
|
||||
const bgColor = 'white';
|
||||
@@ -201,6 +241,32 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(content: string, _delta: any, source: string) => {
|
||||
if (readOnly) return;
|
||||
if (source !== 'user') return;
|
||||
|
||||
const cleaned = cleanEditorHTML(content || '');
|
||||
|
||||
if (cleaned === lastContentRef.current) return;
|
||||
lastContentRef.current = cleaned;
|
||||
|
||||
if (changeDebounceRef.current !== null) {
|
||||
window.clearTimeout(changeDebounceRef.current);
|
||||
}
|
||||
|
||||
changeDebounceRef.current = window.setTimeout(() => {
|
||||
changeDebounceRef.current = null;
|
||||
try {
|
||||
onChangeRef.current(cleaned);
|
||||
} catch (e) {
|
||||
console.error('CustomRichEditor onChange error', e);
|
||||
}
|
||||
}, 150);
|
||||
},
|
||||
[readOnly, cleanEditorHTML]
|
||||
);
|
||||
|
||||
// Image upload handler
|
||||
const handleImageUpload = useCallback(() => {
|
||||
const input = document.createElement('input');
|
||||
@@ -234,6 +300,84 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
setIsLinkOpen(true);
|
||||
}, []);
|
||||
|
||||
const openHtmlEditor = useCallback((mode: 'block' | 'document' = 'block') => {
|
||||
const quill = quillRef.current?.getEditor();
|
||||
if (!quill) return;
|
||||
const range = quill.getSelection();
|
||||
setHtmlEditMode(mode);
|
||||
if (mode === 'document') {
|
||||
try {
|
||||
const currentHTML = (quill.root?.innerHTML as string) || '';
|
||||
setHtmlSource(cleanEditorHTML(currentHTML));
|
||||
htmlRangeRef.current = { index: 0, length: quill.getLength() };
|
||||
} catch {
|
||||
setHtmlSource('');
|
||||
htmlRangeRef.current = { index: 0, length: 0 };
|
||||
}
|
||||
} else {
|
||||
htmlRangeRef.current = range ? { index: range.index, length: range.length } : { index: quill.getLength(), length: 0 };
|
||||
setHtmlSource('');
|
||||
}
|
||||
setIsHtmlEditorOpen(true);
|
||||
}, [cleanEditorHTML]);
|
||||
|
||||
const handleInsertHtmlBlock = useCallback(() => {
|
||||
const quill = quillRef.current?.getEditor();
|
||||
if (!quill) return;
|
||||
|
||||
const html = htmlSource.trim();
|
||||
|
||||
if (!html) {
|
||||
toast({ title: 'Prázdný HTML kód', description: 'Zadejte HTML, které chcete vložit.', status: 'warning', duration: 2000 });
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitized = DOMPurify.sanitize(html, {
|
||||
USE_PROFILES: { html: true },
|
||||
ADD_TAGS: ['iframe'],
|
||||
ADD_ATTR: ['class', 'target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id', 'data-bullets', 'data-list'],
|
||||
});
|
||||
|
||||
if (htmlEditMode === 'document') {
|
||||
const skip = typeof window !== 'undefined' && window.localStorage.getItem('rte_skip_full_html_confirm') === '1';
|
||||
if (!skip) {
|
||||
pendingSanitizedRef.current = sanitized;
|
||||
pendingRawRef.current = html;
|
||||
setConfirmDontAsk(false);
|
||||
setIsFullHtmlConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
const total = quill.getLength();
|
||||
quill.focus();
|
||||
try { quill.deleteText(0, total, 'api'); } catch {}
|
||||
try {
|
||||
quill.clipboard.dangerouslyPasteHTML(0, sanitized, 'user');
|
||||
} catch {
|
||||
const fallback = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
|
||||
quill.insertText(0, fallback, 'user');
|
||||
}
|
||||
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
|
||||
setIsHtmlEditorOpen(false);
|
||||
setHtmlSource('');
|
||||
toast({ title: 'Obsah aktualizován', status: 'success', duration: 2000 });
|
||||
return;
|
||||
}
|
||||
|
||||
const range = htmlRangeRef.current || quill.getSelection() || { index: quill.getLength(), length: 0 };
|
||||
const index = range.index;
|
||||
quill.focus();
|
||||
try {
|
||||
quill.clipboard.dangerouslyPasteHTML(index, sanitized, 'user');
|
||||
} catch {
|
||||
const fallback = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
|
||||
quill.insertText(index, fallback, 'user');
|
||||
}
|
||||
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
|
||||
setIsHtmlEditorOpen(false);
|
||||
setHtmlSource('');
|
||||
toast({ title: 'HTML vloženo', status: 'success', duration: 2000 });
|
||||
}, [htmlSource, toast, cleanEditorHTML, htmlEditMode]);
|
||||
|
||||
const quillModules = useMemo(() => ({
|
||||
toolbar: {
|
||||
container: toolbarConfig,
|
||||
@@ -247,6 +391,54 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
},
|
||||
}), [toolbarConfig, onImageUpload, handleImageUpload, handleLinkToolbar]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted || readOnly) return;
|
||||
const quill = quillRef.current?.getEditor();
|
||||
if (!quill) return;
|
||||
const onTextChange = (delta: any, _old: any, source: any) => {
|
||||
if (source !== 'user' || htmlSwitchingRef.current || isHtmlEditorOpen) return;
|
||||
let inserted = '';
|
||||
if (Array.isArray(delta?.ops)) {
|
||||
delta.ops.forEach((op: any) => {
|
||||
if (typeof op.insert === 'string') inserted += op.insert;
|
||||
});
|
||||
}
|
||||
if (!inserted || !(/[<>]/.test(inserted))) return;
|
||||
const range = quill.getSelection();
|
||||
if (!range) return;
|
||||
const ctxStart = Math.max(0, range.index - 200);
|
||||
const ctx = quill.getText(ctxStart, range.index - ctxStart);
|
||||
const relStart = ctx.lastIndexOf('<');
|
||||
if (relStart < 0) return;
|
||||
let snippet = ctx.slice(relStart);
|
||||
snippet = (snippet || '').trim();
|
||||
if (!/^<\/?[A-Za-z]/.test(snippet)) return;
|
||||
const absStart = ctxStart + relStart;
|
||||
const len = range.index - absStart;
|
||||
if (len <= 0 || len > 200) return;
|
||||
htmlSwitchingRef.current = true;
|
||||
try { quill.deleteText(absStart, len, 'api'); } catch {}
|
||||
try { htmlRangeRef.current = { index: absStart, length: 0 }; } catch {}
|
||||
setIsHtmlEditorOpen(true);
|
||||
setHtmlSource(snippet);
|
||||
setTimeout(() => { htmlSwitchingRef.current = false; }, 120);
|
||||
};
|
||||
quill.on('text-change', onTextChange);
|
||||
return () => {
|
||||
try { quill.off('text-change', onTextChange); } catch {}
|
||||
};
|
||||
}, [isMounted, readOnly, isHtmlEditorOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
if (isHtmlEditorOpen && !MonacoComponent) {
|
||||
import('@monaco-editor/react')
|
||||
.then((m) => { if (mounted) setMonacoComponent(() => (m as any).default || (m as any)); })
|
||||
.catch(() => {});
|
||||
}
|
||||
return () => { mounted = false; };
|
||||
}, [isHtmlEditorOpen, MonacoComponent]);
|
||||
|
||||
const quillFormats = useMemo(
|
||||
() => [
|
||||
'header',
|
||||
@@ -1277,6 +1469,37 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
}
|
||||
}, [selectedImageElement, imageFilters, toast]);
|
||||
|
||||
const deleteSelectedImage = useCallback(() => {
|
||||
if (!selectedImageElement) return;
|
||||
|
||||
const editor = quillRef.current?.getEditor();
|
||||
if (!editor) return;
|
||||
|
||||
try {
|
||||
// Remove the image element from the DOM
|
||||
selectedImageElement.remove();
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
// Remove resize overlay container if present
|
||||
const editorContainer = editor.root.parentElement as HTMLElement | null;
|
||||
const resizeContainer = editorContainer?.querySelector('.custom-image-resize-container') as HTMLElement | null;
|
||||
if (resizeContainer && resizeContainer.parentNode) {
|
||||
resizeContainer.parentNode.removeChild(resizeContainer);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
selectedImageIdRef.current = null;
|
||||
setSelectedImageElement(null);
|
||||
setShowImageToolbar(false);
|
||||
setImageWidth(0);
|
||||
setManualWidth('');
|
||||
setWidthPercent(0);
|
||||
|
||||
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
|
||||
toast({ title: 'Obrázek odstraněn', status: 'info', duration: 1500 });
|
||||
}, [selectedImageElement, toast, cleanEditorHTML]);
|
||||
|
||||
// Align image
|
||||
const alignImage = useCallback((alignment: 'left' | 'center' | 'right') => {
|
||||
if (selectedImageElement) {
|
||||
@@ -1304,7 +1527,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
toast({ title: `Obrázek zarovnán ${alignment === 'left' ? 'vlevo' : alignment === 'center' ? 'na střed' : 'vpravo'}`, status: 'success', duration: 1500 });
|
||||
}
|
||||
}, [selectedImageElement, toast]);
|
||||
|
||||
// Reselect helper after content updates (e.g., when value change triggers rerender)
|
||||
const reselectAfterContentUpdate = useCallback(() => {
|
||||
const id = selectedImageIdRef.current;
|
||||
@@ -1385,33 +1607,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
}
|
||||
}, [selectedImageElement, manualWidth, toast, applyWidthPx]);
|
||||
|
||||
// Delete selected image
|
||||
const deleteSelectedImage = useCallback(() => {
|
||||
if (selectedImageElement) {
|
||||
selectedImageElement.remove();
|
||||
setSelectedImageElement(null);
|
||||
setShowImageToolbar(false);
|
||||
const editor = quillRef.current?.getEditor();
|
||||
if (editor) {
|
||||
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
|
||||
}
|
||||
toast({ title: 'Obrázek odstraněn', status: 'info', duration: 1500 });
|
||||
}
|
||||
}, [selectedImageElement, toast]);
|
||||
|
||||
// Sanitize HTML on change and keep author-selected colors intact
|
||||
const handleChange = (content: string) => {
|
||||
// First sanitize
|
||||
let cleaned = DOMPurify.sanitize(content, {
|
||||
USE_PROFILES: { html: true },
|
||||
ADD_TAGS: ['iframe'],
|
||||
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id', 'data-bullets', 'data-list'],
|
||||
});
|
||||
onChangeRef.current(cleanEditorHTML(cleaned));
|
||||
};
|
||||
|
||||
// Apply bullet style (disc | circle | square) to the current list
|
||||
const applyBulletStyle = useCallback((style: 'disc' | 'circle' | 'square') => {
|
||||
// Apply bullet style (disc | circle | square | none) to the current list
|
||||
const applyBulletStyle = useCallback((style: 'disc' | 'circle' | 'square' | 'none') => {
|
||||
const quill = quillRef.current?.getEditor();
|
||||
if (!quill) return;
|
||||
const range = quill.getSelection();
|
||||
@@ -1426,11 +1623,45 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
}
|
||||
if (el && el.tagName === 'UL') {
|
||||
(el as HTMLElement).setAttribute('data-bullets', style);
|
||||
try {
|
||||
// Also set inline style for public rendering where Quill CSS is not present
|
||||
(el as HTMLElement).style.listStyleType = style === 'none' ? 'none' : style;
|
||||
} catch {}
|
||||
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
|
||||
}
|
||||
}, [onChangeRef]);
|
||||
}, [onChangeRef, cleanEditorHTML]);
|
||||
|
||||
// Enhance toolbar: add bullet-style popover and color reset buttons
|
||||
const insertOrUpdateLink = useCallback(() => {
|
||||
const quill = quillRef.current?.getEditor();
|
||||
if (!quill) return;
|
||||
|
||||
const range = linkRangeRef.current || quill.getSelection();
|
||||
if (!range) return;
|
||||
|
||||
const text = linkText?.trim() || linkUrl?.trim();
|
||||
const url = linkUrl?.trim();
|
||||
if (!url) {
|
||||
toast({ title: 'Zadejte URL', status: 'warning', duration: 1500 });
|
||||
return;
|
||||
}
|
||||
|
||||
quill.focus();
|
||||
if (range.length > 0) {
|
||||
// Replace selected text with provided text and link
|
||||
quill.deleteText(range.index, range.length, 'user');
|
||||
quill.insertText(range.index, text || url, 'link', url, 'user');
|
||||
quill.setSelection(range.index + (text || url).length, 0, 'user');
|
||||
} else {
|
||||
quill.insertText(range.index, text || url, 'link', url, 'user');
|
||||
quill.setSelection(range.index + (text || url).length, 0, 'user');
|
||||
}
|
||||
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
|
||||
setIsLinkOpen(false);
|
||||
setLinkText('');
|
||||
setLinkUrl('');
|
||||
}, [linkText, linkUrl, toast, cleanEditorHTML]);
|
||||
|
||||
// Enhance toolbar: add color/background reset buttons next to pickers
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
const editor = quillRef.current?.getEditor();
|
||||
@@ -1458,128 +1689,42 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
(picker.parentElement as HTMLElement)?.insertBefore(btn, picker.nextSibling);
|
||||
}
|
||||
};
|
||||
addResetButton('.ql-color .ql-picker', 'ql-colorreset', 'color');
|
||||
addResetButton('.ql-background .ql-picker', 'ql-bgreset', 'background');
|
||||
|
||||
// Create bullet styles popover and attach to bullet list button
|
||||
const bulletBtn = toolbarEl.querySelector('button.ql-list[value="bullet"]') as HTMLButtonElement | null;
|
||||
if (!bulletBtn) return;
|
||||
let popover = toolbarEl.querySelector('.bullet-style-popover') as HTMLDivElement | null;
|
||||
if (!popover) {
|
||||
popover = document.createElement('div');
|
||||
popover.className = 'bullet-style-popover';
|
||||
popover.style.cssText = 'position:absolute;display:none;background:#fff;border:1px solid rgba(0,0,0,0.15);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.15);padding:6px;gap:6px;z-index:1000;';
|
||||
const mk = (label: string, st: 'disc'|'circle'|'square') => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'ql-bulletstyle';
|
||||
b.textContent = label;
|
||||
b.style.cssText = 'min-width:32px;height:28px;padding:0 8px;border-radius:6px;border:1px solid #e2e8f0;background:#fff;cursor:pointer;';
|
||||
b.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const q = quillRef.current?.getEditor();
|
||||
if (!q) return;
|
||||
const range = q.getSelection(true);
|
||||
if (range) {
|
||||
q.format('list', 'bullet', 'user');
|
||||
applyBulletStyle(st);
|
||||
}
|
||||
if (popover) popover.style.display = 'none';
|
||||
});
|
||||
b.addEventListener('mouseenter', () => { b.style.background = '#f7fafc'; });
|
||||
b.addEventListener('mouseleave', () => { b.style.background = '#fff'; });
|
||||
return b;
|
||||
};
|
||||
popover.appendChild(mk('•', 'disc'));
|
||||
popover.appendChild(mk('○', 'circle'));
|
||||
popover.appendChild(mk('▪', 'square'));
|
||||
toolbarEl.appendChild(popover);
|
||||
}
|
||||
let hideTimer: number | null = null;
|
||||
const show = () => {
|
||||
if (!popover) return;
|
||||
const rect = bulletBtn.getBoundingClientRect();
|
||||
const tRect = toolbarEl.getBoundingClientRect();
|
||||
popover.style.left = `${rect.left - tRect.left}px`;
|
||||
popover.style.top = `${rect.bottom - tRect.top + 6}px`;
|
||||
popover.style.display = 'flex';
|
||||
};
|
||||
const toggle = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!popover) return;
|
||||
if (popover.style.display === 'flex') {
|
||||
popover.style.display = 'none';
|
||||
} else {
|
||||
show();
|
||||
}
|
||||
};
|
||||
const scheduleHide = () => {
|
||||
if (hideTimer) window.clearTimeout(hideTimer);
|
||||
hideTimer = window.setTimeout(() => { if (popover) popover.style.display = 'none'; }, 200);
|
||||
};
|
||||
const cancelHide = () => { if (hideTimer) { window.clearTimeout(hideTimer); hideTimer = null; } };
|
||||
bulletBtn.addEventListener('mouseenter', show);
|
||||
bulletBtn.addEventListener('click', toggle);
|
||||
bulletBtn.addEventListener('mouseleave', scheduleHide);
|
||||
popover.addEventListener('mouseenter', cancelHide);
|
||||
popover.addEventListener('mouseleave', scheduleHide);
|
||||
return () => {
|
||||
bulletBtn.removeEventListener('mouseenter', show);
|
||||
bulletBtn.removeEventListener('click', toggle);
|
||||
bulletBtn.removeEventListener('mouseleave', scheduleHide);
|
||||
popover && popover.removeEventListener('mouseenter', cancelHide);
|
||||
popover && popover.removeEventListener('mouseleave', scheduleHide);
|
||||
};
|
||||
}, [isMounted, applyBulletStyle]);
|
||||
|
||||
const insertOrUpdateLink = useCallback(() => {
|
||||
const quill = quillRef.current?.getEditor();
|
||||
if (!quill) return;
|
||||
const range = linkRangeRef.current || quill.getSelection() || { index: quill.getLength(), length: 0 };
|
||||
const text = linkText?.trim() || linkUrl?.trim();
|
||||
const url = linkUrl?.trim();
|
||||
if (!url) {
|
||||
toast({ title: 'Zadejte URL', status: 'warning', duration: 1500 });
|
||||
return;
|
||||
}
|
||||
quill.focus();
|
||||
if (range.length > 0) {
|
||||
// Replace selected text with provided text and link
|
||||
quill.deleteText(range.index, range.length, 'user');
|
||||
quill.insertText(range.index, text || url, 'link', url, 'user');
|
||||
quill.setSelection(range.index + (text || url).length, 0, 'user');
|
||||
} else {
|
||||
quill.insertText(range.index, text || url, 'link', url, 'user');
|
||||
quill.setSelection(range.index + (text || url).length, 0, 'user');
|
||||
}
|
||||
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
|
||||
setIsLinkOpen(false);
|
||||
setLinkText('');
|
||||
setLinkUrl('');
|
||||
}, [linkText, linkUrl, toast]);
|
||||
addResetButton('.ql-color .ql-picker-label', 'ql-color-reset', 'color');
|
||||
addResetButton('.ql-background .ql-picker-label', 'ql-background-reset', 'background');
|
||||
}, [isMounted, cleanEditorHTML]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Editor Controls */}
|
||||
{!readOnly && (
|
||||
<VStack align="stretch" spacing={1} mb={2}>
|
||||
{onImageUpload && (
|
||||
<HStack spacing={2} justify="flex-start" flexWrap="wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={<ImageIcon size={16} />}
|
||||
colorScheme="purple"
|
||||
onClick={handleImageUpload}
|
||||
>
|
||||
Vložit obrázek
|
||||
</Button>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
nebo použijte tlačítko obrázku v nástrojové liště
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
<HStack spacing={2} justify="flex-start" flexWrap="wrap">
|
||||
{onImageUpload && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={<ImageIcon size={16} />}
|
||||
colorScheme="purple"
|
||||
onClick={handleImageUpload}
|
||||
>
|
||||
Vložit obrázek
|
||||
</Button>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
nebo použijte tlačítko obrázku v nástrojové liště
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={<Code size={16} />}
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
onClick={() => openHtmlEditor('document')}
|
||||
>
|
||||
Otevřít v HTML
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
@@ -2153,6 +2298,97 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<Drawer isOpen={isHtmlEditorOpen} placement="right" size="lg" onClose={() => setIsHtmlEditorOpen(false)}>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent>
|
||||
<DrawerCloseButton />
|
||||
<DrawerHeader>{htmlEditMode === 'document' ? 'Upravit HTML (celý obsah)' : 'Vložit vlastní HTML'}</DrawerHeader>
|
||||
<DrawerBody>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>HTML kód</FormLabel>
|
||||
{MonacoComponent ? (
|
||||
<MonacoComponent
|
||||
height="60vh"
|
||||
defaultLanguage="html"
|
||||
language="html"
|
||||
theme="vs-light"
|
||||
value={htmlSource}
|
||||
onChange={(v: string | undefined) => setHtmlSource(v || '')}
|
||||
options={{
|
||||
automaticLayout: true,
|
||||
wordWrap: 'on',
|
||||
minimap: { enabled: false },
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
formatOnPaste: true,
|
||||
formatOnType: true,
|
||||
suggestOnTriggerCharacters: true,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
value={htmlSource}
|
||||
onChange={(e) => setHtmlSource(e.target.value)}
|
||||
minH="200px"
|
||||
fontFamily="monospace"
|
||||
fontSize="sm"
|
||||
/>
|
||||
)}
|
||||
<FormHelperText>
|
||||
{htmlEditMode === 'document'
|
||||
? 'Upravujete celý obsah. Po potvrzení se současný obsah editoru nahradí vaším HTML.'
|
||||
: 'Vložte libovolný HTML kód (např. <h1>Nadpis</h1>, <p>odstavec</p>, <ul>...</ul>). Po vložení se převede na plně funkční formátování v editoru.'}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</DrawerBody>
|
||||
<DrawerFooter>
|
||||
<Button variant="ghost" mr={3} onClick={() => setIsHtmlEditorOpen(false)}>
|
||||
Zrušit
|
||||
</Button>
|
||||
<Button colorScheme="blue" onClick={handleInsertHtmlBlock}>
|
||||
{htmlEditMode === 'document' ? 'Použít HTML' : 'Vložit do textu'}
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<AlertDialog isOpen={isFullHtmlConfirmOpen} leastDestructiveRef={confirmCancelRef as any} onClose={() => setIsFullHtmlConfirmOpen(false)} isCentered>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>Nahradit celý obsah HTML?</AlertDialogHeader>
|
||||
<AlertDialogCloseButton />
|
||||
<AlertDialogBody>
|
||||
Tato akce nahradí aktuální obsah editoru vaším HTML. Chcete pokračovat?
|
||||
<Box mt={3}>
|
||||
<Checkbox isChecked={confirmDontAsk} onChange={(e) => setConfirmDontAsk(e.target.checked)}>
|
||||
Neptat se příště
|
||||
</Checkbox>
|
||||
</Box>
|
||||
</AlertDialogBody>
|
||||
<AlertDialogFooter>
|
||||
<Button ref={confirmCancelRef as any} onClick={() => setIsFullHtmlConfirmOpen(false)} mr={3}>Zrušit</Button>
|
||||
<Button colorScheme="blue" onClick={() => {
|
||||
try { if (confirmDontAsk) window.localStorage.setItem('rte_skip_full_html_confirm', '1'); } catch {}
|
||||
const quill = quillRef.current?.getEditor();
|
||||
if (!quill) { setIsFullHtmlConfirmOpen(false); return; }
|
||||
const sanitized = pendingSanitizedRef.current;
|
||||
const raw = pendingRawRef.current;
|
||||
const total = quill.getLength();
|
||||
quill.focus();
|
||||
try { quill.deleteText(0, total, 'api'); } catch {}
|
||||
try { quill.clipboard.dangerouslyPasteHTML(0, sanitized, 'user'); } catch { const fb = DOMPurify.sanitize(raw, { USE_PROFILES: { html: true } }); quill.insertText(0, fb, 'user'); }
|
||||
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
|
||||
setIsHtmlEditorOpen(false);
|
||||
setHtmlSource('');
|
||||
setIsFullHtmlConfirmOpen(false);
|
||||
toast({ title: 'Obsah aktualizován', status: 'success', duration: 2000 });
|
||||
}}>Použít HTML</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Crop Modal */}
|
||||
{/* Image Preview Modal */}
|
||||
<Modal isOpen={isPreviewOpen} onClose={() => setIsPreviewOpen(false)} size="6xl" isCentered>
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
HStack,
|
||||
VStack,
|
||||
Text,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Divider,
|
||||
useColorModeValue,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverArrow,
|
||||
PopoverBody,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon, CalendarIcon } from '@chakra-ui/icons';
|
||||
import {
|
||||
addDays,
|
||||
addMonths,
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
format,
|
||||
getDaysInMonth,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
isWithinInterval,
|
||||
parseISO,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
subMonths,
|
||||
setYear,
|
||||
setMonth,
|
||||
} from 'date-fns';
|
||||
import { cs } from 'date-fns/locale';
|
||||
|
||||
export type DateRangePickerProps = {
|
||||
from?: string;
|
||||
to?: string;
|
||||
onChange: (from: string, to: string) => void;
|
||||
size?: 'sm' | 'md';
|
||||
};
|
||||
|
||||
function parseDate(value?: string | null): Date | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const d = parseISO(value);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toIsoDate(d: Date | null | undefined): string {
|
||||
if (!d || isNaN(d.getTime())) return '';
|
||||
return format(d, 'yyyy-MM-dd');
|
||||
}
|
||||
|
||||
const monthNamesCs = [
|
||||
'Leden',
|
||||
'Únor',
|
||||
'Březen',
|
||||
'Duben',
|
||||
'Květen',
|
||||
'Červen',
|
||||
'Červenec',
|
||||
'Srpen',
|
||||
'Září',
|
||||
'Říjen',
|
||||
'Listopad',
|
||||
'Prosinec',
|
||||
];
|
||||
|
||||
const weekdayLabelsCs = (() => {
|
||||
const start = startOfWeek(new Date(), { weekStartsOn: 1 });
|
||||
return Array.from({ length: 7 }).map((_, i) =>
|
||||
format(addDays(start, i), 'EEEEE', { locale: cs }),
|
||||
);
|
||||
})();
|
||||
|
||||
const buildPresets = () => {
|
||||
const today = new Date();
|
||||
const todayIso = toIsoDate(today);
|
||||
|
||||
const lastNDays = (n: number) => {
|
||||
const end = today;
|
||||
const start = addDays(end, -n + 1);
|
||||
return { from: toIsoDate(start), to: toIsoDate(end) };
|
||||
};
|
||||
|
||||
const thisWeek = () => {
|
||||
const monday = startOfWeek(today, { weekStartsOn: 1 });
|
||||
const sunday = endOfWeek(today, { weekStartsOn: 1 });
|
||||
return { from: toIsoDate(monday), to: toIsoDate(sunday) };
|
||||
};
|
||||
|
||||
const next30Days = () => {
|
||||
const start = today;
|
||||
const end = addDays(start, 30);
|
||||
return { from: toIsoDate(start), to: toIsoDate(end) };
|
||||
};
|
||||
|
||||
const thisMonth = () => {
|
||||
const start = startOfMonth(today);
|
||||
const end = endOfMonth(today);
|
||||
return { from: toIsoDate(start), to: toIsoDate(end) };
|
||||
};
|
||||
|
||||
const last3Months = () => {
|
||||
const end = today;
|
||||
const start = addMonths(startOfMonth(end), -2);
|
||||
return { from: toIsoDate(start), to: toIsoDate(end) };
|
||||
};
|
||||
|
||||
const last12Months = () => {
|
||||
const end = today;
|
||||
const start = addMonths(startOfMonth(end), -11);
|
||||
return { from: toIsoDate(start), to: toIsoDate(end) };
|
||||
};
|
||||
|
||||
const thisYear = () => {
|
||||
const year = today.getFullYear();
|
||||
const start = new Date(year, 0, 1);
|
||||
const end = new Date(year, 11, 31);
|
||||
return { from: toIsoDate(start), to: toIsoDate(end) };
|
||||
};
|
||||
|
||||
return [
|
||||
{ key: 'today', label: 'Dnes', getRange: () => ({ from: todayIso, to: todayIso }) },
|
||||
{ key: 'last7', label: 'Posledních 7 dní', getRange: () => lastNDays(7) },
|
||||
{ key: 'last30', label: 'Posledních 30 dní', getRange: () => lastNDays(30) },
|
||||
{ key: 'thisWeek', label: 'Tento týden', getRange: () => thisWeek() },
|
||||
{ key: 'next30', label: 'Nadcházejících 30 dní', getRange: () => next30Days() },
|
||||
{ key: 'thisMonth', label: 'Tento měsíc', getRange: () => thisMonth() },
|
||||
{ key: 'last3months', label: 'Poslední 3 měsíce', getRange: () => last3Months() },
|
||||
{ key: 'last12months', label: 'Posledních 12 měsíců', getRange: () => last12Months() },
|
||||
{ key: 'thisYear', label: 'Tento rok', getRange: () => thisYear() },
|
||||
];
|
||||
};
|
||||
|
||||
const PRESETS = buildPresets();
|
||||
|
||||
export const DateRangePicker: React.FC<DateRangePickerProps> = ({ from, to, onChange, size = 'sm' }) => {
|
||||
const [viewDate, setViewDate] = useState<Date>(() => parseDate(from) || parseDate(to) || new Date());
|
||||
const [draftFrom, setDraftFrom] = useState<Date | null>(() => parseDate(from));
|
||||
const [draftTo, setDraftTo] = useState<Date | null>(() => parseDate(to));
|
||||
const [hoverDate, setHoverDate] = useState<Date | null>(null);
|
||||
const [activePanel, setActivePanel] = useState<'calendar' | 'year' | 'month' | 'day'>('calendar');
|
||||
const [yearPageStart, setYearPageStart] = useState<number>(() => {
|
||||
const base = (parseDate(from) || parseDate(to) || new Date()).getFullYear();
|
||||
return base - 6; // show 12 years around current
|
||||
});
|
||||
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const sideBg = useColorModeValue('gray.50', 'gray.900');
|
||||
const sideActiveBg = useColorModeValue('blue.50', 'blue.900');
|
||||
const sideActiveColor = useColorModeValue('blue.700', 'blue.100');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const dayBgSelected = useColorModeValue('blue.500', 'blue.400');
|
||||
const dayBgInRange = useColorModeValue('blue.50', 'blue.900');
|
||||
const dayTextSelected = useColorModeValue('white', 'gray.900');
|
||||
const dayTextSubtle = useColorModeValue('gray.400', 'gray.500');
|
||||
const triggerBg = useColorModeValue('white', 'gray.800');
|
||||
const triggerHoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const triggerBorderColor = useColorModeValue('gray.300', 'gray.600');
|
||||
|
||||
useEffect(() => {
|
||||
const start = parseDate(from);
|
||||
const end = parseDate(to);
|
||||
setDraftFrom(start);
|
||||
setDraftTo(end);
|
||||
const base = start || end || new Date();
|
||||
setViewDate(base);
|
||||
setYearPageStart(base.getFullYear() - 6);
|
||||
}, [from, to]);
|
||||
|
||||
const rangeLabel = useMemo(() => {
|
||||
const start = parseDate(from);
|
||||
const end = parseDate(to);
|
||||
if (start && end) {
|
||||
return `${format(start, 'd.M.yyyy', { locale: cs })} – ${format(end, 'd.M.yyyy', { locale: cs })}`;
|
||||
}
|
||||
if (start) return `Od ${format(start, 'd.M.yyyy', { locale: cs })}`;
|
||||
if (end) return `Do ${format(end, 'd.M.yyyy', { locale: cs })}`;
|
||||
return 'Libovolné období';
|
||||
}, [from, to]);
|
||||
|
||||
const handleDayClick = (day: Date) => {
|
||||
if (!draftFrom || (draftFrom && draftTo)) {
|
||||
setDraftFrom(day);
|
||||
setDraftTo(null);
|
||||
return;
|
||||
}
|
||||
if (draftFrom && !draftTo) {
|
||||
if (day < draftFrom) {
|
||||
setDraftTo(draftFrom);
|
||||
setDraftFrom(day);
|
||||
} else if (day.getTime() === draftFrom.getTime()) {
|
||||
// Single day toggle
|
||||
setDraftTo(day);
|
||||
} else {
|
||||
setDraftTo(day);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const currentRange = useMemo(() => {
|
||||
if (!draftFrom && !draftTo) return null;
|
||||
if (draftFrom && draftTo) {
|
||||
return draftFrom <= draftTo
|
||||
? { start: draftFrom, end: draftTo }
|
||||
: { start: draftTo, end: draftFrom };
|
||||
}
|
||||
if (draftFrom && hoverDate) {
|
||||
return draftFrom <= hoverDate
|
||||
? { start: draftFrom, end: hoverDate }
|
||||
: { start: hoverDate, end: draftFrom };
|
||||
}
|
||||
return null;
|
||||
}, [draftFrom, draftTo, hoverDate]);
|
||||
|
||||
const monthStart = startOfMonth(viewDate);
|
||||
const monthEnd = endOfMonth(viewDate);
|
||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 });
|
||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
|
||||
|
||||
const calendarDays: Date[] = [];
|
||||
for (let d = calendarStart; d <= calendarEnd; d = addDays(d, 1)) {
|
||||
calendarDays.push(d);
|
||||
}
|
||||
|
||||
const weeks: Date[][] = [];
|
||||
for (let i = 0; i < calendarDays.length; i += 7) {
|
||||
weeks.push(calendarDays.slice(i, i + 7));
|
||||
}
|
||||
|
||||
const yearOptions = useMemo(() => {
|
||||
return Array.from({ length: 12 }).map((_, i) => yearPageStart + i);
|
||||
}, [yearPageStart]);
|
||||
|
||||
const selectedReferenceDate = draftFrom || draftTo || viewDate;
|
||||
const selectedYear = selectedReferenceDate.getFullYear();
|
||||
const selectedMonth = selectedReferenceDate.getMonth();
|
||||
const daysInSelectedMonth = getDaysInMonth(new Date(selectedYear, selectedMonth, 1));
|
||||
const headerDayLabel = format(selectedReferenceDate, 'd.', { locale: cs });
|
||||
|
||||
const applyDraft = () => {
|
||||
const nextFrom = toIsoDate(draftFrom || null);
|
||||
const nextTo = toIsoDate(draftTo || draftFrom || null);
|
||||
onChange(nextFrom, nextTo);
|
||||
};
|
||||
|
||||
const clearDraft = () => {
|
||||
setDraftFrom(null);
|
||||
setDraftTo(null);
|
||||
setHoverDate(null);
|
||||
};
|
||||
|
||||
const renderCalendar = () => (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<SimpleGrid columns={7} spacing={2} fontSize="sm" textTransform="uppercase" color={dayTextSubtle}>
|
||||
{weekdayLabelsCs.map((d) => (
|
||||
<Box key={d} textAlign="center">{d}</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{weeks.map((week, wi) => (
|
||||
<HStack key={wi} spacing={2}>
|
||||
{week.map((day) => {
|
||||
const isOutside = !isSameMonth(day, monthStart);
|
||||
const isSelectedStart = draftFrom && isSameDay(day, draftFrom);
|
||||
const isSelectedEnd = draftTo && isSameDay(day, draftTo);
|
||||
const inRange = currentRange ? isWithinInterval(day, currentRange) : false;
|
||||
|
||||
let bg: string | undefined;
|
||||
let color: string | undefined;
|
||||
let roundedLeft = false;
|
||||
let roundedRight = false;
|
||||
|
||||
if (inRange) {
|
||||
bg = dayBgInRange;
|
||||
}
|
||||
if (isSelectedStart || isSelectedEnd) {
|
||||
bg = dayBgSelected;
|
||||
color = dayTextSelected;
|
||||
roundedLeft = true;
|
||||
roundedRight = true;
|
||||
}
|
||||
|
||||
const baseColor = isOutside ? dayTextSubtle : undefined;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={day.toISOString()}
|
||||
size={size}
|
||||
variant={bg ? 'solid' : 'ghost'}
|
||||
flex="1"
|
||||
borderRadius={bg ? (roundedLeft && roundedRight ? 'full' : 'md') : 'md'}
|
||||
bg={bg}
|
||||
color={color || baseColor}
|
||||
_hover={{ bg: bg || dayBgInRange }}
|
||||
onClick={() => handleDayClick(day)}
|
||||
onMouseEnter={() => setHoverDate(day)}
|
||||
onMouseLeave={() => setHoverDate(null)}
|
||||
w="100%"
|
||||
h="2.5rem"
|
||||
px={0}
|
||||
>
|
||||
{format(day, 'd')}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
const renderYearPanel = () => (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<HStack justify="space-between">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
leftIcon={<ChevronLeftIcon />}
|
||||
onClick={() => setYearPageStart((prev) => prev - 12)}
|
||||
>
|
||||
Starší
|
||||
</Button>
|
||||
<Text fontSize="sm" fontWeight="semibold">
|
||||
{yearOptions[0]} – {yearOptions[yearOptions.length - 1]}
|
||||
</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
rightIcon={<ChevronRightIcon />}
|
||||
onClick={() => setYearPageStart((prev) => prev + 12)}
|
||||
>
|
||||
Novější
|
||||
</Button>
|
||||
</HStack>
|
||||
<SimpleGrid columns={3} spacing={2}>
|
||||
{yearOptions.map((year) => (
|
||||
<Button
|
||||
key={year}
|
||||
size={size}
|
||||
variant={year === selectedYear ? 'solid' : 'ghost'}
|
||||
colorScheme={year === selectedYear ? 'blue' : undefined}
|
||||
onClick={() => {
|
||||
const next = setYear(viewDate, year);
|
||||
setViewDate(next);
|
||||
setActivePanel('month');
|
||||
}}
|
||||
>
|
||||
{year}
|
||||
</Button>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
const renderMonthPanel = () => (
|
||||
<SimpleGrid columns={3} spacing={2}>
|
||||
{monthNamesCs.map((name, idx) => (
|
||||
<Button
|
||||
key={name}
|
||||
size={size}
|
||||
variant={idx === selectedMonth ? 'solid' : 'ghost'}
|
||||
colorScheme={idx === selectedMonth ? 'blue' : undefined}
|
||||
onClick={() => {
|
||||
const next = setMonth(viewDate, idx);
|
||||
setViewDate(startOfMonth(next));
|
||||
setActivePanel('calendar');
|
||||
}}
|
||||
>
|
||||
{`${idx + 1}. ${name}`}
|
||||
</Button>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
const renderDayPanel = () => (
|
||||
<SimpleGrid columns={7} spacing={2}>
|
||||
{Array.from({ length: daysInSelectedMonth }).map((_, i) => {
|
||||
const dayNum = i + 1;
|
||||
const d = new Date(selectedYear, selectedMonth, dayNum);
|
||||
const isSelectedStart = draftFrom && isSameDay(d, draftFrom);
|
||||
const isSelectedEnd = draftTo && isSameDay(d, draftTo);
|
||||
const inRange = currentRange ? isWithinInterval(d, currentRange) : false;
|
||||
|
||||
let bg: string | undefined;
|
||||
let color: string | undefined;
|
||||
if (inRange) bg = dayBgInRange;
|
||||
if (isSelectedStart || isSelectedEnd) {
|
||||
bg = dayBgSelected;
|
||||
color = dayTextSelected;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={dayNum}
|
||||
size={size}
|
||||
variant={bg ? 'solid' : 'ghost'}
|
||||
bg={bg}
|
||||
color={color}
|
||||
_hover={{ bg: bg || dayBgInRange }}
|
||||
onClick={() => handleDayClick(d)}
|
||||
borderRadius="full"
|
||||
w="100%"
|
||||
h="2.5rem"
|
||||
px={0}
|
||||
>
|
||||
{dayNum}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
const headerYear = viewDate.getFullYear();
|
||||
const headerMonthName = monthNamesCs[viewDate.getMonth()];
|
||||
|
||||
return (
|
||||
<Popover
|
||||
placement="bottom-start"
|
||||
onOpen={() => {
|
||||
const start = parseDate(from);
|
||||
const end = parseDate(to);
|
||||
setDraftFrom(start);
|
||||
setDraftTo(end);
|
||||
const base = start || end || new Date();
|
||||
setViewDate(base);
|
||||
setYearPageStart(base.getFullYear() - 6);
|
||||
setActivePanel('calendar');
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
size={size}
|
||||
variant="outline"
|
||||
leftIcon={<CalendarIcon />}
|
||||
borderRadius="full"
|
||||
px={5}
|
||||
py={3}
|
||||
minW={{ base: '220px', md: '260px', lg: '300px' }}
|
||||
bg={triggerBg}
|
||||
borderColor={triggerBorderColor}
|
||||
boxShadow="sm"
|
||||
_hover={{ bg: triggerHoverBg, boxShadow: 'md' }}
|
||||
overflow="hidden"
|
||||
>
|
||||
<HStack spacing={3} align="center" w="100%" justify="space-between">
|
||||
<Text fontSize="sm" color="gray.600" flexShrink={0}>
|
||||
Období:
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="medium"
|
||||
flex="1"
|
||||
minW={0}
|
||||
textAlign="right"
|
||||
isTruncated
|
||||
noOfLines={1}
|
||||
>
|
||||
{rangeLabel}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
w={{ base: '100%', sm: '360px', md: '520px' }}
|
||||
maxW="520px"
|
||||
borderRadius="xl"
|
||||
boxShadow="xl"
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<PopoverArrow />
|
||||
<PopoverBody p={0}>
|
||||
<Stack
|
||||
direction={{ base: 'column', md: 'row' }}
|
||||
spacing={0}
|
||||
divider={<Divider orientation="vertical" display={{ base: 'none', md: 'block' }} />}
|
||||
>
|
||||
<Box
|
||||
w={{ base: '100%', md: '200px' }}
|
||||
borderBottomWidth={{ base: '1px', md: 0 }}
|
||||
bg={sideBg}
|
||||
p={4}
|
||||
>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.key}
|
||||
size="sm"
|
||||
justifyContent="flex-start"
|
||||
variant="ghost"
|
||||
borderRadius="md"
|
||||
px={3}
|
||||
py={2}
|
||||
fontSize="sm"
|
||||
_hover={{ bg: sideActiveBg, color: sideActiveColor }}
|
||||
onClick={() => {
|
||||
const r = preset.getRange();
|
||||
setDraftFrom(parseDate(r.from));
|
||||
setDraftTo(parseDate(r.to));
|
||||
}}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
borderRadius="md"
|
||||
px={3}
|
||||
py={2}
|
||||
fontSize="sm"
|
||||
onClick={clearDraft}
|
||||
>
|
||||
Vymazat
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<Box flex="1" p={4}>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<HStack justify="space-between" align="center">
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
size="xs"
|
||||
borderRadius="full"
|
||||
variant={activePanel === 'year' ? 'solid' : 'outline'}
|
||||
colorScheme={activePanel === 'year' ? 'blue' : undefined}
|
||||
onClick={() => setActivePanel('year')}
|
||||
>
|
||||
{headerYear}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
borderRadius="full"
|
||||
variant={activePanel === 'month' ? 'solid' : 'outline'}
|
||||
colorScheme={activePanel === 'month' ? 'blue' : undefined}
|
||||
onClick={() => setActivePanel('month')}
|
||||
>
|
||||
{headerMonthName}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
borderRadius="full"
|
||||
variant={activePanel === 'day' ? 'solid' : 'outline'}
|
||||
colorScheme={activePanel === 'day' ? 'blue' : undefined}
|
||||
onClick={() => setActivePanel('day')}
|
||||
>
|
||||
{headerDayLabel}
|
||||
</Button>
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => setViewDate((prev) => subMonths(prev, 1))}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => setViewDate((prev) => addMonths(prev, 1))}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{activePanel === 'calendar' && renderCalendar()}
|
||||
{activePanel === 'year' && renderYearPanel()}
|
||||
{activePanel === 'month' && renderMonthPanel()}
|
||||
{activePanel === 'day' && renderDayPanel()}
|
||||
|
||||
{activePanel !== 'calendar' && (
|
||||
<Button
|
||||
size="xs"
|
||||
alignSelf="flex-start"
|
||||
variant="link"
|
||||
onClick={() => setActivePanel('calendar')}
|
||||
>
|
||||
Zpět na kalendář
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<HStack justify="space-between" pt={2}>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
Rozsah:{' '}
|
||||
{draftFrom && draftTo
|
||||
? `${format(draftFrom, 'd.M.yyyy', { locale: cs })} – ${format(draftTo, 'd.M.yyyy', { locale: cs })}`
|
||||
: draftFrom
|
||||
? `Od ${format(draftFrom, 'd.M.yyyy', { locale: cs })}`
|
||||
: draftTo
|
||||
? `Do ${format(draftTo, 'd.M.yyyy', { locale: cs })}`
|
||||
: 'nenastaveno'}
|
||||
</Text>
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={clearDraft}
|
||||
>
|
||||
Zrušit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
onClick={applyDraft}
|
||||
isDisabled={!draftFrom && !draftTo}
|
||||
>
|
||||
Použít
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateRangePicker;
|
||||
@@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
HStack,
|
||||
Stack,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverBody,
|
||||
PopoverArrow,
|
||||
useColorModeValue,
|
||||
Portal,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { CheckCircleIcon, InfoOutlineIcon } from '@chakra-ui/icons';
|
||||
|
||||
export interface HelpTooltipCardProps {
|
||||
label: string;
|
||||
title: string;
|
||||
items?: string[];
|
||||
showStrengthBars?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* HelpTooltipCard
|
||||
*
|
||||
* Reusable rich tooltip/popover used for contextual help next to form fields.
|
||||
* Triggered by a small pill with label text; shows a card with colored bars
|
||||
* and a checklist inside, visually similar to password requirement examples.
|
||||
*/
|
||||
export const HelpTooltipCard: React.FC<HelpTooltipCardProps> = ({ label, title, items, showStrengthBars = false }) => {
|
||||
const bg = useColorModeValue('white', 'gray.800');
|
||||
const border = useColorModeValue('gray.200', 'gray.700');
|
||||
const pillBg = useColorModeValue('white', 'gray.700');
|
||||
const pillBorder = useColorModeValue('gray.200', 'gray.600');
|
||||
const iconColor = useColorModeValue('gray.600', 'gray.200');
|
||||
|
||||
return (
|
||||
<Popover trigger="hover" openDelay={150} closeDelay={100} placement="right-start">
|
||||
<PopoverTrigger>
|
||||
<Box
|
||||
as="button"
|
||||
type="button"
|
||||
borderRadius="full"
|
||||
bg={pillBg}
|
||||
borderWidth="1px"
|
||||
borderColor={pillBorder}
|
||||
boxShadow="sm"
|
||||
fontSize="xs"
|
||||
fontWeight="medium"
|
||||
_hover={{ boxShadow: 'md', transform: 'translateY(-1px)' }}
|
||||
transition="all 0.15s ease-out"
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
w="18px"
|
||||
h="18px"
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon as={InfoOutlineIcon} boxSize={3} color={iconColor} />
|
||||
</Box>
|
||||
</PopoverTrigger>
|
||||
<Portal>
|
||||
<PopoverContent
|
||||
maxW="sm"
|
||||
bg={bg}
|
||||
borderColor={border}
|
||||
boxShadow="2xl"
|
||||
borderRadius="xl"
|
||||
_focus={{ boxShadow: '2xl' }}
|
||||
>
|
||||
<PopoverArrow />
|
||||
<PopoverBody p={4}>
|
||||
<Stack spacing={3}>
|
||||
{showStrengthBars && (
|
||||
<HStack spacing={1.5}>
|
||||
<Box flex="1" h="2px" borderRadius="full" bg="red.400" />
|
||||
<Box flex="1" h="2px" borderRadius="full" bg="orange.400" />
|
||||
<Box flex="1" h="2px" borderRadius="full" bg="green.400" />
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
<Text fontSize="sm" fontWeight="semibold">
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{items && items.length > 0 && (
|
||||
<Stack as="ul" spacing={1.5} pl={0} m={0} style={{ listStyle: 'none' }}>
|
||||
{items.map((item) => (
|
||||
<HStack as="li" key={item} align="flex-start" spacing={2}>
|
||||
<Icon as={CheckCircleIcon} color="green.400" boxSize={3.5} mt={0.5} />
|
||||
<Text fontSize="sm" color="gray.700" _dark={{ color: 'gray.200' }}>
|
||||
{item}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export const PasswordHelpTooltip: React.FC<{ label?: string }> = ({ label = 'Požadavky na heslo' }) => {
|
||||
return (
|
||||
<HelpTooltipCard
|
||||
label={label}
|
||||
title="Silné heslo by mělo obsahovat:"
|
||||
items={[
|
||||
'Minimálně 8 znaků (povinné)',
|
||||
'Kombinaci malých a velkých písmen',
|
||||
'Čísla a ideálně speciální znaky',
|
||||
]}
|
||||
showStrengthBars
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HelpTooltipCard;
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Menu, MenuButton, MenuList, MenuItem, HStack, Text, Icon } from '@chakra-ui/react';
|
||||
import { FaGlobe, FaChevronDown } from 'react-icons/fa';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../../services/api';
|
||||
|
||||
interface Language {
|
||||
id: string;
|
||||
name: string;
|
||||
native_name: string;
|
||||
code: string;
|
||||
is_default: boolean;
|
||||
is_active: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export const LanguageSwitcher = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const [languages, setLanguages] = useState<Language[]>([]);
|
||||
const [currentLanguage, setCurrentLanguage] = useState<string>('cs');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Fetch available languages from API
|
||||
useEffect(() => {
|
||||
const fetchLanguages = async () => {
|
||||
try {
|
||||
const response = await api.get('/i18n/languages');
|
||||
const activeLanguages = response.data.languages.filter((lang: Language) => lang.is_active);
|
||||
|
||||
if (activeLanguages.length > 0) {
|
||||
setLanguages(activeLanguages);
|
||||
} else {
|
||||
// Fallback to default languages if API returns empty
|
||||
setLanguages([
|
||||
{ id: 'cs', name: 'Czech', native_name: 'Čeština', code: 'cs', is_default: true, is_active: true, sort_order: 1 },
|
||||
{ id: 'en', name: 'English', native_name: 'English', code: 'en', is_default: false, is_active: true, sort_order: 2 },
|
||||
]);
|
||||
}
|
||||
|
||||
// Set current language from i18n or default to Czech
|
||||
const current = i18n.language || 'cs';
|
||||
setCurrentLanguage(current);
|
||||
} catch (error) {
|
||||
// Fallback to default languages on error
|
||||
setLanguages([
|
||||
{ id: 'cs', name: 'Czech', native_name: 'Čeština', code: 'cs', is_default: true, is_active: true, sort_order: 1 },
|
||||
{ id: 'en', name: 'English', native_name: 'English', code: 'en', is_default: false, is_active: true, sort_order: 2 },
|
||||
]);
|
||||
const current = i18n.language || 'cs';
|
||||
setCurrentLanguage(current);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLanguages();
|
||||
}, [i18n.language]);
|
||||
|
||||
// Change language
|
||||
const changeLanguage = async (languageCode: string) => {
|
||||
try {
|
||||
// Change language in i18next
|
||||
await i18n.changeLanguage(languageCode);
|
||||
|
||||
// Save preference to backend if user is authenticated
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
try {
|
||||
await api.post('/i18n/user-language', { language_code: languageCode });
|
||||
} catch (error) {
|
||||
console.warn('Failed to save language preference to backend:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Save to cookie
|
||||
document.cookie = `lang=${languageCode}; max-age=${365 * 24 * 60 * 60}; path=/`;
|
||||
|
||||
setCurrentLanguage(languageCode);
|
||||
|
||||
// Refresh page to update navigation and all content
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Failed to change language:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Get current language display name
|
||||
const getCurrentLanguageDisplay = () => {
|
||||
const lang = languages.find(l => l.code === currentLanguage);
|
||||
return lang ? lang.native_name : 'Čeština';
|
||||
};
|
||||
|
||||
if (loading || languages.length < 2) {
|
||||
return null; // Don't show if less than 2 languages or still loading
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
minWidth="auto"
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: "gray.100" }}
|
||||
_active={{ bg: "gray.200" }}
|
||||
fontSize="sm"
|
||||
fontWeight="medium"
|
||||
>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={FaGlobe} fontSize="sm" />
|
||||
<Text fontSize="sm" fontWeight="medium" textTransform="uppercase">
|
||||
{currentLanguage === 'cs' ? 'CZ' : 'EN'}
|
||||
</Text>
|
||||
<Icon as={FaChevronDown} fontSize="xs" />
|
||||
</HStack>
|
||||
</MenuButton>
|
||||
<MenuList minWidth="100px" p={1} boxShadow="lg" border="1px" borderColor="gray.200">
|
||||
{languages
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((language) => (
|
||||
<MenuItem
|
||||
key={language.code}
|
||||
onClick={() => changeLanguage(language.code)}
|
||||
isDisabled={language.code === currentLanguage}
|
||||
fontSize="sm"
|
||||
borderRadius="md"
|
||||
px={2}
|
||||
py={1}
|
||||
_hover={{ bg: "gray.100" }}
|
||||
_selected={{ bg: "blue.50", color: "blue.600" }}
|
||||
>
|
||||
<HStack spacing={1} width="100%" justifyContent="space-between">
|
||||
<Text fontSize="sm" fontWeight="medium" textTransform="uppercase">
|
||||
{language.code === 'cs' ? 'CZ' : 'EN'}
|
||||
</Text>
|
||||
{language.code === currentLanguage && (
|
||||
<Text fontSize="sm" color="blue.500" fontWeight="bold">
|
||||
✓
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,463 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
HStack,
|
||||
VStack,
|
||||
Icon,
|
||||
Skeleton,
|
||||
Badge,
|
||||
useColorModeValue,
|
||||
Tooltip,
|
||||
Progress,
|
||||
Divider,
|
||||
SimpleGrid,
|
||||
} from '@chakra-ui/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
FaCloud,
|
||||
FaCloudSun,
|
||||
FaSun,
|
||||
FaCloudRain,
|
||||
FaSnowflake,
|
||||
FaWind,
|
||||
FaTint,
|
||||
FaEye,
|
||||
FaThermometerHalf,
|
||||
FaExclamationTriangle,
|
||||
FaMoon,
|
||||
} from 'react-icons/fa';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface MatchWeatherData {
|
||||
weather: {
|
||||
location: {
|
||||
name: string;
|
||||
region: string;
|
||||
country: string;
|
||||
};
|
||||
current: {
|
||||
last_updated: string;
|
||||
temp_c: number;
|
||||
temp_f: number;
|
||||
is_day: number;
|
||||
condition: {
|
||||
text: string;
|
||||
icon: string;
|
||||
code: number;
|
||||
};
|
||||
wind_mph: number;
|
||||
wind_kph: number;
|
||||
wind_degree: number;
|
||||
wind_dir: string;
|
||||
pressure_mb: number;
|
||||
pressure_in: number;
|
||||
precip_mm: number;
|
||||
precip_in: number;
|
||||
humidity: number;
|
||||
cloud: number;
|
||||
feelslike_c: number;
|
||||
feelslike_f: number;
|
||||
vis_km: number;
|
||||
vis_miles: number;
|
||||
uv: number;
|
||||
gust_mph: number;
|
||||
gust_kph: number;
|
||||
};
|
||||
forecast: {
|
||||
forecastday: Array<{
|
||||
date: string;
|
||||
date_epoch: number;
|
||||
day: {
|
||||
maxtemp_c: number;
|
||||
maxtemp_f: number;
|
||||
mintemp_c: number;
|
||||
mintemp_f: number;
|
||||
avgtemp_c: number;
|
||||
avgtemp_f: number;
|
||||
maxwind_mph: number;
|
||||
maxwind_kph: number;
|
||||
totalprecip_mm: number;
|
||||
totalprecip_in: number;
|
||||
totalsnow_cm: number;
|
||||
avgvis_km: number;
|
||||
avgvis_miles: number;
|
||||
avghumidity: number;
|
||||
daily_will_it_rain: number;
|
||||
daily_chance_of_rain: number;
|
||||
daily_will_it_snow: number;
|
||||
daily_chance_of_snow: number;
|
||||
condition: {
|
||||
text: string;
|
||||
icon: string;
|
||||
code: number;
|
||||
};
|
||||
uv: number;
|
||||
};
|
||||
astro: {
|
||||
sunrise: string;
|
||||
sunset: string;
|
||||
moonrise: string;
|
||||
moonset: string;
|
||||
moon_phase: string;
|
||||
moon_illumination: number;
|
||||
is_moon_up: number;
|
||||
is_sun_up: number;
|
||||
};
|
||||
hour: Array<{
|
||||
time_epoch: number;
|
||||
time: string;
|
||||
temp_c: number;
|
||||
temp_f: number;
|
||||
is_day: number;
|
||||
condition: {
|
||||
text: string;
|
||||
icon: string;
|
||||
code: number;
|
||||
};
|
||||
wind_mph: number;
|
||||
wind_kph: number;
|
||||
wind_degree: number;
|
||||
wind_dir: string;
|
||||
pressure_mb: number;
|
||||
pressure_in: number;
|
||||
precip_mm: number;
|
||||
precip_in: number;
|
||||
humidity: number;
|
||||
cloud: number;
|
||||
feelslike_c: number;
|
||||
feelslike_f: number;
|
||||
windchill_c: number;
|
||||
windchill_f: number;
|
||||
heatindex_c: number;
|
||||
heatindex_f: number;
|
||||
dewpoint_c: number;
|
||||
dewpoint_f: number;
|
||||
will_it_rain: number;
|
||||
chance_of_rain: number;
|
||||
will_it_snow: number;
|
||||
chance_of_snow: number;
|
||||
vis_km: number;
|
||||
vis_miles: number;
|
||||
gust_mph: number;
|
||||
gust_kph: number;
|
||||
uv: number;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
match_time: string;
|
||||
closest_hour: {
|
||||
time_epoch: number;
|
||||
time: string;
|
||||
temp_c: number;
|
||||
temp_f: number;
|
||||
is_day: number;
|
||||
condition: {
|
||||
text: string;
|
||||
icon: string;
|
||||
code: number;
|
||||
};
|
||||
wind_mph: number;
|
||||
wind_kph: number;
|
||||
wind_degree: number;
|
||||
wind_dir: string;
|
||||
pressure_mb: number;
|
||||
pressure_in: number;
|
||||
precip_mm: number;
|
||||
precip_in: number;
|
||||
humidity: number;
|
||||
cloud: number;
|
||||
feelslike_c: number;
|
||||
feelslike_f: number;
|
||||
windchill_c: number;
|
||||
windchill_f: number;
|
||||
heatindex_c: number;
|
||||
heatindex_f: number;
|
||||
dewpoint_c: number;
|
||||
dewpoint_f: number;
|
||||
will_it_rain: number;
|
||||
chance_of_rain: number;
|
||||
will_it_snow: number;
|
||||
chance_of_snow: number;
|
||||
vis_km: number;
|
||||
vis_miles: number;
|
||||
gust_mph: number;
|
||||
gust_kph: number;
|
||||
uv: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface MatchWeatherProps {
|
||||
matchDateTime: string;
|
||||
venue?: string;
|
||||
isHomeMatch: boolean;
|
||||
matchHasStarted: boolean;
|
||||
delayLoad?: boolean; // New prop to control when to load weather
|
||||
}
|
||||
|
||||
// Czech and English translations
|
||||
const translations = {
|
||||
cs: {
|
||||
weather: 'Počasí',
|
||||
forecast: 'Předpověď počasí',
|
||||
temperature: 'Teplota',
|
||||
feelsLike: 'Pocitově',
|
||||
wind: 'Vítr',
|
||||
humidity: 'Vlhkost',
|
||||
visibility: 'Viditelnost',
|
||||
pressure: 'Tlak',
|
||||
precipitation: 'Srážky',
|
||||
chanceOfRain: 'Šance na déšť',
|
||||
chanceOfSnow: 'Šance na sníh',
|
||||
atMatchTime: 'V čase zápasu',
|
||||
weatherUnavailable: 'Předpověď počasí není dostupná',
|
||||
forecastNotAvailable: 'Předpověď není dostupná pro tento zápas',
|
||||
tooFarInFuture: 'Zápas je příliš daleko v budoucnosti',
|
||||
matchInPast: 'Zápas již proběhl',
|
||||
loading: 'Načítám počasí...',
|
||||
},
|
||||
en: {
|
||||
weather: 'Weather',
|
||||
forecast: 'Weather Forecast',
|
||||
temperature: 'Temperature',
|
||||
feelsLike: 'Feels Like',
|
||||
wind: 'Wind',
|
||||
humidity: 'Humidity',
|
||||
visibility: 'Visibility',
|
||||
pressure: 'Pressure',
|
||||
precipitation: 'Precipitation',
|
||||
chanceOfRain: 'Rain Chance',
|
||||
chanceOfSnow: 'Snow Chance',
|
||||
atMatchTime: 'At Match Time',
|
||||
weatherUnavailable: 'Weather forecast unavailable',
|
||||
forecastNotAvailable: 'Forecast not available for this match',
|
||||
tooFarInFuture: 'Match is too far in the future',
|
||||
matchInPast: 'Match has already taken place',
|
||||
loading: 'Loading weather...',
|
||||
},
|
||||
};
|
||||
|
||||
const getWeatherIcon = (condition: string, isDay: number) => {
|
||||
const lowerCondition = condition.toLowerCase();
|
||||
|
||||
if (lowerCondition.includes('sunny') || lowerCondition.includes('clear')) {
|
||||
return isDay ? FaSun : FaMoon;
|
||||
}
|
||||
if (lowerCondition.includes('partly cloudy') || lowerCondition.includes('partly sunny')) {
|
||||
return FaCloudSun;
|
||||
}
|
||||
if (lowerCondition.includes('cloudy') || lowerCondition.includes('overcast')) {
|
||||
return FaCloud;
|
||||
}
|
||||
if (lowerCondition.includes('rain') || lowerCondition.includes('drizzle') || lowerCondition.includes('shower')) {
|
||||
return FaCloudRain;
|
||||
}
|
||||
if (lowerCondition.includes('snow') || lowerCondition.includes('sleet') || lowerCondition.includes('blizzard')) {
|
||||
return FaSnowflake;
|
||||
}
|
||||
|
||||
return FaCloud;
|
||||
};
|
||||
|
||||
const getWindDirection = (degree: number) => {
|
||||
const directions = ['S', 'SV', 'V', 'JV', 'J', 'JZ', 'Z', 'SZ'];
|
||||
const index = Math.round(degree / 45) % 8;
|
||||
return directions[index];
|
||||
};
|
||||
|
||||
export const MatchWeather: React.FC<MatchWeatherProps> = ({
|
||||
matchDateTime,
|
||||
venue,
|
||||
isHomeMatch,
|
||||
matchHasStarted,
|
||||
delayLoad = false
|
||||
}) => {
|
||||
const { i18n } = useTranslation();
|
||||
const t = translations[i18n.language as keyof typeof translations] || translations.cs;
|
||||
|
||||
const bgCard = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
// Only show weather for home matches that haven't started yet
|
||||
if (!isHomeMatch || matchHasStarted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data: weatherData, isLoading, error } = useQuery<MatchWeatherData>({
|
||||
queryKey: ['matchWeather', matchDateTime, venue],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
match_datetime: matchDateTime,
|
||||
// Only pass location parameter if it's not a home match (home matches use club location)
|
||||
...(venue && { location: venue }),
|
||||
});
|
||||
|
||||
const response = await api.get(`/weather/match?${params}`);
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 30 * 60 * 1000, // 30 minutes cache
|
||||
enabled: isHomeMatch && !matchHasStarted && !delayLoad, // Disable if delayLoad is true
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box
|
||||
bg={bgCard}
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
mt={4}
|
||||
>
|
||||
<VStack spacing={3} align="stretch">
|
||||
<Text fontSize="sm" fontWeight="bold" color={textColor}>
|
||||
{t.weather}
|
||||
</Text>
|
||||
<Skeleton height="20px" width="60%" />
|
||||
<Skeleton height="16px" width="40%" />
|
||||
<Skeleton height="16px" width="50%" />
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !weatherData) {
|
||||
// Check for specific error messages to provide better user feedback
|
||||
let errorMessage = t.weatherUnavailable;
|
||||
const errorString = String(error || '');
|
||||
if (errorString.includes('too far in the future')) {
|
||||
errorMessage = t.tooFarInFuture;
|
||||
} else if (errorString.includes('match is in the past')) {
|
||||
errorMessage = t.matchInPast;
|
||||
} else if (errorString.includes('no location specified')) {
|
||||
errorMessage = t.forecastNotAvailable;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bgCard}
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
mt={4}
|
||||
>
|
||||
<VStack spacing={2} align="center">
|
||||
<Icon as={FaExclamationTriangle} boxSize={6} color="orange.400" />
|
||||
<Text fontSize="sm" color="gray.500" textAlign="center">
|
||||
{errorMessage}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const { weather, closest_hour } = weatherData;
|
||||
const currentIcon = getWeatherIcon(weather.current.condition.text, weather.current.is_day);
|
||||
const matchIcon = getWeatherIcon(closest_hour.condition.text, closest_hour.is_day);
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bgCard}
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
mt={4}
|
||||
>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* Header */}
|
||||
<HStack spacing={2} align="center">
|
||||
<Icon as={FaCloud} boxSize={4} color="blue.500" />
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
{t.forecast}
|
||||
</Text>
|
||||
<Badge fontSize="xs" colorScheme="blue" variant="subtle">
|
||||
{weather.location.name}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Current Weather */}
|
||||
<HStack spacing={3} align="center">
|
||||
<Icon as={currentIcon} boxSize={8} color="blue.400" />
|
||||
<VStack spacing={1} align="start" flex={1}>
|
||||
<Text fontSize="lg" fontWeight="bold">
|
||||
{Math.round(weather.current.temp_c)}°C
|
||||
</Text>
|
||||
<Text fontSize="xs" color={textColor}>
|
||||
{t.feelsLike} {Math.round(weather.current.feelslike_c)}°C
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack spacing={1} align="end">
|
||||
<Text fontSize="xs" color={textColor}>{weather.current.condition.text}</Text>
|
||||
{(closest_hour.will_it_rain > 0 || closest_hour.chance_of_rain > 0) && (
|
||||
<Badge size="xs" colorScheme="blue">
|
||||
{t.chanceOfRain}: {closest_hour.chance_of_rain}%
|
||||
</Badge>
|
||||
)}
|
||||
{(closest_hour.will_it_snow > 0 || closest_hour.chance_of_snow > 0) && (
|
||||
<Badge size="xs" colorScheme="cyan">
|
||||
{t.chanceOfSnow}: {closest_hour.chance_of_snow}%
|
||||
</Badge>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
{/* Weather at Match Time */}
|
||||
<Box>
|
||||
<Text fontSize="xs" fontWeight="bold" color={textColor} mb={2}>
|
||||
{t.atMatchTime}
|
||||
</Text>
|
||||
<HStack spacing={3} align="center" bg={useColorModeValue('gray.50', 'gray.700')} p={3} borderRadius="md">
|
||||
<Icon as={matchIcon} boxSize={6} color="blue.400" />
|
||||
<VStack spacing={1} align="start" flex={1}>
|
||||
<Text fontSize="md" fontWeight="bold">
|
||||
{Math.round(closest_hour.temp_c)}°C
|
||||
</Text>
|
||||
<Text fontSize="xs" color={textColor}>
|
||||
{closest_hour.condition.text}
|
||||
</Text>
|
||||
</VStack>
|
||||
<HStack spacing={4} fontSize="xs" color={textColor}>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={FaWind} boxSize={3} />
|
||||
<Text>{closest_hour.wind_kph} km/h</Text>
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={FaTint} boxSize={3} />
|
||||
<Text>{closest_hour.humidity}%</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* Additional Details */}
|
||||
<SimpleGrid columns={2} spacing={2} fontSize="xs">
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FaWind} boxSize={3} color={textColor} />
|
||||
<Text color={textColor}>{t.wind}: {weather.current.wind_kph} km/h {getWindDirection(weather.current.wind_degree)}</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FaTint} boxSize={3} color={textColor} />
|
||||
<Text color={textColor}>{t.humidity}: {weather.current.humidity}%</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FaEye} boxSize={3} color={textColor} />
|
||||
<Text color={textColor}>{t.visibility}: {weather.current.vis_km} km</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FaThermometerHalf} boxSize={3} color={textColor} />
|
||||
<Text color={textColor}>{t.pressure}: {weather.current.pressure_mb} hPa</Text>
|
||||
</HStack>
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchWeather;
|
||||
@@ -0,0 +1,65 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Box, VStack, Text, Icon } from '@chakra-ui/react';
|
||||
import { FaCloud } from 'react-icons/fa';
|
||||
import { MatchWeather } from './MatchWeather';
|
||||
|
||||
interface MatchWeatherLazyProps {
|
||||
matchDateTime: string;
|
||||
venue?: string;
|
||||
isHomeMatch: boolean;
|
||||
matchHasStarted: boolean;
|
||||
}
|
||||
|
||||
export const MatchWeatherLazy: React.FC<MatchWeatherLazyProps> = ({
|
||||
matchDateTime,
|
||||
venue,
|
||||
isHomeMatch,
|
||||
matchHasStarted
|
||||
}) => {
|
||||
const [showWeather, setShowWeather] = useState(false);
|
||||
|
||||
// Only show weather for home matches that haven't started yet
|
||||
if (!isHomeMatch || matchHasStarted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!showWeather) {
|
||||
return (
|
||||
<Box
|
||||
bg="white"
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor="gray.200"
|
||||
mt={4}
|
||||
>
|
||||
<VStack spacing={3} align="center">
|
||||
<Icon as={FaCloud} boxSize={8} color="blue.400" />
|
||||
<Text fontSize="sm" color="gray.600" textAlign="center">
|
||||
Zobrazit předpověď počasí
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
onClick={() => setShowWeather(true)}
|
||||
>
|
||||
Zobrazit počasí
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MatchWeather
|
||||
matchDateTime={matchDateTime}
|
||||
venue={undefined} // Don't pass venue for home matches - let backend use club location
|
||||
isHomeMatch={isHomeMatch}
|
||||
matchHasStarted={matchHasStarted}
|
||||
delayLoad={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchWeatherLazy;
|
||||
@@ -0,0 +1,34 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useConfirmDialog } from '../../contexts/ConfirmDialogContext';
|
||||
|
||||
const ServiceWorkerUpdateListener: React.FC = () => {
|
||||
const { confirm } = useConfirmDialog();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const ev = event as CustomEvent<{ registration?: ServiceWorkerRegistration }>;
|
||||
const registration = ev.detail?.registration;
|
||||
if (!registration) return;
|
||||
(async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Aktualizace aplikace',
|
||||
message: 'Nová verze aplikace je k dispozici. Chcete aktualizovat?',
|
||||
confirmText: 'Aktualizovat',
|
||||
cancelText: 'Později',
|
||||
});
|
||||
if (ok && registration.waiting) {
|
||||
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||
window.location.reload();
|
||||
}
|
||||
})();
|
||||
};
|
||||
window.addEventListener('sw-update-available', handler as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('sw-update-available', handler as EventListener);
|
||||
};
|
||||
}, [confirm]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ServiceWorkerUpdateListener;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Image, ImageProps, Skeleton } from '@chakra-ui/react';
|
||||
import { getTeamLogo } from '../../utils/sportLogosAPI';
|
||||
import { getLogoStyle, getLogoClassName } from '../../utils/logoUtils';
|
||||
@@ -10,7 +10,7 @@ import { useIntersectionObserver } from '../../hooks/useIntersectionObserver';
|
||||
let __teamOverridesCache: { ts: number; data: { by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> } } | null = null;
|
||||
const loadTeamOverrides = async (): Promise<{ by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> }> => {
|
||||
const now = Date.now();
|
||||
const TTL = 5_000;
|
||||
const TTL = 30_000; // Extended to 30 seconds to reduce frequent requests
|
||||
if (__teamOverridesCache && now - __teamOverridesCache.ts < TTL) {
|
||||
return __teamOverridesCache.data || {};
|
||||
}
|
||||
@@ -101,11 +101,17 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
|
||||
const [error, setError] = useState(false);
|
||||
const { data: publicSettings } = usePublicSettings();
|
||||
const [observeRef, inView] = useIntersectionObserver({ threshold: 0.01, rootMargin: '150px 0px', freezeOnceVisible: true });
|
||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inView) return; // defer fetching until visible
|
||||
let mounted = true;
|
||||
|
||||
|
||||
// Clear any existing timeout
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
const fetchLogo = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -114,11 +120,13 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
|
||||
let overrides: { by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> } = {} as any;
|
||||
try { overrides = await loadTeamOverrides(); } catch {}
|
||||
// Prefer local club logo for own team when IDs match
|
||||
const currentClubId = publicSettings?.club_id;
|
||||
const currentClubLogoUrl = publicSettings?.club_logo_url;
|
||||
if (
|
||||
teamId && publicSettings?.club_id && String(teamId) === String(publicSettings.club_id) && publicSettings?.club_logo_url
|
||||
teamId && currentClubId && String(teamId) === String(currentClubId) && currentClubLogoUrl
|
||||
) {
|
||||
if (mounted) {
|
||||
setLogoUrl(assetUrl(publicSettings.club_logo_url) || publicSettings.club_logo_url);
|
||||
setLogoUrl(assetUrl(currentClubLogoUrl) || currentClubLogoUrl);
|
||||
}
|
||||
} else if (teamId && overrides?.by_id?.[teamId]?.logo_url) {
|
||||
const v = overrides.by_id[teamId]!.logo_url as string;
|
||||
@@ -180,7 +188,7 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
|
||||
if (mounted) {
|
||||
setError(true);
|
||||
// Fallback to FACR or placeholder
|
||||
setLogoUrl(facrLogo || '/logo192.png');
|
||||
setLogoUrl(facrLogo || '/logo-placeholder.svg');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
@@ -188,13 +196,19 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchLogo();
|
||||
|
||||
// Debounce the fetch to prevent rapid successive calls
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
fetchLogo();
|
||||
}, 100); // 100ms debounce
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [teamId, teamName, facrLogo, publicSettings?.club_id, publicSettings?.club_logo_url, inView]);
|
||||
}, [teamId, teamName, facrLogo, inView]);
|
||||
|
||||
// Size mapping
|
||||
const sizeMap = {
|
||||
@@ -233,7 +247,7 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
|
||||
return (
|
||||
<div ref={observeRef as any} style={{ display: 'inline-block' }}>
|
||||
<Image
|
||||
src={(assetUrl(logoUrl || undefined) || logoUrl || '/logo192.png')}
|
||||
src={(assetUrl(logoUrl || undefined) || logoUrl || '/logo-placeholder.svg')}
|
||||
alt={alt || teamName || 'Team logo'}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
@@ -250,7 +264,14 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
|
||||
onError={() => {
|
||||
if (!error) {
|
||||
setError(true);
|
||||
setLogoUrl(facrLogo || '/logo192.png');
|
||||
// If current URL is a local upload that failed, try fotbal.cz fallback
|
||||
if (logoUrl && logoUrl.startsWith('/uploads/logos/facr/') && teamId) {
|
||||
const fotbalCzUrl = `https://is1.fotbal.cz/media/kluby/${teamId}/${teamId}_crop.jpg`;
|
||||
setLogoUrl(fotbalCzUrl);
|
||||
setError(false); // Reset error to try the new URL
|
||||
} else {
|
||||
setLogoUrl(facrLogo || '/logo-placeholder.svg');
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Icon,
|
||||
Button,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
Progress,
|
||||
IconButton,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Flex,
|
||||
Stack,
|
||||
Collapse,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiUploadCloud,
|
||||
FiFile,
|
||||
FiImage,
|
||||
FiFileText,
|
||||
FiX,
|
||||
FiLink2,
|
||||
FiCheckCircle,
|
||||
FiAlertCircle,
|
||||
} from 'react-icons/fi';
|
||||
import { uploadFile as defaultUploadFile } from '../../services/articles';
|
||||
|
||||
export type UploadPanelFile = {
|
||||
url: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export interface UploadPanelProps {
|
||||
label?: string;
|
||||
description?: string;
|
||||
value?: UploadPanelFile[];
|
||||
onChange?: (files: UploadPanelFile[]) => void;
|
||||
accept?: string;
|
||||
maxFiles?: number;
|
||||
maxFileSizeMB?: number;
|
||||
multiple?: boolean;
|
||||
uploadFn?: (file: File) => Promise<UploadPanelFile>;
|
||||
allowUrlImport?: boolean;
|
||||
onUrlImport?: (url: string) => void | Promise<void>;
|
||||
urlPlaceholder?: string;
|
||||
onUploadFinished?: (uploadedFiles: UploadPanelFile[], allFiles: UploadPanelFile[]) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_MB = 10;
|
||||
|
||||
const UploadPanel: React.FC<UploadPanelProps> = ({
|
||||
label = 'Nahrát soubory',
|
||||
description = 'Vyberte soubory k nahrání nebo je přetáhněte do oblasti níže.',
|
||||
value,
|
||||
onChange,
|
||||
accept = 'image/*,application/pdf',
|
||||
maxFiles = 10,
|
||||
maxFileSizeMB = DEFAULT_MAX_MB,
|
||||
multiple = true,
|
||||
uploadFn,
|
||||
allowUrlImport = true,
|
||||
onUrlImport,
|
||||
urlPlaceholder = 'Vložit URL souboru',
|
||||
onUploadFinished,
|
||||
}) => {
|
||||
const [files, setFiles] = useState<UploadPanelFile[]>(() => value || []);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [urlInput, setUrlInput] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const toast = useToast();
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const bg = useColorModeValue('white', 'gray.800');
|
||||
const dropBg = useColorModeValue('gray.50', 'gray.900');
|
||||
const listHoverBg = useColorModeValue('gray.50', 'gray.900');
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setFiles(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const getFileIcon = (type: string) => {
|
||||
if (type.startsWith('image/')) return FiImage;
|
||||
if (type.includes('pdf')) return FiFileText;
|
||||
return FiFile;
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (!bytes) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${Math.round((bytes / Math.pow(k, i)) * 100) / 100} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const emitChange = useCallback(
|
||||
(next: UploadPanelFile[]) => {
|
||||
setFiles(next);
|
||||
if (onChange) onChange(next);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const doUpload = useCallback(
|
||||
async (file: File): Promise<UploadPanelFile> => {
|
||||
if (uploadFn) {
|
||||
return uploadFn(file);
|
||||
}
|
||||
const res = await defaultUploadFile(file as any);
|
||||
return {
|
||||
url: res.url,
|
||||
name: file.name,
|
||||
type: file.type || res.type || 'application/octet-stream',
|
||||
size: file.size || res.size || 0,
|
||||
};
|
||||
},
|
||||
[uploadFn]
|
||||
);
|
||||
|
||||
const handleFiles = useCallback(
|
||||
async (selected: FileList | File[]) => {
|
||||
const list = Array.from(selected || []);
|
||||
if (!list.length) return;
|
||||
|
||||
if (files.length + list.length > maxFiles) {
|
||||
toast({
|
||||
title: 'Příliš mnoho souborů',
|
||||
description: `Můžete nahrát maximálně ${maxFiles} souborů`,
|
||||
status: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const maxBytes = maxFileSizeMB * 1024 * 1024;
|
||||
const validFiles = list.filter((f) => {
|
||||
if (f.size > maxBytes) {
|
||||
toast({
|
||||
title: 'Soubor je příliš velký',
|
||||
description: `${f.name} překračuje limit ${maxFileSizeMB} MB`,
|
||||
status: 'error',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!validFiles.length) return;
|
||||
|
||||
setUploading(true);
|
||||
setProgress(0);
|
||||
|
||||
const newlyUploaded: UploadPanelFile[] = [];
|
||||
const total = validFiles.length;
|
||||
|
||||
try {
|
||||
for (let i = 0; i < validFiles.length; i += 1) {
|
||||
const f = validFiles[i];
|
||||
try {
|
||||
const uf = await doUpload(f);
|
||||
newlyUploaded.push(uf);
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: 'Chyba při nahrávání',
|
||||
description: err?.message || f.name,
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
setProgress(((i + 1) / total) * 100);
|
||||
}
|
||||
if (newlyUploaded.length) {
|
||||
const next = [...files, ...newlyUploaded];
|
||||
emitChange(next);
|
||||
if (onUploadFinished) {
|
||||
onUploadFinished(newlyUploaded, next);
|
||||
}
|
||||
toast({
|
||||
title: 'Nahrávání dokončeno',
|
||||
description: `${newlyUploaded.length} souborů bylo nahráno`,
|
||||
status: 'success',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setProgress(0);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
[doUpload, emitChange, files, maxFileSizeMB, maxFiles, onUploadFinished, toast]
|
||||
);
|
||||
|
||||
const onDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
if (e.dataTransfer?.files?.length) {
|
||||
handleFiles(e.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
const onDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isDragging) setIsDragging(true);
|
||||
};
|
||||
|
||||
const onDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const removeFile = (idx: number) => {
|
||||
const next = files.filter((_, i) => i !== idx);
|
||||
emitChange(next);
|
||||
};
|
||||
|
||||
const handleUrlSubmit = async () => {
|
||||
const url = urlInput.trim();
|
||||
if (!url) return;
|
||||
try {
|
||||
if (onUrlImport) {
|
||||
await onUrlImport(url);
|
||||
}
|
||||
const next: UploadPanelFile[] = [
|
||||
...files,
|
||||
{
|
||||
url,
|
||||
name: url,
|
||||
type: 'text/url',
|
||||
size: 0,
|
||||
},
|
||||
];
|
||||
emitChange(next);
|
||||
setUrlInput('');
|
||||
toast({ title: 'URL přidána', status: 'success', duration: 2000 });
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: 'Nelze přidat URL',
|
||||
description: err?.message || 'Zkontrolujte odkaz a zkuste to znovu.',
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderWidth="1px"
|
||||
borderRadius="xl"
|
||||
borderColor={borderColor}
|
||||
bg={bg}
|
||||
p={4}
|
||||
>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<HStack justify="space-between" align="flex-start">
|
||||
<Box>
|
||||
<HStack spacing={2} mb={1}>
|
||||
<Icon as={FiUploadCloud} color="blue.500" />
|
||||
<Text fontWeight="semibold">{label}</Text>
|
||||
</HStack>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
{files.length > 0 && (
|
||||
<HStack spacing={1} align="center" color="gray.500" fontSize="xs">
|
||||
<Icon as={FiCheckCircle} color="green.400" />
|
||||
<Text>{files.length} souborů</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<Box
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
borderStyle={isDragging ? 'solid' : 'dashed'}
|
||||
borderColor={isDragging ? 'blue.400' : borderColor}
|
||||
bg={isDragging ? dropBg : 'transparent'}
|
||||
p={6}
|
||||
textAlign="center"
|
||||
cursor="pointer"
|
||||
transition="all 0.15s ease-out"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
>
|
||||
<VStack spacing={2}>
|
||||
<Icon as={FiUploadCloud} boxSize={7} color="blue.400" />
|
||||
<Text fontWeight="medium">Zvolte soubor nebo jej přetáhněte sem</Text>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
Podporované formáty: {accept || 'soubor'} • Limit {maxFileSizeMB} MB / soubor
|
||||
</Text>
|
||||
<Button size="sm" variant="outline" leftIcon={<FiUploadCloud />}>
|
||||
Vybrat soubor
|
||||
</Button>
|
||||
</VStack>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple={multiple}
|
||||
accept={accept}
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
if (e.target.files) handleFiles(e.target.files);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Collapse in={uploading || progress > 0} animateOpacity>
|
||||
{uploading && (
|
||||
<HStack spacing={2} align="center">
|
||||
<Icon as={FiAlertCircle} color="blue.400" />
|
||||
<Text fontSize="sm">Nahrávám soubory…</Text>
|
||||
</HStack>
|
||||
)}
|
||||
{progress > 0 && (
|
||||
<Progress mt={2} value={progress} size="sm" colorScheme="blue" borderRadius="full" />
|
||||
)}
|
||||
</Collapse>
|
||||
|
||||
{files.length > 0 && (
|
||||
<Box borderTopWidth="1px" borderColor={borderColor} pt={3}>
|
||||
<Stack spacing={2}>
|
||||
{files.map((f, idx) => {
|
||||
const IconComp = getFileIcon(f.type || '');
|
||||
return (
|
||||
<Flex
|
||||
key={`${f.url}-${idx}`}
|
||||
align="center"
|
||||
justify="space-between"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: listHoverBg }}
|
||||
>
|
||||
<HStack spacing={3} flex={1} minW={0}>
|
||||
<Icon as={IconComp} boxSize={5} color="blue.500" />
|
||||
<Box flex={1} minW={0}>
|
||||
<Text fontSize="sm" noOfLines={1}>
|
||||
{f.name || f.url}
|
||||
</Text>
|
||||
<HStack spacing={2} fontSize="xs" color="gray.500">
|
||||
{f.size ? <Text>{formatSize(f.size)}</Text> : null}
|
||||
<Text>{f.type}</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</HStack>
|
||||
<IconButton
|
||||
aria-label="Odebrat soubor"
|
||||
icon={<FiX />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(idx);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{allowUrlImport && (
|
||||
<Box pt={2} borderTopWidth={files.length ? '1px' : '0'} borderColor={borderColor}>
|
||||
<Text fontSize="xs" color="gray.500" mb={1}>
|
||||
nebo importujte z URL odkazu
|
||||
</Text>
|
||||
<HStack spacing={2}>
|
||||
<InputGroup>
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<Icon as={FiLink2} color="gray.400" />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
size="sm"
|
||||
placeholder={urlPlaceholder}
|
||||
value={urlInput}
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleUrlSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</InputGroup>
|
||||
<Button size="sm" onClick={handleUrlSubmit} isDisabled={!urlInput.trim()}>
|
||||
Přidat
|
||||
</Button>
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadPanel;
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Text, Button, VStack } from '@chakra-ui/react';
|
||||
|
||||
export const I18nTest = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
// Debug: Check if translation function works
|
||||
console.log('I18nTest: t function type:', typeof t);
|
||||
console.log('I18nTest: Sample translation:', t('common.welcome_message'));
|
||||
console.log('I18nTest: i18n has resource:', i18n.hasResourceBundle('en', 'translation'));
|
||||
console.log('I18nTest: i18n data:', i18n.getResourceBundle('en', 'translation'));
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const newLang = i18n.language === 'cs' ? 'en' : 'cs';
|
||||
i18n.changeLanguage(newLang);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box p={4} borderWidth={1} borderRadius="md" mb={4}>
|
||||
<VStack spacing={2} align="start">
|
||||
<Text fontWeight="bold">i18n Test Component</Text>
|
||||
<Text>Current language: {i18n.language}</Text>
|
||||
<Text>Welcome message: {t('common.welcome_message')}</Text>
|
||||
<Text>Nav home: {t('nav.home')}</Text>
|
||||
<Text>Nav matches: {t('nav.matches')}</Text>
|
||||
<Button onClick={toggleLanguage} size="sm">
|
||||
Switch to {i18n.language === 'cs' ? 'English' : 'Čeština'}
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiRefreshCw } from 'react-icons/fi';
|
||||
import ColorPicker from '../common/ColorPicker';
|
||||
|
||||
interface AdvancedStyleControlsProps {
|
||||
elementName: string;
|
||||
@@ -120,25 +121,11 @@ const AdvancedStyleControls: React.FC<AdvancedStyleControlsProps> = ({
|
||||
<VStack spacing={3}>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Background Color</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
type="color"
|
||||
value={settings.backgroundColor || '#ffffff'}
|
||||
onChange={(e) => updateSetting('backgroundColor', e.target.value)}
|
||||
size="sm"
|
||||
w="60px"
|
||||
h="40px"
|
||||
p={1}
|
||||
cursor="pointer"
|
||||
/>
|
||||
<Input
|
||||
value={settings.backgroundColor || '#ffffff'}
|
||||
onChange={(e) => updateSetting('backgroundColor', e.target.value)}
|
||||
size="sm"
|
||||
placeholder="#ffffff"
|
||||
flex={1}
|
||||
/>
|
||||
</HStack>
|
||||
<ColorPicker
|
||||
value={settings.backgroundColor || '#ffffff'}
|
||||
onChange={(hex) => updateSetting('backgroundColor', hex)}
|
||||
recentStorageKey={`editor-background-${elementName}`}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* Color Presets */}
|
||||
@@ -206,25 +193,11 @@ const AdvancedStyleControls: React.FC<AdvancedStyleControlsProps> = ({
|
||||
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Border Color</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
type="color"
|
||||
value={settings.borderColor || '#000000'}
|
||||
onChange={(e) => updateSetting('borderColor', e.target.value)}
|
||||
size="sm"
|
||||
w="60px"
|
||||
h="40px"
|
||||
p={1}
|
||||
cursor="pointer"
|
||||
/>
|
||||
<Input
|
||||
value={settings.borderColor || '#000000'}
|
||||
onChange={(e) => updateSetting('borderColor', e.target.value)}
|
||||
size="sm"
|
||||
placeholder="#000000"
|
||||
flex={1}
|
||||
/>
|
||||
</HStack>
|
||||
<ColorPicker
|
||||
value={settings.borderColor || '#000000'}
|
||||
onChange={(hex) => updateSetting('borderColor', hex)}
|
||||
recentStorageKey={`editor-border-${elementName}`}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
|
||||
@@ -108,7 +108,7 @@ const SUPPORTED_HOME_VARIANTS: Record<string, string[]> = {
|
||||
hero: ['grid', 'scroller', 'swiper', 'swiper_full'],
|
||||
news: ['grid_one', 'grid_two', 'grid', 'list', 'scroller'],
|
||||
matches: ['compact'],
|
||||
sponsors: ['grid', 'slider', 'scroller', 'pyramid'],
|
||||
sponsors: ['grid', 'slider', 'scroller', 'logos3', 'pyramid'],
|
||||
gallery: ['grid'],
|
||||
videos: ['grid', 'carousel'],
|
||||
merch: ['grid'],
|
||||
@@ -1685,6 +1685,55 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
||||
|
||||
const viewportConfig = useMemo(() => getViewportConfig(), [getViewportConfig]);
|
||||
|
||||
// Apply viewport width changes with smooth transitions - REAL WIDTH CONSTRAINTS
|
||||
useEffect(() => {
|
||||
if (!isEditing) return;
|
||||
|
||||
const wrapper = safeDOM.querySelector('.myuibrix-viewport-wrapper') as HTMLElement;
|
||||
if (!wrapper) return;
|
||||
|
||||
// Apply actual width constraints without scaling for real responsive behavior
|
||||
wrapper.style.width = '100%';
|
||||
if (viewport === 'mobile') {
|
||||
wrapper.style.maxWidth = '420px';
|
||||
} else if (viewport === 'tablet') {
|
||||
wrapper.style.maxWidth = '820px';
|
||||
} else {
|
||||
wrapper.style.maxWidth = '100%';
|
||||
}
|
||||
wrapper.style.transition = 'all 0.3s ease';
|
||||
wrapper.style.margin = '0 auto';
|
||||
wrapper.style.transform = 'none';
|
||||
wrapper.style.transformOrigin = '';
|
||||
|
||||
// Add visual indicator for non-desktop viewports only
|
||||
if (viewport !== 'desktop') {
|
||||
wrapper.style.border = `3px solid ${primaryColor}`;
|
||||
wrapper.style.boxShadow = `0 0 0 9999px rgba(0,0,0,0.25), 0 8px 32px rgba(0,0,0,0.2)`;
|
||||
wrapper.style.marginTop = '20px';
|
||||
wrapper.style.marginBottom = '20px';
|
||||
wrapper.style.minHeight = 'calc(100vh - 100px)';
|
||||
} else {
|
||||
wrapper.style.border = 'none';
|
||||
wrapper.style.boxShadow = 'none';
|
||||
wrapper.style.marginTop = '0';
|
||||
wrapper.style.marginBottom = '0';
|
||||
wrapper.style.minHeight = '100vh';
|
||||
}
|
||||
|
||||
// Show toast notification when changing viewport
|
||||
const label = viewport === 'mobile' ? 'Mobile' : viewport === 'tablet' ? 'Tablet' : 'Desktop';
|
||||
const desc = viewport === 'mobile' ? 'Šířka ~420px' : viewport === 'tablet' ? 'Šířka ~820px' : 'Zobrazení na plnou šířku (100%)';
|
||||
toast({
|
||||
title: `Viewport: ${label}`,
|
||||
description: desc,
|
||||
status: 'info',
|
||||
duration: 1500,
|
||||
isClosable: true,
|
||||
position: 'bottom-right',
|
||||
});
|
||||
}, [isEditing, viewport, primaryColor, toast, viewportConfig]);
|
||||
|
||||
// Prevent all clicks on page content during edit mode
|
||||
useEffect(() => {
|
||||
if (!isEditing) return;
|
||||
@@ -1777,55 +1826,6 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
||||
};
|
||||
}, [isEditing]);
|
||||
|
||||
// Apply viewport width changes with smooth transitions - REAL WIDTH CONSTRAINTS
|
||||
useEffect(() => {
|
||||
if (!isEditing) return;
|
||||
|
||||
const wrapper = safeDOM.querySelector('.myuibrix-viewport-wrapper') as HTMLElement;
|
||||
if (!wrapper) return;
|
||||
|
||||
// Apply actual width constraints without scaling for real responsive behavior
|
||||
wrapper.style.width = '100%';
|
||||
if (viewport === 'mobile') {
|
||||
wrapper.style.maxWidth = '420px';
|
||||
} else if (viewport === 'tablet') {
|
||||
wrapper.style.maxWidth = '820px';
|
||||
} else {
|
||||
wrapper.style.maxWidth = '100%';
|
||||
}
|
||||
wrapper.style.transition = 'all 0.3s ease';
|
||||
wrapper.style.margin = '0 auto';
|
||||
wrapper.style.transform = 'none';
|
||||
wrapper.style.transformOrigin = '';
|
||||
|
||||
// Add visual indicator for non-desktop viewports only
|
||||
if (viewport !== 'desktop') {
|
||||
wrapper.style.border = `3px solid ${primaryColor}`;
|
||||
wrapper.style.boxShadow = `0 0 0 9999px rgba(0,0,0,0.25), 0 8px 32px rgba(0,0,0,0.2)`;
|
||||
wrapper.style.marginTop = '20px';
|
||||
wrapper.style.marginBottom = '20px';
|
||||
wrapper.style.minHeight = 'calc(100vh - 100px)';
|
||||
} else {
|
||||
wrapper.style.border = 'none';
|
||||
wrapper.style.boxShadow = 'none';
|
||||
wrapper.style.marginTop = '0';
|
||||
wrapper.style.marginBottom = '0';
|
||||
wrapper.style.minHeight = '100vh';
|
||||
}
|
||||
|
||||
// Show toast notification when changing viewport
|
||||
const label = viewport === 'mobile' ? 'Mobile' : viewport === 'tablet' ? 'Tablet' : 'Desktop';
|
||||
const desc = viewport === 'mobile' ? 'Šířka ~420px' : viewport === 'tablet' ? 'Šířka ~820px' : 'Zobrazení na plnou šířku (100%)';
|
||||
toast({
|
||||
title: `Viewport: ${label}`,
|
||||
description: desc,
|
||||
status: 'info',
|
||||
duration: 1500,
|
||||
isClosable: true,
|
||||
position: 'bottom-right',
|
||||
});
|
||||
}, [isEditing, viewport, primaryColor, toast, viewportConfig]);
|
||||
|
||||
// Early return if not admin (after all hooks)
|
||||
if (!isAdmin) return null;
|
||||
|
||||
@@ -1834,7 +1834,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
||||
{/* Edit Mode - Active */}
|
||||
{isEditing && (
|
||||
<>
|
||||
{/* Top Control Bar - Viewport Switcher & Actions */}
|
||||
{/* Top Control Bar - Actions */}
|
||||
<Box
|
||||
className="myuibrix-toolbar"
|
||||
position="fixed"
|
||||
|
||||
@@ -1,414 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import Frame from 'react-frame-component';
|
||||
import { Box, HStack, IconButton, Text, Tooltip, VStack, Badge } from '@chakra-ui/react';
|
||||
import { FiMonitor, FiTablet, FiSmartphone, FiRotateCw, FiMaximize2 } from 'react-icons/fi';
|
||||
|
||||
export interface DevicePreset {
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
userAgent: string;
|
||||
icon: React.ReactElement;
|
||||
category: 'mobile' | 'tablet' | 'desktop';
|
||||
}
|
||||
|
||||
export const DEVICE_PRESETS: Record<string, DevicePreset> = {
|
||||
// Mobile devices
|
||||
iphone_se: {
|
||||
name: 'iPhone SE',
|
||||
width: 375,
|
||||
height: 667,
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15',
|
||||
icon: <FiSmartphone />,
|
||||
category: 'mobile',
|
||||
},
|
||||
iphone_14: {
|
||||
name: 'iPhone 14 Pro',
|
||||
width: 393,
|
||||
height: 852,
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
|
||||
icon: <FiSmartphone />,
|
||||
category: 'mobile',
|
||||
},
|
||||
pixel_7: {
|
||||
name: 'Pixel 7',
|
||||
width: 412,
|
||||
height: 915,
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36',
|
||||
icon: <FiSmartphone />,
|
||||
category: 'mobile',
|
||||
},
|
||||
samsung_s23: {
|
||||
name: 'Samsung S23',
|
||||
width: 360,
|
||||
height: 800,
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 13; SM-S911B) AppleWebKit/537.36',
|
||||
icon: <FiSmartphone />,
|
||||
category: 'mobile',
|
||||
},
|
||||
|
||||
// Tablets
|
||||
ipad_mini: {
|
||||
name: 'iPad Mini',
|
||||
width: 768,
|
||||
height: 1024,
|
||||
userAgent: 'Mozilla/5.0 (iPad; CPU OS 15_0 like Mac OS X) AppleWebKit/605.1.15',
|
||||
icon: <FiTablet />,
|
||||
category: 'tablet',
|
||||
},
|
||||
ipad_air: {
|
||||
name: 'iPad Air',
|
||||
width: 820,
|
||||
height: 1180,
|
||||
userAgent: 'Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
|
||||
icon: <FiTablet />,
|
||||
category: 'tablet',
|
||||
},
|
||||
ipad_pro: {
|
||||
name: 'iPad Pro 12.9"',
|
||||
width: 1024,
|
||||
height: 1366,
|
||||
userAgent: 'Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
|
||||
icon: <FiTablet />,
|
||||
category: 'tablet',
|
||||
},
|
||||
|
||||
// Desktop
|
||||
desktop_1080: {
|
||||
name: 'Desktop 1080p',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
icon: <FiMonitor />,
|
||||
category: 'desktop',
|
||||
},
|
||||
desktop_1440: {
|
||||
name: 'Desktop 1440p',
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
icon: <FiMonitor />,
|
||||
category: 'desktop',
|
||||
},
|
||||
laptop: {
|
||||
name: 'Laptop',
|
||||
width: 1366,
|
||||
height: 768,
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
icon: <FiMonitor />,
|
||||
category: 'desktop',
|
||||
},
|
||||
};
|
||||
|
||||
interface ViewportSimulatorProps {
|
||||
children: React.ReactNode;
|
||||
defaultDevice?: string;
|
||||
showControls?: boolean;
|
||||
customCSS?: string;
|
||||
onDeviceChange?: (device: DevicePreset) => void;
|
||||
}
|
||||
|
||||
const ViewportSimulator: React.FC<ViewportSimulatorProps> = ({
|
||||
children,
|
||||
defaultDevice = 'desktop_1080',
|
||||
showControls = true,
|
||||
customCSS = '',
|
||||
onDeviceChange,
|
||||
}) => {
|
||||
const [currentDevice, setCurrentDevice] = useState<string>(defaultDevice);
|
||||
const [isPortrait, setIsPortrait] = useState(true);
|
||||
const [scale, setScale] = useState(1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const device = DEVICE_PRESETS[currentDevice];
|
||||
const width = isPortrait ? device.width : device.height;
|
||||
const height = isPortrait ? device.height : device.width;
|
||||
|
||||
// Auto-scale to fit container
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const updateScale = () => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const containerWidth = container.clientWidth;
|
||||
const containerHeight = container.clientHeight - (showControls ? 80 : 0);
|
||||
|
||||
// Calculate scale to fit both width and height
|
||||
const scaleX = containerWidth / (width + 40); // +40 for padding/borders
|
||||
const scaleY = containerHeight / (height + 40);
|
||||
const newScale = Math.min(scaleX, scaleY, 1); // Don't scale up, only down
|
||||
|
||||
setScale(newScale);
|
||||
};
|
||||
|
||||
updateScale();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateScale);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [width, height, showControls]);
|
||||
|
||||
// Change device
|
||||
const changeDevice = (deviceKey: string) => {
|
||||
setCurrentDevice(deviceKey);
|
||||
setIsPortrait(true);
|
||||
if (onDeviceChange) {
|
||||
onDeviceChange(DEVICE_PRESETS[deviceKey]);
|
||||
}
|
||||
};
|
||||
|
||||
// Rotate device
|
||||
const rotateDevice = () => {
|
||||
setIsPortrait(!isPortrait);
|
||||
};
|
||||
|
||||
// Reset to full width
|
||||
const resetToDesktop = () => {
|
||||
changeDevice('desktop_1080');
|
||||
};
|
||||
|
||||
// Inject CSS into iframe
|
||||
const frameContent = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=${width}, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Import parent styles if needed */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
background: #ffffff;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
/* Responsive breakpoints - REAL media queries */
|
||||
@media (max-width: 767px) {
|
||||
/* Mobile styles */
|
||||
.container {
|
||||
padding: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) and (max-width: 1023px) {
|
||||
/* Tablet styles */
|
||||
.container {
|
||||
padding: 24px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
/* Desktop styles */
|
||||
.container {
|
||||
padding: 32px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom CSS injection */
|
||||
${customCSS}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="frame-root"></div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return (
|
||||
<VStack
|
||||
ref={containerRef}
|
||||
width="100%"
|
||||
height="100%"
|
||||
spacing={0}
|
||||
align="stretch"
|
||||
bg="gray.50"
|
||||
position="relative"
|
||||
>
|
||||
{/* Controls */}
|
||||
{showControls && (
|
||||
<HStack
|
||||
spacing={2}
|
||||
p={3}
|
||||
bg="white"
|
||||
borderBottom="1px solid"
|
||||
borderColor="gray.200"
|
||||
flexWrap="wrap"
|
||||
justify="space-between"
|
||||
>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{/* Mobile */}
|
||||
<Tooltip label="iPhone SE">
|
||||
<IconButton
|
||||
aria-label="iPhone SE"
|
||||
icon={<FiSmartphone />}
|
||||
size="sm"
|
||||
colorScheme={currentDevice === 'iphone_se' ? 'blue' : 'gray'}
|
||||
variant={currentDevice === 'iphone_se' ? 'solid' : 'outline'}
|
||||
onClick={() => changeDevice('iphone_se')}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="iPhone 14 Pro">
|
||||
<IconButton
|
||||
aria-label="iPhone 14 Pro"
|
||||
icon={<FiSmartphone />}
|
||||
size="sm"
|
||||
colorScheme={currentDevice === 'iphone_14' ? 'blue' : 'gray'}
|
||||
variant={currentDevice === 'iphone_14' ? 'solid' : 'outline'}
|
||||
onClick={() => changeDevice('iphone_14')}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* Tablet */}
|
||||
<Tooltip label="iPad Mini">
|
||||
<IconButton
|
||||
aria-label="iPad Mini"
|
||||
icon={<FiTablet />}
|
||||
size="sm"
|
||||
colorScheme={currentDevice === 'ipad_mini' ? 'blue' : 'gray'}
|
||||
variant={currentDevice === 'ipad_mini' ? 'solid' : 'outline'}
|
||||
onClick={() => changeDevice('ipad_mini')}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="iPad Air">
|
||||
<IconButton
|
||||
aria-label="iPad Air"
|
||||
icon={<FiTablet />}
|
||||
size="sm"
|
||||
colorScheme={currentDevice === 'ipad_air' ? 'blue' : 'gray'}
|
||||
variant={currentDevice === 'ipad_air' ? 'solid' : 'outline'}
|
||||
onClick={() => changeDevice('ipad_air')}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* Desktop */}
|
||||
<Tooltip label="Laptop">
|
||||
<IconButton
|
||||
aria-label="Laptop"
|
||||
icon={<FiMonitor />}
|
||||
size="sm"
|
||||
colorScheme={currentDevice === 'laptop' ? 'blue' : 'gray'}
|
||||
variant={currentDevice === 'laptop' ? 'solid' : 'outline'}
|
||||
onClick={() => changeDevice('laptop')}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Desktop 1080p">
|
||||
<IconButton
|
||||
aria-label="Desktop"
|
||||
icon={<FiMonitor />}
|
||||
size="sm"
|
||||
colorScheme={currentDevice === 'desktop_1080' ? 'blue' : 'gray'}
|
||||
variant={currentDevice === 'desktop_1080' ? 'solid' : 'outline'}
|
||||
onClick={() => changeDevice('desktop_1080')}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* Rotate */}
|
||||
{device.category !== 'desktop' && (
|
||||
<Tooltip label="Otočit zařízení">
|
||||
<IconButton
|
||||
aria-label="Rotate"
|
||||
icon={<FiRotateCw />}
|
||||
size="sm"
|
||||
onClick={rotateDevice}
|
||||
colorScheme="purple"
|
||||
variant="outline"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Reset */}
|
||||
<Tooltip label="Resetovat na desktop">
|
||||
<IconButton
|
||||
aria-label="Reset"
|
||||
icon={<FiMaximize2 />}
|
||||
size="sm"
|
||||
onClick={resetToDesktop}
|
||||
variant="ghost"
|
||||
/>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
|
||||
<HStack spacing={3}>
|
||||
<Badge colorScheme="blue" fontSize="xs">
|
||||
{device.name}
|
||||
</Badge>
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
{width} × {height}px
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{(scale * 100).toFixed(0)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* Viewport Frame */}
|
||||
<Box
|
||||
flex={1}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
p={4}
|
||||
overflow="auto"
|
||||
position="relative"
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'top center',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
boxShadow="0 10px 40px rgba(0,0,0,0.2)"
|
||||
borderRadius="8px"
|
||||
overflow="hidden"
|
||||
bg="white"
|
||||
position="relative"
|
||||
>
|
||||
<Frame
|
||||
ref={frameRef}
|
||||
initialContent={frameContent}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
display: 'block',
|
||||
}}
|
||||
mountTarget="#frame-root"
|
||||
>
|
||||
{children}
|
||||
</Frame>
|
||||
</Box>
|
||||
</Box>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewportSimulator;
|
||||
@@ -35,6 +35,7 @@ import ColumnLayoutManager from './ColumnLayoutManager';
|
||||
import ContextualAdminLinks from './ContextualAdminLinks';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import { FONT_PAIRINGS, loadGoogleFont } from '../../config/fonts';
|
||||
import ColorPicker from '../common/ColorPicker';
|
||||
|
||||
interface VisualStylePanelProps {
|
||||
elementName: string;
|
||||
@@ -362,43 +363,21 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
|
||||
{/* Barva textu */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Barva textu</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
type="color"
|
||||
value={styles.color}
|
||||
onChange={(e) => updateStyle('color', e.target.value)}
|
||||
size="sm"
|
||||
w="60px"
|
||||
p={1}
|
||||
/>
|
||||
<Input
|
||||
value={styles.color}
|
||||
onChange={(e) => updateStyle('color', e.target.value)}
|
||||
size="sm"
|
||||
placeholder="#000000"
|
||||
/>
|
||||
</HStack>
|
||||
<ColorPicker
|
||||
value={styles.color}
|
||||
onChange={(hex) => updateStyle('color', hex)}
|
||||
recentStorageKey={`editor-text-${elementName}`}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* Barva pozadí */}
|
||||
<FormControl>
|
||||
<FormLabel fontSize="xs">Barva pozadí</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
type="color"
|
||||
value={styles.backgroundColor}
|
||||
onChange={(e) => updateStyle('backgroundColor', e.target.value)}
|
||||
size="sm"
|
||||
w="60px"
|
||||
p={1}
|
||||
/>
|
||||
<Input
|
||||
value={styles.backgroundColor}
|
||||
onChange={(e) => updateStyle('backgroundColor', e.target.value)}
|
||||
size="sm"
|
||||
placeholder="#ffffff"
|
||||
/>
|
||||
</HStack>
|
||||
<ColorPicker
|
||||
value={styles.backgroundColor}
|
||||
onChange={(hex) => updateStyle('backgroundColor', hex)}
|
||||
recentStorageKey={`editor-bg-${elementName}`}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Divider my={2} />
|
||||
|
||||
@@ -6,11 +6,13 @@ import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import { getNavigationItems, NavigationItem, seedDefaultNavigation } from '../../services/navigation';
|
||||
import { getCategories, Category } from '../../services/public';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Minimal NavLink type used to render items
|
||||
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
|
||||
|
||||
const SpartaNavbar: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: settings } = usePublicSettings();
|
||||
const theme = useClubTheme();
|
||||
const location = useLocation();
|
||||
@@ -78,13 +80,38 @@ const SpartaNavbar: React.FC = () => {
|
||||
};
|
||||
|
||||
const convertToNavLink = (item: NavigationItem): NavLink => {
|
||||
// Map known Czech labels to translation keys
|
||||
const getTranslatedLabel = (label: string) => {
|
||||
const labelMap: Record<string, string> = {
|
||||
'Domů': 'nav.home',
|
||||
'Aktuality': 'nav.news',
|
||||
'Zápasy': 'nav.matches',
|
||||
'Hráči': 'nav.players',
|
||||
'Fotogalerie': 'nav.gallery',
|
||||
'Videa': 'nav.videos',
|
||||
'Kontakt': 'nav.contact',
|
||||
'O klubu': 'nav.about',
|
||||
'Aktivity': 'nav.activities',
|
||||
'Sponzoři': 'nav.sponsors',
|
||||
'Články': 'nav.news',
|
||||
'Blog': 'nav.news',
|
||||
'Kalendář': 'nav.calendar',
|
||||
'Tabulky': 'nav.table'
|
||||
};
|
||||
const translationKey = labelMap[label];
|
||||
return translationKey ? t(translationKey) : label;
|
||||
};
|
||||
|
||||
const link: NavLink = {
|
||||
label: item.label,
|
||||
label: getTranslatedLabel(item.label),
|
||||
to: item.url || '#',
|
||||
external: item.type === 'external',
|
||||
};
|
||||
if (item.type === 'dropdown' && item.children && item.children.length > 0) {
|
||||
link.items = item.children.map(child => ({ label: child.label, to: child.url || '#' }));
|
||||
link.items = item.children.map(child => ({
|
||||
label: getTranslatedLabel(child.label),
|
||||
to: child.url || '#'
|
||||
}));
|
||||
}
|
||||
return link;
|
||||
};
|
||||
@@ -114,7 +141,7 @@ const SpartaNavbar: React.FC = () => {
|
||||
{ label: 'Hráči', to: '/hraci' },
|
||||
categoryItems.length > 0 ? { label: 'Články', to: '/blog', items: categoryItems } : { label: 'Články', to: '/blog' },
|
||||
{ label: 'Videa', to: '/videa' },
|
||||
{ label: settings?.gallery_label || 'Fotogalerie', to: '/galerie' },
|
||||
{ label: settings?.gallery_label || t('nav.gallery'), to: '/galerie' },
|
||||
...(settings?.shop_url ? [{ label: 'Fanshop', to: settings.shop_url, external: true } as NavLink] : []),
|
||||
{ label: 'Sponzoři', to: '/sponzori' },
|
||||
{ label: 'Kontakt', to: '/kontakt' },
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useToast } from '../../hooks/useToast';
|
||||
import { DataTable, Column } from '../common/DataTable';
|
||||
import { ToastContainer } from '../common/ToastContainer';
|
||||
import { exportToCSV, getExportFilename } from '../../utils/export';
|
||||
import { useConfirmDialog } from '../../contexts/ConfirmDialogContext';
|
||||
import './ArticleListExample.css';
|
||||
|
||||
interface Article {
|
||||
@@ -54,6 +55,7 @@ export const ArticleListExample: React.FC = () => {
|
||||
|
||||
// Toast notifications
|
||||
const toast = useToast();
|
||||
const { confirm } = useConfirmDialog();
|
||||
|
||||
// Delete mutation
|
||||
const deleteArticle = useApiDelete<void, { id: number }>((data) => `/articles/${data.id}`);
|
||||
@@ -92,7 +94,14 @@ export const ArticleListExample: React.FC = () => {
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!window.confirm('Are you sure you want to delete this article?')) return;
|
||||
const ok = await confirm({
|
||||
title: 'Delete article',
|
||||
message: 'Are you sure you want to delete this article?',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
isDanger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
const result = await deleteArticle.mutate({ id });
|
||||
if (result !== null) {
|
||||
@@ -106,7 +115,14 @@ export const ArticleListExample: React.FC = () => {
|
||||
// Handle batch delete
|
||||
const handleBatchDelete = async () => {
|
||||
const count = selection.selectedIds.size;
|
||||
if (!window.confirm(`Are you sure you want to delete ${count} articles?`)) return;
|
||||
const ok = await confirm({
|
||||
title: 'Delete selected articles',
|
||||
message: `Are you sure you want to delete ${count} articles?`,
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
isDanger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
// In real implementation, you'd call a batch delete API
|
||||
toast.info(`Would delete ${count} articles`);
|
||||
|
||||
@@ -12,8 +12,11 @@ import {
|
||||
Text,
|
||||
VStack,
|
||||
Link,
|
||||
useColorModeValue,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { ExternalLink, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface PhotoModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -30,61 +33,138 @@ const PhotoModal: React.FC<PhotoModalProps> = ({
|
||||
pageUrl,
|
||||
albumTitle,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [imageDimensions, setImageDimensions] = React.useState({ width: 0, height: 0 });
|
||||
const [imageLoaded, setImageLoaded] = React.useState(false);
|
||||
|
||||
// Color mode values
|
||||
const controlsBg = useColorModeValue('white', 'gray.800');
|
||||
const controlsBorder = useColorModeValue('gray.200', 'gray.600');
|
||||
const controlsText = useColorModeValue('gray.700', 'gray.200');
|
||||
|
||||
const handleImageLoad = (event: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const img = event.currentTarget;
|
||||
setImageDimensions({
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
});
|
||||
setImageLoaded(true);
|
||||
};
|
||||
|
||||
// Calculate if close button should be inside or outside based on image aspect ratio
|
||||
const shouldShowCloseOutside = imageDimensions.width > imageDimensions.height * 1.5;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
|
||||
<ModalOverlay bg="blackAlpha.800" backdropFilter="blur(10px)" />
|
||||
<ModalContent bg="transparent" boxShadow="none" maxW="90vw">
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="full"
|
||||
isCentered
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalOverlay
|
||||
bg="blackAlpha.900"
|
||||
backdropFilter="blur(8px)"
|
||||
/>
|
||||
|
||||
{!shouldShowCloseOutside && (
|
||||
<ModalCloseButton
|
||||
color="white"
|
||||
bg="blackAlpha.600"
|
||||
_hover={{ bg: 'blackAlpha.800' }}
|
||||
size="lg"
|
||||
top={2}
|
||||
right={2}
|
||||
zIndex={2}
|
||||
top={4}
|
||||
right={4}
|
||||
zIndex={10}
|
||||
borderRadius="full"
|
||||
/>
|
||||
<ModalBody p={0}>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* Image */}
|
||||
)}
|
||||
|
||||
<ModalContent
|
||||
bg="transparent"
|
||||
boxShadow="none"
|
||||
maxW="100vw"
|
||||
maxH="100vh"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
<ModalBody p={0} m={0}>
|
||||
<VStack spacing={0} align="stretch" h="100vh">
|
||||
{/* Image Container */}
|
||||
<Box
|
||||
position="relative"
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
maxH="80vh"
|
||||
flex={1}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
position="relative"
|
||||
minH="60vh"
|
||||
>
|
||||
<Image
|
||||
src={photoUrl}
|
||||
alt={albumTitle || 'Fotka'}
|
||||
maxH="80vh"
|
||||
maxW="100%"
|
||||
alt={albumTitle || t('gallery.photo_modal_title')}
|
||||
maxH="85vh"
|
||||
maxW="95vw"
|
||||
objectFit="contain"
|
||||
loading="lazy"
|
||||
loading="eager"
|
||||
onLoad={handleImageLoad}
|
||||
fallback={
|
||||
<Box
|
||||
w="400px"
|
||||
h="300px"
|
||||
bg="gray.200"
|
||||
borderRadius="lg"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text color="gray.500">{t('common.loading')}</Text>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Close button for wide images */}
|
||||
{shouldShowCloseOutside && imageLoaded && (
|
||||
<Button
|
||||
position="absolute"
|
||||
top={4}
|
||||
right={4}
|
||||
color="white"
|
||||
bg="blackAlpha.600"
|
||||
_hover={{ bg: 'blackAlpha.800' }}
|
||||
borderRadius="full"
|
||||
p={2}
|
||||
minW="auto"
|
||||
h="auto"
|
||||
onClick={onClose}
|
||||
zIndex={10}
|
||||
>
|
||||
<Icon as={X} boxSize={6} />
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Controls */}
|
||||
<Box
|
||||
bg="bg.elevated"
|
||||
borderWidth="1px"
|
||||
borderColor="border.subtle"
|
||||
borderRadius="lg"
|
||||
bg={controlsBg}
|
||||
borderTop="1px solid"
|
||||
borderColor={controlsBorder}
|
||||
p={4}
|
||||
boxShadow="xl"
|
||||
boxShadow="0 -4px 6px -1px rgba(0, 0, 0, 0.1)"
|
||||
>
|
||||
<VStack spacing={3} align="stretch">
|
||||
<VStack spacing={3} align="stretch" maxW="800px" mx="auto">
|
||||
{albumTitle && (
|
||||
<Text fontSize="md" fontWeight="600" color="gray.700">
|
||||
<Text
|
||||
fontSize="md"
|
||||
fontWeight="600"
|
||||
color={controlsText}
|
||||
textAlign="center"
|
||||
noOfLines={2}
|
||||
>
|
||||
{albumTitle}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack spacing={2} justify="flex-start" flexWrap="wrap">
|
||||
<HStack spacing={3} justify="center" flexWrap="wrap">
|
||||
<Button
|
||||
as="a"
|
||||
href={pageUrl}
|
||||
@@ -93,12 +173,20 @@ const PhotoModal: React.FC<PhotoModalProps> = ({
|
||||
leftIcon={<ExternalLink size={18} />}
|
||||
colorScheme="purple"
|
||||
size="sm"
|
||||
variant="solid"
|
||||
>
|
||||
Zobrazit originál
|
||||
{t('gallery.view_original')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={onClose}
|
||||
colorScheme="gray"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{/* Attribution moved into image overlay */}
|
||||
</VStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
|
||||
@@ -29,7 +29,7 @@ const BlogCard: React.FC<{ article: Article }> = ({ article }) => {
|
||||
>
|
||||
<Box position="relative" overflow="hidden">
|
||||
<Image
|
||||
src={assetUrl(article.image_url) || '/logo192.png'}
|
||||
src={assetUrl(article.image_url) || '/article-placeholder.svg'}
|
||||
alt={article.title}
|
||||
objectFit="cover"
|
||||
w="100%"
|
||||
|
||||
@@ -167,10 +167,10 @@ const BlogSwiper: React.FC<BlogSwiperProps> = ({ fallbackArticles = [] }) => {
|
||||
const { data: featuredData, isLoading: loadingFeatured } = useQuery({
|
||||
queryKey: ['featured-articles', { page: 1, page_size: 5 }],
|
||||
queryFn: () => getFeaturedArticles({ page: 1, page_size: 5 }),
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: true,
|
||||
refetchInterval: 15000,
|
||||
staleTime: 0,
|
||||
refetchInterval: 300000,
|
||||
staleTime: 60000,
|
||||
});
|
||||
// Fallback to latest published if no featured are available
|
||||
const { data: latestData } = useQuery({
|
||||
@@ -274,7 +274,12 @@ const BlogSwiper: React.FC<BlogSwiperProps> = ({ fallbackArticles = [] }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box position="relative" w="100%" overflow="hidden">
|
||||
<Box
|
||||
position="relative"
|
||||
w="100%"
|
||||
overflow="hidden"
|
||||
minH={{ base: '500px', md: '600px' }}
|
||||
>
|
||||
<AnimatePresence initial={false} custom={direction}>
|
||||
<MotionBox
|
||||
key={slideIndex}
|
||||
@@ -298,7 +303,9 @@ const BlogSwiper: React.FC<BlogSwiperProps> = ({ fallbackArticles = [] }) => {
|
||||
paginate(-1);
|
||||
}
|
||||
}}
|
||||
position="relative"
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
w="100%"
|
||||
h="100%"
|
||||
>
|
||||
|
||||
@@ -2,16 +2,18 @@ import React from 'react';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type ClubHeroTopbarVariant = 'brand' | 'minimal' | 'badge';
|
||||
|
||||
const cls = (...parts: Array<string | false | null | undefined>) => parts.filter(Boolean).join(' ');
|
||||
|
||||
const ClubHeroTopbar: React.FC<{ variant?: ClubHeroTopbarVariant; fullBleed?: boolean }>= ({ variant = 'minimal', fullBleed = false }) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: settings } = usePublicSettings();
|
||||
const theme = useClubTheme();
|
||||
const title = settings?.club_name || theme.name || 'Fotbalový klub';
|
||||
const tagline = 'Oficiální web klubu';
|
||||
const tagline = t('homepage.official_website');
|
||||
const logo = assetUrl(settings?.club_logo_url || theme.logoUrl) || settings?.club_logo_url || theme.logoUrl || '/dist/img/logo-club-empty.svg';
|
||||
const shopUrl = settings?.shop_url || undefined;
|
||||
const calendarUrl = '/kalendar';
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Flex,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TeamLogo } from '../common/TeamLogo';
|
||||
|
||||
interface ClubModalProps {
|
||||
@@ -43,6 +44,7 @@ interface ClubModalProps {
|
||||
}
|
||||
|
||||
const ClubModal: React.FC<ClubModalProps> = ({ isOpen, onClose, club, clubType = 'football' }) => {
|
||||
const { t } = useTranslation();
|
||||
if (!club) return null;
|
||||
|
||||
// Theme-aware colors
|
||||
@@ -92,7 +94,7 @@ const ClubModal: React.FC<ClubModalProps> = ({ isOpen, onClose, club, clubType =
|
||||
</Text>
|
||||
{club.rank && (
|
||||
<Badge colorScheme="blue" fontSize="sm">
|
||||
{club.rank}. místo
|
||||
{t('table.position_place', { position: club.rank })}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
@@ -110,57 +112,57 @@ const ClubModal: React.FC<ClubModalProps> = ({ isOpen, onClose, club, clubType =
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Text fontSize="md" fontWeight="semibold" mb={3} color={useColorModeValue('gray.700', 'gray.200')}>
|
||||
Statistiky
|
||||
{t('club_modal.statistics')}
|
||||
</Text>
|
||||
<VStack spacing={2} align="stretch">
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Odehráno zápasů:</Text>
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>{t('club_modal.matches_played')}:</Text>
|
||||
<Text fontWeight="bold" color={useColorModeValue('gray.800', 'gray.100')}>{club.played || 0}</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Výhry:</Text>
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>{t('club_modal.wins')}:</Text>
|
||||
<Text fontWeight="bold" color="green.600">
|
||||
{club.wins || 0}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Remízy:</Text>
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>{t('club_modal.draws')}:</Text>
|
||||
<Text fontWeight="bold" color={useColorModeValue('gray.600', 'gray.400')}>
|
||||
{club.draws || 0}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Prohry:</Text>
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>{t('club_modal.losses')}:</Text>
|
||||
<Text fontWeight="bold" color="red.600">
|
||||
{club.losses || 0}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Skóre:</Text>
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>{t('club_modal.score')}:</Text>
|
||||
<Text fontWeight="bold" color={useColorModeValue('gray.800', 'gray.100')}>{club.score || '0:0'}</Text>
|
||||
</HStack>
|
||||
{(club.goals_scored !== undefined || club.goals_conceded !== undefined) && (
|
||||
<>
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Vstřelené góly:</Text>
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>{t('club_modal.goals_scored')}:</Text>
|
||||
<Text fontWeight="bold" color="green.500">{club.goals_scored || 0}</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Obdržené góly:</Text>
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>{t('club_modal.goals_conceded')}:</Text>
|
||||
<Text fontWeight="bold" color="red.500">{club.goals_conceded || 0}</Text>
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
{club.goal_difference !== undefined && (
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Skóre rozdíl:</Text>
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>{t('club_modal.goal_difference')}:</Text>
|
||||
<Text fontWeight="bold" color={Number(club.goal_difference) >= 0 ? 'green.600' : 'red.600'}>
|
||||
{Number(club.goal_difference) > 0 ? '+' : ''}{club.goal_difference}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
<HStack justify="space-between" pt={2} borderTopWidth="1px" borderColor={borderColor}>
|
||||
<Text color={useColorModeValue('gray.700', 'gray.200')} fontWeight="semibold">Body:</Text>
|
||||
<Text color={useColorModeValue('gray.700', 'gray.200')} fontWeight="semibold">{t('club_modal.points')}:</Text>
|
||||
<Badge colorScheme="blue" fontSize="lg" px={3} py={1}>
|
||||
{club.points || 0}
|
||||
</Badge>
|
||||
@@ -178,7 +180,7 @@ const ClubModal: React.FC<ClubModalProps> = ({ isOpen, onClose, club, clubType =
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Text fontSize="md" fontWeight="semibold" mb={3} color={useColorModeValue('gray.700', 'gray.200')}>
|
||||
Forma (posledních 5 zápasů)
|
||||
{t('club_modal.form_last_5')}
|
||||
</Text>
|
||||
<HStack spacing={2} justify="center">
|
||||
{club.form.split('').map((result, idx) => (
|
||||
@@ -200,7 +202,7 @@ const ClubModal: React.FC<ClubModalProps> = ({ isOpen, onClose, club, clubType =
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button colorScheme="gray" onClick={onClose}>
|
||||
Zavřít
|
||||
{t('club_modal.close')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@@ -29,7 +29,7 @@ const FeaturedBlog: React.FC = () => {
|
||||
<GridItem>
|
||||
{main && (
|
||||
<Box as={RouterLink} to={main.slug ? `/news/${main.slug}` : `/articles/${main.id}`} position="relative" overflow="hidden" borderRadius="xl">
|
||||
<Image src={assetUrl(main.image_url) || '/logo192.png'} alt={main.title} w="100%" h={{ base: '220px', md: '320px' }} objectFit="cover" />
|
||||
<Image src={assetUrl(main.image_url) || '/article-placeholder.svg'} alt={main.title} w="100%" h={{ base: '220px', md: '320px' }} objectFit="cover" />
|
||||
<Box position="absolute" inset={0} bgGradient="linear(to-t, rgba(0,0,0,0.7), rgba(0,0,0,0.1))" />
|
||||
{/* Stats badges */}
|
||||
{((main.read_time || main.estimated_read_minutes) || (main.view_count && main.view_count > 0)) && (
|
||||
@@ -66,7 +66,7 @@ const FeaturedBlog: React.FC = () => {
|
||||
<VStack spacing={4} align="stretch">
|
||||
{[side1, side2].filter(Boolean).map((a) => (
|
||||
<HStack key={(a as Article).id} align="stretch" spacing={3} as={RouterLink} to={(a as Article).slug ? `/news/${(a as Article).slug}` : `/articles/${(a as Article).id}`} position="relative">
|
||||
<Image src={assetUrl((a as Article).image_url) || '/logo192.png'} alt={(a as Article).title} w="40%" h="120px" objectFit="cover" borderRadius="lg" />
|
||||
<Image src={assetUrl((a as Article).image_url) || '/article-placeholder.svg'} alt={(a as Article).title} w="40%" h="120px" objectFit="cover" borderRadius="lg" />
|
||||
<VStack align="stretch" spacing={2} flex={1}>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{((a as Article).read_time || (a as Article).estimated_read_minutes) && (
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { Calendar, Image as ImageIcon, ExternalLink, ArrowRight } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
@@ -35,8 +36,20 @@ const resolveBackendUrl = (path: string) => {
|
||||
try {
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
|
||||
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
|
||||
const abs = new URL(path, origin);
|
||||
// Prefer explicit asset base (backend origin) when provided; fallback to API_URL origin
|
||||
let origin = '';
|
||||
try {
|
||||
const assetBase = (process.env.REACT_APP_ASSET_BASE_URL || '').trim();
|
||||
if (assetBase) {
|
||||
origin = new URL(assetBase, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
|
||||
}
|
||||
} catch {}
|
||||
if (!origin) {
|
||||
try {
|
||||
origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
|
||||
} catch {}
|
||||
}
|
||||
const abs = new URL(path, origin || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'));
|
||||
return abs.toString();
|
||||
}
|
||||
return path;
|
||||
@@ -46,6 +59,7 @@ const resolveBackendUrl = (path: string) => {
|
||||
};
|
||||
|
||||
const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl }) => {
|
||||
const { t } = useTranslation();
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [profileUrl, setProfileUrl] = useState<string | null>(null);
|
||||
@@ -172,10 +186,10 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
|
||||
<HStack justify="space-between" align="center" flexWrap="wrap">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Heading size="xl" color={headingColor} id="home-gallery-heading">
|
||||
Fotogalerie
|
||||
{t('homepage.gallery')}
|
||||
</Heading>
|
||||
<Text color={textColor} fontSize="sm">
|
||||
Nejnovější alba z našich akcí
|
||||
{t('gallery.latest_albums')}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
@@ -187,7 +201,7 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
|
||||
variant="outline"
|
||||
size="md"
|
||||
>
|
||||
Zobrazit vše
|
||||
{t('nav.view_all')}
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
@@ -228,10 +242,9 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
|
||||
key={album.id}
|
||||
as={RouterLink}
|
||||
to={`/galerie/album/${album.id}`}
|
||||
className="card"
|
||||
bg={cardBg}
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
boxShadow="md"
|
||||
transition="all 0.3s"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
@@ -305,7 +318,7 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
>
|
||||
Zobrazit všechna alba
|
||||
{t('gallery.view_all_albums')}
|
||||
</Button>
|
||||
</Box>
|
||||
</VStack>
|
||||
|
||||
@@ -18,7 +18,7 @@ const HeaderVariants: React.FC<HeaderVariantsProps> = ({
|
||||
}) => {
|
||||
const displayLogo = (assetUrl(clubLogo) || clubLogo) || (clubId
|
||||
? `http://logoapi.sportcreative.eu/logos/${clubId}?format=svg`
|
||||
: '/images/club-logo.png');
|
||||
: '/images/club-logo-fallback.svg');
|
||||
|
||||
// Unified variant - classic header
|
||||
if (variant === 'unified') {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, HStack, Text, VStack, Box, Heading } from '@chakra-ui/react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
export const HomePageHero = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box bg="blue.600" color="white" py={20} px={4}>
|
||||
<VStack maxW="4xl" mx="auto" textAlign="center" spacing={6}>
|
||||
<Heading size="2xl" as="h1">
|
||||
{t('common.welcome_message')}
|
||||
</Heading>
|
||||
<Text fontSize="xl">
|
||||
{t('common.welcome_subtitle')}
|
||||
</Text>
|
||||
<HStack spacing={4}>
|
||||
<Button as={RouterLink} to="/matches" colorScheme="white" variant="solid">
|
||||
{t('nav.matches')}
|
||||
</Button>
|
||||
<Button as={RouterLink} to="/gallery" colorScheme="white" variant="outline">
|
||||
{t('nav.gallery')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -3,11 +3,13 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { facrApi } from '../../services/facr/facrApi';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
import ClubModal from './ClubModal';
|
||||
import { TeamLogo } from '../common/TeamLogo';
|
||||
|
||||
const LeagueTablePro: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: settings } = usePublicSettings();
|
||||
const clubId = settings?.club_id;
|
||||
const clubType = settings?.club_type || 'football';
|
||||
@@ -44,7 +46,7 @@ const LeagueTablePro: React.FC = () => {
|
||||
return (
|
||||
<Box borderRadius="lg" overflow="hidden" bg="white" boxShadow="sm" borderWidth="1px" borderColor="gray.100">
|
||||
<Box bg="primary.600" px={4} py={2} borderBottomWidth="1px" borderColor="primary.700">
|
||||
<Heading size="md" color="white">Tabulka</Heading>
|
||||
<Heading size="md" color="white">{t('nav.table')}</Heading>
|
||||
</Box>
|
||||
|
||||
<Tabs variant="enclosed" colorScheme="gray" size="sm">
|
||||
@@ -78,14 +80,14 @@ const LeagueTablePro: React.FC = () => {
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead position="sticky" top={0} zIndex={1} bg="gray.50">
|
||||
<Tr borderBottomWidth="1px" borderColor="gray.200">
|
||||
<Th width="8" px={2} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">#</Th>
|
||||
<Th px={3} color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Tým</Th>
|
||||
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Z</Th>
|
||||
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">V</Th>
|
||||
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">R</Th>
|
||||
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">P</Th>
|
||||
<Th width="16" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Skóre</Th>
|
||||
<Th width="14" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Body</Th>
|
||||
<Th width="8" px={2} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">{t('table.headers.position')}</Th>
|
||||
<Th px={3} color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">{t('table.headers.team')}</Th>
|
||||
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">{t('table.headers.played')}</Th>
|
||||
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">{t('table.headers.wins')}</Th>
|
||||
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">{t('table.headers.draws')}</Th>
|
||||
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">{t('table.headers.losses')}</Th>
|
||||
<Th width="16" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">{t('table.headers.score')}</Th>
|
||||
<Th width="14" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">{t('table.headers.points')}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
|
||||
@@ -14,10 +14,25 @@ import {
|
||||
Badge,
|
||||
Link,
|
||||
Divider,
|
||||
Box,
|
||||
Grid,
|
||||
Flex,
|
||||
Image,
|
||||
} from '@chakra-ui/react';
|
||||
import { useCountdown } from '../../hooks/useCountdown';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { assetUrl, sanitizeClubName } from '../../utils/url';
|
||||
import { TeamLogo } from '../common/TeamLogo';
|
||||
import MatchWeather from '../common/MatchWeather';
|
||||
import { format, parse } from 'date-fns';
|
||||
import { cs } from 'date-fns/locale';
|
||||
|
||||
const parseScore = (score?: string | null): { h: number; a: number } | null => {
|
||||
if (!score) return null;
|
||||
const m = score.match(/^(\d+)\s*[:\-]\s*(\d+)$/);
|
||||
if (!m) return null;
|
||||
return { h: parseInt(m[1], 10), a: parseInt(m[2], 10) };
|
||||
};
|
||||
|
||||
export type FacrMatchLike = {
|
||||
id?: string | number;
|
||||
@@ -69,6 +84,8 @@ const formatWhen = (m: FacrMatchLike | null) => {
|
||||
};
|
||||
|
||||
export const MatchModal: React.FC<MatchModalProps> = ({ isOpen, match, onClose, onTeamClick }) => {
|
||||
const { settings } = useSettings();
|
||||
|
||||
const kickoffIso = useMemo(() => {
|
||||
if (!match) return null;
|
||||
if (match.date && match.time) return `${match.date}T${(match.time || '00:00')}:00`;
|
||||
@@ -90,8 +107,16 @@ export const MatchModal: React.FC<MatchModalProps> = ({ isOpen, match, onClose,
|
||||
const matchStarted = kickoffIso ? new Date(kickoffIso).getTime() <= Date.now() : false;
|
||||
const hasScore = match?.score && match.score.trim() !== '';
|
||||
|
||||
// Check if this is a home match
|
||||
const isHomeMatch = useMemo(() => {
|
||||
if (!match?.home || !settings) return false;
|
||||
const homeTeam = sanitizeClubName(match.home).toLowerCase();
|
||||
const clubName = sanitizeClubName((settings as any)?.club_name || '').toLowerCase();
|
||||
return homeTeam.includes(clubName) || clubName.includes(homeTeam);
|
||||
}, [match, settings]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} isCentered size={{ base: 'md', md: 'lg' }}>
|
||||
<Modal isOpen={isOpen} onClose={onClose} isCentered size="lg" returnFocusOnClose={false}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
@@ -101,91 +126,273 @@ export const MatchModal: React.FC<MatchModalProps> = ({ isOpen, match, onClose,
|
||||
<ModalBody>
|
||||
{match && (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<HStack justify="space-between" align="center">
|
||||
<VStack
|
||||
align="center"
|
||||
spacing={2}
|
||||
flex={1}
|
||||
minW={0}
|
||||
cursor={onTeamClick ? 'pointer' : 'default'}
|
||||
onClick={() => onTeamClick && onTeamClick(match.home || '', match.home_logo_url)}
|
||||
_hover={onTeamClick ? { opacity: 0.8, transform: 'scale(1.05)' } : {}}
|
||||
transition="all 0.2s"
|
||||
role={onTeamClick ? 'button' : undefined}
|
||||
tabIndex={onTeamClick ? 0 : undefined}
|
||||
>
|
||||
<TeamLogo
|
||||
teamId={match.home_id}
|
||||
teamName={match.home}
|
||||
facrLogo={match.home_logo_url}
|
||||
size="custom"
|
||||
alt={match.home || 'Domácí'}
|
||||
boxSize="56px"
|
||||
{/* Show match status (Výhra/Remíza/Prohra) if determinable; otherwise fall back to competition name */}
|
||||
{(() => {
|
||||
const isPast = kickoffIso ? new Date(kickoffIso).getTime() <= Date.now() : false;
|
||||
const hasScore = match?.score && match.score.trim() !== '';
|
||||
|
||||
if (!isPast) {
|
||||
// Future match - show competition name
|
||||
const compName = match.competition || match.competitionName;
|
||||
if (compName && compName !== 'Všechny soutěže') {
|
||||
return (
|
||||
<Flex justify="center">
|
||||
<Badge colorScheme="purple">{compName}</Badge>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
} else if (hasScore) {
|
||||
// Past match with score - show sentiment
|
||||
const s = parseScore(match.score);
|
||||
if (s) {
|
||||
const ourIsHome = settings && match.home && (
|
||||
sanitizeClubName(match.home).toLowerCase().includes(sanitizeClubName((settings as any)?.club_name || '').toLowerCase()) ||
|
||||
sanitizeClubName((settings as any)?.club_name || '').toLowerCase().includes(sanitizeClubName(match.home).toLowerCase())
|
||||
);
|
||||
const ourIsAway = settings && match.away && (
|
||||
sanitizeClubName(match.away).toLowerCase().includes(sanitizeClubName((settings as any)?.club_name || '').toLowerCase()) ||
|
||||
sanitizeClubName((settings as any)?.club_name || '').toLowerCase().includes(sanitizeClubName(match.away).toLowerCase())
|
||||
);
|
||||
|
||||
if (ourIsHome || ourIsAway) {
|
||||
const ourGoals = ourIsHome ? s.h : s.a;
|
||||
const oppGoals = ourIsHome ? s.a : s.h;
|
||||
let label = 'Remíza';
|
||||
let color = 'blue';
|
||||
if (ourGoals > oppGoals) { label = 'Výhra'; color = 'green'; }
|
||||
else if (ourGoals < oppGoals) { label = 'Prohra'; color = 'red'; }
|
||||
|
||||
return (
|
||||
<Flex justify="center">
|
||||
<Badge colorScheme={color as any} variant="subtle">{label}</Badge>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
<Flex align="center" justify="center" gap={3}>
|
||||
{match.home_logo_url && (
|
||||
<Image
|
||||
src={match.home_logo_url}
|
||||
alt={match.home}
|
||||
boxSize="40px"
|
||||
borderRadius="full"
|
||||
cursor="pointer"
|
||||
onClick={() => onTeamClick && onTeamClick(match.home || '', match.home_logo_url)}
|
||||
_hover={{ opacity: 0.8, transform: 'scale(1.1)' }}
|
||||
transition="all 0.2s"
|
||||
title={`Klikněte pro zobrazení statistik: ${match.home}`}
|
||||
/>
|
||||
<Text fontWeight="semibold" noOfLines={1} textAlign="center">{sanitizeClubName(match.home) || 'Domácí'}</Text>
|
||||
</VStack>
|
||||
<VStack spacing={1} minW="120px">
|
||||
{!matchStarted ? (
|
||||
// Future match - show countdown or vs
|
||||
isActive && countdownString ? (
|
||||
<>
|
||||
<Text fontSize="lg" color="gray.600">Začátek za</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold">{countdownString}</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text fontSize="2xl" fontWeight="bold">vs</Text>
|
||||
</>
|
||||
)
|
||||
) : hasScore ? (
|
||||
// Match finished with score
|
||||
<>
|
||||
<Text fontSize="2xl" fontWeight="bold">{match.score}</Text>
|
||||
<Text fontSize="sm" color="gray.600">Skončeno</Text>
|
||||
</>
|
||||
) : (
|
||||
// Match started but no score yet
|
||||
<>
|
||||
<Text fontSize="2xl" fontWeight="bold">—:—</Text>
|
||||
<Text fontSize="sm" color="green.600">Probíhá</Text>
|
||||
</>
|
||||
)}
|
||||
{(match.competition || match.competitionName) && (
|
||||
<Badge colorScheme="blue" variant="subtle">{match.competition || match.competitionName}</Badge>
|
||||
)}
|
||||
</VStack>
|
||||
<VStack
|
||||
align="center"
|
||||
spacing={2}
|
||||
flex={1}
|
||||
minW={0}
|
||||
cursor={onTeamClick ? 'pointer' : 'default'}
|
||||
onClick={() => onTeamClick && onTeamClick(match.away || '', match.away_logo_url)}
|
||||
_hover={onTeamClick ? { opacity: 0.8, transform: 'scale(1.05)' } : {}}
|
||||
transition="all 0.2s"
|
||||
role={onTeamClick ? 'button' : undefined}
|
||||
tabIndex={onTeamClick ? 0 : undefined}
|
||||
>
|
||||
<TeamLogo
|
||||
teamId={match.away_id}
|
||||
teamName={match.away}
|
||||
facrLogo={match.away_logo_url}
|
||||
size="custom"
|
||||
alt={match.away || 'Hosté'}
|
||||
boxSize="56px"
|
||||
)}
|
||||
{(() => {
|
||||
const isPast = kickoffIso ? new Date(kickoffIso).getTime() <= Date.now() : false;
|
||||
const hasScore = Boolean(match.score);
|
||||
|
||||
// For future matches, always show countdown or "vs" - never score
|
||||
if (!isPast) {
|
||||
if (isActive && countdownString) {
|
||||
return (
|
||||
<Badge colorScheme="orange" fontSize="md" px={3} py={1}>za {countdownString}</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge colorScheme="gray" fontSize="md" px={3} py={1}>vs</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// For past matches, show score or "vs"
|
||||
return (
|
||||
<Badge colorScheme={hasScore ? 'gray' : 'gray'} fontSize="md" px={3} py={1}>
|
||||
{hasScore ? match.score : 'vs'}
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
{match.away_logo_url && (
|
||||
<Image
|
||||
src={match.away_logo_url}
|
||||
alt={match.away}
|
||||
boxSize="40px"
|
||||
borderRadius="full"
|
||||
cursor="pointer"
|
||||
onClick={() => onTeamClick && onTeamClick(match.away || '', match.away_logo_url)}
|
||||
_hover={{ opacity: 0.8, transform: 'scale(1.1)' }}
|
||||
transition="all 0.2s"
|
||||
title={`Klikněte pro zobrazení statistik: ${match.away}`}
|
||||
/>
|
||||
<Text fontWeight="semibold" noOfLines={1} textAlign="center">{sanitizeClubName(match.away) || 'Hosté'}</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Divider />
|
||||
|
||||
<VStack align="stretch" spacing={1} color="gray.700">
|
||||
{when && <Text><strong>Kdy:</strong> {when}</Text>}
|
||||
{match.venue && <Text><strong>Kde:</strong> {match.venue}</Text>}
|
||||
</VStack>
|
||||
{/* Date and Time Display with Countdown */}
|
||||
<Box textAlign="center">
|
||||
<Text fontSize="lg" fontWeight="semibold" color="gray.800" mb={1}>
|
||||
{(() => {
|
||||
try {
|
||||
// Try multiple date formats and use Czech locale
|
||||
if (match.date) {
|
||||
let dateObj: Date;
|
||||
// Try yyyy-MM-dd format first
|
||||
const parsed1 = parse(match.date, 'yyyy-MM-dd', new Date());
|
||||
if (!isNaN(parsed1.getTime())) {
|
||||
dateObj = parsed1;
|
||||
} else {
|
||||
// Try dd.MM.yyyy format
|
||||
const parsed2 = parse(match.date, 'dd.MM.yyyy', new Date());
|
||||
if (!isNaN(parsed2.getTime())) {
|
||||
dateObj = parsed2;
|
||||
} else {
|
||||
return match.date;
|
||||
}
|
||||
}
|
||||
return format(dateObj, 'EEEE d. MMMM yyyy', { locale: cs });
|
||||
}
|
||||
return '';
|
||||
} catch (error) {
|
||||
console.error('Date formatting error:', error);
|
||||
return match.date || '';
|
||||
}
|
||||
})()}
|
||||
</Text>
|
||||
<Text fontSize="md" color="gray.700">
|
||||
{match.time || '—'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Enhanced Countdown Display for Upcoming Matches */}
|
||||
{(() => {
|
||||
const isPast = kickoffIso ? new Date(kickoffIso).getTime() <= Date.now() : false;
|
||||
const hasScore = Boolean(match.score);
|
||||
|
||||
if (!hasScore && !isPast && isActive && timeRemaining && timeRemaining > 0) {
|
||||
const days = Math.floor(timeRemaining / (24 * 60 * 60 * 1000));
|
||||
const hours = Math.floor((timeRemaining % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000));
|
||||
const minutes = Math.floor((timeRemaining % (60 * 60 * 1000)) / (60 * 1000));
|
||||
const seconds = Math.floor((timeRemaining % (60 * 1000)) / 1000);
|
||||
|
||||
return (
|
||||
<Box
|
||||
mt={4}
|
||||
p={4}
|
||||
bg="orange.50"
|
||||
borderRadius="lg"
|
||||
borderWidth="2px"
|
||||
borderColor="orange.200"
|
||||
>
|
||||
<Text fontSize="sm" fontWeight="semibold" color="orange.800" mb={3} textAlign="center">
|
||||
Zápas začíná za
|
||||
</Text>
|
||||
<Grid
|
||||
templateColumns={days > 0 ? "repeat(4, 1fr)" : "repeat(3, 1fr)"}
|
||||
gap={3}
|
||||
>
|
||||
{days > 0 && (
|
||||
<Box textAlign="center">
|
||||
<Box
|
||||
bg="white"
|
||||
borderRadius="md"
|
||||
p={3}
|
||||
borderWidth="1px"
|
||||
borderColor="orange.300"
|
||||
boxShadow="sm"
|
||||
>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
|
||||
{days}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text fontSize="xs" color="gray.600" mt={1} fontWeight="medium">
|
||||
{days === 1 ? 'den' : days < 5 ? 'dny' : 'dní'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box textAlign="center">
|
||||
<Box
|
||||
bg="white"
|
||||
borderRadius="md"
|
||||
p={3}
|
||||
borderWidth="1px"
|
||||
borderColor="orange.300"
|
||||
boxShadow="sm"
|
||||
>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
|
||||
{String(hours).padStart(2, '0')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text fontSize="xs" color="gray.600" mt={1} fontWeight="medium">
|
||||
{hours === 1 ? 'hodina' : hours < 5 ? 'hodiny' : 'hodin'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box textAlign="center">
|
||||
<Box
|
||||
bg="white"
|
||||
borderRadius="md"
|
||||
p={3}
|
||||
borderWidth="1px"
|
||||
borderColor="orange.300"
|
||||
boxShadow="sm"
|
||||
>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
|
||||
{String(minutes).padStart(2, '0')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text fontSize="xs" color="gray.600" mt={1} fontWeight="medium">
|
||||
{minutes === 1 ? 'minuta' : minutes < 5 ? 'minuty' : 'minut'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box textAlign="center">
|
||||
<Box
|
||||
bg="white"
|
||||
borderRadius="md"
|
||||
p={3}
|
||||
borderWidth="1px"
|
||||
borderColor="orange.300"
|
||||
boxShadow="sm"
|
||||
>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
|
||||
{String(seconds).padStart(2, '0')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text fontSize="xs" color="gray.600" mt={1} fontWeight="medium">
|
||||
{seconds === 1 ? 'sekunda' : seconds < 5 ? 'sekundy' : 'sekund'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
<Box h="1px" bg="gray.200" />
|
||||
|
||||
{/* Venue information */}
|
||||
{match.venue && (
|
||||
<Text fontSize="md" color="gray.700" textAlign="center">
|
||||
<strong>Kde:</strong> {match.venue}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Weather widget for home matches that haven't started */}
|
||||
{(() => {
|
||||
const isPast = kickoffIso ? new Date(kickoffIso).getTime() <= Date.now() : false;
|
||||
const hasScore = Boolean(match.score);
|
||||
|
||||
if (isHomeMatch && !isPast && !hasScore) {
|
||||
return (
|
||||
<Box>
|
||||
<MatchWeather
|
||||
matchDateTime={kickoffIso || ''}
|
||||
venue={match.venue}
|
||||
isHomeMatch={true}
|
||||
matchHasStarted={false}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
|
||||
@@ -8,6 +8,7 @@ const MerchSection: React.FC<{ variant?: 'grid' | 'carousel' | 'featured' | 'lis
|
||||
const [items, setItems] = useState<ClothingItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const eshopUrl = process.env.REACT_APP_ESHOP_URL;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchItems = async () => {
|
||||
@@ -33,9 +34,23 @@ const MerchSection: React.FC<{ variant?: 'grid' | 'carousel' | 'featured' | 'lis
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={3}>
|
||||
<Heading as="h3" size="md">Oblečení týmu</Heading>
|
||||
<Link as={RouterLink} to="/obleceni">
|
||||
<Button size="sm" variant="outline" colorScheme="blue">Zobrazit vše</Button>
|
||||
</Link>
|
||||
<HStack spacing={2}>
|
||||
<Link as={RouterLink} to="/obleceni">
|
||||
<Button size="sm" variant="outline" colorScheme="blue">Zobrazit vše</Button>
|
||||
</Link>
|
||||
{eshopUrl && (
|
||||
<Button
|
||||
as={Link}
|
||||
href={eshopUrl}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
variant="solid"
|
||||
isExternal
|
||||
>
|
||||
E-shop
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
<HorizontalScroller draggable>
|
||||
{items.map((it) => (
|
||||
@@ -48,9 +63,7 @@ const MerchSection: React.FC<{ variant?: 'grid' | 'carousel' | 'featured' | 'lis
|
||||
<Box
|
||||
className="card"
|
||||
bg={cardBg}
|
||||
borderRadius="xl"
|
||||
overflow="hidden"
|
||||
boxShadow="sm"
|
||||
borderWidth="1px"
|
||||
transition="all 0.2s"
|
||||
_hover={{ transform: 'translateY(-4px)', boxShadow: 'md' }}
|
||||
@@ -84,9 +97,23 @@ const MerchSection: React.FC<{ variant?: 'grid' | 'carousel' | 'featured' | 'lis
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={3}>
|
||||
<Heading as="h3" size="md">Oblečení týmu</Heading>
|
||||
<Link as={RouterLink} to="/obleceni">
|
||||
<Button size="sm" variant="outline" colorScheme="blue">Zobrazit vše</Button>
|
||||
</Link>
|
||||
<HStack spacing={2}>
|
||||
<Link as={RouterLink} to="/obleceni">
|
||||
<Button size="sm" variant="outline" colorScheme="blue">Zobrazit vše</Button>
|
||||
</Link>
|
||||
{eshopUrl && (
|
||||
<Button
|
||||
as={Link}
|
||||
href={eshopUrl}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
variant="solid"
|
||||
isExternal
|
||||
>
|
||||
E-shop
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
<SimpleGrid columns={{ base: 2, md: 3, lg: 5 }} spacing={4}>
|
||||
{items.map((it) => (
|
||||
@@ -99,9 +126,7 @@ const MerchSection: React.FC<{ variant?: 'grid' | 'carousel' | 'featured' | 'lis
|
||||
<Box
|
||||
className="card"
|
||||
bg={cardBg}
|
||||
borderRadius="xl"
|
||||
overflow="hidden"
|
||||
boxShadow="sm"
|
||||
borderWidth="1px"
|
||||
transition="all 0.2s"
|
||||
_hover={{ transform: 'translateY(-4px)', boxShadow: 'md' }}
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { API_URL } from '../../services/api';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { Calendar, Image as ImageIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
@@ -32,6 +33,7 @@ const resolveBackendUrl = (path: string) => {
|
||||
};
|
||||
|
||||
const PhotosSection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl }) => {
|
||||
const { t } = useTranslation();
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
@@ -62,7 +64,7 @@ const PhotosSection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
|
||||
return (
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={3}>
|
||||
<Heading size="lg">Fotogalerie</Heading>
|
||||
<Heading size="lg">{t('homepage.gallery')}</Heading>
|
||||
<Button as={RouterLink} to="/galerie" size="sm" variant="outline">
|
||||
Zobrazit vše
|
||||
</Button>
|
||||
@@ -70,7 +72,7 @@ const PhotosSection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
|
||||
|
||||
{showSetupHint && (
|
||||
<Box bg="yellow.50" borderWidth="1px" borderColor="yellow.200" color="yellow.800" p={3} borderRadius="md" mb={3}>
|
||||
<Text>Žádné fotky nejsou k dispozici. Zadejte prosím odkaz na Zonerama v nastavení (Sociální sítě → Fotogalerie) a my ji budeme automaticky načítat.</Text>
|
||||
<Text>Žádné fotky nejsou k dispozici. Zadejte prosím odkaz na Zonerama v nastavení (Sociální sítě → Photos) a my ji budeme automaticky načítat.</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { facrApi } from '../../services/facr/facrApi';
|
||||
import { FACR_CLUB_ID, FACR_CLUB_TYPE } from '../../config/facr';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ClubModal from './ClubModal';
|
||||
import { TeamLogo } from '../common/TeamLogo';
|
||||
|
||||
const TableSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: settings } = usePublicSettings();
|
||||
const clubId = settings?.club_id || FACR_CLUB_ID;
|
||||
const clubType = settings?.club_type || FACR_CLUB_TYPE;
|
||||
@@ -223,7 +225,7 @@ const TableSection: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Heading size="lg" mb={4}>Tabulka soutěží</Heading>
|
||||
<Heading size="lg" mb={4}>{t('homepage.tables')}</Heading>
|
||||
{!clubId && (
|
||||
<Text color="orange.500" mb={4}>Nastavte klub v Nastavení (Admin) nebo REACT_APP_FACR_CLUB_ID pro načtení tabulek z FAČR.</Text>
|
||||
)}
|
||||
@@ -289,14 +291,14 @@ const TableSection: React.FC = () => {
|
||||
<Table size="sm" variant="striped" colorScheme="gray">
|
||||
<Thead position="sticky" top={0} zIndex={1} bg="brand.primary">
|
||||
<Tr>
|
||||
<Th color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'rank')}># {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'rank')}</Th>
|
||||
<Th color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'team')}>Tým {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'team')}</Th>
|
||||
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'played')}>Z {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'played')}</Th>
|
||||
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'wins')}>V {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'wins')}</Th>
|
||||
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'draws')}>R {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'draws')}</Th>
|
||||
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'losses')}>P {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'losses')}</Th>
|
||||
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'score')}>Skóre {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'score')}</Th>
|
||||
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'points')}>Body {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'points')}</Th>
|
||||
<Th color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'rank')}>{t('tables.rank')} {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'rank')}</Th>
|
||||
<Th color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'team')}>{t('tables.team')} {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'team')}</Th>
|
||||
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'played')}>{t('tables.played')} {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'played')}</Th>
|
||||
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'wins')}>{t('tables.wins')} {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'wins')}</Th>
|
||||
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'draws')}>{t('tables.draws')} {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'draws')}</Th>
|
||||
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'losses')}>{t('tables.losses')} {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'losses')}</Th>
|
||||
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'score')}>{t('tables.score')} {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'score')}</Th>
|
||||
<Th isNumeric color="text.onPrimary" cursor="pointer" onClick={() => toggleSort(String(c.id ?? c.code ?? c.name ?? 'comp'), 'points')}>{t('tables.points')} {arrow(String(c.id ?? c.code ?? c.name ?? 'comp'), 'points')}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
|
||||
@@ -15,7 +15,7 @@ const TeamScroller: React.FC = () => {
|
||||
<HStack spacing={6} overflowX="auto" py={2} className="hide-scrollbar">
|
||||
{players.map((p: Player) => (
|
||||
<VStack key={p.id} minW="160px" spacing={2} bg={useColorModeValue('white', 'gray.800')} borderRadius="xl" p={4} boxShadow="sm" borderWidth="1px" borderColor={useColorModeValue('gray.200', 'gray.700')}>
|
||||
<Image src={assetUrl(p.image_url) || '/logo192.png'} alt={p.first_name + ' ' + p.last_name} w="140px" h="140px" objectFit="cover" borderRadius="lg" fallbackSrc="/dist/img/logo-club-empty.svg" />
|
||||
<Image src={assetUrl(p.image_url) || '/player-placeholder.svg'} alt={p.first_name + ' ' + p.last_name} w="140px" h="140px" objectFit="cover" borderRadius="lg" fallbackSrc="/dist/img/logo-club-empty.svg" />
|
||||
<Text fontWeight="bold" textAlign="center">{p.first_name} {p.last_name}</Text>
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>{p.position}</Text>
|
||||
{null}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, AspectRatio, Text, useColorModeValue, SimpleGrid, Heading, HStack, Badge, Button, Link, Modal, ModalOverlay, ModalContent, ModalBody, ModalCloseButton, useDisclosure, Icon, VStack } from '@chakra-ui/react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { FaYoutube, FaPlay } from 'react-icons/fa';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import HorizontalScroller from '../ui/HorizontalScroller';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
@@ -41,6 +42,7 @@ const toEmbed = (idOrUrl: string): string => {
|
||||
};
|
||||
|
||||
const VideosSection: React.FC<Props> = ({ videos, variant }) => {
|
||||
const { t } = useTranslation();
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const theme = useClubTheme();
|
||||
const { data: settings } = usePublicSettings();
|
||||
@@ -166,9 +168,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
|
||||
<Box
|
||||
className="video-card card"
|
||||
bg={cardBg}
|
||||
borderRadius="xl"
|
||||
overflow="hidden"
|
||||
boxShadow="sm"
|
||||
borderWidth="2px"
|
||||
borderColor={borderColor}
|
||||
transition="all 0.3s"
|
||||
@@ -193,7 +193,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
|
||||
cursor="pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Přehrát video: ${it.title}`}
|
||||
aria-label={`${t('action.play')}: ${it.title}`}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handlePlayClick(it); } }}
|
||||
onClick={() => handlePlayClick(it)}
|
||||
>
|
||||
@@ -262,7 +262,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
|
||||
boxShadow="0 12px 32px rgba(0,0,0,0.4)"
|
||||
>
|
||||
<Icon as={FaPlay} boxSize={5} />
|
||||
<Text fontSize="lg">Přehrát</Text>
|
||||
<Text fontSize="lg">{t('action.play')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -302,7 +302,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
|
||||
<Box>
|
||||
<Box className="section-head" style={{ marginTop: 8, marginBottom: 16 }}>
|
||||
<HStack spacing={3}>
|
||||
<Heading as="h3" size="lg" fontWeight="700" id="home-videos-heading">Videa</Heading>
|
||||
<Heading as="h3" size="lg" fontWeight="700" id="home-videos-heading">{t('nav.videos')}</Heading>
|
||||
</HStack>
|
||||
<Link as={RouterLink} to="/videa">
|
||||
<Button
|
||||
@@ -314,7 +314,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
|
||||
_hover={{ opacity: 0.9, transform: 'translateX(4px)' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
Více videí
|
||||
{t('homepage.more_videos')}
|
||||
</Button>
|
||||
</Link>
|
||||
</Box>
|
||||
@@ -362,7 +362,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
|
||||
</VStack>
|
||||
{selectedVideo.videoId && (
|
||||
<Link href={`https://www.youtube.com/watch?v=${selectedVideo.videoId}`} isExternal>
|
||||
<Button size="sm" colorScheme="red" leftIcon={<Icon as={FaYoutube} />}>Otevřít na YouTube</Button>
|
||||
<Button size="sm" colorScheme="red" leftIcon={<Icon as={FaYoutube} />}>{t('action.open_on_youtube')}</Button>
|
||||
</Link>
|
||||
)}
|
||||
</HStack>
|
||||
@@ -385,9 +385,9 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
|
||||
return (
|
||||
<Box>
|
||||
<Box className="section-head">
|
||||
<Heading as="h3" size="md" id="home-videos-heading">Videa</Heading>
|
||||
<Heading as="h3" size="md" id="home-videos-heading">{t('nav.videos')}</Heading>
|
||||
<Link as={RouterLink} to="/videa">
|
||||
<Button size="sm" variant="outline" colorScheme="blue">Více videí</Button>
|
||||
<Button size="sm" variant="outline" colorScheme="blue">{t('homepage.more_videos')}</Button>
|
||||
</Link>
|
||||
</Box>
|
||||
<SimpleGrid key={`videos-grid-${style}-${items.length}`} columns={cols} spacing={4}>
|
||||
@@ -412,7 +412,6 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
style={{ borderRadius: '8px' }}
|
||||
/>
|
||||
</AspectRatio>
|
||||
<Box bg={useColorModeValue('white', 'gray.800')} p={4} borderRadius="md" mt={2}>
|
||||
@@ -427,7 +426,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
|
||||
</VStack>
|
||||
{selectedVideo.videoId && (
|
||||
<Link href={`https://www.youtube.com/watch?v=${selectedVideo.videoId}`} isExternal>
|
||||
<Button size="sm" colorScheme="red" leftIcon={<Icon as={FaYoutube} />}>Otevřít na YouTube</Button>
|
||||
<Button size="sm" colorScheme="red" leftIcon={<Icon as={FaYoutube} />}>{t('action.open_on_youtube')}</Button>
|
||||
</Link>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import { API_URL } from '../../services/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const resolveBackendUrl = (path: string) => {
|
||||
try {
|
||||
@@ -32,6 +33,7 @@ interface Sponsor {
|
||||
}
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const currentYear = new Date().getFullYear();
|
||||
const [clubName, setClubName] = useState<string>('Fotbal Club');
|
||||
const [shopUrl, setShopUrl] = useState<string | null>(null);
|
||||
@@ -87,16 +89,16 @@ const Footer: React.FC = () => {
|
||||
|
||||
{/* Navigation links */}
|
||||
<Wrap spacing={4} shouldWrapChildren>
|
||||
<WrapItem><Link href="/blog" color="whiteAlpha.900" fontWeight="600" _hover={{ color: 'white', textDecoration: 'underline' }}>Články</Link></WrapItem>
|
||||
<WrapItem><Link href="/kalendar" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Zápasy</Link></WrapItem>
|
||||
<WrapItem><Link href="/tabulky" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Tabulka</Link></WrapItem>
|
||||
<WrapItem><Link href="/sponzori" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Sponzoři</Link></WrapItem>
|
||||
<WrapItem><Link href="/kontakt" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Kontakt</Link></WrapItem>
|
||||
<WrapItem><Link href="/pravidla-cookies" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Cookies</Link></WrapItem>
|
||||
<WrapItem><Link href="/obchodni-podminky" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Obchodní podmínky</Link></WrapItem>
|
||||
<WrapItem><Link href="/zasady-ochrany-osobnich-udaju" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Zásady ochrany osobních údajů</Link></WrapItem>
|
||||
<WrapItem><Link href="/blog" color="whiteAlpha.900" fontWeight="600" _hover={{ color: 'white', textDecoration: 'underline' }}>{t('footer.articles')}</Link></WrapItem>
|
||||
<WrapItem><Link href="/kalendar" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>{t('footer.matches')}</Link></WrapItem>
|
||||
<WrapItem><Link href="/tabulky" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>{t('footer.table')}</Link></WrapItem>
|
||||
<WrapItem><Link href="/sponzori" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>{t('footer.sponsors')}</Link></WrapItem>
|
||||
<WrapItem><Link href="/kontakt" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>{t('footer.contact')}</Link></WrapItem>
|
||||
<WrapItem><Link href="/pravidla-cookies" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>{t('footer.cookies')}</Link></WrapItem>
|
||||
<WrapItem><Link href="/obchodni-podminky" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>{t('footer.terms')}</Link></WrapItem>
|
||||
<WrapItem><Link href="/zasady-ochrany-osobnich-udaju" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>{t('footer.privacy')}</Link></WrapItem>
|
||||
{shopUrl && (
|
||||
<WrapItem><Link href={shopUrl} color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }} isExternal display="inline-flex" alignItems="center" gap={1}>E‑shop <FiArrowUpRight /></Link></WrapItem>
|
||||
<WrapItem><Link href={shopUrl} color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }} isExternal display="inline-flex" alignItems="center" gap={1}>{t('footer.eshop')} <FiArrowUpRight /></Link></WrapItem>
|
||||
)}
|
||||
</Wrap>
|
||||
</Stack>
|
||||
@@ -109,7 +111,7 @@ const Footer: React.FC = () => {
|
||||
<Container maxW="container.xl">
|
||||
<VStack spacing={6}>
|
||||
<Heading size="md" color="whiteAlpha.900">
|
||||
Naši partneři
|
||||
{t('footer.partners')}
|
||||
</Heading>
|
||||
<SimpleGrid
|
||||
columns={{ base: 2, sm: 3, md: 4, lg: 6 }}
|
||||
@@ -144,7 +146,7 @@ const Footer: React.FC = () => {
|
||||
onClick={() => trackNavigation('footer', `sponsor_${sponsor.name}`)}
|
||||
>
|
||||
<Image
|
||||
src={assetUrl(sponsor.logo_url) || '/logo192.png'}
|
||||
src={assetUrl(sponsor.logo_url) || '/sponsor-placeholder.svg'}
|
||||
alt={sponsor.name}
|
||||
maxH="60px"
|
||||
maxW="full"
|
||||
@@ -166,7 +168,7 @@ const Footer: React.FC = () => {
|
||||
<Container maxW="container.xl">
|
||||
<VStack spacing={4}>
|
||||
<Text fontSize="lg" fontWeight="600" color="whiteAlpha.900">
|
||||
Sledujte nás
|
||||
{t('footer.follow_us')}
|
||||
</Text>
|
||||
<HStack spacing={4}>
|
||||
{settings?.facebook_url && (
|
||||
@@ -240,7 +242,7 @@ const Footer: React.FC = () => {
|
||||
<Box bg="gray.900" color="whiteAlpha.900" py={4}>
|
||||
<Container maxW="container.xl">
|
||||
<Text fontSize="sm" textAlign="center">
|
||||
© {currentYear} {clubName}. Všechna práva vyhrazena.
|
||||
© {currentYear} {clubName}. {i18n.language === 'en' ? 'All rights reserved.' : 'Všechna práva vyhrazena.'}
|
||||
</Text>
|
||||
</Container>
|
||||
</Box>
|
||||
@@ -266,10 +268,10 @@ const Footer: React.FC = () => {
|
||||
/>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontSize={{ base: 'sm', md: 'md' }} fontWeight="600" color="gray.800">
|
||||
Stránku provozuje MyClub
|
||||
{t('footer.powered_by')}
|
||||
</Text>
|
||||
<Text fontSize={{ base: 'xs', md: 'sm' }} color="gray.600">
|
||||
Profesionální webové stránky pro sportovní kluby
|
||||
{t('footer.powered_by_desc')}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
@@ -288,7 +290,7 @@ const Footer: React.FC = () => {
|
||||
_hover={{ transform: 'translateY(-2px)', boxShadow: 'lg' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
Objednat
|
||||
{t('footer.order_now')}
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
@@ -301,7 +303,7 @@ const Footer: React.FC = () => {
|
||||
rightIcon={<FiArrowUpRight />}
|
||||
_hover={{ bg: 'gray.50' }}
|
||||
>
|
||||
Více info
|
||||
{t('footer.learn_more')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Stack>
|
||||
|
||||
@@ -19,6 +19,10 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
|
||||
const headerVariant = getVariant('header', 'unified');
|
||||
const sponsorsVariant = getVariant('sponsors', 'grid');
|
||||
const footerVariant = getVariant('footer', 'standard');
|
||||
const containerVariant = getVariant('container', 'boxed');
|
||||
const isFullWidthLayout = containerVariant === 'fullwidth';
|
||||
const isWideLayout = containerVariant === 'wide';
|
||||
const contentMaxW = isFullWidthLayout ? '100%' : isWideLayout ? '1400px' : 'container.xl';
|
||||
const headerIsInside = headerInsideContainer && headerVariant !== 'fullwidth';
|
||||
|
||||
useEffect(() => {
|
||||
@@ -45,7 +49,7 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
|
||||
<Box id="top" position="absolute" top={0} left={0} />
|
||||
{headerIsInside ? (
|
||||
<>
|
||||
<Container maxW="container.xl" py={8}>
|
||||
<Container maxW={contentMaxW} py={8} px={isFullWidthLayout ? 0 : undefined}>
|
||||
<Box key={`header-${refreshKey}-${headerVariant}`} as="header" data-element="header" data-variant={headerVariant} style={{ ...getStyles('header') }}>
|
||||
{headerVariant === 'sparta_navbar' ? (
|
||||
<SpartaNavbar />
|
||||
@@ -73,7 +77,7 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
|
||||
<Navbar fullWidth={headerVariant === 'fullwidth'} variant={headerVariant} />
|
||||
)}
|
||||
</Box>
|
||||
<Container maxW="container.xl" py={8}>
|
||||
<Container maxW={contentMaxW} py={8} px={isFullWidthLayout ? 0 : undefined}>
|
||||
{children}
|
||||
</Container>
|
||||
{/* Global sponsors section across front-facing pages */}
|
||||
|
||||
@@ -14,12 +14,14 @@ import {
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { subscribeToNewsletter } from '../../services/public';
|
||||
import { trackNewsletterSubscribe, trackFormSubmit } from '../../utils/umami';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type FormData = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
export default function NewsletterSubscribe() {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const toast = useToast();
|
||||
const {
|
||||
@@ -39,8 +41,8 @@ export default function NewsletterSubscribe() {
|
||||
trackFormSubmit('Newsletter Subscribe', true);
|
||||
|
||||
toast({
|
||||
title: 'Přihlášení k odběru proběhlo úspěšně',
|
||||
description: 'Vytvořili jsme vám fanouškovský účet a poslali e‑mail s heslem a odkazy pro správu newsletteru.',
|
||||
title: t('newsletter.subscribe_success'),
|
||||
description: t('newsletter.subscribe_success_desc'),
|
||||
status: 'success',
|
||||
duration: 7000,
|
||||
isClosable: true,
|
||||
@@ -48,13 +50,13 @@ export default function NewsletterSubscribe() {
|
||||
try { window.dispatchEvent(new CustomEvent('engagement:refresh')); } catch {}
|
||||
reset();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Nastala chyba při přihlašování k odběru';
|
||||
const errorMessage = error instanceof Error ? error.message : t('newsletter.subscribe_error_desc');
|
||||
|
||||
// Track failed subscription
|
||||
trackFormSubmit('Newsletter Subscribe', false);
|
||||
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
title: t('newsletter.subscribe_error'),
|
||||
description: errorMessage,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
@@ -73,10 +75,10 @@ export default function NewsletterSubscribe() {
|
||||
<Box w="100%" maxW="xl" mx="auto" p={4}>
|
||||
<VStack spacing={3} align="stretch">
|
||||
<Text fontSize="xl" fontWeight="bold" textAlign="center" color={headingColor}>
|
||||
Přihlaste se k odběru novinek
|
||||
{t('newsletter.subscribe')}
|
||||
</Text>
|
||||
<Text textAlign="center" color={textColor} mb={2}>
|
||||
Budeme vás informovat o novinkách, zápasech a akcích našeho klubu. Současně pro vás vytvoříme fanouškovský účet a pošleme heslo e‑mailem.
|
||||
{t('newsletter.subscribe_description')}
|
||||
</Text>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
@@ -85,13 +87,13 @@ export default function NewsletterSubscribe() {
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Váš e-mail"
|
||||
placeholder={t('newsletter.email_label')}
|
||||
autoComplete="email"
|
||||
{...register('email', {
|
||||
required: 'E-mail je povinný',
|
||||
required: t('newsletter.email_required'),
|
||||
pattern: {
|
||||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||
message: 'Neplatná e-mailová adresa',
|
||||
message: t('newsletter.email_invalid'),
|
||||
},
|
||||
})}
|
||||
size="md"
|
||||
@@ -108,17 +110,17 @@ export default function NewsletterSubscribe() {
|
||||
size="md"
|
||||
width="100%"
|
||||
isLoading={isLoading}
|
||||
loadingText="Odesílám..."
|
||||
loadingText={t('newsletter.loading')}
|
||||
data-umami-event="Newsletter Submit"
|
||||
data-umami-event-location={window.location.pathname}
|
||||
>
|
||||
Odeslat
|
||||
{t('newsletter.subscribe_button')}
|
||||
</Button>
|
||||
</VStack>
|
||||
</form>
|
||||
|
||||
<Text fontSize="xs" color={disclaimerColor} textAlign="center" mt={2}>
|
||||
Odesláním formuláře souhlasíte se zpracováním osobních údajů. Z odběru se můžete kdykoli odhlásit a nastavení upravit v zaslaném e‑mailu. Heslo lze změnit přes stránku pro obnovení hesla.
|
||||
{t('newsletter.consent_text')}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TeamLogo } from '../common/TeamLogo';
|
||||
import { sanitizeClubName } from '../../utils/url';
|
||||
|
||||
@@ -26,7 +27,8 @@ const MatchesSlider: React.FC<{
|
||||
onMatchClick?: (m: SliderMatch, compName?: string) => void;
|
||||
elementProps?: any;
|
||||
variant?: 'carousel' | 'scroller' | 'ticker' | 'compact_split';
|
||||
}> = ({ title = 'Zápasy', comps, activeIndex, onActiveChange, onMatchClick, elementProps, variant }) => {
|
||||
}> = ({ title, comps, activeIndex, onActiveChange, onMatchClick, elementProps, variant }) => {
|
||||
const { t } = useTranslation();
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
const current = comps[Math.max(0, Math.min(activeIndex, comps.length - 1))];
|
||||
|
||||
@@ -62,10 +64,10 @@ const MatchesSlider: React.FC<{
|
||||
const items = (current?.matches || []);
|
||||
const looped = [...items, ...items, ...items];
|
||||
return (
|
||||
<section className="matches-slider matches-ticker" aria-label={`Zápasy – ${current?.name || ''}`} {...(elementProps || {})}>
|
||||
<section className="matches-slider matches-ticker" aria-label={`${t('nav.matches')} – ${current?.name || ''}`} {...(elementProps || {})}>
|
||||
<div className="section-head" style={{ marginTop: 16, marginBottom: 8 }}>
|
||||
<h3>{title}</h3>
|
||||
<a href="/kalendar" className="see-all">Všechny zápasy</a>
|
||||
<h3>{title || t('nav.matches')}</h3>
|
||||
<a href="/kalendar" className="see-all">{t('homepage.all_matches')}</a>
|
||||
</div>
|
||||
<div className="ticker-belt" role="list">
|
||||
{looped.map((m, idx) => (
|
||||
@@ -108,10 +110,10 @@ const MatchesSlider: React.FC<{
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="matches-slider" aria-label={`Zápasy – ${current?.name || ''}`} {...(elementProps || {})}>
|
||||
<section className="matches-slider" aria-label={`${t('nav.matches')} – ${current?.name || ''}`} {...(elementProps || {})}>
|
||||
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
|
||||
<h3>{title}</h3>
|
||||
<a href="/kalendar" className="see-all">Všechny zápasy</a>
|
||||
<h3>{title || t('nav.matches')}</h3>
|
||||
<a href="/kalendar" className="see-all">{t('homepage.all_matches')}</a>
|
||||
</div>
|
||||
<div className="matches-grid">
|
||||
<div className="matches-track" ref={trackRef} role="list">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type NewsListItem = {
|
||||
id: number | string;
|
||||
@@ -14,7 +15,13 @@ const NewsList: React.FC<{
|
||||
emptyText?: string;
|
||||
seeAllHref?: string;
|
||||
seeAllLabel?: string;
|
||||
}> = ({ items, emptyText = 'Zatím nejsou k dispozici žádné aktuality.', seeAllHref, seeAllLabel = 'Zobrazit všechny aktuality' }) => {
|
||||
}> = ({ items, emptyText, seeAllHref, seeAllLabel }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Use provided text or fallback to translations
|
||||
const emptyTextFinal = emptyText || t('news.no_news');
|
||||
const seeAllLabelFinal = seeAllLabel || t('news.view_all_news');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="blog-list">
|
||||
@@ -32,13 +39,13 @@ const NewsList: React.FC<{
|
||||
))
|
||||
) : (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--dark-gray)', background: 'var(--bg-soft)', borderRadius: '12px' }}>
|
||||
<p>{emptyText}</p>
|
||||
<p>{emptyTextFinal}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{seeAllHref && items && items.length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<a className="btn" href={seeAllHref}>{seeAllLabel}</a>
|
||||
<a className="btn" href={seeAllHref}>{seeAllLabelFinal}</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { sanitizeClubName } from '../../utils/url';
|
||||
import { TeamLogo } from '../common/TeamLogo';
|
||||
|
||||
@@ -38,6 +39,7 @@ function deriveTeamIdFromLogoUrl(url?: string): string | undefined {
|
||||
type TeamOverrides = { by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> };
|
||||
|
||||
const StandingsCard: React.FC<{ rows: StandingRow[]; onRowClick?: (row: StandingRow, index: number) => void; variant?: 'logos' | 'plain' }>= ({ rows, onRowClick, variant = 'logos' }) => {
|
||||
const { t } = useTranslation();
|
||||
const safe = Array.isArray(rows) ? rows : [];
|
||||
const [sortKey, setSortKey] = useState<'rank' | 'team' | 'played' | 'wins' | 'draws' | 'losses' | 'score' | 'points' | null>(null);
|
||||
const [sortOrder, setSortOrder] = useState<'desc' | 'asc' | null>(null);
|
||||
@@ -157,13 +159,13 @@ const StandingsCard: React.FC<{ rows: StandingRow[]; onRowClick?: (row: Standing
|
||||
<thead>
|
||||
<tr style={{ fontSize: '0.75rem', color: 'var(--dark-gray)', textTransform: 'uppercase' }}>
|
||||
<th onClick={() => toggleSort('rank')} style={{ padding: '6px 8px', textAlign: 'left', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}># {arrow('rank')}</th>
|
||||
<th onClick={() => toggleSort('team')} style={{ padding: '6px 8px', textAlign: 'left', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>Tým{arrow('team')}</th>
|
||||
<th onClick={() => toggleSort('played')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>Z{arrow('played')}</th>
|
||||
<th onClick={() => toggleSort('wins')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>V{arrow('wins')}</th>
|
||||
<th onClick={() => toggleSort('draws')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>R{arrow('draws')}</th>
|
||||
<th onClick={() => toggleSort('losses')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>P{arrow('losses')}</th>
|
||||
<th onClick={() => toggleSort('score')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, display: 'none', cursor: 'pointer', userSelect: 'none' }} className="hide-mobile">Skóre{arrow('score')}</th>
|
||||
<th onClick={() => toggleSort('points')} style={{ padding: '6px 8px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>Body{arrow('points')}</th>
|
||||
<th onClick={() => toggleSort('team')} style={{ padding: '6px 8px', textAlign: 'left', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>{t('homepage.team')}{arrow('team')}</th>
|
||||
<th onClick={() => toggleSort('played')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>{t('homepage.played')}{arrow('played')}</th>
|
||||
<th onClick={() => toggleSort('wins')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>{t('homepage.won')}{arrow('wins')}</th>
|
||||
<th onClick={() => toggleSort('draws')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>{t('homepage.drawn')}{arrow('draws')}</th>
|
||||
<th onClick={() => toggleSort('losses')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>{t('homepage.lost')}{arrow('losses')}</th>
|
||||
<th onClick={() => toggleSort('score')} style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, display: 'none', cursor: 'pointer', userSelect: 'none' }} className="hide-mobile">{t('homepage.goals')}{arrow('score')}</th>
|
||||
<th onClick={() => toggleSort('points')} style={{ padding: '6px 8px', textAlign: 'center', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }}>{t('homepage.points')}{arrow('points')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -200,7 +202,7 @@ const StandingsCard: React.FC<{ rows: StandingRow[]; onRowClick?: (row: Standing
|
||||
teamName={teamName}
|
||||
facrLogo={(row as any).team_logo_url}
|
||||
size="small"
|
||||
alt={teamName || 'Tým'}
|
||||
alt={teamName || t('homepage.team')}
|
||||
borderRadius="full"
|
||||
/>
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{teamName}</span>
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { useUmami } from '../../hooks/useUmami';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Link as RouterLink, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface PollCardProps {
|
||||
poll: Poll;
|
||||
@@ -52,6 +53,7 @@ const PollCard: React.FC<PollCardProps> = ({
|
||||
onVoteSuccess,
|
||||
flat = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const { trackEvent } = useUmami();
|
||||
@@ -97,8 +99,8 @@ const PollCard: React.FC<PollCardProps> = ({
|
||||
queryKey: ['poll-results', poll.id],
|
||||
queryFn: () => getPollResults(poll.id),
|
||||
enabled: showLiveResults,
|
||||
refetchInterval: 4000,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval: 30000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Vote mutation
|
||||
@@ -257,12 +259,11 @@ const PollCard: React.FC<PollCardProps> = ({
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={flat ? undefined : 'card'}
|
||||
bg={flat ? 'transparent' : bgCard}
|
||||
borderWidth={flat ? '0' : '1px'}
|
||||
borderColor={flat ? 'transparent' : borderColor}
|
||||
borderRadius="xl"
|
||||
p={flat ? 0 : 6}
|
||||
boxShadow={flat ? 'none' : 'md'}
|
||||
>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{poll.image_url && (
|
||||
@@ -322,12 +323,11 @@ const PollCard: React.FC<PollCardProps> = ({
|
||||
// Show voting form
|
||||
return (
|
||||
<Box
|
||||
className={flat ? undefined : 'card'}
|
||||
bg={flat ? 'transparent' : bgCard}
|
||||
borderWidth={flat ? '0' : '1px'}
|
||||
borderColor={flat ? 'transparent' : borderColor}
|
||||
borderRadius="xl"
|
||||
p={flat ? 0 : 6}
|
||||
boxShadow={flat ? 'none' : 'md'}
|
||||
>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{poll.image_url && (
|
||||
@@ -587,7 +587,7 @@ const PollCard: React.FC<PollCardProps> = ({
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
Přihlášením se jméno a e-mail doplní automaticky.{' '}
|
||||
<Link as={RouterLink} color="blue.500" to="/login" state={{ from: location }}>
|
||||
Přihlásit se
|
||||
{t('auth.login')}
|
||||
</Link>
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
@@ -205,8 +205,19 @@ function deriveShortLocal(name?: string) {
|
||||
function shadeColor(hex: string, percent: number) {
|
||||
// Simple hex shade function
|
||||
try {
|
||||
const n = hex.replace('#','');
|
||||
const num = parseInt(n.length === 3 ? n.split('').map((c)=>c+c).join('') : n, 16);
|
||||
const raw = hex.replace('#','');
|
||||
let n = raw;
|
||||
// Normalize to 6-digit RGB, ignore alpha channel if present
|
||||
if (n.length === 3 || n.length === 4) {
|
||||
// #RGB or #RGBA -> expand RGB, drop A
|
||||
const rgb = n.slice(0,3);
|
||||
n = rgb.split('').map((c)=>c+c).join('');
|
||||
} else if (n.length === 8) {
|
||||
// #RRGGBBAA -> drop AA
|
||||
n = n.slice(0,6);
|
||||
}
|
||||
if (n.length !== 6) return hex;
|
||||
const num = parseInt(n, 16);
|
||||
let r = (num >> 16) & 0xff;
|
||||
let g = (num >> 8) & 0xff;
|
||||
let b = num & 0xff;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useAuth } from '../../contexts/AuthContext';
|
||||
import { getCurrentSweepstake, enterSweepstake, markSweepstakeVisualPlayed, CurrentSweepstakeResponse } from '../../services/sweepstakes';
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
import { getImageUrl } from '../../utils/imageUtils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const fmtDate = (iso?: string | null) => {
|
||||
if (!iso) return '';
|
||||
@@ -11,6 +12,7 @@ const fmtDate = (iso?: string | null) => {
|
||||
};
|
||||
|
||||
const SweepstakeWidget: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const [data, setData] = useState<CurrentSweepstakeResponse | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
@@ -43,6 +45,9 @@ const SweepstakeWidget: React.FC = () => {
|
||||
return winners.some(w => w.user_id === myId);
|
||||
}, [winners, isLogged, user]);
|
||||
|
||||
const hasEntered = !!data?.has_entered;
|
||||
const canEnter = !!data?.can_enter;
|
||||
|
||||
useEffect(() => {
|
||||
// Autoplay visualization once for logged users within 3-day window, non-admins
|
||||
if (!s || !isLogged || !winners?.length) return;
|
||||
@@ -70,10 +75,10 @@ const SweepstakeWidget: React.FC = () => {
|
||||
setJoining(true);
|
||||
try {
|
||||
await enterSweepstake(s.id);
|
||||
toast({ status: 'success', title: 'Úspěšně jste vstoupili do soutěže' });
|
||||
toast({ status: 'success', title: 'Úspěšně jste se přihlásili do soutěže' });
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.error || 'Nelze vstoupit do soutěže';
|
||||
const msg = e?.response?.data?.error || 'Nelze se přihlásit do soutěže';
|
||||
toast({ status: 'error', title: msg });
|
||||
} finally {
|
||||
setJoining(false);
|
||||
@@ -111,9 +116,9 @@ const SweepstakeWidget: React.FC = () => {
|
||||
<div style={{ marginTop: 8, fontSize: 14, opacity: 0.8 }}>Začíná: {fmtDate(s.start_at)} • Končí: {fmtDate(s.end_at)}</div>
|
||||
</div>
|
||||
{!isLogged ? (
|
||||
<a href="/login" className="btn" style={{ padding: '10px 16px' }}>Přihlásit se</a>
|
||||
) : data?.has_entered ? (
|
||||
<span style={{ fontWeight: 600 }}>Jste zapojeni ✓</span>
|
||||
<a href="/login" className="btn" style={{ padding: '10px 16px' }}>{t('auth.login')}</a>
|
||||
) : hasEntered ? (
|
||||
<span style={{ fontWeight: 600 }}>Už jste přihlášeni do soutěže ✓</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 14, opacity: 0.85 }}>Soutěž ještě nezačala. Vstup bude možný od {fmtDate(s.start_at)}.</span>
|
||||
)}
|
||||
@@ -144,13 +149,15 @@ const SweepstakeWidget: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
{!isLogged ? (
|
||||
<div style={{ fontWeight: 600 }}>Právě zde probíhá soutěž. <a href="/login">Přihlaste se</a> a zapojte se.</div>
|
||||
) : (data?.can_enter ?? false) ? (
|
||||
<a href="/login" className="btn" style={{ padding: '10px 16px' }}>{t('auth.login')}</a>
|
||||
) : hasEntered ? (
|
||||
<span style={{ fontWeight: 600 }}>Už jste přihlášeni do soutěže ✓</span>
|
||||
) : canEnter ? (
|
||||
<button className="btn" onClick={onJoin} disabled={joining}>
|
||||
{joining ? 'Vstupuji…' : 'Vstoupit'}
|
||||
{joining ? 'Přihlašuji…' : 'Přihlásit do soutěže'}
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ fontWeight: 600 }}>Už jste registrováni v soutěži ✓</span>
|
||||
<span style={{ fontWeight: 600 }}>Vstup není dostupný</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
Text,
|
||||
Stack,
|
||||
Badge,
|
||||
useToast,
|
||||
Divider,
|
||||
SimpleGrid,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Progress,
|
||||
VStack,
|
||||
HStack,
|
||||
} from '@chakra-ui/react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { api } from '../../services/api';
|
||||
|
||||
// Types
|
||||
interface AvailableTicket {
|
||||
campaign_id: number;
|
||||
campaign_title: string;
|
||||
campaign_description: string;
|
||||
external_match_id?: string;
|
||||
competition_code?: string;
|
||||
match_date_time?: string;
|
||||
home_team?: string;
|
||||
away_team?: string;
|
||||
venue?: string;
|
||||
sale_start_time: string;
|
||||
sale_end_time: string;
|
||||
ticket_type_id: number;
|
||||
ticket_type_name: string;
|
||||
ticket_type_description: string;
|
||||
price_cents: number;
|
||||
max_per_order: number;
|
||||
color: string;
|
||||
available_quantity: number;
|
||||
total_capacity: number;
|
||||
sale_status: string;
|
||||
}
|
||||
|
||||
interface TicketCampaign {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
external_match_id?: string;
|
||||
competition_code?: string;
|
||||
match_date_time?: string;
|
||||
home_team?: string;
|
||||
away_team?: string;
|
||||
venue?: string;
|
||||
sale_start_time: string;
|
||||
sale_end_time: string;
|
||||
available_tickets: AvailableTicket[];
|
||||
}
|
||||
|
||||
interface ReservationRequest {
|
||||
ticket_id: number;
|
||||
quantity: number;
|
||||
holder_name: string;
|
||||
holder_email: string;
|
||||
holder_phone?: string;
|
||||
}
|
||||
|
||||
interface TicketOrderRequest {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
ticket_reservations: { ticket_id: number }[];
|
||||
payment_method: 'stripe' | 'gopay';
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
const formatPrice = (cents: number) => {
|
||||
return (cents / 100).toFixed(0) + ' Kč';
|
||||
};
|
||||
|
||||
const getSaleStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'available': return 'green';
|
||||
case 'upcoming': return 'yellow';
|
||||
case 'sold_out': return 'red';
|
||||
case 'ended': return 'gray';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getSaleStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'available': return 'V prodeji';
|
||||
case 'upcoming': return 'Připravuje se';
|
||||
case 'sold_out': return 'Vyprodáno';
|
||||
case 'ended': return 'Ukončeno';
|
||||
default: return 'Neznámý';
|
||||
}
|
||||
};
|
||||
|
||||
// Main Component
|
||||
interface TicketPurchaseProps {
|
||||
matchId?: string;
|
||||
campaignId?: number;
|
||||
}
|
||||
|
||||
export const TicketPurchase: React.FC<TicketPurchaseProps> = ({ matchId, campaignId }) => {
|
||||
const [selectedTickets, setSelectedTickets] = useState<{ [key: number]: number }>({});
|
||||
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
|
||||
const [customerInfo, setCustomerInfo] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
});
|
||||
const toast = useToast();
|
||||
|
||||
// Fetch available tickets and group by campaign
|
||||
const { data: availableTickets, isLoading, error } = useQuery<AvailableTicket[]>(
|
||||
['ticket-campaigns', matchId],
|
||||
async () => {
|
||||
const params = matchId ? `?match_id=${matchId}` : '';
|
||||
const response = await api.get(`/api/v1/tickets/available${params}`);
|
||||
return response.data;
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Group available tickets by campaign
|
||||
const campaigns = React.useMemo(() => {
|
||||
if (!availableTickets) return [];
|
||||
|
||||
const grouped = availableTickets.reduce((acc, ticket) => {
|
||||
const campaignId = ticket.campaign_id;
|
||||
if (!acc[campaignId]) {
|
||||
acc[campaignId] = {
|
||||
id: campaignId,
|
||||
title: ticket.campaign_title,
|
||||
description: ticket.campaign_description,
|
||||
external_match_id: ticket.external_match_id,
|
||||
competition_code: ticket.competition_code,
|
||||
match_date_time: ticket.match_date_time,
|
||||
home_team: ticket.home_team,
|
||||
away_team: ticket.away_team,
|
||||
venue: ticket.venue,
|
||||
sale_start_time: ticket.sale_start_time,
|
||||
sale_end_time: ticket.sale_end_time,
|
||||
available_tickets: []
|
||||
};
|
||||
}
|
||||
acc[campaignId].available_tickets.push(ticket);
|
||||
return acc;
|
||||
}, {} as Record<number, TicketCampaign>);
|
||||
|
||||
return Object.values(grouped);
|
||||
}, [availableTickets]);
|
||||
|
||||
// Reserve tickets mutation
|
||||
const reserveMutation = useMutation(
|
||||
async (reservation: ReservationRequest) => {
|
||||
const response = await api.post('/api/v1/tickets/reserve', reservation);
|
||||
return response.data;
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Rezervace úspěšná',
|
||||
description: 'Vstupenky byly rezervovány',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
setIsCheckoutOpen(true);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Chyba při rezervaci',
|
||||
description: error.response?.data?.error || 'Nepodařilo se rezervovat vstupenky',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Create order mutation
|
||||
const orderMutation = useMutation(
|
||||
async (orderData: TicketOrderRequest) => {
|
||||
const response = await api.post('/api/v1/ticket-checkout/order', orderData);
|
||||
return response.data;
|
||||
},
|
||||
{
|
||||
onSuccess: (data: any) => {
|
||||
toast({
|
||||
title: 'Objednávka vytvořena',
|
||||
description: 'Přesměrováváme na platební bránu...',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
// Redirect to payment
|
||||
if (data.payment?.redirect_url) {
|
||||
window.location.href = data.payment.redirect_url;
|
||||
} else if (data.payment?.client_secret) {
|
||||
// Handle Stripe payment
|
||||
// TODO: Integrate Stripe Elements
|
||||
console.log('Stripe payment:', data.payment);
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Chyba při vytváření objednávky',
|
||||
description: error.response?.data?.error || 'Nepodařilo se vytvořit objednávku',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const handleQuantityChange = (ticketId: number, quantity: number, maxQuantity: number) => {
|
||||
if (quantity >= 0 && quantity <= maxQuantity) {
|
||||
setSelectedTickets(prev => ({
|
||||
...prev,
|
||||
[ticketId]: quantity,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const getTotalPrice = () => {
|
||||
if (!campaigns) return 0;
|
||||
|
||||
let total = 0;
|
||||
campaigns.forEach(campaign => {
|
||||
campaign.available_tickets.forEach(ticket => {
|
||||
const quantity = selectedTickets[ticket.ticket_type_id] || 0;
|
||||
total += ticket.price_cents * quantity;
|
||||
});
|
||||
});
|
||||
|
||||
return total;
|
||||
};
|
||||
|
||||
const getTotalQuantity = () => {
|
||||
return Object.values(selectedTickets).reduce((sum, qty) => sum + qty, 0);
|
||||
};
|
||||
|
||||
const handleReserve = async () => {
|
||||
if (getTotalQuantity() === 0) {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: 'Nejprve vyberte vstupenky',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create reservations for each selected ticket
|
||||
const reservations: ReservationRequest[] = [];
|
||||
|
||||
campaigns?.forEach(campaign => {
|
||||
campaign.available_tickets.forEach(ticket => {
|
||||
const quantity = selectedTickets[ticket.ticket_type_id] || 0;
|
||||
if (quantity > 0) {
|
||||
reservations.push({
|
||||
ticket_id: ticket.ticket_type_id,
|
||||
quantity,
|
||||
holder_name: `${customerInfo.firstName} ${customerInfo.lastName}`,
|
||||
holder_email: customerInfo.email,
|
||||
holder_phone: customerInfo.phone,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// For now, just reserve the first ticket (simplified)
|
||||
if (reservations.length > 0) {
|
||||
reserveMutation.mutate(reservations[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckout = () => {
|
||||
if (!customerInfo.firstName || !customerInfo.lastName || !customerInfo.email) {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: 'Vyplňte všechny povinné údaje',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const ticketReservations = Object.entries(selectedTickets)
|
||||
.filter(([_, quantity]) => quantity > 0)
|
||||
.map(([ticketId, _]) => ({ ticket_id: parseInt(ticketId) }));
|
||||
|
||||
const orderData: TicketOrderRequest = {
|
||||
first_name: customerInfo.firstName,
|
||||
last_name: customerInfo.lastName,
|
||||
email: customerInfo.email,
|
||||
phone: customerInfo.phone,
|
||||
ticket_reservations: ticketReservations,
|
||||
payment_method: 'stripe', // Default to Stripe
|
||||
};
|
||||
|
||||
orderMutation.mutate(orderData);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Box><Text>Načítání vstupenek...</Text></Box>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert status="error">
|
||||
<AlertIcon />
|
||||
Nepodařilo se načíst vstupenky
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!campaigns || campaigns.length === 0) {
|
||||
return (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
Momentálně nejsou k dispozici žádné vstupenky
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<VStack spacing={6} align="stretch">
|
||||
{campaigns.map((campaign) => (
|
||||
<Card key={campaign.id} variant="outline">
|
||||
<CardHeader>
|
||||
<VStack align="start" spacing={2}>
|
||||
<Heading size="md">{campaign.title}</Heading>
|
||||
{campaign.description && (
|
||||
<Text color="gray.600">{campaign.description}</Text>
|
||||
)}
|
||||
{campaign.home_team && campaign.away_team && (
|
||||
<Text fontWeight="bold">
|
||||
{campaign.home_team} vs {campaign.away_team}
|
||||
</Text>
|
||||
)}
|
||||
{campaign.match_date_time && (
|
||||
<Text>
|
||||
{new Date(campaign.match_date_time).toLocaleString('cs-CZ')}
|
||||
</Text>
|
||||
)}
|
||||
{campaign.venue && (
|
||||
<Text color="gray.600">Místo: {campaign.venue}</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
{campaign.available_tickets.map((ticket) => {
|
||||
const quantity = selectedTickets[ticket.ticket_type_id] || 0;
|
||||
const availabilityPercentage = (ticket.available_quantity / ticket.total_capacity) * 100;
|
||||
|
||||
return (
|
||||
<Card key={ticket.ticket_type_id} variant="filled" bg="gray.50">
|
||||
<CardBody>
|
||||
<VStack align="start" spacing={3}>
|
||||
<HStack justify="space-between" width="100%">
|
||||
<Heading size="sm">{ticket.ticket_type_name}</Heading>
|
||||
<Badge colorScheme={getSaleStatusColor(ticket.sale_status)}>
|
||||
{getSaleStatusText(ticket.sale_status)}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
{ticket.ticket_type_description && (
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{ticket.ticket_type_description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack justify="space-between" width="100%">
|
||||
<Text fontWeight="bold" fontSize="lg" color="blue.600">
|
||||
{formatPrice(ticket.price_cents)}
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{ticket.available_quantity} / {ticket.total_capacity}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{ticket.total_capacity > 0 && (
|
||||
<Progress
|
||||
value={availabilityPercentage}
|
||||
colorScheme={availabilityPercentage < 20 ? 'red' : availabilityPercentage < 50 ? 'yellow' : 'green'}
|
||||
height="8px"
|
||||
borderRadius="4px"
|
||||
/>
|
||||
)}
|
||||
|
||||
{ticket.sale_status === 'available' && ticket.available_quantity > 0 && (
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">Množství</FormLabel>
|
||||
<NumberInput
|
||||
value={quantity}
|
||||
min={0}
|
||||
max={Math.min(ticket.max_per_order, ticket.available_quantity)}
|
||||
onChange={(value) => handleQuantityChange(ticket.ticket_type_id, parseInt(value) || 0, Math.min(ticket.max_per_order, ticket.available_quantity))}
|
||||
>
|
||||
<NumberInputField />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{getTotalQuantity() > 0 && (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<VStack spacing={4}>
|
||||
<Divider />
|
||||
<HStack justify="space-between" width="100%">
|
||||
<Text fontWeight="bold">Celkem:</Text>
|
||||
<Text fontWeight="bold" fontSize="xl" color="blue.600">
|
||||
{formatPrice(getTotalPrice())}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
width="100%"
|
||||
onClick={handleReserve}
|
||||
isLoading={reserveMutation.isLoading}
|
||||
>
|
||||
Rezervovat vstupenky
|
||||
</Button>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* Checkout Modal */}
|
||||
<Modal isOpen={isCheckoutOpen} onClose={() => setIsCheckoutOpen(false)} size="md">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Dokončit objednávku</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<Text>Celkem k úhradě: {formatPrice(getTotalPrice())}</Text>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Jméno</FormLabel>
|
||||
<Input
|
||||
value={customerInfo.firstName}
|
||||
onChange={(e) => setCustomerInfo(prev => ({ ...prev, firstName: e.target.value }))}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Příjmení</FormLabel>
|
||||
<Input
|
||||
value={customerInfo.lastName}
|
||||
onChange={(e) => setCustomerInfo(prev => ({ ...prev, lastName: e.target.value }))}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>E-mail</FormLabel>
|
||||
<Input
|
||||
type="email"
|
||||
value={customerInfo.email}
|
||||
onChange={(e) => setCustomerInfo(prev => ({ ...prev, email: e.target.value }))}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Telefon (volitelné)</FormLabel>
|
||||
<Input
|
||||
value={customerInfo.phone}
|
||||
onChange={(e) => setCustomerInfo(prev => ({ ...prev, phone: e.target.value }))}
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" onClick={() => setIsCheckoutOpen(false)}>
|
||||
Zrušit
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={handleCheckout}
|
||||
isLoading={orderMutation.isLoading}
|
||||
>
|
||||
Zaplatit
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,272 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Heading,
|
||||
Badge,
|
||||
Divider,
|
||||
Button,
|
||||
Image,
|
||||
Spinner,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { DownloadIcon, ViewIcon } from '@chakra-ui/icons';
|
||||
import { QRCodeService, TicketQRData } from '../../services/qrCode';
|
||||
|
||||
interface TicketQRDisplayProps {
|
||||
ticket: any;
|
||||
campaign: any;
|
||||
showValidation?: boolean;
|
||||
}
|
||||
|
||||
export const TicketQRDisplay: React.FC<TicketQRDisplayProps> = ({
|
||||
ticket,
|
||||
campaign,
|
||||
showValidation = false
|
||||
}) => {
|
||||
const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isValidated, setIsValidated] = useState(false);
|
||||
const [validationError, setValidationError] = useState<string>('');
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
generateQRCode();
|
||||
}, [ticket, campaign]);
|
||||
|
||||
const generateQRCode = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const ticketData = QRCodeService.formatTicketData(ticket, campaign);
|
||||
const qrCode = await QRCodeService.generateTicketQR(ticketData);
|
||||
setQrCodeDataUrl(qrCode);
|
||||
} catch (error) {
|
||||
console.error('Error generating QR code:', error);
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: 'Nepodařilo se vygenerovat QR kód',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateTicket = async () => {
|
||||
try {
|
||||
setIsValidated(false);
|
||||
setValidationError('');
|
||||
|
||||
// Simulate validation by calling the validation API
|
||||
const response = await fetch(`/api/v1/tickets/${ticket.id}/validate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
barcode: ticket.barcode,
|
||||
used_by: 'QR Code Validation',
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
setIsValidated(true);
|
||||
toast({
|
||||
title: 'Validace úspěšná',
|
||||
description: result.message || 'Vstupenka je platná',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
} else {
|
||||
const error = await response.json();
|
||||
setValidationError(error.error || 'Validace selhala');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error validating ticket:', error);
|
||||
setValidationError('Chyba při validaci vstupenky');
|
||||
}
|
||||
};
|
||||
|
||||
const downloadQRCode = () => {
|
||||
if (!qrCodeDataUrl) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = qrCodeDataUrl;
|
||||
link.download = `vstupenka-${ticket.id}-${ticket.barcode}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
toast({
|
||||
title: 'QR kód stažen',
|
||||
description: 'QR kód byl uložen do vašeho zařízení',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
};
|
||||
|
||||
const formatDateTime = (dateTimeString?: string) => {
|
||||
if (!dateTimeString) return '';
|
||||
return new Date(dateTimeString).toLocaleString('cs-CZ', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'green';
|
||||
case 'reserved': return 'yellow';
|
||||
case 'used': return 'gray';
|
||||
case 'cancelled': return 'red';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'Zaplaceno';
|
||||
case 'reserved': return 'Rezervováno';
|
||||
case 'used': return 'Použito';
|
||||
case 'cancelled': return 'Zrušeno';
|
||||
default: return 'Neznámý';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box bg="white" p={6} borderRadius="lg" boxShadow="md" maxW="400px">
|
||||
<VStack spacing={4} align="center">
|
||||
{/* Header */}
|
||||
<VStack spacing={2} align="center" w="100%">
|
||||
<Heading size="md" textAlign="center">{campaign.title}</Heading>
|
||||
<Badge colorScheme={getStatusColor(ticket.status)}>
|
||||
{getStatusText(ticket.status)}
|
||||
</Badge>
|
||||
</VStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Event Details */}
|
||||
<VStack spacing={2} align="start" w="100%">
|
||||
{campaign.home_team && campaign.away_team && (
|
||||
<Text fontWeight="bold">
|
||||
{campaign.home_team} vs {campaign.away_team}
|
||||
</Text>
|
||||
)}
|
||||
{campaign.match_date_time && (
|
||||
<Text fontSize="sm">
|
||||
{formatDateTime(campaign.match_date_time)}
|
||||
</Text>
|
||||
)}
|
||||
{campaign.venue && (
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
Místo: {campaign.venue}
|
||||
</Text>
|
||||
)}
|
||||
<Text fontSize="sm">
|
||||
Typ: {ticket.ticket_type?.name || 'Vstupenka'}
|
||||
</Text>
|
||||
<Text fontSize="sm">
|
||||
Množství: {ticket.quantity}
|
||||
</Text>
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
Cena: {(ticket.total_price_cents / 100).toFixed(2)} Kč
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* QR Code */}
|
||||
<VStack spacing={3} align="center">
|
||||
{isLoading ? (
|
||||
<Spinner size="xl" />
|
||||
) : qrCodeDataUrl ? (
|
||||
<>
|
||||
<Image
|
||||
src={qrCodeDataUrl}
|
||||
alt="QR kód vstupenky"
|
||||
maxW="200px"
|
||||
maxH="200px"
|
||||
border="2px solid"
|
||||
borderColor="gray.200"
|
||||
borderRadius="md"
|
||||
/>
|
||||
<Text fontSize="xs" color="gray.500" textAlign="center">
|
||||
Kód pro ověření vstupenky
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Alert status="error">
|
||||
<AlertIcon />
|
||||
Nepodařilo se vygenerovat QR kód
|
||||
</Alert>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* Ticket Holder Info */}
|
||||
<VStack spacing={1} align="center" w="100%">
|
||||
<Text fontSize="sm" fontWeight="bold">Držitel vstupenky:</Text>
|
||||
<Text fontSize="sm">{ticket.holder_name}</Text>
|
||||
<Text fontSize="sm" color="gray.600">{ticket.holder_email}</Text>
|
||||
</VStack>
|
||||
|
||||
{/* Barcode */}
|
||||
<VStack spacing={1} align="center">
|
||||
<Text fontSize="xs" color="gray.500">Kód vstupenky:</Text>
|
||||
<Text fontSize="sm" fontFamily="monospace" fontWeight="bold">
|
||||
{ticket.barcode}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* Actions */}
|
||||
<HStack spacing={3} pt={2}>
|
||||
<Button
|
||||
leftIcon={<DownloadIcon />}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={downloadQRCode}
|
||||
isDisabled={!qrCodeDataUrl}
|
||||
>
|
||||
Stáhnout QR
|
||||
</Button>
|
||||
|
||||
{showValidation && (
|
||||
<Button
|
||||
leftIcon={<ViewIcon />}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
onClick={validateTicket}
|
||||
isDisabled={isValidated}
|
||||
>
|
||||
{isValidated ? 'Ověřeno' : 'Ověřit'}
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* Validation Status */}
|
||||
{isValidated && (
|
||||
<Alert status="success" size="sm">
|
||||
<AlertIcon />
|
||||
Vstupenka úspěšně ověřena
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{validationError && (
|
||||
<Alert status="error" size="sm">
|
||||
<AlertIcon />
|
||||
{validationError}
|
||||
</Alert>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { IconButton, useColorMode } from '@chakra-ui/react';
|
||||
import { MoonIcon, SunIcon } from '@chakra-ui/icons';
|
||||
|
||||
// Basic Chakra-powered theme toggle button
|
||||
export const ThemeToggle: React.FC = () => {
|
||||
const { colorMode, toggleColorMode } = useColorMode();
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
size="sm"
|
||||
fontSize="lg"
|
||||
aria-label="Přepnout barevné téma"
|
||||
variant="ghost"
|
||||
color="current"
|
||||
onClick={toggleColorMode}
|
||||
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Wrapper matching the requested DefaultToggle example
|
||||
export function DefaultToggle() {
|
||||
return (
|
||||
<div className="space-y-2 text-center">
|
||||
<div className="flex justify-center">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { DefaultToggle as DefaultThemeToggle };
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Box, Text, VStack, HStack, Badge, Icon, Spinner, Alert, AlertIcon, Image, Input, Button, useToast } from '@chakra-ui/react';
|
||||
import React, { useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { FaCalendarAlt, FaFutbol, FaExclamationTriangle, FaMapMarkerAlt } from 'react-icons/fa';
|
||||
import { FaCalendarAlt, FaFutbol, FaExclamationTriangle, FaMapMarkerAlt, FaCloud } from 'react-icons/fa';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api, API_URL } from '../../services/api';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Widget } from './Widget';
|
||||
import { format, parse, isToday, isTomorrow, isAfter } from 'date-fns';
|
||||
import { cs } from 'date-fns/locale';
|
||||
@@ -40,6 +41,7 @@ export const MatchesWidget: React.FC<{
|
||||
hideEmpty?: boolean;
|
||||
onMatchClick?: (match: Match) => void;
|
||||
}> = ({ categoryName, hideEmpty = false, onMatchClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [prefWeekly, setPrefWeekly] = useState<boolean>(true);
|
||||
@@ -60,7 +62,7 @@ export const MatchesWidget: React.FC<{
|
||||
|
||||
const subscribe = async () => {
|
||||
if (!email) {
|
||||
toast({ title: 'Zadejte email', status: 'warning' });
|
||||
toast({ title: t('form.email_required'), status: 'warning' });
|
||||
return;
|
||||
}
|
||||
setSubscribing(true);
|
||||
@@ -74,15 +76,24 @@ export const MatchesWidget: React.FC<{
|
||||
const jt = await res.json().catch(() => ({} as any));
|
||||
throw new Error(jt?.error || `HTTP ${res.status}`);
|
||||
}
|
||||
toast({ title: 'Přihlášeno k odběru', status: 'success' });
|
||||
toast({ title: t('matches.subscribe_success'), status: 'success' });
|
||||
setEmail('');
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Chyba přihlášení', description: e?.message || String(e), status: 'error' });
|
||||
toast({ title: t('matches.subscribe_error'), description: e?.message || String(e), status: 'error' });
|
||||
} finally {
|
||||
setSubscribing(false);
|
||||
}
|
||||
};
|
||||
const { settings } = useSettings();
|
||||
|
||||
// Check if a match is a home match
|
||||
const isHomeMatch = (match: Match) => {
|
||||
if (!match?.home || !settings) return false;
|
||||
const homeTeam = sanitizeClubName(match.home).toLowerCase();
|
||||
const clubName = sanitizeClubName((settings as any)?.club_name || '').toLowerCase();
|
||||
return homeTeam.includes(clubName) || clubName.includes(homeTeam);
|
||||
};
|
||||
|
||||
const { data: overrides = {} } = useQuery({
|
||||
queryKey: ['teamLogoOverrides'],
|
||||
queryFn: fetchTeamLogoOverrides,
|
||||
@@ -282,10 +293,10 @@ export const MatchesWidget: React.FC<{
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Widget title="Nadcházející zápasy">
|
||||
<Widget title={t('matches.upcoming')}>
|
||||
<VStack p={4}>
|
||||
<Spinner size="md" />
|
||||
<Text>Načítám zápasy...</Text>
|
||||
<Text>{t('matches.loading')}</Text>
|
||||
</VStack>
|
||||
</Widget>
|
||||
);
|
||||
@@ -293,10 +304,10 @@ export const MatchesWidget: React.FC<{
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Widget title="Nadcházející zápasy">
|
||||
<Widget title={t('matches.upcoming')}>
|
||||
<Alert status="error" variant="left-accent">
|
||||
<AlertIcon />
|
||||
Nepodařilo se načíst zápasy. Zkuste to prosím později.
|
||||
{t('matches.error')}
|
||||
</Alert>
|
||||
</Widget>
|
||||
);
|
||||
@@ -305,11 +316,11 @@ export const MatchesWidget: React.FC<{
|
||||
if (!displayMatches || displayMatches.length === 0) {
|
||||
if (hideEmpty) return null;
|
||||
return (
|
||||
<Widget title="Nadcházející zápasy">
|
||||
<Widget title={t('matches.upcoming')}>
|
||||
<VStack p={4} spacing={4}>
|
||||
<Icon as={FaCalendarAlt} boxSize={8} color="gray.400" />
|
||||
<Text color="gray.500" textAlign="center">
|
||||
Žádné nadcházející zápasy nebyly nalezeny.
|
||||
{t('matches.none_found')}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Widget>
|
||||
@@ -317,7 +328,7 @@ export const MatchesWidget: React.FC<{
|
||||
}
|
||||
|
||||
return (
|
||||
<Widget title="Nadcházející zápasy">
|
||||
<Widget title={t('matches.upcoming')}>
|
||||
<VStack spacing={{ base: 2, md: 3 }} align="stretch" divider={<Box borderBottomWidth="1px" borderColor="gray.200" />}>
|
||||
{displayMatches.map((match) => (
|
||||
<Box
|
||||
@@ -349,15 +360,20 @@ export const MatchesWidget: React.FC<{
|
||||
>
|
||||
{formatMatchDate(match.date_time)}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme="blue"
|
||||
variant="subtle"
|
||||
fontSize="xs"
|
||||
bg="blue.50"
|
||||
color="blue.700"
|
||||
>
|
||||
{match.competitionName}
|
||||
</Badge>
|
||||
<HStack spacing={1}>
|
||||
{isHomeMatch(match) && (
|
||||
<Icon as={FaCloud} boxSize={3} color="blue.500" title="Domácí zápas - počasí v modálu" />
|
||||
)}
|
||||
<Badge
|
||||
colorScheme="blue"
|
||||
variant="subtle"
|
||||
fontSize="xs"
|
||||
bg="blue.50"
|
||||
color="blue.700"
|
||||
>
|
||||
{match.competitionName}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<HStack justify="space-between" align="center">
|
||||
@@ -423,10 +439,10 @@ export const MatchesWidget: React.FC<{
|
||||
{/* Fan subscription form (hidden on admin pages) */}
|
||||
{!isAdminRoute && (
|
||||
<VStack p={3} spacing={2} align="stretch">
|
||||
<Text fontSize="sm" color="gray.600">Chcete dostávat novinky o zápasech e‑mailem?</Text>
|
||||
<Text fontSize="sm" color="gray.600">{t('matches.subscribe_prompt')}</Text>
|
||||
<HStack>
|
||||
<Input type="email" placeholder="váš@email.cz" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<Button colorScheme="red" onClick={subscribe} isLoading={subscribing}>Odebírat</Button>
|
||||
<Input type="email" placeholder={t('form.email_placeholder')} value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<Button colorScheme="red" onClick={subscribe} isLoading={subscribing}>{t('matches.subscribe')}</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { createContext, useCallback, useContext, useRef, useState } from 'react';
|
||||
import ConfirmationDialog from '../components/common/ConfirmationDialog';
|
||||
|
||||
interface ConfirmDialogOptions {
|
||||
title?: string;
|
||||
message?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
isDanger?: boolean;
|
||||
}
|
||||
|
||||
interface ConfirmDialogState {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
isDanger?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface ConfirmDialogContextValue {
|
||||
confirm: (options: ConfirmDialogOptions) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const ConfirmDialogContext = createContext<ConfirmDialogContextValue | undefined>(undefined);
|
||||
|
||||
export const ConfirmDialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [state, setState] = useState<ConfirmDialogState>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
confirmText: 'Potvrdit',
|
||||
cancelText: 'Zrušit',
|
||||
isDanger: false,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const pendingRef = useRef<{ resolve: (value: boolean) => void } | null>(null);
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
setState((prev) => ({ ...prev, isOpen: false, isLoading: false }));
|
||||
if (pendingRef.current) {
|
||||
pendingRef.current.resolve(false);
|
||||
pendingRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setState((prev) => ({ ...prev, isLoading: false, isOpen: false }));
|
||||
if (pendingRef.current) {
|
||||
pendingRef.current.resolve(true);
|
||||
pendingRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const confirm = useCallback((options: ConfirmDialogOptions): Promise<boolean> => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
pendingRef.current = { resolve };
|
||||
setState({
|
||||
isOpen: true,
|
||||
title: options.title || 'Potvrzení',
|
||||
message: options.message || '',
|
||||
confirmText: options.confirmText || 'Potvrdit',
|
||||
cancelText: options.cancelText || 'Zrušit',
|
||||
isDanger: options.isDanger || false,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfirmDialogContext.Provider value={{ confirm }}>
|
||||
{children}
|
||||
<ConfirmationDialog
|
||||
isOpen={state.isOpen}
|
||||
onClose={closeDialog}
|
||||
onConfirm={handleConfirm}
|
||||
title={state.title}
|
||||
message={state.message}
|
||||
confirmText={state.confirmText}
|
||||
cancelText={state.cancelText}
|
||||
isDanger={state.isDanger}
|
||||
isLoading={state.isLoading}
|
||||
/>
|
||||
</ConfirmDialogContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useConfirmDialog = (): ConfirmDialogContextValue => {
|
||||
const ctx = useContext(ConfirmDialogContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useConfirmDialog must be used within ConfirmDialogProvider');
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { PageElementConfig } from '../services/pageElements';
|
||||
// Only these should be available in the editor
|
||||
export const HOMEPAGE_IMPLEMENTED_ELEMENTS = [
|
||||
'style-pack', // Global style pack selector
|
||||
'container', // Hlavní kontejner stránky (šířka rozložení)
|
||||
'header', // Site navigation/header
|
||||
'hero-topbar', // Club bar above hero
|
||||
'hero', // Hero section with news cards (grid/scroller/swiper variants)
|
||||
@@ -33,6 +34,14 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
|
||||
display_order: -1,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
page_type: 'homepage',
|
||||
element_name: 'container',
|
||||
variant: 'boxed',
|
||||
visible: true,
|
||||
display_order: -1,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
page_type: 'homepage',
|
||||
element_name: 'header',
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
interface UseAdminNavScrollRetentionOptions {
|
||||
scrollContainerSelector?: string;
|
||||
navItemSelector?: string;
|
||||
enableDebug?: boolean;
|
||||
}
|
||||
|
||||
export const useAdminNavScrollRetention = (options: UseAdminNavScrollRetentionOptions = {}) => {
|
||||
const {
|
||||
scrollContainerSelector = '[data-sidebar="true"]',
|
||||
navItemSelector = 'a[href*="/admin/"]',
|
||||
enableDebug = false
|
||||
} = options;
|
||||
|
||||
const location = useLocation();
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||
const isScrollingRef = useRef<boolean>(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
const debug = useCallback((message: string, data?: any) => {
|
||||
if (enableDebug) {
|
||||
console.log(`[AdminNavScroll] ${message}`, data || '');
|
||||
}
|
||||
}, [enableDebug]);
|
||||
|
||||
// Always log critical events for debugging
|
||||
const log = useCallback((message: string, data?: any) => {
|
||||
console.log(`[AdminNavScroll] ${message}`, data || '');
|
||||
}, []);
|
||||
|
||||
// Get scroll container element
|
||||
const getScrollContainer = useCallback((): HTMLElement | null => {
|
||||
if (scrollContainerRef.current) {
|
||||
return scrollContainerRef.current;
|
||||
}
|
||||
|
||||
const container = document.querySelector(scrollContainerSelector) as HTMLElement;
|
||||
if (container) {
|
||||
scrollContainerRef.current = container;
|
||||
debug('Found scroll container:', container);
|
||||
}
|
||||
return container;
|
||||
}, [scrollContainerSelector, debug]);
|
||||
|
||||
// Find the nav item for the current path
|
||||
const findCurrentNavItem = useCallback((): HTMLElement | null => {
|
||||
const container = getScrollContainer();
|
||||
if (!container) return null;
|
||||
|
||||
// Find nav item that matches current pathname
|
||||
const navItems = container.querySelectorAll(navItemSelector);
|
||||
for (let i = 0; i < navItems.length; i++) {
|
||||
const item = navItems[i] as HTMLElement;
|
||||
const href = item.getAttribute('href');
|
||||
if (href === location.pathname) {
|
||||
log('Found current nav item:', { href, element: item });
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
// Try partial match if exact match not found
|
||||
for (let i = 0; i < navItems.length; i++) {
|
||||
const item = navItems[i] as HTMLElement;
|
||||
const href = item.getAttribute('href');
|
||||
if (href && location.pathname.startsWith(href) && href !== '/admin') {
|
||||
log('Found partial match nav item:', { href, pathname: location.pathname, element: item });
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
debug('No matching nav item found for:', location.pathname);
|
||||
return null;
|
||||
}, [getScrollContainer, navItemSelector, location.pathname, log, debug]);
|
||||
|
||||
// Scroll to center the current nav item
|
||||
const scrollToCurrentPage = useCallback(() => {
|
||||
const container = getScrollContainer();
|
||||
if (!container || isScrollingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const navItem = findCurrentNavItem();
|
||||
if (!navItem) {
|
||||
debug('No nav item to scroll to');
|
||||
return;
|
||||
}
|
||||
|
||||
isScrollingRef.current = true;
|
||||
|
||||
// Calculate scroll position to center the item
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const itemRect = navItem.getBoundingClientRect();
|
||||
|
||||
// Calculate item position relative to container
|
||||
const itemTopRelativeToContainer = itemRect.top - containerRect.top + container.scrollTop;
|
||||
const itemHeight = itemRect.height;
|
||||
const containerHeight = containerRect.height;
|
||||
|
||||
// Center the item in the viewport
|
||||
const targetScrollTop = itemTopRelativeToContainer - (containerHeight / 2) + (itemHeight / 2);
|
||||
|
||||
// Ensure we don't scroll beyond bounds
|
||||
const maxScrollTop = container.scrollHeight - containerHeight;
|
||||
const finalScrollTop = Math.max(0, Math.min(targetScrollTop, maxScrollTop));
|
||||
|
||||
log('🎯 Centering nav item:', {
|
||||
pathname: location.pathname,
|
||||
itemTopRelativeToContainer,
|
||||
targetScrollTop,
|
||||
finalScrollTop,
|
||||
containerHeight,
|
||||
itemHeight
|
||||
});
|
||||
|
||||
// Smooth scroll to position
|
||||
container.scrollTo({
|
||||
top: finalScrollTop,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
// Mark scrolling as complete after animation
|
||||
setTimeout(() => {
|
||||
isScrollingRef.current = false;
|
||||
log('✅ Scroll to current page completed');
|
||||
}, 300);
|
||||
|
||||
}, [getScrollContainer, findCurrentNavItem, location.pathname, log, debug]);
|
||||
|
||||
// Handle navigation - scroll to current page
|
||||
useEffect(() => {
|
||||
log('🧭 Navigation detected:', location.pathname);
|
||||
|
||||
// Scroll to current page after a short delay to allow DOM to update
|
||||
const scrollTimer = setTimeout(() => {
|
||||
scrollToCurrentPage();
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(scrollTimer);
|
||||
}, [location.pathname, scrollToCurrentPage, log]);
|
||||
|
||||
// Initialize and scroll to current page when component mounts
|
||||
useEffect(() => {
|
||||
const initTimer = setTimeout(() => {
|
||||
const container = getScrollContainer();
|
||||
if (container) {
|
||||
setIsReady(true);
|
||||
|
||||
// Scroll to current page
|
||||
scrollToCurrentPage();
|
||||
|
||||
log('✅ Initialization completed');
|
||||
} else {
|
||||
debug('Scroll container not found after delay');
|
||||
}
|
||||
}, 150); // Slightly longer delay to ensure nav items are rendered
|
||||
|
||||
return () => {
|
||||
clearTimeout(initTimer);
|
||||
};
|
||||
}, [getScrollContainer, scrollToCurrentPage, log, debug]);
|
||||
|
||||
return {
|
||||
scrollToCurrentPage,
|
||||
scrollPosition: scrollContainerRef.current?.scrollTop || 0,
|
||||
isReady,
|
||||
debug
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { translateBlogContent, detectLanguage } from '../services/translation';
|
||||
|
||||
export interface BlogTranslationHook {
|
||||
translateBlog: (title: string, content: string) => Promise<{ title: string; content: string }>;
|
||||
isTranslating: boolean;
|
||||
translationError: string | null;
|
||||
detectSourceLanguage: (text: string) => 'cs' | 'en';
|
||||
getTargetLanguage: () => 'cs' | 'en';
|
||||
}
|
||||
|
||||
export const useBlogTranslation = (): BlogTranslationHook => {
|
||||
const { i18n } = useTranslation();
|
||||
const [isTranslating, setIsTranslating] = useState(false);
|
||||
const [translationError, setTranslationError] = useState<string | null>(null);
|
||||
|
||||
const detectSourceLanguage = (text: string): 'cs' | 'en' => {
|
||||
return detectLanguage(text);
|
||||
};
|
||||
|
||||
const getTargetLanguage = (): 'cs' | 'en' => {
|
||||
// If current language is Czech, translate to English, and vice versa
|
||||
return i18n.language === 'cs' ? 'en' : 'cs';
|
||||
};
|
||||
|
||||
const translateBlog = async (title: string, content: string): Promise<{ title: string; content: string }> => {
|
||||
setIsTranslating(true);
|
||||
setTranslationError(null);
|
||||
|
||||
try {
|
||||
const sourceLang = detectSourceLanguage(title + ' ' + content);
|
||||
const targetLang = getTargetLanguage();
|
||||
|
||||
// Don't translate if source and target are the same
|
||||
if (sourceLang === targetLang) {
|
||||
return { title, content };
|
||||
}
|
||||
|
||||
const result = await translateBlogContent(title, content, sourceLang, targetLang);
|
||||
|
||||
return {
|
||||
title: result.translatedTitle,
|
||||
content: result.translatedContent,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Translation failed';
|
||||
setTranslationError(errorMessage);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsTranslating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
translateBlog,
|
||||
isTranslating,
|
||||
translationError,
|
||||
detectSourceLanguage,
|
||||
getTargetLanguage,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
// Direct scroll retention fix - bypass React hooks
|
||||
// Add this script directly to AdminLayout as a useEffect
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const useDirectScrollRetention = () => {
|
||||
useEffect(() => {
|
||||
console.log('[DirectScrollRetention] Setting up scroll retention...');
|
||||
|
||||
let savedScrollPosition = 0;
|
||||
let isNavigating = false;
|
||||
|
||||
// Save scroll position before navigation
|
||||
const saveScrollPosition = () => {
|
||||
if (isNavigating) return;
|
||||
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (sidebar && sidebar.scrollTop > 0) {
|
||||
savedScrollPosition = sidebar.scrollTop;
|
||||
console.log('[DirectScrollRetention] Saved position:', savedScrollPosition);
|
||||
}
|
||||
};
|
||||
|
||||
// Restore scroll position after navigation
|
||||
const restoreScrollPosition = () => {
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (!sidebar || savedScrollPosition === 0) return;
|
||||
|
||||
console.log('[DirectScrollRetention] Restoring position:', savedScrollPosition);
|
||||
|
||||
// Aggressive restoration
|
||||
const targetScroll = savedScrollPosition;
|
||||
|
||||
// Multiple restoration attempts
|
||||
const restoreAttempts = [
|
||||
() => { sidebar.scrollTop = targetScroll; },
|
||||
() => { sidebar.scrollTo({ top: targetScroll, behavior: 'auto' }); },
|
||||
() => { sidebar.scrollTop = targetScroll; },
|
||||
];
|
||||
|
||||
// Try restoration at different intervals
|
||||
restoreAttempts.forEach((restore, index) => {
|
||||
setTimeout(() => {
|
||||
restore();
|
||||
console.log(`[DirectScrollRetention] Restore attempt ${index + 1}:`, sidebar.scrollTop);
|
||||
}, index * 100);
|
||||
});
|
||||
|
||||
// Final attempt
|
||||
setTimeout(() => {
|
||||
if (sidebar.scrollTop !== targetScroll) {
|
||||
sidebar.scrollTop = targetScroll;
|
||||
console.log('[DirectScrollRetention] Final restore:', sidebar.scrollTop);
|
||||
}
|
||||
isNavigating = false;
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Intercept navigation
|
||||
const originalPushState = history.pushState;
|
||||
history.pushState = function(...args) {
|
||||
if (!isNavigating) {
|
||||
isNavigating = true;
|
||||
saveScrollPosition();
|
||||
|
||||
// Restore after a short delay
|
||||
setTimeout(restoreScrollPosition, 50);
|
||||
}
|
||||
return originalPushState.apply(this, args);
|
||||
};
|
||||
|
||||
// Also intercept popstate (back/forward buttons)
|
||||
const handlePopState = () => {
|
||||
if (!isNavigating) {
|
||||
isNavigating = true;
|
||||
saveScrollPosition();
|
||||
setTimeout(restoreScrollPosition, 50);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
|
||||
// Save scroll on every scroll event
|
||||
const scrollHandler = () => {
|
||||
if (!isNavigating) {
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (sidebar && sidebar.scrollTop > 0) {
|
||||
savedScrollPosition = sidebar.scrollTop;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (sidebar) {
|
||||
sidebar.addEventListener('scroll', scrollHandler);
|
||||
}
|
||||
|
||||
console.log('[DirectScrollRetention] Scroll retention setup complete');
|
||||
|
||||
return () => {
|
||||
history.pushState = originalPushState;
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
if (sidebar) {
|
||||
sidebar.removeEventListener('scroll', scrollHandler);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
// Fast, native-speed admin scroll retention - optimized for performance
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
export const useFastAdminScroll = (enabled: boolean = true) => {
|
||||
const location = useLocation();
|
||||
const scrollPositions = useRef<Map<string, number>>(new Map());
|
||||
const lastPath = useRef<string>('');
|
||||
const isRestoring = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (!sidebar) return;
|
||||
|
||||
// Save current scroll position instantly before navigation
|
||||
if (lastPath.current && !isRestoring.current) {
|
||||
const currentScroll = sidebar.scrollTop;
|
||||
if (currentScroll > 0) {
|
||||
scrollPositions.current.set(lastPath.current, currentScroll);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore scroll position for new path
|
||||
const savedPosition = scrollPositions.current.get(location.pathname) || 0;
|
||||
if (savedPosition > 0 && location.pathname !== lastPath.current) {
|
||||
isRestoring.current = true;
|
||||
|
||||
// Use direct assignment for instant scroll - no animation, no delays
|
||||
sidebar.scrollTop = savedPosition;
|
||||
|
||||
// Clear restoration flag immediately after next frame
|
||||
requestAnimationFrame(() => {
|
||||
isRestoring.current = false;
|
||||
});
|
||||
}
|
||||
|
||||
lastPath.current = location.pathname;
|
||||
}, [location.pathname, enabled]);
|
||||
};
|
||||
@@ -0,0 +1,259 @@
|
||||
// 100% GUARANTEED Admin Sidebar Scroll Retention Solution
|
||||
// This uses multiple approaches to ensure it works no matter what
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
// Global scroll state that persists across component unmounts
|
||||
declare global {
|
||||
interface Window {
|
||||
__adminSidebarScrollTarget?: number;
|
||||
__adminScrollRetentionActive?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export const useGuaranteedScrollRetention = () => {
|
||||
useEffect(() => {
|
||||
console.log('[GuaranteedScrollRetention] INITIALIZING 100% GUARANTEED SOLUTION...');
|
||||
|
||||
// Mark as active globally
|
||||
window.__adminScrollRetentionActive = true;
|
||||
|
||||
let savedScrollPosition = 0;
|
||||
let isNavigating = false;
|
||||
let restorationIntervals: NodeJS.Timeout[] = [];
|
||||
let forceScrollInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
// Save scroll position continuously
|
||||
const saveScrollPosition = () => {
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (sidebar && sidebar.scrollTop > 0) {
|
||||
savedScrollPosition = sidebar.scrollTop;
|
||||
window.__adminSidebarScrollTarget = savedScrollPosition;
|
||||
console.log('[GuaranteedScrollRetention] Saved position:', savedScrollPosition);
|
||||
}
|
||||
};
|
||||
|
||||
// Aggressive scroll restoration with multiple methods
|
||||
const restoreScrollPosition = () => {
|
||||
// Wait for DOM to be ready
|
||||
setTimeout(() => {
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (!sidebar || savedScrollPosition === 0) return;
|
||||
|
||||
console.log('[GuaranteedScrollRetention] RESTORING POSITION:', savedScrollPosition);
|
||||
console.log('[GuaranteedScrollRetention] Sidebar element found:', !!sidebar);
|
||||
console.log('[GuaranteedScrollRetention] Current scrollTop:', sidebar.scrollTop);
|
||||
|
||||
// Clear previous intervals
|
||||
restorationIntervals.forEach(interval => clearInterval(interval));
|
||||
restorationIntervals = [];
|
||||
if (forceScrollInterval) clearInterval(forceScrollInterval);
|
||||
|
||||
// Method 1: Direct assignment
|
||||
sidebar.scrollTop = savedScrollPosition;
|
||||
|
||||
// Method 2: scrollTo
|
||||
sidebar.scrollTo({ top: savedScrollPosition, behavior: 'auto' });
|
||||
|
||||
// Method 3: Force scroll with requestAnimationFrame
|
||||
const forceScroll = () => {
|
||||
if (sidebar && sidebar.scrollTop !== savedScrollPosition) {
|
||||
sidebar.scrollTop = savedScrollPosition;
|
||||
console.log('[GuaranteedScrollRetention] Forced scroll to:', sidebar.scrollTop);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(forceScroll);
|
||||
|
||||
// Method 4: Multiple timed attempts with longer delays
|
||||
const delays = [0, 50, 100, 200, 300, 500, 750, 1000, 1500, 2000];
|
||||
delays.forEach(delay => {
|
||||
const timeout = setTimeout(() => {
|
||||
const currentSidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (currentSidebar) {
|
||||
currentSidebar.scrollTop = savedScrollPosition;
|
||||
currentSidebar.scrollTo({ top: savedScrollPosition, behavior: 'auto' });
|
||||
console.log(`[GuaranteedScrollRetention] Attempt at ${delay}ms:`, currentSidebar.scrollTop);
|
||||
}
|
||||
}, delay);
|
||||
restorationIntervals.push(timeout);
|
||||
});
|
||||
|
||||
// Method 5: Continuous force scroll for 5 seconds
|
||||
forceScrollInterval = setInterval(() => {
|
||||
const currentSidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (currentSidebar && currentSidebar.scrollTop !== savedScrollPosition) {
|
||||
currentSidebar.scrollTop = savedScrollPosition;
|
||||
console.log('[GuaranteedScrollRetention] Continuous force scroll:', currentSidebar.scrollTop);
|
||||
}
|
||||
}, 50);
|
||||
|
||||
setTimeout(() => {
|
||||
if (forceScrollInterval) {
|
||||
clearInterval(forceScrollInterval);
|
||||
forceScrollInterval = null;
|
||||
console.log('[GuaranteedScrollRetention] Continuous force scroll stopped');
|
||||
}
|
||||
isNavigating = false;
|
||||
console.log('[GuaranteedScrollRetention] Navigation lock released');
|
||||
}, 5000);
|
||||
}, 100); // Wait 100ms for DOM to settle
|
||||
};
|
||||
|
||||
// Intercept ALL possible navigation methods
|
||||
const originalPushState = history.pushState;
|
||||
const originalReplaceState = history.replaceState;
|
||||
// const originalAssign = (window as any).location.assign; // REMOVED - read-only property
|
||||
// const originalReplace = (window as any).location.replace; // REMOVED - read-only property
|
||||
const originalHref = Object.getOwnPropertyDescriptor(window.location, 'href')?.set;
|
||||
|
||||
// Override pushState
|
||||
history.pushState = function(state: any, unused: string, url?: string | URL | null) {
|
||||
if (!isNavigating) {
|
||||
isNavigating = true;
|
||||
saveScrollPosition();
|
||||
console.log('[GuaranteedScrollRetention] PushState intercepted');
|
||||
setTimeout(restoreScrollPosition, 5);
|
||||
}
|
||||
return originalPushState.call(history, state, unused, url);
|
||||
};
|
||||
|
||||
// Override replaceState
|
||||
history.replaceState = function(state: any, unused: string, url?: string | URL | null) {
|
||||
if (!isNavigating) {
|
||||
isNavigating = true;
|
||||
saveScrollPosition();
|
||||
console.log('[GuaranteedScrollRetention] ReplaceState intercepted');
|
||||
setTimeout(restoreScrollPosition, 5);
|
||||
}
|
||||
return originalReplaceState.call(history, state, unused, url);
|
||||
};
|
||||
|
||||
// Override location.assign - REMOVED due to read-only property error
|
||||
// (window as any).location.assign = function(url: string | URL) {
|
||||
// if (!isNavigating) {
|
||||
// isNavigating = true;
|
||||
// saveScrollPosition();
|
||||
// console.log('[GuaranteedScrollRetention] Location.assign intercepted');
|
||||
// setTimeout(restoreScrollPosition, 5);
|
||||
// }
|
||||
// return originalAssign.call(window.location, url);
|
||||
// };
|
||||
|
||||
// Override location.replace - REMOVED due to read-only property error
|
||||
// (window as any).location.replace = function(url: string | URL) {
|
||||
// if (!isNavigating) {
|
||||
// isNavigating = true;
|
||||
// saveScrollPosition();
|
||||
// console.log('[GuaranteedScrollRetention] Location.replace intercepted');
|
||||
// setTimeout(restoreScrollPosition, 5);
|
||||
// }
|
||||
// return originalReplace.call(window.location, url);
|
||||
// };
|
||||
|
||||
// Handle popstate (back/forward buttons)
|
||||
const handlePopState = () => {
|
||||
if (!isNavigating) {
|
||||
isNavigating = true;
|
||||
saveScrollPosition();
|
||||
console.log('[GuaranteedScrollRetention] PopState intercepted');
|
||||
setTimeout(restoreScrollPosition, 5);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
|
||||
// Continuous scroll saving
|
||||
const handleScroll = () => {
|
||||
if (!isNavigating) {
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (sidebar && sidebar.scrollTop > 0) {
|
||||
savedScrollPosition = sidebar.scrollTop;
|
||||
window.__adminSidebarScrollTarget = savedScrollPosition;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add scroll listener to sidebar
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (sidebar) {
|
||||
sidebar.addEventListener('scroll', handleScroll, { passive: true });
|
||||
console.log('[GuaranteedScrollRetention] Scroll listener attached');
|
||||
|
||||
// Initial save
|
||||
if (sidebar.scrollTop > 0) {
|
||||
savedScrollPosition = sidebar.scrollTop;
|
||||
window.__adminSidebarScrollTarget = savedScrollPosition;
|
||||
}
|
||||
}
|
||||
|
||||
// MutationObserver to catch DOM changes
|
||||
const observer = new MutationObserver(() => {
|
||||
if (isNavigating && savedScrollPosition > 0) {
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (sidebar && sidebar.scrollTop !== savedScrollPosition) {
|
||||
sidebar.scrollTop = savedScrollPosition;
|
||||
console.log('[GuaranteedScrollRetention] MutationObserver restored scroll');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (sidebar) {
|
||||
observer.observe(sidebar, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class', 'id']
|
||||
});
|
||||
console.log('[GuaranteedScrollRetention] MutationObserver attached');
|
||||
}
|
||||
|
||||
// Also observe document body for any layout changes
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class']
|
||||
});
|
||||
|
||||
// Global scroll restoration check every 100ms for 5 seconds after navigation
|
||||
const globalCheckInterval = setInterval(() => {
|
||||
if (isNavigating && savedScrollPosition > 0) {
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (sidebar && sidebar.scrollTop !== savedScrollPosition) {
|
||||
sidebar.scrollTop = savedScrollPosition;
|
||||
console.log('[GuaranteedScrollRetention] Global check restored scroll');
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(globalCheckInterval);
|
||||
console.log('[GuaranteedScrollRetention] Global check stopped');
|
||||
}, 5000);
|
||||
|
||||
console.log('[GuaranteedScrollRetention] 100% GUARANTEED SOLUTION SETUP COMPLETE');
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
console.log('[GuaranteedScrollRetention] Cleaning up...');
|
||||
window.__adminScrollRetentionActive = false;
|
||||
|
||||
history.pushState = originalPushState;
|
||||
history.replaceState = originalReplaceState;
|
||||
// (window as any).location.assign = originalAssign; // REMOVED - read-only property
|
||||
// (window as any).location.replace = originalReplace; // REMOVED - read-only property
|
||||
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
|
||||
if (sidebar) {
|
||||
sidebar.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
|
||||
observer.disconnect();
|
||||
|
||||
restorationIntervals.forEach(interval => clearInterval(interval));
|
||||
if (forceScrollInterval) clearInterval(forceScrollInterval);
|
||||
clearInterval(globalCheckInterval);
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getCategories, Category } from '../services/public';
|
||||
import { getNavigationItems, NavigationItem } from '../services/navigation';
|
||||
import { getEvents } from '../services/eventService';
|
||||
import { getPlayers } from '../services/public';
|
||||
import { getArticles } from '../services/articles';
|
||||
import { getCachedYouTube } from '../services/youtube';
|
||||
import { getZoneramaManifestWithFallbacks } from '../services/zonerama';
|
||||
import { API_URL } from '../services/api';
|
||||
|
||||
export interface NavbarData {
|
||||
categories: Category[] | null;
|
||||
dynamicNavItems: NavigationItem[];
|
||||
navLoading: boolean;
|
||||
hasTables: boolean;
|
||||
hasActivities: boolean;
|
||||
hasPlayers: boolean;
|
||||
hasArticles: boolean;
|
||||
hasVideos: boolean;
|
||||
hasGallery: boolean;
|
||||
}
|
||||
|
||||
export const useNavbarData = (isAdmin: boolean, settings?: any): NavbarData => {
|
||||
const [categories, setCategories] = useState<Category[] | null>(null);
|
||||
const [dynamicNavItems, setDynamicNavItems] = useState<NavigationItem[]>([]);
|
||||
const [navLoading, setNavLoading] = useState(true);
|
||||
const [hasTables, setHasTables] = useState<boolean>(false);
|
||||
const [hasActivities, setHasActivities] = useState<boolean>(false);
|
||||
const [hasPlayers, setHasPlayers] = useState<boolean>(false);
|
||||
const [hasArticles, setHasArticles] = useState<boolean>(false);
|
||||
const [hasVideos, setHasVideos] = useState<boolean>(false);
|
||||
const [hasGallery, setHasGallery] = useState<boolean>(false);
|
||||
|
||||
// Combined data loading effect
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
const loadAllData = async () => {
|
||||
try {
|
||||
// Load navigation and categories in parallel
|
||||
const [navItems, cats] = await Promise.all([
|
||||
getNavigationItems().catch(() => []),
|
||||
getCategories().catch(() => [])
|
||||
]);
|
||||
|
||||
if (active) {
|
||||
// Process navigation
|
||||
const publicItems = Array.isArray(navItems)
|
||||
? navItems.filter(item => !item.requires_admin)
|
||||
: [];
|
||||
|
||||
// Auto-seed if navigation is empty (only if user is authenticated as admin)
|
||||
if (publicItems.length === 0 && isAdmin) {
|
||||
try {
|
||||
console.log('Navigation empty, auto-seeding...');
|
||||
// Note: seedDefaultNavigation() would need to be imported
|
||||
// For now, continue with empty navigation
|
||||
} catch (seedError) {
|
||||
console.error('Auto-seed failed:', seedError);
|
||||
}
|
||||
}
|
||||
|
||||
setDynamicNavItems(publicItems);
|
||||
|
||||
// Process categories
|
||||
if (Array.isArray(cats) && cats.length > 0) {
|
||||
setCategories(cats);
|
||||
} else if (Array.isArray(settings?.categories)) {
|
||||
setCategories(settings.categories as any);
|
||||
} else {
|
||||
setCategories(null);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load navigation/categories:', error);
|
||||
if (active) {
|
||||
setDynamicNavItems([]);
|
||||
setCategories(Array.isArray(settings?.categories) ? settings.categories as any : null);
|
||||
}
|
||||
} finally {
|
||||
if (active) setNavLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAllData();
|
||||
return () => { active = false };
|
||||
}, [isAdmin, settings?.categories]);
|
||||
|
||||
// Load content availability data in parallel
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
|
||||
const resolveBackendUrl = (path: string) => {
|
||||
try {
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
|
||||
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
|
||||
return new URL(path, origin).toString();
|
||||
}
|
||||
return path;
|
||||
} catch { return path; }
|
||||
};
|
||||
|
||||
const loadContentData = async () => {
|
||||
try {
|
||||
// Load all content availability checks in parallel
|
||||
const [
|
||||
tablesResponse,
|
||||
events,
|
||||
players,
|
||||
articlesResponse,
|
||||
youtube,
|
||||
manifest
|
||||
] = await Promise.allSettled([
|
||||
fetch(resolveBackendUrl('/cache/prefetch/facr_tables.json'), { cache: 'no-cache' }),
|
||||
getEvents().catch(() => []),
|
||||
getPlayers().catch(() => []),
|
||||
getArticles({ page: 1, page_size: 1, published: true }).catch(() => ({ total: 0 })),
|
||||
getCachedYouTube().catch(() => null),
|
||||
getZoneramaManifestWithFallbacks().catch(() => [])
|
||||
]);
|
||||
|
||||
if (!disposed) {
|
||||
// Process tables
|
||||
if (tablesResponse.status === 'fulfilled') {
|
||||
const res = tablesResponse.value;
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
const anyRows = Array.isArray(json?.competitions) &&
|
||||
json.competitions.some((c: any) => Array.isArray(c?.table?.overall) && c.table.overall.length > 0);
|
||||
setHasTables(!!anyRows);
|
||||
} else {
|
||||
setHasTables(false);
|
||||
}
|
||||
} else {
|
||||
setHasTables(false);
|
||||
}
|
||||
|
||||
// Process other content with proper type guards
|
||||
setHasActivities(events.status === 'fulfilled' && Array.isArray(events.value) && events.value.length > 0);
|
||||
setHasPlayers(players.status === 'fulfilled' && Array.isArray(players.value) && players.value.length > 0);
|
||||
setHasArticles(articlesResponse.status === 'fulfilled' && typeof articlesResponse.value === 'object' && articlesResponse.value !== null && 'total' in articlesResponse.value && (articlesResponse.value as any).total > 0);
|
||||
setHasVideos(youtube.status === 'fulfilled' && youtube.value !== null && typeof youtube.value === 'object' && 'videos' in youtube.value && Array.isArray((youtube.value as any).videos) && (youtube.value as any).videos.length > 0);
|
||||
setHasGallery(manifest.status === 'fulfilled' && Array.isArray(manifest.value) && manifest.value.length > 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load content data:', error);
|
||||
if (!disposed) {
|
||||
setHasTables(false);
|
||||
setHasActivities(false);
|
||||
setHasPlayers(false);
|
||||
setHasArticles(false);
|
||||
setHasVideos(false);
|
||||
setHasGallery(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadContentData();
|
||||
return () => { disposed = true; };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
categories,
|
||||
dynamicNavItems,
|
||||
navLoading,
|
||||
hasTables,
|
||||
hasActivities,
|
||||
hasPlayers,
|
||||
hasArticles,
|
||||
hasVideos,
|
||||
hasGallery,
|
||||
};
|
||||
};
|
||||
@@ -5,8 +5,8 @@ export const usePublicSettings = () =>
|
||||
useQuery<PublicSettings>({
|
||||
queryKey: ['public-settings'],
|
||||
queryFn: getPublicSettings,
|
||||
staleTime: 0,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes instead of 0
|
||||
cacheTime: 30 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: true,
|
||||
refetchOnMount: false, // Don't refetch on mount to improve performance
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// Simple and reliable scroll retention that targets the active nav item
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const useSimpleScrollRetention = () => {
|
||||
useEffect(() => {
|
||||
console.log('[SimpleScrollRetention] Setting up simple scroll retention...');
|
||||
|
||||
let savedActiveItem: string | null = null;
|
||||
|
||||
// Save the active nav item before navigation
|
||||
const saveActiveItem = () => {
|
||||
const activeItem = document.querySelector('[data-navitem="true"][data-active="true"]') as HTMLElement;
|
||||
if (activeItem) {
|
||||
savedActiveItem = activeItem.getAttribute('href') || null;
|
||||
console.log('[SimpleScrollRetention] Saved active item:', savedActiveItem);
|
||||
}
|
||||
};
|
||||
|
||||
// Restore scroll to the active nav item after navigation
|
||||
const restoreScrollToActiveItem = () => {
|
||||
if (!savedActiveItem) return;
|
||||
|
||||
// Try multiple times with increasing delays
|
||||
const attempts = [100, 300, 500, 1000];
|
||||
|
||||
attempts.forEach((delay, index) => {
|
||||
setTimeout(() => {
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
const activeItem = document.querySelector(`[data-navitem="true"][href="${savedActiveItem}"]`) as HTMLElement;
|
||||
|
||||
console.log(`[SimpleScrollRetention] Attempt ${index + 1} at ${delay}ms:`, {
|
||||
sidebar: !!sidebar,
|
||||
activeItem: !!activeItem,
|
||||
sidebarScrollTop: sidebar?.scrollTop || 0,
|
||||
savedActiveItem
|
||||
});
|
||||
|
||||
if (sidebar && activeItem) {
|
||||
// Calculate the position to center the active item
|
||||
const sidebarRect = sidebar.getBoundingClientRect();
|
||||
const itemRect = activeItem.getBoundingClientRect();
|
||||
const relativeTop = itemRect.top - sidebarRect.top + sidebar.scrollTop;
|
||||
const targetScroll = relativeTop - (sidebarRect.height / 2) + (itemRect.height / 2);
|
||||
|
||||
console.log('[SimpleScrollRetention] Calculated scroll position:', targetScroll);
|
||||
console.log('[SimpleScrollRetention] Current sidebar scrollTop:', sidebar.scrollTop);
|
||||
|
||||
// Scroll to the calculated position
|
||||
sidebar.scrollTo({ top: targetScroll, behavior: 'auto' });
|
||||
|
||||
// Verify and force if needed
|
||||
setTimeout(() => {
|
||||
if (Math.abs(sidebar.scrollTop - targetScroll) > 10) {
|
||||
sidebar.scrollTop = targetScroll;
|
||||
console.log('[SimpleScrollRetention] Forced scroll to:', sidebar.scrollTop);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}, delay);
|
||||
});
|
||||
};
|
||||
|
||||
// Intercept navigation
|
||||
const originalPushState = history.pushState;
|
||||
const originalReplaceState = history.replaceState;
|
||||
|
||||
history.pushState = function(...args) {
|
||||
saveActiveItem();
|
||||
restoreScrollToActiveItem();
|
||||
return originalPushState.apply(this, args);
|
||||
};
|
||||
|
||||
history.replaceState = function(...args) {
|
||||
saveActiveItem();
|
||||
restoreScrollToActiveItem();
|
||||
return originalReplaceState.apply(this, args);
|
||||
};
|
||||
|
||||
// Handle popstate (back/forward buttons)
|
||||
const handlePopState = () => {
|
||||
saveActiveItem();
|
||||
restoreScrollToActiveItem();
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
|
||||
console.log('[SimpleScrollRetention] Setup complete');
|
||||
|
||||
return () => {
|
||||
history.pushState = originalPushState;
|
||||
history.replaceState = originalReplaceState;
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,16 @@ button, input, textarea, select {
|
||||
font-family: var(--font-body, var(--chakra-fonts-body, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif));
|
||||
}
|
||||
|
||||
input[type="date"],
|
||||
input[type="datetime-local"],
|
||||
input[type="time"] {
|
||||
border-radius: 9999px;
|
||||
padding-inline: 1rem;
|
||||
padding-block: 0.5rem;
|
||||
height: 2.5rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Dark mode root styles */
|
||||
html.chakra-ui-dark,
|
||||
.chakra-ui-dark body,
|
||||
|
||||
@@ -10,6 +10,10 @@ import 'quill/dist/quill.snow.css';
|
||||
import 'react-image-crop/dist/ReactCrop.css';
|
||||
// Custom editor styles AFTER quill base styles to ensure proper override
|
||||
import './styles/custom-editor.css';
|
||||
// Public rich content styles to mirror Quill rendering outside editor
|
||||
import './styles/public-rich-content.css';
|
||||
// Import i18n configuration
|
||||
import './i18n';
|
||||
import { theme } from './App';
|
||||
import AppLazy from './App.lazy';
|
||||
import { ColorModeScript } from '@chakra-ui/react';
|
||||
|
||||
@@ -11,19 +11,22 @@ import {
|
||||
HStack,
|
||||
|
||||
} from '@chakra-ui/react';
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { ReactNode, useEffect, useState, useRef } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import AdminSidebar from '../components/admin/AdminSidebar';
|
||||
import AdminHeader from '../components/admin/AdminHeader';
|
||||
import { FiMenu } from 'react-icons/fi';
|
||||
import { MoonIcon, SunIcon } from '@chakra-ui/icons';
|
||||
import { FaSearch } from 'react-icons/fa';
|
||||
import AdminSearchModal from '../components/admin/AdminSearchModal';
|
||||
import AdminSupportButton from '../components/admin/AdminSupportButton';
|
||||
import AdminHealthIndicator from '../components/admin/AdminHealthIndicator';
|
||||
import { logAction } from '../services/actionLog';
|
||||
import '../styles/admin-scroll-fix.css';
|
||||
import { useFastAdminScroll } from '../hooks/useFastAdminScroll';
|
||||
import { usePublicSettings } from '../hooks/usePublicSettings';
|
||||
import { assetUrl } from '../utils/url';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -31,19 +34,52 @@ interface AdminLayoutProps {
|
||||
}
|
||||
|
||||
const AdminLayout = ({ children, requireAdmin = true }: AdminLayoutProps) => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure({ defaultIsOpen: false });
|
||||
const { isOpen: isSearchOpen, onOpen: onSearchOpen, onClose: onSearchClose } = useDisclosure();
|
||||
const { isAuthenticated, isLoading, user } = useAuth();
|
||||
|
||||
// Track if sidebar was manually opened (to prevent auto-close)
|
||||
const manuallyOpenedRef = useRef(false);
|
||||
|
||||
// Wrap onOpen and onClose with manual open tracking
|
||||
const handleOpen = () => {
|
||||
manuallyOpenedRef.current = true;
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
manuallyOpenedRef.current = false;
|
||||
onClose();
|
||||
};
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { colorMode, toggleColorMode } = useColorMode();
|
||||
const { isAuthenticated, isLoading, user } = useAuth();
|
||||
const { colorMode } = useColorMode();
|
||||
const bg = useColorModeValue('gray.50', 'gray.900');
|
||||
const contentBg = useColorModeValue('white', '#1a1d29');
|
||||
const sidebarBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const { data: publicSettings } = usePublicSettings();
|
||||
|
||||
// Color values - Matching admin-enhancements.css dark mode colors
|
||||
const bg = useColorModeValue('gray.50', '#0f1115');
|
||||
const contentBg = useColorModeValue('white', '#1a1d29');
|
||||
const borderColor = useColorModeValue('gray.200', 'rgba(255, 255, 255, 0.12)');
|
||||
const sidebarBg = useColorModeValue('white', '#1a1d29');
|
||||
// Track mobile state
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
// Update mobile state on resize
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
const mobile = window.innerWidth < 768;
|
||||
setIsMobile(mobile);
|
||||
};
|
||||
checkMobile();
|
||||
const handleResize = () => checkMobile();
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [isMobile]);
|
||||
|
||||
// Fast native-speed scroll retention (disabled on mobile)
|
||||
useFastAdminScroll(!isMobile);
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
useEffect(() => {
|
||||
@@ -60,10 +96,17 @@ const AdminLayout = ({ children, requireAdmin = true }: AdminLayoutProps) => {
|
||||
e.preventDefault();
|
||||
onSearchOpen();
|
||||
}
|
||||
// Close sidebar on Escape key (mobile only)
|
||||
if (key === 'escape' && isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [onSearchOpen]);
|
||||
}, [onSearchOpen, isOpen, handleClose]);
|
||||
|
||||
// Note: Removed auto-close on route change for mobile to prevent immediate closing after opening
|
||||
// Users can close the sidebar by clicking the overlay or pressing Escape
|
||||
|
||||
// Redirect non-admin users only when page requires admin
|
||||
useEffect(() => {
|
||||
@@ -117,56 +160,60 @@ const AdminLayout = ({ children, requireAdmin = true }: AdminLayoutProps) => {
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg={bg} className="admin-layout">
|
||||
<AdminSidebar
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
bg={sidebarBg}
|
||||
borderRight="1px"
|
||||
borderColor={borderColor}
|
||||
{/* Mobile overlay */}
|
||||
<Box
|
||||
display={{ base: isOpen ? 'block' : 'none', md: 'none' }}
|
||||
position="fixed"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
bg="blackAlpha.600"
|
||||
zIndex={9}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
<Box
|
||||
ml={{ base: 0, md: '260px' }}
|
||||
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||
minH="100vh"
|
||||
>
|
||||
<AdminHeader
|
||||
onMenuToggle={onOpen}
|
||||
rightContent={
|
||||
<HStack spacing={3}>
|
||||
<IconButton
|
||||
aria-label="Vyhledávání v administraci"
|
||||
icon={<FaSearch />}
|
||||
onClick={onSearchOpen}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
borderRadius="full"
|
||||
_hover={{ bg: useColorModeValue('gray.100', 'gray.700'), transform: 'scale(1.05)' }}
|
||||
transition="all 0.2s"
|
||||
/>
|
||||
<AdminSupportButton />
|
||||
<IconButton
|
||||
aria-label={`Switch to ${colorMode === 'light' ? 'dark' : 'light'} mode`}
|
||||
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
|
||||
onClick={toggleColorMode}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
borderRadius="full"
|
||||
_hover={{ bg: useColorModeValue('gray.100', 'gray.700'), transform: 'scale(1.05)' }}
|
||||
transition="all 0.2s"
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
<AdminSidebar
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
bg={sidebarBg}
|
||||
borderRight="1px"
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
<Box as="main" className="admin-main" p={{ base: 4, md: 8 }} pb={{ base: 8, md: 12 }}>
|
||||
{children}
|
||||
<Box
|
||||
ml={{ base: 0, md: '260px' }}
|
||||
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||
minH="100vh"
|
||||
>
|
||||
<AdminHeader
|
||||
onMenuToggle={handleOpen}
|
||||
rightContent={
|
||||
<HStack spacing={3}>
|
||||
<AdminHealthIndicator />
|
||||
<IconButton
|
||||
aria-label="Vyhledávání v administraci"
|
||||
icon={<FaSearch />}
|
||||
onClick={onSearchOpen}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
borderRadius="full"
|
||||
_hover={{ bg: useColorModeValue('gray.100', 'gray.700'), transform: 'scale(1.05)' }}
|
||||
transition="all 0.2s"
|
||||
/>
|
||||
<AdminSupportButton />
|
||||
<ThemeToggle />
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
<Box as="main" className="admin-main" p={{ base: 4, md: 8 }} pb={{ base: 8, md: 12 }}>
|
||||
{children}
|
||||
</Box>
|
||||
{/* Admin Search Modal */}
|
||||
<AdminSearchModal
|
||||
isOpen={isSearchOpen}
|
||||
onClose={onSearchClose}
|
||||
onSelectPath={(path: string) => navigate(path)}
|
||||
/>
|
||||
</Box>
|
||||
{/* Admin Search Modal */}
|
||||
<AdminSearchModal
|
||||
isOpen={isSearchOpen}
|
||||
onClose={onSearchClose}
|
||||
onSelectPath={(path: string) => navigate(path)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// Ultimate scroll retention fix - inline script
|
||||
// Add this directly to AdminLayout component as a script tag or useEffect
|
||||
|
||||
// This script bypasses React entirely and uses direct DOM manipulation
|
||||
|
||||
export {}; // Make this a module for TypeScript isolatedModules
|
||||
|
||||
(function initUltimateScrollRetention() {
|
||||
console.log('[UltimateScrollRetention] Initializing...');
|
||||
|
||||
let savedScrollPosition = 0;
|
||||
let isNavigating = false;
|
||||
let restorationTimeouts: NodeJS.Timeout[] = [];
|
||||
|
||||
// Save scroll position
|
||||
const saveScrollPosition = () => {
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (sidebar && sidebar.scrollTop > 0) {
|
||||
savedScrollPosition = sidebar.scrollTop;
|
||||
console.log('[UltimateScrollRetention] Saved position:', savedScrollPosition);
|
||||
}
|
||||
};
|
||||
|
||||
// Restore scroll position with maximum aggression
|
||||
const restoreScrollPosition = () => {
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (!sidebar || savedScrollPosition === 0) return;
|
||||
|
||||
console.log('[UltimateScrollRetention] Restoring position:', savedScrollPosition);
|
||||
|
||||
// Clear any existing restoration timeouts
|
||||
restorationTimeouts.forEach(timeout => clearTimeout(timeout));
|
||||
restorationTimeouts = [];
|
||||
|
||||
// Immediate restoration
|
||||
sidebar.scrollTop = savedScrollPosition;
|
||||
|
||||
// Aggressive restoration attempts
|
||||
const attempts = [
|
||||
{ delay: 0, method: () => sidebar.scrollTop = savedScrollPosition },
|
||||
{ delay: 25, method: () => sidebar.scrollTop = savedScrollPosition },
|
||||
{ delay: 50, method: () => sidebar.scrollTop = savedScrollPosition },
|
||||
{ delay: 100, method: () => sidebar.scrollTop = savedScrollPosition },
|
||||
{ delay: 200, method: () => sidebar.scrollTop = savedScrollPosition },
|
||||
{ delay: 300, method: () => sidebar.scrollTop = savedScrollPosition },
|
||||
{ delay: 500, method: () => sidebar.scrollTop = savedScrollPosition },
|
||||
{ delay: 750, method: () => sidebar.scrollTop = savedScrollPosition },
|
||||
{ delay: 1000, method: () => sidebar.scrollTop = savedScrollPosition },
|
||||
{ delay: 1500, method: () => sidebar.scrollTop = savedScrollPosition },
|
||||
{ delay: 2000, method: () => sidebar.scrollTop = savedScrollPosition },
|
||||
];
|
||||
|
||||
attempts.forEach(({ delay, method }) => {
|
||||
const timeout = setTimeout(() => {
|
||||
method();
|
||||
console.log(`[UltimateScrollRetention] Restore attempt at ${delay}ms:`, sidebar.scrollTop);
|
||||
|
||||
// If still not correct, force it
|
||||
if (sidebar.scrollTop !== savedScrollPosition) {
|
||||
sidebar.scrollTop = savedScrollPosition;
|
||||
}
|
||||
}, delay);
|
||||
restorationTimeouts.push(timeout);
|
||||
});
|
||||
|
||||
// Final cleanup
|
||||
setTimeout(() => {
|
||||
isNavigating = false;
|
||||
console.log('[UltimateScrollRetention] Navigation lock released');
|
||||
}, 2500);
|
||||
};
|
||||
|
||||
// Intercept ALL navigation methods
|
||||
const originalPushState = history.pushState;
|
||||
const originalReplaceState = history.replaceState;
|
||||
|
||||
history.pushState = function(...args) {
|
||||
if (!isNavigating) {
|
||||
isNavigating = true;
|
||||
saveScrollPosition();
|
||||
console.log('[UltimateScrollRetention] PushState intercepted');
|
||||
|
||||
// Start restoration immediately
|
||||
setTimeout(restoreScrollPosition, 10);
|
||||
}
|
||||
return originalPushState.apply(this, args);
|
||||
};
|
||||
|
||||
history.replaceState = function(...args) {
|
||||
if (!isNavigating) {
|
||||
isNavigating = true;
|
||||
saveScrollPosition();
|
||||
console.log('[UltimateScrollRetention] ReplaceState intercepted');
|
||||
|
||||
setTimeout(restoreScrollPosition, 10);
|
||||
}
|
||||
return originalReplaceState.apply(this, args);
|
||||
};
|
||||
|
||||
// Handle popstate (back/forward buttons)
|
||||
const handlePopState = () => {
|
||||
if (!isNavigating) {
|
||||
isNavigating = true;
|
||||
saveScrollPosition();
|
||||
console.log('[UltimateScrollRetention] PopState intercepted');
|
||||
setTimeout(restoreScrollPosition, 10);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
|
||||
// Continuous scroll saving
|
||||
const handleScroll = () => {
|
||||
if (!isNavigating) {
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (sidebar && sidebar.scrollTop > 0) {
|
||||
savedScrollPosition = sidebar.scrollTop;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add scroll listener to sidebar
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (sidebar) {
|
||||
sidebar.addEventListener('scroll', handleScroll, { passive: true });
|
||||
console.log('[UltimateScrollRetention] Scroll listener attached');
|
||||
}
|
||||
|
||||
// MutationObserver to catch DOM changes that might reset scroll
|
||||
const observer = new MutationObserver(() => {
|
||||
if (isNavigating && savedScrollPosition > 0) {
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (sidebar && sidebar.scrollTop !== savedScrollPosition) {
|
||||
sidebar.scrollTop = savedScrollPosition;
|
||||
console.log('[UltimateScrollRetention] MutationObserver restored scroll');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Observe the sidebar and its parents
|
||||
if (sidebar) {
|
||||
observer.observe(sidebar, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class']
|
||||
});
|
||||
console.log('[UltimateScrollRetention] MutationObserver attached');
|
||||
}
|
||||
|
||||
console.log('[UltimateScrollRetention] Setup complete');
|
||||
|
||||
// Cleanup function (if needed)
|
||||
return () => {
|
||||
history.pushState = originalPushState;
|
||||
history.replaceState = originalReplaceState;
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
if (sidebar) {
|
||||
sidebar.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
observer.disconnect();
|
||||
restorationTimeouts.forEach(timeout => clearTimeout(timeout));
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,141 @@
|
||||
// Add this directly to AdminLayout.tsx as a useEffect
|
||||
// This bypasses all React hooks and uses pure JavaScript
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export {}; // Make this a module for TypeScript isolatedModules
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[InlineScrollRetention] Setting up...');
|
||||
|
||||
let savedScrollPosition = 0;
|
||||
let isNavigating = false;
|
||||
let restorationTimeouts: number[] = [];
|
||||
|
||||
const saveScrollPosition = () => {
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (sidebar && sidebar.scrollTop > 0) {
|
||||
savedScrollPosition = sidebar.scrollTop;
|
||||
console.log('[InlineScrollRetention] Saved position:', savedScrollPosition);
|
||||
}
|
||||
};
|
||||
|
||||
const restoreScrollPosition = () => {
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (!sidebar || savedScrollPosition === 0) return;
|
||||
|
||||
console.log('[InlineScrollRetention] Restoring position:', savedScrollPosition);
|
||||
|
||||
// Clear existing timeouts
|
||||
restorationTimeouts.forEach(timeout => clearTimeout(timeout));
|
||||
restorationTimeouts = [];
|
||||
|
||||
// Aggressive restoration
|
||||
const attempts = [0, 25, 50, 100, 200, 300, 500, 750, 1000, 1500, 2000];
|
||||
|
||||
attempts.forEach(delay => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
sidebar.scrollTop = savedScrollPosition;
|
||||
console.log(`[InlineScrollRetention] Restore at ${delay}ms:`, sidebar.scrollTop);
|
||||
|
||||
// Force if needed
|
||||
if (sidebar.scrollTop !== savedScrollPosition) {
|
||||
sidebar.scrollTop = savedScrollPosition;
|
||||
}
|
||||
}, delay);
|
||||
restorationTimeouts.push(timeout);
|
||||
});
|
||||
|
||||
// Release navigation lock
|
||||
window.setTimeout(() => {
|
||||
isNavigating = false;
|
||||
console.log('[InlineScrollRetention] Navigation lock released');
|
||||
}, 2500);
|
||||
};
|
||||
|
||||
// Intercept navigation
|
||||
const originalPushState = history.pushState;
|
||||
const originalReplaceState = history.replaceState;
|
||||
|
||||
history.pushState = function(...args) {
|
||||
if (!isNavigating) {
|
||||
isNavigating = true;
|
||||
saveScrollPosition();
|
||||
console.log('[InlineScrollRetention] PushState intercepted');
|
||||
setTimeout(restoreScrollPosition, 10);
|
||||
}
|
||||
return originalPushState.apply(this, args);
|
||||
};
|
||||
|
||||
history.replaceState = function(...args) {
|
||||
if (!isNavigating) {
|
||||
isNavigating = true;
|
||||
saveScrollPosition();
|
||||
console.log('[InlineScrollRetention] ReplaceState intercepted');
|
||||
setTimeout(restoreScrollPosition, 10);
|
||||
}
|
||||
return originalReplaceState.apply(this, args);
|
||||
};
|
||||
|
||||
// Handle back/forward
|
||||
const handlePopState = () => {
|
||||
if (!isNavigating) {
|
||||
isNavigating = true;
|
||||
saveScrollPosition();
|
||||
console.log('[InlineScrollRetention] PopState intercepted');
|
||||
setTimeout(restoreScrollPosition, 10);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
|
||||
// Scroll listener
|
||||
const handleScroll = () => {
|
||||
if (!isNavigating) {
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (sidebar && sidebar.scrollTop > 0) {
|
||||
savedScrollPosition = sidebar.scrollTop;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (sidebar) {
|
||||
sidebar.addEventListener('scroll', handleScroll, { passive: true });
|
||||
console.log('[InlineScrollRetention] Scroll listener attached');
|
||||
}
|
||||
|
||||
// MutationObserver
|
||||
const observer = new MutationObserver(() => {
|
||||
if (isNavigating && savedScrollPosition > 0) {
|
||||
const sidebar = document.querySelector('[data-sidebar="true"]') as HTMLElement;
|
||||
if (sidebar && sidebar.scrollTop !== savedScrollPosition) {
|
||||
sidebar.scrollTop = savedScrollPosition;
|
||||
console.log('[InlineScrollRetention] MutationObserver restored scroll');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (sidebar) {
|
||||
observer.observe(sidebar, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class']
|
||||
});
|
||||
console.log('[InlineScrollRetention] MutationObserver attached');
|
||||
}
|
||||
|
||||
console.log('[InlineScrollRetention] Setup complete');
|
||||
|
||||
return () => {
|
||||
history.pushState = originalPushState;
|
||||
history.replaceState = originalReplaceState;
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
if (sidebar) {
|
||||
sidebar.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
observer.disconnect();
|
||||
restorationTimeouts.forEach(timeout => clearTimeout(timeout));
|
||||
};
|
||||
}, []);
|
||||
@@ -0,0 +1,185 @@
|
||||
{
|
||||
"common.welcome_message": "Vítejte v našem fotbalovém klubu",
|
||||
"common.welcome_subtitle": "Oficiální stránky fotbalového klubu",
|
||||
"common.yes": "Ano",
|
||||
"common.no": "Ne",
|
||||
"common.ok": "OK",
|
||||
"common.close": "Zavřít",
|
||||
"common.back": "Zpět",
|
||||
"common.next": "Další",
|
||||
"common.previous": "Předchozí",
|
||||
"common.loading": "Načítání...",
|
||||
"common.search": "Hledat",
|
||||
"common.filter": "Filtr",
|
||||
"common.sort": "Seřadit",
|
||||
"common.export": "Exportovat",
|
||||
"common.import": "Importovat",
|
||||
"common.print": "Tisknout",
|
||||
"common.share": "Sdílet",
|
||||
"common.copy": "Kopírovat",
|
||||
"common.paste": "Vložit",
|
||||
"common.cut": "Vyjmout",
|
||||
"common.select_all": "Vybrat vše",
|
||||
"common.clear": "Vymazat",
|
||||
"common.refresh": "Obnovit",
|
||||
"common.settings": "Nastavení",
|
||||
"common.help": "Nápověda",
|
||||
"common.about": "O aplikaci",
|
||||
"common.version": "Verze",
|
||||
"common.copyright": "Autorská práva",
|
||||
"common.all_rights_reserved": "Všechna práva vyhrazena",
|
||||
"nav.home": "Domů",
|
||||
"nav.news": "Aktuality",
|
||||
"nav.matches": "Zápasy",
|
||||
"nav.players": "Hráči",
|
||||
"nav.gallery": "Galerie",
|
||||
"nav.videos": "Videa",
|
||||
"nav.contact": "Kontakt",
|
||||
"nav.about": "O klubu",
|
||||
"nav.activities": "Aktivity",
|
||||
"nav.sponsors": "Sponzoři",
|
||||
"nav.table": "Tabulka",
|
||||
"action.save": "Uložit",
|
||||
"action.cancel": "Zrušit",
|
||||
"action.edit": "Upravit",
|
||||
"action.delete": "Smazat",
|
||||
"action.create": "Vytvořit",
|
||||
"action.update": "Aktualizovat",
|
||||
"action.publish": "Publikovat",
|
||||
"action.unpublish": "Skrýt",
|
||||
"form.required": "Toto pole je povinné",
|
||||
"form.email": "E-mail",
|
||||
"form.password": "Heslo",
|
||||
"form.name": "Jméno",
|
||||
"form.message": "Zpráva",
|
||||
"form.submit": "Odeslat",
|
||||
"message.success": "Operace proběhla úspěšně",
|
||||
"message.error": "Došlo k chybě",
|
||||
"message.loading": "Načítám...",
|
||||
"message.no_data": "Žádná data",
|
||||
"admin.dashboard": "Nástěnka",
|
||||
"admin.articles": "Články",
|
||||
"admin.matches": "Zápasy",
|
||||
"admin.players": "Hráči",
|
||||
"admin.settings": "Nastavení",
|
||||
"admin.users": "Uživatelé",
|
||||
"content.read_more": "Číst více",
|
||||
"content.published_at": "Publikováno",
|
||||
"content.updated_at": "Aktualizováno",
|
||||
"content.author": "Autor",
|
||||
"match.date": "Datum",
|
||||
"match.time": "Čas",
|
||||
"match.place": "Místo",
|
||||
"match.result": "Výsledek",
|
||||
"match.score": "Skóre",
|
||||
"match.team_home": "Domácí",
|
||||
"match.team_away": "Hosté",
|
||||
"team.name": "Název týmu",
|
||||
"team.players": "Hráči",
|
||||
"team.coach": "Trenér",
|
||||
"team.category": "Kategorie",
|
||||
"gallery.albums": "Alba",
|
||||
"gallery.photos": "Fotky",
|
||||
"gallery.view_all": "Zobrazit vše",
|
||||
"search.placeholder": "Hledat...",
|
||||
"search.results": "Výsledky hledání",
|
||||
"search.no_results": "Nebyly nalezeny žádné výsledky",
|
||||
"pagination.previous": "Předchozí",
|
||||
"pagination.next": "Další",
|
||||
"pagination.page": "Strana",
|
||||
"pagination.of": "z",
|
||||
"date.today": "Dnes",
|
||||
"date.yesterday": "Včera",
|
||||
"date.tomorrow": "Zítra",
|
||||
"date.format": "DD.MM.YYYY",
|
||||
"date.time_format": "HH:mm",
|
||||
"auth.login": "Přihlásit",
|
||||
"auth.logout": "Odhlásit",
|
||||
"auth.register": "Registrovat",
|
||||
"auth.forgot_password": "Zapomněl jsem heslo",
|
||||
"auth.reset_password": "Obnovit heslo",
|
||||
"auth.profile": "Profil",
|
||||
"auth.change_password": "Změnit heslo",
|
||||
"error.404": "Stránka nenalezena",
|
||||
"error.500": "Interní chyba serveru",
|
||||
"error.403": "Přístup zakázán",
|
||||
"error.401": "Neautorizovaný přístup",
|
||||
"error.network": "Chyba sítě",
|
||||
"error.validation": "Chyba validace",
|
||||
"calendar.title": "Kalendář",
|
||||
"calendar.subtitle": "Přehled všech zápasů v sezóně",
|
||||
"calendar.loading": "Načítám kalendář...",
|
||||
"calendar.no_matches": "Žádné zápasy nenalezeny",
|
||||
"calendar.all_competitions": "Všechny soutěže",
|
||||
"calendar.list_view": "Seznam",
|
||||
"calendar.today": "Dnes",
|
||||
"calendar.previous_month": "Předchozí měsíc",
|
||||
"calendar.next_month": "Další měsíc",
|
||||
"calendar.show_more": "+{{count}} další…",
|
||||
"calendar.show_less": "Zobrazit méně",
|
||||
"calendar.show_past_matches": "Zobrazit minulé zápasy ({{count}})",
|
||||
"calendar.hide_past_matches": "Skrýt minulé zápasy",
|
||||
"calendar.win": "Výhra",
|
||||
"calendar.loss": "Prohra",
|
||||
"calendar.draw": "Remíza",
|
||||
"homepage.more_videos": "Více videí",
|
||||
"action.play": "Přehrát",
|
||||
"action.open_on_youtube": "Otevřít na YouTube",
|
||||
"table.headers.position": "#",
|
||||
"table.headers.team": "Tým",
|
||||
"table.headers.played": "Z",
|
||||
"table.headers.wins": "V",
|
||||
"table.headers.draws": "R",
|
||||
"table.headers.losses": "P",
|
||||
"table.headers.score": "Skóre",
|
||||
"table.headers.points": "Body",
|
||||
"matches.upcoming": "Nadcházející zápasy",
|
||||
"matches.loading": "Načítám zápasy...",
|
||||
"matches.error": "Nepodařilo se načíst zápasy. Zkuste to prosím později.",
|
||||
"matches.none_found": "Žádné nadcházející zápasy nebyly nalezeny.",
|
||||
"matches.subscribe_prompt": "Chcete dostávat novinky o zápasech e‑mailem?",
|
||||
"matches.subscribe": "Odebírat",
|
||||
"form.email_required": "Zadejte email",
|
||||
"form.email_placeholder": "váš@email.cz",
|
||||
"matches.subscribe_success": "Přihlášeno k odběru",
|
||||
"matches.subscribe_error": "Chyba přihlášení",
|
||||
"homepage.all_matches": "Všechny zápasy",
|
||||
"matches.load_more_matches": "Načíst více",
|
||||
"matches.showing_matches": "Zobrazuji {{shown}} z {{total}} zápasů",
|
||||
"matches.all_categories": "Všechny soutěže",
|
||||
"matches.no_matches_to_display": "Žádné zápasy k zobrazení",
|
||||
"matches.check_club_settings": "Zkontrolujte nastavení klubu",
|
||||
"matches.oldest_first": "Nejstarší první",
|
||||
"matches.newest_first": "Nejnovější první",
|
||||
"matches.all_matches": "Všechny zápasy",
|
||||
"matches.win": "Výhra",
|
||||
"matches.draw": "Remíza",
|
||||
"matches.loss": "Prohra",
|
||||
"matches.played": "Odehráno",
|
||||
"club_modal.statistics": "Statistiky",
|
||||
"club_modal.matches_played": "Odehráno zápasů",
|
||||
"club_modal.wins": "Výhry",
|
||||
"club_modal.draws": "Remízy",
|
||||
"club_modal.losses": "Prohry",
|
||||
"club_modal.score": "Skóre",
|
||||
"club_modal.goals_scored": "Vstřelené góly",
|
||||
"club_modal.goals_conceded": "Obdržené góly",
|
||||
"club_modal.goal_difference": "Rozdíl skóre",
|
||||
"club_modal.points": "Body",
|
||||
"club_modal.form_last_5": "Forma (posledních 5 zápasů)",
|
||||
"club_modal.close": "Zavřít",
|
||||
"table.position_place": "{{position}}. místo",
|
||||
"tables.title": "Tabulky",
|
||||
"tables.subtitle": "Aktuální tabulky a soutěže",
|
||||
"tables.loading": "Načítám tabulky...",
|
||||
"tables.no_tables": "Žádné tabulky k dispozici",
|
||||
"tables.schedule_link": "Program",
|
||||
"tables.rank": "#",
|
||||
"tables.team": "Tým",
|
||||
"tables.played": "Z",
|
||||
"tables.wins": "V",
|
||||
"tables.draws": "R",
|
||||
"tables.losses": "P",
|
||||
"tables.score": "Skóre",
|
||||
"tables.points": "Body"
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"common.welcome_message": "Welcome to our football club",
|
||||
"common.welcome_subtitle": "Official website of the football club",
|
||||
"common.yes": "Yes",
|
||||
"common.no": "No",
|
||||
"common.ok": "OK",
|
||||
"common.close": "Close",
|
||||
"common.back": "Back",
|
||||
"common.next": "Next",
|
||||
"common.previous": "Previous",
|
||||
"common.loading": "Loading...",
|
||||
"common.search": "Search",
|
||||
"common.filter": "Filter",
|
||||
"common.sort": "Sort",
|
||||
"common.export": "Export",
|
||||
"common.import": "Import",
|
||||
"common.print": "Print",
|
||||
"common.share": "Share",
|
||||
"common.copy": "Copy",
|
||||
"common.paste": "Paste",
|
||||
"common.cut": "Cut",
|
||||
"common.select_all": "Select All",
|
||||
"common.clear": "Clear",
|
||||
"common.refresh": "Refresh",
|
||||
"common.settings": "Settings",
|
||||
"common.help": "Help",
|
||||
"common.about": "About",
|
||||
"common.version": "Version",
|
||||
"common.copyright": "Copyright",
|
||||
"common.all_rights_reserved": "All rights reserved",
|
||||
"nav.home": "Home",
|
||||
"nav.news": "News",
|
||||
"nav.matches": "Matches",
|
||||
"nav.players": "Players",
|
||||
"nav.gallery": "Photos",
|
||||
"nav.videos": "Videos",
|
||||
"nav.contact": "Contact",
|
||||
"nav.about": "About Club",
|
||||
"nav.activities": "Activities",
|
||||
"nav.sponsors": "Sponsors",
|
||||
"nav.calendar": "Calendar",
|
||||
"nav.table": "Table",
|
||||
"action.save": "Save",
|
||||
"action.cancel": "Cancel",
|
||||
"action.edit": "Edit",
|
||||
"action.delete": "Delete",
|
||||
"action.create": "Create",
|
||||
"action.update": "Update",
|
||||
"action.publish": "Publish",
|
||||
"action.unpublish": "Hide",
|
||||
"action.play": "Play",
|
||||
"action.open_on_youtube": "Open on YouTube",
|
||||
"form.required": "This field is required",
|
||||
"form.email": "E-mail",
|
||||
"form.password": "Password",
|
||||
"form.name": "Name",
|
||||
"form.message": "Message",
|
||||
"form.submit": "Submit",
|
||||
"message.success": "Operation completed successfully",
|
||||
"message.error": "An error occurred",
|
||||
"message.loading": "Loading...",
|
||||
"message.no_data": "No data",
|
||||
"admin.dashboard": "Dashboard",
|
||||
"admin.articles": "Articles",
|
||||
"admin.matches": "Matches",
|
||||
"admin.players": "Players",
|
||||
"admin.settings": "Settings",
|
||||
"admin.users": "Users",
|
||||
"content.read_more": "Read more",
|
||||
"content.published_at": "Published",
|
||||
"content.updated_at": "Updated",
|
||||
"content.author": "Author",
|
||||
"match.date": "Date",
|
||||
"match.time": "Time",
|
||||
"match.place": "Place",
|
||||
"match.result": "Result",
|
||||
"match.score": "Score",
|
||||
"match.team_home": "Home",
|
||||
"match.team_away": "Away",
|
||||
"team.name": "Team Name",
|
||||
"team.players": "Players",
|
||||
"team.coach": "Coach",
|
||||
"team.category": "Category",
|
||||
"gallery.albums": "Albums",
|
||||
"gallery.photos": "Photos",
|
||||
"gallery.view_all": "View All",
|
||||
"search.placeholder": "Search...",
|
||||
"search.results": "Search Results",
|
||||
"search.no_results": "No results found",
|
||||
"pagination.previous": "Previous",
|
||||
"pagination.next": "Next",
|
||||
"pagination.page": "Page",
|
||||
"pagination.of": "of",
|
||||
"date.today": "Today",
|
||||
"date.yesterday": "Yesterday",
|
||||
"date.tomorrow": "Tomorrow",
|
||||
"date.format": "DD.MM.YYYY",
|
||||
"date.time_format": "HH:mm",
|
||||
"auth.login": "Login",
|
||||
"auth.logout": "Logout",
|
||||
"auth.register": "Register",
|
||||
"auth.forgot_password": "Forgot Password",
|
||||
"auth.reset_password": "Reset Password",
|
||||
"auth.profile": "Profile",
|
||||
"auth.change_password": "Change Password",
|
||||
"error.404": "Page Not Found",
|
||||
"error.500": "Internal Server Error",
|
||||
"error.403": "Access Denied",
|
||||
"error.401": "Unauthorized Access",
|
||||
"error.network": "Network Error",
|
||||
"error.validation": "Validation Error",
|
||||
"calendar.title": "Calendar",
|
||||
"calendar.subtitle": "Overview of all matches in the season",
|
||||
"calendar.loading": "Loading calendar...",
|
||||
"calendar.no_matches": "No matches found",
|
||||
"calendar.all_competitions": "All Competitions",
|
||||
"calendar.list_view": "List",
|
||||
"calendar.today": "Today",
|
||||
"calendar.previous_month": "Previous Month",
|
||||
"calendar.next_month": "Next Month",
|
||||
"calendar.show_more": "+{{count}} more…",
|
||||
"calendar.show_less": "Show Less",
|
||||
"calendar.show_past_matches": "Show Past Matches ({{count}})",
|
||||
"calendar.hide_past_matches": "Hide Past Matches",
|
||||
"calendar.win": "Win",
|
||||
"calendar.loss": "Loss",
|
||||
"calendar.draw": "Draw",
|
||||
"homepage.more_videos": "More Videos",
|
||||
"table.headers.position": "#",
|
||||
"table.headers.team": "Team",
|
||||
"table.headers.played": "P",
|
||||
"table.headers.wins": "W",
|
||||
"table.headers.draws": "D",
|
||||
"table.headers.losses": "L",
|
||||
"table.headers.score": "Score",
|
||||
"table.headers.points": "Points",
|
||||
"matches.upcoming": "Upcoming Matches",
|
||||
"matches.loading": "Loading matches...",
|
||||
"matches.error": "Failed to load matches. Please try again later.",
|
||||
"matches.none_found": "No upcoming matches found.",
|
||||
"matches.subscribe_prompt": "Want to receive match news by email?",
|
||||
"matches.subscribe": "Subscribe",
|
||||
"form.email_required": "Please enter email",
|
||||
"form.email_placeholder": "your@email.com",
|
||||
"matches.subscribe_success": "Subscribed successfully",
|
||||
"matches.subscribe_error": "Subscription error",
|
||||
"homepage.all_matches": "All Matches",
|
||||
"matches.load_more_matches": "Load More",
|
||||
"matches.showing_matches": "Showing {{shown}} of {{total}} matches",
|
||||
"matches.all_categories": "All Categories",
|
||||
"matches.no_matches_to_display": "No matches to display",
|
||||
"matches.check_club_settings": "Check club settings",
|
||||
"matches.oldest_first": "Oldest First",
|
||||
"matches.newest_first": "Newest First",
|
||||
"matches.all_matches": "All Matches",
|
||||
"matches.win": "Win",
|
||||
"matches.draw": "Draw",
|
||||
"matches.loss": "Loss",
|
||||
"matches.played": "Played",
|
||||
"club_modal.statistics": "Statistics",
|
||||
"club_modal.matches_played": "Matches Played",
|
||||
"club_modal.wins": "Wins",
|
||||
"club_modal.draws": "Draws",
|
||||
"club_modal.losses": "Losses",
|
||||
"club_modal.score": "Score",
|
||||
"club_modal.goals_scored": "Goals Scored",
|
||||
"club_modal.goals_conceded": "Goals Conceded",
|
||||
"club_modal.goal_difference": "Goal Difference",
|
||||
"club_modal.points": "Points",
|
||||
"club_modal.form_last_5": "Form (Last 5 Matches)",
|
||||
"club_modal.close": "Close",
|
||||
"table.position_place": "{{position}}. place",
|
||||
"tables.title": "Tables",
|
||||
"tables.subtitle": "Current standings and league tables",
|
||||
"tables.loading": "Loading tables...",
|
||||
"tables.no_tables": "No tables available",
|
||||
"tables.schedule_link": "Schedule",
|
||||
"tables.rank": "#",
|
||||
"tables.team": "Team",
|
||||
"tables.played": "P",
|
||||
"tables.wins": "W",
|
||||
"tables.draws": "D",
|
||||
"tables.losses": "L",
|
||||
"tables.score": "Score",
|
||||
"tables.points": "Points"
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { Helmet } from 'react-helmet-async';
|
||||
import api from '../services/api';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { usePublicSettings } from '../hooks/usePublicSettings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MainLayout from '../components/layout/MainLayout';
|
||||
import CompetitionMatches from '../components/home/CompetitionMatches';
|
||||
import { getCategories, CategoryItem } from '../services/categories';
|
||||
@@ -35,6 +36,7 @@ type AboutPageData = {
|
||||
};
|
||||
|
||||
const AboutPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<AboutPageData | null>(null);
|
||||
const [error, setError] = useState<string>('');
|
||||
@@ -106,7 +108,7 @@ const AboutPage: React.FC = () => {
|
||||
if (categories.length === 0) return null;
|
||||
return (
|
||||
<Box mt={marginTop}>
|
||||
<Heading as="h2" size="md" mb={4}>Rubriky</Heading>
|
||||
<Heading as="h2" size="md" mb={4}>{t('blog.categories')}</Heading>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{categories.map((c) => (
|
||||
<Stack
|
||||
@@ -141,7 +143,7 @@ const AboutPage: React.FC = () => {
|
||||
fontWeight="bold"
|
||||
w={{ base: 'full', sm: 'auto' }}
|
||||
>
|
||||
Otevřít
|
||||
{t('action.open')}
|
||||
</Button>
|
||||
</Stack>
|
||||
))}
|
||||
@@ -154,19 +156,19 @@ const AboutPage: React.FC = () => {
|
||||
return (
|
||||
<MainLayout>
|
||||
<Helmet>
|
||||
<title>{settings?.club_name ? `O klubu | ${settings.club_name}` : 'O klubu'}</title>
|
||||
<meta name="description" content="Informace o našem klubu, soutěžích, nadcházejících zápasech a rubrikách." />
|
||||
<title>{settings?.club_name ? `${t('nav.club')} | ${settings.club_name}` : t('nav.club')}</title>
|
||||
<meta name="description" content={t('about.meta_description')} />
|
||||
{settings?.club_logo_url && <meta property="og:image" content={assetUrl(settings.club_logo_url) || settings.club_logo_url} />}
|
||||
</Helmet>
|
||||
<Container maxW="container.lg" py={8}>
|
||||
<Box textAlign="center" py={6}>
|
||||
<Heading size="xl" mb={2}>O klubu</Heading>
|
||||
<Text color={textSecondary}>Tato stránka ještě není nastavena. Zde je přehled klubu, soutěží a rubrik.</Text>
|
||||
<Heading size="xl" mb={2}>{t('nav.club')}</Heading>
|
||||
<Text color={textSecondary}>{t('about.page_not_setup')}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Matches slider by competition (FACR) */}
|
||||
<Box mt={4} mb={10}>
|
||||
<Heading as="h2" size="md" mb={4}>Zápasy podle soutěží</Heading>
|
||||
<Heading as="h2" size="md" mb={4}>{t('about.matches_by_competition')}</Heading>
|
||||
<CompetitionMatches />
|
||||
</Box>
|
||||
{renderCategoriesList()}
|
||||
@@ -179,7 +181,11 @@ const AboutPage: React.FC = () => {
|
||||
const seoDesc = data.seo_description || data.subtitle;
|
||||
const clubName = settings?.club_name || data.title;
|
||||
const clubLogo = settings?.club_logo_url ? (assetUrl(settings.club_logo_url) || settings.club_logo_url) : undefined;
|
||||
const cleanContent = DOMPurify.sanitize(data.content);
|
||||
const cleanContent = DOMPurify.sanitize(data.content, {
|
||||
USE_PROFILES: { html: true },
|
||||
ADD_TAGS: ['iframe'],
|
||||
ADD_ATTR: ['class', 'style', 'data-bullets', 'data-list', 'target', 'rel', 'allow', 'allowfullscreen'],
|
||||
});
|
||||
|
||||
const renderContent = () => {
|
||||
switch (data.style) {
|
||||
@@ -223,7 +229,13 @@ const AboutPage: React.FC = () => {
|
||||
'& h2': { fontSize: 'xl' },
|
||||
'& h3': { fontSize: 'lg' },
|
||||
'& img': { maxW: '100%', borderRadius: 'md', my: 6 },
|
||||
'& ul, & ol': { pl: 8, mb: 4 },
|
||||
'& ul, & ol': { pl: 8, mb: 4, listStylePosition: 'outside' },
|
||||
'& ul': { listStyleType: 'disc' },
|
||||
'& ul[data-bullets="disc"]': { listStyleType: 'disc' },
|
||||
'& ul[data-bullets="circle"]': { listStyleType: 'circle' },
|
||||
'& ul[data-bullets="square"]': { listStyleType: 'square' },
|
||||
'& ul[data-bullets="none"]': { listStyleType: 'none' },
|
||||
'& ol': { listStyleType: 'decimal' },
|
||||
'& li': { mb: 2 },
|
||||
}}
|
||||
/>
|
||||
@@ -283,7 +295,13 @@ const AboutPage: React.FC = () => {
|
||||
boxShadow: 'md',
|
||||
},
|
||||
},
|
||||
'& ul, & ol': { pl: 12, mb: 4 },
|
||||
'& ul, & ol': { pl: 12, mb: 4, listStylePosition: 'outside' },
|
||||
'& ul': { listStyleType: 'disc' },
|
||||
'& ul[data-bullets="disc"]': { listStyleType: 'disc' },
|
||||
'& ul[data-bullets="circle"]': { listStyleType: 'circle' },
|
||||
'& ul[data-bullets="square"]': { listStyleType: 'square' },
|
||||
'& ul[data-bullets="none"]': { listStyleType: 'none' },
|
||||
'& ol': { listStyleType: 'decimal' },
|
||||
'& li': { mb: 2 },
|
||||
'& img': { maxW: '100%', borderRadius: 'md', my: 6, ml: 12 },
|
||||
}}
|
||||
@@ -336,7 +354,13 @@ const AboutPage: React.FC = () => {
|
||||
'& h2': { fontSize: 'xl' },
|
||||
'& h3': { fontSize: 'lg' },
|
||||
'& img': { maxW: '100%', borderRadius: 'md', my: 4 },
|
||||
'& ul, & ol': { pl: 8, mb: 4 },
|
||||
'& ul, & ol': { pl: 8, mb: 4, listStylePosition: 'outside' },
|
||||
'& ul': { listStyleType: 'disc' },
|
||||
'& ul[data-bullets="disc"]': { listStyleType: 'disc' },
|
||||
'& ul[data-bullets="circle"]': { listStyleType: 'circle' },
|
||||
'& ul[data-bullets="square"]': { listStyleType: 'square' },
|
||||
'& ul[data-bullets="none"]': { listStyleType: 'none' },
|
||||
'& ol': { listStyleType: 'decimal' },
|
||||
'& li': { mb: 2 },
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -27,9 +27,10 @@ import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
||||
import { addMonths, format, isSameDay, isSameMonth, startOfMonth, startOfWeek, addDays, parse } from 'date-fns';
|
||||
import { cs } from 'date-fns/locale';
|
||||
import { getEvents } from '../services/eventService';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Weekday headers (Czech, starting Monday)
|
||||
const WEEKDAYS_SHORT: string[] = ['Po', 'Út', 'St', 'Čt', 'Pá', 'So', 'Ne'];
|
||||
// Weekday headers (starting Monday)
|
||||
const WEEKDAYS_SHORT: string[] = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
||||
|
||||
type UiEvent = {
|
||||
id: number | string;
|
||||
@@ -41,7 +42,15 @@ type UiEvent = {
|
||||
image_url?: string | null;
|
||||
};
|
||||
|
||||
const typeLabel = (t?: string) => (t === 'match' ? 'Zápas' : t === 'training' ? 'Trénink' : t === 'meeting' ? 'Schůzka' : 'Jiné');
|
||||
const typeLabel = (t?: string, translate?: (key: string) => string) => {
|
||||
if (!translate) return t === 'match' ? 'Zápas' : t === 'training' ? 'Trénink' : t === 'meeting' ? 'Schůzka' : 'Jiné';
|
||||
switch (t) {
|
||||
case 'match': return translate('calendar.match_type');
|
||||
case 'training': return translate('calendar.training_type');
|
||||
case 'meeting': return translate('calendar.meeting_type');
|
||||
default: return translate('calendar.other_type');
|
||||
}
|
||||
};
|
||||
const typeColor = (t?: string) => (t === 'match' ? 'red' : t === 'training' ? 'blue' : t === 'meeting' ? 'green' : 'gray');
|
||||
|
||||
const toDateKey = (iso: string) => {
|
||||
@@ -49,6 +58,7 @@ const toDateKey = (iso: string) => {
|
||||
};
|
||||
|
||||
const ActivitiesCalendarPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [events, setEvents] = useState<UiEvent[]>([]);
|
||||
@@ -126,7 +136,7 @@ const ActivitiesCalendarPage: React.FC = () => {
|
||||
}));
|
||||
if (active) setEvents(mapped);
|
||||
} catch (e: any) {
|
||||
if (active) setError(e?.message || 'Nepodařilo se načíst aktivity.');
|
||||
if (active) setError(e?.message || t('message.error_occurred'));
|
||||
} finally {
|
||||
if (active) setLoading(false);
|
||||
}
|
||||
@@ -139,17 +149,17 @@ const ActivitiesCalendarPage: React.FC = () => {
|
||||
return (
|
||||
<MainLayout>
|
||||
<Container maxW="7xl" py={{ base: 6, md: 10 }}>
|
||||
<Heading as="h1" size="xl" mb={2}>Aktivity</Heading>
|
||||
<Text color={mutedText} mb={6}>Kalendář tréninků, schůzek a dalších klubových aktivit.</Text>
|
||||
<Heading as="h1" size="xl" mb={2}>{t('nav.activities')}</Heading>
|
||||
<Text color={mutedText} mb={6}>{t('calendar.activities_subtitle')}</Text>
|
||||
|
||||
<Flex align="center" justify="space-between" mb={3} gap={2} flexWrap={{ base: 'wrap', md: 'nowrap' }}>
|
||||
<Flex align="center" gap={2}>
|
||||
<Select size="sm" value={filter} onChange={(e) => setFilter(e.target.value as any)} width={{ base: '100%', sm: '220px' }}>
|
||||
<option value="all">Všechny typy</option>
|
||||
<option value="match">Zápasy</option>
|
||||
<option value="training">Tréninky</option>
|
||||
<option value="meeting">Schůzky</option>
|
||||
<option value="other">Ostatní</option>
|
||||
<option value="all">{t('calendar.all_types')}</option>
|
||||
<option value="match">{t('calendar.matches_filter')}</option>
|
||||
<option value="training">{t('calendar.training_filter')}</option>
|
||||
<option value="meeting">{t('calendar.meeting_filter')}</option>
|
||||
<option value="other">{t('calendar.other_filter')}</option>
|
||||
</Select>
|
||||
</Flex>
|
||||
<ButtonGroup size="sm" isAttached>
|
||||
@@ -159,21 +169,21 @@ const ActivitiesCalendarPage: React.FC = () => {
|
||||
color={viewMode==='calendar' ? 'text.onPrimary' : undefined}
|
||||
_hover={{ filter: viewMode==='calendar' ? 'brightness(0.95)' : undefined, borderColor: 'brand.primary', color: viewMode==='calendar' ? 'text.onPrimary' : undefined }}
|
||||
onClick={() => setViewMode('calendar')}
|
||||
>Kalendář</Button>
|
||||
>{t('calendar.calendar_view')}</Button>
|
||||
<Button
|
||||
variant={viewMode==='list' ? 'solid' : 'outline'}
|
||||
bg={viewMode==='list' ? 'brand.primary' : undefined}
|
||||
color={viewMode==='list' ? 'text.onPrimary' : undefined}
|
||||
_hover={{ filter: viewMode==='list' ? 'brightness(0.95)' : undefined, borderColor: 'brand.primary', color: viewMode==='list' ? 'text.onPrimary' : undefined }}
|
||||
onClick={() => setViewMode('list')}
|
||||
>Seznam</Button>
|
||||
>{t('calendar.list_view')}</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
|
||||
{loading && (
|
||||
<Flex align="center" gap={3} color={mutedText} mb={6}>
|
||||
<Spinner size="sm" />
|
||||
<span>Načítám aktivity…</span>
|
||||
<span>{t('calendar.loading_activities')}</span>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
@@ -182,7 +192,7 @@ const ActivitiesCalendarPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{!loading && !hasData && !error && (
|
||||
<Box color={mutedText}>Zatím nemáme žádné aktivity k zobrazení.</Box>
|
||||
<Box color={mutedText}>{t('calendar.no_activities')}</Box>
|
||||
)}
|
||||
|
||||
{!loading && hasData && (
|
||||
@@ -190,7 +200,7 @@ const ActivitiesCalendarPage: React.FC = () => {
|
||||
<>
|
||||
<Flex align="center" justify="space-between" mb={3} gap={2} flexWrap={{ base: 'wrap', md: 'nowrap' }}>
|
||||
<IconButton
|
||||
aria-label="Předchozí měsíc"
|
||||
aria-label={t('calendar.previous_month')}
|
||||
size="sm"
|
||||
onClick={() => setMonthRef(addMonths(monthRef, -1))}
|
||||
icon={<ChevronLeftIcon />}
|
||||
@@ -204,9 +214,9 @@ const ActivitiesCalendarPage: React.FC = () => {
|
||||
bg={'brand.primary'}
|
||||
color={'text.onPrimary'}
|
||||
_hover={{ filter: 'brightness(0.95)' }}
|
||||
>Dnes</Button>
|
||||
>{t('calendar.today')}</Button>
|
||||
<IconButton
|
||||
aria-label="Další měsíc"
|
||||
aria-label={t('calendar.next_month')}
|
||||
size="sm"
|
||||
onClick={() => setMonthRef(addMonths(monthRef, 1))}
|
||||
icon={<ChevronRightIcon />}
|
||||
@@ -273,7 +283,7 @@ const ActivitiesCalendarPage: React.FC = () => {
|
||||
_hover={{ bg: 'rgba(0,0,0,0.03)', borderColor: 'brand.primary' }}
|
||||
>
|
||||
<Flex align="center" gap={2}>
|
||||
<Badge colorScheme={typeColor(e.type)}>{typeLabel(e.type)}</Badge>
|
||||
<Badge colorScheme={typeColor(e.type)}>{typeLabel(e.type, t)}</Badge>
|
||||
<Text fontSize="xs">
|
||||
{format(new Date(e.start_time), 'HH:mm')}
|
||||
{e.end_time && !isSameDay(new Date(e.start_time), new Date(e.end_time))
|
||||
@@ -285,7 +295,7 @@ const ActivitiesCalendarPage: React.FC = () => {
|
||||
</Box>
|
||||
))}
|
||||
{list.length > 3 && (
|
||||
<Text fontSize="xs" color={subtleText}>+{list.length - 3} další…</Text>
|
||||
<Text fontSize="xs" color={subtleText}>{t('calendar.more_events', { count: list.length - 3 })}</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
@@ -335,7 +345,7 @@ const ActivitiesCalendarPage: React.FC = () => {
|
||||
{e.location && <Text color={mutedText} fontSize="sm">{e.location}</Text>}
|
||||
</Flex>
|
||||
<Flex align="center" gap={2} flex="1" justify="center">
|
||||
<Badge colorScheme={typeColor(e.type)}>{typeLabel(e.type)}</Badge>
|
||||
<Badge colorScheme={typeColor(e.type)}>{typeLabel(e.type, t)}</Badge>
|
||||
<Text>{e.title}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -114,6 +114,8 @@ const ActivityDetailPage: React.FC = () => {
|
||||
const mutedText = useColorModeValue('gray.600', 'gray.300');
|
||||
const linkColor = useColorModeValue('blue.600', 'blue.300');
|
||||
const linkHoverColor = useColorModeValue('blue.700', 'blue.200');
|
||||
const blockquoteText = useColorModeValue('#4a5568', '#cbd5e0');
|
||||
const blockquoteBg = useColorModeValue('#f7fafc', '#1a202c');
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
@@ -177,17 +179,23 @@ const ActivityDetailPage: React.FC = () => {
|
||||
p={5}
|
||||
className="blog-content"
|
||||
sx={{
|
||||
' ul, ol': { pl: 6, listStylePosition: 'outside', mb: 3 },
|
||||
' ul': { listStyleType: 'disc' },
|
||||
' ul[data-bullets="disc"]': { listStyleType: 'disc' },
|
||||
' ul[data-bullets="circle"]': { listStyleType: 'circle' },
|
||||
' ul[data-bullets="square"]': { listStyleType: 'square' },
|
||||
' ol': { listStyleType: 'decimal' },
|
||||
' li': { mb: 2 },
|
||||
' h1, h2, h3, h4': { fontWeight: 'bold', mt: 3 },
|
||||
' p': { lineHeight: 1.8, mb: 3 },
|
||||
' ul, ol': { pl: 6, mb: 3 },
|
||||
' a': { color: linkColor, textDecoration: 'underline', _hover: { color: linkHoverColor } },
|
||||
' blockquote': {
|
||||
borderLeft: '4px solid #3182ce',
|
||||
paddingLeft: '16px',
|
||||
margin: '1em 0',
|
||||
color: useColorModeValue('#4a5568','#cbd5e0'),
|
||||
color: blockquoteText,
|
||||
fontStyle: 'italic',
|
||||
backgroundColor: useColorModeValue('#f7fafc','#1a202c'),
|
||||
backgroundColor: blockquoteBg,
|
||||
padding: '12px 16px',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
@@ -207,7 +215,7 @@ const ActivityDetailPage: React.FC = () => {
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(toAbsoluteUploads(String(data.description)), {
|
||||
USE_PROFILES: { html: true },
|
||||
ADD_TAGS: ['iframe'],
|
||||
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id'],
|
||||
ADD_ATTR: ['class', 'target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id', 'data-bullets', 'data-list'],
|
||||
}) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { API_URL } from '../services/api';
|
||||
import PhotoModal from '../components/gallery/PhotoModal';
|
||||
import CommentsSection from '../components/comments/CommentsSection';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Photo {
|
||||
id: string;
|
||||
@@ -57,6 +58,7 @@ const resolveBackendUrl = (path: string) => {
|
||||
};
|
||||
|
||||
const AlbumDetailPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [album, setAlbum] = useState<Album | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -103,9 +105,54 @@ const AlbumDetailPage: React.FC = () => {
|
||||
const blogAlbums = Array.isArray(albumsData) ? albumsData : [];
|
||||
foundAlbum = blogAlbums.find((a: Album) => a.id === id);
|
||||
}
|
||||
|
||||
|
||||
// Fallback: fetch album directly via zonerama-album API using the numeric ID
|
||||
if (!foundAlbum) {
|
||||
throw new Error('Album nenalezen');
|
||||
try {
|
||||
const albumUrl = `https://eu.zonerama.com/Album/${id}`;
|
||||
const params = new URLSearchParams({
|
||||
link: albumUrl,
|
||||
photo_limit: '200',
|
||||
rendered: 'true',
|
||||
});
|
||||
const resp = await fetch(`${API_URL}/zonerama-album?${params.toString()}`);
|
||||
if (resp.ok) {
|
||||
const payload = await resp.json();
|
||||
let albumData: any = null;
|
||||
let photos: any[] = [];
|
||||
|
||||
if (Array.isArray(payload?.albums) && payload.albums.length > 0) {
|
||||
albumData = payload.albums[0];
|
||||
photos = albumData.photos || [];
|
||||
} else if (payload?.album && Array.isArray(payload?.photos)) {
|
||||
albumData = payload.album;
|
||||
photos = payload.photos;
|
||||
}
|
||||
|
||||
if (albumData) {
|
||||
const mapped: Album = {
|
||||
id: String(albumData.id || id),
|
||||
title: String(albumData.title || ''),
|
||||
url: String(albumData.url || albumUrl),
|
||||
date: String(albumData.date || ''),
|
||||
photos_count: Array.isArray(photos) ? photos.length : 0,
|
||||
views_count: typeof albumData.views_count === 'number' ? albumData.views_count : undefined,
|
||||
photos: (photos || []).map((p: any) => ({
|
||||
id: String(p.id || ''),
|
||||
page_url: String(p.page_url || ''),
|
||||
image_1500: String(p.image_1500 || ''),
|
||||
})),
|
||||
fetched_at: albumData.fetched_at,
|
||||
};
|
||||
setAlbum(mapped);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore and fall through to error handling below
|
||||
}
|
||||
|
||||
throw new Error('Album nenalezeno');
|
||||
}
|
||||
|
||||
setAlbum(foundAlbum);
|
||||
@@ -135,7 +182,7 @@ const AlbumDetailPage: React.FC = () => {
|
||||
<Container maxW="7xl" py={8}>
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="xl" color="brand.primary" />
|
||||
<Text color={textSecondary}>Načítám album...</Text>
|
||||
<Text color={textSecondary}>{t('gallery.loading_album')}</Text>
|
||||
</VStack>
|
||||
</Container>
|
||||
</MainLayout>
|
||||
@@ -148,10 +195,10 @@ const AlbumDetailPage: React.FC = () => {
|
||||
<Container maxW="7xl" py={8}>
|
||||
<VStack spacing={4}>
|
||||
<Text color="red.500" fontSize="lg">
|
||||
{error || 'Album nenalezeno'}
|
||||
{error || t('gallery.album_not_found')}
|
||||
</Text>
|
||||
<Button as={RouterLink} to="/galerie" colorScheme="blue">
|
||||
Zpět na galerii
|
||||
{t('gallery.back_to_gallery')}
|
||||
</Button>
|
||||
</VStack>
|
||||
</Container>
|
||||
@@ -162,8 +209,8 @@ const AlbumDetailPage: React.FC = () => {
|
||||
return (
|
||||
<MainLayout>
|
||||
<Helmet>
|
||||
<title>{album.title} | Fotogalerie</title>
|
||||
<meta name="description" content={`Fotogalerie: ${album.title}.`} />
|
||||
<title>{album.title} | {t('gallery.page_title')}</title>
|
||||
<meta name="description" content={`${t('gallery.page_title')}: ${album.title}.`} />
|
||||
</Helmet>
|
||||
<Box bg={bgColor} minH="100vh" py={8}>
|
||||
<Container maxW="7xl">
|
||||
@@ -176,12 +223,12 @@ const AlbumDetailPage: React.FC = () => {
|
||||
>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink as={RouterLink} to="/">
|
||||
Domů
|
||||
{t('nav.home')}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink as={RouterLink} to="/galerie">
|
||||
Galerie
|
||||
{t('nav.gallery')}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbItem isCurrentPage>
|
||||
@@ -205,10 +252,24 @@ const AlbumDetailPage: React.FC = () => {
|
||||
)}
|
||||
<HStack spacing={1}>
|
||||
<ImageIcon size={16} />
|
||||
<Text>{album.photos_count} fotografií</Text>
|
||||
<Text>
|
||||
{album.photos_count === 1
|
||||
? t('gallery.photos_count_one', { count: album.photos_count })
|
||||
: (album.photos_count >= 2 && album.photos_count <= 4)
|
||||
? t('gallery.photos_count_few', { count: album.photos_count })
|
||||
: t('gallery.photos_count_many', { count: album.photos_count })
|
||||
}
|
||||
</Text>
|
||||
</HStack>
|
||||
{album.views_count !== undefined && album.views_count > 0 && (
|
||||
<Badge colorScheme="purple">{album.views_count} zhlédnutí</Badge>
|
||||
<Badge colorScheme="purple">
|
||||
{album.views_count === 1
|
||||
? t('gallery.views_count_one', { count: album.views_count })
|
||||
: (album.views_count >= 2 && album.views_count <= 4)
|
||||
? t('gallery.views_count_few', { count: album.views_count })
|
||||
: t('gallery.views_count_many', { count: album.views_count })
|
||||
}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
@@ -222,7 +283,7 @@ const AlbumDetailPage: React.FC = () => {
|
||||
colorScheme="purple"
|
||||
size="md"
|
||||
>
|
||||
Zobrazit na Zonerama
|
||||
{t('gallery.view_on_zonerama')}
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
@@ -235,7 +296,7 @@ const AlbumDetailPage: React.FC = () => {
|
||||
p={3}
|
||||
>
|
||||
<Text fontSize="sm" color={infoText}>
|
||||
📸 Všechny fotografie jsou z platformy{' '}
|
||||
{t('gallery.photos_from_zonerama')}{' '}
|
||||
<Text
|
||||
as="a"
|
||||
href={album.url || 'https://zonerama.com'}
|
||||
@@ -289,7 +350,7 @@ const AlbumDetailPage: React.FC = () => {
|
||||
textAlign="center"
|
||||
>
|
||||
<Text color="gray.500">
|
||||
V tomto albu nejsou žádné fotografie.
|
||||
{t('gallery.no_photos_in_album')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Box, Button, FormControl, FormLabel, Heading, Input, Switch, Textarea, VStack, Image, Text, HStack } from '@chakra-ui/react';
|
||||
import { createArticle, uploadFile } from '../services/articles';
|
||||
import { Box, Button, FormControl, FormLabel, Heading, Input, Switch, Textarea, VStack, Image, Text } from '@chakra-ui/react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { createArticle } from '../services/articles';
|
||||
import UploadPanel, { UploadPanelFile } from '../components/common/UploadPanel';
|
||||
|
||||
const ArticleCreatePage: React.FC = () => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [published, setPublished] = useState(true);
|
||||
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadPanelFile[]>([]);
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
@@ -21,20 +22,6 @@ const ArticleCreatePage: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const onUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const res = await uploadFile(file);
|
||||
setImageUrl(res.url);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit = title.trim().length > 0 && content.trim().length > 0 && !createMut.isLoading;
|
||||
|
||||
return (
|
||||
@@ -53,10 +40,21 @@ const ArticleCreatePage: React.FC = () => {
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Obrázek</FormLabel>
|
||||
<HStack align="start" spacing={4}>
|
||||
<Input type="file" accept="image/*,application/pdf" onChange={onUpload} isDisabled={uploading} />
|
||||
{uploading && <Text>Uploaduji...</Text>}
|
||||
</HStack>
|
||||
<UploadPanel
|
||||
label="Obrázek článku"
|
||||
description="Vyberte nebo přetáhněte obrázek, který se zobrazí jako titulní obrázek článku."
|
||||
accept="image/*,application/pdf"
|
||||
multiple={false}
|
||||
maxFiles={1}
|
||||
value={uploadedFiles}
|
||||
onChange={(files) => {
|
||||
setUploadedFiles(files);
|
||||
const last = files[files.length - 1];
|
||||
setImageUrl(last?.url);
|
||||
}}
|
||||
allowUrlImport
|
||||
urlPlaceholder="/uploads/... nebo https://example.com/obrazek.jpg"
|
||||
/>
|
||||
{imageUrl && (
|
||||
<Box mt={2}>
|
||||
<Image src={imageUrl} alt="náhled" maxH="160px" objectFit="cover" borderRadius="md" />
|
||||
|
||||
@@ -10,6 +10,7 @@ import NewsletterCTA from '../components/common/NewsletterCTA';
|
||||
import EmbeddedPoll from '../components/polls/EmbeddedPoll';
|
||||
import { ArrowRight, Eye, Clock, SearchX, ChevronLeft, ChevronRight, ExternalLink, Copy, Play } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { trackEvent as umamiTrackEvent, trackMatchView as umamiTrackMatchView, trackVideoPlay as umamiTrackVideoPlay, trackArticleView as umamiTrackArticleView } from '../utils/umami';
|
||||
import { assetUrl } from '../utils/url';
|
||||
import { API_URL } from '../services/api';
|
||||
@@ -38,6 +39,7 @@ const toText = (html?: string) => {
|
||||
};
|
||||
|
||||
const ArticleDetailPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id, slug } = useParams<{ id?: string; slug?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
@@ -148,7 +150,7 @@ const ArticleDetailPage: React.FC = () => {
|
||||
} catch {}
|
||||
}, [id, (data as any)?.slug, navigate]);
|
||||
|
||||
// Award engagement for article read after 15s dwell (once per article per device)
|
||||
// Award engagement for article read after 15s dwell (once per article)
|
||||
React.useEffect(() => {
|
||||
const aid = (data as any)?.id;
|
||||
if (!aid) return;
|
||||
@@ -192,7 +194,7 @@ const ArticleDetailPage: React.FC = () => {
|
||||
return DOMPurify.sanitize(transformed || '', {
|
||||
USE_PROFILES: { html: true },
|
||||
ADD_TAGS: ['iframe'],
|
||||
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id', 'data-bullets', 'data-list'],
|
||||
ADD_ATTR: ['class', 'target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id', 'data-bullets', 'data-list'],
|
||||
});
|
||||
}, [(data as any)?.content, toAbsoluteUploads]);
|
||||
|
||||
@@ -220,7 +222,19 @@ const ArticleDetailPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const origin = new URL(API_URL, window.location.origin).origin;
|
||||
// Resolve backend origin for cached files regardless of frontend origin
|
||||
let origin = '';
|
||||
try {
|
||||
const assetBase = (process.env.REACT_APP_ASSET_BASE_URL || '').trim();
|
||||
if (assetBase) {
|
||||
origin = new URL(assetBase, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
|
||||
}
|
||||
} catch {}
|
||||
if (!origin) {
|
||||
try {
|
||||
origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Try to find album in both sources
|
||||
const [profileRes, albumsRes] = await Promise.allSettled([
|
||||
@@ -940,6 +954,7 @@ const ArticleDetailPage: React.FC = () => {
|
||||
'ul[data-bullets="disc"]': { listStyleType: 'disc' },
|
||||
'ul[data-bullets="circle"]': { listStyleType: 'circle' },
|
||||
'ul[data-bullets="square"]': { listStyleType: 'square' },
|
||||
'ul[data-bullets="none"]': { listStyleType: 'none' },
|
||||
'ol': { listStyleType: 'decimal' },
|
||||
'li': { mb: 2 },
|
||||
'a': { color: 'blue.600', textDecoration: 'underline', _hover: { color: 'blue.700' } },
|
||||
@@ -1049,7 +1064,7 @@ const ArticleDetailPage: React.FC = () => {
|
||||
{((data as any)?.gallery_album_id || (data as any)?.gallery_album_url) && (
|
||||
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={galleryBg} borderColor={galleryBorder}>
|
||||
<Box mb={3}>
|
||||
<Heading as="h3" size="md" textAlign="center">Fotogalerie</Heading>
|
||||
<Heading as="h3" size="md" textAlign="center">{t('gallery.page_title')}</Heading>
|
||||
{(() => {
|
||||
const albumPhotos = Array.isArray(galleryAlbumQuery.data?.photos) ? (galleryAlbumQuery.data?.photos as any[]) : [];
|
||||
if (albumPhotos.length === 0) return null;
|
||||
|
||||
@@ -21,7 +21,7 @@ const ArticlesListPage: React.FC = () => {
|
||||
<Stack spacing={4}>
|
||||
{data?.data.map((a) => (
|
||||
<HStack key={a.id} align="start" spacing={4} borderWidth="1px" borderRadius="md" p={4} bg="white">
|
||||
<Image src={a.image_url || '/logo192.png'} alt={a.title} boxSize="100px" objectFit="cover" borderRadius="md" />
|
||||
<Image src={a.image_url || '/article-placeholder.svg'} alt={a.title} boxSize="100px" objectFit="cover" borderRadius="md" />
|
||||
<Box>
|
||||
<ChakraLink as={Link} to={`/articles/${a.id}`} fontWeight="bold" fontSize="lg">{a.title}</ChakraLink>
|
||||
<Text noOfLines={3} mt={2}>{a.content}</Text>
|
||||
|
||||
@@ -18,8 +18,10 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import api from '../services/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getPublicSettings, PublicSettings } from '../services/settings';
|
||||
import { assetUrl } from '../utils/url';
|
||||
import { PasswordHelpTooltip } from '../components/common/HelpTooltipCard';
|
||||
|
||||
interface LocationState {
|
||||
from: {
|
||||
@@ -29,6 +31,7 @@ interface LocationState {
|
||||
|
||||
const AuthPage: React.FC = () => {
|
||||
// All hooks must be called at the top level, before any conditional returns
|
||||
const { t } = useTranslation();
|
||||
const { isAuthenticated, adminExists, login } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -76,12 +79,20 @@ const AuthPage: React.FC = () => {
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
// Role-based redirect after login
|
||||
// Role-based redirect after login, honoring optional `from` target
|
||||
// and a stored post-setup target (fc_post_setup_target) for step 2
|
||||
const role = String(user?.role || '').toLowerCase();
|
||||
if (role === 'admin') {
|
||||
navigate('/admin', { replace: true });
|
||||
} else if (role === 'editor') {
|
||||
navigate('/admin', { replace: true });
|
||||
const targetFrom = (location.state as LocationState)?.from?.pathname;
|
||||
let storedTarget: string | null = null;
|
||||
try {
|
||||
storedTarget = localStorage.getItem('fc_post_setup_target');
|
||||
if (storedTarget) {
|
||||
localStorage.removeItem('fc_post_setup_target');
|
||||
}
|
||||
} catch {}
|
||||
if (role === 'admin' || role === 'editor') {
|
||||
const target = targetFrom || storedTarget || '/admin';
|
||||
navigate(target, { replace: true });
|
||||
} else if (role === 'user') {
|
||||
navigate('/', { replace: true });
|
||||
} else {
|
||||
@@ -125,14 +136,26 @@ const AuthPage: React.FC = () => {
|
||||
placeholder="Zadejte svůj e-mail"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl id="login-password" isRequired>
|
||||
<FormLabel>Heslo</FormLabel>
|
||||
<FormControl id="login-password">
|
||||
<FormLabel
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Box as="span" display="inline-flex" alignItems="center">
|
||||
<Box as="span" mr={1}>Heslo</Box>
|
||||
<Box as="span" color="red.500">*</Box>
|
||||
</Box>
|
||||
<PasswordHelpTooltip />
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Zadejte své heslo"
|
||||
required
|
||||
aria-required="true"
|
||||
/>
|
||||
<InputRightElement width="4.5rem">
|
||||
<Button h="1.75rem" size="sm" onClick={() => setShowPassword((s) => !s)}>{showPassword ? 'Skrýt' : 'Zobrazit'}</Button>
|
||||
@@ -147,10 +170,10 @@ const AuthPage: React.FC = () => {
|
||||
loadingText="Přihlašuji..."
|
||||
mt={4}
|
||||
>
|
||||
Přihlásit se
|
||||
{t('auth.login')}
|
||||
</Button>
|
||||
<Box display="flex" alignItems="center" justifyContent="flex-end">
|
||||
<Button variant="link" colorScheme="blue" onClick={() => navigate('/forgot-password?admin=1')}>Zapomněli jste heslo?</Button>
|
||||
<Button variant="link" colorScheme="blue" onClick={() => navigate('/forgot-password?admin=1')}>{t('auth.forgot_password')}</Button>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Box, Container, Heading, VStack, Image, Text, Skeleton, LinkBox, HStack, Select, Badge, useColorModeValue, Input, InputGroup, InputLeftElement, InputRightElement, IconButton, Grid, GridItem, useMediaQuery, Tooltip } from '@chakra-ui/react';
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||
import { getArticles, Article, Paginated, getFeaturedArticles } from '../services/articles';
|
||||
import { getArticles, Article, Paginated } from '../services/articles';
|
||||
import { getBanners, Banner as UIBanner } from '../services/banners';
|
||||
import { Link as RouterLink, useSearchParams } from 'react-router-dom';
|
||||
import { assetUrl } from '../utils/url';
|
||||
@@ -11,8 +11,10 @@ import NewsletterCTA from '../components/common/NewsletterCTA';
|
||||
import { Eye, Clock, Search, X } from 'lucide-react';
|
||||
import InstagramGeneratorButton from '../components/admin/InstagramGeneratorButton';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({ article, variant }) => {
|
||||
const { t } = useTranslation();
|
||||
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
|
||||
const readTime = article.read_time || article.estimated_read_minutes;
|
||||
const viewCount = article.view_count;
|
||||
@@ -55,21 +57,21 @@ const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({
|
||||
{/* Top info row: category (left), date (center), read time (right) */}
|
||||
<HStack position="absolute" top={2} left={2} right={2} justify="space-between" align="center">
|
||||
{categoryName ? (
|
||||
<Tooltip label="Kategorie" hasArrow>
|
||||
<Tooltip label={t('blog.category')} hasArrow>
|
||||
<Badge bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1} borderRadius="md">
|
||||
{categoryName}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : <Box />}
|
||||
{publishedDateStr ? (
|
||||
<Tooltip label="Datum publikace" hasArrow>
|
||||
<Tooltip label={t('blog.publish_date')} hasArrow>
|
||||
<Badge bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1} borderRadius="md">
|
||||
{publishedDateStr}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : <Box />}
|
||||
{readTime ? (
|
||||
<Tooltip label="Doba čtení" hasArrow>
|
||||
<Tooltip label={t('blog.read_time')} hasArrow>
|
||||
<Badge display="flex" alignItems="center" gap={1} bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1} borderRadius="md">
|
||||
<Clock size={12} />
|
||||
{readTime} min
|
||||
@@ -106,6 +108,7 @@ const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({
|
||||
};
|
||||
|
||||
const BlogPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const pageSize = 18;
|
||||
const [categories, setCategories] = React.useState<CategoryItem[]>([]);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -173,11 +176,6 @@ const BlogPage: React.FC = () => {
|
||||
});
|
||||
return Array.from(uniq.values()).slice(0, 10);
|
||||
}, [matchSuggestQ.data]);
|
||||
const featuredQ = useQuery<Paginated<Article>>(
|
||||
['articles-featured', { page_size: 3 }],
|
||||
() => getFeaturedArticles({ page_size: 3 }),
|
||||
{ refetchOnWindowFocus: true, refetchOnMount: true, refetchInterval: 30000, staleTime: 0 }
|
||||
);
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
@@ -207,9 +205,8 @@ const BlogPage: React.FC = () => {
|
||||
);
|
||||
|
||||
const articles = data?.pages?.flatMap((p) => p?.data || []) || [];
|
||||
const featuredList = featuredQ.data?.data || [];
|
||||
const featuredIdSet = React.useMemo(() => new Set((featuredList || []).map((a) => a.id)), [featuredList]);
|
||||
const visibleArticles = featuredList.length ? articles.filter((a) => !featuredIdSet.has(a.id)) : articles;
|
||||
const heroArticles = React.useMemo(() => articles.slice(0, 3), [articles]);
|
||||
const listArticles = React.useMemo(() => articles.slice(3), [articles]);
|
||||
|
||||
// Fetch inline article banners (active, placement=article_inline)
|
||||
const articleBannersQ = useQuery<UIBanner[]>(
|
||||
@@ -238,20 +235,20 @@ const BlogPage: React.FC = () => {
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
// Derive page SEO title/description
|
||||
const parts: string[] = ['Blog'];
|
||||
const parts: string[] = [t('blog.title')];
|
||||
if (categoryId) {
|
||||
const cat = categories.find((c) => c.id === Number(categoryId));
|
||||
if (cat?.name) parts.push(cat.name);
|
||||
}
|
||||
if (month) parts.push(month);
|
||||
if (matchId) parts.push('Zápas');
|
||||
if (matchId) parts.push(t('blog.match'));
|
||||
if (qParam) parts.push(`Hledání: ${qParam}`);
|
||||
const pageTitle = parts.join(' · ');
|
||||
const pageDesc = qParam
|
||||
? `Výsledky hledání článků pro „${qParam}“.`
|
||||
? t('blog.search_results', { query: qParam })
|
||||
: categoryId
|
||||
? `Články v kategorii ${(categories.find((c) => c.id === Number(categoryId))?.name) || ''}.`
|
||||
: 'Nejnovější články, rozhovory a novinky z klubu.';
|
||||
? t('blog.category_articles', { category: (categories.find((c) => c.id === Number(categoryId))?.name) || '' })
|
||||
: t('blog.latest_articles');
|
||||
const canonical = typeof window !== 'undefined' ? window.location.href : undefined;
|
||||
|
||||
// Debounced search param update when typing
|
||||
@@ -275,7 +272,7 @@ const BlogPage: React.FC = () => {
|
||||
{JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
itemListElement: (featuredList.concat(visibleArticles)).slice(0, 12).map((a, idx) => ({
|
||||
itemListElement: (articles || []).slice(0, 12).map((a, idx) => ({
|
||||
'@type': 'ListItem',
|
||||
position: idx + 1,
|
||||
url: (typeof window !== 'undefined' ? window.location.origin : '') + (a.slug ? `/news/${a.slug}` : `/articles/${a.id}`),
|
||||
@@ -289,7 +286,7 @@ const BlogPage: React.FC = () => {
|
||||
<Box bg="transparent" color="inherit" py={{ base: 8, md: 10 }} mb={4} borderBottom="1px" borderColor={borderColor}>
|
||||
<Container maxW="7xl">
|
||||
<HStack justify="space-between" align="center" spacing={4} wrap="wrap">
|
||||
<Heading as="h1" size={{ base: 'xl', md: '2xl' }}>Blog</Heading>
|
||||
<Heading as="h1" size={{ base: 'xl', md: '2xl' }}>{t('blog.title')}</Heading>
|
||||
<HStack spacing={3} w={{ base: '100%', md: '620px' }}>
|
||||
<Box flex="1">
|
||||
<InputGroup>
|
||||
@@ -297,7 +294,7 @@ const BlogPage: React.FC = () => {
|
||||
<Search size={16} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="Hledat články…"
|
||||
placeholder={t('blog.search_articles')}
|
||||
value={qInput}
|
||||
onChange={(e) => setQInput(e.target.value)}
|
||||
/>
|
||||
@@ -324,7 +321,7 @@ const BlogPage: React.FC = () => {
|
||||
{!!categories.length && (
|
||||
<Select
|
||||
maxW={{ base: '48%', md: '220px' }}
|
||||
placeholder="Všechny kategorie"
|
||||
placeholder={t('blog.all_categories')}
|
||||
value={categoryId}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value ? Number(e.target.value) : '';
|
||||
@@ -345,7 +342,7 @@ const BlogPage: React.FC = () => {
|
||||
<Box flex={{ base: '1', md: '0 0 220px' }} position="relative">
|
||||
<InputGroup>
|
||||
<Input
|
||||
placeholder="Hledat zápas…"
|
||||
placeholder={t('blog.search_match')}
|
||||
value={matchInput}
|
||||
onChange={(e) => setMatchInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -386,15 +383,15 @@ const BlogPage: React.FC = () => {
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{featuredList.length > 0 && (
|
||||
{heroArticles.length > 0 && (
|
||||
<Container maxW="7xl" mb={6}>
|
||||
<Grid templateColumns={{ base: '1fr', md: '2fr 1fr' }} gap={6}>
|
||||
<GridItem>
|
||||
<BlogTile article={featuredList[0]} variant="large" />
|
||||
<BlogTile article={heroArticles[0]} variant="large" />
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<VStack spacing={6} align="stretch">
|
||||
{featuredList.slice(1, 3).map((a) => (
|
||||
{heroArticles.slice(1, 3).map((a) => (
|
||||
<BlogTile key={a.id} article={a} variant="small" />
|
||||
))}
|
||||
</VStack>
|
||||
@@ -405,11 +402,11 @@ const BlogPage: React.FC = () => {
|
||||
|
||||
<Container maxW="7xl">
|
||||
{/* Responsive grid with consistent card sizing */}
|
||||
<Grid templateColumns={{ base: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={8}>
|
||||
<Grid templateColumns={{ base: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(4, 1fr)' }} gap={8}>
|
||||
{isLoading && Array.from({ length: 9 }).map((_, i) => (
|
||||
<Skeleton key={i} h={{ base: '260px', md: '300px' }} borderRadius="md" />
|
||||
))}
|
||||
{!isLoading && visibleArticles.map((a, idx) => (
|
||||
{!isLoading && listArticles.map((a, idx) => (
|
||||
<React.Fragment key={`row-${a.id}`}>
|
||||
<GridItem>
|
||||
<BlogTile article={a} />
|
||||
@@ -438,7 +435,7 @@ const BlogPage: React.FC = () => {
|
||||
</Grid>
|
||||
{isFetchingNextPage && (
|
||||
<VStack py={6}>
|
||||
<Text color={textColor}>Načítání…</Text>
|
||||
<Text color={textColor}>{t('blog.loading')}</Text>
|
||||
</VStack>
|
||||
)}
|
||||
</Container>
|
||||
|
||||
@@ -13,9 +13,18 @@ import ClubModal from '../components/home/ClubModal';
|
||||
import { assetUrl } from '../utils/url';
|
||||
import { API_URL } from '../services/api';
|
||||
import { TeamLogo } from '../components/common/TeamLogo';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MatchWeatherLazy } from '../components/common/MatchWeatherLazy';
|
||||
import { MatchWeather } from '../components/common/MatchWeather';
|
||||
|
||||
// Weekday headers (Czech, starting Monday)
|
||||
const WEEKDAYS_SHORT: string[] = ['Po', 'Út', 'St', 'Čt', 'Pá', 'So', 'Ne'];
|
||||
// Dynamic weekday headers based on language
|
||||
const getWeekdayHeaders = (language: string): string[] => {
|
||||
if (language === 'en') {
|
||||
return ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
}
|
||||
// Default to Czech
|
||||
return ['Po', 'Út', 'St', 'Čt', 'Pá', 'So', 'Ne'];
|
||||
};
|
||||
|
||||
type MatchItem = {
|
||||
id: number | string;
|
||||
@@ -45,6 +54,7 @@ type Competition = {
|
||||
};
|
||||
|
||||
const CalendarPage: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [competitions, setCompetitions] = useState<Competition[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -58,6 +68,30 @@ const CalendarPage: React.FC = () => {
|
||||
const [clubType, setClubType] = useState<'football' | 'futsal'>('football');
|
||||
const [standings, setStandings] = useState<any[]>([]);
|
||||
|
||||
// Get weekday headers based on current language
|
||||
const weekdayHeaders = getWeekdayHeaders(i18n.language);
|
||||
|
||||
// Helper functions inside component to access t()
|
||||
const getSentiment = (m: MatchItem): { label: string; color: string } | null => {
|
||||
// Don't show sentiment for future matches
|
||||
const dt = new Date(`${m.date}T${(m.time || '00:00')}:00`);
|
||||
const isPast = Date.now() >= dt.getTime();
|
||||
if (!isPast) return null;
|
||||
|
||||
const s = parseScore(m.score);
|
||||
if (!s) return null;
|
||||
|
||||
const ourIsHome = normalize(m.home).includes(normalize(clubName));
|
||||
const ourIsAway = normalize(m.away).includes(normalize(clubName));
|
||||
|
||||
if (!ourIsHome && !ourIsAway) return null; // unknown perspective
|
||||
if (s.h === s.a) return { label: t('calendar.draw'), color: 'blue' };
|
||||
const ourGoals = ourIsHome ? s.h : s.a;
|
||||
const oppGoals = ourIsHome ? s.a : s.h;
|
||||
if (ourGoals > oppGoals) return { label: t('calendar.win'), color: 'green' };
|
||||
return { label: t('calendar.loss'), color: 'red' };
|
||||
};
|
||||
|
||||
// Active competition for current tab (memoized)
|
||||
const activeCompetition = useMemo(() => competitions[tabIndex], [competitions, tabIndex]);
|
||||
|
||||
@@ -421,7 +455,7 @@ const CalendarPage: React.FC = () => {
|
||||
__compDisplayOrder: c.display_order ?? (1000 + idx) // Use display_order or fallback to sorted index
|
||||
} as MatchItem)))
|
||||
.sort((a, b) => new Date(`${a.date}T${a.time}:00`).getTime() - new Date(`${b.date}T${b.time}:00`).getTime());
|
||||
const allComp: Competition = { id: 'all', name: 'Všechny soutěže', matches: allMatches };
|
||||
const allComp: Competition = { id: 'all', name: t('calendar.all_competitions'), matches: allMatches };
|
||||
|
||||
// Load standings data
|
||||
let standingsData: any[] = [];
|
||||
@@ -621,7 +655,10 @@ const CalendarPage: React.FC = () => {
|
||||
const b = stripPrefixes(clubName || '');
|
||||
if (!a || !b) return false;
|
||||
// Allow equality or suffix match (handles prefixes like TJ, SK, etc.)
|
||||
return a === b || a.endsWith(b) || b.endsWith(a);
|
||||
// Also handle common abbreviations like "FK" vs "Fotbalový klub"
|
||||
const aClean = a.replace(/\bfk\b/g, '').replace(/\bfotbalový\s+klub\b/g, '').trim();
|
||||
const bClean = b.replace(/\bfk\b/g, '').replace(/\bfotbalový\s+klub\b/g, '').trim();
|
||||
return a === b || a.endsWith(b) || b.endsWith(a) || aClean === bClean || aClean.endsWith(bClean) || bClean.endsWith(aClean);
|
||||
} catch { return false; }
|
||||
};
|
||||
const parseScore = (score?: string): { h: number; a: number } | null => {
|
||||
@@ -630,52 +667,17 @@ const CalendarPage: React.FC = () => {
|
||||
if (!m) return null;
|
||||
return { h: parseInt(m[1], 10), a: parseInt(m[2], 10) };
|
||||
};
|
||||
const getSentiment = (m: MatchItem): { label: 'Výhra'|'Remíza'|'Prohra'; color: string } | null => {
|
||||
// Don't show sentiment for future matches
|
||||
const dt = new Date(`${m.date}T${(m.time || '00:00')}:00`);
|
||||
const isPast = Date.now() >= dt.getTime();
|
||||
if (!isPast) return null;
|
||||
|
||||
const s = parseScore(m.score);
|
||||
if (!s) return null;
|
||||
|
||||
// First try ID-based matching (most reliable)
|
||||
let ourIsHome = false;
|
||||
let ourIsAway = false;
|
||||
if (clubId) {
|
||||
// Check each team ID individually - even if one is missing, we can still match the other
|
||||
if (m.home_id) {
|
||||
ourIsHome = m.home_id === clubId;
|
||||
}
|
||||
if (m.away_id) {
|
||||
ourIsAway = m.away_id === clubId;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to name matching if IDs not available or no match
|
||||
if (!ourIsHome && !ourIsAway) {
|
||||
ourIsHome = isClubTeam(m.home);
|
||||
ourIsAway = isClubTeam(m.away);
|
||||
}
|
||||
|
||||
if (!ourIsHome && !ourIsAway) return null; // unknown perspective
|
||||
if (s.h === s.a) return { label: 'Remíza', color: 'blue' };
|
||||
const ourGoals = ourIsHome ? s.h : s.a;
|
||||
const oppGoals = ourIsHome ? s.a : s.h;
|
||||
if (ourGoals > oppGoals) return { label: 'Výhra', color: 'green' };
|
||||
return { label: 'Prohra', color: 'red' };
|
||||
};
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<Container maxW="7xl" py={{ base: 6, md: 10 }}>
|
||||
<Heading as="h1" size="xl" mb={2}>Kalendář</Heading>
|
||||
<Text color="gray.600" mb={6}>Přehled zápasů podle soutěží (FACR).</Text>
|
||||
<Heading as="h1" size="xl" mb={2}>{t('calendar.title')}</Heading>
|
||||
<Text color="gray.600" mb={6}>{t('calendar.subtitle')}</Text>
|
||||
|
||||
{loading && (
|
||||
<Flex align="center" gap={3} color="gray.600" mb={6}>
|
||||
<Spinner size="sm" />
|
||||
<span>Načítám rozpis…</span>
|
||||
<span>{t('calendar.loading')}</span>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
@@ -684,7 +686,7 @@ const CalendarPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{!loading && !hasData && !error && (
|
||||
<Box color="gray.600">Zatím nemáme žádné zápasy k zobrazení.</Box>
|
||||
<Box color="gray.600">{t('calendar.no_matches')}</Box>
|
||||
)}
|
||||
|
||||
{!!competitions.length && (
|
||||
@@ -838,21 +840,21 @@ const CalendarPage: React.FC = () => {
|
||||
color={viewMode==='calendar' ? 'text.onPrimary' : undefined}
|
||||
_hover={{ filter: viewMode==='calendar' ? 'brightness(0.95)' : undefined, borderColor: 'brand.primary', color: viewMode==='calendar' ? 'text.onPrimary' : undefined }}
|
||||
onClick={() => setViewMode('calendar')}
|
||||
>Kalendář</Button>
|
||||
>{t('calendar.title')}</Button>
|
||||
<Button
|
||||
variant={viewMode==='list' ? 'solid' : 'outline'}
|
||||
bg={viewMode==='list' ? 'brand.primary' : undefined}
|
||||
color={viewMode==='list' ? 'text.onPrimary' : undefined}
|
||||
_hover={{ filter: viewMode==='list' ? 'brightness(0.95)' : undefined, borderColor: 'brand.primary', color: viewMode==='list' ? 'text.onPrimary' : undefined }}
|
||||
onClick={() => setViewMode('list')}
|
||||
>Seznam</Button>
|
||||
>{t('calendar.list_view')}</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
{viewMode === 'calendar' ? (
|
||||
<>
|
||||
<Flex align="center" justify="space-between" mb={3} gap={2} flexWrap={{ base: 'wrap', md: 'nowrap' }}>
|
||||
<IconButton
|
||||
aria-label="Předchozí měsíc"
|
||||
aria-label={t('calendar.previous_month')}
|
||||
size="sm"
|
||||
onClick={() => setMonthRef(addMonths(monthRef, -1))}
|
||||
icon={<ChevronLeftIcon />}
|
||||
@@ -866,9 +868,9 @@ const CalendarPage: React.FC = () => {
|
||||
bg={'brand.primary'}
|
||||
color={'text.onPrimary'}
|
||||
_hover={{ filter: 'brightness(0.95)' }}
|
||||
>Dnes</Button>
|
||||
>{t('calendar.today')}</Button>
|
||||
<IconButton
|
||||
aria-label="Další měsíc"
|
||||
aria-label={t('calendar.next_month')}
|
||||
size="sm"
|
||||
onClick={() => setMonthRef(addMonths(monthRef, 1))}
|
||||
icon={<ChevronRightIcon />}
|
||||
@@ -878,7 +880,7 @@ const CalendarPage: React.FC = () => {
|
||||
</Flex>
|
||||
<Box overflowX="auto">
|
||||
<Grid templateColumns="repeat(7, 1fr)" gap={3} minW="980px">
|
||||
{WEEKDAYS_SHORT.map((w) => (
|
||||
{weekdayHeaders.map((w: string) => (
|
||||
<Box key={w} textAlign="center" fontWeight="semibold" color="gray.600" fontSize={{ base: 'xs', md: 'sm' }}>{w}</Box>
|
||||
))}
|
||||
</Grid>
|
||||
@@ -972,12 +974,12 @@ const CalendarPage: React.FC = () => {
|
||||
})}
|
||||
{list.length > 3 && !expandedDates[key] && (
|
||||
<Button size="xs" variant="link" colorScheme="gray" onClick={() => setExpandedDates((s) => ({ ...s, [key]: true }))}>
|
||||
+{list.length - 3} další…
|
||||
{t('calendar.show_more', { count: list.length - 3 })}
|
||||
</Button>
|
||||
)}
|
||||
{expandedDates[key] && list.length > 3 && (
|
||||
<Button size="xs" variant="link" colorScheme="gray" onClick={() => setExpandedDates((s) => ({ ...s, [key]: false }))}>
|
||||
Zobrazit méně
|
||||
{t('calendar.show_less')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -1010,7 +1012,7 @@ const CalendarPage: React.FC = () => {
|
||||
{format(parse(dKey, 'yyyy-MM-dd', new Date()), 'EEEE d. M. yyyy', { locale: cs })}
|
||||
</Text>
|
||||
{highlight && (
|
||||
<Badge colorScheme="blue" variant="subtle" borderRadius="full">Dnes</Badge>
|
||||
<Badge colorScheme="blue" variant="subtle" borderRadius="full">{t('calendar.today')}</Badge>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
@@ -1063,7 +1065,7 @@ const CalendarPage: React.FC = () => {
|
||||
>
|
||||
<Flex direction="column" minW="100px">
|
||||
<Text fontWeight="semibold" color={listDateText} fontSize="sm">{m.date}</Text>
|
||||
<Text color={listTimeText} fontSize="sm">{m.time || '—'}</Text>
|
||||
<Text color={listDateText} fontSize="sm">{m.time || '—'}</Text>
|
||||
{m.venue && <Text color={listVenueText} fontSize="xs" mt={1}>{m.venue}</Text>}
|
||||
</Flex>
|
||||
|
||||
@@ -1141,9 +1143,13 @@ const CalendarPage: React.FC = () => {
|
||||
{!!pastKeys.length && (
|
||||
<Box>
|
||||
{!showPast ? (
|
||||
<Button size="sm" variant="link" onClick={() => setShowPast(true)}>Zobrazit předchozí zápasy ({pastKeys.reduce((acc,k)=>acc+(byDate.get(k)?.length||0),0)})</Button>
|
||||
<Button size="sm" variant="link" onClick={() => setShowPast(true)}>
|
||||
Zobrazit předchozí zápasy ({pastKeys.reduce((acc, k) => acc + (byDate.get(k)?.length || 0), 0)})
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="link" onClick={() => setShowPast(false)}>Skrýt předchozí zápasy</Button>
|
||||
<Button size="sm" variant="link" onClick={() => setShowPast(false)}>
|
||||
{t('calendar.hide_past_matches')}
|
||||
</Button>
|
||||
)}
|
||||
{showPast && (
|
||||
<Stack spacing={4} mt={2}>
|
||||
@@ -1370,6 +1376,30 @@ const CalendarPage: React.FC = () => {
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
<Box h="1px" bg="gray.200" />
|
||||
|
||||
{/* Weather widget for home matches that haven't started */}
|
||||
{(() => {
|
||||
const dt = new Date(`${selected.match.date}T${(selected.match.time || '00:00')}:00`);
|
||||
const isPast = Date.now() >= dt.getTime();
|
||||
const isHomeMatch = clubName && selected.match.home && isClubTeam(selected.match.home);
|
||||
|
||||
if (isHomeMatch && !isPast) {
|
||||
return (
|
||||
<Box>
|
||||
<MatchWeather
|
||||
matchDateTime={`${selected.match.date}T${selected.match.time || '15:00'}:00`}
|
||||
venue={undefined} // Home matches use club location
|
||||
isHomeMatch={true}
|
||||
matchHasStarted={false}
|
||||
delayLoad={false} // Auto-load when modal opens
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
<Box h="1px" bg="gray.200" />
|
||||
<Heading as="h3" size="sm">Odběr notifikací pro fanoušky</Heading>
|
||||
<Text fontSize="sm" color="gray.600">Zadejte svůj email a budeme vás informovat o novinkách a zápasech.</Text>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
VStack,
|
||||
HStack,
|
||||
Link as ChakraLink,
|
||||
Button,
|
||||
useColorModeValue,
|
||||
Spinner,
|
||||
Center
|
||||
@@ -24,6 +25,7 @@ const ClothingPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const eshopUrl = process.env.REACT_APP_ESHOP_URL;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchItems = async () => {
|
||||
@@ -68,6 +70,20 @@ const ClothingPage: React.FC = () => {
|
||||
<Text fontSize="lg" color="gray.600">
|
||||
Podpořte svůj tým! Prohlédněte si naši nabídku dresů, triček a dalšího merchandisingu.
|
||||
</Text>
|
||||
{eshopUrl && (
|
||||
<Box mt={4}>
|
||||
<Button
|
||||
as={ChakraLink}
|
||||
href={eshopUrl}
|
||||
isExternal
|
||||
colorScheme="blue"
|
||||
rightIcon={<FiExternalLink />}
|
||||
size="sm"
|
||||
>
|
||||
Přejít do plného e-shopu
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{items.length === 0 ? (
|
||||
|
||||
@@ -2,11 +2,13 @@ import React from 'react';
|
||||
import MainLayout from '../components/layout/MainLayout';
|
||||
import { Box, Container, Heading, Text, Stack, Image, SimpleGrid, Divider } from '@chakra-ui/react';
|
||||
import { usePublicSettings } from '../hooks/usePublicSettings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { assetUrl } from '../utils/url';
|
||||
import NewsletterCTA from '../components/common/NewsletterCTA';
|
||||
|
||||
const ClubPage: React.FC = () => {
|
||||
const { data: settings } = usePublicSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
@@ -17,8 +19,8 @@ const ClubPage: React.FC = () => {
|
||||
<Image src={assetUrl(settings.club_logo_url)} alt={settings?.club_name || 'Logo'} boxSize={{ base: '64px', md: '80px' }} objectFit="contain" />
|
||||
)}
|
||||
<Box>
|
||||
<Heading as="h1" size="xl">{settings?.club_name || 'O klubu'}</Heading>
|
||||
<Text color="gray.600">Oficiální informace o klubu</Text>
|
||||
<Heading as="h1" size="xl">{settings?.club_name || t('nav.club')}</Heading>
|
||||
<Text color="gray.600">{t('about.club_info')}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import { getPublicContacts, GroupedContacts } from '../services/contactInfo';
|
||||
import { facrApi } from '../services/facr/facrApi';
|
||||
import { getCompetitionAliasesPublic } from '../services/competitionAliases';
|
||||
import { getImageUrl } from '../utils/imageUtils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type ContactFormData = {
|
||||
name: string;
|
||||
@@ -48,6 +49,7 @@ type ContactFormData = {
|
||||
};
|
||||
|
||||
const ContactPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { settings } = useSettings();
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
@@ -77,8 +79,8 @@ const ContactPage: React.FC = () => {
|
||||
trackContactSubmit(true);
|
||||
trackFormSubmit('Contact Form', true);
|
||||
toast({
|
||||
title: 'Zpráva odeslána',
|
||||
description: 'Děkujeme za vaši zprávu. Brzy se vám ozveme zpět.',
|
||||
title: t('contact.message_sent'),
|
||||
description: t('contact.message_sent_desc'),
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
@@ -92,16 +94,16 @@ const ContactPage: React.FC = () => {
|
||||
const isNetwork = !!error?.isAxiosError && !error?.response;
|
||||
|
||||
const description = msgFromServer
|
||||
|| (isTimeout ? 'Vypršel časový limit požadavku. Zkuste to prosím znovu za chvíli.'
|
||||
: isNetwork ? 'Požadavek se nezdařil (síť/CORS). Zkuste to znovu nebo obnovte stránku.'
|
||||
: 'Něco se pokazilo. Zkuste to prosím znovu později.');
|
||||
|| (isTimeout ? t('contact.timeout_error')
|
||||
: isNetwork ? t('contact.network_error')
|
||||
: t('contact.general_error'));
|
||||
|
||||
// Track failed contact form submission
|
||||
trackContactSubmit(false);
|
||||
trackFormSubmit('Contact Form', false);
|
||||
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
title: t('contact.error_title'),
|
||||
description,
|
||||
status: 'error',
|
||||
duration: 6000,
|
||||
@@ -204,13 +206,13 @@ const ContactPage: React.FC = () => {
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{hasContactInfo && (
|
||||
<Box bg={bgColor} p={4} borderRadius="lg" borderWidth="1px" borderColor={borderColor} boxShadow="sm">
|
||||
<Heading size="md" mb={3}>Kontaktní údaje</Heading>
|
||||
<Heading size="md" mb={3}>{t('contact.contact_info')}</Heading>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{(settings as any)?.contact_address && (
|
||||
<HStack align="start">
|
||||
<Icon as={FiMapPin} boxSize={5} color="blue.500" mt={1} />
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="bold">Adresa</Text>
|
||||
<Text fontWeight="bold">{t('contact.address')}</Text>
|
||||
<Text>{(settings as any)?.contact_address}</Text>
|
||||
{(settings as any)?.contact_city && (
|
||||
<Text>
|
||||
@@ -227,7 +229,7 @@ const ContactPage: React.FC = () => {
|
||||
<HStack align="start">
|
||||
<Icon as={FiPhone} boxSize={5} color="blue.500" mt={1} />
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="bold">Telefon</Text>
|
||||
<Text fontWeight="bold">{t('contact.phone')}</Text>
|
||||
<Link href={`tel:${(settings as any)?.contact_phone}`} color="blue.500">
|
||||
{(settings as any)?.contact_phone}
|
||||
</Link>
|
||||
@@ -239,7 +241,7 @@ const ContactPage: React.FC = () => {
|
||||
<HStack align="start">
|
||||
<Icon as={FiMail} boxSize={5} color="blue.500" mt={1} />
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="bold">Email</Text>
|
||||
<Text fontWeight="bold">{t('contact.email')}</Text>
|
||||
<Link href={`mailto:${(settings as any)?.contact_email}`} color="blue.500">
|
||||
{(settings as any)?.contact_email}
|
||||
</Link>
|
||||
@@ -252,7 +254,7 @@ const ContactPage: React.FC = () => {
|
||||
|
||||
{hasContacts && (
|
||||
<Box bg={bgColor} p={4} borderRadius="lg" borderWidth="1px" borderColor={borderColor} boxShadow="sm">
|
||||
<Heading size="md" mb={3}>Kontaktní osoby</Heading>
|
||||
<Heading size="md" mb={3}>{t('contact.contact_persons')}</Heading>
|
||||
<Tabs colorScheme="blue" isFitted isLazy>
|
||||
{(() => {
|
||||
const categoryEntries = Object.entries(contactsData?.categories || {});
|
||||
@@ -266,7 +268,7 @@ const ContactPage: React.FC = () => {
|
||||
{tabs.map((n) => (
|
||||
<Tab key={n}>{n}</Tab>
|
||||
))}
|
||||
{hasOthers && <Tab>Ostatní</Tab>}
|
||||
{hasOthers && <Tab>{t('contact.others')}</Tab>}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{useCategories
|
||||
@@ -348,7 +350,7 @@ const ContactPage: React.FC = () => {
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text color="gray.500">Pro tuto kategorii zatím nemáme kontaktní osobu.</Text>
|
||||
<Text color="gray.500">{t('contact.no_contacts')}</Text>
|
||||
)}
|
||||
</TabPanel>
|
||||
);
|
||||
@@ -410,10 +412,10 @@ const ContactPage: React.FC = () => {
|
||||
{/* Contact form at the end */}
|
||||
<Box>
|
||||
<Heading size="lg" mb={2} color={settings?.primaryColor || 'brand.500'}>
|
||||
Kontaktujte nás
|
||||
{t('contact.contact_us')}
|
||||
</Heading>
|
||||
<Text color="gray.500">
|
||||
Máte dotaz nebo připomínku? Napište nám zprávu a my se vám ozveme co nejdříve zpět.
|
||||
{t('contact.contact_description')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -428,13 +430,13 @@ const ContactPage: React.FC = () => {
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isInvalid={!!errors.name}>
|
||||
<FormLabel htmlFor="name">Jméno a příjmení *</FormLabel>
|
||||
<FormLabel htmlFor="name">{t('contact.name_label')}</FormLabel>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Jan Novák"
|
||||
placeholder={t('contact.name_placeholder')}
|
||||
{...register('name', {
|
||||
required: 'Toto pole je povinné',
|
||||
minLength: { value: 2, message: 'Jméno musí mít alespoň 2 znaky' },
|
||||
required: t('contact.name_required'),
|
||||
minLength: { value: 2, message: t('contact.name_min_length') },
|
||||
})}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
@@ -443,16 +445,16 @@ const ContactPage: React.FC = () => {
|
||||
</FormControl>
|
||||
|
||||
<FormControl isInvalid={!!errors.email}>
|
||||
<FormLabel htmlFor="email">E-mailová adresa *</FormLabel>
|
||||
<FormLabel htmlFor="email">{t('contact.email_label')}</FormLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="vas@email.cz"
|
||||
placeholder={t('contact.email_placeholder')}
|
||||
{...register('email', {
|
||||
required: 'Toto pole je povinné',
|
||||
required: t('contact.email_required'),
|
||||
pattern: {
|
||||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||
message: 'Neplatná e-mailová adresa',
|
||||
message: t('contact.email_invalid'),
|
||||
},
|
||||
})}
|
||||
/>
|
||||
@@ -462,13 +464,13 @@ const ContactPage: React.FC = () => {
|
||||
</FormControl>
|
||||
|
||||
<FormControl isInvalid={!!errors.subject}>
|
||||
<FormLabel htmlFor="subject">Předmět</FormLabel>
|
||||
<FormLabel htmlFor="subject">{t('contact.subject_label')}</FormLabel>
|
||||
<Input
|
||||
id="subject"
|
||||
placeholder="Předmět zprávy"
|
||||
placeholder={t('contact.subject_placeholder')}
|
||||
{...register('subject', {
|
||||
required: 'Předmět je povinný',
|
||||
maxLength: { value: 100, message: 'Předmět může mít maximálně 100 znaků' },
|
||||
required: t('contact.subject_required'),
|
||||
maxLength: { value: 100, message: t('contact.subject_max_length') },
|
||||
})}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
@@ -477,15 +479,15 @@ const ContactPage: React.FC = () => {
|
||||
</FormControl>
|
||||
|
||||
<FormControl isInvalid={!!errors.message}>
|
||||
<FormLabel htmlFor="message">Zpráva *</FormLabel>
|
||||
<FormLabel htmlFor="message">{t('contact.message_label')}</FormLabel>
|
||||
<Textarea
|
||||
id="message"
|
||||
rows={6}
|
||||
placeholder="Napište nám zprávu..."
|
||||
placeholder={t('contact.message_placeholder')}
|
||||
{...register('message', {
|
||||
required: 'Toto pole je povinné',
|
||||
minLength: { value: 10, message: 'Zpráva musí mít alespoň 10 znaků' },
|
||||
maxLength: { value: 2000, message: 'Zpráva může mít maximálně 2000 znaků' },
|
||||
required: t('contact.message_required'),
|
||||
minLength: { value: 10, message: t('contact.message_min_length') },
|
||||
maxLength: { value: 2000, message: t('contact.message_max_length') },
|
||||
})}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
@@ -500,10 +502,10 @@ const ContactPage: React.FC = () => {
|
||||
width="full"
|
||||
mt={4}
|
||||
isLoading={isLoading}
|
||||
loadingText="Odesílám..."
|
||||
loadingText={t('contact.sending')}
|
||||
data-umami-event="Contact Form Submit"
|
||||
>
|
||||
Odeslat zprávu
|
||||
{t('contact.send_message')}
|
||||
</Button>
|
||||
</VStack>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
useToast,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Textarea,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
Select,
|
||||
Badge,
|
||||
Divider,
|
||||
useDisclosure,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Grid,
|
||||
GridItem,
|
||||
SimpleGrid,
|
||||
Flex,
|
||||
Spacer,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiCalendar,
|
||||
FiClock,
|
||||
FiMapPin,
|
||||
FiUsers,
|
||||
FiDollarSign,
|
||||
FiSun,
|
||||
FiHome,
|
||||
FiCheck,
|
||||
FiX,
|
||||
FiInfo,
|
||||
FiRefreshCw,
|
||||
} from 'react-icons/fi';
|
||||
import { api } from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
interface Facility {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
status: string;
|
||||
capacity: number;
|
||||
area: number;
|
||||
location: string;
|
||||
is_indoor: boolean;
|
||||
is_outdoor: boolean;
|
||||
image_url: string;
|
||||
requires_approval: boolean;
|
||||
min_booking_duration: number;
|
||||
max_booking_duration: number;
|
||||
booking_advance_days: number;
|
||||
price_per_hour: number;
|
||||
}
|
||||
|
||||
interface BookingFormData {
|
||||
facility_id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
attendees_count: number;
|
||||
}
|
||||
|
||||
interface TimeSlot {
|
||||
start: string;
|
||||
end: string;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
interface CalendarDay {
|
||||
date: string;
|
||||
slots: TimeSlot[];
|
||||
}
|
||||
|
||||
const FacilitiesBookingPage: React.FC = () => {
|
||||
const [facilities, setFacilities] = useState<Facility[]>([]);
|
||||
const [selectedFacility, setSelectedFacility] = useState<Facility | null>(null);
|
||||
const [availability, setAvailability] = useState<Record<string, CalendarDay>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedDate, setSelectedDate] = useState<string>('');
|
||||
const [selectedSlot, setSelectedSlot] = useState<TimeSlot | null>(null);
|
||||
const [formData, setFormData] = useState<BookingFormData>({
|
||||
facility_id: 0,
|
||||
title: '',
|
||||
description: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
attendees_count: 1,
|
||||
});
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
useEffect(() => {
|
||||
fetchFacilities();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFacility && selectedDate) {
|
||||
fetchAvailability();
|
||||
}
|
||||
}, [selectedFacility, selectedDate]);
|
||||
|
||||
const fetchFacilities = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get('/facilities');
|
||||
setFacilities(response.data.facilities || []);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Chyba při načítání',
|
||||
description: 'Nepodařilo se načíst seznam zařízení',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAvailability = async () => {
|
||||
if (!selectedFacility || !selectedDate) return;
|
||||
|
||||
try {
|
||||
const endDate = new Date(selectedDate);
|
||||
endDate.setDate(endDate.getDate() + 7); // Get 7 days of availability
|
||||
|
||||
const response = await api.get(`/facilities/${selectedFacility.id}/availability`, {
|
||||
params: {
|
||||
start_date: selectedDate,
|
||||
end_date: endDate.toISOString().split('T')[0],
|
||||
},
|
||||
});
|
||||
setAvailability(response.data.availability || {});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Chyba při načítání',
|
||||
description: 'Nepodařilo se načíst dostupnost',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFacilitySelect = (facility: Facility) => {
|
||||
setSelectedFacility(facility);
|
||||
setFormData({ ...formData, facility_id: facility.id });
|
||||
|
||||
// Set default date to today
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
setSelectedDate(today);
|
||||
};
|
||||
|
||||
const handleSlotSelect = (date: string, slot: TimeSlot) => {
|
||||
if (!slot.available || !selectedFacility) return;
|
||||
|
||||
setSelectedDate(date);
|
||||
setSelectedSlot(slot);
|
||||
|
||||
// Set form data with selected slot
|
||||
const startTime = new Date(`${date}T${slot.start}:00`);
|
||||
const endTime = new Date(`${date}T${slot.end}:00`);
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
start_time: startTime.toISOString(),
|
||||
end_time: endTime.toISOString(),
|
||||
});
|
||||
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!user) {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: 'Pro rezervaci musíte být přihlášeni',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post('/facilities/bookings', formData);
|
||||
toast({
|
||||
title: 'Úspěch',
|
||||
description: 'Rezervace byla vytvořena',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
onClose();
|
||||
fetchAvailability(); // Refresh availability
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: error.response?.data?.error || 'Nepodařilo se vytvořit rezervaci',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeText = (type: string) => {
|
||||
switch (type) {
|
||||
case 'field':
|
||||
return 'Hřiště';
|
||||
case 'gym':
|
||||
return 'Posilovna';
|
||||
case 'locker':
|
||||
return 'Šatna';
|
||||
case 'classroom':
|
||||
return 'Učebna';
|
||||
case 'storage':
|
||||
return 'Sklad';
|
||||
case 'other':
|
||||
return 'Ostatní';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'field':
|
||||
return <FiSun />;
|
||||
case 'gym':
|
||||
return <FiUsers />;
|
||||
case 'locker':
|
||||
return <FiHome />;
|
||||
case 'classroom':
|
||||
return <FiUsers />;
|
||||
case 'storage':
|
||||
return <FiHome />;
|
||||
default:
|
||||
return <FiHome />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
return price > 0 ? `${price} Kč/hod` : 'Zdarma';
|
||||
};
|
||||
|
||||
const calculateDuration = () => {
|
||||
if (!formData.start_time || !formData.end_time) return 0;
|
||||
const start = new Date(formData.start_time);
|
||||
const end = new Date(formData.end_time);
|
||||
return Math.round((end.getTime() - start.getTime()) / (1000 * 60)); // minutes
|
||||
};
|
||||
|
||||
const calculatePrice = () => {
|
||||
if (!selectedFacility || !formData.start_time || !formData.end_time) return 0;
|
||||
const duration = calculateDuration();
|
||||
const hours = duration / 60;
|
||||
return selectedFacility.price_per_hour * hours;
|
||||
};
|
||||
|
||||
const generateCalendarDays = () => {
|
||||
const days = [];
|
||||
const startDate = selectedDate || new Date().toISOString().split('T')[0];
|
||||
const start = new Date(startDate);
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = new Date(start);
|
||||
date.setDate(start.getDate() + i);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
days.push(dateStr);
|
||||
}
|
||||
|
||||
return days;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box p={8}>
|
||||
<Text>Načítání...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p={8} maxW="1200px" mx="auto">
|
||||
<VStack spacing={8} align="stretch">
|
||||
<Heading size="lg">Rezervace zařízení</Heading>
|
||||
|
||||
{!selectedFacility ? (
|
||||
// Facility selection
|
||||
<VStack spacing={6}>
|
||||
<Text fontSize="lg" fontWeight="medium">
|
||||
Vyberte zařízení pro rezervaci
|
||||
</Text>
|
||||
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
|
||||
{facilities.map((facility) => (
|
||||
<Card
|
||||
key={facility.id}
|
||||
bg={bgColor}
|
||||
borderWidth={1}
|
||||
borderColor={borderColor}
|
||||
cursor="pointer"
|
||||
_hover={{ shadow: 'md', transform: 'translateY(-2px)' }}
|
||||
transition="all 0.2s"
|
||||
onClick={() => handleFacilitySelect(facility)}
|
||||
>
|
||||
<CardBody>
|
||||
<VStack align="start" spacing={3}>
|
||||
<HStack>
|
||||
{getTypeIcon(facility.type)}
|
||||
<Heading size="md">{facility.name}</Heading>
|
||||
</HStack>
|
||||
|
||||
<Text color="gray.600" fontSize="sm">
|
||||
{facility.description}
|
||||
</Text>
|
||||
|
||||
<HStack>
|
||||
<Badge colorScheme="blue">{getTypeText(facility.type)}</Badge>
|
||||
<Badge colorScheme="green">
|
||||
{formatPrice(facility.price_per_hour)}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
<VStack align="start" spacing={1} fontSize="sm">
|
||||
{facility.capacity > 0 && (
|
||||
<HStack>
|
||||
<FiUsers />
|
||||
<Text>Kapacita: {facility.capacity} osob</Text>
|
||||
</HStack>
|
||||
)}
|
||||
{facility.location && (
|
||||
<HStack>
|
||||
<FiMapPin />
|
||||
<Text>{facility.location}</Text>
|
||||
</HStack>
|
||||
)}
|
||||
<HStack>
|
||||
<FiClock />
|
||||
<Text>
|
||||
{facility.min_booking_duration}-{facility.max_booking_duration} minut
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{facility.requires_approval && (
|
||||
<Alert status="info" fontSize="sm">
|
||||
<AlertIcon />
|
||||
Rezervace vyžaduje schválení
|
||||
</Alert>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
) : (
|
||||
// Booking calendar
|
||||
<VStack spacing={6}>
|
||||
<Flex justify="space-between" align="center" w="full">
|
||||
<HStack>
|
||||
<IconButton
|
||||
aria-label="Zpět"
|
||||
icon={<FiX />}
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSelectedFacility(null);
|
||||
setAvailability({});
|
||||
setSelectedDate('');
|
||||
}}
|
||||
/>
|
||||
<Heading size="md">{selectedFacility.name}</Heading>
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
leftIcon={<FiRefreshCw />}
|
||||
variant="outline"
|
||||
onClick={fetchAvailability}
|
||||
>
|
||||
Obnovit
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Card bg={bgColor} borderWidth={1} borderColor={borderColor}>
|
||||
<CardBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Vyberte datum</FormLabel>
|
||||
<Input
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Divider />
|
||||
|
||||
<VStack spacing={4} w="full">
|
||||
{generateCalendarDays().map((date) => {
|
||||
const dayData = availability[date];
|
||||
const dateObj = new Date(date);
|
||||
const dayName = dateObj.toLocaleDateString('cs-CZ', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'numeric'
|
||||
});
|
||||
|
||||
return (
|
||||
<Box key={date} w="full">
|
||||
<Heading size="sm" mb={2}>{dayName}</Heading>
|
||||
|
||||
{dayData && dayData.slots && dayData.slots.length > 0 ? (
|
||||
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={2}>
|
||||
{dayData.slots.map((slot, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
size="sm"
|
||||
variant={slot.available ? 'outline' : 'ghost'}
|
||||
colorScheme={slot.available ? 'green' : 'gray'}
|
||||
isDisabled={!slot.available}
|
||||
onClick={() => slot.available && handleSlotSelect(date, slot)}
|
||||
w="full"
|
||||
>
|
||||
<VStack spacing={1}>
|
||||
<Text fontSize="xs" fontWeight="bold">
|
||||
{slot.start} - {slot.end}
|
||||
</Text>
|
||||
{slot.available ? (
|
||||
<Text fontSize="xs">Volno</Text>
|
||||
) : (
|
||||
<Text fontSize="xs">Obsazeno</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Button>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
Žádné dostupné termíny
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* Booking Modal */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="md">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Vytvořit rezervaci</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
<Box>
|
||||
<Text fontWeight="medium">
|
||||
{selectedFacility?.name}
|
||||
</Text>
|
||||
<Text fontSize="sm">
|
||||
{selectedDate} • {selectedSlot?.start} - {selectedSlot?.end}
|
||||
</Text>
|
||||
<Text fontSize="sm">
|
||||
Cena: {calculatePrice()} Kč
|
||||
</Text>
|
||||
</Box>
|
||||
</Alert>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Název rezervace</FormLabel>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="Např. Trénink A-týmu"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Popis</FormLabel>
|
||||
<Textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Další informace o rezervaci"
|
||||
rows={3}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Počet účastníků</FormLabel>
|
||||
<NumberInput
|
||||
value={formData.attendees_count}
|
||||
onChange={(value) => setFormData({ ...formData, attendees_count: parseInt(value) || 1 })}
|
||||
min={1}
|
||||
max={selectedFacility?.capacity || 1}
|
||||
>
|
||||
<NumberInputField />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
|
||||
{selectedFacility?.requires_approval && (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
<Text>
|
||||
Tato rezervace vyžaduje schválení administrátorem.
|
||||
Budete informováni o schválení nebo zamítnutí.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onClose}>
|
||||
Zrušit
|
||||
</Button>
|
||||
<Button colorScheme="blue" onClick={handleSubmit}>
|
||||
Vytvořit rezervaci
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FacilitiesBookingPage;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user