mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #89
This commit is contained in:
+4
-1
@@ -29,4 +29,7 @@ OPENROUTER_MODEL=mistralai/mistral-small-3.2-24b-instruct:free
|
||||
OPENROUTER_FALLBACK_MODEL=mistralai/mistral-nemo:free
|
||||
# Optional headers to identify your site/app to OpenRouter
|
||||
OPENROUTER_SITE_URL=http://localhost:8080
|
||||
OPENROUTER_APP_NAME=Fotbal Club Manager
|
||||
OPENROUTER_APP_NAME=Fotbal Club Manager
|
||||
|
||||
REACT_APP_ERROR_INGEST_URL=http://127.0.0.1:8083/api/v1/errors
|
||||
REACT_APP_ERROR_INGEST_TOKEN=
|
||||
@@ -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
@@ -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 />} />
|
||||
|
||||
@@ -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}>E‑mailové 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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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%',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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><img src="/uploads/2025/01/foto.jpg" alt="Popis" /></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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}>Auto‑refresh</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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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á e‑mailová adresa"
|
||||
/>
|
||||
<FormHelperText>
|
||||
Přihlašovací jméno k SMTP (obvykle e‑mailová 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 e‑mail</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 e‑mailech. 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">CRAM‑MD5</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 (self‑signed apod.). Nedoporučeno v produkci – snižuje bezpečnost.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<Divider />
|
||||
|
||||
@@ -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ýherců</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">
|
||||
|
||||
@@ -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>E‑mail</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 ? 'Aktivní' : 'Neaktivní'}
|
||||
</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>E‑mail</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 ? 'Aktivní' : 'Neaktivní'}
|
||||
</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>E‑mail</FormLabel>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter email"
|
||||
placeholder="Zadejte e‑mail"
|
||||
/>
|
||||
</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; }
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user