dev day #90 🥳

This commit is contained in:
Tomas Dvorak
2025-11-12 20:31:37 +01:00
parent 8762bde4bf
commit f3db65d350
103 changed files with 4053 additions and 2189 deletions
+10 -4
View File
@@ -86,7 +86,6 @@ const PrivacyPolicyPage = lazy(() => import('./pages/legal/PrivacyPolicyPage'));
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 MatchesAdminPage = lazy(() => import('./pages/admin/MatchesAdminPage'));
const PlayersAdminPage = lazy(() => import('./pages/admin/PlayersAdminPage'));
const TeamsAdminPage = lazy(() => import('./pages/admin/TeamsAdminPage'));
@@ -132,7 +131,7 @@ const FontLoader: React.FC = () => {
// Public route wrapper
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated, isLoading } = useAuth();
const { isAuthenticated, isLoading, user } = useAuth();
const [checkingSetup, setCheckingSetup] = useState(true);
const [requiresSetup, setRequiresSetup] = useState<boolean>(false);
@@ -156,7 +155,14 @@ const PublicRoute = ({ children }: { children: React.ReactNode }) => {
}
if (isAuthenticated) {
return <Navigate to="/admin" replace />;
const role = (user as any)?.role;
if (role === 'admin') {
return <Navigate to="/admin" replace />;
}
if (role === 'editor') {
return <Navigate to="/admin/clanky" replace />;
}
return <Navigate to="/" replace />;
}
const currentPath = window.location.pathname;
@@ -261,6 +267,7 @@ const AppLazy: React.FC = () => {
<Route element={<ProtectedRoute requiredRole="editor"><AdminRoutesWrapper /></ProtectedRoute>}>
<Route path="/admin/clanky" element={<ArticlesAdminPage />} />
<Route path="/admin/aktivity" element={<AdminActivitiesPage />} />
<Route path="/admin/shortlinks" element={<ShortlinksAdminPage />} />
</Route>
{/* Admin routes */}
@@ -272,7 +279,6 @@ const AppLazy: React.FC = () => {
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
<Route path="/admin/kategorie" element={<CategoriesAdminPage />} />
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
<Route path="/admin/tymy" element={<TeamsAdminPage />} />
-2
View File
@@ -32,7 +32,6 @@ import ActivitiesCalendarPage from './pages/ActivitiesCalendarPage';
import AdminDashboardPage from './pages/admin/AdminDashboardPage';
import ArticlesAdminPage from './pages/admin/ArticlesAdminPage';
import SponsorsAdminPage from './pages/admin/SponsorsAdminPage';
import CategoriesAdminPage from './pages/admin/CategoriesAdminPage';
import MatchesAdminPage from './pages/admin/MatchesAdminPage';
import PlayersAdminPage from './pages/admin/PlayersAdminPage';
import TeamsAdminPage from './pages/admin/TeamsAdminPage';
@@ -493,7 +492,6 @@ const App: React.FC = () => {
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
<Route path="/admin/kategorie" element={<CategoriesAdminPage />} />
{/* moved to editor-accessible routes below */}
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
@@ -37,7 +37,6 @@ const adminIndex: AdminSearchItem[] = [
{ 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 },
{ label: 'Nastavení', path: '/admin/nastaveni', section: 'Systém', keywords: ['settings', 'config'], icon: FaCog },
{ label: 'Newsletter', path: '/admin/newsletter', section: 'Komunikace', keywords: ['email', 'campaign'], icon: FaEnvelope },
{ label: 'Uživatelé', path: '/admin/uzivatele', section: 'Systém', keywords: ['users', 'accounts'], icon: FaKey },
+165 -148
View File
@@ -263,9 +263,14 @@ const AdminSidebar = ({
sessionStorage.setItem(STORAGE_KEY, String(node.scrollTop));
}, []);
// Load dynamic navigation from API
// Load dynamic navigation from API (admins only)
useEffect(() => {
let active = true;
// Editors should not call admin-only navigation endpoint; use fallback
if (!isAdmin) {
setNavLoading(false);
return () => { active = false };
}
(async () => {
try {
const items = await getAllNavigationItems();
@@ -470,8 +475,8 @@ const AdminSidebar = ({
);
})}
{/* Ensure Shortlinks is present even if not configured in dynamic nav */}
{!hasShortlinks && (
{/* Ensure Shortlinks is present even if not configured in dynamic nav (admins only) */}
{isAdmin && !hasShortlinks && (
<NavItem
icon={FaLink}
to="/admin/shortlinks"
@@ -481,8 +486,8 @@ const AdminSidebar = ({
</NavItem>
)}
{/* Ensure Engagement page is present even if not configured in dynamic nav */}
{!hasEngagement && (
{/* Ensure Engagement page is present even if not configured in dynamic nav (admins only) */}
{isAdmin && !hasEngagement && (
<NavItem
icon={FaAward}
to="/admin/engagement"
@@ -492,8 +497,8 @@ const AdminSidebar = ({
</NavItem>
)}
{/* Ensure Comments moderation is present even if not configured in dynamic nav */}
{!hasComments && (
{/* Ensure Comments moderation is present even if not configured in dynamic nav (admins only) */}
{isAdmin && !hasComments && (
<NavItem
icon={FaComments}
to="/admin/komentare"
@@ -502,8 +507,8 @@ const AdminSidebar = ({
Komentáře
</NavItem>
)}
{/* Ensure Sweepstakes is present even if not configured in dynamic nav */}
{!hasSweepstakes && (
{/* Ensure Sweepstakes is present even if not configured in dynamic nav (admins only) */}
{isAdmin && !hasSweepstakes && (
<NavItem
icon={FaGift}
to="/admin/sweepstakes"
@@ -512,8 +517,8 @@ const AdminSidebar = ({
Soutěže
</NavItem>
)}
{/* Ensure Competition Aliases is present even if not configured in dynamic nav */}
{!hasCompetitionAliases && (
{/* Ensure Competition Aliases is present even if not configured in dynamic nav (admins only) */}
{isAdmin && !hasCompetitionAliases && (
<NavItem
icon={FaAward}
to="/admin/aliasy-soutezi"
@@ -523,8 +528,8 @@ const AdminSidebar = ({
</NavItem>
)}
{/* Ensure Clothing is present even if not configured in dynamic nav */}
{!hasClothing && (
{/* Ensure Clothing is present even if not configured in dynamic nav (admins only) */}
{isAdmin && !hasClothing && (
<NavItem
icon={FaTshirt}
to="/admin/obleceni"
@@ -541,13 +546,15 @@ const AdminSidebar = ({
Hlavní
</Text>
<NavItem
icon={FaTachometerAlt}
to="/admin"
onClick={onClose}
>
Nástěnka
</NavItem>
{isAdmin && (
<NavItem
icon={FaTachometerAlt}
to="/admin"
onClick={onClose}
>
Nástěnka
</NavItem>
)}
{isAdmin && (
<NavItem
@@ -563,26 +570,30 @@ const AdminSidebar = ({
Obsah
</Text>
{/* Core sports entities first */}
<NavItem
icon={FaUsers}
to="/admin/tymy"
onClick={onClose}
>
Týmy
</NavItem>
<NavItem
icon={FaCalendarAlt}
to="/admin/zapasy"
onClick={onClose}
>
{/* Add subtle scroller hint */}
<Text as="span">
Zápasy
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('gray.100','whiteAlpha.200')} color={useColorModeValue('gray.700','gray.300')} borderWidth="1px" borderColor={useColorModeValue('gray.200','whiteAlpha.300')}>
scroller
</Text>
</Text>
</NavItem>
{isAdmin && (
<>
<NavItem
icon={FaUsers}
to="/admin/tymy"
onClick={onClose}
>
Týmy
</NavItem>
<NavItem
icon={FaCalendarAlt}
to="/admin/zapasy"
onClick={onClose}
>
{/* Add subtle scroller hint */}
<Text as="span">
Zápasy
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('gray.100','whiteAlpha.200')} color={useColorModeValue('gray.700','gray.300')} borderWidth="1px" borderColor={useColorModeValue('gray.200','whiteAlpha.300')}>
scroller
</Text>
</Text>
</NavItem>
</>
)}
<NavItem
icon={FaCalendarAlt}
to="/admin/aktivity"
@@ -597,13 +608,15 @@ const AdminSidebar = ({
)}
</Text>
</NavItem>
<NavItem
icon={FaFutbol}
to="/admin/hraci"
onClick={onClose}
>
Hráči
</NavItem>
{isAdmin && (
<NavItem
icon={FaFutbol}
to="/admin/hraci"
onClick={onClose}
>
Hráči
</NavItem>
)}
{/* Other content */}
<NavItem
icon={FaNewspaper}
@@ -613,110 +626,114 @@ const AdminSidebar = ({
Články
</NavItem>
<NavItem
icon={FaFileAlt}
to="/admin/kategorie"
icon={FaLink}
to="/admin/shortlinks"
onClick={onClose}
>
Kategorie
</NavItem>
<NavItem
icon={FaBook}
to="/admin/o-klubu"
onClick={onClose}
>
O klubu
</NavItem>
<NavItem
icon={FaImage}
to="/admin/videa"
onClick={onClose}
>
Videa
</NavItem>
<NavItem
icon={FaImage}
to="/admin/galerie"
onClick={onClose}
>
Galerie (Zonerama)
</NavItem>
<NavItem
icon={FaTachometerAlt}
to="/admin/scoreboard"
onClick={onClose}
>
Tabule (Scoreboard)
</NavItem>
<NavItem
icon={FaMobileAlt}
to="/admin/scoreboard/remote"
onClick={onClose}
>
Scoreboard Remote
</NavItem>
<NavItem
icon={FaPalette}
to="/admin/obleceni"
onClick={onClose}
>
Oblečení
</NavItem>
<NavItem
icon={FaHandshake}
to="/admin/sponzori"
onClick={onClose}
>
Sponzoři
</NavItem>
<NavItem
icon={FaImage}
to="/admin/bannery"
onClick={onClose}
>
Bannery
</NavItem>
<NavItem
icon={FaEnvelope}
to="/admin/zpravy"
onClick={onClose}
>
Zprávy
</NavItem>
<NavItem
icon={FaComments}
to="/admin/komentare"
onClick={onClose}
>
Komentáře
</NavItem>
<NavItem
icon={FaAddressBook}
to="/admin/kontakty"
onClick={onClose}
>
Kontakty
</NavItem>
<NavItem
icon={FaPaperPlane}
to="/admin/newsletter"
onClick={onClose}
>
Zpravodaj
</NavItem>
<NavItem
icon={FaPoll}
to="/admin/ankety"
onClick={onClose}
>
Ankety
</NavItem>
<NavItem
icon={FaAward}
to="/admin/engagement"
onClick={onClose}
>
Odměny & Úspěchy
Zkrácené odkazy
</NavItem>
{isAdmin && (
<>
<NavItem
icon={FaBook}
to="/admin/o-klubu"
onClick={onClose}
>
O klubu
</NavItem>
<NavItem
icon={FaImage}
to="/admin/videa"
onClick={onClose}
>
Videa
</NavItem>
<NavItem
icon={FaImage}
to="/admin/galerie"
onClick={onClose}
>
Galerie (Zonerama)
</NavItem>
<NavItem
icon={FaTachometerAlt}
to="/admin/scoreboard"
onClick={onClose}
>
Tabule (Scoreboard)
</NavItem>
<NavItem
icon={FaMobileAlt}
to="/admin/scoreboard/remote"
onClick={onClose}
>
Scoreboard Remote
</NavItem>
<NavItem
icon={FaPalette}
to="/admin/obleceni"
onClick={onClose}
>
Oblečení
</NavItem>
<NavItem
icon={FaHandshake}
to="/admin/sponzori"
onClick={onClose}
>
Sponzoři
</NavItem>
<NavItem
icon={FaImage}
to="/admin/bannery"
onClick={onClose}
>
Bannery
</NavItem>
<NavItem
icon={FaEnvelope}
to="/admin/zpravy"
onClick={onClose}
>
Zprávy
</NavItem>
<NavItem
icon={FaComments}
to="/admin/komentare"
onClick={onClose}
>
Komentáře
</NavItem>
<NavItem
icon={FaAddressBook}
to="/admin/kontakty"
onClick={onClose}
>
Kontakty
</NavItem>
<NavItem
icon={FaPaperPlane}
to="/admin/newsletter"
onClick={onClose}
>
Zpravodaj
</NavItem>
<NavItem
icon={FaPoll}
to="/admin/ankety"
onClick={onClose}
>
Ankety
</NavItem>
<NavItem
icon={FaAward}
to="/admin/engagement"
onClick={onClose}
>
Odměny & Úspěchy
</NavItem>
</>
)}
<Divider my={2} />
{isAdmin && (
@@ -86,24 +86,21 @@ const InstagramGeneratorButton: React.FC<Props> = ({
if (!fullUrl) throw new Error('Nelze zjistit URL článku/aktivity');
// Deterministic shortlink code to keep link stable across generations
const code = article?.id ? `ig-a-${article.id}` : (activity?.id ? `ig-e-${activity.id}` : `ig-share`);
const payload = {
target_url: fullUrl,
title: article?.title || activity?.title || 'Link',
source_type: article ? 'article' : (activity ? 'event' : 'other'),
source_id: article?.id || activity?.id,
code,
} as any;
let sUrl = '';
try {
const res = await createShortLink(payload);
sUrl = res?.short_url || '';
} catch (err) {
// If code already exists or creation fails, fallback to computed short URL path
// Fallback to public shortlink (deterministic per URL) or use long URL
try {
const origin = typeof window !== 'undefined' ? window.location.origin : '';
sUrl = origin ? `${origin}/s/${code}` : fullUrl;
const resPub = await createPublicShortLink({ target_url: fullUrl, title: article?.title || activity?.title || 'Link' });
sUrl = resPub?.short_url || fullUrl;
} catch {
sUrl = fullUrl;
}
@@ -74,18 +74,65 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
}) => {
const toast = useToast();
const quillRef = useRef<ReactQuill | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const toolbarRef = useRef<HTMLDivElement | null>(null);
const onChangeRef = useRef(onChange);
const selectedImageIdRef = useRef<string | null>(null);
const selectImageByIdRef = useRef<(id: string) => void>(() => {});
const toolbarDragRef = useRef<{ active: boolean; startX: number; startY: number; startLeft: number; startTop: number }>({ active: false, startX: 0, startY: 0, startLeft: 0, startTop: 0 });
const [isMounted, setIsMounted] = useState(false);
const [isVisible, setIsVisible] = useState(false);
// Ensure component is mounted before rendering Quill
useEffect(() => {
setIsMounted(true);
return () => setIsMounted(false);
}, []);
// Track visibility of the editor container (avoid mounting Quill while hidden)
useEffect(() => {
const el = containerRef.current;
if (!el) return;
let ro: ResizeObserver | null = null;
let io: IntersectionObserver | null = null;
let mo: MutationObserver | null = null;
const check = () => {
try {
const inDoc = document.contains(el);
const rects = el.getClientRects();
const style = window.getComputedStyle(el);
const visible = inDoc && rects.length > 0 && style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
setIsVisible(visible);
} catch {}
};
// Observe size/visibility changes
try {
ro = new ResizeObserver(() => check());
ro.observe(el);
} catch {}
try {
io = new IntersectionObserver((entries) => {
const entry = entries[0];
setIsVisible(!!entry && (entry.isIntersecting || entry.intersectionRatio > 0));
}, { root: null, threshold: [0, 0.01] });
io.observe(el);
} catch {}
try {
mo = new MutationObserver(() => check());
mo.observe(document.body, { attributes: true, childList: true, subtree: true });
} catch {}
// Initial check
check();
return () => {
try { ro && ro.disconnect(); } catch {}
try { io && io.disconnect(); } catch {}
try { mo && mo.disconnect(); } catch {}
};
}, [containerRef]);
// Keep onChange ref up to date
useEffect(() => {
@@ -143,7 +190,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
full: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ color: [] }, { background: [] }, 'colorreset', 'bgreset'],
[{ color: [] }, { background: [] }],
[{ list: 'ordered' }, { list: 'bullet' }, 'liststyle'],
[{ align: [] }],
['link', 'image'],
@@ -153,7 +200,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
basic: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline'],
[{ color: [] }, { background: [] }, 'colorreset', 'bgreset'],
[{ color: [] }, { background: [] }],
[{ list: 'ordered' }, { list: 'bullet' }, 'liststyle'],
[{ align: [] }],
['link', 'image'],
@@ -302,16 +349,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
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: {
@@ -391,8 +428,50 @@ 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í');
// Inject reset option inside color/background pickers
try {
const injectReset = (
pickerSelector: string,
format: 'color' | 'background',
label: string
) => {
const picker = toolbarEl.querySelector(pickerSelector) as HTMLElement | null; // .ql-color or .ql-background
const options = picker?.querySelector('.ql-picker-options') as HTMLElement | null;
if (!options) return;
if (options.querySelector(`button.ql-picker-item[data-reset="${format}"]`)) return;
const btn = document.createElement('button');
btn.setAttribute('type', 'button');
btn.className = 'ql-picker-item';
btn.setAttribute('data-reset', format);
btn.setAttribute('title', label);
btn.setAttribute('aria-label', label);
btn.style.width = '16px';
btn.style.height = '16px';
btn.style.border = '1px solid #e2e8f0';
btn.style.borderRadius = '2px';
btn.style.position = 'relative';
btn.style.background = '#ffffff';
const slash = document.createElement('span');
slash.style.position = 'absolute';
slash.style.left = '2px';
slash.style.right = '2px';
slash.style.top = '7px';
slash.style.height = '2px';
slash.style.background = '#e53e3e';
slash.style.transform = 'rotate(-45deg)';
btn.appendChild(slash);
btn.addEventListener('click', (e) => {
e.preventDefault();
const q = quillRef.current?.getEditor();
if (!q) return;
q.format(format, false);
try { picker?.classList.remove('ql-expanded'); } catch {}
});
options.insertBefore(btn, options.firstChild);
};
injectReset('.ql-color', 'color', 'Zrušit barvu');
injectReset('.ql-background', 'background', 'Zrušit pozadí');
} catch {}
// Headers
setTitle('.ql-header .ql-picker-label', 'Nadpis');
@@ -1401,7 +1480,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Defer heavy sanitization to submit time to prevent selection glitches; keep minimal cleanup only
const handleChange = (content: string) => {
onChangeRef.current(cleanEditorHTML(content));
onChangeRef.current(content);
};
@@ -1462,6 +1541,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
borderRadius="md"
overflow="visible"
bg={bgColor}
ref={containerRef}
sx={{
'.ql-toolbar': {
borderBottom: '1px solid',
@@ -1622,7 +1702,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Allow user-chosen colors to show. White-on-white is handled during paste/sanitize only.
}}
>
{isMounted && (
{isMounted && isVisible && (
<ReactQuill
theme="snow"
value={value}
@@ -1632,6 +1712,12 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
ref={quillRef}
modules={quillModules}
formats={quillFormats}
onBlur={(_prev, _source, editor) => {
try {
const html = editor?.getHTML ? editor.getHTML() : (quillRef.current?.getEditor().root.innerHTML || value);
onChangeRef.current(cleanEditorHTML(html));
} catch {}
}}
/>
)}
</Box>
@@ -1,7 +1,7 @@
import React from 'react';
import { Box, Image, Heading, Text, VStack, HStack, Badge, Skeleton, useColorModeValue, Button } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getArticles, Article } from '../../services/articles';
import { getArticles, getFeaturedArticles, Article } from '../../services/articles';
import HorizontalScroller from '../ui/HorizontalScroller';
import { Link as RouterLink } from 'react-router-dom';
import { useClubTheme } from '../../contexts/ClubThemeContext';
@@ -108,8 +108,17 @@ const BlogCardsScroller: React.FC = () => {
queryKey: ['articles', { page: 1, page_size: 12, published: true }],
queryFn: () => getArticles({ page: 1, page_size: 12, published: true }),
});
// Load featured (primary) to exclude from scroller
const { data: featuredData } = useQuery({
queryKey: ['articles', 'featured', { page: 1, page_size: 100 }],
queryFn: () => getFeaturedArticles({ page: 1, page_size: 100 }),
});
const list: Article[] = data?.data || [];
const listAll: Article[] = data?.data || [];
const featuredKeys = new Set(
(featuredData?.data || []).map((a: Article) => (a.slug ? `s:${a.slug}` : `i:${a.id}`))
);
const list: Article[] = listAll.filter((a) => !featuredKeys.has(a.slug ? `s:${a.slug}` : `i:${a.id}`));
return (
<Box>
@@ -147,7 +147,7 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
{/* Header */}
<HStack justify="space-between" align="center" flexWrap="wrap">
<VStack align="start" spacing={1}>
<Heading size="xl" color={headingColor}>
<Heading size="xl" color={headingColor} id="home-gallery-heading">
Fotogalerie
</Heading>
<Text color={textColor} fontSize="sm">
@@ -227,6 +227,7 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
h="200px"
objectFit="cover"
loading="lazy"
decoding="async"
/>
) : (
<Box
+11 -4
View File
@@ -66,7 +66,6 @@ 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 {
@@ -171,7 +170,15 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
}}
>
<AspectRatio ratio={16 / 9}>
<Box position="relative" cursor="pointer" onClick={() => handlePlayClick(it)}>
<Box
position="relative"
cursor="pointer"
role="button"
tabIndex={0}
aria-label={`Přehrát video: ${it.title}`}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handlePlayClick(it); } }}
onClick={() => handlePlayClick(it)}
>
{/* Thumbnail */}
{thumb ? (
<Box
@@ -257,7 +264,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
<Box>
<Box className="section-head" style={{ marginTop: 8, marginBottom: 16 }}>
<HStack spacing={3}>
<Heading as="h3" size="lg" fontWeight="700">Videa</Heading>
<Heading as="h3" size="lg" fontWeight="700" id="home-videos-heading">Videa</Heading>
</HStack>
<Link as={RouterLink} to="/videa">
<Button
@@ -340,7 +347,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
return (
<Box>
<Box className="section-head">
<Heading as="h3" size="md">Videa</Heading>
<Heading as="h3" size="md" id="home-videos-heading">Videa</Heading>
<Link as={RouterLink} to="/videa">
<Button size="sm" variant="outline" colorScheme="blue">Více videí</Button>
</Link>
+20 -5
View File
@@ -62,16 +62,20 @@ const MatchesSlider: React.FC<{
const items = (current?.matches || []);
const looped = [...items, ...items, ...items];
return (
<section className="matches-slider matches-ticker" {...(elementProps || {})}>
<section className="matches-slider matches-ticker" aria-label={`Zápasy ${current?.name || ''}`} {...(elementProps || {})}>
<div className="section-head" style={{ marginTop: 16, marginBottom: 8 }}>
<h3>{title}</h3>
<a href="/kalendar" className="see-all">Všechny zápasy</a>
</div>
<div className="ticker-belt">
<div className="ticker-belt" role="list">
{looped.map((m, idx) => (
<div
key={`${m.id || idx}-ticker`}
className="match-card card"
role="button"
tabIndex={0}
aria-label={`${sanitizeClubName(m.home || '')} proti ${sanitizeClubName(m.away || '')}${m.time ? `, ${m.time}` : ''}${m.score ? `, skóre ${String(m.score)}` : ''}`}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onMatchClick?.(m, current?.name); } }}
onClick={(e) => { e.preventDefault(); onMatchClick?.(m, current?.name); }}
style={{ cursor: onMatchClick ? 'pointer' as const : 'default' as const }}
>
@@ -104,17 +108,21 @@ const MatchesSlider: React.FC<{
}
return (
<section className="matches-slider" {...(elementProps || {})}>
<section className="matches-slider" aria-label={`Zápasy ${current?.name || ''}`} {...(elementProps || {})}>
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
<h3>{title}</h3>
<a href="/kalendar" className="see-all">Všechny zápasy</a>
</div>
<div className="matches-grid">
<div className="matches-track" ref={trackRef}>
<div className="matches-track" ref={trackRef} role="list">
{(current?.matches || []).map((m, idx) => (
<div
key={m.id || idx}
className="match-card card"
role="button"
tabIndex={0}
aria-label={`${sanitizeClubName(m.home || '')} proti ${sanitizeClubName(m.away || '')}${m.date ? `, ${new Date(`${m.date}T${(m.time || '00:00')}:00`).toLocaleDateString()}` : ''}${m.time ? `, ${m.time}` : ''}${m.score ? `, skóre ${String(m.score)}` : ''}`}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onMatchClick?.(m, current?.name); } }}
onClick={(e) => { e.preventDefault(); onMatchClick?.(m, current?.name); }}
style={{ cursor: onMatchClick ? 'pointer' as const : 'default' as const }}
>
@@ -163,7 +171,14 @@ const MatchesSlider: React.FC<{
</div>
<div className="matches-tabs">
{comps.map((c, i) => (
<button key={`${c.name}-${i}`} className={i === activeIndex ? 'active' : ''} onClick={() => onActiveChange(i)}>{c.name}</button>
<button
key={`${c.name}-${i}`}
className={i === activeIndex ? 'active' : ''}
aria-pressed={i === activeIndex}
onClick={() => onActiveChange(i)}
>
{c.name}
</button>
))}
</div>
</div>
@@ -27,12 +27,17 @@ const NextMatch: React.FC<{
<section
className="next-match"
{...(elementProps as any)}
role={onOpen ? 'button' : 'region'}
aria-label={`Další zápas: ${sanitizeClubName(show?.home || '')} vs ${sanitizeClubName(show?.away || '')}${competitionName ? `, ${competitionName}` : ''}`}
tabIndex={onOpen ? 0 : -1}
onKeyDown={(e) => { if (!onOpen) return; if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); onOpen?.(); } }}
onClick={(e) => { e.stopPropagation(); onOpen?.(); }}
style={{ cursor: onOpen ? 'pointer' : 'default', position: 'relative', ...(elementProps?.style || {}) }}
>
{onPrev && (
<button
aria-label="Předchozí soutěž"
type="button"
onClick={(e) => { e.stopPropagation(); onPrev?.(); }}
className="nav prev"
style={{ background: 'transparent', border: 'none', color: 'var(--text-on-primary)' }}
@@ -78,6 +83,7 @@ const NextMatch: React.FC<{
{onNext && (
<button
aria-label="Další soutěž"
type="button"
onClick={(e) => { e.stopPropagation(); onNext?.(); }}
className="nav next"
style={{ background: 'transparent', border: 'none', color: 'var(--text-on-primary)' }}
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { getCurrentSweepstake, enterSweepstake, markSweepstakeVisualPlayed, CurrentSweepstakeResponse } from '../../services/sweepstakes';
import { useToast } from '@chakra-ui/react';
const fmtDate = (iso?: string | null) => {
if (!iso) return '';
@@ -15,6 +16,7 @@ const SweepstakeWidget: React.FC = () => {
const [joining, setJoining] = useState<boolean>(false);
const [playing, setPlaying] = useState<boolean>(false);
const playedRef = useRef(false);
const toast = useToast();
const load = async () => {
setLoading(true);
@@ -65,9 +67,11 @@ const SweepstakeWidget: React.FC = () => {
setJoining(true);
try {
await enterSweepstake(s.id);
toast({ status: 'success', title: 'Úspěšně jste vstoupili do soutěže' });
await load();
} catch (e) {
// ignore
} catch (e: any) {
const msg = e?.response?.data?.error || 'Nelze vstoupit do soutěže';
toast({ status: 'error', title: msg });
} finally {
setJoining(false);
}
@@ -104,13 +108,11 @@ const SweepstakeWidget: React.FC = () => {
<div style={{ marginTop: 8, fontSize: 14, opacity: 0.8 }}>Začíná: {fmtDate(s.start_at)} Končí: {fmtDate(s.end_at)}</div>
</div>
{!isLogged ? (
<a href="/login" className="btn" style={{ padding: '10px 16px' }}>Přihlásit se a zapojit</a>
<a href="/login" className="btn" style={{ padding: '10px 16px' }}>Přihlásit se</a>
) : data?.has_entered ? (
<span style={{ fontWeight: 600 }}>Jste zapojeni </span>
) : (
<button className="btn" onClick={onJoin} disabled={joining}>
{joining ? 'Přihlašuji…' : 'Zapojit se'}
</button>
<span style={{ fontSize: 14, opacity: 0.85 }}>Soutěž ještě nezačala. Vstup bude možný od {fmtDate(s.start_at)}.</span>
)}
</div>
</div>
@@ -131,6 +133,12 @@ const SweepstakeWidget: React.FC = () => {
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 4 }}>{s.title}</div>
{s.description && <div style={{ opacity: 0.8 }}>{s.description}</div>}
<div style={{ marginTop: 8, fontSize: 14, opacity: 0.8 }}>Konec: {fmtDate(s.end_at)}</div>
<div style={{ marginTop: 6, display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, background: '#eef', color: '#223', padding: '2px 8px', borderRadius: 12 }}>Vstup: {(s as any).entry_cost_points ? `${(s as any).entry_cost_points} bodů` : 'zdarma'}</span>
{(s as any).max_entries_per_user > 1 && (
<span style={{ fontSize: 13, background: '#f3f3f3', color: '#333', padding: '2px 8px', borderRadius: 12 }}>max {(s as any).max_entries_per_user}×/osoba</span>
)}
</div>
</div>
{!isLogged ? (
<div style={{ fontWeight: 600 }}>Právě zde probíhá soutěž. <a href="/login">Přihlaste se</a> a zapojte se.</div>
@@ -138,7 +146,7 @@ const SweepstakeWidget: React.FC = () => {
<span style={{ fontWeight: 600 }}>Jste zapojeni </span>
) : (
<button className="btn" onClick={onJoin} disabled={joining}>
{joining ? 'Přihlašuji…' : 'Zapojit se'}
{joining ? 'Vstupuji…' : 'Vstoupit'}
</button>
)}
</div>
@@ -175,6 +175,27 @@ const HorizontalScroller: React.FC<HorizontalScrollerProps> = ({ title, rightAct
py={2}
px={1}
cursor={draggable ? 'grab' : 'default'}
role="region"
aria-roledescription="carousel"
aria-label={title ? `Posuvník: ${title}` : 'Posuvník obsahu'}
tabIndex={0}
onKeyDown={(e) => {
const el = containerRef.current;
if (!el) return;
if (e.key === 'ArrowLeft') {
e.preventDefault();
scrollBy(-1);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
scrollBy(1);
} else if (e.key === 'Home') {
e.preventDefault();
el.scrollTo({ left: 0, behavior: 'smooth' });
} else if (e.key === 'End') {
e.preventDefault();
el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' });
}
}}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => { setIsHovering(false); if (draggable) onPointerUp(); }}
onMouseDown={(e) => { if (!draggable) return; e.preventDefault(); onPointerDown(e.clientX); }}
@@ -208,7 +229,7 @@ const HorizontalScroller: React.FC<HorizontalScrollerProps> = ({ title, rightAct
{/* navigation buttons - must be above gradient masks */}
<IconButton
aria-label="scroll left"
aria-label="Posunout doleva"
icon={<ChevronLeftIcon boxSize={6} />}
onClick={(e) => {
e.preventDefault();
@@ -234,7 +255,7 @@ const HorizontalScroller: React.FC<HorizontalScrollerProps> = ({ title, rightAct
pointerEvents="auto"
/>
<IconButton
aria-label="scroll right"
aria-label="Posunout doprava"
icon={<ChevronRightIcon boxSize={6} />}
onClick={(e) => {
e.preventDefault();
+74 -5
View File
@@ -1,4 +1,4 @@
import { Box, Container, Heading, Image, Spinner, Stack, Text, HStack, Badge, Link, SimpleGrid, Button, AspectRatio, useColorModeValue, Flex, VStack, Tag } from '@chakra-ui/react';
import { Box, Container, Heading, Image, Spinner, Stack, Text, HStack, Badge, Link, SimpleGrid, Button, AspectRatio, useColorModeValue, Flex, VStack, Tag, Breadcrumb, BreadcrumbItem, BreadcrumbLink } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { useParams, Link as RouterLink } from 'react-router-dom';
import { getArticle, getArticleBySlug, getArticleMatchLink, trackArticleView, getArticles } from '../services/articles';
@@ -17,6 +17,7 @@ import TeamLogo from '../components/common/TeamLogo';
import MatchModal from '../components/home/MatchModal';
import { extractPalette } from '../utils/colors';
import { getTeamLogo } from '../utils/sportLogosAPI';
import { getBanners, Banner as UIBanner } from '../services/banners';
import FilePreview from '../components/common/FilePreview';
import { usePublicSettings } from '../hooks/usePublicSettings';
import InstagramGeneratorButton from '../components/admin/InstagramGeneratorButton';
@@ -42,6 +43,8 @@ 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'],
@@ -336,6 +339,13 @@ const ArticleDetailPage: React.FC = () => {
});
}, [(data as any)?.content, toAbsoluteUploads]);
const articleBannersQ = useQuery<UIBanner[]>({
queryKey: ['banners', { placement: 'article_inline' }],
queryFn: () => getBanners({ active: true, placement: 'article_inline' }),
staleTime: 60 * 1000,
});
const articleBanners = (articleBannersQ.data || []) as UIBanner[];
const relatedArticlesQuery = useQuery({
queryKey: ['related-articles', (data as any)?.category?.id || 'none', (data as any)?.id],
enabled: Boolean((data as any)?.id),
@@ -526,6 +536,22 @@ const ArticleDetailPage: React.FC = () => {
</HStack>
) : null}
</HStack>
<Breadcrumb fontSize="sm" mt={2} color={textMuted} separator="/">
<BreadcrumbItem>
<BreadcrumbLink as={RouterLink} to="/">Domů</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem>
<BreadcrumbLink as={RouterLink} to="/blog">Blog</BreadcrumbLink>
</BreadcrumbItem>
{(data as any)?.category?.id ? (
<BreadcrumbItem>
<BreadcrumbLink as={RouterLink} to={`/news?category_id=${(data as any).category.id}`}>{(data as any).category.name}</BreadcrumbLink>
</BreadcrumbItem>
) : null}
<BreadcrumbItem isCurrentPage>
<BreadcrumbLink>{data.title}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
</Container>
</Box>
<Container maxW="7xl">
@@ -541,7 +567,8 @@ const ArticleDetailPage: React.FC = () => {
{/* 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">
<Box position="relative" borderWidth="1px" borderRadius="lg" p={{ base: 4, md: 5 }} bg={cardBg} overflow="hidden" cursor="pointer"
onClick={() => { if (facrMatchQuery?.data) { setSelectedMatch({ ...(facrMatchQuery.data as any), competition: (facrMatchQuery.data as any).competitionName }); setIsMatchModalOpen(true); } }}>
{/* Edge fades */}
<Box position="absolute" top={0} left={0} bottom={0} w={{ base: '6px', md: '12px' }} bgGradient={`linear(to-r, var(--club-primary, #0b5cff), transparent)`} pointerEvents="none" />
{opponentColor && (
@@ -639,6 +666,27 @@ const ArticleDetailPage: React.FC = () => {
}}
dangerouslySetInnerHTML={{ __html: safeContentHTML }}
/>
{articleBanners.length > 0 && (
<Box textAlign="center" mt={{ base: 4, md: 6 }}>
<a
href={articleBanners[0].click_url || '#'}
target={articleBanners[0].click_url ? '_blank' : undefined}
rel={articleBanners[0].click_url ? 'noopener noreferrer' : undefined}
style={{ display: 'inline-block' }}
>
<Image
src={assetUrl((articleBanners[0] as any).image_url) || '/images/sponsors/placeholder.png'}
alt={articleBanners[0].name}
maxW="100%"
w={articleBanners[0].width ? `${articleBanners[0].width}px` : '100%'}
h={articleBanners[0].height ? `${articleBanners[0].height}px` : 'auto'}
borderRadius="md"
loading="lazy"
decoding="async"
/>
</a>
</Box>
)}
{/* YouTube Video Section - simplified with rounded edges */}
{(data as any)?.youtube_video_id && (
<Box borderRadius="xl" overflow="hidden">
@@ -708,6 +756,13 @@ const ArticleDetailPage: React.FC = () => {
</Stack>
</Box>
<VStack align="stretch" spacing={6} gridColumn={{ base: '1 / -1', lg: 'span 4' }}>
{/* Polls in sidebar */}
{data?.id ? (
<Widget title="Anketa">
<EmbeddedPoll articleId={(data as any).id} maxPolls={1} />
</Widget>
) : null}
{relatedArticlesQuery.isLoading ? null : (() => {
const list = ((relatedArticlesQuery.data as any)?.data || [])
.filter((a: any) => a?.id !== (data as any)?.id)
@@ -768,12 +823,28 @@ const ArticleDetailPage: React.FC = () => {
</Widget>
);
})()}
{/* Attachments in sidebar */}
{Array.isArray((data as any)?.attachments) && (data as any).attachments.length > 0 && (
<Widget title="Přílohy">
<VStack align="stretch" spacing={2}>
{(data as any).attachments.map((f: any, idx: number) => (
<HStack key={idx} justify="space-between">
<Text noOfLines={1}>{f.name || f.url}</Text>
<FilePreview url={assetUrl(f.url) || f.url} name={f.name || ''} mimeType={f.mime_type || ''} size={f.size} />
</HStack>
))}
</VStack>
</Widget>
)}
</VStack>
</SimpleGrid>
</Container>
</Box>
{/* Attachments - bottom above CTA */}
{/* Polls (Ankety) above attachments */}
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
{/* Attachments - bottom above comments */}
{Array.isArray((data as any)?.attachments) && (data as any).attachments.length > 0 && (
<Container maxW="7xl" mt={4}>
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={attachmentsBg}>
@@ -789,8 +860,6 @@ const ArticleDetailPage: React.FC = () => {
</Box>
</Container>
)}
{/* 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}>
+28 -53
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { Box, Container, Heading, VStack, Image, Text, Skeleton, LinkBox, HStack, Select, Badge, useColorModeValue, Input, InputGroup, InputLeftElement, InputRightElement, IconButton, Grid, GridItem, useMediaQuery } from '@chakra-ui/react';
import { Box, Container, Heading, VStack, Image, Text, Skeleton, LinkBox, HStack, Select, Badge, useColorModeValue, Input, InputGroup, InputLeftElement, InputRightElement, IconButton, Grid, GridItem, useMediaQuery, Tooltip } from '@chakra-ui/react';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { getArticles, Article, Paginated, getFeaturedArticles } from '../services/articles';
import { getBanners, Banner as UIBanner } from '../services/banners';
@@ -24,6 +24,9 @@ const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({
? ({ base: '160px', md: '180px' } as const)
: ({ base: '200px', md: '220px' } as const);
const publishedAt = (article as any).published_at || (article as any).created_at;
const publishedDateStr = publishedAt ? (()=>{ try { return new Date(publishedAt).toLocaleDateString('cs-CZ'); } catch { return ''; } })() : '';
return (
<LinkBox
as={RouterLink}
@@ -49,59 +52,31 @@ const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({
fetchPriority={variant === 'large' ? 'high' as any : 'auto' as any}
/>
<Box position="absolute" inset={0} bgGradient="linear(to-t, rgba(0,0,0,0.55), rgba(0,0,0,0.15))" />
{categoryName && (
<Badge
position="absolute"
top={2}
left={2}
bg="rgba(0,0,0,0.7)"
color="white"
fontSize="xs"
px={2}
py={1}
borderRadius="md"
>
{categoryName}
</Badge>
)}
{/* Stats badges at top */}
{(readTime || (viewCount && viewCount > 0)) && (
<HStack position="absolute" top={2} right={2} spacing={1}>
{readTime && (
<Badge
display="flex"
alignItems="center"
gap={1}
bg="rgba(0,0,0,0.7)"
color="white"
fontSize="xs"
px={2}
py={1}
borderRadius="md"
>
{/* Top info row: category (left), date (center), read time (right) */}
<HStack position="absolute" top={2} left={2} right={2} justify="space-between" align="center">
{categoryName ? (
<Tooltip label="Kategorie" hasArrow>
<Badge bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1} borderRadius="md">
{categoryName}
</Badge>
</Tooltip>
) : <Box />}
{publishedDateStr ? (
<Tooltip label="Datum publikace" hasArrow>
<Badge bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1} borderRadius="md">
{publishedDateStr}
</Badge>
</Tooltip>
) : <Box />}
{readTime ? (
<Tooltip label="Doba čtení" hasArrow>
<Badge display="flex" alignItems="center" gap={1} bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1} borderRadius="md">
<Clock size={12} />
{readTime} min
</Badge>
)}
{viewCount && viewCount > 0 && (
<Badge
display="flex"
alignItems="center"
gap={1}
bg="rgba(0,0,0,0.7)"
color="white"
fontSize="xs"
px={2}
py={1}
borderRadius="md"
>
<Eye size={12} />
{viewCount}
</Badge>
)}
</HStack>
)}
</Tooltip>
) : <Box />}
</HStack>
<Heading
as="h3"
@@ -367,9 +342,9 @@ const BlogPage: React.FC = () => {
</Container>
)}
<Container maxW="5xl">
<Container maxW="7xl">
{/* Responsive grid with consistent card sizing */}
<Grid templateColumns={{ base: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={6}>
<Grid templateColumns={{ base: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={8}>
{isLoading && Array.from({ length: 9 }).map((_, i) => (
<Skeleton key={i} h={{ base: '260px', md: '300px' }} borderRadius="md" />
))}
+4 -3
View File
@@ -37,6 +37,7 @@ import ContactMap from '../components/home/ContactMap';
import { getPublicContacts, GroupedContacts } from '../services/contactInfo';
import { facrApi } from '../services/facr/facrApi';
import { getCompetitionAliasesPublic } from '../services/competitionAliases';
import { getImageUrl } from '../utils/imageUtils';
type ContactFormData = {
name: string;
@@ -276,7 +277,7 @@ const ContactPage: React.FC = () => {
<Box key={contact.id} bg={bgColor} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
<VStack align="start" spacing={3}>
{contact.image_url && (
<Avatar src={contact.image_url} name={contact.name} size="lg" />
<Avatar src={getImageUrl(contact.image_url)} name={contact.name} size="lg" />
)}
<Box>
<Heading size="sm">{contact.name}</Heading>
@@ -317,7 +318,7 @@ const ContactPage: React.FC = () => {
<Box key={contact.id} bg={bgColor} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
<VStack align="start" spacing={3}>
{contact.image_url && (
<Avatar src={contact.image_url} name={contact.name} size="lg" />
<Avatar src={getImageUrl(contact.image_url)} name={contact.name} size="lg" />
)}
<Box>
<Heading size="sm">{contact.name}</Heading>
@@ -359,7 +360,7 @@ const ContactPage: React.FC = () => {
<Box key={contact.id} bg={bgColor} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
<VStack align="start" spacing={3}>
{contact.image_url && (
<Avatar src={contact.image_url} name={contact.name} size="lg" />
<Avatar src={getImageUrl(contact.image_url)} name={contact.name} size="lg" />
)}
<Box>
<Heading size="sm">{contact.name}</Heading>
+279 -125
View File
@@ -39,6 +39,7 @@ const MatchesSlider = React.lazy(() => import('../components/pack/MatchesSlider'
import ActivitiesList from '../components/pack/ActivitiesList';
import { useAuth } from '../contexts/AuthContext';
import SweepstakeWidget from '../components/sweepstakes/SweepstakeWidget';
import { sortCategoriesWithOrder } from '../utils/categorySort';
// Types for real API-driven data
type NewsItem = {
@@ -92,7 +93,7 @@ const HomePage: React.FC = () => {
const [edgeRoleIdx, setEdgeRoleIdx] = useState<number>(0);
const blogAutoRef = useRef<HTMLDivElement | null>(null);
// FACR competitions with matches (for slider)
const [facrCompetitions, setFacrCompetitions] = useState<Array<{ name:string; matches:Array<any>; matches_link?:string }>>([]);
const [facrCompetitions, setFacrCompetitions] = useState<Array<{ name:string; matches:Array<any>; matches_link?:string; display_order?: number }>>([]);
const [matchesTab, setMatchesTab] = useState<number>(0);
const [selectedClub, setSelectedClub] = useState<any>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -118,10 +119,11 @@ const HomePage: React.FC = () => {
const [merchItems, setMerchItems] = useState<UiMerch[]>([]);
const [merchEnabled, setMerchEnabled] = useState<boolean>(false);
const [upcomingEvents, setUpcomingEvents] = useState<UiEvent[]>([]);
const [activitiesLoaded, setActivitiesLoaded] = useState<boolean>(false);
const [defer, setDefer] = useState<boolean>(false);
// Aliases
const [aliases, setAliases] = useState<CompetitionAlias[]>([]);
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string }>>({});
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string; display_order?: number }>>({});
const [settings, setSettings] = useState<any>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isEditingMode, setIsEditingMode] = useState<boolean>(false);
@@ -164,6 +166,33 @@ const HomePage: React.FC = () => {
slug: item.slug,
})), [featured]);
const upcomingCompIndices = useMemo(() => {
const now = Date.now();
try {
return (facrCompetitions || [])
.map((c, i) => {
const items = Array.isArray(c?.matches) ? c.matches : [];
const hasUpcoming = items.some((m: any) => {
const t = new Date(`${m.date || ''}T${(m.time || '00:00')}:00`).getTime();
return !isNaN(t) && t > now;
});
return hasUpcoming ? i : -1;
})
.filter((i) => i !== -1);
} catch {
return [] as number[];
}
}, [facrCompetitions]);
useEffect(() => {
try {
if (!Array.isArray(upcomingCompIndices) || upcomingCompIndices.length === 0) return;
if (!upcomingCompIndices.includes(nextCompIdx)) {
setNextCompIdx(upcomingCompIndices[0]);
}
} catch {}
}, [upcomingCompIndices, nextCompIdx]);
useEffect(() => {
let cancelled = false;
@@ -262,8 +291,8 @@ const HomePage: React.FC = () => {
try {
aliasesList = await getCompetitionAliasesPublic();
} catch {}
const amap: Record<string, { alias: string; original_name?: string }> = {};
(aliasesList || []).forEach((a) => { if (a?.code && a?.alias) amap[a.code] = { alias: a.alias, original_name: a.original_name }; });
const amap: Record<string, { alias: string; original_name?: string; display_order?: number }> = {};
(aliasesList || []).forEach((a) => { if (a?.code && a?.alias) amap[a.code] = { alias: a.alias, original_name: a.original_name, display_order: a.display_order }; });
// Try live settings API first
let liveSettings: any = null;
try {
@@ -392,10 +421,12 @@ const HomePage: React.FC = () => {
return {
name: (amap?.[c?.code]?.alias) || c.name || c.code || 'Soutěž',
matches_link: c.matches_link,
matches: filtered
matches: filtered,
display_order: (amap?.[c?.code]?.display_order),
};
});
setFacrCompetitions(comps);
const sortedComps = sortCategoriesWithOrder(comps as any);
setFacrCompetitions(sortedComps as any);
// Next match FACR link
const first = filteredMatches?.[0];
@@ -414,7 +445,7 @@ const HomePage: React.FC = () => {
// Load players via API (include inactive to show as non-active instead of hiding)
try {
const apiPlayers: ApiPlayer[] = await apiGetPlayers({ active: false });
const apiPlayers: ApiPlayer[] = await apiGetPlayers();
const mappedPlayers: UiPlayer[] = (apiPlayers || []).map((p: ApiPlayer) => ({
id: p.id,
name: [p.first_name, p.last_name].filter(Boolean).join(' '),
@@ -481,7 +512,7 @@ const HomePage: React.FC = () => {
const top3 = all.slice(0, 3);
setFeatured(top3);
setNews((prev) => {
const featuredKeys = new Set(top3.map((f) => (f.slug ? `s:${f.slug}` : `i:${f.id}`)));
const featuredKeys = new Set(all.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 {}
@@ -531,6 +562,8 @@ const HomePage: React.FC = () => {
if (facrTablesJSON?.competitions?.length) {
const comps = (facrTablesJSON.competitions || []).map((c: any) => ({
name: (amap?.[c?.code]?.alias) || c.name || c.code,
display_order: (amap?.[c?.code]?.display_order),
code: c.code,
table: (c.table?.overall || []).map((r: any, idx: number) => ({
position: Number(r.rank || idx + 1),
team: r.team || r.team_name || '-',
@@ -544,7 +577,8 @@ const HomePage: React.FC = () => {
score: r.score || '0:0',
})),
}));
setStandings(comps);
const sortedTables = sortCategoriesWithOrder(comps as any);
setStandings(sortedTables);
}
// Club name/logo from FACR if not provided by settings
@@ -630,6 +664,9 @@ const HomePage: React.FC = () => {
}));
if (active) setUpcomingEvents(mapped);
} catch {}
finally {
if (active) setActivitiesLoaded(true);
}
})();
return () => { active = false; };
}, []);
@@ -1402,13 +1439,17 @@ const HomePage: React.FC = () => {
</div>
</a>
) : (
<a href="/news" className="hero-card big" style={{ textDecoration: 'none' }}>
<div className="bg" style={{ backgroundImage: `url('/images/news/placeholder.jpg')` }} />
<div className="overlay">
<div style={{ opacity: 0.9, fontSize: '0.8rem', color: '#ffffff' }}>Aktuality</div>
<h2 style={{ margin: '4px 0 0 0', color: '#ffffff' }}>Nejnovější titulek</h2>
</div>
</a>
isLoading ? (
<div className="hero-card big skeleton" style={{ borderRadius: 16 }} />
) : (
<a href="/news" className="hero-card big" style={{ textDecoration: 'none' }}>
<div className="bg" style={{ backgroundImage: `url('/images/news/placeholder.jpg')` }} />
<div className="overlay">
<div style={{ opacity: 0.9, fontSize: '0.8rem', color: '#ffffff' }}>Aktuality</div>
<h2 style={{ margin: '4px 0 0 0', color: '#ffffff' }}>Nejnovější titulek</h2>
</div>
</a>
)
)}
<div className="small-col">
{featured.slice(1, 3).map((n, idx) => (
@@ -1421,13 +1462,17 @@ const HomePage: React.FC = () => {
</a>
))}
{Array.from({ length: Math.max(0, 2 - Math.min(2, Math.max(0, featured.length - 1))) }).map((_, idx) => (
<a key={`placeholder-${idx}`} href="/news" className="hero-card small" style={{ textDecoration: 'none' }}>
<div className="bg" style={{ backgroundImage: `url('/images/news/placeholder.jpg')`, filter: 'grayscale(50%) brightness(0.7)' }} />
<div className="overlay">
<div style={{ opacity: 0.8, fontSize: '0.8rem', color: '#fff' }}>Aktuality</div>
<h3 style={{ margin: '4px 0 0 0', color: '#fff' }}>Připravujeme...</h3>
</div>
</a>
isLoading ? (
<div key={`placeholder-${idx}`} className="hero-card small skeleton" style={{ borderRadius: 16 }} />
) : (
<a key={`placeholder-${idx}`} href="/news" className="hero-card small" style={{ textDecoration: 'none' }}>
<div className="bg" style={{ backgroundImage: `url('/images/news/placeholder.jpg')`, filter: 'grayscale(50%) brightness(0.7)' }} />
<div className="overlay">
<div style={{ opacity: 0.8, fontSize: '0.8rem', color: '#fff' }}>Aktuality</div>
<h3 style={{ margin: '4px 0 0 0', color: '#fff' }}>Připravujeme...</h3>
</div>
</a>
)
))}
</div>
</section>
@@ -1438,7 +1483,7 @@ const HomePage: React.FC = () => {
{(banners || []).filter(b => b.placement === 'homepage_middle').map((b) => (
<a key={b.id} href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'inline-block', margin: 8 }}>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img src={b.image} alt={b.name} style={{ maxWidth: '100%', width: b.width ? `${b.width}px` : undefined, height: b.height ? `${b.height}px` : 'auto' }} />
<img loading="lazy" decoding="async" src={b.image} alt={b.name} style={{ maxWidth: '100%', width: b.width ? `${b.width}px` : undefined, height: b.height ? `${b.height}px` : 'auto' }} />
</a>
))}
</section>
@@ -1446,34 +1491,37 @@ const HomePage: React.FC = () => {
{/* Featured articles are now shown in the hero grid above, not here */}
{/* Sidebar banners (homepage_sidebar) - fixed edge rail, left/right via MyUIbrix variant */}
{/* Sidebar banners (homepage_sidebar) - sticky within page container */}
{(banners || []).some(b => b.placement === 'homepage_sidebar') && (
<section
key={`sidebar-${refreshKey}-${getVariant('sidebar', 'right')}`}
data-element="sidebar"
data-variant={getVariant('sidebar', 'right')}
className={`banner banner-sidebar sidebar-${getVariant('sidebar', 'right')}`}
style={{
// Use configured styles but force fixed rail placement
...getStyles('sidebar'),
position: 'fixed',
top: 112,
left: getVariant('sidebar', 'right') === 'left' ? 12 : 'auto',
right: getVariant('sidebar', 'right') === 'left' ? 'auto' : 12,
width: 320,
maxWidth: '100%',
zIndex: 50,
pointerEvents: 'none',
}}
style={{ margin: '24px 0', ...getStyles('sidebar') }}
>
{(banners || []).filter(b => b.placement === 'homepage_sidebar').map((b) => (
<div key={b.id} className="card" style={{ display: 'block', marginBottom: 12, pointerEvents: 'auto', padding: 4 }}>
<a href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'block' }}>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img loading="lazy" src={b.image} alt={b.name} style={{ width: b.width ? `${b.width}px` : '100%', height: b.height ? `${b.height}px` : 'auto', maxWidth: '100%' }} />
</a>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<div
style={{
position: 'sticky',
top: 112,
width: 320,
maxWidth: '100%',
marginLeft: getVariant('sidebar', 'right') === 'left' ? 0 : 'auto',
marginRight: getVariant('sidebar', 'right') === 'left' ? 'auto' : 0,
zIndex: 1,
}}
>
{(banners || []).filter(b => b.placement === 'homepage_sidebar').map((b) => (
<div key={b.id} className="card" style={{ display: 'block', marginBottom: 12, padding: 4 }}>
<a href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'block' }}>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img loading="lazy" decoding="async" src={b.image} alt={b.name} style={{ width: b.width ? `${b.width}px` : '100%', height: b.height ? `${b.height}px` : 'auto', maxWidth: '100%' }} />
</a>
</div>
))}
</div>
))}
</div>
</section>
)}
{getVariant('hero', heroStyle) === 'scroller' && isVisible('hero', true) && (
@@ -1492,58 +1540,68 @@ const HomePage: React.FC = () => {
)}
{/* Next match: categories (competitions) with left/right navigation - synced with matchesTab */}
{facrCompetitions.length > 0 && isVisible('matches', true) ? (
(() => {
const comp = facrCompetitions[Math.max(0, Math.min(matchesTab, facrCompetitions.length - 1))];
const items = Array.isArray(comp?.matches) ? comp.matches : [];
const upcoming = items
.map((m: any) => ({ m, t: new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime() }))
.filter((x: any) => !isNaN(x.t) && x.t > Date.now())
.sort((a: any, b: any) => a.t - b.t)[0]?.m;
const show = upcoming || items[0] || null;
const link = (show && (show.facr_link || show.report_url)) || comp?.matches_link || nextMatchLink;
const handleNextMatchClick = () => {
if (show) {
setSelectedMatch({
...show,
competition: comp?.name,
});
setIsMatchModalOpen(true);
} else if (link) {
window.open(link, '_blank', 'noopener,noreferrer');
}
};
{isVisible('matches', true) ? (
facrCompetitions.length > 0 ? (
upcomingCompIndices.length > 0 ? (
(() => {
const safeIndex = Math.max(0, Math.min(nextCompIdx, facrCompetitions.length - 1));
const pos = upcomingCompIndices.indexOf(safeIndex);
const effectiveIndex = pos >= 0 ? upcomingCompIndices[pos] : upcomingCompIndices[0];
const comp = facrCompetitions[effectiveIndex];
const items = Array.isArray(comp?.matches) ? comp.matches : [];
const upcoming = items
.map((m: any) => ({ m, t: new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime() }))
.filter((x: any) => !isNaN(x.t) && x.t > Date.now())
.sort((a: any, b: any) => a.t - b.t)[0]?.m;
const show = upcoming || null;
const link = (show && (show.facr_link || show.report_url)) || comp?.matches_link || nextMatchLink;
const prevIdx = upcomingCompIndices[(Math.max(0, pos) - 1 + upcomingCompIndices.length) % upcomingCompIndices.length];
const nextIdx = upcomingCompIndices[(Math.max(0, pos) + 1) % upcomingCompIndices.length];
const handleNextMatchClick = () => {
if (show) {
setSelectedMatch({
...show,
competition: comp?.name,
});
setIsMatchModalOpen(true);
} else if (link) {
window.open(link, '_blank', 'noopener,noreferrer');
}
};
return (
return (
<NextMatch
data={show}
competitionName={comp?.name}
countdown={countdown}
onPrev={() => setNextCompIdx(prevIdx)}
onNext={() => setNextCompIdx(nextIdx)}
onOpen={handleNextMatchClick}
elementProps={{
'data-element': 'matches' as any,
'data-variant': getVariant('matches', 'compact') as any,
'aria-live': 'polite' as any,
style: { ...getStyles('matches') },
}}
/>
);
})()
) : null
) : (
<div className="card">
<NextMatch
data={show}
competitionName={comp?.name}
countdown={countdown}
onPrev={() => setMatchesTab((i) => (i - 1 + facrCompetitions.length) % facrCompetitions.length)}
onNext={() => setMatchesTab((i) => (i + 1) % facrCompetitions.length)}
onOpen={handleNextMatchClick}
elementProps={{
'data-element': 'matches' as any,
'data-variant': getVariant('matches', 'compact') as any,
style: { ...getStyles('matches') },
key={`matches-${refreshKey}-${getVariant('matches', 'compact')}`}
data={{
home: matches[0]?.homeTeam || clubName,
home_logo_url: matches[0]?.homeLogoURL || clubLogo,
away: matches[0]?.awayTeam || 'Soupeř',
away_logo_url: matches[0]?.awayLogoURL,
}}
countdown={countdown}
elementProps={{ 'data-element': 'matches', 'data-variant': getVariant('matches', 'compact'), 'aria-live': 'polite', style: { position: 'relative', ...getStyles('matches') } }}
/>
);
})()
) : isVisible('matches', true) ? (
<div className="card">
<NextMatch
key={`matches-${refreshKey}-${getVariant('matches', 'compact')}`}
data={{
home: matches[0]?.homeTeam || clubName,
home_logo_url: matches[0]?.homeLogoURL || clubLogo,
away: matches[0]?.awayTeam || 'Soupeř',
away_logo_url: matches[0]?.awayLogoURL,
}}
countdown={countdown}
elementProps={{ 'data-element': 'matches', 'data-variant': getVariant('matches', 'compact'), style: { position: 'relative', ...getStyles('matches') } }}
/>
</div>
</div>
)
) : null}
{/* Sweepstakes / Lottery widget (visible around matches section) */}
@@ -1570,6 +1628,20 @@ const HomePage: React.FC = () => {
</Suspense>
) : null
)}
{facrCompetitions.length === 0 && isLoading && (
<section data-element="matches-slider" data-variant={getVariant('matches-slider', 'carousel')} aria-label="Zápasy" style={{ position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '280px', ...getStyles('matches-slider') }}>
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
<h3>Zápasy</h3>
<a href="/kalendar" className="see-all">Všechny zápasy</a>
</div>
<div style={{ display: 'flex', gap: 18, overflow: 'hidden', padding: '8px 2px 16px 2px' }}>
{[1,2,3].map((i) => (
<div key={i} className="card skeleton" style={{ minWidth: 340, height: 160, borderRadius: 12 }} />
))}
</div>
</section>
)}
{/* News + Tables: split into two independent sections */}
{(() => {
@@ -1597,23 +1669,31 @@ const HomePage: React.FC = () => {
style={{ marginTop: 32 }}
>
{showNews && (
<section key={`news-${refreshKey}-${newsVariant}`} data-element="news" data-variant={newsVariant} className="news-list" style={{ ...getStyles('news') }}>
<section key={`news-${refreshKey}-${newsVariant}`} data-element="news" data-variant={newsVariant} className="news-list" aria-labelledby="home-news-heading" style={{ ...getStyles('news'), contentVisibility: 'auto' as any, containIntrinsicSize: '600px' }}>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Další aktuality</h3>
<h3 id="home-news-heading">Další aktuality</h3>
<a href="/news" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
</div>
{newsVariant === 'scroller' ? (
<BlogCardsScroller />
) : (
<NewsList items={news as any} />
isLoading && (!news || (news as any).length === 0) ? (
<div className="blog-list">
{[1,2,3,4].map(i => (
<div key={i} className="card skeleton" style={{ height: 96, borderRadius: 12 }} />
))}
</div>
) : (
<NewsList items={news as any} />
)
)}
</section>
)}
{showTable && (
<div key={`table-${refreshKey}-${getVariant('table', 'split_news')}`} data-element="table" data-variant={getVariant('table', 'split_news')} style={{ ...getStyles('table') }}>
<div key={`table-${refreshKey}-${getVariant('table', 'split_news')}`} data-element="table" data-variant={getVariant('table', 'split_news')} role="region" aria-labelledby="home-table-heading" style={{ ...getStyles('table'), contentVisibility: 'auto' as any, containIntrinsicSize: '520px' }}>
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
<h3>Tabulky</h3>
<h3 id="home-table-heading">Tabulky</h3>
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
</div>
{defer ? (
@@ -1639,7 +1719,15 @@ const HomePage: React.FC = () => {
}}
/>
</Suspense>
) : null}
) : (
<div className="table-card">
<div className="standings">
{[1,2,3,4,5,6,7,8].map(i => (
<div key={i} className="standing-row skeleton" style={{ borderRadius: 12 }} />
))}
</div>
</div>
)}
{/* Banners under the table, inside the table column */}
{(banners || []).some(b => b.placement === 'homepage_under_table') && (
defer ? (
@@ -1657,12 +1745,28 @@ const HomePage: React.FC = () => {
{/* (Moved) Banner under tables now renders inside the table column above */}
{/* Competition tables moved into right column below */}
{upcomingEvents.length > 0 && isVisible('activities', true) && (
<section key={`activities-${refreshKey}-${getVariant('activities', 'list')}`} data-element="activities" data-variant={getVariant('activities', 'list')} style={{ marginTop: 32, marginBottom: 16, position: 'relative', ...getStyles('activities') }}>
{isVisible('activities', true) && !activitiesLoaded && (
<section data-element="activities" data-variant={getVariant('activities', 'list')} aria-labelledby="home-activities-heading" style={{ marginTop: 32, marginBottom: 16, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '600px', ...getStyles('activities') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Aktivity</h3>
<h3 id="home-activities-heading">Aktivity</h3>
<a href="/aktivity" className="see-all">Zobrazit vše <FiArrowRight /></a>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 12 }}>
{[1,2,3].map(i => (
<div key={i} className="card skeleton" style={{ height: 120, borderRadius: 12 }} />
))}
</div>
</div>
</section>
)}
{upcomingEvents.length > 0 && isVisible('activities', true) && (
<section key={`activities-${refreshKey}-${getVariant('activities', 'list')}`} data-element="activities" data-variant={getVariant('activities', 'list')} aria-labelledby="home-activities-heading" style={{ marginTop: 32, marginBottom: 16, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '600px', ...getStyles('activities') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<div className="section-head" style={{ marginTop: 0 }}>
<h3 id="home-activities-heading">Aktivity</h3>
<a href="/aktivity" className="see-all">Zobrazit vše <FiArrowRight /></a>
</div>
<ActivitiesList items={upcomingEvents as any} />
@@ -1671,10 +1775,25 @@ const HomePage: React.FC = () => {
)}
{/* Players scroller */}
{players.length > 0 && isVisible('team', false) && (
<section key={`team-${refreshKey}-${getVariant('team', 'grid')}`} data-element="team" data-variant={getVariant('team', 'grid')} className="players-scroller" style={{ marginTop: 32, position: 'relative', ...getStyles('team') }}>
{isVisible('team', false) && players.length === 0 && isLoading && (
<section data-element="team" data-variant={getVariant('team', 'grid')} className="players-scroller" aria-labelledby="home-players-heading" style={{ marginTop: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '600px', ...getStyles('team') }}>
<div className="section-head">
<h3>Hráči</h3>
<h3 id="home-players-heading">Hráči</h3>
<a href="/players" className="see-all">Zobrazit vše <FiArrowRight /></a>
</div>
<div className="scroll-x">
{[1,2,3,4,5,6].map(i => (
<div key={i} className="player-card skeleton" style={{ width: 170, height: 210, borderRadius: 14 }} />
))}
</div>
</section>
)}
{players.length > 0 && isVisible('team', false) && (
<section key={`team-${refreshKey}-${getVariant('team', 'grid')}`} data-element="team" data-variant={getVariant('team', 'grid')} className="players-scroller" aria-labelledby="home-players-heading" style={{ marginTop: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '600px', ...getStyles('team') }}>
<div className="section-head">
<h3 id="home-players-heading">Hráči</h3>
<a href="/players" className="see-all">Zobrazit vše <FiArrowRight /></a>
</div>
<div className="scroll-x">
@@ -1691,7 +1810,7 @@ const HomePage: React.FC = () => {
{/* Gallery */}
{isVisible('gallery', false) && (
<section key={`gallery-${refreshKey}-${getVariant('gallery', 'grid')}`} data-element="gallery" data-variant={getVariant('gallery', 'grid')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('gallery') }}>
<section key={`gallery-${refreshKey}-${getVariant('gallery', 'grid')}`} data-element="gallery" data-variant={getVariant('gallery', 'grid')} aria-labelledby="home-gallery-heading" style={{ marginTop: 32, marginBottom: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '700px', ...getStyles('gallery') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
{defer ? (
<Suspense fallback={null}>
@@ -1704,7 +1823,7 @@ const HomePage: React.FC = () => {
{/* Videos */}
{isVisible('videos', false) && (
<section key={`videos-${refreshKey}-${getVariant('videos', 'carousel')}`} data-element="videos" data-variant={getVariant('videos', 'carousel')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('videos') }}>
<section key={`videos-${refreshKey}-${getVariant('videos', 'carousel')}`} data-element="videos" data-variant={getVariant('videos', 'carousel')} aria-labelledby="home-videos-heading" style={{ marginTop: 32, marginBottom: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '700px', ...getStyles('videos') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
{defer ? (
<Suspense fallback={null}>
@@ -1713,26 +1832,50 @@ const HomePage: React.FC = () => {
variant={(getVariant('videos', 'carousel') as any) as 'grid' | 'carousel'}
/>
</Suspense>
) : null}
) : (
<>
<div className="section-head">
<h3 id="home-videos-heading">Videa</h3>
<a href="/videa" className="see-all">Více videí</a>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 12 }}>
{[1,2,3].map((i) => (
<div key={i} className="card skeleton" style={{ height: 240, borderRadius: 12 }} />
))}
</div>
</>
)}
</div>
</section>
)}
{isVisible('merch', true) && (
<section key={`merch-${refreshKey}-${getVariant('merch', 'grid')}`} data-element="merch" data-variant={getVariant('merch', 'grid')} style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('merch') }}>
<section key={`merch-${refreshKey}-${getVariant('merch', 'grid')}`} data-element="merch" data-variant={getVariant('merch', 'grid')} aria-labelledby="home-merch-heading" style={{ marginTop: 24, marginBottom: 24, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '600px', ...getStyles('merch') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
{defer ? (
<Suspense fallback={null}>
<MerchSection variant={(getVariant('merch', 'grid') as any) as 'grid' | 'carousel' | 'featured' | 'list'} />
</Suspense>
) : null}
) : (
<>
<div className="section-head">
<h3 id="home-merch-heading">Oblečení týmu</h3>
<a href="/obleceni" className="see-all">Zobrazit vše</a>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
{[1,2,3,4,5].map((i) => (
<div key={i} className="card skeleton" style={{ height: 180, borderRadius: 12 }} />
))}
</div>
</>
)}
</div>
</section>
)}
{/* Polls / Voting */}
{isVisible('poll', false) && (
<section key={`poll-${refreshKey}-${getVariant('poll', 'vertical')}`} data-element="poll" data-variant={getVariant('poll', 'vertical')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('poll') }}>
<section key={`poll-${refreshKey}-${getVariant('poll', 'vertical')}`} data-element="poll" data-variant={getVariant('poll', 'vertical')} aria-label="Anketa" style={{ marginTop: 32, marginBottom: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '500px', ...getStyles('poll') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
{defer ? (
<Suspense fallback={null}>
@@ -1740,7 +1883,9 @@ const HomePage: React.FC = () => {
<PollsWidget featuredOnly={true} maxPolls={1} title="Anketa" />
</div>
</Suspense>
) : null}
) : (
<div className="card skeleton" style={{ height: 320, borderRadius: 12 }} />
)}
</div>
</section>
)}
@@ -1751,7 +1896,7 @@ const HomePage: React.FC = () => {
{(banners || []).filter(b => b.placement === 'homepage_footer').map((b) => (
<a key={b.id} href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'inline-block', margin: 8 }}>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img src={b.image} alt={b.name} style={{ maxWidth: '100%', width: b.width ? `${b.width}px` : undefined, height: b.height ? `${b.height}px` : 'auto' }} />
<img loading="lazy" decoding="async" src={b.image} alt={b.name} style={{ maxWidth: '100%', width: b.width ? `${b.width}px` : undefined, height: b.height ? `${b.height}px` : 'auto' }} />
</a>
))}
</section>
@@ -1759,13 +1904,15 @@ const HomePage: React.FC = () => {
{/* CTA (Newsletter) moved up */}
{isVisible('newsletter', false) && (
<section key={`newsletter-${refreshKey}-${getVariant('newsletter', 'default')}`} data-element="newsletter" data-variant={getVariant('newsletter', 'default')} className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('newsletter') }}>
<section key={`newsletter-${refreshKey}-${getVariant('newsletter', 'default')}`} data-element="newsletter" data-variant={getVariant('newsletter', 'default')} className="newsletter-cta" aria-label="Přihlášení k newsletteru" style={{ marginTop: 24, marginBottom: 24, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '420px', ...getStyles('newsletter') }}>
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
{defer ? (
<Suspense fallback={null}>
<NewsletterSubscribe />
</Suspense>
) : null}
) : (
<div className="skeleton" style={{ height: 280, borderRadius: 12 }} />
)}
</div>
</section>
)}
@@ -1830,6 +1977,7 @@ const HomePage: React.FC = () => {
data-element="sponsors"
data-variant={variant}
className={`sponsors ${sponsorsTheme === 'dark' ? 'dark' : ''}`}
aria-labelledby="home-sponsors-heading"
style={{
width: '100vw',
position: 'relative',
@@ -1839,19 +1987,28 @@ const HomePage: React.FC = () => {
paddingLeft: 'max(16px, calc((100vw - 1200px) / 2))',
paddingRight: 'max(16px, calc((100vw - 1200px) / 2))',
boxSizing: 'border-box',
contentVisibility: 'auto' as any,
containIntrinsicSize: '520px',
...getStyles('sponsors')
}}
>
<div className="section-head">
<h3>Sponzoři</h3>
<h3 id="home-sponsors-heading">Sponzoři</h3>
</div>
{isLoading && ordered.length === 0 && (
<div className="sponsors-grid">
{[1,2,3,4,5,6,7,8].map(i => (
<div key={i} className="sponsor-tile skeleton" style={{ minHeight: 90, borderRadius: 12 }} />
))}
</div>
)}
{variant === 'grid' && (
<>
{general.length > 0 && (
<div className="title-sponsor">
{general.map((g) => (
<a key={`g-${g.id}`} className="sponsor-tile" href={g.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(g.logo) || '/images/sponsors/placeholder.png'} alt={g.name} />
<img loading="lazy" decoding="async" src={assetUrl(g.logo) || '/images/sponsors/placeholder.png'} alt={g.name} />
</a>
))}
</div>
@@ -1860,7 +2017,7 @@ const HomePage: React.FC = () => {
<div className="sponsors-grid">
{(standard.length > 0 ? standard : (general.length === 0 ? ordered : [])).map((s) => (
<a key={s.id} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
<img loading="lazy" decoding="async" src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
</a>
))}
</div>
@@ -1872,7 +2029,7 @@ const HomePage: React.FC = () => {
<div className="track">
{[...ordered, ...ordered].map((s, idx) => (
<a key={`${s.id}-${idx}`} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
<img loading="lazy" decoding="async" src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
</a>
))}
</div>
@@ -1883,7 +2040,7 @@ const HomePage: React.FC = () => {
<div className="belt">
{[...ordered, ...ordered, ...ordered].map((s, idx) => (
<a key={`${s.id}-${idx}`} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
<img loading="lazy" decoding="async" src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
</a>
))}
</div>
@@ -1943,11 +2100,8 @@ const HomePage: React.FC = () => {
};
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';
if (n === 1) return 'rok';
if (n >= 2 && n <= 4) return 'roky';
return 'let';
}
+2 -5
View File
@@ -130,11 +130,8 @@ function calculateAge(iso: string): number | null {
}
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';
if (n === 1) return 'rok';
if (n >= 2 && n <= 4) return 'roky';
return 'let';
}
+1 -1
View File
@@ -11,7 +11,7 @@ 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-all'], queryFn: () => getPlayers({ active: false }) });
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textSecondary = useColorModeValue('gray.600', 'gray.400');
@@ -49,6 +49,7 @@ import { api } from '../../services/api';
// Removed react-datepicker to prevent crash; using native date/time inputs instead
import { getPublicSettings } from '../../services/settings';
import PollLinker from '../../components/admin/PollLinker';
import { useAuth } from '../../contexts/AuthContext';
import FilePreview from '../../components/common/FilePreview';
import { facrApi } from '../../services/facr/facrApi';
import { getCompetitionAliasesPublic } from '../../services/competitionAliases';
@@ -73,6 +74,8 @@ const types: Array<{ value: Event['type']; label: string }> = [
];
const AdminActivitiesPage: React.FC = () => {
const { user } = useAuth();
const isAdmin = (user as any)?.role === 'admin';
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.700');
@@ -1135,7 +1138,7 @@ const AdminActivitiesPage: React.FC = () => {
{/* Poll Section */}
<Box mt={6} pt={4} borderTopWidth="1px" borderColor={borderColor}>
<Heading size="sm" mb={3}>Anketa</Heading>
{editing?.id ? (
{isAdmin && editing?.id ? (
<PollLinker eventId={editing.id} />
) : (
<Box bg={useColorModeValue('blue.50', 'blue.900')} p={4} borderRadius="md" borderWidth="1px" borderColor="blue.200">
+9 -9
View File
@@ -99,15 +99,6 @@ 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]);
@@ -159,6 +150,15 @@ const AdminVideosPage: React.FC = () => {
}
};
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 });
}
};
const fetchChannelVideos = async () => {
const channel = channelInput?.trim();
if (!channel) {
+24 -15
View File
@@ -11,6 +11,7 @@ import {
import { FiEdit2, FiTrash2, FiPlus, FiSearch, FiUpload, FiExternalLink, FiVideo, FiX, FiRefreshCcw, FiLink } from 'react-icons/fi';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { useAuth } from '../../contexts/AuthContext';
import { Article, deleteArticle, getArticles, createArticle, updateArticle, uploadFile, CreateArticlePayload, UpdateArticlePayload, getArticleMatchLink, putArticleMatchLink, deleteArticleMatchLink } from '../../services/articles';
import { generateBlogAI } from '../../services/ai';
import { useState, useRef, useCallback, useMemo } from 'react';
@@ -172,6 +173,8 @@ const parseYoutubeVideoId = (raw: string): string => {
};
const ArticlesAdminPage = () => {
const { user } = useAuth();
const isAdmin = (user as any)?.role === 'admin';
const toast = useToast();
const qc = useQueryClient();
const [page, setPage] = useState(1);
@@ -519,16 +522,20 @@ const ArticlesAdminPage = () => {
try {
// Set cover image immediately
setEditing((prev) => ({ ...(prev as any), image_url: pick.image_url }));
// Persist pick to unified cache (admin)
await putZoneramaPick({
id: pick.id,
album_id: pick.album_id,
album_url: pick.album_url,
page_url: pick.page_url,
image_url: pick.image_url,
title: pick.title,
} as any);
toast({ title: 'Obrázek vybrán ze Zonerama', status: 'success' });
// Persist pick to unified cache (admin only)
if (isAdmin) {
await putZoneramaPick({
id: pick.id,
album_id: pick.album_id,
album_url: pick.album_url,
page_url: pick.page_url,
image_url: pick.image_url,
title: pick.title,
} as any);
toast({ title: 'Obrázek vybrán ze Zonerama', status: 'success' });
} else {
toast({ title: 'Obrázek nastaven', status: 'success' });
}
} catch (e: any) {
toast({ title: 'Uložení výběru selhalo', description: e?.response?.data?.error || e?.message || 'Chyba', status: 'error' });
}
@@ -537,9 +544,11 @@ const ArticlesAdminPage = () => {
// Handle album photo selection for blog content
const handleAlbumPhotosSelected = useCallback(async (photos: Array<{ id: string; page_url: string; image_1500: string }>, albumInfo: any) => {
try {
// Save album to cache
toast({ title: 'Ukládám album...', status: 'info', duration: 2000 });
await saveAlbumToCache(albumInfo.url, photos.length);
// Save album to cache (admins only)
if (isAdmin) {
toast({ title: 'Ukládám album...', status: 'info', duration: 2000 });
await saveAlbumToCache(albumInfo.url, photos.length);
}
// Store album info with article and append images to content
setEditing((prev) => {
@@ -573,7 +582,7 @@ const ArticlesAdminPage = () => {
toast({
title: 'Album přidáno',
description: `${photos.length} fotografií vloženo do článku. Album dostupné také v sekci Média.`,
description: isAdmin ? `${photos.length} fotografií vloženo do článku. Album dostupné také v sekci Média.` : `${photos.length} fotografií vloženo do článku.`,
status: 'success',
duration: 4000
});
@@ -2092,7 +2101,7 @@ const ArticlesAdminPage = () => {
</Text>
</Box>
{editing?.id ? (
{isAdmin && editing?.id ? (
<PollLinker articleId={editing.id} onPollsChanged={() => {
// Invalidate queries to refresh polls
qc.invalidateQueries({ queryKey: ['linked-polls'] });
+54 -4
View File
@@ -2,7 +2,7 @@ import React from 'react';
import AdminLayout from '../../layouts/AdminLayout';
import { Box, Heading, HStack, VStack, Button, Select, Input, Table, Thead, Tbody, Tr, Th, Td, Text, Badge, IconButton, useToast, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, ModalCloseButton, useDisclosure, FormControl, FormLabel, NumberInput, NumberInputField, Switch } from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { adminListComments, adminUpdateCommentStatus, adminBanUser, adminListUnbanRequests, adminResolveUnban } from '../../services/admin/comments';
import { adminListComments, adminUpdateCommentStatus, adminBanUser, adminListUnbanRequests, adminResolveUnban, adminListBans, adminLiftBan } from '../../services/admin/comments';
import { deleteComment } from '../../services/comments';
import { FiTrash2 } from 'react-icons/fi';
import { getArticles } from '../../services/articles';
@@ -37,6 +37,11 @@ const CommentsAdminPage: React.FC = () => {
queryFn: adminListUnbanRequests,
});
const bansQ = useQuery({
queryKey: ['admin-comment-bans'],
queryFn: adminListBans,
});
const updateStatusMut = useMutation({
mutationFn: (args: { id: number; s: 'visible'|'hidden' }) => adminUpdateCommentStatus(args.id, args.s),
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-comments'] }); },
@@ -57,7 +62,16 @@ const CommentsAdminPage: React.FC = () => {
const resolveUnbanMut = useMutation({
mutationFn: (args: { id: number; action: 'approve'|'reject' }) => adminResolveUnban(args.id, args.action),
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-unban-requests'] }); toast({ status: 'success', title: 'Vyřízeno' }); },
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['admin-unban-requests'] });
await qc.invalidateQueries({ queryKey: ['admin-comment-bans'] });
toast({ status: 'success', title: 'Vyřízeno' });
},
});
const liftBanMut = useMutation({
mutationFn: (id: number) => adminLiftBan(id),
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-comment-bans'] }); toast({ status: 'success', title: 'Ban zrušen' }); },
});
React.useEffect(() => {
@@ -167,7 +181,10 @@ const CommentsAdminPage: React.FC = () => {
<Tr key={c.id}>
<Td>#{c.id}</Td>
<Td>#{c.user?.id} {c.user?.first_name} {c.user?.last_name}</Td>
<Td><Badge>{c.target_type}</Badge> <Text as="span">{c.target_id}</Text></Td>
<Td>
<Badge mr={2}>{c.target_type}</Badge>
<Text as="span">{c.target_label || c.target_id}</Text>
</Td>
<Td maxW="420px"><Text noOfLines={2}>{c.content}</Text></Td>
<Td>{(c as any).spam_score ? <Badge colorScheme={(c as any).spam_score > 0.5 ? 'orange' : 'green'}>{(c as any).spam_score.toFixed(2)}</Badge> : '-'}</Td>
<Td>{(c as any).reports ? <Badge colorScheme={(c as any).reports > 2 ? 'red' : 'yellow'}>{(c as any).reports}</Badge> : '-'}</Td>
@@ -213,7 +230,7 @@ const CommentsAdminPage: React.FC = () => {
{(unbanQ.data?.items || []).map((r) => (
<Tr key={r.id}>
<Td>#{r.id}</Td>
<Td>#{r.user_id}</Td>
<Td>#{r.user?.id} {r.user?.first_name} {r.user?.last_name} <Text as="span" color="gray.500" fontSize="sm">{r.user?.email}</Text></Td>
<Td maxW="480px"><Text noOfLines={2}>{r.message}</Text></Td>
<Td><Badge>{r.status}</Badge></Td>
<Td>
@@ -228,6 +245,39 @@ const CommentsAdminPage: React.FC = () => {
</Table>
</Box>
<Heading size="sm" mt={6} mb={2}>Zablokovaní uživatelé</Heading>
<Box borderWidth="1px" borderRadius="md" overflowX="auto">
<Table size="sm">
<Thead>
<Tr>
<Th>ID</Th>
<Th>Uživatel</Th>
<Th>Důvod</Th>
<Th>Zabanován</Th>
<Th>Platné do</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
{(bansQ.data?.items || []).map((b) => {
const untilText = !b.until ? 'Trvale' : new Date(b.until).toLocaleString();
return (
<Tr key={b.id}>
<Td>#{b.id}</Td>
<Td>#{b.user?.id} {b.user?.first_name} {b.user?.last_name} <Text as="span" color="gray.500" fontSize="sm">{b.user?.email}</Text></Td>
<Td>{b.reason || '-'}</Td>
<Td>{new Date(b.created_at).toLocaleString()}</Td>
<Td>{untilText}</Td>
<Td>
<Button size="xs" variant="outline" onClick={() => liftBanMut.mutate(b.id)}>Zrušit ban</Button>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</Box>
{/* Ban modal */}
<Modal isOpen={banModal.isOpen} onClose={banModal.onClose} isCentered>
<ModalOverlay />
+24 -1
View File
@@ -101,12 +101,32 @@ const ContactsAdminPage: React.FC = () => {
const [savingSettings, setSavingSettings] = useState(false);
const [facrCompetitions, setFacrCompetitions] = useState<any[]>([]);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
// Map of competition code -> alias (public aliases)
const [compAliasMap, setCompAliasMap] = useState<Record<string, string>>({});
useEffect(() => {
loadData();
loadSettings();
}, []);
// Load competition aliases map for filtering categories (so alias-named categories are visible)
useEffect(() => {
(async () => {
try {
const aliases = await getCompetitionAliasesPublic().catch(() => [] as Array<{ code?: string; alias?: string }>);
const map: Record<string, string> = {};
(aliases || []).forEach((a: any) => {
const code = String(a?.code || '').trim();
const alias = String(a?.alias || '').trim();
if (code && alias) map[code] = alias;
});
setCompAliasMap(map);
} catch {
// ignore
}
})();
}, []);
const loadData = async () => {
setLoading(true);
try {
@@ -170,12 +190,15 @@ const ContactsAdminPage: React.FC = () => {
for (const comp of facrCompetitions || []) {
const n = String(comp?.name || '').trim();
if (n) names.add(n);
const code = String(comp?.code || '').trim();
const alias = code && compAliasMap[code] ? String(compAliasMap[code]).trim() : '';
if (alias) names.add(alias);
}
return Array.from(names);
} catch {
return [] as string[];
}
}, [facrCompetitions]);
}, [facrCompetitions, compAliasMap]);
const filteredContactCategories = useMemo(() => {
try {
+137 -94
View File
@@ -88,6 +88,7 @@ const EngagementAdminPage: React.FC = () => {
const editModal = useDisclosure();
const [editForm, setEditForm] = React.useState<Partial<AdminRewardItem>>({});
// Remove raw JSON editing, keep structured metadata only
const batchEnabled = false;
const [batch, setBatch] = React.useState({
base_url: '',
@@ -330,7 +331,9 @@ const EngagementAdminPage: React.FC = () => {
</FormControl>
</WrapItem>
<WrapItem>
<Button size="sm" variant="outline" onClick={batchModal.onOpen}>Dávkové vytvoření</Button>
{batchEnabled && (
<Button size="sm" variant="outline" onClick={batchModal.onOpen}>Dávkové vytvoření</Button>
)}
</WrapItem>
</Wrap>
<HStack align="start" spacing={4}>
@@ -361,22 +364,38 @@ const EngagementAdminPage: React.FC = () => {
</NumberInput>
<FormHelperText>~ {Math.round(Number(form.cost_points || 0) * 0.1)} </FormHelperText>
</FormControl>
{(form.type !== 'avatar_upload_unlock' && form.type !== 'avatar_animated_upload_unlock') && (
<FormControl>
<FormLabel>Sklad</FormLabel>
<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>
{(form.type === 'avatar_static' || form.type === 'avatar_animated') && (
<>
<FormControl>
<FormLabel>Obrázek URL</FormLabel>
<Input placeholder="https://.../avatar-1.png" value={form.image_url} onChange={(e) => setForm({ ...form, image_url: e.target.value })} />
<FormHelperText>Pro avatar uveďte URL obrázku.</FormHelperText>
</FormControl>
<HStack>
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e)=>handleUpload(e.target.files?.[0])} />
<Button size="sm" variant="outline" onClick={() => fileInputRef.current?.click()}>Nahrát obrázek</Button>
</HStack>
</>
)}
<VStack align="stretch" spacing={2}>
<FormControl>
<FormLabel>Sklad</FormLabel>
<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>
<FormLabel>Platnost od</FormLabel>
<Input type="datetime-local" value={meta.valid_from || ''} onChange={(e)=>setMetaField('valid_from', e.target.value)} />
</FormControl>
</HStack>
<FormControl>
<FormLabel>Obrázek URL</FormLabel>
<Input placeholder="https://.../avatar-1.png" value={form.image_url} onChange={(e) => setForm({ ...form, image_url: e.target.value })} />
<FormHelperText>Pro avatar uveďte URL obrázku. Pro odemknutí uploadu není třeba.</FormHelperText>
</FormControl>
<HStack>
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e)=>handleUpload(e.target.files?.[0])} />
<Button size="sm" variant="outline" onClick={() => fileInputRef.current?.click()}>Nahrát obrázek</Button>
</HStack>
<FormControl>
<FormLabel>Platnost do</FormLabel>
<Input type="datetime-local" value={meta.valid_to || ''} onChange={(e)=>setMetaField('valid_to', e.target.value)} />
</FormControl>
</VStack>
{/* Metadata helpers */}
{form.type === 'merch_coupon' && (
<VStack align="stretch" spacing={2}>
@@ -384,10 +403,6 @@ const EngagementAdminPage: React.FC = () => {
<FormLabel>Kód kuponu</FormLabel>
<Input value={meta.coupon_code || ''} onChange={(e)=>setMetaField('coupon_code', e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Platnost do (ISO nebo datum)</FormLabel>
<Input value={meta.expires_at || ''} onChange={(e)=>setMetaField('expires_at', e.target.value)} placeholder="2025-12-31" />
</FormControl>
<FormControl>
<FormLabel>Poznámka</FormLabel>
<Input value={meta.note || ''} onChange={(e)=>setMetaField('note', e.target.value)} />
@@ -432,16 +447,18 @@ const EngagementAdminPage: React.FC = () => {
<Button colorScheme="blue" onClick={() => createMut.mutate()} isLoading={createMut.isPending} isDisabled={!form.name.trim()}>Vytvořit</Button>
</HStack>
</VStack>
<Box>
<Text fontSize="sm" mb={2} color="gray.500">Náhled</Text>
<Box borderWidth="1px" borderRadius="md" p={2}>
{form.image_url ? (
<Image src={form.image_url} alt={form.name} boxSize="96px" objectFit="cover" borderRadius="md" />
) : (
<Box boxSize="96px" borderWidth="1px" borderRadius="md" display="flex" alignItems="center" justifyContent="center" color="gray.400">Bez obrázku</Box>
)}
{(form.type === 'avatar_static' || form.type === 'avatar_animated') && (
<Box>
<Text fontSize="sm" mb={2} color="gray.500">Náhled</Text>
<Box borderWidth="1px" borderRadius="md" p={2}>
{form.image_url ? (
<Image src={form.image_url} alt={form.name} boxSize="96px" objectFit="cover" borderRadius="md" />
) : (
<Box boxSize="96px" borderWidth="1px" borderRadius="md" display="flex" alignItems="center" justifyContent="center" color="gray.400">Bez obrázku</Box>
)}
</Box>
</Box>
</Box>
)}
</HStack>
</VStack>
</Box>
@@ -468,6 +485,7 @@ const EngagementAdminPage: React.FC = () => {
<Th>Body</Th>
<Th>Sklad</Th>
<Th>Obrázek</Th>
<Th>Platnost</Th>
<Th>Aktivní</Th>
<Th>Akce</Th>
</Tr>
@@ -496,6 +514,20 @@ const EngagementAdminPage: React.FC = () => {
</NumberInput>
</Td>
<Td>{r.image_url ? <Image src={r.image_url} alt={r.name} boxSize="40px" objectFit="cover" borderRadius="md" /> : '-'}</Td>
<Td>
{(() => {
const m = (r.metadata || {}) as any;
const vf = m.valid_from ? new Date(m.valid_from) : null;
const vt = m.valid_to ? new Date(m.valid_to) : null;
if (!vf && !vt) return <Text color="gray.500">-</Text>;
return (
<VStack align="start" spacing={0}>
{vf && <Text fontSize="xs">od {vf.toLocaleString()}</Text>}
{vt && <Text fontSize="xs">do {vt.toLocaleString()}</Text>}
</VStack>
);
})()}
</Td>
<Td>
<Switch
isChecked={!!r.active}
@@ -630,7 +662,6 @@ const EngagementAdminPage: React.FC = () => {
{editForm.type === 'merch_coupon' && (
<>
<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>
</>
)}
@@ -665,6 +696,16 @@ const EngagementAdminPage: React.FC = () => {
)}
</VStack>
)}
<VStack align="stretch" spacing={2}>
<FormControl>
<FormLabel>Platnost od</FormLabel>
<Input type="datetime-local" isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).valid_from || ''} onChange={(e)=>setEditMetaField('valid_from', e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Platnost do</FormLabel>
<Input type="datetime-local" isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).valid_to || ''} onChange={(e)=>setEditMetaField('valid_to', e.target.value)} />
</FormControl>
</VStack>
{/* Odstraněno: ruční JSON metadata v editoru. */}
<HStack>
<Text>Aktivní</Text>
@@ -699,76 +740,78 @@ const EngagementAdminPage: React.FC = () => {
</ModalContent>
</Modal>
{/* Batch create modal */}
<Modal isOpen={batchModal.isOpen} onClose={batchModal.onClose} isCentered>
<ModalOverlay />
<ModalContent>
<ModalHeader>Dávkové vytvoření odměn</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={3}>
<FormControl>
<FormLabel>Základní URL (použijte {`{i}`} pro index)</FormLabel>
<Input placeholder="https://cdn.example.com/avatars/avatar-{i}.png" value={batch.base_url} onChange={(e)=>setBatch({ ...batch, base_url: e.target.value })} />
<FormHelperText>Příklad: avatar-{`{i}`}.png avatar-1.png, avatar-2.png</FormHelperText>
</FormControl>
<HStack>
{/* Batch create modal (hidden) */}
{batchEnabled && (
<Modal isOpen={batchModal.isOpen} onClose={batchModal.onClose} isCentered>
<ModalOverlay />
<ModalContent>
<ModalHeader>Dávkové vytvoření odměn</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={3}>
<FormControl>
<FormLabel>Počet</FormLabel>
<NumberInput min={1} value={batch.count} onChange={(_v,n)=>setBatch({ ...batch, count: Number.isFinite(n)? n : 1 })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Počáteční index</FormLabel>
<NumberInput min={0} value={batch.start_index} onChange={(_v,n)=>setBatch({ ...batch, start_index: Number.isFinite(n)? n : 1 })}>
<NumberInputField />
</NumberInput>
</FormControl>
</HStack>
<FormControl>
<FormLabel>Předpona názvu</FormLabel>
<Input value={batch.name_prefix} onChange={(e)=>setBatch({ ...batch, name_prefix: e.target.value })} />
</FormControl>
<HStack>
<FormControl>
<FormLabel>Typ</FormLabel>
<Select value={batch.type} onChange={(e)=>setBatch({ ...batch, type: e.target.value })}>
<option value="avatar_static">Avatar (statický)</option>
<option value="avatar_animated">Avatar (animovaný)</option>
<option value="merch_coupon">Merch kupon</option>
<option value="custom">Vlastní</option>
</Select>
</FormControl>
<FormControl>
<FormLabel>Body</FormLabel>
<NumberInput min={0} value={batch.cost_points} onChange={(_v,n)=>setBatch({ ...batch, cost_points: Number.isFinite(n)? n : 0 })}>
<NumberInputField />
</NumberInput>
</FormControl>
</HStack>
<HStack>
<FormControl>
<FormLabel>Sklad</FormLabel>
<NumberInput min={-1} value={batch.stock} onChange={(_v,n)=>setBatch({ ...batch, stock: Number.isFinite(n)? n : -1 })}>
<NumberInputField />
</NumberInput>
<FormLabel>Základní URL (použijte {`{i}`} pro index)</FormLabel>
<Input placeholder="https://cdn.example.com/avatars/avatar-{i}.png" value={batch.base_url} onChange={(e)=>setBatch({ ...batch, base_url: e.target.value })} />
<FormHelperText>Příklad: avatar-{`{i}`}.png avatar-1.png, avatar-2.png</FormHelperText>
</FormControl>
<HStack>
<Text>Aktivní</Text>
<Switch isChecked={batch.active} onChange={(e)=>setBatch({ ...batch, active: e.target.checked })} />
<FormControl>
<FormLabel>Počet</FormLabel>
<NumberInput min={1} value={batch.count} onChange={(_v,n)=>setBatch({ ...batch, count: Number.isFinite(n)? n : 1 })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Počáteční index</FormLabel>
<NumberInput min={0} value={batch.start_index} onChange={(_v,n)=>setBatch({ ...batch, start_index: Number.isFinite(n)? n : 1 })}>
<NumberInputField />
</NumberInput>
</FormControl>
</HStack>
<FormControl>
<FormLabel>Předpona názvu</FormLabel>
<Input value={batch.name_prefix} onChange={(e)=>setBatch({ ...batch, name_prefix: e.target.value })} />
</FormControl>
<HStack>
<FormControl>
<FormLabel>Typ</FormLabel>
<Select value={batch.type} onChange={(e)=>setBatch({ ...batch, type: e.target.value })}>
<option value="avatar_static">Avatar (statický)</option>
<option value="avatar_animated">Avatar (animovaný)</option>
<option value="merch_coupon">Merch kupon</option>
<option value="custom">Vlastní</option>
</Select>
</FormControl>
<FormControl>
<FormLabel>Body</FormLabel>
<NumberInput min={0} value={batch.cost_points} onChange={(_v,n)=>setBatch({ ...batch, cost_points: Number.isFinite(n)? n : 0 })}>
<NumberInputField />
</NumberInput>
</FormControl>
</HStack>
<HStack>
<FormControl>
<FormLabel>Sklad</FormLabel>
<NumberInput min={-1} value={batch.stock} onChange={(_v,n)=>setBatch({ ...batch, stock: Number.isFinite(n)? n : -1 })}>
<NumberInputField />
</NumberInput>
</FormControl>
<HStack>
<Text>Aktivní</Text>
<Switch isChecked={batch.active} onChange={(e)=>setBatch({ ...batch, active: e.target.checked })} />
</HStack>
</HStack>
</VStack>
</ModalBody>
<ModalFooter>
<HStack>
<Button onClick={batchModal.onClose}>Zrušit</Button>
<Button colorScheme="blue" isLoading={batchMut.isPending} onClick={()=>batchMut.mutate()}>Vytvořit dávku</Button>
</HStack>
</VStack>
</ModalBody>
<ModalFooter>
<HStack>
<Button onClick={batchModal.onClose}>Zrušit</Button>
<Button colorScheme="blue" isLoading={batchMut.isPending} onClick={()=>batchMut.mutate()}>Vytvořit dávku</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
</ModalFooter>
</ModalContent>
</Modal>
)}
</AdminLayout>
);
};
+93 -2
View File
@@ -78,6 +78,8 @@ const FilesAdminPage: React.FC = () => {
const [forceDelete, setForceDelete] = useState(false);
const [scanResult, setScanResult] = useState<any>(null);
const [refreshResult, setRefreshResult] = useState<any>(null);
const [isBulkDeletingUnused, setIsBulkDeletingUnused] = useState(false);
const [isBulkDeletingDuplicates, setIsBulkDeletingDuplicates] = useState(false);
const { isOpen: isUsagesOpen, onOpen: onUsagesOpen, onClose: onUsagesClose } = useDisclosure();
const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onClose: onDeleteClose } = useDisclosure();
@@ -202,6 +204,71 @@ const FilesAdminPage: React.FC = () => {
return full || url;
};
const handleDeleteAllUnused = async () => {
if (unusedFiles.length === 0) return;
const confirmed = window.confirm(`Opravdu chcete smazat ${unusedFiles.length} nepoužívaných souborů? Tuto akci nelze vrátit.`);
if (!confirmed) return;
setIsBulkDeletingUnused(true);
let deleted = 0;
let failed = 0;
for (const f of unusedFiles) {
try {
await deleteFile(f.id, false);
deleted++;
} catch (e) {
failed++;
}
}
setIsBulkDeletingUnused(false);
qc.invalidateQueries({ queryKey: ['admin-files'] });
qc.invalidateQueries({ queryKey: ['admin-files-unused'] });
qc.invalidateQueries({ queryKey: ['admin-files-duplicates'] });
qc.invalidateQueries({ queryKey: ['admin-files-usage'] });
toast({ title: 'Hromadné mazání dokončeno', description: `Smazáno ${deleted} / ${unusedFiles.length}. Chyby: ${failed}.`, status: failed > 0 ? 'warning' : 'success' });
};
const handleDeleteAllDuplicates = async () => {
if (duplicateGroups.length === 0) return;
const confirmed = window.confirm('Smazat všechny duplicitní soubory bez použití? V každé skupině bude ponechán 1 soubor. Používané soubory budou přeskočeny.');
if (!confirmed) return;
setIsBulkDeletingDuplicates(true);
// Build list of files to delete: in each group keep one (oldest by created_at), delete the rest only if usage_count === 0
type FI = typeof duplicateFiles extends Record<string, infer A> ? A extends Array<infer B> ? B : never : never;
const toDelete: FI[] = [] as any;
duplicateGroups.forEach(([, files]) => {
if (files.length <= 1) return;
const sorted = [...files].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
const [, ...rest] = sorted;
rest.forEach(f => {
if ((f.usage_count ?? 0) === 0) toDelete.push(f as any);
});
});
let deleted = 0;
let skipped = 0;
let failed = 0;
for (const f of toDelete) {
try {
await deleteFile((f as any).id, false);
deleted++;
} catch (e) {
failed++;
}
}
// Count duplicates with usage to report as skipped
duplicateGroups.forEach(([, files]) => {
if (files.length <= 1) return;
const sorted = [...files].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
const [, ...rest] = sorted;
rest.forEach(f => { if ((f.usage_count ?? 0) > 0) skipped++; });
});
setIsBulkDeletingDuplicates(false);
qc.invalidateQueries({ queryKey: ['admin-files'] });
qc.invalidateQueries({ queryKey: ['admin-files-unused'] });
qc.invalidateQueries({ queryKey: ['admin-files-duplicates'] });
qc.invalidateQueries({ queryKey: ['admin-files-usage'] });
toast({ title: 'Mazání duplicit dokončeno', description: `Smazáno ${deleted}, přeskočeno (použité) ${skipped}, chyby ${failed}.`, status: failed > 0 ? 'warning' : 'success' });
};
// Mime type options
const mimeTypes = useMemo(() => {
const types = new Set<string>();
@@ -443,7 +510,19 @@ const FilesAdminPage: React.FC = () => {
</AlertDescription>
</Box>
</Alert>
<HStack>
<Spacer />
<Button
leftIcon={<FiTrash2 />}
colorScheme="red"
size="sm"
onClick={handleDeleteAllUnused}
isLoading={isBulkDeletingUnused}
isDisabled={unusedFiles.length === 0}
>
Vymazat vše
</Button>
</HStack>
<Box overflowX="auto" borderWidth="1px" borderRadius="md" borderColor={borderColor}>
<Table size="sm">
<Thead>
@@ -483,7 +562,19 @@ const FilesAdminPage: React.FC = () => {
</AlertDescription>
</Box>
</Alert>
<HStack>
<Spacer />
<Button
leftIcon={<FiTrash2 />}
colorScheme="red"
size="sm"
onClick={handleDeleteAllDuplicates}
isLoading={isBulkDeletingDuplicates}
isDisabled={duplicateGroups.length === 0}
>
Vymazat vše
</Button>
</HStack>
{duplicateGroups.length === 0 ? (
<Box textAlign="center" py={8}>
<Text color="gray.500">Žádné duplicity nenalezeny</Text>
@@ -131,7 +131,7 @@ const GalleryAdminPage: React.FC = () => {
try {
// Use the api service which automatically includes authentication
await api.post('/admin/gallery/refresh');
await api.post('/admin/gallery/refresh', {});
toast({
title: 'Galerie obnovena',
+16 -46
View File
@@ -34,7 +34,7 @@ import {
} from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { putMatchOverride } from '../../services/adminMatches';
import { putMatchOverride, fetchAdminMatches } from '../../services/adminMatches';
import { getPublicSettings } from '../../services/settings';
import { useEffect, useMemo, useRef, useState } from 'react';
@@ -85,51 +85,21 @@ const MatchesAdminPage = () => {
const { data: matches = [], isLoading, error } = useQuery<any[], Error>({
queryKey: ['admin-matches-list-cache'],
queryFn: async () => {
// Read cached FACR club info
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
const url = `${origin}/cache/prefetch/facr_club_info.json`;
const res = await fetch(url, { headers: { 'Cache-Control': 'no-cache' } });
if (!res.ok) throw new Error(`Failed to load cache: ${res.status}`);
const json = await res.json();
const comps = Array.isArray(json?.competitions) ? json.competitions : [];
const items: any[] = comps.flatMap((c: any) =>
(Array.isArray(c.matches) ? c.matches : []).map((m: any) => ({ ...m, competitionName: c.name, competition_id: c.id }))
);
// Optional: stable sort by date ascending
const items = await fetchAdminMatches();
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm';
const formatDisplayDate = (s: string): string => {
const str = String(s || '').trim();
if (!str) return '';
try {
const dt = parse(str, FACR_DATE_FMT, new Date());
if (!isNaN(dt.getTime())) return format(dt, FACR_DATE_FMT);
} catch {}
const d2 = new Date(str);
if (!isNaN(d2.getTime())) return format(d2, FACR_DATE_FMT);
return str;
};
items.sort((a, b) => {
const da = parse(String(a.date_time || a.date), FACR_DATE_FMT, new Date()).getTime();
const db = parse(String(b.date_time || b.date), FACR_DATE_FMT, new Date()).getTime();
return da - db;
});
return items.map((m: any) => ({
id: m.match_id,
date_time: m.date_time || m.date,
competitionName: m.competitionName,
competition_id: m.competition_id,
home: m.home || m.home_team,
home_id: m.home_id || m.home_team_id || m.home_team_facr_id,
away: m.away || m.away_team,
away_id: m.away_id || m.away_team_id || m.away_team_facr_id,
score: m.score,
venue: m.venue,
home_logo_url: m.home_logo_url,
away_logo_url: m.away_logo_url,
}));
const parseTs = (obj: any): number => {
const s = String(obj?.date_time || obj?.date || '').trim();
if (!s) return Number.MAX_SAFE_INTEGER;
try {
const dt = parse(s, FACR_DATE_FMT, new Date());
if (!isNaN(dt.getTime())) return dt.getTime();
} catch {}
const d2 = new Date(s);
if (!isNaN(d2.getTime())) return d2.getTime();
return Number.MAX_SAFE_INTEGER;
};
items.sort((a: any, b: any) => parseTs(a) - parseTs(b));
return items;
},
});
@@ -374,7 +344,7 @@ const MatchesAdminPage = () => {
const saveMutation = useMutation({
mutationFn: async () => {
const externalMatchId: string = selected?.match_id || selected?.id;
const externalMatchId: string = String((selected?.match_id ?? selected?.id ?? '')).trim();
if (!externalMatchId) throw new Error('Chybí match_id');
const payload: any = {
venue_override: form.venue_override,
@@ -132,7 +132,6 @@ const ADMIN_PAGE_PRESETS = [
{ value: 'activities', label: 'Aktivity', url: '/admin/aktivity' },
{ value: 'players', label: 'Hráči', url: '/admin/hraci' },
{ value: 'articles', label: 'Články', url: '/admin/clanky' },
{ value: 'categories', label: 'Kategorie', url: '/admin/kategorie' },
{ value: 'comments', label: 'Komentáře', url: '/admin/komentare' },
{ value: 'about', label: 'O klubu', url: '/admin/o-klubu' },
{ value: 'videos', label: 'Videa', url: '/admin/videa' },
@@ -1149,6 +1148,8 @@ const NavigationAdminPage = () => {
onChildMoveUp={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'up')}
onChildMoveDown={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'down')}
onToggleVisible={toggleVisible}
childrenDroppableId={`admin-children-${item.id}`}
draggableChildPrefix={'admin-child'}
onEditTarget={(it) => openNavModal(it, undefined, true)}
onDeleteTarget={(it) => deleteNav(it.id!)}
/>
@@ -602,6 +602,25 @@ export default function NewsletterAdminPage() {
<Text>Automatické rozesílky</Text>
</HStack>
</HStack>
{/* Weekly schedule detail */}
<Box mt={3} color={textSecondary} fontSize="sm">
{statusData?.weekly_day ? (
<>
<Text>
<b>Týdenní přehled:</b> {statusData?.weekly_enabled ? 'Zapnuto' : 'Vypnuto'}
{statusData?.weekly_enabled ? (
<> {({sun:'Neděle', mon:'Pondělí', tue:'Úterý', wed:'Středa', thu:'Čtvrtek', fri:'Pátek', sat:'Sobota'} as any)[statusData.weekly_day as any]}
{' '}{String(statusData?.weekly_hour ?? 9).padStart(2,'0')}:00</>
) : null}
</Text>
{statusData?.weekly_next_scheduled ? (
<Text>
<b>Příští týdenní odeslání:</b> {format(new Date(statusData.weekly_next_scheduled), 'd. M. yyyy HH:mm', { locale: cs })}
</Text>
) : null}
</>
) : null}
</Box>
{statusData?.next_approximate ? (
<Text color="gray.600" fontSize="sm" mt={2}>
Další automatický newsletter za {(() => {
@@ -639,11 +639,8 @@ const PlayersAdminPage: React.FC = () => {
// Czech pluralization for years: 1 rok, 24 roky, 5+ let (1114 let)
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';
if (n === 1) return 'rok';
if (n >= 2 && n <= 4) return 'roky';
return 'let';
}
@@ -27,11 +27,14 @@ import {
Badge,
} from '@chakra-ui/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '../../contexts/AuthContext';
import { createShortLink, listShortLinks, getShortLinkStats } from '../../services/shortlinks';
import { FiClipboard, FiExternalLink, FiRefreshCcw, FiBarChart2 } from 'react-icons/fi';
const ShortlinksAdminPage: React.FC = () => {
const toast = useToast();
const { user } = useAuth();
const isAdmin = (user as any)?.role === 'admin';
const qc = useQueryClient();
const [targetUrl, setTargetUrl] = React.useState('');
const [title, setTitle] = React.useState('');
@@ -77,7 +80,7 @@ const ShortlinksAdminPage: React.FC = () => {
};
return (
<AdminLayout>
<AdminLayout requireAdmin={false}>
<Box>
<HStack justify="space-between" mb={4}>
<Text fontSize="xl" fontWeight="bold">Zkrácené odkazy</Text>
@@ -125,7 +128,9 @@ const ShortlinksAdminPage: React.FC = () => {
<HStack>
<IconButton aria-label="Otevřít krátkou URL" icon={<FiExternalLink />} as={ChakraLink as any} href={shortUrl} isExternal />
<IconButton aria-label="Zkopírovat" icon={<FiClipboard />} onClick={async ()=>{ await navigator.clipboard.writeText(shortUrl); toast({ title: 'Zkopírováno', description: shortUrl, status: 'success', duration: 2000 }); }} />
<IconButton aria-label="Statistiky" icon={<FiBarChart2 />} onClick={()=> openStats(it)} />
{isAdmin && (
<IconButton aria-label="Statistiky" icon={<FiBarChart2 />} onClick={()=> openStats(it)} />
)}
</HStack>
</Td>
</Tr>
@@ -138,7 +143,8 @@ const ShortlinksAdminPage: React.FC = () => {
</Table>
</Box>
{/* Stats modal */}
{/* Stats modal (admins only) */}
{isAdmin && (
<Modal isOpen={statsModal.isOpen} onClose={statsModal.onClose} size="xl">
<ModalOverlay />
<ModalContent>
@@ -190,6 +196,7 @@ const ShortlinksAdminPage: React.FC = () => {
</ModalFooter>
</ModalContent>
</Modal>
)}
</Box>
</AdminLayout>
);
+225 -209
View File
@@ -35,6 +35,11 @@ import {
Divider,
Image,
FormHelperText,
Tabs,
TabList,
Tab,
TabPanels,
TabPanel,
} from '@chakra-ui/react';
import { Link as RouterLink } from 'react-router-dom';
import AdminLayout from '../../layouts/AdminLayout';
@@ -88,15 +93,15 @@ const SweepstakesAdminPage: React.FC = () => {
const [form, setForm] = useState<any>(defaultForm);
const [editing, setEditing] = useState<Sweepstake | null>(null);
// Prizes modal state
const prizesDisc = useDisclosure();
const [prizeSweep, setPrizeSweep] = useState<Sweepstake | null>(null);
// Prizes state (integrated tab)
const [prizes, setPrizes] = useState<SweepstakePrize[]>([]);
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 [activeTab, setActiveTab] = useState<number>(0);
const [coverPreview, setCoverPreview] = useState<string>('');
const onUploadImage = async (file?: File | null) => {
if (!file) return;
@@ -143,24 +148,19 @@ const SweepstakesAdminPage: React.FC = () => {
};
const openPrizes = async (it: Sweepstake) => {
try {
setPrizeSweep(it);
prizesDisc.onOpen();
const list = await adminListPrizes(it.id);
setPrizes(list);
} catch {
setPrizes([]);
}
openEdit(it);
setActiveTab(2);
try { setPrizes(await adminListPrizes(it.id)); } catch { setPrizes([]); }
};
const addPrize = async () => {
if (!prizeSweep) return;
if (!editing) { toast({ status: 'info', title: 'Uložte soutěž a poté přidejte výhry' }); return; }
if (!prizeForm.name.trim()) { toast({ status: 'error', title: 'Název výhry je povinný' }); return; }
try {
setSavingPrize(true);
await adminCreatePrize(prizeSweep.id, { name: prizeForm.name, quantity: prizeForm.quantity, value: prizeForm.value, image_url: prizeForm.image_url, display_order: prizes.length, kind: prizeForm.kind, points: prizeForm.points, xp: prizeForm.xp });
await adminCreatePrize(editing.id, { name: prizeForm.name, quantity: prizeForm.quantity, value: prizeForm.value, image_url: prizeForm.image_url, display_order: prizes.length, kind: prizeForm.kind, points: prizeForm.points, xp: prizeForm.xp });
setPrizeForm({ name: '', quantity: 1, value: '', image_url: '' });
setPrizes(await adminListPrizes(prizeSweep.id));
setPrizes(await adminListPrizes(editing.id));
} catch (e:any) {
toast({ status: 'error', title: 'Nelze uložit výhru' });
} finally {
@@ -169,14 +169,14 @@ const SweepstakesAdminPage: React.FC = () => {
};
const delPrize = async (p: SweepstakePrize) => {
if (!prizeSweep) return;
if (!editing) return;
if (!window.confirm('Smazat výhru?')) return;
await adminDeletePrize(prizeSweep.id, p.id as any);
setPrizes(await adminListPrizes(prizeSweep.id));
await adminDeletePrize(editing.id, p.id as any);
setPrizes(await adminListPrizes(editing.id));
};
const movePrize = async (idx: number, dir: -1 | 1) => {
if (!prizeSweep) return;
if (!editing) return;
const arr = [...prizes];
const ni = idx + dir;
if (ni < 0 || ni >= arr.length) return;
@@ -184,12 +184,12 @@ const SweepstakesAdminPage: React.FC = () => {
arr[idx] = arr[ni];
arr[ni] = tmp;
setPrizes(arr);
await adminReorderPrizes(prizeSweep.id, arr.map(p => p.id as any));
await adminReorderPrizes(editing.id, arr.map(p => p.id as any));
};
useEffect(() => { load(); }, [status]);
const openCreate = () => { setEditing(null); setForm(defaultForm); onOpen(); };
const openCreate = () => { setEditing(null); setForm(defaultForm); setPrizes([]); setActiveTab(0); onOpen(); };
const openEdit = (it: Sweepstake) => {
setEditing(it);
setForm({
@@ -205,7 +205,9 @@ const SweepstakesAdminPage: React.FC = () => {
entry_cost_points: (it as any).entry_cost_points ?? 0,
max_entries_per_user: (it as any).max_entries_per_user ?? 1,
});
setActiveTab(0);
onOpen();
(async ()=>{ try { setPrizes(await adminListPrizes(it.id)); } catch { setPrizes([]); } })();
};
const save = async () => {
@@ -229,12 +231,16 @@ const SweepstakesAdminPage: React.FC = () => {
if (editing) {
await adminUpdateSweepstake(editing.id, payload);
toast({ status: 'success', title: 'Uloženo' });
onClose();
await load();
} else {
await adminCreateSweepstake(payload);
toast({ status: 'success', title: 'Vytvořeno' });
const created = await adminCreateSweepstake(payload);
toast({ status: 'success', title: 'Vytvořeno', description: 'Nyní můžete přidat výhry' });
setEditing(created);
setActiveTab(2);
try { setPrizes(await adminListPrizes(created.id)); } catch { setPrizes([]); }
await load();
}
onClose();
await load();
} catch (e: any) {
toast({ status: 'error', title: 'Chyba', description: e?.response?.data?.error || 'Operace selhala' });
}
@@ -325,106 +331,206 @@ const SweepstakesAdminPage: React.FC = () => {
</Box>
)}
{/* Create/Edit Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="2xl">
{/* Create/Edit Modal with tabs */}
<Modal isOpen={isOpen} onClose={onClose} size="3xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>{editing ? 'Upravit soutěž' : 'Nová soutěž'}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4} align="stretch">
<FormControl isRequired>
<FormLabel>Název</FormLabel>
<Input value={form.title} onChange={(e)=>setForm({ ...form, title: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Popis</FormLabel>
<Textarea value={form.description} onChange={(e)=>setForm({ ...form, description: e.target.value })} />
</FormControl>
<SimpleGrid columns={2} spacing={4}>
<FormControl>
<FormLabel>Začátek</FormLabel>
<Input type="datetime-local" value={form.start_at} onChange={(e)=>setForm({ ...form, start_at: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Konec</FormLabel>
<Input type="datetime-local" value={form.end_at} onChange={(e)=>setForm({ ...form, end_at: e.target.value })} />
</FormControl>
</SimpleGrid>
<SimpleGrid columns={2} spacing={4}>
<FormControl>
<FormLabel>Styl vizualizace</FormLabel>
<Select value={form.picker_style} onChange={(e)=>setForm({ ...form, picker_style: e.target.value })}>
<option value="wheel">Kolo štěstí</option>
<option value="cycler">Náhodný přepínač</option>
</Select>
</FormControl>
<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>
<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>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>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>
<Tabs index={activeTab} onChange={setActiveTab as any} isFitted>
<TabList>
<Tab>Základní</Tab>
<Tab>Termíny a limity</Tab>
<Tab>Výhry</Tab>
</TabList>
<TabPanels>
<TabPanel>
<VStack spacing={4} align="stretch">
<FormControl isRequired>
<FormLabel>Název</FormLabel>
<Input value={form.title} onChange={(e)=>setForm({ ...form, title: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Popis</FormLabel>
<Textarea value={form.description} onChange={(e)=>setForm({ ...form, description: e.target.value })} />
</FormControl>
<SimpleGrid columns={2} spacing={4}>
<FormControl>
<FormLabel>Titulní obrázek</FormLabel>
<VStack align="start" spacing={2}>
<HStack>
<Image src={coverPreview || 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={async (e)=>{ const f=e.target.files?.[0]; if(!f) return; try { setCoverPreview(URL.createObjectURL(f)); const r=await uploadFile(f); setForm((prev:any)=>({ ...prev, image_url: r.url })); setCoverPreview(''); toast({ status:'success', title:'Obrázek nahrán' }); } catch { toast({ status:'error', title:'Nahrávání selhalo' }); } }} />
</Button>
{form.image_url && (<Button size="sm" variant="ghost" onClick={()=>{ setForm((prev:any)=>({ ...prev, image_url: '' })); setCoverPreview(''); }}>Odebrat</Button>)}
</HStack>
<Input placeholder="nebo vložte URL" value={form.image_url} onChange={(e)=>setForm({ ...form, image_url: e.target.value })} />
</VStack>
</FormControl>
<FormControl>
<FormLabel>Pravidla</FormLabel>
<VStack align="start" spacing={2}>
<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>
{form.rules_url && (<Button as={RouterLink} to={form.rules_url} target="_blank" rel="noreferrer noopener" variant="ghost">Otevřít</Button>)}
</HStack>
<Input placeholder="nebo vložte URL" value={form.rules_url} onChange={(e)=>setForm({ ...form, rules_url: e.target.value })} />
</VStack>
</FormControl>
</SimpleGrid>
</VStack>
</TabPanel>
<TabPanel>
<VStack spacing={4} align="stretch">
<SimpleGrid columns={2} spacing={4}>
<FormControl>
<FormLabel>Začátek</FormLabel>
<Input type="datetime-local" value={form.start_at} onChange={(e)=>setForm({ ...form, start_at: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Konec</FormLabel>
<Input type="datetime-local" value={form.end_at} onChange={(e)=>setForm({ ...form, end_at: e.target.value })} />
</FormControl>
</SimpleGrid>
<SimpleGrid columns={2} spacing={4}>
<FormControl>
<FormLabel>Styl vizualizace</FormLabel>
<Select value={form.picker_style} onChange={(e)=>setForm({ ...form, picker_style: e.target.value })}>
<option value="wheel">Kolo štěstí</option>
<option value="cycler">Náhodný přepínač</option>
</Select>
</FormControl>
<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>
<SimpleGrid columns={3} spacing={4}>
<FormControl>
<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>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>
</VStack>
</TabPanel>
<TabPanel>
<VStack align="stretch" spacing={3}>
<HStack>
<Button size="sm" onClick={()=>setActiveTab(0)} variant="outline">Zpět na základní</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' }); setPrizes(await adminListPrizes(editing.id)); } 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' }); setPrizes(await adminListPrizes(editing.id)); } 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ů' }); setPrizes(await adminListPrizes(editing.id)); } 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' }); setPrizes(await adminListPrizes(editing.id)); } catch { toast({ status:'error', title:'Nelze přidat XP' }); }
}}>5× 500 XP</Button>
</HStack>
<Divider />
{prizes.length === 0 && <Text color="gray.500">Zatím žádné výhry</Text>}
{prizes.map((p, i) => (
<HStack key={p.id} spacing={2} borderWidth="1px" borderRadius="md" p={2}>
<IconButton aria-label="Nahoru" size="xs" icon={<ArrowUpIcon />} onClick={()=>movePrize(i,-1)} />
<IconButton aria-label="Dolů" size="xs" icon={<ArrowDownIcon />} onClick={()=>movePrize(i,1)} />
<Text flex={1} fontWeight="600">{p.name}</Text>
<Text>×{p.quantity}</Text>
{p.kind && (
<Text fontSize="xs" px={2} py={0.5} borderRadius="md" borderWidth="1px" color="gray.600">
{p.kind === 'physical' ? 'fyzická' : p.kind === 'points' ? `body ${p.points||0}` : p.kind === 'xp' ? `XP ${p.xp||0}` : `body ${p.points||0} + XP ${p.xp||0}`}
</Text>
)}
<Text color="gray.500">{p.value}</Text>
<IconButton aria-label="Smazat" size="xs" colorScheme="red" icon={<DeleteIcon />} onClick={()=>delPrize(p)} />
</HStack>
))}
<Divider />
<Heading size="sm">Přidat výhru</Heading>
<SimpleGrid columns={{ base: 1, md: 4 }} spacing={2} alignItems="end">
<FormControl isRequired>
<FormLabel>Název</FormLabel>
<Input value={prizeForm.name} onChange={(e)=>setPrizeForm({ ...prizeForm, name: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Počet</FormLabel>
<NumberInput min={1} value={prizeForm.quantity} onChange={(v)=>setPrizeForm({ ...prizeForm, quantity: Number(v) || 1 })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Hodnota</FormLabel>
<Input value={prizeForm.value} onChange={(e)=>setPrizeForm({ ...prizeForm, value: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Obrázek URL</FormLabel>
<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">
<FormControl>
<FormLabel>Typ výhry</FormLabel>
<Select value={prizeForm.kind || 'physical'} onChange={(e)=>setPrizeForm({ ...prizeForm, kind: e.target.value as any })}>
<option value="physical">Fyzická výhra</option>
<option value="points">Body</option>
<option value="xp">XP</option>
<option value="points_xp">Body + XP</option>
</Select>
</FormControl>
{(prizeForm.kind === 'points' || prizeForm.kind === 'points_xp') && (
<FormControl>
<FormLabel>Body</FormLabel>
<NumberInput min={0} value={Number(prizeForm.points)||0} onChange={(v)=>setPrizeForm({ ...prizeForm, points: Number(v)||0 })}>
<NumberInputField />
</NumberInput>
</FormControl>
)}
{(prizeForm.kind === 'xp' || prizeForm.kind === 'points_xp') && (
<FormControl>
<FormLabel>XP</FormLabel>
<NumberInput min={0} value={Number(prizeForm.xp)||0} onChange={(v)=>setPrizeForm({ ...prizeForm, xp: Number(v)||0 })}>
<NumberInputField />
</NumberInput>
</FormControl>
)}
</SimpleGrid>
<HStack justify="flex-end">
<Button leftIcon={<AddIcon />} colorScheme="blue" size="sm" onClick={addPrize} isLoading={savingPrize}>Přidat</Button>
</HStack>
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</ModalBody>
<ModalFooter>
<HStack>
@@ -434,96 +540,6 @@ const SweepstakesAdminPage: React.FC = () => {
</ModalFooter>
</ModalContent>
</Modal>
{/* Prizes Modal */}
<Modal isOpen={prizesDisc.isOpen} onClose={prizesDisc.onClose} size="2xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Výhry {prizeSweep?.title}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={3}>
{prizes.length === 0 && <Text color="gray.500">Zatím žádné výhry</Text>}
{prizes.map((p, i) => (
<HStack key={p.id} spacing={2} borderWidth="1px" borderRadius="md" p={2}>
<IconButton aria-label="Nahoru" size="xs" icon={<ArrowUpIcon />} onClick={()=>movePrize(i,-1)} />
<IconButton aria-label="Dolů" size="xs" icon={<ArrowDownIcon />} onClick={()=>movePrize(i,1)} />
<Text flex={1} fontWeight="600">{p.name}</Text>
<Text>×{p.quantity}</Text>
{p.kind && (
<Text fontSize="xs" px={2} py={0.5} borderRadius="md" borderWidth="1px" color="gray.600">
{p.kind === 'physical' ? 'fyzická' : p.kind === 'points' ? `body ${p.points||0}` : p.kind === 'xp' ? `XP ${p.xp||0}` : `body ${p.points||0} + XP ${p.xp||0}`}
</Text>
)}
<Text color="gray.500">{p.value}</Text>
<IconButton aria-label="Smazat" size="xs" colorScheme="red" icon={<DeleteIcon />} onClick={()=>delPrize(p)} />
</HStack>
))}
<Divider />
<Heading size="sm">Přidat výhru</Heading>
<SimpleGrid columns={{ base: 1, md: 4 }} spacing={2} alignItems="end">
<FormControl isRequired>
<FormLabel>Název</FormLabel>
<Input value={prizeForm.name} onChange={(e)=>setPrizeForm({ ...prizeForm, name: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Počet</FormLabel>
<NumberInput min={1} value={prizeForm.quantity} onChange={(v)=>setPrizeForm({ ...prizeForm, quantity: Number(v) || 1 })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Hodnota</FormLabel>
<Input value={prizeForm.value} onChange={(e)=>setPrizeForm({ ...prizeForm, value: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Obrázek URL</FormLabel>
<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">
<FormControl>
<FormLabel>Typ výhry</FormLabel>
<Select value={prizeForm.kind || 'physical'} onChange={(e)=>setPrizeForm({ ...prizeForm, kind: e.target.value as any })}>
<option value="physical">Fyzická výhra</option>
<option value="points">Body</option>
<option value="xp">XP</option>
<option value="points_xp">Body + XP</option>
</Select>
</FormControl>
{(prizeForm.kind === 'points' || prizeForm.kind === 'points_xp') && (
<FormControl>
<FormLabel>Body</FormLabel>
<NumberInput min={0} value={Number(prizeForm.points)||0} onChange={(v)=>setPrizeForm({ ...prizeForm, points: Number(v)||0 })}>
<NumberInputField />
</NumberInput>
</FormControl>
)}
{(prizeForm.kind === 'xp' || prizeForm.kind === 'points_xp') && (
<FormControl>
<FormLabel>XP</FormLabel>
<NumberInput min={0} value={Number(prizeForm.xp)||0} onChange={(v)=>setPrizeForm({ ...prizeForm, xp: Number(v)||0 })}>
<NumberInputField />
</NumberInput>
</FormControl>
)}
</SimpleGrid>
<HStack justify="flex-end">
<Button leftIcon={<AddIcon />} colorScheme="blue" size="sm" onClick={addPrize} isLoading={savingPrize}>Přidat</Button>
</HStack>
</VStack>
</ModalBody>
<ModalFooter>
<Button onClick={prizesDisc.onClose}>Zavřít</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Container>
</AdminLayout>
);
+54 -15
View File
@@ -63,6 +63,8 @@ function normalize(s: string): string {
.toLowerCase();
// Unify various dash characters to a simple hyphen
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
out = out.replace(/\bn\.?\b/g, ' nad ');
out = out.replace(/\bp\.?\b/g, ' pod ');
// Remove legal suffixes like ", z.s." / ", z. s." / " z.s." / "o.s." at end
out = out.replace(/[,,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
// Remove organization phrases/prefixes anywhere (keep core locality/name)
@@ -140,6 +142,16 @@ const TeamsAdminPage = () => {
staleTime: 5 * 60 * 1000,
});
const overridesById: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
// Lowercase-key index for robust UUID lookups irrespective of source casing
const overridesByIdLC = useMemo(() => {
const m: Record<string, { name?: string; logo_url?: string }> = {};
try {
for (const [k, v] of Object.entries(overridesById)) {
m[String(k).toLowerCase()] = v as any;
}
} catch {}
return m;
}, [overridesById]);
// Build an index by normalized team name for overrides that carry an ID
const overridesNameIndex = useMemo(() => {
const idx: Record<string, { id: string; name: string; logo_url: string }> = {};
@@ -168,7 +180,7 @@ const TeamsAdminPage = () => {
for (const comp of competitions) {
const rows: TableRow[] = comp?.table?.overall || [];
for (const r of rows) {
if (r.team_id) teamIds.add(r.team_id);
if (r.team_id) teamIds.add(String(r.team_id).toLowerCase());
else {
const derived = deriveTeamIdFromLogoUrl(r.team_logo_url);
if (derived) teamIds.add(derived);
@@ -200,8 +212,9 @@ const TeamsAdminPage = () => {
const getLogo = (teamName?: string, teamId?: string, original?: string) => {
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
// Priority 0: Admin override by team ID
if (teamId && overridesById[teamId] && overridesById[teamId]?.logo_url) {
const u = String(overridesById[teamId].logo_url);
const tid = teamId ? String(teamId).toLowerCase() : '';
if (tid && overridesByIdLC[tid] && overridesByIdLC[tid]?.logo_url) {
const u = String(overridesByIdLC[tid].logo_url);
if (u.startsWith('/')) return assetUrl(u) as string;
return u;
}
@@ -254,8 +267,8 @@ const TeamsAdminPage = () => {
}
// Priority 2: logoapi.sportcreative.eu if we have a team ID
if (teamId && sportLogosMap[teamId]) {
return sportLogosMap[teamId];
if (tid && sportLogosMap[tid]) {
return sportLogosMap[tid];
}
// Priority 3: FACR original
@@ -268,8 +281,9 @@ const TeamsAdminPage = () => {
};
const getName = (teamName?: string, teamId?: string) => {
if (teamId && overridesById[teamId] && overridesById[teamId]?.name) {
return String(overridesById[teamId].name || '').trim() || String(teamName || '');
const tid = teamId ? String(teamId).toLowerCase() : '';
if (tid && overridesByIdLC[tid] && overridesByIdLC[tid]?.name) {
return String(overridesByIdLC[tid].name || '').trim() || String(teamName || '');
}
// If no ID, but override exists for the normalized name, use canonical override name
try {
@@ -326,6 +340,7 @@ const TeamsAdminPage = () => {
for (const r of rows) {
const rawName = (r.team || '').trim();
let teamId = ((r as any).team_id as string | undefined) || deriveTeamIdFromLogoUrl(r.team_logo_url);
if (teamId) teamId = String(teamId).toLowerCase();
if (!teamId && mainClubId) {
const rn = normalize(rawName);
if (
@@ -431,7 +446,30 @@ const TeamsAdminPage = () => {
const onSave = useMutation({
mutationFn: async () => {
if (!form.external_team_id) {
let extTeamId = (form.external_team_id || '').trim();
if (!extTeamId) {
let derived: string | undefined = undefined;
try { derived = deriveTeamIdFromLogoUrl(form.logo_url); } catch {}
if (!derived && selected?.teamLogoUrl) {
try { derived = deriveTeamIdFromLogoUrl(selected.teamLogoUrl); } catch {}
}
if (!derived) {
const primaryNameTry = (form.team_name || selected?.teamName || '').trim();
if (primaryNameTry) {
try {
const results = await searchClubs(primaryNameTry);
const norm = (s: string) => normalize(s);
const exact = results.find(r => norm(r.name) === norm(primaryNameTry));
const pick = exact || results[0];
if (pick?.id) derived = String(pick.id);
} catch {}
}
}
if (derived) {
extTeamId = derived;
}
}
if (!extTeamId) {
throw new Error('Vyberte tým ze seznamu vyhledávání (chybí ID).');
}
let logoUrl = (form.logo_url || '').trim();
@@ -443,8 +481,8 @@ const TeamsAdminPage = () => {
.filter(Boolean);
// Prefer highest-quality logo from logoapi if available (unless uploading a new file)
try {
if (!uploadedFile && form.external_team_id) {
const apiLogo = await fetchLogoFromLogoAPI(form.external_team_id, primaryName);
if (!uploadedFile && extTeamId) {
const apiLogo = await fetchLogoFromLogoAPI(extTeamId, primaryName);
if (apiLogo) {
logoUrl = apiLogo;
}
@@ -482,10 +520,10 @@ const TeamsAdminPage = () => {
}
if (logoFileToUpload) {
const logaResult = await uploadToLogaSportcreative(
form.external_team_id,
extTeamId,
logoFileToUpload,
{
filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
filename: `${extTeamId}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
}
);
@@ -497,7 +535,7 @@ const TeamsAdminPage = () => {
try {
let confirmedUrl: string | null = null;
for (let i = 0; i < 10; i++) {
confirmedUrl = await fetchLogoFromLogoAPI(form.external_team_id, primaryName);
confirmedUrl = await fetchLogoFromLogoAPI(extTeamId, primaryName);
if (confirmedUrl) break;
await new Promise((r) => setTimeout(r, 700));
}
@@ -532,7 +570,7 @@ const TeamsAdminPage = () => {
}
}
await putTeamLogoOverride(form.external_team_id, primaryName, logoUrl);
await putTeamLogoOverride(extTeamId, primaryName, logoUrl);
return true;
},
@@ -706,7 +744,8 @@ const TeamsAdminPage = () => {
<Td isNumeric py={1.5} fontSize="xs" fontWeight="bold">{r.points}</Td>
<Td py={1.5}>
<Button size="xs" fontSize="xs" onClick={() => {
const tid = ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url);
const tidRaw = ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url);
const tid = tidRaw ? String(tidRaw).toLowerCase() : undefined;
const displayName = getName(r.team, tid);
const key = tid ? `id:${tid}` : normalize(displayName);
onOpenEdit(displayName || '', getLogo(r.team, tid, r.team_logo_url), variantsByKey[key], tid);
@@ -821,6 +821,15 @@ html {
100% { transform: translateX(-33.333%); }
}
/* Reduce motion preferences: disable continuous marquee-style animations */
@media (prefers-reduced-motion: reduce) {
.sponsors-slider .track,
.sponsors-scroller .belt,
.matches-slider.matches-ticker .ticker-belt {
animation: none !important;
}
}
/* Matches slider */
.matches-slider { margin: 12px 0 20px; }
.matches-slider .matches-grid {
+17
View File
@@ -29,6 +29,15 @@ export type CommentBan = {
reason?: string;
until?: string | null;
created_at: string;
created_by_id?: number;
user?: {
id: number;
first_name?: string;
last_name?: string;
email?: string;
role?: string;
username?: string;
};
};
export async function adminListBans(): Promise<{ items: CommentBan[] }>{
@@ -49,6 +58,14 @@ export type UnbanRequest = {
created_at: string;
resolved_by_id?: number | null;
resolved_at?: string | null;
user?: {
id: number;
first_name?: string;
last_name?: string;
email?: string;
role?: string;
username?: string;
};
};
export async function adminListUnbanRequests(): Promise<{ items: UnbanRequest[] }>{
+10
View File
@@ -84,6 +84,16 @@ export interface NewsletterStatus {
interval_minutes: number;
next_approximate: string;
newsletter_enabled?: boolean;
// Scheduling detail (optional; provided by backend)
weekly_enabled?: boolean;
weekly_day?: 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat';
weekly_hour?: number;
weekly_next_scheduled?: string;
matches_enabled?: boolean;
reminder_lead_hours?: number;
results_enabled?: boolean;
quiet_start?: number;
quiet_end?: number;
}
export const getNewsletterStatus = async (): Promise<NewsletterStatus> => {
+1
View File
@@ -6,6 +6,7 @@ export type CommentItem = {
id: number;
target_type: TargetType;
target_id: string;
target_label?: string;
parent_id?: number | null;
content: string;
status?: 'visible' | 'hidden';
+9 -2
View File
@@ -36,8 +36,15 @@ export async function createPublicShortLink(payload: { target_url: string; title
}
export async function listShortLinks(): Promise<{ items: any[] }> {
const res = await api.get<{ items: any[] }>('/admin/shortlinks');
return res.data;
// Prefer editor-accessible endpoint
try {
const res = await api.get<{ items: any[] }>('/shortlinks');
return res.data;
} catch (e) {
// Fallback to admin endpoint (admins only)
const res2 = await api.get<{ items: any[] }>('/admin/shortlinks');
return res2.data;
}
}
export async function getShortLinkStats(id: number | string): Promise<any> {