mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
dev day #90 🥳
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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" />
|
||||
))}
|
||||
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'] });
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)} Kč</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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, 2–4 roky, 5+ let (11–14 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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[] }>{
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user