This commit is contained in:
Tomas Dvorak
2025-11-11 10:29:30 +01:00
parent d5b4faea61
commit 8762bde4bf
139 changed files with 7240 additions and 2870 deletions
+8 -4
View File
@@ -87,7 +87,6 @@ const AdminDashboardPage = lazy(() => import('./pages/admin/AdminDashboardPage')
const ArticlesAdminPage = lazy(() => import('./pages/admin/ArticlesAdminPage'));
const SponsorsAdminPage = lazy(() => import('./pages/admin/SponsorsAdminPage'));
const CategoriesAdminPage = lazy(() => import('./pages/admin/CategoriesAdminPage'));
const MediaAdminPage = lazy(() => import('./pages/admin/MediaAdminPage'));
const MatchesAdminPage = lazy(() => import('./pages/admin/MatchesAdminPage'));
const PlayersAdminPage = lazy(() => import('./pages/admin/PlayersAdminPage'));
const TeamsAdminPage = lazy(() => import('./pages/admin/TeamsAdminPage'));
@@ -118,6 +117,7 @@ const EngagementAdminPage = lazy(() => import('./pages/admin/EngagementAdminPage
const SweepstakesAdminPage = lazy(() => import('./pages/admin/SweepstakesAdminPage'));
const SweepstakeVisualPage = lazy(() => import('./pages/admin/SweepstakeVisualPage'));
const SemiAdminPage = lazy(() => import('./pages/SemiAdminPage'));
const ErrorsAdminPage = lazy(() => import('./pages/admin/ErrorsAdminPage'));
// Analytics and font loader
const AnalyticsInitializer: React.FC = () => {
@@ -257,19 +257,22 @@ const AppLazy: React.FC = () => {
<Route path="/403" element={<ForbiddenPage />} />
<Route path="/semiadmin" element={<ProtectedRoute><SemiAdminPage /></ProtectedRoute>} />
{/* Editor-level content admin routes (accessible to editors and admins) */}
<Route element={<ProtectedRoute requiredRole="editor"><AdminRoutesWrapper /></ProtectedRoute>}>
<Route path="/admin/clanky" element={<ArticlesAdminPage />} />
<Route path="/admin/aktivity" element={<AdminActivitiesPage />} />
</Route>
{/* Admin routes */}
<Route element={<ProtectedRoute requiredRole="admin"><AdminRoutesWrapper /></ProtectedRoute>}>
<Route path="/admin" element={<AdminDashboardPage />} />
<Route path="/admin/docs" element={<AdminDocsPage />} />
<Route path="/admin/clanky" element={<ArticlesAdminPage />} />
<Route path="/admin/o-klubu" element={<AboutAdminPage />} />
<Route path="/admin/videa" element={<AdminVideosPage />} />
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
<Route path="/admin/aktivity" element={<AdminActivitiesPage />} />
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
<Route path="/admin/kategorie" element={<CategoriesAdminPage />} />
<Route path="/admin/media" element={<MediaAdminPage />} />
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
<Route path="/admin/tymy" element={<TeamsAdminPage />} />
@@ -285,6 +288,7 @@ const AppLazy: React.FC = () => {
<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 />} />
<Route path="/admin/kontakty" element={<ContactsAdminPage />} />
<Route path="/admin/navigace" element={<NavigationAdminPage />} />
+10 -9
View File
@@ -33,7 +33,6 @@ import AdminDashboardPage from './pages/admin/AdminDashboardPage';
import ArticlesAdminPage from './pages/admin/ArticlesAdminPage';
import SponsorsAdminPage from './pages/admin/SponsorsAdminPage';
import CategoriesAdminPage from './pages/admin/CategoriesAdminPage';
import MediaAdminPage from './pages/admin/MediaAdminPage';
import MatchesAdminPage from './pages/admin/MatchesAdminPage';
import PlayersAdminPage from './pages/admin/PlayersAdminPage';
import TeamsAdminPage from './pages/admin/TeamsAdminPage';
@@ -91,6 +90,15 @@ import { useUmami } from './hooks/useUmami';
import { checkin } from './services/engagement';
import { useFontLoader } from './hooks/useFontLoader';
import { usePublicSettings } from './hooks/usePublicSettings';
import { logAction } from './services/actionLog';
const RouteLogger: React.FC = () => {
const loc = useLocation();
useEffect(() => {
logAction({ type: 'nav', at: Date.now(), path: loc.pathname + loc.search });
}, [loc.pathname, loc.search]);
return null;
};
// Create a client with better cache configuration
const queryClient = new QueryClient({
@@ -380,6 +388,7 @@ const App: React.FC = () => {
<ClubThemeProvider>
<AnalyticsInitializer />
<FontLoader />
<RouteLogger />
<CheckinInitializer />
<DefaultSEO />
<Routes>
@@ -566,14 +575,6 @@ const App: React.FC = () => {
</ProtectedRoute>
}
/>
<Route
path="/admin/media"
element={
<ProtectedRoute requiredRole="editor">
<MediaAdminPage />
</ProtectedRoute>
}
/>
{/* Not found route */}
<Route path="*" element={<NotFoundRoute />} />
+94 -48
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useMemo } from 'react';
import React, { useEffect, useState, useMemo, useRef } from 'react';
import {
Box,
Flex,
@@ -128,6 +128,9 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
return (
<React.Fragment key={item.id || idx}>
<Button
as={RouterLink}
to="/blog"
onClick={onClose}
variant="ghost"
justifyContent="flex-start"
fontWeight="bold"
@@ -141,7 +144,7 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
const catLinkProps = catIsExternal ? { href: catHref } : { to: catHref };
const CatComp: any = catIsExternal ? 'a' : RouterLink;
return (
<Button key={cat.slug || cat.id || cat.name} as={CatComp} {...(catLinkProps as any)} variant="ghost" justifyContent="flex-start" fontWeight="normal" size="sm">
<Button key={cat.slug || cat.id || cat.name} as={CatComp} {...(catLinkProps as any)} onClick={onClose} variant="ghost" justifyContent="flex-start" fontWeight="normal" size="sm">
{cat.name}
</Button>
);
@@ -230,14 +233,14 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
<>
{Array.isArray(categories) && categories.length > 0 ? (
<>
<Button variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
<Button as={RouterLink} to="/blog" onClick={onClose} variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
<VStack align="stretch" pl={4} spacing={1}>
{categories.map((cat: any) => {
const catIsExternal = typeof cat.url === 'string' && /^https?:\/\//i.test(cat.url);
const catHref = cat.url || (cat.id ? `/blog?category_id=${cat.id}` : (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog'));
const catLinkProps = catIsExternal ? { href: catHref } : { to: catHref };
return (
<Button key={cat.slug || cat.id || cat.name} as={catIsExternal ? 'a' : RouterLink} {...(catLinkProps as any)} variant="ghost" justifyContent="flex-start" fontWeight="normal" size="sm">
<Button key={cat.slug || cat.id || cat.name} as={catIsExternal ? 'a' : RouterLink} {...(catLinkProps as any)} onClick={onClose} variant="ghost" justifyContent="flex-start" fontWeight="normal" size="sm">
{cat.name}
</Button>
);
@@ -245,7 +248,7 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
</VStack>
</>
) : (
<Button as={RouterLink} to="/blog" variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
<Button as={RouterLink} to="/blog" onClick={onClose} variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
)}
</>
)}
@@ -864,7 +867,7 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
// Handle items with dropdown (like Články with categories)
if (nav.items && nav.items.length > 0) {
return (
<HoverMenu key={nav.label} label={nav.label} items={nav.items} isActive={isPathActive(nav.to)} />
<HoverMenu key={nav.label} label={nav.label} to={nav.to || '/blog'} items={nav.items} isActive={isPathActive(nav.to)} />
);
}
@@ -985,8 +988,12 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
<Text fontSize="xs" color="gray.500">Progres</Text>
<Progress value={levelProgress.pct} size="xs" colorScheme="blue" borderRadius="full" mt={1} />
</Box>
<MenuItem onClick={onAchOpen}>Úspěchy</MenuItem>
<MenuItem onClick={onRewOpen}>Odměny</MenuItem>
{!isAdmin && (
<>
<MenuItem onClick={onAchOpen}>Úspěchy</MenuItem>
<MenuItem onClick={onRewOpen}>Odměny</MenuItem>
</>
)}
<MenuItem as={RouterLink} to={accountPath}>Můj účet</MenuItem>
<MenuItem onClick={openMyNewsletterPrefs}>Emailové preference</MenuItem>
{isAdmin && <MenuItem as={RouterLink} to="/admin/nastaveni">Nastavení stránky</MenuItem>}
@@ -1036,13 +1043,17 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
</ModalBody>
</ModalContent>
</Modal>
<AchievementsModal isOpen={isAchOpen} onClose={onAchClose} onOpenRewards={onRewOpen} />
<RewardsModal
isOpen={isRewOpen}
onClose={onRewClose}
availablePoints={engProfile?.points || 0}
onRedeemed={async () => { try { const p = await getEngagementProfile(); setEngProfile(p); } catch {} }}
/>
{!isAdmin && (
<>
<AchievementsModal isOpen={isAchOpen} onClose={onAchClose} onOpenRewards={onRewOpen} />
<RewardsModal
isOpen={isRewOpen}
onClose={onRewClose}
availablePoints={engProfile?.points || 0}
onRedeemed={async () => { try { const p = await getEngagementProfile(); setEngProfile(p); } catch {} }}
/>
</>
)}
</Box>
</Box>
@@ -1050,48 +1061,83 @@ const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth
};
// HoverMenu component for desktop dropdown nav
const HoverMenu = ({ label, items, isActive }: { label: string; items: { label: string; to: string }[]; isActive?: boolean }) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const HoverMenu = ({ label, items, to, isActive }: { label: string; items: { label: string; to: string }[]; to?: string; isActive?: boolean }) => {
const menuColorActive = useColorModeValue('brand.primary', 'brand.accent');
const menuColorInactive = useColorModeValue('gray.700', 'gray.200');
const menuBgActive = useColorModeValue('blackAlpha.50', 'whiteAlpha.100');
const menuHoverBg = useColorModeValue('blackAlpha.100', 'whiteAlpha.200');
const navigate = useNavigate();
const [open, setOpen] = React.useState(false);
const openTimer = useRef<number | null>(null);
const closeTimer = useRef<number | null>(null);
const clearTimers = () => {
if (openTimer.current) { window.clearTimeout(openTimer.current); openTimer.current = null; }
if (closeTimer.current) { window.clearTimeout(closeTimer.current); closeTimer.current = null; }
};
const handleOpen = () => {
if (closeTimer.current) { window.clearTimeout(closeTimer.current); closeTimer.current = null; }
if (!open) {
openTimer.current = window.setTimeout(() => setOpen(true), 60);
}
};
const handleClose = () => {
if (openTimer.current) { window.clearTimeout(openTimer.current); openTimer.current = null; }
closeTimer.current = window.setTimeout(() => setOpen(false), 120);
};
useEffect(() => () => clearTimers(), []);
const hasParentLink = typeof to === 'string' && to.trim().length > 0;
const isExternalParent = hasParentLink && /^https?:\/\//i.test(to!);
const onParentClick = (e: React.MouseEvent) => {
if (!hasParentLink) return;
if (isExternalParent) {
window.open(to!, '_blank', 'noopener,noreferrer');
} else {
navigate(to!);
}
};
return (
<Box onMouseEnter={onOpen} onMouseLeave={onClose}>
<Menu isOpen={isOpen} placement="bottom-start" gutter={4}>
<MenuButton
as={Button}
rightIcon={<ChevronDownIcon />}
variant="ghost"
size="sm"
px={3}
fontWeight={isActive ? '700' : '600'}
color={isActive ? menuColorActive : menuColorInactive}
bg={isActive ? menuBgActive : 'transparent'}
_hover={{ bg: menuHoverBg, transform: 'translateY(-1px)' }}
transition="all 0.2s"
>
{label}
</MenuButton>
<MenuList>
{items.map((it) => {
const isExternal = /^https?:\/\//i.test(it.to);
if (isExternal) {
return (
<MenuItem as="a" href={it.to} key={it.to} target="_blank" rel="noreferrer">
{it.label}
</MenuItem>
);
}
<Menu isOpen={open} placement="bottom-start" gutter={0} closeOnBlur={false} isLazy>
<MenuButton
as={Button}
rightIcon={<ChevronDownIcon />}
variant="ghost"
size="sm"
px={3}
fontWeight={isActive ? '700' : '600'}
color={isActive ? menuColorActive : menuColorInactive}
bg={isActive ? menuBgActive : 'transparent'}
_hover={{ bg: menuHoverBg, transform: 'translateY(-1px)' }}
transition="all 0.2s"
onMouseEnter={handleOpen}
onMouseLeave={handleClose}
onClick={onParentClick}
>
{label}
</MenuButton>
<MenuList onMouseEnter={handleOpen} onMouseLeave={handleClose}>
{items.map((it) => {
const isExternal = /^https?:\/\//i.test(it.to);
if (isExternal) {
return (
<MenuItem as={RouterLink} to={it.to} key={it.to}>
<MenuItem as="a" href={it.to} key={it.to} target="_blank" rel="noreferrer">
{it.label}
</MenuItem>
);
})}
</MenuList>
</Menu>
</Box>
}
return (
<MenuItem as={RouterLink} to={it.to} key={it.to}>
{it.label}
</MenuItem>
);
})}
</MenuList>
</Menu>
);
};
@@ -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 } 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 } from 'react-icons/fa';
export type AdminSearchItem = {
label: string;
@@ -34,7 +34,7 @@ const adminIndex: AdminSearchItem[] = [
{ label: 'Hráči', path: '/admin/hraci', section: 'Kádry', keywords: ['players'], icon: FaUsers },
{ label: 'Týmy', path: '/admin/tymy', section: 'Kádry', keywords: ['teams'], icon: FaUsers },
{ label: 'Zápasy', path: '/admin/zapasy', section: 'FAČR', keywords: ['matches', 'facr'], icon: FaCalendarAlt },
{ label: 'Média', path: '/admin/media', section: 'Obsah', keywords: ['uploads', 'images'], icon: FaImage },
{ label: '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: 'Kategorie', path: '/admin/kategorie', section: 'Obsah', keywords: ['categories'], icon: FaAward },
+2 -11
View File
@@ -202,7 +202,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 hasMedia = useMemo(() => hasItemDeep(it => (it.page_type === 'media') || (it.url === '/admin/media')), [hasItemDeep]);
// Collapsed state for admin categories (dropdown items)
type CollapsedMap = Record<number, boolean>;
@@ -522,16 +522,7 @@ const AdminSidebar = ({
Alias soutěží
</NavItem>
)}
{/* Ensure Media Library is present even if not configured in dynamic nav */}
{!hasMedia && (
<NavItem
icon={FaFolder}
to="/admin/media"
onClick={onClose}
>
Média
</NavItem>
)}
{/* Ensure Clothing is present even if not configured in dynamic nav */}
{!hasClothing && (
<NavItem
@@ -0,0 +1,145 @@
import { useEffect, useMemo, useState } from 'react';
import {
IconButton,
Tooltip,
useDisclosure,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
Button,
Textarea,
Text,
Box,
VStack,
HStack,
Badge,
useColorModeValue,
useToast,
Avatar,
} from '@chakra-ui/react';
import { FaLifeRing } from 'react-icons/fa';
import { useLocation } from 'react-router-dom';
import { getRecentActions } from '../../services/actionLog';
import { reportError } from '../../services/errorReporter';
import { useAuth } from '../../contexts/AuthContext';
import { getAdminSettings } from '../../services/settings';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { assetUrl } from '../../utils/url';
export default function AdminSupportButton() {
const { isOpen, onOpen, onClose } = useDisclosure();
const [message, setMessage] = useState('');
const [submitting, setSubmitting] = useState(false);
const location = useLocation();
const toast = useToast();
const { user } = useAuth();
const [extUI, setExtUI] = useState<string>('');
const [extToken, setExtToken] = useState<string>('');
const { data: publicSettings } = usePublicSettings();
const actions = useMemo(() => getRecentActions(12), [isOpen]);
const path = location.pathname + location.search;
const bg = useColorModeValue('gray.50', 'gray.800');
const border = useColorModeValue('gray.200', 'gray.600');
const timeline = useMemo(() => {
const now = Date.now();
return actions.map((a) => {
const ago = Math.max(0, Math.round((now - a.at) / 1000));
if (a.type === 'nav') return `-${ago}s NAV ${a.path}`;
return `-${ago}s ${a.method.toUpperCase()} ${a.url} ${a.status ?? ''} ${a.ms ? a.ms + 'ms' : ''}`.trim();
});
}, [actions]);
// Load external error-review UI/token when modal opens
useEffect(() => {
if (!isOpen) return;
(async () => {
try {
const s = await getAdminSettings();
setExtUI(String((s as any).error_review_ui_url || ''));
setExtToken(String((s as any).error_review_admin_token || ''));
} catch {}
})();
}, [isOpen]);
const submit = async () => {
if (submitting) return;
setSubmitting(true);
try {
await reportError({
severity: 'warn',
message: message?.trim() ? `Support: ${message.trim()}` : 'Support: bez popisu',
tags: { type: 'support', source: 'admin' },
context: { path, recentActions: actions },
user_id: (user as any)?.id,
url: path,
});
toast({ title: 'Odesláno', description: 'Děkujeme, ozveme se co nejdříve.', status: 'success', duration: 3000 });
setMessage('');
onClose();
} catch {
toast({ title: 'Chyba', description: 'Nepodařilo se odeslat.', status: 'error' });
} finally {
setSubmitting(false);
}
};
return (
<>
<Box position="fixed" right={4} bottom={4} zIndex={40}>
<Tooltip label="Zákaznická podpora" hasArrow>
<IconButton
aria-label="Zákaznická podpora"
icon={(publicSettings?.club_logo_url ? <Avatar size="sm" src={assetUrl(publicSettings.club_logo_url) || publicSettings.club_logo_url} /> : <FaLifeRing />)}
onClick={onOpen}
colorScheme="blue"
borderRadius="full"
boxShadow="lg"
/>
</Tooltip>
</Box>
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalOverlay />
<ModalContent>
<ModalHeader>Nahlásit problém</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={4}>
<HStack spacing={3}>
<Badge colorScheme="blue">Admin</Badge>
<Text fontSize="sm" color="gray.500">{path}</Text>
</HStack>
<Box bg={bg} borderWidth="1px" borderColor={border} borderRadius="md" p={3} maxH="160px" overflowY="auto">
<Text fontSize="xs" color="gray.500" mb={2}>Poslední akce</Text>
<VStack align="stretch" spacing={1}>
{timeline.map((t, i) => (
<Text key={i} fontFamily="mono" fontSize="xs">{t}</Text>
))}
</VStack>
</Box>
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Popište, co se stalo…"
rows={5}
/>
</VStack>
</ModalBody>
<ModalFooter>
<VStack align="stretch" spacing={2} flex={1} mr={3}>
<HStack>
<Button size="sm" variant="outline" onClick={() => window.open('/admin/docs', '_blank')}>Nápověda</Button>
</HStack>
</VStack>
<Button colorScheme="blue" onClick={submit} isLoading={submitting}>Odeslat</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
}
@@ -22,6 +22,7 @@ interface Props {
variant?: 'button' | 'icon';
onGenerated?: (text: string, shortUrl: string) => void;
align?: 'left' | 'right';
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
}
const InstagramGeneratorButton: React.FC<Props> = ({
@@ -37,6 +38,7 @@ const InstagramGeneratorButton: React.FC<Props> = ({
variant = 'icon',
onGenerated,
align = 'right',
position = 'top-right',
}) => {
const { user } = useAuth();
const role = String(user?.role || '').toLowerCase();
@@ -297,7 +299,14 @@ const InstagramGeneratorButton: React.FC<Props> = ({
{isAdmin ? AdminButtonEl : VisitorShareEl}
</Box>
) : (
<Box position="absolute" top={2} zIndex={zIndex} {...(align === 'left' ? { left: 2 } : { right: 2 })}>
<Box
position="absolute"
zIndex={zIndex}
{...(position === 'top-left' ? { top: 2, left: 2 } : {})}
{...(position === 'top-right' ? { top: 2, right: 2 } : {})}
{...(position === 'bottom-left' ? { bottom: 2, left: 2 } : {})}
{...(position === 'bottom-right' ? { bottom: 2, right: 2 } : {})}
>
{isAdmin ? AdminButtonEl : VisitorShareEl}
</Box>
)}
@@ -3,7 +3,7 @@ import { Box, VStack, HStack, Text, Heading, Textarea, Button, Avatar, IconButto
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { listComments, createComment, updateComment, deleteComment, CommentItem, reactComment, unreactComment, requestUnban, reportComment } from '../../services/comments';
import { useAuth } from '../../contexts/AuthContext';
import { Pencil, Trash2, Send } from 'lucide-react';
import { Pencil, Trash2, Send, CheckCircle2 } from 'lucide-react';
import { Link as RouterLink } from 'react-router-dom';
type Props = {
@@ -26,6 +26,7 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
const border = useColorModeValue('gray.200', 'gray.700');
const muted = useColorModeValue('gray.600', 'gray.400');
const appealBg = useColorModeValue('gray.50','gray.700');
const adminLikedColor = useColorModeValue('blue.600','blue.300');
const queryClient = useQueryClient();
const { isAuthenticated, user } = useAuth();
@@ -169,6 +170,7 @@ 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>}
<Text fontSize="sm" color={muted}>{new Date(c.created_at).toLocaleString()}</Text>
{c.is_edited && <Text fontSize="xs" color={muted}>(upraveno)</Text>}
</HStack>
@@ -191,6 +193,12 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
<Text whiteSpace="pre-wrap">{c.content}</Text>
)}
<ReactionBar c={c} />
{c.admin_liked && (
<HStack spacing={2} mt={1} color={adminLikedColor}>
<CheckCircle2 size={16} />
<Text fontSize="sm">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>}
@@ -136,14 +136,15 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const [imageWidth, setImageWidth] = useState<number>(0);
const [manualWidth, setManualWidth] = useState<string>('');
const [widthPercent, setWidthPercent] = useState<number>(0);
const [isListStyleOpen, setIsListStyleOpen] = useState(false);
// Define toolbar configurations
const toolbarConfigs = {
full: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ color: [] }, { background: [] }],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ color: [] }, { background: [] }, 'colorreset', 'bgreset'],
[{ list: 'ordered' }, { list: 'bullet' }, 'liststyle'],
[{ align: [] }],
['link', 'image'],
['blockquote'],
@@ -152,7 +153,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
basic: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline'],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ color: [] }, { background: [] }, 'colorreset', 'bgreset'],
[{ list: 'ordered' }, { list: 'bullet' }, 'liststyle'],
[{ align: [] }],
['link', 'image'],
['clean'],
@@ -234,18 +236,101 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setIsLinkOpen(true);
}, []);
// Apply bullet style (disc | circle | square) to the current list
const applyBulletStyle = useCallback((style: 'disc' | 'circle' | 'square') => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const range = quill.getSelection();
if (!range) return;
const [line] = quill.getLine(range.index);
const node = (line as any)?.domNode as HTMLElement | null;
if (!node) return;
// find nearest UL
let el: HTMLElement | null = node;
while (el && el.tagName !== 'UL' && el !== quill.root) {
el = el.parentElement;
}
if (el && el.tagName === 'UL') {
(el as HTMLElement).style.listStyleType = style;
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
}
}, [onChangeRef]);
// Toggle bullet style through toolbar handler
const toggleListStyle = useCallback(() => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const range = quill.getSelection();
if (!range) return;
const [line] = quill.getLine(range.index);
let el: HTMLElement | null = (line as any)?.domNode as HTMLElement | null;
while (el && el.tagName !== 'UL' && el !== quill.root) {
el = el.parentElement;
}
if (el && el.tagName === 'UL') {
const current = (el.style.listStyleType || '').toLowerCase();
const next: 'disc' | 'circle' | 'square' = current === 'disc' ? 'circle' : current === 'circle' ? 'square' : 'disc';
applyBulletStyle(next);
} else {
quill.format('list', 'bullet');
setTimeout(() => {
try {
const [ln] = quill.getLine(range.index);
let n: HTMLElement | null = (ln as any)?.domNode as HTMLElement | null;
while (n && n.tagName !== 'UL' && n !== quill.root) n = n.parentElement;
if (n && n.tagName === 'UL') {
(n as HTMLElement).style.listStyleType = 'disc';
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
}
} catch {}
}, 0);
}
}, [applyBulletStyle]);
const quillModules = useMemo(() => ({
toolbar: {
container: toolbarConfig,
handlers: {
image: onImageUpload ? handleImageUpload : undefined,
link: handleLinkToolbar,
liststyle: toggleListStyle,
list: (value: any) => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
quill.format('list', value);
if (value === 'bullet') {
setTimeout(() => setIsListStyleOpen(true), 0);
}
},
colorreset: () => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
quill.format('color', false);
},
bgreset: () => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
quill.format('background', false);
},
},
},
clipboard: {
matchVisual: false,
},
}), [toolbarConfig, onImageUpload, handleImageUpload, handleLinkToolbar]);
}), [toolbarConfig, onImageUpload, handleImageUpload, handleLinkToolbar, toggleListStyle]);
useEffect(() => {
if (!isMounted) return;
try {
const ed = quillRef.current?.getEditor();
if (!ed) return;
const toolbarEl = ed.root.parentElement?.previousElementSibling as HTMLElement | null;
const btn = toolbarEl?.querySelector('.ql-liststyle') as HTMLButtonElement | null;
if (btn) {
btn.setAttribute('title', 'Styl odrážek');
}
} catch {}
}, [isMounted, toolbarConfig]);
const quillFormats = useMemo(
() => [
@@ -306,14 +391,19 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Colors and background
setTitle('.ql-color .ql-picker-label', 'Barva textu');
setTitle('.ql-background .ql-picker-label', 'Barva pozadí');
setTitle('button.ql-colorreset', 'Zrušit barvu');
setTitle('button.ql-bgreset', 'Zrušit pozadí');
// Headers
setTitle('.ql-header .ql-picker-label', 'Nadpis');
setTitle('.ql-header .ql-picker-item[data-value="1"]', 'Nadpis 1');
setTitle('.ql-header .ql-picker-item[data-value="2"]', 'Nadpis 2');
setTitle('.ql-header .ql-picker-item[data-value="3"]', 'Nadpis 3');
setTitle('button.ql-liststyle', 'Styl odrážek');
}, [isMounted, toolbar]);
// (Removed) Previously injected custom bullet-style group; now using a single toolbar button 'liststyle'.
// Get cropped blob
const getCroppedBlob = (image: HTMLImageElement, cropPixels: { x: number; y: number; width: number; height: number }): Promise<Blob> => {
const canvas = document.createElement('canvas');
@@ -509,7 +599,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
`;
// Position relative to Quill container (parent of .ql-editor)
const editorContainer = editor.root.parentElement as HTMLElement | null;
const editorContainer = editor.root?.parentElement as HTMLElement | null;
if (!editorContainer) return null;
const sizeLabel = document.createElement('div');
sizeLabel.style.cssText = `
@@ -642,6 +732,27 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
startWidth = img.offsetWidth;
const startHeight = img.offsetHeight;
const aspectRatio = startWidth / startHeight;
let lastWidth = startWidth;
// Reduce selection/paint costs during resize
try { (editor.root as HTMLElement).style.userSelect = 'none'; } catch {}
let frame = 0;
let pendingWidth: number | null = null;
const flush = () => {
frame = 0;
if (pendingWidth == null) return;
const newWidth = pendingWidth;
pendingWidth = null;
img.style.width = `${newWidth}px`;
img.style.maxWidth = '100%';
img.style.height = 'auto';
try { img.setAttribute('width', String(Math.round(newWidth))); } catch {}
updateHandlePositions();
updateSizeLabel(newWidth);
};
const schedule = () => {
if (frame) return;
frame = requestAnimationFrame(flush);
};
const onPointerMove = (ev: PointerEvent) => {
if (!isResizing) return;
const deltaX = ev.clientX - startX;
@@ -655,23 +766,23 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
newWidth = startWidth + (deltaY * aspectRatio);
}
newWidth = Math.max(50, Math.min(newWidth, editor.root.clientWidth - 40));
img.style.width = `${newWidth}px`;
img.style.maxWidth = '100%';
img.style.height = 'auto';
try { img.setAttribute('width', String(Math.round(newWidth))); } catch {}
setImageWidth(newWidth);
setManualWidth(newWidth.toString());
try {
const editorWidth = editor.root.clientWidth || newWidth || 1;
setWidthPercent(Math.max(1, Math.min(100, Math.round((newWidth / editorWidth) * 100))));
} catch {}
updateHandlePositions();
updateSizeLabel(newWidth);
lastWidth = newWidth;
pendingWidth = newWidth;
schedule();
};
const onPointerUp = () => {
isResizing = false;
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
if (frame) cancelAnimationFrame(frame);
if (pendingWidth != null) flush();
try { (editor.root as HTMLElement).style.userSelect = ''; } catch {}
setImageWidth(lastWidth);
setManualWidth(String(Math.round(lastWidth)));
try {
const editorWidth = editor.root.clientWidth || lastWidth || 1;
setWidthPercent(Math.max(1, Math.min(100, Math.round((lastWidth / editorWidth) * 100))));
} catch {}
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
const id = selectedImageIdRef.current;
setTimeout(() => { if (id) { try { selectImageByIdRef.current?.(id); } catch {} } }, 30);
@@ -859,7 +970,22 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const startHeight = (target as HTMLImageElement).offsetHeight;
const aspectRatio = startWidth / Math.max(1, startHeight);
const edge = nearRight ? 'right' : nearLeft ? 'left' : nearBottom ? 'bottom' : 'top';
let lastWidth = startWidth;
try { (editor.root as HTMLElement).style.userSelect = 'none'; } catch {}
let raf = 0;
let queued: number | null = null;
const flush = () => {
raf = 0;
if (queued == null) return;
const newWidth = queued; queued = null;
const imgEl = target as HTMLImageElement;
imgEl.style.width = `${newWidth}px`;
imgEl.style.maxWidth = '100%';
imgEl.style.height = 'auto';
try { imgEl.setAttribute('width', String(Math.round(newWidth))); } catch {}
handleScroll();
};
const schedule = () => { if (!raf) raf = requestAnimationFrame(flush); };
const onMouseMove: (ev: MouseEvent) => void = (ev: MouseEvent) => {
if (!isResizing) return;
const deltaX = ev.clientX - startX;
@@ -871,32 +997,30 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
else if (edge === 'top') newWidth = startWidth - (deltaY * aspectRatio);
const maxW = editor.root.clientWidth - 40;
newWidth = Math.max(50, Math.min(newWidth, maxW));
const imgEl = target as HTMLImageElement;
imgEl.style.width = `${newWidth}px`;
imgEl.style.maxWidth = '100%';
imgEl.style.height = 'auto';
try { imgEl.setAttribute('width', String(Math.round(newWidth))); } catch {}
setImageWidth(newWidth);
setManualWidth(String(Math.round(newWidth)));
try {
const editorWidth = editor.root.clientWidth || newWidth || 1;
setWidthPercent(Math.max(1, Math.min(100, Math.round((newWidth / editorWidth) * 100))));
} catch {}
handleScroll();
lastWidth = newWidth;
queued = newWidth;
schedule();
};
const onMouseUp = () => {
isResizing = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
if (raf) cancelAnimationFrame(raf);
if (queued != null) flush();
try { (editor.root as HTMLElement).style.userSelect = ''; } catch {}
setImageWidth(lastWidth);
setManualWidth(String(Math.round(lastWidth)));
try {
const editorWidth = editor.root.clientWidth || lastWidth || 1;
setWidthPercent(Math.max(1, Math.min(100, Math.round((lastWidth / editorWidth) * 100))));
} catch {}
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
return;
}
e.preventDefault();
e.stopPropagation();
isDragging = true;
@@ -1275,53 +1399,11 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
}
}, [selectedImageElement, toast]);
// Sanitize HTML on change and fix white text colors
// Defer heavy sanitization to submit time to prevent selection glitches; keep minimal cleanup only
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'],
});
// Replace white and very light colors with dark colors for visibility
const whiteColorPatterns = [
/color:\s*rgb\(255,\s*255,\s*255\)/gi,
/color:\s*rgb\(255\s*,\s*255\s*,\s*255\)/gi,
/color:\s*white/gi,
/color:\s*#fff(?:fff)?(?=[;\s"'])/gi,
/color:\s*rgba?\(255,\s*255,\s*255/gi,
// Very light grays that are barely visible on white
/color:\s*rgb\(25[0-4],\s*25[0-4],\s*25[0-4]\)/gi,
/color:\s*rgb\(24[5-9],\s*24[5-9],\s*24[5-9]\)/gi,
];
whiteColorPatterns.forEach(pattern => {
cleaned = cleaned.replace(pattern, 'color: #1a202c');
});
onChangeRef.current(cleanEditorHTML(cleaned));
onChangeRef.current(cleanEditorHTML(content));
};
// Apply bullet style (disc | circle | square) to the current list
const applyBulletStyle = useCallback((style: 'disc' | 'circle' | 'square') => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const range = quill.getSelection();
if (!range) return;
const [line] = quill.getLine(range.index);
const node = (line as any)?.domNode as HTMLElement | null;
if (!node) return;
// find nearest UL
let el: HTMLElement | null = node;
while (el && el.tagName !== 'UL' && el !== quill.root) {
el = el.parentElement;
}
if (el && el.tagName === 'UL') {
(el as HTMLElement).style.listStyleType = style;
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
}
}, [onChangeRef]);
const insertOrUpdateLink = useCallback(() => {
const quill = quillRef.current?.getEditor();
@@ -1369,13 +1451,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
</Text>
</HStack>
)}
{/* Bullet style controls */}
<HStack spacing={2} justify="flex-start" flexWrap="wrap" pt={1}>
<Text fontSize="xs" color="gray.600">Styl odrážek:</Text>
<Button size="xs" variant="outline" onClick={() => applyBulletStyle('disc')}> plné</Button>
<Button size="xs" variant="outline" onClick={() => applyBulletStyle('circle')}> kruh</Button>
<Button size="xs" variant="outline" onClick={() => applyBulletStyle('square')}> čtverec</Button>
</HStack>
<Box display="none" />
</VStack>
)}
@@ -1439,6 +1515,11 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
padding: '8px',
},
'& .ql-liststyle::before': {
content: '"•◦▪"',
fontSize: '14px',
fontWeight: 'bold',
},
},
'.ql-container': {
fontSize: '16px',
@@ -1522,7 +1603,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
height: 'auto',
display: 'block',
margin: '12px 0',
transition: 'all 0.2s ease',
transition: 'box-shadow 0.15s ease, opacity 0.15s ease, transform 0.15s ease',
borderRadius: '4px',
userSelect: 'none',
pointerEvents: 'auto',
@@ -1946,11 +2027,47 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={() => setIsLinkOpen(false)}>Zrušit</Button>
<Button
variant="outline"
colorScheme="red"
mr={3}
onClick={() => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const r = quill.getSelection() || linkRangeRef.current || { index: quill.getLength(), length: 0 };
quill.format('link', false);
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
setIsLinkOpen(false);
setLinkText('');
setLinkUrl('');
}}
>
Odstranit odkaz
</Button>
<Button colorScheme="blue" onClick={insertOrUpdateLink}>Vložit</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Bullet Style Modal */}
<Modal isOpen={isListStyleOpen} onClose={() => setIsListStyleOpen(false)} isCentered>
<ModalOverlay />
<ModalContent>
<ModalHeader>Styl odrážek</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={2}>
<Button onClick={() => { applyBulletStyle('disc'); setIsListStyleOpen(false); }}> Plné tečky</Button>
<Button onClick={() => { applyBulletStyle('circle'); setIsListStyleOpen(false); }}> Kroužky</Button>
<Button onClick={() => { applyBulletStyle('square'); setIsListStyleOpen(false); }}> Čtverečky</Button>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={() => setIsListStyleOpen(false)}>Zavřít</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Crop Modal */}
{/* Image Preview Modal */}
<Modal isOpen={isPreviewOpen} onClose={() => setIsPreviewOpen(false)} size="6xl" isCentered>
+16 -7
View File
@@ -5,6 +5,7 @@ import { getLogoStyle, getLogoClassName } from '../../utils/logoUtils';
import '../../styles/logos.css';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { assetUrl } from '../../utils/url';
import { useIntersectionObserver } from '../../hooks/useIntersectionObserver';
// Lightweight cached overrides loader
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> }> => {
@@ -99,8 +100,10 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const { data: publicSettings } = usePublicSettings();
const [observeRef, inView] = useIntersectionObserver({ threshold: 0.01, rootMargin: '150px 0px', freezeOnceVisible: true });
useEffect(() => {
if (!inView) return; // defer fetching until visible
let mounted = true;
const fetchLogo = async () => {
@@ -191,7 +194,7 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
return () => {
mounted = false;
};
}, [teamId, teamName, facrLogo, publicSettings?.club_id, publicSettings?.club_logo_url]);
}, [teamId, teamName, facrLogo, publicSettings?.club_id, publicSettings?.club_logo_url, inView]);
// Size mapping
const sizeMap = {
@@ -208,11 +211,13 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
if (loading) {
return (
<Skeleton
{...sizeProps}
borderRadius="4px"
className="logo-loading"
/>
<div ref={observeRef as any} style={{ display: 'inline-block' }}>
<Skeleton
{...sizeProps}
borderRadius="4px"
className="logo-loading"
/>
</div>
);
}
@@ -226,9 +231,12 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
const logoClassName = getLogoClassName(logoUrl, isCircular, utilSize);
return (
<Image
<div ref={observeRef as any} style={{ display: 'inline-block' }}>
<Image
src={(assetUrl(logoUrl || undefined) || logoUrl || '/logo192.png')}
alt={alt || teamName || 'Team logo'}
decoding="async"
draggable={false}
{...sizeProps}
{...imageProps}
className={`${className} ${logoClassName}`}
@@ -246,6 +254,7 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
}
}}
/>
</div>
);
};
@@ -44,7 +44,7 @@ const ContextualAdminLinks: React.FC<ContextualAdminLinksProps> = ({ elementName
const links: Record<string, AdminLink[]> = {
hero: [
{ label: 'Manage Articles', url: '/admin/articles', icon: FiFileText, description: 'Edit featured articles' },
{ label: 'Upload Images', url: '/admin/media', icon: FiImage, description: 'Manage hero images' },
{ label: 'Upload Images', url: '/admin/soubory', icon: FiImage, description: 'Manage hero images' },
],
news: [
{ label: 'Manage Articles', url: '/admin/articles', icon: FiFileText, description: 'Create and edit news' },
+161 -39
View File
@@ -144,11 +144,12 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const [elementPosition, setElementPosition] = useState<ElementPosition | null>(null);
const [showElementPicker, setShowElementPicker] = useState(false);
const [showLayersPanel, setShowLayersPanel] = useState(false);
const [showElementsPanel, setShowElementsPanel] = useState(false);
const [visibleElements, setVisibleElements] = useState<Set<string>>(new Set());
const [elementOrder, setElementOrder] = useState<string[]>([]);
const [draggedElement, setDraggedElement] = useState<string | null>(null);
const [dragOverElement, setDragOverElement] = useState<string | null>(null);
const [viewport] = useState<'desktop'>('desktop');
const [viewport, setViewport] = useState<'desktop' | 'tablet' | 'mobile'>('desktop');
const [elementStyles, setElementStyles] = useState<Record<string, any>>({});
const [showStylePanel, setShowStylePanel] = useState(false);
const [stylePanelRight, setStylePanelRight] = useState(false);
@@ -163,13 +164,15 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const [containerGridCols, setContainerGridCols] = useState<number>(0);
const elementOrderRef = useRef<string[]>([]);
useEffect(() => { elementOrderRef.current = elementOrder; }, [elementOrder]);
const applyVisualReorderRef = useRef<(order: string[]) => void>(() => {});
// Draggable panel states
const [panelPositions, setPanelPositions] = useState({
stylePicker: { x: 0, y: 0, width: 360, height: 550 },
layersPanel: { x: 0, y: 0, width: 320, height: 600 },
visualStylePanel: { x: 0, y: 60, width: 320, height: 700 },
elementPicker: { x: 0, y: 0, width: 600, height: 600 }
elementPicker: { x: 0, y: 0, width: 600, height: 600 },
elementsPanel: { x: 0, y: 72, width: 420, height: 700 }
});
const [draggingPanel, setDraggingPanel] = useState<string | null>(null);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
@@ -561,6 +564,51 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
setHasChanges(unsavedCount > 0);
}, [unsavedCount]);
const historyRef = useRef<Array<{ localChanges: Record<string, string>; visible: string[]; order: string[]; styles: Record<string, any> }>>([]);
const historyIndexRef = useRef<number>(-1);
const pushHistorySnapshot = useCallback(() => {
try {
const snap = {
localChanges: { ...localChanges },
visible: Array.from(visibleElements),
order: [...elementOrder],
styles: JSON.parse(JSON.stringify(elementStyles || {})),
};
const arr = historyRef.current.slice(0, historyIndexRef.current + 1);
arr.push(snap);
historyRef.current = arr;
historyIndexRef.current = arr.length - 1;
} catch {}
}, [localChanges, visibleElements, elementOrder, elementStyles]);
const canUndo = () => historyIndexRef.current > 0;
const canRedo = () => historyIndexRef.current >= 0 && historyIndexRef.current < historyRef.current.length - 1;
const applySnapshot = useCallback((idx: number) => {
const snap = historyRef.current[idx];
if (!snap) return;
setLocalChanges(snap.localChanges);
setVisibleElements(new Set(snap.visible));
setElementOrder(snap.order);
setElementStyles(snap.styles);
try {
if (isEditing) {
requestAnimationFrame(() => {
applyVisualReorderRef.current(snap.order);
window.dispatchEvent(new CustomEvent('myuibrix-reorder', { detail: { order: snap.order, previewMode: true } }));
});
}
} catch {}
}, [isEditing]);
const undo = useCallback(() => {
if (!canUndo()) return;
historyIndexRef.current -= 1;
applySnapshot(historyIndexRef.current);
}, [applySnapshot]);
const redo = useCallback(() => {
if (!canRedo()) return;
historyIndexRef.current += 1;
applySnapshot(historyIndexRef.current);
}, [applySnapshot]);
// Keyboard shortcuts
useEffect(() => {
if (!isEditing) return;
@@ -576,6 +624,14 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
e.preventDefault();
handleSave();
}
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === 'z' || e.code === 'KeyZ')) {
e.preventDefault();
undo();
}
if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'z' || e.code === 'KeyZ')) {
e.preventDefault();
redo();
}
if (e.key === 'l' && !e.ctrlKey && !e.metaKey && !e.altKey) {
setShowLayersPanel(!showLayersPanel);
}
@@ -600,18 +656,17 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isEditing, selectedElement, showElementPicker, showLayersPanel, hasChanges]);
}, [isEditing, selectedElement, showElementPicker, showLayersPanel, hasChanges, undo, redo]);
// Add element highlighting and click handlers when editing
// Also re-run when order/visibility changes so overlays are added for newly shown elements
useEffect(() => {
if (!isEditing) {
// Clean up overlays when exiting edit mode
safeDOM.querySelectorAll('.elementor-overlay').forEach(el => {
// Remove event listeners before removing element
el.replaceWith(el.cloneNode(true));
el.remove();
});
try {
safeDOM.querySelectorAll('.elementor-overlay').forEach(el => {
try { el.replaceWith(el.cloneNode(true)); } catch {}
try { el.remove(); } catch {}
});
} catch {}
setSelectedElement(null);
return;
}
@@ -950,7 +1005,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
newOrder.splice(targetIndex, 0, draggedElement as string);
setElementOrder(newOrder);
setHasChanges(true);
applyVisualReorder(newOrder);
applyVisualReorderRef.current(newOrder);
}
}
overlay.style.border = `2px dashed ${primaryColor}`;
@@ -958,31 +1013,29 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
});
});
};
// Add overlays for all present [data-element] nodes in DOM (dynamic)
try {
const nodes = Array.from(safeDOM.querySelectorAll('[data-element]')) as HTMLElement[];
const names = Array.from(new Set(nodes
.map(n => n.getAttribute('data-element'))
.filter((v): v is string => !!v && v !== 'container')
));
names.forEach(name => addOverlay(name));
} catch {
// fallback to implemented list if needed
const implementedElements = pageType === 'homepage' ? HOMEPAGE_IMPLEMENTED_ELEMENTS : Object.keys(ELEMENT_VARIANTS);
implementedElements.forEach((elementName) => {
if (ELEMENT_VARIANTS[elementName]) {
addOverlay(elementName);
// Add overlays for all present [data-element] nodes in DOM (dynamic)
try {
const nodes = Array.from(safeDOM.querySelectorAll('[data-element]')) as HTMLElement[];
const names = Array.from(new Set(nodes
.map(n => n.getAttribute('data-element'))
.filter((v): v is string => !!v && v !== 'container')
));
names.forEach(name => addOverlay(name));
} catch {
// fallback to implemented list if needed
const implementedElements = pageType === 'homepage' ? HOMEPAGE_IMPLEMENTED_ELEMENTS : Object.keys(ELEMENT_VARIANTS);
implementedElements.forEach((elementName) => {
if (ELEMENT_VARIANTS[elementName]) {
addOverlay(elementName);
}
});
}
});
}
// Close panel on escape
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setSelectedElement(null);
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setSelectedElement(null);
}
};
document.addEventListener('keydown', handleEscape);
return () => {
@@ -1058,6 +1111,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
// Helper function to apply the variant change
const applyChange = () => {
try {
pushHistorySnapshot();
const newChanges = { ...localChanges, [elementName]: safeVariant };
setLocalChanges(newChanges);
setHasChanges(true);
@@ -1175,6 +1229,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}
});
}, [allowCrossContainer]);
applyVisualReorderRef.current = applyVisualReorder;
// Debounce style changes to prevent lag
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
@@ -1214,6 +1269,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
// Add element from picker and make it visible + ordered in preview
const handleAddElement = useCallback((elementName: string, insertAt?: number) => {
pushHistorySnapshot();
const element = PREDEFINED_ELEMENTS.find(e => e.name === elementName);
const existingVariant = localChanges[elementName];
const defaultVariant = normalizeVariant(elementName, element?.defaultVariant || 'default');
@@ -1336,6 +1392,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}, [localChanges, isEditing, normalizeVariant, pageType, applyVisualReorder, pendingInsertIndex]);
const handleRemoveElement = useCallback((elementName: string) => {
pushHistorySnapshot();
// Update state - React will handle DOM removal
const newVisible = new Set(visibleElements);
newVisible.delete(elementName);
@@ -1362,6 +1419,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const handleMoveUp = useCallback((elementName: string) => {
pushHistorySnapshot();
const currentIndex = elementOrder.indexOf(elementName);
if (currentIndex <= 0) return;
@@ -1380,6 +1438,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}, [elementOrder, isEditing, applyVisualReorder]);
const handleMoveDown = useCallback((elementName: string) => {
pushHistorySnapshot();
const currentIndex = elementOrder.indexOf(elementName);
if (currentIndex < 0 || currentIndex >= elementOrder.length - 1) return;
@@ -1727,7 +1786,13 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
// Apply actual width constraints without scaling for real responsive behavior
wrapper.style.width = '100%';
wrapper.style.maxWidth = '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';
@@ -1749,11 +1814,13 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}
// 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 nastaven na Desktop',
description: 'Zobrazení na plnou šířku (100%)',
title: `Viewport: ${label}`,
description: desc,
status: 'info',
duration: 2000,
duration: 1500,
isClosable: true,
position: 'bottom-right',
});
@@ -1837,6 +1904,61 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
{/* Right: Actions */}
<HStack spacing={2}>
{/* Viewport toggles */}
<Tooltip label="Mobilní náhled">
<IconButton
aria-label="Mobile viewport"
icon={<FiSmartphone />}
size="sm"
variant={viewport === 'mobile' ? 'solid' : 'ghost'}
colorScheme={viewport === 'mobile' ? 'blue' : 'whiteAlpha'}
onClick={() => setViewport('mobile')}
/>
</Tooltip>
<Tooltip label="Tablet náhled">
<IconButton
aria-label="Tablet viewport"
icon={<FiTablet />}
size="sm"
variant={viewport === 'tablet' ? 'solid' : 'ghost'}
colorScheme={viewport === 'tablet' ? 'blue' : 'whiteAlpha'}
onClick={() => setViewport('tablet')}
/>
</Tooltip>
<Tooltip label="Desktop náhled">
<IconButton
aria-label="Desktop viewport"
icon={<FiMonitor />}
size="sm"
variant={viewport === 'desktop' ? 'solid' : 'ghost'}
colorScheme={viewport === 'desktop' ? 'blue' : 'whiteAlpha'}
onClick={() => setViewport('desktop')}
/>
</Tooltip>
<Divider orientation="vertical" borderColor="whiteAlpha.400" />
{/* Undo/Redo */}
<Tooltip label="Zpět (Ctrl+Z)">
<IconButton
aria-label="Undo"
icon={<FaUndo />}
size="sm"
variant="ghost"
colorScheme="whiteAlpha"
onClick={undo}
isDisabled={!canUndo()}
/>
</Tooltip>
<Tooltip label="Znovu (Ctrl+Shift+Z)">
<IconButton
aria-label="Redo"
icon={<FaRedo />}
size="sm"
variant="ghost"
colorScheme="whiteAlpha"
onClick={redo}
isDisabled={!canRedo()}
/>
</Tooltip>
<Button
leftIcon={<FaPaintBrush />}
size="sm"
@@ -155,11 +155,13 @@ const SpartaNavbar: React.FC = () => {
const isActive = isPathActive(nav.to);
const className = isActive ? 'sparta-button-tertiary' : 'sparta-button-tertiary';
// When categories are present under Články/Blog, render non-clickable label + category links
// When categories are present under Články/Blog, render clickable parent + category links
if (nav.items && nav.items.length > 0 && (nav.label === 'Články' || nav.label === 'Blog' || (nav.to || '').startsWith('/blog'))) {
return (
<React.Fragment key={nav.label}>
<span className="sparta-button-tertiary" style={{ pointerEvents: 'none', opacity: 0.9 }}>{nav.label}</span>
<RouterLink to={nav.to || '/blog'} className={className} onClick={() => setMobileOpen(false)}>
{nav.label}
</RouterLink>
{nav.items.map((it) => (
<RouterLink key={`${nav.label}-${it.to}`} to={it.to} className={className} onClick={() => setMobileOpen(false)}>
{it.label}
@@ -74,7 +74,7 @@ const RewardsModal: React.FC<RewardsModalProps> = ({ isOpen, onClose, availableP
)}
<VStack align="start" spacing={0} flex={1}>
<Text fontWeight="600">{it.name}</Text>
<Text fontSize="sm" color="gray.500">{it.type}</Text>
<></>
</VStack>
<Badge>{it.cost_points} bodů</Badge>
<Button
@@ -91,14 +91,13 @@ const Card: React.FC<{ a: Article }> = ({ a }) => {
)}
</HStack>
</VStack>
<Box position="absolute" top={2} right={2} zIndex={2}>
<InstagramGeneratorButton
article={a as any}
targetUrl={typeof window !== 'undefined' ? new URL(link, window.location.origin).toString() : undefined}
placement="inline"
size="sm"
/>
</Box>
<InstagramGeneratorButton
article={a as any}
targetUrl={typeof window !== 'undefined' ? new URL(link, window.location.origin).toString() : undefined}
placement="inline"
position="bottom-right"
size="sm"
/>
</Box>
);
};
+7 -8
View File
@@ -72,14 +72,13 @@ const BlogCard: React.FC<{ article: Article }> = ({ article }) => {
)}
</HStack>
</VStack>
<Box position="absolute" top={2} right={2} zIndex={2}>
<InstagramGeneratorButton
article={article as any}
targetUrl={typeof window !== 'undefined' ? new URL(link, window.location.origin).toString() : undefined}
placement="inline"
size="sm"
/>
</Box>
<InstagramGeneratorButton
article={article as any}
targetUrl={typeof window !== 'undefined' ? new URL(link, window.location.origin).toString() : undefined}
placement="inline"
position="bottom-right"
size="sm"
/>
</VStack>
);
};
@@ -167,6 +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,
refetchOnMount: true,
refetchInterval: 15000,
staleTime: 0,
});
// Fallback to latest published if no featured are available
const { data: latestData } = useQuery({
+17 -19
View File
@@ -1,6 +1,6 @@
import { Box, Grid, GridItem, Heading, Image, Text, VStack, HStack, Button, Skeleton, Badge } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getArticles, Article } from '../../services/articles';
import { getFeaturedArticles, Article } from '../../services/articles';
import { Link as RouterLink } from 'react-router-dom';
import { assetUrl } from '../../utils/url';
import { useClubTheme } from '../../contexts/ClubThemeContext';
@@ -9,8 +9,8 @@ import InstagramGeneratorButton from '../admin/InstagramGeneratorButton';
const FeaturedBlog: React.FC = () => {
const { data, isLoading } = useQuery({
queryKey: ['articles', { page: 1, page_size: 3, published: true }],
queryFn: () => getArticles({ page: 1, page_size: 3, published: true }),
queryKey: ['featured-articles-home', { page: 1, page_size: 3 }],
queryFn: () => getFeaturedArticles({ page: 1, page_size: 3 }),
});
const theme = useClubTheme();
const articles = data?.data || [];
@@ -52,14 +52,13 @@ const FeaturedBlog: React.FC = () => {
<Text fontSize="xs" bg={theme.secondary} color="black" px={2} py={0.5} borderRadius="md" w="fit-content">Novinka</Text>
<Heading size="md">{main.title}</Heading>
</VStack>
<Box position="absolute" top={3} right={3} zIndex={2}>
<InstagramGeneratorButton
article={main as any}
targetUrl={typeof window !== 'undefined' ? new URL(main.slug ? `/news/${main.slug}` : `/articles/${main.id}`, window.location.origin).toString() : undefined}
placement="inline"
size="sm"
/>
</Box>
<InstagramGeneratorButton
article={main as any}
targetUrl={typeof window !== 'undefined' ? new URL(main.slug ? `/news/${main.slug}` : `/articles/${main.id}`, window.location.origin).toString() : undefined}
placement="inline"
position="bottom-right"
size="sm"
/>
</Box>
)}
</GridItem>
@@ -89,14 +88,13 @@ const FeaturedBlog: React.FC = () => {
</HStack>
<Heading size="sm" noOfLines={3}>{(a as Article).title}</Heading>
</VStack>
<Box position="absolute" top={2} right={2} zIndex={2}>
<InstagramGeneratorButton
article={a as any}
targetUrl={typeof window !== 'undefined' ? new URL((a as Article).slug ? `/news/${(a as Article).slug}` : `/articles/${(a as Article).id}`, window.location.origin).toString() : undefined}
placement="inline"
size="sm"
/>
</Box>
<InstagramGeneratorButton
article={a as any}
targetUrl={typeof window !== 'undefined' ? new URL((a as Article).slug ? `/news/${(a as Article).slug}` : `/articles/${(a as Article).id}`, window.location.origin).toString() : undefined}
placement="inline"
position="bottom-right"
size="sm"
/>
</HStack>
))}
</VStack>
+157 -15
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { Box, Heading, Tabs, TabList, TabPanels, Tab, TabPanel, Table, Thead, Tbody, Tr, Th, Td, Skeleton, Text, Badge, HStack, useColorModeValue } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { facrApi } from '../../services/facr/facrApi';
@@ -15,6 +15,8 @@ const TableSection: React.FC = () => {
const [movementMap, setMovementMap] = useState<Record<string, Record<string, number>>>({});
const [selectedClub, setSelectedClub] = useState<any>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
// Public overrides state
const [overrides, setOverrides] = useState<{ by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> } | null>(null);
const handleClubClick = (row: any) => {
// Transform row data to match ClubModal interface
@@ -60,6 +62,114 @@ const TableSection: React.FC = () => {
retryDelay: attempt => Math.min(1000 * 2 ** attempt, 8000),
});
// Load public team-logo overrides (API with static fallback)
useEffect(() => {
let cancelled = false;
(async () => {
try {
const now = Date.now();
let ovr: any = null;
try {
const res = await fetch(`/api/v1/public/team-logo-overrides?t=${now}`, { cache: 'no-cache' });
if (res.ok) ovr = await res.json();
} catch {}
if (!ovr) {
try {
const res2 = await fetch('/cache/prefetch/team_logo_overrides.json', { cache: 'no-cache' });
if (res2.ok) ovr = await res2.json();
} catch {}
}
if (!cancelled) setOverrides(ovr || { by_id: {}, by_name: {} });
} catch {
if (!cancelled) setOverrides({ by_id: {}, by_name: {} });
}
})();
return () => { cancelled = true; };
}, []);
type SortKey = 'rank' | 'team' | 'played' | 'wins' | 'draws' | 'losses' | 'score' | 'points';
type SortOrder = 'desc' | 'asc';
const [sortState, setSortState] = useState<Record<string, { key: SortKey; order: SortOrder } | null>>({});
const toNumber = (v: any): number => {
if (typeof v === 'number') return v;
const n = parseFloat(String(v ?? '').replace(/[^0-9\-\.]/g, ''));
return isNaN(n) ? 0 : n;
};
const scoreDiff = (s: any): number => {
const str = String(s ?? '').trim();
const m = str.match(/(-?\d+)\s*[:\-]\s*(-?\d+)/);
if (m) return Number(m[1]) - Number(m[2]);
return toNumber(str);
};
const toggleSort = (compKey: string, key: SortKey) => {
const cur = sortState[compKey];
if (!cur || cur.key !== key) { setSortState({ ...sortState, [compKey]: { key, order: 'desc' } }); return; }
if (cur.order === 'desc') { setSortState({ ...sortState, [compKey]: { key, order: 'asc' } }); return; }
const next = { ...sortState }; next[compKey] = null; setSortState(next);
};
const arrow = (compKey: string, key: SortKey) => {
const cur = sortState[compKey];
if (!cur || cur.key !== key) return '';
return cur.order === 'desc' ? '▼' : '▲';
};
// Normalization helpers (aligned with CalendarPage)
const normalize = (s: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const stripPrefixes = (s: string) => {
let x = normalize(s);
x = x.replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g, '');
return x.replace(/\s+/g, ' ').trim();
};
const byNameMap = useMemo(() => {
const m: Record<string, string> = {};
const src = overrides?.by_name || {};
for (const k of Object.keys(src)) {
m[normalize(k)] = src[k];
}
return m;
}, [overrides]);
const byIdMap = useMemo(() => (overrides?.by_id || {}) as Record<string, { name?: string; logo_url?: string }>, [overrides]);
const pickName = (teamId?: string, original?: string): string => {
if (teamId && byIdMap?.[teamId]?.name) {
const v = byIdMap[teamId]!.name as string;
if (v && v.trim().length > 0) return v;
}
return original || '';
};
const pickLogo = (teamId?: string, teamName?: string, original?: string): string | undefined => {
// Prefer ID-based override
if (teamId && byIdMap?.[teamId]?.logo_url) {
return byIdMap[teamId]!.logo_url as string;
}
// Name-based fallback (exact/normalized and stripped prefix matching)
if (teamName) {
const exact = (overrides?.by_name || {})[teamName];
if (exact) return exact;
const n = normalize(teamName);
const cand = byNameMap[n];
if (cand) return cand;
const stripped = stripPrefixes(teamName);
// Try suffix/containment across keys
for (const k of Object.keys(overrides?.by_name || {})) {
const kn = stripPrefixes(k);
if (!kn) continue;
if (stripped.endsWith(kn) || kn.endsWith(stripped)) {
return (overrides!.by_name as any)[k];
}
}
}
return original;
};
// After data loads, compare with previous snapshot stored in localStorage to compute movement
useEffect(() => {
try {
@@ -179,18 +289,46 @@ 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">#</Th>
<Th color="text.onPrimary">Tým</Th>
<Th isNumeric color="text.onPrimary">Z</Th>
<Th isNumeric color="text.onPrimary">V</Th>
<Th isNumeric color="text.onPrimary">R</Th>
<Th isNumeric color="text.onPrimary">P</Th>
<Th isNumeric color="text.onPrimary">Skóre</Th>
<Th isNumeric color="text.onPrimary">Body</Th>
<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>
</Tr>
</Thead>
<Tbody>
{c.table?.overall?.map((row, idx) => {
{(() => {
const compKey = String(c.id ?? c.code ?? c.name ?? 'comp');
const cur = sortState[compKey];
const overall = c.table?.overall;
const arr = Array.isArray(overall) ? [...overall] : [];
if (cur) {
arr.sort((a: any, b: any) => {
let va: any; let vb: any; let isText = false;
switch (cur.key) {
case 'team': va = pickName(a.team_id, a.team || a.team_name); vb = pickName(b.team_id, b.team || b.team_name); isText = true; break;
case 'rank': va = toNumber(a.rank); vb = toNumber(b.rank); break;
case 'played': va = toNumber(a.played); vb = toNumber(b.played); break;
case 'wins': va = toNumber(a.wins); vb = toNumber(b.wins); break;
case 'draws': va = toNumber(a.draws); vb = toNumber(b.draws); break;
case 'losses': va = toNumber(a.losses); vb = toNumber(b.losses); break;
case 'score': va = scoreDiff(a.score); vb = scoreDiff(b.score); break;
case 'points': va = toNumber(a.points); vb = toNumber(b.points); break;
default: va = 0; vb = 0;
}
let res = isText ? String(va).localeCompare(String(vb)) : (va as number) - (vb as number);
if (cur.order === 'desc') res = -res;
if (res === 0) {
const ra = toNumber(a.rank); const rb = toNumber(b.rank);
res = ra - rb;
}
return res;
});
}
return arr.map((row, idx) => {
const compKey = String(c.id ?? c.code ?? c.name ?? 'comp');
const teamKeyRaw = String((row as any).team_id ?? (row as any).team ?? (row as any).team_name ?? idx);
const deltaStored = movementMap?.[compKey]?.[teamKeyRaw];
@@ -202,6 +340,10 @@ const TableSection: React.FC = () => {
const ourClubName = (settings?.club_name || '').toLowerCase();
const isOurClub = (ourClubId && row.team_id === ourClubId) || (!!ourClubName && String(row.team || '').toLowerCase() === ourClubName);
// Apply overrides locally for display (robust even if API/table lacks team_id)
const displayTeam: string = pickName((row as any).team_id, (row as any).team || (row as any).team_name);
const displayLogo: string | undefined = pickLogo((row as any).team_id, displayTeam, (row as any).team_logo_url);
return (
<Tr
key={`${row.team_id}-${idx}`}
@@ -225,17 +367,17 @@ const TableSection: React.FC = () => {
<HStack spacing={2} align="center">
<TeamLogo
teamId={row.team_id}
teamName={row.team}
facrLogo={row.team_logo_url}
teamName={displayTeam}
facrLogo={displayLogo}
size="small"
alt={row.team}
alt={displayTeam}
borderRadius="full"
bg="white"
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'whiteAlpha.300')}
/>
<Text as="span" color={isOurClub ? 'brand.primary' : useColorModeValue('gray.800', 'gray.100')} fontWeight={isOurClub ? 'bold' : 'normal'}>
{row.team}
{displayTeam}
</Text>
<Text as="span" fontSize="xs" color={movement === 'up' ? 'green.500' : movement === 'down' ? 'red.500' : 'gray.500'}>
{movement === 'up' ? '▲' : movement === 'down' ? '▼' : '•'}
@@ -264,7 +406,7 @@ const TableSection: React.FC = () => {
</Td>
</Tr>
);
})}
});})()}
</Tbody>
</Table>
</Box>
+3 -37
View File
@@ -2,10 +2,10 @@ import { Box, Heading, HStack, VStack, Image, Text, useColorModeValue } from '@c
import { useQuery } from '@tanstack/react-query';
import { getPlayers, Player } from '../../services/players';
import { assetUrl } from '../../utils/url';
import { getCountryFlag, translateNationality } from '../../utils/nationality';
const TeamScroller: React.FC = () => {
const { data } = useQuery({ queryKey: ['players'], queryFn: getPlayers });
const { data } = useQuery<Player[]>({ queryKey: ['players'], queryFn: () => getPlayers() });
const players = (data || []).filter(p => p.is_active);
if (!players.length) return null;
@@ -18,17 +18,7 @@ const TeamScroller: React.FC = () => {
<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" />
<Text fontWeight="bold" textAlign="center">{p.first_name} {p.last_name}</Text>
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>{p.position}</Text>
{p.nationality ? (
<HStack spacing={2}>
<Text as="span" fontSize="lg">{getCountryFlag(p.nationality)}</Text>
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>{translateNationality(p.nationality)}</Text>
</HStack>
) : null}
{p.date_of_birth ? (
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>
Věk: {(() => { const a = calculateAge(p.date_of_birth); return a != null ? `${a} ${czYears(a)}` : '' })()}
</Text>
) : null}
{null}
</VStack>
))}
</HStack>
@@ -36,28 +26,4 @@ const TeamScroller: React.FC = () => {
);
};
function calculateAge(dob: string): number | null {
try {
const d = new Date(dob);
if (isNaN(d.getTime())) return null;
const today = new Date();
let age = today.getFullYear() - d.getFullYear();
const m = today.getMonth() - d.getMonth();
if (m < 0 || (m === 0 && today.getDate() < d.getDate())) age--;
return age;
} catch {
return null;
}
}
// Czech pluralization for years
function czYears(n: number): string {
const mod100 = n % 100;
if (mod100 >= 11 && mod100 <= 14) return 'let';
const mod10 = n % 10;
if (mod10 === 1) return 'rok';
if (mod10 >= 2 && mod10 <= 4) return 'roky';
return 'let';
}
export default TeamScroller;
+78 -27
View File
@@ -5,7 +5,8 @@ import HorizontalScroller from '../ui/HorizontalScroller';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
import { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import CommentsSection from '../comments/CommentsSection';
type Props = {
videos?: string[];
@@ -46,6 +47,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
const [yt, setYt] = useState<YouTubeVideo[]>([]);
const { isOpen, onOpen, onClose } = useDisclosure();
const [selectedVideo, setSelectedVideo] = useState<RenderItem | null>(null);
const titleOverrides: Record<string, string> = (settings as any)?.videos_title_overrides || {};
// If admin explicitly disabled, respect it. Otherwise default to ON when there are manual videos configured
// or when a YouTube URL is present for auto mode.
const hasManualConfigured = Boolean((settings as any)?.videos_items?.length || (settings as any)?.videos?.length);
@@ -64,13 +66,14 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
// Default to 6 items on homepage unless overridden by settings (max 12)
const limit = Math.max(1, Math.min(12, settings?.videos_limit ?? 6));
const youtubeUrl = (settings as any)?.youtube_url || (settings as any)?.social_youtube || null;
const titleOverrides: Record<string, string> = (settings as any)?.videos_title_overrides || {};
useEffect(() => {
try {
if (isOpen) onClose();
setSelectedVideo(null);
} catch {}
}, [style, isOpen, onClose]);
}, [style]);
useEffect(() => {
let canceled = false;
@@ -97,7 +100,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
if (source === 'auto') {
return (yt || []).slice(0, limit).map(v => ({
key: v.video_id,
title: v.title,
title: (titleOverrides?.[v.video_id]?.trim()) || v.title,
embedUrl: toEmbed(v.video_id),
thumbnail: v.thumbnail_url,
date: v.published_date,
@@ -126,7 +129,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
};
});
return (manual.length ? manual : legacy).slice(0, limit);
}, [source, yt, settings?.videos_items, settings?.videos, videos, limit]);
}, [source, yt, settings?.videos_items, settings?.videos, videos, limit, titleOverrides]);
if (!enabled || items.length === 0) return null;
@@ -290,17 +293,41 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
<ModalCloseButton color="white" size="lg" bg="blackAlpha.600" _hover={{ bg: 'blackAlpha.700' }} borderRadius="full" zIndex={2} />
<ModalBody p={0}>
{selectedVideo && (
<AspectRatio ratio={16 / 9} maxH="90vh">
<iframe
src={`${selectedVideo.embedUrl}?autoplay=1&vq=hd1080&rel=0&modestbranding=1&playsinline=1`}
title={selectedVideo.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
style={{ borderRadius: '8px' }}
/>
</AspectRatio>
<Box>
<AspectRatio ratio={16 / 9} maxH="90vh">
<iframe
src={`${selectedVideo.embedUrl}?autoplay=1&vq=hd1080&rel=0&modestbranding=1&playsinline=1`}
title={selectedVideo.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
style={{ borderRadius: '8px' }}
/>
</AspectRatio>
<Box bg={useColorModeValue('white', 'gray.800')} p={4} borderRadius="md" mt={2}>
<HStack justify="space-between" align="start">
<VStack align="start" flex={1}>
<Text fontWeight="bold" fontSize="lg">{selectedVideo.title}</Text>
{selectedVideo.date && (
<Text color={useColorModeValue('gray.600', 'gray.300')} fontSize="sm">
{new Date(selectedVideo.date).toLocaleDateString('cs-CZ', { year: 'numeric', month: 'long', day: 'numeric' })}
</Text>
)}
</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>
</Link>
)}
</HStack>
{selectedVideo.videoId && (
<Box mt={4}>
<CommentsSection targetType="youtube_video" targetId={selectedVideo.videoId} />
</Box>
)}
</Box>
</Box>
)}
</ModalBody>
</ModalContent>
@@ -331,17 +358,41 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
<ModalCloseButton color="white" size="lg" bg="blackAlpha.600" _hover={{ bg: 'blackAlpha.700' }} borderRadius="full" zIndex={2} />
<ModalBody p={0}>
{selectedVideo && (
<AspectRatio ratio={16 / 9} maxH="90vh">
<iframe
src={`${selectedVideo.embedUrl}?autoplay=1&vq=hd1080&rel=0&modestbranding=1&playsinline=1`}
title={selectedVideo.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
style={{ borderRadius: '8px' }}
/>
</AspectRatio>
<Box>
<AspectRatio ratio={16 / 9} maxH="90vh">
<iframe
src={`${selectedVideo.embedUrl}?autoplay=1&vq=hd1080&rel=0&modestbranding=1&playsinline=1`}
title={selectedVideo.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
style={{ borderRadius: '8px' }}
/>
</AspectRatio>
<Box bg={useColorModeValue('white', 'gray.800')} p={4} borderRadius="md" mt={2}>
<HStack justify="space-between" align="start">
<VStack align="start" flex={1}>
<Text fontWeight="bold" fontSize="lg">{selectedVideo.title}</Text>
{selectedVideo.date && (
<Text color={useColorModeValue('gray.600', 'gray.300')} fontSize="sm">
{new Date(selectedVideo.date).toLocaleDateString('cs-CZ', { year: 'numeric', month: 'long', day: 'numeric' })}
</Text>
)}
</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>
</Link>
)}
</HStack>
{selectedVideo.videoId && (
<Box mt={4}>
<CommentsSection targetType="youtube_video" targetId={selectedVideo.videoId} />
</Box>
)}
</Box>
</Box>
)}
</ModalBody>
</ModalContent>
@@ -350,4 +401,4 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
);
};
export default VideosSection;
export default React.memo(VideosSection);
+148 -30
View File
@@ -1,5 +1,6 @@
import React from 'react';
import { assetUrl, sanitizeClubName } from '../../utils/url';
import React, { useEffect, useMemo, useState } from 'react';
import { sanitizeClubName } from '../../utils/url';
import { TeamLogo } from '../common/TeamLogo';
export interface StandingRow {
position?: number;
@@ -22,30 +23,154 @@ export interface StandingRow {
score?: string;
}
function deriveTeamIdFromLogoUrl(url?: string): string | undefined {
try {
const u = String(url || '');
if (!u) return undefined;
const m = u.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/);
return m ? m[0].toLowerCase() : undefined;
} catch {
return 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 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);
const [overrides, setOverrides] = useState<TeamOverrides>({});
const toNumber = (v: any): number => {
if (typeof v === 'number') return v;
const n = parseFloat(String(v ?? '').replace(/[^0-9\-\.]/g, ''));
return isNaN(n) ? 0 : n;
};
const scoreDiff = (s: any): number => {
const str = String(s ?? '').trim();
const m = str.match(/(-?\d+)\s*[:\-]\s*(-?\d+)/);
if (m) return Number(m[1]) - Number(m[2]);
return toNumber(str);
};
const normalize = (s: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-')
.replace(/[.,!;:()\[\]{}]/g, ' ')
.replace(/[\s,]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const byIdMap = useMemo(() => (overrides?.by_id || {}) as Record<string, { name?: string; logo_url?: string }>, [overrides]);
const overridesNameIndex = useMemo(() => {
const idx: Record<string, { id: string; name: string }> = {};
try {
for (const [id, v] of Object.entries(byIdMap)) {
const nm = String((v as any)?.name || '').trim();
if (!nm) continue;
const key = normalize(nm);
if (!key) continue;
idx[key] = { id, name: nm };
}
} catch {}
return idx;
}, [byIdMap]);
const pickName = (teamId?: string | number, original?: string, logoUrl?: string): string => {
const id = String(teamId || '') || deriveTeamIdFromLogoUrl(logoUrl) || '';
const v = id ? (byIdMap[id]?.name || '') : '';
if (v && v.trim().length > 0) return v;
if (original) {
const n = normalize(original);
let hit = overridesNameIndex[n];
if (!hit) {
for (const [k, val] of Object.entries(overridesNameIndex)) {
if (!k) continue;
if (n.endsWith(k) || k.endsWith(n)) { hit = val as any; break; }
}
}
if (hit?.name) return hit.name;
}
return original || '';
};
useEffect(() => {
let mounted = true;
(async () => {
const now = Date.now();
let data: any = null;
try {
const res = await fetch(`/api/v1/public/team-logo-overrides?t=${now}`, { cache: 'no-cache' });
if (res.ok) data = await res.json();
} catch {}
if (!data) {
try {
const res2 = await fetch('/cache/prefetch/team_logo_overrides.json', { cache: 'no-cache' });
if (res2.ok) data = await res2.json();
} catch {}
}
if (mounted) setOverrides(data || { by_id: {}, by_name: {} });
})();
return () => { mounted = false; };
}, []);
const getTeam = (r: any): string => sanitizeClubName(pickName(r?.team_id, r?.team?.name ?? r?.team ?? r?.club ?? '', r?.team_logo_url));
const getRank = (r: any): number => toNumber(r?.rank ?? r?.pos ?? r?.position);
const toggleSort = (key: 'rank' | 'team' | 'played' | 'wins' | 'draws' | 'losses' | 'score' | 'points') => {
if (sortKey !== key) { setSortKey(key); setSortOrder('desc'); return; }
if (sortOrder === 'desc') { setSortOrder('asc'); return; }
setSortKey(null); setSortOrder(null);
};
const sorted = useMemo(() => {
if (!sortKey || !sortOrder) return safe;
const arr = [...safe];
arr.sort((a: any, b: any) => {
let va: any; let vb: any; let isText = false;
switch (sortKey) {
case 'team': va = getTeam(a); vb = getTeam(b); isText = true; break;
case 'rank': va = getRank(a); vb = getRank(b); break;
case 'played': va = toNumber(a?.played ?? a?.matches); vb = toNumber(b?.played ?? b?.matches); break;
case 'wins': va = toNumber(a?.wins ?? a?.win); vb = toNumber(b?.wins ?? b?.win); break;
case 'draws': va = toNumber(a?.draws ?? a?.draw); vb = toNumber(b?.draws ?? b?.draw); break;
case 'losses': va = toNumber(a?.losses ?? a?.loss); vb = toNumber(b?.losses ?? b?.loss); break;
case 'score': va = scoreDiff(a?.score); vb = scoreDiff(b?.score); break;
case 'points': va = toNumber(a?.points ?? a?.pts); vb = toNumber(b?.points ?? b?.pts); break;
default: va = 0; vb = 0;
}
let res = isText ? String(va).localeCompare(String(vb)) : (va as number) - (vb as number);
if (sortOrder === 'desc') res = -res;
if (res === 0) {
const ra = getRank(a); const rb = getRank(b);
res = ra - rb;
}
return res;
});
return arr;
}, [safe, sortKey, sortOrder]);
const arrow = (key: 'rank' | 'team' | 'played' | 'wins' | 'draws' | 'losses' | 'score' | 'points') => sortKey === key ? (sortOrder === 'desc' ? ' ▼' : ' ▲') : '';
return (
<div className="table-card">
<div className="standings-table-wrapper" style={{ overflowX: 'auto' }}>
<table className="standings-table-compact" style={{ width: '100%', borderCollapse: 'separate', borderSpacing: '0 4px' }}>
<thead>
<tr style={{ fontSize: '0.75rem', color: 'var(--dark-gray)', textTransform: 'uppercase' }}>
<th style={{ padding: '6px 8px', textAlign: 'left', fontWeight: 600 }}>#</th>
<th style={{ padding: '6px 8px', textAlign: 'left', fontWeight: 600 }}>Tým</th>
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>Z</th>
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>V</th>
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>R</th>
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>P</th>
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, display: 'none' }} className="hide-mobile">Skóre</th>
<th style={{ padding: '6px 8px', textAlign: 'center', fontWeight: 600 }}>Body</th>
<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>
</tr>
</thead>
<tbody>
{safe.slice(0, 8).map((row, idx) => {
{sorted.slice(0, 8).map((row, idx) => {
const teamNameRaw = (row as any).team?.name ?? (row as any).team ?? (row as any).club ?? '-';
const teamName = sanitizeClubName(teamNameRaw);
const logo = (row as any).team_logo_url;
const logoSrc = logo ? (assetUrl(logo) || logo) : null;
const teamName = sanitizeClubName(pickName((row as any).team_id, teamNameRaw, (row as any).team_logo_url));
const tid = (row as any).team_id || deriveTeamIdFromLogoUrl((row as any).team_logo_url);
return (
<tr
key={idx}
@@ -70,25 +195,18 @@ const StandingsCard: React.FC<{ rows: StandingRow[]; onRowClick?: (row: Standing
<td style={{ padding: '10px 8px', fontWeight: 600 }}>
{variant === 'logos' ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
{logoSrc ? (
<img
src={logoSrc as string}
alt={teamName || 'Tým'}
loading="lazy"
style={{
width: 20,
height: 20,
borderRadius: '50%',
objectFit: 'cover',
background: 'var(--bg-soft)',
border: '1px solid var(--card-border)',
}}
/>
) : null}
<TeamLogo
teamId={tid}
teamName={teamName}
facrLogo={(row as any).team_logo_url}
size="small"
alt={teamName || 'Tým'}
borderRadius="full"
/>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{teamName}</span>
</span>
) : (
teamNameRaw
teamName
)}
</td>
<td style={{ padding: '10px 4px', textAlign: 'center' }}>{(row as any).played ?? (row as any).matches ?? '-'}</td>
@@ -133,9 +133,6 @@ const SegmentTeam: React.FC<{ colorA?: string; left?: boolean; right?: boolean;
bgGradient={`linear(to-r, ${colorA}, ${shadeColor(colorA, 20)})`}
color="white"
spacing={1.5}
position="relative"
_before={left ? { content: '""', position: 'absolute', left: '-10px', top: 0, bottom: 0, width: '14px', bgGradient: `linear(to-r, ${colorA}, ${shadeColor(colorA, 20)})`, borderTopLeftRadius: '999px', borderBottomLeftRadius: '999px' } : undefined}
_after={right ? { content: '""', position: 'absolute', right: '-10px', top: 0, bottom: 0, width: '14px', bgGradient: `linear(to-r, ${colorA}, ${shadeColor(colorA, 20)})`, borderTopRightRadius: '999px', borderBottomRightRadius: '999px' } : undefined}
minW="46px"
>
{children}
@@ -201,17 +201,23 @@ export const MatchesWidget: React.FC<{
return name;
}, [aliasesQ.data?.list]);
const filteredMatches = React.useMemo(() => {
const displayMatches = React.useMemo(() => {
if (!Array.isArray(matches)) return [] as Match[];
if (!categoryName) return matches as Match[];
const needle = normalize(categoryName);
return (matches as Match[]).filter((m: any) => {
const filtered = (matches as Match[]).filter((m: any) => {
const comp = String((m as any).competitionName || '');
const resolved = resolveAliasName(comp);
const nComp = normalize(comp);
const nResolved = normalize(resolved);
return nResolved.includes(needle) || nComp.includes(needle);
// Prefer exact match on alias or competition name
if (nResolved && nResolved === needle) return true;
if (nComp === needle) return true;
// Fallback: substring (kept for robustness when names vary)
return (nResolved && nResolved.includes(needle)) || nComp.includes(needle);
});
// Respect hideEmpty: when no matches for the filter, return empty list
return filtered;
}, [matches, categoryName, resolveAliasName]);
if (isLoading) {
@@ -236,7 +242,7 @@ export const MatchesWidget: React.FC<{
);
}
if (!filteredMatches || filteredMatches.length === 0) {
if (!displayMatches || displayMatches.length === 0) {
if (hideEmpty) return null;
return (
<Widget title="Nadcházející zápasy">
@@ -253,7 +259,7 @@ export const MatchesWidget: React.FC<{
return (
<Widget title="Nadcházející zápasy">
<VStack spacing={{ base: 2, md: 3 }} align="stretch" divider={<Box borderBottomWidth="1px" borderColor="gray.200" />}>
{filteredMatches.map((match) => (
{displayMatches.map((match) => (
<Box
key={match.id}
p={{ base: 3, md: 4 }}
+7 -2
View File
@@ -17,6 +17,7 @@ import { HelmetProvider } from 'react-helmet-async';
import reportWebVitals from './reportWebVitals';
import * as serviceWorkerRegistration from './serviceWorkerRegistration';
import { promptUserToUpdate } from './serviceWorkerRegistration';
import { installGlobalErrorHandlers, reportError } from './services/errorReporter';
// Cookie consent utilities
type Consent = { analytics?: boolean };
const getConsent = (): Consent | null => {
@@ -51,6 +52,7 @@ class ErrorBoundary extends React.Component<{children: React.ReactNode}, {hasErr
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
reportError({ message: error.message, stack: error.stack, component: 'ErrorBoundary', context: { react: errorInfo.componentStack } });
console.error('Error caught by ErrorBoundary:', error, errorInfo);
}
@@ -68,9 +70,12 @@ class ErrorBoundary extends React.Component<{children: React.ReactNode}, {hasErr
}
}
// Log unhandled promise rejections
installGlobalErrorHandlers();
window.addEventListener('unhandledrejection', (event) => {
// Optionally report to monitoring service here
const reason: any = (event as any).reason;
const message = typeof reason === 'string' ? reason : (reason?.message || 'Unhandled rejection');
const stack = typeof reason === 'object' ? (reason?.stack || '') : '';
reportError({ message, stack });
});
const rootElement = document.getElementById('root');
+30
View File
@@ -20,6 +20,10 @@ 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 { logAction } from '../services/actionLog';
import { usePublicSettings } from '../hooks/usePublicSettings';
import { assetUrl } from '../utils/url';
interface AdminLayoutProps {
children: ReactNode;
@@ -33,6 +37,7 @@ const AdminLayout = ({ children, requireAdmin = true }: AdminLayoutProps) => {
const navigate = useNavigate();
const location = useLocation();
const { colorMode, toggleColorMode } = useColorMode();
const { data: publicSettings } = usePublicSettings();
// Color values - Matching admin-enhancements.css dark mode colors
const bg = useColorModeValue('gray.50', '#0f1115');
@@ -70,6 +75,30 @@ const AdminLayout = ({ children, requireAdmin = true }: AdminLayoutProps) => {
}
}, [isLoading, isAuthenticated, user, navigate, requireAdmin]);
useEffect(() => {
logAction({ type: 'nav', at: Date.now(), path: location.pathname + location.search });
}, [location.pathname, location.search]);
useEffect(() => {
try {
const raw = publicSettings?.club_logo_url || '/dist/img/logo-club-empty.svg';
if (!raw) return;
const url = assetUrl(raw) || raw;
const setIcon = (rel: string) => {
let link = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`);
if (!link) {
link = document.createElement('link');
link.rel = rel as any;
document.head.appendChild(link);
}
link.href = url;
if (url.endsWith('.svg')) link.type = 'image/svg+xml';
};
setIcon('icon');
setIcon('shortcut icon');
} catch {}
}, [publicSettings?.club_logo_url]);
if (isLoading) {
return (
<Flex minH="100vh" bg={bg}>
@@ -114,6 +143,7 @@ const AdminLayout = ({ children, requireAdmin = true }: AdminLayoutProps) => {
_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 />}
+22
View File
@@ -207,6 +207,17 @@ const AboutPage: React.FC = () => {
dangerouslySetInnerHTML={{ __html: cleanContent }}
sx={{
'& p': { mb: 4, lineHeight: 1.8 },
'& a': { color: 'blue.600', textDecoration: 'underline', _hover: { color: 'blue.700' } },
'& blockquote': {
borderLeft: '4px solid #3182ce',
paddingLeft: '16px',
margin: '1em 0',
color: textSecondary,
fontStyle: 'italic',
backgroundColor: 'gray.50',
padding: '12px 16px',
borderRadius: '4px',
},
'& h1, & h2, & h3': { mt: 8, mb: 4, fontWeight: 'bold' },
'& h1': { fontSize: '2xl' },
'& h2': { fontSize: 'xl' },
@@ -309,6 +320,17 @@ const AboutPage: React.FC = () => {
dangerouslySetInnerHTML={{ __html: cleanContent }}
sx={{
'& p': { mb: 4, lineHeight: 1.8 },
'& a': { color: 'blue.600', textDecoration: 'underline', _hover: { color: 'blue.700' } },
'& blockquote': {
borderLeft: '4px solid #3182ce',
paddingLeft: '16px',
margin: '1em 0',
color: textSecondary,
fontStyle: 'italic',
backgroundColor: 'gray.50',
padding: '12px 16px',
borderRadius: '4px',
},
'& h1, & h2, & h3': { mt: 6, mb: 3, fontWeight: 'bold' },
'& h1': { fontSize: '2xl' },
'& h2': { fontSize: 'xl' },
+10
View File
@@ -181,6 +181,16 @@ const ActivityDetailPage: React.FC = () => {
' 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'),
fontStyle: 'italic',
backgroundColor: useColorModeValue('#f7fafc','#1a202c'),
padding: '12px 16px',
borderRadius: '4px',
},
' img': {
display: 'block',
maxWidth: '100%',
+6 -1
View File
@@ -23,6 +23,7 @@ import MainLayout from '../components/layout/MainLayout';
import { API_URL } from '../services/api';
import PhotoModal from '../components/gallery/PhotoModal';
import CommentsSection from '../components/comments/CommentsSection';
import { Helmet } from 'react-helmet-async';
interface Photo {
id: string;
@@ -160,6 +161,10 @@ const AlbumDetailPage: React.FC = () => {
return (
<MainLayout>
<Helmet>
<title>{album.title} | Fotogalerie</title>
<meta name="description" content={`Fotogalerie: ${album.title}.`} />
</Helmet>
<Box bg={bgColor} minH="100vh" py={8}>
<Container maxW="7xl">
{/* Breadcrumbs */}
@@ -233,7 +238,7 @@ const AlbumDetailPage: React.FC = () => {
📸 Všechny fotografie jsou z platformy{' '}
<Text
as="a"
href="https://zonerama.com"
href={album.url || 'https://zonerama.com'}
target="_blank"
rel="noopener noreferrer"
fontWeight="600"
+79 -29
View File
@@ -23,6 +23,7 @@ import InstagramGeneratorButton from '../components/admin/InstagramGeneratorButt
import { MatchSnapshot } from '../services/instagram';
import { Widget } from '../components/widgets/Widget';
import { MatchesWidget } from '../components/widgets/MatchesWidget';
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
import { getUpcomingEvents } from '../services/eventService';
import CommentsSection from '../components/comments/CommentsSection';
@@ -41,6 +42,49 @@ const ArticleDetailPage: React.FC = () => {
enabled: Boolean(slug || id),
});
// Load competition aliases to resolve category → alias mapping for MatchesWidget filtering
const aliasesQ = useQuery<{ list: CompetitionAlias[] }>({
queryKey: ['competition-aliases-public'],
queryFn: async () => {
try {
const list = await getCompetitionAliasesPublic();
return { list };
} catch {
return { list: [] as CompetitionAlias[] };
}
},
staleTime: 5 * 60 * 1000,
});
const normalize = (s?: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const resolveAliasName = React.useCallback((compName?: string): string => {
const name = String(compName || '');
const normComp = normalize(name);
const list = aliasesQ.data?.list || [];
for (const a of list) {
const aAlias = normalize(a.alias);
const aOrig = normalize(a.original_name || '');
if (aOrig && (normComp.includes(aOrig) || aOrig.includes(normComp))) return a.alias;
if (aAlias && (normComp.includes(aAlias) || aAlias.includes(normComp))) return a.alias;
}
return name;
}, [aliasesQ.data?.list]);
// Determine which category name to use for MatchesWidget (prefer backend-provided competition_alias)
const categoryNameForMatches = React.useMemo(() => {
const fromBackend = (data as any)?.competition_alias;
if (fromBackend) return fromBackend as string;
const cat = (data as any)?.category?.name as string | undefined;
if (!cat) return undefined;
return resolveAliasName(cat);
}, [(data as any)?.competition_alias, (data as any)?.category?.name, resolveAliasName]);
// UI colors and public settings
const { data: publicSettings } = usePublicSettings();
@@ -49,8 +93,8 @@ const ArticleDetailPage: React.FC = () => {
const textMuted = useColorModeValue('gray.600','gray.400');
// Hoist all color mode values to top-level to avoid conditional hook calls
const videoTitleColor = useColorModeValue('gray.700','gray.300');
const galleryBg = useColorModeValue('blue.50','blue.900');
const galleryBorder = useColorModeValue('blue.200','blue.700');
const galleryBg = useColorModeValue('gray.50','gray.800');
const galleryBorder = useColorModeValue('gray.200','gray.700');
const attachmentsBg = useColorModeValue('gray.50','gray.800');
// Derive opponent color (for right edge fade) from team logo
@@ -218,10 +262,8 @@ const ArticleDetailPage: React.FC = () => {
const profileData = await profileRes.value.json();
const album = (profileData.albums || []).find((a: any) => a.id === albumId);
if (album) {
// Filter photos by selected IDs if available
const photos = photoIds.length > 0
? album.photos.filter((p: any) => photoIds.includes(p.id))
: album.photos;
// Use full album photos for the article preview (ignore selected IDs)
const photos = album.photos;
return { ...album, photos };
}
}
@@ -232,9 +274,7 @@ const ArticleDetailPage: React.FC = () => {
const blogAlbums = Array.isArray(albumsData) ? albumsData : [];
const album = blogAlbums.find((a: any) => a.id === albumId);
if (album) {
const photos = photoIds.length > 0
? album.photos.filter((p: any) => photoIds.includes(p.id))
: album.photos;
const photos = album.photos;
return { ...album, photos };
}
}
@@ -252,7 +292,6 @@ const ArticleDetailPage: React.FC = () => {
} else if (Array.isArray(payload?.photos)) {
photos = payload.photos;
}
if (photoIds.length > 0) photos = photos.filter((p: any) => photoIds.includes(p.id));
return { id: albumUrl, title: 'Album', date: '', photos } as any;
}
}
@@ -500,9 +539,6 @@ const ArticleDetailPage: React.FC = () => {
</Box>
)}
{(data as any)?.id ? (
<CommentsSection targetType="article" targetId={String((data as any).id)} />
) : null}
{/* Match Section - Card with logos, score/countdown, venue/date */}
{(matchLinkQuery.data as any)?.external_match_id && (
<Box position="relative" borderWidth="1px" borderRadius="lg" p={{ base: 4, md: 5 }} bg={cardBg} overflow="hidden">
@@ -579,6 +615,17 @@ const ArticleDetailPage: React.FC = () => {
'ul': { listStyleType: 'disc' },
'ol': { listStyleType: 'decimal' },
'li': { mb: 2 },
'a': { color: 'blue.600', textDecoration: 'underline', _hover: { color: 'blue.700' } },
'blockquote': {
borderLeft: '4px solid #3182ce',
paddingLeft: '16px',
margin: '1em 0',
color: useColorModeValue('#4a5568','#cbd5e0'),
fontStyle: 'italic',
backgroundColor: useColorModeValue('#f7fafc','#1a202c'),
padding: '12px 16px',
borderRadius: '4px',
},
'img': {
display: 'block',
maxWidth: '100%',
@@ -592,10 +639,9 @@ const ArticleDetailPage: React.FC = () => {
}}
dangerouslySetInnerHTML={{ __html: safeContentHTML }}
/>
{/* YouTube Video Section - simplified */}
{/* YouTube Video Section - simplified with rounded edges */}
{(data as any)?.youtube_video_id && (
<Box>
<Box borderRadius="xl" overflow="hidden">
<AspectRatio ratio={16 / 9}>
<Box
as="iframe"
@@ -607,13 +653,10 @@ const ArticleDetailPage: React.FC = () => {
onClick={() => umamiTrackVideoPlay((data as any).youtube_video_id, (data as any).youtube_video_title)}
/>
</AspectRatio>
{(data as any).youtube_video_title ? (
<Text mt={2} color={videoTitleColor}>{(data as any).youtube_video_title}</Text>
) : null}
</Box>
)}
{/* Gallery Section - Mosaic of 5 images with grayscale + hover color */}
{/* Video title intentionally hidden per requirement */}
{/* Gallery Section - Mosaic of 5 color images (random) */}
{((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}>
@@ -632,12 +675,13 @@ const ArticleDetailPage: React.FC = () => {
</Button>
</HStack>
{Array.isArray(galleryAlbumQuery.data?.photos) && (galleryAlbumQuery.data?.photos?.length || 0) > 0 && (() => {
const photos = (galleryAlbumQuery.data?.photos ?? []).slice(0, 5);
const all = galleryAlbumQuery.data?.photos ?? [];
const photos = [...all].sort(() => Math.random() - 0.5).slice(0, Math.min(5, all.length));
if (photos.length < 5) {
return (
<SimpleGrid columns={{ base: 2, sm: 3 }} spacing={2} role="group">
{photos.map((p: any) => (
<Image key={p.id} src={p.image_1500} alt={String(p.id)} w="100%" h="140px" objectFit="cover" filter="grayscale(100%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(0)' }} />
<Image key={p.id} src={p.image_1500} alt={String(p.id)} w="100%" h="140px" objectFit="cover" borderRadius="md" />
))}
</SimpleGrid>
);
@@ -649,11 +693,11 @@ const ArticleDetailPage: React.FC = () => {
gridTemplateRows: 'repeat(2, 140px)',
gap: '8px'
}}>
<Image src={photos[0].image_1500} alt={String(photos[0].id)} sx={{ gridColumn: 1, gridRow: 1 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(0)' }} borderRadius="md" />
<Image src={photos[1].image_1500} alt={String(photos[1].id)} sx={{ gridColumn: 1, gridRow: 2 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(0)' }} borderRadius="md" />
<Image src={photos[2].image_1500} alt={String(photos[2].id)} sx={{ gridColumn: 2, gridRow: '1 / span 2' }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(0)' }} borderRadius="md" />
<Image src={photos[3].image_1500} alt={String(photos[3].id)} sx={{ gridColumn: 3, gridRow: 1 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(0)' }} borderRadius="md" />
<Image src={photos[4].image_1500} alt={String(photos[4].id)} sx={{ gridColumn: 3, gridRow: 2 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(0)' }} borderRadius="md" />
<Image src={photos[0].image_1500} alt={String(photos[0].id)} sx={{ gridColumn: 1, gridRow: 1 }} objectFit="cover" w="100%" h="100%" borderRadius="md" />
<Image src={photos[1].image_1500} alt={String(photos[1].id)} sx={{ gridColumn: 1, gridRow: 2 }} objectFit="cover" w="100%" h="100%" borderRadius="md" />
<Image src={photos[2].image_1500} alt={String(photos[2].id)} sx={{ gridColumn: 2, gridRow: '1 / span 2' }} objectFit="cover" w="100%" h="100%" borderRadius="md" />
<Image src={photos[3].image_1500} alt={String(photos[3].id)} sx={{ gridColumn: 3, gridRow: 1 }} objectFit="cover" w="100%" h="100%" borderRadius="md" />
<Image src={photos[4].image_1500} alt={String(photos[4].id)} sx={{ gridColumn: 3, gridRow: 2 }} objectFit="cover" w="100%" h="100%" borderRadius="md" />
<Button as={RouterLink} to={(data as any).gallery_album_id ? `/galerie/album/${(data as any).gallery_album_id}` : '#'} size="sm" colorScheme="blue" position="absolute" top="50%" left="50%" transform="translate(-50%, -50%)" onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })}>Zobrazit galerii</Button>
</Box>
);
@@ -692,7 +736,7 @@ const ArticleDetailPage: React.FC = () => {
})()}
<MatchesWidget
categoryName={(data as any)?.category?.name}
categoryName={categoryNameForMatches}
hideEmpty
onMatchClick={(m: any) => {
setSelectedMatch({ ...m, competition: (m as any).competitionName, competitionName: (m as any).competitionName });
@@ -747,6 +791,12 @@ const ArticleDetailPage: React.FC = () => {
)}
{/* Polls (Ankety) above CTA */}
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
{/* Comments at the end */}
{(data as any)?.id ? (
<Container maxW="7xl" mt={4}>
<CommentsSection targetType="article" targetId={String((data as any).id)} />
</Container>
) : null}
{/* Newsletter CTA */}
<NewsletterCTA />
<MatchModal isOpen={isMatchModalOpen} onClose={() => setIsMatchModalOpen(false)} match={selectedMatch} />
+29 -56
View File
@@ -119,14 +119,13 @@ const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({
{article.title}
</Heading>
</Box>
<Box position="absolute" top={2} right={2} zIndex={2}>
<InstagramGeneratorButton
article={article as any}
targetUrl={typeof window !== 'undefined' ? new URL(link, window.location.origin).toString() : undefined}
placement="inline"
size="sm"
/>
</Box>
<InstagramGeneratorButton
article={article as any}
targetUrl={typeof window !== 'undefined' ? new URL(link, window.location.origin).toString() : undefined}
placement="inline"
position="bottom-right"
size="sm"
/>
</LinkBox>
);
};
@@ -180,7 +179,7 @@ const BlogPage: React.FC = () => {
const featuredQ = useQuery<Paginated<Article>>(
['articles-featured', { page_size: 3 }],
() => getFeaturedArticles({ page_size: 3 }),
{ staleTime: 5 * 60 * 1000 }
{ refetchOnWindowFocus: true, refetchOnMount: true, refetchInterval: 30000, staleTime: 0 }
);
const {
data,
@@ -258,6 +257,17 @@ const BlogPage: React.FC = () => {
: 'Nejnovější články, rozhovory a novinky z klubu.';
const canonical = typeof window !== 'undefined' ? window.location.href : undefined;
// Debounced search param update when typing
React.useEffect(() => {
const next: Record<string, string> = {};
if (categoryId) next.category_id = String(categoryId);
if (month) next.month = month;
if (matchId) next.match_id = matchId;
if (qInput) next.q = qInput;
const id = window.setTimeout(() => setSearchParams(next), 400);
return () => window.clearTimeout(id);
}, [qInput, categoryId, month, matchId]);
return (
<MainLayout>
<Helmet>
@@ -293,16 +303,6 @@ const BlogPage: React.FC = () => {
placeholder="Hledat články…"
value={qInput}
onChange={(e) => setQInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const next: Record<string, string> = {};
if (categoryId) next.category_id = String(categoryId);
if (month) next.month = month;
if (matchId) next.match_id = matchId;
if (qInput) next.q = qInput;
setSearchParams(next);
}
}}
/>
{qInput && (
<InputRightElement>
@@ -367,39 +367,19 @@ const BlogPage: React.FC = () => {
</Container>
)}
<Container maxW="7xl">
{/* Masonry using CSS columns */}
<Box
sx={{
columnCount: { base: 1, sm: 2, lg: 3 } as any,
columnGap: '28px',
}}
>
<Container maxW="5xl">
{/* Responsive grid with consistent card sizing */}
<Grid templateColumns={{ base: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={6}>
{isLoading && Array.from({ length: 9 }).map((_, i) => (
<Skeleton key={i} h={{ base: '220px', md: '260px' }} borderRadius="md" mb={7} />
<Skeleton key={i} h={{ base: '260px', md: '300px' }} borderRadius="md" />
))}
{!isLoading && visibleArticles.map((a, idx) => (
<React.Fragment key={`row-${a.id}`}>
<Box
mb={7}
sx={{
breakInside: 'avoid',
WebkitColumnBreakInside: 'avoid',
pageBreakInside: 'avoid',
}}
>
<GridItem>
<BlogTile article={a} />
</Box>
</GridItem>
{articleBanners.length > 0 && idx === insertionIndex && (
<Box
key={`banner-inline-${articleBanners[0].id}`}
mb={7}
sx={{
breakInside: 'avoid',
WebkitColumnBreakInside: 'avoid',
pageBreakInside: 'avoid',
}}
>
<GridItem key={`banner-inline-${articleBanners[0].id}`} colSpan={{ base: 1, sm: 2, lg: 3 }}>
<a
href={articleBanners[0].click_url || '#'}
target={articleBanners[0].click_url ? '_blank' : undefined}
@@ -410,23 +390,16 @@ const BlogPage: React.FC = () => {
<img
src={assetUrl(articleBanners[0].image_url) || '/images/sponsors/placeholder.png'}
alt={articleBanners[0].name}
style={{ width: '100%', height: 'auto', display: 'block' }}
style={{ width: '100%', height: 'auto', display: 'block', borderRadius: 8 }}
loading="lazy"
decoding="async"
/>
</a>
</Box>
</GridItem>
)}
</React.Fragment>
))}
</Box>
{!isLoading && !featuredList.length && !visibleArticles.length && (
<VStack py={16}>
<Text color={textColor}>Žádné články k zobrazení.</Text>
</VStack>
)}
{/* Infinite scroll sentinel */}
<Box ref={sentinelRef} h="1px" />
</Grid>
{isFetchingNextPage && (
<VStack py={6}>
<Text color={textColor}>Načítání</Text>
+280 -222
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
import MainLayout from '../components/layout/MainLayout';
import { Box, Container, Heading, Text, Tabs, TabList, TabPanels, Tab, TabPanel, Flex, Badge, Stack, Spinner, Grid, IconButton, ButtonGroup, Button, Link, Image, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, ModalFooter, useToast, Input, useColorModeValue } from '@chakra-ui/react';
import { Box, Container, Heading, Text, Tabs, TabList, TabPanels, Tab, TabPanel, Flex, Badge, Stack, Spinner, Grid, IconButton, ButtonGroup, Button, Link, Image, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, ModalFooter, useToast, Input, useColorModeValue, Tooltip } from '@chakra-ui/react';
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';
@@ -58,6 +58,9 @@ const CalendarPage: React.FC = () => {
const [clubType, setClubType] = useState<'football' | 'futsal'>('football');
const [standings, setStandings] = useState<any[]>([]);
// Active competition for current tab (memoized)
const activeCompetition = useMemo(() => competitions[tabIndex], [competitions, tabIndex]);
// Color mode values for dark/light theme
const calendarDayBg = useColorModeValue('white', 'gray.800');
const calendarDayBorder = useColorModeValue('gray.200', 'gray.700');
@@ -442,13 +445,12 @@ const CalendarPage: React.FC = () => {
// Get upcoming matches for live countdowns (only future matches)
const upcomingMatches = useMemo(() => {
return competitions.flatMap(comp =>
comp.matches.filter(match => {
const matchTime = new Date(`${match.date}T${match.time || '00:00'}:00`).getTime();
return matchTime > Date.now();
})
);
}, [competitions]);
const list = activeCompetition?.matches || [];
return list.filter(match => {
const matchTime = new Date(`${match.date}T${match.time || '00:00'}:00`).getTime();
return matchTime > Date.now();
});
}, [activeCompetition]);
const liveCountdowns = useMultipleCountdowns(upcomingMatches, 30000); // Update every 30 seconds for better performance
@@ -491,6 +493,20 @@ const CalendarPage: React.FC = () => {
const [viewMode, setViewMode] = useState<'calendar'|'list'>('calendar');
const [expandedDates, setExpandedDates] = useState<Record<string, boolean>>({});
const [showPast, setShowPast] = useState<boolean>(false);
// Compute today's date string in Prague timezone once and reuse
const pragueTodayStr = useMemo(() => {
try {
const parts = new Intl.DateTimeFormat('cs-CZ', {
timeZone: 'Europe/Prague',
year: 'numeric', month: '2-digit', day: '2-digit'
}).formatToParts(new Date());
const y = parts.find(p => p.type === 'year')?.value;
const m = parts.find(p => p.type === 'month')?.value;
const d = parts.find(p => p.type === 'day')?.value;
if (y && m && d) return `${y}-${m}-${d}`;
} catch {}
return format(new Date(), 'yyyy-MM-dd');
}, []);
const weeks = useMemo(() => {
const start = startOfWeek(startOfMonth(monthRef), { weekStartsOn: 1 });
// Build 6 weeks x 7 days
@@ -527,6 +543,20 @@ const CalendarPage: React.FC = () => {
return map;
};
const getMatchCompInfo = (m: MatchItem, comp?: Competition): { display: string; alias?: string } => {
try {
let baseComp: Competition | undefined = comp;
if ((comp?.id === 'all' || !comp) && m.__compId) {
baseComp = competitions.find((cc) => String(cc.id) === String(m.__compId));
}
const display = (m.__compName) || (baseComp?.name || '');
const alias = baseComp?.code ? (aliasMap[baseComp.code]?.alias) : undefined;
return { display, alias };
} catch {
return { display: m.__compName || comp?.name || '', alias: undefined };
}
};
// Sentiment helpers
const isClubTeam = (team: string) => {
try {
@@ -601,7 +631,7 @@ const CalendarPage: React.FC = () => {
)}
{!!competitions.length && (
<Tabs variant="soft-rounded" colorScheme="blue" index={tabIndex} onChange={(i) => setTabIndex(i)}>
<Tabs variant="soft-rounded" colorScheme="blue" index={tabIndex} onChange={(i) => setTabIndex(i)} isLazy lazyBehavior="keepMounted">
{/* Compact, wrapped TabList with better spacing (no overlap) */}
<Box mb={3} position="relative" zIndex={1}>
<TabList
@@ -638,87 +668,106 @@ const CalendarPage: React.FC = () => {
</TabList>
</Box>
<TabPanels>
{competitions.map((c) => {
const byDate = groupByDate(c.matches);
{competitions.map((c, idx) => {
const isActive = idx === tabIndex;
const byDate = isActive ? groupByDate(c.matches) : (new Map() as Map<string, MatchItem[]>);
const mkHref = (m: MatchItem) => (m.facr_link || m.report_url || undefined) ?? (`/zapas/${m.id}`);
// Build latest results (only matches with score)
const nowTs = Date.now();
const nowTs = isActive ? Date.now() : 0;
const compareByDateDesc = (a: MatchItem, b: MatchItem) => new Date(`${b.date}T${(b.time||'00:00')}:00`).getTime() - new Date(`${a.date}T${(a.time||'00:00')}:00`).getTime();
let latestResults: MatchItem[] = [];
if (c.id === 'all') {
// For 'all', pick most recent scored match per competition
const grouped: Record<string, MatchItem[]> = {};
(c.matches || []).forEach((m) => {
if (!m.score) return;
const ts = new Date(`${m.date}T${(m.time||'00:00')}:00`).getTime();
if (isNaN(ts) || ts > nowTs) return; // future results not allowed
const key = m.__compId || 'na';
grouped[key] = grouped[key] || [];
grouped[key].push(m);
});
latestResults = Object.values(grouped)
.map(list => list.sort(compareByDateDesc)[0])
.filter(Boolean)
.sort(compareByDateDesc);
} else {
// Single competition: pick the most recent scored match
latestResults = (c.matches || [])
.filter(m => !!m.score && new Date(`${m.date}T${(m.time||'00:00')}:00`).getTime() <= nowTs)
.sort(compareByDateDesc)
.slice(0, 1);
if (isActive) {
if (c.id === 'all') {
// For 'all', pick most recent scored match per competition
const grouped: Record<string, MatchItem[]> = {};
(c.matches || []).forEach((m) => {
if (!m.score) return;
const ts = new Date(`${m.date}T${(m.time||'00:00')}:00`).getTime();
if (isNaN(ts) || ts > nowTs) return; // future results not allowed
const key = m.__compId || 'na';
grouped[key] = grouped[key] || [];
grouped[key].push(m);
});
latestResults = Object.values(grouped)
.map(list => list.sort(compareByDateDesc)[0])
.filter(Boolean)
.sort(compareByDateDesc);
} else {
// Single competition: pick the most recent scored match
latestResults = (c.matches || [])
.filter(m => !!m.score && new Date(`${m.date}T${(m.time||'00:00')}:00`).getTime() <= nowTs)
.sort(compareByDateDesc)
.slice(0, 1);
}
}
return (
<TabPanel key={c.id} px={0}>
{/* Latest results header list rendered above both calendar and list modes */}
{latestResults.length > 0 && (
{isActive && latestResults.length > 0 && (
<Box mb={4}>
<Heading as="h3" size="md" mb={2}>Nejnovější výsledky</Heading>
<Grid templateColumns={{ base: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={3}>
{latestResults.map((m) => {
const href = mkHref(m);
const info = getMatchCompInfo(m, c);
return (
<Box key={`latest-${c.id}-${m.id}`} position="relative" data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)} borderWidth="1px" borderRadius="md" p={2} _hover={{ textDecoration: 'none', bg: 'rgba(0,0,0,0.03)', borderColor: 'brand.primary', cursor: 'pointer' }}>
{href && (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => { if (!(e.ctrlKey || e.metaKey || e.shiftKey || e.altKey)) e.preventDefault(); }}
onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
onAuxClick={(e) => { if ((e as any).button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
style={{ position: 'absolute', inset: 0, zIndex: 1 }}
aria-hidden
/>
<Tooltip
key={`latest-${c.id}-${m.id}`}
label={(
<Box>
<Text fontSize="xs">Kategorie: {info.display || '—'}</Text>
{info.alias && info.alias !== info.display && (
<Text fontSize="xs">Alias: {info.alias}</Text>
)}
</Box>
)}
<Flex align="center" justify="space-between" mb={2}>
<Text fontSize="sm" color="gray.700">{m.date} {m.time || ''}</Text>
<Badge colorScheme="purple">{m.__compName || c.name}</Badge>
</Flex>
<Flex align="center" gap={2} justify="center">
<TeamLogo
teamId={m.home_id}
teamName={m.home}
facrLogo={m.home_logo_url}
size="custom"
boxSize="18px"
alt={m.home}
borderRadius="full"
/>
<Text fontSize="sm">{m.home}</Text>
<Badge colorScheme={getSentiment(m)?.color || 'gray'}>{m.score || 'vs'}</Badge>
<TeamLogo
teamId={m.away_id}
teamName={m.away}
facrLogo={m.away_logo_url}
size="custom"
boxSize="18px"
alt={m.away}
borderRadius="full"
/>
<Text fontSize="sm">{m.away}</Text>
</Flex>
{href && <Link href={href} isExternal onClick={(e)=> e.stopPropagation()} display="none"/>}
</Box>
hasArrow
placement="top"
openDelay={200}
>
<Box position="relative" data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)} borderWidth="1px" borderRadius="md" p={2} _hover={{ textDecoration: 'none', bg: 'rgba(0,0,0,0.03)', borderColor: 'brand.primary', cursor: 'pointer' }}>
{href && (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => { if (!(e.ctrlKey || e.metaKey || e.shiftKey || e.altKey)) e.preventDefault(); }}
onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
onAuxClick={(e) => { if ((e as any).button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
style={{ position: 'absolute', inset: 0, zIndex: 1 }}
aria-hidden
/>
)}
<Flex align="center" justify="space-between" mb={2}>
<Text fontSize="sm" color="gray.700">{m.date} {m.time || ''}</Text>
<Badge colorScheme="purple">{m.__compName || c.name}</Badge>
</Flex>
<Flex align="center" gap={2} justify="center">
<TeamLogo
teamId={m.home_id}
teamName={m.home}
facrLogo={m.home_logo_url}
size="custom"
boxSize="18px"
alt={m.home}
borderRadius="full"
/>
<Text fontSize="sm">{m.home}</Text>
<Badge colorScheme={getSentiment(m)?.color || 'gray'}>{m.score || 'vs'}</Badge>
<TeamLogo
teamId={m.away_id}
teamName={m.away}
facrLogo={m.away_logo_url}
size="custom"
boxSize="18px"
alt={m.away}
borderRadius="full"
/>
<Text fontSize="sm">{m.away}</Text>
</Flex>
{href && <Link href={href} isExternal onClick={(e)=> e.stopPropagation()} display="none"/>}
</Box>
</Tooltip>
);
})}
</Grid>
@@ -783,22 +832,7 @@ const CalendarPage: React.FC = () => {
const key = format(day, 'yyyy-MM-dd');
const list = byDate.get(key) || [];
const faded = !isSameMonth(day, monthRef);
const today = (() => {
try {
const parts = new Intl.DateTimeFormat('cs-CZ', {
timeZone: 'Europe/Prague',
year: 'numeric', month: '2-digit', day: '2-digit'
}).formatToParts(new Date());
const y = parts.find(p => p.type === 'year')?.value;
const m = parts.find(p => p.type === 'month')?.value;
const d = parts.find(p => p.type === 'day')?.value;
if (y && m && d) {
const pragueToday = parse(`${y}-${m}-${d}`, 'yyyy-MM-dd', new Date());
return isSameDay(day, pragueToday);
}
} catch {}
return isSameDay(day, new Date());
})();
const today = format(day, 'yyyy-MM-dd') === pragueTodayStr;
return (
<Box
key={idx}
@@ -822,45 +856,61 @@ const CalendarPage: React.FC = () => {
const href = mkHref(m);
const isPast = new Date(`${m.date}T${(m.time||'00:00')}:00`).getTime() < Date.now();
const countdown = liveCountdowns[String(m.id)];
const info = getMatchCompInfo(m, c);
return (
<Box key={m.id} position="relative" _hover={{ textDecoration: 'none' }} data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)}>
{href && (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => { if (!(e.ctrlKey || e.metaKey || e.shiftKey || e.altKey)) e.preventDefault(); }}
onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
onAuxClick={(e) => { if ((e as any).button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
style={{ position: 'absolute', inset: 0, zIndex: 1 }}
aria-hidden
/>
<Tooltip
key={m.id}
label={(
<Box>
<Text fontSize="xs">Kategorie: {info.display || '—'}</Text>
{info.alias && info.alias !== info.display && (
<Text fontSize="xs">Alias: {info.alias}</Text>
)}
</Box>
)}
<Box p={2} borderWidth="1px" borderRadius="md" bg={calendarMatchBg} _hover={{ bg: calendarMatchHoverBg, borderColor: 'brand.primary', cursor: 'pointer' }} textAlign="center">
{!isPast && countdown ? (
<>
<Flex align="center" justify="center" gap={2} mb={1}>
{m.home_logo_url && <Image src={m.home_logo_url} alt={m.home} boxSize="18px" borderRadius="full" objectFit="cover" />}
<Badge colorScheme="orange">za {countdown}</Badge>
{m.away_logo_url && <Image src={m.away_logo_url} alt={m.away} boxSize="18px" borderRadius="full" objectFit="cover" />}
</Flex>
<Text fontSize="xs" color="text.secondary">{m.time || '—'}</Text>
</>
) : (
<>
<Flex align="center" justify="center" gap={2} mb={1}>
{m.home_logo_url && <Image src={m.home_logo_url} alt={m.home} boxSize="18px" borderRadius="full" objectFit="cover" />}
<Badge colorScheme={isPast && m.score ? (getSentiment(m)?.color || 'gray') : 'gray'}>{isPast && m.score ? m.score : 'vs'}</Badge>
{m.away_logo_url && <Image src={m.away_logo_url} alt={m.away} boxSize="18px" borderRadius="full" objectFit="cover" />}
</Flex>
<Text fontSize="xs" color="text.secondary">{m.time || '—'}</Text>
</>
hasArrow
placement="top"
openDelay={200}
>
<Box position="relative" _hover={{ textDecoration: 'none' }} data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)}>
{href && (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => { if (!(e.ctrlKey || e.metaKey || e.shiftKey || e.altKey)) e.preventDefault(); }}
onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
onAuxClick={(e) => { if ((e as any).button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
style={{ position: 'absolute', inset: 0, zIndex: 1 }}
aria-hidden
/>
)}
<Box p={2} borderWidth="1px" borderRadius="md" bg={calendarMatchBg} _hover={{ bg: calendarMatchHoverBg, borderColor: 'brand.primary', cursor: 'pointer' }} textAlign="center">
{!isPast && countdown ? (
<>
<Flex align="center" justify="center" gap={2} mb={1}>
{m.home_logo_url && <Image src={m.home_logo_url} alt={m.home} boxSize="18px" borderRadius="full" objectFit="cover" loading="lazy" decoding="async" />}
<Badge colorScheme="orange">za {countdown}</Badge>
{m.away_logo_url && <Image src={m.away_logo_url} alt={m.away} boxSize="18px" borderRadius="full" objectFit="cover" loading="lazy" decoding="async" />}
</Flex>
<Text fontSize="xs" color="text.secondary">{m.time || '—'}</Text>
</>
) : (
<>
<Flex align="center" justify="center" gap={2} mb={1}>
{m.home_logo_url && <Image src={m.home_logo_url} alt={m.home} boxSize="18px" borderRadius="full" objectFit="cover" loading="lazy" decoding="async" />}
<Badge colorScheme={isPast && m.score ? (getSentiment(m)?.color || 'gray') : 'gray'}>{isPast && m.score ? m.score : 'vs'}</Badge>
{m.away_logo_url && <Image src={m.away_logo_url} alt={m.away} boxSize="18px" borderRadius="full" objectFit="cover" loading="lazy" decoding="async" />}
</Flex>
<Text fontSize="xs" color="text.secondary">{m.time || '—'}</Text>
</>
)}
</Box>
{href && (
<Link href={href} isExternal onClick={(e)=> e.stopPropagation()} display="none"/>
)}
</Box>
{href && (
<Link href={href} isExternal onClick={(e)=> e.stopPropagation()} display="none"/>
)}
</Box>
</Tooltip>
);
})}
{list.length > 3 && !expandedDates[key] && (
@@ -884,19 +934,7 @@ const CalendarPage: React.FC = () => {
<Stack spacing={4}>
{(() => {
const keys = Array.from(byDate.keys());
const todayStr = (() => {
try {
const parts = new Intl.DateTimeFormat('cs-CZ', {
timeZone: 'Europe/Prague',
year: 'numeric', month: '2-digit', day: '2-digit'
}).formatToParts(new Date());
const y = parts.find(p => p.type === 'year')?.value;
const m = parts.find(p => p.type === 'month')?.value;
const d = parts.find(p => p.type === 'day')?.value;
if (y && m && d) return `${y}-${m}-${d}`;
} catch {}
return format(new Date(), 'yyyy-MM-dd');
})();
const todayStr = pragueTodayStr;
const pastKeys = keys.filter(k => k < todayStr).sort().reverse();
const futureKeys = keys.filter(k => k >= todayStr).sort();
const renderGroup = (dKey: string, highlight: boolean) => {
@@ -925,96 +963,116 @@ const CalendarPage: React.FC = () => {
const isPast = new Date(`${m.date}T${(m.time||'00:00')}:00`).getTime() < Date.now();
const sentiment = isPast ? getSentiment(m) : null;
const countdown = liveCountdowns[String(m.id)];
const info = getMatchCompInfo(m, c);
return (
<Box key={m.id} position="relative" _hover={{ textDecoration: 'none' }} data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)}>
{href && (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => { e.preventDefault(); }}
onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
onAuxClick={(e) => { if ((e as any).button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
style={{ position: 'absolute', inset: 0, zIndex: 1 }}
aria-hidden
/>
<Tooltip
key={m.id}
label={(
<Box>
<Text fontSize="xs">Kategorie: {info.display || '—'}</Text>
{info.alias && info.alias !== info.display && (
<Text fontSize="xs">Alias: {info.alias}</Text>
)}
</Box>
)}
<Flex
align="center"
justify="space-between"
p={3}
borderWidth="1px"
borderRadius="md"
bg={listMatchBg}
borderColor={listMatchBorder}
_hover={{ bg: listMatchHoverBg, borderColor: 'brand.primary', cursor: 'pointer', transform: 'translateY(-2px)', boxShadow: 'md' }}
transition="all 0.2s"
gap={3}
>
<Flex direction="column" minW="100px">
<Text fontWeight="semibold" color={listDateText} fontSize="sm">{m.date}</Text>
<Text color={listTimeText} fontSize="sm">{m.time || '—'}</Text>
{m.venue && <Text color={listVenueText} fontSize="xs" mt={1}>{m.venue}</Text>}
</Flex>
<Flex align="center" gap={3} flex="1">
{/* Home Team */}
<Flex align="center" gap={2} flex="1" justify="flex-end">
<Text fontSize="sm" fontWeight="medium" textAlign="right" color={listDateText}>
{m.home}
</Text>
{m.home_logo_url && (
<Image
src={m.home_logo_url}
alt={m.home}
boxSize="32px"
borderRadius="full"
objectFit="cover"
border="2px solid"
borderColor="gray.200"
/>
)}
hasArrow
placement="top"
openDelay={200}
>
<Box position="relative" _hover={{ textDecoration: 'none' }} data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)}>
{href && (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => { e.preventDefault(); }}
onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
onAuxClick={(e) => { if ((e as any).button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
style={{ position: 'absolute', inset: 0, zIndex: 1 }}
aria-hidden
/>
)}
<Flex
align="center"
justify="space-between"
p={3}
borderWidth="1px"
borderRadius="md"
bg={listMatchBg}
borderColor={listMatchBorder}
_hover={{ bg: listMatchHoverBg, borderColor: 'brand.primary', cursor: 'pointer', transform: 'translateY(-2px)', boxShadow: 'md' }}
transition="all 0.2s"
gap={3}
>
<Flex direction="column" minW="100px">
<Text fontWeight="semibold" color={listDateText} fontSize="sm">{m.date}</Text>
<Text color={listTimeText} fontSize="sm">{m.time || '—'}</Text>
{m.venue && <Text color={listVenueText} fontSize="xs" mt={1}>{m.venue}</Text>}
</Flex>
{/* Score or Countdown */}
<Flex direction="column" align="center" gap={1} minW="80px">
{!isPast && countdown ? (
<Badge colorScheme="orange" fontSize="sm" px={2}>za {countdown}</Badge>
) : (
<Badge colorScheme={isPast && m.score ? (getSentiment(m)?.color || 'gray') : 'gray'} fontSize="md" px={3} py={1}>
{isPast && m.score ? m.score : 'vs'}
</Badge>
)}
{sentiment && (
<Text fontSize="xs" color={`${sentiment.color}.600`} fontWeight="semibold">
{sentiment.label}
<Flex align="center" gap={3} flex="1">
{/* Home Team */}
<Flex align="center" gap={2} flex="1" justify="flex-end">
<Text fontSize="sm" fontWeight="medium" textAlign="right" color={listDateText}>
{m.home}
</Text>
)}
</Flex>
{/* Away Team */}
<Flex align="center" gap={2} flex="1" justify="flex-start">
{m.away_logo_url && (
<Image
src={m.away_logo_url}
alt={m.away}
boxSize="32px"
borderRadius="full"
objectFit="cover"
border="2px solid"
borderColor="gray.200"
/>
)}
<Text fontSize="sm" fontWeight="medium" textAlign="left" color={listDateText}>
{m.away}
</Text>
{m.home_logo_url && (
<Image
src={m.home_logo_url}
alt={m.home}
boxSize="32px"
borderRadius="full"
objectFit="cover"
border="2px solid"
borderColor="gray.200"
loading="lazy"
decoding="async"
/>
)}
</Flex>
{/* Score or Countdown */}
<Flex direction="column" align="center" gap={1} minW="80px">
{!isPast && countdown ? (
<Badge colorScheme="orange" fontSize="sm" px={2}>za {countdown}</Badge>
) : (
<Badge colorScheme={isPast && m.score ? (getSentiment(m)?.color || 'gray') : 'gray'} fontSize="md" px={3} py={1}>
{isPast && m.score ? m.score : 'vs'}
</Badge>
)}
{sentiment && (
<Text fontSize="xs" color={`${sentiment.color}.600`} fontWeight="semibold">
{sentiment.label}
</Text>
)}
</Flex>
{/* Away Team */}
<Flex align="center" gap={2} flex="1" justify="flex-start">
{m.away_logo_url && (
<Image
src={m.away_logo_url}
alt={m.away}
boxSize="32px"
borderRadius="full"
objectFit="cover"
border="2px solid"
borderColor="gray.200"
loading="lazy"
decoding="async"
/>
)}
<Text fontSize="sm" fontWeight="medium" textAlign="left" color={listDateText}>
{m.away}
</Text>
</Flex>
</Flex>
</Flex>
</Flex>
{href && (
<Link href={href} isExternal onClick={(e)=> e.stopPropagation()} display="none"/>
)}
</Box>
{href && (
<Link href={href} isExternal onClick={(e)=> e.stopPropagation()} display="none"/>
)}
</Box>
</Tooltip>
);
})}
</Stack>
+6 -1
View File
@@ -18,6 +18,7 @@ import { Calendar, Image as ImageIcon, ExternalLink } from 'lucide-react';
import MainLayout from '../components/layout/MainLayout';
import { API_URL } from '../services/api';
import NewsletterCTA from '../components/common/NewsletterCTA';
import { Helmet } from 'react-helmet-async';
interface Album {
id: string;
@@ -133,6 +134,10 @@ const GalleryPage: React.FC = () => {
return (
<MainLayout>
<Helmet>
<title>Fotogalerie</title>
<meta name="description" content="Prohlédněte si alba a fotografie našeho klubu." />
</Helmet>
<Box bg={bgApp} minH="100vh" py={8}>
<Container maxW="7xl">
{/* Header */}
@@ -153,7 +158,7 @@ const GalleryPage: React.FC = () => {
📸 Všechny fotografie jsou z platformy{' '}
<Text
as="a"
href="https://zonerama.com"
href={zoneramaProfileUrl}
target="_blank"
rel="noopener noreferrer"
fontWeight="600"
+14 -15
View File
@@ -20,7 +20,7 @@ const VideosSection = React.lazy(() => import('../components/home/VideosSection'
const MerchSection = React.lazy(() => import('../components/home/MerchSection'));
const PollsWidget = React.lazy(() => import('../components/home/PollsWidget'));
const GallerySection = React.lazy(() => import('../components/home/GallerySection'));
import { getArticles as apiGetArticles, Article as ApiArticle } from '../services/articles';
import { getArticles as apiGetArticles, getFeaturedArticles, Article as ApiArticle } from '../services/articles';
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
import { getUpcomingEvents } from '../services/eventService';
const NewsletterSubscribe = React.lazy(() => import('../components/newsletter/NewsletterSubscribe'));
@@ -104,7 +104,7 @@ const HomePage: React.FC = () => {
// Matches slider auto-centering handled internally by MatchesSlider component
// API-driven players and sponsors
type UiPlayer = { id:number|string; name:string; number?:number; position?:string; image?:string; slug?:string; age?: number; nationality?: string };
type UiPlayer = { id:number|string; name:string; number?:number; position?:string; image?:string; slug?:string; age?: number; nationality?: string; active?: boolean };
type UiSponsor = { id:number|string; name:string; logo:string; url?:string; tier?: string };
type UiBanner = { id:number|string; name:string; image:string; url?:string; placement?:string; width?:number; height?:number };
type UiMerch = { id?: number|string; title?: string; image_url: string; url?: string };
@@ -412,9 +412,9 @@ const HomePage: React.FC = () => {
if (name) setClubName(name);
if (logo) setClubLogo(logo);
// Load players via API
// Load players via API (include inactive to show as non-active instead of hiding)
try {
const apiPlayers: ApiPlayer[] = await apiGetPlayers();
const apiPlayers: ApiPlayer[] = await apiGetPlayers({ active: false });
const mappedPlayers: UiPlayer[] = (apiPlayers || []).map((p: ApiPlayer) => ({
id: p.id,
name: [p.first_name, p.last_name].filter(Boolean).join(' '),
@@ -422,6 +422,7 @@ const HomePage: React.FC = () => {
position: p.position,
image: assetUrl(p.image_url) || undefined,
nationality: (p as any).nationality,
active: Boolean((p as any).is_active),
age: (function(iso?: string){
if (!iso) return undefined;
const d = new Date(iso);
@@ -464,10 +465,10 @@ const HomePage: React.FC = () => {
setBanners(mappedBanners);
} catch {}
// Load featured articles (homepage primary) via API
// Load featured articles (homepage primary) via dedicated endpoint
try {
const resp = await apiGetArticles({ featured: true, page_size: 3 });
const items = (resp?.data || []).map((a: ApiArticle, idx: number) => ({
const resp = await getFeaturedArticles({ page_size: 100 });
const all = (resp?.data || []).map((a: ApiArticle, idx: number) => ({
id: a.id ?? idx + 1,
title: a.title,
excerpt: (a as any).excerpt || (a.content || '').slice(0, 140),
@@ -476,10 +477,11 @@ const HomePage: React.FC = () => {
category: 'Aktuality',
slug: a.slug,
}));
setFeatured(items);
// Ensure non-featured 'news' excludes featured items
// Show only first 3 in hero; exclude only those 3 from the other news list
const top3 = all.slice(0, 3);
setFeatured(top3);
setNews((prev) => {
const featuredKeys = new Set(items.map((f) => (f.slug ? `s:${f.slug}` : `i:${f.id}`)));
const featuredKeys = new Set(top3.map((f) => (f.slug ? `s:${f.slug}` : `i:${f.id}`)));
return (prev || []).filter((n) => !featuredKeys.has(n.slug ? `s:${n.slug}` : `i:${n.id}`));
});
} catch {}
@@ -1064,12 +1066,11 @@ const HomePage: React.FC = () => {
</div>
<div className="track">
{items.map((p)=> (
<div key={p.id} className="card">
<div key={p.id} className="card" style={{ opacity: p.active === false ? 0.6 : 1 }}>
<div className="photo" style={{ backgroundImage: `url(${assetUrl((p as any).image) || '/images/player-placeholder.jpg'})` }} />
<div className="name">{p.name}</div>
<div className="role">{p.position || 'Hráč'}</div>
{typeof p.number !== 'undefined' && <div className="number">#{p.number}</div>}
{typeof p.age === 'number' && <div className="age">{p.age} {czYears(p.age)}</div>}
</div>
))}
</div>
@@ -1678,12 +1679,10 @@ const HomePage: React.FC = () => {
</div>
<div className="scroll-x">
{players.map((p) => (
<a key={p.id} href={p.slug ? `/players/${p.slug}` : `/players/${p.id}`} className="player-card card">
<a key={p.id} href={p.slug ? `/players/${p.slug}` : `/players/${p.id}`} className="player-card card" style={{ opacity: p.active === false ? 0.6 : 1 }}>
<div className="photo" style={{ backgroundImage: `url(${assetUrl(p.image) || p.image})` }} />
<div className="meta">{typeof p.number !== 'undefined' ? (<><span className="nr">#{p.number}</span> {p.name}</>) : p.name}</div>
<div className="pos">{p.position}</div>
{p.nationality ? (<div className="nat"><span className="flag" style={{ marginRight: 6 }}>{getCountryFlag(p.nationality)}</span>{translateNationality(p.nationality)}</div>) : null}
{typeof p.age === 'number' && <div className="age">{p.age} let</div>}
</a>
))}
</div>
+1 -1
View File
@@ -12,7 +12,7 @@ const OverlaySponsorsPage: React.FC = () => {
});
return (
<Box minH="100vh" bg={bg} display="flex" alignItems="center" justifyContent="center" p={6}>
<Box minH="100vh" bg={bg} display="flex" alignItems="center" justifyContent="center" p={4}>
{isLoading ? (
<Center><Spinner /></Center>
) : (
-5
View File
@@ -83,11 +83,6 @@ const PlayerDetailPage: React.FC = () => {
<Text><b>Národnost:</b></Text>
<Text as="span" fontSize="xl">{getCountryFlag(data.nationality)}</Text>
<Text>{translateNationality(data.nationality)}</Text>
{data.date_of_birth ? (
<Text color={useColorModeValue('gray.600', 'gray.400')}>
{(() => { const a = calculateAge(data.date_of_birth); return a != null ? `${a} ${czYears(a)}` : ''; })()}
</Text>
) : null}
</HStack>
)}
{data.date_of_birth && (
+54 -10
View File
@@ -1,4 +1,4 @@
import { Box, Container, Heading, HStack, Image, SimpleGrid, Spinner, Stack, Text, VStack, useColorModeValue, Badge } from '@chakra-ui/react';
import { Box, Container, Heading, HStack, Image, SimpleGrid, Spinner, Stack, Text, VStack, useColorModeValue, Badge, Input, Select, Checkbox, InputGroup, InputLeftElement } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getPlayers } from '../services/public';
import type { Player } from '../services/public';
@@ -6,14 +6,43 @@ import { assetUrl } from '../utils/url';
import { Link as RouterLink } from 'react-router-dom';
import MainLayout from '../components/layout/MainLayout';
import NewsletterCTA from '../components/common/NewsletterCTA';
import { translateNationality, getCountryFlag } from '../utils/nationality';
// nationality display removed per requirements
import { useMemo, useState } from 'react';
import { SearchIcon } from '@chakra-ui/icons';
const PlayersPage: React.FC = () => {
const { data, isLoading, isError } = useQuery<Player[]>({ queryKey: ['players'], queryFn: getPlayers });
const { data, isLoading, isError } = useQuery<Player[]>({ queryKey: ['players'], queryFn: () => getPlayers() });
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textSecondary = useColorModeValue('gray.600', 'gray.400');
const [q, setQ] = useState('');
const [gender, setGender] = useState('');
const [position, setPosition] = useState('');
const [activeOnly, setActiveOnly] = useState(true);
const positions = useMemo(() => {
const all = (data || []).map(p => p.position).filter(Boolean) as string[];
return Array.from(new Set(all));
}, [data]);
const filtered = useMemo(() => {
let list = (data || []).slice();
if (activeOnly) list = list.filter(p => p.is_active !== false);
if (gender) list = list.filter(p => (p.gender || '').toLowerCase() === gender);
if (position) list = list.filter(p => (p.position || '') === position);
if (q.trim()) {
const needle = q.trim().toLowerCase();
list = list.filter(p => {
const name = `${p.first_name} ${p.last_name}`.trim().toLowerCase();
const pos = (p.position || '').toLowerCase();
const jersey = typeof p.jersey_number === 'number' ? String(p.jersey_number) : '';
return name.includes(needle) || pos.includes(needle) || jersey.includes(needle);
});
}
return list;
}, [data, q, gender, position, activeOnly]);
if (isLoading) {
return (
<MainLayout>
@@ -40,8 +69,28 @@ const PlayersPage: React.FC = () => {
<Container maxW="7xl" py={{ base: 6, md: 10 }}>
<VStack align="stretch" spacing={6}>
<Heading as="h1" size={{ base: 'xl', md: '2xl' }}>Hráči</Heading>
<SimpleGrid columns={{ base: 1, md: 4 }} spacing={4}>
<InputGroup>
<InputLeftElement pointerEvents="none">
<SearchIcon color="gray.400" />
</InputLeftElement>
<Input value={q} onChange={(e)=>setQ(e.target.value)} placeholder="Hledat jméno, číslo, pozici" />
</InputGroup>
<Select value={gender} onChange={(e)=>setGender(e.target.value)} placeholder="Pohlaví">
<option value="men">Muž</option>
<option value="women">Žena</option>
</Select>
<Select value={position} onChange={(e)=>setPosition(e.target.value)} placeholder="Pozice">
{positions.map((pos)=> (
<option key={pos} value={pos}>{pos}</option>
))}
</Select>
<HStack>
<Checkbox isChecked={activeOnly} onChange={(e)=>setActiveOnly(e.target.checked)}>Pouze aktivní</Checkbox>
</HStack>
</SimpleGrid>
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={6}>
{data?.map((p) => (
{filtered.map((p) => (
<Stack
key={p.id}
as={RouterLink}
@@ -69,12 +118,7 @@ const PlayersPage: React.FC = () => {
</Box>
<Text fontWeight="bold" fontSize="lg">{p.first_name} {p.last_name}</Text>
<Text color={textSecondary}>{p.position}</Text>
{p.nationality ? (
<HStack spacing={2} color={textSecondary}>
<Text as="span" fontSize="lg">{getCountryFlag(p.nationality)}</Text>
<Text>{translateNationality(p.nationality)}</Text>
</HStack>
) : null}
{/* Národnost skryta */}
</Stack>
))}
</SimpleGrid>
+176 -16
View File
@@ -22,6 +22,17 @@ type TableRow = {
points: string;
};
function deriveTeamIdFromLogoUrl(url?: string): string | undefined {
try {
const u = String(url || '');
if (!u) return undefined;
const m = u.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/);
return m ? m[0].toLowerCase() : undefined;
} catch {
return undefined;
}
}
type CompetitionTable = {
id: string;
name: string;
@@ -51,6 +62,7 @@ const TablesPage: React.FC = () => {
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string; display_order?: number }>>({});
const [selectedClub, setSelectedClub] = useState<any>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [overrides, setOverrides] = useState<{ by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> } | null>(null);
const { data: settings } = usePublicSettings();
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
@@ -61,6 +73,32 @@ const TablesPage: React.FC = () => {
const rowOddBg = useColorModeValue('white', 'gray.800');
const rowEvenBg = useColorModeValue('gray.50', 'gray.700');
type SortKey = 'rank' | 'team' | 'played' | 'wins' | 'draws' | 'losses' | 'score' | 'points';
type SortOrder = 'desc' | 'asc';
const [sortState, setSortState] = useState<Record<string, { key: SortKey; order: SortOrder } | null>>({});
const toNumber = (v: any): number => {
if (typeof v === 'number') return v;
const n = parseFloat(String(v ?? '').replace(/[^0-9\-\.]/g, ''));
return isNaN(n) ? 0 : n;
};
const scoreDiff = (s: any): number => {
const str = String(s ?? '').trim();
const m = str.match(/(-?\d+)\s*[:\-]\s*(-?\d+)/);
if (m) return Number(m[1]) - Number(m[2]);
return toNumber(str);
};
const toggleSort = (compId: string, key: SortKey) => {
const cur = sortState[compId];
if (!cur || cur.key !== key) { setSortState({ ...sortState, [compId]: { key, order: 'desc' } }); return; }
if (cur.order === 'desc') { setSortState({ ...sortState, [compId]: { key, order: 'asc' } }); return; }
const next = { ...sortState }; next[compId] = null; setSortState(next);
};
const arrow = (compId: string, key: SortKey) => {
const cur = sortState[compId];
if (!cur || cur.key !== key) return '';
return cur.order === 'desc' ? '▼' : '▲';
};
const handleClubClick = (club: any) => {
setSelectedClub(club);
setIsModalOpen(true);
@@ -72,6 +110,22 @@ const TablesPage: React.FC = () => {
setLoading(true);
setError(null);
try {
// Load overrides (API + cached file)
try {
const now = Date.now();
let ovr: any = null;
try {
const res = await fetch(`/api/v1/public/team-logo-overrides?t=${now}`, { cache: 'no-cache' });
if (res.ok) ovr = await res.json();
} catch {}
if (!ovr) {
try {
const res2 = await fetch('/cache/prefetch/team_logo_overrides.json', { cache: 'no-cache' });
if (res2.ok) ovr = await res2.json();
} catch {}
}
if (!cancelled) setOverrides(ovr || { by_id: {}, by_name: {} });
} catch {}
// Load aliases first
let amap: Record<string, { alias: string; original_name?: string; display_order?: number }> = {};
try {
@@ -123,6 +177,83 @@ const TablesPage: React.FC = () => {
return () => { cancelled = true; };
}, []);
// Normalization helpers (same as CalendarPage/TableSection)
const normalize = (s: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const stripPrefixes = (s: string) => {
let x = normalize(s);
x = x.replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g, '');
return x.replace(/\s+/g, ' ').trim();
};
const byNameMap = useMemo(() => {
const m: Record<string, string> = {};
const src = overrides?.by_name || {};
for (const k of Object.keys(src)) m[normalize(k)] = src[k];
return m;
}, [overrides]);
const byIdMap = useMemo(() => (overrides?.by_id || {}) as Record<string, { name?: string; logo_url?: string }>, [overrides]);
const overridesNameIndex = useMemo(() => {
const idx: Record<string, { id: string; name: string }> = {};
try {
for (const [id, v] of Object.entries(byIdMap)) {
const name = String((v as any)?.name || '').trim();
if (!name) continue;
const key = normalize(name);
if (!key) continue;
idx[key] = { id, name };
}
} catch {}
return idx;
}, [byIdMap]);
const pickName = (teamId?: string, original?: string, logoUrl?: string) => {
const id = String(teamId || '') || deriveTeamIdFromLogoUrl(logoUrl) || '';
const v = id ? byIdMap?.[id]?.name : undefined;
if (v && String(v).trim().length > 0) return String(v);
const orig = String(original || '');
if (orig) {
const n = normalize(orig);
let hit = overridesNameIndex[n];
if (!hit) {
for (const [k, val] of Object.entries(overridesNameIndex)) {
if (!k) continue;
if (n.endsWith(k) || k.endsWith(n)) { hit = val as any; break; }
}
}
if (!hit) {
const t1 = n.split(' ')[0];
if (t1 && t1.length >= 5) {
for (const [k, val] of Object.entries(overridesNameIndex)) {
const k1 = String(k).split(' ')[0];
if (k1 === t1) { hit = val as any; break; }
}
}
}
if (hit?.name) return hit.name;
}
return orig;
};
const pickLogo = (teamId?: string, teamName?: string, original?: string): string | undefined => {
if (teamId && byIdMap?.[teamId]?.logo_url) return byIdMap[teamId]!.logo_url as string;
if (teamName) {
const exact = (overrides?.by_name || {})[teamName];
if (exact) return exact;
const n = normalize(teamName);
const cand = byNameMap[n];
if (cand) return cand;
const stripped = stripPrefixes(teamName);
for (const k of Object.keys(overrides?.by_name || {})) {
const kn = stripPrefixes(k);
if (!kn) continue;
if (stripped.endsWith(kn) || kn.endsWith(stripped)) return (overrides!.by_name as any)[k];
}
}
return original;
};
return (
<MainLayout>
<Container maxW="7xl" py={{ base: 6, md: 10 }}>
@@ -172,18 +303,47 @@ const TablesPage: React.FC = () => {
<Table size="sm" variant="unstyled" color={tableTextColor}>
<Thead position="sticky" top={0} zIndex={2}>
<Tr bg={tableHeaderBg} color="white">
<Th w="56px" color="white">#</Th>
<Th color="white">Tým</Th>
<Th isNumeric color="white">Z</Th>
<Th isNumeric color="white">V</Th>
<Th isNumeric color="white">R</Th>
<Th isNumeric color="white">P</Th>
<Th isNumeric color="white">Skóre</Th>
<Th isNumeric color="white">Body</Th>
<Th w="56px" color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'rank')}># {arrow(c.id, 'rank')}</Th>
<Th color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'team')}>Tým {arrow(c.id, 'team')}</Th>
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'played')}>Z {arrow(c.id, 'played')}</Th>
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'wins')}>V {arrow(c.id, 'wins')}</Th>
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'draws')}>R {arrow(c.id, 'draws')}</Th>
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'losses')}>P {arrow(c.id, 'losses')}</Th>
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'score')}>Skóre {arrow(c.id, 'score')}</Th>
<Th isNumeric color="white" cursor="pointer" onClick={() => toggleSort(c.id, 'points')}>Body {arrow(c.id, 'points')}</Th>
</Tr>
</Thead>
<Tbody>
{c.rows.map((r, idx) => (
{(() => {
const cur = sortState[c.id];
const arr = [...(c.rows || [])];
if (cur) {
arr.sort((a: any, b: any) => {
let va: any; let vb: any; let isText = false;
switch (cur.key) {
case 'team': va = pickName(a.team_id, a.team, a.team_logo_url); vb = pickName(b.team_id, b.team, b.team_logo_url); isText = true; break;
case 'rank': va = toNumber(a.rank); vb = toNumber(b.rank); break;
case 'played': va = toNumber(a.played); vb = toNumber(b.played); break;
case 'wins': va = toNumber(a.wins); vb = toNumber(b.wins); break;
case 'draws': va = toNumber(a.draws); vb = toNumber(b.draws); break;
case 'losses': va = toNumber(a.losses); vb = toNumber(b.losses); break;
case 'score': va = scoreDiff(a.score); vb = scoreDiff(b.score); break;
case 'points': va = toNumber(a.points); vb = toNumber(b.points); break;
default: va = 0; vb = 0;
}
let res = isText ? String(va).localeCompare(String(vb)) : (va as number) - (vb as number);
if (cur.order === 'desc') res = -res;
if (res === 0) {
const ra = toNumber(a.rank); const rb = toNumber(b.rank);
res = ra - rb;
}
return res;
});
}
return arr.map((r, idx) => {
const displayTeam = pickName(r.team_id, r.team, r.team_logo_url);
const displayLogo = pickLogo(r.team_id, displayTeam, r.team_logo_url);
return (
<Tr
key={`${c.id}-${r.rank}-${r.team}`}
transition="all 0.15s"
@@ -198,17 +358,17 @@ const TablesPage: React.FC = () => {
<Td>
<Flex align="center" gap={3}>
<TeamLogo
teamId={r.team_id}
teamName={r.team}
facrLogo={r.team_logo_url}
teamId={r.team_id || deriveTeamIdFromLogoUrl(r.team_logo_url)}
teamName={displayTeam}
facrLogo={displayLogo}
size="small"
alt={r.team}
borderRadius="full"
alt={displayTeam}
objectFit="contain"
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
/>
<Text fontWeight="medium" color={tableTextColor}>{r.team}</Text>
<Text fontWeight="medium" color={tableTextColor}>{displayTeam}</Text>
</Flex>
</Td>
<Td isNumeric color={tableTextColor}>{r.played}</Td>
@@ -220,7 +380,7 @@ const TablesPage: React.FC = () => {
<Badge variant="solid" bg="blue.600" color="white">{r.points}</Badge>
</Td>
</Tr>
))}
);});})()}
</Tbody>
</Table>
</Box>
+8 -2
View File
@@ -28,6 +28,7 @@ import { getCachedYouTube, YouTubeVideo } from '../services/youtube';
import { FaPlay, FaExternalLinkAlt, FaYoutube } from 'react-icons/fa';
import NewsletterCTA from '../components/common/NewsletterCTA';
import CommentsSection from '../components/comments/CommentsSection';
import { Helmet } from 'react-helmet-async';
type RenderItem = {
key: string;
@@ -79,6 +80,7 @@ const VideosPage: React.FC = () => {
const source = settings?.videos_source || 'auto';
const youtubeUrl = (settings as any)?.youtube_url || (settings as any)?.social_youtube || null;
const titleOverrides: Record<string, string> = (settings as any)?.videos_title_overrides || {};
useEffect(() => {
let canceled = false;
@@ -113,7 +115,7 @@ const VideosPage: React.FC = () => {
if (source === 'auto') {
return (yt || []).map((v) => ({
key: v.video_id,
title: v.title,
title: (titleOverrides?.[v.video_id]?.trim()) || v.title,
embedUrl: toEmbed(v.video_id),
thumbnail: v.thumbnail_url,
date: v.published_date,
@@ -142,7 +144,7 @@ const VideosPage: React.FC = () => {
};
});
return manual.length ? manual : legacy;
}, [source, yt, settings?.videos_items, settings]);
}, [source, yt, settings?.videos_items, settings, titleOverrides]);
const openVideo = (item: RenderItem) => {
setSelectedVideo(item);
@@ -267,6 +269,10 @@ const VideosPage: React.FC = () => {
return (
<MainLayout>
<Helmet>
<title>Videa</title>
<meta name="description" content="Sledujte naše nejnovější videa a zápasy." />
</Helmet>
<Container maxW="7xl" py={{ base: 6, md: 10 }}>
<Box mb={6}>
<HStack justify="space-between" mb={2} flexWrap="wrap">
@@ -4,11 +4,9 @@ import AdminLayout from '../../layouts/AdminLayout';
import { getAnalytics, AnalyticsData, getAnalyticsOverview, getTopPages, AnalyticsOverview, PageStats } from '../../services/analyticsService';
import { MatchesWidget } from '../../components/widgets/MatchesWidget';
import { ArticlesWidget } from '../../components/widgets/ArticlesWidget';
import { FaUsers, FaCalendarAlt, FaNewspaper, FaTrophy, FaChartLine, FaCog, FaBook, FaRocket, FaEye, FaMousePointer } from 'react-icons/fa';
import { FaUsers, FaCalendarAlt, FaNewspaper, FaChartLine, FaCog, FaBook, FaRocket, FaEye, FaMousePointer } from 'react-icons/fa';
import AdminHelp from '../../components/admin/AdminHelp';
import { getFacrTablesCache } from '../../services/facr/cache';
import ScoreboardPreview from '../../components/scoreboard/ScoreboardPreview';
import { getScoreboardState, ScoreboardState } from '../../services/scoreboard';
import { Link as RouterLink } from 'react-router-dom';
import api from '../../services/api';
@@ -197,13 +195,7 @@ const AdminDashboardPage = () => {
return 0;
}
})();
// Scoreboard state for compact preview
const { data: scoreboardState } = useQuery<ScoreboardState>({
queryKey: ['scoreboard-state'],
queryFn: getScoreboardState,
staleTime: 60 * 1000,
});
return (
<AdminLayout>
@@ -449,29 +441,6 @@ const AdminDashboardPage = () => {
<SimpleGrid columns={{ base: 1, md: 2, xl: 3 }} spacing={6} mb={8}>
<MatchesWidget />
<ArticlesWidget />
{/* Compact Scoreboard card */}
<Box
bg={useColorModeValue('white', 'gray.800')}
p={5}
borderRadius="xl"
boxShadow="md"
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'gray.700')}
>
<HStack justify="space-between" mb={4}>
<Text fontWeight="bold" fontSize="lg">Aktuální tabule</Text>
<Link as={RouterLink} to="/admin/scoreboard" color="blue.500" fontSize="sm" fontWeight="semibold">
Upravit
</Link>
</HStack>
{scoreboardState ? (
<Box display="flex" justifyContent="center">
<ScoreboardPreview state={scoreboardState} />
</Box>
) : (
<Skeleton height="40px" />
)}
</Box>
</SimpleGrid>
{/* Admin guidance */}
+8 -8
View File
@@ -337,8 +337,8 @@ const AdminDocsPage: React.FC = () => {
{ icon: FaNewspaper, title: 'Články', desc: 'Publikujte novinky a reportáže', link: '/admin/clanky' },
{ icon: FaFutbol, title: 'Zápasy', desc: 'Automatické načítání z FAČR', link: '/admin/zapasy' },
{ icon: FaUsers, title: 'Hráči a týmy', desc: 'Správa soupisek', link: '/admin/hraci' },
{ icon: FaImage, title: 'Galerie', desc: 'Fotogalerie a alba', link: '/admin/gallery' },
{ icon: FaPhotoVideo, title: 'Média', desc: 'Nahrávání obrázků a souborů', link: '/admin/media' },
{ icon: FaImage, title: 'Galerie', desc: 'Fotogalerie a alba', link: '/admin/galerie' },
{ icon: FaPhotoVideo, title: 'Média', desc: 'Nahrávání obrázků a souborů', link: '/admin/soubory' },
{ icon: FaEnvelope, title: 'Newsletter', desc: 'E-mailové kampaně', link: '/admin/newsletter' },
{ icon: FaCog, title: 'Nastavení klubu', desc: 'Logo, barvy, kontakty', link: '/admin/nastaveni' },
{ icon: FaHandshake, title: 'Sponzoři', desc: 'Správa partnerů klubu', link: '/admin/sponzori' },
@@ -442,7 +442,7 @@ const AdminDocsPage: React.FC = () => {
</ListItem>
<ListItem>
<strong>Logo klubu</strong> Nejdříve nahrajte logo do sekce{' '}
<Link href="/admin/media" color="blue.600" fontWeight="bold">
<Link href="/admin/soubory" color="blue.600" fontWeight="bold">
Média
</Link>
, poté zkopírujte adresu obrázku (URL) a vložte ji sem
@@ -631,7 +631,7 @@ const AdminDocsPage: React.FC = () => {
</ListItem>
<ListItem>
<strong>Přidejte hlavní obrázek</strong> Nejprve nahrajte obrázek do{' '}
<Link href="/admin/media" color="blue.600" fontWeight="bold">Média</Link>,
<Link href="/admin/soubory" color="blue.600" fontWeight="bold">Média</Link>,
poté zkopírujte jeho adresu (URL) a vložte ji do pole "Obrázek"
</ListItem>
<ListItem>
@@ -660,7 +660,7 @@ const AdminDocsPage: React.FC = () => {
<strong>Pro obrázky:</strong>
</Text>
<OrderedList spacing={2} fontSize="sm">
<ListItem>Nahrajte obrázek v sekci <Link href="/admin/media" color="blue.600">Média</Link></ListItem>
<ListItem>Nahrajte obrázek v sekci <Link href="/admin/soubory" color="blue.600">Média</Link></ListItem>
<ListItem>Zkopírujte adresu obrázku (např. <Code>/uploads/2025/01/foto.jpg</Code>)</ListItem>
<ListItem>V editoru článku použijte HTML: <Code>&lt;img src="/uploads/2025/01/foto.jpg" alt="Popis" /&gt;</Code></ListItem>
</OrderedList>
@@ -891,7 +891,7 @@ const AdminDocsPage: React.FC = () => {
Vše, co nahrajete zde, můžete pak použít v článcích, bannerech, newsletterech nebo na stránce O klubu.
</Text>
<Link href="/admin/media" isExternal>
<Link href="/admin/soubory" isExternal>
<HStack
p={3}
bg={useColorModeValue('blue.50', 'blue.900')}
@@ -913,7 +913,7 @@ const AdminDocsPage: React.FC = () => {
<OrderedList spacing={2} pl={5}>
<ListItem>
Otevřete sekci{' '}
<Link href="/admin/media" color="blue.600" fontWeight="bold">Média</Link>
<Link href="/admin/soubory" color="blue.600" fontWeight="bold">Média</Link>
</ListItem>
<ListItem>
Klikněte na tlačítko <strong>"Nahrát soubor"</strong> nebo <strong>"Upload"</strong>
@@ -1806,7 +1806,7 @@ const AdminDocsPage: React.FC = () => {
<List spacing={2} styleType="disc" pl={5}>
<ListItem>
Nahrajte obrázek do{' '}
<Link href="/admin/media" color="blue.600">
<Link href="/admin/soubory" color="blue.600">
Média
</Link>
</ListItem>
+33 -2
View File
@@ -45,6 +45,8 @@ const AdminVideosPage: React.FC = () => {
const [autoLoading, setAutoLoading] = useState<boolean>(false);
const [autoError, setAutoError] = useState<string>('');
const [filter, setFilter] = useState<string>('');
// Title overrides for auto mode (video_id -> title)
const [titleOverrides, setTitleOverrides] = useState<Record<string, string>>({});
// Derived flags
const hasChannel = useMemo(() => (channelInput || '').trim().length > 0, [channelInput]);
@@ -67,6 +69,8 @@ const AdminVideosPage: React.FC = () => {
// Prefill channel handle from settings if available (social/youtube_url)
const ytUrl = (s as any).youtube_url || (s as any).social_youtube || '';
if (ytUrl) setChannelInput(ytUrl);
// Load existing overrides
setTitleOverrides(((s as any).videos_title_overrides as any) || {});
} catch (e) {
// ignore
} finally {
@@ -95,6 +99,15 @@ const AdminVideosPage: React.FC = () => {
if (mounted) setAutoLoading(false);
}
};
const saveOverrides = async () => {
try {
await updateAdminSettings({ videos_title_overrides: titleOverrides } as any);
toast({ status: 'success', title: 'Přepisy uloženy', description: 'Názvy videí byly aktualizovány.', duration: 2500 });
} catch (e) {
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se uložit přepisy názvů.', duration: 3000 });
}
};
run();
return () => { mounted = false; };
}, [loading, videosSource]);
@@ -410,6 +423,7 @@ const AdminVideosPage: React.FC = () => {
<HStack spacing={2} flexWrap="wrap">
<Input size="sm" placeholder="Filtrovat podle názvu" value={filter} onChange={(e) => setFilter(e.target.value)} width={{ base: '100%', md: '260px' }} />
<Button size="sm" onClick={refreshAuto} isLoading={autoLoading} variant="outline" flexShrink={0} minW="max-content">Aktualizovat cache</Button>
<Button size="sm" colorScheme="blue" variant="solid" onClick={saveOverrides} flexShrink={0} minW="max-content">Uložit přepisy názvů</Button>
</HStack>
)}
</HStack>
@@ -422,10 +436,10 @@ const AdminVideosPage: React.FC = () => {
<HStack color="gray.600"><Spinner size="sm" /><Text>Načítám videa</Text></HStack>
) : (
<>
<Text fontSize="sm" color="gray.600" mb={2}>Počet videí: {autoVideos.filter(v => v.title.toLowerCase().includes(filter.toLowerCase())).length}</Text>
<Text fontSize="sm" color="gray.600" mb={2}>Počet videí: {autoVideos.filter(v => (v.title || '').toLowerCase().includes(filter.toLowerCase())).length}</Text>
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={3}>
{autoVideos
.filter(v => v.title.toLowerCase().includes(filter.toLowerCase()))
.filter(v => (v.title || '').toLowerCase().includes(filter.toLowerCase()))
.map((v) => (
<Box key={v.video_id} borderWidth="1px" borderRadius="md" p={2}>
<VStack align="stretch" spacing={2}>
@@ -435,6 +449,23 @@ const AdminVideosPage: React.FC = () => {
<HStack spacing={2} color="gray.600" fontSize="sm">
{v.published_date && <Badge>{new Date(v.published_date).toLocaleDateString('cs-CZ')}</Badge>}
</HStack>
<FormControl mt={2}>
<FormLabel fontSize="xs" mb={1}>Přepis názvu (volitelné)</FormLabel>
<Input
size="sm"
placeholder="Např. Zápas A-týmu vs. B-tým"
value={(titleOverrides[v.video_id] ?? '')}
onChange={(e) => {
const val = e.target.value;
setTitleOverrides(prev => ({ ...prev, [v.video_id]: val }));
}}
/>
</FormControl>
{!!(titleOverrides[v.video_id]?.length) && (
<HStack justify="flex-end" mt={1}>
<Button size="xs" variant="ghost" onClick={() => setTitleOverrides(prev => { const n = { ...prev }; delete n[v.video_id]; return n; })}>Vymazat přepis</Button>
</HStack>
)}
</Box>
</VStack>
</Box>
+91 -38
View File
@@ -214,6 +214,7 @@ const ArticlesAdminPage = () => {
date: m.date_time || m.date || '',
label: `${m.date_time || m.date || ''}${m.home || m.home_team || ''} ${score} ${m.away || m.away_team || ''} ${c?.name ? '('+c.name+')' : ''}`.trim(),
competition: c?.name || '',
competition_code: c?.code || c?.id || '',
home: m.home || m.home_team || '',
away: m.away || m.away_team || '',
score: score
@@ -248,6 +249,7 @@ const ArticlesAdminPage = () => {
const [featSwitchLoading, setFeatSwitchLoading] = useState<boolean>(false);
const [competitions, setCompetitions] = useState<Array<{ code?: string; name: string }>>([]);
const [aliasesMap, setAliasesMap] = useState<Record<string, string>>({});
const [aliasesList, setAliasesList] = useState<Array<{ code: string; alias: string; original_name?: string }>>([]);
// Match link state
const [linkedMatchId, setLinkedMatchId] = useState<string>('');
const [linkedMatchTitle, setLinkedMatchTitle] = useState<string>('');
@@ -281,7 +283,51 @@ const ArticlesAdminPage = () => {
// If article has ID, update it as draft
if (data.id) {
try {
return await updateArticle(data.id, { ...data as any, published: false });
// Build safe minimal payload the backend expects
const attachmentsNorm = (() => {
const a: any = (data as any)?.attachments;
if (!Array.isArray(a) || a.length === 0) return undefined;
return a.map((it: any) => {
const name = it?.name || (String(it?.url || '').split('/').pop() || 'soubor');
const url = it?.url || '';
const mime_type = it?.mime_type || it?.type;
const size = typeof it?.size === 'number' ? it.size : undefined;
return { name, url, mime_type, size };
});
})();
const galleryIdsNorm = (() => {
const g: any = (data as any)?.gallery_photo_ids;
if (Array.isArray(g)) return g.map(String);
return undefined;
})();
const isPublished = !!(data as any)?.published;
const payload: UpdateArticlePayload = {
title: (data as any)?.title || '',
...(((typeof (data as any)?.content === 'string') && ((String((data as any)?.content || '').trim().length > 0) || !isPublished)) ? { content: (data as any)?.content || '' } : {}),
image_url: (data as any)?.image_url || '',
...(typeof (data as any)?.category_id === 'number' ? { category_id: (data as any).category_id } : {}),
category_name: (data as any)?.category_name || undefined,
slug: (data as any)?.slug || undefined,
seo_title: (data as any)?.seo_title || undefined,
seo_description: (data as any)?.seo_description || undefined,
og_image_url: (data as any)?.og_image_url || undefined,
featured: !!(data as any)?.featured,
// Gallery fields
gallery_album_id: (data as any)?.gallery_album_id || undefined,
gallery_album_url: (data as any)?.gallery_album_url || undefined,
...(galleryIdsNorm ? { gallery_photo_ids: galleryIdsNorm } : {}),
// YouTube fields
youtube_video_id: (data as any)?.youtube_video_id || undefined,
youtube_video_title: (data as any)?.youtube_video_title || undefined,
youtube_video_url: (data as any)?.youtube_video_url || undefined,
youtube_video_thumbnail: (data as any)?.youtube_video_thumbnail || undefined,
// Attachments
...(attachmentsNorm ? { attachments: attachmentsNorm } : {}),
} as UpdateArticlePayload;
return await updateArticle(data.id, payload);
} catch (e: any) {
const status = e?.response?.status;
if (status === 404 && data.title?.trim()) {
@@ -422,7 +468,7 @@ const ArticlesAdminPage = () => {
if (!q) return youtubeVideos;
return youtubeVideos.filter((video) => {
const title = (video.title || '').toLowerCase();
return title.includes(q) || video.video_id.toLowerCase().includes(q);
return title.includes(q) || String(video.video_id || '').toLowerCase().includes(q);
});
}, [youtubeVideos, youtubeSearch]);
@@ -586,48 +632,54 @@ const ArticlesAdminPage = () => {
const filteredMatchOptions = useMemo(() => {
let opts = matchOptions;
// Get category name and find all possible matches (including via aliases)
const cat = String((editing as any)?.category_name || '').trim().toLowerCase();
const normalize = (s?: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const toAlias = (compName?: string): string => {
const n = normalize(compName);
for (const a of aliasesList) {
const aAlias = normalize(a.alias);
const aOrig = normalize(a.original_name || '');
if ((aOrig && (n === aOrig || n.includes(aOrig) || aOrig.includes(n))) ||
(aAlias && (n === aAlias || n.includes(aAlias) || aAlias.includes(n)))) {
return a.alias;
}
}
return String(compName || '');
};
const catRaw = String((editing as any)?.category_name || '').trim();
const cat = normalize(catRaw);
if (cat) {
// Find matching competition codes from aliases
const matchingCodes = Object.entries(aliasesMap)
.filter(([code, alias]) => alias.toLowerCase() === cat)
.map(([code]) => code);
// Find matching competition names from competitions list
const matchingNames = competitions
.filter(c => c.name.toLowerCase() === cat)
.map(c => c.code)
.filter(Boolean) as string[];
const allCodes = [...new Set([...matchingCodes, ...matchingNames])];
// Filter matches by competition code or name
const selectedCodes = new Set<string>();
for (const a of aliasesList) {
const aAlias = normalize(a.alias);
const aOrig = normalize(a.original_name || '');
if ((aAlias && aAlias === cat) || (aOrig && aOrig === cat)) {
selectedCodes.add(a.code);
}
}
opts = opts.filter(o => {
const compName = (o.competition || '').toLowerCase();
// Match by alias, category name, or competition name
if (compName.includes(cat)) return true;
// Check if competition name matches any of our codes via reverse lookup
const compCode = Object.entries(aliasesMap).find(([code, alias]) =>
compName.includes(alias.toLowerCase())
)?.[0];
if (compCode && allCodes.includes(compCode)) return true;
// Direct code matching
return allCodes.some(code => {
const aliasForCode = aliasesMap[code] || '';
return compName.includes(code.toLowerCase()) || compName.includes(aliasForCode.toLowerCase());
});
const compName = String(o.competition || '');
const compNorm = normalize(compName);
const compAlias = normalize(toAlias(compName));
const code = String((o as any).competition_code || '');
const codeMatch = selectedCodes.size > 0 && code ? selectedCodes.has(code) : false;
return codeMatch || compNorm === cat || compAlias === cat || compNorm.includes(cat) || compAlias.includes(cat);
});
}
// Search filter
const q = matchSearch.trim().toLowerCase();
if (q) {
opts = opts.filter(o => o.label.toLowerCase().includes(q));
}
// Date filter
if (matchDateFilter) {
opts = opts.filter(o => {
const dateStr = o.date || '';
@@ -660,9 +712,9 @@ const ArticlesAdminPage = () => {
if (aUpcoming) return da - db;
return Math.abs(da) - Math.abs(db);
});
return opts;
}, [matchOptions, matchSearch, matchDateFilter, (editing as any)?.category_name, aliasesMap, competitions]);
}, [matchOptions, matchSearch, matchDateFilter, (editing as any)?.category_name, aliasesList]);
// Load club competitions + aliases for quick category pick
React.useEffect(() => {
@@ -683,6 +735,7 @@ const ArticlesAdminPage = () => {
try {
const list = await getCompetitionAliasesPublic();
list.forEach((a) => { if (a.code && a.alias) amap[a.code] = a.alias; });
setAliasesList(list as any);
} catch {}
// Apply aliases to names for display
const withAliases = comps.map((c) => ({ code: c.code, name: (c.code && amap[c.code]) ? amap[c.code] : c.name }));
@@ -3,14 +3,9 @@ import {
Box,
Button,
Flex,
FormControl,
FormLabel,
Heading,
HStack,
IconButton,
Input,
Select,
Spinner,
Table,
Tbody,
Td,
@@ -18,17 +13,12 @@ import {
Th,
Thead,
Tr,
useDisclosure,
useToast,
VStack,
Badge,
Tooltip,
Alert,
AlertIcon,
useColorModeValue,
Divider,
} from '@chakra-ui/react';
import { FiPlus, FiTrash2, FiSave, FiRefreshCcw, FiDownload, FiEdit3, FiMove } from 'react-icons/fi';
import { FiTrash2, FiSave, FiRefreshCcw, FiDownload, FiEdit3, FiMove } from 'react-icons/fi';
import {
CompetitionAlias,
getCompetitionAliasesAdmin,
@@ -42,13 +32,9 @@ import { API_URL } from '../../services/api';
const CompetitionAliasesAdminPage: React.FC = () => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.700');
const toast = useToast();
const [items, setItems] = useState<CompetitionAlias[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [newCode, setNewCode] = useState('');
const [newAlias, setNewAlias] = useState('');
const [editing, setEditing] = useState<Record<string, { alias: string }>>({});
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [reorderMode, setReorderMode] = useState<boolean>(false);
@@ -152,26 +138,6 @@ const CompetitionAliasesAdminPage: React.FC = () => {
}
};
const onAdd = async () => {
const code = newCode.trim();
const alias = newAlias.trim();
if (!code || !alias) {
toast({ title: 'Vyplňte code a alias', status: 'warning' });
return;
}
try {
const saved = await upsertCompetitionAlias(code, { alias });
setItems((prev) => {
const filtered = prev.filter((i) => i.code !== saved.code);
return [...filtered, saved].sort((a, b) => a.code.localeCompare(b.code));
});
setNewCode(''); setNewAlias('');
toast({ title: 'Alias uložen', status: 'success' });
} catch (e: any) {
toast({ title: 'Uložení selhalo', description: e?.message || 'Zkuste znovu', status: 'error' });
}
};
const onSave = async (code: string) => {
const data = editing[code];
if (!data) return;
@@ -408,71 +374,6 @@ const CompetitionAliasesAdminPage: React.FC = () => {
)}
</Flex>
{/* Add New Section - Hidden in reorder mode */}
{!reorderMode && (
<Box
bg={cardBg}
borderWidth="1px"
borderColor="gray.200"
borderRadius="lg"
p={6}
mb={6}
shadow="sm"
_hover={{ shadow: 'md' }}
transition="all 0.2s"
>
<HStack mb={4} spacing={2}>
<Box bg="blue.500" p={2} borderRadius="md">
<FiPlus color="white" size={18} />
</Box>
<Heading size="md" color="gray.700">Přidat nový alias</Heading>
</HStack>
<Divider mb={4} />
<Flex gap={3} wrap="wrap" align="flex-end">
<VStack align="flex-start" spacing={2} flex="0 0 240px">
<Text fontSize="sm" fontWeight="medium" color="gray.600">Kód soutěže</Text>
<Input
placeholder="např. A1A"
value={newCode}
onChange={(e) => setNewCode(e.target.value.toUpperCase())}
size="md"
bg={useColorModeValue('gray.50', 'gray.900')}
borderColor="gray.300"
_hover={{ borderColor: 'blue.400', bg: 'white' }}
_focus={{ borderColor: 'blue.500', bg: 'white', shadow: 'sm' }}
fontFamily="mono"
fontWeight="semibold"
/>
</VStack>
<VStack align="flex-start" spacing={2} flex="1" minW="300px">
<Text fontSize="sm" fontWeight="medium" color="gray.600">Zobrazovaný název (alias)</Text>
<Input
placeholder="např. Krajský přebor"
value={newAlias}
onChange={(e) => setNewAlias(e.target.value)}
size="md"
bg={useColorModeValue('gray.50', 'gray.900')}
borderColor="gray.300"
_hover={{ borderColor: 'blue.400', bg: 'white' }}
_focus={{ borderColor: 'blue.500', bg: 'white', shadow: 'sm' }}
/>
</VStack>
<Button
leftIcon={<FiPlus />}
onClick={onAdd}
colorScheme="blue"
size="md"
px={8}
shadow="sm"
_hover={{ shadow: 'md', transform: 'translateY(-1px)' }}
transition="all 0.2s"
>
Přidat
</Button>
</Flex>
</Box>
)}
{/* Table Section */}
<Box
bg={cardBg}
@@ -668,8 +569,8 @@ const CompetitionAliasesAdminPage: React.FC = () => {
Žádné aliasy zatím nejsou
</Text>
<Text color="gray.400" fontSize="sm">
Přidejte nový alias nebo importujte ze soutěží
</Text>
Importujte ze soutěží (FACR) pomocí tlačítka nahoře
</Text>
</VStack>
</Td>
</Tr>
@@ -52,8 +52,10 @@ import {
deleteContact,
Contact,
getContactCategories,
createContactCategory,
ContactCategory,
} from '../../services/contactInfo';
import { getCompetitionAliasesPublic } from '../../services/competitionAliases';
import { uploadImage } from '../../services/api';
import { getImageUrl } from '../../utils/imageUtils';
import { getAdminSettings, updateAdminSettings, AdminSettings, PublicSettings } from '../../services/settings';
@@ -116,6 +118,40 @@ const ContactsAdminPage: React.FC = () => {
setContacts(contactsData);
setCategories(categoriesData);
setFacrCompetitions(Array.isArray(facrData?.competitions) ? facrData!.competitions : []);
// Auto-seed contact categories from club competitions (with aliases) if none exist yet
if ((categoriesData || []).length === 0 && Array.isArray(facrData?.competitions) && facrData.competitions.length > 0) {
try {
const aliases = await getCompetitionAliasesPublic().catch(() => [] as Array<{ code?: string; alias?: string; original_name?: string }>);
const aliasMap: Record<string, string> = {};
(aliases || []).forEach((a: any) => { if (a?.code && a?.alias) aliasMap[String(a.code)] = String(a.alias); });
const namesSet = new Set<string>();
for (const c of facrData.competitions) {
const code = String(c?.code || '').trim();
const name = String(c?.name || c?.code || '').trim();
const display = code && aliasMap[code] ? aliasMap[code] : name;
if (display) namesSet.add(display);
}
const names = Array.from(namesSet);
if (names.length > 0) {
// Create categories sequentially to avoid overwhelming API
let order = 0;
for (const n of names) {
try {
await createContactCategory({ name: n, display_order: order, is_active: true });
order += 10;
} catch (e) {
// ignore duplicates or transient errors
}
}
const refreshed = await getContactCategories();
setCategories(refreshed);
toast({ title: 'Kategorie doplněny', description: 'Kategorie pro kontakty byly doplněny podle soutěží klubu.', status: 'success', duration: 3000 });
}
} catch (e: any) {
// Best-effort seeding; keep silent on failure
}
}
} catch (error) {
toast({
title: 'Chyba při načítání',
@@ -80,7 +80,7 @@ const EngagementAdminPage: React.FC = () => {
type: 'avatar_static',
cost_points: 50,
image_url: '',
stock: 0,
stock: -1,
active: true,
});
@@ -96,7 +96,7 @@ const EngagementAdminPage: React.FC = () => {
start_index: 1,
type: 'avatar_static' as string,
cost_points: 50,
stock: 0,
stock: -1,
active: true,
});
const batchModal = useDisclosure();
@@ -124,7 +124,7 @@ const EngagementAdminPage: React.FC = () => {
setTemplate(tpl);
switch (tpl) {
case 'avatar_upload_unlock':
setForm((prev) => ({ ...prev, type: 'avatar_upload_unlock', cost_points: 250, stock: 0, image_url: '' }));
setForm((prev) => ({ ...prev, type: 'avatar_upload_unlock', cost_points: 50, stock: -1, image_url: '' }));
break;
case 'avatar_animated_upload_unlock':
setForm((prev) => ({ ...prev, type: 'avatar_animated_upload_unlock', cost_points: 150, stock: 0 }));
@@ -194,7 +194,7 @@ const EngagementAdminPage: React.FC = () => {
return adminCreateReward({ ...form, metadata });
},
onSuccess: async () => {
setForm({ name: '', type: 'avatar_static', cost_points: 50, image_url: '', stock: 0, active: true });
setForm({ name: '', type: 'avatar_static', cost_points: 50, image_url: '', stock: -1, active: true });
await qc.invalidateQueries({ queryKey: ['admin-engagement-rewards'] });
toast({ status: 'success', title: 'Odměna vytvořena' });
},
@@ -320,7 +320,7 @@ const EngagementAdminPage: React.FC = () => {
<FormControl>
<FormLabel m={0} fontSize="sm">Šablona odměny</FormLabel>
<Select size="sm" maxW="280px" value={template} onChange={(e)=>applyTemplate(e.target.value)}>
<option value="avatar_upload_unlock">Odemknutí vlastního avataru (250b)</option>
<option value="avatar_upload_unlock">Odemknutí vlastního avataru (50b)</option>
<option value="avatar_animated_upload_unlock">Odemknutí animovaného avataru (150b)</option>
<option value="avatar_static_50">Avatar (statický) 50b</option>
<option value="merch_coupon_1000">Merch kupon (1000b)</option>
@@ -363,8 +363,8 @@ const EngagementAdminPage: React.FC = () => {
</FormControl>
<FormControl>
<FormLabel>Sklad</FormLabel>
<NumberInput value={form.stock} min={0} onChange={(_v, n) => setForm({ ...form, stock: Number.isFinite(n) ? n : 0 })}>
<NumberInputField placeholder="Ks (0 = neomezeně)" />
<NumberInput value={form.stock} min={-1} onChange={(_v, n) => setForm({ ...form, stock: Number.isFinite(n) ? n : -1 })}>
<NumberInputField placeholder="Ks (-1 = neomezeně, 0 = vyprodáno)" />
</NumberInput>
</FormControl>
</HStack>
@@ -484,18 +484,31 @@ const EngagementAdminPage: React.FC = () => {
</NumberInput>
</Td>
<Td>
<NumberInput size="sm" value={r.stock || 0} min={0} maxW="100px" onChange={(_v, n) => updateMut.mutate({ id: r.id, body: { stock: Number.isFinite(n) ? n : 0 } })}>
<NumberInput
size="sm"
value={r.stock ?? 0}
min={-1}
maxW="100px"
isDisabled={r.type === 'avatar_upload_unlock'}
onChange={(_v, n) => updateMut.mutate({ id: r.id, body: { stock: Number.isFinite(n) ? n : 0 } })}
>
<NumberInputField />
</NumberInput>
</Td>
<Td>{r.image_url ? <Image src={r.image_url} alt={r.name} boxSize="40px" objectFit="cover" borderRadius="md" /> : '-'}</Td>
<Td>
<Switch isChecked={!!r.active} onChange={(e) => updateMut.mutate({ id: r.id, body: { active: e.target.checked } })} />
<Switch
isChecked={!!r.active}
isDisabled={r.type === 'avatar_upload_unlock'}
onChange={(e) => updateMut.mutate({ id: r.id, body: { active: e.target.checked } })}
/>
</Td>
<Td>
<HStack>
<IconButton aria-label="Upravit" size="xs" icon={<FiEdit2 />} onClick={() => { setEditItem(r); setEditForm(r); setEditMeta(r.metadata || {}); editModal.onOpen(); }} />
<IconButton aria-label="Smazat" size="xs" icon={<FiTrash2 />} onClick={() => deleteMut.mutate(r.id)} />
{r.type !== 'avatar_upload_unlock' && (
<IconButton aria-label="Smazat" size="xs" icon={<FiTrash2 />} onClick={() => deleteMut.mutate(r.id)} />
)}
</HStack>
</Td>
</Tr>
@@ -574,11 +587,11 @@ const EngagementAdminPage: React.FC = () => {
<VStack align="stretch" spacing={3}>
<FormControl>
<FormLabel>Název</FormLabel>
<Input value={editForm.name || ''} onChange={(e)=>setEditForm({ ...editForm, name: e.target.value })} />
<Input value={editForm.name || ''} onChange={(e)=>setEditForm({ ...editForm, name: e.target.value })} isDisabled={editItem?.type === 'avatar_upload_unlock'} />
</FormControl>
<FormControl>
<FormLabel>Typ</FormLabel>
<Select value={editForm.type || ''} onChange={(e)=>setEditForm({ ...editForm, type: e.target.value })}>
<Select value={editForm.type || ''} onChange={(e)=>setEditForm({ ...editForm, type: e.target.value })} isDisabled={editItem?.type === 'avatar_upload_unlock'}>
<option value="avatar_static">Avatar (statický)</option>
<option value="avatar_animated">Avatar (animovaný)</option>
<option value="avatar_upload_unlock">Odemknutí vlastního avataru</option>
@@ -598,51 +611,51 @@ const EngagementAdminPage: React.FC = () => {
</FormControl>
<FormControl>
<FormLabel>Sklad</FormLabel>
<NumberInput value={Number(editForm.stock || 0)} min={0} onChange={(_v, n)=>setEditForm({ ...editForm, stock: Number.isFinite(n)? n : 0 })}>
<NumberInput value={Number(editForm.stock || 0)} min={-1} onChange={(_v, n)=>setEditForm({ ...editForm, stock: Number.isFinite(n)? n : 0 })} isDisabled={editItem?.type === 'avatar_upload_unlock'}>
<NumberInputField />
</NumberInput>
</FormControl>
</HStack>
<FormControl>
<FormLabel>Obrázek URL</FormLabel>
<Input value={editForm.image_url || ''} onChange={(e)=>setEditForm({ ...editForm, image_url: e.target.value })} />
<Input value={editForm.image_url || ''} onChange={(e)=>setEditForm({ ...editForm, image_url: e.target.value })} isDisabled={editItem?.type === 'avatar_upload_unlock'} />
</FormControl>
<HStack>
<input ref={editFileInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e)=>handleUploadEdit(e.target.files?.[0])} />
<Button size="sm" variant="outline" onClick={() => editFileInputRef.current?.click()}>Nahrát obrázek</Button>
<Button size="sm" variant="outline" onClick={() => editFileInputRef.current?.click()} isDisabled={editItem?.type === 'avatar_upload_unlock'}>Nahrát obrázek</Button>
</HStack>
{/* Edit metadata helpers (structured) */}
{ (editForm.type === 'merch_coupon' || editForm.type === 'merch_physical' || editForm.type === 'merch_digital' || editForm.type === 'custom') && (
<VStack align="stretch" spacing={2}>
{editForm.type === 'merch_coupon' && (
<>
<FormControl><FormLabel>Kód kuponu</FormLabel><Input value={(editMeta as any).coupon_code || ''} onChange={(e)=>setEditMetaField('coupon_code', e.target.value)} /></FormControl>
<FormControl><FormLabel>Platnost do</FormLabel><Input value={(editMeta as any).expires_at || ''} onChange={(e)=>setEditMetaField('expires_at', e.target.value)} /></FormControl>
<FormControl><FormLabel>Poznámka</FormLabel><Input value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
<FormControl><FormLabel>Kód kuponu</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).coupon_code || ''} onChange={(e)=>setEditMetaField('coupon_code', e.target.value)} /></FormControl>
<FormControl><FormLabel>Platnost do</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).expires_at || ''} onChange={(e)=>setEditMetaField('expires_at', e.target.value)} /></FormControl>
<FormControl><FormLabel>Poznámka</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
</>
)}
{editForm.type === 'merch_physical' && (
<>
<FormControl><FormLabel>SKU</FormLabel><Input value={(editMeta as any).sku || ''} onChange={(e)=>setEditMetaField('sku', e.target.value)} /></FormControl>
<FormControl><FormLabel>SKU</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).sku || ''} onChange={(e)=>setEditMetaField('sku', e.target.value)} /></FormControl>
<HStack>
<FormControl><FormLabel>Velikost</FormLabel><Input value={(editMeta as any).size || ''} onChange={(e)=>setEditMetaField('size', e.target.value)} /></FormControl>
<FormControl><FormLabel>Barva</FormLabel><Input value={(editMeta as any).color || ''} onChange={(e)=>setEditMetaField('color', e.target.value)} /></FormControl>
<FormControl><FormLabel>Velikost</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).size || ''} onChange={(e)=>setEditMetaField('size', e.target.value)} /></FormControl>
<FormControl><FormLabel>Barva</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).color || ''} onChange={(e)=>setEditMetaField('color', e.target.value)} /></FormControl>
</HStack>
<FormControl><FormLabel>Poznámka</FormLabel><Input value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
<FormControl><FormLabel>Poznámka</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
</>
)}
{editForm.type === 'merch_digital' && (
<>
<FormControl><FormLabel>Licenční klíč</FormLabel><Input value={(editMeta as any).license_key || ''} onChange={(e)=>setEditMetaField('license_key', e.target.value)} /></FormControl>
<FormControl><FormLabel>Stažení (URL)</FormLabel><Input value={(editMeta as any).download_url || ''} onChange={(e)=>setEditMetaField('download_url', e.target.value)} /></FormControl>
<FormControl><FormLabel>Poznámka</FormLabel><Input value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
<FormControl><FormLabel>Licenční klíč</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).license_key || ''} onChange={(e)=>setEditMetaField('license_key', e.target.value)} /></FormControl>
<FormControl><FormLabel>Stažení (URL)</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).download_url || ''} onChange={(e)=>setEditMetaField('download_url', e.target.value)} /></FormControl>
<FormControl><FormLabel>Poznámka</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
</>
)}
{editForm.type === 'custom' && (
<HStack>
<Input placeholder="klíč" id="edit-kv-key" />
<Input placeholder="hodnota" id="edit-kv-value" />
<Button size="sm" onClick={()=>{
<Input placeholder="klíč" id="edit-kv-key" isDisabled={editItem?.type === 'avatar_upload_unlock'} />
<Input placeholder="hodnota" id="edit-kv-value" isDisabled={editItem?.type === 'avatar_upload_unlock'} />
<Button size="sm" isDisabled={editItem?.type === 'avatar_upload_unlock'} onClick={()=>{
const k = (document.getElementById('edit-kv-key') as HTMLInputElement)?.value?.trim();
const v = (document.getElementById('edit-kv-value') as HTMLInputElement)?.value?.trim();
if (!k) return;
@@ -655,7 +668,7 @@ const EngagementAdminPage: React.FC = () => {
{/* Odstraněno: ruční JSON metadata v editoru. */}
<HStack>
<Text>Aktivní</Text>
<Switch isChecked={!!editForm.active} onChange={(e)=>setEditForm({ ...editForm, active: e.target.checked })} />
<Switch isChecked={!!editForm.active} onChange={(e)=>setEditForm({ ...editForm, active: e.target.checked })} isDisabled={editItem?.type === 'avatar_upload_unlock'} />
{editForm.image_url ? <Image src={editForm.image_url} alt={String(editForm.name || '')} boxSize="56px" objectFit="cover" borderRadius="md" /> : null}
</HStack>
</VStack>
@@ -665,16 +678,20 @@ const EngagementAdminPage: React.FC = () => {
<Button onClick={editModal.onClose}>Zrušit</Button>
<Button colorScheme="blue" isLoading={updateMut.isPending} onClick={async ()=>{
if (!editItem) return;
const metadata: Record<string, any> | undefined = Object.keys(editMeta || {}).length ? (editMeta as any) : {} as any;
await updateMut.mutateAsync({ id: editItem.id, body: {
name: editForm.name,
type: editForm.type,
cost_points: editForm.cost_points as any,
stock: editForm.stock as any,
image_url: editForm.image_url,
active: editForm.active as any,
metadata: metadata as any,
} as any });
if (editItem.type === 'avatar_upload_unlock') {
await updateMut.mutateAsync({ id: editItem.id, body: { cost_points: editForm.cost_points as any } });
} else {
const metadata: Record<string, any> | undefined = Object.keys(editMeta || {}).length ? (editMeta as any) : {} as any;
await updateMut.mutateAsync({ id: editItem.id, body: {
name: editForm.name,
type: editForm.type,
cost_points: editForm.cost_points as any,
stock: editForm.stock as any,
image_url: editForm.image_url,
active: editForm.active as any,
metadata: metadata as any,
} as any });
}
editModal.onClose();
}}>Uložit</Button>
</HStack>
@@ -733,7 +750,7 @@ const EngagementAdminPage: React.FC = () => {
<HStack>
<FormControl>
<FormLabel>Sklad</FormLabel>
<NumberInput min={0} value={batch.stock} onChange={(_v,n)=>setBatch({ ...batch, stock: Number.isFinite(n)? n : 0 })}>
<NumberInput min={-1} value={batch.stock} onChange={(_v,n)=>setBatch({ ...batch, stock: Number.isFinite(n)? n : -1 })}>
<NumberInputField />
</NumberInput>
</FormControl>
@@ -0,0 +1,302 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Box,
Heading,
HStack,
VStack,
Input,
Select,
Button,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Text,
Badge,
useColorModeValue,
Spinner,
Checkbox,
useDisclosure,
Drawer,
DrawerOverlay,
DrawerContent,
DrawerHeader,
DrawerBody,
Code,
IconButton,
} from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { ErrorEvent, ErrorListResponse, getError, getErrors, getExternalError, getExternalErrors } from '../../services/errors';
import { RepeatIcon } from '@chakra-ui/icons';
import { getAdminSettings } from '../../services/settings';
const useAutoRefresh = (enabled: boolean, tickMs: number, onTick: () => void) => {
useEffect(() => {
if (!enabled) return;
const id = setInterval(onTick, tickMs);
return () => clearInterval(id);
}, [enabled, tickMs, onTick]);
};
const Row: React.FC<{ ev: ErrorEvent; onOpenDetail: (id: number) => void }> = ({ ev, onOpenDetail }) => {
const color = ev.severity === 'fatal' ? 'red' : ev.severity === 'warn' ? 'yellow' : 'gray';
let tags: any = (ev as any).tags;
try { if (typeof tags === 'string') tags = JSON.parse(tags); } catch {}
const isSupport = tags && tags.type === 'support';
return (
<Tr _hover={{ bg: useColorModeValue('gray.50', 'gray.700'), cursor: 'pointer' }} onClick={() => onOpenDetail(ev.id)}>
<Td whiteSpace="nowrap">{new Date(ev.occurred_at || ev.created_at).toLocaleString()}</Td>
<Td><Badge colorScheme={color}>{ev.severity || 'error'}</Badge></Td>
<Td>{ev.origin}</Td>
<Td>
<Text noOfLines={1} maxW="420px" title={ev.message}>{ev.message}</Text>
</Td>
<Td>{ev.method}</Td>
<Td><Text noOfLines={1} maxW="260px" title={ev.url}>{ev.url}</Text></Td>
<Td>{ev.status || ''}</Td>
<Td>
<HStack spacing={1} onClick={(e) => e.stopPropagation()}>
<Code fontSize="xs">{ev.request_id || ''}</Code>
{ev.request_id ? (
<Button size="xs" variant="ghost" onClick={async () => { try { await navigator.clipboard.writeText(ev.request_id || ''); } catch {} }}>Kopírovat</Button>
) : null}
</HStack>
</Td>
<Td>{isSupport ? <Badge colorScheme="purple">Podpora</Badge> : null}</Td>
</Tr>
);
};
const ErrorsAdminPage: React.FC = () => {
const [origin, setOrigin] = useState('');
const [severity, setSeverity] = useState('');
const [method, setMethod] = useState('');
const [status, setStatus] = useState('');
const [search, setSearch] = useState('');
const [from, setFrom] = useState('');
const [to, setTo] = useState('');
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(20);
const [autoRefresh, setAutoRefresh] = useState(true);
const [selectedId, setSelectedId] = useState<number | null>(null);
const detail = useDisclosure();
const [useExternal, setUseExternal] = useState(false);
const [extUI, setExtUI] = useState<string>('');
const [supportOnly, setSupportOnly] = useState(false);
const effectiveSearch = useMemo(() => supportOnly ? ((search && search.trim()) ? `Support: ${search.trim()}` : 'Support:') : search, [supportOnly, search]);
const effectiveSeverity = useMemo(() => supportOnly ? (severity || 'warn') : severity, [supportOnly, severity]);
const effectiveOrigin = useMemo(() => supportOnly ? (origin || 'frontend') : origin, [supportOnly, origin]);
const params = useMemo(() => ({ origin: effectiveOrigin, severity: effectiveSeverity, method, status: status || undefined, search: effectiveSearch, from, to, page, limit }), [effectiveOrigin, effectiveSeverity, method, status, effectiveSearch, from, to, page, limit]);
const query = useQuery<ErrorListResponse>({
queryKey: ['admin', 'errors', useExternal ? 'external' : 'local', params],
queryFn: () => (useExternal ? getExternalErrors(params) : getErrors(params)),
keepPreviousData: true,
staleTime: 10_000,
});
useAutoRefresh(autoRefresh, 10_000, () => query.refetch());
const [detailData, setDetailData] = useState<ErrorEvent | null>(null);
useEffect(() => {
if (!detail.isOpen || selectedId == null) return;
let cancelled = false;
(async () => {
try {
const d = useExternal ? await getExternalError(selectedId) : await getError(selectedId);
if (!cancelled) setDetailData(d);
} catch {}
})();
return () => { cancelled = true; };
}, [detail.isOpen, selectedId, useExternal]);
useEffect(() => {
(async () => {
try {
const s = await getAdminSettings();
const u = (s as any)?.error_review_ui_url;
if (u) setExtUI(u);
} catch {}
})();
}, []);
const bg = useColorModeValue('white', 'gray.800');
const border = useColorModeValue('gray.200', 'gray.700');
const detailCtxBoxBg = useColorModeValue('gray.50','gray.700');
const detailCtxBorder = useColorModeValue('gray.200','gray.600');
return (
<AdminLayout>
<Box maxW="1400px" mx="auto">
<HStack justify="space-between" mb={6} align="center">
<Heading size="lg">Chyby a výjimky</Heading>
<HStack>
<Checkbox isChecked={autoRefresh} onChange={(e) => setAutoRefresh(e.target.checked)}>Autorefresh</Checkbox>
<IconButton aria-label="Refresh" icon={<RepeatIcon />} size="sm" onClick={() => query.refetch()} isLoading={query.isRefetching} />
<Checkbox isChecked={useExternal} onChange={(e) => setUseExternal(e.target.checked)}>Externí zdroj</Checkbox>
{extUI ? (
<Button as="a" href={extUI} target="_blank" rel="noreferrer" size="sm" variant="outline">Konzole</Button>
) : null}
</HStack>
</HStack>
<VStack align="stretch" spacing={4} mb={4}>
<HStack>
<Select placeholder="Původ (origin)" value={origin} onChange={(e) => setOrigin(e.target.value)} maxW="220px">
<option value="frontend">frontend</option>
<option value="backend">backend</option>
<option value="docker">docker</option>
</Select>
<Select placeholder="Závažnost" value={severity} onChange={(e) => setSeverity(e.target.value)} maxW="200px">
<option value="fatal">fatal</option>
<option value="error">error</option>
<option value="warn">warn</option>
</Select>
<Select placeholder="Metoda" value={method} onChange={(e) => setMethod(e.target.value)} maxW="160px">
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
<option value="DELETE">DELETE</option>
</Select>
<Input placeholder="Status" value={status} onChange={(e) => setStatus(e.target.value)} maxW="120px" />
<Input placeholder="Hledat zpráva/stack/url" value={search} onChange={(e) => setSearch(e.target.value)} />
</HStack>
<HStack>
<Input type="datetime-local" placeholder="Od" value={from} onChange={(e) => setFrom(e.target.value)} maxW="240px" />
<Input type="datetime-local" placeholder="Do" value={to} onChange={(e) => setTo(e.target.value)} maxW="240px" />
<Select value={limit} onChange={(e) => setLimit(parseInt(e.target.value || '20', 10))} maxW="140px">
{[20,50,100,200].map(n => <option key={n} value={n}>{n}/strana</option>)}
</Select>
<HStack>
<Button size="sm" onClick={() => { setPage(1); query.refetch(); }}>Filtr</Button>
<Button size="sm" variant="outline" onClick={() => { setOrigin(''); setSeverity(''); setMethod(''); setStatus(''); setSearch(''); setFrom(''); setTo(''); setPage(1); setSupportOnly(false); }}>Reset</Button>
<Checkbox isChecked={supportOnly} onChange={(e) => setSupportOnly(e.target.checked)}>Pouze podpora</Checkbox>
</HStack>
</HStack>
</VStack>
<Box bg={bg} borderWidth="1px" borderColor={border} borderRadius="lg" overflow="hidden">
<Table size="sm">
<Thead>
<Tr>
<Th>Čas</Th>
<Th>Sev.</Th>
<Th>Původ</Th>
<Th>Zpráva</Th>
<Th>Metoda</Th>
<Th>URL</Th>
<Th>Status</Th>
<Th>Request ID</Th>
<Th>Tagy</Th>
</Tr>
</Thead>
<Tbody>
{query.isLoading ? (
<Tr><Td colSpan={9}><HStack><Spinner size="sm" /><Text>Načítám...</Text></HStack></Td></Tr>
) : (
query.data?.items?.length ? (
query.data.items.map(ev => <Row key={ev.id} ev={ev} onOpenDetail={(id) => { setSelectedId(id); detail.onOpen(); }} />)
) : (
<Tr><Td colSpan={9}><Text color="gray.500">Žádné chyby</Text></Td></Tr>
)
)}
</Tbody>
</Table>
</Box>
<HStack justify="space-between" mt={4}>
<Text color="gray.500">Celkem: {query.data?.total ?? 0}</Text>
<HStack>
<Button size="sm" onClick={() => setPage(p => Math.max(1, p-1))} isDisabled={page === 1}>Předchozí</Button>
<Text>Strana {page}</Text>
<Button size="sm" onClick={() => setPage(p => p + 1)} isDisabled={(query.data?.items?.length || 0) < limit}>Další</Button>
</HStack>
</HStack>
<HStack mt={3} spacing={2} wrap="wrap">
<Text fontSize="sm" color="gray.500">Rychlé filtry:</Text>
<Button size="xs" variant="outline" onClick={() => {
const now = new Date();
const start = new Date(); start.setHours(0,0,0,0);
setFrom(start.toISOString().slice(0,16));
setTo(now.toISOString().slice(0,16));
setPage(1);
}}>Dnes</Button>
<Button size="xs" variant="outline" onClick={() => {
const now = new Date();
const past = new Date(now.getTime() - 24*60*60*1000);
setFrom(past.toISOString().slice(0,16));
setTo(now.toISOString().slice(0,16));
setPage(1);
}}>24 h</Button>
<Button size="xs" variant="outline" onClick={() => {
const now = new Date();
const past = new Date(now.getTime() - 7*24*60*60*1000);
setFrom(past.toISOString().slice(0,16));
setTo(now.toISOString().slice(0,16));
setPage(1);
}}>7 dní</Button>
<Button size="xs" variant="ghost" onClick={() => { setFrom(''); setTo(''); }}>Vymazat</Button>
</HStack>
<Drawer isOpen={detail.isOpen} placement="right" onClose={detail.onClose} size="lg">
<DrawerOverlay />
<DrawerContent>
<DrawerHeader>Detail chyby</DrawerHeader>
<DrawerBody>
{selectedId == null ? null : detailData ? (
<VStack align="stretch" spacing={3}>
<HStack><Text fontWeight="bold">Čas:</Text><Text>{new Date(detailData.occurred_at || detailData.created_at).toLocaleString()}</Text></HStack>
<HStack><Text fontWeight="bold">Původ:</Text><Text>{detailData.origin}</Text></HStack>
<HStack><Text fontWeight="bold">Závažnost:</Text><Badge>{detailData.severity || 'error'}</Badge></HStack>
<HStack><Text fontWeight="bold">URL:</Text><Text>{detailData.method} {detailData.url}</Text></HStack>
<HStack><Text fontWeight="bold">Status:</Text><Text>{detailData.status || ''}</Text></HStack>
<HStack><Text fontWeight="bold">Request ID:</Text><Code>{detailData.request_id || ''}</Code></HStack>
{(() => {
let tags: any = (detailData as any).tags; try { if (typeof tags === 'string') tags = JSON.parse(tags); } catch {}
if (!tags) return null;
return <HStack><Text fontWeight="bold">Tagy:</Text>{Object.entries(tags).map(([k,v]) => <Badge key={k} colorScheme={k==='type'&&v==='support'?'purple':'gray'}>{String(k)}={String(v)}</Badge>)}</HStack>;
})()}
{(() => {
let ctx: any = (detailData as any).context; try { if (typeof ctx === 'string') ctx = JSON.parse(ctx); } catch {}
const ra = ctx?.recentActions;
if (!ra) return null;
return (
<Box>
<Text fontWeight="bold" mb={1}>Poslední akce</Text>
<Box bg={detailCtxBoxBg} borderWidth="1px" borderColor={detailCtxBorder} borderRadius="md" p={2} maxH="220px" overflowY="auto">
{(Array.isArray(ra) ? ra : []).map((a: any, i: number) => (
<Text key={i} fontFamily="mono" fontSize="xs">{new Date((a.at)||Date.now()).toLocaleTimeString()} {a.type === 'nav' ? `NAV ${a.path}` : `${(a.method||'').toUpperCase()} ${a.url} ${a.status ?? ''} ${a.ms ? a.ms+'ms' : ''}`}</Text>
))}
</Box>
</Box>
);
})()}
<Box>
<Text fontWeight="bold" mb={1}>Zpráva</Text>
<Code whiteSpace="pre-wrap" width="100%" p={2}>{detailData.message}</Code>
</Box>
{detailData.stack ? (
<Box>
<Text fontWeight="bold" mb={1}>Stack</Text>
<Code whiteSpace="pre" width="100%" p={2} display="block">{detailData.stack}</Code>
</Box>
) : null}
</VStack>
) : (
<HStack><Spinner size="sm" /><Text>Načítání</Text></HStack>
)}
</DrawerBody>
</DrawerContent>
</Drawer>
</Box>
</AdminLayout>
);
};
export default ErrorsAdminPage;
+113 -11
View File
@@ -23,8 +23,19 @@ import {
AlertTitle,
AlertDescription,
useColorModeValue,
Input,
FormControl,
FormLabel,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
useDisclosure,
} from '@chakra-ui/react';
import { RefreshCw, ExternalLink, Calendar, Image as ImageIcon, Eye } from 'lucide-react';
import { RefreshCw, ExternalLink, Calendar, Image as ImageIcon, Eye, Plus } from 'lucide-react';
import AdminLayout from '../../layouts/AdminLayout';
import api from '../../services/api';
@@ -66,6 +77,10 @@ const GalleryAdminPage: React.FC = () => {
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string>('');
const toast = useToast();
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure();
const [albumUrl, setAlbumUrl] = useState<string>('');
const [photoLimit, setPhotoLimit] = useState<number>(50);
const [adding, setAdding] = useState<boolean>(false);
const fetchAlbums = async () => {
setLoading(true);
@@ -145,6 +160,46 @@ const GalleryAdminPage: React.FC = () => {
}
};
const handleAddAlbum = async () => {
const link = albumUrl.trim();
if (!link || !link.includes('/Album/')) {
toast({
title: 'Neplatný odkaz',
description: 'URL musí obsahovat "/Album/"',
status: 'error',
duration: 4000,
isClosable: true,
});
return;
}
setAdding(true);
try {
const limit = Number.isFinite(photoLimit) && photoLimit > 0 ? photoLimit : 50;
await api.post('/admin/gallery/albums/fetch', { link, photo_limit: limit });
toast({
title: 'Album přidáno',
description: 'Album bylo načteno a uloženo.',
status: 'success',
duration: 3000,
isClosable: true,
});
onAddClose();
setAlbumUrl('');
await fetchAlbums();
} catch (err: any) {
const errorMessage = err?.response?.data?.error || err?.message || 'Nepodařilo se přidat album';
toast({
title: 'Chyba při přidání alba',
description: errorMessage,
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setAdding(false);
}
};
useEffect(() => {
fetchAlbums();
}, []);
@@ -164,16 +219,25 @@ const GalleryAdminPage: React.FC = () => {
Správa alb a fotografií ze Zonerama
</Text>
</VStack>
<Button
leftIcon={<RefreshCw size={18} />}
colorScheme="blue"
onClick={handleRefresh}
isLoading={refreshing}
loadingText="Obnova..."
>
Obnovit z Zonerama
</Button>
<HStack spacing={3}>
<Button
leftIcon={<Plus size={18} />}
colorScheme="green"
onClick={onAddOpen}
>
Přidat album
</Button>
<Button
leftIcon={<RefreshCw size={18} />}
colorScheme="blue"
onClick={handleRefresh}
isLoading={refreshing}
loadingText="Obnova..."
>
Obnovit z Zonerama
</Button>
</HStack>
</HStack>
{/* Zonerama Info */}
@@ -352,6 +416,44 @@ const GalleryAdminPage: React.FC = () => {
</Table>
</Box>
)}
<Modal isOpen={isAddOpen} onClose={onAddClose} size="lg">
<ModalOverlay />
<ModalContent>
<ModalHeader>Přidat Zonerama album</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={4}>
<FormControl>
<FormLabel>URL Zonerama alba</FormLabel>
<Input
placeholder="https://eu.zonerama.com/…/Album/12345"
value={albumUrl}
onChange={(e) => setAlbumUrl(e.target.value)}
/>
</FormControl>
<FormControl>
<FormLabel>Limit fotek</FormLabel>
<Input
type="number"
min={1}
max={200}
value={String(photoLimit)}
onChange={(e) => {
const v = parseInt(e.target.value || '0', 10);
setPhotoLimit(Number.isFinite(v) ? v : 50);
}}
/>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<HStack spacing={3}>
<Button variant="ghost" onClick={onAddClose}>Zrušit</Button>
<Button colorScheme="blue" onClick={handleAddAlbum} isLoading={adding}>Načíst album</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
</VStack>
</Container>
</AdminLayout>
@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Box, Button, Center, HStack, Heading, Image, SimpleGrid, Text, useColorModeValue, useToast, VStack } from '@chakra-ui/react';
import AdminLayout from '@/layouts/AdminLayout';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { getAdminScoreboard, updateAdminScoreboard, ScoreboardState } from '@/services/scoreboard';
import { getAdminScoreboard, updateAdminScoreboard, ScoreboardState, startTimer, pauseTimer, resetTimer } from '@/services/scoreboard';
const MobileScoreboardControlPage: React.FC = () => {
const toast = useToast();
@@ -13,8 +13,8 @@ const MobileScoreboardControlPage: React.FC = () => {
const { data: state, isLoading } = useQuery<ScoreboardState>({
queryKey: ['admin-scoreboard-mobile'],
queryFn: getAdminScoreboard,
refetchInterval: 5000,
staleTime: 3000,
refetchInterval: 1000,
staleTime: 500,
});
const setPartial = async (patch: Partial<ScoreboardState>) => {
@@ -26,30 +26,25 @@ const MobileScoreboardControlPage: React.FC = () => {
}
};
// Simple local match timer (upwards). Does not persist to backend; overlay remains score-only.
const [running, setRunning] = useState(false);
const [elapsed, setElapsed] = useState(0); // seconds
const startRef = useRef<number | null>(null);
useEffect(() => {
let raf: number;
const tick = () => {
if (running) {
const now = Date.now();
const base = startRef.current ?? now;
startRef.current = base;
setElapsed(Math.floor((now - base) / 1000));
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [running]);
const resetTimer = () => { setRunning(false); setElapsed(0); startRef.current = null; };
// Use backend timer; control via API and reflect state via polling
const handleStartTimer = async () => {
await startTimer();
await qc.invalidateQueries({ queryKey: ['admin-scoreboard-mobile'] });
};
const handlePauseTimer = async () => {
await pauseTimer();
await qc.invalidateQueries({ queryKey: ['admin-scoreboard-mobile'] });
};
const handleResetTimer = async () => {
await resetTimer();
await qc.invalidateQueries({ queryKey: ['admin-scoreboard-mobile'] });
};
const mmss = useMemo(() => {
const mm = Math.floor(elapsed / 60);
const ss = elapsed % 60;
return `${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
}, [elapsed]);
return state?.timer || '00:00';
}, [state?.timer]);
if (isLoading || !state) {
return (
@@ -82,8 +77,8 @@ const MobileScoreboardControlPage: React.FC = () => {
<VStack spacing={2}>
<Text fontSize="5xl" fontWeight="black">{state.homeScore} : {state.awayScore}</Text>
<HStack>
<Button onClick={() => setRunning((r) => !r)}>{running ? 'Stop' : 'Start'}</Button>
<Button variant="outline" onClick={resetTimer}>Reset</Button>
<Button onClick={() => (state.running ? handlePauseTimer() : handleStartTimer())}>{state.running ? 'Stop' : 'Start'}</Button>
<Button variant="outline" onClick={handleResetTimer}>Reset</Button>
</HStack>
<Text fontSize="2xl" fontFamily="mono">{mmss}</Text>
</VStack>
@@ -103,17 +98,7 @@ const MobileScoreboardControlPage: React.FC = () => {
</SimpleGrid>
</Box>
<Box borderWidth="1px" borderColor={borderCol} bg={cardBg} borderRadius="lg" p={3}>
<HStack justify="space-between">
<Text>Vybraný zápas</Text>
<Text fontWeight="bold">{state.externalMatchId ? state.externalMatchId : '—'}</Text>
</HStack>
<HStack mt={2} spacing={2}>
<Button onClick={() => setPartial({ active: true })} colorScheme="blue">Aktivovat</Button>
<Button variant="outline" onClick={() => setPartial({ active: false })}>Deaktivovat</Button>
<Button variant="ghost" onClick={() => setPartial({ homeScore: 0, awayScore: 0 })}>Reset skóre</Button>
</HStack>
</Box>
{/* Removed 'Vybraný zápas' section for remote managed on main Tabule page */}
</VStack>
</Box>
</AdminLayout>
+170 -29
View File
@@ -201,8 +201,8 @@ interface NavItemCardProps {
total: number;
onMoveUp: () => void;
onMoveDown: () => void;
onEdit: () => void;
onDelete: () => void;
onEdit?: () => void;
onDelete?: () => void;
onAddChild: () => void;
isExpanded: boolean;
onToggleExpand: () => void;
@@ -212,6 +212,11 @@ interface NavItemCardProps {
level?: number;
onChildMoveUp?: (parentId: number, index: number) => void;
onChildMoveDown?: (parentId: number, index: number) => void;
onToggleVisible: (item: NavigationItem) => void;
childrenDroppableId?: string;
draggableChildPrefix?: string;
onEditTarget?: (item: NavigationItem) => void;
onDeleteTarget?: (item: NavigationItem) => void;
}
const NavItemCard: React.FC<NavItemCardProps> = ({
@@ -231,6 +236,11 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
level = 0,
onChildMoveUp,
onChildMoveDown,
onToggleVisible,
childrenDroppableId,
draggableChildPrefix,
onEditTarget,
onDeleteTarget,
}) => {
const hasChildren = item.children && item.children.length > 0;
const indentPx = level * 32;
@@ -321,6 +331,15 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
{/* Action buttons */}
<HStack spacing={1}>
<Tooltip label={item.visible ? 'Skrýt' : 'Zobrazit'}>
<IconButton
aria-label={item.visible ? 'Skrýt' : 'Zobrazit'}
icon={item.visible ? <ViewOffIcon /> : <ViewIcon />}
size="sm"
variant="ghost"
onClick={() => onToggleVisible(item)}
/>
</Tooltip>
{item.type === 'dropdown' && (
<Tooltip label="Přidat podpoložku">
<IconButton
@@ -339,7 +358,7 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
icon={<EditIcon />}
size="sm"
variant="ghost"
onClick={onEdit}
onClick={() => (typeof onEditTarget === 'function' ? onEditTarget(item) : onEdit && onEdit())}
/>
</Tooltip>
<Tooltip label="Smazat">
@@ -349,7 +368,7 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
size="sm"
colorScheme="red"
variant="ghost"
onClick={onDelete}
onClick={() => (typeof onDeleteTarget === 'function' ? onDeleteTarget(item) : onDelete && onDelete())}
/>
</Tooltip>
</HStack>
@@ -357,31 +376,45 @@ const NavItemCard: React.FC<NavItemCardProps> = ({
</CardBody>
</Card>
{/* Render children if expanded */}
{/* Render children with nested DnD if expanded */}
{hasChildren && isExpanded && (
<VStack spacing={2} align="stretch" mt={2}>
{item.children!.map((child, childIndex) => (
<NavItemCard
key={child.id}
item={child}
index={childIndex}
total={item.children!.length}
onMoveUp={() => onChildMoveUp && onChildMoveUp(item.id!, childIndex)}
onMoveDown={() => onChildMoveDown && onChildMoveDown(item.id!, childIndex)}
onEdit={() => onEdit()}
onDelete={() => onDelete()}
onAddChild={() => {}}
isExpanded={false}
onToggleExpand={() => {}}
cardBg={cardBg}
borderColor={borderColor}
hoverBg={hoverBg}
level={level + 1}
onChildMoveUp={onChildMoveUp}
onChildMoveDown={onChildMoveDown}
/>
))}
</VStack>
<Droppable droppableId={childrenDroppableId || `children-${item.id}`}>
{(provided) => (
<VStack spacing={2} align="stretch" mt={2} ref={provided.innerRef} {...provided.droppableProps}>
{item.children!.map((child, childIndex) => (
<Draggable key={String(child.id)} draggableId={`${(draggableChildPrefix || 'child')}-${child.id}`} index={childIndex}>
{(dragProvided) => (
<Box ref={dragProvided.innerRef} {...dragProvided.draggableProps} {...dragProvided.dragHandleProps}>
<NavItemCard
key={child.id}
item={child}
index={childIndex}
total={item.children!.length}
onMoveUp={() => onChildMoveUp && onChildMoveUp(item.id!, childIndex)}
onMoveDown={() => onChildMoveDown && onChildMoveDown(item.id!, childIndex)}
onEdit={() => (typeof onEditTarget === 'function' ? onEditTarget(child) : onEdit && onEdit())}
onDelete={() => (typeof onDeleteTarget === 'function' ? onDeleteTarget(child) : onDelete && onDelete())}
onAddChild={() => {}}
isExpanded={false}
onToggleExpand={() => {}}
cardBg={cardBg}
borderColor={borderColor}
hoverBg={hoverBg}
level={level + 1}
onChildMoveUp={onChildMoveUp}
onChildMoveDown={onChildMoveDown}
onToggleVisible={onToggleVisible}
onEditTarget={onEditTarget}
onDeleteTarget={onDeleteTarget}
/>
</Box>
)}
</Draggable>
))}
{provided.placeholder}
</VStack>
)}
</Droppable>
)}
</Box>
);
@@ -467,9 +500,29 @@ const NavigationAdminPage = () => {
}
};
const toggleVisible = async (target: NavigationItem) => {
const newVisible = !target.visible;
try {
await updateNavigationItem(target.id!, { visible: newVisible } as any);
const applyToggle = (list: NavigationItem[]): NavigationItem[] =>
(list || []).map((it) => {
if (it.id === target.id) return { ...it, visible: newVisible } as NavigationItem;
const children = it.children ? applyToggle(it.children) : it.children;
return { ...it, children } as NavigationItem;
});
setNavItems((prev) => applyToggle(prev));
setAdminNavItems((prev) => applyToggle(prev));
toast({ title: newVisible ? 'Zobrazeno' : 'Skryto', status: 'success', duration: 1500 });
} catch (e) {
toast({ title: 'Chyba při změně viditelnosti', status: 'error', duration: 3000 });
}
};
const onDragEnd = async (result: DropResult) => {
if (!result.destination) return;
const { source, destination } = result;
const parseAdminChildrenId = (id: string) => id.startsWith('admin-children-') ? parseInt(id.replace('admin-children-', ''), 10) : null;
if (source.droppableId === 'frontend-nav') {
const items = Array.from(navItems);
const [moved] = items.splice(source.index, 1);
@@ -483,7 +536,7 @@ const NavigationAdminPage = () => {
toast({ title: 'Chyba při aktualizaci pořadí', status: 'error', duration: 3000 });
loadData();
}
} else if (source.droppableId === 'admin-nav') {
} else if (source.droppableId === 'admin-nav' && destination.droppableId === 'admin-nav') {
const items = Array.from(adminNavItems);
const [moved] = items.splice(source.index, 1);
items.splice(destination.index, 0, moved);
@@ -496,6 +549,86 @@ const NavigationAdminPage = () => {
toast({ title: 'Chyba při aktualizaci pořadí', status: 'error', duration: 3000 });
loadData();
}
} else if (
source.droppableId.startsWith('admin-children-') || destination.droppableId.startsWith('admin-children-') ||
(source.droppableId === 'admin-nav' && destination.droppableId.startsWith('admin-children-')) ||
(source.droppableId.startsWith('admin-children-') && destination.droppableId === 'admin-nav')
) {
const srcParentId = parseAdminChildrenId(source.droppableId);
const destParentId = parseAdminChildrenId(destination.droppableId);
const items = Array.from(adminNavItems);
// Helper to find parent index by id
const findParentIndex = (pid: number | null) => {
if (pid === null) return -1;
return items.findIndex((it) => it.id === pid);
};
let moved: NavigationItem | null = null;
// Remove from source list
if (srcParentId === null) {
const [m] = items.splice(source.index, 1);
moved = m;
} else {
const pIdx = findParentIndex(srcParentId);
if (pIdx >= 0) {
const srcChildren = Array.isArray(items[pIdx].children) ? Array.from(items[pIdx].children!) : [];
const [m] = srcChildren.splice(source.index, 1);
moved = m;
items[pIdx] = { ...items[pIdx], children: srcChildren } as NavigationItem;
}
}
if (!moved) return;
// Insert into destination list
if (destParentId === null) {
items.splice(destination.index, 0, moved);
} else {
const dIdx = findParentIndex(destParentId);
if (dIdx >= 0) {
const destChildren = Array.isArray(items[dIdx].children) ? Array.from(items[dIdx].children!) : [];
destChildren.splice(destination.index, 0, moved);
items[dIdx] = { ...items[dIdx], children: destChildren } as NavigationItem;
}
}
setAdminNavItems(items);
// Persist parent change and reorder siblings at both source and destination
try {
await updateNavigationItem(moved.id!, { parent_id: destParentId === null ? null : destParentId, display_order: destination.index } as any);
// Reorder source siblings
if (srcParentId === null) {
const topOrders = items.map((it, idx) => ({ id: it.id!, display_order: idx }));
await reorderNavigationItems(topOrders);
} else {
const srcIdx = findParentIndex(srcParentId);
if (srcIdx >= 0) {
const orders = (items[srcIdx].children || []).map((c, idx) => ({ id: c.id!, display_order: idx }));
await reorderNavigationItems(orders);
}
}
// Reorder destination siblings
if (destParentId === null) {
const topOrders = items.map((it, idx) => ({ id: it.id!, display_order: idx }));
await reorderNavigationItems(topOrders);
} else {
const destIdx = findParentIndex(destParentId);
if (destIdx >= 0) {
const orders = (items[destIdx].children || []).map((c, idx) => ({ id: c.id!, display_order: idx }));
await reorderNavigationItems(orders);
}
}
toast({ title: 'Přesunuto', status: 'success', duration: 2000 });
} catch (error) {
toast({ title: 'Chyba při přesunu', status: 'error', duration: 3000 });
loadData();
}
}
};
@@ -947,6 +1080,11 @@ const NavigationAdminPage = () => {
hoverBg={hoverBg}
onChildMoveUp={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'up')}
onChildMoveDown={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'down')}
onToggleVisible={toggleVisible}
childrenDroppableId={`frontend-children-${item.id}`}
draggableChildPrefix={'front-child'}
onEditTarget={(it) => openNavModal(it)}
onDeleteTarget={(it) => deleteNav(it.id!)}
/>
</Box>
)}
@@ -1010,6 +1148,9 @@ const NavigationAdminPage = () => {
hoverBg={hoverBg}
onChildMoveUp={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'up')}
onChildMoveDown={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'down')}
onToggleVisible={toggleVisible}
onEditTarget={(it) => openNavModal(it, undefined, true)}
onDeleteTarget={(it) => deleteNav(it.id!)}
/>
</Box>
)}
@@ -68,8 +68,8 @@ const PlayersAdminPage: React.FC = () => {
// Simple fuzzy match scoring: higher is better
function fuzzyScore(text: string, query: string): number {
if (!query) return 0;
const t = text.toLowerCase();
const q = query.toLowerCase();
const t = (text || '').toLowerCase();
const q = (query || '').toLowerCase();
// Exact and prefix bonuses
if (t === q) return 1000;
if (t.startsWith(q)) return 800 - (t.length - q.length);
@@ -216,7 +216,7 @@ const PlayersAdminPage: React.FC = () => {
}
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ['admin-players'], queryFn: getPlayers });
const { data, isLoading } = useQuery({ queryKey: ['admin-players', { active: false }], queryFn: () => getPlayers({ active: false }) });
const [editing, setEditing] = useState<Editing | null>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
@@ -238,7 +238,7 @@ const PlayersAdminPage: React.FC = () => {
mutationFn: (payload: any) => createPlayer(payload),
onSuccess: (created: any) => {
try {
qc.setQueryData(['admin-players'], (old: any) => {
qc.setQueryData(['admin-players', { active: false }], (old: any) => {
const list = Array.isArray(old) ? old : (old?.data || []);
const newList = [created, ...list];
if (old && old.data) return { ...old, data: newList };
@@ -246,7 +246,7 @@ const PlayersAdminPage: React.FC = () => {
});
} catch (e) {}
toast({ title: 'Hráč vytvořen', status: 'success' });
qc.invalidateQueries({ queryKey: ['admin-players'] });
qc.invalidateQueries({ queryKey: ['admin-players', { active: false }] });
closeModal();
},
onError: (e: any) => {
@@ -257,7 +257,7 @@ const PlayersAdminPage: React.FC = () => {
});
const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: number | string; payload: any }) => updatePlayer(id, payload),
onSuccess: () => { toast({ title: 'Hráč upraven', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-players'] }); closeModal(); },
onSuccess: () => { toast({ title: 'Hráč upraven', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-players', { active: false }] }); closeModal(); },
onError: (e: any) => {
const status = e?.response?.status;
const msg = e?.response?.data?.chyba || e?.response?.data?.error || e?.message || 'Chyba';
@@ -266,7 +266,7 @@ const PlayersAdminPage: React.FC = () => {
});
const deleteMut = useMutation({
mutationFn: (id: number) => deletePlayer(id),
onSuccess: () => { toast({ title: 'Hráč smazán', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-players'] }); },
onSuccess: () => { toast({ title: 'Hráč smazán', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-players', { active: false }] }); },
onError: (e: any) => {
const status = e?.response?.status;
const msg = e?.response?.data?.chyba || e?.response?.data?.error || e?.message || 'Chyba';
@@ -468,8 +468,8 @@ const PlayersAdminPage: React.FC = () => {
<FormLabel>Pohlaví</FormLabel>
<Select value={(editing as any)?.gender || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), gender: e.target.value }))}>
<option value=""> nevybráno </option>
<option value="men">Muži</option>
<option value="women">Ženy</option>
<option value="men">Muž</option>
<option value="women">Žena</option>
</Select>
</FormControl>
+105 -49
View File
@@ -46,6 +46,9 @@ import {
listSponsorsAdmin,
uploadSponsors,
deleteSponsor,
prefillSponsorsFromPage,
getQr,
uploadQr,
} from '@/services/scoreboard';
import { useFacrApi } from '@/hooks/useFacrApi';
import { SearchResult } from '@/services/facr/types';
@@ -53,6 +56,7 @@ import { API_URL } from '@/services/api';
import { useQuery } from '@tanstack/react-query';
import { AdminMatch, fetchAdminMatches } from '@/services/adminMatches';
import { getFacrClubInfoCache } from '@/services/facr/cache';
import { createSponsor } from '@/services/sponsors';
const themes: ScoreboardTheme[] = ['classic', 'pill', 'var1', 'var2', 'var3', 'var4'];
@@ -79,6 +83,8 @@ const ScoreboardAdminPage: React.FC = () => {
const [presetName, setPresetName] = useState('');
const [sponsors, setSponsors] = useState<string[]>([]);
const [sUploadBusy, setSUploadBusy] = useState(false);
const [qrUrl, setQrUrl] = useState<string>('');
const [qrBusy, setQrBusy] = useState(false);
// Club search inline (home/away target)
const [clubQuery, setClubQuery] = useState('');
@@ -93,6 +99,7 @@ const ScoreboardAdminPage: React.FC = () => {
// load presets & sponsors lists
try { setPresets(await listPresets()); } catch {}
try { setSponsors(await listSponsorsAdmin()); } catch {}
try { setQrUrl(await getQr()); } catch {}
})();
}, []);
@@ -539,63 +546,112 @@ const ScoreboardAdminPage: React.FC = () => {
<Divider my={6} />
<HStack spacing={3}>
<Button onClick={() => setPartial({ homeScore: (state.homeScore || 0) + 1 })}>+ Gól DOM</Button>
<Button onClick={() => setPartial({ homeScore: Math.max(0, (state.homeScore || 0) - 1) })}> Gól DOM</Button>
<Button onClick={() => setPartial({ awayScore: (state.awayScore || 0) + 1 })}>+ Gól HOS</Button>
<Button onClick={() => setPartial({ awayScore: Math.max(0, (state.awayScore || 0) - 1) })}> Gól HOS</Button>
<Button variant="outline" onClick={() => setPartial({ homeScore: 0, awayScore: 0 })}>Reset skóre</Button>
<Button variant="outline" onClick={async () => { await resetTimer(); const s = await getScoreboardState(); setState(s); }}>Reset čas</Button>
<Button variant="outline" onClick={() => setPartial({ homeFouls: 0, awayFouls: 0 })}>Reset fauly</Button>
</HStack>
<Divider my={6} />
{/* Timer controls */}
{/* Časovač ovládá pouze mobilní ovladač. Na této stránce ponechány pouze reset akce. */}
<Box borderWidth="1px" borderRadius="lg" p={4} bg={cardBg} mb={6}>
<Heading size="md" mb={3}>Časovač</Heading>
<HStack spacing={4} align="center" flexWrap="wrap">
<Text fontSize="4xl" fontFamily="mono" minW="120px">{state.timer || '00:00'}</Text>
{state.running ? (
<Badge colorScheme="green">Běží</Badge>
) : (
<Badge>Stojí</Badge>
)}
<HStack>
<Button colorScheme="green" onClick={async () => {
await startTimer();
const s = await getScoreboardState();
setState(s);
}}>Start</Button>
<Button onClick={async () => {
await pauseTimer();
const s = await getScoreboardState();
setState(s);
}}>Pauza</Button>
<Button variant="outline" onClick={async () => {
await resetTimer();
const s = await getScoreboardState();
setState(s);
}}>Reset</Button>
<Button colorScheme="purple" onClick={async () => {
await startSecondHalf();
const s = await getScoreboardState();
setState(s);
}}>Začít 2. poločas</Button>
<Heading size="md" mb={3}>Sponzoři (overlay)</Heading>
{(() => { const href = (typeof window !== 'undefined' ? window.location.origin.replace(/\/$/, '') : '') + '/overlay/sponsors'; return (
<HStack spacing={3} mb={3}>
<Badge colorScheme="green">OBS</Badge>
<Button as="a" href={href} target="_blank" rel="noreferrer">Otevřít overlay sponzoři</Button>
<Text fontSize="sm" color="gray.500">Veřejná URL: {href}</Text>
</HStack>
); })()}
<HStack spacing={3} mb={3}>
<Button as="label" isLoading={sUploadBusy}>Nahrát loga
<Input type="file" accept="image/*,image/svg+xml" display="none" multiple onChange={async (e) => {
const files = Array.from(e.target.files || []);
if (!files.length) return;
try {
setSUploadBusy(true);
const res = await uploadSponsors(files);
setSponsors(await listSponsorsAdmin());
(e.target as HTMLInputElement).value = '';
toast({ title: 'Loga nahrána', status: 'success' });
try {
const urls = (res?.files || []).filter(Boolean) as string[];
if (urls.length > 0) {
const want = window.confirm('Chcete přidat nahraná loga i jako nové sponzory na web?');
if (want) {
for (const u of urls) {
const fname = (u.split('/').pop() || '').replace(/\.[a-z0-9]+$/i, '');
const name = window.prompt('Název sponzora pro logo '+fname, fname) || '';
if (!name.trim()) continue;
const website = window.prompt('Web sponzora (volitelné, včetně https://)', '') || '';
try { await createSponsor({ name: name.trim(), logo_url: u, website_url: website.trim() || undefined, is_active: true }); } catch {}
}
toast({ title: 'Sponzoři přidáni', status: 'success' });
}
}
} catch {}
} catch (err: any) {
toast({ title: 'Nahrání selhalo', description: err?.message, status: 'error' });
} finally {
setSUploadBusy(false);
}
}} />
</Button>
<Button variant="ghost" onClick={async ()=>{ try { setSponsors(await listSponsorsAdmin()); toast({ title: 'Seznam aktualizován', status: 'info' }); } catch {} }}>Obnovit</Button>
<Button variant="outline" onClick={async ()=>{
try {
const res = await prefillSponsorsFromPage();
setSponsors(await listSponsorsAdmin());
toast({ title: 'Předvyplněno ze Sponzorů', description: `Přidáno ${res?.saved || 0} log`, status: 'success' });
} catch (e:any) {
toast({ title: 'Předvyplnění selhalo', description: e?.message, status: 'error' });
}
}}>Předvyplnit ze stránky Sponzoři</Button>
</HStack>
<HStack mt={3} spacing={3} align="center">
<FormControl maxW="160px" isDisabled={!!state.running}>
<FormLabel>Nastavit čas (MM:SS)</FormLabel>
<Input
value={state.timer || '00:00'}
onChange={async (e) => {
const v = e.target.value.trim();
// allow edit only when not running
if (!state.running) {
const next = await saveScoreboardState({ timer: v });
setState(next);
}
}}
/>
</FormControl>
<SimpleGrid columns={{ base: 2, md: 4, lg: 6 }} spacing={3}>
{sponsors.map((u) => {
const name = (u || '').split('/').pop() || '';
return (
<VStack key={u} spacing={2} borderWidth="1px" borderRadius="md" p={2}>
<Image src={u} alt={name} boxSize="64px" objectFit="contain" />
<Text fontSize="xs" noOfLines={1} maxW="120px">{name}</Text>
<Button size="xs" colorScheme="red" variant="ghost" onClick={async ()=>{ try { await deleteSponsor(name); setSponsors(await listSponsorsAdmin()); toast({ title: 'Smazáno', status: 'success' }); } catch { toast({ title: 'Smazání selhalo', status: 'error' }); } }}>Smazat</Button>
</VStack>
);
})}
</SimpleGrid>
</Box>
<Box borderWidth="1px" borderRadius="lg" p={4} bg={cardBg} mb={6}>
<Heading size="md" mb={3}>QR kód</Heading>
<HStack spacing={4} align="flex-start" flexWrap="wrap">
<VStack align="start" spacing={2}>
<Text fontSize="sm" color="gray.500">Aktuální QR:</Text>
{qrUrl ? (
<Image src={qrUrl} alt="QR" boxSize="128px" objectFit="contain" borderWidth="1px" borderRadius="md" />
) : (
<Text fontSize="sm" color="gray.400">Nenahrán</Text>
)}
</VStack>
<Button as="label" isLoading={qrBusy}>Nahrát QR
<Input type="file" accept="image/*" display="none" onChange={async (e)=>{
const f = e.target.files?.[0];
if (!f) return;
try {
setQrBusy(true);
await uploadQr(f);
setQrUrl(await getQr());
(e.target as HTMLInputElement).value = '';
toast({ title: 'QR nahrán', status: 'success' });
} catch (err: any) {
toast({ title: 'Nahrání selhalo', description: err?.message, status: 'error' });
} finally {
setQrBusy(false);
}
}} />
</Button>
<Button variant="ghost" onClick={async ()=>{ try { setQrUrl(await getQr()); toast({ title: 'Obnoveno', status: 'info' }); } catch {} }}>Obnovit</Button>
</HStack>
</Box>
+79 -11
View File
@@ -155,6 +155,14 @@ const SettingsAdminPage: React.FC = () => {
setSettings((prev) => ({ ...prev, [key]: e.target.value }));
};
const presetStorageThresholds = () => {
setSettings((prev) => ({
...prev,
storage_warn_threshold: 80 as any,
storage_critical_threshold: 95 as any,
}));
};
const handleSave = async () => {
setSaving(true);
try {
@@ -167,7 +175,6 @@ const SettingsAdminPage: React.FC = () => {
youtube_url: (settings as any).youtube_url,
// generic gallery (preferred)
gallery_url: (settings as any).gallery_url,
gallery_label: (settings as any).gallery_label,
// backward compatibility
zonerama_url: (settings as any).zonerama_url,
// SMTP
@@ -203,6 +210,9 @@ const SettingsAdminPage: React.FC = () => {
finished_match_display_days: (settings as any).finished_match_display_days as any,
storage_warn_threshold: (settings as any).storage_warn_threshold as any,
storage_critical_threshold: (settings as any).storage_critical_threshold as any,
// error-review integration (domain managed via .env; only tokens are saved)
error_review_admin_token: (settings as any).error_review_admin_token,
error_review_ingest_token: (settings as any).error_review_ingest_token,
};
const saved = await updateAdminSettings(payload);
setSettings((prev) => ({ ...prev, ...saved }));
@@ -224,6 +234,11 @@ const SettingsAdminPage: React.FC = () => {
try { (api as any).defaults.baseURL = ab; } catch {}
setTimeout(() => { try { window.location.reload(); } catch {} }, 600);
}
// Persist ingest token for frontend errorReporter (URL is fixed by default wiring)
try {
const ingestToken = (saved as any).error_review_ingest_token || (settings as any).error_review_ingest_token;
if (ingestToken) localStorage.setItem('fc_error_ingest_token', String(ingestToken));
} catch {}
} catch {}
} catch (e: any) {
toast({ title: 'Chyba', description: e?.message || 'Uložení nastavení se nezdařilo', status: 'error' });
@@ -301,6 +316,10 @@ const SettingsAdminPage: React.FC = () => {
onChange={handleNumChange('storage_critical_threshold' as any)}
/>
</FormControl>
<FormControl>
<FormLabel>Přednastavit</FormLabel>
<Button onClick={presetStorageThresholds} variant="outline">80 % / 95 %</Button>
</FormControl>
</SimpleGrid>
<FormControl>
<FormLabel>Logo klubu</FormLabel>
@@ -369,10 +388,6 @@ const SettingsAdminPage: React.FC = () => {
<FormLabel>Fotogalerie URL</FormLabel>
<Input value={(settings as any).gallery_url || ''} onChange={handleChange('gallery_url')} />
</FormControl>
<FormControl>
<FormLabel>Popisek fotogalerie</FormLabel>
<Input value={(settings as any).gallery_label || ''} onChange={handleChange('gallery_label')} />
</FormControl>
<HStack>
<Button onClick={handleSave} isLoading={saving} bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }}>Uložit nastavení</Button>
@@ -435,27 +450,71 @@ const SettingsAdminPage: React.FC = () => {
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
<FormControl>
<FormLabel>SMTP Host</FormLabel>
<Input value={(settings as any).smtp_host || ''} onChange={handleChange('smtp_host' as any)} />
<Input
value={(settings as any).smtp_host || ''}
onChange={handleChange('smtp_host' as any)}
placeholder="smtp.seznam.cz nebo smtp.gmail.com"
/>
<FormHelperText>
Adresa SMTP serveru. Příklad: smtp.seznam.cz, smtp.gmail.com, smtp.office365.com
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>SMTP Port</FormLabel>
<Input type="number" value={(settings as any).smtp_port ?? ''} onChange={handleNumChange('smtp_port' as any)} />
<Input
type="number"
value={(settings as any).smtp_port ?? ''}
onChange={handleNumChange('smtp_port' as any)}
placeholder="587 pro TLS, 465 pro SSL, 25 bez šifrování"
/>
<FormHelperText>
Nejčastěji 587 (TLS/STARTTLS) nebo 465 (SSL). Port 25 je bez šifrování a často blokovaný.
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>SMTP Uživatel</FormLabel>
<Input value={(settings as any).smtp_user || ''} onChange={handleChange('smtp_user' as any)} />
<Input
value={(settings as any).smtp_user || ''}
onChange={handleChange('smtp_user' as any)}
placeholder="většinou celá emailová adresa"
/>
<FormHelperText>
Přihlašovací jméno k SMTP (obvykle emailová adresa). Nechte prázdné, pokud server nevyžaduje přihlášení.
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>SMTP Heslo</FormLabel>
<Input type="password" value={(settings as any).smtp_password || ''} onChange={handleChange('smtp_password' as any)} />
<Input
type="password"
value={(settings as any).smtp_password || ''}
onChange={handleChange('smtp_password' as any)}
placeholder="heslo nebo aplikační heslo"
/>
<FormHelperText>
Heslo k účtu nebo aplikační heslo (Gmail/Seznam/Office 365 často vyžadují). Vkládejte bez mezer.
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>From email</FormLabel>
<Input value={(settings as any).smtp_from || ''} onChange={handleChange('smtp_from' as any)} />
<Input
value={(settings as any).smtp_from || ''}
onChange={handleChange('smtp_from' as any)}
placeholder="noreply@vasklub.cz"
/>
<FormHelperText>
Adresa odesílatele uvedená v emailech. Ideálně existující schránka na vašem SMTP serveru.
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>From jméno</FormLabel>
<Input value={(settings as any).smtp_from_name || ''} onChange={handleChange('smtp_from_name' as any)} />
<Input
value={(settings as any).smtp_from_name || ''}
onChange={handleChange('smtp_from_name' as any)}
placeholder="FK Váš Klub"
/>
<FormHelperText>
Zobrazované jméno odesílatele (např. FK Váš Klub).
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Šifrování</FormLabel>
@@ -464,6 +523,9 @@ const SettingsAdminPage: React.FC = () => {
<option value="ssl">SSL</option>
<option value="tls">TLS</option>
</Select>
<FormHelperText>
SSL = implicitní šifrování (obvykle port 465). TLS/STARTTLS = šifrování po navázání spojení (obvykle port 587).
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Autentizace</FormLabel>
@@ -472,11 +534,17 @@ const SettingsAdminPage: React.FC = () => {
<option value="login">LOGIN</option>
<option value="cram-md5">CRAMMD5</option>
</Select>
<FormHelperText>
Mechanismus přihlášení k SMTP. Pokud si nejste jisti, zvolte PLAIN nebo LOGIN. Někteří poskytovatelé vyžadují konkrétní metodu.
</FormHelperText>
</FormControl>
</SimpleGrid>
<FormControl display="flex" alignItems="center">
<FormLabel mb={0}>Přeskočit ověření certifikátu</FormLabel>
<Switch isChecked={!!(settings as any).smtp_skip_verify} onChange={handleBoolChange('smtp_skip_verify' as any)} />
<FormHelperText ml={{ base: 0, md: 4 }}>
Pokročilé: povolte pouze při chybách certifikátu (selfsigned apod.). Nedoporučeno v produkci snižuje bezpečnost.
</FormHelperText>
</FormControl>
<Divider />
+134 -17
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Button,
@@ -33,6 +33,8 @@ import {
NumberInputField,
IconButton,
Divider,
Image,
FormHelperText,
} from '@chakra-ui/react';
import { Link as RouterLink } from 'react-router-dom';
import AdminLayout from '../../layouts/AdminLayout';
@@ -53,6 +55,8 @@ import {
SweepstakePrize,
} from '../../services/sweepstakes';
import { AddIcon, ArrowUpIcon, ArrowDownIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons';
import { FiUpload } from 'react-icons/fi';
import { uploadFile, createArticle } from '../../services/articles';
const fmt = (iso?: string | null) => {
if (!iso) return '';
@@ -70,6 +74,8 @@ const defaultForm = {
picker_style: 'wheel',
total_prizes: 1,
prize_summary: '',
entry_cost_points: 0,
max_entries_per_user: 1,
};
const SweepstakesAdminPage: React.FC = () => {
@@ -89,6 +95,43 @@ const SweepstakesAdminPage: React.FC = () => {
const [prizeForm, setPrizeForm] = useState<{ name: string; quantity: number; value?: string; image_url?: string; kind?: 'physical'|'points'|'xp'|'points_xp'; points?: number; xp?: number }>(() => ({ name: '', quantity: 1, value: '', image_url: '', kind: 'physical', points: 0, xp: 0 }));
const [savingPrize, setSavingPrize] = useState<boolean>(false);
const imageInputRef = useRef<HTMLInputElement>(null);
const rulesInputRef = useRef<HTMLInputElement>(null);
const onUploadImage = async (file?: File | null) => {
if (!file) return;
try {
const res = await uploadFile(file);
setForm((prev: any) => ({ ...prev, image_url: res.url }));
toast({ status: 'success', title: 'Obrázek nahrán' });
} catch (e: any) {
toast({ status: 'error', title: 'Nahrávání selhalo' });
}
};
const onUploadRules = async (file?: File | null) => {
if (!file) return;
try {
const res = await uploadFile(file);
setForm((prev: any) => ({ ...prev, rules_url: res.url }));
toast({ status: 'success', title: 'Pravidla nahrána' });
} catch (e: any) {
toast({ status: 'error', title: 'Nahrávání selhalo' });
}
};
const onCreateRulesArticle = async () => {
try {
const title = (form.title ? `Pravidla soutěže: ${form.title}` : 'Pravidla soutěže').trim();
const a = await createArticle({ title, content: '<p>Zde doplňte pravidla soutěže.</p>', published: true });
const url = `/articles/${a.id}`;
setForm((prev: any) => ({ ...prev, rules_url: url }));
toast({ status: 'success', title: 'Stránka pravidel vytvořena' });
} catch (e: any) {
toast({ status: 'error', title: 'Nelze vytvořit stránku pravidel' });
}
};
const load = async () => {
setLoading(true);
try {
@@ -159,6 +202,8 @@ const SweepstakesAdminPage: React.FC = () => {
picker_style: (it as any).picker_style || 'wheel',
total_prizes: (it as any).total_prizes || 1,
prize_summary: (it as any).prize_summary || '',
entry_cost_points: (it as any).entry_cost_points ?? 0,
max_entries_per_user: (it as any).max_entries_per_user ?? 1,
});
onOpen();
};
@@ -169,11 +214,23 @@ const SweepstakesAdminPage: React.FC = () => {
toast({ status: 'error', title: 'Vyplňte název a datumy' });
return;
}
const tpRaw = Number(form.total_prizes || 1);
const tp = Number.isFinite(tpRaw) ? Math.floor(tpRaw) : 1;
const total_prizes = tp < 1 ? 1 : (tp > 100 ? 100 : tp);
// Normalize datetime-local (YYYY-MM-DDTHH:mm) to RFC3339 with timezone for Go backend
const s = new Date(form.start_at);
const e = new Date(form.end_at);
const payload = {
...form,
total_prizes,
start_at: isNaN(s.getTime()) ? form.start_at : s.toISOString(),
end_at: isNaN(e.getTime()) ? form.end_at : e.toISOString(),
};
if (editing) {
await adminUpdateSweepstake(editing.id, form);
await adminUpdateSweepstake(editing.id, payload);
toast({ status: 'success', title: 'Uloženo' });
} else {
await adminCreateSweepstake(form);
await adminCreateSweepstake(payload);
toast({ status: 'success', title: 'Vytvořeno' });
}
onClose();
@@ -239,6 +296,14 @@ const SweepstakesAdminPage: React.FC = () => {
<VStack align="start" spacing={0}>
<Text fontWeight="bold">{it.title}</Text>
{it.prize_summary && <Text fontSize="xs" opacity={0.8}>{it.prize_summary}</Text>}
<HStack spacing={2} wrap="wrap">
<Badge colorScheme={(it as any).entry_cost_points ? 'purple' : 'green'} fontSize="0.7rem">
{(it as any).entry_cost_points ? `Vstup: ${(it as any).entry_cost_points} bodů` : 'Vstup: zdarma'}
</Badge>
{(it as any).max_entries_per_user > 1 && (
<Badge colorScheme="gray" fontSize="0.7rem">max {(it as any).max_entries_per_user}×/osoba</Badge>
)}
</HStack>
</VStack>
</Td>
<Td>{fmt((it as any).start_at)} {fmt((it as any).end_at)}</Td>
@@ -261,7 +326,7 @@ const SweepstakesAdminPage: React.FC = () => {
)}
{/* Create/Edit Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<Modal isOpen={isOpen} onClose={onClose} size="2xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>{editing ? 'Upravit soutěž' : 'Nová soutěž'}</ModalHeader>
@@ -294,23 +359,69 @@ const SweepstakesAdminPage: React.FC = () => {
<option value="cycler">Náhodný přepínač</option>
</Select>
</FormControl>
<FormControl>
<FormLabel>Počet výher</FormLabel>
<Input type="number" value={form.total_prizes} onChange={(e)=>setForm({ ...form, total_prizes: Number(e.target.value) || 1 })} />
<FormControl isInvalid={Number(form.total_prizes) < 1 || Number(form.total_prizes) > 100}>
<FormLabel>Počet výher</FormLabel>
<NumberInput value={Number(form.total_prizes)||1} min={1} keepWithinRange={false} clampValueOnBlur={false} onChange={(_v, n)=>setForm({ ...form, total_prizes: Number.isFinite(n) ? Math.floor(n) : 1 })}>
<NumberInputField />
</NumberInput>
<FormHelperText>Max. 100 výherců</FormHelperText>
</FormControl>
</SimpleGrid>
<FormControl>
<FormLabel>Souhrn výher</FormLabel>
<Input value={form.prize_summary} onChange={(e)=>setForm({ ...form, prize_summary: e.target.value })} />
</FormControl>
<SimpleGrid columns={2} spacing={4}>
<HStack>
<Button variant="outline" onClick={()=> editing ? openPrizes(editing) : toast({ status: 'info', title: 'Uložte soutěž a poté přidejte výhry' })}>Upravit výhry</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: 'Hlavní výhra', quantity: 1 }); toast({ status:'success', title:'Přidáno: Hlavní výhra' }); } catch { toast({ status:'error', title:'Nelze přidat výhru' }); }
}}>1× Hlavní výhra</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: 'Menší výhra', quantity: 3 }); toast({ status:'success', title:'Přidáno: 3× Menší výhra' }); } catch { toast({ status:'error', title:'Nelze přidat výhry' }); }
}}>3× Menší výhry</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: '100 bodů', kind:'points', points: 100, quantity: 10 }); toast({ status:'success', title:'Přidáno: 10× 100 bodů' }); } catch { toast({ status:'error', title:'Nelze přidat body' }); }
}}>10× 100 bodů</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: '500 XP', kind:'xp', xp: 500, quantity: 5 }); toast({ status:'success', title:'Přidáno: 5× 500 XP' }); } catch { toast({ status:'error', title:'Nelze přidat XP' }); }
}}>5× 500 XP</Button>
</HStack>
<SimpleGrid columns={3} spacing={4}>
<FormControl>
<FormLabel>Obrázek (URL)</FormLabel>
<Input value={form.image_url} onChange={(e)=>setForm({ ...form, image_url: e.target.value })} />
<FormLabel>Vstupné (body)</FormLabel>
<NumberInput min={0} value={Number(form.entry_cost_points)||0} onChange={(v)=>setForm({ ...form, entry_cost_points: Number(v) || 0 })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Pravidla (URL)</FormLabel>
<Input value={form.rules_url} onChange={(e)=>setForm({ ...form, rules_url: e.target.value })} />
<FormLabel>Max. účastí / uživatel</FormLabel>
<NumberInput min={1} value={Number(form.max_entries_per_user)||1} onChange={(v)=>setForm({ ...form, max_entries_per_user: Number(v) || 1 })}>
<NumberInputField />
</NumberInput>
</FormControl>
</SimpleGrid>
<SimpleGrid columns={2} spacing={4}>
<FormControl>
<FormLabel>Titulní obrázek</FormLabel>
<HStack>
<Image src={form.image_url || '/dist/img/logo-club-empty.svg'} alt="cover" boxSize="80px" objectFit="cover" borderRadius="md" />
<Button as="label" leftIcon={<FiUpload />} variant="outline">
Nahrát
<Input ref={imageInputRef} type="file" display="none" accept="image/*" onChange={(e)=>onUploadImage(e.target.files?.[0])} />
</Button>
</HStack>
<Input mt={2} placeholder="nebo vložte URL" value={form.image_url} onChange={(e)=>setForm({ ...form, image_url: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Pravidla</FormLabel>
<HStack>
<Button as="label" leftIcon={<FiUpload />} variant="outline">
Nahrát PDF/obrázek
<Input ref={rulesInputRef} type="file" display="none" accept="image/*,application/pdf" onChange={(e)=>onUploadRules(e.target.files?.[0])} />
</Button>
<Button variant="outline" onClick={onCreateRulesArticle}>Vytvořit stránku</Button>
</HStack>
<Input mt={2} placeholder="nebo vložte URL" value={form.rules_url} onChange={(e)=>setForm({ ...form, rules_url: e.target.value })} />
</FormControl>
</SimpleGrid>
</VStack>
@@ -367,7 +478,13 @@ const SweepstakesAdminPage: React.FC = () => {
</FormControl>
<FormControl>
<FormLabel>Obrázek URL</FormLabel>
<Input value={prizeForm.image_url} onChange={(e)=>setPrizeForm({ ...prizeForm, image_url: e.target.value })} />
<HStack>
<Input value={prizeForm.image_url} onChange={(e)=>setPrizeForm({ ...prizeForm, image_url: e.target.value })} />
<Button as="label" leftIcon={<FiUpload />} size="sm" variant="outline">
Upload
<Input type="file" display="none" accept="image/*" onChange={async (e)=>{ const f=e.target.files?.[0]; if(f){ const r=await uploadFile(f); setPrizeForm(prev=>({...prev, image_url: r.url })); } }} />
</Button>
</HStack>
</FormControl>
</SimpleGrid>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={2} alignItems="end">
+43 -43
View File
@@ -80,8 +80,8 @@ const UsersAdminPage = () => {
} catch (error) {
console.error('Error fetching users:', error);
toast({
title: 'Error',
description: 'Failed to fetch users',
title: 'Chyba',
description: 'Nepodařilo se načíst uživatele',
status: 'error',
duration: 5000,
isClosable: true,
@@ -125,8 +125,8 @@ const UsersAdminPage = () => {
}
await api.put(`/admin/users/${selectedUser.id}`, payload);
toast({
title: 'Success',
description: 'User updated successfully',
title: 'Hotovo',
description: 'Uživatel aktualizován',
status: 'success',
duration: 3000,
isClosable: true,
@@ -135,8 +135,8 @@ const UsersAdminPage = () => {
// Create new user
await api.post('/admin/users', formData);
toast({
title: 'Success',
description: 'User created successfully',
title: 'Hotovo',
description: 'Uživatel vytvořen',
status: 'success',
duration: 3000,
isClosable: true,
@@ -148,8 +148,8 @@ const UsersAdminPage = () => {
} catch (error: any) {
console.error('Error saving user:', error);
toast({
title: 'Error',
description: error.response?.data?.error || error.response?.data?.message || 'Failed to save user',
title: 'Chyba',
description: error.response?.data?.error || error.response?.data?.message || 'Nepodařilo se uložit uživatele',
status: 'error',
duration: 5000,
isClosable: true,
@@ -169,12 +169,12 @@ const UsersAdminPage = () => {
toast({ title: 'Zakázáno', description: 'Nemůžete smazat sám sebe.', status: 'warning' });
return;
}
if (window.confirm('Are you sure you want to delete this user?')) {
if (window.confirm('Opravdu smazat tohoto uživatele?')) {
try {
await api.delete(`/admin/users/${userId}`);
toast({
title: 'Success',
description: 'User deleted successfully',
title: 'Hotovo',
description: 'Uživatel smazán',
status: 'success',
duration: 3000,
isClosable: true,
@@ -183,8 +183,8 @@ const UsersAdminPage = () => {
} catch (error: any) {
console.error('Error deleting user:', error);
toast({
title: 'Error',
description: error.response?.data?.error || error.response?.data?.message || 'Failed to delete user',
title: 'Chyba',
description: error.response?.data?.error || error.response?.data?.message || 'Nepodařilo se smazat uživatele',
status: 'error',
duration: 5000,
isClosable: true,
@@ -241,12 +241,12 @@ const UsersAdminPage = () => {
<Table variant="simple">
<Thead>
<Tr>
<Th>Name</Th>
<Th>Email</Th>
<Th>Jméno</Th>
<Th>Email</Th>
<Th>Role</Th>
<Th>Status</Th>
<Th>Created</Th>
<Th>Actions</Th>
<Th>Stav</Th>
<Th>Vytvořeno</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
@@ -261,7 +261,7 @@ const UsersAdminPage = () => {
</Td>
<Td>
<Badge colorScheme={user.isActive ? 'green' : 'red'}>
{user.isActive ? 'Active' : 'Inactive'}
{user.isActive ? 'Aktiv' : 'Neaktiv'}
</Badge>
</Td>
<Td>{new Date(user.createdAt).toLocaleDateString()}</Td>
@@ -276,7 +276,7 @@ const UsersAdminPage = () => {
/>
<MenuList>
<MenuItem icon={<EditIcon />} onClick={() => openEditModal(user)}>
Edit
Upravit
</MenuItem>
<MenuItem onClick={async () => {
try {
@@ -292,7 +292,7 @@ const UsersAdminPage = () => {
</MenuItem>
{user.role !== 'admin' && String(authUser?.id) !== String(user.id) && (
<MenuItem icon={<DeleteIcon />} color="red.500" onClick={() => handleDelete(user.id)}>
Delete
Smazat
</MenuItem>
)}
</MenuList>
@@ -309,12 +309,12 @@ const UsersAdminPage = () => {
<Table variant="simple">
<Thead>
<Tr>
<Th>Name</Th>
<Th>Email</Th>
<Th>Jméno</Th>
<Th>Email</Th>
<Th>Role</Th>
<Th>Status</Th>
<Th>Created</Th>
<Th>Actions</Th>
<Th>Stav</Th>
<Th>Vytvořeno</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
@@ -324,12 +324,12 @@ const UsersAdminPage = () => {
<Td>{user.email}</Td>
<Td>
<Badge colorScheme={'gray'}>
Fan
Fanoušek
</Badge>
</Td>
<Td>
<Badge colorScheme={user.isActive ? 'green' : 'red'}>
{user.isActive ? 'Active' : 'Inactive'}
{user.isActive ? 'Aktiv' : 'Neaktiv'}
</Badge>
</Td>
<Td>{new Date(user.createdAt).toLocaleDateString()}</Td>
@@ -344,11 +344,11 @@ const UsersAdminPage = () => {
/>
<MenuList>
<MenuItem icon={<EditIcon />} onClick={() => openEditModal(user)}>
Edit
Upravit
</MenuItem>
{String(authUser?.id) !== String(user.id) && (
<MenuItem icon={<DeleteIcon />} color="red.500" onClick={() => handleDelete(user.id)}>
Delete
Smazat
</MenuItem>
)}
</MenuList>
@@ -365,45 +365,45 @@ const UsersAdminPage = () => {
<ModalOverlay />
<ModalContent as="form" onSubmit={handleSubmit}>
<ModalHeader>
{selectedUser ? 'Edit User' : 'Add New User'}
{selectedUser ? 'Upravit uživatele' : 'Přidat uživatele'}
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel>Full Name</FormLabel>
<FormLabel>Jméno a příjmení</FormLabel>
<Input
name="name"
value={formData.name}
onChange={handleInputChange}
placeholder="Enter full name"
placeholder="Zadejte jméno a příjmení"
/>
</FormControl>
<FormControl isRequired>
<FormLabel>Email</FormLabel>
<FormLabel>Email</FormLabel>
<Input
type="email"
name="email"
value={formData.email}
onChange={handleInputChange}
placeholder="Enter email"
placeholder="Zadejte email"
/>
</FormControl>
{!selectedUser && (
<FormControl isRequired={!selectedUser}>
<FormLabel>Password</FormLabel>
<FormLabel>Heslo</FormLabel>
<Input
type="password"
name="password"
value={formData.password}
onChange={handleInputChange}
placeholder="Enter password"
placeholder="Zadejte heslo"
minLength={8}
/>
<FormHelperText>
Password must be at least 8 characters long
Heslo musí mít alespoň 8 znaků
</FormHelperText>
</FormControl>
)}
@@ -429,7 +429,7 @@ const UsersAdminPage = () => {
value={formData.role}
onChange={handleInputChange}
>
<option value="fan">Fan</option>
<option value="fan">Fanoušek</option>
<option value="editor" disabled={!!selectedUser && selectedUser.role === 'admin'}>Editor</option>
<option value="admin">Admin</option>
</Select>
@@ -440,7 +440,7 @@ const UsersAdminPage = () => {
<FormControl display="flex" alignItems="center">
<FormLabel mb="0" mr={2}>
Active
Aktivní
</FormLabel>
<Switch
name="isActive"
@@ -454,15 +454,15 @@ const UsersAdminPage = () => {
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
Cancel
Zrušit
</Button>
<Button
colorScheme="blue"
type="submit"
isLoading={isSubmitting}
loadingText="Saving..."
loadingText="Ukládám..."
>
{selectedUser ? 'Update' : 'Create'} User
{selectedUser ? 'Uložit' : 'Vytvořit'}
</Button>
</ModalFooter>
</ModalContent>
@@ -49,6 +49,7 @@ const vendorScripts: string[] = [
'/premium-assets/js/modernizr-2.6.2.min.js',
'/premium-assets/js/bootstrap.min.js',
'/premium-assets/js/imagesloaded.min.js',
'/premium-assets/js/masonry.min.js',
'/premium-assets/js/jquery.masonry.min.js',
'/premium-assets/js/jquery.nicescroll.js',
'/premium-assets/js/jquery.selectBox.min.js',
@@ -141,6 +142,7 @@ function useInjectAssets() {
if (typeof $.fn.magnificPopup !== 'function') { $.fn.magnificPopup = function(){ return this; }; }
if (typeof $.fn.counterUp !== 'function') { $.fn.counterUp = function(){ return this; }; }
if (typeof $.fn.ripples !== 'function') { $.fn.ripples = function(){ return this; }; }
if (typeof $.fn.masonry !== 'function') { $.fn.masonry = function(){ return this; }; }
return true;
} catch(e){ return true; }
}
+23 -3
View File
@@ -11,6 +11,12 @@ import { getClothing, ClothingItem } from '../../services/clothing';
const PremiumHomePage: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubName = settings?.club_name || 'Fotbal Club';
const igUrl = React.useMemo(() => {
const u = (settings?.instagram_url || '').trim();
if (!u) return '';
const rx = /^https?:\/\/(?:www\.)?instagram\.com\/(?:p|reel|tv)\/[^\/?#]+/i;
return rx.test(u) ? u : '';
}, [settings?.instagram_url]);
// Build zoom slider images from featured/news
const [heroImages, setHeroImages] = React.useState<string[]>([]);
@@ -118,7 +124,7 @@ const PremiumHomePage: React.FC = () => {
const items = (resp?.data || []).slice(0, 4);
const frag = document.createDocumentFragment();
items.forEach((a) => {
const col = h('div', { class: 'items col-xl-6 col-lg-6 col-md-6 col-sm-6 col-ms-6 col-xs-12' });
const col = h('div', { class: 'item div-thumbnail col-xl-6 col-lg-6 col-md-6 col-sm-6 col-ms-6 col-xs-12' });
const art = h('article', { class: 'post type-post has-post-thumbnail hentry' });
const url = a.slug ? `/news/${a.slug}` : `/articles/${a.id}`;
const aPhoto = h('a', { href: url, class: 'lte-photo' });
@@ -134,6 +140,16 @@ const PremiumHomePage: React.FC = () => {
});
mount.innerHTML = '';
mount.appendChild(frag);
// Re-init Masonry for the home grid
try {
const w: any = window as any;
const $: any = (w && (w.jQuery || w.$)) || null;
if ($ && typeof $.fn.imagesLoaded === 'function') {
$(mount).imagesLoaded(() => { try { if (typeof w.initMasonry === 'function') w.initMasonry(); } catch {} });
} else {
try { if (typeof w.initMasonry === 'function') w.initMasonry(); } catch {}
}
} catch {}
} catch {
mount.innerHTML = '<div style="width:100%;text-align:center;padding:12px;color:#c00;">Nepodařilo se načíst novinky.</div>';
}
@@ -480,7 +496,7 @@ const PremiumHomePage: React.FC = () => {
<div className="elementor-element elementor-widget elementor-widget-lte-blog">
<div className="elementor-widget-container">
<div className="blog lte-blog-sc row centered layout-posts">
<div id="latest-blog-items" className="row" aria-live="polite"></div>
<div id="latest-blog-items" className="row masonry" aria-live="polite"></div>
</div>
</div>
</div>
@@ -593,7 +609,11 @@ const PremiumHomePage: React.FC = () => {
</div>
</div>
<div className="col-xl-6 col-lg-6 col-md-12 col-sm-12 col-xs-12">
<blockquote className="instagram-media" data-instgrm-permalink={settings?.instagram_url || 'https://www.instagram.com/'} data-instgrm-version="14" style={{ background: '#FFF', border: 0, borderRadius: 3, boxShadow: '0 0 1px 0 rgba(0,0,0,0.5),0 1px 10px 0 rgba(0,0,0,0.15)', margin: 1, maxWidth: 658, minWidth: 326, padding: 0, width: '99.375%' }}></blockquote>
{igUrl ? (
<blockquote className="instagram-media" data-instgrm-permalink={igUrl} data-instgrm-version="14" style={{ background: '#FFF', border: 0, borderRadius: 3, boxShadow: '0 0 1px 0 rgba(0,0,0,0.5),0 1px 10px 0 rgba(0,0,0,0.15)', margin: 1, maxWidth: 658, minWidth: 326, padding: 0, width: '99.375%' }}></blockquote>
) : (
<a href={settings?.instagram_url || 'https://www.instagram.com/'} target="_blank" rel="noreferrer">Instagram</a>
)}
</div>
</div>
</div>
@@ -3,9 +3,11 @@ import { usePublicSettings } from '../../hooks/usePublicSettings';
import PremiumAssetsLoader from './PremiumAssetsLoader';
import { assetUrl } from '../../utils/url';
import { useAuth } from '../../contexts/AuthContext';
import { useClubTheme } from '../../hooks/useClubTheme';
const PremiumLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { data: s } = usePublicSettings();
const theme = useClubTheme();
const clubLogo = s?.club_logo_url || '/dist/img/logo-club-empty.svg';
const clubName = s?.club_name || 'Fotbal Club';
const galleryUrl = s?.gallery_url || s?.zonerama_url || undefined;
@@ -13,6 +15,61 @@ const PremiumLayout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
const role = String(user?.role || '').toLowerCase();
const accountHref = role === 'admin' || role === 'editor' ? '/admin' : '/semiadmin';
React.useEffect(() => {
try {
const root = document.documentElement as HTMLElement;
root.style.setProperty('--main', theme.primaryColor);
root.style.setProperty('--second', theme.secondaryColor);
root.style.setProperty('--accent', theme.accentColor);
root.style.setProperty('--background', theme.backgroundColor);
document.body.style.backgroundColor = theme.backgroundColor;
} catch {}
}, [theme.primaryColor, theme.secondaryColor, theme.accentColor, theme.backgroundColor]);
React.useEffect(() => {
const t = setTimeout(() => {
try {
const w: any = window as any;
if (typeof w.initStyles === 'function') { w.initStyles(); }
if (typeof w.setResizeStyles === 'function') { w.setResizeStyles(); }
if (typeof w.checkNavbar === 'function') { w.checkNavbar(); }
if (typeof w.initMasonry === 'function') { w.initMasonry(); }
if (typeof w.initParallax === 'function') { w.initParallax(); }
if ((w.jQuery || w.$) && typeof (w.jQuery || w.$)(window).trigger === 'function') {
(w.jQuery || w.$)(window).trigger('resize');
} else {
window.dispatchEvent(new Event('resize'));
}
} catch {}
}, 50);
return () => clearTimeout(t);
}, []);
React.useEffect(() => {
const w: any = window as any;
let raf = 0;
const relayout = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
try { if (typeof w.initMasonry === 'function') w.initMasonry(); } catch {}
});
};
// Reflow on new images loading inside masonry containers
const imgs: NodeListOf<HTMLImageElement> = document.querySelectorAll('.masonry img');
imgs.forEach((img) => { if (!img.complete) img.addEventListener('load', relayout, { once: true } as any); });
// Observe DOM mutations inside masonry containers
const containers = document.querySelectorAll('.masonry');
const observers: MutationObserver[] = [];
containers.forEach((c) => {
const mo = new MutationObserver(() => relayout());
mo.observe(c, { childList: true, subtree: true });
observers.push(mo);
});
// Initial attempt
relayout();
return () => { observers.forEach((o) => o.disconnect()); cancelAnimationFrame(raf); };
}, []);
return (
<div className="lte-content-wrapper lte-layout-transparent-full">
<PremiumAssetsLoader />
+13
View File
@@ -863,6 +863,12 @@ html {
padding: 8px 2px 16px 2px;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
touch-action: pan-x pinch-zoom;
overscroll-behavior-x: contain;
user-select: none;
-webkit-user-select: none;
scroll-snap-type: x proximity;
contain: paint;
}
.matches-slider .matches-track::-webkit-scrollbar {
height: 12px;
@@ -894,6 +900,10 @@ html {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
position: relative;
scroll-snap-align: start;
will-change: transform;
content-visibility: auto;
contain-intrinsic-size: 160px 340px;
}
.match-card::after {
content: '';
@@ -952,6 +962,9 @@ html {
object-fit: cover;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transition: box-shadow 0.2s ease;
pointer-events: none;
-webkit-user-drag: none;
user-select: none;
}
.match-card:hover .team img {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+16
View File
@@ -0,0 +1,16 @@
export type AdminAction =
| { type: 'nav'; at: number; path: string }
| { type: 'request'; at: number; method: string; url: string; status?: number; ms?: number; ok?: boolean };
const CAP = 200;
let buf: AdminAction[] = [];
export function logAction(a: AdminAction) {
buf.push(a);
if (buf.length > CAP) buf = buf.slice(buf.length - CAP);
}
export function getRecentActions(limit = 12): AdminAction[] {
const n = Math.max(1, Math.min(limit, CAP));
return buf.slice(-n);
}
+43
View File
@@ -1,5 +1,7 @@
import axios, { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { reportError } from './errorReporter';
import { getToken } from '../utils/auth';
import { logAction } from './actionLog';
function readStored(key: string): string | null {
try { return localStorage.getItem(key); } catch { return null; }
@@ -63,11 +65,22 @@ async function getCsrfToken(): Promise<string | null> {
// Request interceptor - attach bearer token when available
api.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
(config as any).metadata = { ...(config as any).metadata, start: Date.now() };
const token = getToken();
config.headers = config.headers || {};
if (token) {
(config.headers as any).Authorization = `Bearer ${token}`;
}
// Dev helper: attach X-Admin-Token from localStorage if present (allows admin calls without rebuild)
if (process.env.NODE_ENV !== 'production') {
try {
const devAdmin = readStored('fc_admin_token');
if (devAdmin && !(config.headers as any)['X-Admin-Token']) {
(config.headers as any)['X-Admin-Token'] = devAdmin;
(config.headers as any)['X-Dev-Admin'] = 'true';
}
} catch {}
}
// For cookie-based flows (no Bearer header), attach X-CSRF-Token on mutating methods
const method = (config.method || 'get').toLowerCase();
const isMutating = method === 'post' || method === 'put' || method === 'patch' || method === 'delete';
@@ -89,6 +102,23 @@ api.interceptors.request.use(
api.interceptors.response.use(
(response: AxiosResponse) => response,
(error) => {
try {
const status = error?.response?.status;
try {
const cfg = error?.config || {};
const method: string = (cfg?.method || 'get').toUpperCase();
const url: string = cfg?.url || '';
const start: number | undefined = (cfg as any)?.metadata?.start;
const ms = typeof start === 'number' ? Date.now() - start : undefined;
logAction({ type: 'request', at: Date.now(), method, url, status, ms, ok: false });
} catch {}
if (typeof status === 'number' && status >= 500) {
const reqUrl: string = error.config?.url || '';
const method: string = (error.config?.method || 'get').toUpperCase();
const requestId: string | undefined = error.response?.headers?.['x-request-id'] || error.response?.headers?.['X-Request-ID'];
reportError({ message: `HTTP ${status} ${method} ${reqUrl}`, status, method, url: reqUrl, request_id: requestId });
}
} catch {}
if (error.response?.status === 401) {
// Avoid redirect loop on the login call itself
const reqUrl: string = error.config?.url || '';
@@ -108,6 +138,19 @@ api.interceptors.response.use(
}
);
api.interceptors.response.use((response: AxiosResponse) => {
try {
const cfg = response.config || {} as any;
const method: string = (cfg?.method || 'get').toUpperCase();
const url: string = cfg?.url || '';
const start: number | undefined = (cfg as any)?.metadata?.start;
const ms = typeof start === 'number' ? Date.now() - start : undefined;
const status = response.status;
logAction({ type: 'request', at: Date.now(), method, url, status, ms, ok: true });
} catch {}
return response;
});
// Upload image helper
export const uploadImage = async (formData: FormData): Promise<{ url: string }> => {
const res = await api.post('/upload', formData, {
+31 -1
View File
@@ -2,15 +2,45 @@ import api, { API_URL } from './api';
import { getToken } from '../utils/auth';
const normalizeArticle = (raw: any): Article => {
if (!raw) return raw;
if (!raw) return raw as Article;
const id = raw.id ?? raw.ID ?? raw.article_id ?? raw.articleId;
const category = raw.category ?? raw.Category;
const author = raw.author ?? raw.Author;
// Normalize attachments: backend may send a JSON string or an array
let attachments: Array<{ name: string; url: string; mime_type?: string; size?: number }> | undefined = undefined;
const aRaw = raw.attachments ?? raw.Attachments;
try {
if (Array.isArray(aRaw)) {
attachments = aRaw.map((it: any) => {
if (typeof it === 'string') {
const name = it.split('/').pop() || 'soubor';
return { name, url: it };
}
return { name: it?.name || (String(it?.url || '').split('/').pop() || 'soubor'), url: it?.url || '', mime_type: it?.mime_type || it?.type, size: it?.size };
});
} else if (typeof aRaw === 'string' && aRaw.trim() !== '') {
const parsed = JSON.parse(aRaw);
if (Array.isArray(parsed)) {
attachments = parsed.map((it: any) => {
if (typeof it === 'string') {
const name = it.split('/').pop() || 'soubor';
return { name, url: it };
}
return { name: it?.name || (String(it?.url || '').split('/').pop() || 'soubor'), url: it?.url || '', mime_type: it?.mime_type || it?.type, size: it?.size };
});
}
}
} catch {
// ignore malformed attachments
}
return {
...(raw as Article),
id,
category,
author,
...(attachments ? { attachments } : {}),
} as Article;
};
+1
View File
@@ -15,6 +15,7 @@ export type CommentItem = {
updated_at: string;
reactions?: Record<string, number>;
my_reaction?: string;
admin_liked?: boolean;
user: {
id: number;
first_name?: string;
+147
View File
@@ -0,0 +1,147 @@
import { getRecentActions } from './actionLog';
export type FEErrorEvent = {
origin: 'frontend';
language?: 'ts' | 'tsx' | string;
severity?: 'error' | 'warn' | 'fatal';
message: string;
stack?: string;
component?: string;
file?: string;
line?: number;
column?: number;
url?: string;
method?: string;
status?: number;
request_id?: string;
user_id?: number;
session_token?: string;
tags?: Record<string, string>;
context?: Record<string, any>;
env?: string;
version?: string;
hostname?: string;
occurred_at?: string;
};
function readLS(key: string): string | null {
try { return localStorage.getItem(key); } catch { return null; }
}
function getIngestUrl(): string | null {
if (process.env.REACT_APP_ERROR_INGEST_URL) return process.env.REACT_APP_ERROR_INGEST_URL as string;
try {
if (typeof window !== 'undefined') {
const host = window.location.hostname || '';
const isLocal = host === 'localhost' || host === '127.0.0.1' || host === '::1' || /^[0-9.]+$/.test(host) || host.endsWith('.local');
if (!isLocal) {
return 'https://errors.tdvorak.dev/api/v1/errors';
}
}
} catch {}
return '/api/v1/errors';
}
function getIngestToken(): string | null {
return (process.env.REACT_APP_ERROR_INGEST_TOKEN as string) || null;
}
let lastSentAt = 0;
let lastHash = '';
function fingerprint(ev: FEErrorEvent): string {
const basis = [ev.message, ev.stack || '', ev.url || '', ev.component || ''].join('|');
let h = 0;
for (let i = 0; i < basis.length; i++) {
h = ((h << 5) - h) + basis.charCodeAt(i);
h |= 0;
}
return String(h);
}
export async function reportError(ev: Partial<FEErrorEvent>): Promise<void> {
const url = getIngestUrl();
if (!url) return; // disabled until configured
const now = Date.now();
const full: FEErrorEvent = {
origin: 'frontend',
language: ev.language || 'tsx',
severity: ev.severity || 'error',
message: ev.message || 'Unknown error',
stack: ev.stack,
component: ev.component,
file: ev.file,
line: ev.line,
column: ev.column,
url: ev.url || (typeof window !== 'undefined' ? window.location.pathname + window.location.search : undefined),
method: ev.method,
status: ev.status,
request_id: ev.request_id,
user_id: ev.user_id,
session_token: ev.session_token,
tags: {
service: 'frontend',
instance_env: String(process.env.NODE_ENV || ''),
instance_host: (typeof window !== 'undefined' && window.location && window.location.hostname) ? window.location.hostname : '',
...(ev.tags || {}),
},
context: {
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
referrer: typeof document !== 'undefined' ? document.referrer : undefined,
viewport: typeof window !== 'undefined' ? { w: window.innerWidth, h: window.innerHeight } : undefined,
recentActions: getRecentActions(18),
...ev.context,
},
env: ev.env || process.env.NODE_ENV,
version: ev.version,
hostname: ev.hostname || (typeof window !== 'undefined' ? window.location.hostname : undefined),
occurred_at: new Date(now).toISOString(),
};
const hash = fingerprint(full);
if (hash === lastHash && (now - lastSentAt) < 1500) {
return; // basic de-dupe burst
}
lastHash = hash;
lastSentAt = now;
try {
await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(getIngestToken() ? { 'Authorization': `Bearer ${getIngestToken()}` } : {}),
},
body: JSON.stringify(full),
credentials: 'omit',
keepalive: true,
});
} catch {
// swallow
}
}
export function installGlobalErrorHandlers() {
if (typeof window === 'undefined') return;
// window.onerror
window.addEventListener('error', (event: ErrorEvent) => {
const isResourceError = (event as any).message === undefined && event.filename === '';
const message = (event.message || (isResourceError ? 'Resource load error' : 'Unhandled error')) as string;
reportError({
message,
stack: event.error?.stack,
file: event.filename,
line: event.lineno,
column: event.colno,
url: window.location.pathname + window.location.search,
});
});
// unhandledrejection
window.addEventListener('unhandledrejection', (event) => {
const reason: any = (event as any).reason;
const message = typeof reason === 'string' ? reason : (reason?.message || 'Unhandled rejection');
const stack = typeof reason === 'object' ? (reason?.stack || '') : '';
reportError({ message, stack, url: window.location.pathname + window.location.search });
});
}
+72
View File
@@ -0,0 +1,72 @@
import api from './api';
export interface ErrorEvent {
id: number;
origin: string;
language?: string;
severity?: string;
message: string;
stack?: string;
component?: string;
file?: string;
line?: number;
column?: number;
url?: string;
method?: string;
status?: number;
request_id?: string;
user_id?: number;
session_token?: string;
tags?: Record<string, any> | null;
context?: Record<string, any> | null;
env?: string;
version?: string;
hostname?: string;
occurred_at: string;
created_at: string;
}
export interface ErrorListResponse {
items: ErrorEvent[];
total: number;
}
export async function getErrors(params?: {
origin?: string;
severity?: string;
method?: string;
status?: string | number;
search?: string;
from?: string; // ISO
to?: string; // ISO
page?: number;
limit?: number;
}): Promise<ErrorListResponse> {
const res = await api.get('/admin/errors', { params });
return res.data as ErrorListResponse;
}
export async function getError(id: number): Promise<ErrorEvent> {
const res = await api.get(`/admin/errors/${id}`);
return res.data as ErrorEvent;
}
export async function getExternalErrors(params?: {
origin?: string;
severity?: string;
method?: string;
status?: string | number;
search?: string;
from?: string; // ISO
to?: string; // ISO
page?: number;
limit?: number;
}): Promise<ErrorListResponse> {
const res = await api.get('/admin/errors/external', { params });
return res.data as ErrorListResponse;
}
export async function getExternalError(id: number): Promise<ErrorEvent> {
const res = await api.get(`/admin/errors/external/${id}`);
return res.data as ErrorEvent;
}
+9 -29
View File
@@ -1,5 +1,4 @@
import axios from 'axios';
import { API_URL } from './api';
import api from './api';
// Use shared API_URL which already resolves to '/api/v1' under current origin
@@ -62,46 +61,32 @@ export const getAllFiles = async (params?: {
sort_by?: string;
sort_order?: string;
}): Promise<FileInfo[]> => {
const response = await axios.get(`${API_URL}/admin/files`, {
params,
withCredentials: true,
});
const response = await api.get(`/admin/files`, { params });
return response.data;
};
export const getUnusedFiles = async (): Promise<FileInfo[]> => {
const response = await axios.get(`${API_URL}/admin/files/unused`, {
withCredentials: true,
});
const response = await api.get(`/admin/files/unused`);
return response.data;
};
export const getDuplicateFiles = async (): Promise<DuplicateFiles> => {
const response = await axios.get(`${API_URL}/admin/files/duplicates`, {
withCredentials: true,
});
const response = await api.get(`/admin/files/duplicates`);
return response.data;
};
export const getStorageUsage = async (): Promise<StorageUsage> => {
const response = await axios.get(`${API_URL}/admin/files/usage`, {
withCredentials: true,
});
const response = await api.get(`/admin/files/usage`);
return response.data;
};
export const getFileUsages = async (fileId: number): Promise<any[]> => {
const response = await axios.get(`${API_URL}/admin/files/${fileId}/usages`, {
withCredentials: true,
});
const response = await api.get(`/admin/files/${fileId}/usages`);
return response.data;
};
export const deleteFile = async (fileId: number, force: boolean = false): Promise<void> => {
await axios.delete(`${API_URL}/admin/files/${fileId}`, {
params: { force },
withCredentials: true,
});
await api.delete(`/admin/files/${fileId}`, { params: { force } });
};
export const scanAndSyncFiles = async (): Promise<{
@@ -113,9 +98,7 @@ export const scanAndSyncFiles = async (): Promise<{
new_files_list?: string[];
orphaned_list?: string[];
}> => {
const response = await axios.post(`${API_URL}/admin/files/scan`, {}, {
withCredentials: true,
});
const response = await api.post(`/admin/files/scan`, {});
return response.data;
};
@@ -131,10 +114,7 @@ export const refreshFileTracking = async (entityType?: string): Promise<{
settings_scanned: number;
};
}> => {
const response = await axios.post(`${API_URL}/admin/files/refresh-tracking`, {}, {
params: entityType ? { entity_type: entityType } : {},
withCredentials: true,
});
const response = await api.post(`/admin/files/refresh-tracking`, {}, { params: entityType ? { entity_type: entityType } : {} });
return response.data;
};
+9 -2
View File
@@ -44,8 +44,15 @@ function normalize(p: any): Player {
} as Player;
}
export async function getPlayers(): Promise<Player[]> {
const res = await api.get<any[] | { data?: any[]; items?: any[] }>('/players');
export async function getPlayers(opts?: { active?: boolean; team_id?: number | string }): Promise<Player[]> {
let url = '/players';
const params = new URLSearchParams();
if (opts && opts.active === false) params.set('active', 'false');
if (opts && opts.team_id != null) params.set('team_id', String(opts.team_id));
if (Array.from(params.keys()).length > 0) {
url += `?${params.toString()}`;
}
const res = await api.get<any[] | { data?: any[]; items?: any[] }>(url);
const raw = Array.isArray(res.data)
? res.data
: ((res.data as any).data || (res.data as any).items);
+9 -2
View File
@@ -9,6 +9,7 @@ export type Player = {
position?: string;
jersey_number?: number;
image_url?: string;
gender?: string;
is_active?: boolean;
// Extended detail fields (optional on public endpoint)
nationality?: string;
@@ -33,6 +34,7 @@ function normalizePlayer(p: any): Player {
position: p.position ?? p.Position ?? undefined,
jersey_number: p.jersey_number ?? p.JerseyNumber ?? undefined,
image_url: p.image_url ?? p.ImageURL ?? undefined,
gender: p.gender ?? p.Gender ?? undefined,
is_active: Boolean(p.is_active ?? p.IsActive ?? true),
nationality: p.nationality ?? p.Nationality ?? undefined,
date_of_birth: p.date_of_birth ?? p.DateOfBirth ?? undefined,
@@ -55,8 +57,13 @@ export async function getStandings() {
return Array.isArray(res.data) ? res.data : res.data.data;
}
export async function getPlayers() {
const res = await api.get<any[] | { data?: any[]; items?: any[] }>('/players');
export async function getPlayers(opts?: { active?: boolean; team_id?: number | string }) {
let url = '/players';
const params = new URLSearchParams();
if (opts && opts.active === false) params.set('active', 'false');
if (opts && opts.team_id != null) params.set('team_id', String(opts.team_id));
if (Array.from(params.keys()).length > 0) url += `?${params.toString()}`;
const res = await api.get<any[] | { data?: any[]; items?: any[] }>(url);
const raw = Array.isArray(res.data)
? res.data
: ((res.data as any).data || (res.data as any).items);
+63 -4
View File
@@ -162,13 +162,20 @@ export async function loadPreset(filename: string): Promise<void> {
// Admin: sponsors management
export async function listSponsorsAdmin(): Promise<string[]> {
const res = await api.get<string[]>('/admin/scoreboard/sponsors');
return res.data || [];
const list = res.data || [];
try {
const base = new URL(API_URL || '', typeof window !== 'undefined' ? window.location.origin : undefined);
const origin = `${base.protocol}//${base.host}`;
return list.map((u) => (u && u.startsWith('/uploads/') ? origin + u : u));
} catch {
return list;
}
}
export async function uploadSponsors(files: File[]): Promise<{ saved: number }> {
export async function uploadSponsors(files: File[]): Promise<{ saved: number; files?: string[] }> {
const fd = new FormData();
for (const f of files) fd.append('files', f);
const res = await api.post<{ saved: number }>('/admin/scoreboard/sponsors/upload', fd, { headers: { 'Content-Type': 'multipart/form-data' } });
const res = await api.post<{ saved: number; files?: string[] }>('/admin/scoreboard/sponsors/upload', fd, { headers: { 'Content-Type': 'multipart/form-data' } });
return res.data || { saved: 0 };
}
@@ -176,10 +183,62 @@ export async function deleteSponsor(name: string): Promise<void> {
await api.delete('/admin/scoreboard/sponsors', { params: { name } });
}
export async function prefillSponsorsFromPage(ids?: number[]): Promise<{ saved: number; files?: string[] }> {
const payload = Array.isArray(ids) && ids.length ? { ids } : {};
const res = await api.post<{ saved: number; files?: string[] }>('/admin/scoreboard/sponsors/prefill', payload);
return res.data || { saved: 0 };
}
// Public: sponsors list for overlay
export async function listSponsorsPublic(): Promise<string[]> {
const res = await api.get<string[]>('/scoreboard/sponsors');
return res.data || [];
const list = res.data || [];
try {
const base = new URL(API_URL || '', typeof window !== 'undefined' ? window.location.origin : undefined);
const origin = `${base.protocol}//${base.host}`;
return list.map((u) => (u && u.startsWith('/uploads/') ? origin + u : u));
} catch {
return list;
}
}
// Admin/public: QR management
export async function getQr(): Promise<string> {
// Prefer admin endpoint when available to avoid public cache
try {
const res = await api.get<{ qr?: string }>('/admin/scoreboard/qr');
const u = res.data?.qr || '';
if (u && u.startsWith('/uploads/')) {
try {
const base = new URL(API_URL || '', typeof window !== 'undefined' ? window.location.origin : undefined);
return `${base.protocol}//${base.host}${u}`;
} catch {}
}
return u;
} catch {
try {
const base = (API_URL || '').replace(/\/$/, '');
const r = await fetch(`${base}/scoreboard/qr`, { credentials: 'include' });
if (r.ok) {
const data = await r.json();
const u = data?.qr || '';
if (u && u.startsWith('/uploads/')) {
try {
const b = new URL(API_URL || '', typeof window !== 'undefined' ? window.location.origin : undefined);
return `${b.protocol}//${b.host}${u}`;
} catch {}
}
return u;
}
} catch {}
return '';
}
}
export async function uploadQr(file: File): Promise<void> {
const fd = new FormData();
fd.append('file', file);
await api.post('/admin/scoreboard/qr', fd, { headers: { 'Content-Type': 'multipart/form-data' } });
}
// Utilities
+9
View File
@@ -49,6 +49,8 @@ export type PublicSettings = {
videos_style?: 'slider' | 'grid3' | 'grid';
videos_source?: 'auto' | 'manual';
videos_limit?: number;
// Auto videos title overrides (YouTube): video_id -> title
videos_title_overrides?: Record<string, string>;
// Merch module
merch_module_enabled?: boolean;
merch_style?: 'grid' | 'slider';
@@ -126,6 +128,13 @@ export type AdminSettings = PublicSettings & {
storage_quota_mb?: number;
storage_warn_threshold?: number;
storage_critical_threshold?: number;
// External error-review integration
error_review_ingest_url?: string;
error_review_ingest_token?: string;
error_review_admin_url?: string;
error_review_admin_token?: string;
error_review_ui_url?: string;
};
export const getPublicSettings = async (): Promise<PublicSettings> => {
+3
View File
@@ -12,6 +12,9 @@ export type Sweepstake = {
picker_style?: 'wheel' | 'cycler' | string;
total_prizes?: number;
prize_summary?: string;
entry_cost_points?: number;
entry_fee_czk?: number;
max_entries_per_user?: number;
winners_selected_at?: string | null;
visibility_until?: string | null;
};
+20 -9
View File
@@ -164,6 +164,21 @@
z-index: 6000 !important;
}
/* Custom buttons: color/background reset */
.ql-toolbar.ql-snow button.ql-colorreset,
.ql-toolbar.ql-snow button.ql-bgreset {
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
.ql-toolbar.ql-snow button.ql-colorreset::before,
.ql-toolbar.ql-snow button.ql-bgreset::before {
content: "×";
font-size: 16px;
line-height: 1;
}
/* Center icons and enlarge align icon */
.ql-toolbar .ql-picker-label svg {
width: 18px;
@@ -390,15 +405,11 @@
outline-offset: 2px;
}
/* Prevent White Text on White Background */
.ql-editor [style*="color: rgb(255, 255, 255)"],
.ql-editor [style*="color: white"],
.ql-editor [style*="color: #fff"],
.ql-editor [style*="color: #ffffff"],
.ql-editor [style*="color: rgb(255,255,255)"],
.ql-editor [style*="color: rgba(255, 255, 255"],
.ql-editor [style*="color: rgba(255,255,255"] {
color: #1a202c !important;
/* Allow white color in editor; no forced override */
/* Hide default Quill tooltip (we use our own link modal) */
.ql-tooltip {
display: none !important;
}
/* Let Quill inline color styles take precedence; keep only weight for bold */